Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 `~/` 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
Expand Down
13 changes: 1 addition & 12 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 { expandHomePrefix } from '../../utils/expand-home.ts';

type SkillType = 'mcp' | 'cli';

Expand Down Expand Up @@ -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));
}
Expand Down
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'),
);
});
});
38 changes: 38 additions & 0 deletions src/utils/__tests__/expand-home.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
57 changes: 57 additions & 0 deletions src/utils/__tests__/project-config.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions src/utils/build-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tilde expansion missed in duplicate resolvePathFromCwd

Medium Severity

app-path-resolver.ts contains its own resolvePathFromCwd that is functionally identical to the one in build-utils.ts, but was not updated with expandHomePrefix. This means tilde-prefixed paths like ~/Code/App.xcodeproj are correctly expanded when passed to executeXcodeBuildCommand but remain literal ~ when the same params flow into resolveAppPathFromBuildSettings (used by device build/run, get-app-path, and macOS build tools). Within a single tool execution (e.g., build_macos.ts), the same projectPath is now expanded in the build step but not in the app-path resolution step — an inconsistency introduced by this PR.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f98824a. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate resolvePathFromCwd not updated with tilde expansion

Medium Severity

app-path-resolver.ts contains a private resolvePathFromCwd that is functionally identical to the one in build-utils.ts, but was not updated with expandHomePrefix. This creates inconsistent behavior: MCP tools like build_run_device and build_run_macos call executeXcodeBuildCommand (which now expands tildes) and then resolveAppPathFromBuildSettings (which does not) with the same raw params. A tilde-prefixed path would build successfully but then fail during app-path resolution.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f98824a. Configure here.

}

function getDefaultSwiftPackageCachePath(): string {
Expand Down
8 changes: 5 additions & 3 deletions src/utils/derived-data-path.ts
Original file line number Diff line number Diff line change
@@ -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);
}
18 changes: 18 additions & 0 deletions src/utils/expand-home.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 6 additions & 3 deletions src/utils/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Loading