From 860bd9870668baba98f6622a892fad78cc171be3 Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Thu, 30 Jul 2026 10:52:04 -0700 Subject: [PATCH 1/4] Add project delete dry-run regression coverage --- packages/cli/test/cmd/project/delete.test.ts | 124 +++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/cli/test/cmd/project/delete.test.ts 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..e8479d4b8 --- /dev/null +++ b/packages/cli/test/cmd/project/delete.test.ts @@ -0,0 +1,124 @@ +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]'); + expect(result.stdout).toContain(`Dry Run Test (${PROJECT_ID})`); + }); + + 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}` }]); + }); +}); From 7574de4c0d20ab4f8247301db5eca837b880cacf Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Thu, 30 Jul 2026 10:55:33 -0700 Subject: [PATCH 2/4] Honor dry-run in project deletion --- packages/cli/src/cmd/project/delete.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cmd/project/delete.ts b/packages/cli/src/cmd/project/delete.ts index dbe45bf6e..2a5d76ca9 100644 --- a/packages/cli/src/cmd/project/delete.ts +++ b/packages/cli/src/cmd/project/delete.ts @@ -4,6 +4,7 @@ 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'; interface ProjectDisplayInfo { id: string; @@ -53,7 +54,7 @@ export const deleteSubcommand = createSubcommand({ }, async handler(ctx) { - const { args, opts, apiClient } = ctx; + const { args, opts, apiClient, options } = ctx; let projectsToDelete: ProjectDisplayInfo[] = []; @@ -136,6 +137,16 @@ export const deleteSubcommand = createSubcommand({ return { success: false, projectIds: [], count: 0 }; } + if (isDryRunMode(options)) { + const projectDisplay = projectsToDelete.map(formatProjectDisplay).join(', '); + outputDryRun(`Would delete project(s): ${projectDisplay}`, options); + if (!options.json) { + 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) { From 76f04b74b40e43e21bcf991f97f057ce4d36e11c Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Thu, 30 Jul 2026 11:07:29 -0700 Subject: [PATCH 3/4] Polish project delete dry-run copy --- packages/cli/src/cmd/project/delete.ts | 5 +++-- packages/cli/test/cmd/project/delete.test.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/cmd/project/delete.ts b/packages/cli/src/cmd/project/delete.ts index 2a5d76ca9..d1fc0f392 100644 --- a/packages/cli/src/cmd/project/delete.ts +++ b/packages/cli/src/cmd/project/delete.ts @@ -36,7 +36,7 @@ export const deleteSubcommand = createSubcommand({ }, { command: getCommand('--dry-run project delete proj_abc123def456'), - description: 'Delete item', + description: 'Preview project deletion without making changes', }, ], schema: { @@ -138,8 +138,9 @@ export const deleteSubcommand = createSubcommand({ } if (isDryRunMode(options)) { + const projectLabel = projectsToDelete.length === 1 ? 'project' : 'projects'; const projectDisplay = projectsToDelete.map(formatProjectDisplay).join(', '); - outputDryRun(`Would delete project(s): ${projectDisplay}`, options); + outputDryRun(`Would delete ${projectLabel}: ${projectDisplay}`, options); if (!options.json) { tui.newline(); tui.info('[DRY RUN] Project deletion skipped'); diff --git a/packages/cli/test/cmd/project/delete.test.ts b/packages/cli/test/cmd/project/delete.test.ts index e8479d4b8..1c49e4b03 100644 --- a/packages/cli/test/cmd/project/delete.test.ts +++ b/packages/cli/test/cmd/project/delete.test.ts @@ -109,8 +109,10 @@ describe('project delete command', () => { expect(result).toEqual(expect.objectContaining({ exitCode: 0 })); expect(requests).toEqual([{ method: 'GET', pathname: `/cli/project/${PROJECT_ID}` }]); - expect(result.stdout).toContain('[DRY RUN]'); - expect(result.stdout).toContain(`Dry Run Test (${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 () => { From 50672207f47aa48ab2b0aeed086459494d70f9dd Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Thu, 30 Jul 2026 11:11:01 -0700 Subject: [PATCH 4/4] Use shared JSON mode guard --- packages/cli/src/cmd/project/delete.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cmd/project/delete.ts b/packages/cli/src/cmd/project/delete.ts index d1fc0f392..588821205 100644 --- a/packages/cli/src/cmd/project/delete.ts +++ b/packages/cli/src/cmd/project/delete.ts @@ -5,6 +5,7 @@ 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; @@ -141,7 +142,7 @@ export const deleteSubcommand = createSubcommand({ const projectLabel = projectsToDelete.length === 1 ? 'project' : 'projects'; const projectDisplay = projectsToDelete.map(formatProjectDisplay).join(', '); outputDryRun(`Would delete ${projectLabel}: ${projectDisplay}`, options); - if (!options.json) { + if (!isJSONMode(options)) { tui.newline(); tui.info('[DRY RUN] Project deletion skipped'); }