From 40f017c59884e01f3a7276cb72ceaa4879bff63a Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:22:38 +0100 Subject: [PATCH 1/8] Add shared expandHomePrefix utility Move the tilde-expansion logic that previously lived as a private helper inside src/cli/commands/init.ts into a shared src/utils/expand-home.ts module so it can be reused by other call sites that need the same behavior. The shared version handles a bare '~', '~/' on POSIX, and '~\\' for Windows-style separators. --- src/cli/commands/init.ts | 13 +-------- src/utils/__tests__/expand-home.test.ts | 38 +++++++++++++++++++++++++ src/utils/expand-home.ts | 18 ++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 src/utils/__tests__/expand-home.test.ts create mode 100644 src/utils/expand-home.ts diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 0779817f..14dce53c 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 { expandHomePrefix } from '../../utils/expand-home.ts'; type SkillType = 'mcp' | 'cli'; @@ -72,18 +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)); } diff --git a/src/utils/__tests__/expand-home.test.ts b/src/utils/__tests__/expand-home.test.ts new file mode 100644 index 00000000..b0c241e6 --- /dev/null +++ b/src/utils/__tests__/expand-home.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { homedir } from 'node:os'; +import { expandHomePrefix } from '../expand-home.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('expands a leading ~\\ on Windows-style separators', () => { + 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('returns an empty string unchanged', () => { + expect(expandHomePrefix('')).toBe(''); + }); +}); diff --git a/src/utils/expand-home.ts b/src/utils/expand-home.ts new file mode 100644 index 00000000..749dfe82 --- /dev/null +++ b/src/utils/expand-home.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; +import { homedir } from 'node:os'; + +/** + * Expand a leading ~ or ~/ (or ~\ on Windows) prefix to the user's home directory. + * Returns the path unchanged if it does not start with ~. + */ +export function expandHomePrefix(inputPath: string): string { + if (inputPath === '~') { + return homedir(); + } + + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { + return path.join(homedir(), inputPath.slice(2)); + } + + return inputPath; +} From 34bef5a91cda2a3684b1234b23814e117dee564c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:22:54 +0100 Subject: [PATCH 2/8] Expand ~ in resolveEffectiveDerivedDataPath resolveEffectiveDerivedDataPath() previously fed any non-absolute input to path.resolve(process.cwd(), ...), which caused configured derivedDataPath values like '~/.foo/derivedData' to materialize as a literal '~' directory under the current working directory. Run the input through expandHomePrefix() before the absolute/relative check so tilde-prefixed paths resolve under the user's home directory as users expect. Add unit coverage for the absolute, relative, default, and tilde-expansion code paths. --- src/utils/__tests__/derived-data-path.test.ts | 39 +++++++++++++++++++ src/utils/derived-data-path.ts | 8 ++-- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 src/utils/__tests__/derived-data-path.test.ts 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/derived-data-path.ts b/src/utils/derived-data-path.ts index 2b281a2c..a629a5ef 100644 --- a/src/utils/derived-data-path.ts +++ b/src/utils/derived-data-path.ts @@ -1,12 +1,14 @@ import * as path from 'node:path'; import { DERIVED_DATA_DIR } from './log-paths.ts'; +import { expandHomePrefix } from './expand-home.ts'; export function resolveEffectiveDerivedDataPath(input?: string): string { if (!input || input.trim().length === 0) { return DERIVED_DATA_DIR; } - if (path.isAbsolute(input)) { - return input; + const expanded = expandHomePrefix(input); + if (path.isAbsolute(expanded)) { + return expanded; } - return path.resolve(process.cwd(), input); + return path.resolve(process.cwd(), expanded); } From da13af67f6f97174b048b2567d40a3d14178deb8 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:23:07 +0100 Subject: [PATCH 3/8] Expand ~ in project-config path normalization normalizePathValue() resolved relative path values against cwd but left a literal '~' prefix in place, so derivedDataPath, projectPath, workspacePath, axePath, and the iOS/macOS template paths under both sessionDefaults and sessionDefaultsProfiles all leaked unexpanded tilde paths through to consumers. Apply expandHomePrefix() before the absolute/relative check inside normalizePathValue() so all callers (session defaults, profiles, and top-level path keys) benefit from a single expansion. Cover the behavior with unit tests for sessionDefaults, top-level path keys, and sessionDefaultsProfiles. --- src/utils/__tests__/project-config.test.ts | 57 ++++++++++++++++++++++ src/utils/project-config.ts | 9 ++-- 2 files changed, 63 insertions(+), 3 deletions(-) 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/project-config.ts b/src/utils/project-config.ts index 0a61c588..4246f945 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 { expandHomePrefix } from './expand-home.ts'; const CONFIG_DIR = '.xcodebuildmcp'; const CONFIG_FILE = 'config.yaml'; @@ -130,11 +131,13 @@ function normalizePathValue(value: string, cwd: string): string { return fileUrlPath; } - if (path.isAbsolute(value)) { - return value; + const expanded = expandHomePrefix(value); + + if (path.isAbsolute(expanded)) { + return expanded; } - return path.resolve(cwd, value); + return path.resolve(cwd, expanded); } function resolveRelativeSessionPaths( From 83c9f9c508225bf04a6570afc84c82a7657689dd Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:23:18 +0100 Subject: [PATCH 4/8] Expand ~ in build-utils path resolution executeXcodeBuildCommand() resolves projectPath, workspacePath, and the derivedDataPath argument before invoking xcodebuild. The resolvePathFromCwd() helper passed non-absolute values straight to path.resolve(process.cwd(), ...), so callers that supplied a tilde path (for example, direct API callers without project-config normalization) ended up with a literal '~' segment under cwd. Run the input through expandHomePrefix() inside resolvePathFromCwd() so tilde-prefixed projectPath and workspacePath resolve under the home directory. Add a unit test that verifies tilde expansion of projectPath and derivedDataPath flows through to the xcodebuild command line. --- src/utils/__tests__/build-utils.test.ts | 42 +++++++++++++++++++++++++ src/utils/build-utils.ts | 8 +++-- 2 files changed, 47 insertions(+), 3 deletions(-) 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/build-utils.ts b/src/utils/build-utils.ts index a7f54e0d..c743aed6 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 { expandHomePrefix } from './expand-home.ts'; import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; import { createNoticeFragment } from './xcodebuild-output.ts'; @@ -22,10 +23,11 @@ export interface BuildCommandResult { } function resolvePathFromCwd(pathValue: string): string { - if (path.isAbsolute(pathValue)) { - return pathValue; + const expanded = expandHomePrefix(pathValue); + if (path.isAbsolute(expanded)) { + return expanded; } - return path.resolve(process.cwd(), pathValue); + return path.resolve(process.cwd(), expanded); } function getDefaultSwiftPackageCachePath(): string { From f98824ab94269afd4facf1753083832fd23ca1a1 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 25 Apr 2026 23:23:43 +0100 Subject: [PATCH 5/8] Add changelog entry for tilde expansion in config paths --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b74603a..870a6b13 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 `~/` or `~\` on Windows) 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 ([#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 From 1535672d8b0bdffd414f54903806ad4b0d851f47 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 27 Apr 2026 09:44:21 +0100 Subject: [PATCH 6/8] refactor(utils): unify path resolution helpers in src/utils/path.ts Consolidate the duplicated expand-tilde + resolve-cwd path helpers into a single canonical pair (expandHomePrefix, resolvePathFromCwd) in src/utils/path.ts. The helper accepts an optional cwd argument so the project-config call site (which passes its own cwd) shares the same function as the default-cwd callers. Drives out the cursor-bugbot-flagged inconsistency in app-path-resolver.ts: its private resolvePathFromCwd was missing the expandHomePrefix step, so xcodebuild -showBuildSettings received literal '~' paths even when executeXcodeBuildCommand expanded them for the build step. Both copies now resolve to the shared helper, eliminating the class of defect. Migrations: - Remove src/utils/expand-home.ts (contents moved into path.ts). - build-utils.ts, app-path-resolver.ts: drop private resolvePathFromCwd, import shared helper. app-path-resolver call sites pre-check optional params (matches build-utils style) so no overloads are needed. - derived-data-path.ts: keep public resolveEffectiveDerivedDataPath, body simplified to fallback-then-delegate. - project-config.ts: keep normalizePathValue, body simplified to keep tryFileUrlToPath step then delegate. - init.ts: delete the 1-line resolveDestinationPath forwarder, call sites use resolvePathFromCwd directly. Tests: - src/utils/__tests__/path.test.ts covers expandHomePrefix and the new resolvePathFromCwd (default cwd, explicit cwd, tilde, absolute, relative, ~user passthrough). - src/utils/__tests__/app-path-resolver.test.ts asserts the captured xcodebuild command sees expanded projectPath/workspacePath, locking in parity with the build-utils end-to-end test. --- src/cli/commands/init.ts | 14 ++- src/utils/__tests__/app-path-resolver.test.ts | 88 +++++++++++++++++++ src/utils/__tests__/expand-home.test.ts | 38 -------- src/utils/__tests__/path.test.ts | 70 +++++++++++++++ src/utils/app-path-resolver.ts | 17 +--- src/utils/build-utils.ts | 10 +-- src/utils/derived-data-path.ts | 9 +- src/utils/expand-home.ts | 18 ---- src/utils/path.ts | 30 +++++++ src/utils/project-config.ts | 10 +-- 10 files changed, 201 insertions(+), 103 deletions(-) create mode 100644 src/utils/__tests__/app-path-resolver.test.ts delete mode 100644 src/utils/__tests__/expand-home.test.ts create mode 100644 src/utils/__tests__/path.test.ts delete mode 100644 src/utils/expand-home.ts create mode 100644 src/utils/path.ts diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 14dce53c..466d58fd 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -5,7 +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 { expandHomePrefix } from '../../utils/expand-home.ts'; +import { resolvePathFromCwd } from '../../utils/path.ts'; type SkillType = 'mcp' | 'cli'; @@ -73,10 +73,6 @@ function readSkillContent(skillType: SkillType): string { return fs.readFileSync(sourcePath, 'utf8'); } -function resolveDestinationPath(inputPath: string): string { - return path.resolve(expandHomePrefix(inputPath)); -} - async function promptConfirm(question: string): Promise { if (!isInteractiveTTY()) { return false; @@ -205,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.', @@ -350,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.', @@ -432,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.'; } @@ -445,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/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__/expand-home.test.ts b/src/utils/__tests__/expand-home.test.ts deleted file mode 100644 index b0c241e6..00000000 --- a/src/utils/__tests__/expand-home.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import path from 'node:path'; -import { homedir } from 'node:os'; -import { expandHomePrefix } from '../expand-home.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('expands a leading ~\\ on Windows-style separators', () => { - 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('returns an empty string unchanged', () => { - expect(expandHomePrefix('')).toBe(''); - }); -}); diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts new file mode 100644 index 00000000..d4a2a851 --- /dev/null +++ b/src/utils/__tests__/path.test.ts @@ -0,0 +1,70 @@ +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('expands a leading ~\\ on Windows-style separators', () => { + 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('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')); + }); +}); diff --git a/src/utils/app-path-resolver.ts b/src/utils/app-path-resolver.ts index 4a7a5504..424dc9eb 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) { @@ -57,8 +46,8 @@ export async function resolveAppPathFromBuildSettings( ): Promise { const command = ['xcodebuild', '-showBuildSettings']; - const workspacePath = resolvePathFromCwd(params.workspacePath); - const projectPath = resolvePathFromCwd(params.projectPath); + const workspacePath = params.workspacePath ? resolvePathFromCwd(params.workspacePath) : undefined; + const projectPath = params.projectPath ? resolvePathFromCwd(params.projectPath) : undefined; const derivedDataPath = resolveEffectiveDerivedDataPath(params.derivedDataPath); let projectDir: string | undefined; diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts index c743aed6..75e9dda4 100644 --- a/src/utils/build-utils.ts +++ b/src/utils/build-utils.ts @@ -13,7 +13,7 @@ import { import path from 'path'; import os from 'node:os'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; -import { expandHomePrefix } from './expand-home.ts'; +import { resolvePathFromCwd } from './path.ts'; import type { XcodebuildPipeline } from './xcodebuild-pipeline.ts'; import { createNoticeFragment } from './xcodebuild-output.ts'; @@ -22,14 +22,6 @@ export interface BuildCommandResult { isError?: boolean; } -function resolvePathFromCwd(pathValue: string): string { - const expanded = expandHomePrefix(pathValue); - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(process.cwd(), expanded); -} - 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 a629a5ef..b0c21034 100644 --- a/src/utils/derived-data-path.ts +++ b/src/utils/derived-data-path.ts @@ -1,14 +1,9 @@ -import * as path from 'node:path'; import { DERIVED_DATA_DIR } from './log-paths.ts'; -import { expandHomePrefix } from './expand-home.ts'; +import { resolvePathFromCwd } from './path.ts'; export function resolveEffectiveDerivedDataPath(input?: string): string { if (!input || input.trim().length === 0) { return DERIVED_DATA_DIR; } - const expanded = expandHomePrefix(input); - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(process.cwd(), expanded); + return resolvePathFromCwd(input); } diff --git a/src/utils/expand-home.ts b/src/utils/expand-home.ts deleted file mode 100644 index 749dfe82..00000000 --- a/src/utils/expand-home.ts +++ /dev/null @@ -1,18 +0,0 @@ -import path from 'node:path'; -import { homedir } from 'node:os'; - -/** - * Expand a leading ~ or ~/ (or ~\ on Windows) prefix to the user's home directory. - * Returns the path unchanged if it does not start with ~. - */ -export function expandHomePrefix(inputPath: string): string { - if (inputPath === '~') { - return homedir(); - } - - if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { - return path.join(homedir(), inputPath.slice(2)); - } - - return inputPath; -} diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 00000000..4c8bb885 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { homedir } from 'node:os'; + +/** + * Expand a leading ~ or ~/ (or ~\ on Windows) prefix to the user's home directory. + * Returns the path unchanged if it does not start with ~ or starts with ~userName. + */ +export function expandHomePrefix(inputPath: string): string { + if (inputPath === '~') { + return homedir(); + } + + if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { + return path.join(homedir(), inputPath.slice(2)); + } + + return inputPath; +} + +/** + * Resolve a user-supplied path: expand ~ then return as-is when absolute, + * otherwise resolve against `cwd` (defaults to process.cwd()). + */ +export function resolvePathFromCwd(pathValue: string, cwd: string = process.cwd()): string { + const expanded = expandHomePrefix(pathValue); + if (path.isAbsolute(expanded)) { + return expanded; + } + return path.resolve(cwd, expanded); +} diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index 4246f945..e8526fda 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -7,7 +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 { expandHomePrefix } from './expand-home.ts'; +import { resolvePathFromCwd } from './path.ts'; const CONFIG_DIR = '.xcodebuildmcp'; const CONFIG_FILE = 'config.yaml'; @@ -131,13 +131,7 @@ function normalizePathValue(value: string, cwd: string): string { return fileUrlPath; } - const expanded = expandHomePrefix(value); - - if (path.isAbsolute(expanded)) { - return expanded; - } - - return path.resolve(cwd, expanded); + return resolvePathFromCwd(value, cwd); } function resolveRelativeSessionPaths( From c83368affd8a8cf3f22387d7512f1e1f26678d80 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 27 Apr 2026 09:58:21 +0100 Subject: [PATCH 7/8] fix(path): normalize traversal segments in resolvePathFromCwd The unification commit (1535672d) replaced init.ts's resolveDestinationPath wrapper, which did `path.resolve(expandHomePrefix(input))`. The shared helper kept absolute inputs verbatim, dropping the implicit normalization step that path.resolve() with one argument provides. That regressed init.ts's filesystem-root guard (`resolvedDest === path.parse(resolvedDest).root`), which only matches exact-root strings. Inputs like `--dest /foo/..` or `--dest /..` resolved to themselves, bypassing the guard, even though the kernel-level path collapses to `/`. Restore normalization at the helper layer by delegating unconditionally to `path.resolve(cwd, expanded)`. `path.resolve(cwd, X)` already produces the correct result for both absolute and relative `X`, and always normalizes, so the absolute/relative branch is redundant. The four other migrated call sites (build-utils, app-path-resolver, derived-data-path, project-config) gain a benign normalization improvement; only init.ts's guards depended on it for correctness. Tests added cover `/foo/..` -> `/` and `/a/b/../c` -> `/a/c`. --- src/utils/__tests__/path.test.ts | 8 ++++++++ src/utils/path.ts | 11 ++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts index d4a2a851..eaed67bd 100644 --- a/src/utils/__tests__/path.test.ts +++ b/src/utils/__tests__/path.test.ts @@ -67,4 +67,12 @@ describe('resolvePathFromCwd', () => { 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'); + }); }); diff --git a/src/utils/path.ts b/src/utils/path.ts index 4c8bb885..1a94234d 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -18,13 +18,10 @@ export function expandHomePrefix(inputPath: string): string { } /** - * Resolve a user-supplied path: expand ~ then return as-is when absolute, - * otherwise resolve against `cwd` (defaults to process.cwd()). + * 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 `/`. */ export function resolvePathFromCwd(pathValue: string, cwd: string = process.cwd()): string { - const expanded = expandHomePrefix(pathValue); - if (path.isAbsolute(expanded)) { - return expanded; - } - return path.resolve(cwd, expanded); + return path.resolve(cwd, expandHomePrefix(pathValue)); } From 8f66e41b6db7c2e87f8eb47c0f7165415a975095 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 27 Apr 2026 10:33:26 +0100 Subject: [PATCH 8/8] refactor(path): drop Windows tilde branch and consolidate helpers - Remove dead `~\` expansion branch and its test; only POSIX is supported. - Add overload so resolvePathFromCwd accepts string | undefined, dropping the ternary at app-path-resolver call sites. - Replace duplicate tilde handling in snapshot-tests/output-parsers with the shared expandHomePrefix helper. - Add defensive empty-input guard and clarified doc comments to expandHomePrefix; document that ~userName prefixes are not expanded. - Cover whitespace, multi-byte, and undefined cases in path.test.ts. - Note absolute-path normalization in the CHANGELOG fix entry. --- CHANGELOG.md | 2 +- src/snapshot-tests/output-parsers.ts | 7 ++----- src/utils/__tests__/path.test.ts | 16 +++++++++++---- src/utils/app-path-resolver.ts | 4 ++-- src/utils/path.ts | 29 +++++++++++++++++++++------- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 870a6b13..11b94ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### Fixed -- Expanded leading `~` (and `~/` or `~\` on Windows) 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 ([#283](https://github.com/getsentry/XcodeBuildMCP/issues/283), supersedes [#301](https://github.com/getsentry/XcodeBuildMCP/pull/301) by [@trmquang93](https://github.com/trmquang93)). +- 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] 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__/path.test.ts b/src/utils/__tests__/path.test.ts index eaed67bd..6a000d76 100644 --- a/src/utils/__tests__/path.test.ts +++ b/src/utils/__tests__/path.test.ts @@ -12,10 +12,6 @@ describe('expandHomePrefix', () => { expect(expandHomePrefix('~/foo/bar')).toBe(path.join(homedir(), 'foo/bar')); }); - it('expands a leading ~\\ on Windows-style separators', () => { - expect(expandHomePrefix('~\\foo\\bar')).toBe(path.join(homedir(), 'foo\\bar')); - }); - it('returns absolute paths unchanged', () => { expect(expandHomePrefix('/absolute/path')).toBe('/absolute/path'); }); @@ -32,6 +28,14 @@ describe('expandHomePrefix', () => { 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(''); }); @@ -75,4 +79,8 @@ describe('resolvePathFromCwd', () => { 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/app-path-resolver.ts b/src/utils/app-path-resolver.ts index 424dc9eb..69eff5a8 100644 --- a/src/utils/app-path-resolver.ts +++ b/src/utils/app-path-resolver.ts @@ -46,8 +46,8 @@ export async function resolveAppPathFromBuildSettings( ): Promise { const command = ['xcodebuild', '-showBuildSettings']; - const workspacePath = params.workspacePath ? resolvePathFromCwd(params.workspacePath) : undefined; - const projectPath = params.projectPath ? resolvePathFromCwd(params.projectPath) : undefined; + const workspacePath = resolvePathFromCwd(params.workspacePath); + const projectPath = resolvePathFromCwd(params.projectPath); const derivedDataPath = resolveEffectiveDerivedDataPath(params.derivedDataPath); let projectDir: string | undefined; diff --git a/src/utils/path.ts b/src/utils/path.ts index 1a94234d..10f54649 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -2,15 +2,21 @@ import path from 'node:path'; import { homedir } from 'node:os'; /** - * Expand a leading ~ or ~/ (or ~\ on Windows) prefix to the user's home directory. - * Returns the path unchanged if it does not start with ~ or starts with ~userName. + * 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('~/') || inputPath.startsWith('~\\')) { + if (inputPath.startsWith('~/')) { return path.join(homedir(), inputPath.slice(2)); } @@ -18,10 +24,19 @@ export function expandHomePrefix(inputPath: string): string { } /** - * 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 `/`. + * 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 = process.cwd()): string { +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)); }