Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Added `xcodebuildmcp purge` to report and explicitly clean XcodeBuildMCP-managed workspace storage, including opt-in DerivedData cleanup with dry-run and confirmation safeguards.
- Added `extraArgs` as a first-class session-default value. Repo config or runtime defaults can now carry common `xcodebuild` flags (for example `-skipPackagePluginValidation` or `-disableAutomaticPackageResolution`) so they don't need repeating on every build or test call. Per-call `extraArgs` replace matching configured flags or build settings and append after non-matching defaults, while an explicit empty array (`extraArgs: []`) clears the defaults for a single call. The session management tools show, set, sync, and clear `extraArgs` alongside the other defaults.

## [2.6.2]
Expand Down
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ sessionDefaults:
useLatestOS: true
arch: 'arm64'
suppressWarnings: false
derivedDataPath: './.derivedData'
derivedDataPath: './.derivedData' # optional; overrides isolated XcodeBuildMCP-managed DerivedData
preferXcodebuild: false
platform: 'iOS'
bundleId: 'io.sentry.myapp'
Expand Down
209 changes: 209 additions & 0 deletions src/cli/commands/__tests__/purge-interactive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import type { Prompter } from '../../interactive/prompts.ts';
import {
PURGE_INTERACTIVE_ROOT_PROMPT,
purgeInteractiveProjectPrompt,
purgeInteractiveWorkspacePrompt,
} from '../purge-interactive.ts';
import { runPurgeCommand } from '../purge.ts';
import {
getWorkspaceFilesystemLayout,
setXcodeBuildMCPAppDirOverrideForTests,
} from '../../../utils/log-paths.ts';

const DAY_MS = 24 * 60 * 60 * 1000;
const now = Date.UTC(2026, 5, 8, 12);
const currentWorkspaceKey = 'DemoApp-123456789abc';

let appDir: string;

