diff --git a/config.example.yaml b/config.example.yaml index bc75ab0..b9c153f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/src/cli.test.ts b/src/cli.test.ts index 06de219..2af1f1a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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'); diff --git a/src/core/device-deploy.ts b/src/core/device-deploy.ts new file mode 100644 index 0000000..ea46b50 --- /dev/null +++ b/src/core/device-deploy.ts @@ -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; +}): Promise { + 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); +} diff --git a/src/core/devices.test.ts b/src/core/devices.test.ts index 8b012fb..611561f 100644 --- a/src/core/devices.test.ts +++ b/src/core/devices.test.ts @@ -31,8 +31,10 @@ async function devices() { } type DevicesModule = Awaited>; +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']; @@ -59,8 +61,10 @@ beforeEach(async () => { vi.clearAllMocks(); mockSpawn.mockImplementation(() => new MockChild()); ({ + countSigningIdentities, deviceInfo, findPymobiledevice3, + formatDeviceError, installAppOnDevice, launchAppOnDevice, listDevicePairs, @@ -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)); + }); +}); diff --git a/src/core/devices.ts b/src/core/devices.ts index d8d10c8..1cef45b 100644 --- a/src/core/devices.ts +++ b/src/core/devices.ts @@ -419,4 +419,36 @@ export async function unpairDevice(deviceId: string): Promise { ); } +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 }; diff --git a/src/core/workflows.ts b/src/core/workflows.ts index 66c4508..af60e77 100644 --- a/src/core/workflows.ts +++ b/src/core/workflows.ts @@ -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', @@ -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[] { diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index 31f0873..647c47a 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -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'); diff --git a/src/runtime/config.ts b/src/runtime/config.ts index 70450ab..20e2d39 100644 --- a/src/runtime/config.ts +++ b/src/runtime/config.ts @@ -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, @@ -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; } diff --git a/src/tools/bazel-tools.test.ts b/src/tools/bazel-tools.test.ts index 9d15a8e..541db08 100644 --- a/src/tools/bazel-tools.test.ts +++ b/src/tools/bazel-tools.test.ts @@ -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', @@ -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', () => { diff --git a/src/tools/handlers/build.ts b/src/tools/handlers/build.ts index 8f386c6..37752d6 100644 --- a/src/tools/handlers/build.ts +++ b/src/tools/handlers/build.ts @@ -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, @@ -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: { @@ -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' } }, @@ -305,7 +313,8 @@ export async function handle(name: string, args: JsonObject): Promise | undefined) || {}, + }); + } + const config = getConfig(); const appPath = findAppBundle(config.workspacePath, requireLabel(buildRunArgs.target)); if (!appPath) { diff --git a/src/tools/handlers/device.ts b/src/tools/handlers/device.ts index 2a19235..1ca4dbf 100644 --- a/src/tools/handlers/device.ts +++ b/src/tools/handlers/device.ts @@ -7,8 +7,10 @@ import { runBazel, testFilterArgs, } from '../../core/bazel.js'; +import { deployBuiltAppToDevice } from '../../core/device-deploy.js'; import { deviceInfo, + formatDeviceError, installAppOnDevice, launchAppOnDevice, listDevicePairs, @@ -70,6 +72,17 @@ export const definitions: ToolDefinition[] = [ required: ['target'], }, }, + { + name: 'bazel_ios_device_get_app_path', + description: 'Return the .app bundle path for a previously built Bazel device target.', + inputSchema: { + type: 'object', + properties: { + target: { type: 'string', description: 'Bazel target label (e.g. //app:app).' }, + }, + required: ['target'], + }, + }, { name: 'bazel_ios_device_install_app', description: 'Install a previously built .app bundle onto a connected physical iOS device.', @@ -245,7 +258,11 @@ export async function handle(name: string, args: JsonObject): Promise | undefined) || {}, }); - - const installResult = await installAppOnDevice(device.udid, appPath); - if (installResult.exitCode !== 0) { - return toolText( - `${formatCommandResult(buildResult)}\n\nInstall failed:\n${formatCommandResult(installResult)}`, - true, - ); - } - - const bundleId = readBundleId(appPath); - const launchResult = await launchAppOnDevice( - device.udid, - bundleId, - asStringArray(args.launchArgs, 'launchArgs'), - (args.launchEnv as Record | undefined) || {}, - ); - - const lines = [ - formatCommandResult(buildResult), - '', - `App: ${appPath}`, - `Bundle ID: ${bundleId}`, - `Device: ${device.name} (${device.udid}) — iOS ${device.osVersion}`, - `Install: ${installResult.exitCode === 0 ? 'OK' : 'FAILED'}`, - `Launch: ${launchResult.exitCode === 0 ? 'OK' : 'FAILED'}`, - ]; - if (launchResult.output.trim()) { - lines.push('', launchResult.output.trim()); + } + case 'bazel_ios_device_get_app_path': { + const target = requireLabel(args.target); + const config = getConfig(); + const appPath = findAppBundle(config.workspacePath, target); + if (!appPath) { + return toolText(`No .app bundle found for ${target}. Build the target first (platform=device).`, true); } - return toolText(lines.join('\n'), launchResult.exitCode !== 0); + return toolText(appPath); } case 'bazel_ios_device_install_app': { if (typeof args.appPath !== 'string' || !args.appPath.trim()) { @@ -325,12 +317,14 @@ export async function handle(name: string, args: JsonObject): Promise { } }); - it('total definitions across all handlers is 116', () => { + it('total definitions across all handlers is 117', () => { const total = handlers.reduce((sum, { mod }) => sum + mod.definitions.length, 0); - expect(total).toBe(116); + expect(total).toBe(117); }); }); diff --git a/src/tools/handlers/session.ts b/src/tools/handlers/session.ts index b153ca5..c2fe969 100644 --- a/src/tools/handlers/session.ts +++ b/src/tools/handlers/session.ts @@ -48,6 +48,8 @@ export const definitions: ToolDefinition[] = [ target: { type: 'string', description: 'Default Bazel target label.' }, simulatorName: { type: 'string', description: 'Default simulator device name.' }, simulatorId: { type: 'string', description: 'Default simulator UDID.' }, + deviceName: { type: 'string', description: 'Default physical device name.' }, + deviceId: { type: 'string', description: 'Default physical device UDID.' }, buildMode: { type: 'string', enum: ['none', 'debug', 'release', 'release_with_symbols'] }, platform: { type: 'string', enum: ['none', 'simulator', 'device'] }, streaming: { @@ -122,9 +124,17 @@ export async function handle(name: string, args: JsonObject): Promise d.state === 'connected'); + lines.push( '', 'Dependencies', ` bazel: ${bazelVersion.exitCode === 0 ? bazelVersion.output.trim() : `❌ exit ${bazelVersion.exitCode}`}`, ` xcode: ${xcode.exitCode === 0 ? xcode.output.trim().replace(/\n/g, ' / ') : `❌ exit ${xcode.exitCode}`}`, ` simctl: ${simctl.exitCode === 0 ? '✅ available' : '❌ unavailable'}`, + ` devicectl: ${deviceList.command.exitCode === 0 ? '✅ available' : `❌ exit ${deviceList.command.exitCode}`}`, + ` codesigning identities: ${signing.command.exitCode === 0 ? `${signing.count} valid` : `❌ exit ${signing.command.exitCode}`}`, ); + lines.push('', 'Physical Devices'); + if (deviceList.command.exitCode !== 0) { + lines.push(' ❌ Could not list devices via devicectl.'); + } else if (connectedDevices.length === 0) { + lines.push(' ⚠️ No connected devices. Connect via USB/Wi-Fi and trust this Mac for device deploy.'); + } else { + for (const d of connectedDevices) { + lines.push(` ✅ ${d.name} — iOS ${d.osVersion}`); + lines.push(` hardware UDID: ${d.udid}`); + if (d.coreDeviceIdentifier && d.coreDeviceIdentifier !== d.udid) { + lines.push(` CoreDevice ID: ${d.coreDeviceIdentifier}`); + } + } + } + if (signing.count === 0 && signing.command.exitCode === 0) { + lines.push('', ' ⚠️ No code signing identities found. Device builds will fail until certificates are installed.'); + } + const configWorkflows = getEnabledWorkflows(); const effective = configWorkflows || DEFAULT_WORKFLOWS; const enabledNames = getEnabledToolNames(effective); const toolCount = enabledNames ? enabledNames.size : bazelToolDefinitions.length; + const deviceWorkflowEnabled = effective.includes('all') || effective.includes('device'); lines.push( '', @@ -176,6 +221,15 @@ export async function handle(name: string, args: JsonObject): Promise