diff --git a/VERSION b/VERSION index 6d44d227..5d11b147 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.14 +0.9.15 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 13e4bcc4..8a91b5d6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.14`. Generated from `capy help --json`. +Version `0.9.15`. Generated from `capy help --json`. ## Commands @@ -482,6 +482,54 @@ capy org [options] JSON support: no +### `capy org delete-project` + +Delete a project (owners only; hidden, not destroyed; an owner can restore it) + +``` +capy org delete-project [options] +``` + +| Option | Description | Default | +|---|---|---| +| `--force` | delete even if the project has live deploy tokens (those deploys stop on their next restart) | | +| `--yes` | skip the confirmation question | | +| `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | +| `--json` | emit machine-readable JSON instead of the human UI | | + +JSON support: yes (`--json`) + +### `capy org restore-project` + +Restore a deleted project (owners only) + +``` +capy org restore-project [options] +``` + +| Option | Description | Default | +|---|---|---| +| `--yes` | skip the confirmation question | | +| `--non-tty` | treat stdin as not a terminal: refuse unless --yes is passed | | +| `--json` | emit machine-readable JSON instead of the human UI | | + +JSON support: yes (`--json`) + +### `capy org projects` + +List projects in the active organization; --deleted also lists deleted ones (owners only) + +``` +capy org projects [options] +``` + +| Option | Description | Default | +|---|---|---| +| `--deleted` | also list deleted projects, marked as deleted, with the date | | +| `--json` | emit machine-readable JSON instead of the human UI | | + +JSON support: yes (`--json`) + ## `capy info` Show current session info @@ -1059,6 +1107,13 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `SECRET_NOT_FOUND` - `SECRETS_NOTHING_SELECTED` - `SECRETS_PARTIAL` +- `PROJECT_DELETED` +- `PROJECT_DELETE_OWNER_ONLY` +- `PROJECT_HAS_LIVE_DEPLOY_TOKENS` +- `PROJECT_NOT_DELETED` +- `SYSTEM_STORE_IMMUTABLE` +- `PROJECT_AMBIGUOUS` +- `CONFIRMATION_REQUIRED` - `REPO_LINKS_UNSUPPORTED` - `DRY_RUN_UNSUPPORTED` - `GITHUB_TIMEOUT` diff --git a/package.json b/package.json index 03d3ce89..2972ad68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.14", + "version": "0.9.15", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": { diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index 9062640c..54b6d3d9 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -42,6 +42,7 @@ import { excludeSystemProject, assertProjectNameAllowed } from '../system/reserv import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { isMembershipRevokedError } from '../errors/membershipRevoked'; +import { isProjectDeletedError } from '../errors/projectDeleted'; import { cleanupOrgData } from '../cleanup/orgCleanup'; import { formatSnippet } from './statusCommand'; import { compareSecrets, hashValue, type DiffResult } from '../sync/compareSecrets'; @@ -1476,6 +1477,11 @@ export class CapyCommand { return { currentKeep: bootstrappedKeep, networkAvailable: true, remotePlaintext, remoteHashes, seenSnapshot: snapshot }; } catch (err: any) { this.debugError('remote fetch failed', err); + // CAP-729: a deleted project is not "offline". Stop here: the error screen names it and says how to restore it. + if (isProjectDeletedError(err)) { + fetchSpinner.stop(); + throw err; + } // 403 may be one of two different cases: // (a) User was kicked from the org — confirmed by an explicit // `code: 'MEMBERSHIP_REVOKED'` from the server. Destructive @@ -1610,6 +1616,7 @@ export class CapyCommand { return { ...localMatches, ...fetched }; } catch (err) { this.debugError('pinned fetch failed', err); + if (isProjectDeletedError(err)) throw err; return localMatches; } })() @@ -1776,6 +1783,7 @@ export class CapyCommand { return { kind: 'proceed', finalEnv: fetchedEnv }; } catch (err) { this.debugError('retrieve_pinned fetch failed', err); + if (isProjectDeletedError(err)) throw err; console.log('Could not fetch pinned values from remote.'); return { kind: 'abort' }; } diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts new file mode 100644 index 00000000..9acb1427 --- /dev/null +++ b/src/commands/projectLifecycleCommand.ts @@ -0,0 +1,334 @@ +/** + * `capy org delete-project` and `capy org restore-project` (CAP-729). + * + * A delete hides a project; it destroys nothing, and an org owner can restore + * it. The service decides everything that matters (who may delete, whether live + * deploy tokens block it, whether the project is the system store); this file + * resolves the name the person typed, asks the question, and shows the answer. + * + * Every refusal is coded, and every decision here keys on a code, a status or a + * structured field, never on message text. `--json` is the MCP contract: pure + * JSON on stdout, prose on stderr, and a stable `code` on every refusal. + * + * `--dry-run` reads the project and its deploy tokens and stops. The service has + * no dry run, so the DELETE is simply never sent. + */ +import inquirer from 'inquirer'; +import { resolveOrgContext } from '../core/orgContext'; +import { excludeSystemProject } from '../system/reservedProjectName'; +import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; +import { CapyError, ERROR_CODES } from '../types/index'; +import type { DeleteProjectResponse, LiveDeployTokenInfo } from '../service/serviceClient'; + +export interface ProjectLifecycleOpts { + json?: boolean; + yes?: boolean; + /** `--non-tty`: treat stdin as not a terminal, so the confirmation is refused unless `--yes`. */ + nonTty?: boolean; + apiUrl?: string; + devMode?: boolean; +} + +export interface DeleteProjectOpts extends ProjectLifecycleOpts { + /** Delete even though the project has live deploy tokens. Those deploys stop on their next restart. */ + force?: boolean; + /** The global `--dry-run`: show what would happen; send nothing that writes. */ + dryRun?: boolean; +} + +/** What commander hands an action's `command`: only the merged view is needed. */ +interface CommandWithGlobals { + optsWithGlobals(): Record; +} + +/** + * The options of `delete-project` / `restore-project` as typed, wherever commander put them. + * + * A flag that the program or `org` also defines (`--force`, `--non-tty`, `--dry-run`) is taken by that outer command + * and never reaches the subcommand's own options, so reading `options.force` there is always undefined. The merged view + * has every one of them; `--yes` and `--json` come out of it the same way, so a later outer flag of that name cannot shadow them. + */ +export function lifecycleOptsFrom(command: CommandWithGlobals): DeleteProjectOpts { + const merged = command.optsWithGlobals(); + return { + force: merged.force === true, + yes: merged.yes === true, + nonTty: merged.nonTty === true, + json: merged.json === true, + dryRun: merged.dryRun === true, + }; +} + +interface ProjectRef { + id: string; + name: string; + deleted_at?: string | null; +} + +/** The 403 codes the service sends that reach the client as a PERMISSION_DENIED with the server's code in `details.code`. */ +const CODED_FORBIDDEN: ReadonlySet = new Set([ + ERROR_CODES.PROJECT_DELETE_OWNER_ONLY, + ERROR_CODES.SYSTEM_STORE_IMMUTABLE, +]); + +interface Refusal { + readonly code: string; + readonly message: string; + readonly exitCode: number; + /** Extra structured fields for `--json` (the token list, the ambiguous ids). */ + readonly extra?: Readonly>; + /** Extra prose lines for the human refusal. */ + readonly lines?: readonly string[]; +} + +/** The code a thrown error carries: the server's own 403 code when the client wrapped it as PERMISSION_DENIED. */ +function codeOf(err: CapyError): string { + const serverCode = err.details?.code; + return err.code === ERROR_CODES.PERMISSION_DENIED && typeof serverCode === 'string' && CODED_FORBIDDEN.has(serverCode) + ? serverCode + : err.code; +} + +function liveTokensOf(err: CapyError): readonly LiveDeployTokenInfo[] { + const tokens = err.details?.data?.live_deploy_tokens; + return Array.isArray(tokens) ? tokens : []; +} + +function describeToken(t: LiveDeployTokenInfo): string { + return `${t.deploy_id}${t.label ? ` ${t.label}` : ''} (created ${t.created_at})`; // COPY-FLAG +} + +function refusalFor(err: unknown): Refusal { + if (!(err instanceof CapyError)) { + return { code: ERROR_CODES.SERVICE_ERROR, message: err instanceof Error ? err.message : String(err), exitCode: 1 }; + } + const code = codeOf(err); + if (code === ERROR_CODES.PROJECT_HAS_LIVE_DEPLOY_TOKENS) { + const tokens = liveTokensOf(err); + return { + code, + message: 'This project has live deploy tokens.', // COPY-FLAG + exitCode: 1, + extra: { live_deploy_tokens: tokens }, + lines: [ + ...tokens.map((t) => ` ${describeToken(t)}`), + '--force deletes the project anyway. Those deploys stop on their next restart.', // COPY-FLAG + ], + }; + } + if (code === ERROR_CODES.PROJECT_AMBIGUOUS) { + const matches: readonly ProjectRef[] = Array.isArray(err.details?.matches) ? err.details.matches : []; + return { + code, + message: err.message, + exitCode: 1, + extra: { matches: matches.map((m) => ({ id: m.id, name: m.name })) }, + lines: matches.map((m) => ` ${m.id} ${m.name}`), + }; + } + return { code, message: err.message, exitCode: 1 }; +} + +/** The refusal on the right stream: JSON on stdout under `--json`, prose on stderr otherwise. Never returns. */ +function refuse(json: boolean, r: Refusal): never { + if (json) { + console.log(JSON.stringify({ ok: false, code: r.code, error: r.message, ...(r.extra ?? {}) }, null, 2)); + } else { + console.error(r.message); + (r.lines ?? []).forEach((l) => console.error(l)); + } + return process.exit(r.exitCode); +} + +type Settled = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; + +/** Runs `fn` and returns what it threw as a value, so a refusal's `process.exit` is never caught by the code that raised it. */ +async function settle(fn: () => Promise): Promise> { + return fn().then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ); +} + +/** Prose goes to stdout in human mode and to stderr under `--json`, so stdout stays pure JSON. */ +function say(json: boolean, line: string): void { + if (json) console.error(line); + else console.log(line); +} + +/** `--json` renders the prompt on stderr; `inquirer.prompt` always writes to stdout. */ +function promptModuleFor(json: boolean): typeof inquirer.prompt { + return json ? inquirer.createPromptModule({ output: process.stderr }) : inquirer.prompt; +} + +async function confirm(json: boolean, message: string): Promise { + const { confirmed } = await promptModuleFor(json)([{ type: 'confirm', name: 'confirmed', message, default: false }]); + return confirmed === true; +} + +/** + * The projects a name or id points at. An id wins over a name; the system store never matches. + * `preferDeleted` (restore) narrows to deleted projects when any match, so a live twin with the same name is not picked. + */ +function matchProjects(projects: readonly ProjectRef[], ref: string, preferDeleted: boolean): readonly ProjectRef[] { + const candidates = excludeSystemProject(projects); + const byId = candidates.filter((p) => p.id === ref); + const matches = byId.length > 0 ? byId : candidates.filter((p) => p.name === ref); + const deleted = matches.filter((p) => p.deleted_at !== null && p.deleted_at !== undefined); + return preferDeleted && deleted.length > 0 ? deleted : matches; +} + +/** + * One project, or a coded refusal. No match is passed through as an id: the service answers PROJECT_NOT_FOUND for a typo, + * and a non-owner (whose listing hides deleted projects) still gets the owner-only refusal rather than a vague "not found". + */ +function pickOne(projects: readonly ProjectRef[], ref: string, preferDeleted: boolean): ProjectRef { + const matches = matchProjects(projects, ref, preferDeleted); + if (matches.length === 0) return { id: ref, name: ref }; + if (matches.length === 1) return matches[0]; + throw new CapyError( + `More than one project matches "${ref}". Use the id.`, // COPY-FLAG + ERROR_CODES.PROJECT_AMBIGUOUS, + { matches }, + ); +} + +function refuseWithoutTerminal(json: boolean, what: string): never { + return refuse(json, { + code: ERROR_CODES.CONFIRMATION_REQUIRED, + message: `\`${what}\` needs confirmation. Pass --yes, or run it in a terminal.`, // COPY-FLAG + exitCode: EXIT_NEEDS_INPUT, + }); +} + +function liveOnly(tokens: readonly T[]): readonly T[] { + return tokens.filter((t) => t.revoked_at === null); +} + +type DeleteOutcome = + | { readonly kind: 'dry-run'; readonly project: ProjectRef; readonly tokens: readonly LiveDeployTokenInfo[] } + | { readonly kind: 'cancelled' } + | { readonly kind: 'deleted'; readonly result: DeleteProjectResponse }; + +function showPreview(json: boolean, project: ProjectRef, tokens: readonly LiveDeployTokenInfo[], force: boolean): void { + say(json, ''); + say(json, ` Project: ${project.name} (${project.id})`); // COPY-FLAG + say(json, tokens.length === 0 ? ' No live deploy tokens.' : ` Live deploy tokens: ${tokens.length}`); // COPY-FLAG + tokens.forEach((t) => say(json, ` ${describeToken(t)}`)); + if (tokens.length > 0) { + say(json, force + ? ' --force: those deploys stop on their next restart.' // COPY-FLAG + : ' Without --force the delete is refused while these exist.'); // COPY-FLAG + } + say(json, ''); +} + +async function deleteFlow(ref: string, opts: DeleteProjectOpts): Promise { + const json = opts.json === true; + const force = opts.force === true; + const { orgId, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + const project = pickOne(await serviceClient.listProjects(), ref, false); + + // The token list is only read when it is shown: a dry run, or a person about to be asked. + const showsPreview = opts.dryRun === true || opts.yes !== true; + const tokens = showsPreview ? liveOnly((await serviceClient.listDeployTokens(orgId, project.id)).tokens) : []; + + if (opts.dryRun === true) return { kind: 'dry-run', project, tokens }; + + if (opts.yes !== true) { + showPreview(json, project, tokens, force); + if (!(await confirm(json, `Delete project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG + } + return { kind: 'deleted', result: await serviceClient.deleteProject(orgId, project.id, force) }; +} + +function restoreHint(id: string): string { + return `capy org restore-project ${id}`; +} + +function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome): void { + if (outcome.kind === 'cancelled') { + if (json) console.log(JSON.stringify({ ok: false, code: ERROR_CODES.CANCELLED, error: 'Cancelled.' }, null, 2)); // COPY-FLAG + else console.log('Cancelled.'); // COPY-FLAG + return; + } + if (outcome.kind === 'dry-run') { + const { project, tokens } = outcome; + if (json) { + console.log(JSON.stringify({ + ok: true, + dry_run: true, + project: { id: project.id, name: project.name }, + live_deploy_tokens: tokens, + requires_force: tokens.length > 0 && !force, + }, null, 2)); + return; + } + showPreview(false, project, tokens, force); + console.log('Dry run: nothing was deleted.'); // COPY-FLAG + return; + } + const { project, live_deploy_tokens } = outcome.result; + if (json) { + console.log(JSON.stringify({ ok: true, project, live_deploy_tokens }, null, 2)); + return; + } + console.log(`Deleted project ${project.name}.`); // COPY-FLAG + console.log(`An owner can restore it with: ${restoreHint(project.id)}`); // COPY-FLAG +} + +/** `capy org delete-project [--force] [--yes] [--dry-run] [--json]` */ +export async function deleteProjectCommand(ref: string, opts: DeleteProjectOpts): Promise { + const json = opts.json === true; + + // Before any network call: no terminal and no --yes means nobody can answer. A dry run asks nothing. + if (opts.dryRun !== true && opts.yes !== true && !isInteractive(opts.nonTty)) { + refuseWithoutTerminal(json, 'capy org delete-project'); + } + + const settled = await settle(() => deleteFlow(ref, opts)); + if (!settled.ok) return refuse(json, refusalFor(settled.error)); + emitDeleteOutcome(json, opts.force === true, settled.value); +} + +type RestoreOutcome = + | { readonly kind: 'cancelled' } + | { readonly kind: 'restored'; readonly project: { readonly id: string; readonly name: string } }; + +async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { + const json = opts.json === true; + const { orgId, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + // Only an owner's `include_deleted` is honoured; for anyone else the service answers with the live projects, and the id still works. + const project = pickOne(await serviceClient.listProjects({ includeDeleted: true }), ref, true); + + if (opts.yes !== true && !(await confirm(json, `Restore project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG + + const result = await serviceClient.restoreProject(orgId, project.id); + return { kind: 'restored', project: result.project }; +} + +function emitRestoreOutcome(json: boolean, outcome: RestoreOutcome): void { + if (outcome.kind === 'cancelled') { + if (json) console.log(JSON.stringify({ ok: false, code: ERROR_CODES.CANCELLED, error: 'Cancelled.' }, null, 2)); // COPY-FLAG + else console.log('Cancelled.'); // COPY-FLAG + return; + } + if (json) { + console.log(JSON.stringify({ ok: true, project: outcome.project }, null, 2)); + return; + } + console.log(`Restored project ${outcome.project.name}.`); // COPY-FLAG +} + +/** `capy org restore-project [--yes] [--json]` */ +export async function restoreProjectCommand(ref: string, opts: ProjectLifecycleOpts): Promise { + const json = opts.json === true; + + if (opts.yes !== true && !isInteractive(opts.nonTty)) { + refuseWithoutTerminal(json, 'capy org restore-project'); + } + + const settled = await settle(() => restoreFlow(ref, opts)); + if (!settled.ok) return refuse(json, refusalFor(settled.error)); + emitRestoreOutcome(json, settled.value); +} diff --git a/src/commands/projectsCommand.ts b/src/commands/projectsCommand.ts index aea70910..d95e7184 100644 --- a/src/commands/projectsCommand.ts +++ b/src/commands/projectsCommand.ts @@ -27,6 +27,11 @@ export interface ProjectSummary { id: string; name: string; branches: ProjectBranchSummary[]; + /** + * Only on `--deleted` output: when the project was deleted, `null` for a live one. + * A deleted project lists no branches (the service answers 410 for them). + */ + deleted_at?: string | null; /** * Linked repos. Absent when the caller may not read them (only owners, admins * and project admins can) or the lookup failed; `[]` means "none linked". @@ -79,8 +84,9 @@ export class ProjectsCommand { private readonly devMode: boolean = false, ) {} - async execute(opts: { json?: boolean } = {}): Promise { + async execute(opts: { json?: boolean; deleted?: boolean } = {}): Promise { const json = opts.json === true; + const includeDeleted = opts.deleted === true; // Same CAP-273 contract as `usersCommand`/`listCommand`: under --json, no // progress output at all, so stdout stays pure JSON even on a TTY. const spinner = json ? null : new Spinner('Loading projects...'); @@ -88,7 +94,7 @@ export class ProjectsCommand { try { const { orgId, serviceClient } = await resolveOrgContext(this.apiUrl, this.devMode); - const summaries = await this.loadProjectSummaries(serviceClient); + const summaries = await this.loadProjectSummaries(serviceClient, includeDeleted); const projects = withRepos(summaries, await loadOrgRepos(serviceClient, orgId)); spinner?.succeed(`${projects.length} project${projects.length !== 1 ? 's' : ''}`); @@ -99,19 +105,25 @@ export class ProjectsCommand { } } - private async loadProjectSummaries(serviceClient: ServiceClient): Promise { - const rawProjects = excludeSystemProject(await serviceClient.listProjects()); + private async loadProjectSummaries(serviceClient: ServiceClient, includeDeleted: boolean): Promise { + // The argument is only passed for `--deleted`, so a plain listing sends exactly the request it always did. + const listed = includeDeleted ? await serviceClient.listProjects({ includeDeleted: true }) : await serviceClient.listProjects(); + const rawProjects = excludeSystemProject(listed); // Concurrent per-project branch fetch — independent GETs, fine to fan out. return Promise.all( - rawProjects.map(async (p) => ({ - id: p.id, - name: p.name, - branches: (await serviceClient.listBranches(p.id)).map((b) => ({ - id: b.id, - name: b.name, - protected: b.is_protected, - })), - })), + rawProjects.map(async (p) => { + const deletedAt = includeDeleted ? (p.deleted_at ?? null) : undefined; + // A deleted project answers 410 PROJECT_DELETED to its branch list: it has none to show. + const branches = deletedAt === null || deletedAt === undefined + ? (await serviceClient.listBranches(p.id)).map((b) => ({ id: b.id, name: b.name, protected: b.is_protected })) + : []; + return { + id: p.id, + name: p.name, + branches, + ...(includeDeleted ? { deleted_at: deletedAt } : {}), + }; + }), ); } @@ -128,7 +140,7 @@ export class ProjectsCommand { console.log(''); for (const project of projects) { - console.log(` ${B(project.name)} ${DIM}·${RESET} ${this.formatBranches(project.branches)}${this.formatRepos(project.repos)}`); + console.log(` ${B(project.name)} ${DIM}·${RESET} ${this.formatBranchesOrDeleted(project)}${this.formatRepos(project.repos)}`); } console.log(''); } @@ -139,6 +151,12 @@ export class ProjectsCommand { return ` ${DIM}·${RESET} ${repos.map(formatRepo).join(', ')}`; } + /** A deleted project reads `deleted ` where the branches would be. */ + private formatBranchesOrDeleted(project: ProjectSummary): string { + if (project.deleted_at === null || project.deleted_at === undefined) return this.formatBranches(project.branches); + return `${DIM}deleted ${project.deleted_at.slice(0, 10)}${RESET}`; // COPY-FLAG + } + private formatBranches(branches: ProjectBranchSummary[]): string { if (branches.length === 0) return `${DIM}(no branches)${RESET}`; return branches diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index 8838811e..abe5a67d 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -10,6 +10,7 @@ import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { pinnedHashes } from '../sync/commit'; import { placeholdersOnBranch, placeholderSetCommand } from '../core/placeholder'; +import { isProjectDeletedError } from '../errors/projectDeleted'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -76,13 +77,25 @@ export class StatusCommand { async execute(opts: { json?: boolean } = {}): Promise { try { await this._execute(opts); - } catch { - // Exit silently on any error (auth, network, etc.) + } catch (err) { + // CAP-729: a deleted project is the one failure that is not silent (the git-hook form stays silent: it must never block git). + if (isProjectDeletedError(err) && !this.terse) return this.endProjectDeleted(err, opts.json === true); + // Exit silently on any other error (auth, network, etc.) // Hooks must never block git operations process.exit(0); } } + /** The folder's project was deleted: the coded failure (JSON under `--json`, the error screen otherwise), exit 1. */ + private async endProjectDeleted(err: CapyError, json: boolean): Promise { + if (json) { + console.log(JSON.stringify({ ok: false, code: err.code, error: err.message }, null, 2)); + return process.exit(1); + } + const { displayErrorAndExit } = await import('../ui/errorScreen'); + return displayErrorAndExit(err); + } + /** The project key, or why it could not be had. Never throws. */ private async resolveEncryptionKey( projectState: { userId?: string; organizationId?: string; projectId?: string }, @@ -115,6 +128,7 @@ export class StatusCommand { ), }; } catch (err: any) { + if (isProjectDeletedError(err)) throw err; // Auth or key resolution failed — compare pinned vs local only return { skip: { reason: err?.message || 'auth or key resolution failed', kind: classifyRemoteFailure(err) } }; } @@ -188,6 +202,7 @@ export class StatusCommand { ); return { remoteHashes, skip: undefined }; } catch (err: any) { + if (isProjectDeletedError(err)) throw err; return { remoteHashes: {}, skip: { reason: err?.message || 'network error', kind: classifyRemoteFailure(err) } }; } } diff --git a/src/errors/projectDeleted.ts b/src/errors/projectDeleted.ts new file mode 100644 index 00000000..e2ceb336 --- /dev/null +++ b/src/errors/projectDeleted.ts @@ -0,0 +1,35 @@ +/** + * CAP-729: the folder's project was deleted. + * + * The service answers 410 with the code `PROJECT_DELETED` on every route that takes the project's id, and the + * service client mints that code (serviceClient.ts `classifyResponse`). Everything here keys on the code, never on text. + * + * A deleted project is not a missing one: nothing may offer to recreate it, and nothing may touch keep.lock. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { CapyError, ERROR_CODES } from '../types/index'; + +/** Whether `err` is the service's "this project was deleted" answer. */ +export function isProjectDeletedError(err: unknown): err is CapyError { + return err instanceof CapyError && err.code === ERROR_CODES.PROJECT_DELETED; +} + +/** The project the folder's keep.lock points at. Read only, never written; `undefined` when there is no readable keep.lock. */ +export function keepLockProject(dir: string): { name?: string; id?: string } | undefined { + try { + const raw = JSON.parse(readFileSync(join(dir, 'keep.lock'), 'utf-8')); + return { + name: typeof raw?.project_name === 'string' ? raw.project_name : undefined, + id: typeof raw?.project_id === 'string' ? raw.project_id : undefined, + }; + } catch { + return undefined; + } +} + +/** The command an owner runs to bring the folder's project back, or `undefined` when keep.lock names no project id. */ +export function restoreCommandFor(dir: string): string | undefined { + const id = keepLockProject(dir)?.id; + return id === undefined ? undefined : `capy org restore-project ${id}`; +} diff --git a/src/index-dev.ts b/src/index-dev.ts index 7aff3ecb..b39fce64 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -733,7 +733,7 @@ program } }); -program +const orgCmd = program .command('org') .description('Switch organization') .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG @@ -745,6 +745,43 @@ program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); +orgCmd + .command('delete-project ') + .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG + .option('--force', 'delete even if the project has live deploy tokens (those deploys stop on their next restart)') // COPY-FLAG + .option('--yes', 'skip the confirmation question') + .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (ref: string, _options: any, command: any) => { + const { deleteProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); + // Read from the merged view: the program's `-f/--force` and `org`'s `--non-tty` take those flags before this command sees them. + await deleteProjectCommand(ref, { ...lifecycleOptsFrom(command), apiUrl: process.env.CAPY_API_URL, devMode: true }); + }); + +orgCmd + .command('restore-project ') + .description('Restore a deleted project (owners only)') // COPY-FLAG + .option('--yes', 'skip the confirmation question') + .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (ref: string, _options: any, command: any) => { + const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); + const { yes, nonTty, json } = lifecycleOptsFrom(command); + await restoreProjectCommand(ref, { yes, nonTty, json, apiUrl: process.env.CAPY_API_URL, devMode: true }); + }); + +orgCmd + .command('projects') + .description('List projects in the active organization; --deleted also lists deleted ones (owners only)') // COPY-FLAG + .option('--deleted', 'also list deleted projects, marked as deleted, with the date') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (_options: any, command: any) => { + const { ProjectsCommand } = await import('./commands/projectsCommand'); + const cmd = new ProjectsCommand(process.env.CAPY_API_URL, true); + const merged = command.optsWithGlobals(); + await cmd.execute({ json: merged.json, deleted: merged.deleted === true }); + }); + program .command('info') .description('Show current session info') diff --git a/src/index.ts b/src/index.ts index 438f8f8f..5f3327fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -409,9 +409,9 @@ const deploy = program yes: options.yes ?? merged.yes, dryRun: options.dryRun ?? merged.dryRun, edit: options.edit, - // Deploy-level flag only — the global `-f/--force` means "re-encrypt", - // a different thing, so it must NOT be merged in here. - force: options.force, + // `--force` is also the program's `-f/--force` ("re-encrypt"), and commander gives + // that flag to the program, never to `deploy`: only the merged view carries it. + force: options.force ?? merged.force, // commander negates `--no-deploy` onto the positive `deploy` property. noDeploy: options.deploy === false, replaceSensitive: !!options.replaceSensitive, @@ -435,7 +435,7 @@ const deploy = program scope: options.scope, envName: options.envName, yes: !!options.yes, - force: !!options.force, + force: !!(options.force ?? merged.force), json: !!options.json, dryRun: (options.dryRun ?? merged.dryRun) === true, conflictFlags: rawConflictFlagsOf(cmd), @@ -766,7 +766,7 @@ systemCmd await systemRmCommand(name, { org: options.org, json: options.json, yes: options.yes }); }); -program +const orgCmd = program .command('org') .description('Switch organization') .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG @@ -781,6 +781,46 @@ program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); +orgCmd + .command('delete-project ') + .description('Delete a project (owners only; hidden, not destroyed; an owner can restore it)') // COPY-FLAG + .option('--force', 'delete even if the project has live deploy tokens (those deploys stop on their next restart)') // COPY-FLAG + .option('--yes', 'skip the confirmation question') + .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (ref: string, _options: any, command: any) => { + assertNotLocalOnly('org delete-project'); + const { deleteProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); + // Read from the merged view: the program's `-f/--force` and `org`'s `--non-tty` take those flags before this command sees them. + await deleteProjectCommand(ref, lifecycleOptsFrom(command)); + }); + +orgCmd + .command('restore-project ') + .description('Restore a deleted project (owners only)') // COPY-FLAG + .option('--yes', 'skip the confirmation question') + .option('--non-tty', 'treat stdin as not a terminal: refuse unless --yes is passed') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (ref: string, _options: any, command: any) => { + assertNotLocalOnly('org restore-project'); + const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); + const { yes, nonTty, json } = lifecycleOptsFrom(command); + await restoreProjectCommand(ref, { yes, nonTty, json }); + }); + +orgCmd + .command('projects') + .description('List projects in the active organization; --deleted also lists deleted ones (owners only)') // COPY-FLAG + .option('--deleted', 'also list deleted projects, marked as deleted, with the date') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (_options: any, command: any) => { + assertNotLocalOnly('org projects'); + const { ProjectsCommand } = await import('./commands/projectsCommand'); + const cmd = new ProjectsCommand(); + const merged = command.optsWithGlobals(); + await cmd.execute({ json: merged.json, deleted: merged.deleted === true }); + }); + program .command('info') .description('Show current session info') diff --git a/src/service/serviceClient.ts b/src/service/serviceClient.ts index 3fe57b11..5bf70cc3 100644 --- a/src/service/serviceClient.ts +++ b/src/service/serviceClient.ts @@ -50,6 +50,13 @@ const SERVER_CODES = new Set([ // longer the server's latest: HTTP 409. The commit path (sync/commit.ts) // re-reads and re-plans on exactly this code. ERROR_CODES.SECRETS_STALE_BASE, + // CAP-729 — project soft delete. 410 on any route reached with a deleted + // project's id; 409s on delete (live deploy tokens) and restore (not deleted). + // The two 403 codes (owner only, system store) cannot be minted here: a 403 is + // thrown as PERMISSION_DENIED above, with the server's code in `details.code`. + ERROR_CODES.PROJECT_DELETED, + ERROR_CODES.PROJECT_HAS_LIVE_DEPLOY_TOKENS, + ERROR_CODES.PROJECT_NOT_DELETED, ]); /** @@ -115,6 +122,20 @@ export type TransportStatusResult = | { readonly kind: 'expired' } | { readonly kind: 'unknown' }; +/** CAP-729: an unrevoked deploy token, as the project delete lists it. Never carries a secret. */ +export interface LiveDeployTokenInfo { + deploy_id: string; + label: string | null; + created_at: string; +} + +/** CAP-729: `DELETE /orgs/:orgId/projects/:projectId` answered 200. */ +export interface DeleteProjectResponse { + ok: true; + project: { id: string; name: string; deleted_at: string }; + live_deploy_tokens: LiveDeployTokenInfo[]; +} + export interface MemberProjectBranch { id: string; name: string; @@ -737,13 +758,25 @@ export class ServiceClient { * List all projects in the caller's organization. Used by the client to * bootstrap when running in a directory with no local keep.lock. */ - async listProjects(): Promise> { + async listProjects(options?: { includeDeleted?: boolean }): Promise> { + // CAP-729: only an org owner's `include_deleted=1` is honoured; for anyone else the service ignores it. + const query = options?.includeDeleted === true ? '?include_deleted=1' : ''; const data = await this.request<{ - projects: Array<{ id: string; name: string; organization_id: string }>; - }>('GET', '/projects'); + projects: Array<{ id: string; name: string; organization_id: string; deleted_at?: string | null }>; + }>('GET', `/projects${query}`); return data.projects; } + /** CAP-729: soft-delete a project (org owner only). Refused with 409 PROJECT_HAS_LIVE_DEPLOY_TOKENS unless `force`. */ + async deleteProject(orgId: string, projectId: string, force: boolean): Promise { + return this.request('DELETE', `/orgs/${orgId}/projects/${encodeURIComponent(projectId)}`, force ? { force: true } : undefined); + } + + /** CAP-729: bring a soft-deleted project back (org owner only). */ + async restoreProject(orgId: string, projectId: string): Promise<{ ok: true; project: { id: string; name: string } }> { + return this.request('POST', `/orgs/${orgId}/projects/${encodeURIComponent(projectId)}/restore`, {}); + } + async createBranch(projectId: string, name: string, isProtected: boolean = false): Promise { return this.request( 'POST', `/projects/${projectId}/branches`, diff --git a/src/sync/gate.ts b/src/sync/gate.ts index 3ed9a189..dc28fd96 100644 --- a/src/sync/gate.ts +++ b/src/sync/gate.ts @@ -38,6 +38,7 @@ import { type FolderContext, } from './commit'; import { CapyError, ERROR_CODES } from '../types/index'; +import { restoreCommandFor } from '../errors/projectDeleted'; import type { SyncOutcome } from '../commands/capyCommand'; export interface GateOptions { @@ -191,6 +192,8 @@ async function failedSync(err: unknown, opts: GateOptions): Promise { // Off a terminal (no one to read the error screen, stdout reserved for payloads): the code and what to do, on stderr. if (answersByProtocol(opts)) { console.error(`${coded.code}: ${coded.message}`); + const restore = coded.code === ERROR_CODES.PROJECT_DELETED ? restoreCommandFor(opts.cwd ?? process.cwd()) : undefined; + if (restore !== undefined) console.error(`To restore it, an organization owner runs: ${restore}`); // COPY-FLAG return process.exit(1); } const { displayErrorAndExit } = await import('../ui/errorScreen'); diff --git a/src/types/index.ts b/src/types/index.ts index 4874a847..f11dbe3c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -886,6 +886,21 @@ export const ERROR_CODES = { SECRETS_NOTHING_SELECTED: 'SECRETS_NOTHING_SELECTED', /** `capy secrets set NAME`: the run finished but at least one location or repo failed. Exit 1. */ SECRETS_PARTIAL: 'SECRETS_PARTIAL', + // --- Project soft delete and restore (CAP-729) --- + /** The service answered 410: the project was deleted. Sent by every route that takes a project id. Never an invitation to recreate it. */ + PROJECT_DELETED: 'PROJECT_DELETED', + /** Delete or restore was called by someone who is not an org owner (403). Reaches the client as a PERMISSION_DENIED whose `details.code` is this. */ + PROJECT_DELETE_OWNER_ONLY: 'PROJECT_DELETE_OWNER_ONLY', + /** Delete without `--force` while the project has unrevoked deploy tokens (409). The body lists them. */ + PROJECT_HAS_LIVE_DEPLOY_TOKENS: 'PROJECT_HAS_LIVE_DEPLOY_TOKENS', + /** Restore called on a project that is not deleted (409). */ + PROJECT_NOT_DELETED: 'PROJECT_NOT_DELETED', + /** Delete called on the org's system store (403). Reaches the client as a PERMISSION_DENIED whose `details.code` is this. */ + SYSTEM_STORE_IMMUTABLE: 'SYSTEM_STORE_IMMUTABLE', + /** `capy org delete-project` / `restore-project`: the name or id matched more than one project. The refusal lists the ids. */ + PROJECT_AMBIGUOUS: 'PROJECT_AMBIGUOUS', + /** A destructive command needs a yes and there is no terminal to ask, and `--yes` was not passed. Exit 3. */ + CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED', /** `GET /orgs/:orgId/repos` answered 404: the service predates project -> repo links. Never a failure of the command that asked. */ REPO_LINKS_UNSUPPORTED: 'REPO_LINKS_UNSUPPORTED', /** `--dry-run` on a path that would prompt (the `capy edit` TUI, an interactive `capy add`, `capy remove`'s confirmation): it cannot preview, and a dry run never runs for real. Exit 1. */ diff --git a/src/ui/errorScreen.ts b/src/ui/errorScreen.ts index cdf64f59..49c4ae9f 100644 --- a/src/ui/errorScreen.ts +++ b/src/ui/errorScreen.ts @@ -1,5 +1,6 @@ import { CapyError, ERROR_CODES } from '../types/index'; import { isMembershipRevokedError } from '../errors/membershipRevoked'; +import { keepLockProject } from '../errors/projectDeleted'; const grey = (s: string) => `\x1b[90m${s}\x1b[0m`; const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -42,6 +43,9 @@ export function renderError(error: any, context: ErrorContext = {}): string { // now, so they are ordinary cases in the same switch as everything else. case ERROR_CODES.PROJECT_NOT_FOUND: return renderProjectNotFound(context); + // CAP-729: a deleted project is not a missing one. It has its own screen so the "recreate" advice above can never reach it. + case ERROR_CODES.PROJECT_DELETED: + return renderProjectDeleted(context); case ERROR_CODES.BRANCH_NOT_FOUND: return renderBranchNotFound(error); case ERROR_CODES.INVALID_FORMAT: @@ -211,6 +215,35 @@ function renderProjectNotFound(ctx: ErrorContext): string { return lines.filter(l => l !== '').join('\n') + '\n'; } +/** + * CAP-729: the folder's project was deleted (HTTP 410, code `PROJECT_DELETED`). + * + * Tells the person how to get it back and nothing else: it never offers to + * create a new project in its place and never touches keep.lock. The project is + * named from the caller's context, else from keep.lock (read only). + * + * COPY-FLAG: every string here is a minimal-neutral placeholder. + */ +function renderProjectDeleted(ctx: ErrorContext): string { + const fromLock = ctx.projectName !== undefined && ctx.projectId !== undefined ? undefined : keepLockProject(process.cwd()); + const name = ctx.projectName ?? fromLock?.name; + const id = ctx.projectId ?? fromLock?.id; + const target = id ?? name ?? ''; + const lines = [ + '', + ` ${bold('Project deleted')}`, // COPY-FLAG + name ? ` ${grey(`Project "${name}" was deleted.`)}` : '', // COPY-FLAG + id ? ` ${grey(`ID: ${id}`)}` : '', // COPY-FLAG + '', + ` To restore it, an organization owner runs:`, // COPY-FLAG + ` ${bold(`capy org restore-project ${target}`)}`, + '', + ` ${grey('Only an owner can restore a project.')}`, // COPY-FLAG + '', + ]; + return lines.filter(l => l !== '').join('\n') + '\n'; +} + function renderBranchNotFound(error: CapyError): string { const lines = [ '', diff --git a/tests/commands/projectDeletedCli.test.ts b/tests/commands/projectDeletedCli.test.ts new file mode 100644 index 00000000..a16f3463 --- /dev/null +++ b/tests/commands/projectDeletedCli.test.ts @@ -0,0 +1,196 @@ +/** + * CAP-729, through the BUILT cli and its real argument parser (the piped harness: an isolated HOME and a mock service). + * Needs `bun run build` first. + * + * 1. `org delete-project --force` reaches the command. The program defines `-f/--force` and `org` defines `--non-tty`, + * so commander gives those flags to the outer command and the subcommand's own options never hold them. + * 2. A folder whose project was deleted (the service answers 410 `PROJECT_DELETED`): bare `capy`, `capy status` and + * `capy push` end non-zero with that code, offer no recreate, and leave keep.lock byte-identical. + */ +import { describe, test, expect } from 'bun:test'; +import { Command } from 'commander'; +import { readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { withHarness, PROJECT_ID, type Harness } from '../helpers/pipedHarness'; +import { lifecycleOptsFrom } from '../../src/commands/projectLifecycleCommand'; + +const NAME = 'DELETED_FOLDER_TOKEN'; +const RESTORE = `capy org restore-project ${PROJECT_ID}`; +const QUIET = { CAPY_NO_REPO_LINK: '1' } as const; + +const requests = (h: Harness, method: string) => h.requests().filter((r) => r.method === method && r.path.includes('/projects/')); + +/** A folder with one real secret, then the project deleted on the service side and the keep cache gone (so a read must go to the service). */ +async function deletedFolder(h: Harness): Promise { + const seeded = await h.run(['edit', NAME, '--json', '--no-pr'], 'first-value\n', undefined, QUIET); + expect(seeded.code).toBe(0); + rmSync(join(h.home, '.capy', 'keep'), { recursive: true, force: true }); + h.markProjectDeleted(); +} + +describe('org delete-project / restore-project / projects: flags the program or `org` also defines', () => { + test('--force arrives: the DELETE body is {"force":true}; without it there is no body', async () => { + await withHarness(async (h) => { + const forced = await h.run(['org', 'delete-project', PROJECT_ID, '--force', '--yes', '--json']); + expect(forced.code).toBe(0); + const without = await h.run(['org', 'delete-project', PROJECT_ID, '--yes', '--json']); + expect(without.code).toBe(0); + + const deletes = requests(h, 'DELETE'); + expect(deletes.map((r) => r.body)).toEqual([JSON.stringify({ force: true }), '']); + }); + }); + + test('-f, typed before the subcommand or after it, is the same flag', async () => { + await withHarness(async (h) => { + await h.run(['-f', 'org', 'delete-project', PROJECT_ID, '--yes', '--json']); + await h.run(['org', 'delete-project', PROJECT_ID, '-f', '--yes', '--json']); + expect(requests(h, 'DELETE').map((r) => r.body)).toEqual([JSON.stringify({ force: true }), JSON.stringify({ force: true })]); + }); + }); + + test('--dry-run still sends no DELETE', async () => { + await withHarness(async (h) => { + const out = await h.run(['org', 'delete-project', PROJECT_ID, '--dry-run', '--json']); + expect(out.code).toBe(0); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: true, dry_run: true }); + expect(requests(h, 'DELETE')).toHaveLength(0); + }); + }); + + test('restore-project --yes --json posts the restore', async () => { + await withHarness(async (h) => { + const out = await h.run(['org', 'restore-project', PROJECT_ID, '--yes', '--json']); + expect(out.code).toBe(0); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: true, project: { id: PROJECT_ID } }); + expect(requests(h, 'POST').map((r) => r.path)).toEqual([`/orgs/org_test_piped/projects/${PROJECT_ID}/restore`]); + }); + }); + + test('org projects --deleted asks the service for the deleted ones too', async () => { + await withHarness(async (h) => { + h.setBranches(PROJECT_ID, [{ id: 'br_1', name: 'production', is_protected: false }]); + const out = await h.run(['org', 'projects', '--deleted', '--json']); + expect(out.code).toBe(0); + expect(h.requests().some((r) => r.method === 'GET' && r.path === '/projects?include_deleted=1')).toBe(true); + }); + }); +}); + +describe('lifecycleOptsFrom: --non-tty and --force, wherever the outer command took them', () => { + /** The same shape as src/index.ts: the program has -f/--force and -d/--dry-run, `org` has --non-tty. */ + const parse = (argv: readonly string[]) => { + const seen: Array> = []; + const program = new Command().option('-f, --force').option('-d, --dry-run').exitOverride(); + const org = program.command('org').option('--non-tty').action(() => undefined); + org + .command('delete-project ') + .option('--force') + .option('--yes') + .option('--non-tty') + .option('--json') + .action((_ref: string, _options: unknown, command: Command) => seen.push(lifecycleOptsFrom(command))); + program.parse(['bun', 'capy', ...argv]); + return seen[0]; + }; + + test('--non-tty reaches the command (org takes it)', () => { + expect(parse(['org', 'delete-project', 'web', '--non-tty'])?.nonTty).toBe(true); + expect(parse(['org', '--non-tty', 'delete-project', 'web'])?.nonTty).toBe(true); + }); + + test('--force reaches the command (the program takes it)', () => { + expect(parse(['org', 'delete-project', 'web', '--force'])?.force).toBe(true); + expect(parse(['org', 'delete-project', 'web'])?.force).toBe(false); + }); + + test('--yes, --json and --dry-run come through too', () => { + expect(parse(['org', 'delete-project', 'web', '--yes', '--json', '--dry-run'])).toEqual({ + force: false, + yes: true, + nonTty: false, + json: true, + dryRun: true, + }); + }); +}); + +describe('a folder whose project was deleted (the service answers 410 PROJECT_DELETED)', () => { + test('bare `capy`: exit 1, the deleted-project screen with the restore command, no recreate, no "up to date", keep.lock untouched', async () => { + await withHarness(async (h) => { + await deletedFolder(h); + const lockBefore = h.keepLockText(); + + const out = await h.run([]); + + expect(out.code).toBe(1); + expect(out.stdout).toContain('Project deleted'); + expect(out.stdout).toContain(RESTORE); + const everything = `${out.stdout}\n${out.stderr}`; + expect(everything).not.toContain('Everything is up to date'); + expect(everything).not.toContain('Cannot reach remote'); + expect(everything.toLowerCase()).not.toContain('recreate'); + expect(h.keepLockText()).toBe(lockBefore); + }); + }); + + test('bare `capy` leaves .env as it found it too', async () => { + await withHarness(async (h) => { + await deletedFolder(h); + const envBefore = readFileSync(join(h.project, '.env'), 'utf8'); + await h.run([]); + expect(readFileSync(join(h.project, '.env'), 'utf8')).toBe(envBefore); + }); + }); + + test('`capy status --json`: exit 1, JSON with ok false and code PROJECT_DELETED, not inSync', async () => { + await withHarness(async (h) => { + await deletedFolder(h); + const lockBefore = h.keepLockText(); + + const out = await h.run(['status', '--json']); + + expect(out.code).toBe(1); + const body = JSON.parse(out.stdout) as Record; + expect(body).toMatchObject({ ok: false, code: 'PROJECT_DELETED' }); + expect(body.inSync).toBeUndefined(); + expect(out.stdout.toLowerCase()).not.toContain('recreate'); + expect(h.keepLockText()).toBe(lockBefore); + }); + }); + + test('`capy status` (human): exit 1 and the restore command, no recreate', async () => { + await withHarness(async (h) => { + await deletedFolder(h); + const out = await h.run(['status']); + expect(out.code).toBe(1); + expect(out.stdout).toContain('Project deleted'); + expect(out.stdout).toContain(RESTORE); + expect(`${out.stdout}${out.stderr}`.toLowerCase()).not.toContain('recreate'); + }); + }); + + test('`capy push --non-tty` (off a terminal): exit 1, the code and the restore command on stderr, keep.lock untouched', async () => { + await withHarness(async (h) => { + await deletedFolder(h); + const lockBefore = h.keepLockText(); + + const out = await h.run(['push', '--non-tty']); + + expect(out.code).toBe(1); + expect(out.stderr).toContain('PROJECT_DELETED'); + expect(out.stderr).toContain(RESTORE); + expect(`${out.stdout}${out.stderr}`.toLowerCase()).not.toContain('recreate'); + expect(h.keepLockText()).toBe(lockBefore); + }); + }); + + test('`capy edit NAME --json` (a command behind the gate): exit 1 with code PROJECT_DELETED in the JSON', async () => { + await withHarness(async (h) => { + await deletedFolder(h); + const out = await h.run(['edit', NAME, '--json', '--no-pr'], 'second-value\n', undefined, QUIET); + expect(out.code).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_DELETED' }); + }); + }); +}); diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts new file mode 100644 index 00000000..5e60e677 --- /dev/null +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -0,0 +1,546 @@ +import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } from 'bun:test'; + +/** + * `capy org delete-project` and `capy org restore-project` (CAP-729). + * + * The REAL `ServiceClient` talks HTTP to a local fake of the service, so these + * tests see exactly what goes on the wire (the method, the path, the body) and + * run the real status/`code` classification on the way back. In particular: + * "`--dry-run` sends no write" is asserted as "no DELETE request ever arrived", + * not as "a mock was not called". + * + * Only org resolution (auth) and the inquirer prompt are replaced. + * `mock.module()` is process-wide: tests/run-tests.sh runs this file isolated. + */ + +import { ServiceClient } from '../../src/service/serviceClient'; + +type Reply = { readonly status: number; readonly body: unknown }; +const reply = (status: number, body: unknown): Reply => ({ status, body }); + +/** (method, path with query, raw body) -> the fake service's answer. Replaced per test. */ +const respond = mock((_method: string, _path: string, _body: string): Reply => reply(500, { error: 'unexpected request' })); + +const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const answer = respond(req.method, `${url.pathname}${url.search}`, await req.text()); + return new Response(JSON.stringify(answer.body), { status: answer.status, headers: { 'Content-Type': 'application/json' } }); + }, +}); + +const client = new ServiceClient(`http://localhost:${server.port}`); +client.setTokenProvider((async () => ({ access_token: 'test-token' })) as never); + +mock.module('../../src/core/orgContext', () => ({ + resolveOrgContext: mock(async () => ({ + orgId: 'org_1', + userId: 'user_1', + userEmail: 'owner@example.com', + authService: {}, + serviceClient: client, + })), +})); + +/** What the person answers when asked. Replaced per test. */ +const fakePrompt = mock(async (_questions: unknown) => ({ confirmed: true })); +const createPromptModule = mock((_opts: unknown) => fakePrompt); +mock.module('inquirer', () => ({ + default: { prompt: fakePrompt, createPromptModule }, +})); + +afterAll(() => { + server.stop(true); + mock.restore(); +}); + +/** Distinguishes the intentional `process.exit()` throw from a real bug, without parsing message text. */ +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +interface Captured { + readonly exitCode?: number; + readonly stdout: string; + readonly stderr: string; +} + +/** Runs `fn`, capturing stdout/stderr and the exit code; `process.exit` never exits the runner. */ +async function capture(fn: () => Promise): Promise { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + + const thrown: unknown = await fn().then(() => null, (err: unknown) => err); + + // Read the calls BEFORE mockRestore(): bun clears them on restore. + const stdout = logSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + const stderr = errSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + const exitCalls = exitSpy.mock.calls; + const exitCode = exitCalls.length > 0 ? (exitCalls[exitCalls.length - 1][0] as number | undefined) : undefined; + + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + + if (thrown !== null && !(thrown instanceof ExitSignal)) throw thrown; + return { exitCode, stdout, stderr }; +} + +const setTTY = (value: boolean) => Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); + +const requests = () => respond.mock.calls.map(([method, path, body]) => ({ method, path, body })); +const writes = () => requests().filter((r) => r.method === 'DELETE' || r.method === 'POST'); +const deletes = () => requests().filter((r) => r.method === 'DELETE'); + +const WEB = { id: 'proj_web', name: 'web' }; +const API = { id: 'proj_api', name: 'api' }; +const LIVE = [WEB, API].map((p) => ({ ...p, organization_id: 'org_1' })); + +const TOKEN = { deploy_id: 'dep_1', label: 'prod web', created_at: '2026-10-01T00:00:00.000Z' }; +const OWNER_ONLY = reply(403, { error: 'Only the organization owner can delete a project', code: 'PROJECT_DELETE_OWNER_ONLY' }); + +/** A service that lists `projects`, reports `tokens` as unrevoked, and answers the write with `onWrite`. */ +function service(opts: { + projects?: readonly object[]; + tokens?: readonly object[]; + onWrite?: (method: string, path: string, body: string) => Reply; +}) { + const { projects = LIVE, tokens = [], onWrite = () => reply(500, { error: 'no write expected' }) } = opts; + respond.mockImplementation((method, path, body) => { + if (method === 'GET' && path.startsWith('/projects')) return reply(200, { projects }); + if (method === 'GET' && path.endsWith('/deploy-tokens')) { + return reply(200, { tokens: tokens.map((t) => ({ created_by: 'user_1', revoked_at: null, ...t })) }); + } + return onWrite(method, path, body); + }); +} + +const deleted = (id: string, name: string) => + reply(200, { ok: true, project: { id, name, deleted_at: '2026-11-05T12:00:00.000Z' }, live_deploy_tokens: [] }); + +// After the mocks above: the command module picks up the replaced org resolution and prompt. +const { deleteProjectCommand, restoreProjectCommand } = await import('../../src/commands/projectLifecycleCommand'); + +describe('capy org delete-project', () => { + beforeEach(() => { + respond.mockReset(); + fakePrompt.mockReset(); + fakePrompt.mockImplementation(async () => ({ confirmed: true })); + createPromptModule.mockClear(); + setTTY(false); + }); + afterEach(() => setTTY(false)); + + describe('confirmation', () => { + it('off a terminal without --yes: refuses with CONFIRMATION_REQUIRED (exit 3) before any request', async () => { + service({ onWrite: () => deleted('proj_web', 'web') }); + + const out = await capture(() => deleteProjectCommand('web', { json: true })); + + expect(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED' }); + expect(requests()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); + }); + + it('--non-tty on a real terminal refuses the same way', async () => { + setTTY(true); + service({}); + + const out = await capture(() => deleteProjectCommand('web', { json: true, nonTty: true })); + + expect(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout).code).toBe('CONFIRMATION_REQUIRED'); + expect(requests()).toEqual([]); + }); + + it('the prompt defaults to no, and shows the project and its live tokens first', async () => { + setTTY(true); + service({ tokens: [TOKEN], onWrite: () => deleted('proj_web', 'web') }); + fakePrompt.mockImplementation(async () => ({ confirmed: false })); + + const out = await capture(() => deleteProjectCommand('web', {})); + + const question = (fakePrompt.mock.calls[0][0] as Array<{ type: string; default: boolean }>)[0]; + expect(question.type).toBe('confirm'); + expect(question.default).toBe(false); + expect(out.stdout).toContain('web'); + expect(out.stdout).toContain('proj_web'); + expect(out.stdout).toContain('dep_1'); + expect(out.stdout).toContain('prod web'); + expect(out.stdout).toContain('Cancelled.'); + expect(deletes()).toEqual([]); + }); + + it('answering no with --json says CANCELLED on stdout, deletes nothing, and asks on stderr', async () => { + setTTY(true); + service({}); + fakePrompt.mockImplementation(async () => ({ confirmed: false })); + + const out = await capture(() => deleteProjectCommand('web', { json: true })); + + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CANCELLED' }); + expect(createPromptModule.mock.calls[0][0]).toEqual({ output: process.stderr }); + expect(out.stdout).not.toContain('Project:'); + expect(deletes()).toEqual([]); + }); + + it('answering yes sends one DELETE for the resolved id, with no force', async () => { + setTTY(true); + service({ onWrite: () => deleted('proj_web', 'web') }); + + const out = await capture(() => deleteProjectCommand('web', {})); + + expect(deletes()).toEqual([{ method: 'DELETE', path: '/orgs/org_1/projects/proj_web', body: '' }]); + expect(out.stdout).toContain('Deleted project web'); + expect(out.stdout).toContain('capy org restore-project proj_web'); + }); + + it('--yes skips the question and the token read, even off a terminal', async () => { + service({ onWrite: () => deleted('proj_web', 'web') }); + + await capture(() => deleteProjectCommand('web', { yes: true })); + + expect(fakePrompt).not.toHaveBeenCalled(); + expect(requests().map((r) => r.method)).toEqual(['GET', 'DELETE']); + expect(requests().some((r) => r.path.endsWith('/deploy-tokens'))).toBe(false); + }); + }); + + describe('--force and live deploy tokens', () => { + it('--force sends {"force":true}', async () => { + service({ onWrite: () => deleted('proj_web', 'web') }); + + await capture(() => deleteProjectCommand('web', { yes: true, force: true })); + + expect(deletes()).toHaveLength(1); + expect(JSON.parse(deletes()[0].body)).toEqual({ force: true }); + }); + + it('a 409 PROJECT_HAS_LIVE_DEPLOY_TOKENS lists the tokens and says --force deletes anyway (human)', async () => { + service({ + onWrite: () => + reply(409, { error: 'The project has live deploy tokens', code: 'PROJECT_HAS_LIVE_DEPLOY_TOKENS', live_deploy_tokens: [TOKEN] }), + }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true })); + + expect(out.exitCode).toBe(1); + expect(out.stderr).toContain('dep_1'); + expect(out.stderr).toContain('prod web'); + expect(out.stderr).toContain('--force'); + expect(out.stderr).toContain('next restart'); + expect(out.stdout).toBe(''); + }); + + it('the same refusal under --json: code and the token list on stdout, pure JSON', async () => { + service({ + onWrite: () => + reply(409, { error: 'The project has live deploy tokens', code: 'PROJECT_HAS_LIVE_DEPLOY_TOKENS', live_deploy_tokens: [TOKEN] }), + }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_HAS_LIVE_DEPLOY_TOKENS', live_deploy_tokens: [TOKEN] }); + expect(out.stderr).toBe(''); + }); + + it('a revoked token is not listed as live in the preview', async () => { + setTTY(true); + service({ tokens: [TOKEN, { ...TOKEN, deploy_id: 'dep_old', revoked_at: '2026-10-02T00:00:00.000Z' }] }); + fakePrompt.mockImplementation(async () => ({ confirmed: false })); + + const out = await capture(() => deleteProjectCommand('web', {})); + + expect(out.stdout).toContain('dep_1'); + expect(out.stdout).not.toContain('dep_old'); + }); + }); + + describe('--dry-run', () => { + it('sends no DELETE (and no other write), needs no terminal, and says nothing was deleted', async () => { + service({ tokens: [TOKEN], onWrite: () => deleted('proj_web', 'web') }); + + const out = await capture(() => deleteProjectCommand('web', { dryRun: true })); + + expect(out.exitCode).toBeUndefined(); + expect(requests().length).toBeGreaterThan(0); + expect(writes()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); + expect(out.stdout).toContain('proj_web'); + expect(out.stdout).toContain('dep_1'); + expect(out.stdout).toContain('Dry run'); + }); + + it('--yes --force --dry-run still sends no write', async () => { + service({ tokens: [TOKEN], onWrite: () => deleted('proj_web', 'web') }); + + await capture(() => deleteProjectCommand('web', { dryRun: true, yes: true, force: true })); + + expect(writes()).toEqual([]); + }); + + it('--json shape: the project, the live tokens, and whether --force would be needed', async () => { + service({ tokens: [TOKEN] }); + + const out = await capture(() => deleteProjectCommand('web', { dryRun: true, json: true })); + + expect(JSON.parse(out.stdout)).toEqual({ + ok: true, + dry_run: true, + project: { id: 'proj_web', name: 'web' }, + live_deploy_tokens: [{ ...TOKEN, created_by: 'user_1', revoked_at: null }], + requires_force: true, + }); + expect(writes()).toEqual([]); + }); + + it('with no live tokens, requires_force is false', async () => { + service({}); + + const out = await capture(() => deleteProjectCommand('web', { dryRun: true, json: true })); + + expect(JSON.parse(out.stdout)).toMatchObject({ live_deploy_tokens: [], requires_force: false }); + }); + }); + + describe('--json', () => { + it('success: the service answer on stdout, nothing else', async () => { + service({ onWrite: () => deleted('proj_web', 'web') }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(out.exitCode).toBeUndefined(); + expect(JSON.parse(out.stdout)).toEqual({ + ok: true, + project: { id: 'proj_web', name: 'web', deleted_at: '2026-11-05T12:00:00.000Z' }, + live_deploy_tokens: [], + }); + expect(out.stderr).toBe(''); + }); + }); + + describe('refusals are coded', () => { + it('owner only: the 403 surfaces as PROJECT_DELETE_OWNER_ONLY (json)', async () => { + service({ onWrite: () => OWNER_ONLY }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_DELETE_OWNER_ONLY' }); + }); + + it('owner only, human: prose on stderr, nothing on stdout, exit 1', async () => { + service({ onWrite: () => OWNER_ONLY }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true })); + + expect(out.exitCode).toBe(1); + expect(out.stderr).toContain('owner'); + expect(out.stdout).toBe(''); + }); + + it('the system store: SYSTEM_STORE_IMMUTABLE', async () => { + service({ onWrite: () => reply(403, { error: 'The org system store cannot be deleted', code: 'SYSTEM_STORE_IMMUTABLE' }) }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(JSON.parse(out.stdout).code).toBe('SYSTEM_STORE_IMMUTABLE'); + }); + + it('a plain 403 stays PERMISSION_DENIED (the unwrapping is only for the two known codes)', async () => { + service({ onWrite: () => reply(403, { error: 'nope', code: 'MEMBERSHIP_REVOKED' }) }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(JSON.parse(out.stdout).code).toBe('PERMISSION_DENIED'); + }); + + it('already deleted: 410 PROJECT_DELETED', async () => { + service({ onWrite: () => reply(410, { error: 'This project has been deleted', code: 'PROJECT_DELETED' }) }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(JSON.parse(out.stdout).code).toBe('PROJECT_DELETED'); + }); + + it('no such project: the name is sent on as an id and the service answers PROJECT_NOT_FOUND', async () => { + service({ onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); + + const out = await capture(() => deleteProjectCommand('nope', { yes: true, json: true })); + + expect(deletes()[0].path).toBe('/orgs/org_1/projects/nope'); + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NOT_FOUND'); + }); + }); + + describe('resolving the name', () => { + it('an ambiguous name is refused with every id listed, and nothing is deleted', async () => { + const twins = [ + { id: 'proj_a1', name: 'web', organization_id: 'org_1' }, + { id: 'proj_a2', name: 'web', organization_id: 'org_1' }, + ]; + service({ projects: twins, onWrite: () => deleted('proj_a1', 'web') }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true, json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toEqual({ + ok: false, + code: 'PROJECT_AMBIGUOUS', + error: expect.any(String), + matches: [{ id: 'proj_a1', name: 'web' }, { id: 'proj_a2', name: 'web' }], + }); + expect(deletes()).toEqual([]); + }); + + it('an ambiguous name, human: both ids on stderr', async () => { + service({ + projects: [ + { id: 'proj_a1', name: 'web', organization_id: 'org_1' }, + { id: 'proj_a2', name: 'web', organization_id: 'org_1' }, + ], + }); + + const out = await capture(() => deleteProjectCommand('web', { yes: true })); + + expect(out.stderr).toContain('proj_a1'); + expect(out.stderr).toContain('proj_a2'); + expect(deletes()).toEqual([]); + }); + + it('an id works where a name would, and wins over a project whose name is that id', async () => { + service({ + projects: [{ id: 'proj_web', name: 'web', organization_id: 'org_1' }, { id: 'proj_x', name: 'proj_web', organization_id: 'org_1' }], + onWrite: () => deleted('proj_web', 'web'), + }); + + await capture(() => deleteProjectCommand('proj_web', { yes: true })); + + expect(deletes()[0].path).toBe('/orgs/org_1/projects/proj_web'); + }); + + it('the org system store is never a match', async () => { + service({ projects: [{ id: 'proj_sys', name: '_system', organization_id: 'org_1' }], onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); + + await capture(() => deleteProjectCommand('_system', { yes: true, json: true })); + + // Not resolved from the listing: it goes on as a bare id and the service decides. + expect(deletes()[0].path).toBe('/orgs/org_1/projects/_system'); + }); + }); +}); + +describe('capy org restore-project', () => { + beforeEach(() => { + respond.mockReset(); + fakePrompt.mockReset(); + fakePrompt.mockImplementation(async () => ({ confirmed: true })); + createPromptModule.mockClear(); + setTTY(false); + }); + afterEach(() => setTTY(false)); + + const DELETED = { id: 'proj_old', name: 'old', organization_id: 'org_1', deleted_at: '2026-11-05T12:00:00.000Z' }; + const restored = reply(200, { ok: true, project: { id: 'proj_old', name: 'old' } }); + + it('--yes: asks the service for the listing with include_deleted=1, then POSTs the restore', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { yes: true })); + + expect(requests().map((r) => `${r.method} ${r.path}`)).toEqual([ + 'GET /projects?include_deleted=1', + 'POST /orgs/org_1/projects/proj_old/restore', + ]); + expect(out.stdout).toContain('Restored project old'); + }); + + it('--json: {ok, project} on stdout and nothing on stderr', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { yes: true, json: true })); + + expect(JSON.parse(out.stdout)).toEqual({ ok: true, project: { id: 'proj_old', name: 'old' } }); + expect(out.stderr).toBe(''); + }); + + it('an id works', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + await capture(() => restoreProjectCommand('proj_old', { yes: true })); + + expect(writes().map((r) => r.path)).toEqual(['/orgs/org_1/projects/proj_old/restore']); + }); + + it('prompts with a default of no; answering no restores nothing', async () => { + setTTY(true); + service({ projects: [DELETED], onWrite: () => restored }); + fakePrompt.mockImplementation(async () => ({ confirmed: false })); + + const out = await capture(() => restoreProjectCommand('old', {})); + + const question = (fakePrompt.mock.calls[0][0] as Array<{ default: boolean }>)[0]; + expect(question.default).toBe(false); + expect(out.stdout).toContain('Cancelled.'); + expect(writes()).toEqual([]); + }); + + it('off a terminal without --yes: CONFIRMATION_REQUIRED before any request', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { json: true })); + + expect(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout).code).toBe('CONFIRMATION_REQUIRED'); + expect(requests()).toEqual([]); + }); + + it('a project that is not deleted: 409 PROJECT_NOT_DELETED', async () => { + service({ + projects: [{ id: 'proj_web', name: 'web', organization_id: 'org_1', deleted_at: null }], + onWrite: () => reply(409, { error: 'The project is not deleted', code: 'PROJECT_NOT_DELETED' }), + }); + + const out = await capture(() => restoreProjectCommand('web', { yes: true, json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NOT_DELETED' }); + }); + + it('owner only: PROJECT_DELETE_OWNER_ONLY, and an id still reaches the service when the listing hides deleted projects', async () => { + service({ projects: [], onWrite: () => reply(403, { error: 'Only the organization owner can restore a project', code: 'PROJECT_DELETE_OWNER_ONLY' }) }); + + const out = await capture(() => restoreProjectCommand('proj_old', { yes: true, json: true })); + + expect(writes()[0].path).toBe('/orgs/org_1/projects/proj_old/restore'); + expect(JSON.parse(out.stdout).code).toBe('PROJECT_DELETE_OWNER_ONLY'); + }); + + it('a name shared by a live and a deleted project restores the deleted one', async () => { + service({ + projects: [{ id: 'proj_live', name: 'old', organization_id: 'org_1', deleted_at: null }, DELETED], + onWrite: () => restored, + }); + + await capture(() => restoreProjectCommand('old', { yes: true })); + + expect(writes()[0].path).toBe('/orgs/org_1/projects/proj_old/restore'); + }); + + it('two deleted projects with the same name: PROJECT_AMBIGUOUS with the ids', async () => { + service({ projects: [DELETED, { ...DELETED, id: 'proj_old2' }], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { yes: true, json: true })); + + expect(JSON.parse(out.stdout)).toMatchObject({ code: 'PROJECT_AMBIGUOUS', matches: [{ id: 'proj_old' }, { id: 'proj_old2' }] }); + expect(writes()).toEqual([]); + }); +}); diff --git a/tests/commands/projectsDeleted.test.ts b/tests/commands/projectsDeleted.test.ts new file mode 100644 index 00000000..bb825bc1 --- /dev/null +++ b/tests/commands/projectsDeleted.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, spyOn, mock, afterAll, beforeEach } from 'bun:test'; + +/** + * `capy org projects --deleted` (CAP-729): the listing includes deleted + * projects, each marked deleted with its date; a plain listing is unchanged. + * + * Org resolution and the spinner are replaced; `projectsCommand` and the + * `_system` filter run for real. `mock.module()` is process-wide, so + * tests/run-tests.sh runs this file isolated. + */ + +interface FakeProject { + id: string; + name: string; + organization_id: string; + deleted_at?: string | null; +} + +const listProjectsImpl = mock(async (_options?: { includeDeleted?: boolean }): Promise => []); +const listBranchesImpl = mock(async (_projectId: string) => [{ id: 'b1', name: 'development', project_id: 'x', is_protected: false }]); + +const fakeServiceClient = { + listProjects: (options?: { includeDeleted?: boolean }) => listProjectsImpl(options), + listBranches: (projectId: string) => listBranchesImpl(projectId), + getOrgRepos: async () => { + throw new Error('not readable'); + }, +}; + +mock.module('../../src/ui/spinner', () => ({ + Spinner: class { + start() { + return this; + } + succeed() {} + fail() {} + stop() {} + }, +})); + +mock.module('../../src/core/orgContext', () => ({ + resolveOrgContext: mock(async () => ({ + orgId: 'org_1', + userId: 'user_1', + userEmail: 'owner@example.com', + authService: {}, + serviceClient: fakeServiceClient, + })), +})); + +afterAll(() => mock.restore()); + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +async function capture(fn: () => Promise): Promise<{ exitCode?: number; stdout: string; stderr: string }> { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + const thrown: unknown = await fn().then(() => null, (err: unknown) => err); + const stdout = logSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + const stderr = errSpy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + const exitCalls = exitSpy.mock.calls; + const exitCode = exitCalls.length > 0 ? (exitCalls[exitCalls.length - 1][0] as number | undefined) : undefined; + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + if (thrown !== null && !(thrown instanceof ExitSignal)) throw thrown; + return { exitCode, stdout, stderr }; +} + +const LIVE: FakeProject = { id: 'proj_web', name: 'web', organization_id: 'org_1', deleted_at: null }; +const GONE: FakeProject = { id: 'proj_old', name: 'old', organization_id: 'org_1', deleted_at: '2026-11-05T12:30:00.000Z' }; + +// After the mocks above. +const { ProjectsCommand } = await import('../../src/commands/projectsCommand'); + +describe('ProjectsCommand --deleted', () => { + beforeEach(() => { + listProjectsImpl.mockReset(); + listBranchesImpl.mockClear(); + }); + + it('asks the service for deleted projects too', async () => { + listProjectsImpl.mockImplementation(async () => [LIVE, GONE]); + + await capture(() => new ProjectsCommand().execute({ deleted: true })); + + expect(listProjectsImpl.mock.calls[0]).toEqual([{ includeDeleted: true }]); + }); + + it('a plain listing asks exactly as it always did (no option)', async () => { + listProjectsImpl.mockImplementation(async () => [LIVE]); + + await capture(() => new ProjectsCommand().execute({})); + + expect(listProjectsImpl.mock.calls[0][0]).toBeUndefined(); + }); + + it('human output: the deleted project is marked deleted with its date; the live one shows its branches', async () => { + listProjectsImpl.mockImplementation(async () => [LIVE, GONE]); + + const out = await capture(() => new ProjectsCommand().execute({ deleted: true })); + + const lines = out.stdout.split('\n'); + const oldLine = lines.find((l) => l.includes('old')) ?? ''; + const webLine = lines.find((l) => l.includes('web')) ?? ''; + expect(oldLine).toContain('deleted 2026-11-05'); + expect(webLine).toContain('development'); + expect(webLine).not.toContain('deleted'); + }); + + it('does not ask for the branches of a deleted project (the service answers 410 for them)', async () => { + listProjectsImpl.mockImplementation(async () => [LIVE, GONE]); + + await capture(() => new ProjectsCommand().execute({ deleted: true })); + + expect(listBranchesImpl.mock.calls.map((c) => c[0])).toEqual(['proj_web']); + }); + + it('--json: every project carries deleted_at (null when live) and a deleted one has no branches', async () => { + listProjectsImpl.mockImplementation(async () => [LIVE, GONE]); + + const out = await capture(() => new ProjectsCommand().execute({ deleted: true, json: true })); + + const parsed = JSON.parse(out.stdout); + expect(parsed.ok).toBe(true); + expect(parsed.projects).toEqual([ + { id: 'proj_web', name: 'web', branches: [{ id: 'b1', name: 'development', protected: false }], deleted_at: null }, + { id: 'proj_old', name: 'old', branches: [], deleted_at: '2026-11-05T12:30:00.000Z' }, + ]); + }); + + it('a plain --json listing has no deleted_at field', async () => { + listProjectsImpl.mockImplementation(async () => [{ id: 'proj_web', name: 'web', organization_id: 'org_1' }]); + + const out = await capture(() => new ProjectsCommand().execute({ json: true })); + + expect(JSON.parse(out.stdout).projects[0]).not.toHaveProperty('deleted_at'); + }); +}); diff --git a/tests/deploy/awsSsm.test.ts b/tests/deploy/awsSsm.test.ts index 13434116..1b588fae 100644 --- a/tests/deploy/awsSsm.test.ts +++ b/tests/deploy/awsSsm.test.ts @@ -85,6 +85,10 @@ describe('awsSsm — name mapping', () => { }); }); +// detect() runs the real `aws configure get region` when the aws CLI is on +// PATH. GitHub runners ship it, and its first cold start can pass 5s. +const DETECT_TIMEOUT_MS = 30_000; + describe('awsSsm — detect', () => { test('infers prefix + kebab naming from literal tf parameter names', async () => { mkdirSync(join(ROOT, 'infra'), { recursive: true }); @@ -104,7 +108,7 @@ data "aws_ssm_parameter" "workos_api_key" { expect(r.options?.naming).toBe('kebab'); expect(r.summary).toContain('2 aws_ssm_parameter'); expect(r.summary).toContain('/capy/prod/'); - }); + }, DETECT_TIMEOUT_MS); test('interpolated tf names: naming inferred, prefix NOT prefilled', async () => { writeFileSync( @@ -121,7 +125,7 @@ data "aws_ssm_parameter" "workos_api_key" { const r = await awsSsmAdapter.detect(ROOT); expect(r.options?.pathPrefix).toBeUndefined(); expect(r.options?.naming).toBe('kebab'); - }); + }, DETECT_TIMEOUT_MS); test('verbatim naming inferred from uppercase leaves', async () => { writeFileSync( @@ -135,7 +139,7 @@ data "aws_ssm_parameter" "db" { const r = await awsSsmAdapter.detect(ROOT); expect(r.options?.naming).toBe('verbatim'); expect(r.options?.pathPrefix).toBe('/myapp/'); - }); + }, DETECT_TIMEOUT_MS); test('returns empty when no tf files mention aws_ssm_parameter', async () => { writeFileSync(join(ROOT, 'main.tf'), `resource "aws_s3_bucket" "b" {}`); diff --git a/tests/helpers/pipedHarness.ts b/tests/helpers/pipedHarness.ts index eda340dc..10e1a12c 100644 --- a/tests/helpers/pipedHarness.ts +++ b/tests/helpers/pipedHarness.ts @@ -10,6 +10,8 @@ * POST /orgs/:org/wrap identity * POST /secrets/:project answers like the real one, or 500 once `failPushes()` was called * GET /secrets/:project?branch= the branch fixture if set, else what was pushed (404 before any push) + * GET /projects, GET .../deploy-tokens, DELETE .../projects/:id, POST .../projects/:id/restore the project lifecycle (CAP-729) + * every route that names the project, once `markProjectDeleted()` was called: 410 PROJECT_DELETED * * Every request it receives is appended to a log file (and the push-failure * switch is a flag file) rather than held in a mutable variable, so tests can @@ -81,6 +83,8 @@ export interface Harness { setBranchData(project: string, branch: string, body: unknown): void; /** Makes the mock answer `GET /projects/:project/branches` with these branches (without it: 404, like an unknown project). */ setBranches(project: string, branches: readonly { readonly id: string; readonly name: string; readonly is_protected: boolean }[]): void; + /** From now on every route that takes this project's id answers 410 `PROJECT_DELETED`, like the service does for a deleted project (CAP-729). */ + markProjectDeleted(): void; /** How `PUT /orgs/:org/projects/:project/repos` answers: `ok`, `mismatch` (other repos known), `500`, or `hang` (never). Default `ok`. */ setRepoPutMode(mode: 'ok' | 'mismatch' | '500' | 'hang'): void; /** The `PUT .../repos` requests the mock has received (parsed bodies). */ @@ -186,6 +190,21 @@ async function startMockService(requestLog: string, failFlag: string, root: stri }; const fixture = (name: string): unknown | undefined => existsSync(join(root, name)) ? JSON.parse(readFileSync(join(root, name), 'utf8')) : undefined; + // CAP-729: a deleted project answers 410 on every route that takes its id (the org-level lifecycle routes stay reachable). + const lifecycleRoute = /^\/orgs\/[^/]+\/projects\/[^/]+(\/restore)?$/.test(path.split('?')[0]) && req.method !== 'PUT'; + if (existsSync(join(root, 'project-deleted')) && path.includes(PROJECT_ID) && !lifecycleRoute) { + return send(410, { error: 'This project was deleted', code: 'PROJECT_DELETED' }); + } + if (req.method === 'GET' && /^\/projects(\?.*)?$/.test(path)) { + return send(200, { projects: [{ id: PROJECT_ID, name: 'piped-test', organization_id: ORG_ID }] }); + } + if (req.method === 'GET' && /^\/orgs\/[^/]+\/projects\/[^/]+\/deploy-tokens$/.test(path)) return send(200, { tokens: [] }); + if (req.method === 'DELETE' && /^\/orgs\/[^/]+\/projects\/[^/]+$/.test(path)) { + return send(200, { ok: true, project: { id: PROJECT_ID, name: 'piped-test', deleted_at: '2026-11-06T00:00:00.000Z' }, live_deploy_tokens: [] }); + } + if (req.method === 'POST' && /^\/orgs\/[^/]+\/projects\/[^/]+\/restore$/.test(path)) { + return send(200, { ok: true, project: { id: PROJECT_ID, name: 'piped-test' } }); + } if (req.method === 'GET' && /^\/orgs\/[^/]+\/secrets$/.test(path)) { const body = fixture('secrets-index.json'); return body === undefined ? send(404, { error: 'no index fixture' }) : send(200, body); @@ -298,6 +317,7 @@ export async function createHarness(): Promise { setOrgRepos: (body) => writeFileSync(join(root, 'org-repos.json'), JSON.stringify(body)), setBranchData: (projectId, branch, body) => writeFileSync(join(root, `branch-${projectId}-${branch}.json`), JSON.stringify(body)), setBranches: (projectId, branches) => writeFileSync(join(root, `branches-${projectId}.json`), JSON.stringify({ branches })), + markProjectDeleted: () => writeFileSync(join(root, 'project-deleted'), '1'), setRepoPutMode: (mode) => writeFileSync(join(root, 'repo-put-mode'), mode), repoPuts: () => requests() diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 69b555fd..19d7367e 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -75,6 +75,8 @@ ISOLATED_FILES=( tests/system/systemStoreFreshStore.test.ts tests/commands/systemCommand.test.ts tests/commands/projectsCommand.test.ts + tests/commands/projectsDeleted.test.ts + tests/commands/projectLifecycleCommand.test.ts tests/commands/secretsCommand.test.ts tests/commands/runCommandSystemStore.test.ts tests/commands/connectDiscoveryRouting.test.ts diff --git a/tests/ui/projectDeletedFolder.test.ts b/tests/ui/projectDeletedFolder.test.ts new file mode 100644 index 00000000..ffa5b05b --- /dev/null +++ b/tests/ui/projectDeletedFolder.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, spyOn, mock, afterAll, beforeEach } from 'bun:test'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +/** + * A folder whose keep.lock points at a deleted project (CAP-729). + * + * The REAL `ServiceClient` asks a local fake of the service for the folder's + * secrets and gets the 410 / 404 the service sends. What comes out is judged + * as a person or an agent would see it: + * - the "Project deleted" screen: the name, the id, the restore command, and + * that only an owner can restore it; + * - NO "recreate" advice, which belongs to PROJECT_NOT_FOUND only (the control + * test below proves the check can see that advice); + * - the folder untouched: keep.lock is byte-identical and no file appeared; + * - the code `PROJECT_DELETED` in `--json` and as the process's exit. + */ + +import { ServiceClient } from '../../src/service/serviceClient'; +import { CapyError } from '../../src/types/index'; +import { renderError, displayErrorAndExit } from '../../src/ui/errorScreen'; +import { gateFirst, type SyncRunner } from '../../src/sync/gate'; + +const KEEP_LOCK = JSON.stringify( + { version: 1, org_id: 'org_1', project_id: 'proj_web', project_name: 'web', variables: {} }, + null, + 2, +) + '\n'; + +const DELETED = { status: 410, body: { error: 'This project has been deleted', code: 'PROJECT_DELETED' } }; +const MISSING = { status: 404, body: { error: 'Project not found', code: 'PROJECT_NOT_FOUND' } }; + +const answer = mock((): { status: number; body: unknown } => DELETED); + +const server = Bun.serve({ + port: 0, + fetch() { + const a = answer(); + return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'Content-Type': 'application/json' } }); + }, +}); +afterAll(() => { + server.stop(true); + mock.restore(); +}); + +const client = new ServiceClient(`http://localhost:${server.port}`); +client.setTokenProvider((async () => ({ access_token: 'test-token' })) as never); + +/** The error the real client throws for this folder's project. */ +const errorFromService = (): Promise => + client.getDecryptData('proj_web', 'development').then( + () => { + throw new Error('expected the service to refuse'); + }, + (err: unknown) => err as CapyError, + ); + +/** A fresh folder holding just a keep.lock. */ +const makeFolder = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'capy-deleted-')); + writeFileSync(join(dir, 'keep.lock'), KEEP_LOCK); + return dir; +}; + +const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +/** Runs `fn` with `dir` as the working directory, capturing stdout/stderr/exit. */ +async function inFolder(dir: string, fn: () => Promise) { + const cwdSpy = spyOn(process, 'cwd').mockReturnValue(dir); + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + const thrown: unknown = await fn().then(() => null, (err: unknown) => err); + const stdout = strip(logSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n')); + const stderr = strip(errSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n')); + const exitCode = exitSpy.mock.calls.length > 0 ? (exitSpy.mock.calls[exitSpy.mock.calls.length - 1][0] as number | undefined) : undefined; + cwdSpy.mockRestore(); + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + if (thrown !== null && !(thrown instanceof ExitSignal)) throw thrown; + return { stdout, stderr, exitCode }; +} + +const setTTY = (value: boolean) => Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); + +describe('a folder whose project was deleted', () => { + beforeEach(() => { + answer.mockReset(); + answer.mockImplementation(() => DELETED); + setTTY(true); + }); + + it('the real client mints PROJECT_DELETED from the 410 (its code, not its text)', async () => { + const err = await errorFromService(); + + expect(err).toBeInstanceOf(CapyError); + expect(err.code).toBe('PROJECT_DELETED'); + expect(err.details.status).toBe(410); + }); + + it('the screen says Project deleted, names the project, and gives the restore command and the owner note', async () => { + const err = await errorFromService(); + + const screen = strip(renderError(err, { projectName: 'web', projectId: 'proj_web' })); + + expect(screen).toContain('Project deleted'); + expect(screen).toContain('web'); + expect(screen).toContain('proj_web'); + expect(screen).toContain('capy org restore-project proj_web'); + expect(screen.toLowerCase()).toContain('only an owner'); + }); + + it('with no context, the name and id come from keep.lock', async () => { + const dir = makeFolder(); + const err = await errorFromService(); + + const out = await inFolder(dir, async () => displayErrorAndExit(err)); + + expect(out.stdout).toContain('Project deleted'); + expect(out.stdout).toContain('"web"'); + expect(out.stdout).toContain('capy org restore-project proj_web'); + rmSync(dir, { recursive: true, force: true }); + }); + + it('offers no recreate, and never the project-not-found advice', async () => { + const dir = makeFolder(); + const err = await errorFromService(); + + const out = await inFolder(dir, async () => displayErrorAndExit(err)); + + const text = out.stdout.toLowerCase(); + expect(text).not.toContain('recreate'); + expect(text).not.toContain('re-initialize'); + expect(text).not.toContain('project not found'); + expect(text).not.toContain('delete keep.lock'); + rmSync(dir, { recursive: true, force: true }); + }); + + it('control: the same check does see the recreate advice on a PROJECT_NOT_FOUND', async () => { + answer.mockImplementation(() => MISSING); + const err = await errorFromService(); + + const screen = strip(renderError(err, { projectName: 'web', projectId: 'proj_web' })).toLowerCase(); + + expect(err.code).toBe('PROJECT_NOT_FOUND'); + expect(screen).toContain('recreate'); + }); + + it('exits non-zero', async () => { + const dir = makeFolder(); + const err = await errorFromService(); + + const out = await inFolder(dir, async () => displayErrorAndExit(err)); + + expect(out.exitCode).toBe(1); + rmSync(dir, { recursive: true, force: true }); + }); + + it('keep.lock is byte-identical afterwards and no file appeared in the folder', async () => { + const dir = makeFolder(); + const before = readFileSync(join(dir, 'keep.lock')); + const filesBefore = readdirSync(dir).toSorted(); + const err = await errorFromService(); + + await inFolder(dir, async () => displayErrorAndExit(err)); + + expect(readFileSync(join(dir, 'keep.lock')).equals(before)).toBe(true); + expect(readdirSync(dir).toSorted()).toEqual(filesBefore); + rmSync(dir, { recursive: true, force: true }); + }); + + it('a command run with --json reports the code PROJECT_DELETED, exits 1, and leaves keep.lock alone', async () => { + const dir = makeFolder(); + const before = readFileSync(join(dir, 'keep.lock')); + const filesBefore = readdirSync(dir).toSorted(); + // The gate every folder command runs first; its sync reaches the service for this folder's project. + const sync: SyncRunner = async () => { + throw await errorFromService(); + }; + + const out = await inFolder(dir, () => gateFirst({ json: true }, sync)); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_DELETED' }); + expect(readFileSync(join(dir, 'keep.lock')).equals(before)).toBe(true); + expect(readdirSync(dir).toSorted()).toEqual(filesBefore); + rmSync(dir, { recursive: true, force: true }); + }); + + it('a command run in a terminal shows the Project deleted screen and exits 1', async () => { + const dir = makeFolder(); + const sync: SyncRunner = async () => { + throw await errorFromService(); + }; + + const out = await inFolder(dir, () => gateFirst({}, sync)); + + expect(out.exitCode).toBe(1); + expect(out.stdout).toContain('Project deleted'); + expect(out.stdout).toContain('capy org restore-project proj_web'); + expect(out.stdout.toLowerCase()).not.toContain('recreate'); + rmSync(dir, { recursive: true, force: true }); + }); + + it('a command run off a terminal ends with the code PROJECT_DELETED on stderr and exit 1', async () => { + setTTY(false); + const dir = makeFolder(); + const sync: SyncRunner = async () => { + throw await errorFromService(); + }; + + const out = await inFolder(dir, () => gateFirst({}, sync)); + + expect(out.exitCode).toBe(1); + expect(out.stderr).toContain('PROJECT_DELETED'); + rmSync(dir, { recursive: true, force: true }); + }); +});