diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb19a02..559ce30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +### Added + +- 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 + +- 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] - Fix AXe bundling issue. @@ -395,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 7f3cddd0..412b2493 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 - [Config file](#config-file) +- [Configuration layering](#configuration-layering) +- [Environment variables](#environment-variables) - [Session defaults](#session-defaults) - [Workflow selection](#workflow-selection) - [Build settings](#build-settings) @@ -13,13 +15,12 @@ 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) --- ## 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,15 +338,36 @@ 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: + +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** 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` | @@ -360,7 +382,55 @@ Environment variables are supported for backwards compatibility but the config f | `macosTemplatePath` | `XCODEBUILDMCP_MACOS_TEMPLATE_PATH` | | `macosTemplateVersion` | `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION` | -Config file takes precedence over environment variables when both are set. +### 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. --- diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts index 189bf49b..c5cca3e8 100644 --- a/src/cli/commands/__tests__/setup.test.ts +++ b/src/cli/commands/__tests__/setup.test.ts @@ -12,9 +12,96 @@ import { runSetupWizard } from '../setup.ts'; const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); +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 { 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, @@ -43,39 +130,17 @@ 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') { + setTempFile(command[5], mockDeviceListJson()); + return createMockCommandResponse({ + success: true, + output: '', + }); + } + if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -116,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[]; @@ -129,44 +194,25 @@ 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'); }); 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') { + setTempFile(command[5], mockDeviceListJson()); + return createMockCommandResponse({ + success: true, + output: '', + }); + } + if (command.includes('--json')) { return createMockCommandResponse({ success: true, @@ -199,7 +245,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); @@ -215,7 +264,7 @@ describe('setup command', () => { quietOutput: true, }); - const parsed = parseYaml(storedConfig) as { + const parsed = parseYaml(getStoredConfig()) as { debug?: boolean; enabledWorkflows?: string[]; }; @@ -248,6 +297,498 @@ describe('setup command', () => { ).rejects.toThrow('Setup prerequisites failed'); }); + it('outputs MCP config JSON when format is mcp-json', async () => { + const { fs, setTempFile } = createSetupFs(); + + const executor: CommandExecutor = async (command) => { + if (command[0] === 'xcrun' && command[1] === 'devicectl') { + setTempFile(command[5], mockDeviceListJson()); + 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, + }, + ], + }, + }), + }); + } + + 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_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 () => { + const { fs, getStoredConfig } = createSetupFs(); + + 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(getStoredConfig()) 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, setTempFile } = createSetupFs(); + + 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') { + setTempFile(command[5], mockDeviceListJson()); + 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 () => { + const { fs, getStoredConfig } = createSetupFs({ + storedConfig: `schemaVersion: 1 +enabledWorkflows: + - simulator +sessionDefaults: + workspacePath: App.xcworkspace + scheme: App + simulatorId: SIM-1 + simulatorName: iPhone 15 +`, + }); + + 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(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 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(); + + 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; + }; + + 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 00a660b1..bf5d242b 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -13,6 +13,7 @@ import { persistProjectConfigPatch, type ProjectConfig, } from '../../utils/project-config.ts'; +import type { SessionDefaults } from '../../utils/session-store.ts'; import { createPrompter, isInteractiveTTY, @@ -30,24 +31,39 @@ 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'; + 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']); +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) { @@ -105,6 +121,7 @@ function normalizeExistingDefaults(config?: ProjectConfig): { projectPath?: string; workspacePath?: string; scheme?: string; + deviceId?: string; simulatorId?: string; simulatorName?: string; } { @@ -113,6 +130,7 @@ function normalizeExistingDefaults(config?: ProjectConfig): { projectPath: sessionDefaults.projectPath, workspacePath: sessionDefaults.workspacePath, scheme: sessionDefaults.scheme, + deviceId: sessionDefaults.deviceId, simulatorId: sessionDefaults.simulatorId, simulatorName: sessionDefaults.simulatorName, }; @@ -175,6 +193,11 @@ function getChangedFields( beforeValue: beforeDefaults.scheme, afterValue: afterDefaults.scheme, }, + { + label: 'sessionDefaults.deviceId', + beforeValue: beforeDefaults.deviceId, + afterValue: afterDefaults.deviceId, + }, { label: 'sessionDefaults.simulatorId', beforeValue: beforeDefaults.simulatorId, @@ -350,7 +373,7 @@ async function selectSimulator(opts: { prompter: Prompter; isTTY: boolean; quietOutput: boolean; -}): Promise { +}): Promise { const simulators = await withSpinner({ isTTY: opts.isTTY, quietOutput: opts.quietOutput, @@ -358,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.', @@ -374,12 +393,241 @@ 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: + simulators.length > 0 && (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( + 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, + ); + + 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(() => {}); + } + + const fallbackResult = await executor( + ['xcrun', 'xctrace', 'list', 'devices'], + 'List Devices (setup fallback)', + false, + undefined, + ); + + if (!fallbackResult.success) { + return []; + } + + 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; + fs: FileSystemExecutor; + 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.fs, opts.executor), + }); + + const defaultIndex = + devices.length > 0 ? getDefaultDeviceIndex(devices, opts.existingDeviceId) : 0; + + 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: devices.length > 0 && opts.existingDeviceId != null ? defaultIndex + 1 : 0, }); } @@ -459,14 +707,27 @@ 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, + fs: deps.fs, + executor: deps.executor, + prompter: deps.prompter, + isTTY, + quietOutput: deps.quietOutput, + }) + : undefined; return { debug, @@ -475,11 +736,59 @@ 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, }; } +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; + 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: { + 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 +801,30 @@ 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, 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, 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.', + ); + } } await ensureSetupPrerequisites({ @@ -515,8 +838,35 @@ export async function runSetupWizard(deps?: Partial): Promise const selection = await collectSetupSelection(beforeConfig, resolvedDeps); - const deleteSessionDefaultKeys: Array<'projectPath' | 'workspacePath'> = + 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) when you need\n' + + 'env-based bootstrap defaults:', + ); + // Print raw JSON to stdout so it can be piped/copied + process.stdout.write(`${mcpConfigJson}\n`); + clack.outro('Setup complete.'); + } + + return { + changedFields: [], + mcpConfigJson, + }; + } + + 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 @@ -538,6 +888,7 @@ export async function runSetupWizard(deps?: Partial): Promise projectPath: persistedProjectPath, workspacePath: persistedWorkspacePath, scheme: selection.scheme, + deviceId: selection.deviceId, simulatorId: selection.simulatorId, simulatorName: selection.simulatorName, }, @@ -570,10 +921,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 an env-based MCP bootstrap 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..78dda5a2 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -262,6 +262,101 @@ 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('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 3e23ed9b..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; @@ -233,6 +234,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 +322,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: { @@ -312,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, @@ -501,6 +556,7 @@ function resolveConfig(opts: { sessionDefaults: resolveSessionDefaults({ overrides: opts.overrides, fileConfig: opts.fileConfig, + env: opts.env, }), sessionDefaultsProfiles: resolveSessionDefaultsProfiles({ overrides: opts.overrides, @@ -521,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; @@ -620,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 };