From 7311ae8f9991f5caea888ebc51f02ebdeaada08f Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 10 Jul 2026 10:02:46 +0100 Subject: [PATCH 1/6] feat(cli): Add workspace storage purge command Add an explicit purge command for reporting, dry-running, and deleting XcodeBuildMCP-managed workspace storage. The command keeps DerivedData cleanup opt-in, supports interactive and scriptable flows, and revalidates deletion candidates under workspace locks. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 6 + config.example.yaml | 2 +- .../__tests__/purge-interactive.test.ts | 189 ++++++ src/cli/commands/__tests__/purge-ui.test.ts | 84 +++ src/cli/commands/__tests__/purge.test.ts | 591 ++++++++++++++++++ src/cli/commands/purge-interactive-model.ts | 146 +++++ src/cli/commands/purge-interactive.ts | 407 ++++++++++++ src/cli/commands/purge-ui.ts | 349 +++++++++++ src/cli/commands/purge.ts | 374 +++++++++++ src/cli/interactive/prompts.ts | 119 +++- src/cli/yargs-app.ts | 2 + src/smoke-tests/__tests__/cli-surface.test.ts | 7 + src/utils/__tests__/fs-lock.test.ts | 230 +++++++ src/utils/__tests__/log-paths.test.ts | 9 + src/utils/__tests__/purge-storage.test.ts | 537 ++++++++++++++++ .../workspace-filesystem-lifecycle.test.ts | 21 + src/utils/fs-lock-sync.ts | 8 +- src/utils/fs-lock.ts | 68 +- src/utils/log-paths.ts | 3 + src/utils/purge-storage.ts | 23 + src/utils/purge-storage/enumerate.ts | 224 +++++++ src/utils/purge-storage/execution.ts | 394 ++++++++++++ src/utils/purge-storage/planning.ts | 522 ++++++++++++++++ src/utils/purge-storage/registry-record.ts | 49 ++ src/utils/purge-storage/scan.ts | 101 +++ src/utils/purge-storage/types.ts | 171 +++++ src/utils/result-bundle-path.ts | 16 +- src/utils/workspace-filesystem-lifecycle.ts | 126 +++- 28 files changed, 4677 insertions(+), 101 deletions(-) create mode 100644 src/cli/commands/__tests__/purge-interactive.test.ts create mode 100644 src/cli/commands/__tests__/purge-ui.test.ts create mode 100644 src/cli/commands/__tests__/purge.test.ts create mode 100644 src/cli/commands/purge-interactive-model.ts create mode 100644 src/cli/commands/purge-interactive.ts create mode 100644 src/cli/commands/purge-ui.ts create mode 100644 src/cli/commands/purge.ts create mode 100644 src/utils/__tests__/fs-lock.test.ts create mode 100644 src/utils/__tests__/purge-storage.test.ts create mode 100644 src/utils/purge-storage.ts create mode 100644 src/utils/purge-storage/enumerate.ts create mode 100644 src/utils/purge-storage/execution.ts create mode 100644 src/utils/purge-storage/planning.ts create mode 100644 src/utils/purge-storage/registry-record.ts create mode 100644 src/utils/purge-storage/scan.ts create mode 100644 src/utils/purge-storage/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6037b622..a47d9cf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Added `xcodebuildmcp purge` to report and explicitly clean XcodeBuildMCP-managed workspace storage, including opt-in DerivedData cleanup with dry-run and confirmation safeguards. + ## [2.6.2] ### Fixed diff --git a/config.example.yaml b/config.example.yaml index fc6ab8271..d29109649 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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' diff --git a/src/cli/commands/__tests__/purge-interactive.test.ts b/src/cli/commands/__tests__/purge-interactive.test.ts new file mode 100644 index 000000000..c7777dca6 --- /dev/null +++ b/src/cli/commands/__tests__/purge-interactive.test.ts @@ -0,0 +1,189 @@ +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 type { Prompter } from '../../interactive/prompts.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 (opts: { + message: string; + options: Array<{ value: T; label?: string }>; + }) => { + if (opts.message === 'Select a project to clean') { + 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 (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 delete confirmation directly for a single-workspace project', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + writeFileWithMtime(path.join(layout.logs, managedLogName('known')), 'known', now - 10 * DAY_MS); + const output = captureOutput(); + const messages: string[] = []; + let selectedProject = false; + const prompter: Prompter = { + selectOne: async (opts: { + message: string; + options: Array<{ value: T; label?: string }>; + }) => { + messages.push(opts.message); + if (opts.message === 'Select a project to clean' && !selectedProject) { + selectedProject = true; + return opts.options.find((option) => option.label?.startsWith('› DemoApp - '))!.value; + } + return opts.options.find((option) => option.value === 'cancel')!.value; + }, + selectMany: async () => [], + confirm: async (opts) => { + messages.push(opts.message); + return false; + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(messages).toContain('Select a project to clean'); + expect(messages.some((message) => /^Delete .*\?$/u.test(message))).toBe(true); + expect(messages).not.toContain('Project DemoApp'); + expect(output.chunks.join('')).toContain('No storage deleted.'); + }); + + 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 (opts: { + message: string; + options: Array<{ value: T; label?: string }>; + }) => { + if (opts.message === 'Select a project to clean') { + 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'); + }); +}); diff --git a/src/cli/commands/__tests__/purge-ui.test.ts b/src/cli/commands/__tests__/purge-ui.test.ts new file mode 100644 index 000000000..ac29e64de --- /dev/null +++ b/src/cli/commands/__tests__/purge-ui.test.ts @@ -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}`, + }); + }); +}); diff --git a/src/cli/commands/__tests__/purge.test.ts b/src/cli/commands/__tests__/purge.test.ts new file mode 100644 index 000000000..d5c7ef401 --- /dev/null +++ b/src/cli/commands/__tests__/purge.test.ts @@ -0,0 +1,591 @@ +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 { + PromptCancelledError, + PromptInterruptedError, + type Prompter, +} from '../../interactive/prompts.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 command', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-cli-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + }); + + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + rmSync(appDir, { recursive: true, force: true }); + }); + + it('defaults to report mode when not running in a TTY', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await runPurgeCommand({}, { currentWorkspaceKey, isTTY: false, now, write: output.write }); + + expect(output.chunks.join('')).toContain('XcodeBuildMCP storage report'); + expect(output.chunks.join('')).toContain(currentWorkspaceKey); + expect(existsSync(logPath)).toBe(true); + }); + + it('prints deterministic JSON report output', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + writeFileWithMtime(path.join(layout.logs, managedLogName('old')), 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await runPurgeCommand( + { json: true }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + action: string; + deletionHappened: boolean; + workspaces: Array<{ workspaceKey: string }>; + }; + expect(parsed.action).toBe('report'); + expect(parsed.deletionHappened).toBe(false); + expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + }); + + it('dry-runs selected purge candidates without deleting them', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await runPurgeCommand( + { dryRun: true, scope: 'all', classes: 'logs' }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('Purge dry run'); + expect(output.chunks.join('')).toContain('Candidates: 1'); + expect(existsSync(logPath)).toBe(true); + }); + + it('scopes dry-run enumeration to the requested workspace', async () => { + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const target = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); + writeFileWithMtime( + path.join(target.logs, managedLogName('target')), + 'target', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + + await runPurgeCommand( + { + dryRun: true, + scope: 'workspace', + workspaceKey: currentWorkspaceKey, + classes: 'logs', + json: true, + }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + selectedWorkspaceKeys: string[]; + candidates: Array<{ workspaceKey: string }>; + report: { workspaces: Array<{ workspaceKey: string }> }; + }; + expect(parsed.selectedWorkspaceKeys).toEqual([currentWorkspaceKey]); + expect(parsed.candidates.map((candidate) => candidate.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + expect(parsed.report.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + }); + + it('rejects non-interactive delete without the exact confirmation phrase', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await expect( + runPurgeCommand( + { delete: true, scope: 'all', classes: 'logs', confirm: 'delete' }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ), + ).rejects.toThrow('Destructive purge requires --confirm delete-xcodebuildmcp-storage'); + expect(existsSync(logPath)).toBe(true); + }); + + it('requires an explicit non-interactive mode when TTY purge flags are supplied', async () => { + await expect( + runPurgeCommand( + { classes: 'logs' }, + { currentWorkspaceKey, isTTY: true, now, write: captureOutput().write }, + ), + ).rejects.toThrow( + 'Purge flags require an explicit mode: --report, --dry-run, or --delete. Supplied: --classes.', + ); + }); + + it('rejects planning-only flags in report mode', async () => { + await expect( + runPurgeCommand( + { report: true, classes: 'logs' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--report cannot be combined with --classes.'); + + await expect( + runPurgeCommand( + { report: true, olderThan: '7d' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--report cannot be combined with --older-than.'); + }); + + it('rejects delete confirmation in dry-run mode', async () => { + await expect( + runPurgeCommand( + { dryRun: true, classes: 'logs', confirm: 'delete-xcodebuildmcp-storage' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--dry-run cannot be combined with --confirm.'); + }); + + it('runs the interactive project to workspace to folder flow for one workspace', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstLog = path.join(first.logs, managedLogName('first')); + const secondLog = path.join(second.logs, managedLogName('second')); + writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + const output = captureOutput(); + const messages: string[] = []; + let deleted = false; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + messages.push(opts.message); + if (opts.message === 'What do you want to do?') { + if (deleted) return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === 'Project DemoApp') { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'workspaceKey' in option.value && + option.value.workspaceKey === currentWorkspaceKey, + )!.value; + } + if (opts.message === `Workspace ${currentWorkspaceKey}`) { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find((option) => option.value === 'logs')!.value; + } + return opts.options[0].value; + }, + selectMany: async (opts: { message: string; options: Array<{ value: T }> }) => { + messages.push(opts.message); + return opts.options + .filter((option) => option.value === 'logs') + .map((option) => option.value); + }, + confirm: async (opts) => { + messages.push(opts.message); + deleted = true; + return true; + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(messages).toContain('What do you want to do?'); + expect(messages).toContain('Project DemoApp'); + expect(messages).toContain(`Workspace ${currentWorkspaceKey}`); + expect(messages).not.toContain('Select project'); + expect(messages).not.toContain('Select workspace in DemoApp'); + expect(messages).not.toContain(`Select folders in ${currentWorkspaceKey}`); + expect(text).toContain('Ready to delete'); + expect(text).not.toContain('Warnings:'); + expect(existsSync(firstLog)).toBe(false); + expect(existsSync(secondLog)).toBe(true); + }); + + it('runs the interactive workspace DerivedData action without deleting logs', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstDerivedData = path.join(first.derivedData, 'DemoApp-a'); + const secondDerivedData = path.join(second.derivedData, 'DemoApp-b'); + const logPath = path.join(first.logs, managedLogName('old')); + writeFileWithMtime(firstDerivedData, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondDerivedData, 'second', now - 10 * DAY_MS); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + let deleted = false; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === 'What do you want to do?') { + if (deleted) return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === 'Project DemoApp') { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'workspaceKey' in option.value && + option.value.workspaceKey === currentWorkspaceKey, + )!.value; + } + if (opts.message === `Workspace ${currentWorkspaceKey}`) { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find((option) => option.value === 'derivedData')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => { + deleted = true; + return true; + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(text).toContain('Includes DerivedData.'); + expect(existsSync(firstDerivedData)).toBe(false); + expect(existsSync(secondDerivedData)).toBe(true); + expect(existsSync(logPath)).toBe(true); + }); + + it('backs out of nested interactive menus without deleting storage', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstLog = path.join(first.logs, managedLogName('first')); + const secondLog = path.join(second.logs, managedLogName('second')); + writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + const output = captureOutput(); + const messages: string[] = []; + let rootVisits = 0; + let projectVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + messages.push(opts.message); + if (opts.message === 'What do you want to do?') { + rootVisits += 1; + if (rootVisits > 1) + return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === 'Project DemoApp') { + projectVisits += 1; + if (projectVisits > 1) + return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'workspaceKey' in option.value && + option.value.workspaceKey === currentWorkspaceKey, + )!.value; + } + if (opts.message === `Workspace ${currentWorkspaceKey}`) { + return opts.options.find((option) => option.value === 'back')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => { + throw new Error('selectMany should not be reached'); + }, + confirm: async () => { + throw new Error('confirm should not be reached'); + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(messages).toEqual([ + 'What do you want to do?', + 'Project DemoApp', + `Workspace ${currentWorkspaceKey}`, + 'Project DemoApp', + 'What do you want to do?', + ]); + expect(output.chunks.join('')).toContain('No storage deleted.'); + expect(existsSync(firstLog)).toBe(true); + expect(existsSync(secondLog)).toBe(true); + }); + + it('treats prompt cancellation as back in nested menus and cancel at the root', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + let rootVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === 'What do you want to do?') { + rootVisits += 1; + if (rootVisits > 1) throw new PromptCancelledError(); + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === 'Project DemoApp') { + throw new PromptCancelledError(); + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('No storage deleted.'); + expect(existsSync(logPath)).toBe(true); + }); + + it('quits cleanly when an interactive prompt is interrupted from a nested menu', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === 'What do you want to do?') { + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === 'Project DemoApp') { + throw new PromptInterruptedError(); + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('No storage deleted.'); + expect(existsSync(logPath)).toBe(true); + }); + + it('hides workspaces that only contain lock and lifecycle marker files in interactive mode', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + mkdirSync(layout.filesystemLifecycle.lockDir, { recursive: true }); + writeFileWithMtime(layout.filesystemLifecycle.markerPath, 'cleanup-marker', now - 10 * DAY_MS); + const output = captureOutput(); + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === 'What do you want to do?') { + const demoApp = opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + ); + expect(demoApp).toBeUndefined(); + 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 }, + ); + + const text = output.chunks.join(''); + expect(text).toContain('0 projects / 0 workspaces'); + expect(text).not.toContain('Largest projects:'); + }); + + it('does not confirm an interactive delete when selected storage disappears before planning', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + let rootVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === 'What do you want to do?') { + rootVisits += 1; + if (rootVisits === 1) { + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + return opts.options.find((option) => option.value === 'cancel')!.value; + } + if (opts.message === 'Project DemoApp') { + rmSync(logPath, { force: true }); + return opts.options.find((option) => option.value === 'deleteAllWorkspaces')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => { + throw new Error('confirm should not be reached for an empty purge plan'); + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(rootVisits).toBe(2); + expect(text).toContain('No matching purgeable storage found for that selection.'); + expect(text).not.toContain('Ready to delete'); + expect(text).not.toContain('Delete 0 B?'); + }); + + it('refreshes interactive project options after deleting a project', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstLog = path.join(first.logs, managedLogName('first')); + const secondLog = path.join(second.logs, managedLogName('second')); + writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + const output = captureOutput(); + let rootVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === 'What do you want to do?') { + rootVisits += 1; + const demoApp = opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + ); + if (rootVisits === 1) { + return demoApp!.value; + } + expect(demoApp).toBeUndefined(); + return opts.options.find((option) => option.value === 'cancel')!.value; + } + if (opts.message === 'Project DemoApp') { + return opts.options.find((option) => option.value === 'deleteAllWorkspaces')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(rootVisits).toBe(2); + expect(existsSync(firstLog)).toBe(false); + expect(existsSync(secondLog)).toBe(false); + }); +}); diff --git a/src/cli/commands/purge-interactive-model.ts b/src/cli/commands/purge-interactive-model.ts new file mode 100644 index 000000000..513908726 --- /dev/null +++ b/src/cli/commands/purge-interactive-model.ts @@ -0,0 +1,146 @@ +import { + PURGE_STORAGE_DELETABLE_CLASSES, + enumeratePurgeStorage, + planPurgeStorage, + type PurgeStorageDeletableClass, + type PurgeStorageReport, + type PurgeWorkspaceSummary, +} from '../../utils/purge-storage.ts'; + +export interface WorkspaceGroupSummary { + id: string; + name: string; + workspaces: PurgeWorkspaceSummary[]; + bytes: number; + recognized: boolean; +} + +const UNKNOWN_WORKSPACES_GROUP_ID = 'unknown-workspaces'; +const UNKNOWN_WORKSPACES_GROUP_NAME = 'Unknown workspaces'; +const TIMESTAMP_FALLBACK_FAMILY_PATTERN = /^\d{8}T\d{6}Z$/u; + +function isRecognizedProjectWorkspace(workspace: PurgeWorkspaceSummary): boolean { + return ( + workspace.recognized && + workspace.family !== null && + !TIMESTAMP_FALLBACK_FAMILY_PATTERN.test(workspace.family) + ); +} + +export interface PurgeInteractiveState { + report: PurgeStorageReport; + groups: WorkspaceGroupSummary[]; + purgeableBytesByWorkspace: Map; + purgeableBytesByWorkspaceClass: Map>; +} + +export function workspacePurgeableBytes( + workspace: PurgeWorkspaceSummary, + purgeableBytesByWorkspace: ReadonlyMap, +): number { + return purgeableBytesByWorkspace.get(workspace.workspaceKey) ?? 0; +} + +export function workspaceClassPurgeableBytes( + workspace: PurgeWorkspaceSummary, + storageClass: PurgeStorageDeletableClass, + purgeableBytesByWorkspaceClass: ReadonlyMap< + string, + ReadonlyMap + >, +): number { + return purgeableBytesByWorkspaceClass.get(workspace.workspaceKey)?.get(storageClass) ?? 0; +} + +async function calculatePurgeableBytesByWorkspace( + report: PurgeStorageReport, + now: number, +): Promise<{ + bytesByWorkspace: Map; + bytesByWorkspaceClass: Map>; +}> { + const plan = await planPurgeStorage({ + report, + scope: { + type: 'workspaces', + workspaceKeys: report.workspaces.map((workspace) => workspace.workspaceKey), + }, + classes: [...PURGE_STORAGE_DELETABLE_CLASSES], + now, + derivedDataExplicit: true, + }); + const bytesByWorkspace = new Map(); + const bytesByWorkspaceClass = new Map>(); + for (const candidate of plan.candidates) { + bytesByWorkspace.set( + candidate.workspaceKey, + (bytesByWorkspace.get(candidate.workspaceKey) ?? 0) + candidate.bytes, + ); + const classBytes = + bytesByWorkspaceClass.get(candidate.workspaceKey) ?? + new Map(); + classBytes.set( + candidate.storageClass, + (classBytes.get(candidate.storageClass) ?? 0) + candidate.bytes, + ); + bytesByWorkspaceClass.set(candidate.workspaceKey, classBytes); + } + return { bytesByWorkspace, bytesByWorkspaceClass }; +} + +export function groupWorkspaces( + workspaces: PurgeWorkspaceSummary[], + purgeableBytesByWorkspace: ReadonlyMap, +): WorkspaceGroupSummary[] { + const grouped = new Map(); + for (const workspace of workspaces) { + const bytes = workspacePurgeableBytes(workspace, purgeableBytesByWorkspace); + if (bytes <= 0) { + continue; + } + const recognizedProject = isRecognizedProjectWorkspace(workspace); + const groupId = recognizedProject ? `project:${workspace.family}` : UNKNOWN_WORKSPACES_GROUP_ID; + const group = grouped.get(groupId) ?? { + id: groupId, + name: recognizedProject ? workspace.family! : UNKNOWN_WORKSPACES_GROUP_NAME, + workspaces: [], + bytes: 0, + recognized: recognizedProject, + }; + group.workspaces.push(workspace); + group.bytes += bytes; + grouped.set(groupId, group); + } + + return Array.from(grouped.values()) + .map((group) => ({ + ...group, + workspaces: [...group.workspaces].sort( + (left, right) => + workspacePurgeableBytes(right, purgeableBytesByWorkspace) - + workspacePurgeableBytes(left, purgeableBytesByWorkspace), + ), + })) + .sort((left, right) => right.bytes - left.bytes || left.name.localeCompare(right.name)); +} + +export async function loadInteractiveState(now: number): Promise { + const report = await enumeratePurgeStorage({ now }); + const { bytesByWorkspace, bytesByWorkspaceClass } = await calculatePurgeableBytesByWorkspace( + report, + now, + ); + return { + report, + groups: groupWorkspaces(report.workspaces, bytesByWorkspace), + purgeableBytesByWorkspace: bytesByWorkspace, + purgeableBytesByWorkspaceClass: bytesByWorkspaceClass, + }; +} + +export async function refreshInteractiveState( + state: PurgeInteractiveState, + now: number, +): Promise { + Object.assign(state, await loadInteractiveState(now)); +} diff --git a/src/cli/commands/purge-interactive.ts b/src/cli/commands/purge-interactive.ts new file mode 100644 index 000000000..92ed84891 --- /dev/null +++ b/src/cli/commands/purge-interactive.ts @@ -0,0 +1,407 @@ +import { + PURGE_STORAGE_DELETABLE_CLASSES, + executePurgeStoragePlan, + planPurgeStorage, + type PurgeStorageDeletableClass, + type PurgeStoragePlan, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeWorkspaceSummary, +} from '../../utils/purge-storage.ts'; +import { + isPromptCancelledError, + isPromptInterruptedError, + type Prompter, + type SelectOption, +} from '../interactive/prompts.ts'; +import { + loadInteractiveState, + refreshInteractiveState, + workspaceClassPurgeableBytes, + workspacePurgeableBytes, + type PurgeInteractiveState, + type WorkspaceGroupSummary, +} from './purge-interactive-model.ts'; +import { + SKIPPED_FOR_SAFETY_HINT, + formatBytes, + renderInteractiveOverview, + storageClassLabel, +} from './purge-ui.ts'; + +const FULL_WORKSPACE_CLASSES: PurgeStorageDeletableClass[] = [...PURGE_STORAGE_DELETABLE_CLASSES]; + +type RootSelection = 'cancel' | WorkspaceGroupSummary; +type ProjectAction = 'deleteAllWorkspaces' | 'selectWorkspaces' | 'back'; +type ProjectSelection = ProjectAction | PurgeWorkspaceSummary; +type WorkspaceAction = 'deleteAllFolders' | 'back'; +type WorkspaceSelection = WorkspaceAction | PurgeStorageDeletableClass; + +interface PurgeInteractiveDependencies { + currentWorkspaceKey: string; + prompter: Prompter; + now: number; + write: (text: string) => void; +} + +function groupLabel(group: WorkspaceGroupSummary): string { + return `› ${group.name} - ${formatBytes(group.bytes)} - ${group.workspaces.length} workspace${group.workspaces.length === 1 ? '' : 's'}`; +} + +function workspaceLabel( + workspace: PurgeWorkspaceSummary, + currentWorkspaceKey: string, + state: PurgeInteractiveState, +): string { + const current = workspace.workspaceKey === currentWorkspaceKey ? ' - current' : ''; + return `${workspace.workspaceKey} - ${formatBytes(workspacePurgeableBytes(workspace, state.purgeableBytesByWorkspace))}${current}`; +} + +function isProjectAction(selection: ProjectSelection): selection is ProjectAction { + return typeof selection === 'string'; +} + +function isWorkspaceAction(selection: WorkspaceSelection): selection is WorkspaceAction { + return selection === 'deleteAllFolders' || selection === 'back'; +} + +function rootOptions(groups: WorkspaceGroupSummary[]): SelectOption[] { + return [...groupOptions(groups), { value: 'cancel', label: 'Cancel' }]; +} + +function projectOptions( + group: WorkspaceGroupSummary, + currentWorkspaceKey: string, + state: PurgeInteractiveState, +): SelectOption[] { + return [ + ...workspaceOptions(group.workspaces, currentWorkspaceKey, state), + { value: 'deleteAllWorkspaces', label: `Delete all workspaces in project ${group.name}` }, + { value: 'selectWorkspaces', label: 'Select multiple workspaces to delete' }, + { value: 'back', label: 'Back' }, + ]; +} + +function workspaceActionOptions( + workspace: PurgeWorkspaceSummary, + state: PurgeInteractiveState, +): SelectOption[] { + return [ + ...folderOptions(workspace, state), + { + value: 'deleteAllFolders', + label: `Delete all folders in workspace ${workspace.workspaceKey}`, + }, + { value: 'back', label: 'Back' }, + ]; +} + +function groupOptions(groups: WorkspaceGroupSummary[]): SelectOption[] { + return groups.map((group) => ({ value: group, label: groupLabel(group) })); +} + +function workspaceOptions( + workspaces: PurgeWorkspaceSummary[], + currentWorkspaceKey: string, + state: PurgeInteractiveState, +): SelectOption[] { + return workspaces.map((workspace) => ({ + value: workspace, + label: workspaceLabel(workspace, currentWorkspaceKey, state), + })); +} + +function interactiveFolderLabel(storageClass: PurgeStorageDeletableClass): string { + if (storageClass === 'stateTransients') { + return 'State'; + } + return storageClassLabel(storageClass); +} + +function folderOptions( + workspace: PurgeWorkspaceSummary, + state: PurgeInteractiveState, +): SelectOption[] { + return PURGE_STORAGE_DELETABLE_CLASSES.map((storageClass) => ({ + value: storageClass, + label: `${interactiveFolderLabel(storageClass)} - ${formatBytes(workspaceClassPurgeableBytes(workspace, storageClass, state.purgeableBytesByWorkspaceClass))}`, + })).filter( + (option) => + workspaceClassPurgeableBytes(workspace, option.value, state.purgeableBytesByWorkspaceClass) > + 0, + ); +} + +function selectedWorkspaceKeys(workspaces: PurgeWorkspaceSummary[]): string[] { + return workspaces + .map((workspace) => workspace.workspaceKey) + .sort((left, right) => left.localeCompare(right)); +} + +async function planForScope(params: { + report: PurgeStorageReport; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + now: number; +}): Promise { + return planPurgeStorage({ + report: params.report, + scope: params.scope, + classes: params.classes, + now: params.now, + derivedDataExplicit: params.classes.includes('derivedData'), + }); +} + +function classTotals(plan: PurgeStoragePlan): Map { + const totals = new Map(); + for (const storageClass of plan.classes) { + totals.set(storageClass, 0); + } + for (const candidate of plan.candidates) { + totals.set(candidate.storageClass, (totals.get(candidate.storageClass) ?? 0) + candidate.bytes); + } + return totals; +} + +function candidateWorkspaceCount(plan: PurgeStoragePlan): number { + return new Set(plan.candidates.map((candidate) => candidate.workspaceKey)).size; +} + +function renderFinalSummary(plan: PurgeStoragePlan): string { + const totals = classTotals(plan); + const workspaceCount = candidateWorkspaceCount(plan); + let output = '\nReady to delete\n'; + output += `Scope: ${workspaceCount} workspace${workspaceCount === 1 ? '' : 's'} with candidates\n`; + output += 'Folders:\n'; + for (const [storageClass, bytes] of totals.entries()) { + output += ` ${storageClassLabel(storageClass)} ${formatBytes(bytes)}\n`; + } + output += `Estimated reclaim: ${formatBytes(plan.totals.bytes)}\n`; + if (plan.classes.includes('derivedData')) { + output += 'Includes DerivedData.\n'; + } + if (plan.report.warnings.length > 0 || plan.skipped.length > 0) { + output += `Note: ${SKIPPED_FOR_SAFETY_HINT}\n`; + } + return `${output}\n`; +} + +async function confirmAndDelete( + plan: PurgeStoragePlan, + deps: PurgeInteractiveDependencies, + state: PurgeInteractiveState, +): Promise { + if (plan.candidates.length === 0) { + deps.write('No matching purgeable storage found for that selection.\n'); + await refreshInteractiveState(state, deps.now); + return false; + } + + deps.write(renderFinalSummary(plan)); + let confirmed: boolean; + try { + confirmed = await deps.prompter.confirm({ + message: `Delete ${formatBytes(plan.totals.bytes)}?`, + defaultValue: false, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + deps.write('No storage deleted.\n'); + return false; + } + throw error; + } + if (!confirmed) { + deps.write('No storage deleted.\n'); + return false; + } + + const result = await executePurgeStoragePlan(plan, { now: deps.now }); + deps.write( + `Deleted ${result.totals.deletedCount} item${result.totals.deletedCount === 1 ? '' : 's'}; freed ${formatBytes(result.totals.bytes)}.\n`, + ); + if (result.totals.skippedCount > 0) { + deps.write(`${SKIPPED_FOR_SAFETY_HINT}\n`); + } + if (result.totals.deletedCount > 0) { + deps.write('Refreshing storage view...\n'); + await refreshInteractiveState(state, deps.now); + } + return true; +} + +async function confirmAndDeleteScope( + deps: PurgeInteractiveDependencies, + state: PurgeInteractiveState, + scope: PurgeStorageScope, + classes: PurgeStorageDeletableClass[], +): Promise { + const plan = await planForScope({ + report: state.report, + scope, + classes, + now: deps.now, + }); + await confirmAndDelete(plan, deps, state); +} + +async function confirmAndDeleteWorkspaces( + deps: PurgeInteractiveDependencies, + state: PurgeInteractiveState, + workspaceKeys: string[], + classes: PurgeStorageDeletableClass[], +): Promise { + await confirmAndDeleteScope(deps, state, { type: 'workspaces', workspaceKeys }, classes); +} + +async function handleWorkspace(params: { + deps: PurgeInteractiveDependencies; + state: PurgeInteractiveState; + workspaceKey: string; +}): Promise { + const { deps, state, workspaceKey } = params; + while (true) { + const workspace = state.report.workspaces.find((entry) => entry.workspaceKey === workspaceKey); + if (!workspace || workspacePurgeableBytes(workspace, state.purgeableBytesByWorkspace) <= 0) { + return; + } + let selection: WorkspaceSelection; + try { + selection = await deps.prompter.selectOne({ + message: `Workspace ${workspace.workspaceKey}`, + options: workspaceActionOptions(workspace, state), + initialIndex: 0, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + return; + } + throw error; + } + + if (!isWorkspaceAction(selection)) { + await confirmAndDeleteWorkspaces(deps, state, [workspace.workspaceKey], [selection]); + continue; + } + + if (selection === 'back') { + return; + } + + await confirmAndDeleteWorkspaces(deps, state, [workspace.workspaceKey], FULL_WORKSPACE_CLASSES); + } +} + +async function handleProject(params: { + deps: PurgeInteractiveDependencies; + state: PurgeInteractiveState; + groupId: string; +}): Promise { + const { deps, state, groupId } = params; + while (true) { + const group = state.groups.find((entry) => entry.id === groupId); + if (!group) { + return; + } + if (group.workspaces.length === 1) { + await confirmAndDeleteWorkspaces( + deps, + state, + [group.workspaces[0].workspaceKey], + FULL_WORKSPACE_CLASSES, + ); + return; + } + let selection: ProjectSelection; + try { + selection = await deps.prompter.selectOne({ + message: `Project ${group.name}`, + options: projectOptions(group, deps.currentWorkspaceKey, state), + initialIndex: 0, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + return; + } + throw error; + } + + if (!isProjectAction(selection)) { + await handleWorkspace({ deps, state, workspaceKey: selection.workspaceKey }); + continue; + } + + if (selection === 'back') { + return; + } + + if (selection === 'deleteAllWorkspaces') { + await confirmAndDeleteWorkspaces( + deps, + state, + selectedWorkspaceKeys(group.workspaces), + FULL_WORKSPACE_CLASSES, + ); + continue; + } + + let workspaces: PurgeWorkspaceSummary[]; + try { + workspaces = await deps.prompter.selectMany({ + message: `Select workspaces in ${group.name}`, + options: workspaceOptions(group.workspaces, deps.currentWorkspaceKey, state), + getKey: (workspace) => workspace.workspaceKey, + minSelected: 1, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + continue; + } + throw error; + } + await confirmAndDeleteWorkspaces( + deps, + state, + selectedWorkspaceKeys(workspaces), + FULL_WORKSPACE_CLASSES, + ); + } +} + +export async function runInteractivePurge(deps: PurgeInteractiveDependencies): Promise { + const state = await loadInteractiveState(deps.now); + deps.write(renderInteractiveOverview(state.groups, state.report.warnings.length)); + + try { + while (true) { + let selection: RootSelection; + try { + selection = await deps.prompter.selectOne({ + message: 'Select a project to clean', + options: rootOptions(state.groups), + initialIndex: 0, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + deps.write('No storage deleted.\n'); + return; + } + throw error; + } + + if (selection === 'cancel') { + deps.write('No storage deleted.\n'); + return; + } + + await handleProject({ deps, state, groupId: selection.id }); + } + } catch (error) { + if (isPromptInterruptedError(error)) { + deps.write('No storage deleted.\n'); + return; + } + throw error; + } +} diff --git a/src/cli/commands/purge-ui.ts b/src/cli/commands/purge-ui.ts new file mode 100644 index 000000000..78bf51ecf --- /dev/null +++ b/src/cli/commands/purge-ui.ts @@ -0,0 +1,349 @@ +import { displayPath } from '../../utils/build-preflight.ts'; +import { + PURGE_STORAGE_DELETABLE_CLASSES, + type PurgeStorageClass, + type PurgeStorageDeletableClass, + type PurgeStorageExecutionResult, + type PurgeStoragePlan, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeWorkspaceSummary, +} from '../../utils/purge-storage.ts'; + +const CLASS_LABELS: Record = { + derivedData: 'DerivedData', + logs: 'Logs', + resultBundles: 'Result bundles', + stateTransients: 'State transients', + locks: 'Locks', +}; + +const BAR_WIDTH = 18; + +export const SKIPPED_FOR_SAFETY_HINT = + 'Some paths were skipped for safety. Run `xcodebuildmcp purge --report --json` for diagnostics.'; + +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB'] as const; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${units[unitIndex]}`; +} + +export function storageClassLabel(storageClass: PurgeStorageClass): string { + return CLASS_LABELS[storageClass]; +} + +export function renderInteractiveOverview( + groups: Array<{ name: string; bytes: number; workspaces: readonly unknown[] }>, + warningsCount: number, +): string { + const totalBytes = groups.reduce((total, group) => total + group.bytes, 0); + const workspaceCount = groups.reduce((total, group) => total + group.workspaces.length, 0); + let output = 'XcodeBuildMCP storage\n\n'; + output += `Purgeable: ${formatBytes(totalBytes)} across ${groups.length} projects / ${workspaceCount} workspaces\n`; + if (groups.length > 0) { + output += '\nLargest projects:\n'; + for (const group of groups.slice(0, 5)) { + output += ` ${group.name} ${formatBytes(group.bytes)} ${group.workspaces.length} workspace${group.workspaces.length === 1 ? '' : 's'}\n`; + } + } + if (warningsCount > 0) { + output += `\nNote: ${SKIPPED_FOR_SAFETY_HINT}\n`; + } + output += '\n'; + return output; +} + +export function scopeLabel(scope: PurgeStorageScope): string { + switch (scope.type) { + case 'all': + return 'all recognized workspaces'; + case 'family': + return `family ${scope.family}`; + case 'workspace': + return `workspace ${scope.workspaceKey}`; + case 'workspaces': + return `${scope.workspaceKeys.length} selected workspaces`; + } +} + +function usageBar(bytes: number, maxBytes: number): string { + if (maxBytes <= 0) { + return `[${'-'.repeat(BAR_WIDTH)}]`; + } + + const filled = Math.max(1, Math.round((bytes / maxBytes) * BAR_WIDTH)); + return `[${'#'.repeat(filled)}${'-'.repeat(BAR_WIDTH - filled)}]`; +} + +function line(parts: string[]): string { + return `${parts.join(' ')}\n`; +} + +function warningMarker(workspace: PurgeWorkspaceSummary): string { + if (!workspace.recognized) { + return 'unknown key'; + } + if (workspace.warnings.length > 0) { + return 'warnings'; + } + return ''; +} + +const ABSOLUTE_PATH_PATTERN = /\/[^\s'":]+/g; + +function displayWarning(warning: string): string { + return warning.replace(ABSOLUTE_PATH_PATTERN, (match) => displayPath(match)); +} + +function renderWarningsSection(warnings: string[]): string { + if (warnings.length === 0) { + return ''; + } + return `\nWarnings:\n${warnings.map((warning) => `- ${displayWarning(warning)}`).join('\n')}\n`; +} + +export function renderWorkspaceUsage( + report: PurgeStorageReport, + currentWorkspaceKey: string, +): string { + const maxBytes = Math.max(0, ...report.workspaces.map((workspace) => workspace.totals.bytes)); + const rows = [...report.workspaces].sort((left, right) => { + if (left.workspaceKey === currentWorkspaceKey) return -1; + if (right.workspaceKey === currentWorkspaceKey) return 1; + return right.totals.bytes - left.totals.bytes; + }); + + let output = line(['Workspace', 'Family', 'Size', 'Usage', 'Notes']); + output += line(['---------', '------', '----', '-----', '-----']); + for (const workspace of rows) { + const name = + workspace.workspaceKey === currentWorkspaceKey + ? `${workspace.workspaceKey} (current)` + : workspace.workspaceKey; + output += line([ + name, + workspace.family ?? '-', + formatBytes(workspace.totals.bytes).padStart(9), + usageBar(workspace.totals.bytes, maxBytes), + warningMarker(workspace), + ]); + } + return output; +} + +export function renderFamilyUsage(report: PurgeStorageReport): string { + if (report.families.length === 0) { + return 'No recognized workspace families found.\n'; + } + + const maxBytes = Math.max(0, ...report.families.map((family) => family.bytes)); + let output = line(['Family', 'Workspaces', 'Size', 'Usage']); + output += line(['------', '----------', '----', '-----']); + for (const family of [...report.families].sort((left, right) => right.bytes - left.bytes)) { + output += line([ + family.family, + String(family.workspaceKeys.length).padStart(10), + formatBytes(family.bytes).padStart(9), + usageBar(family.bytes, maxBytes), + ]); + } + return output; +} + +export function renderClassUsage( + workspaces: PurgeWorkspaceSummary[], + classes: readonly PurgeStorageClass[], +): string { + const totals = new Map(); + for (const storageClass of classes) { + totals.set(storageClass, 0); + } + + for (const workspace of workspaces) { + for (const storageClass of classes) { + totals.set( + storageClass, + (totals.get(storageClass) ?? 0) + workspace.classes[storageClass].bytes, + ); + } + } + + const maxBytes = Math.max(0, ...Array.from(totals.values())); + let output = line(['Class', 'Size', 'Usage']); + output += line(['-----', '----', '-----']); + for (const storageClass of classes) { + const bytes = totals.get(storageClass) ?? 0; + output += line([ + storageClassLabel(storageClass), + formatBytes(bytes).padStart(9), + usageBar(bytes, maxBytes), + ]); + } + return output; +} + +export function workspacesForScope( + report: PurgeStorageReport, + scope: PurgeStorageScope, +): PurgeWorkspaceSummary[] { + switch (scope.type) { + case 'all': + return report.workspaces.filter((workspace) => workspace.recognized); + case 'family': + return report.workspaces.filter( + (workspace) => workspace.recognized && workspace.family === scope.family, + ); + case 'workspace': + return report.workspaces.filter((workspace) => workspace.workspaceKey === scope.workspaceKey); + case 'workspaces': { + const workspaceKeys = new Set(scope.workspaceKeys); + return report.workspaces.filter((workspace) => workspaceKeys.has(workspace.workspaceKey)); + } + } +} + +export function renderReportText( + report: PurgeStorageReport, + currentWorkspaceKey: string, + selectedScope: PurgeStorageScope, +): string { + const selectedWorkspaces = workspacesForScope(report, selectedScope); + let output = 'XcodeBuildMCP storage report\n'; + output += `App root: ${displayPath(report.appRoot)}\n`; + output += `Total: ${formatBytes(report.totals.bytes)} (${report.totals.fileCount} files, ${report.totals.directoryCount} directories)\n`; + output += `Selected scope: ${scopeLabel(selectedScope)} (${selectedWorkspaces.length} workspaces)\n\n`; + output += 'Workspace usage:\n'; + output += renderWorkspaceUsage(report, currentWorkspaceKey); + output += '\nFamily usage:\n'; + output += renderFamilyUsage(report); + output += '\nSelected scope by class:\n'; + output += renderClassUsage(selectedWorkspaces, PURGE_STORAGE_DELETABLE_CLASSES); + output += renderWarningsSection(report.warnings); + + return output; +} + +export function renderPlanText(plan: PurgeStoragePlan): string { + let output = 'Purge dry run\n'; + output += `Scope: ${scopeLabel(plan.scope)}\n`; + output += `Classes: ${plan.classes.map(storageClassLabel).join(', ')}\n`; + output += `Candidates: ${plan.totals.candidateCount}\n`; + output += `Reclaimable: ${formatBytes(plan.totals.bytes)} (${plan.totals.fileCount} files, ${plan.totals.directoryCount} directories)\n`; + if (plan.skipped.length > 0) { + output += `Skipped: ${plan.skipped.length}\n`; + } + if (plan.candidates.length > 0) { + output += '\nCandidates:\n'; + for (const candidate of plan.candidates.slice(0, 20)) { + output += `- ${storageClassLabel(candidate.storageClass)} ${formatBytes(candidate.bytes)} ${displayPath(candidate.path)}\n`; + } + if (plan.candidates.length > 20) { + output += `- ... ${plan.candidates.length - 20} more\n`; + } + } + output += renderWarningsSection(plan.warnings); + return output; +} + +export function renderExecutionText(result: PurgeStorageExecutionResult): string { + let output = 'Purge delete result\n'; + output += `Scope: ${scopeLabel(result.scope)}\n`; + output += `Classes: ${result.classes.map(storageClassLabel).join(', ')}\n`; + output += `Deleted: ${result.totals.deletedCount}\n`; + output += `Freed: ${formatBytes(result.totals.bytes)}\n`; + output += `Skipped: ${result.totals.skippedCount}\n`; + output += renderWarningsSection(result.warnings); + return output; +} + +function jsonWorkspace(workspace: PurgeWorkspaceSummary): object { + return { + workspaceKey: workspace.workspaceKey, + path: displayPath(workspace.path), + recognized: workspace.recognized, + family: workspace.family, + hash: workspace.hash, + totals: workspace.totals, + classes: Object.fromEntries( + Object.entries(workspace.classes).map(([storageClass, census]) => [ + storageClass, + { + ...census, + path: displayPath(census.path), + warnings: census.warnings.map(displayWarning), + }, + ]), + ), + warnings: workspace.warnings.map(displayWarning), + }; +} + +export function reportToJson(report: PurgeStorageReport, selectedScope: PurgeStorageScope): object { + return { + action: 'report', + deletionHappened: false, + appRoot: displayPath(report.appRoot), + workspacesDir: displayPath(report.workspacesDir), + selectedScope, + totals: report.totals, + families: report.families, + workspaces: report.workspaces.map(jsonWorkspace), + warnings: report.warnings.map(displayWarning), + }; +} + +export function planToJson(plan: PurgeStoragePlan): object { + return { + action: 'dry-run', + deletionHappened: false, + selectedScope: plan.scope, + classes: plan.classes, + selectedWorkspaceKeys: plan.selectedWorkspaceKeys, + totals: plan.totals, + candidates: plan.candidates.map((candidate) => ({ + ...candidate, + path: displayPath(candidate.path), + sidecarPaths: candidate.sidecarPaths.map(displayPath), + })), + skipped: plan.skipped.map((candidate) => ({ + ...candidate, + path: displayPath(candidate.path), + reason: displayWarning(candidate.reason), + })), + warnings: plan.warnings.map(displayWarning), + report: reportToJson(plan.report, plan.scope), + }; +} + +export function executionToJson(result: PurgeStorageExecutionResult): object { + return { + action: 'delete', + deletionHappened: result.deleted.length > 0, + selectedScope: result.scope, + classes: result.classes, + selectedWorkspaceKeys: result.selectedWorkspaceKeys, + totals: result.totals, + deleted: result.deleted.map((entry) => ({ ...entry, path: displayPath(entry.path) })), + skipped: result.skipped.map((candidate) => ({ + ...candidate, + path: displayPath(candidate.path), + reason: displayWarning(candidate.reason), + })), + warnings: result.warnings.map(displayWarning), + }; +} + +export function defaultClassKeys(): PurgeStorageDeletableClass[] { + return ['logs', 'resultBundles', 'stateTransients']; +} diff --git a/src/cli/commands/purge.ts b/src/cli/commands/purge.ts new file mode 100644 index 000000000..7d83dfef6 --- /dev/null +++ b/src/cli/commands/purge.ts @@ -0,0 +1,374 @@ +import type { Argv } from 'yargs'; +import { + PURGE_STORAGE_DELETABLE_CLASSES, + enumeratePurgeStorage, + executePurgeStoragePlan, + planPurgeStorage, + type PurgeStorageDeletableClass, + type PurgeStoragePlan, + type PurgeStorageScope, +} from '../../utils/purge-storage.ts'; +import { createPrompter, isInteractiveTTY, type Prompter } from '../interactive/prompts.ts'; +import { + defaultClassKeys, + executionToJson, + planToJson, + renderExecutionText, + renderPlanText, + renderReportText, + reportToJson, +} from './purge-ui.ts'; +import { runInteractivePurge } from './purge-interactive.ts'; + +const DELETE_CONFIRMATION = 'delete-xcodebuildmcp-storage'; +const DAY_MS = 24 * 60 * 60 * 1000; + +type PurgeMode = 'report' | 'dry-run' | 'delete' | 'interactive'; +type CliScope = 'current' | 'workspace' | 'family' | 'all'; + +export interface PurgeCommandDependencies { + currentWorkspaceKey: string; + prompter?: Prompter; + isTTY?: boolean; + now?: number; + write?: (text: string) => void; +} + +export interface PurgeCommandArguments { + report?: boolean; + dryRun?: boolean; + delete?: boolean; + scope?: CliScope; + workspaceKey?: string; + family?: string; + classes?: string | string[]; + olderThan?: string; + confirm?: string; + json?: boolean; +} + +function writeJson(write: (text: string) => void, value: object): void { + write(`${JSON.stringify(value, null, 2)}\n`); +} + +function determineMode(args: PurgeCommandArguments, isTTY: boolean): PurgeMode { + const selected = [args.report, args.dryRun, args.delete].filter(Boolean).length; + if (selected > 1) { + throw new Error('Choose only one purge mode: --report, --dry-run, or --delete.'); + } + if (args.report) return 'report'; + if (args.dryRun) return 'dry-run'; + if (args.delete) return 'delete'; + return isTTY && !args.json ? 'interactive' : 'report'; +} + +function suppliedModeSpecificFlagNames(args: PurgeCommandArguments): string[] { + const supplied: string[] = []; + if (args.scope !== undefined) supplied.push('--scope'); + if (args.workspaceKey !== undefined) supplied.push('--workspace-key'); + if (args.family !== undefined) supplied.push('--family'); + if (args.classes !== undefined) supplied.push('--classes'); + if (args.olderThan !== undefined) supplied.push('--older-than'); + if (args.confirm !== undefined) supplied.push('--confirm'); + return supplied; +} + +function validateModeSpecificArgs(mode: PurgeMode, args: PurgeCommandArguments): void { + const explicitMode = args.report === true || args.dryRun === true || args.delete === true; + const suppliedFlags = suppliedModeSpecificFlagNames(args); + if (mode === 'interactive') { + if (!explicitMode && suppliedFlags.length > 0) { + throw new Error( + `Purge flags require an explicit mode: --report, --dry-run, or --delete. Supplied: ${suppliedFlags.join(', ')}.`, + ); + } + return; + } + + if (mode === 'report') { + if (args.classes !== undefined) { + throw new Error('--report cannot be combined with --classes.'); + } + if (args.olderThan !== undefined) { + throw new Error('--report cannot be combined with --older-than.'); + } + if (args.confirm !== undefined) { + throw new Error('--report cannot be combined with --confirm.'); + } + return; + } + + if (mode === 'dry-run' && args.confirm !== undefined) { + throw new Error('--dry-run cannot be combined with --confirm.'); + } +} + +function hasExplicitReportScope(args: PurgeCommandArguments): boolean { + return args.scope !== undefined || args.workspaceKey !== undefined || args.family !== undefined; +} + +function parseOlderThan(value: string | undefined): number | undefined { + if (value == null || value.trim().length === 0) { + return undefined; + } + + const match = value.trim().match(/^(\d+)\s*d$/u); + if (!match) { + throw new Error('--older-than must use a day value such as 1d, 7d, 14d, or 30d.'); + } + + const days = Number(match[1]); + if (!Number.isSafeInteger(days) || days <= 0) { + throw new Error('--older-than must be a positive day value.'); + } + return days * DAY_MS; +} + +function parseClasses(value: string | string[] | undefined): { + classes: PurgeStorageDeletableClass[]; + explicit: boolean; +} { + if (value == null) { + return { classes: defaultClassKeys(), explicit: false }; + } + + const rawValues = (Array.isArray(value) ? value : [value]) + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (rawValues.length === 0) { + throw new Error('--classes must include at least one storage class.'); + } + + const validClasses = new Set(PURGE_STORAGE_DELETABLE_CLASSES); + const parsed: PurgeStorageDeletableClass[] = []; + for (const rawValue of rawValues) { + if (rawValue === 'all') { + parsed.push(...defaultClassKeys()); + continue; + } + if (!validClasses.has(rawValue)) { + throw new Error( + `Unknown purge storage class '${rawValue}'. Use one of: ${PURGE_STORAGE_DELETABLE_CLASSES.join(', ')}.`, + ); + } + parsed.push(rawValue as PurgeStorageDeletableClass); + } + + return { classes: Array.from(new Set(parsed)), explicit: true }; +} + +function resolveScope(args: PurgeCommandArguments, currentWorkspaceKey: string): PurgeStorageScope { + if (args.scope === 'current' && (args.workspaceKey || args.family)) { + throw new Error('--scope current cannot be combined with --workspace-key or --family.'); + } + + if (args.scope === 'workspace' && args.family) { + throw new Error('--scope workspace cannot be combined with --family.'); + } + + if (args.scope === 'all') { + if (args.workspaceKey || args.family) { + throw new Error('--scope all cannot be combined with --workspace-key or --family.'); + } + return { type: 'all' }; + } + + if (args.scope === 'family' || args.family) { + if (!args.family) { + throw new Error('--scope family requires --family .'); + } + if (args.workspaceKey) { + throw new Error('--family cannot be combined with --workspace-key.'); + } + return { type: 'family', family: args.family }; + } + + if (args.scope === 'workspace' || args.workspaceKey) { + return { type: 'workspace', workspaceKey: args.workspaceKey ?? currentWorkspaceKey }; + } + + if (args.scope === 'current') { + return { type: 'workspace', workspaceKey: currentWorkspaceKey }; + } + + return { type: 'all' }; +} + +function resolvePlanningScope( + args: PurgeCommandArguments, + currentWorkspaceKey: string, +): PurgeStorageScope { + if (args.scope == null && args.workspaceKey == null && args.family == null) { + return { type: 'workspace', workspaceKey: currentWorkspaceKey }; + } + return resolveScope(args, currentWorkspaceKey); +} + +function validateDelete(args: PurgeCommandArguments, classesWereExplicit: boolean): void { + if (args.confirm !== DELETE_CONFIRMATION) { + throw new Error(`Destructive purge requires --confirm ${DELETE_CONFIRMATION}.`); + } + if (!classesWereExplicit) { + throw new Error('Destructive purge requires explicit --classes.'); + } +} + +interface ResolvedPlanOptions { + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + olderThanMs?: number; + derivedDataExplicit: boolean; + classesWereExplicit: boolean; +} + +function planOptionsForArgs( + args: PurgeCommandArguments, + currentWorkspaceKey: string, +): ResolvedPlanOptions { + const parsedClasses = parseClasses(args.classes); + const scope = resolvePlanningScope(args, currentWorkspaceKey); + return { + scope, + classes: parsedClasses.classes, + olderThanMs: parseOlderThan(args.olderThan), + derivedDataExplicit: parsedClasses.classes.includes('derivedData') && parsedClasses.explicit, + classesWereExplicit: parsedClasses.explicit, + }; +} + +async function buildPlan(options: ResolvedPlanOptions, now: number): Promise { + return planPurgeStorage({ + scope: options.scope, + classes: options.classes, + now, + olderThanMs: options.olderThanMs, + derivedDataExplicit: options.derivedDataExplicit, + }); +} + +export async function runPurgeCommand( + args: PurgeCommandArguments, + deps: PurgeCommandDependencies, +): Promise { + const resolvedDeps: Required = { + currentWorkspaceKey: deps.currentWorkspaceKey, + prompter: deps.prompter ?? createPrompter(), + isTTY: deps.isTTY ?? isInteractiveTTY(), + now: deps.now ?? Date.now(), + write: deps.write ?? ((text): void => void process.stdout.write(text)), + }; + + const mode = determineMode(args, resolvedDeps.isTTY); + validateModeSpecificArgs(mode, args); + if (mode === 'interactive') { + await runInteractivePurge(resolvedDeps); + return; + } + + if (mode === 'report') { + const selectedScope = resolveScope(args, resolvedDeps.currentWorkspaceKey); + const report = await enumeratePurgeStorage({ + now: resolvedDeps.now, + scope: hasExplicitReportScope(args) ? selectedScope : undefined, + }); + if (args.json) { + writeJson(resolvedDeps.write, reportToJson(report, selectedScope)); + } else { + resolvedDeps.write(renderReportText(report, resolvedDeps.currentWorkspaceKey, selectedScope)); + } + return; + } + + const planArgs = planOptionsForArgs(args, resolvedDeps.currentWorkspaceKey); + if (mode === 'delete') { + validateDelete(args, planArgs.classesWereExplicit); + } + + const plan = await buildPlan(planArgs, resolvedDeps.now); + if (mode === 'dry-run') { + if (args.json) { + writeJson(resolvedDeps.write, planToJson(plan)); + } else { + resolvedDeps.write(renderPlanText(plan)); + } + return; + } + + const result = await executePurgeStoragePlan(plan, { now: resolvedDeps.now }); + if (args.json) { + writeJson(resolvedDeps.write, executionToJson(result)); + } else { + resolvedDeps.write(renderExecutionText(result)); + } +} + +export function registerPurgeCommand(app: Argv, opts: { currentWorkspaceKey: string }): void { + app.command( + 'purge', + 'Report and clean XcodeBuildMCP workspace storage', + (yargs) => + yargs + .option('report', { + type: 'boolean', + describe: 'Report storage usage without planning or deleting', + }) + .option('dry-run', { + type: 'boolean', + describe: 'Plan purge candidates without deleting anything', + }) + .option('delete', { + type: 'boolean', + describe: 'Delete planned purge candidates', + }) + .option('scope', { + type: 'string', + choices: ['current', 'workspace', 'family', 'all'] as const, + describe: 'Storage scope to report, plan, or delete', + }) + .option('workspace-key', { + type: 'string', + describe: 'Explicit workspace key for --scope workspace', + }) + .option('family', { + type: 'string', + describe: 'Workspace family basename prefix for --scope family', + }) + .option('classes', { + type: 'string', + describe: + 'Comma-separated storage classes: derivedData,logs,resultBundles,stateTransients. "all" excludes DerivedData.', + }) + .option('older-than', { + type: 'string', + describe: 'Only purge entries older than a day value such as 1d, 7d, 14d, or 30d', + }) + .option('confirm', { + type: 'string', + describe: `Required for --delete: ${DELETE_CONFIRMATION}`, + }) + .option('json', { + type: 'boolean', + default: false, + describe: 'Output deterministic JSON', + }), + async (argv) => { + await runPurgeCommand( + { + report: argv.report as boolean | undefined, + dryRun: argv.dryRun as boolean | undefined, + delete: argv.delete as boolean | undefined, + scope: argv.scope as CliScope | undefined, + workspaceKey: argv.workspaceKey as string | undefined, + family: argv.family as string | undefined, + classes: argv.classes as string | undefined, + olderThan: argv.olderThan as string | undefined, + confirm: argv.confirm as string | undefined, + json: argv.json as boolean | undefined, + }, + { currentWorkspaceKey: opts.currentWorkspaceKey }, + ); + }, + ); +} diff --git a/src/cli/interactive/prompts.ts b/src/cli/interactive/prompts.ts index 62f615ec7..0ce48f7f5 100644 --- a/src/cli/interactive/prompts.ts +++ b/src/cli/interactive/prompts.ts @@ -1,5 +1,27 @@ import * as clack from '@clack/prompts'; +export class PromptCancelledError extends Error { + constructor() { + super('Prompt cancelled.'); + this.name = 'PromptCancelledError'; + } +} + +export class PromptInterruptedError extends Error { + constructor() { + super('Prompt interrupted.'); + this.name = 'PromptInterruptedError'; + } +} + +export function isPromptCancelledError(error: unknown): error is PromptCancelledError { + return error instanceof PromptCancelledError; +} + +export function isPromptInterruptedError(error: unknown): error is PromptInterruptedError { + return error instanceof PromptInterruptedError; +} + export interface SelectOption { value: T; label: string; @@ -58,13 +80,50 @@ function createNonInteractivePrompter(): Prompter { }; } -function handleCancel(result: unknown): void { +type PromptCancelKind = 'cancel' | 'interrupt'; + +interface KeypressInfo { + name?: string; + sequence?: string; + ctrl?: boolean; +} + +async function runClackPrompt( + prompt: Promise, +): Promise<{ result: T; cancelKind: PromptCancelKind }> { + let cancelKind: PromptCancelKind = 'cancel'; + const onKeypress = (_text: string, key: KeypressInfo = {}): void => { + if (key.sequence === '\u0003' || (key.name === 'c' && key.ctrl === true)) { + cancelKind = 'interrupt'; + } + }; + + process.stdin.prependListener('keypress', onKeypress); + try { + return { result: await prompt, cancelKind }; + } finally { + process.stdin.off('keypress', onKeypress); + } +} + +function handleCancel(result: unknown, cancelKind: PromptCancelKind): void { if (clack.isCancel(result)) { - clack.cancel('Setup cancelled.'); - throw new Error('Setup cancelled.'); + clack.cancel(cancelKind === 'interrupt' ? 'Interrupted.' : 'Cancelled.'); + if (cancelKind === 'interrupt') { + throw new PromptInterruptedError(); + } + throw new PromptCancelledError(); } } +function toClackOptions(options: SelectOption[]): clack.Option[] { + return options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description ? { hint: option.description } : {}), + })) as unknown as clack.Option[]; +} + function createTtyPrompter(): Prompter { return { async selectOne(opts: { @@ -78,19 +137,15 @@ function createTtyPrompter(): Prompter { const initialIndex = clampIndex(opts.initialIndex ?? 0, opts.options.length); - const promptOptions = opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.description ? { hint: option.description } : {}), - })) as unknown as clack.Option[]; - - const result = await clack.select({ - message: opts.message, - options: promptOptions, - initialValue: opts.options[initialIndex].value, - }); + const { result, cancelKind } = await runClackPrompt( + clack.select({ + message: opts.message, + options: toClackOptions(opts.options), + initialValue: opts.options[initialIndex].value, + }), + ); - handleCancel(result); + handleCancel(result, cancelKind); return result as T; }, @@ -110,30 +165,28 @@ function createTtyPrompter(): Prompter { .filter((option) => initialKeys.has(opts.getKey(option.value))) .map((option) => option.value); - const promptOptions = opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.description ? { hint: option.description } : {}), - })) as unknown as clack.Option[]; - - const result = await clack.multiselect({ - message: opts.message, - options: promptOptions, - initialValues, - required: (opts.minSelected ?? 0) > 0, - }); + const { result, cancelKind } = await runClackPrompt( + clack.multiselect({ + message: opts.message, + options: toClackOptions(opts.options), + initialValues, + required: (opts.minSelected ?? 0) > 0, + }), + ); - handleCancel(result); + handleCancel(result, cancelKind); return result as T[]; }, async confirm(opts: { message: string; defaultValue: boolean }): Promise { - const result = await clack.confirm({ - message: opts.message, - initialValue: opts.defaultValue, - }); + const { result, cancelKind } = await runClackPrompt( + clack.confirm({ + message: opts.message, + initialValue: opts.defaultValue, + }), + ); - handleCancel(result); + handleCancel(result, cancelKind); return result as boolean; }, }; diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 874e3b0f7..47240e5fb 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -5,6 +5,7 @@ import type { ResolvedRuntimeConfig } from '../utils/config-store.ts'; import { registerDaemonCommands } from './commands/daemon.ts'; import { registerInitCommand } from './commands/init.ts'; import { registerMcpCommand } from './commands/mcp.ts'; +import { registerPurgeCommand } from './commands/purge.ts'; import { registerSetupCommand } from './commands/setup.ts'; import { registerToolsCommand } from './commands/tools.ts'; import { registerUpgradeCommand } from './commands/upgrade.ts'; @@ -83,6 +84,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { registerSetupCommand(app); registerUpgradeCommand(app); registerToolsCommand(app); + registerPurgeCommand(app, { currentWorkspaceKey: opts.workspaceKey }); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, runtimeConfig: opts.runtimeConfig, diff --git a/src/smoke-tests/__tests__/cli-surface.test.ts b/src/smoke-tests/__tests__/cli-surface.test.ts index 86bc3322c..aff550619 100644 --- a/src/smoke-tests/__tests__/cli-surface.test.ts +++ b/src/smoke-tests/__tests__/cli-surface.test.ts @@ -56,6 +56,13 @@ describe('CLI Surface (e2e)', () => { expect(output).toContain('simulator:'); expect(output).toContain('build'); }); + + it('purge --help shows storage management options', () => { + const output = run('purge --help'); + expect(output).toContain('Report and clean XcodeBuildMCP workspace storage'); + expect(output).toContain('--dry-run'); + expect(output).toContain('--delete'); + }); }); describe('workflow subcommands', () => { diff --git a/src/utils/__tests__/fs-lock.test.ts b/src/utils/__tests__/fs-lock.test.ts new file mode 100644 index 000000000..f5dca2238 --- /dev/null +++ b/src/utils/__tests__/fs-lock.test.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { FS_LOCK_OWNER_FILE, tryAcquireFsLock, type FsLockOwner } from '../fs-lock.ts'; +import { guardDirForLockDir } from '../fs-lock-shared.ts'; +import { tryAcquireFsLockSync } from '../fs-lock-sync.ts'; + +const PURPOSE = 'filesystem-lifecycle'; +const LEASE_MS = 10_000; +const DEAD_PID = 999_999_999; + +let tempDirs: string[] = []; + +function makeOwner(overrides: Partial = {}): FsLockOwner { + const now = Date.UTC(2026, 4, 2, 12); + return { + token: 'stale-owner-token', + pid: DEAD_PID, + purpose: PURPOSE, + acquiredAtMs: now - 2 * LEASE_MS, + expiresAtMs: now - LEASE_MS, + ...overrides, + }; +} + +function ownerPath(lockDir: string): string { + return path.join(lockDir, FS_LOCK_OWNER_FILE); +} + +function lockDirFor(appDir: string): string { + return path.join(appDir, 'workspace-a', 'locks', 'filesystem-lifecycle.lock'); +} + +async function makeTempDir(): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), 'xcodebuildmcp-fs-lock-')); + tempDirs.push(tempDir); + return tempDir; +} + +function makeTempDirSync(): string { + const tempDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-fs-lock-')); + tempDirs.push(tempDir); + return tempDir; +} + +async function writeOwner(lockDir: string, owner: FsLockOwner): Promise { + await mkdir(lockDir, { recursive: true }); + await writeFile(ownerPath(lockDir), `${JSON.stringify(owner)}\n`, 'utf8'); +} + +function writeOwnerSync(lockDir: string, owner: FsLockOwner): void { + mkdirSync(lockDir, { recursive: true }); + writeFileSync(ownerPath(lockDir), `${JSON.stringify(owner)}\n`, 'utf8'); +} + +async function readOwner(lockDir: string): Promise { + return JSON.parse(await readFile(ownerPath(lockDir), 'utf8')) as FsLockOwner; +} + +function readOwnerSync(lockDir: string): FsLockOwner { + return JSON.parse(readFileSync(ownerPath(lockDir), 'utf8')) as FsLockOwner; +} + +afterEach(async () => { + const dirs = tempDirs; + tempDirs = []; + await Promise.all(dirs.map((tempDir) => rm(tempDir, { recursive: true, force: true }))); +}); + +describe('tryAcquireFsLock', () => { + it('acquires and releases a normal lock', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).not.toBeNull(); + expect(await readOwner(lockDir)).toMatchObject({ + token: lock?.owner.token, + pid: process.pid, + purpose: PURPOSE, + }); + + await lock?.release(); + + await expect(readFile(ownerPath(lockDir), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not create the main lock while a guard is held', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + await mkdir(guardDirForLockDir(lockDir), { recursive: true }); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).toBeNull(); + await expect(readFile(ownerPath(lockDir), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('recovers an expired stale guard before acquiring the main lock', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + await writeOwner( + guardDirForLockDir(lockDir), + makeOwner({ purpose: `${PURPOSE}:guard`, expiresAtMs: now - 1 }), + ); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + expect(await readOwner(lockDir)).toMatchObject({ purpose: PURPOSE, token: lock?.owner.token }); + await lock?.release(); + }); + + it('recovers an expired stale main lock after acquiring the guard', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const staleOwner = makeOwner({ token: 'expired-main-lock', expiresAtMs: now - 1 }); + await writeOwner(lockDir, staleOwner); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + const currentOwner = await readOwner(lockDir); + expect(currentOwner.token).toBe(lock?.owner.token); + expect(currentOwner.token).not.toBe(staleOwner.token); + await lock?.release(); + }); + + it('does not recover an expired lock owned by a live process', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const liveOwner = makeOwner({ + pid: process.pid, + token: 'live-main-lock', + expiresAtMs: now - 1, + }); + await writeOwner(lockDir, liveOwner); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).toBeNull(); + expect(await readOwner(lockDir)).toMatchObject(liveOwner); + }); +}); + +describe('tryAcquireFsLockSync', () => { + it('acquires and releases a normal lock', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).not.toBeNull(); + expect(readOwnerSync(lockDir)).toMatchObject({ + token: lock?.owner.token, + pid: process.pid, + purpose: PURPOSE, + }); + + lock?.release(); + + expect(() => readFileSync(ownerPath(lockDir), 'utf8')).toThrow(); + }); + + it('does not create the main lock while a guard is held', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + mkdirSync(guardDirForLockDir(lockDir), { recursive: true }); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).toBeNull(); + expect(() => readFileSync(ownerPath(lockDir), 'utf8')).toThrow(); + }); + + it('recovers an expired stale guard before acquiring the main lock', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + writeOwnerSync( + guardDirForLockDir(lockDir), + makeOwner({ purpose: `${PURPOSE}:guard`, expiresAtMs: now - 1 }), + ); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + expect(readOwnerSync(lockDir)).toMatchObject({ purpose: PURPOSE, token: lock?.owner.token }); + lock?.release(); + }); + + it('recovers an expired stale main lock after acquiring the guard', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const staleOwner = makeOwner({ token: 'expired-main-lock', expiresAtMs: now - 1 }); + writeOwnerSync(lockDir, staleOwner); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + const currentOwner = readOwnerSync(lockDir); + expect(currentOwner.token).toBe(lock?.owner.token); + expect(currentOwner.token).not.toBe(staleOwner.token); + lock?.release(); + }); + + it('does not recover an expired lock owned by a live process', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const liveOwner = makeOwner({ + pid: process.pid, + token: 'live-main-lock', + expiresAtMs: now - 1, + }); + writeOwnerSync(lockDir, liveOwner); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).toBeNull(); + expect(readOwnerSync(lockDir)).toMatchObject(liveOwner); + }); +}); diff --git a/src/utils/__tests__/log-paths.test.ts b/src/utils/__tests__/log-paths.test.ts index d437d3d18..e5e4c4ebf 100644 --- a/src/utils/__tests__/log-paths.test.ts +++ b/src/utils/__tests__/log-paths.test.ts @@ -11,6 +11,15 @@ describe('log paths', () => { setXcodeBuildMCPAppDirOverrideForTests(null); }); + it('rejects relative path segment workspace keys', () => { + expect(() => getWorkspaceFilesystemLayout('.')).toThrow( + 'Workspace key cannot be a relative path segment', + ); + expect(() => getWorkspaceFilesystemLayout('..')).toThrow( + 'Workspace key cannot be a relative path segment', + ); + }); + it('builds the workspace-first filesystem layout', () => { const appDir = path.join('/tmp', 'xcodebuildmcp-app'); setXcodeBuildMCPAppDirOverrideForTests(appDir); diff --git a/src/utils/__tests__/purge-storage.test.ts b/src/utils/__tests__/purge-storage.test.ts new file mode 100644 index 000000000..833d11446 --- /dev/null +++ b/src/utils/__tests__/purge-storage.test.ts @@ -0,0 +1,537 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + enumeratePurgeStorage, + executePurgeStoragePlan, + parseWorkspaceFamilyKey, + planPurgeStorage, + type PurgeStoragePlan, +} from '../purge-storage.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; +import { getResultBundleCompletionMarkerPath } from '../result-bundle-path.ts'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEAD_PID = 999_999_999; + +let appDir: string; + +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 writeDirectoryWithMtime(dir: string, mtimeMs: number): void { + mkdirSync(dir, { recursive: true }); + writeFileWithMtime(path.join(dir, 'file.txt'), 'content', mtimeMs); + const mtime = new Date(mtimeMs); + utimesSync(dir, mtime, mtime); +} + +function managedLogName(name = 'build_sim'): string { + return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; +} + +function managedSimulatorLogName(helperPid: number): string { + return `simulator_2026-05-02T12-00-00-000Z_helperpid${helperPid}_ownerpid123_abcdef12.log`; +} + +function managedResultBundleName(name = 'test', pid = DEAD_PID): string { + return `${name}_2026-05-02T12-00-00-000Z_pid${pid}_abcdef12.xcresult`; +} + +describe('purge storage', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-storage-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + }); + + afterEach(async () => { + setXcodeBuildMCPAppDirOverrideForTests(null); + await rm(appDir, { recursive: true, force: true }); + }); + + it('parses only exact lowercase workspace family keys', () => { + expect(parseWorkspaceFamilyKey('DemoApp-123456789abc')).toEqual({ + family: 'DemoApp', + hash: '123456789abc', + }); + expect(parseWorkspaceFamilyKey('DemoApp-Main-123456789abc')).toEqual({ + family: 'DemoApp-Main', + hash: '123456789abc', + }); + expect(parseWorkspaceFamilyKey('DemoApp-123456789ABC')).toBeNull(); + expect(parseWorkspaceFamilyKey('DemoApp')).toBeNull(); + }); + + it('enumerates workspace storage and groups only recognized family keys', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const first = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const second = getWorkspaceFilesystemLayout('DemoApp-abcdefabcdef'); + const separateFamily = getWorkspaceFilesystemLayout('DemoApp-Main-123456789abc'); + const unknown = getWorkspaceFilesystemLayout('manual-workspace'); + writeFileWithMtime(path.join(first.logs, managedLogName()), 'old', now); + writeFileWithMtime(path.join(second.derivedData, 'Build', 'artifact'), 'derived', now); + mkdirSync(separateFamily.root, { recursive: true }); + mkdirSync(unknown.root, { recursive: true }); + + const report = await enumeratePurgeStorage({ now }); + + expect(report.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + 'DemoApp-123456789abc', + 'DemoApp-abcdefabcdef', + 'DemoApp-Main-123456789abc', + 'manual-workspace', + ]); + expect(report.families.map((family) => [family.family, family.workspaceKeys])).toEqual([ + ['DemoApp', ['DemoApp-123456789abc', 'DemoApp-abcdefabcdef']], + ['DemoApp-Main', ['DemoApp-Main-123456789abc']], + ]); + expect( + report.workspaces.find((workspace) => workspace.workspaceKey === 'manual-workspace'), + ).toMatchObject({ + recognized: false, + family: null, + hash: null, + }); + expect(report.totals.bytes).toBeGreaterThan(0); + }); + + it('skips symlinks during recursive census scans', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const outside = path.join(appDir, 'outside'); + writeFileWithMtime(path.join(outside, 'secret.txt'), 'outside', now); + mkdirSync(layout.derivedData, { recursive: true }); + symlinkSync(outside, path.join(layout.derivedData, 'linked-outside')); + + const report = await enumeratePurgeStorage({ now }); + const workspace = report.workspaces[0]; + + expect(workspace.classes.derivedData.fileCount).toBe(0); + expect(workspace.classes.derivedData.scanComplete).toBe(false); + expect(workspace.classes.derivedData.warnings.join('\n')).toContain('symbolic link skipped'); + }); + + it('skips recent managed logs during planning', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const recentLog = path.join(layout.logs, managedLogName('recent')); + writeFileWithMtime(recentLog, 'recent', now - 1000); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped).toHaveLength(1); + expect(plan.skipped[0].path).toBe(recentLog); + expect(plan.skipped[0].reason).toContain('lifecycle visibility window'); + expect(existsSync(recentLog)).toBe(true); + }); + + it('skips helper-owned managed logs during planning', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const helperLog = path.join(layout.logs, managedSimulatorLogName(process.pid)); + writeFileWithMtime(helperLog, 'helper', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped).toHaveLength(1); + expect(plan.skipped[0].path).toBe(helperLog); + expect(plan.skipped[0].reason).toContain('active helper process'); + expect(existsSync(helperLog)).toBe(true); + }); + + it('plans managed logs and result bundles without selecting unknown files', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldLog = path.join(layout.logs, managedLogName('old')); + const unknownLog = path.join(layout.logs, 'manual.log'); + const bundle = path.join(layout.resultBundles, managedResultBundleName('old')); + writeFileWithMtime(oldLog, 'old', now - 10 * DAY_MS); + writeFileWithMtime(unknownLog, 'unknown', now - 10 * DAY_MS); + writeDirectoryWithMtime(bundle, now - 10 * DAY_MS); + writeFileWithMtime(getResultBundleCompletionMarkerPath(bundle), 'done', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs', 'resultBundles'], + now, + }); + + expect(plan.candidates.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + managedLogName('old'), + managedResultBundleName('old'), + ]); + expect(plan.candidates.some((candidate) => candidate.path === unknownLog)).toBe(false); + }); + + it('requires DerivedData to be explicitly enabled before planning DerivedData candidates', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + writeDirectoryWithMtime(path.join(layout.derivedData, 'Project-a'), now - 10 * DAY_MS); + + await expect( + planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + }), + ).rejects.toThrow('DerivedData purge requires derivedDataExplicit: true'); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + derivedDataExplicit: true, + }); + + expect(plan.candidates).toHaveLength(1); + expect(path.basename(plan.candidates[0].path)).toBe('Project-a'); + }); + + it('applies the older-than filter but otherwise deletes recent artifacts', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldDerivedData = path.join(layout.derivedData, 'OldProject'); + const newDerivedData = path.join(layout.derivedData, 'NewProject'); + const recentDerivedData = path.join(layout.derivedData, 'RecentProject'); + writeDirectoryWithMtime(oldDerivedData, now - 30 * DAY_MS); + writeDirectoryWithMtime(newDerivedData, now - 2 * DAY_MS); + writeDirectoryWithMtime(recentDerivedData, now - 1000); + + const retentionPlan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(retentionPlan.candidates.map((candidate) => path.basename(candidate.path))).toEqual([ + 'OldProject', + ]); + expect(retentionPlan.skipped.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + 'NewProject', + 'RecentProject', + ]); + + const fullPlan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + derivedDataExplicit: true, + }); + + expect(fullPlan.candidates.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + 'NewProject', + 'OldProject', + 'RecentProject', + ]); + expect(fullPlan.skipped).toHaveLength(0); + }); + + it('excludes unknown workspace keys from all and family scopes but allows explicit workspace scope', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const recognized = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const unknown = getWorkspaceFilesystemLayout('manual-workspace'); + writeFileWithMtime( + path.join(recognized.logs, managedLogName('known')), + 'known', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unknown.logs, managedLogName('unknown')), + 'unknown', + now - 10 * DAY_MS, + ); + + const allPlan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + const familyPlan = await planPurgeStorage({ + scope: { type: 'family', family: 'manual-workspace' }, + classes: ['logs'], + now, + }); + const explicitPlan = await planPurgeStorage({ + scope: { type: 'workspace', workspaceKey: 'manual-workspace' }, + classes: ['logs'], + now, + }); + + expect(allPlan.selectedWorkspaceKeys).toEqual(['DemoApp-123456789abc']); + expect(familyPlan.selectedWorkspaceKeys).toEqual([]); + expect(explicitPlan.selectedWorkspaceKeys).toEqual(['manual-workspace']); + expect(explicitPlan.candidates).toHaveLength(1); + }); + + it('plans only allowlisted stale state transients', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const staleCallTool = path.join( + layout.state, + 'xcode-ide', + 'call-tool', + 'ownerpid999999999_stale', + ); + const liveCallTool = path.join( + layout.state, + 'xcode-ide', + 'call-tool', + `ownerpid${process.pid}_live`, + ); + const staleRegistry = path.join(layout.simulatorLaunchOsLogRegistryDir, 'stale.json'); + const liveRegistry = path.join(layout.simulatorLaunchOsLogRegistryDir, 'live.json'); + writeDirectoryWithMtime(staleCallTool, now - 10 * DAY_MS); + writeDirectoryWithMtime(liveCallTool, now - 10 * DAY_MS); + writeFileWithMtime( + staleRegistry, + JSON.stringify({ owner: { pid: DEAD_PID }, helperPid: DEAD_PID }), + now - 10 * DAY_MS, + ); + writeFileWithMtime( + liveRegistry, + JSON.stringify({ owner: { pid: process.pid }, helperPid: DEAD_PID }), + now - 10 * DAY_MS, + ); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['stateTransients'], + now, + }); + + expect(plan.candidates.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + 'ownerpid999999999_stale', + 'stale.json', + ]); + expect(plan.skipped.map((candidate) => path.basename(candidate.path))).toEqual(['live.json']); + }); + + it('revalidates helper-owned managed logs before deletion', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const workspaceKey = 'DemoApp-123456789abc'; + const layout = getWorkspaceFilesystemLayout(workspaceKey); + const helperLog = path.join(layout.logs, managedSimulatorLogName(process.pid)); + writeFileWithMtime(helperLog, 'helper', now - 10 * DAY_MS); + const stat = lstatSync(helperLog); + const report = await enumeratePurgeStorage({ now }); + const plan: PurgeStoragePlan = { + action: 'dry-run', + scope: { type: 'all' }, + classes: ['logs'], + report, + selectedWorkspaceKeys: [workspaceKey], + candidates: [ + { + workspaceKey, + storageClass: 'logs', + path: helperLog, + kind: 'file', + bytes: stat.size, + fileCount: 1, + directoryCount: 0, + mtimeMs: stat.mtimeMs, + reason: 'managed log', + sidecarPaths: [], + }, + ], + skipped: [], + totals: { + bytes: stat.size, + fileCount: 1, + directoryCount: 0, + candidateCount: 1, + }, + warnings: [], + }; + + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.skipped[0].reason).toContain('active helper process'); + expect(existsSync(helperLog)).toBe(true); + }); + + it('deletes planned candidates and managed result-bundle completion markers', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const bundle = path.join(layout.resultBundles, managedResultBundleName('old')); + const marker = getResultBundleCompletionMarkerPath(bundle); + writeDirectoryWithMtime(bundle, now - 10 * DAY_MS); + writeFileWithMtime(marker, 'done', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['resultBundles'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(1); + expect(result.totals.skippedCount).toBe(0); + expect(existsSync(bundle)).toBe(false); + expect(existsSync(marker)).toBe(false); + }); + + it('reports deleted result bundles even when a sidecar cannot be unlinked', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const bundle = path.join(layout.resultBundles, managedResultBundleName('old')); + const marker = getResultBundleCompletionMarkerPath(bundle); + writeDirectoryWithMtime(bundle, now - 10 * DAY_MS); + mkdirSync(marker, { recursive: true }); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['resultBundles'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(1); + expect(result.totals.skippedCount).toBe(0); + expect(result.warnings.join('\n')).toContain('Sidecar'); + expect(existsSync(bundle)).toBe(false); + expect(existsSync(marker)).toBe(true); + }); + + it('deletes orphan result-bundle completion temp markers', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const tempMarker = path.join( + layout.resultBundles, + `${managedResultBundleName('old')}.xcodebuildmcp-completed.1234_abcd1234.tmp`, + ); + writeFileWithMtime(tempMarker, 'tmp', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['resultBundles'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(plan.candidates.map((candidate) => candidate.path)).toEqual([tempMarker]); + expect(result.totals.deletedCount).toBe(1); + expect(result.totals.skippedCount).toBe(0); + expect(existsSync(tempMarker)).toBe(false); + }); + + it('skips deletion when the per-workspace lifecycle lock is held', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldLog = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(oldLog, 'old', now - 10 * DAY_MS); + mkdirSync(layout.filesystemLifecycle.lockDir, { recursive: true }); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.warnings.join('\n')).toContain('filesystem lock is held'); + expect(existsSync(oldLog)).toBe(true); + }); + + it('reports planned candidates that disappear before deletion as skipped', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldLog = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(oldLog, 'old', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + rmSync(oldLog, { force: true }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.skipped[0].reason).toContain('disappeared'); + }); + + it('revalidates containment and symlink ancestors under the lock before deleting', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const project = path.join(layout.derivedData, 'Project-a'); + const outsideDerivedData = path.join(appDir, 'outside-derived-data'); + const outsideProject = path.join(outsideDerivedData, 'Project-a'); + writeDirectoryWithMtime(project, now - 10 * DAY_MS); + writeDirectoryWithMtime(outsideProject, now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + derivedDataExplicit: true, + }); + rmSync(layout.derivedData, { recursive: true, force: true }); + symlinkSync(outsideDerivedData, layout.derivedData); + + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.skipped[0].reason).toContain('symbolic link'); + expect(existsSync(outsideProject)).toBe(true); + }); + + it('uses the newest descendant mtime for the older-than filter, not the directory mtime', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const staleDirFreshFile = path.join(layout.derivedData, 'StaleDirFreshFile'); + mkdirSync(staleDirFreshFile, { recursive: true }); + writeFileWithMtime(path.join(staleDirFreshFile, 'fresh.txt'), 'fresh', now - 1000); + const oldMtime = new Date(now - 30 * DAY_MS); + utimesSync(staleDirFreshFile, oldMtime, oldMtime); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped.map((candidate) => path.basename(candidate.path))).toEqual([ + 'StaleDirFreshFile', + ]); + }); +}); diff --git a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts index 1cd8b5893..0d45666c8 100644 --- a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts +++ b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts @@ -7,6 +7,11 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { cleanupOwnedWorkspaceFilesystemArtifacts, + getManagedResultBundleOwnerPid, + getXcodeIdeCallToolTransientOwnerPid, + isStaleXcodeIdeCallToolTransientDirectoryName, + isXcodeBuildMCPManagedLogName, + isXcodeBuildMCPManagedResultBundleName, resetWorkspaceFilesystemLifecycleStateForTests, runWorkspaceFilesystemLifecycleSweep, scheduleWorkspaceFilesystemLifecycleSweep, @@ -81,6 +86,22 @@ describe('workspace filesystem lifecycle', () => { await rm(appDir, { recursive: true, force: true }); }); + it('exposes cleanup classification helpers without broadening managed artifact matches', () => { + const logName = managedXcodebuildLogName(); + const resultBundleName = managedResultBundleName('test', 123); + + expect(isXcodeBuildMCPManagedLogName(logName)).toBe(true); + expect(isXcodeBuildMCPManagedLogName('manual.log')).toBe(false); + expect(isXcodeBuildMCPManagedResultBundleName(resultBundleName)).toBe(true); + expect(isXcodeBuildMCPManagedResultBundleName('manual.xcresult')).toBe(false); + expect(getManagedResultBundleOwnerPid(resultBundleName)).toBe(123); + expect(getXcodeIdeCallToolTransientOwnerPid('ownerpid999999999_stale')).toBe(999999999); + expect(isStaleXcodeIdeCallToolTransientDirectoryName('ownerpid999999999_stale')).toBe(true); + expect(isStaleXcodeIdeCallToolTransientDirectoryName(`ownerpid${process.pid}_live`)).toBe( + false, + ); + }); + it('prunes only known workspace log files and never scans DerivedData', async () => { const now = Date.UTC(2026, 4, 2, 12); const layout = getWorkspaceFilesystemLayout('workspace-a'); diff --git a/src/utils/fs-lock-sync.ts b/src/utils/fs-lock-sync.ts index b10b18e55..146d40134 100644 --- a/src/utils/fs-lock-sync.ts +++ b/src/utils/fs-lock-sync.ts @@ -103,10 +103,10 @@ function tryRecoverExpiredLockDir( function createLock(lockDir: string, owner: FsLockOwner): AcquiredFsLockSync { mkdirSync(lockDir, { mode: 0o700 }); try { - writeFileSync(join(lockDir, FS_LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`, { - encoding: 'utf8', - mode: 0o600, - }); + const ownerPath = join(lockDir, FS_LOCK_OWNER_FILE); + const tempPath = `${ownerPath}.${randomUUID()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(tempPath, ownerPath); } catch (error) { rmSync(lockDir, { recursive: true, force: true }); throw error; diff --git a/src/utils/fs-lock.ts b/src/utils/fs-lock.ts index 8d0660f30..09f9c4554 100644 --- a/src/utils/fs-lock.ts +++ b/src/utils/fs-lock.ts @@ -128,10 +128,10 @@ async function tryRecoverExpiredLockDir( async function createLock(lockDir: string, owner: FsLockOwner): Promise { await fs.mkdir(lockDir, { mode: 0o700 }); try { - await fs.writeFile(path.join(lockDir, FS_LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`, { - encoding: 'utf8', - mode: 0o600, - }); + const ownerPath = path.join(lockDir, FS_LOCK_OWNER_FILE); + const tempPath = `${ownerPath}.${randomUUID()}.tmp`; + await fs.writeFile(tempPath, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 }); + await fs.rename(tempPath, ownerPath); } catch (error) { await removeLockDir(lockDir); throw error; @@ -168,7 +168,7 @@ async function tryAcquireGuard( return await createLock(guardDir, guardOwner); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - return null; + throw error; } const recovered = await tryRecoverExpiredLockDir(guardDir, guardOwner.purpose, now, leaseMs); if (!recovered) { @@ -176,8 +176,11 @@ async function tryAcquireGuard( } try { return await createLock(guardDir, guardOwner); - } catch { - return null; + } catch (retryError) { + if ((retryError as NodeJS.ErrnoException).code === 'EEXIST') { + return null; + } + throw retryError; } } } @@ -194,39 +197,34 @@ export async function tryAcquireFsLock( expiresAtMs: now + options.leaseMs, }; + await fs.mkdir(path.dirname(options.lockDir), { recursive: true, mode: 0o700 }); + const guard = await tryAcquireGuard(options.lockDir, options.purpose, options.leaseMs, now); + if (!guard) { + return null; + } + try { - await fs.mkdir(path.dirname(options.lockDir), { recursive: true, mode: 0o700 }); - const guard = await tryAcquireGuard(options.lockDir, options.purpose, options.leaseMs, now); - if (!guard) { - return null; - } + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await createLock(options.lockDir, owner); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } - try { - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - return await createLock(options.lockDir, owner); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST') { - return null; - } - - const recovered = await tryRecoverExpiredLockDir( - options.lockDir, - options.purpose, - now, - options.leaseMs, - ); - if (!recovered) { - return null; - } + const recovered = await tryRecoverExpiredLockDir( + options.lockDir, + options.purpose, + now, + options.leaseMs, + ); + if (!recovered) { + return null; } } - } finally { - await guard.release(); } - } catch { - return null; + } finally { + await guard.release(); } return null; diff --git a/src/utils/log-paths.ts b/src/utils/log-paths.ts index 234042b01..b6faf7ff8 100644 --- a/src/utils/log-paths.ts +++ b/src/utils/log-paths.ts @@ -44,6 +44,9 @@ function normalizeWorkspaceKey(workspaceKey: string): string { if (normalized.includes('/') || normalized.includes('\\')) { throw new Error(`Workspace key cannot contain path separators: ${workspaceKey}`); } + if (normalized === '.' || normalized === '..') { + throw new Error(`Workspace key cannot be a relative path segment: ${workspaceKey}`); + } return normalized; } diff --git a/src/utils/purge-storage.ts b/src/utils/purge-storage.ts new file mode 100644 index 000000000..4b2bc814a --- /dev/null +++ b/src/utils/purge-storage.ts @@ -0,0 +1,23 @@ +export { + PURGE_STORAGE_CLASSES, + PURGE_STORAGE_DELETABLE_CLASSES, + type EnumeratePurgeStorageOptions, + type ExecutePurgeStoragePlanOptions, + type PlanPurgeStorageOptions, + type PurgeStorageCandidate, + type PurgeStorageClass, + type PurgeStorageClassCensus, + type PurgeStorageDeletableClass, + type PurgeStorageDeletedEntry, + type PurgeStorageExecutionResult, + type PurgeStoragePlan, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeStorageSkippedCandidate, + type PurgeWorkspaceFamilySummary, + type PurgeWorkspaceSummary, + type WorkspaceFamilyKeyParts, +} from './purge-storage/types.ts'; +export { enumeratePurgeStorage, parseWorkspaceFamilyKey } from './purge-storage/enumerate.ts'; +export { planPurgeStorage } from './purge-storage/planning.ts'; +export { executePurgeStoragePlan } from './purge-storage/execution.ts'; diff --git a/src/utils/purge-storage/enumerate.ts b/src/utils/purge-storage/enumerate.ts new file mode 100644 index 000000000..fdc330fef --- /dev/null +++ b/src/utils/purge-storage/enumerate.ts @@ -0,0 +1,224 @@ +import type { Dirent } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { getWorkspaceFilesystemLayout, getWorkspacesDir } from '../log-paths.ts'; +import { + PURGE_STORAGE_CLASSES, + type EnumeratePurgeStorageOptions, + type PurgeStorageClass, + type PurgeStorageClassCensus, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeWorkspaceFamilySummary, + type PurgeWorkspaceSummary, + type WorkspaceFamilyKeyParts, +} from './types.ts'; +import { isEnoent, scanPath, warningForPath, zeroAccumulator } from './scan.ts'; + +const WORKSPACE_FAMILY_KEY_PATTERN = /^(.+)-([a-f0-9]{12})$/u; + +export function parseWorkspaceFamilyKey(workspaceKey: string): WorkspaceFamilyKeyParts | null { + const match = workspaceKey.match(WORKSPACE_FAMILY_KEY_PATTERN); + if (!match) { + return null; + } + return { family: match[1], hash: match[2] }; +} + +function classPathForWorkspace(workspaceKey: string, storageClass: PurgeStorageClass): string { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + switch (storageClass) { + case 'derivedData': + return layout.derivedData; + case 'logs': + return layout.logs; + case 'resultBundles': + return layout.resultBundles; + case 'stateTransients': + return layout.state; + case 'locks': + return layout.locks; + } +} + +async function scanClass( + workspaceKey: string, + storageClass: PurgeStorageClass, +): Promise { + const classPath = classPathForWorkspace(workspaceKey, storageClass); + let exists = true; + try { + await fs.lstat(classPath); + } catch (error) { + if (isEnoent(error)) { + exists = false; + } + } + + const scan = exists ? await scanPath(classPath) : zeroAccumulator(); + return { + storageClass, + path: classPath, + exists, + bytes: scan.bytes, + fileCount: scan.fileCount, + directoryCount: scan.directoryCount, + latestMtimeMs: scan.latestMtimeMs, + scanComplete: scan.scanComplete, + cleanupEligible: storageClass !== 'locks', + warnings: scan.warnings, + }; +} + +function classRecordFromEntries( + entries: [PurgeStorageClass, PurgeStorageClassCensus][], +): Record { + return Object.fromEntries(entries) as Record; +} + +function sumStorageTotals( + items: T[], + select: (item: T) => { bytes: number; fileCount: number; directoryCount: number }, +): { bytes: number; fileCount: number; directoryCount: number } { + return items.reduce( + (totals, item) => { + const value = select(item); + return { + bytes: totals.bytes + value.bytes, + fileCount: totals.fileCount + value.fileCount, + directoryCount: totals.directoryCount + value.directoryCount, + }; + }, + { bytes: 0, fileCount: 0, directoryCount: 0 }, + ); +} + +async function summarizeWorkspace(workspaceKey: string): Promise { + const parsed = parseWorkspaceFamilyKey(workspaceKey); + const workspaceRoot = getWorkspaceFilesystemLayout(workspaceKey).root; + const classEntries = await Promise.all( + PURGE_STORAGE_CLASSES.map(async (storageClass) => { + const census = await scanClass(workspaceKey, storageClass); + return [storageClass, census] satisfies [PurgeStorageClass, PurgeStorageClassCensus]; + }), + ); + const classes = classRecordFromEntries(classEntries); + const classValues = Object.values(classes); + const warnings = classValues.flatMap((census) => census.warnings); + if (!parsed) { + warnings.push( + `${workspaceRoot}: unknown workspace key; only explicit workspace scope can purge it`, + ); + } + + return { + workspaceKey, + path: workspaceRoot, + recognized: parsed !== null, + family: parsed?.family ?? null, + hash: parsed?.hash ?? null, + classes, + totals: sumStorageTotals(classValues, (census) => census), + warnings, + }; +} + +function buildFamilySummaries(workspaces: PurgeWorkspaceSummary[]): PurgeWorkspaceFamilySummary[] { + const families = new Map(); + for (const workspace of workspaces) { + if (!workspace.recognized || workspace.family === null) { + continue; + } + const family = families.get(workspace.family) ?? { + family: workspace.family, + workspaceKeys: [], + bytes: 0, + }; + family.workspaceKeys.push(workspace.workspaceKey); + family.bytes += workspace.totals.bytes; + families.set(workspace.family, family); + } + + return Array.from(families.values()).sort((left, right) => + left.family.localeCompare(right.family), + ); +} + +function workspaceKeyMatchesScope(workspaceKey: string, scope: PurgeStorageScope): boolean { + switch (scope.type) { + case 'workspace': + return workspaceKey === scope.workspaceKey; + case 'workspaces': + return scope.workspaceKeys.includes(workspaceKey); + case 'family': { + const parsed = parseWorkspaceFamilyKey(workspaceKey); + return parsed !== null && parsed.family === scope.family; + } + case 'all': + return parseWorkspaceFamilyKey(workspaceKey) !== null; + } +} + +function workspaceKeysForScope( + workspaceKeys: string[], + scope: PurgeStorageScope | undefined, +): string[] { + if (scope === undefined) { + return workspaceKeys; + } + return workspaceKeys.filter((workspaceKey) => workspaceKeyMatchesScope(workspaceKey, scope)); +} + +async function listWorkspaceKeys(warnings: string[]): Promise { + const workspacesDir = getWorkspacesDir(); + let entries: Dirent[]; + try { + entries = await fs.readdir(workspacesDir, { withFileTypes: true }); + } catch (error) { + if (isEnoent(error)) { + return []; + } + warnings.push(error instanceof Error ? error.message : String(error)); + return []; + } + + const workspaceKeys: string[] = []; + for (const entry of entries) { + const entryPath = path.join(workspacesDir, entry.name); + if (entry.name.includes('/') || entry.name.includes('\\')) { + warnings.push( + warningForPath(entryPath, 'workspace key contains a path separator and was skipped'), + ); + continue; + } + if (entry.isSymbolicLink()) { + warnings.push(warningForPath(entryPath, 'workspace symbolic link skipped')); + continue; + } + if (!entry.isDirectory()) { + continue; + } + workspaceKeys.push(entry.name); + } + return workspaceKeys.sort((left, right) => left.localeCompare(right)); +} + +export async function enumeratePurgeStorage( + options: EnumeratePurgeStorageOptions = {}, +): Promise { + const warnings: string[] = []; + const workspacesDir = getWorkspacesDir(); + const workspaceKeys = workspaceKeysForScope(await listWorkspaceKeys(warnings), options.scope); + const workspaces = await Promise.all(workspaceKeys.map(summarizeWorkspace)); + const families = buildFamilySummaries(workspaces); + warnings.push(...workspaces.flatMap((workspace) => workspace.warnings)); + + return { + appRoot: path.dirname(workspacesDir), + workspacesDir, + workspaces, + families, + totals: sumStorageTotals(workspaces, (workspace) => workspace.totals), + warnings, + }; +} diff --git a/src/utils/purge-storage/execution.ts b/src/utils/purge-storage/execution.ts new file mode 100644 index 000000000..6e89c52d3 --- /dev/null +++ b/src/utils/purge-storage/execution.ts @@ -0,0 +1,394 @@ +import type { Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { tryAcquireFsLock, type AcquiredFsLock } from '../fs-lock.ts'; +import { getWorkspaceFilesystemLayout, getWorkspacesDir } from '../log-paths.ts'; +import { isPidAlive } from '../process-liveness.ts'; +import { + getResultBundleCompletionMarkerPath, + isResultBundleCompletionMarkerTempName, +} from '../result-bundle-path.ts'; +import { + WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + collectWorkspaceLifecycleProtectedLogPaths, + getWorkspaceLifecycleProtectedLogReason, + isStaleXcodeIdeCallToolTransientDirectoryName, + isWorkspaceLifecycleProtectedResultBundleDirectory, + isXcodeBuildMCPManagedLogName, + isXcodeBuildMCPManagedResultBundleName, + xcodeIdeCallToolTransientRoot, + type WorkspaceLifecycleLogProtectionReason, +} from '../workspace-filesystem-lifecycle.ts'; +import { readRegistryRecord } from './registry-record.ts'; +import { describeFsError, errorMessage, isEnoent } from './scan.ts'; +import type { + ExecutePurgeStoragePlanOptions, + PurgeStorageCandidate, + PurgeStorageDeletableClass, + PurgeStorageDeletedEntry, + PurgeStorageExecutionResult, + PurgeStoragePlan, + PurgeStorageSkippedCandidate, +} from './types.ts'; + +const PURGE_LOCK_PURPOSE = 'filesystem-lifecycle'; + +function pathIsInside(parentPath: string, childPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function lifecycleLogProtectionReasonText(reason: WorkspaceLifecycleLogProtectionReason): string { + switch (reason) { + case 'protectedPath': + return 'protected by active lifecycle owner'; + case 'recent': + return 'protected by lifecycle visibility window'; + case 'liveHelperPid': + return 'protected by active helper process'; + } +} + +function deletionRootForClass( + workspaceKey: string, + storageClass: PurgeStorageDeletableClass, +): string { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + switch (storageClass) { + case 'derivedData': + return layout.derivedData; + case 'logs': + return layout.logs; + case 'resultBundles': + return layout.resultBundles; + case 'stateTransients': + return layout.state; + } +} + +async function pathContainsSymlink(filePath: string, rootPath: string): Promise { + const relative = path.relative(rootPath, filePath); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + return true; + } + + let current = rootPath; + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + return true; + } + } catch { + return true; + } + } + return false; +} + +async function validateStateTransientCandidate( + candidate: PurgeStorageCandidate, +): Promise { + const layout = getWorkspaceFilesystemLayout(candidate.workspaceKey); + const name = path.basename(candidate.path); + const callToolRoot = xcodeIdeCallToolTransientRoot(candidate.workspaceKey); + if ( + path.dirname(candidate.path) === callToolRoot && + isStaleXcodeIdeCallToolTransientDirectoryName(name) + ) { + return null; + } + if ( + path.dirname(candidate.path) !== layout.simulatorLaunchOsLogRegistryDir || + !name.endsWith('.json') + ) { + return 'state transient candidate is outside allowlisted transient roots'; + } + + const result = await readRegistryRecord(candidate.path); + if (result.status === 'unreadable') { + return 'state transient candidate registry record is unreadable; protected'; + } + if ( + result.status === 'record' && + (isPidAlive(result.record.ownerPid) || isPidAlive(result.record.helperPid)) + ) { + return 'state transient candidate is protected by active OSLog owner'; + } + return null; +} + +async function validateClassSpecificDeletionCandidate( + candidate: PurgeStorageCandidate, + now: number, +): Promise { + const name = path.basename(candidate.path); + switch (candidate.storageClass) { + case 'derivedData': + return null; + case 'logs': { + if (!isXcodeBuildMCPManagedLogName(name)) { + return 'log candidate is not a managed log'; + } + const protectionReason = getWorkspaceLifecycleProtectedLogReason( + { path: candidate.path, name, mtimeMs: candidate.mtimeMs }, + { + now, + minVisibleMs: WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + protectedPaths: await collectWorkspaceLifecycleProtectedLogPaths({ + workspaceKey: candidate.workspaceKey, + }), + }, + ); + if (protectionReason) { + return `log candidate is ${lifecycleLogProtectionReasonText(protectionReason)}`; + } + return null; + } + case 'resultBundles': + if (isResultBundleCompletionMarkerTempName(name)) { + return candidate.kind === 'file' + ? null + : 'result bundle temp marker candidate is not a file'; + } + if (!isXcodeBuildMCPManagedResultBundleName(name)) { + return 'result bundle candidate is not managed'; + } + if ( + await isWorkspaceLifecycleProtectedResultBundleDirectory( + { name, path: candidate.path, mtimeMs: candidate.mtimeMs }, + { now, minVisibleMs: 0 }, + ) + ) { + return 'result bundle candidate is protected by active lifecycle owner'; + } + return null; + case 'stateTransients': + return validateStateTransientCandidate(candidate); + } +} + +async function validateDeletionPath( + candidate: PurgeStorageCandidate, + now: number, +): Promise { + const workspaceLayout = getWorkspaceFilesystemLayout(candidate.workspaceKey); + const root = deletionRootForClass(candidate.workspaceKey, candidate.storageClass); + if (candidate.path === root || !pathIsInside(root, candidate.path)) { + return 'candidate is outside the intended class root'; + } + if (!pathIsInside(workspaceLayout.root, candidate.path)) { + return 'candidate is outside the workspace root'; + } + if (await pathContainsSymlink(candidate.path, getWorkspacesDir())) { + return 'candidate path contains a symbolic link or disappeared'; + } + const stableError = await validateCandidateStillMatchesPlan(candidate); + if (stableError) { + return stableError; + } + return validateClassSpecificDeletionCandidate(candidate, now); +} + +async function validateCandidateStillMatchesPlan( + candidate: PurgeStorageCandidate, +): Promise { + let stat: Stats; + try { + stat = await fs.lstat(candidate.path); + } catch (error) { + return isEnoent(error) ? 'path disappeared during deletion' : String(error); + } + if (stat.isSymbolicLink()) { + return 'candidate path contains a symbolic link or disappeared'; + } + if (candidate.kind === 'directory' && !stat.isDirectory()) { + return 'candidate kind changed since planning'; + } + if (candidate.kind === 'file' && !stat.isFile()) { + return 'candidate kind changed since planning'; + } + if (stat.mtimeMs !== candidate.mtimeMs) { + return 'candidate changed since planning'; + } + return null; +} + +async function validateSidecarPath( + candidate: PurgeStorageCandidate, + sidecarPath: string, +): Promise { + if (candidate.storageClass !== 'resultBundles') { + return 'sidecar path is not associated with a result bundle'; + } + if (sidecarPath !== getResultBundleCompletionMarkerPath(candidate.path)) { + return 'sidecar path does not match the candidate completion marker'; + } + const layout = getWorkspaceFilesystemLayout(candidate.workspaceKey); + if (!pathIsInside(layout.resultBundles, sidecarPath)) { + return 'sidecar path is outside result-bundles'; + } + try { + await fs.lstat(sidecarPath); + } catch (error) { + return isEnoent(error) ? null : errorMessage(error); + } + if (await pathContainsSymlink(sidecarPath, getWorkspacesDir())) { + return 'sidecar path contains a symbolic link or disappeared'; + } + return null; +} + +async function deleteCandidate(candidate: PurgeStorageCandidate): Promise { + const warnings: string[] = []; + if (candidate.kind === 'directory') { + await fs.rm(candidate.path, { recursive: true, force: false }); + } else { + await fs.unlink(candidate.path); + } + + for (const sidecarPath of candidate.sidecarPaths) { + const sidecarError = await validateSidecarPath(candidate, sidecarPath); + if (sidecarError) { + continue; + } + await fs.unlink(sidecarPath).catch((error: unknown) => { + if (!isEnoent(error)) { + warnings.push(`Sidecar ${sidecarPath} was not deleted: ${errorMessage(error)}`); + } + }); + } + return warnings; +} + +function groupCandidatesByWorkspace( + candidates: PurgeStorageCandidate[], +): Map { + const candidatesByWorkspace = new Map(); + for (const candidate of candidates) { + const existing = candidatesByWorkspace.get(candidate.workspaceKey) ?? []; + existing.push(candidate); + candidatesByWorkspace.set(candidate.workspaceKey, existing); + } + return candidatesByWorkspace; +} + +function skipWorkspaceCandidates(params: { + workspaceKey: string; + candidates: PurgeStorageCandidate[]; + message: string; + skipped: PurgeStorageSkippedCandidate[]; +}): void { + for (const candidate of params.candidates) { + params.skipped.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + reason: params.message, + }); + } +} + +async function executeWorkspaceCandidates(params: { + workspaceKey: string; + candidates: PurgeStorageCandidate[]; + deleted: PurgeStorageDeletedEntry[]; + skipped: PurgeStorageSkippedCandidate[]; + warnings: string[]; + now: number; +}): Promise { + for (const candidate of params.candidates) { + const validationError = await validateDeletionPath(candidate, params.now); + if (validationError) { + params.skipped.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + reason: validationError, + }); + continue; + } + + try { + params.warnings.push(...(await deleteCandidate(candidate))); + params.deleted.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + bytes: candidate.bytes, + }); + } catch (error) { + params.skipped.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + reason: describeFsError(error, 'path disappeared during deletion'), + }); + } + } +} + +export async function executePurgeStoragePlan( + plan: PurgeStoragePlan, + options: ExecutePurgeStoragePlanOptions = {}, +): Promise { + const deleted: PurgeStorageDeletedEntry[] = []; + const skipped: PurgeStorageSkippedCandidate[] = [...plan.skipped]; + const warnings: string[] = [...plan.warnings]; + + const now = options.now ?? Date.now(); + for (const [workspaceKey, candidates] of groupCandidatesByWorkspace(plan.candidates).entries()) { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + let lock: AcquiredFsLock | null; + try { + lock = await tryAcquireFsLock({ + lockDir: layout.filesystemLifecycle.lockDir, + purpose: PURGE_LOCK_PURPOSE, + leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + now, + }); + } catch (error) { + const message = `Workspace ${workspaceKey} skipped because its filesystem lock could not be acquired: ${errorMessage(error)}`; + warnings.push(message); + skipWorkspaceCandidates({ workspaceKey, candidates, message, skipped }); + continue; + } + + if (!lock) { + const message = `Workspace ${workspaceKey} skipped because its filesystem lock is held`; + warnings.push(message); + skipWorkspaceCandidates({ workspaceKey, candidates, message, skipped }); + continue; + } + + try { + await executeWorkspaceCandidates({ + workspaceKey, + candidates, + deleted, + skipped, + warnings, + now, + }); + } finally { + await lock.release(); + } + } + + return { + action: 'delete', + scope: plan.scope, + classes: plan.classes, + selectedWorkspaceKeys: plan.selectedWorkspaceKeys, + deleted, + skipped, + totals: { + bytes: deleted.reduce((total, entry) => total + entry.bytes, 0), + deletedCount: deleted.length, + skippedCount: skipped.length, + }, + warnings, + }; +} diff --git a/src/utils/purge-storage/planning.ts b/src/utils/purge-storage/planning.ts new file mode 100644 index 000000000..021d0e1aa --- /dev/null +++ b/src/utils/purge-storage/planning.ts @@ -0,0 +1,522 @@ +import type { Dirent, Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { getWorkspaceFilesystemLayout } from '../log-paths.ts'; +import { isPidAlive } from '../process-liveness.ts'; +import { + getResultBundleCompletionMarkerPath, + isResultBundleCompletionMarkerTempName, +} from '../result-bundle-path.ts'; +import { + WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + collectWorkspaceLifecycleProtectedLogPaths, + getWorkspaceLifecycleProtectedLogReason, + isStaleXcodeIdeCallToolTransientDirectoryName, + isWorkspaceLifecycleProtectedResultBundleDirectory, + isXcodeBuildMCPManagedLogName, + isXcodeBuildMCPManagedResultBundleName, + xcodeIdeCallToolTransientRoot, + type WorkspaceLifecycleLogProtectionReason, +} from '../workspace-filesystem-lifecycle.ts'; +import { enumeratePurgeStorage } from './enumerate.ts'; +import { readRegistryRecord } from './registry-record.ts'; +import { describeFsError, errorMessage, isEnoent, scanPath } from './scan.ts'; +import type { + PlanPurgeStorageOptions, + PurgeStorageCandidate, + PurgeStorageDeletableClass, + PurgeStoragePlan, + PurgeStorageReport, + PurgeStorageScope, + PurgeStorageSkippedCandidate, + PurgeWorkspaceSummary, + RequiredPlanContext, +} from './types.ts'; + +function assertValidPlanOptions(options: PlanPurgeStorageOptions): void { + if (options.classes.includes('derivedData') && options.derivedDataExplicit !== true) { + throw new Error('DerivedData purge requires derivedDataExplicit: true'); + } +} + +function selectedWorkspacesForScope( + report: PurgeStorageReport, + scope: PurgeStorageScope, + warnings: string[], +): PurgeWorkspaceSummary[] { + switch (scope.type) { + case 'workspace': { + const workspace = report.workspaces.find( + (entry) => entry.workspaceKey === scope.workspaceKey, + ); + if (!workspace) { + warnings.push(`Workspace ${scope.workspaceKey} was not found`); + return []; + } + return [workspace]; + } + case 'workspaces': { + const workspaceKeys = new Set(scope.workspaceKeys); + const selected = report.workspaces.filter((workspace) => + workspaceKeys.has(workspace.workspaceKey), + ); + for (const workspaceKey of workspaceKeys) { + if (!selected.some((workspace) => workspace.workspaceKey === workspaceKey)) { + warnings.push(`Workspace ${workspaceKey} was not found`); + } + } + return selected; + } + case 'family': + return report.workspaces.filter( + (workspace) => workspace.recognized && workspace.family === scope.family, + ); + case 'all': + return report.workspaces.filter((workspace) => workspace.recognized); + } +} + +function shouldKeepForRetention(params: { + mtimeMs: number; + now: number; + olderThanMs?: number; +}): string | null { + if (params.olderThanMs !== undefined && params.now - params.mtimeMs <= params.olderThanMs) { + return `newer than retention filter (${params.olderThanMs}ms)`; + } + return null; +} + +function lifecycleLogProtectionReasonText(reason: WorkspaceLifecycleLogProtectionReason): string { + switch (reason) { + case 'protectedPath': + return 'protected by active lifecycle owner'; + case 'recent': + return 'protected by lifecycle visibility window'; + case 'liveHelperPid': + return 'protected by active helper process'; + } +} + +async function candidateFromPath(params: { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + candidatePath: string; + reason: string; + now: number; + olderThanMs?: number; + sidecarPaths?: string[]; +}): Promise { + const skip = (reason: string): PurgeStorageSkippedCandidate => ({ + workspaceKey: params.workspaceKey, + storageClass: params.storageClass, + path: params.candidatePath, + reason, + }); + + let stat: Stats; + try { + stat = await fs.lstat(params.candidatePath); + } catch (error) { + return skip(describeFsError(error, 'path disappeared during planning')); + } + + if (stat.isSymbolicLink()) { + return skip('symbolic link skipped'); + } + + if (!stat.isFile() && !stat.isDirectory()) { + return skip('non-regular filesystem entry skipped'); + } + + const scan = await scanPath(params.candidatePath); + const effectiveMtimeMs = + scan.latestMtimeMs === null ? stat.mtimeMs : Math.max(stat.mtimeMs, scan.latestMtimeMs); + + const retentionReason = shouldKeepForRetention({ + mtimeMs: effectiveMtimeMs, + now: params.now, + olderThanMs: params.olderThanMs, + }); + if (retentionReason) { + return skip(retentionReason); + } + + return { + workspaceKey: params.workspaceKey, + storageClass: params.storageClass, + path: params.candidatePath, + kind: stat.isDirectory() ? 'directory' : 'file', + bytes: scan.bytes, + fileCount: scan.fileCount, + directoryCount: scan.directoryCount, + mtimeMs: stat.mtimeMs, + reason: params.reason, + sidecarPaths: params.sidecarPaths ?? [], + }; +} + +function isCandidate( + candidate: PurgeStorageCandidate | PurgeStorageSkippedCandidate, +): candidate is PurgeStorageCandidate { + return 'bytes' in candidate; +} + +async function readManagedDir(dir: string): Promise<{ entries: Dirent[]; error: string | null }> { + let stat: Stats; + try { + stat = await fs.lstat(dir); + } catch (error) { + return { entries: [], error: isEnoent(error) ? null : errorMessage(error) }; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { entries: [], error: null }; + } + try { + return { entries: await fs.readdir(dir, { withFileTypes: true }), error: null }; + } catch (error) { + return { entries: [], error: isEnoent(error) ? null : errorMessage(error) }; + } +} + +function unreadableDirSkip( + workspaceKey: string, + storageClass: PurgeStorageDeletableClass, + dir: string, + error: string, +): PurgeStorageSkippedCandidate { + return { + workspaceKey, + storageClass, + path: dir, + reason: `directory unreadable; skipped (${error})`, + }; +} + +async function collectDerivedDataCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const derivedDataDir = getWorkspaceFilesystemLayout(workspaceKey).derivedData; + const { entries, error } = await readManagedDir(derivedDataDir); + const candidates = await Promise.all( + entries.map((entry) => + candidateFromPath({ + workspaceKey, + storageClass: 'derivedData', + candidatePath: path.join(derivedDataDir, entry.name), + reason: 'explicit DerivedData purge', + ...options, + }), + ), + ); + return error + ? [unreadableDirSkip(workspaceKey, 'derivedData', derivedDataDir, error), ...candidates] + : candidates; +} + +async function collectLogCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + const { entries, error } = await readManagedDir(layout.logs); + const protectedPaths = await collectWorkspaceLifecycleProtectedLogPaths({ workspaceKey }); + const candidates = entries + .filter((entry) => entry.isFile() && isXcodeBuildMCPManagedLogName(entry.name)) + .map((entry) => ({ name: entry.name, path: path.join(layout.logs, entry.name) })); + + const planned = await Promise.all( + candidates.map(async (candidate) => { + let stat: Stats; + try { + stat = await fs.lstat(candidate.path); + } catch (error) { + return { + workspaceKey, + storageClass: 'logs' as const, + path: candidate.path, + reason: describeFsError(error, 'path disappeared during planning'), + }; + } + + if (!stat.isFile()) { + return { + workspaceKey, + storageClass: 'logs' as const, + path: candidate.path, + reason: 'non-regular filesystem entry skipped', + }; + } + + const protectionReason = getWorkspaceLifecycleProtectedLogReason( + { path: candidate.path, name: candidate.name, mtimeMs: stat.mtimeMs }, + { + now: options.now, + minVisibleMs: WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + protectedPaths, + }, + ); + if (protectionReason) { + return { + workspaceKey, + storageClass: 'logs' as const, + path: candidate.path, + reason: lifecycleLogProtectionReasonText(protectionReason), + }; + } + + return candidateFromPath({ + workspaceKey, + storageClass: 'logs', + candidatePath: candidate.path, + reason: 'managed log', + ...options, + }); + }), + ); + return error + ? [unreadableDirSkip(workspaceKey, 'logs', layout.logs, error), ...planned] + : planned; +} + +async function collectResultBundleCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + const { entries, error } = await readManagedDir(layout.resultBundles); + const candidates = entries + .filter((entry) => entry.isDirectory() && isXcodeBuildMCPManagedResultBundleName(entry.name)) + .map((entry) => ({ + name: entry.name, + bundlePath: path.join(layout.resultBundles, entry.name), + })); + const tempMarkers = entries + .filter((entry) => entry.isFile() && isResultBundleCompletionMarkerTempName(entry.name)) + .map((entry) => path.join(layout.resultBundles, entry.name)); + + const planned: Array = error + ? [unreadableDirSkip(workspaceKey, 'resultBundles', layout.resultBundles, error)] + : []; + for (const tempMarker of tempMarkers) { + planned.push( + await candidateFromPath({ + workspaceKey, + storageClass: 'resultBundles', + candidatePath: tempMarker, + reason: 'orphan result bundle completion temp marker', + ...options, + }), + ); + } + for (const candidate of candidates) { + let stat: Stats; + try { + stat = await fs.lstat(candidate.bundlePath); + } catch (error) { + planned.push({ + workspaceKey, + storageClass: 'resultBundles', + path: candidate.bundlePath, + reason: describeFsError(error, 'path disappeared during planning'), + }); + continue; + } + + if ( + await isWorkspaceLifecycleProtectedResultBundleDirectory( + { name: candidate.name, path: candidate.bundlePath, mtimeMs: stat.mtimeMs }, + { now: options.now, minVisibleMs: 0 }, + ) + ) { + planned.push({ + workspaceKey, + storageClass: 'resultBundles', + path: candidate.bundlePath, + reason: 'protected by active lifecycle owner', + }); + continue; + } + + planned.push( + await candidateFromPath({ + workspaceKey, + storageClass: 'resultBundles', + candidatePath: candidate.bundlePath, + reason: 'managed result bundle', + sidecarPaths: [getResultBundleCompletionMarkerPath(candidate.bundlePath)], + ...options, + }), + ); + } + return planned; +} + +async function collectSimulatorOsLogRegistryCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const registryDir = getWorkspaceFilesystemLayout(workspaceKey).simulatorLaunchOsLogRegistryDir; + const { entries, error } = await readManagedDir(registryDir); + const candidates: Array = error + ? [unreadableDirSkip(workspaceKey, 'stateTransients', registryDir, error)] + : []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + const candidatePath = path.join(registryDir, entry.name); + const result = await readRegistryRecord(candidatePath); + if (result.status === 'unreadable') { + candidates.push({ + workspaceKey, + storageClass: 'stateTransients', + path: candidatePath, + reason: `registry record unreadable; protected (${result.reason})`, + }); + continue; + } + if ( + result.status === 'record' && + (isPidAlive(result.record.ownerPid) || isPidAlive(result.record.helperPid)) + ) { + candidates.push({ + workspaceKey, + storageClass: 'stateTransients', + path: candidatePath, + reason: 'protected by active OSLog owner', + }); + continue; + } + candidates.push( + await candidateFromPath({ + workspaceKey, + storageClass: 'stateTransients', + candidatePath, + reason: 'stale simulator OSLog registry record', + ...options, + }), + ); + } + + return candidates; +} + +async function collectStateTransientCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const callToolRoot = xcodeIdeCallToolTransientRoot(workspaceKey); + const { entries: callToolEntries, error } = await readManagedDir(callToolRoot); + const callToolCandidates = await Promise.all( + callToolEntries + .filter( + (entry) => entry.isDirectory() && isStaleXcodeIdeCallToolTransientDirectoryName(entry.name), + ) + .map((entry) => + candidateFromPath({ + workspaceKey, + storageClass: 'stateTransients', + candidatePath: path.join(callToolRoot, entry.name), + reason: 'stale xcode-ide call-tool transient', + ...options, + }), + ), + ); + + return [ + ...(error ? [unreadableDirSkip(workspaceKey, 'stateTransients', callToolRoot, error)] : []), + ...callToolCandidates, + ...(await collectSimulatorOsLogRegistryCandidates(workspaceKey, options)), + ]; +} + +async function collectCandidatesForWorkspaceClass( + workspaceKey: string, + storageClass: PurgeStorageDeletableClass, + options: RequiredPlanContext, +): Promise> { + switch (storageClass) { + case 'derivedData': + return collectDerivedDataCandidates(workspaceKey, options); + case 'logs': + return collectLogCandidates(workspaceKey, options); + case 'resultBundles': + return collectResultBundleCandidates(workspaceKey, options); + case 'stateTransients': + return collectStateTransientCandidates(workspaceKey, options); + } +} + +function buildPlanContext(options: PlanPurgeStorageOptions): RequiredPlanContext { + const context: RequiredPlanContext = { + now: options.now ?? Date.now(), + }; + if (options.olderThanMs !== undefined) { + context.olderThanMs = options.olderThanMs; + } + return context; +} + +export async function planPurgeStorage( + options: PlanPurgeStorageOptions, +): Promise { + assertValidPlanOptions(options); + const report = + options.report ?? (await enumeratePurgeStorage({ now: options.now, scope: options.scope })); + const warnings = [...report.warnings]; + if (options.classes.includes('derivedData')) { + warnings.push( + 'DerivedData purge has no active-build signal; safety relies on explicit selection, age filters, workspace locks, and containment checks', + ); + } + const selectedWorkspaces = selectedWorkspacesForScope(report, options.scope, warnings); + const context = buildPlanContext(options); + + if (options.scope.type === 'all') { + warnings.push('Scope "all" targets every recognized workspace on this machine.'); + } else if (options.scope.type === 'family') { + warnings.push( + `Scope "family ${options.scope.family}" targets every workspace whose project basename is "${options.scope.family}", which may include unrelated projects that share that name.`, + ); + } + + const candidates: PurgeStorageCandidate[] = []; + const skipped: PurgeStorageSkippedCandidate[] = []; + for (const workspace of selectedWorkspaces) { + for (const storageClass of options.classes) { + const planned = await collectCandidatesForWorkspaceClass( + workspace.workspaceKey, + storageClass, + context, + ); + for (const item of planned) { + if (isCandidate(item)) { + candidates.push(item); + } else { + skipped.push(item); + } + } + } + } + + const plan: PurgeStoragePlan = { + action: 'dry-run', + scope: options.scope, + classes: options.classes, + report, + selectedWorkspaceKeys: selectedWorkspaces.map((workspace) => workspace.workspaceKey), + candidates, + skipped, + totals: { + bytes: candidates.reduce((total, candidate) => total + candidate.bytes, 0), + fileCount: candidates.reduce((total, candidate) => total + candidate.fileCount, 0), + directoryCount: candidates.reduce((total, candidate) => total + candidate.directoryCount, 0), + candidateCount: candidates.length, + }, + warnings, + }; + return plan; +} diff --git a/src/utils/purge-storage/registry-record.ts b/src/utils/purge-storage/registry-record.ts new file mode 100644 index 000000000..2fa192e93 --- /dev/null +++ b/src/utils/purge-storage/registry-record.ts @@ -0,0 +1,49 @@ +import * as fs from 'node:fs/promises'; +import { errorMessage, isEnoent } from './scan.ts'; +import type { RegistryRecord } from './types.ts'; + +export type RegistryReadResult = + | { status: 'record'; record: RegistryRecord } + | { status: 'absent' } + | { status: 'unreadable'; reason: string }; + +export async function readRegistryRecord(filePath: string): Promise { + let content: string; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (isEnoent(error)) { + return { status: 'absent' }; + } + return { status: 'unreadable', reason: errorMessage(error) }; + } + + const record = parseRegistryRecord(content); + if (!record) { + return { status: 'unreadable', reason: 'malformed registry record' }; + } + return { status: 'record', record }; +} + +export function parseRegistryRecord(content: string): RegistryRecord | null { + try { + const parsed = JSON.parse(content) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const record = parsed as { owner?: { pid?: unknown }; helperPid?: unknown }; + if ( + typeof record.owner?.pid !== 'number' || + !Number.isInteger(record.owner.pid) || + record.owner.pid <= 0 || + typeof record.helperPid !== 'number' || + !Number.isInteger(record.helperPid) || + record.helperPid <= 0 + ) { + return null; + } + return { ownerPid: record.owner.pid, helperPid: record.helperPid }; + } catch { + return null; + } +} diff --git a/src/utils/purge-storage/scan.ts b/src/utils/purge-storage/scan.ts new file mode 100644 index 000000000..355b4cb1c --- /dev/null +++ b/src/utils/purge-storage/scan.ts @@ -0,0 +1,101 @@ +import type { Dirent, Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { ScanAccumulator } from './types.ts'; + +export function isEnoent(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function describeFsError(error: unknown, enoentMessage: string): string { + return isEnoent(error) ? enoentMessage : errorMessage(error); +} + +export function warningForPath(filePath: string, message: string): string { + return `${filePath}: ${message}`; +} + +export function zeroAccumulator(): ScanAccumulator { + return { + bytes: 0, + fileCount: 0, + directoryCount: 0, + latestMtimeMs: null, + scanComplete: true, + warnings: [], + }; +} + +function updateLatestMtime(current: number | null, next: number): number { + return current === null ? next : Math.max(current, next); +} + +function appendScanWarning(accumulator: ScanAccumulator, warning: string): void { + accumulator.scanComplete = false; + accumulator.warnings.push(warning); +} + +function scanErrorMessage(filePath: string, error: unknown): string { + return warningForPath(filePath, describeFsError(error, 'path disappeared during scan')); +} + +export async function scanPath(filePath: string): Promise { + const accumulator = zeroAccumulator(); + await scanPathInto(filePath, accumulator, new Set()); + return accumulator; +} + +async function scanPathInto( + filePath: string, + accumulator: ScanAccumulator, + countedInodes: Set, +): Promise { + let stat: Stats; + try { + stat = await fs.lstat(filePath); + } catch (error) { + appendScanWarning(accumulator, scanErrorMessage(filePath, error)); + return; + } + + accumulator.latestMtimeMs = updateLatestMtime(accumulator.latestMtimeMs, stat.mtimeMs); + + if (stat.isSymbolicLink()) { + appendScanWarning(accumulator, warningForPath(filePath, 'symbolic link skipped')); + return; + } + + if (stat.isDirectory()) { + accumulator.directoryCount += 1; + let entries: Dirent[]; + try { + entries = await fs.readdir(filePath, { withFileTypes: true }); + } catch (error) { + appendScanWarning(accumulator, scanErrorMessage(filePath, error)); + return; + } + for (const entry of entries) { + await scanPathInto(path.join(filePath, entry.name), accumulator, countedInodes); + } + return; + } + + if (stat.isFile()) { + accumulator.fileCount += 1; + if (stat.nlink > 1) { + const inodeKey = `${stat.dev}:${stat.ino}`; + if (countedInodes.has(inodeKey)) { + return; + } + countedInodes.add(inodeKey); + } + accumulator.bytes += stat.size; + return; + } + + appendScanWarning(accumulator, warningForPath(filePath, 'non-regular filesystem entry skipped')); +} diff --git a/src/utils/purge-storage/types.ts b/src/utils/purge-storage/types.ts new file mode 100644 index 000000000..32d2aac9e --- /dev/null +++ b/src/utils/purge-storage/types.ts @@ -0,0 +1,171 @@ +export const PURGE_STORAGE_CLASSES = [ + 'derivedData', + 'logs', + 'resultBundles', + 'stateTransients', + 'locks', +] as const; + +export const PURGE_STORAGE_DELETABLE_CLASSES = [ + 'derivedData', + 'logs', + 'resultBundles', + 'stateTransients', +] as const; + +export type PurgeStorageClass = (typeof PURGE_STORAGE_CLASSES)[number]; +export type PurgeStorageDeletableClass = (typeof PURGE_STORAGE_DELETABLE_CLASSES)[number]; + +export type PurgeStorageScope = + | { type: 'all' } + | { type: 'family'; family: string } + | { type: 'workspace'; workspaceKey: string } + | { type: 'workspaces'; workspaceKeys: string[] }; + +export interface WorkspaceFamilyKeyParts { + family: string; + hash: string; +} + +export interface PurgeStorageClassCensus { + storageClass: PurgeStorageClass; + path: string; + exists: boolean; + bytes: number; + fileCount: number; + directoryCount: number; + latestMtimeMs: number | null; + scanComplete: boolean; + cleanupEligible: boolean; + warnings: string[]; +} + +export interface PurgeWorkspaceSummary { + workspaceKey: string; + path: string; + recognized: boolean; + family: string | null; + hash: string | null; + classes: Record; + totals: { + bytes: number; + fileCount: number; + directoryCount: number; + }; + warnings: string[]; +} + +export interface PurgeWorkspaceFamilySummary { + family: string; + workspaceKeys: string[]; + bytes: number; +} + +export interface PurgeStorageReport { + appRoot: string; + workspacesDir: string; + workspaces: PurgeWorkspaceSummary[]; + families: PurgeWorkspaceFamilySummary[]; + totals: { + bytes: number; + fileCount: number; + directoryCount: number; + }; + warnings: string[]; +} + +export interface EnumeratePurgeStorageOptions { + now?: number; + scope?: PurgeStorageScope; +} + +export interface PlanPurgeStorageOptions { + report?: PurgeStorageReport; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + now?: number; + olderThanMs?: number; + derivedDataExplicit?: boolean; +} + +export interface PurgeStorageCandidate { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + path: string; + kind: 'file' | 'directory'; + bytes: number; + fileCount: number; + directoryCount: number; + mtimeMs: number; + reason: string; + sidecarPaths: string[]; +} + +export interface PurgeStorageSkippedCandidate { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + path: string; + reason: string; +} + +export interface PurgeStoragePlan { + action: 'dry-run'; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + report: PurgeStorageReport; + selectedWorkspaceKeys: string[]; + candidates: PurgeStorageCandidate[]; + skipped: PurgeStorageSkippedCandidate[]; + totals: { + bytes: number; + fileCount: number; + directoryCount: number; + candidateCount: number; + }; + warnings: string[]; +} + +export interface ExecutePurgeStoragePlanOptions { + now?: number; +} + +export interface PurgeStorageDeletedEntry { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + path: string; + bytes: number; +} + +export interface PurgeStorageExecutionResult { + action: 'delete'; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + selectedWorkspaceKeys: string[]; + deleted: PurgeStorageDeletedEntry[]; + skipped: PurgeStorageSkippedCandidate[]; + totals: { + bytes: number; + deletedCount: number; + skippedCount: number; + }; + warnings: string[]; +} + +export interface ScanAccumulator { + bytes: number; + fileCount: number; + directoryCount: number; + latestMtimeMs: number | null; + scanComplete: boolean; + warnings: string[]; +} + +export interface RegistryRecord { + ownerPid: number; + helperPid: number; +} + +export interface RequiredPlanContext { + now: number; + olderThanMs?: number; +} diff --git a/src/utils/result-bundle-path.ts b/src/utils/result-bundle-path.ts index e0ccb22ed..30574729b 100644 --- a/src/utils/result-bundle-path.ts +++ b/src/utils/result-bundle-path.ts @@ -6,7 +6,11 @@ import { formatLogTimestamp, shortRandomSuffix } from './log-naming.ts'; import { getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; import { workspaceKeyForRoot } from './workspace-identity.ts'; -const RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX = '.xcodebuildmcp-completed'; +export const RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX = '.xcodebuildmcp-completed'; + +export function isResultBundleCompletionMarkerTempName(name: string): boolean { + return name.includes(`${RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX}.`) && name.endsWith('.tmp'); +} function resolveWorkspaceKey(): string { return getRuntimeInstanceIfConfigured()?.workspaceKey ?? workspaceKeyForRoot(process.cwd()); @@ -44,7 +48,15 @@ export function markResultBundlePathCompleted(resultBundlePath: string | undefin if (!fs.existsSync(resultBundlePath) || !fs.statSync(resultBundlePath).isDirectory()) { return; } - fs.writeFileSync(getResultBundleCompletionMarkerPath(resultBundlePath), `${Date.now()}\n`); + const markerPath = getResultBundleCompletionMarkerPath(resultBundlePath); + const tempPath = `${markerPath}.${process.pid}_${shortRandomSuffix()}.tmp`; + fs.writeFileSync(tempPath, `${Date.now()}\n`); + try { + fs.renameSync(tempPath, markerPath); + } catch (renameError) { + fs.rmSync(tempPath, { force: true }); + throw renameError; + } } catch (error) { const message = error instanceof Error ? error.message : String(error); log('warn', `Unable to mark result bundle completed at ${resultBundlePath}: ${message}`); diff --git a/src/utils/workspace-filesystem-lifecycle.ts b/src/utils/workspace-filesystem-lifecycle.ts index 43faefd40..0364bb3c6 100644 --- a/src/utils/workspace-filesystem-lifecycle.ts +++ b/src/utils/workspace-filesystem-lifecycle.ts @@ -12,7 +12,7 @@ import { } from './log-capture/simulator-launch-oslog-sessions.ts'; import { log } from './logging/index.ts'; import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; -import { tryAcquireFsLock } from './fs-lock.ts'; +import { tryAcquireFsLock, type AcquiredFsLock } from './fs-lock.ts'; import { isPidAlive } from './process-liveness.ts'; import { getResultBundleCompletionMarkerPath } from './result-bundle-path.ts'; @@ -113,6 +113,30 @@ interface RetainedLogFile { mtimeMs: number; } +export interface WorkspaceLifecycleProtectedLogPathsOptions { + workspaceKey: string; + protectedLogPaths?: string[]; +} + +export interface WorkspaceLifecycleRetainedArtifact { + path: string; + name: string; + mtimeMs: number; +} + +export type WorkspaceLifecycleLogProtectionReason = 'protectedPath' | 'recent' | 'liveHelperPid'; + +export interface WorkspaceLifecycleLogProtectionOptions { + now: number; + minVisibleMs: number; + protectedPaths: ReadonlySet; +} + +export interface WorkspaceLifecycleResultBundleProtectionOptions { + now: number; + minVisibleMs: number; +} + function resolveWorkspaceKey(options: WorkspaceFilesystemLifecycleOptions): string { if (options.workspaceKey) { return options.workspaceKey; @@ -193,18 +217,18 @@ function hasLiveHelperPidInName(fileName: string): boolean { return false; } -function isXcodeBuildMCPManagedLogName(fileName: string): boolean { +export function isXcodeBuildMCPManagedLogName(fileName: string): boolean { if (fileName === 'daemon.log') { return true; } return XCODEBUILD_LOG_NAME_PATTERN.test(fileName) || SIMULATOR_LOG_NAME_PATTERN.test(fileName); } -function isXcodeBuildMCPManagedResultBundleName(fileName: string): boolean { +export function isXcodeBuildMCPManagedResultBundleName(fileName: string): boolean { return RESULT_BUNDLE_NAME_PATTERN.test(fileName); } -function getManagedResultBundleOwnerPid(fileName: string): number | null { +export function getManagedResultBundleOwnerPid(fileName: string): number | null { const pid = Number(fileName.match(RESULT_BUNDLE_OWNER_PID_PATTERN)?.[1]); return Number.isInteger(pid) && pid > 0 ? pid : null; } @@ -218,24 +242,40 @@ async function deleteFile(filePath: string): Promise { } } +export function getWorkspaceLifecycleProtectedLogReason( + file: WorkspaceLifecycleRetainedArtifact, + options: WorkspaceLifecycleLogProtectionOptions, +): WorkspaceLifecycleLogProtectionReason | null { + if (options.protectedPaths.has(file.path)) { + return 'protectedPath'; + } + if (options.now - file.mtimeMs < options.minVisibleMs) { + return 'recent'; + } + if (hasLiveHelperPidInName(file.name)) { + return 'liveHelperPid'; + } + return null; +} + function isProtectedLogFile( file: RetainedLogFile, options: ResolvedWorkspaceFilesystemLifecycleOptions, protectedPaths: Set, ): boolean { - if (protectedPaths.has(file.path)) { - return true; - } - if (options.now - file.mtimeMs < options.minVisibleMs) { - return true; - } - return hasLiveHelperPidInName(file.name); + return ( + getWorkspaceLifecycleProtectedLogReason(file, { + now: options.now, + minVisibleMs: options.minVisibleMs, + protectedPaths, + }) !== null + ); } -async function collectProtectedLogPaths( - options: ResolvedWorkspaceFilesystemLifecycleOptions, +export async function collectWorkspaceLifecycleProtectedLogPaths( + options: WorkspaceLifecycleProtectedLogPathsOptions, ): Promise> { - const protectedPaths = new Set(options.protectedLogPaths); + const protectedPaths = new Set(options.protectedLogPaths ?? []); try { for (const osLogPath of await listSimulatorLaunchOsLogProtectedPaths({ @@ -255,6 +295,15 @@ async function collectProtectedLogPaths( return protectedPaths; } +async function collectProtectedLogPaths( + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise> { + return collectWorkspaceLifecycleProtectedLogPaths({ + workspaceKey: options.workspaceKey, + protectedLogPaths: options.protectedLogPaths, + }); +} + async function pruneKnownLogDirectory( options: ResolvedWorkspaceFilesystemLifecycleOptions, protectedPaths: Set, @@ -319,9 +368,9 @@ async function hasResultBundleCompletionMarker(bundlePath: string): Promise { if (options.now - bundle.mtimeMs < options.minVisibleMs) { return true; @@ -335,6 +384,16 @@ async function isProtectedResultBundleDirectory( return false; } +async function isProtectedResultBundleDirectory( + bundle: RetainedLogFile, + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise { + return isWorkspaceLifecycleProtectedResultBundleDirectory(bundle, { + now: options.now, + minVisibleMs: options.minVisibleMs, + }); +} + async function pruneKnownResultBundleDirectory( options: ResolvedWorkspaceFilesystemLifecycleOptions, ): Promise<{ scanned: number; deleted: number }> { @@ -449,13 +508,23 @@ function runDaemonCleanup(options: ResolvedWorkspaceFilesystemLifecycleOptions): cleanupWorkspaceDaemonFiles(options.workspaceKey, options.daemonCleanup); } -function xcodeIdeCallToolTransientRoot(workspaceKey: string): string { +export function xcodeIdeCallToolTransientRoot(workspaceKey: string): string { return path.join( getWorkspaceFilesystemLayout(workspaceKey).state, XCODE_IDE_CALL_TOOL_TRANSIENT_DIR, ); } +export function getXcodeIdeCallToolTransientOwnerPid(directoryName: string): number | null { + const ownerPid = Number(directoryName.match(XCODE_IDE_CALL_TOOL_OWNER_DIR_PATTERN)?.[1]); + return Number.isInteger(ownerPid) && ownerPid > 0 ? ownerPid : null; +} + +export function isStaleXcodeIdeCallToolTransientDirectoryName(directoryName: string): boolean { + const ownerPid = getXcodeIdeCallToolTransientOwnerPid(directoryName); + return ownerPid !== null && !isPidAlive(ownerPid); +} + async function cleanupXcodeIdeCallToolTransientArtifacts( workspaceKey: string, errors: string[], @@ -482,9 +551,8 @@ async function cleanupXcodeIdeCallToolTransientArtifacts( if (!entry.isDirectory()) { continue; } - const ownerPid = Number(entry.name.match(XCODE_IDE_CALL_TOOL_OWNER_DIR_PATTERN)?.[1]); const ownedByCurrentRuntime = options.includeCurrentOwner && currentOwnerDir === entry.name; - const ownedByDeadRuntime = Number.isInteger(ownerPid) && ownerPid > 0 && !isPidAlive(ownerPid); + const ownedByDeadRuntime = isStaleXcodeIdeCallToolTransientDirectoryName(entry.name); if (!ownedByCurrentRuntime && !ownedByDeadRuntime) { continue; } @@ -539,12 +607,18 @@ export async function runWorkspaceFilesystemLifecycleSweep( return zeroResult(resolved, true, false, stopped, errors); } - const lock = await tryAcquireFsLock({ - lockDir: resolved.lockDir, - purpose: resolved.lockPurpose, - leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, - now: resolved.now, - }); + let lock: AcquiredFsLock | null; + try { + lock = await tryAcquireFsLock({ + lockDir: resolved.lockDir, + purpose: resolved.lockPurpose, + leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + now: resolved.now, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + return zeroResult(resolved, false, true, stopped, errors); + } if (!lock) { return zeroResult(resolved, false, true, stopped, errors); } From 301d5e85f80eef3318729d0dd8e5b83d63f4c01e Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 10 Jul 2026 10:34:02 +0100 Subject: [PATCH 2/6] fix(purge): Age directory candidates by file contents Use newest regular file mtime for non-empty directory-backed purge candidates so harmless directory metadata updates do not defeat --older-than retention. Keep empty directory candidates on directory mtime as the fallback and continue deleting directory candidates atomically. Co-Authored-By: Codex --- src/utils/__tests__/purge-storage.test.ts | 46 ++++++++++++++++++++++- src/utils/purge-storage/planning.ts | 22 +++++++++-- src/utils/purge-storage/scan.ts | 7 ++++ src/utils/purge-storage/types.ts | 2 + 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/purge-storage.test.ts b/src/utils/__tests__/purge-storage.test.ts index 833d11446..b16577383 100644 --- a/src/utils/__tests__/purge-storage.test.ts +++ b/src/utils/__tests__/purge-storage.test.ts @@ -512,7 +512,51 @@ describe('purge storage', () => { expect(existsSync(outsideProject)).toBe(true); }); - it('uses the newest descendant mtime for the older-than filter, not the directory mtime', async () => { + it('uses newest file mtime for non-empty directory retention instead of directory metadata mtime', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const freshDirOldFile = path.join(layout.derivedData, 'FreshDirOldFile'); + writeFileWithMtime(path.join(freshDirOldFile, 'old.txt'), 'old', now - 30 * DAY_MS); + const freshMtime = new Date(now - 1000); + utimesSync(freshDirOldFile, freshMtime, freshMtime); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(plan.candidates.map((candidate) => path.basename(candidate.path))).toEqual([ + 'FreshDirOldFile', + ]); + expect(plan.skipped).toHaveLength(0); + }); + + it('uses directory mtime as the older-than fallback for empty directory candidates', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const emptyRecentDir = path.join(layout.derivedData, 'EmptyRecentDir'); + mkdirSync(emptyRecentDir, { recursive: true }); + const freshMtime = new Date(now - 1000); + utimesSync(emptyRecentDir, freshMtime, freshMtime); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped.map((candidate) => path.basename(candidate.path))).toEqual([ + 'EmptyRecentDir', + ]); + }); + + it('uses newest file mtime to keep non-empty directory candidates with fresh files', async () => { const now = Date.UTC(2026, 4, 2, 12); const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); const staleDirFreshFile = path.join(layout.derivedData, 'StaleDirFreshFile'); diff --git a/src/utils/purge-storage/planning.ts b/src/utils/purge-storage/planning.ts index 021d0e1aa..f6bec9a69 100644 --- a/src/utils/purge-storage/planning.ts +++ b/src/utils/purge-storage/planning.ts @@ -87,6 +87,18 @@ function shouldKeepForRetention(params: { return null; } +function retentionMtimeForCandidate(params: { + isDirectory: boolean; + statMtimeMs: number; + latestFileMtimeMs: number | null; + latestDirectoryMtimeMs: number | null; +}): number { + if (!params.isDirectory) { + return params.statMtimeMs; + } + return params.latestFileMtimeMs ?? params.latestDirectoryMtimeMs ?? params.statMtimeMs; +} + function lifecycleLogProtectionReasonText(reason: WorkspaceLifecycleLogProtectionReason): string { switch (reason) { case 'protectedPath': @@ -130,11 +142,15 @@ async function candidateFromPath(params: { } const scan = await scanPath(params.candidatePath); - const effectiveMtimeMs = - scan.latestMtimeMs === null ? stat.mtimeMs : Math.max(stat.mtimeMs, scan.latestMtimeMs); + const retentionMtimeMs = retentionMtimeForCandidate({ + isDirectory: stat.isDirectory(), + statMtimeMs: stat.mtimeMs, + latestFileMtimeMs: scan.latestFileMtimeMs, + latestDirectoryMtimeMs: scan.latestDirectoryMtimeMs, + }); const retentionReason = shouldKeepForRetention({ - mtimeMs: effectiveMtimeMs, + mtimeMs: retentionMtimeMs, now: params.now, olderThanMs: params.olderThanMs, }); diff --git a/src/utils/purge-storage/scan.ts b/src/utils/purge-storage/scan.ts index 355b4cb1c..7d24e8abd 100644 --- a/src/utils/purge-storage/scan.ts +++ b/src/utils/purge-storage/scan.ts @@ -25,6 +25,8 @@ export function zeroAccumulator(): ScanAccumulator { fileCount: 0, directoryCount: 0, latestMtimeMs: null, + latestFileMtimeMs: null, + latestDirectoryMtimeMs: null, scanComplete: true, warnings: [], }; @@ -70,6 +72,10 @@ async function scanPathInto( } if (stat.isDirectory()) { + accumulator.latestDirectoryMtimeMs = updateLatestMtime( + accumulator.latestDirectoryMtimeMs, + stat.mtimeMs, + ); accumulator.directoryCount += 1; let entries: Dirent[]; try { @@ -85,6 +91,7 @@ async function scanPathInto( } if (stat.isFile()) { + accumulator.latestFileMtimeMs = updateLatestMtime(accumulator.latestFileMtimeMs, stat.mtimeMs); accumulator.fileCount += 1; if (stat.nlink > 1) { const inodeKey = `${stat.dev}:${stat.ino}`; diff --git a/src/utils/purge-storage/types.ts b/src/utils/purge-storage/types.ts index 32d2aac9e..993901034 100644 --- a/src/utils/purge-storage/types.ts +++ b/src/utils/purge-storage/types.ts @@ -156,6 +156,8 @@ export interface ScanAccumulator { fileCount: number; directoryCount: number; latestMtimeMs: number | null; + latestFileMtimeMs: number | null; + latestDirectoryMtimeMs: number | null; scanComplete: boolean; warnings: string[]; } From a0bb5db8a45dda5595ea98ea9096de8c5fc9fe4c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 10 Jul 2026 12:08:01 +0100 Subject: [PATCH 3/6] fix(cli): Align purge report scope totals Default purge reports labeled the selected scope as all recognized workspaces while enumerating every workspace directory. Pass the resolved scope into enumeration so text and JSON output stay consistent. Add regression coverage for unrecognized workspace directories. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 4 + .../__tests__/purge-report-scope.test.ts | 81 +++++++++++++++++++ src/cli/commands/purge.ts | 6 +- 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 src/cli/commands/__tests__/purge-report-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a47d9cf06..dca5bfd14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `xcodebuildmcp purge` to report and explicitly clean XcodeBuildMCP-managed workspace storage, including opt-in DerivedData cleanup with dry-run and confirmation safeguards. +### Fixed + +- Fixed `xcodebuildmcp purge --report` so default reports keep totals, workspace rows, class breakdowns, and JSON output scoped to recognized workspaces consistently. + ## [2.6.2] ### Fixed diff --git a/src/cli/commands/__tests__/purge-report-scope.test.ts b/src/cli/commands/__tests__/purge-report-scope.test.ts new file mode 100644 index 000000000..f45893a24 --- /dev/null +++ b/src/cli/commands/__tests__/purge-report-scope.test.ts @@ -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 default report enumeration aligned with the all recognized workspaces scope', async () => { + const recognized = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrecognizedWorkspaceKey = '20260524T184215Z'; + const unrecognized = getWorkspaceFilesystemLayout(unrecognizedWorkspaceKey); + writeFileWithMtime( + path.join(recognized.logs, managedLogName('recognized')), + 'recognized', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unrecognized.logs, managedLogName('unrecognized')), + 'unrecognized', + 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 }; + totals: { bytes: number }; + workspaces: Array<{ workspaceKey: string }>; + }; + expect(parsed.selectedScope).toEqual({ type: 'all' }); + expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + expect(parsed.totals.bytes).toBe(Buffer.byteLength('recognized')); + }); +}); diff --git a/src/cli/commands/purge.ts b/src/cli/commands/purge.ts index 7d83dfef6..ff2ff1b3d 100644 --- a/src/cli/commands/purge.ts +++ b/src/cli/commands/purge.ts @@ -103,10 +103,6 @@ function validateModeSpecificArgs(mode: PurgeMode, args: PurgeCommandArguments): } } -function hasExplicitReportScope(args: PurgeCommandArguments): boolean { - return args.scope !== undefined || args.workspaceKey !== undefined || args.family !== undefined; -} - function parseOlderThan(value: string | undefined): number | undefined { if (value == null || value.trim().length === 0) { return undefined; @@ -271,7 +267,7 @@ export async function runPurgeCommand( const selectedScope = resolveScope(args, resolvedDeps.currentWorkspaceKey); const report = await enumeratePurgeStorage({ now: resolvedDeps.now, - scope: hasExplicitReportScope(args) ? selectedScope : undefined, + scope: selectedScope, }); if (args.json) { writeJson(resolvedDeps.write, reportToJson(report, selectedScope)); From 42c925d8a58be3f21ad1175205573b6154772b30 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 10 Jul 2026 13:29:49 +0100 Subject: [PATCH 4/6] test(cli): Share purge prompt strings Export interactive purge prompt helpers so tests assert the same strings used by the implementation. This prevents prompt wording changes from drifting between the CLI flow and its interaction tests. Document why confirmation-gated interactive purge includes DerivedData while non-TTY modes keep stricter defaults for scripts and agents. Co-Authored-By: OpenAI Codex --- .../__tests__/purge-interactive.test.ts | 14 ++-- src/cli/commands/__tests__/purge.test.ts | 77 ++++++++++++------- src/cli/commands/purge-interactive.ts | 18 ++++- 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/cli/commands/__tests__/purge-interactive.test.ts b/src/cli/commands/__tests__/purge-interactive.test.ts index c7777dca6..927a79735 100644 --- a/src/cli/commands/__tests__/purge-interactive.test.ts +++ b/src/cli/commands/__tests__/purge-interactive.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node: import { tmpdir } from 'node:os'; import * as path from 'node:path'; import type { Prompter } from '../../interactive/prompts.ts'; +import { + PURGE_INTERACTIVE_ROOT_PROMPT, + purgeInteractiveProjectPrompt, +} from '../purge-interactive.ts'; import { runPurgeCommand } from '../purge.ts'; import { getWorkspaceFilesystemLayout, @@ -69,7 +73,7 @@ describe('purge interactive command', () => { message: string; options: Array<{ value: T; label?: string }>; }) => { - if (opts.message === 'Select a project to clean') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { rootLabels.push(...opts.options.map((option) => option.label ?? '')); return opts.options.find((option) => option.value === 'cancel')!.value; } @@ -131,7 +135,7 @@ describe('purge interactive command', () => { options: Array<{ value: T; label?: string }>; }) => { messages.push(opts.message); - if (opts.message === 'Select a project to clean' && !selectedProject) { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT && !selectedProject) { selectedProject = true; return opts.options.find((option) => option.label?.startsWith('› DemoApp - '))!.value; } @@ -149,9 +153,9 @@ describe('purge interactive command', () => { { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, ); - expect(messages).toContain('Select a project to clean'); + expect(messages).toContain(PURGE_INTERACTIVE_ROOT_PROMPT); expect(messages.some((message) => /^Delete .*\?$/u.test(message))).toBe(true); - expect(messages).not.toContain('Project DemoApp'); + expect(messages).not.toContain(purgeInteractiveProjectPrompt('DemoApp')); expect(output.chunks.join('')).toContain('No storage deleted.'); }); @@ -165,7 +169,7 @@ describe('purge interactive command', () => { message: string; options: Array<{ value: T; label?: string }>; }) => { - if (opts.message === 'Select a project to clean') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { rootLabels.push(...opts.options.map((option) => option.label ?? '')); return opts.options.find((option) => option.value === 'cancel')!.value; } diff --git a/src/cli/commands/__tests__/purge.test.ts b/src/cli/commands/__tests__/purge.test.ts index d5c7ef401..c3c74599c 100644 --- a/src/cli/commands/__tests__/purge.test.ts +++ b/src/cli/commands/__tests__/purge.test.ts @@ -7,6 +7,11 @@ import { PromptInterruptedError, type Prompter, } from '../../interactive/prompts.ts'; +import { + PURGE_INTERACTIVE_ROOT_PROMPT, + purgeInteractiveProjectPrompt, + purgeInteractiveWorkspacePrompt, +} from '../purge-interactive.ts'; import { runPurgeCommand } from '../purge.ts'; import { getWorkspaceFilesystemLayout, @@ -197,18 +202,22 @@ describe('purge command', () => { it('runs the interactive project to workspace to folder flow for one workspace', async () => { const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const thirdWorkspaceKey = 'DemoApp-bbbbbbbbbbbb'; const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const third = getWorkspaceFilesystemLayout(thirdWorkspaceKey); const firstLog = path.join(first.logs, managedLogName('first')); const secondLog = path.join(second.logs, managedLogName('second')); + const thirdLog = path.join(third.logs, managedLogName('third')); writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + writeFileWithMtime(thirdLog, 'third', now - 10 * DAY_MS); const output = captureOutput(); const messages: string[] = []; let deleted = false; const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { messages.push(opts.message); - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { if (deleted) return opts.options.find((option) => option.value === 'cancel')!.value; return opts.options.find( (option) => @@ -218,7 +227,7 @@ describe('purge command', () => { option.value.name === 'DemoApp', )!.value; } - if (opts.message === 'Project DemoApp') { + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { if (deleted) return opts.options.find((option) => option.value === 'back')!.value; return opts.options.find( (option) => @@ -228,7 +237,7 @@ describe('purge command', () => { option.value.workspaceKey === currentWorkspaceKey, )!.value; } - if (opts.message === `Workspace ${currentWorkspaceKey}`) { + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { if (deleted) return opts.options.find((option) => option.value === 'back')!.value; return opts.options.find((option) => option.value === 'logs')!.value; } @@ -253,9 +262,9 @@ describe('purge command', () => { ); const text = output.chunks.join(''); - expect(messages).toContain('What do you want to do?'); - expect(messages).toContain('Project DemoApp'); - expect(messages).toContain(`Workspace ${currentWorkspaceKey}`); + expect(messages).toContain(PURGE_INTERACTIVE_ROOT_PROMPT); + expect(messages).toContain(purgeInteractiveProjectPrompt('DemoApp')); + expect(messages).toContain(purgeInteractiveWorkspacePrompt(currentWorkspaceKey)); expect(messages).not.toContain('Select project'); expect(messages).not.toContain('Select workspace in DemoApp'); expect(messages).not.toContain(`Select folders in ${currentWorkspaceKey}`); @@ -263,6 +272,7 @@ describe('purge command', () => { expect(text).not.toContain('Warnings:'); expect(existsSync(firstLog)).toBe(false); expect(existsSync(secondLog)).toBe(true); + expect(existsSync(thirdLog)).toBe(true); }); it('runs the interactive workspace DerivedData action without deleting logs', async () => { @@ -279,7 +289,7 @@ describe('purge command', () => { let deleted = false; const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { if (deleted) return opts.options.find((option) => option.value === 'cancel')!.value; return opts.options.find( (option) => @@ -289,7 +299,7 @@ describe('purge command', () => { option.value.name === 'DemoApp', )!.value; } - if (opts.message === 'Project DemoApp') { + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { if (deleted) return opts.options.find((option) => option.value === 'back')!.value; return opts.options.find( (option) => @@ -299,7 +309,7 @@ describe('purge command', () => { option.value.workspaceKey === currentWorkspaceKey, )!.value; } - if (opts.message === `Workspace ${currentWorkspaceKey}`) { + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { if (deleted) return opts.options.find((option) => option.value === 'back')!.value; return opts.options.find((option) => option.value === 'derivedData')!.value; } @@ -339,7 +349,7 @@ describe('purge command', () => { const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { messages.push(opts.message); - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { rootVisits += 1; if (rootVisits > 1) return opts.options.find((option) => option.value === 'cancel')!.value; @@ -351,7 +361,7 @@ describe('purge command', () => { option.value.name === 'DemoApp', )!.value; } - if (opts.message === 'Project DemoApp') { + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { projectVisits += 1; if (projectVisits > 1) return opts.options.find((option) => option.value === 'back')!.value; @@ -363,7 +373,7 @@ describe('purge command', () => { option.value.workspaceKey === currentWorkspaceKey, )!.value; } - if (opts.message === `Workspace ${currentWorkspaceKey}`) { + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { return opts.options.find((option) => option.value === 'back')!.value; } return opts.options[0].value; @@ -382,11 +392,11 @@ describe('purge command', () => { ); expect(messages).toEqual([ - 'What do you want to do?', - 'Project DemoApp', - `Workspace ${currentWorkspaceKey}`, - 'Project DemoApp', - 'What do you want to do?', + PURGE_INTERACTIVE_ROOT_PROMPT, + purgeInteractiveProjectPrompt('DemoApp'), + purgeInteractiveWorkspacePrompt(currentWorkspaceKey), + purgeInteractiveProjectPrompt('DemoApp'), + PURGE_INTERACTIVE_ROOT_PROMPT, ]); expect(output.chunks.join('')).toContain('No storage deleted.'); expect(existsSync(firstLog)).toBe(true); @@ -395,13 +405,19 @@ describe('purge command', () => { it('treats prompt cancellation as back in nested menus and cancel at the root', async () => { const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const second = getWorkspaceFilesystemLayout('DemoApp-abcdefabcdef'); const logPath = path.join(layout.logs, managedLogName('old')); writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(second.logs, managedLogName('second')), + 'second', + now - 10 * DAY_MS, + ); const output = captureOutput(); let rootVisits = 0; const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { rootVisits += 1; if (rootVisits > 1) throw new PromptCancelledError(); return opts.options.find( @@ -412,7 +428,7 @@ describe('purge command', () => { option.value.name === 'DemoApp', )!.value; } - if (opts.message === 'Project DemoApp') { + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { throw new PromptCancelledError(); } return opts.options[0].value; @@ -432,12 +448,18 @@ describe('purge command', () => { it('quits cleanly when an interactive prompt is interrupted from a nested menu', async () => { const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const second = getWorkspaceFilesystemLayout('DemoApp-abcdefabcdef'); const logPath = path.join(layout.logs, managedLogName('old')); writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(second.logs, managedLogName('second')), + 'second', + now - 10 * DAY_MS, + ); const output = captureOutput(); const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { return opts.options.find( (option) => typeof option.value === 'object' && @@ -446,7 +468,7 @@ describe('purge command', () => { option.value.name === 'DemoApp', )!.value; } - if (opts.message === 'Project DemoApp') { + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { throw new PromptInterruptedError(); } return opts.options[0].value; @@ -471,7 +493,7 @@ describe('purge command', () => { const output = captureOutput(); const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { const demoApp = opts.options.find( (option) => typeof option.value === 'object' && @@ -506,9 +528,10 @@ describe('purge command', () => { let rootVisits = 0; const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { rootVisits += 1; if (rootVisits === 1) { + rmSync(logPath, { force: true }); return opts.options.find( (option) => typeof option.value === 'object' && @@ -519,10 +542,6 @@ describe('purge command', () => { } return opts.options.find((option) => option.value === 'cancel')!.value; } - if (opts.message === 'Project DemoApp') { - rmSync(logPath, { force: true }); - return opts.options.find((option) => option.value === 'deleteAllWorkspaces')!.value; - } return opts.options[0].value; }, selectMany: async () => [], @@ -555,7 +574,7 @@ describe('purge command', () => { let rootVisits = 0; const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { - if (opts.message === 'What do you want to do?') { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { rootVisits += 1; const demoApp = opts.options.find( (option) => @@ -570,7 +589,7 @@ describe('purge command', () => { expect(demoApp).toBeUndefined(); return opts.options.find((option) => option.value === 'cancel')!.value; } - if (opts.message === 'Project DemoApp') { + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { return opts.options.find((option) => option.value === 'deleteAllWorkspaces')!.value; } return opts.options[0].value; diff --git a/src/cli/commands/purge-interactive.ts b/src/cli/commands/purge-interactive.ts index 92ed84891..5d2e65766 100644 --- a/src/cli/commands/purge-interactive.ts +++ b/src/cli/commands/purge-interactive.ts @@ -29,6 +29,18 @@ import { storageClassLabel, } from './purge-ui.ts'; +export const PURGE_INTERACTIVE_ROOT_PROMPT = 'Select a project to clean'; + +export function purgeInteractiveProjectPrompt(projectName: string): string { + return `Project ${projectName}`; +} + +export function purgeInteractiveWorkspacePrompt(workspaceKey: string): string { + return `Workspace ${workspaceKey}`; +} + +// Interactive purge is a confirmation-gated human flow. Bulk actions intentionally +// include DerivedData; non-TTY modes keep stricter defaults for scripts and agents. const FULL_WORKSPACE_CLASSES: PurgeStorageDeletableClass[] = [...PURGE_STORAGE_DELETABLE_CLASSES]; type RootSelection = 'cancel' | WorkspaceGroupSummary; @@ -269,7 +281,7 @@ async function handleWorkspace(params: { let selection: WorkspaceSelection; try { selection = await deps.prompter.selectOne({ - message: `Workspace ${workspace.workspaceKey}`, + message: purgeInteractiveWorkspacePrompt(workspace.workspaceKey), options: workspaceActionOptions(workspace, state), initialIndex: 0, }); @@ -316,7 +328,7 @@ async function handleProject(params: { let selection: ProjectSelection; try { selection = await deps.prompter.selectOne({ - message: `Project ${group.name}`, + message: purgeInteractiveProjectPrompt(group.name), options: projectOptions(group, deps.currentWorkspaceKey, state), initialIndex: 0, }); @@ -378,7 +390,7 @@ export async function runInteractivePurge(deps: PurgeInteractiveDependencies): P let selection: RootSelection; try { selection = await deps.prompter.selectOne({ - message: 'Select a project to clean', + message: PURGE_INTERACTIVE_ROOT_PROMPT, options: rootOptions(state.groups), initialIndex: 0, }); From c6e5e2940312769de013f90d0aa0d75669fa4355 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 10 Jul 2026 14:35:10 +0100 Subject: [PATCH 5/6] fix(cli): Align purge report default scope Make report mode use the same default scope as dry-run and delete so a no-scope report previews the current workspace instead of all workspaces. Keep explicit all-workspace reports available through --scope all. Co-Authored-By: OpenAI Codex --- src/cli/commands/__tests__/purge.test.ts | 43 +++++++++++++++++++++++- src/cli/commands/purge.ts | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/__tests__/purge.test.ts b/src/cli/commands/__tests__/purge.test.ts index c3c74599c..ebed3f4c9 100644 --- a/src/cli/commands/__tests__/purge.test.ts +++ b/src/cli/commands/__tests__/purge.test.ts @@ -69,9 +69,16 @@ describe('purge command', () => { expect(existsSync(logPath)).toBe(true); }); - it('prints deterministic JSON report output', async () => { + it('prints deterministic JSON report output for the current workspace by default', async () => { const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); writeFileWithMtime(path.join(layout.logs, managedLogName('old')), 'old', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); const output = captureOutput(); await runPurgeCommand( @@ -82,15 +89,49 @@ describe('purge command', () => { const parsed = JSON.parse(output.chunks.join('')) as { action: string; deletionHappened: boolean; + selectedScope: { type: string; workspaceKey?: string }; workspaces: Array<{ workspaceKey: string }>; }; expect(parsed.action).toBe('report'); expect(parsed.deletionHappened).toBe(false); + expect(parsed.selectedScope).toEqual({ type: 'workspace', workspaceKey: currentWorkspaceKey }); expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ currentWorkspaceKey, ]); }); + it('reports all workspaces when all scope is explicit', async () => { + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const target = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); + writeFileWithMtime( + path.join(target.logs, managedLogName('target')), + 'target', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + + await runPurgeCommand( + { report: true, scope: 'all', json: true }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + selectedScope: { type: string }; + workspaces: Array<{ workspaceKey: string }>; + }; + expect(parsed.selectedScope).toEqual({ type: 'all' }); + expect(parsed.workspaces.map((workspace) => workspace.workspaceKey).sort()).toEqual([ + currentWorkspaceKey, + unrelatedWorkspaceKey, + ]); + }); + it('dry-runs selected purge candidates without deleting them', async () => { const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); const logPath = path.join(layout.logs, managedLogName('old')); diff --git a/src/cli/commands/purge.ts b/src/cli/commands/purge.ts index ff2ff1b3d..f90631501 100644 --- a/src/cli/commands/purge.ts +++ b/src/cli/commands/purge.ts @@ -264,7 +264,7 @@ export async function runPurgeCommand( } if (mode === 'report') { - const selectedScope = resolveScope(args, resolvedDeps.currentWorkspaceKey); + const selectedScope = resolvePlanningScope(args, resolvedDeps.currentWorkspaceKey); const report = await enumeratePurgeStorage({ now: resolvedDeps.now, scope: selectedScope, From 0c602d031719bd5c00dd9e7be542ccd0a375efb1 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 10 Jul 2026 15:42:21 +0100 Subject: [PATCH 6/6] fix(cli): Tighten purge interactive and scope handling Route single-workspace interactive purge through the workspace class menu so users can choose a specific storage class instead of jumping straight to all folders. Reject blank family and workspace-key values before scope resolution. Update stale report-scope coverage to match the current-workspace default introduced by the recent report-default alignment change. Co-Authored-By: OpenAI Codex --- .../__tests__/purge-interactive.test.ts | 32 ++++++++++++++----- .../__tests__/purge-report-scope.test.ts | 22 ++++++------- src/cli/commands/__tests__/purge.test.ts | 16 ++++++++++ src/cli/commands/purge-interactive.ts | 7 +--- src/cli/commands/purge.ts | 32 +++++++++++++------ 5 files changed, 75 insertions(+), 34 deletions(-) diff --git a/src/cli/commands/__tests__/purge-interactive.test.ts b/src/cli/commands/__tests__/purge-interactive.test.ts index 927a79735..34da97988 100644 --- a/src/cli/commands/__tests__/purge-interactive.test.ts +++ b/src/cli/commands/__tests__/purge-interactive.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +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 { @@ -123,28 +124,40 @@ describe('purge interactive command', () => { expect(output.chunks.join('')).not.toContain('2 workspaces'); }); - it('opens the delete confirmation directly for a single-workspace project', async () => { + it('opens the workspace class menu for a single-workspace project', async () => { const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); - writeFileWithMtime(path.join(layout.logs, managedLogName('known')), 'known', now - 10 * DAY_MS); + 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 selectedProject = false; + let rootVisits = 0; + let workspaceVisits = 0; const prompter: Prompter = { selectOne: async (opts: { message: string; options: Array<{ value: T; label?: string }>; }) => { messages.push(opts.message); - if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT && !selectedProject) { - selectedProject = true; + 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 false; + return true; }, }; @@ -154,9 +167,12 @@ describe('purge interactive command', () => { ); 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('No storage deleted.'); + 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 () => { diff --git a/src/cli/commands/__tests__/purge-report-scope.test.ts b/src/cli/commands/__tests__/purge-report-scope.test.ts index f45893a24..afc585e4d 100644 --- a/src/cli/commands/__tests__/purge-report-scope.test.ts +++ b/src/cli/commands/__tests__/purge-report-scope.test.ts @@ -46,18 +46,18 @@ describe('purge report scope', () => { rmSync(appDir, { recursive: true, force: true }); }); - it('keeps default report enumeration aligned with the all recognized workspaces scope', async () => { - const recognized = getWorkspaceFilesystemLayout(currentWorkspaceKey); - const unrecognizedWorkspaceKey = '20260524T184215Z'; - const unrecognized = getWorkspaceFilesystemLayout(unrecognizedWorkspaceKey); + 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(recognized.logs, managedLogName('recognized')), - 'recognized', + path.join(current.logs, managedLogName('current')), + 'current', now - 10 * DAY_MS, ); writeFileWithMtime( - path.join(unrecognized.logs, managedLogName('unrecognized')), - 'unrecognized', + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', now - 10 * DAY_MS, ); const output = captureOutput(); @@ -68,14 +68,14 @@ describe('purge report scope', () => { ); const parsed = JSON.parse(output.chunks.join('')) as { - selectedScope: { type: string }; + selectedScope: { type: string; workspaceKey?: string }; totals: { bytes: number }; workspaces: Array<{ workspaceKey: string }>; }; - expect(parsed.selectedScope).toEqual({ type: 'all' }); + expect(parsed.selectedScope).toEqual({ type: 'workspace', workspaceKey: currentWorkspaceKey }); expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ currentWorkspaceKey, ]); - expect(parsed.totals.bytes).toBe(Buffer.byteLength('recognized')); + expect(parsed.totals.bytes).toBe(Buffer.byteLength('current')); }); }); diff --git a/src/cli/commands/__tests__/purge.test.ts b/src/cli/commands/__tests__/purge.test.ts index ebed3f4c9..f82d56a1c 100644 --- a/src/cli/commands/__tests__/purge.test.ts +++ b/src/cli/commands/__tests__/purge.test.ts @@ -240,6 +240,22 @@ describe('purge command', () => { ).rejects.toThrow('--dry-run cannot be combined with --confirm.'); }); + it('rejects blank workspace and family scope values', async () => { + await expect( + runPurgeCommand( + { dryRun: true, workspaceKey: '', classes: 'logs' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--workspace-key must not be empty.'); + + await expect( + runPurgeCommand( + { report: true, family: ' ' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--family must not be empty.'); + }); + it('runs the interactive project to workspace to folder flow for one workspace', async () => { const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; diff --git a/src/cli/commands/purge-interactive.ts b/src/cli/commands/purge-interactive.ts index 5d2e65766..8411c6a3c 100644 --- a/src/cli/commands/purge-interactive.ts +++ b/src/cli/commands/purge-interactive.ts @@ -317,12 +317,7 @@ async function handleProject(params: { return; } if (group.workspaces.length === 1) { - await confirmAndDeleteWorkspaces( - deps, - state, - [group.workspaces[0].workspaceKey], - FULL_WORKSPACE_CLASSES, - ); + await handleWorkspace({ deps, state, workspaceKey: group.workspaces[0].workspaceKey }); return; } let selection: ProjectSelection; diff --git a/src/cli/commands/purge.ts b/src/cli/commands/purge.ts index f90631501..e76cc75d8 100644 --- a/src/cli/commands/purge.ts +++ b/src/cli/commands/purge.ts @@ -155,34 +155,48 @@ function parseClasses(value: string | string[] | undefined): { return { classes: Array.from(new Set(parsed)), explicit: true }; } +function normalizedScopeValue(value: string | undefined, flagName: string): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`${flagName} must not be empty.`); + } + return trimmed; +} + function resolveScope(args: PurgeCommandArguments, currentWorkspaceKey: string): PurgeStorageScope { - if (args.scope === 'current' && (args.workspaceKey || args.family)) { + const workspaceKey = normalizedScopeValue(args.workspaceKey, '--workspace-key'); + const family = normalizedScopeValue(args.family, '--family'); + + if (args.scope === 'current' && (workspaceKey !== undefined || family !== undefined)) { throw new Error('--scope current cannot be combined with --workspace-key or --family.'); } - if (args.scope === 'workspace' && args.family) { + if (args.scope === 'workspace' && family !== undefined) { throw new Error('--scope workspace cannot be combined with --family.'); } if (args.scope === 'all') { - if (args.workspaceKey || args.family) { + if (workspaceKey !== undefined || family !== undefined) { throw new Error('--scope all cannot be combined with --workspace-key or --family.'); } return { type: 'all' }; } - if (args.scope === 'family' || args.family) { - if (!args.family) { + if (args.scope === 'family' || family !== undefined) { + if (family === undefined) { throw new Error('--scope family requires --family .'); } - if (args.workspaceKey) { + if (workspaceKey !== undefined) { throw new Error('--family cannot be combined with --workspace-key.'); } - return { type: 'family', family: args.family }; + return { type: 'family', family }; } - if (args.scope === 'workspace' || args.workspaceKey) { - return { type: 'workspace', workspaceKey: args.workspaceKey ?? currentWorkspaceKey }; + if (args.scope === 'workspace' || workspaceKey !== undefined) { + return { type: 'workspace', workspaceKey: workspaceKey ?? currentWorkspaceKey }; } if (args.scope === 'current') {