diff --git a/docs/SESSION_DEFAULTS.md b/docs/SESSION_DEFAULTS.md index be4015d5..a2ad7db7 100644 --- a/docs/SESSION_DEFAULTS.md +++ b/docs/SESSION_DEFAULTS.md @@ -25,6 +25,40 @@ The persistence is patch-only: only keys provided in that call are written (plus You can also manually create the config file to essentially seed the defaults at startup; see [CONFIGURATION.md](CONFIGURATION.md) for more information. +## Namespaced profiles +Session defaults support named profiles so one workspace can keep separate defaults for iOS/watchOS/macOS (or any custom profile names). + +- Use `session_use_defaults_profile` to switch the active profile. +- Existing tools (`session_set_defaults`, `session_show_defaults`, build/test tools) use the active profile automatically. +- Use `global: true` to switch back to the unnamed global profile. +- Set `persist: true` on `session_use_defaults_profile` to write `activeSessionDefaultsProfile` in `.xcodebuildmcp/config.yaml`. + +## Recommended startup flow (monorepo / multi-target) +Copy/paste this sequence when starting a new session: + +```json +{"name":"session_use_defaults_profile","arguments":{"profile":"ios","persist":true}} +{"name":"session_set_defaults","arguments":{ + "workspacePath":"/repo/MyApp.xcworkspace", + "scheme":"MyApp-iOS", + "simulatorName":"iPhone 16 Pro", + "persist":true +}} +{"name":"session_show_defaults","arguments":{}} +``` + +Switch targets later in the same session: + +```json +{"name":"session_use_defaults_profile","arguments":{"profile":"watch","persist":true}} +{"name":"session_set_defaults","arguments":{ + "workspacePath":"/repo/MyApp.xcworkspace", + "scheme":"MyApp-watchOS", + "simulatorName":"Apple Watch Series 10 (45mm)", + "persist":true +}} +``` + ## Related docs - Configuration options: [CONFIGURATION.md](CONFIGURATION.md) - Tools reference: [TOOLS.md](TOOLS.md) diff --git a/docs/TOOLS-CLI.md b/docs/TOOLS-CLI.md index a1eb08c8..ff44912e 100644 --- a/docs/TOOLS-CLI.md +++ b/docs/TOOLS-CLI.md @@ -187,4 +187,4 @@ XcodeBuildMCP provides 71 canonical tools organized into 13 workflow groups. --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T12:09:33.648Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-11T19:34:51.141Z UTC* diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 71950ad7..941b9a4b 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # XcodeBuildMCP MCP Tools Reference -This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 76 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. +This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 77 canonical tools organized into 15 workflow groups for comprehensive Apple development workflows. ## Workflow Groups @@ -126,11 +126,12 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ### Session Management (`session-management`) -**Purpose**: Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings. (4 tools) +**Purpose**: Manage session defaults for project/workspace paths, scheme, configuration, simulator/device settings. (5 tools) - `session_clear_defaults` - Clear session defaults. - `session_set_defaults` - Set the session defaults, should be called at least once to set tool defaults. - `session_show_defaults` - Show session defaults. +- `session_use_defaults_profile` - Select the active session defaults profile for multi-target workflows. - `sync_xcode_defaults` - Sync session defaults (scheme, simulator) from Xcode's current IDE selection. @@ -196,10 +197,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov ## Summary Statistics -- **Canonical Tools**: 76 -- **Total Tools**: 100 +- **Canonical Tools**: 77 +- **Total Tools**: 101 - **Workflow Groups**: 15 --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-08T12:09:33.648Z UTC* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-11T19:34:51.141Z UTC* diff --git a/manifests/tools/session_use_defaults_profile.yaml b/manifests/tools/session_use_defaults_profile.yaml new file mode 100644 index 00000000..1080f222 --- /dev/null +++ b/manifests/tools/session_use_defaults_profile.yaml @@ -0,0 +1,9 @@ +id: session_use_defaults_profile +module: mcp/tools/session-management/session_use_defaults_profile +names: + mcp: session_use_defaults_profile + cli: use-defaults-profile +description: Select the active session defaults profile for multi-target workflows. +annotations: + title: Use Session Defaults Profile + readOnlyHint: true diff --git a/manifests/workflows/session-management.yaml b/manifests/workflows/session-management.yaml index b2ea01ba..0c246917 100644 --- a/manifests/workflows/session-management.yaml +++ b/manifests/workflows/session-management.yaml @@ -9,6 +9,7 @@ selection: autoInclude: true tools: - session_show_defaults + - session_use_defaults_profile - session_set_defaults - session_clear_defaults - sync_xcode_defaults diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index e59af5b4..47d60561 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -57,7 +57,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { setLogLevel(level); } }) - .version(version) + .version(String(version)) .help() .alias('h', 'help') .alias('v', 'version') diff --git a/src/core/manifest/__tests__/load-manifest.test.ts b/src/core/manifest/__tests__/load-manifest.test.ts index dc55d4cc..4d38caa5 100644 --- a/src/core/manifest/__tests__/load-manifest.test.ts +++ b/src/core/manifest/__tests__/load-manifest.test.ts @@ -33,6 +33,7 @@ describe('load-manifest', () => { expect(manifest.tools.has('build_sim')).toBe(true); expect(manifest.tools.has('discover_projs')).toBe(true); expect(manifest.tools.has('session_show_defaults')).toBe(true); + expect(manifest.tools.has('session_use_defaults_profile')).toBe(true); }); it('should validate tool references in workflows', () => { diff --git a/src/daemon.ts b/src/daemon.ts index 02151fa8..c14bd533 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -323,7 +323,7 @@ async function main(): Promise { pid: process.pid, startedAt, enabledWorkflows: daemonWorkflows, - version, + version: String(version), }); writeLine(`Daemon started (PID: ${process.pid})`); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 84364beb..255ebb1c 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -136,7 +136,7 @@ export async function runDoctor( ); const doctorInfo = { - serverVersion: version, + serverVersion: String(version), timestamp: new Date().toISOString(), system: systemInfo, node: nodeInfo, diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index 878ea02d..0959189d 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -50,12 +50,17 @@ describe('session-clear-defaults tool', () => { }); it('should clear all when all=true', async () => { + sessionStore.setActiveProfile('ios'); + sessionStore.setDefaults({ scheme: 'IOS' }); + sessionStore.setActiveProfile(null); const result = await sessionClearDefaultsLogic({ all: true }); expect(result.isError).toBe(false); expect(result.content[0].text).toBe('Session defaults cleared'); const current = sessionStore.getAll(); expect(Object.keys(current).length).toBe(0); + expect(sessionStore.listProfiles()).toEqual([]); + expect(sessionStore.getActiveProfile()).toBeNull(); }); it('should clear all when no params provided', async () => { diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index f1cb8c3b..42d2ed2f 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -258,6 +258,49 @@ describe('session-set-defaults tool', () => { expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); + it('should persist into the active named profile when selected', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaultsProfiles:', + ' ios:', + ' scheme: "Old"', + '', + ].join('\n'); + + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + + await initConfigStore({ cwd, fs }); + sessionStore.setActiveProfile('ios'); + + await sessionSetDefaultsLogic( + { + scheme: 'NewIOS', + simulatorName: 'iPhone 16', + persist: true, + }, + createContext(), + ); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + sessionDefaultsProfiles?: Record>; + }; + expect(parsed.sessionDefaultsProfiles?.ios?.scheme).toBe('NewIOS'); + expect(parsed.sessionDefaultsProfiles?.ios?.simulatorName).toBe('iPhone 16'); + }); + it('should not persist when persist is true but no defaults were provided', async () => { const result = await sessionSetDefaultsLogic({ persist: true }, createContext()); diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index bd836024..b61488cb 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -41,5 +41,15 @@ describe('session-show-defaults tool', () => { expect(parsed.scheme).toBe('MyScheme'); expect(parsed.simulatorId).toBe('SIM-123'); }); + + it('shows defaults from the active profile', async () => { + sessionStore.setDefaults({ scheme: 'GlobalScheme' }); + sessionStore.setActiveProfile('ios'); + sessionStore.setDefaults({ scheme: 'IOSScheme' }); + + const result = await handler(); + const parsed = JSON.parse(result.content[0].text as string); + expect(parsed.scheme).toBe('IOSScheme'); + }); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts new file mode 100644 index 00000000..f61f3abe --- /dev/null +++ b/src/mcp/tools/session-management/__tests__/session_use_defaults_profile.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { __resetConfigStoreForTests, initConfigStore } from '../../../../utils/config-store.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; +import { sessionStore } from '../../../../utils/session-store.ts'; +import { + handler, + schema, + sessionUseDefaultsProfileLogic, +} from '../session_use_defaults_profile.ts'; + +describe('session-use-defaults-profile tool', () => { + beforeEach(() => { + __resetConfigStoreForTests(); + sessionStore.clear(); + }); + + const cwd = '/repo'; + const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); + + it('exports handler and schema', () => { + expect(typeof handler).toBe('function'); + expect(schema).toBeDefined(); + expect(typeof schema).toBe('object'); + }); + + it('activates a named profile', async () => { + const result = await sessionUseDefaultsProfileLogic({ profile: 'ios' }); + expect(result.isError).toBe(false); + expect(sessionStore.getActiveProfile()).toBe('ios'); + expect(sessionStore.listProfiles()).toContain('ios'); + }); + + it('switches back to global profile', async () => { + sessionStore.setActiveProfile('watch'); + const result = await sessionUseDefaultsProfileLogic({ global: true }); + expect(result.isError).toBe(false); + expect(sessionStore.getActiveProfile()).toBeNull(); + }); + + it('returns error when both global and profile are provided', async () => { + const result = await sessionUseDefaultsProfileLogic({ global: true, profile: 'ios' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('either global=true or profile'); + }); + + it('returns error when profile is missing and create=false', async () => { + const result = await sessionUseDefaultsProfileLogic({ profile: 'macos', create: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('does not exist'); + }); + + it('returns status for empty args', async () => { + const result = await sessionUseDefaultsProfileLogic({}); + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Active defaults profile: global'); + }); + + it('persists active profile when persist=true', async () => { + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async () => ['schemaVersion: 1', ''].join('\n'), + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + await initConfigStore({ cwd, fs }); + + const result = await sessionUseDefaultsProfileLogic({ profile: 'ios', persist: true }); + expect(result.isError).toBe(false); + expect(result.content[0].text).toContain('Persisted active profile selection'); + expect(writes).toHaveLength(1); + const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; + expect(parsed.activeSessionDefaultsProfile).toBe('ios'); + }); + + it('removes active profile from config when persisting global selection', async () => { + const writes: { path: string; content: string }[] = []; + const yaml = ['schemaVersion: 1', 'activeSessionDefaultsProfile: "ios"', ''].join('\n'); + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async () => yaml, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + await initConfigStore({ cwd, fs }); + + const result = await sessionUseDefaultsProfileLogic({ global: true, persist: true }); + expect(result.isError).toBe(false); + expect(writes).toHaveLength(1); + const parsed = parseYaml(writes[0].content) as { activeSessionDefaultsProfile?: string }; + expect(parsed.activeSessionDefaultsProfile).toBeUndefined(); + }); +}); diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index c55a43ba..1f2704c6 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -27,6 +27,7 @@ export async function sessionSetDefaultsLogic( context: SessionSetDefaultsContext, ): Promise { const notices: string[] = []; + const activeProfile = sessionStore.getActiveProfile(); const current = sessionStore.getAll(); const { persist, ...rawParams } = params; const nextParams = removeUndefined( @@ -133,6 +134,7 @@ export async function sessionSetDefaultsLogic( const { path } = await persistSessionDefaultsPatch({ patch: nextParams, deleteKeys: Array.from(toClear), + profile: activeProfile, }); notices.push(`Persisted defaults to ${path}`); } @@ -145,6 +147,7 @@ export async function sessionSetDefaultsLogic( executor: context.executor, expectedRevision: revision, reason: 'session-set-defaults', + profile: activeProfile, persist: Boolean(persist), simulatorId: defaultsForRefresh.simulatorId, simulatorName: defaultsForRefresh.simulatorName, diff --git a/src/mcp/tools/session-management/session_use_defaults_profile.ts b/src/mcp/tools/session-management/session_use_defaults_profile.ts new file mode 100644 index 00000000..6e498477 --- /dev/null +++ b/src/mcp/tools/session-management/session_use_defaults_profile.ts @@ -0,0 +1,110 @@ +import * as z from 'zod'; +import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { persistActiveSessionDefaultsProfile } from '../../../utils/config-store.ts'; +import { sessionStore } from '../../../utils/session-store.ts'; +import type { ToolResponse } from '../../../types/common.ts'; + +const schemaObj = z.object({ + profile: z + .string() + .min(1) + .optional() + .describe('Activate a named session defaults profile (example: ios or watch).'), + global: z.boolean().optional().describe('Activate the global unnamed defaults profile.'), + create: z.boolean().optional().describe('Create the profile when missing. Defaults to true.'), + persist: z + .boolean() + .optional() + .describe('Persist activeSessionDefaultsProfile to .xcodebuildmcp/config.yaml.'), +}); + +type Params = z.infer; + +function normalizeProfileName(profile: string): string { + return profile.trim(); +} + +function errorResponse(text: string): ToolResponse { + return { + content: [{ type: 'text', text }], + isError: true, + }; +} + +function resolveProfileToActivate(params: Params): string | null | undefined { + if (params.global === true) return null; + if (params.profile === undefined) return undefined; + return normalizeProfileName(params.profile); +} + +function validateProfileActivation( + profileToActivate: string | null | undefined, + create: boolean | undefined, +): ToolResponse | null { + if (profileToActivate === undefined || profileToActivate === null) { + return null; + } + + if (profileToActivate.length === 0) { + return errorResponse('Profile name cannot be empty.'); + } + + const profileExists = sessionStore.listProfiles().includes(profileToActivate); + if (!profileExists && create === false) { + return errorResponse(`Profile "${profileToActivate}" does not exist.`); + } + + return null; +} + +export async function sessionUseDefaultsProfileLogic(params: Params): Promise { + const notices: string[] = []; + + if (params.global === true && params.profile !== undefined) { + return errorResponse('Provide either global=true or profile, not both.'); + } + + const profileToActivate = resolveProfileToActivate(params); + const validationError = validateProfileActivation(profileToActivate, params.create); + if (validationError) { + return validationError; + } + + if (profileToActivate !== undefined) { + sessionStore.setActiveProfile(profileToActivate); + } + + const active = sessionStore.getActiveProfile(); + if (params.persist) { + const { path } = await persistActiveSessionDefaultsProfile(active); + notices.push(`Persisted active profile selection to ${path}`); + } + + const activeLabel = active ?? 'global'; + const profiles = sessionStore.listProfiles(); + const current = sessionStore.getAll(); + + return { + content: [ + { + type: 'text', + text: [ + `Active defaults profile: ${activeLabel}`, + `Known profiles: ${profiles.length > 0 ? profiles.join(', ') : '(none)'}`, + `Current defaults: ${JSON.stringify(current, null, 2)}`, + ...(notices.length > 0 ? [`Notices:`, ...notices.map((notice) => `- ${notice}`)] : []), + ].join('\n'), + }, + ], + isError: false, + }; +} + +export const schema = schemaObj.shape; + +export const handler = createTypedTool( + schemaObj, + sessionUseDefaultsProfileLogic, + getDefaultCommandExecutor, +); diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index 89b39b60..4c9b91fb 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -110,7 +110,12 @@ export async function syncXcodeDefaultsLogic( export const schema = schemaObj.shape; -export const handler = createTypedToolWithContext(schemaObj, syncXcodeDefaultsLogic, () => ({ - executor: getDefaultCommandExecutor(), - cwd: process.cwd(), -})); +export const handler = createTypedToolWithContext(schemaObj, syncXcodeDefaultsLogic, () => { + const { projectPath, workspacePath } = sessionStore.getAll(); + return { + executor: getDefaultCommandExecutor(), + cwd: process.cwd(), + projectPath, + workspacePath, + }; +}); diff --git a/src/runtime/__tests__/bootstrap-runtime.test.ts b/src/runtime/__tests__/bootstrap-runtime.test.ts index e12a147d..c845506f 100644 --- a/src/runtime/__tests__/bootstrap-runtime.test.ts +++ b/src/runtime/__tests__/bootstrap-runtime.test.ts @@ -43,6 +43,30 @@ function createFsWithSchemeOnlySessionDefaults() { }); } +function createFsWithProfiles() { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' scheme: "GlobalScheme"', + 'sessionDefaultsProfiles:', + ' ios:', + ' scheme: "IOSScheme"', + ' simulatorName: "iPhone 16"', + 'activeSessionDefaultsProfile: "ios"', + '', + ].join('\n'); + + return createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + }); +} + describe('bootstrapRuntime', () => { beforeEach(() => { __resetConfigStoreForTests(); @@ -88,4 +112,16 @@ describe('bootstrapRuntime', () => { expect(sessionStore.getAll()).toEqual({}); }, ); + + it('hydrates the active session defaults profile for mcp runtime', async () => { + await bootstrapRuntime({ + runtime: 'mcp', + cwd, + fs: createFsWithProfiles(), + }); + + expect(sessionStore.getActiveProfile()).toBe('ios'); + expect(sessionStore.getAll().scheme).toBe('IOSScheme'); + expect(sessionStore.getAll().simulatorName).toBe('iPhone 16'); + }); }); diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts index cb6040ef..5ab888c8 100644 --- a/src/runtime/bootstrap-runtime.ts +++ b/src/runtime/bootstrap-runtime.ts @@ -43,26 +43,58 @@ interface MCPSessionHydrationResult { */ function hydrateSessionDefaultsForMcp( defaults: Partial | undefined, + profiles: Record> | undefined, + activeProfile: string | undefined, ): MCPSessionHydrationResult { const hydratedDefaults = { ...(defaults ?? {}) }; - if (Object.keys(hydratedDefaults).length === 0) { + const hydratedProfiles = profiles ?? {}; + const hasHydratedDefaults = Object.keys(hydratedDefaults).length > 0; + const hydratedProfileEntries = Object.entries(hydratedProfiles); + if (!hasHydratedDefaults && hydratedProfileEntries.length === 0) { return { hydrated: false, refreshScheduled: false }; } - sessionStore.setDefaults(hydratedDefaults); + if (hasHydratedDefaults) { + sessionStore.setDefaultsForProfile(null, hydratedDefaults); + } + for (const [profileName, profileDefaults] of hydratedProfileEntries) { + const trimmedName = profileName.trim(); + if (!trimmedName) continue; + sessionStore.setDefaultsForProfile(trimmedName, profileDefaults); + } + const normalizedActiveProfile = activeProfile?.trim(); + if (normalizedActiveProfile) { + sessionStore.setActiveProfile(normalizedActiveProfile); + } + + const activeDefaults = sessionStore.getAll(); const revision = sessionStore.getRevision(); const refreshScheduled = scheduleSimulatorDefaultsRefresh({ expectedRevision: revision, reason: 'startup-hydration', + profile: sessionStore.getActiveProfile(), persist: true, - simulatorId: hydratedDefaults.simulatorId, - simulatorName: hydratedDefaults.simulatorName, + simulatorId: activeDefaults.simulatorId, + simulatorName: activeDefaults.simulatorName, recomputePlatform: true, }); return { hydrated: true, refreshScheduled }; } +function logHydrationResult(hydration: MCPSessionHydrationResult): void { + if (!hydration.hydrated) { + return; + } + + if (hydration.refreshScheduled) { + log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); + return; + } + + log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh not scheduled.'); +} + export async function bootstrapRuntime( opts: BootstrapRuntimeOptions, ): Promise { @@ -85,15 +117,12 @@ export async function bootstrapRuntime( const config = getConfig(); if (opts.runtime === 'mcp') { - const hydration = hydrateSessionDefaultsForMcp(config.sessionDefaults); - if (hydration.hydrated && hydration.refreshScheduled) { - log('info', '[Session] Hydrated MCP session defaults; simulator metadata refresh scheduled.'); - } else if (hydration.hydrated) { - log( - 'info', - '[Session] Hydrated MCP session defaults; simulator metadata refresh not scheduled.', - ); - } + const hydration = hydrateSessionDefaultsForMcp( + config.sessionDefaults, + config.sessionDefaultsProfiles, + config.activeSessionDefaultsProfile, + ); + logHydrationResult(hydration); } return { diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index e5c6d791..e33ee672 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -75,10 +75,10 @@ export async function bootstrapServer( if (xcodeDetection.runningUnderXcode) { log('info', `[xcode] Running under Xcode agent environment`); - // Get project/workspace path from config session defaults (for monorepo disambiguation) - const configSessionDefaults = result.runtime.config.sessionDefaults; - const projectPath = configSessionDefaults?.projectPath; - const workspacePath = configSessionDefaults?.workspacePath; + // Use the active session defaults profile for monorepo disambiguation. + const activeSessionDefaults = sessionStore.getAll(); + const projectPath = activeSessionDefaults.projectPath; + const workspacePath = activeSessionDefaults.workspacePath; // Sync session defaults from Xcode's IDE state const xcodeState = await readXcodeIdeState({ diff --git a/src/server/server.ts b/src/server/server.ts index 565a048b..d9ccc4d3 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -31,7 +31,7 @@ export function createServer(): McpServer { const baseServer = new McpServer( { name: 'xcodebuildmcp', - version, + version: String(version), }, { instructions: `XcodeBuildMCP provides comprehensive tooling for Apple platform development (iOS, macOS, watchOS, tvOS, visionOS). diff --git a/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts b/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts index 2ee7068f..5d2304db 100644 --- a/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts +++ b/src/smoke-tests/__tests__/e2e-mcp-discovery.test.ts @@ -69,6 +69,7 @@ describe('MCP Discovery (e2e)', () => { expect(names).toContain('session_set_defaults'); expect(names).toContain('session_show_defaults'); expect(names).toContain('session_clear_defaults'); + expect(names).toContain('session_use_defaults_profile'); }); it('excludes workflow discovery when experimentalWorkflowDiscovery is disabled', async () => { diff --git a/src/smoke-tests/__tests__/e2e-mcp-sessions.test.ts b/src/smoke-tests/__tests__/e2e-mcp-sessions.test.ts index 401465ab..eae3f358 100644 --- a/src/smoke-tests/__tests__/e2e-mcp-sessions.test.ts +++ b/src/smoke-tests/__tests__/e2e-mcp-sessions.test.ts @@ -177,4 +177,41 @@ describe('MCP Session Management (e2e)', () => { expect(buildCommand).toBeDefined(); expect(buildCommand).toContain('UpdatedScheme'); }); + + it('supports namespaced defaults by switching active profile', async () => { + await harness.client.callTool({ + name: 'session_use_defaults_profile', + arguments: { profile: 'ios' }, + }); + await harness.client.callTool({ + name: 'session_set_defaults', + arguments: { scheme: 'IOSScheme', projectPath: '/ios/project.xcodeproj' }, + }); + + await harness.client.callTool({ + name: 'session_use_defaults_profile', + arguments: { profile: 'watch' }, + }); + await harness.client.callTool({ + name: 'session_set_defaults', + arguments: { scheme: 'WatchScheme', projectPath: '/watch/project.xcodeproj' }, + }); + + const activeWatch = await harness.client.callTool({ + name: 'session_show_defaults', + arguments: {}, + }); + expect(extractText(activeWatch)).toContain('WatchScheme'); + expect(extractText(activeWatch)).not.toContain('IOSScheme'); + + await harness.client.callTool({ + name: 'session_use_defaults_profile', + arguments: { profile: 'ios' }, + }); + const activeIos = await harness.client.callTool({ + name: 'session_show_defaults', + arguments: {}, + }); + expect(extractText(activeIos)).toContain('IOSScheme'); + }); }); diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts index 8f57a4f0..b31bb544 100644 --- a/src/utils/__tests__/config-store.test.ts +++ b/src/utils/__tests__/config-store.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, it } from 'vitest'; import path from 'node:path'; import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; -import { __resetConfigStoreForTests, getConfig, initConfigStore } from '../config-store.ts'; +import { + __resetConfigStoreForTests, + getConfig, + initConfigStore, + persistActiveSessionDefaultsProfile, +} from '../config-store.ts'; const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); @@ -105,4 +110,50 @@ describe('config-store', () => { const updated = getConfig(); expect(updated.enabledWorkflows).toEqual(['device']); }); + + it('merges namespaced session defaults profiles from file and overrides', async () => { + const yaml = [ + 'schemaVersion: 1', + 'activeSessionDefaultsProfile: "ios"', + 'sessionDefaultsProfiles:', + ' ios:', + ' scheme: "FromFile"', + ' workspacePath: "./App.xcworkspace"', + '', + ].join('\n'); + + await initConfigStore({ + cwd, + fs: createFs(yaml), + overrides: { + sessionDefaultsProfiles: { + ios: { simulatorName: 'iPhone 16' }, + watch: { scheme: 'WatchScheme' }, + }, + }, + }); + + const config = getConfig(); + expect(config.activeSessionDefaultsProfile).toBe('ios'); + expect(config.sessionDefaultsProfiles?.ios?.scheme).toBe('FromFile'); + expect(config.sessionDefaultsProfiles?.ios?.simulatorName).toBe('iPhone 16'); + expect(config.sessionDefaultsProfiles?.watch?.scheme).toBe('WatchScheme'); + }); + + it('persists active profile selection and updates resolved config', async () => { + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: () => true, + readFile: async () => 'schemaVersion: 1\n', + writeFile: async (targetPath, content) => { + writes.push({ path: targetPath, content }); + }, + }); + + await initConfigStore({ cwd, fs }); + await persistActiveSessionDefaultsProfile('ios'); + + expect(getConfig().activeSessionDefaultsProfile).toBe('ios'); + expect(writes).toHaveLength(1); + }); }); diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index df36e6a6..128158ca 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import path from 'node:path'; import { parse as parseYaml } from 'yaml'; import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; -import { loadProjectConfig, persistSessionDefaultsToProjectConfig } from '../project-config.ts'; +import { + loadProjectConfig, + persistActiveSessionDefaultsProfileToProjectConfig, + persistSessionDefaultsToProjectConfig, +} from '../project-config.ts'; const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); @@ -124,6 +128,34 @@ describe('project-config', () => { expect(defaults.derivedDataPath).toBe('/repo/.derivedData'); }); + it('normalizes namespaced session defaults profiles and active profile', async () => { + const yaml = [ + 'schemaVersion: 1', + 'activeSessionDefaultsProfile: "ios"', + 'sessionDefaultsProfiles:', + ' ios:', + ' projectPath: "./App.xcodeproj"', + ' workspacePath: "./App.xcworkspace"', + ' simulatorName: "iPhone 16"', + ' watch:', + ' workspacePath: "./Watch.xcworkspace"', + '', + ].join('\n'); + + const { fs } = createFsFixture({ exists: true, readFile: yaml }); + const result = await loadProjectConfig({ fs, cwd }); + if (!result.found) throw new Error('expected config to be found'); + + expect(result.config.activeSessionDefaultsProfile).toBe('ios'); + expect(result.config.sessionDefaultsProfiles?.ios?.workspacePath).toBe( + path.join(cwd, 'App.xcworkspace'), + ); + expect(result.config.sessionDefaultsProfiles?.ios?.projectPath).toBeUndefined(); + expect(result.config.sessionDefaultsProfiles?.watch?.workspacePath).toBe( + path.join(cwd, 'Watch.xcworkspace'), + ); + }); + it('should return an error result when schemaVersion is unsupported', async () => { const yaml = ['schemaVersion: 2', 'sessionDefaults:', ' scheme: "App"', ''].join('\n'); const { fs } = createFsFixture({ exists: true, readFile: yaml }); @@ -212,5 +244,65 @@ describe('project-config', () => { expect(parsed.schemaVersion).toBe(1); expect(parsed.sessionDefaults?.scheme).toBe('App'); }); + + it('persists session defaults to a named profile', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaultsProfiles:', + ' ios:', + ' scheme: "Old"', + '', + ].join('\n'); + const { fs, writes } = createFsFixture({ exists: true, readFile: yaml }); + + await persistSessionDefaultsToProjectConfig({ + fs, + cwd, + profile: 'ios', + patch: { scheme: 'NewIOS', simulatorId: 'SIM-1' }, + }); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + sessionDefaultsProfiles?: Record>; + }; + expect(parsed.sessionDefaultsProfiles?.ios?.scheme).toBe('NewIOS'); + expect(parsed.sessionDefaultsProfiles?.ios?.simulatorId).toBe('SIM-1'); + }); + }); + + describe('persistActiveSessionDefaultsProfileToProjectConfig', () => { + it('persists active profile name', async () => { + const { fs, writes } = createFsFixture({ exists: true, readFile: 'schemaVersion: 1\n' }); + + await persistActiveSessionDefaultsProfileToProjectConfig({ + fs, + cwd, + profile: 'ios', + }); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + activeSessionDefaultsProfile?: string; + }; + expect(parsed.activeSessionDefaultsProfile).toBe('ios'); + }); + + it('removes active profile when switching to global', async () => { + const yaml = ['schemaVersion: 1', 'activeSessionDefaultsProfile: "watch"', ''].join('\n'); + const { fs, writes } = createFsFixture({ exists: true, readFile: yaml }); + + await persistActiveSessionDefaultsProfileToProjectConfig({ + fs, + cwd, + profile: null, + }); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + activeSessionDefaultsProfile?: string; + }; + expect(parsed.activeSessionDefaultsProfile).toBeUndefined(); + }); }); }); diff --git a/src/utils/__tests__/session-store.test.ts b/src/utils/__tests__/session-store.test.ts index 752c8f47..8cd4a627 100644 --- a/src/utils/__tests__/session-store.test.ts +++ b/src/utils/__tests__/session-store.test.ts @@ -43,4 +43,42 @@ describe('SessionStore', () => { expect(all.scheme).toBe('App'); expect(all.simulatorId).toBe('SIM-1'); }); + + it('isolates defaults by active profile', () => { + sessionStore.setDefaults({ scheme: 'GlobalApp' }); + sessionStore.setActiveProfile('ios'); + sessionStore.setDefaults({ scheme: 'iOSApp', simulatorName: 'iPhone 16' }); + sessionStore.setActiveProfile('watch'); + sessionStore.setDefaults({ scheme: 'WatchApp' }); + + sessionStore.setActiveProfile('ios'); + expect(sessionStore.getAll()).toMatchObject({ scheme: 'iOSApp', simulatorName: 'iPhone 16' }); + + sessionStore.setActiveProfile('watch'); + expect(sessionStore.getAll()).toMatchObject({ scheme: 'WatchApp' }); + expect(sessionStore.getAll().simulatorName).toBeUndefined(); + + sessionStore.setActiveProfile(null); + expect(sessionStore.getAll()).toMatchObject({ scheme: 'GlobalApp' }); + }); + + it('clear(keys) only affects active profile while clear() clears all profiles', () => { + sessionStore.setActiveProfile('ios'); + sessionStore.setDefaults({ scheme: 'iOSApp', simulatorId: 'SIM-1' }); + sessionStore.setActiveProfile('watch'); + sessionStore.setDefaults({ scheme: 'WatchApp', simulatorId: 'SIM-2' }); + + sessionStore.setActiveProfile('ios'); + sessionStore.clear(['simulatorId']); + expect(sessionStore.getAll().scheme).toBe('iOSApp'); + expect(sessionStore.getAll().simulatorId).toBeUndefined(); + + sessionStore.setActiveProfile('watch'); + expect(sessionStore.getAll().simulatorId).toBe('SIM-2'); + + sessionStore.clear(); + expect(sessionStore.getAll()).toEqual({}); + expect(sessionStore.getActiveProfile()).toBeNull(); + expect(sessionStore.listProfiles()).toEqual([]); + }); }); diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts index d1daa65f..cf404c35 100644 --- a/src/utils/config-store.ts +++ b/src/utils/config-store.ts @@ -3,6 +3,7 @@ import type { SessionDefaults } from './session-store.ts'; import { log } from './logger.ts'; import { loadProjectConfig, + persistActiveSessionDefaultsProfileToProjectConfig, persistSessionDefaultsToProjectConfig, type ProjectConfig, } from './project-config.ts'; @@ -27,6 +28,8 @@ export type RuntimeConfigOverrides = Partial<{ macosTemplateVersion: string; debuggerBackend: DebuggerBackendKind; sessionDefaults: Partial; + sessionDefaultsProfiles: Record>; + activeSessionDefaultsProfile: string; }>; export type ResolvedRuntimeConfig = { @@ -47,6 +50,8 @@ export type ResolvedRuntimeConfig = { macosTemplateVersion?: string; debuggerBackend: DebuggerBackendKind; sessionDefaults?: Partial; + sessionDefaultsProfiles?: Record>; + activeSessionDefaultsProfile?: string; }; type ConfigStoreState = { @@ -77,6 +82,13 @@ const storeState: ConfigStoreState = { resolved: { ...DEFAULT_CONFIG }, }; +function refreshResolvedConfig(): void { + storeState.resolved = resolveConfig({ + fileConfig: storeState.fileConfig, + overrides: storeState.overrides, + }); +} + function hasOwnProperty( obj: T | undefined, key: K, @@ -257,6 +269,70 @@ function resolveSessionDefaults(opts: { return { ...(fileDefaults ?? {}), ...(overrideDefaults ?? {}) }; } +function resolveSessionDefaultsProfiles(opts: { + overrides?: RuntimeConfigOverrides; + fileConfig?: ProjectConfig; +}): Record> | undefined { + const overrideProfiles = opts.overrides?.sessionDefaultsProfiles; + const fileProfiles = opts.fileConfig?.sessionDefaultsProfiles; + if (!overrideProfiles && !fileProfiles) return undefined; + + const merged: Record> = {}; + for (const [name, defaults] of Object.entries(fileProfiles ?? {})) { + merged[name] = { ...defaults }; + } + for (const [name, defaults] of Object.entries(overrideProfiles ?? {})) { + merged[name] = { ...(merged[name] ?? {}), ...defaults }; + } + return merged; +} + +function getCurrentFileConfig(): ProjectConfig { + return storeState.fileConfig ?? { schemaVersion: 1 }; +} + +function applySessionDefaultsPatchToFileConfig(opts: { + fileConfig: ProjectConfig; + profile: string | null; + patch: Partial; + deleteKeys?: (keyof SessionDefaults)[]; +}): ProjectConfig { + const nextFileConfig: ProjectConfig = { ...opts.fileConfig }; + const baseDefaults = + opts.profile === null + ? (nextFileConfig.sessionDefaults ?? {}) + : (nextFileConfig.sessionDefaultsProfiles?.[opts.profile] ?? {}); + + const nextSessionDefaults: Partial = { ...baseDefaults, ...opts.patch }; + for (const key of opts.deleteKeys ?? []) { + delete nextSessionDefaults[key]; + } + + if (opts.profile === null) { + nextFileConfig.sessionDefaults = nextSessionDefaults; + return nextFileConfig; + } + + nextFileConfig.sessionDefaultsProfiles = { + ...(nextFileConfig.sessionDefaultsProfiles ?? {}), + [opts.profile]: nextSessionDefaults, + }; + return nextFileConfig; +} + +function applyActiveProfileToFileConfig(opts: { + fileConfig: ProjectConfig; + profile: string | null; +}): ProjectConfig { + const nextFileConfig: ProjectConfig = { ...opts.fileConfig }; + if (opts.profile === null) { + delete nextFileConfig.activeSessionDefaultsProfile; + } else { + nextFileConfig.activeSessionDefaultsProfile = opts.profile; + } + return nextFileConfig; +} + function resolveConfig(opts: { fileConfig?: ProjectConfig; overrides?: RuntimeConfigOverrides; @@ -376,6 +452,16 @@ function resolveConfig(opts: { overrides: opts.overrides, fileConfig: opts.fileConfig, }), + sessionDefaultsProfiles: resolveSessionDefaultsProfiles({ + overrides: opts.overrides, + fileConfig: opts.fileConfig, + }), + activeSessionDefaultsProfile: resolveFromLayers({ + key: 'activeSessionDefaultsProfile', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + }), }; } @@ -411,11 +497,7 @@ export async function initConfigStore(opts: { } storeState.fileConfig = fileConfig; - storeState.resolved = resolveConfig({ - fileConfig, - overrides: opts.overrides, - env: opts.env, - }); + storeState.resolved = resolveConfig({ fileConfig, overrides: opts.overrides, env: opts.env }); storeState.initialized = true; return { found, path, notices }; } @@ -431,6 +513,7 @@ export function getConfig(): ResolvedRuntimeConfig { export async function persistSessionDefaultsPatch(opts: { patch: Partial; deleteKeys?: (keyof SessionDefaults)[]; + profile?: string | null; }): Promise<{ path: string }> { if (!storeState.initialized || !storeState.fs || !storeState.cwd) { throw new Error('Config store has not been initialized.'); @@ -441,26 +524,38 @@ export async function persistSessionDefaultsPatch(opts: { cwd: storeState.cwd, patch: opts.patch, deleteKeys: opts.deleteKeys, + profile: opts.profile, }); - const nextSessionDefaults: Partial = { - ...(storeState.fileConfig?.sessionDefaults ?? {}), - ...opts.patch, - }; + storeState.fileConfig = applySessionDefaultsPatchToFileConfig({ + fileConfig: getCurrentFileConfig(), + profile: opts.profile ?? null, + patch: opts.patch, + deleteKeys: opts.deleteKeys, + }); + refreshResolvedConfig(); - for (const key of opts.deleteKeys ?? []) { - delete nextSessionDefaults[key]; + return result; +} + +export async function persistActiveSessionDefaultsProfile( + profile: string | null, +): Promise<{ path: string }> { + if (!storeState.initialized || !storeState.fs || !storeState.cwd) { + throw new Error('Config store has not been initialized.'); } - storeState.fileConfig = { - ...(storeState.fileConfig ?? { schemaVersion: 1 }), - sessionDefaults: nextSessionDefaults, - }; + const result = await persistActiveSessionDefaultsProfileToProjectConfig({ + fs: storeState.fs, + cwd: storeState.cwd, + profile, + }); - storeState.resolved = resolveConfig({ - fileConfig: storeState.fileConfig, - overrides: storeState.overrides, + storeState.fileConfig = applyActiveProfileToFileConfig({ + fileConfig: getCurrentFileConfig(), + profile, }); + refreshResolvedConfig(); return result; } diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index dd919ad5..e14997a8 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -13,6 +13,8 @@ const CONFIG_FILE = 'config.yaml'; export type ProjectConfig = RuntimeConfigFile & { schemaVersion: 1; sessionDefaults?: Partial; + sessionDefaultsProfiles?: Record>; + activeSessionDefaultsProfile?: string; enabledWorkflows?: string[]; debuggerBackend?: 'dap' | 'lldb-cli'; [key: string]: unknown; @@ -33,6 +35,18 @@ export type PersistSessionDefaultsOptions = { cwd: string; patch: Partial; deleteKeys?: (keyof SessionDefaults)[]; + profile?: string | null; +}; + +export type PersistActiveSessionDefaultsProfileOptions = { + fs: FileSystemExecutor; + cwd: string; + profile?: string | null; +}; + +type PersistenceTargetOptions = { + fs: FileSystemExecutor; + configPath: string; }; function getConfigDir(cwd: string): string { @@ -152,6 +166,27 @@ function resolveRelativeTopLevelPaths(config: ProjectConfig, cwd: string): Proje return resolved; } +function normalizeSessionDefaultsProfiles( + profiles: Record>, + cwd: string, +): { profiles: Record>; notices: string[] } { + const normalizedProfiles: Record> = {}; + const notices: string[] = []; + + for (const [profileName, defaults] of Object.entries(profiles)) { + const trimmedName = profileName.trim(); + if (trimmedName.length === 0) { + notices.push('Ignored sessionDefaultsProfiles entry with an empty profile name.'); + continue; + } + const normalized = normalizeMutualExclusivity(defaults); + notices.push(...normalized.notices.map((notice) => `[profile:${trimmedName}] ${notice}`)); + normalizedProfiles[trimmedName] = resolveRelativeSessionPaths(normalized.normalized, cwd); + } + + return { profiles: normalizedProfiles, notices }; +} + function normalizeDebuggerBackend(config: RuntimeConfigFile): ProjectConfig { if (config.debuggerBackend === 'lldb') { const normalized: RuntimeConfigFile = { ...config, debuggerBackend: 'lldb-cli' }; @@ -181,6 +216,27 @@ function parseProjectConfig(rawText: string): RuntimeConfigFile { return runtimeConfigFileSchema.parse(parsed) as RuntimeConfigFile; } +async function readBaseConfigForPersistence( + options: PersistenceTargetOptions, +): Promise { + if (!options.fs.existsSync(options.configPath)) { + return { schemaVersion: 1 }; + } + + try { + const rawText = await options.fs.readFile(options.configPath, 'utf8'); + const parsed = parseProjectConfig(rawText); + return { ...normalizeConfigForPersistence(parsed), schemaVersion: 1 }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log( + 'warning', + `Failed to read or parse project config at ${options.configPath}. Overwriting with new config. ${errorMessage}`, + ); + return { schemaVersion: 1 }; + } +} + export async function loadProjectConfig( options: LoadProjectConfigOptions, ): Promise { @@ -209,6 +265,15 @@ export async function loadProjectConfig( config = { ...config, sessionDefaults: resolved }; } + if (config.sessionDefaultsProfiles) { + const normalizedProfiles = normalizeSessionDefaultsProfiles( + config.sessionDefaultsProfiles, + options.cwd, + ); + notices.push(...normalizedProfiles.notices); + config = { ...config, sessionDefaultsProfiles: normalizedProfiles.profiles }; + } + config = resolveRelativeTopLevelPaths(config, options.cwd); return { found: true, path: configPath, config, notices }; @@ -224,39 +289,53 @@ export async function persistSessionDefaultsToProjectConfig( const configPath = getConfigPath(options.cwd); await options.fs.mkdir(configDir, { recursive: true }); - - let baseConfig: ProjectConfig = { schemaVersion: 1 }; - - if (options.fs.existsSync(configPath)) { - try { - const rawText = await options.fs.readFile(configPath, 'utf8'); - const parsed = parseProjectConfig(rawText); - baseConfig = { ...normalizeConfigForPersistence(parsed), schemaVersion: 1 }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log( - 'warning', - `Failed to read or parse project config at ${configPath}. Overwriting with new config. ${errorMessage}`, - ); - baseConfig = { schemaVersion: 1 }; - } - } + const baseConfig = await readBaseConfigForPersistence({ fs: options.fs, configPath }); const patch = removeUndefined(options.patch as Record); - const nextSessionDefaults: Partial = { - ...(baseConfig.sessionDefaults ?? {}), - ...patch, - }; - - for (const key of options.deleteKeys ?? []) { - delete nextSessionDefaults[key]; - } + const targetProfile = options.profile ?? null; + const isGlobalProfile = targetProfile === null; + const baseDefaults = isGlobalProfile + ? (baseConfig.sessionDefaults ?? {}) + : (baseConfig.sessionDefaultsProfiles?.[targetProfile] ?? {}); + const nextSessionDefaults: Partial = { ...baseDefaults, ...patch }; const nextConfig: ProjectConfig = { ...baseConfig, schemaVersion: 1, - sessionDefaults: nextSessionDefaults, }; + for (const key of options.deleteKeys ?? []) { + delete nextSessionDefaults[key]; + } + if (isGlobalProfile) { + nextConfig.sessionDefaults = nextSessionDefaults; + } else { + nextConfig.sessionDefaultsProfiles = { + ...(nextConfig.sessionDefaultsProfiles ?? {}), + [targetProfile]: nextSessionDefaults, + }; + } + + await options.fs.writeFile(configPath, stringifyYaml(nextConfig), 'utf8'); + + return { path: configPath }; +} + +export async function persistActiveSessionDefaultsProfileToProjectConfig( + options: PersistActiveSessionDefaultsProfileOptions, +): Promise<{ path: string }> { + const configDir = getConfigDir(options.cwd); + const configPath = getConfigPath(options.cwd); + + await options.fs.mkdir(configDir, { recursive: true }); + const baseConfig = await readBaseConfigForPersistence({ fs: options.fs, configPath }); + + const nextConfig: ProjectConfig = { ...baseConfig, schemaVersion: 1 }; + const activeProfile = options.profile ?? null; + if (activeProfile === null) { + delete nextConfig.activeSessionDefaultsProfile; + } else { + nextConfig.activeSessionDefaultsProfile = activeProfile; + } await options.fs.writeFile(configPath, stringifyYaml(nextConfig), 'utf8'); diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts index 7ca8902a..8900ac19 100644 --- a/src/utils/runtime-config-schema.ts +++ b/src/utils/runtime-config-schema.ts @@ -21,6 +21,8 @@ export const runtimeConfigFileSchema = z macosTemplateVersion: z.string().optional(), debuggerBackend: z.enum(['dap', 'lldb-cli', 'lldb']).optional(), sessionDefaults: sessionDefaultsSchema.optional(), + sessionDefaultsProfiles: z.record(z.string(), sessionDefaultsSchema).optional(), + activeSessionDefaultsProfile: z.string().optional(), }) .passthrough(); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 85ddf05c..0f39c1c3 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -23,38 +23,121 @@ export type SessionDefaults = { }; class SessionStore { - private defaults: SessionDefaults = {}; + private globalDefaults: SessionDefaults = {}; + private profiles: Record = {}; + private activeProfile: string | null = null; private revision = 0; + private getProfileLabel(profile: string | null): string { + return profile ?? 'global'; + } + + private clearAll(): void { + this.globalDefaults = {}; + this.profiles = {}; + this.activeProfile = null; + this.revision += 1; + log('info', '[Session] All defaults cleared'); + } + + private clearAllForProfile(profile: string | null): void { + if (profile === null) { + this.globalDefaults = {}; + this.revision += 1; + log('info', '[Session] All defaults cleared (global)'); + return; + } + + delete this.profiles[profile]; + this.revision += 1; + log('info', `[Session] All defaults cleared (${profile})`); + } + + private setDefaultsForResolvedProfile(profile: string | null, defaults: SessionDefaults): void { + if (profile === null) { + this.globalDefaults = defaults; + return; + } + this.profiles[profile] = defaults; + } + setDefaults(partial: Partial): void { - this.defaults = { ...this.defaults, ...partial }; + this.setDefaultsForProfile(this.activeProfile, partial); + } + + setDefaultsForProfile(profile: string | null, partial: Partial): void { + const previous = this.getAllForProfile(profile); + const next = { ...previous, ...partial }; + this.setDefaultsForResolvedProfile(profile, next); this.revision += 1; - log('info', `[Session] Defaults updated: ${Object.keys(partial).join(', ')}`); + const profileLabel = this.getProfileLabel(profile); + log('info', `[Session] Defaults updated (${profileLabel}): ${Object.keys(partial).join(', ')}`); } clear(keys?: (keyof SessionDefaults)[]): void { if (keys == null) { - this.defaults = {}; - this.revision += 1; - log('info', '[Session] All defaults cleared'); + this.clearAll(); return; } + + this.clearForProfile(this.activeProfile, keys); + } + + clearForProfile(profile: string | null, keys?: (keyof SessionDefaults)[]): void { + if (keys == null) { + this.clearAllForProfile(profile); + return; + } + if (keys.length === 0) { // No-op when an empty array is provided (e.g., empty UI selection) log('info', '[Session] No keys provided to clear; no changes made'); return; } - for (const k of keys) delete this.defaults[k]; + + const next = this.getAllForProfile(profile); + for (const k of keys) delete next[k]; + + this.setDefaultsForResolvedProfile(profile, next); this.revision += 1; - log('info', `[Session] Defaults cleared: ${keys.join(', ')}`); + const profileLabel = this.getProfileLabel(profile); + log('info', `[Session] Defaults cleared (${profileLabel}): ${keys.join(', ')}`); } get(key: K): SessionDefaults[K] { - return this.defaults[key]; + return this.getAll()[key]; } getAll(): SessionDefaults { - return { ...this.defaults }; + return this.getAllForProfile(this.activeProfile); + } + + getAllGlobal(): SessionDefaults { + return { ...this.globalDefaults }; + } + + getAllForProfile(profile: string | null): SessionDefaults { + if (profile === null) { + return { ...this.globalDefaults }; + } + return { ...(this.profiles[profile] ?? {}) }; + } + + listProfiles(): string[] { + return Object.keys(this.profiles).sort((a, b) => a.localeCompare(b)); + } + + getActiveProfile(): string | null { + return this.activeProfile; + } + + setActiveProfile(profile: string | null): void { + this.activeProfile = profile; + this.revision += 1; + if (profile != null && this.profiles[profile] == null) { + this.profiles[profile] = {}; + } + log('info', `[Session] Active defaults profile: ${profile ?? 'global'}`); } getRevision(): number { @@ -62,10 +145,18 @@ class SessionStore { } setDefaultsIfRevision(partial: Partial, expectedRevision: number): boolean { + return this.setDefaultsIfRevisionForProfile(this.activeProfile, partial, expectedRevision); + } + + setDefaultsIfRevisionForProfile( + profile: string | null, + partial: Partial, + expectedRevision: number, + ): boolean { if (this.revision !== expectedRevision) { return false; } - this.setDefaults(partial); + this.setDefaultsForProfile(profile, partial); return true; } } diff --git a/src/utils/simulator-defaults-refresh.ts b/src/utils/simulator-defaults-refresh.ts index e3d60b54..1aa8b02c 100644 --- a/src/utils/simulator-defaults-refresh.ts +++ b/src/utils/simulator-defaults-refresh.ts @@ -11,6 +11,7 @@ export interface ScheduleSimulatorDefaultsRefreshOptions { executor?: CommandExecutor; expectedRevision: number; reason: RefreshReason; + profile: string | null; persist?: boolean; simulatorId?: string; simulatorName?: string; @@ -72,7 +73,7 @@ async function refreshSimulatorDefaults( simulatorId, simulatorName, sessionDefaults: { - ...sessionStore.getAll(), + ...sessionStore.getAllForProfile(options.profile), ...patch, simulatorId, simulatorName, @@ -91,7 +92,11 @@ async function refreshSimulatorDefaults( return; } - const applied = sessionStore.setDefaultsIfRevision(patch, options.expectedRevision); + const applied = sessionStore.setDefaultsIfRevisionForProfile( + options.profile, + patch, + options.expectedRevision, + ); if (!applied) { log( 'info', @@ -101,7 +106,7 @@ async function refreshSimulatorDefaults( } if (options.persist) { - await persistSessionDefaultsPatch({ patch }); + await persistSessionDefaultsPatch({ patch, profile: options.profile }); } } catch (error) { log( diff --git a/src/utils/template-manager.ts b/src/utils/template-manager.ts index ab3b8495..55302ea8 100644 --- a/src/utils/template-manager.ts +++ b/src/utils/template-manager.ts @@ -67,7 +67,8 @@ export class TemplateManager { fileSystemExecutor: FileSystemExecutor, ): Promise { const repo = platform === 'iOS' ? this.IOS_TEMPLATE_REPO : this.MACOS_TEMPLATE_REPO; - const defaultVersion = platform === 'iOS' ? iOSTemplateVersion : macOSTemplateVersion; + const defaultVersion = + platform === 'iOS' ? String(iOSTemplateVersion) : String(macOSTemplateVersion); const config = getConfig(); const version = String( platform === 'iOS'