Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 5 additions & 20 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<boolean> {
if (!isInteractiveTTY()) {
return false;
Expand Down Expand Up @@ -216,7 +201,7 @@ function resolveTargets(
operation: 'install' | 'uninstall',
): ClientInfo[] {
if (destFlag) {
const resolvedDest = resolveDestinationPath(destFlag);
const resolvedDest = resolvePathFromCwd(destFlag);
Comment thread
cursor[bot] marked this conversation as resolved.
if (resolvedDest === path.parse(resolvedDest).root) {
throw new Error(
'Refusing to use filesystem root as skills destination. Use a dedicated directory.',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -443,7 +428,7 @@ async function promptCustomPath(): Promise<string> {
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.';
}
Expand All @@ -456,7 +441,7 @@ async function promptCustomPath(): Promise<string> {
throw new Error('Operation cancelled.');
}

return resolveDestinationPath(result as string);
return resolvePathFromCwd(result as string);
}

export function registerInitCommand(app: Argv, ctx?: { workspaceRoot: string }): void {
Expand Down
7 changes: 2 additions & 5 deletions src/snapshot-tests/output-parsers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import os from 'node:os';
import { expandHomePrefix } from '../utils/path.ts';

export interface SnapshotSimulatorEntry {
name: string;
Expand All @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions src/utils/__tests__/app-path-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
42 changes: 42 additions & 0 deletions src/utils/__tests__/build-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
});
39 changes: 39 additions & 0 deletions src/utils/__tests__/derived-data-path.test.ts
Original file line number Diff line number Diff line change
@@ -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'),
);
});
});
86 changes: 86 additions & 0 deletions src/utils/__tests__/path.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading