Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,20 @@ bazelPath: /opt/homebrew/bin/bazel
defaultSimulatorName: iPhone 16 Pro
defaultPlatform: simulator
defaultTestOutput: errors
# enabledWorkflows: build, test, simulator, app_lifecycle, session
defaultStreaming: true
enabledWorkflows: build, test, simulator, app_lifecycle, device, project, session, agent_debug

profiles:
app-simulator:
defaultTarget: //app:MyApp
defaultSimulatorName: iPhone 16 Pro
defaultBuildMode: debug
defaultPlatform: simulator
streaming: true

app-device:
defaultTarget: //app:MyApp
defaultDeviceName: "My iPhone"
defaultBuildMode: debug
defaultPlatform: device
streaming: true
4 changes: 2 additions & 2 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ describe('CLI help', () => {
});

describe('CLI tools', () => {
it('lists all 116 tools', () => {
it('lists all 117 tools', () => {
const out = run(['tools']);
const toolLines = out
.split('\n')
.filter((line) => line.match(/^[a-z_]+$/));
expect(toolLines.length).toBe(116);
expect(toolLines.length).toBe(117);
expect(out).toContain('bazel_ios_build');
expect(out).toContain('bazel_macos_build');
expect(out).toContain('bazel_tvos_build');
Expand Down
111 changes: 111 additions & 0 deletions src/core/device-deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { requireLabel } from './bazel.js';
import {
formatDeviceError,
installAppOnDevice,
launchAppOnDevice,
resolveDevice,
} from './devices.js';
import { findAppBundle, readBundleId } from './simulators.js';
import { getConfig } from '../runtime/config.js';
import type { CommandResult, ToolCallResult } from '../types/index.js';
import { formatCommandResult, toolResult, toolText } from '../utils/output.js';

export async function deployBuiltAppToDevice(params: {
target: string;
buildResult: CommandResult;
deviceId?: string;
deviceName?: string;
launchArgs?: string[];
launchEnv?: Record<string, string>;
}): Promise<ToolCallResult> {
const config = getConfig();
const label = requireLabel(params.target);
const appPath = findAppBundle(config.workspacePath, label);
if (!appPath) {
return toolText(
`${formatCommandResult(params.buildResult)}\n\nBuild succeeded but .app bundle not found in bazel-bin. Check the target produces an ios_application.`,
true,
);
}

const device = await resolveDevice({
deviceId: params.deviceId,
deviceName: params.deviceName,
});

const installResult = await installAppOnDevice(device.udid, appPath);
if (installResult.exitCode !== 0) {
let bundleId: string | undefined;
try { bundleId = readBundleId(appPath); } catch { /* best effort */ }

const hint = formatDeviceError(installResult.output);
const lines = [
formatCommandResult(params.buildResult),
'',
'Build succeeded but install failed.',
`App: ${appPath}`,
bundleId ? `Bundle ID: ${bundleId}` : '',
`Device: ${device.name} (${device.udid})`,
'',
formatCommandResult(installResult),
'',
'Retry without rebuilding:',
` bazel_ios_device_install_app appPath="${appPath}" deviceName="${device.name}"`,
bundleId ? ` bazel_ios_device_launch_app bundleId="${bundleId}" deviceName="${device.name}"` : '',
].filter(Boolean);
if (hint) lines.push('', hint);

return toolResult(lines.join('\n'), {
build: 'ok',
install: 'failed',
appPath,
bundleId,
deviceId: device.udid,
deviceName: device.name,
installError: installResult.output.trim(),
retryTools: ['bazel_ios_device_install_app', 'bazel_ios_device_launch_app'],
}, true);
}

let bundleId: string;
try {
bundleId = readBundleId(appPath);
} catch (err) {
return toolText(
`${formatCommandResult(params.buildResult)}\n\nBuild and install succeeded but failed to read bundle ID: ${(err as Error).message}`,
true,
);
}

const launchResult = await launchAppOnDevice(
device.udid,
bundleId,
params.launchArgs || [],
params.launchEnv || {},
);

const lines = [
formatCommandResult(params.buildResult),
'',
`App: ${appPath}`,
`Bundle ID: ${bundleId}`,
`Device: ${device.name} (${device.udid}) — iOS ${device.osVersion}`,
`Install: OK`,
`Launch: ${launchResult.exitCode === 0 ? 'OK' : 'FAILED'}`,
];
if (launchResult.output.trim()) {
lines.push('', launchResult.output.trim());
}
const launchHint = launchResult.exitCode !== 0 ? formatDeviceError(launchResult.output) : undefined;
if (launchHint) lines.push('', launchHint);

return toolResult(lines.join('\n'), {
build: 'ok',
install: 'ok',
launch: launchResult.exitCode === 0 ? 'ok' : 'failed',
appPath,
bundleId,
deviceId: device.udid,
deviceName: device.name,
}, launchResult.exitCode !== 0);
}
29 changes: 29 additions & 0 deletions src/core/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ async function devices() {
}

type DevicesModule = Awaited<ReturnType<typeof devices>>;
let countSigningIdentities: DevicesModule['countSigningIdentities'];
let deviceInfo: DevicesModule['deviceInfo'];
let findPymobiledevice3: DevicesModule['findPymobiledevice3'];
let formatDeviceError: DevicesModule['formatDeviceError'];
let installAppOnDevice: DevicesModule['installAppOnDevice'];
let launchAppOnDevice: DevicesModule['launchAppOnDevice'];
let listDevicePairs: DevicesModule['listDevicePairs'];
Expand All @@ -59,8 +61,10 @@ beforeEach(async () => {
vi.clearAllMocks();
mockSpawn.mockImplementation(() => new MockChild());
({
countSigningIdentities,
deviceInfo,
findPymobiledevice3,
formatDeviceError,
installAppOnDevice,
launchAppOnDevice,
listDevicePairs,
Expand Down Expand Up @@ -381,3 +385,28 @@ describe('unpairDevice', () => {
expect(mockRunCommand).toHaveBeenCalledWith('xcrun', ['devicectl', 'manage', 'unpair', '--device', 'ABC-123'], expect.any(Object));
});
});

describe('formatDeviceError', () => {
it('maps locked device errors', () => {
expect(formatDeviceError('kAMDMobileImageMounterDeviceLocked')).toMatch(/locked/i);
expect(formatDeviceError('CoreDeviceError 12040')).toMatch(/locked/i);
});

it('returns undefined for unknown errors', () => {
expect(formatDeviceError('something else entirely')).toBeUndefined();
});
});

describe('countSigningIdentities', () => {
it('parses valid identity count from security output', async () => {
mockRunCommand.mockResolvedValueOnce({
...mockSuccess,
output: ' 1) ABC "Apple Development: Me"\n 2 valid identities found\n',
});

const result = await countSigningIdentities();

expect(result.count).toBe(2);
expect(mockRunCommand).toHaveBeenCalledWith('security', ['find-identity', '-v', '-p', 'codesigning'], expect.any(Object));
});
});
32 changes: 32 additions & 0 deletions src/core/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,36 @@ export async function unpairDevice(deviceId: string): Promise<CommandResult> {
);
}

export function formatDeviceError(output: string): string | undefined {
const lower = output.toLowerCase();
if (
lower.includes('devicelocked')
|| lower.includes('imagemounterdevicelocked')
|| lower.includes('0xe80000e2')
|| lower.includes('12040')
) {
return 'Device is locked. Unlock your iPhone and keep the screen on, then retry with bazel_ios_device_install_app.';
}
if (lower.includes('nottrusted') || (lower.includes('pairing') && lower.includes('required'))) {
return 'Device does not trust this computer. Unlock the device, tap "Trust", then retry.';
}
if (lower.includes('developer mode') || lower.includes('developermode')) {
return 'Enable Developer Mode on the device: Settings → Privacy & Security → Developer Mode.';
}
if (lower.includes('provisioning') || lower.includes('mobileprovision') || lower.includes('signing')) {
return 'Code signing issue. Ensure a valid development certificate and provisioning profile are installed.';
}
return undefined;
}

export async function countSigningIdentities(): Promise<{ count: number; command: CommandResult }> {
const command = await runCommand(
'security',
['find-identity', '-v', '-p', 'codesigning'],
{ cwd: process.cwd(), timeoutSeconds: 15, maxOutput: 50_000 },
);
const match = command.output.match(/(\d+) valid identities found/);
return { count: match ? parseInt(match[1], 10) : 0, command };
}

export { readBundleId };
3 changes: 2 additions & 1 deletion src/core/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const WORKFLOWS: WorkflowInfo[] = [
description: 'Build, install, launch, test, and manage physical iOS devices.',
tools: [
'bazel_ios_list_devices', 'bazel_ios_device_build_and_run',
'bazel_ios_device_get_app_path',
'bazel_ios_device_install_app', 'bazel_ios_device_launch_app',
'bazel_ios_device_stop_app', 'bazel_ios_device_test',
'bazel_ios_device_screenshot', 'bazel_ios_device_log_start',
Expand Down Expand Up @@ -195,7 +196,7 @@ export const WORKFLOWS: WorkflowInfo[] = [
const ALL_WORKFLOW_IDS = new Set(WORKFLOWS.map((w) => w.id));

export const DEFAULT_WORKFLOWS = [
'build', 'test', 'simulator', 'app_lifecycle', 'project', 'session', 'agent_debug',
'build', 'test', 'simulator', 'app_lifecycle', 'device', 'project', 'session', 'agent_debug',
];

export function validateWorkflowIds(ids: string[]): string[] {
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ describe('MCP server protocol', () => {

const toolsResp = lines.map((l) => JSON.parse(l)).find((r) => r.id === 2);
expect(toolsResp).toBeDefined();
expect(toolsResp.result.tools).toHaveLength(38);
expect(toolsResp.result.tools).toHaveLength(52);
const names = toolsResp.result.tools.map((t: { name: string }) => t.name);
expect(names).toContain('bazel_ios_build');
expect(names).toContain('bazel_ios_test');
expect(names).toContain('bazel_ios_device_build_and_run');
expect(names).toContain('bazel_ios_list_devices');
expect(names).toContain('bazel_list_workflows');
expect(names).toContain('bazel_toggle_workflow');
expect(names).not.toContain('bazel_tvos_build');
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export function activateProfile(name: string): SessionDefaults {
target: profile.defaultTarget || config.defaults.target,
simulatorName: profile.defaultSimulatorName || config.defaults.simulatorName,
simulatorId: profile.defaultSimulatorId || config.defaults.simulatorId,
deviceName: profile.defaultDeviceName || config.defaults.deviceName,
deviceId: profile.defaultDeviceId || config.defaults.deviceId,
buildMode: profile.defaultBuildMode || config.defaults.buildMode,
platform: profile.defaultPlatform || config.defaults.platform,
streaming: profile.streaming ?? config.defaults.streaming,
Expand Down Expand Up @@ -133,6 +135,12 @@ function applyFileConfig(fileConfig: FileConfig, filePath: string): void {
if (fileConfig.defaultSimulatorName) {
config.defaults.simulatorName = config.defaults.simulatorName || fileConfig.defaultSimulatorName;
}
if (fileConfig.defaultDeviceName) {
config.defaults.deviceName = config.defaults.deviceName || fileConfig.defaultDeviceName;
}
if (fileConfig.defaultDeviceId) {
config.defaults.deviceId = config.defaults.deviceId || fileConfig.defaultDeviceId;
}
if (fileConfig.defaultPlatform) {
config.defaults.platform = config.defaults.platform || fileConfig.defaultPlatform;
}
Expand Down
3 changes: 2 additions & 1 deletion src/tools/bazel-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('Bazel MCP tool definitions', () => {
'bazel_ios_ui_dump',
'bazel_ios_list_devices',
'bazel_ios_device_build_and_run',
'bazel_ios_device_get_app_path',
'bazel_ios_device_install_app',
'bazel_ios_device_launch_app',
'bazel_ios_device_stop_app',
Expand Down Expand Up @@ -126,7 +127,7 @@ describe('Bazel MCP tool definitions', () => {
];
expect([...names].sort()).toEqual([...expected].sort());
expect(new Set(names).size).toBe(names.length);
expect(bazelToolDefinitions.length).toBe(116);
expect(bazelToolDefinitions.length).toBe(117);
});

it('advertises startupArgs on every Bazel command tool that can need startup flags', () => {
Expand Down
24 changes: 22 additions & 2 deletions src/tools/handlers/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
testFilterArgs,
} from '../../core/bazel.js';
import { withTestSimulatorHooks } from '../../core/test-simulator.js';
import { deployBuiltAppToDevice } from '../../core/device-deploy.js';
import {
bootSimulatorIfNeeded,
findAppBundle,
Expand Down Expand Up @@ -101,7 +102,7 @@ export const definitions: ToolDefinition[] = [
{
name: 'bazel_ios_build_and_run',
description:
'Build a Bazel iOS app, install it on a simulator, and launch it. One-shot build-run cycle.',
'Build a Bazel iOS app, install it on a simulator or physical device, and launch it. One-shot build-run cycle. Default platform is simulator; pass platform=device for physical device.',
inputSchema: {
type: 'object',
properties: {
Expand All @@ -110,9 +111,16 @@ export const definitions: ToolDefinition[] = [
type: 'string',
enum: ['none', 'debug', 'release', 'release_with_symbols'],
},
platform: {
type: 'string',
enum: ['simulator', 'device'],
description: 'Deploy target. Defaults to simulator.',
},
simulatorName: { type: 'string', description: 'Simulator device name.' },
simulatorVersion: { type: 'string' },
simulatorId: { type: 'string', description: 'Simulator UDID. Takes precedence over name.' },
deviceId: { type: 'string', description: 'Physical device UDID (when platform=device).' },
deviceName: { type: 'string', description: 'Physical device name (when platform=device).' },
configs: { type: 'array', items: { type: 'string' } },
startupArgs: { type: 'array', items: { type: 'string' } },
extraArgs: { type: 'array', items: { type: 'string' } },
Expand Down Expand Up @@ -305,7 +313,8 @@ export async function handle(name: string, args: JsonObject): Promise<ToolCallRe
);
}
case 'bazel_ios_build_and_run': {
const buildRunArgs = { ...args, platform: 'simulator' } as BuildAndRunArgs;
const platform = stringOrUndefined(args.platform) === 'device' ? 'device' : 'simulator';
const buildRunArgs = { ...args, platform } as BuildAndRunArgs;
const buildResult = await runBazel(
buildCommandArgs(buildRunArgs),
numberOrUndefined(buildRunArgs.timeoutSeconds) || 1_800,
Expand All @@ -315,6 +324,17 @@ export async function handle(name: string, args: JsonObject): Promise<ToolCallRe
return toolText(formatCommandResult(buildResult), true);
}

if (platform === 'device') {
return deployBuiltAppToDevice({
target: requireLabel(buildRunArgs.target),
buildResult,
deviceId: stringOrUndefined(buildRunArgs.deviceId),
deviceName: stringOrUndefined(buildRunArgs.deviceName),
launchArgs: asStringArray(buildRunArgs.launchArgs, 'launchArgs'),
launchEnv: (buildRunArgs.launchEnv as Record<string, string> | undefined) || {},
});
}

const config = getConfig();
const appPath = findAppBundle(config.workspacePath, requireLabel(buildRunArgs.target));
if (!appPath) {
Expand Down
Loading
Loading