From c11eabdf70037e84d65b1ae52130574e4838c486 Mon Sep 17 00:00:00 2001 From: Ismar Iljazovic Date: Mon, 9 Mar 2026 21:17:42 +0100 Subject: [PATCH 1/6] feat: env var session defaults and setup --format mcp-json - Add readEnvSessionDefaults() to parse all 15 session default env vars (XCODEBUILDMCP_WORKSPACE_PATH, XCODEBUILDMCP_SCHEME, etc.) - Wire env layer into resolveSessionDefaults() with precedence: tool overrides > config file > env vars - Add --format mcp-json flag to xcodebuildmcp setup: outputs a ready-to-paste MCP client config JSON block instead of writing config.yaml - Update CONFIGURATION.md: env vars documented as recommended method for MCP client integration, remove 'legacy' label, add layering section - Add tests for env var parsing and file-over-env precedence - Add test for --format mcp-json output Closes #267 --- CHANGELOG.md | 12 ++ docs/CONFIGURATION.md | 190 ++++++++++++++++++++--- src/cli/commands/__tests__/setup.test.ts | 85 ++++++++++ src/cli/commands/setup.ts | 103 ++++++++++-- src/utils/__tests__/config-store.test.ts | 49 ++++++ src/utils/config-store.ts | 58 ++++++- 6 files changed, 460 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb19a02..dabd4f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Added + +- Added environment variable support for all session defaults (e.g., `XCODEBUILDMCP_WORKSPACE_PATH`, `XCODEBUILDMCP_SCHEME`, `XCODEBUILDMCP_PLATFORM`), enabling full configuration via the MCP client `env` field without requiring a config file ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +- Added `--format mcp-json` flag to `xcodebuildmcp setup` that outputs a ready-to-paste MCP client config JSON block instead of writing `config.yaml` ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +- Added copy-pastable MCP config examples for macOS, iOS, multi-platform, tvOS, and watchOS projects to [docs/CONFIGURATION.md](docs/CONFIGURATION.md) ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). + +### Changed + +- Environment variables are now documented as the recommended configuration method for MCP client integration, replacing the previous "legacy" designation. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). + ## [2.2.1] - Fix AXe bundling issue. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7f3cddd0..2c6426e7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,10 +1,12 @@ # Configuration -XcodeBuildMCP reads configuration from a project config file. The config file is optional but provides deterministic, repo-scoped behavior for every session. +XcodeBuildMCP reads configuration from environment variables and/or a project config file. Both are optional but provide deterministic behavior for every session. ## Contents +- [Environment variables](#environment-variables) - [Config file](#config-file) +- [Configuration layering](#configuration-layering) - [Session defaults](#session-defaults) - [Workflow selection](#workflow-selection) - [Build settings](#build-settings) @@ -13,13 +15,164 @@ XcodeBuildMCP reads configuration from a project config file. The config file is - [Templates](#templates) - [Telemetry](#telemetry) - [Quick reference](#quick-reference) -- [Environment variables (legacy)](#environment-variables-legacy) + +--- + +## Environment variables + +Environment variables are the recommended configuration method for MCP client integration. Set them in the `env` field of your MCP client config (e.g., `mcp_config.json` for Windsurf, `.vscode/mcp.json` for VS Code, `claude_desktop_config.json` for Claude Desktop). + +This approach works reliably across all MCP clients regardless of working directory, and avoids the need for filesystem-based config discovery. + +### General settings + +| Config option | Environment variable | +|---------------|---------------------| +| `enabledWorkflows` | `XCODEBUILDMCP_ENABLED_WORKFLOWS` (comma-separated) | +| `experimentalWorkflowDiscovery` | `XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY` | +| `disableSessionDefaults` | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | +| `disableXcodeAutoSync` | `XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC` | +| `incrementalBuildsEnabled` | `INCREMENTAL_BUILDS_ENABLED` | +| `debug` | `XCODEBUILDMCP_DEBUG` | +| `sentryDisabled` | `XCODEBUILDMCP_SENTRY_DISABLED` | +| `debuggerBackend` | `XCODEBUILDMCP_DEBUGGER_BACKEND` | +| `dapRequestTimeoutMs` | `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` | +| `dapLogEvents` | `XCODEBUILDMCP_DAP_LOG_EVENTS` | +| `launchJsonWaitMs` | `XBMCP_LAUNCH_JSON_WAIT_MS` | +| `uiDebuggerGuardMode` | `XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE` | +| `axePath` | `XCODEBUILDMCP_AXE_PATH` | +| `iosTemplatePath` | `XCODEBUILDMCP_IOS_TEMPLATE_PATH` | +| `iosTemplateVersion` | `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION` | +| `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` | +| `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` | + +### Session default settings + +| Session default | Environment variable | +|----------------|---------------------| +| `workspacePath` | `XCODEBUILDMCP_WORKSPACE_PATH` | +| `projectPath` | `XCODEBUILDMCP_PROJECT_PATH` | +| `scheme` | `XCODEBUILDMCP_SCHEME` | +| `configuration` | `XCODEBUILDMCP_CONFIGURATION` | +| `simulatorName` | `XCODEBUILDMCP_SIMULATOR_NAME` | +| `simulatorId` | `XCODEBUILDMCP_SIMULATOR_ID` | +| `simulatorPlatform` | `XCODEBUILDMCP_SIMULATOR_PLATFORM` | +| `deviceId` | `XCODEBUILDMCP_DEVICE_ID` | +| `platform` | `XCODEBUILDMCP_PLATFORM` | +| `useLatestOS` | `XCODEBUILDMCP_USE_LATEST_OS` | +| `arch` | `XCODEBUILDMCP_ARCH` | +| `suppressWarnings` | `XCODEBUILDMCP_SUPPRESS_WARNINGS` | +| `derivedDataPath` | `XCODEBUILDMCP_DERIVED_DATA_PATH` | +| `preferXcodebuild` | `XCODEBUILDMCP_PREFER_XCODEBUILD` | +| `bundleId` | `XCODEBUILDMCP_BUNDLE_ID` | + +### Example MCP configs + +Use one of these as a starting point and fill in your workspace path and scheme. + +**macOS app** + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "coverage,debugging,doctor,logging,macos,project-discovery,project-scaffolding,swift-package,ui-automation,utilities,xcode-ide", + "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", + "XCODEBUILDMCP_SCHEME": "MyApp", + "XCODEBUILDMCP_PLATFORM": "macOS" + } + } + } +} +``` + +> `macos` provides build/run/test/stop tools for macOS apps. No simulator workflow needed — macOS apps run natively. + +--- + +**iOS app** + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "coverage,debugging,doctor,logging,project-discovery,project-scaffolding,simulator,swift-package,ui-automation,utilities,xcode-ide", + "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", + "XCODEBUILDMCP_SCHEME": "MyApp", + "XCODEBUILDMCP_PLATFORM": "iOS Simulator", + "XCODEBUILDMCP_SIMULATOR_NAME": "iPhone 16 Pro" + } + } + } +} +``` + +> `simulator` provides build/run/test/install tools targeting iOS Simulator. Use `XCODEBUILDMCP_SIMULATOR_NAME` or `XCODEBUILDMCP_SIMULATOR_ID` to pin the target device. + +--- + +**iOS + macOS (multi-platform or Catalyst)** + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "coverage,debugging,doctor,logging,macos,project-discovery,project-scaffolding,simulator,swift-package,ui-automation,utilities,xcode-ide", + "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", + "XCODEBUILDMCP_SCHEME": "MyApp" + } + } + } +} +``` + +> Include both `simulator` and `macos` when the project supports multiple platforms. Omit `XCODEBUILDMCP_PLATFORM` to let the agent choose per-command. + +--- + +**tvOS or watchOS app** + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "debugging,doctor,logging,project-discovery,simulator,swift-package,utilities,xcode-ide", + "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", + "XCODEBUILDMCP_SCHEME": "MyApp", + "XCODEBUILDMCP_PLATFORM": "tvOS Simulator" + } + } + } +} +``` + +> Replace `tvOS Simulator` with `watchOS Simulator` for watchOS. Coverage and UI automation are not available on these platforms. + +--- + +You can also generate a config block interactively: + +```bash +xcodebuildmcp setup --format mcp-json +``` --- ## Config file -Create a config file at your workspace root: +The config file provides repo-scoped, version-controllable configuration. Create it at your workspace root: ``` /.xcodebuildmcp/config.yaml @@ -337,30 +490,19 @@ Notes: --- -## Environment variables (legacy) +## Configuration layering -Environment variables are supported for backwards compatibility but the config file is preferred. +When multiple configuration sources are present, they are merged with clear precedence: -| Config option | Environment variable | -|---------------|---------------------| -| `enabledWorkflows` | `XCODEBUILDMCP_ENABLED_WORKFLOWS` (comma-separated) | -| `experimentalWorkflowDiscovery` | `XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY` | -| `disableSessionDefaults` | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | -| `incrementalBuildsEnabled` | `INCREMENTAL_BUILDS_ENABLED` | -| `debug` | `XCODEBUILDMCP_DEBUG` | -| `sentryDisabled` | `XCODEBUILDMCP_SENTRY_DISABLED` | -| `debuggerBackend` | `XCODEBUILDMCP_DEBUGGER_BACKEND` | -| `dapRequestTimeoutMs` | `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` | -| `dapLogEvents` | `XCODEBUILDMCP_DAP_LOG_EVENTS` | -| `launchJsonWaitMs` | `XBMCP_LAUNCH_JSON_WAIT_MS` | -| `uiDebuggerGuardMode` | `XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE` | -| `axePath` | `XCODEBUILDMCP_AXE_PATH` | -| `iosTemplatePath` | `XCODEBUILDMCP_IOS_TEMPLATE_PATH` | -| `iosTemplateVersion` | `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION` | -| `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` | -| `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` | +1. **`session_set_defaults` tool** (highest) — agent runtime overrides, set during a session +2. **Config file** — project-local config (`config.yaml`), committed to repo +3. **Environment variables** (lowest) — MCP client integration, set in `mcp_config.json` + +This follows the same pattern as tools like `git config` (`--flag` > `--local` > `--global`). Each layer serves a different context: -Config file takes precedence over environment variables when both are set. +- **Env vars** are the portable MCP client integration path — they work regardless of working directory and are supported by every MCP client. +- **Config file** is for repo-scoped, version-controlled settings and interactive CLI usage. +- **Tool calls** are for agent-driven runtime adjustments. --- diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 189bf49b..298c933c 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -248,6 +248,91 @@ describe('setup command', () => { ).rejects.toThrow('Setup prerequisites failed'); }); + it('outputs MCP config JSON when format is 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: createTestPrompter(), + quietOutput: true, + outputFormat: 'mcp-json', + }); + + expect(result.configPath).toBeUndefined(); + expect(result.mcpConfigJson).toBeDefined(); + + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { + XcodeBuildMCP: { + command: string; + args: string[]; + env: Record; + }; + }; + }; + + const serverConfig = parsed.mcpServers.XcodeBuildMCP; + expect(serverConfig.command).toBe('npx'); + expect(serverConfig.args).toEqual(['-y', 'xcodebuildmcp@latest', 'mcp']); + expect(serverConfig.env.XCODEBUILDMCP_ENABLED_WORKFLOWS).toBeDefined(); + expect(serverConfig.env.XCODEBUILDMCP_WORKSPACE_PATH).toBe(path.join(cwd, 'App.xcworkspace')); + expect(serverConfig.env.XCODEBUILDMCP_SCHEME).toBe('App'); + expect(serverConfig.env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); + expect(serverConfig.env.XCODEBUILDMCP_SIMULATOR_NAME).toBe('iPhone 15'); + }); + it('fails in non-interactive mode', async () => { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 00a660b1..a97968fe 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -34,17 +34,21 @@ interface SetupSelection { simulatorName: string; } +type SetupOutputFormat = 'yaml' | 'mcp-json'; + interface SetupDependencies { cwd: string; fs: FileSystemExecutor; executor: CommandExecutor; prompter: Prompter; quietOutput: boolean; + outputFormat: SetupOutputFormat; } export interface SetupRunResult { - configPath: string; + configPath?: string; changedFields: string[]; + mcpConfigJson?: string; } const WORKFLOW_EXCLUDES = new Set(['session-management', 'workflow-discovery']); @@ -480,6 +484,44 @@ async function collectSetupSelection( }; } +function selectionToMcpConfigJson(selection: SetupSelection): string { + const env: Record = {}; + + if (selection.enabledWorkflows.length > 0) { + env.XCODEBUILDMCP_ENABLED_WORKFLOWS = selection.enabledWorkflows.join(','); + } + + if (selection.debug) { + env.XCODEBUILDMCP_DEBUG = 'true'; + } + + if (selection.sentryDisabled) { + env.XCODEBUILDMCP_SENTRY_DISABLED = 'true'; + } + + if (selection.workspacePath) { + env.XCODEBUILDMCP_WORKSPACE_PATH = selection.workspacePath; + } else if (selection.projectPath) { + env.XCODEBUILDMCP_PROJECT_PATH = selection.projectPath; + } + + env.XCODEBUILDMCP_SCHEME = selection.scheme; + env.XCODEBUILDMCP_SIMULATOR_ID = selection.simulatorId; + env.XCODEBUILDMCP_SIMULATOR_NAME = selection.simulatorName; + + const mcpConfig = { + mcpServers: { + XcodeBuildMCP: { + command: 'npx', + args: ['-y', 'xcodebuildmcp@latest', 'mcp'], + env, + }, + }, + }; + + return JSON.stringify(mcpConfig, null, 2); +} + export async function runSetupWizard(deps?: Partial): Promise { const isTTY = isInteractiveTTY(); if (!isTTY) { @@ -492,16 +534,28 @@ export async function runSetupWizard(deps?: Partial): Promise executor: deps?.executor ?? getDefaultCommandExecutor(), prompter: deps?.prompter ?? createPrompter(), quietOutput: deps?.quietOutput ?? false, + outputFormat: deps?.outputFormat ?? 'yaml', }; + const isMcpJson = resolvedDeps.outputFormat === 'mcp-json'; + if (!resolvedDeps.quietOutput) { clack.intro('XcodeBuildMCP Setup'); - clack.log.info( - 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + - 'You will select a project or workspace, scheme, simulator, and\n' + - 'which workflows to enable. Settings are saved to\n' + - '.xcodebuildmcp/config.yaml in your project directory.', - ); + if (isMcpJson) { + clack.log.info( + 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + + 'You will select a project or workspace, scheme, simulator, and\n' + + 'which workflows to enable. A ready-to-paste MCP config JSON\n' + + 'block will be printed at the end.', + ); + } else { + clack.log.info( + 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + + 'You will select a project or workspace, scheme, simulator, and\n' + + 'which workflows to enable. Settings are saved to\n' + + '.xcodebuildmcp/config.yaml in your project directory.', + ); + } } await ensureSetupPrerequisites({ @@ -515,6 +569,26 @@ export async function runSetupWizard(deps?: Partial): Promise const selection = await collectSetupSelection(beforeConfig, resolvedDeps); + if (isMcpJson) { + const mcpConfigJson = selectionToMcpConfigJson(selection); + + if (!resolvedDeps.quietOutput) { + clack.log.info( + 'Copy the following JSON block into your MCP client config\n' + + '(e.g. mcp_config.json for Windsurf, .vscode/mcp.json for VS Code,\n' + + 'claude_desktop_config.json for Claude Desktop):', + ); + // Print raw JSON to stdout so it can be piped/copied + console.log(mcpConfigJson); + clack.outro('Setup complete.'); + } + + return { + changedFields: [], + mcpConfigJson, + }; + } + const deleteSessionDefaultKeys: Array<'projectPath' | 'workspacePath'> = selection.workspacePath != null ? ['projectPath'] : ['workspacePath']; @@ -570,10 +644,17 @@ export async function runSetupWizard(deps?: Partial): Promise export function registerSetupCommand(app: Argv): void { app.command( 'setup', - 'Interactively create or update .xcodebuildmcp/config.yaml', - (yargs) => yargs, - async () => { - await runSetupWizard(); + 'Interactively configure XcodeBuildMCP project defaults', + (yargs) => + yargs.option('format', { + type: 'string', + choices: ['yaml', 'mcp-json'] as const, + default: 'yaml', + describe: + 'Output format: yaml writes .xcodebuildmcp/config.yaml, mcp-json prints a ready-to-paste MCP client config block', + }), + async (argv) => { + await runSetupWizard({ outputFormat: argv.format as SetupOutputFormat }); }, ); } diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index c815faef..c1a1c275 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -262,6 +262,55 @@ describe('config-store', () => { expect(getConfig().sessionDefaults?.scheme).toBe('App'); }); + it('reads session defaults from env vars', async () => { + const env = { + XCODEBUILDMCP_WORKSPACE_PATH: '/path/to/App.xcworkspace', + XCODEBUILDMCP_SCHEME: 'MyApp', + XCODEBUILDMCP_PLATFORM: 'macOS', + XCODEBUILDMCP_SUPPRESS_WARNINGS: 'true', + XCODEBUILDMCP_DERIVED_DATA_PATH: '/tmp/dd', + XCODEBUILDMCP_USE_LATEST_OS: 'true', + XCODEBUILDMCP_ARCH: 'arm64', + XCODEBUILDMCP_SIMULATOR_NAME: 'iPhone 17', + XCODEBUILDMCP_BUNDLE_ID: 'com.example.app', + }; + + await initConfigStore({ cwd, fs: createFs(), env }); + + const config = getConfig(); + expect(config.sessionDefaults?.workspacePath).toBe('/path/to/App.xcworkspace'); + expect(config.sessionDefaults?.scheme).toBe('MyApp'); + expect(config.sessionDefaults?.platform).toBe('macOS'); + expect(config.sessionDefaults?.suppressWarnings).toBe(true); + expect(config.sessionDefaults?.derivedDataPath).toBe('/tmp/dd'); + expect(config.sessionDefaults?.useLatestOS).toBe(true); + expect(config.sessionDefaults?.arch).toBe('arm64'); + expect(config.sessionDefaults?.simulatorName).toBe('iPhone 17'); + expect(config.sessionDefaults?.bundleId).toBe('com.example.app'); + }); + + it('file config session defaults take precedence over env var session defaults', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' scheme: "FromFile"', + ' workspacePath: "./FromFile.xcworkspace"', + '', + ].join('\n'); + const env = { + XCODEBUILDMCP_SCHEME: 'FromEnv', + XCODEBUILDMCP_WORKSPACE_PATH: '/env/path/App.xcworkspace', + XCODEBUILDMCP_PLATFORM: 'iOS', + }; + + await initConfigStore({ cwd, fs: createFs(yaml), env }); + + const config = getConfig(); + expect(config.sessionDefaults?.scheme).toBe('FromFile'); + expect(config.sessionDefaults?.workspacePath).toBe('/repo/FromFile.xcworkspace'); + expect(config.sessionDefaults?.platform).toBe('iOS'); + }); + it('keeps non-session config immutable after init when persisting active profile', async () => { let persistedYaml = 'schemaVersion: 1\n'; const fs = createMockFileSystemExecutor({ diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index 3e23ed9b..5b70c5f8 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -233,6 +233,57 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { return config; } +function readEnvSessionDefaults(env: NodeJS.ProcessEnv): Partial | undefined { + const defaults: Partial = {}; + let hasAny = false; + + function setString(key: K, value: string | undefined): void { + if (value) { + (defaults as Record)[key] = value; + hasAny = true; + } + } + + function setBool(key: K, value: string | undefined): void { + const parsed = parseBoolean(value); + if (parsed !== undefined) { + (defaults as Record)[key] = parsed; + hasAny = true; + } + } + + setString('workspacePath', env.XCODEBUILDMCP_WORKSPACE_PATH); + setString('projectPath', env.XCODEBUILDMCP_PROJECT_PATH); + setString('scheme', env.XCODEBUILDMCP_SCHEME); + setString('configuration', env.XCODEBUILDMCP_CONFIGURATION); + setString('simulatorName', env.XCODEBUILDMCP_SIMULATOR_NAME); + setString('simulatorId', env.XCODEBUILDMCP_SIMULATOR_ID); + setString('deviceId', env.XCODEBUILDMCP_DEVICE_ID); + setString('derivedDataPath', env.XCODEBUILDMCP_DERIVED_DATA_PATH); + setString('platform', env.XCODEBUILDMCP_PLATFORM); + setString('bundleId', env.XCODEBUILDMCP_BUNDLE_ID); + setBool('useLatestOS', env.XCODEBUILDMCP_USE_LATEST_OS); + setBool('suppressWarnings', env.XCODEBUILDMCP_SUPPRESS_WARNINGS); + setBool('preferXcodebuild', env.XCODEBUILDMCP_PREFER_XCODEBUILD); + + const simulatorPlatform = env.XCODEBUILDMCP_SIMULATOR_PLATFORM; + if (simulatorPlatform) { + const valid = ['iOS Simulator', 'watchOS Simulator', 'tvOS Simulator', 'visionOS Simulator']; + if (valid.includes(simulatorPlatform)) { + defaults.simulatorPlatform = simulatorPlatform as SessionDefaults['simulatorPlatform']; + hasAny = true; + } + } + + const arch = env.XCODEBUILDMCP_ARCH; + if (arch === 'arm64' || arch === 'x86_64') { + defaults.arch = arch; + hasAny = true; + } + + return hasAny ? defaults : undefined; +} + function resolveFromLayers(opts: { key: keyof RuntimeConfigOverrides; overrides?: RuntimeConfigOverrides; @@ -270,11 +321,13 @@ function resolveFromLayers(opts: { function resolveSessionDefaults(opts: { overrides?: RuntimeConfigOverrides; fileConfig?: ProjectConfig; + env?: NodeJS.ProcessEnv; }): Partial | undefined { const overrideDefaults = opts.overrides?.sessionDefaults; const fileDefaults = opts.fileConfig?.sessionDefaults; - if (!overrideDefaults && !fileDefaults) return undefined; - return { ...(fileDefaults ?? {}), ...(overrideDefaults ?? {}) }; + const envDefaults = readEnvSessionDefaults(opts.env ?? process.env); + if (!overrideDefaults && !fileDefaults && !envDefaults) return undefined; + return { ...(envDefaults ?? {}), ...(fileDefaults ?? {}), ...(overrideDefaults ?? {}) }; } function resolveSessionDefaultsProfiles(opts: { @@ -501,6 +554,7 @@ function resolveConfig(opts: { sessionDefaults: resolveSessionDefaults({ overrides: opts.overrides, fileConfig: opts.fileConfig, + env: opts.env, }), sessionDefaultsProfiles: resolveSessionDefaultsProfiles({ overrides: opts.overrides, From c07a7a82a46ad90029cfdf217f82cd20cae0a40f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 11 Mar 2026 22:03:51 +0000 Subject: [PATCH 2/6] ref(setup): Make target prompts workflow-driven Collect simulator and device defaults based on the workflows a user selects instead of tying setup questions to the output format. This keeps yaml and mcp-json setup aligned and lets users skip pinning a default target when they do not want one. Reframe the docs so config.yaml remains the canonical config surface and env vars are described as bootstrap values for constrained MCP clients. --- CHANGELOG.md | 9 +- docs/CONFIGURATION.md | 238 ++++++---------- src/cli/commands/__tests__/setup.test.ts | 330 +++++++++++++++++++++- src/cli/commands/setup.ts | 339 +++++++++++++++++++++-- 4 files changed, 724 insertions(+), 192 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dabd4f75..559ce30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,13 @@ ### Added -- Added environment variable support for all session defaults (e.g., `XCODEBUILDMCP_WORKSPACE_PATH`, `XCODEBUILDMCP_SCHEME`, `XCODEBUILDMCP_PLATFORM`), enabling full configuration via the MCP client `env` field without requiring a config file ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). -- Added `--format mcp-json` flag to `xcodebuildmcp setup` that outputs a ready-to-paste MCP client config JSON block instead of writing `config.yaml` ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). -- Added copy-pastable MCP config examples for macOS, iOS, multi-platform, tvOS, and watchOS projects to [docs/CONFIGURATION.md](docs/CONFIGURATION.md) ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +- Added environment variable support for flat session default bootstrap values (for example `XCODEBUILDMCP_WORKSPACE_PATH`, `XCODEBUILDMCP_SCHEME`, and `XCODEBUILDMCP_PLATFORM`) so constrained MCP clients can supply startup defaults without changing project config files ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +- Added `--format mcp-json` flag to `xcodebuildmcp setup` that exports an env-based MCP bootstrap config block instead of writing `config.yaml` ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +- Added MCP bootstrap config examples to [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for clients that need env-based startup defaults ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). ### Changed -- Environment variables are now documented as the recommended configuration method for MCP client integration, replacing the previous "legacy" designation. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). +- Clarified configuration layering: `session_set_defaults` overrides `config.yaml`, which overrides env-based bootstrap values. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) ([#268](https://github.com/getsentry/XcodeBuildMCP/pull/268) by [@detailobsessed](https://github.com/detailobsessed)). ## [2.2.1] @@ -407,4 +407,3 @@ Please note that the UI automation features are an early preview and currently i - Initial release of XcodeBuildMCP - Basic support for building iOS and macOS applications - diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2c6426e7..412b2493 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,9 +4,9 @@ XcodeBuildMCP reads configuration from environment variables and/or a project co ## Contents -- [Environment variables](#environment-variables) - [Config file](#config-file) - [Configuration layering](#configuration-layering) +- [Environment variables](#environment-variables) - [Session defaults](#session-defaults) - [Workflow selection](#workflow-selection) - [Build settings](#build-settings) @@ -18,158 +18,6 @@ XcodeBuildMCP reads configuration from environment variables and/or a project co --- -## Environment variables - -Environment variables are the recommended configuration method for MCP client integration. Set them in the `env` field of your MCP client config (e.g., `mcp_config.json` for Windsurf, `.vscode/mcp.json` for VS Code, `claude_desktop_config.json` for Claude Desktop). - -This approach works reliably across all MCP clients regardless of working directory, and avoids the need for filesystem-based config discovery. - -### General settings - -| Config option | Environment variable | -|---------------|---------------------| -| `enabledWorkflows` | `XCODEBUILDMCP_ENABLED_WORKFLOWS` (comma-separated) | -| `experimentalWorkflowDiscovery` | `XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY` | -| `disableSessionDefaults` | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | -| `disableXcodeAutoSync` | `XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC` | -| `incrementalBuildsEnabled` | `INCREMENTAL_BUILDS_ENABLED` | -| `debug` | `XCODEBUILDMCP_DEBUG` | -| `sentryDisabled` | `XCODEBUILDMCP_SENTRY_DISABLED` | -| `debuggerBackend` | `XCODEBUILDMCP_DEBUGGER_BACKEND` | -| `dapRequestTimeoutMs` | `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` | -| `dapLogEvents` | `XCODEBUILDMCP_DAP_LOG_EVENTS` | -| `launchJsonWaitMs` | `XBMCP_LAUNCH_JSON_WAIT_MS` | -| `uiDebuggerGuardMode` | `XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE` | -| `axePath` | `XCODEBUILDMCP_AXE_PATH` | -| `iosTemplatePath` | `XCODEBUILDMCP_IOS_TEMPLATE_PATH` | -| `iosTemplateVersion` | `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION` | -| `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` | -| `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` | - -### Session default settings - -| Session default | Environment variable | -|----------------|---------------------| -| `workspacePath` | `XCODEBUILDMCP_WORKSPACE_PATH` | -| `projectPath` | `XCODEBUILDMCP_PROJECT_PATH` | -| `scheme` | `XCODEBUILDMCP_SCHEME` | -| `configuration` | `XCODEBUILDMCP_CONFIGURATION` | -| `simulatorName` | `XCODEBUILDMCP_SIMULATOR_NAME` | -| `simulatorId` | `XCODEBUILDMCP_SIMULATOR_ID` | -| `simulatorPlatform` | `XCODEBUILDMCP_SIMULATOR_PLATFORM` | -| `deviceId` | `XCODEBUILDMCP_DEVICE_ID` | -| `platform` | `XCODEBUILDMCP_PLATFORM` | -| `useLatestOS` | `XCODEBUILDMCP_USE_LATEST_OS` | -| `arch` | `XCODEBUILDMCP_ARCH` | -| `suppressWarnings` | `XCODEBUILDMCP_SUPPRESS_WARNINGS` | -| `derivedDataPath` | `XCODEBUILDMCP_DERIVED_DATA_PATH` | -| `preferXcodebuild` | `XCODEBUILDMCP_PREFER_XCODEBUILD` | -| `bundleId` | `XCODEBUILDMCP_BUNDLE_ID` | - -### Example MCP configs - -Use one of these as a starting point and fill in your workspace path and scheme. - -**macOS app** - -```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "npx", - "args": ["-y", "xcodebuildmcp@latest", "mcp"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "coverage,debugging,doctor,logging,macos,project-discovery,project-scaffolding,swift-package,ui-automation,utilities,xcode-ide", - "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", - "XCODEBUILDMCP_SCHEME": "MyApp", - "XCODEBUILDMCP_PLATFORM": "macOS" - } - } - } -} -``` - -> `macos` provides build/run/test/stop tools for macOS apps. No simulator workflow needed — macOS apps run natively. - ---- - -**iOS app** - -```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "npx", - "args": ["-y", "xcodebuildmcp@latest", "mcp"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "coverage,debugging,doctor,logging,project-discovery,project-scaffolding,simulator,swift-package,ui-automation,utilities,xcode-ide", - "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", - "XCODEBUILDMCP_SCHEME": "MyApp", - "XCODEBUILDMCP_PLATFORM": "iOS Simulator", - "XCODEBUILDMCP_SIMULATOR_NAME": "iPhone 16 Pro" - } - } - } -} -``` - -> `simulator` provides build/run/test/install tools targeting iOS Simulator. Use `XCODEBUILDMCP_SIMULATOR_NAME` or `XCODEBUILDMCP_SIMULATOR_ID` to pin the target device. - ---- - -**iOS + macOS (multi-platform or Catalyst)** - -```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "npx", - "args": ["-y", "xcodebuildmcp@latest", "mcp"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "coverage,debugging,doctor,logging,macos,project-discovery,project-scaffolding,simulator,swift-package,ui-automation,utilities,xcode-ide", - "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", - "XCODEBUILDMCP_SCHEME": "MyApp" - } - } - } -} -``` - -> Include both `simulator` and `macos` when the project supports multiple platforms. Omit `XCODEBUILDMCP_PLATFORM` to let the agent choose per-command. - ---- - -**tvOS or watchOS app** - -```json -{ - "mcpServers": { - "XcodeBuildMCP": { - "command": "npx", - "args": ["-y", "xcodebuildmcp@latest", "mcp"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "debugging,doctor,logging,project-discovery,simulator,swift-package,utilities,xcode-ide", - "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", - "XCODEBUILDMCP_SCHEME": "MyApp", - "XCODEBUILDMCP_PLATFORM": "tvOS Simulator" - } - } - } -} -``` - -> Replace `tvOS Simulator` with `watchOS Simulator` for watchOS. Coverage and UI automation are not available on these platforms. - ---- - -You can also generate a config block interactively: - -```bash -xcodebuildmcp setup --format mcp-json -``` - ---- - ## Config file The config file provides repo-scoped, version-controllable configuration. Create it at your workspace root: @@ -500,12 +348,92 @@ When multiple configuration sources are present, they are merged with clear prec This follows the same pattern as tools like `git config` (`--flag` > `--local` > `--global`). Each layer serves a different context: -- **Env vars** are the portable MCP client integration path — they work regardless of working directory and are supported by every MCP client. -- **Config file** is for repo-scoped, version-controlled settings and interactive CLI usage. +- **Config file** is the canonical home for structured, repo-scoped, version-controlled settings. +- **Env vars** are best used to bootstrap flat startup defaults for MCP clients with limited workspace support. - **Tool calls** are for agent-driven runtime adjustments. --- +## Environment variables + +Environment variables are supported for MCP client integration when a client cannot reliably provide workspace context to the server. Set them in the `env` field of your MCP client config (for example `mcp_config.json` for Windsurf, `.vscode/mcp.json` for VS Code, or `claude_desktop_config.json` for Claude Desktop). + +Use env vars for flat bootstrap values such as startup workflow selection, project path, scheme, simulator selector, or other scalar defaults. Keep structured project-owned configuration in `config.yaml`. + +### General settings + +| Config option | Environment variable | +|---------------|---------------------| +| `enabledWorkflows` | `XCODEBUILDMCP_ENABLED_WORKFLOWS` (comma-separated) | +| `experimentalWorkflowDiscovery` | `XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY` | +| `disableSessionDefaults` | `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | +| `disableXcodeAutoSync` | `XCODEBUILDMCP_DISABLE_XCODE_AUTO_SYNC` | +| `incrementalBuildsEnabled` | `INCREMENTAL_BUILDS_ENABLED` | +| `debug` | `XCODEBUILDMCP_DEBUG` | +| `sentryDisabled` | `XCODEBUILDMCP_SENTRY_DISABLED` | +| `debuggerBackend` | `XCODEBUILDMCP_DEBUGGER_BACKEND` | +| `dapRequestTimeoutMs` | `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS` | +| `dapLogEvents` | `XCODEBUILDMCP_DAP_LOG_EVENTS` | +| `launchJsonWaitMs` | `XBMCP_LAUNCH_JSON_WAIT_MS` | +| `uiDebuggerGuardMode` | `XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE` | +| `axePath` | `XCODEBUILDMCP_AXE_PATH` | +| `iosTemplatePath` | `XCODEBUILDMCP_IOS_TEMPLATE_PATH` | +| `iosTemplateVersion` | `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION` | +| `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` | +| `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` | + +### Session default bootstrap values + +| Session default | Environment variable | +|----------------|---------------------| +| `workspacePath` | `XCODEBUILDMCP_WORKSPACE_PATH` | +| `projectPath` | `XCODEBUILDMCP_PROJECT_PATH` | +| `scheme` | `XCODEBUILDMCP_SCHEME` | +| `configuration` | `XCODEBUILDMCP_CONFIGURATION` | +| `simulatorName` | `XCODEBUILDMCP_SIMULATOR_NAME` | +| `simulatorId` | `XCODEBUILDMCP_SIMULATOR_ID` | +| `simulatorPlatform` | `XCODEBUILDMCP_SIMULATOR_PLATFORM` | +| `deviceId` | `XCODEBUILDMCP_DEVICE_ID` | +| `platform` | `XCODEBUILDMCP_PLATFORM` | +| `useLatestOS` | `XCODEBUILDMCP_USE_LATEST_OS` | +| `arch` | `XCODEBUILDMCP_ARCH` | +| `suppressWarnings` | `XCODEBUILDMCP_SUPPRESS_WARNINGS` | +| `derivedDataPath` | `XCODEBUILDMCP_DERIVED_DATA_PATH` | +| `preferXcodebuild` | `XCODEBUILDMCP_PREFER_XCODEBUILD` | +| `bundleId` | `XCODEBUILDMCP_BUNDLE_ID` | + +### Example MCP config + +Use this when your MCP client needs env-based bootstrap defaults: + +```json +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "env": { + "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,debugging,logging", + "XCODEBUILDMCP_WORKSPACE_PATH": "/Users/me/MyApp/MyApp.xcworkspace", + "XCODEBUILDMCP_SCHEME": "MyApp", + "XCODEBUILDMCP_PLATFORM": "iOS Simulator", + "XCODEBUILDMCP_SIMULATOR_NAME": "iPhone 16 Pro" + } + } + } +} +``` + +You can also export a bootstrap block interactively: + +```bash +xcodebuildmcp setup --format mcp-json +``` + +That export is intended for MCP client bootstrap. It does not replace `config.yaml` as the canonical project configuration. + +--- + ## Related docs - Session defaults: [SESSION_DEFAULTS.md](SESSION_DEFAULTS.md) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 298c933c..9e1e9edb 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; import { parse as parseYaml } from 'yaml'; import { @@ -12,9 +13,36 @@ import { runSetupWizard } from '../setup.ts'; const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); +async function writeMockDeviceList(jsonPath: string): Promise { + await fs.writeFile( + jsonPath, + JSON.stringify({ + result: { + devices: [ + { + identifier: 'DEVICE-1', + visibilityClass: 'Default', + connectionProperties: { + pairingState: 'paired', + tunnelState: 'connected', + }, + deviceProperties: { + name: 'Cam iPhone', + platformIdentifier: 'com.apple.platform.iphoneos', + }, + }, + ], + }, + }), + ); +} + function createTestPrompter(): Prompter { return { - selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + 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 }> }) => opts.options.map((option) => option.value), confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, @@ -76,6 +104,14 @@ describe('setup command', () => { }); const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + await writeMockDeviceList(command[5]); + return createMockCommandResponse({ + success: true, + output: '', + }); + } + if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -129,6 +165,7 @@ describe('setup command', () => { expect(parsed.sentryDisabled).toBe(false); expect(parsed.sessionDefaults?.workspacePath).toBe('App.xcworkspace'); expect(parsed.sessionDefaults?.scheme).toBe('App'); + expect(parsed.sessionDefaults?.deviceId).toBe('DEVICE-1'); expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); }); @@ -167,6 +204,14 @@ describe('setup command', () => { }); const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + await writeMockDeviceList(command[5]); + return createMockCommandResponse({ + success: true, + output: '', + }); + } + if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -199,7 +244,10 @@ describe('setup command', () => { }; const prompter: Prompter = { - selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + 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 }> }) => { offeredWorkflowIds = opts.options.map((option) => String(option.value)); return opts.options.map((option) => option.value); @@ -270,6 +318,14 @@ describe('setup command', () => { }); const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + await writeMockDeviceList(command[5]); + return createMockCommandResponse({ + success: true, + output: '', + }); + } + if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -329,10 +385,280 @@ describe('setup command', () => { expect(serverConfig.env.XCODEBUILDMCP_ENABLED_WORKFLOWS).toBeDefined(); expect(serverConfig.env.XCODEBUILDMCP_WORKSPACE_PATH).toBe(path.join(cwd, 'App.xcworkspace')); expect(serverConfig.env.XCODEBUILDMCP_SCHEME).toBe('App'); + expect(serverConfig.env.XCODEBUILDMCP_DEVICE_ID).toBe('DEVICE-1'); expect(serverConfig.env.XCODEBUILDMCP_SIMULATOR_ID).toBe('SIM-1'); expect(serverConfig.env.XCODEBUILDMCP_SIMULATOR_NAME).toBe('iPhone 15'); }); + it('does not require simulator or device defaults when selected workflows do not depend on them', 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[0] === 'xcrun' && command.includes('simctl')) { + throw new Error('simulator lookup should not run for workflows without simulator defaults'); + } + + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + throw new Error('device lookup should not run for workflows without device defaults'); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + 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 }> }) => { + const macosOption = opts.options.find((option) => option.value === ('macos' as T)); + return macosOption ? [macosOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + expect(result.configPath).toBe(configPath); + + const parsed = parseYaml(storedConfig) as { + enabledWorkflows?: string[]; + sessionDefaults?: Record; + }; + + expect(parsed.enabledWorkflows).toEqual(['macos']); + expect(parsed.sessionDefaults?.workspacePath).toBe('App.xcworkspace'); + expect(parsed.sessionDefaults?.scheme).toBe('App'); + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('collects a device default without requiring simulator selection when only device-dependent workflows are enabled', 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[0] === 'xcrun' && command.includes('simctl')) { + throw new Error('simulator lookup should not run for device-only workflows'); + } + + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + await writeMockDeviceList(command[5]); + return createMockCommandResponse({ + success: true, + output: '', + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + 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 }> }) => { + const deviceOption = opts.options.find((option) => option.value === ('device' as T)); + return deviceOption ? [deviceOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + const result = await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + outputFormat: 'mcp-json', + }); + + const parsed = JSON.parse(result.mcpConfigJson!) as { + mcpServers: { + XcodeBuildMCP: { + env: Record; + }; + }; + }; + + const env = parsed.mcpServers.XcodeBuildMCP.env; + expect(env.XCODEBUILDMCP_ENABLED_WORKFLOWS).toBe('device'); + expect(env.XCODEBUILDMCP_WORKSPACE_PATH).toBe(path.join(cwd, 'App.xcworkspace')); + expect(env.XCODEBUILDMCP_SCHEME).toBe('App'); + expect(env.XCODEBUILDMCP_DEVICE_ID).toBe('DEVICE-1'); + expect(env.XCODEBUILDMCP_SIMULATOR_ID).toBeUndefined(); + expect(env.XCODEBUILDMCP_SIMULATOR_NAME).toBeUndefined(); + }); + + it('allows clearing an existing simulator default when simulator workflows are enabled', async () => { + let storedConfig = `schemaVersion: 1 +enabledWorkflows: + - simulator +sessionDefaults: + workspacePath: App.xcworkspace + scheme: App + simulatorId: SIM-1 + simulatorName: iPhone 15 +`; + + 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: '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 selectCallCount = 0; + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => { + selectCallCount += 1; + if (selectCallCount === 3) { + return opts.options[0].value; + } + const preferredOption = opts.options.find((option) => option.value != null); + return (preferredOption ?? opts.options[0]).value; + }, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const simulatorOption = opts.options.find((option) => option.value === ('simulator' as T)); + return simulatorOption + ? [simulatorOption.value] + : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(storedConfig) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + it('fails in non-interactive mode', async () => { Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index a97968fe..b1a34ee5 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1,4 +1,6 @@ import type { Argv } from 'yargs'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import * as clack from '@clack/prompts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../utils/command.ts'; @@ -13,6 +15,7 @@ import { persistProjectConfigPatch, type ProjectConfig, } from '../../utils/project-config.ts'; +import type { SessionDefaults } from '../../utils/session-store.ts'; import { createPrompter, isInteractiveTTY, @@ -30,8 +33,11 @@ interface SetupSelection { projectPath?: string; workspacePath?: string; scheme: string; - simulatorId: string; - simulatorName: string; + deviceId?: string; + simulatorId?: string; + simulatorName?: string; + clearDeviceDefault: boolean; + clearSimulatorDefault: boolean; } type SetupOutputFormat = 'yaml' | 'mcp-json'; @@ -52,6 +58,14 @@ export interface SetupRunResult { } const WORKFLOW_EXCLUDES = new Set(['session-management', 'workflow-discovery']); +const SIMULATOR_DEFAULT_WORKFLOWS = new Set(['debugging', 'logging', 'simulator', 'ui-automation']); +const DEVICE_DEFAULT_WORKFLOWS = new Set(['device', 'logging']); + +interface SetupDevice { + name: string; + udid: string; + platform: string; +} function showPromptHelp(helpText: string, quietOutput: boolean): void { if (quietOutput) { @@ -109,6 +123,7 @@ function normalizeExistingDefaults(config?: ProjectConfig): { projectPath?: string; workspacePath?: string; scheme?: string; + deviceId?: string; simulatorId?: string; simulatorName?: string; } { @@ -117,6 +132,7 @@ function normalizeExistingDefaults(config?: ProjectConfig): { projectPath: sessionDefaults.projectPath, workspacePath: sessionDefaults.workspacePath, scheme: sessionDefaults.scheme, + deviceId: sessionDefaults.deviceId, simulatorId: sessionDefaults.simulatorId, simulatorName: sessionDefaults.simulatorName, }; @@ -179,6 +195,11 @@ function getChangedFields( beforeValue: beforeDefaults.scheme, afterValue: afterDefaults.scheme, }, + { + label: 'sessionDefaults.deviceId', + beforeValue: beforeDefaults.deviceId, + afterValue: afterDefaults.deviceId, + }, { label: 'sessionDefaults.simulatorId', beforeValue: beforeDefaults.simulatorId, @@ -354,7 +375,7 @@ async function selectSimulator(opts: { prompter: Prompter; isTTY: boolean; quietOutput: boolean; -}): Promise { +}): Promise { const simulators = await withSpinner({ isTTY: opts.isTTY, quietOutput: opts.quietOutput, @@ -378,12 +399,238 @@ async function selectSimulator(opts: { ); return opts.prompter.selectOne({ message: 'Select a simulator', - options: simulators.map((simulator) => ({ - value: simulator, - label: `${simulator.runtime} — ${simulator.name} (${simulator.udid})`, - description: simulator.state, - })), - initialIndex: defaultIndex, + options: [ + { + value: null, + label: 'No default simulator', + description: 'Leave simulator commands unpinned during setup.', + }, + ...simulators.map((simulator) => ({ + value: simulator, + label: `${simulator.runtime} — ${simulator.name} (${simulator.udid})`, + description: simulator.state, + })), + ], + initialIndex: + (opts.existingSimulatorId ?? opts.existingSimulatorName) != null ? defaultIndex + 1 : 0, + }); +} + +function requiresSimulatorDefault(enabledWorkflows: string[]): boolean { + return enabledWorkflows.some((workflowId) => SIMULATOR_DEFAULT_WORKFLOWS.has(workflowId)); +} + +function requiresDeviceDefault(enabledWorkflows: string[]): boolean { + return enabledWorkflows.some((workflowId) => DEVICE_DEFAULT_WORKFLOWS.has(workflowId)); +} + +function getDevicePlatformLabel(platformIdentifier?: string): string { + const platformId = platformIdentifier?.toLowerCase() ?? ''; + + if (platformId.includes('ios') || platformId.includes('iphone')) { + return 'iOS'; + } + if (platformId.includes('ipad')) { + return 'iPadOS'; + } + if (platformId.includes('watch')) { + return 'watchOS'; + } + if (platformId.includes('tv') || platformId.includes('apple tv')) { + return 'tvOS'; + } + if (platformId.includes('vision')) { + return 'visionOS'; + } + + return 'Unknown'; +} + +function parseDeviceListResponse(value: unknown): SetupDevice[] { + if (!value || typeof value !== 'object') { + return []; + } + + const result = (value as { result?: unknown }).result; + if (!result || typeof result !== 'object') { + return []; + } + + const devices = (result as { devices?: unknown }).devices; + if (!Array.isArray(devices)) { + return []; + } + + const listed: SetupDevice[] = []; + for (const device of devices) { + if (!device || typeof device !== 'object') { + continue; + } + + const record = device as { + identifier?: unknown; + visibilityClass?: unknown; + connectionProperties?: { + pairingState?: unknown; + tunnelState?: unknown; + }; + deviceProperties?: { + name?: unknown; + platformIdentifier?: unknown; + }; + }; + + if (record.visibilityClass === 'Simulator') { + continue; + } + + if ( + typeof record.identifier !== 'string' || + typeof record.deviceProperties?.name !== 'string' || + typeof record.connectionProperties?.pairingState !== 'string' + ) { + continue; + } + + if (record.connectionProperties.pairingState !== 'paired') { + continue; + } + + const tunnelState = record.connectionProperties.tunnelState; + if ( + tunnelState !== 'connected' && + tunnelState !== undefined && + tunnelState !== 'disconnected' + ) { + continue; + } + + listed.push({ + name: record.deviceProperties.name, + udid: record.identifier, + platform: + typeof record.deviceProperties.platformIdentifier === 'string' + ? getDevicePlatformLabel(record.deviceProperties.platformIdentifier) + : 'Unknown', + }); + } + + return listed; +} + +function parseXctraceDevices(output: string): SetupDevice[] { + const listed: SetupDevice[] = []; + const lines = output.split('\n'); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line.length === 0 || line.includes('Simulator')) { + continue; + } + + const match = line.match(/^(.+?) \(([0-9A-Fa-f-]{8,})\)(?: .*)?$/); + if (!match) { + continue; + } + + listed.push({ + name: match[1].trim(), + udid: match[2], + platform: 'Unknown', + }); + } + + return listed; +} + +async function listAvailableDevices(executor: CommandExecutor): Promise { + const jsonPath = path.join(tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`); + + try { + const result = await executor( + ['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath], + 'List Devices (setup)', + false, + undefined, + ); + + if (result.success) { + const jsonContent = await fs.readFile(jsonPath, 'utf8'); + const devices = parseDeviceListResponse(JSON.parse(jsonContent)); + if (devices.length > 0) { + return devices; + } + } + } catch { + // Fall back to xctrace below. + } finally { + await fs.unlink(jsonPath).catch(() => {}); + } + + const fallbackResult = await executor( + ['xcrun', 'xctrace', 'list', 'devices'], + 'List Devices (setup fallback)', + false, + undefined, + ); + + if (!fallbackResult.success) { + throw new Error(`Failed to list devices: ${fallbackResult.error}`); + } + + return parseXctraceDevices(fallbackResult.output); +} + +function getDefaultDeviceIndex(devices: SetupDevice[], existingDeviceId?: string): number { + if (existingDeviceId != null) { + const existingIndex = devices.findIndex((device) => device.udid === existingDeviceId); + if (existingIndex >= 0) { + return existingIndex; + } + } + + return 0; +} + +async function selectDevice(opts: { + existingDeviceId?: string; + executor: CommandExecutor; + prompter: Prompter; + isTTY: boolean; + quietOutput: boolean; +}): Promise { + const devices = await withSpinner({ + isTTY: opts.isTTY, + quietOutput: opts.quietOutput, + startMessage: 'Loading devices...', + stopMessage: 'Devices loaded.', + task: () => listAvailableDevices(opts.executor), + }); + + if (devices.length === 0) { + throw new Error('No available devices were found.'); + } + + const defaultIndex = getDefaultDeviceIndex(devices, opts.existingDeviceId); + + showPromptHelp( + 'Select a device to set the default target used by physical-device commands.', + opts.quietOutput, + ); + return opts.prompter.selectOne({ + message: 'Select a device', + options: [ + { + value: null, + label: 'No default device', + description: 'Leave device commands unpinned during setup.', + }, + ...devices.map((device) => ({ + value: device, + label: `${device.platform} — ${device.name} (${device.udid})`, + })), + ], + initialIndex: opts.existingDeviceId != null ? defaultIndex + 1 : 0, }); } @@ -463,14 +710,26 @@ async function collectSetupSelection( quietOutput: deps.quietOutput, }); - const simulator = await selectSimulator({ - existingSimulatorId: existing.simulatorId, - existingSimulatorName: existing.simulatorName, - executor: deps.executor, - prompter: deps.prompter, - isTTY, - 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, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }) + : undefined; return { debug, @@ -479,8 +738,11 @@ async function collectSetupSelection( projectPath: projectChoice.kind === 'project' ? projectChoice.absolutePath : undefined, workspacePath: projectChoice.kind === 'workspace' ? projectChoice.absolutePath : undefined, scheme, - simulatorId: simulator.udid, - simulatorName: simulator.name, + deviceId: device?.udid, + simulatorId: simulator?.udid, + simulatorName: simulator?.name, + clearDeviceDefault: requiresDeviceDefault(enabledWorkflows) && device == null, + clearSimulatorDefault: requiresSimulatorDefault(enabledWorkflows) && simulator == null, }; } @@ -506,8 +768,15 @@ function selectionToMcpConfigJson(selection: SetupSelection): string { } env.XCODEBUILDMCP_SCHEME = selection.scheme; - env.XCODEBUILDMCP_SIMULATOR_ID = selection.simulatorId; - env.XCODEBUILDMCP_SIMULATOR_NAME = selection.simulatorName; + if (selection.deviceId) { + env.XCODEBUILDMCP_DEVICE_ID = selection.deviceId; + } + if (selection.simulatorId) { + env.XCODEBUILDMCP_SIMULATOR_ID = selection.simulatorId; + } + if (selection.simulatorName) { + env.XCODEBUILDMCP_SIMULATOR_NAME = selection.simulatorName; + } const mcpConfig = { mcpServers: { @@ -544,15 +813,17 @@ 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, simulator, and\n' + - 'which workflows to enable. A ready-to-paste MCP config JSON\n' + - 'block will be printed at the end.', + '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.', ); } else { clack.log.info( 'This wizard will configure your project defaults for XcodeBuildMCP.\n' + - 'You will select a project or workspace, scheme, simulator, and\n' + - 'which workflows to enable. Settings are saved to\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.', ); } @@ -576,10 +847,11 @@ export async function runSetupWizard(deps?: Partial): Promise clack.log.info( 'Copy the following JSON block into your MCP client config\n' + '(e.g. mcp_config.json for Windsurf, .vscode/mcp.json for VS Code,\n' + - 'claude_desktop_config.json for Claude Desktop):', + 'claude_desktop_config.json for Claude Desktop) when you need\n' + + 'env-based bootstrap defaults:', ); // Print raw JSON to stdout so it can be piped/copied - console.log(mcpConfigJson); + process.stdout.write(`${mcpConfigJson}\n`); clack.outro('Setup complete.'); } @@ -589,8 +861,14 @@ export async function runSetupWizard(deps?: Partial): Promise }; } - const deleteSessionDefaultKeys: Array<'projectPath' | 'workspacePath'> = + const deleteSessionDefaultKeys: Array = selection.workspacePath != null ? ['projectPath'] : ['workspacePath']; + if (selection.clearDeviceDefault) { + deleteSessionDefaultKeys.push('deviceId'); + } + if (selection.clearSimulatorDefault) { + deleteSessionDefaultKeys.push('simulatorId', 'simulatorName'); + } const persistedProjectPath = selection.projectPath != null @@ -612,6 +890,7 @@ export async function runSetupWizard(deps?: Partial): Promise projectPath: persistedProjectPath, workspacePath: persistedWorkspacePath, scheme: selection.scheme, + deviceId: selection.deviceId, simulatorId: selection.simulatorId, simulatorName: selection.simulatorName, }, @@ -651,7 +930,7 @@ export function registerSetupCommand(app: Argv): void { choices: ['yaml', 'mcp-json'] as const, default: 'yaml', describe: - 'Output format: yaml writes .xcodebuildmcp/config.yaml, mcp-json prints a ready-to-paste MCP client config block', + 'Output format: yaml writes .xcodebuildmcp/config.yaml, mcp-json prints an env-based MCP bootstrap config block', }), async (argv) => { await runSetupWizard({ outputFormat: argv.format as SetupOutputFormat }); From 14f9f2299b0b5791aa3d080c34daa4d81f10c1ef Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 11 Mar 2026 22:32:03 +0000 Subject: [PATCH 3/6] fix(setup): Allow skipping unavailable dynamic defaults Continue setup when simulator or device discovery returns no candidates instead of aborting. This keeps the explicit no-default path usable and prevents setup from failing on machines that are missing optional runtime targets. Route setup device discovery temp-file handling through FileSystemExecutor and preserve injected env-backed session defaults after config-store refreshes so tests and runtime state stay consistent. --- src/cli/commands/__tests__/setup.test.ts | 452 +++++++++++++---------- src/cli/commands/setup.ts | 42 +-- src/utils/__tests__/config-store.test.ts | 46 +++ src/utils/config-store.ts | 4 + 4 files changed, 330 insertions(+), 214 deletions(-) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 9e1e9edb..69a540a9 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { promises as fs } from 'node:fs'; import path from 'node:path'; import { parse as parseYaml } from 'yaml'; import { @@ -13,28 +12,88 @@ import { runSetupWizard } from '../setup.ts'; const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); -async function writeMockDeviceList(jsonPath: string): Promise { - await fs.writeFile( - jsonPath, - JSON.stringify({ - result: { - devices: [ - { - identifier: 'DEVICE-1', - visibilityClass: 'Default', - connectionProperties: { - pairingState: 'paired', - tunnelState: 'connected', - }, - deviceProperties: { - name: 'Cam iPhone', - platformIdentifier: 'com.apple.platform.iphoneos', - }, +function mockDeviceListJson(): string { + return JSON.stringify({ + result: { + devices: [ + { + identifier: 'DEVICE-1', + visibilityClass: 'Default', + connectionProperties: { + pairingState: 'paired', + tunnelState: 'connected', }, - ], - }, - }), - ); + deviceProperties: { + name: 'Cam iPhone', + platformIdentifier: 'com.apple.platform.iphoneos', + }, + }, + ], + }, + }); +} + +function createSetupFs(opts?: { + storedConfig?: string; + projectEntries?: Array<{ + name: string; + isDirectory: () => boolean; + isSymbolicLink: () => boolean; + }>; +}) { + let storedConfig = opts?.storedConfig ?? ''; + const tempFiles = new Map(); + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0, + stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), + readdir: async (targetPath) => { + if (targetPath === cwd) { + return ( + opts?.projectEntries ?? [ + { + name: 'App.xcworkspace', + isDirectory: () => true, + isSymbolicLink: () => false, + }, + ] + ); + } + + return []; + }, + readFile: async (targetPath) => { + if (targetPath === configPath) { + return storedConfig; + } + + const tempContent = tempFiles.get(targetPath); + if (tempContent != null) { + return tempContent; + } + + throw new Error(`Unexpected read path: ${targetPath}`); + }, + writeFile: async (targetPath, content) => { + if (targetPath === configPath) { + storedConfig = content; + return; + } + + tempFiles.set(targetPath, content); + }, + rm: async (targetPath) => { + tempFiles.delete(targetPath); + }, + }); + + return { + fs, + getStoredConfig: () => storedConfig, + setTempFile: (targetPath: string, content: string) => { + tempFiles.set(targetPath, content); + }, + }; } function createTestPrompter(): Prompter { @@ -71,41 +130,11 @@ describe('setup command', () => { }); it('exports a setup wizard that writes config selections', 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 { fs, getStoredConfig, setTempFile } = createSetupFs(); const executor: CommandExecutor = async (command) => { if (command[0] === 'xcrun' && command[1] === 'devicectl') { - await writeMockDeviceList(command[5]); + setTempFile(command[5], mockDeviceListJson()); return createMockCommandResponse({ success: true, output: '', @@ -152,7 +181,7 @@ describe('setup command', () => { }); expect(result.configPath).toBe(configPath); - const parsed = parseYaml(storedConfig) as { + const parsed = parseYaml(getStoredConfig()) as { debug?: boolean; sentryDisabled?: boolean; enabledWorkflows?: string[]; @@ -170,42 +199,14 @@ describe('setup command', () => { }); it('shows debug-gated workflows when existing config enables debug', async () => { - let storedConfig = 'schemaVersion: 1\ndebug: true\n'; - let offeredWorkflowIds: string[] = []; - - 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 { fs, getStoredConfig, setTempFile } = createSetupFs({ + storedConfig: 'schemaVersion: 1\ndebug: true\n', }); + let offeredWorkflowIds: string[] = []; const executor: CommandExecutor = async (command) => { if (command[0] === 'xcrun' && command[1] === 'devicectl') { - await writeMockDeviceList(command[5]); + setTempFile(command[5], mockDeviceListJson()); return createMockCommandResponse({ success: true, output: '', @@ -263,7 +264,7 @@ describe('setup command', () => { quietOutput: true, }); - const parsed = parseYaml(storedConfig) as { + const parsed = parseYaml(getStoredConfig()) as { debug?: boolean; enabledWorkflows?: string[]; }; @@ -297,29 +298,11 @@ describe('setup command', () => { }); it('outputs MCP config JSON when format is 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 { fs, setTempFile } = createSetupFs(); const executor: CommandExecutor = async (command) => { if (command[0] === 'xcrun' && command[1] === 'devicectl') { - await writeMockDeviceList(command[5]); + setTempFile(command[5], mockDeviceListJson()); return createMockCommandResponse({ success: true, output: '', @@ -391,37 +374,7 @@ describe('setup command', () => { }); it('does not require simulator or device defaults when selected workflows do not depend on them', 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 { fs, getStoredConfig } = createSetupFs(); const executor: CommandExecutor = async (command) => { if (command[0] === 'xcrun' && command.includes('simctl')) { @@ -460,7 +413,7 @@ describe('setup command', () => { expect(result.configPath).toBe(configPath); - const parsed = parseYaml(storedConfig) as { + const parsed = parseYaml(getStoredConfig()) as { enabledWorkflows?: string[]; sessionDefaults?: Record; }; @@ -474,25 +427,7 @@ describe('setup command', () => { }); it('collects a device default without requiring simulator selection when only device-dependent workflows are enabled', 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 { fs, setTempFile } = createSetupFs(); const executor: CommandExecutor = async (command) => { if (command[0] === 'xcrun' && command.includes('simctl')) { @@ -500,7 +435,7 @@ describe('setup command', () => { } if (command[0] === 'xcrun' && command[1] === 'devicectl') { - await writeMockDeviceList(command[5]); + setTempFile(command[5], mockDeviceListJson()); return createMockCommandResponse({ success: true, output: '', @@ -552,7 +487,8 @@ describe('setup command', () => { }); it('allows clearing an existing simulator default when simulator workflows are enabled', async () => { - let storedConfig = `schemaVersion: 1 + const { fs, getStoredConfig } = createSetupFs({ + storedConfig: `schemaVersion: 1 enabledWorkflows: - simulator sessionDefaults: @@ -560,36 +496,7 @@ sessionDefaults: scheme: App simulatorId: SIM-1 simulatorName: iPhone 15 -`; - - 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) => { @@ -651,7 +558,168 @@ sessionDefaults: quietOutput: true, }); - const parsed = parseYaml(storedConfig) as { + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('continues setup with no default device when no devices are available', async () => { + const { fs, getStoredConfig } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + return createMockCommandResponse({ success: true, output: '' }); + } + + if (command[0] === 'xcrun' && command[1] === 'xctrace') { + return createMockCommandResponse({ success: true, output: '' }); + } + + 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, + }, + ], + }, + }), + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const loggingOption = opts.options.find((option) => option.value === ('logging' as T)); + return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('continues setup with no default device when an existing device default no longer exists', async () => { + const { fs, getStoredConfig } = createSetupFs({ + storedConfig: `schemaVersion: 1 +enabledWorkflows: + - device +sessionDefaults: + workspacePath: App.xcworkspace + scheme: App + deviceId: DEVICE-OLD +`, + }); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + return createMockCommandResponse({ success: true, output: '' }); + } + + if (command[0] === 'xcrun' && command[1] === 'xctrace') { + return createMockCommandResponse({ success: true, output: '' }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const deviceOption = opts.options.find((option) => option.value === ('device' as T)); + return deviceOption ? [deviceOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + }); + + it('continues setup with no default simulator when no simulators are available', async () => { + const { fs, getStoredConfig } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + throw new Error('device lookup should not run for simulator-only workflows'); + } + + if (command.includes('--json')) { + return createMockCommandResponse({ + success: true, + output: JSON.stringify({ devices: {} }), + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const simulatorOption = opts.options.find((option) => option.value === ('simulator' as T)); + return simulatorOption + ? [simulatorOption.value] + : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { sessionDefaults?: Record; }; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index b1a34ee5..6bae9245 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1,6 +1,4 @@ import type { Argv } from 'yargs'; -import { promises as fs } from 'node:fs'; -import { tmpdir } from 'node:os'; import path from 'node:path'; import * as clack from '@clack/prompts'; import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../utils/command.ts'; @@ -383,15 +381,11 @@ async function selectSimulator(opts: { stopMessage: 'Simulators loaded.', task: () => listSimulators(opts.executor), }); - if (simulators.length === 0) { - throw new Error('No available simulators were found.'); - } - const defaultIndex = getDefaultSimulatorIndex( - simulators, - opts.existingSimulatorId, - opts.existingSimulatorName, - ); + const defaultIndex = + simulators.length > 0 + ? getDefaultSimulatorIndex(simulators, opts.existingSimulatorId, opts.existingSimulatorName) + : 0; showPromptHelp( 'Select a simulator to set the default device target used by simulator commands.', @@ -412,7 +406,9 @@ async function selectSimulator(opts: { })), ], initialIndex: - (opts.existingSimulatorId ?? opts.existingSimulatorName) != null ? defaultIndex + 1 : 0, + simulators.length > 0 && (opts.existingSimulatorId ?? opts.existingSimulatorName) != null + ? defaultIndex + 1 + : 0, }); } @@ -543,8 +539,11 @@ function parseXctraceDevices(output: string): SetupDevice[] { return listed; } -async function listAvailableDevices(executor: CommandExecutor): Promise { - const jsonPath = path.join(tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`); +async function listAvailableDevices( + fileSystem: FileSystemExecutor, + executor: CommandExecutor, +): Promise { + const jsonPath = path.join(fileSystem.tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`); try { const result = await executor( @@ -555,7 +554,7 @@ async function listAvailableDevices(executor: CommandExecutor): Promise 0) { return devices; @@ -564,7 +563,7 @@ async function listAvailableDevices(executor: CommandExecutor): Promise {}); + await fileSystem.rm(jsonPath, { force: true }).catch(() => {}); } const fallbackResult = await executor( @@ -594,6 +593,7 @@ function getDefaultDeviceIndex(devices: SetupDevice[], existingDeviceId?: string async function selectDevice(opts: { existingDeviceId?: string; + fs: FileSystemExecutor; executor: CommandExecutor; prompter: Prompter; isTTY: boolean; @@ -604,14 +604,11 @@ async function selectDevice(opts: { quietOutput: opts.quietOutput, startMessage: 'Loading devices...', stopMessage: 'Devices loaded.', - task: () => listAvailableDevices(opts.executor), + task: () => listAvailableDevices(opts.fs, opts.executor), }); - if (devices.length === 0) { - throw new Error('No available devices were found.'); - } - - const defaultIndex = getDefaultDeviceIndex(devices, opts.existingDeviceId); + const defaultIndex = + devices.length > 0 ? getDefaultDeviceIndex(devices, opts.existingDeviceId) : 0; showPromptHelp( 'Select a device to set the default target used by physical-device commands.', @@ -630,7 +627,7 @@ async function selectDevice(opts: { label: `${device.platform} — ${device.name} (${device.udid})`, })), ], - initialIndex: opts.existingDeviceId != null ? defaultIndex + 1 : 0, + initialIndex: devices.length > 0 && opts.existingDeviceId != null ? defaultIndex + 1 : 0, }); } @@ -724,6 +721,7 @@ async function collectSetupSelection( const device = requiresDeviceDefault(enabledWorkflows) ? await selectDevice({ existingDeviceId: existing.deviceId, + fs: deps.fs, executor: deps.executor, prompter: deps.prompter, isTTY, diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index c1a1c275..78dda5a2 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -311,6 +311,52 @@ describe('config-store', () => { expect(config.sessionDefaults?.platform).toBe('iOS'); }); + it('preserves injected env session defaults after persisting a session defaults patch', async () => { + let persistedYaml = 'schemaVersion: 1\n'; + const fs = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => persistedYaml, + writeFile: async (_targetPath, content) => { + persistedYaml = content; + }, + }); + const env = { + XCODEBUILDMCP_WORKSPACE_PATH: '/env/path/App.xcworkspace', + XCODEBUILDMCP_SCHEME: 'FromEnv', + }; + + await initConfigStore({ cwd, fs, env }); + await persistSessionDefaultsPatch({ patch: { simulatorName: 'iPhone 17' } }); + + const config = getConfig(); + expect(config.sessionDefaults?.workspacePath).toBe('/env/path/App.xcworkspace'); + expect(config.sessionDefaults?.scheme).toBe('FromEnv'); + expect(config.sessionDefaults?.simulatorName).toBe('iPhone 17'); + }); + + it('preserves injected env session defaults after persisting the active profile', async () => { + let persistedYaml = 'schemaVersion: 1\n'; + const fs = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => persistedYaml, + writeFile: async (_targetPath, content) => { + persistedYaml = content; + }, + }); + const env = { + XCODEBUILDMCP_WORKSPACE_PATH: '/env/path/App.xcworkspace', + XCODEBUILDMCP_SCHEME: 'FromEnv', + }; + + await initConfigStore({ cwd, fs, env }); + await persistActiveSessionDefaultsProfile('ios'); + + const config = getConfig(); + expect(config.sessionDefaults?.workspacePath).toBe('/env/path/App.xcworkspace'); + expect(config.sessionDefaults?.scheme).toBe('FromEnv'); + expect(config.activeSessionDefaultsProfile).toBe('ios'); + }); + it('keeps non-session config immutable after init when persisting active profile', async () => { let persistedYaml = 'schemaVersion: 1\n'; const fs = createMockFileSystemExecutor({ diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index 5b70c5f8..f0bc26ae 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -63,6 +63,7 @@ type ConfigStoreState = { initialized: boolean; cwd?: string; fs?: FileSystemExecutor; + env?: NodeJS.ProcessEnv; overrides?: RuntimeConfigOverrides; fileConfig?: ProjectConfig; resolved: ResolvedRuntimeConfig; @@ -365,6 +366,7 @@ function refreshResolvedSessionFields(): void { storeState.resolved.sessionDefaults = resolveSessionDefaults({ overrides: storeState.overrides, fileConfig: storeState.fileConfig, + env: storeState.env, }); storeState.resolved.sessionDefaultsProfiles = resolveSessionDefaultsProfiles({ overrides: storeState.overrides, @@ -575,6 +577,7 @@ export async function initConfigStore(opts: { }): Promise<{ found: boolean; path?: string; notices: string[] }> { storeState.cwd = opts.cwd; storeState.fs = opts.fs; + storeState.env = opts.env; storeState.overrides = opts.overrides; let fileConfig: ProjectConfig | undefined; @@ -674,6 +677,7 @@ export function __resetConfigStoreForTests(): void { storeState.initialized = false; storeState.cwd = undefined; storeState.fs = undefined; + storeState.env = undefined; storeState.overrides = undefined; storeState.fileConfig = undefined; storeState.resolved = { ...DEFAULT_CONFIG }; From 7171f15e1ac9e7bd55ab108870ace09bdbee9b7b Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 11 Mar 2026 22:40:24 +0000 Subject: [PATCH 4/6] fix(setup): Tolerate device fallback discovery failures Return an empty device list when both devicectl and xctrace discovery fail so setup can continue with no default device instead of aborting. Add regression coverage for the double-failure discovery path. --- src/cli/commands/__tests__/setup.test.ts | 62 ++++++++++++++++++++++++ src/cli/commands/setup.ts | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 69a540a9..c5cca3e8 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -679,6 +679,68 @@ sessionDefaults: expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); }); + it('continues setup with no default device when both discovery commands fail', async () => { + const { fs, getStoredConfig } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + throw new Error('devicectl unavailable'); + } + + if (command[0] === 'xcrun' && command[1] === 'xctrace') { + return createMockCommandResponse({ success: false, output: '', error: 'xctrace failed' }); + } + + 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, + }, + ], + }, + }), + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const loggingOption = opts.options.find((option) => option.value === ('logging' as T)); + return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + it('continues setup with no default simulator when no simulators are available', async () => { const { fs, getStoredConfig } = createSetupFs(); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 6bae9245..bf5d242b 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -574,7 +574,7 @@ async function listAvailableDevices( ); if (!fallbackResult.success) { - throw new Error(`Failed to list devices: ${fallbackResult.error}`); + return []; } return parseXctraceDevices(fallbackResult.output); From 51ee6790e2335244fdbac26ee62b27521d83c711 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 12 Mar 2026 08:27:21 +0000 Subject: [PATCH 5/6] fix(setup): Tolerate optional discovery failures Allow setup to continue when optional simulator or device discovery fails instead of aborting the wizard. Catch rejecting simulator and xctrace lookups, and treat injected filesystem temp-path failures the same way so optional defaults can still be skipped during setup. --- src/cli/commands/__tests__/setup.test.ts | 150 +++++++++++++++++++++++ src/cli/commands/setup.ts | 67 ++++++---- 2 files changed, 192 insertions(+), 25 deletions(-) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index c5cca3e8..1f3ba9e7 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -741,6 +741,156 @@ sessionDefaults: expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); + it('continues setup with no default device when xctrace spawn fails', async () => { + const { fs, getStoredConfig } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + throw new Error('devicectl unavailable'); + } + + if (command[0] === 'xcrun' && command[1] === 'xctrace') { + throw new Error('xctrace spawn failed'); + } + + 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, + }, + ], + }, + }), + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const loggingOption = opts.options.find((option) => option.value === ('logging' as T)); + return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('continues setup with no default device when temp path creation fails', async () => { + const { fs, getStoredConfig } = createSetupFs(); + fs.tmpdir = () => { + throw new Error('tmpdir unavailable'); + }; + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'xctrace') { + throw new Error('xctrace spawn failed'); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const loggingOption = opts.options.find((option) => option.value === ('logging' as T)); + return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('continues setup with no default simulator when simulator discovery fails', async () => { + const { fs, getStoredConfig } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + throw new Error('device lookup should not run for simulator-only workflows'); + } + + if (command[0] === 'xcrun' && command[1] === 'simctl') { + throw new Error('simctl unavailable'); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const simulatorOption = opts.options.find((option) => option.value === ('simulator' as T)); + return simulatorOption + ? [simulatorOption.value] + : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + it('continues setup with no default simulator when no simulators are available', async () => { const { fs, getStoredConfig } = createSetupFs(); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index bf5d242b..18abe6df 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -379,7 +379,13 @@ async function selectSimulator(opts: { quietOutput: opts.quietOutput, startMessage: 'Loading simulators...', stopMessage: 'Simulators loaded.', - task: () => listSimulators(opts.executor), + task: async () => { + try { + return await listSimulators(opts.executor); + } catch { + return []; + } + }, }); const defaultIndex = @@ -543,41 +549,52 @@ async function listAvailableDevices( fileSystem: FileSystemExecutor, executor: CommandExecutor, ): Promise { - const jsonPath = path.join(fileSystem.tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`); - try { - const result = await executor( - ['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath], - 'List Devices (setup)', - false, - undefined, + const jsonPath = path.join( + fileSystem.tmpdir(), + `xcodebuildmcp-setup-devices-${Date.now()}.json`, ); - if (result.success) { - const jsonContent = await fileSystem.readFile(jsonPath, 'utf8'); - const devices = parseDeviceListResponse(JSON.parse(jsonContent)); - if (devices.length > 0) { - return devices; + try { + const result = await executor( + ['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath], + 'List Devices (setup)', + false, + undefined, + ); + + if (result.success) { + const jsonContent = await fileSystem.readFile(jsonPath, 'utf8'); + const devices = parseDeviceListResponse(JSON.parse(jsonContent)); + if (devices.length > 0) { + return devices; + } } + } catch { + // Fall back to xctrace below. + } finally { + await fileSystem.rm(jsonPath, { force: true }).catch(() => {}); } } catch { - // Fall back to xctrace below. - } finally { - await fileSystem.rm(jsonPath, { force: true }).catch(() => {}); + return []; } - const fallbackResult = await executor( - ['xcrun', 'xctrace', 'list', 'devices'], - 'List Devices (setup fallback)', - false, - undefined, - ); + try { + const fallbackResult = await executor( + ['xcrun', 'xctrace', 'list', 'devices'], + 'List Devices (setup fallback)', + false, + undefined, + ); - if (!fallbackResult.success) { + if (!fallbackResult.success) { + return []; + } + + return parseXctraceDevices(fallbackResult.output); + } catch { return []; } - - return parseXctraceDevices(fallbackResult.output); } function getDefaultDeviceIndex(devices: SetupDevice[], existingDeviceId?: string): number { From ab3f9dd2eb4a06da2631a45d0c1bec0d3c456a21 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 12 Mar 2026 08:43:35 +0000 Subject: [PATCH 6/6] fix(setup): Preserve device fallback discovery Keep optional device setup resilient without skipping viable fallback behavior. When temp-path creation fails, still fall through to xctrace instead of returning an empty device list immediately. Add regression coverage for tmpdir failures, malformed device JSON, and simulator text-fallback failures so optional defaults keep degrading to skip instead of aborting setup. --- src/cli/commands/__tests__/setup.test.ts | 111 ++++++++++++++++++++++- src/cli/commands/setup.ts | 41 ++++----- 2 files changed, 129 insertions(+), 23 deletions(-) diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 1f3ba9e7..ac8f8a7f 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -803,13 +803,61 @@ sessionDefaults: expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); - it('continues setup with no default device when temp path creation fails', async () => { + it('uses xctrace fallback when temp path creation fails', async () => { const { fs, getStoredConfig } = createSetupFs(); fs.tmpdir = () => { throw new Error('tmpdir unavailable'); }; const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'xctrace') { + return createMockCommandResponse({ + success: true, + output: 'Cam iPhone (12345678-1234-1234-1234-123456789ABC)', + }); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => + opts.options.find((option) => option.value != null)?.value ?? opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const loggingOption = opts.options.find((option) => option.value === ('logging' as T)); + return loggingOption ? [loggingOption.value] : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + + expect(parsed.sessionDefaults?.deviceId).toBe('12345678-1234-1234-1234-123456789ABC'); + }); + + it('continues setup with no default device when device json parsing fails', async () => { + const { fs, getStoredConfig, setTempFile } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + const jsonPath = command[command.length - 1]; + setTempFile(jsonPath, 'not json'); + return createMockCommandResponse({ success: true, output: '' }); + } + if (command[0] === 'xcrun' && command[1] === 'xctrace') { throw new Error('xctrace spawn failed'); } @@ -842,6 +890,67 @@ sessionDefaults: }; expect(parsed.sessionDefaults?.deviceId).toBeUndefined(); + }); + + it('continues setup with no default simulator when simctl text fallback fails', async () => { + const { fs, getStoredConfig } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + throw new Error('device lookup should not run for simulator-only workflows'); + } + + if (command[0] === 'xcrun' && command[1] === 'simctl' && 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' && command[1] === 'simctl') { + throw new Error('simctl text fallback unavailable'); + } + + return createMockCommandResponse({ + success: true, + output: `Information about workspace "App":\n Schemes:\n App`, + }); + }; + + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => opts.options[0].value, + selectMany: async (opts: { options: Array<{ value: T }> }) => { + const simulatorOption = opts.options.find((option) => option.value === ('simulator' as T)); + return simulatorOption + ? [simulatorOption.value] + : opts.options.map((option) => option.value); + }, + confirm: async (opts: { defaultValue: boolean }) => opts.defaultValue, + }; + + await runSetupWizard({ + cwd, + fs, + executor, + prompter, + quietOutput: true, + }); + + const parsed = parseYaml(getStoredConfig()) as { + sessionDefaults?: Record; + }; + expect(parsed.sessionDefaults?.simulatorId).toBeUndefined(); expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 18abe6df..3bc2aab1 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -549,34 +549,31 @@ async function listAvailableDevices( fileSystem: FileSystemExecutor, executor: CommandExecutor, ): Promise { + let jsonPath: string | undefined; + try { - const jsonPath = path.join( - fileSystem.tmpdir(), - `xcodebuildmcp-setup-devices-${Date.now()}.json`, - ); + jsonPath = path.join(fileSystem.tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`); - try { - const result = await executor( - ['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath], - 'List Devices (setup)', - false, - undefined, - ); + const result = await executor( + ['xcrun', 'devicectl', 'list', 'devices', '--json-output', jsonPath], + 'List Devices (setup)', + false, + undefined, + ); - if (result.success) { - const jsonContent = await fileSystem.readFile(jsonPath, 'utf8'); - const devices = parseDeviceListResponse(JSON.parse(jsonContent)); - if (devices.length > 0) { - return devices; - } + if (result.success) { + const jsonContent = await fileSystem.readFile(jsonPath, 'utf8'); + const devices = parseDeviceListResponse(JSON.parse(jsonContent)); + if (devices.length > 0) { + return devices; } - } catch { - // Fall back to xctrace below. - } finally { - await fileSystem.rm(jsonPath, { force: true }).catch(() => {}); } } catch { - return []; + // Fall back to xctrace below. + } finally { + if (jsonPath != null) { + await fileSystem.rm(jsonPath, { force: true }).catch(() => {}); + } } try {