Skip to content

Commit 77c206a

Browse files
authored
fix(config): expand leading ~ in config paths (supersedes #301) (#370)
1 parent 13bb282 commit 77c206a

13 files changed

Lines changed: 371 additions & 54 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
- 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)).
1414
- 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)).
1515

16+
### Fixed
17+
18+
- 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)).
19+
1620
## [2.3.2]
1721

1822
### Fixed

src/cli/commands/init.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as os from 'node:os';
55
import * as clack from '@clack/prompts';
66
import { getResourceRoot } from '../../core/resource-root.ts';
77
import { createPrompter, isInteractiveTTY, type Prompter } from '../interactive/prompts.ts';
8+
import { resolvePathFromCwd } from '../../utils/path.ts';
89

910
type SkillType = 'mcp' | 'cli';
1011

@@ -72,22 +73,6 @@ function readSkillContent(skillType: SkillType): string {
7273
return fs.readFileSync(sourcePath, 'utf8');
7374
}
7475

75-
function expandHomePrefix(inputPath: string): string {
76-
if (inputPath === '~') {
77-
return os.homedir();
78-
}
79-
80-
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
81-
return path.join(os.homedir(), inputPath.slice(2));
82-
}
83-
84-
return inputPath;
85-
}
86-
87-
function resolveDestinationPath(inputPath: string): string {
88-
return path.resolve(expandHomePrefix(inputPath));
89-
}
90-
9176
async function promptConfirm(question: string): Promise<boolean> {
9277
if (!isInteractiveTTY()) {
9378
return false;
@@ -216,7 +201,7 @@ function resolveTargets(
216201
operation: 'install' | 'uninstall',
217202
): ClientInfo[] {
218203
if (destFlag) {
219-
const resolvedDest = resolveDestinationPath(destFlag);
204+
const resolvedDest = resolvePathFromCwd(destFlag);
220205
if (resolvedDest === path.parse(resolvedDest).root) {
221206
throw new Error(
222207
'Refusing to use filesystem root as skills destination. Use a dedicated directory.',
@@ -361,7 +346,7 @@ async function collectInitSelection(
361346
}
362347

363348
if (destProvided) {
364-
const resolvedDest = resolveDestinationPath(argv.dest!);
349+
const resolvedDest = resolvePathFromCwd(argv.dest!);
365350
if (resolvedDest === path.parse(resolvedDest).root) {
366351
throw new Error(
367352
'Refusing to use filesystem root as skills destination. Use a dedicated directory.',
@@ -443,7 +428,7 @@ async function promptCustomPath(): Promise<string> {
443428
message: 'Enter the destination directory path:',
444429
validate: (value: string | undefined) => {
445430
if (!value?.trim()) return 'Path cannot be empty.';
446-
const resolved = resolveDestinationPath(value);
431+
const resolved = resolvePathFromCwd(value);
447432
if (resolved === path.parse(resolved).root) {
448433
return 'Refusing to use filesystem root. Use a dedicated directory.';
449434
}
@@ -456,7 +441,7 @@ async function promptCustomPath(): Promise<string> {
456441
throw new Error('Operation cancelled.');
457442
}
458443

459-
return resolveDestinationPath(result as string);
444+
return resolvePathFromCwd(result as string);
460445
}
461446

462447
export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): void {

src/snapshot-tests/output-parsers.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import os from 'node:os';
1+
import { expandHomePrefix } from '../utils/path.ts';
22

33
export interface SnapshotSimulatorEntry {
44
name: string;
@@ -7,10 +7,7 @@ export interface SnapshotSimulatorEntry {
77
}
88

99
export function expandSnapshotPath(pathValue: string): string {
10-
if (pathValue.startsWith('~/')) {
11-
return `${os.homedir()}${pathValue.slice(1)}`;
12-
}
13-
return pathValue;
10+
return expandHomePrefix(pathValue);
1411
}
1512

1613
export function extractAppPathFromSnapshotOutput(output: string): string {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from 'vitest';
2+
import path from 'node:path';
3+
import { homedir } from 'node:os';
4+
import { createMockExecutor } from '../../test-utils/mock-executors.ts';
5+
import { resolveAppPathFromBuildSettings } from '../app-path-resolver.ts';
6+
import { XcodePlatform } from '../../types/common.ts';
7+
8+
describe('resolveAppPathFromBuildSettings', () => {
9+
it('expands tilde-prefixed projectPath when invoking xcodebuild', async () => {
10+
let capturedCommand: string[] | undefined;
11+
12+
const mockExecutor = createMockExecutor({
13+
success: true,
14+
output:
15+
'BUILT_PRODUCTS_DIR = /Build/Products/Debug-iphonesimulator\nFULL_PRODUCT_NAME = App.app\n',
16+
exitCode: 0,
17+
onExecute: (command) => {
18+
capturedCommand = command;
19+
},
20+
});
21+
22+
await resolveAppPathFromBuildSettings(
23+
{
24+
projectPath: '~/Code/App.xcodeproj',
25+
scheme: 'App',
26+
platform: XcodePlatform.iOSSimulator,
27+
},
28+
mockExecutor,
29+
);
30+
31+
const expected = path.join(homedir(), 'Code/App.xcodeproj');
32+
expect(capturedCommand).toBeDefined();
33+
expect(capturedCommand).toContain(expected);
34+
expect(capturedCommand).not.toContain('~/Code/App.xcodeproj');
35+
});
36+
37+
it('expands tilde-prefixed workspacePath when invoking xcodebuild', async () => {
38+
let capturedCommand: string[] | undefined;
39+
40+
const mockExecutor = createMockExecutor({
41+
success: true,
42+
output:
43+
'BUILT_PRODUCTS_DIR = /Build/Products/Debug-iphonesimulator\nFULL_PRODUCT_NAME = App.app\n',
44+
exitCode: 0,
45+
onExecute: (command) => {
46+
capturedCommand = command;
47+
},
48+
});
49+
50+
await resolveAppPathFromBuildSettings(
51+
{
52+
workspacePath: '~/Code/App.xcworkspace',
53+
scheme: 'App',
54+
platform: XcodePlatform.iOSSimulator,
55+
},
56+
mockExecutor,
57+
);
58+
59+
const expected = path.join(homedir(), 'Code/App.xcworkspace');
60+
expect(capturedCommand).toBeDefined();
61+
expect(capturedCommand).toContain(expected);
62+
});
63+
64+
it('leaves absolute paths unchanged', async () => {
65+
let capturedCommand: string[] | undefined;
66+
67+
const mockExecutor = createMockExecutor({
68+
success: true,
69+
output:
70+
'BUILT_PRODUCTS_DIR = /Build/Products/Debug-iphonesimulator\nFULL_PRODUCT_NAME = App.app\n',
71+
exitCode: 0,
72+
onExecute: (command) => {
73+
capturedCommand = command;
74+
},
75+
});
76+
77+
await resolveAppPathFromBuildSettings(
78+
{
79+
projectPath: '/abs/path/App.xcodeproj',
80+
scheme: 'App',
81+
platform: XcodePlatform.iOSSimulator,
82+
},
83+
mockExecutor,
84+
);
85+
86+
expect(capturedCommand).toContain('/abs/path/App.xcodeproj');
87+
});
88+
});

src/utils/__tests__/build-utils.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { describe, it, expect, vi, afterEach } from 'vitest';
66
import path from 'node:path';
7+
import { homedir } from 'node:os';
78
import { createMockExecutor } from '../../test-utils/mock-executors.ts';
89
import { executeXcodeBuildCommand } from '../build-utils.ts';
910
import { XcodePlatform } from '../xcode.ts';
@@ -477,5 +478,46 @@ describe('build-utils Sentry Classification', () => {
477478
expect.objectContaining({ cwd: path.dirname(expectedProjectPath) }),
478479
);
479480
});
481+
482+
it('should expand ~ in projectPath and derivedDataPath before execution', async () => {
483+
let capturedCommand: string[] | undefined;
484+
const mockExecutor = createMockExecutor({
485+
success: true,
486+
output: 'BUILD SUCCEEDED',
487+
exitCode: 0,
488+
onExecute: (command) => {
489+
capturedCommand = command;
490+
},
491+
});
492+
493+
const tildeProjectPath = '~/Code/App.xcodeproj';
494+
const tildeDerivedDataPath = '~/.foo/derivedData';
495+
const expectedProjectPath = path.join(homedir(), 'Code/App.xcodeproj');
496+
const expectedDerivedDataPath = path.join(homedir(), '.foo/derivedData');
497+
498+
await executeXcodeBuildCommand(
499+
{
500+
scheme: 'TestScheme',
501+
configuration: 'Debug',
502+
projectPath: tildeProjectPath,
503+
derivedDataPath: tildeDerivedDataPath,
504+
},
505+
{
506+
platform: XcodePlatform.iOSSimulator,
507+
simulatorName: 'iPhone 17 Pro',
508+
useLatestOS: true,
509+
logPrefix: 'iOS Simulator Build',
510+
},
511+
false,
512+
'build',
513+
mockExecutor,
514+
undefined,
515+
createMockPipeline(),
516+
);
517+
518+
expect(capturedCommand).toBeDefined();
519+
expect(capturedCommand).toContain(expectedProjectPath);
520+
expect(capturedCommand).toContain(expectedDerivedDataPath);
521+
});
480522
});
481523
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest';
2+
import path from 'node:path';
3+
import { homedir } from 'node:os';
4+
import { resolveEffectiveDerivedDataPath } from '../derived-data-path.ts';
5+
import { DERIVED_DATA_DIR } from '../log-paths.ts';
6+
7+
describe('resolveEffectiveDerivedDataPath', () => {
8+
it('returns the default derived data dir when input is undefined', () => {
9+
expect(resolveEffectiveDerivedDataPath(undefined)).toBe(DERIVED_DATA_DIR);
10+
});
11+
12+
it('returns the default derived data dir when input is empty', () => {
13+
expect(resolveEffectiveDerivedDataPath('')).toBe(DERIVED_DATA_DIR);
14+
});
15+
16+
it('returns the default derived data dir when input is whitespace', () => {
17+
expect(resolveEffectiveDerivedDataPath(' ')).toBe(DERIVED_DATA_DIR);
18+
});
19+
20+
it('returns absolute paths unchanged', () => {
21+
expect(resolveEffectiveDerivedDataPath('/abs/path/dd')).toBe('/abs/path/dd');
22+
});
23+
24+
it('resolves relative paths against the current working directory', () => {
25+
expect(resolveEffectiveDerivedDataPath('.derivedData/e2e')).toBe(
26+
path.resolve(process.cwd(), '.derivedData/e2e'),
27+
);
28+
});
29+
30+
it('expands a bare ~ input to the home directory', () => {
31+
expect(resolveEffectiveDerivedDataPath('~')).toBe(homedir());
32+
});
33+
34+
it('expands a ~/-prefixed input under the home directory', () => {
35+
expect(resolveEffectiveDerivedDataPath('~/.foo/derivedData')).toBe(
36+
path.join(homedir(), '.foo/derivedData'),
37+
);
38+
});
39+
});

src/utils/__tests__/path.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from 'vitest';
2+
import path from 'node:path';
3+
import { homedir } from 'node:os';
4+
import { expandHomePrefix, resolvePathFromCwd } from '../path.ts';
5+
6+
describe('expandHomePrefix', () => {
7+
it('expands a bare ~ to the home directory', () => {
8+
expect(expandHomePrefix('~')).toBe(homedir());
9+
});
10+
11+
it('expands a leading ~/ to the home directory', () => {
12+
expect(expandHomePrefix('~/foo/bar')).toBe(path.join(homedir(), 'foo/bar'));
13+
});
14+
15+
it('returns absolute paths unchanged', () => {
16+
expect(expandHomePrefix('/absolute/path')).toBe('/absolute/path');
17+
});
18+
19+
it('returns relative paths unchanged', () => {
20+
expect(expandHomePrefix('relative/path')).toBe('relative/path');
21+
});
22+
23+
it('does not expand ~user style prefixes', () => {
24+
expect(expandHomePrefix('~other/foo')).toBe('~other/foo');
25+
});
26+
27+
it('does not expand ~ embedded later in the path', () => {
28+
expect(expandHomePrefix('foo/~/bar')).toBe('foo/~/bar');
29+
});
30+
31+
it('does not expand a leading ~ followed by whitespace', () => {
32+
expect(expandHomePrefix(' ~/foo')).toBe(' ~/foo');
33+
});
34+
35+
it('preserves multi-byte characters in the expanded segment', () => {
36+
expect(expandHomePrefix('~/日本語/файл')).toBe(path.join(homedir(), '日本語/файл'));
37+
});
38+
39+
it('returns an empty string unchanged', () => {
40+
expect(expandHomePrefix('')).toBe('');
41+
});
42+
});
43+
44+
describe('resolvePathFromCwd', () => {
45+
it('expands a bare ~ to the home directory', () => {
46+
expect(resolvePathFromCwd('~')).toBe(homedir());
47+
});
48+
49+
it('expands a leading ~/ under the home directory', () => {
50+
expect(resolvePathFromCwd('~/.foo/derivedData')).toBe(path.join(homedir(), '.foo/derivedData'));
51+
});
52+
53+
it('returns absolute paths unchanged', () => {
54+
expect(resolvePathFromCwd('/abs/path')).toBe('/abs/path');
55+
});
56+
57+
it('resolves relative paths against process.cwd() by default', () => {
58+
expect(resolvePathFromCwd('rel/path')).toBe(path.resolve(process.cwd(), 'rel/path'));
59+
});
60+
61+
it('resolves relative paths against an explicit cwd when provided', () => {
62+
expect(resolvePathFromCwd('rel/path', '/some/base')).toBe(
63+
path.resolve('/some/base', 'rel/path'),
64+
);
65+
});
66+
67+
it('does not resolve absolute paths against an explicit cwd', () => {
68+
expect(resolvePathFromCwd('/abs/path', '/some/base')).toBe('/abs/path');
69+
});
70+
71+
it('does not expand ~user style prefixes', () => {
72+
expect(resolvePathFromCwd('~other/foo')).toBe(path.resolve(process.cwd(), '~other/foo'));
73+
});
74+
75+
it('normalizes traversal segments in absolute paths', () => {
76+
expect(resolvePathFromCwd('/foo/..')).toBe('/');
77+
});
78+
79+
it('normalizes interior traversal segments in absolute paths', () => {
80+
expect(resolvePathFromCwd('/a/b/../c')).toBe('/a/c');
81+
});
82+
83+
it('returns undefined when pathValue is undefined', () => {
84+
expect(resolvePathFromCwd(undefined)).toBeUndefined();
85+
});
86+
});

0 commit comments

Comments
 (0)