function managedLogName(name = 'build_sim'): string {
return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`;
}

function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
const mtime = new Date(mtimeMs);
utimesSync(filePath, mtime, mtime);
}

function captureOutput(): { chunks: string[]; write: (text: string) => void } {
const chunks: string[] = [];
return {
chunks,
write: (text) => {
chunks.push(text);
},
};
}

describe('purge interactive command', () => {
beforeEach(() => {
appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-interactive-'));
setXcodeBuildMCPAppDirOverrideForTests(appDir);
});

afterEach(() => {
setXcodeBuildMCPAppDirOverrideForTests(null);
rmSync(appDir, { recursive: true, force: true });
});

it('groups purgeable unknown workspace keys under one Unknown workspaces row', async () => {
const known = getWorkspaceFilesystemLayout(currentWorkspaceKey);
const unknownOne = getWorkspaceFilesystemLayout('20260524T184215Z');
const unknownTwo = getWorkspaceFilesystemLayout('20260524T204326Z-49434c797e95');
writeFileWithMtime(path.join(known.logs, managedLogName('known')), 'known', now - 10 * DAY_MS);
writeFileWithMtime(
path.join(unknownOne.logs, managedLogName('unknown-one')),
'one',
now - 10 * DAY_MS,
);
writeFileWithMtime(
path.join(unknownTwo.logs, managedLogName('unknown-two')),
'two',
now - 10 * DAY_MS,
);
const output = captureOutput();
const rootLabels: string[] = [];
const prompter: Prompter = {
selectOne: async <T>(opts: {
message: string;
options: Array<{ value: T; label?: string }>;
}) => {
if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) {
rootLabels.push(...opts.options.map((option) => option.label ?? ''));
return opts.options.find((option) => option.value === 'cancel')!.value;
}
return opts.options[0].value;
},
selectMany: async () => [],
confirm: async () => true,
};

await runPurgeCommand(
{},
{ currentWorkspaceKey, prompter, isTTY: true, now, write: output.write },
);

expect(rootLabels.some((label) => label.startsWith('› Unknown workspaces - '))).toBe(true);
expect(rootLabels.some((label) => label.startsWith('20260524T184215Z - '))).toBe(false);
expect(rootLabels.some((label) => label.startsWith('20260524T204326Z-49434c797e95 - '))).toBe(
false,
);
expect(output.chunks.join('')).toContain('Purgeable:');
});

it('reports interactive overview counts from visible purgeable groups only', async () => {
const purgeable = getWorkspaceFilesystemLayout(currentWorkspaceKey);
const hidden = getWorkspaceFilesystemLayout('HiddenApp-abcdefabcdef');
writeFileWithMtime(
path.join(purgeable.logs, managedLogName('known')),
'known',
now - 10 * DAY_MS,
);
mkdirSync(hidden.filesystemLifecycle.lockDir, { recursive: true });
writeFileWithMtime(hidden.filesystemLifecycle.markerPath, 'marker', now - 10 * DAY_MS);
const output = captureOutput();
const prompter: Prompter = {
selectOne: async <T>(opts: { options: Array<{ value: T }> }) =>
opts.options.find((option) => option.value === 'cancel')!.value,
selectMany: async () => [],
confirm: async () => true,
};

await runPurgeCommand(
{},
{ currentWorkspaceKey, prompter, isTTY: true, now, write: output.write },
);

expect(output.chunks.join('')).toContain('1 projects / 1 workspaces');
expect(output.chunks.join('')).not.toContain('2 workspaces');
});

it('opens the workspace class menu for a single-workspace project', async () => {
const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey);
const logPath = path.join(layout.logs, managedLogName('known'));
const derivedDataPath = path.join(layout.derivedData, 'DemoApp-a');
writeFileWithMtime(logPath, 'known', now - 10 * DAY_MS);
writeFileWithMtime(derivedDataPath, 'derived', now - 10 * DAY_MS);
const output = captureOutput();
const messages: string[] = [];
let rootVisits = 0;
let workspaceVisits = 0;
const prompter: Prompter = {
selectOne: async <T>(opts: {
message: string;
options: Array<{ value: T; label?: string }>;
}) => {
messages.push(opts.message);
if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) {
rootVisits += 1;
if (rootVisits > 1)
return opts.options.find((option) => option.value === 'cancel')!.value;
return opts.options.find((option) => option.label?.startsWith('› DemoApp - '))!.value;
}
if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) {
workspaceVisits += 1;
if (workspaceVisits > 1)
return opts.options.find((option) => option.value === 'back')!.value;
return opts.options.find((option) => option.value === 'logs')!.value;
}
return opts.options.find((option) => option.value === 'cancel')!.value;
},
selectMany: async () => [],
confirm: async (opts) => {
messages.push(opts.message);
return true;
},
};

await runPurgeCommand(
{},
{ currentWorkspaceKey, prompter, isTTY: true, now, write: output.write },
);

expect(messages).toContain(PURGE_INTERACTIVE_ROOT_PROMPT);
expect(messages).toContain(purgeInteractiveWorkspacePrompt(currentWorkspaceKey));
expect(messages.some((message) => /^Delete .*\?$/u.test(message))).toBe(true);
expect(messages).not.toContain(purgeInteractiveProjectPrompt('DemoApp'));
expect(output.chunks.join('')).toContain('Deleted 1 item;');
expect(existsSync(logPath)).toBe(false);
expect(existsSync(derivedDataPath)).toBe(true);
});

it('omits global destructive actions from the root menu', async () => {
const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey);
writeFileWithMtime(path.join(layout.logs, managedLogName('known')), 'known', now - 10 * DAY_MS);
const output = captureOutput();
const rootLabels: string[] = [];
const prompter: Prompter = {
selectOne: async <T>(opts: {
message: string;
options: Array<{ value: T; label?: string }>;
}) => {
if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) {
rootLabels.push(...opts.options.map((option) => option.label ?? ''));
return opts.options.find((option) => option.value === 'cancel')!.value;
}
return opts.options[0].value;
},
selectMany: async () => [],
confirm: async () => true,
};

await runPurgeCommand(
{},
{ currentWorkspaceKey, prompter, isTTY: true, now, write: output.write },
);

expect(rootLabels).toContain('Cancel');
expect(rootLabels.some((label) => label.startsWith('› DemoApp - '))).toBe(true);
expect(rootLabels).not.toContain('Delete all projects');
expect(rootLabels).not.toContain('Select multiple projects to delete');
expect(rootLabels).not.toContain('Delete all DerivedData folders');
});
});
81 changes: 81 additions & 0 deletions src/cli/commands/__tests__/purge-report-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { runPurgeCommand } from '../purge.ts';
import {
getWorkspaceFilesystemLayout,
setXcodeBuildMCPAppDirOverrideForTests,
} from '../../../utils/log-paths.ts';

const DAY_MS = 24 * 60 * 60 * 1000;
const now = Date.UTC(2026, 5, 8, 12);
const currentWorkspaceKey = 'DemoApp-123456789abc';

let appDir: string;

function managedLogName(name: string): string {
return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`;
}

function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
const mtime = new Date(mtimeMs);
utimesSync(filePath, mtime, mtime);
}

