diff --git a/packages/cli/src/cmd/project/delete.ts b/packages/cli/src/cmd/project/delete.ts index dbe45bf6e..588821205 100644 --- a/packages/cli/src/cmd/project/delete.ts +++ b/packages/cli/src/cmd/project/delete.ts @@ -4,6 +4,8 @@ import * as tui from '../../tui.ts'; import { projectDelete, projectList, projectGet } from '@agentuity/server'; import enquirer from 'enquirer'; import { getCommand } from '../../command-prefix.ts'; +import { isDryRunMode, outputDryRun } from '../../explain.ts'; +import { isJSONMode } from '../../output.ts'; interface ProjectDisplayInfo { id: string; @@ -35,7 +37,7 @@ export const deleteSubcommand = createSubcommand({ }, { command: getCommand('--dry-run project delete proj_abc123def456'), - description: 'Delete item', + description: 'Preview project deletion without making changes', }, ], schema: { @@ -53,7 +55,7 @@ export const deleteSubcommand = createSubcommand({ }, async handler(ctx) { - const { args, opts, apiClient } = ctx; + const { args, opts, apiClient, options } = ctx; let projectsToDelete: ProjectDisplayInfo[] = []; @@ -136,6 +138,17 @@ export const deleteSubcommand = createSubcommand({ return { success: false, projectIds: [], count: 0 }; } + if (isDryRunMode(options)) { + const projectLabel = projectsToDelete.length === 1 ? 'project' : 'projects'; + const projectDisplay = projectsToDelete.map(formatProjectDisplay).join(', '); + outputDryRun(`Would delete ${projectLabel}: ${projectDisplay}`, options); + if (!isJSONMode(options)) { + tui.newline(); + tui.info('[DRY RUN] Project deletion skipped'); + } + return { success: false, projectIds: [], count: 0 }; + } + const skipConfirm = opts?.confirm === true; if (!process.stdout.isTTY && !skipConfirm) { diff --git a/packages/cli/test/cmd/project/delete.test.ts b/packages/cli/test/cmd/project/delete.test.ts new file mode 100644 index 000000000..1c49e4b03 --- /dev/null +++ b/packages/cli/test/cmd/project/delete.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const CLI_ROOT = resolve(import.meta.dir, '..', '..', '..'); +const SRC_ENTRY = join(CLI_ROOT, 'src', 'main.ts'); +const SERVER_ENTRY = join(CLI_ROOT, '..', 'server', 'dist', 'index.js'); +const PROJECT_ID = 'proj_dry_run_test'; + +interface RecordedRequest { + readonly method: string; + readonly pathname: string; +} + +interface CliRunResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +let configDir: string; +let server: ReturnType | undefined; +let requests: RecordedRequest[]; + +beforeAll(async () => { + if (existsSync(SERVER_ENTRY)) return; + + const build = Bun.spawn(['bun', 'run', 'build'], { + cwd: CLI_ROOT, + stdout: 'inherit', + stderr: 'inherit', + }); + const exitCode = await build.exited; + if (exitCode !== 0) { + throw new Error(`bun run build failed with exit code ${exitCode}`); + } +}); + +beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), 'agentuity-project-delete-test-')); + requests = []; + server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + requests.push({ method: request.method, pathname: url.pathname }); + + if (request.method === 'GET' && url.pathname === `/cli/project/${PROJECT_ID}`) { + return Response.json({ + success: true, + data: { + id: PROJECT_ID, + name: 'Dry Run Test', + orgId: 'org_test', + }, + }); + } + + if (request.method === 'DELETE' && url.pathname === '/cli/project') { + return Response.json({ success: true, data: [PROJECT_ID] }); + } + + return Response.json({ success: false, message: 'Not found' }, { status: 404 }); + }, + }); +}); + +afterEach(async () => { + server?.stop(true); + server = undefined; + await rm(configDir, { recursive: true, force: true }); +}); + +async function runCLI(args: readonly string[]): Promise { + if (!server) { + throw new Error('Test server is not running'); + } + + const env: Record = { + AGENTUITY_AGENT_MODE: 'none', + AGENTUITY_API_KEY: 'ag_test', + AGENTUITY_API_URL: `http://127.0.0.1:${server.port}`, + AGENTUITY_CONFIG_DIR: configDir, + AGENTUITY_SKIP_VERSION_CHECK: '1', + }; + if (process.env.HOME) env.HOME = process.env.HOME; + if (process.env.PATH) env.PATH = process.env.PATH; + if (process.env.TMPDIR) env.TMPDIR = process.env.TMPDIR; + + const proc = Bun.spawn(['bun', SRC_ENTRY, ...args], { + cwd: configDir, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +describe('project delete command', () => { + test('dry run resolves the project without deleting it', async () => { + const result = await runCLI(['--dry-run', 'project', 'delete', PROJECT_ID, '--confirm']); + + expect(result).toEqual(expect.objectContaining({ exitCode: 0 })); + expect(requests).toEqual([{ method: 'GET', pathname: `/cli/project/${PROJECT_ID}` }]); + expect(result.stdout).toContain( + `[DRY RUN] Would delete project: Dry Run Test (${PROJECT_ID})` + ); + expect(result.stderr).toContain('[DRY RUN] Project deletion skipped'); + }); + + test('dry run does not require deletion confirmation', async () => { + const result = await runCLI(['--dry-run', 'project', 'delete', PROJECT_ID]); + + expect(result).toEqual(expect.objectContaining({ exitCode: 0 })); + expect(result.stdout).toContain('[DRY RUN]'); + expect(result.stderr).not.toContain('no TTY and --confirm is false'); + expect(requests).toEqual([{ method: 'GET', pathname: `/cli/project/${PROJECT_ID}` }]); + }); +});