From 10aa674727c2198fea0c6c6528106249f4909896 Mon Sep 17 00:00:00 2001 From: Ismar Iljazovic Date: Mon, 9 Mar 2026 21:44:30 +0100 Subject: [PATCH 1/9] Add platform-aware setup wizard and mcp-json output Introduce platform selection to the setup wizard, recommend workflows per platform, and avoid prompting for a simulator when macOS is the only platform selected. Add helpers and constants (SetupPlatform, PLATFORM_WORKFLOWS, PLATFORM_OPTIONS, infer/derive/filter helpers), a multi-select platform prompt, and make the setup flow platform-aware (seed workflow defaults, filter simulators, preserve platform in sessionDefaults). Add selectionToMcpConfigJson() and a --format mcp-json option to print a ready-to-paste MCP client config JSON block (runSetupWizard supports 'mcp-json' early-exit). Update tests (createPlatformPrompter and four platform-aware cases) and CHANGELOG.md to document the new behavior. --- CHANGELOG.md | 7 + src/cli/commands/__tests__/setup.test.ts | 246 ++++++++++++++++++++ src/cli/commands/setup.ts | 279 ++++++++++++++++++++--- 3 files changed, 497 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f657851aa..7b74603ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Added - Added `xcodebuildmcp upgrade` command to check for updates and upgrade in place. Supports `--check` (report-only) and `--yes`/`-y` (skip confirmation). Detects install method (Homebrew, npm-global, npx) and queries the appropriate channel source (`brew info`, `npm view`, or GitHub Releases) for the latest version. Non-interactive environments exit 1 when an auto-upgrade is possible but `--yes` was not supplied. +- Added platform selection step to the `xcodebuildmcp setup` wizard. You now choose which platforms you are developing for (macOS, iOS, tvOS, watchOS, visionOS) before selecting workflows. Based on the selection, the wizard automatically recommends the appropriate workflow set ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). + +### Changed + +- The `setup` wizard no longer prompts for a simulator or device when macOS is the only selected platform — macOS apps run natively and do not require a simulator or physical device ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). +- When a single platform is selected, `xcodebuildmcp setup` now writes `platform` to `sessionDefaults` in `config.yaml` and includes `XCODEBUILDMCP_PLATFORM` in `--format mcp-json` output. For multi-platform projects the platform key is omitted so the agent can choose per-command ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). +- The `setup` wizard remembers previous choices on re-run: existing `config.yaml` values (including the new `platform`) are pre-loaded as defaults for every prompt ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). ## [2.3.2] diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index ac8f8a7fc..20ad292c4 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -108,6 +108,26 @@ function createTestPrompter(): Prompter { }; } +function createPlatformPrompter(platforms: string[]): Prompter { + let selectManyCalls = 0; + return { + selectOne: async (opts: { options: Array<{ value: T }> }) => { + const preferredOption = opts.options.find((option) => option.value != null); + return (preferredOption ?? opts.options[0]).value; + }, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + selectManyCalls++; + if (selectManyCalls === 1) { + return opts.options + .filter((option) => platforms.includes(String(option.value))) + .map((option) => option.value); + } + return opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; +} + describe('setup command', () => { const originalStdinIsTTY = process.stdin.isTTY; const originalStdoutIsTTY = process.stdout.isTTY; @@ -1054,4 +1074,230 @@ sessionDefaults: await expect(runSetupWizard()).rejects.toThrow('requires an interactive TTY'); }); + + it('skips simulator and sets platform for macOS-only selection', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.platform).toBe('macOS'); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('outputs XCODEBUILDMCP_PLATFORM=macOS and no simulator fields for macOS-only mcp-json', async () => { + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async () => '', + writeFile: async () => {}, + }); + + const executor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS']), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.mcpConfigJson).toBeDefined(); + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { XcodeBuildMCP: { env: Record } }; + }; + const env = parsed.mcpServers.XcodeBuildMCP.env; + + expect(env.XCODEBUILDMCP_PLATFORM).toBe('macOS'); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBeUndefined(); + expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBeUndefined(); + }); + + it('outputs XCODEBUILDMCP_PLATFORM=iOS Simulator and simulator fields for iOS-only mcp-json', async () => { + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async () => '', + writeFile: async () => {}, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'SIM-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`, + }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['iOS']), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.mcpConfigJson).toBeDefined(); + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { XcodeBuildMCP: { env: Record } }; + }; + const env = parsed.mcpServers.XcodeBuildMCP.env; + + expect(env.XCODEBUILDMCP_PLATFORM).toBe('iOS Simulator'); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); + expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBe('iPhone 15'); + }); + + it('omits XCODEBUILDMCP_PLATFORM for multi-platform mcp-json', async () => { + const fs = createMockFileSystemExecutor({ + existsSync: () => false, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async () => '', + writeFile: async () => {}, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'SIM-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`, + }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS', 'iOS']), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.mcpConfigJson).toBeDefined(); + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { XcodeBuildMCP: { env: Record } }; + }; + const env = parsed.mcpServers.XcodeBuildMCP.env; + + expect(env.XCODEBUILDMCP_PLATFORM).toBeUndefined(); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); + }); }); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 3bc2aab16..7f9809db4 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -24,10 +24,13 @@ import type { FileSystemExecutor } from '../../utils/FileSystemExecutor.ts'; import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts'; +type SetupPlatform = 'macOS' | 'iOS' | 'tvOS' | 'watchOS' | 'visionOS'; + interface SetupSelection { debug: boolean; sentryDisabled: boolean; enabledWorkflows: string[]; + platforms: SetupPlatform[]; projectPath?: string; workspacePath?: string; scheme: string; @@ -65,6 +68,84 @@ interface SetupDevice { platform: string; } +const PLATFORM_WORKFLOWS: Record = { + macOS: [ + 'coverage', + 'debugging', + 'doctor', + 'logging', + 'macos', + 'project-discovery', + 'project-scaffolding', + 'swift-package', + 'ui-automation', + 'utilities', + 'xcode-ide', + ], + iOS: [ + 'coverage', + 'debugging', + 'doctor', + 'logging', + 'project-discovery', + 'project-scaffolding', + 'simulator', + 'swift-package', + 'ui-automation', + 'utilities', + 'xcode-ide', + ], + tvOS: [ + 'debugging', + 'doctor', + 'logging', + 'project-discovery', + 'simulator', + 'swift-package', + 'utilities', + 'xcode-ide', + ], + watchOS: [ + 'debugging', + 'doctor', + 'logging', + 'project-discovery', + 'simulator', + 'swift-package', + 'utilities', + 'xcode-ide', + ], + visionOS: [ + 'coverage', + 'debugging', + 'doctor', + 'logging', + 'project-discovery', + 'project-scaffolding', + 'simulator', + 'swift-package', + 'ui-automation', + 'utilities', + 'xcode-ide', + ], +}; + +const PLATFORM_OPTIONS: Array<{ value: SetupPlatform; label: string; description: string }> = [ + { value: 'macOS', label: 'macOS', description: 'Native macOS apps — no simulator needed' }, + { value: 'iOS', label: 'iOS', description: 'iPhone and iPad apps, runs on iOS Simulator' }, + { value: 'tvOS', label: 'tvOS', description: 'Apple TV apps, runs on tvOS Simulator' }, + { + value: 'watchOS', + label: 'watchOS', + description: 'Apple Watch apps, runs on watchOS Simulator', + }, + { + value: 'visionOS', + label: 'visionOS', + description: 'Apple Vision Pro apps, runs on visionOS Simulator', + }, +]; + function showPromptHelp(helpText: string, quietOutput: boolean): void { if (quietOutput) { return; @@ -136,6 +217,64 @@ function normalizeExistingDefaults(config?: ProjectConfig): { }; } +function inferPlatformsFromExisting(config?: ProjectConfig): SetupPlatform[] { + if (!config) return []; + + const platform = config.sessionDefaults?.platform; + if (platform === 'macOS') return ['macOS']; + if (platform === 'iOS Simulator') return ['iOS']; + if (platform === 'tvOS Simulator') return ['tvOS']; + if (platform === 'watchOS Simulator') return ['watchOS']; + if (platform === 'visionOS Simulator') return ['visionOS']; + + // Multi-platform or legacy config: combine workflow heuristic (macOS) with + // simulatorPlatform to recover the non-macOS component. + const results: SetupPlatform[] = []; + const workflows = new Set(config.enabledWorkflows ?? []); + if (workflows.has('macos')) results.push('macOS'); + + const simPlatform = config.sessionDefaults?.simulatorPlatform; + if (simPlatform === 'iOS Simulator') results.push('iOS'); + else if (simPlatform === 'tvOS Simulator') results.push('tvOS'); + else if (simPlatform === 'watchOS Simulator') results.push('watchOS'); + else if (simPlatform === 'visionOS Simulator') results.push('visionOS'); + else if (workflows.has('simulator')) results.push('iOS'); // legacy fallback + + return results; +} + +function derivePlatformSessionDefault(platforms: SetupPlatform[]): string | undefined { + if (platforms.length !== 1) return undefined; + const platformMap: Record = { + macOS: 'macOS', + iOS: 'iOS Simulator', + tvOS: 'tvOS Simulator', + watchOS: 'watchOS Simulator', + visionOS: 'visionOS Simulator', + }; + return platformMap[platforms[0]]; +} + +function filterSimulatorsByPlatforms( + simulators: ListedSimulator[], + platforms: SetupPlatform[], +): ListedSimulator[] { + const nonMacPlatforms = platforms.filter((p) => p !== 'macOS') as Exclude< + SetupPlatform, + 'macOS' + >[]; + if (nonMacPlatforms.length !== 1) return simulators; + + const platform = nonMacPlatforms[0]; + const filtered = simulators.filter((sim) => { + if (platform === 'visionOS') { + return sim.runtime.includes('xrOS') || sim.runtime.includes('visionOS'); + } + return sim.runtime.includes(platform); + }); + return filtered.length > 0 ? filtered : simulators; +} + function getWorkflowOptions( debug: boolean, existingConfig?: ProjectConfig, @@ -208,6 +347,11 @@ function getChangedFields( beforeValue: beforeDefaults.simulatorName, afterValue: afterDefaults.simulatorName, }, + { + label: 'sessionDefaults.platform', + beforeValue: beforeDefaults.platform, + afterValue: afterDefaults.platform, + }, ]; const changed: string[] = []; @@ -226,6 +370,7 @@ async function selectWorkflowIds(opts: { debug: boolean; existingConfig?: ProjectConfig; existingEnabledWorkflows: string[]; + platforms: SetupPlatform[]; prompter: Prompter; quietOutput: boolean; }): Promise { @@ -240,11 +385,25 @@ async function selectWorkflowIds(opts: { description: workflow.description, })); - const defaults = - opts.existingEnabledWorkflows.length > 0 ? opts.existingEnabledWorkflows : ['simulator']; + let defaults: string[]; + if (opts.existingEnabledWorkflows.length > 0) { + defaults = opts.existingEnabledWorkflows; + } else if (opts.platforms.length > 0) { + const availableIds = new Set(workflows.map((w) => w.id)); + const recommended = new Set(); + for (const platform of opts.platforms) { + for (const workflowId of PLATFORM_WORKFLOWS[platform]) { + if (availableIds.has(workflowId)) recommended.add(workflowId); + } + } + defaults = Array.from(recommended); + } else { + defaults = ['simulator']; + } showPromptHelp( - 'Select workflows to choose which groups of tools are enabled by default in this project.', + 'Select workflows to choose which groups of tools are enabled by default in this project.\n' + + 'The selection above is recommended for your chosen platforms — you can adjust it freely.', opts.quietOutput, ); const selected = await opts.prompter.selectMany({ @@ -258,6 +417,26 @@ async function selectWorkflowIds(opts: { return selected; } +async function selectPlatforms(opts: { + existingPlatforms: SetupPlatform[]; + prompter: Prompter; + quietOutput: boolean; +}): Promise { + const defaults = opts.existingPlatforms.length > 0 ? opts.existingPlatforms : ['iOS']; + showPromptHelp( + 'Select which platforms you are developing for. This determines which workflows are\n' + + 'recommended and whether a simulator needs to be configured.', + opts.quietOutput, + ); + return opts.prompter.selectMany({ + message: 'Select target platforms', + options: PLATFORM_OPTIONS, + initialSelectedKeys: new Set(defaults), + getKey: (value) => value, + minSelected: 1, + }); +} + type ProjectChoice = { kind: 'workspace' | 'project'; absolutePath: string }; async function selectProjectChoice(opts: { @@ -369,12 +548,13 @@ function getDefaultSimulatorIndex( async function selectSimulator(opts: { existingSimulatorId?: string; existingSimulatorName?: string; + platformFilter: SetupPlatform[]; executor: CommandExecutor; prompter: Prompter; isTTY: boolean; quietOutput: boolean; }): Promise { - const simulators = await withSpinner({ + const allSimulators = await withSpinner({ isTTY: opts.isTTY, quietOutput: opts.quietOutput, startMessage: 'Loading simulators...', @@ -387,6 +567,7 @@ async function selectSimulator(opts: { } }, }); + const simulators = filterSimulatorsByPlatforms(allSimulators, opts.platformFilter); const defaultIndex = simulators.length > 0 @@ -692,10 +873,17 @@ async function collectSetupSelection( defaultValue: existingConfig?.sentryDisabled ?? false, }); + const platforms = await selectPlatforms({ + existingPlatforms: inferPlatformsFromExisting(existingConfig), + prompter: deps.prompter, + quietOutput: deps.quietOutput, + }); + const enabledWorkflows = await selectWorkflowIds({ debug, existingConfig, existingEnabledWorkflows: existingConfig?.enabledWorkflows ?? [], + platforms, prompter: deps.prompter, quietOutput: deps.quietOutput, }); @@ -721,40 +909,47 @@ async function collectSetupSelection( quietOutput: deps.quietOutput, }); - const simulator = requiresSimulatorDefault(enabledWorkflows) - ? await selectSimulator({ - existingSimulatorId: existing.simulatorId, - existingSimulatorName: existing.simulatorName, - executor: deps.executor, - prompter: deps.prompter, - isTTY, - quietOutput: deps.quietOutput, - }) - : undefined; - - const device = requiresDeviceDefault(enabledWorkflows) - ? await selectDevice({ - existingDeviceId: existing.deviceId, - fs: deps.fs, - executor: deps.executor, - prompter: deps.prompter, - isTTY, - quietOutput: deps.quietOutput, - }) - : undefined; + const isMacOsOnly = platforms.length > 0 && platforms.every((p) => p === 'macOS'); + + const simulator = + !isMacOsOnly && requiresSimulatorDefault(enabledWorkflows) + ? await selectSimulator({ + existingSimulatorId: existing.simulatorId, + existingSimulatorName: existing.simulatorName, + platformFilter: platforms, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }) + : undefined; + + const device = + !isMacOsOnly && requiresDeviceDefault(enabledWorkflows) + ? await selectDevice({ + existingDeviceId: existing.deviceId, + fs: deps.fs, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }) + : undefined; return { debug, sentryDisabled, enabledWorkflows, + platforms, projectPath: projectChoice.kind === 'project' ? projectChoice.absolutePath : undefined, workspacePath: projectChoice.kind === 'workspace' ? projectChoice.absolutePath : undefined, scheme, deviceId: device?.udid, simulatorId: simulator?.udid, simulatorName: simulator?.name, - clearDeviceDefault: requiresDeviceDefault(enabledWorkflows) && device == null, - clearSimulatorDefault: requiresSimulatorDefault(enabledWorkflows) && simulator == null, + clearDeviceDefault: isMacOsOnly || (requiresDeviceDefault(enabledWorkflows) && device == null), + clearSimulatorDefault: + isMacOsOnly || (requiresSimulatorDefault(enabledWorkflows) && simulator == null), }; } @@ -783,6 +978,12 @@ function selectionToMcpConfigJson(selection: SetupSelection): string { if (selection.deviceId) { env.XCODEBUILDMCP_DEVICE_ID = selection.deviceId; } + + const derivedPlatform = derivePlatformSessionDefault(selection.platforms); + if (derivedPlatform) { + env.XCODEBUILDMCP_PLATFORM = derivedPlatform; + } + if (selection.simulatorId) { env.XCODEBUILDMCP_SIMULATOR_ID = selection.simulatorId; } @@ -825,18 +1026,20 @@ export async function runSetupWizard(deps?: Partial): Promise if (isMcpJson) { clack.log.info( 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + - 'You will select a project or workspace, scheme, and any\n' + - 'simulator/device defaults required by the workflows you enable.\n' + - 'A bootstrap MCP config JSON block for\n' + - 'clients with limited workspace support will be printed at the end.', + 'You will select target platforms, workflows, a project or workspace,\n' + + 'scheme, and any simulator/device defaults required by the workflows\n' + + 'you enable. A ready-to-paste MCP config JSON block will be printed\n' + + 'at the end. You can rerun this wizard at any time — previous choices\n' + + 'are pre-loaded automatically.', ); } else { clack.log.info( 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + - 'You will select a project or workspace, scheme, and any\n' + - 'simulator/device defaults required by the workflows you enable.\n' + - 'Settings are saved to\n' + - '.xcodebuildmcp/config.yaml in your project directory.', + 'You will select target platforms, workflows, a project or workspace,\n' + + 'scheme, and any simulator/device defaults required by the workflows\n' + + 'you enable. Settings are saved to .xcodebuildmcp/config.yaml in your\n' + + 'project directory. You can rerun this wizard at any time — previous\n' + + 'choices are pre-loaded automatically.', ); } } @@ -882,6 +1085,11 @@ export async function runSetupWizard(deps?: Partial): Promise deleteSessionDefaultKeys.push('simulatorId', 'simulatorName'); } + const derivedPlatform = derivePlatformSessionDefault(selection.platforms); + if (!derivedPlatform) { + deleteSessionDefaultKeys.push('platform'); + } + const persistedProjectPath = selection.projectPath != null ? relativePathOrAbsolute(selection.projectPath, resolvedDeps.cwd) @@ -905,6 +1113,7 @@ export async function runSetupWizard(deps?: Partial): Promise deviceId: selection.deviceId, simulatorId: selection.simulatorId, simulatorName: selection.simulatorName, + platform: derivedPlatform, }, }, deleteSessionDefaultKeys, From c38a5d3e16db70447ef9455ab1a03916478135ee Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:23:00 +0100 Subject: [PATCH 2/9] test(setup): expand platform-aware setup wizard coverage Add focused tests requested by the PR audit: - Stale deviceId, simulatorId, and simulatorName are cleared when re-running setup with macOS-only after a prior iOS-with-device config. - YAML persistence for tvOS, watchOS, and visionOS single-platform selections writes the correct platform string and selects a platform-matching simulator from a multi-runtime simctl listing. - Verify filterSimulatorsByPlatforms matches a SimRuntime-style visionOS runtime via the xrOS keyword. --- src/cli/commands/__tests__/setup.test.ts | 347 +++++++++++++++++++++++ 1 file changed, 347 insertions(+) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 20ad292c4..3bff86297 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -1300,4 +1300,351 @@ sessionDefaults: expect(env.XCODEBUILDMCP_PLATFORM).toBeUndefined(); expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); }); + + it('clears stale deviceId, simulatorId, and simulatorName for macOS-only re-runs', async () => { + let storedConfig = [ + 'enabledWorkflows:', + ' - simulator', + ' - logging', + 'sessionDefaults:', + ' scheme: App', + ' workspacePath: ./App.xcworkspace', + ' deviceId: STALE-DEVICE', + ' simulatorId: STALE-SIM', + ' simulatorName: Old iPhone', + ' platform: iOS Simulator', + '', + ].join('\n'); + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async () => + createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['macOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.platform).toBe('macOS'); + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('persists platform=tvOS Simulator and a tvOS-runtime simulator for tvOS-only YAML setup', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'tvOS 17.0': [ + { name: 'Apple TV 4K', udid: 'TVOS-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['tvOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.platform).toBe('tvOS Simulator'); + expect(parsed.sessionDefaults?.simulatorId).toBe('TVOS-1'); + expect(parsed.sessionDefaults?.simulatorName).toBe('Apple TV 4K'); + }); + + it('persists platform=watchOS Simulator and a watchOS-runtime simulator for watchOS-only YAML setup', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'watchOS 10.0': [ + { + name: 'Apple Watch Series 9', + udid: 'WATCH-1', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['watchOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.platform).toBe('watchOS Simulator'); + expect(parsed.sessionDefaults?.simulatorId).toBe('WATCH-1'); + expect(parsed.sessionDefaults?.simulatorName).toBe('Apple Watch Series 9'); + }); + + it('persists platform=visionOS Simulator and an xrOS-runtime simulator for visionOS-only YAML setup', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'xrOS 1.0': [ + { name: 'Apple Vision Pro', udid: 'XROS-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['visionOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.platform).toBe('visionOS Simulator'); + expect(parsed.sessionDefaults?.simulatorId).toBe('XROS-1'); + expect(parsed.sessionDefaults?.simulatorName).toBe('Apple Vision Pro'); + }); + + it('matches a SimRuntime-style visionOS runtime via the xrOS keyword', async () => { + let storedConfig = ''; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ]; + } + return []; + }, + readFile: async (targetPath) => { + if (targetPath !== configPath) throw new Error(`Unexpected read path: ${targetPath}`); + return storedConfig; + }, + writeFile: async (targetPath, content) => { + if (targetPath !== configPath) throw new Error(`Unexpected write path: ${targetPath}`); + storedConfig = content; + }, + }); + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-17-0': [ + { name: 'iPhone 15', udid: 'IOS-1', state: 'Shutdown', isAvailable: true }, + ], + 'com.apple.CoreSimulator.SimRuntime.xrOS-1-0': [ + { name: 'Apple Vision Pro', udid: 'XROS-1', state: 'Shutdown', isAvailable: true }, + ], + }, + }), + }); + } + if (command[0] === 'xcrun') { + return createMockCommandResponse({ success: true, output: '' }); + } + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter: createPlatformPrompter(['visionOS']), + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.simulatorId).toBe('XROS-1'); + }); }); From ab998aff71782a0ce52d0e5daf905d67e320917f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 09:03:19 +0100 Subject: [PATCH 3/9] refactor(cli/setup): consolidate platform string mappings Replace three parallel string-literal cascades for SetupPlatform <-> session-defaults platform conversion with two derived lookup tables, reusing the existing XcodePlatform enum so the session-defaults platform value has a single source of truth. Replace the inline visionOS/xrOS branch in filterSimulatorsByPlatforms with a SIMULATOR_RUNTIME_KEYWORDS table so runtime keyword matching is data-driven. --- src/cli/commands/setup.ts | 68 +++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 7f9809db4..ee5aed808 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -23,9 +23,32 @@ import { import type { FileSystemExecutor } from '../../utils/FileSystemExecutor.ts'; import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts'; +import { XcodePlatform } from '../../types/common.ts'; type SetupPlatform = 'macOS' | 'iOS' | 'tvOS' | 'watchOS' | 'visionOS'; +const SETUP_PLATFORM_TO_SESSION_DEFAULT: Record = { + macOS: XcodePlatform.macOS, + iOS: XcodePlatform.iOSSimulator, + tvOS: XcodePlatform.tvOSSimulator, + watchOS: XcodePlatform.watchOSSimulator, + visionOS: XcodePlatform.visionOSSimulator, +}; + +const SESSION_DEFAULT_TO_SETUP_PLATFORM: Record = Object.fromEntries( + Object.entries(SETUP_PLATFORM_TO_SESSION_DEFAULT).map(([setup, session]) => [ + session, + setup as SetupPlatform, + ]), +); + +const SIMULATOR_RUNTIME_KEYWORDS: Record, string[]> = { + iOS: ['iOS'], + tvOS: ['tvOS'], + watchOS: ['watchOS'], + visionOS: ['visionOS', 'xrOS'], +}; + interface SetupSelection { debug: boolean; sentryDisabled: boolean; @@ -221,38 +244,30 @@ function inferPlatformsFromExisting(config?: ProjectConfig): SetupPlatform[] { if (!config) return []; const platform = config.sessionDefaults?.platform; - if (platform === 'macOS') return ['macOS']; - if (platform === 'iOS Simulator') return ['iOS']; - if (platform === 'tvOS Simulator') return ['tvOS']; - if (platform === 'watchOS Simulator') return ['watchOS']; - if (platform === 'visionOS Simulator') return ['visionOS']; - - // Multi-platform or legacy config: combine workflow heuristic (macOS) with - // simulatorPlatform to recover the non-macOS component. + if (platform != null && SESSION_DEFAULT_TO_SETUP_PLATFORM[platform] != null) { + return [SESSION_DEFAULT_TO_SETUP_PLATFORM[platform]]; + } + + // Multi-platform or legacy config: macOS is recovered from the workflow set, + // the non-macOS component from the cached simulatorPlatform. const results: SetupPlatform[] = []; const workflows = new Set(config.enabledWorkflows ?? []); if (workflows.has('macos')) results.push('macOS'); const simPlatform = config.sessionDefaults?.simulatorPlatform; - if (simPlatform === 'iOS Simulator') results.push('iOS'); - else if (simPlatform === 'tvOS Simulator') results.push('tvOS'); - else if (simPlatform === 'watchOS Simulator') results.push('watchOS'); - else if (simPlatform === 'visionOS Simulator') results.push('visionOS'); - else if (workflows.has('simulator')) results.push('iOS'); // legacy fallback + const fromSim = simPlatform != null ? SESSION_DEFAULT_TO_SETUP_PLATFORM[simPlatform] : undefined; + if (fromSim != null && fromSim !== 'macOS') { + results.push(fromSim); + } else if (workflows.has('simulator')) { + results.push('iOS'); + } return results; } function derivePlatformSessionDefault(platforms: SetupPlatform[]): string | undefined { if (platforms.length !== 1) return undefined; - const platformMap: Record = { - macOS: 'macOS', - iOS: 'iOS Simulator', - tvOS: 'tvOS Simulator', - watchOS: 'watchOS Simulator', - visionOS: 'visionOS Simulator', - }; - return platformMap[platforms[0]]; + return SETUP_PLATFORM_TO_SESSION_DEFAULT[platforms[0]]; } function filterSimulatorsByPlatforms( @@ -265,13 +280,10 @@ function filterSimulatorsByPlatforms( >[]; if (nonMacPlatforms.length !== 1) return simulators; - const platform = nonMacPlatforms[0]; - const filtered = simulators.filter((sim) => { - if (platform === 'visionOS') { - return sim.runtime.includes('xrOS') || sim.runtime.includes('visionOS'); - } - return sim.runtime.includes(platform); - }); + const keywords = SIMULATOR_RUNTIME_KEYWORDS[nonMacPlatforms[0]]; + const filtered = simulators.filter((sim) => + keywords.some((keyword) => sim.runtime.includes(keyword)), + ); return filtered.length > 0 ? filtered : simulators; } From abfef061051b57e62d5b1e0a367f8276998ed592 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 19:40:26 +0100 Subject: [PATCH 4/9] refactor(setup): derive workflow recommendations from manifest metadata Add `targetPlatforms` to the workflow manifest schema and populate it on all built-in workflows. The setup wizard now derives recommended workflows from this manifest metadata instead of a hand-coded `PLATFORM_WORKFLOWS` map, and splits the prompt into recommended vs additional workflows so the default selection stays minimal (macos and/or simulator) while non-recommended workflows remain reachable. Custom workflows from `config.yaml` and the workflow-discovery entry declare empty `targetPlatforms`; tests for schema, exposure, registry, and the setup wizard cover required/empty/invalid metadata and the new recommended/additional prompt flow. --- manifests/workflows/coverage.yaml | 1 + manifests/workflows/debugging.yaml | 1 + manifests/workflows/device.yaml | 5 +- manifests/workflows/doctor.yaml | 1 + manifests/workflows/macos.yaml | 1 + manifests/workflows/project-discovery.yaml | 1 + manifests/workflows/project-scaffolding.yaml | 1 + manifests/workflows/session-management.yaml | 1 + manifests/workflows/simulator-management.yaml | 1 + manifests/workflows/simulator.yaml | 1 + manifests/workflows/swift-package.yaml | 1 + manifests/workflows/ui-automation.yaml | 1 + manifests/workflows/utilities.yaml | 1 + manifests/workflows/workflow-discovery.yaml | 1 + manifests/workflows/xcode-ide.yaml | 1 + src/cli/commands/__tests__/setup.test.ts | 220 +++++++++++++++++- src/cli/commands/setup.ts | 196 +++++++++------- src/core/manifest/__tests__/schema.test.ts | 39 ++++ src/core/manifest/schema.ts | 10 + src/utils/__tests__/tool-registry.test.ts | 2 + src/utils/tool-registry.ts | 1 + src/visibility/__tests__/exposure.test.ts | 1 + 22 files changed, 395 insertions(+), 93 deletions(-) diff --git a/manifests/workflows/coverage.yaml b/manifests/workflows/coverage.yaml index f3ee6116e..ccd736a55 100644 --- a/manifests/workflows/coverage.yaml +++ b/manifests/workflows/coverage.yaml @@ -1,6 +1,7 @@ id: coverage title: Code Coverage description: View code coverage data from xcresult bundles produced by test runs. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - get_coverage_report - get_file_coverage diff --git a/manifests/workflows/debugging.yaml b/manifests/workflows/debugging.yaml index d3a92819e..f57eb1ae3 100644 --- a/manifests/workflows/debugging.yaml +++ b/manifests/workflows/debugging.yaml @@ -1,6 +1,7 @@ id: debugging title: LLDB Debugging description: Attach LLDB debugger to simulator apps, set breakpoints, inspect variables and call stacks. +targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - debug_attach_sim - debug_breakpoint_add diff --git a/manifests/workflows/device.yaml b/manifests/workflows/device.yaml index abeef9e74..ed97f2afa 100644 --- a/manifests/workflows/device.yaml +++ b/manifests/workflows/device.yaml @@ -1,6 +1,7 @@ id: device -title: iOS Device Development -description: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). +title: Device Development +description: Complete development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). +targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - build_device - build_run_device diff --git a/manifests/workflows/doctor.yaml b/manifests/workflows/doctor.yaml index cc2b18794..649f86933 100644 --- a/manifests/workflows/doctor.yaml +++ b/manifests/workflows/doctor.yaml @@ -1,6 +1,7 @@ id: doctor title: MCP Doctor description: Diagnostic tool providing comprehensive information about the MCP server environment, dependencies, and configuration. +targetPlatforms: [] selection: mcp: autoInclude: true diff --git a/manifests/workflows/macos.yaml b/manifests/workflows/macos.yaml index 489d70767..0f600bca9 100644 --- a/manifests/workflows/macos.yaml +++ b/manifests/workflows/macos.yaml @@ -1,6 +1,7 @@ id: macos title: macOS Development description: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. +targetPlatforms: [macOS] tools: - build_macos - build_run_macos diff --git a/manifests/workflows/project-discovery.yaml b/manifests/workflows/project-discovery.yaml index 279a74bae..00f800161 100644 --- a/manifests/workflows/project-discovery.yaml +++ b/manifests/workflows/project-discovery.yaml @@ -1,6 +1,7 @@ id: project-discovery title: Project Discovery description: Discover and examine Xcode projects, workspaces, and Swift packages. Analyze project structure, schemes, build settings, and bundle information. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - discover_projs - list_schemes diff --git a/manifests/workflows/project-scaffolding.yaml b/manifests/workflows/project-scaffolding.yaml index 2ffdf8cb7..137ce2e64 100644 --- a/manifests/workflows/project-scaffolding.yaml +++ b/manifests/workflows/project-scaffolding.yaml @@ -1,6 +1,7 @@ id: project-scaffolding title: Project Scaffolding description: Scaffold new iOS and macOS projects from templates. +targetPlatforms: [iOS, macOS] tools: - scaffold_ios_project - scaffold_macos_project diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml index 0c2469176..e28e34e58 100644 --- a/manifests/workflows/session-management.yaml +++ b/manifests/workflows/session-management.yaml @@ -1,6 +1,7 @@ id: session-management title: Session Management description: Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings. +targetPlatforms: [] availability: cli: false selection: diff --git a/manifests/workflows/simulator-management.yaml b/manifests/workflows/simulator-management.yaml index ca55174e3..0f7814727 100644 --- a/manifests/workflows/simulator-management.yaml +++ b/manifests/workflows/simulator-management.yaml @@ -1,6 +1,7 @@ id: simulator-management title: Simulator Management description: Tools for managing simulators from booting, opening simulators, listing simulators, stopping simulators, erasing simulator content and settings, and setting simulator environment options like location, network, statusbar and appearance. +targetPlatforms: [iOS, tvOS, watchOS, visionOS] tools: - boot_sim - list_sims diff --git a/manifests/workflows/simulator.yaml b/manifests/workflows/simulator.yaml index cd70c6a2d..636e2b077 100644 --- a/manifests/workflows/simulator.yaml +++ b/manifests/workflows/simulator.yaml @@ -1,6 +1,7 @@ id: simulator title: iOS Simulator Development description: Complete iOS development workflow for both .xcodeproj and .xcworkspace files targeting simulators. +targetPlatforms: [iOS, tvOS, watchOS, visionOS] selection: mcp: defaultEnabled: true diff --git a/manifests/workflows/swift-package.yaml b/manifests/workflows/swift-package.yaml index 856e3a67e..640ab3637 100644 --- a/manifests/workflows/swift-package.yaml +++ b/manifests/workflows/swift-package.yaml @@ -1,6 +1,7 @@ id: swift-package title: Swift Package Development description: Build, test, run and manage Swift Package Manager projects. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - swift_package_build - swift_package_test diff --git a/manifests/workflows/ui-automation.yaml b/manifests/workflows/ui-automation.yaml index 9f471b3e1..c11e5dd72 100644 --- a/manifests/workflows/ui-automation.yaml +++ b/manifests/workflows/ui-automation.yaml @@ -1,6 +1,7 @@ id: ui-automation title: UI Automation description: UI automation and accessibility testing tools for iOS simulators. Perform gestures, interactions, screenshots, and UI analysis for automated testing workflows. +targetPlatforms: [iOS] tools: - tap - touch diff --git a/manifests/workflows/utilities.yaml b/manifests/workflows/utilities.yaml index dfcc802cf..01fd713f9 100644 --- a/manifests/workflows/utilities.yaml +++ b/manifests/workflows/utilities.yaml @@ -1,5 +1,6 @@ id: utilities title: Build Utilities description: Utility tools for cleaning build products and managing build artifacts. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] tools: - clean diff --git a/manifests/workflows/workflow-discovery.yaml b/manifests/workflows/workflow-discovery.yaml index 099034f6d..4c82eec64 100644 --- a/manifests/workflows/workflow-discovery.yaml +++ b/manifests/workflows/workflow-discovery.yaml @@ -1,6 +1,7 @@ id: workflow-discovery title: Workflow Discovery description: Manage enabled workflows at runtime. +targetPlatforms: [] availability: cli: false selection: diff --git a/manifests/workflows/xcode-ide.yaml b/manifests/workflows/xcode-ide.yaml index b9295e7a3..46c0d2ffe 100644 --- a/manifests/workflows/xcode-ide.yaml +++ b/manifests/workflows/xcode-ide.yaml @@ -1,6 +1,7 @@ id: xcode-ide title: Xcode IDE Integration description: Bridge tools for connecting to Xcode's built-in MCP server (mcpbridge) to access IDE-specific functionality. +targetPlatforms: [iOS, macOS, tvOS, watchOS, visionOS] availability: cli: true predicates: diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 3bff86297..7b28284b6 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -273,7 +273,8 @@ describe('setup command', () => { offeredWorkflowIds = opts.options.map((option) => String(option.value)); return opts.options.map((option) => option.value); }, - confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + confirm: async (opts: { defaultValue: boolean; message: string }) => + opts.message === 'Show additional workflows?' ? true : opts.defaultValue, }; await runSetupWizard({ @@ -293,6 +294,223 @@ describe('setup command', () => { expect(offeredWorkflowIds).toContain('doctor'); }); + async function getWorkflowPromptStateForPlatforms( + selectedPlatforms: string[], + opts?: { showAdditionalWorkflows?: boolean; storedConfig?: string }, + ): Promise<{ + additionalWorkflowIds: string[]; + flatWorkflowIds: string[]; + recommendedInitialKeys: string[]; + recommendedWorkflowIds: string[]; + }> { + const { fs } = createSetupFs({ storedConfig: opts?.storedConfig }); + let additionalWorkflowIds: string[] = []; + let flatWorkflowIds: string[] = []; + let recommendedInitialKeys: string[] = []; + let recommendedWorkflowIds: string[] = []; + + const executor: CommandExecutor = async (command) => { + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ + devices: { + 'iOS 17.0': [ + { + name: 'iPhone 15', + udid: 'SIM-1', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + }); + } + + if (command[0] === 'xcrun') { + return createMockCommandResponse({ + success: true, + output: `== Devices ==\n-- iOS 17.0 --\n iPhone 15 (SIM-1) (Shutdown)`, + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + let selectManyCalls = 0; + const prompter: Prompter = { + selectOne: async (selectOpts: { options: Array<{ value: T }> }) => { + const preferredOption = selectOpts.options.find((option) => option.value != null); + return (preferredOption ?? selectOpts.options[0]).value; + }, + selectMany: async (selectOpts: { + options: Array<{ value: T }>; + initialSelectedKeys?: ReadonlySet; + getKey: (value: T) => string; + }) => { + selectManyCalls++; + if (selectManyCalls === 1) { + return selectOpts.options + .filter((option) => selectedPlatforms.includes(String(option.value))) + .map((option) => option.value); + } + + const workflowIds = selectOpts.options + .map((option) => selectOpts.getKey(option.value)) + .sort(); + + if (opts?.storedConfig != null) { + flatWorkflowIds = workflowIds; + return selectOpts.options + .filter((option) => + selectOpts.initialSelectedKeys?.has(selectOpts.getKey(option.value)), + ) + .map((option) => option.value); + } + + if (selectManyCalls === 2) { + recommendedInitialKeys = [ + ...(selectOpts.initialSelectedKeys ?? new Set()), + ].sort(); + recommendedWorkflowIds = workflowIds; + return selectOpts.options + .filter((option) => recommendedInitialKeys.includes(selectOpts.getKey(option.value))) + .map((option) => option.value); + } + + additionalWorkflowIds = workflowIds; + return []; + }, + confirm: async (confirmOpts: { defaultValue: boolean; message: string }) => { + if (confirmOpts.message === 'Show additional workflows?') { + return opts?.showAdditionalWorkflows ?? false; + } + return confirmOpts.defaultValue; + }, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + return { + additionalWorkflowIds, + flatWorkflowIds, + recommendedInitialKeys, + recommendedWorkflowIds, + }; + } + + it('shows iOS workflows as recommended options with only simulator selected by default', async () => { + const state = await getWorkflowPromptStateForPlatforms(['iOS']); + + expect(state.recommendedInitialKeys).toEqual(['simulator']); + expect(state.recommendedWorkflowIds).toEqual([ + 'coverage', + 'debugging', + 'device', + 'project-discovery', + 'project-scaffolding', + 'simulator', + 'simulator-management', + 'swift-package', + 'ui-automation', + 'utilities', + 'xcode-ide', + ]); + expect(state.recommendedWorkflowIds).not.toContain('macos'); + expect(state.recommendedWorkflowIds).not.toContain('doctor'); + expect(state.recommendedWorkflowIds).not.toContain('session-management'); + expect(state.recommendedWorkflowIds).not.toContain('workflow-discovery'); + }); + + it('shows macOS workflows as recommended options with only macos selected by default', async () => { + const state = await getWorkflowPromptStateForPlatforms(['macOS']); + + expect(state.recommendedInitialKeys).toEqual(['macos']); + expect(state.recommendedWorkflowIds).toEqual([ + 'coverage', + 'macos', + 'project-discovery', + 'project-scaffolding', + 'swift-package', + 'utilities', + 'xcode-ide', + ]); + expect(state.recommendedWorkflowIds).not.toContain('debugging'); + expect(state.recommendedWorkflowIds).not.toContain('device'); + expect(state.recommendedWorkflowIds).not.toContain('simulator'); + expect(state.recommendedWorkflowIds).not.toContain('simulator-management'); + expect(state.recommendedWorkflowIds).not.toContain('ui-automation'); + }); + + it('shows tvOS workflows as recommended options with only simulator selected by default', async () => { + const state = await getWorkflowPromptStateForPlatforms(['tvOS']); + + expect(state.recommendedInitialKeys).toEqual(['simulator']); + expect(state.recommendedWorkflowIds).toEqual([ + 'coverage', + 'debugging', + 'device', + 'project-discovery', + 'simulator', + 'simulator-management', + 'swift-package', + 'utilities', + 'xcode-ide', + ]); + expect(state.recommendedWorkflowIds).not.toContain('macos'); + expect(state.recommendedWorkflowIds).not.toContain('project-scaffolding'); + expect(state.recommendedWorkflowIds).not.toContain('ui-automation'); + }); + + it('selects macos and simulator by default for mixed macOS and simulator platforms', async () => { + const state = await getWorkflowPromptStateForPlatforms(['macOS', 'iOS']); + + expect(state.recommendedInitialKeys).toEqual(['macos', 'simulator']); + expect(state.recommendedWorkflowIds).toContain('macos'); + expect(state.recommendedWorkflowIds).toContain('simulator'); + }); + + it('does not recommend ui-automation for visionOS from manifest target platform metadata', async () => { + const state = await getWorkflowPromptStateForPlatforms(['visionOS']); + + expect(state.recommendedInitialKeys).toEqual(['simulator']); + expect(state.recommendedWorkflowIds).not.toContain('ui-automation'); + }); + + it('shows non-recommended workflows only after the user asks for additional options', async () => { + const state = await getWorkflowPromptStateForPlatforms(['iOS'], { + showAdditionalWorkflows: true, + }); + + expect(state.recommendedWorkflowIds).toContain('simulator'); + expect(state.recommendedWorkflowIds).toContain('xcode-ide'); + expect(state.additionalWorkflowIds).toContain('macos'); + expect(state.additionalWorkflowIds).not.toContain('simulator'); + expect(state.additionalWorkflowIds).not.toContain('xcode-ide'); + }); + + it('uses the normal flat workflow list when loading an existing config', async () => { + const state = await getWorkflowPromptStateForPlatforms(['iOS'], { + storedConfig: 'schemaVersion: 1\nenabledWorkflows:\n - simulator\n', + }); + + expect(state.flatWorkflowIds).toContain('simulator'); + expect(state.flatWorkflowIds).toContain('macos'); + expect(state.flatWorkflowIds).toContain('xcode-ide'); + expect(state.recommendedWorkflowIds).toEqual([]); + expect(state.additionalWorkflowIds).toEqual([]); + }); + it('fails fast when Xcode command line tools are unavailable', async () => { const failingExecutor: CommandExecutor = async (command) => { if (command[0] === 'xcodebuild') { diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index ee5aed808..6684dd698 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -6,6 +6,7 @@ import { discoverProjects } from '../../mcp/tools/project-discovery/discover_pro import { listSchemes } from '../../mcp/tools/project-discovery/list_schemes.ts'; import { listSimulators, type ListedSimulator } from '../../mcp/tools/simulator/list_sims.ts'; import { loadManifest, type WorkflowManifestEntry } from '../../core/manifest/load-manifest.ts'; +import type { WorkflowTargetPlatform } from '../../core/manifest/schema.ts'; import { isWorkflowEnabledForRuntime } from '../../visibility/exposure.ts'; import { getConfig } from '../../utils/config-store.ts'; import { @@ -25,7 +26,7 @@ import type { CommandExecutor } from '../../utils/CommandExecutor.ts'; import { createDoctorDependencies } from '../../mcp/tools/doctor/lib/doctor.deps.ts'; import { XcodePlatform } from '../../types/common.ts'; -type SetupPlatform = 'macOS' | 'iOS' | 'tvOS' | 'watchOS' | 'visionOS'; +type SetupPlatform = WorkflowTargetPlatform; const SETUP_PLATFORM_TO_SESSION_DEFAULT: Record = { macOS: XcodePlatform.macOS, @@ -91,68 +92,6 @@ interface SetupDevice { platform: string; } -const PLATFORM_WORKFLOWS: Record = { - macOS: [ - 'coverage', - 'debugging', - 'doctor', - 'logging', - 'macos', - 'project-discovery', - 'project-scaffolding', - 'swift-package', - 'ui-automation', - 'utilities', - 'xcode-ide', - ], - iOS: [ - 'coverage', - 'debugging', - 'doctor', - 'logging', - 'project-discovery', - 'project-scaffolding', - 'simulator', - 'swift-package', - 'ui-automation', - 'utilities', - 'xcode-ide', - ], - tvOS: [ - 'debugging', - 'doctor', - 'logging', - 'project-discovery', - 'simulator', - 'swift-package', - 'utilities', - 'xcode-ide', - ], - watchOS: [ - 'debugging', - 'doctor', - 'logging', - 'project-discovery', - 'simulator', - 'swift-package', - 'utilities', - 'xcode-ide', - ], - visionOS: [ - 'coverage', - 'debugging', - 'doctor', - 'logging', - 'project-discovery', - 'project-scaffolding', - 'simulator', - 'swift-package', - 'ui-automation', - 'utilities', - 'xcode-ide', - ], -}; - const PLATFORM_OPTIONS: Array<{ value: SetupPlatform; label: string; description: string }> = [ { value: 'macOS', label: 'macOS', description: 'Native macOS apps — no simulator needed' }, { value: 'iOS', label: 'iOS', description: 'iPhone and iPad apps, runs on iOS Simulator' }, @@ -310,6 +249,54 @@ function getWorkflowOptions( .sort((left, right) => left.id.localeCompare(right.id)); } +function getRecommendedWorkflowIds( + workflows: WorkflowManifestEntry[], + platforms: SetupPlatform[], +): string[] { + const selectedPlatforms = new Set(platforms); + return workflows + .filter((workflow) => + workflow.targetPlatforms.some((platform) => selectedPlatforms.has(platform)), + ) + .map((workflow) => workflow.id); +} + +function getDefaultWorkflowIdsForPlatforms( + workflows: WorkflowManifestEntry[], + platforms: SetupPlatform[], +): string[] { + const availableIds = new Set(workflows.map((workflow) => workflow.id)); + const defaults: string[] = []; + + if (platforms.includes('macOS') && availableIds.has('macos')) { + defaults.push('macos'); + } + + if (platforms.some((platform) => platform !== 'macOS') && availableIds.has('simulator')) { + defaults.push('simulator'); + } + + return defaults; +} + +function toWorkflowSelectOptions(workflows: WorkflowManifestEntry[]): SelectOption[] { + return workflows.map((workflow) => ({ + value: workflow.id, + label: workflow.id, + description: workflow.description, + })); +} + +function mergeWorkflowSelections( + workflowOptions: SelectOption[], + selectedIds: Iterable, +): string[] { + const selected = new Set(selectedIds); + return workflowOptions + .filter((option) => selected.has(option.value)) + .map((option) => option.value); +} + function getChangedFields( beforeConfig: ProjectConfig | undefined, afterConfig: ProjectConfig, @@ -391,42 +378,71 @@ async function selectWorkflowIds(opts: { return []; } - const workflowOptions: SelectOption[] = workflows.map((workflow) => ({ - value: workflow.id, - label: workflow.id, - description: workflow.description, - })); - - let defaults: string[]; - if (opts.existingEnabledWorkflows.length > 0) { - defaults = opts.existingEnabledWorkflows; - } else if (opts.platforms.length > 0) { - const availableIds = new Set(workflows.map((w) => w.id)); - const recommended = new Set(); - for (const platform of opts.platforms) { - for (const workflowId of PLATFORM_WORKFLOWS[platform]) { - if (availableIds.has(workflowId)) recommended.add(workflowId); - } - } - defaults = Array.from(recommended); - } else { - defaults = ['simulator']; + const recommendedIds = new Set(getRecommendedWorkflowIds(workflows, opts.platforms)); + const workflowOptions = toWorkflowSelectOptions(workflows); + const defaults = + opts.existingEnabledWorkflows.length > 0 + ? opts.existingEnabledWorkflows + : getDefaultWorkflowIdsForPlatforms(workflows, opts.platforms); + + if (opts.existingEnabledWorkflows.length > 0 || recommendedIds.size === 0) { + showPromptHelp( + 'Select workflows to choose which groups of tools are enabled by default in this project.', + opts.quietOutput, + ); + return opts.prompter.selectMany({ + message: 'Select workflows to enable', + options: workflowOptions, + initialSelectedKeys: new Set(defaults), + getKey: (value) => value, + minSelected: 1, + }); } + const recommendedOptions = workflowOptions.filter((option) => recommendedIds.has(option.value)); + const otherOptions = workflowOptions.filter((option) => !recommendedIds.has(option.value)); + showPromptHelp( - 'Select workflows to choose which groups of tools are enabled by default in this project.\n' + - 'The selection above is recommended for your chosen platforms — you can adjust it freely.', + 'Recommended workflows are based on your selected platform(s).\n' + + 'Only the core default workflow is selected automatically; you can adjust the recommendation list freely.', opts.quietOutput, ); - const selected = await opts.prompter.selectMany({ - message: 'Select workflows to enable', - options: workflowOptions, + const selectedRecommended = await opts.prompter.selectMany({ + message: 'Select recommended workflows to enable', + options: recommendedOptions, initialSelectedKeys: new Set(defaults), getKey: (value) => value, - minSelected: 1, + minSelected: otherOptions.length > 0 ? 0 : 1, + }); + + if (otherOptions.length === 0) { + return selectedRecommended; + } + + showPromptHelp( + 'Additional workflows are not specifically recommended for your selected platform(s),\n' + + 'but you can still enable them if they fit your project.', + opts.quietOutput, + ); + const showAdditionalWorkflows = + selectedRecommended.length === 0 || + (await opts.prompter.confirm({ + message: 'Show additional workflows?', + defaultValue: false, + })); + + if (!showAdditionalWorkflows) { + return selectedRecommended; + } + + const selectedOther = await opts.prompter.selectMany({ + message: 'Select additional workflows to enable', + options: otherOptions, + getKey: (value) => value, + minSelected: selectedRecommended.length === 0 ? 1 : 0, }); - return selected; + return mergeWorkflowSelections(workflowOptions, [...selectedRecommended, ...selectedOther]); } async function selectPlatforms(opts: { diff --git a/src/core/manifest/__tests__/schema.test.ts b/src/core/manifest/__tests__/schema.test.ts index 177bb6e70..528339bd3 100644 --- a/src/core/manifest/__tests__/schema.test.ts +++ b/src/core/manifest/__tests__/schema.test.ts @@ -17,6 +17,7 @@ describe('schema', () => { id: 'simulator', title: 'iOS Simulator Development', description: 'Build and test iOS apps on simulators', + targetPlatforms: ['iOS'], tools: ['build_sim'], }; @@ -36,10 +37,48 @@ describe('schema', () => { expect(toolResult.data.predicates).toEqual([]); expect(workflowResult.data.availability).toEqual({ mcp: true, cli: true }); expect(workflowResult.data.predicates).toEqual([]); + expect(workflowResult.data.targetPlatforms).toEqual(['iOS']); expect(workflowResult.data.tools).toEqual(['build_sim']); expect(getEffectiveCliName(toolResult.data)).toBe('build-sim'); }); + it('requires workflow target platform metadata', () => { + const result = workflowManifestEntrySchema.safeParse({ + id: 'simulator', + title: 'iOS Simulator Development', + description: 'Build and test iOS apps on simulators', + tools: ['build_sim'], + }); + + expect(result.success).toBe(false); + }); + + it('rejects invalid workflow target platform metadata', () => { + const result = workflowManifestEntrySchema.safeParse({ + id: 'simulator', + title: 'iOS Simulator Development', + description: 'Build and test iOS apps on simulators', + targetPlatforms: ['iPhoneOS'], + tools: ['build_sim'], + }); + + expect(result.success).toBe(false); + }); + + it('allows empty workflow target platform metadata', () => { + const result = workflowManifestEntrySchema.safeParse({ + id: 'workflow-discovery', + title: 'Workflow Discovery', + description: 'Manage enabled workflows at runtime', + targetPlatforms: [], + tools: ['manage_workflows'], + }); + + expect(result.success).toBe(true); + if (!result.success) throw new Error('Expected empty targetPlatforms to parse'); + expect(result.data.targetPlatforms).toEqual([]); + }); + it('parses output schema metadata for tool manifests', () => { const result = toolManifestEntrySchema.safeParse({ id: 'list_sims', diff --git a/src/core/manifest/schema.ts b/src/core/manifest/schema.ts index 2f4730dca..d75be3219 100644 --- a/src/core/manifest/schema.ts +++ b/src/core/manifest/schema.ts @@ -140,6 +140,13 @@ export const workflowSelectionSchema = z.object({ export type WorkflowSelection = z.infer; +/** + * Apple platforms used by setup to recommend workflows. + */ +export const workflowTargetPlatformSchema = z.enum(['iOS', 'macOS', 'tvOS', 'watchOS', 'visionOS']); + +export type WorkflowTargetPlatform = z.infer; + /** * Workflow manifest entry schema. * Describes a workflow's metadata and tool composition. @@ -154,6 +161,9 @@ export const workflowManifestEntrySchema = z.object({ /** Workflow description */ description: z.string(), + /** Setup platforms this workflow is recommended for */ + targetPlatforms: z.array(workflowTargetPlatformSchema), + /** Per-runtime availability flags */ availability: availabilitySchema.default({ mcp: true, cli: true }), diff --git a/src/utils/__tests__/tool-registry.test.ts b/src/utils/__tests__/tool-registry.test.ts index 46569bb55..a740183f7 100644 --- a/src/utils/__tests__/tool-registry.test.ts +++ b/src/utils/__tests__/tool-registry.test.ts @@ -35,6 +35,7 @@ function createManifestFixture(): ResolvedManifest { id: 'simulator', title: 'Simulator', description: 'Built-in simulator workflow', + targetPlatforms: ['iOS'], availability: { mcp: true, cli: true }, predicates: [], tools: ['build_run_sim'], @@ -56,6 +57,7 @@ describe('createCustomWorkflowsFromConfig', () => { expect(result.workflows).toEqual([ expect.objectContaining({ id: 'my-workflow', + targetPlatforms: [], tools: ['build_run_sim', 'screenshot'], }), ]); diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index c4722d6bd..ff39b981f 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -158,6 +158,7 @@ export function createCustomWorkflowsFromConfig( id: workflowName, title: workflowName, description: `Custom workflow '${workflowName}' from config.yaml.`, + targetPlatforms: [], availability: { mcp: true, cli: false }, selection: { mcp: { defaultEnabled: false, autoInclude: false } }, predicates: [], diff --git a/src/visibility/__tests__/exposure.test.ts b/src/visibility/__tests__/exposure.test.ts index 3eedd2856..a2563d28e 100644 --- a/src/visibility/__tests__/exposure.test.ts +++ b/src/visibility/__tests__/exposure.test.ts @@ -62,6 +62,7 @@ function createWorkflow(overrides: Partial = {}): Workflo id: 'test-workflow', title: 'Test Workflow', description: 'A test workflow', + targetPlatforms: [], availability: { mcp: true, cli: true }, predicates: [], tools: ['test_tool'], From 264119a46396296c7c00fd75cd62e23c24a8193c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 19:40:44 +0100 Subject: [PATCH 5/9] feat(device): support tvOS, watchOS, and visionOS device builds Extract a shared `devicePlatformSchema` and reuse `mapDevicePlatform` across `build_device`, `build_run_device`, `get_device_app_path`, and `test_device` so each device tool accepts a `platform` argument and threads it into the xcodebuild invocation and log prefix. The schema preprocesses simulator-flavored session defaults (e.g. `tvOS Simulator`) down to the matching device platform so a session configured for a simulator runtime still produces a valid device build target. Tests cover the new platform argument, the simulator-to-device normalization, and the rejected `macOS` case. --- .../device/__tests__/build_device.test.ts | 45 ++++++++++++++++++- .../device/__tests__/build_run_device.test.ts | 5 ++- .../__tests__/get_device_app_path.test.ts | 8 ++-- .../device/__tests__/test_device.test.ts | 6 ++- src/mcp/tools/device/build-settings.ts | 25 ++++++++++- src/mcp/tools/device/build_device.ts | 10 +++-- src/mcp/tools/device/build_run_device.ts | 5 +-- src/mcp/tools/device/get_device_app_path.ts | 5 +-- src/mcp/tools/device/test_device.ts | 6 +-- 9 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts index 41b25741b..1532be260 100644 --- a/src/mcp/tools/device/__tests__/build_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_device.test.ts @@ -5,6 +5,12 @@ import { createMockExecutor } from '../../../../test-utils/mock-executors.ts'; import { expectPendingBuildResponse, runToolLogic } from '../../../../test-utils/test-helpers.ts'; import { schema, handler, buildDeviceLogic } from '../build_device.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; + +const runHandlerWithExecutor = handler as unknown as ( + args: Record, + executor: CommandExecutor, +) => Promise<{ isError?: boolean }>; function createSpyExecutor(): { commandCalls: Array<{ args: string[]; logPrefix?: string }>; @@ -41,8 +47,12 @@ describe('build_device plugin', () => { false, ); + expect(schemaObj.safeParse({ platform: 'tvOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); + const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs']); + expect(schemaKeys).toEqual(['extraArgs', 'platform']); }); }); @@ -202,6 +212,39 @@ describe('build_device plugin', () => { expect(spy.commandCalls[0].logPrefix).toBe('iOS Device Build'); }); + it('should build for a selected non-iOS device platform', async () => { + const spy = createSpyExecutor(); + + sessionStore.setDefaults({ + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + }); + + const result = await runHandlerWithExecutor({ platform: 'tvOS' }, spy.executor); + + expect(result.isError).toBeUndefined(); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toContain('generic/platform=tvOS'); + expect(spy.commandCalls[0].logPrefix).toBe('tvOS Device Build'); + }); + + it('should normalize simulator session platforms for device builds', async () => { + const spy = createSpyExecutor(); + + sessionStore.setDefaults({ + projectPath: '/path/to/MyProject.xcodeproj', + scheme: 'MyScheme', + platform: 'tvOS Simulator', + }); + + const result = await runHandlerWithExecutor({}, spy.executor); + + expect(result.isError).toBeUndefined(); + expect(spy.commandCalls).toHaveLength(1); + expect(spy.commandCalls[0].args).toContain('generic/platform=tvOS'); + expect(spy.commandCalls[0].logPrefix).toBe('tvOS Device Build'); + }); + it('should return exact successful build response', async () => { const mockExecutor = createMockExecutor({ success: true, diff --git a/src/mcp/tools/device/__tests__/build_run_device.test.ts b/src/mcp/tools/device/__tests__/build_run_device.test.ts index a9e0f60f5..3b7664b35 100644 --- a/src/mcp/tools/device/__tests__/build_run_device.test.ts +++ b/src/mcp/tools/device/__tests__/build_run_device.test.ts @@ -33,12 +33,15 @@ describe('build_run_device tool', () => { expect(schemaObj.safeParse({}).success).toBe(true); expect(schemaObj.safeParse({ extraArgs: ['-quiet'] }).success).toBe(true); expect(schemaObj.safeParse({ env: { FOO: 'bar' } }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); expect(schemaObj.safeParse({ scheme: 'App' }).success).toBe(false); expect(schemaObj.safeParse({ deviceId: 'device-id' }).success).toBe(false); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['env', 'extraArgs']); + expect(schemaKeys).toEqual(['env', 'extraArgs', 'platform']); }); }); diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts index acfde74e9..d28dd821c 100644 --- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts +++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts @@ -24,16 +24,18 @@ describe('get_device_app_path plugin', () => { expect(typeof handler).toBe('function'); }); - it('should expose empty public schema', () => { + it('should expose only session-free fields in public schema', () => { const schemaObj = z.strictObject(schema); expect(schemaObj.safeParse({}).success).toBe(true); - expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); expect(schemaObj.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe( false, ); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual([]); + expect(schemaKeys).toEqual(['platform']); }); }); diff --git a/src/mcp/tools/device/__tests__/test_device.test.ts b/src/mcp/tools/device/__tests__/test_device.test.ts index a737ccf14..974c560dd 100644 --- a/src/mcp/tools/device/__tests__/test_device.test.ts +++ b/src/mcp/tools/device/__tests__/test_device.test.ts @@ -59,13 +59,15 @@ describe('test_device plugin', () => { expect(schemaObj.safeParse({}).success).toBe(true); expect(schemaObj.safeParse({ derivedDataPath: '/path/to/derived-data' }).success).toBe(false); expect(schemaObj.safeParse({ preferXcodebuild: true }).success).toBe(false); - expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(false); + expect(schemaObj.safeParse({ platform: 'iOS' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'tvOS Simulator' }).success).toBe(true); + expect(schemaObj.safeParse({ platform: 'macOS' }).success).toBe(false); expect(schemaObj.safeParse({ projectPath: '/path/to/project.xcodeproj' }).success).toBe( false, ); const schemaKeys = Object.keys(schema).sort(); - expect(schemaKeys).toEqual(['extraArgs', 'progress', 'testRunnerEnv']); + expect(schemaKeys).toEqual(['extraArgs', 'platform', 'progress', 'testRunnerEnv']); }); it('should validate XOR between projectPath and workspacePath', async () => { diff --git a/src/mcp/tools/device/build-settings.ts b/src/mcp/tools/device/build-settings.ts index f4213a05a..cb7883bb3 100644 --- a/src/mcp/tools/device/build-settings.ts +++ b/src/mcp/tools/device/build-settings.ts @@ -1,6 +1,28 @@ +import * as z from 'zod'; import { XcodePlatform } from '../../../types/common.ts'; -export type DevicePlatform = 'iOS' | 'watchOS' | 'tvOS' | 'visionOS'; +const devicePlatformValues = ['iOS', 'watchOS', 'tvOS', 'visionOS'] as const; + +export type DevicePlatform = (typeof devicePlatformValues)[number]; + +function normalizeDevicePlatform(platform?: unknown): unknown { + switch (platform) { + case XcodePlatform.iOSSimulator: + return 'iOS'; + case XcodePlatform.watchOSSimulator: + return 'watchOS'; + case XcodePlatform.tvOSSimulator: + return 'tvOS'; + case XcodePlatform.visionOSSimulator: + return 'visionOS'; + default: + return platform; + } +} + +export const devicePlatformSchema = z + .preprocess(normalizeDevicePlatform, z.enum(devicePlatformValues).optional()) + .describe('Device platform: iOS, watchOS, tvOS, or visionOS. Defaults to iOS.'); export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { switch (platform) { @@ -12,7 +34,6 @@ export function mapDevicePlatform(platform?: DevicePlatform): XcodePlatform { return XcodePlatform.visionOS; case 'iOS': case undefined: - default: return XcodePlatform.iOS; } } diff --git a/src/mcp/tools/device/build_device.ts b/src/mcp/tools/device/build_device.ts index d7f2bf09f..eed277795 100644 --- a/src/mcp/tools/device/build_device.ts +++ b/src/mcp/tools/device/build_device.ts @@ -8,8 +8,8 @@ import * as z from 'zod'; import type { BuildResultDomainResult } from '../../../types/domain-results.ts'; import type { StreamingExecutor } from '../../../types/tool-execution.ts'; -import { XcodePlatform } from '../../../types/common.ts'; import { executeXcodeBuildCommand } from '../../../utils/build/index.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import type { CommandExecutor } from '../../../utils/execution/index.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { @@ -35,7 +35,7 @@ function createBuildDeviceRequest(params: BuildDeviceParams): BuildInvocationReq workspacePath: params.workspacePath, projectPath: params.projectPath, configuration: params.configuration ?? 'Debug', - platform: 'iOS', + platform: String(mapDevicePlatform(params.platform)), target: 'device', }; } @@ -48,6 +48,7 @@ const baseSchemaObject = z.object({ derivedDataPath: z.string().optional(), extraArgs: z.array(z.string()).optional(), preferXcodebuild: z.boolean().optional(), + platform: devicePlatformSchema, }); const buildDeviceSchema = z.preprocess( @@ -71,6 +72,7 @@ export function createBuildDeviceExecutor( executor: CommandExecutor, ): StreamingExecutor { return async (params, ctx) => { + const platform = mapDevicePlatform(params.platform); const processedParams = { ...params, configuration: params.configuration ?? 'Debug', @@ -80,8 +82,8 @@ export function createBuildDeviceExecutor( const buildResult = await executeXcodeBuildCommand( processedParams, { - platform: XcodePlatform.iOS, - logPrefix: 'iOS Device Build', + platform, + logPrefix: `${platform} Device Build`, }, params.preferXcodebuild ?? false, 'build', diff --git a/src/mcp/tools/device/build_run_device.ts b/src/mcp/tools/device/build_run_device.ts index 6fc9eb59f..cde8768b6 100644 --- a/src/mcp/tools/device/build_run_device.ts +++ b/src/mcp/tools/device/build_run_device.ts @@ -23,7 +23,7 @@ import { } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings, withProjectOrWorkspace } from '../../../utils/schema-helpers.ts'; import { extractBundleIdFromAppPath } from '../../../utils/bundle-id.ts'; -import { mapDevicePlatform } from './build-settings.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; import { installAppOnDevice, launchAppOnDevice } from '../../../utils/device-steps.ts'; import type { BuildInvocationRequest } from '../../../types/domain-fragments.ts'; @@ -53,7 +53,7 @@ const baseSchemaObject = z.object({ workspacePath: z.string().optional().describe('Path to the .xcworkspace file'), scheme: z.string().describe('The scheme to build and run'), deviceId: z.string().describe('UDID of the device (obtained from list_devices)'), - platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional().describe('default: iOS'), + platform: devicePlatformSchema, configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), derivedDataPath: z.string().optional(), extraArgs: z.array(z.string()).optional(), @@ -269,7 +269,6 @@ const publicSchemaObject = baseSchemaObject.omit({ workspacePath: true, scheme: true, deviceId: true, - platform: true, configuration: true, derivedDataPath: true, preferXcodebuild: true, diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts index 6dfe93456..e8f47c8b1 100644 --- a/src/mcp/tools/device/get_device_app_path.ts +++ b/src/mcp/tools/device/get_device_app_path.ts @@ -18,7 +18,7 @@ import { toInternalSchema, } from '../../../utils/typed-tool-factory.ts'; import { nullifyEmptyStrings, withProjectOrWorkspace } from '../../../utils/schema-helpers.ts'; -import { mapDevicePlatform } from './build-settings.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import { resolveAppPathFromBuildSettings } from '../../../utils/app-path-resolver.ts'; import { toErrorMessage } from '../../../utils/errors.ts'; import { @@ -32,7 +32,7 @@ import { const baseOptions = { scheme: z.string().describe('The scheme to use'), configuration: z.string().optional().describe('Build configuration (Debug, Release, etc.)'), - platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional().describe('default: iOS'), + platform: devicePlatformSchema, }; const baseSchemaObject = z.object({ @@ -53,7 +53,6 @@ const publicSchemaObject = baseSchemaObject.omit({ workspacePath: true, scheme: true, configuration: true, - platform: true, } as const); function createRequest(params: GetDeviceAppPathParams) { diff --git a/src/mcp/tools/device/test_device.ts b/src/mcp/tools/device/test_device.ts index e41879f60..2e66c0558 100644 --- a/src/mcp/tools/device/test_device.ts +++ b/src/mcp/tools/device/test_device.ts @@ -9,6 +9,7 @@ import * as z from 'zod'; import type { TestResultDomainResult } from '../../../types/domain-results.ts'; import type { StreamingExecutor } from '../../../types/tool-execution.ts'; import { XcodePlatform } from '../../../types/common.ts'; +import { devicePlatformSchema, mapDevicePlatform } from './build-settings.ts'; import { createTestExecutor } from '../../../utils/test/index.ts'; import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts'; import { @@ -39,7 +40,7 @@ const baseSchemaObject = z.object({ derivedDataPath: z.string().optional(), extraArgs: z.array(z.string()).optional(), preferXcodebuild: z.boolean().optional(), - platform: z.enum(['iOS', 'watchOS', 'tvOS', 'visionOS']).optional(), + platform: devicePlatformSchema, testRunnerEnv: z .record(z.string(), z.string()) .optional() @@ -68,7 +69,6 @@ const publicSchemaObject = baseSchemaObject.omit({ configuration: true, derivedDataPath: true, preferXcodebuild: true, - platform: true, } as const); interface PreparedTestDeviceExecution { @@ -83,7 +83,7 @@ async function prepareTestDeviceExecution( fileSystemExecutor: FileSystemExecutor, ): Promise { const configuration = params.configuration ?? 'Debug'; - const platform = (params.platform as XcodePlatform) || XcodePlatform.iOS; + const platform = mapDevicePlatform(params.platform); const preflight = await resolveTestPreflight( { projectPath: params.projectPath, From e3e2ee50c264ac64cd1293ee791382464661fca2 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 19:42:51 +0100 Subject: [PATCH 6/9] docs(agents): scope review focus to branch regressions Add a guidance line to AGENTS.md and CLAUDE.md telling reviewers to focus on behavior changes caused by the current branch and to ignore known test flakes, environment setup issues, and nondeterministic tool output churn unless explicitly asked to investigate them. --- AGENTS.md | 1 + CLAUDE.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a0e45a7a8..2a29f48bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ Use these sections under `## [Unreleased]`: - Common hang causes: locked physical device, stale simulator state, `devicectl diagnose` waiting for password, orphaned daemon process. - Capture what you find before killing, so the root cause can be fixed rather than papered over. - If physical-device snapshot tests hang after the final test summary, the likely cause is Apple post-failure diagnostics invoking `devicectl diagnose`, which may prompt for a macOS password and wedge in automated runs. +- When asked to review changes or test failures, focus on regressions: behavior changes caused by the branch. Do not treat known/acceptable test flakes, environment setup issues, or nondeterministic tool output churn as regressions unless explicitly asked to investigate them. ## **CRITICAL** Tool Usage Rules **CRITICAL** - NEVER use sed/cat to read a file or a range of a file. Always use the native read tool. diff --git a/CLAUDE.md b/CLAUDE.md index 74a52bc62..40ab6744f 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,7 @@ Use these sections under `## [Unreleased]`: - Do NOT just kill the run — first inspect the process tree (`ps -ef | grep -E "vitest|xcodebuild|simctl|devicectl"`) to identify what's stuck. - Common hang causes: locked physical device, stale simulator state, `devicectl diagnose` waiting for password, orphaned daemon process. - Capture what you find before killing, so the root cause can be fixed rather than papered over. +- When asked to review changes or test failures, focus on regressions: behavior changes caused by the branch. Do not treat known/acceptable test flakes, environment setup issues, or nondeterministic tool output churn as regressions unless explicitly asked to investigate them. ## **CRITICAL** Tool Usage Rules **CRITICAL** - NEVER use sed/cat to read a file or a range of a file. Always use the native read tool. From f0eb2575f7363f6110f800c4c4a89542612a8ce8 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 20:38:22 +0100 Subject: [PATCH 7/9] ci: remove tool authoring guidance workflow The reminder workflow posted a PR comment when tool contract files changed, but org-level GITHUB_TOKEN policy blocks the comment write and the check stays failing without surfacing useful information on the PR. Authoring guidance is maintained in the docs site at xcodebuildmcp.com/docs/tool-authoring. --- .github/workflows/tool-authoring-guidance.yml | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 .github/workflows/tool-authoring-guidance.yml diff --git a/.github/workflows/tool-authoring-guidance.yml b/.github/workflows/tool-authoring-guidance.yml deleted file mode 100644 index 74c494268..000000000 --- a/.github/workflows/tool-authoring-guidance.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Tool Authoring Guidance - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - issues: write - pull-requests: read - -jobs: - comment: - name: Comment when tool contracts change - runs-on: ubuntu-latest - - steps: - - name: Post contributor guidance - uses: actions/github-script@v7 - with: - script: | - const marker = ''; - const watchedPrefixes = [ - 'manifests/', - 'schemas/', - 'src/mcp/tools/', - ]; - - const { owner, repo } = context.repo; - const pull_number = context.payload.pull_request.number; - - const files = await github.paginate(github.rest.pulls.listFiles, { - owner, - repo, - pull_number, - per_page: 100, - }); - - const changedFiles = files - .map((file) => file.filename) - .filter((filename) => watchedPrefixes.some((prefix) => filename.startsWith(prefix))) - .sort(); - - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number: pull_number, - per_page: 100, - }); - - const existingComment = comments.find((comment) => - comment.user?.type === 'Bot' && comment.body?.includes(marker) - ); - - if (changedFiles.length === 0) { - if (existingComment) { - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: existingComment.id, - }); - } - return; - } - - const maxFilesToShow = 30; - const shownFiles = changedFiles.slice(0, maxFilesToShow); - const hiddenCount = changedFiles.length - shownFiles.length; - const fileList = shownFiles.map((filename) => `- \`${filename}\``).join('\n'); - const hiddenText = hiddenCount > 0 ? `\n- ...and ${hiddenCount} more` : ''; - - const body = `${marker} - ## Tool authoring reminder - - This PR modifies tool contract files: - - ${fileList}${hiddenText} - - Please review the [Tool Authoring guide](https://xcodebuildmcp.com/docs/tool-authoring) before merging. - - Checklist: - - Run \`npm run test:snapshots\` for any added, modified, or deleted tool. - - If fixtures need to change, regenerate them with \`npm run test:snapshots:update\` and review the diff. - - Add, update, or remove the matching MCP, CLI, and JSON fixtures for the changed tool surface. - - Run \`npm run test:schema-fixtures\` after changing structured output schemas or JSON fixtures. - - Keep tool manifests, workflow manifests, output schemas, structured content, and fixtures aligned. - - If you changed tool metadata, run \`npm run docs:check\`. - - Snapshot tests are intentionally not a required PR gate because they are slow and environment-sensitive. This reminder exists so contributors know when they need to run them locally for tool additions, changes, and removals.`; - - const normalizedBody = body - .split('\n') - .map((line) => line.trimStart()) - .join('\n'); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existingComment.id, - body: normalizedBody, - }); - return; - } - - await github.rest.issues.createComment({ - owner, - repo, - issue_number: pull_number, - body: normalizedBody, - }); From 82d310126521cb926741e697b6b5782c6bedb03d Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 21:33:23 +0100 Subject: [PATCH 8/9] refactor(setup): persist wizard platform selection in setupPreferences The setup wizard was writing the user's "which platforms does this project target?" answer into sessionDefaults.platform, conflating UI memory with a runtime tool-param default. It also relied on sessionDefaults.simulatorPlatform (an internal cache) to recover the non-macOS half of multi-platform selections, which silently reverted [macOS, visionOS] to [macOS, iOS] on re-run. Move wizard memory to a dedicated top-level setupPreferences.platforms field. sessionDefaults.platform/simulatorPlatform are no longer touched by setup; they remain agent-controlled session defaults. The mcp-json output still seeds XCODEBUILDMCP_PLATFORM for fresh clients, since that is an explicit env-var bootstrap, not internal state. Follow-up #366 tracks moving simulatorPlatform out of sessionDefaults. --- src/cli/commands/__tests__/setup.test.ts | 26 ++++++++++++---- src/cli/commands/setup.ts | 38 ++++++++---------------- src/utils/project-config.ts | 11 +++++++ src/utils/runtime-config-schema.ts | 5 ++++ 4 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 7b28284b6..c86265026 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -1337,9 +1337,11 @@ sessionDefaults: const parsed = parseYaml(storedConfig) as { sessionDefaults?: Record; + setupPreferences?: { platforms?: string[] }; }; - expect(parsed.sessionDefaults?.platform).toBe('macOS'); + expect(parsed.setupPreferences?.platforms).toEqual(['macOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); @@ -1577,7 +1579,12 @@ sessionDefaults: sessionDefaults?: Record; }; - expect(parsed.sessionDefaults?.platform).toBe('macOS'); + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['macOS']); + // setup intentionally does not touch sessionDefaults.platform (agent-controlled field); + // the pre-existing value from the fixture is preserved. + expect(parsed.sessionDefaults?.platform).toBe('iOS Simulator'); expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); @@ -1648,7 +1655,10 @@ sessionDefaults: sessionDefaults?: Record; }; - expect(parsed.sessionDefaults?.platform).toBe('tvOS Simulator'); + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['tvOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorId).toBe('TVOS-1'); expect(parsed.sessionDefaults?.simulatorName).toBe('Apple TV 4K'); }); @@ -1723,7 +1733,10 @@ sessionDefaults: sessionDefaults?: Record; }; - expect(parsed.sessionDefaults?.platform).toBe('watchOS Simulator'); + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['watchOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorId).toBe('WATCH-1'); expect(parsed.sessionDefaults?.simulatorName).toBe('Apple Watch Series 9'); }); @@ -1793,7 +1806,10 @@ sessionDefaults: sessionDefaults?: Record; }; - expect(parsed.sessionDefaults?.platform).toBe('visionOS Simulator'); + expect( + (parsed as { setupPreferences?: { platforms?: string[] } }).setupPreferences?.platforms, + ).toEqual(['visionOS']); + expect(parsed.sessionDefaults?.platform).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorId).toBe('XROS-1'); expect(parsed.sessionDefaults?.simulatorName).toBe('Apple Vision Pro'); }); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 6684dd698..3355cf3ca 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -182,26 +182,16 @@ function normalizeExistingDefaults(config?: ProjectConfig): { function inferPlatformsFromExisting(config?: ProjectConfig): SetupPlatform[] { if (!config) return []; - const platform = config.sessionDefaults?.platform; - if (platform != null && SESSION_DEFAULT_TO_SETUP_PLATFORM[platform] != null) { - return [SESSION_DEFAULT_TO_SETUP_PLATFORM[platform]]; + const stored = config.setupPreferences?.platforms; + if (stored && stored.length > 0) { + return [...stored]; } - // Multi-platform or legacy config: macOS is recovered from the workflow set, - // the non-macOS component from the cached simulatorPlatform. - const results: SetupPlatform[] = []; + // No stored preference: only macOS is unambiguously recoverable from enabledWorkflows. + // Simulator-platform identity (iOS vs tvOS vs watchOS vs visionOS) cannot be inferred + // from workflow ids alone, so leave it blank and let the wizard re-prompt. const workflows = new Set(config.enabledWorkflows ?? []); - if (workflows.has('macos')) results.push('macOS'); - - const simPlatform = config.sessionDefaults?.simulatorPlatform; - const fromSim = simPlatform != null ? SESSION_DEFAULT_TO_SETUP_PLATFORM[simPlatform] : undefined; - if (fromSim != null && fromSim !== 'macOS') { - results.push(fromSim); - } else if (workflows.has('simulator')) { - results.push('iOS'); - } - - return results; + return workflows.has('macos') ? ['macOS'] : []; } function derivePlatformSessionDefault(platforms: SetupPlatform[]): string | undefined { @@ -347,9 +337,9 @@ function getChangedFields( afterValue: afterDefaults.simulatorName, }, { - label: 'sessionDefaults.platform', - beforeValue: beforeDefaults.platform, - afterValue: afterDefaults.platform, + label: 'setupPreferences.platforms', + beforeValue: beforeConfig?.setupPreferences?.platforms, + afterValue: afterConfig.setupPreferences?.platforms, }, ]; @@ -1113,11 +1103,6 @@ export async function runSetupWizard(deps?: Partial): Promise deleteSessionDefaultKeys.push('simulatorId', 'simulatorName'); } - const derivedPlatform = derivePlatformSessionDefault(selection.platforms); - if (!derivedPlatform) { - deleteSessionDefaultKeys.push('platform'); - } - const persistedProjectPath = selection.projectPath != null ? relativePathOrAbsolute(selection.projectPath, resolvedDeps.cwd) @@ -1141,8 +1126,9 @@ export async function runSetupWizard(deps?: Partial): Promise deviceId: selection.deviceId, simulatorId: selection.simulatorId, simulatorName: selection.simulatorName, - platform: derivedPlatform, }, + setupPreferences: + selection.platforms.length > 0 ? { platforms: [...selection.platforms] } : null, }, deleteSessionDefaultKeys, }); diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index fb9af4952..0a61c588a 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -46,6 +46,10 @@ export type PersistActiveSessionDefaultsProfileOptions = { profile?: string | null; }; +export type SetupPreferences = { + platforms?: ('macOS' | 'iOS' | 'tvOS' | 'watchOS' | 'visionOS')[]; +}; + export type PersistProjectConfigPatchOptions = { fs: FileSystemExecutor; cwd: string; @@ -56,6 +60,7 @@ export type PersistProjectConfigPatchOptions = { experimentalWorkflowDiscovery?: boolean; disableSessionDefaults?: boolean; sessionDefaults?: Partial; + setupPreferences?: SetupPreferences | null; }; deleteSessionDefaultKeys?: (keyof SessionDefaults)[]; }; @@ -424,6 +429,12 @@ export async function persistProjectConfigPatch( nextConfig[key] = value; } + if (options.patch.setupPreferences === null) { + delete nextConfig.setupPreferences; + } else if (options.patch.setupPreferences !== undefined) { + nextConfig.setupPreferences = options.patch.setupPreferences; + } + if (options.patch.sessionDefaults) { const patch = removeUndefined(options.patch.sessionDefaults as Record); const nextSessionDefaults: Partial = { diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index ce0690c86..878164099 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -25,6 +25,11 @@ export const runtimeConfigFileSchema = z sessionDefaults: sessionDefaultsSchema.optional(), sessionDefaultsProfiles: z.record(z.string(), sessionDefaultsSchema).optional(), activeSessionDefaultsProfile: z.string().optional(), + setupPreferences: z + .object({ + platforms: z.array(z.enum(['macOS', 'iOS', 'tvOS', 'watchOS', 'visionOS'])).optional(), + }) + .optional(), }) .passthrough(); From 1eb1734da8f940c33c2a5de8c979050782e7020f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sun, 26 Apr 2026 21:40:09 +0100 Subject: [PATCH 9/9] refactor(setup): remove unused SESSION_DEFAULT_TO_SETUP_PLATFORM The reverse mapping had a single consumer in the legacy inferPlatformsFromExisting branch. After 82d31012 moved wizard memory to setupPreferences.platforms, that lookup is gone and the constant is dead. CodeQL flagged it. --- src/cli/commands/setup.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 3355cf3ca..3de1f6631 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -36,13 +36,6 @@ const SETUP_PLATFORM_TO_SESSION_DEFAULT: Record = visionOS: XcodePlatform.visionOSSimulator, }; -const SESSION_DEFAULT_TO_SETUP_PLATFORM: Record = Object.fromEntries( - Object.entries(SETUP_PLATFORM_TO_SESSION_DEFAULT).map(([setup, session]) => [ - session, - setup as SetupPlatform, - ]), -); - const SIMULATOR_RUNTIME_KEYWORDS: Record, string[]> = { iOS: ['iOS'], tvOS: ['tvOS'],