function captureOutput(): { chunks: string[]; write: (text: string) => void } {
const chunks: string[] = [];
return {
chunks,
write: (text) => {
chunks.push(text);
},
};
}

describe('purge report scope', () => {
beforeEach(() => {
appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-report-scope-'));
setXcodeBuildMCPAppDirOverrideForTests(appDir);
});

afterEach(() => {
setXcodeBuildMCPAppDirOverrideForTests(null);
rmSync(appDir, { recursive: true, force: true });
});

it('keeps explicit report defaults aligned with the current workspace planning scope', async () => {
const current = getWorkspaceFilesystemLayout(currentWorkspaceKey);
const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef';
const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey);
writeFileWithMtime(
path.join(current.logs, managedLogName('current')),
'current',
now - 10 * DAY_MS,
);
writeFileWithMtime(
path.join(unrelated.logs, managedLogName('unrelated')),
'unrelated',
now - 10 * DAY_MS,
);
const output = captureOutput();

await runPurgeCommand(
{ report: true, json: true },
{ currentWorkspaceKey, isTTY: false, now, write: output.write },
);

const parsed = JSON.parse(output.chunks.join('')) as {
selectedScope: { type: string; workspaceKey?: string };
totals: { bytes: number };
workspaces: Array<{ workspaceKey: string }>;
};
expect(parsed.selectedScope).toEqual({ type: 'workspace', workspaceKey: currentWorkspaceKey });
expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([
currentWorkspaceKey,
]);
expect(parsed.totals.bytes).toBe(Buffer.byteLength('current'));
});
});
84 changes: 84 additions & 0 deletions src/cli/commands/__tests__/purge-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
import { executionToJson, planToJson } from '../purge-ui.ts';
import type {
PurgeStorageExecutionResult,
PurgeStoragePlan,
PurgeStorageReport,
} from '../../../utils/purge-storage.ts';

function minimalReport(): PurgeStorageReport {
return {
appRoot: process.cwd(),
workspacesDir: path.join(process.cwd(), 'tmp', 'workspaces'),
workspaces: [],
families: [],
totals: { bytes: 0, fileCount: 0, directoryCount: 0 },
warnings: [],
};
}

describe('purge UI JSON', () => {
it('sanitizes skipped reasons in plan JSON', () => {
const rawPath = path.join(process.cwd(), 'tmp', 'purge-json', 'active.log');
const displayFriendlyPath = path.join('tmp', 'purge-json', 'active.log');
const plan: PurgeStoragePlan = {
action: 'dry-run',
scope: { type: 'workspace', workspaceKey: 'DemoApp-123456789abc' },
classes: ['logs'],
report: minimalReport(),
selectedWorkspaceKeys: ['DemoApp-123456789abc'],
candidates: [],
skipped: [
{
workspaceKey: 'DemoApp-123456789abc',
storageClass: 'logs',
path: rawPath,
reason: `refused to delete ${rawPath}`,
},
],
totals: { bytes: 0, fileCount: 0, directoryCount: 0, candidateCount: 0 },
warnings: [],
};

const output = planToJson(plan) as { skipped: Array<{ path: string; reason: string }> };

expect(JSON.stringify(output)).not.toContain(rawPath);
expect(output.skipped[0]).toMatchObject({
path: displayFriendlyPath,
reason: `refused to delete ${displayFriendlyPath}`,
});
});

it('sanitizes skipped reasons in execution JSON', () => {
const rawPath = path.join(process.cwd(), 'tmp', 'purge-json', 'active.log');
const displayFriendlyPath = path.join('tmp', 'purge-json', 'active.log');
const result: PurgeStorageExecutionResult = {
action: 'delete',
scope: { type: 'workspace', workspaceKey: 'DemoApp-123456789abc' },
classes: ['logs'],
selectedWorkspaceKeys: ['DemoApp-123456789abc'],
deleted: [],
skipped: [
{
workspaceKey: 'DemoApp-123456789abc',
storageClass: 'logs',
path: rawPath,
reason: `refused to delete ${rawPath}`,
},
],
totals: { bytes: 0, deletedCount: 0, skippedCount: 1 },
warnings: [],
};

const output = executionToJson(result) as {
skipped: Array<{ path: string; reason: string }>;
};

expect(JSON.stringify(output)).not.toContain(rawPath);
expect(output.skipped[0]).toMatchObject({
path: displayFriendlyPath,
reason: `refused to delete ${displayFriendlyPath}`,
});
});
});
Loading
Loading