diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b74603a..11b94ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ - When a single platform is selected, `xcodebuildmcp setup` now writes `platform` to `sessionDefaults` in `config.yaml` and includes `XCODEBUILDMCP_PLATFORM` in `--format mcp-json` output. For multi-platform projects the platform key is omitted so the agent can choose per-command ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). - The `setup` wizard remembers previous choices on re-run: existing `config.yaml` values (including the new `platform`) are pre-loaded as defaults for every prompt ([#281](https://github.com/getsentry/XcodeBuildMCP/pull/281) by [@detailobsessed](https://github.com/detailobsessed)). +### Fixed + +- Expanded leading `~` and `~/` prefixes in configured `derivedDataPath`, `projectPath`, `workspacePath`, `axePath`, and the iOS/macOS template paths so values like `~/.foo/derivedData` resolve under the user's home directory instead of creating a literal `~` directory under the project root. As part of this change, configured absolute paths are now lexically normalized (e.g. `/a/b/../c` collapses to `/a/c`) before being passed to `xcodebuild` ([#283](https://github.com/getsentry/XcodeBuildMCP/issues/283), supersedes [#301](https://github.com/getsentry/XcodeBuildMCP/pull/301) by [@trmquang93](https://github.com/trmquang93)). + ## [2.3.2] ### Fixed diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 0779817f..466d58fd 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -5,6 +5,7 @@ import * as os from 'node:os'; import * as clack from '@clack/prompts'; import { getResourceRoot } from '../../core/resource-root.ts'; import { createPrompter, isInteractiveTTY, type Prompter } from '../interactive/prompts.ts'; +import { resolvePathFromCwd } from '../../utils/path.ts'; type SkillType = 'mcp' | 'cli'; @@ -72,22 +73,6 @@ function readSkillContent(skillType: SkillType): string { return fs.readFileSync(sourcePath, 'utf8'); } -function expandHomePrefix(inputPath: string): string { - if (inputPath === '~') { - return os.homedir(); - } - - if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { - return path.join(os.homedir(), inputPath.slice(2)); - } - - return inputPath; -} - -function resolveDestinationPath(inputPath: string): string { - return path.resolve(expandHomePrefix(inputPath)); -} - async function promptConfirm(question: string): Promise { if (!isInteractiveTTY()) { return false; @@ -216,7 +201,7 @@ function resolveTargets( operation: 'install' | 'uninstall', ): ClientInfo[] { if (destFlag) { - const resolvedDest = resolveDestinationPath(destFlag); + const resolvedDest = resolvePathFromCwd(destFlag); if (resolvedDest === path.parse(resolvedDest).root) { throw new Error( 'Refusing to use filesystem root as skills destination. Use a dedicated directory.', @@ -361,7 +346,7 @@ async function collectInitSelection( } if (destProvided) { - const resolvedDest = resolveDestinationPath(argv.dest!); + const resolvedDest = resolvePathFromCwd(argv.dest!); if (resolvedDest === path.parse(resolvedDest).root) { throw new Error( 'Refusing to use filesystem root as skills destination. Use a dedicated directory.', @@ -443,7 +428,7 @@ async function promptCustomPath(): Promise { message: 'Enter the destination directory path:', validate: (value: string | undefined) => { if (!value?.trim()) return 'Path cannot be empty.'; - const resolved = resolveDestinationPath(value); + const resolved = resolvePathFromCwd(value); if (resolved === path.parse(resolved).root) { return 'Refusing to use filesystem root. Use a dedicated directory.'; } @@ -456,7 +441,7 @@ async function promptCustomPath(): Promise { throw new Error('Operation cancelled.'); } - return resolveDestinationPath(result as string); + return resolvePathFromCwd(result as string); } export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): void { diff --git a/src/snapshot-tests/output-parsers.ts b/src/snapshot-tests/output-parsers.ts index 94a00390..e03a6875 100644 --- a/src/snapshot-tests/output-parsers.ts +++ b/src/snapshot-tests/output-parsers.ts @@ -1,4 +1,4 @@ -import os from 'node:os'; +import { expandHomePrefix } from '../utils/path.ts'; export interface SnapshotSimulatorEntry { name: string; @@ -7,10 +7,7 @@ export interface SnapshotSimulatorEntry { } export function expandSnapshotPath(pathValue: string): string { - if (pathValue.startsWith('~/')) { - return `${os.homedir()}${pathValue.slice(1)}`; - } - return pathValue; + return expandHomePrefix(pathValue); } export function extractAppPathFromSnapshotOutput(output: string): string { diff --git a/src/utils/__tests__/app-path-resolver.test.ts b/src/utils/__tests__/app-path-resolver.test.ts new file mode 100644 index 00000000..94aabf49 --- /dev/null +++ b/src/utils/__tests__/app-path-resolver.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { homedir } from 'node:os'; +import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { resolveAppPathFromBuildSettings } from '../app-path-resolver.ts'; +import { XcodePlatform } from '../../types/common.ts'; + +describe('resolveAppPathFromBuildSettings', () => { + it('expands tilde-prefixed projectPath when invoking xcodebuild', async () => { + let capturedCommand: string[] | undefined; + + const mockExecutor = createMockExecutor({ + success: true, + output: + 'BUILT_PRODUCTS_DIR = /Build/Products/Debug-iphonesimulator\nFULL_PRODUCT_NAME = App.app\n', + exitCode: 0, + onExecute: (command) => { + capturedCommand = command; + }, + }); + + await resolveAppPathFromBuildSettings( + { + projectPath: '~/Code/App.xcodeproj', + scheme: 'App', + platform: XcodePlatform.iOSSimulator, + }, + mockExecutor, + ); + + const expected = path.join(homedir(), 'Code/App.xcodeproj'); + expect(capturedCommand).toBeDefined(); + expect(capturedCommand).toContain(expected); + expect(capturedCommand).not.toContain('~/Code/App.xcodeproj'); + }); + + it('expands tilde-prefixed workspacePath when invoking xcodebuild', async () => { + let capturedCommand: string[] | undefined; + + const mockExecutor = createMockExecutor({ + success: true, + output: + 'BUILT_PRODUCTS_DIR = /Build/Products/Debug-iphonesimulator\nFULL_PRODUCT_NAME = App.app\n', + exitCode: 0, + onExecute: (command) => { + capturedCommand = command; + }, + }); + + await resolveAppPathFromBuildSettings( + { + workspacePath: '~/Code/App.xcworkspace', + scheme: 'App', + platform: XcodePlatform.iOSSimulator, + }, + mockExecutor, + ); + + const expected = path.join(homedir(), 'Code/App.xcworkspace'); + expect(capturedCommand).toBeDefined(); + expect(capturedCommand).toContain(expected); + }); + + it('leaves absolute paths unchanged', async () => { + let capturedCommand: string[] | undefined; + + const mockExecutor = createMockExecutor({ + success: true, + output: + 'BUILT_PRODUCTS_DIR = /Build/Products/Debug-iphonesimulator\nFULL_PRODUCT_NAME = App.app\n', + exitCode: 0, + onExecute: (command) => { + capturedCommand = command; + }, + }); + + await resolveAppPathFromBuildSettings( + { + projectPath: '/abs/path/App.xcodeproj', + scheme: 'App', + platform: XcodePlatform.iOSSimulator, + }, + mockExecutor, + ); + + expect(capturedCommand).toContain('/abs/path/App.xcodeproj'); + }); +}); diff --git a/src/utils/__tests__/build-utils.test.ts b/src/utils/__tests__/build-utils.test.ts index 4aa424fd..7799523f 100644 --- a/src/utils/__tests__/build-utils.test.ts +++ b/src/utils/__tests__/build-utils.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import path from 'node:path'; +import { homedir } from 'node:os'; import { createMockExecutor } from '../../test-utils/mock-executors.ts'; import { executeXcodeBuildCommand } from '../build-utils.ts'; import { XcodePlatform } from '../xcode.ts'; @@ -477,5 +478,46 @@ describe('build-utils Sentry Classification', () => { expect.objectContaining({ cwd: path.dirname(expectedProjectPath) }), ); }); + + it('should expand ~ in projectPath and derivedDataPath before execution', async () => { + let capturedCommand: string[] | undefined; + const mockExecutor = createMockExecutor({ + success: true, + output: 'BUILD SUCCEEDED', + exitCode: 0, + onExecute: (command) => { + capturedCommand = command; + }, + }); + + const tildeProjectPath = '~/Code/App.xcodeproj'; + const tildeDerivedDataPath = '~/.foo/derivedData'; + const expectedProjectPath = path.join(homedir(), 'Code/App.xcodeproj'); + const expectedDerivedDataPath = path.join(homedir(), '.foo/derivedData'); + + await executeXcodeBuildCommand( + { + scheme: 'TestScheme', + configuration: 'Debug', + projectPath: tildeProjectPath, + derivedDataPath: tildeDerivedDataPath, + }, + { + platform: XcodePlatform.iOSSimulator, + simulatorName: 'iPhone 17 Pro', + useLatestOS: true, + logPrefix: 'iOS Simulator Build', + }, + false, + 'build', + mockExecutor, + undefined, + createMockPipeline(), + ); + + expect(capturedCommand).toBeDefined(); + expect(capturedCommand).toContain(expectedProjectPath); + expect(capturedCommand).toContain(expectedDerivedDataPath); + }); }); }); diff --git a/src/utils/__tests__/derived-data-path.test.ts b/src/utils/__tests__/derived-data-path.test.ts new file mode 100644 index 00000000..0bd41d1d --- /dev/null +++ b/src/utils/__tests__/derived-data-path.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { homedir } from 'node:os'; +import { resolveEffectiveDerivedDataPath } from '../derived-data-path.ts'; +import { DERIVED_DATA_DIR } from '../log-paths.ts'; + +describe('resolveEffectiveDerivedDataPath', () => { + it('returns the default derived data dir when input is undefined', () => { + expect(resolveEffectiveDerivedDataPath(undefined)).toBe(DERIVED_DATA_DIR); + }); + + it('returns the default derived data dir when input is empty', () => { + expect(resolveEffectiveDerivedDataPath('')).toBe(DERIVED_DATA_DIR); + }); + + it('returns the default derived data dir when input is whitespace', () => { + expect(resolveEffectiveDerivedDataPath(' ')).toBe(DERIVED_DATA_DIR); + }); + + it('returns absolute paths unchanged', () => { + expect(resolveEffectiveDerivedDataPath('/abs/path/dd')).toBe('/abs/path/dd'); + }); + + it('resolves relative paths against the current working directory', () => { + expect(resolveEffectiveDerivedDataPath('.derivedData/e2e')).toBe( + path.resolve(process.cwd(), '.derivedData/e2e'), + ); + }); + + it('expands a bare ~ input to the home directory', () => { + expect(resolveEffectiveDerivedDataPath('~')).toBe(homedir()); + }); + + it('expands a ~/-prefixed input under the home directory', () => { + expect(resolveEffectiveDerivedDataPath('~/.foo/derivedData')).toBe( + path.join(homedir(), '.foo/derivedData'), + ); + }); +}); diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts new file mode 100644 index 00000000..6a000d76 --- /dev/null +++ b/src/utils/__tests__/path.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { homedir } from 'node:os'; +import { expandHomePrefix, resolvePathFromCwd } from '../path.ts'; + +describe('expandHomePrefix', () => { + it('expands a bare ~ to the home directory', () => { + expect(expandHomePrefix('~')).toBe(homedir()); + }); + + it('expands a leading ~/ to the home directory', () => { + expect(expandHomePrefix('~/foo/bar')).toBe(path.join(homedir(), 'foo/bar')); + }); + + it('returns absolute paths unchanged', () => { + expect(expandHomePrefix('/absolute/path')).toBe('/absolute/path'); + }); + + it('returns relative paths unchanged', () => { + expect(expandHomePrefix('relative/path')).toBe('relative/path'); + }); + + it('does not expand ~user style prefixes', () => { + expect(expandHomePrefix('~other/foo')).toBe('~other/foo'); + }); + + it('does not expand ~ embedded later in the path', () => { + expect(expandHomePrefix('foo/~/bar')).toBe('foo/~/bar'); + }); + + it('does not expand a leading ~ followed by whitespace', () => { + expect(expandHomePrefix(' ~/foo')).toBe(' ~/foo'); + }); + + it('preserves multi-byte characters in the expanded segment', () => { + expect(expandHomePrefix('~/日本語/файл')).toBe(path.join(homedir(), '日本語/файл')); + }); + + it('returns an empty string unchanged', () => { + expect(expandHomePrefix('')).toBe(''); + }); +}); + +describe('resolvePathFromCwd', () => { + it('expands a bare ~ to the home directory', () => { + expect(resolvePathFromCwd('~')).toBe(homedir()); + }); + + it('expands a leading ~/ under the home directory', () => { + expect(resolvePathFromCwd('~/.foo/derivedData')).toBe(path.join(homedir(), '.foo/derivedData')); + }); + + it('returns absolute paths unchanged', () => { + expect(resolvePathFromCwd('/abs/path')).toBe('/abs/path'); + }); + + it('resolves relative paths against process.cwd() by default', () => { + expect(resolvePathFromCwd('rel/path')).toBe(path.resolve(process.cwd(), 'rel/path')); + }); + + it('resolves relative paths against an explicit cwd when provided', () => { + expect(resolvePathFromCwd('rel/path', '/some/base')).toBe( + path.resolve('/some/base', 'rel/path'), + ); + }); + + it('does not resolve absolute paths against an explicit cwd', () => { + expect(resolvePathFromCwd('/abs/path', '/some/base')).toBe('/abs/path'); + }); + + it('does not expand ~user style prefixes', () => { + expect(resolvePathFromCwd('~other/foo')).toBe(path.resolve(process.cwd(), '~other/foo')); + }); + + it('normalizes traversal segments in absolute paths', () => { + expect(resolvePathFromCwd('/foo/..')).toBe('/'); + }); + + it('normalizes interior traversal segments in absolute paths', () => { + expect(resolvePathFromCwd('/a/b/../c')).toBe('/a/c'); + }); + + it('returns undefined when pathValue is undefined', () => { + expect(resolvePathFromCwd(undefined)).toBeUndefined(); + }); +}); diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index 31c23e8d..fb2e3a57 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import path from 'node:path'; +import { homedir } from 'node:os'; import { parse as parseYaml } from 'yaml'; import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; import { @@ -159,6 +160,62 @@ describe('project-config', () => { expect(defaults.derivedDataPath).toBe('/repo/.derivedData'); }); + it('should expand ~ prefixes in session defaults paths', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' projectPath: "~/Code/App.xcodeproj"', + ' derivedDataPath: "~/.foo/derivedData"', + '', + ].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'); + + const defaults = result.config.sessionDefaults ?? {}; + expect(defaults.projectPath).toBe(path.join(homedir(), 'Code/App.xcodeproj')); + expect(defaults.derivedDataPath).toBe(path.join(homedir(), '.foo/derivedData')); + }); + + it('should expand ~ prefixes in top-level path keys', async () => { + const yaml = [ + 'schemaVersion: 1', + 'axePath: "~/tools/axe"', + 'iosTemplatePath: "~/templates/ios"', + '', + ].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.axePath).toBe(path.join(homedir(), 'tools/axe')); + expect(result.config.iosTemplatePath).toBe(path.join(homedir(), 'templates/ios')); + }); + + it('should expand ~ prefixes in session defaults profiles', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaultsProfiles:', + ' ios:', + ' workspacePath: "~/Code/App.xcworkspace"', + ' derivedDataPath: "~/.cache/dd"', + '', + ].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.sessionDefaultsProfiles?.ios?.workspacePath).toBe( + path.join(homedir(), 'Code/App.xcworkspace'), + ); + expect(result.config.sessionDefaultsProfiles?.ios?.derivedDataPath).toBe( + path.join(homedir(), '.cache/dd'), + ); + }); + it('normalizes namespaced session defaults profiles and active profile', async () => { const yaml = [ 'schemaVersion: 1', diff --git a/src/utils/app-path-resolver.ts b/src/utils/app-path-resolver.ts index 4a7a5504..69eff5a8 100644 --- a/src/utils/app-path-resolver.ts +++ b/src/utils/app-path-resolver.ts @@ -2,18 +2,7 @@ import path from 'node:path'; import type { XcodePlatform } from '../types/common.ts'; import type { CommandExecutor } from './command.ts'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; - -function resolvePathFromCwd(pathValue?: string): string | undefined { - if (!pathValue) { - return undefined; - } - - if (path.isAbsolute(pathValue)) { - return pathValue; - } - - return path.resolve(process.cwd(), pathValue); -} +import { resolvePathFromCwd } from './path.ts'; export function getBuildSettingsDestination(platform: XcodePlatform, deviceId?: string): string { if (deviceId) { diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index a7f54e0d..75e9dda4 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -13,6 +13,7 @@ import { import path from 'path'; import os from 'node:os'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; +import { resolvePathFromCwd } from './path.ts'; import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; import { createNoticeFragment } from './xcodebuild-output.ts'; @@ -21,13 +22,6 @@ export interface BuildCommandResult { isError?: boolean; } -function resolvePathFromCwd(pathValue: string): string { - if (path.isAbsolute(pathValue)) { - return pathValue; - } - return path.resolve(process.cwd(), pathValue); -} - function getDefaultSwiftPackageCachePath(): string { return path.join(os.homedir(), 'Library', 'Caches', 'org.swift.swiftpm'); } diff --git a/src/utils/derived-data-path.ts b/src/utils/derived-data-path.ts index 2b281a2c..b0c21034 100644 --- a/src/utils/derived-data-path.ts +++ b/src/utils/derived-data-path.ts @@ -1,12 +1,9 @@ -import * as path from 'node:path'; import { DERIVED_DATA_DIR } from './log-paths.ts'; +import { resolvePathFromCwd } from './path.ts'; export function resolveEffectiveDerivedDataPath(input?: string): string { if (!input || input.trim().length === 0) { return DERIVED_DATA_DIR; } - if (path.isAbsolute(input)) { - return input; - } - return path.resolve(process.cwd(), input); + return resolvePathFromCwd(input); } diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 00000000..10f54649 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; +import { homedir } from 'node:os'; + +/** + * Expand a leading `~` or `~/` prefix to the user's home directory. + * Returns the path unchanged if it does not begin with `~` or `~/`. + * Shell-style `~userName` prefixes (e.g. `~bob/foo`) are not expanded + * and will be treated as literal path segments by `resolvePathFromCwd`. + */ +export function expandHomePrefix(inputPath: string): string { + if (!inputPath) { + return inputPath; + } + + if (inputPath === '~') { + return homedir(); + } + + if (inputPath.startsWith('~/')) { + return path.join(homedir(), inputPath.slice(2)); + } + + return inputPath; +} + +/** + * Resolve a user-supplied path: expand `~` then resolve against `cwd` + * (defaults to `process.cwd()`). Always returns a normalized absolute path — + * traversal segments like `/foo/..` collapse to `/`. Returns `undefined` + * when `pathValue` is `undefined`. + */ +export function resolvePathFromCwd(pathValue: string, cwd?: string): string; +export function resolvePathFromCwd(pathValue: string | undefined, cwd?: string): string | undefined; +export function resolvePathFromCwd( + pathValue: string | undefined, + cwd: string = process.cwd(), +): string | undefined { + if (pathValue === undefined) { + return undefined; + } + return path.resolve(cwd, expandHomePrefix(pathValue)); +} diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index 0a61c588..e8526fda 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -7,6 +7,7 @@ import { log } from './logger.ts'; import { removeUndefined } from './remove-undefined.ts'; import { runtimeConfigFileSchema, type RuntimeConfigFile } from './runtime-config-schema.ts'; import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.ts'; +import { resolvePathFromCwd } from './path.ts'; const CONFIG_DIR = '.xcodebuildmcp'; const CONFIG_FILE = 'config.yaml'; @@ -130,11 +131,7 @@ function normalizePathValue(value: string, cwd: string): string { return fileUrlPath; } - if (path.isAbsolute(value)) { - return value; - } - - return path.resolve(cwd, value); + return resolvePathFromCwd(value, cwd); } function resolveRelativeSessionPaths(