diff --git a/README.md b/README.md index eb8c1daf..86477370 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Good when you want to see drift across the whole branch, edit one variable witho | [`capy kick `](https://docs.capy.sc/cli/kick) | Remove a teammate. | | [`capy users`](https://docs.capy.sc/cli/users) | Interactive member management. | | [`capy projects`](https://docs.capy.sc/cli/projects) | List projects in the org and their branches. | +| [`capy project create\|delete\|restore`](https://docs.capy.sc/cli/project) | Create, delete or restore a project in the org. | | [`capy secrets`](https://docs.capy.sc/cli/secrets) | List every secret name in the org, grouped by value (never shows a value). | | [`capy org`](https://docs.capy.sc/cli/org) | List or switch organizations. | | [`capy branch`](https://docs.capy.sc/cli/branch) | List or switch branches. | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8a91b5d6..9a7dffc0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -482,54 +482,89 @@ capy org [options] JSON support: no -### `capy org delete-project` +### `capy org projects` -Delete a project (owners only; hidden, not destroyed; an owner can restore it) +List projects in the active organization; --deleted also lists deleted ones (owners only) ``` -capy org delete-project [options] +capy org projects [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 | | +| `--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 org restore-project` +## `capy project` -Restore a deleted project (owners only) +Create, delete or restore a project in the active organization + +``` +capy project +``` + +_No options._ + +JSON support: no + +### `capy project create` + +Create a project and its first branch in the active organization (owners and admins only; does not link this folder) + +``` +capy project create [name] [options] +``` + +| Option | Description | Default | +|---|---|---| +| `--branch ` | the first branch; a Capy branch works like a git branch (offered by default: development) | | +| `--protected ` | whether the first branch is protected (invite-only) | | +| `--non-tty` | same as --json: never ask, and answer in JSON (agents/CI) | | +| `--json` | emit machine-readable JSON instead of the human UI | | + +JSON support: yes (`--json`) + +Dry run: yes (`--dry-run`) + +### `capy project delete` + +Delete a project (owners only; hidden, not destroyed; an owner can restore it) ``` -capy org restore-project [options] +capy project delete [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 | | +| `--non-tty` | same as --json: never ask, and answer in JSON (agents/CI) | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) -### `capy org projects` +Dry run: yes (`--dry-run`) -List projects in the active organization; --deleted also lists deleted ones (owners only) +### `capy project restore` + +Restore a deleted project (owners only) ``` -capy org projects [options] +capy project restore [options] ``` | Option | Description | Default | |---|---|---| -| `--deleted` | also list deleted projects, marked as deleted, with the date | | +| `--yes` | skip the confirmation question | | +| `--non-tty` | same as --json: never ask, and answer in JSON (agents/CI) | | | `--json` | emit machine-readable JSON instead of the human UI | | JSON support: yes (`--json`) +Dry run: yes (`--dry-run`) + ## `capy info` Show current session info @@ -1114,6 +1149,15 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `SYSTEM_STORE_IMMUTABLE` - `PROJECT_AMBIGUOUS` - `CONFIRMATION_REQUIRED` +- `PROJECT_NAME_REQUIRED` +- `PROJECT_NAME_INVALID` +- `PROJECT_NAME_EXISTS` +- `BRANCH_NAME_REQUIRED` +- `BRANCH_NAME_INVALID` +- `BRANCH_CREATE_FAILED` +- `BRANCH_PROTECTION_REQUIRED` +- `BRANCH_PROTECTION_INVALID` +- `ORG_SELECTION_REQUIRED` - `REPO_LINKS_UNSUPPORTED` - `DRY_RUN_UNSUPPORTED` - `GITHUB_TIMEOUT` @@ -1140,3 +1184,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. - **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. +- **Elicitation**: A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field's own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool's own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own. diff --git a/src/commands/agentsCommand.ts b/src/commands/agentsCommand.ts index 0b1de65f..2cd48fff 100644 --- a/src/commands/agentsCommand.ts +++ b/src/commands/agentsCommand.ts @@ -96,10 +96,13 @@ function existingAgentsFileNames(root: string): string[] { return AGENTS_FILE_NAMES.filter((name) => existsSync(join(root, name))); } -/** Which file(s) a write targets: whichever of AGENTS.md/CLAUDE.md exist; if neither exists, AGENTS.md only. */ -function writeTargetFileNames(root: string): string[] { - const existing = existingAgentsFileNames(root); - return existing.length > 0 ? existing : ['AGENTS.md']; +/** + * Which file(s) a write targets: always both AGENTS.md and CLAUDE.md, creating whichever is missing. Agents read + * different files (Claude Code reads CLAUDE.md, others read AGENTS.md), and an agent that never sees the block asks its + * own questions instead of letting the CLI's elicitations ask. + */ +function writeTargetFileNames(_root: string): string[] { + return [...AGENTS_FILE_NAMES]; } function malformedError(name: string): CapyError { diff --git a/src/commands/infoCommand.ts b/src/commands/infoCommand.ts index e1ced32e..96979373 100644 --- a/src/commands/infoCommand.ts +++ b/src/commands/infoCommand.ts @@ -1,6 +1,8 @@ import { AuthService } from '../auth/authService'; import { ProjectManager } from '../core/projectManager'; import { ServiceClient } from '../service/serviceClient'; +import { isProjectDeletedError } from '../errors/projectDeleted'; +import type { AuthResult } from '../types/index'; const DIM = '\x1b[90m'; const RESET = '\x1b[0m'; @@ -15,6 +17,50 @@ const ROLE_LABELS: Record = { member: 'Member', }; +/** Whether the folder's project is live, deleted (the service answers PROJECT_DELETED), or could not be checked. */ +export type ProjectStateLabel = 'live' | 'deleted' | 'unknown'; + +interface ProjectStateClient { + listBranches(projectId: string): Promise; +} + +/** Asks the service about the project: a branch list answers live, the PROJECT_DELETED code answers deleted. */ +export async function projectStateFor(client: ProjectStateClient, projectId: string): Promise { + try { + await client.listBranches(projectId); + return 'live'; + } catch (err) { + return isProjectDeletedError(err) ? 'deleted' : 'unknown'; + } +} + +/** Silent sign-in for the folder's org, then any org; only a folder with a keep.lock may fall back to a browser sign-in. */ +async function authenticateForInfo(authService: AuthService, organizationId: string | undefined, hasKeep: boolean): Promise { + const scoped = await authService.authenticateSilent(organizationId); + if (scoped.success) return scoped; + const any = await authService.authenticateSilent(); + if (any.success || !hasKeep) return any; + return authService.authenticate(organizationId!); +} + +/** The user's role in the org, best effort: `{ slug: null, label: '—' }` when it cannot be read. */ +async function roleIn( + serviceClient: ServiceClient, + orgId: string | undefined, + userId: string | undefined, + hasToken: boolean, +): Promise<{ readonly slug: string | null; readonly label: string }> { + const none = { slug: null, label: '—' } as const; + if (!orgId || !hasToken || !userId) return none; + try { + const { members } = await serviceClient.listMembers(orgId); + const slug: unknown = members.find((m: any) => m.userId === userId)?.role?.slug; + return typeof slug === 'string' && slug !== '' ? { slug, label: ROLE_LABELS[slug] || slug } : none; + } catch { + return none; + } +} + export class InfoCommand { private apiUrl?: string; private devMode: boolean; @@ -34,11 +80,7 @@ export class InfoCommand { // belong to, etc. Fall through to a session-only view when there's no // keep.lock instead of forcing the user to init a project first. const authService = new AuthService(this.apiUrl, this.devMode, projectState.userId); - let authResult = await authService.authenticateSilent(projectState.organizationId); - if (!authResult.success) authResult = await authService.authenticateSilent(); - if (!authResult.success && hasKeep) { - authResult = await authService.authenticate(projectState.organizationId!); - } + const authResult = await authenticateForInfo(authService, projectState.organizationId, hasKeep); if (!authResult.success) { console.error(`Not signed in. Run ${B('capy')} to authenticate.`); process.exit(1); @@ -56,24 +98,11 @@ export class InfoCommand { const workosOrgId = activeOrg?.workos_org_id; const branch = projectState.activeBranch; - // Resolve the user's role in the active org (best effort). - let roleLabel = '—'; - let roleSlug: string | null = null; - if (activeOrgId && authService.getToken() && authResult.user_id) { - try { - const serviceClient = new ServiceClient(this.apiUrl, this.devMode); - serviceClient.setTokenProvider(() => authService.getValidToken()); - const { members } = await serviceClient.listMembers(activeOrgId); - const me = members.find((m: any) => m.userId === authResult.user_id); - const slug = me?.role?.slug; - if (slug) { - roleLabel = ROLE_LABELS[slug] || slug; - roleSlug = slug; - } - } catch { - // leave as — - } - } + const serviceClient = new ServiceClient(this.apiUrl, this.devMode); + serviceClient.setTokenProvider(() => authService.getValidToken()); + const { slug: roleSlug, label: roleLabel } = await roleIn(serviceClient, activeOrgId, authResult.user_id, !!authService.getToken()); + const projectId = projectState.projectId; + const projectStatus: ProjectStateLabel | null = projectId ? await projectStateFor(serviceClient, projectId) : null; if (opts.json) { console.log( @@ -82,7 +111,7 @@ export class InfoCommand { user: { email: authResult.user_email ?? null, userId: authResult.user_id ?? null }, org: { id: activeOrgId ?? null, name: activeOrgName ?? null, workosOrgId: workosOrgId ?? null, fromKeepLock: hasKeep }, role: roleSlug, - project: { name: projectState.projectName ?? null, id: projectState.projectId ?? null }, + project: { name: projectState.projectName ?? null, id: projectState.projectId ?? null, state: projectStatus }, branch: hasKeep ? branch : null, memberships: allOrgs.map((o) => ({ id: o.id, name: o.name ?? null, workosOrgId: o.workos_org_id ?? null })), }, @@ -106,7 +135,7 @@ export class InfoCommand { ['Org ID', activeOrgId || '—'], ['WorkOS Org ID', workosOrgId || '—'], ['Role', roleLabel], - ['Project', projectState.projectName || '—'], + ['Project', projectStatus === 'deleted' ? `${projectState.projectName || '—'} ${YELLOW}(deleted)${RESET}` : projectState.projectName || '—'], // COPY-FLAG ['Project ID', projectState.projectId || '—'], ['Branch', (hasKeep && branch) || '—'], ]; diff --git a/src/commands/projectCreateCommand.ts b/src/commands/projectCreateCommand.ts new file mode 100644 index 00000000..751a6192 --- /dev/null +++ b/src/commands/projectCreateCommand.ts @@ -0,0 +1,368 @@ +/** + * `capy project create [name]` (CAP-748). + * + * Creates a project in the ACTIVE organization with the same service call the init flow uses + * (`ServiceClient.initializeProject`), then its first branch (`createBranch`), and nothing else: it does not link the + * current folder, write a keep.lock or touch a key. Bare `capy` still does those. + * + * Agent mode (`--json`, `--non-tty`, or stdin that is not a terminal) never opens a prompt. With no name it refuses + * with PROJECT_NAME_REQUIRED, and with a name but no `--branch` with BRANCH_NAME_REQUIRED (both exit 3), each with + * an `elicitation` of kind "form" (`ui/elicitation.ts`) asking for both, that the agent puts to the human. In a + * terminal it asks for the name with the init prompt and for the branch with the init branch question. + * + * Whether the first branch is protected (invite-only) is asked the way `capy checkout -b` asks it: a yes/no in a + * terminal, and the same yes/no as a field of the elicitation (BRANCH_PROTECTION_REQUIRED when only that is missing). + * + * Every refusal is coded, and every decision keys on a code, an HTTP status or a typed field, never on message + * text. The service answers a duplicate name with a bare 409 and no `code`, so the STATUS decides that one. + */ +import { resolveOrgContext } from '../core/orgContext'; +import { ProjectManager } from '../core/projectManager'; +import { PROJECT_NAME_PATTERN_SOURCE, projectNameProblem } from '../core/projectNameRule'; +import { BRANCH_DESCRIPTION, BRANCH_NAME_PATTERN_SOURCE, DEFAULT_FIRST_BRANCH, FIRST_BRANCH_QUESTION, branchNameProblem } from '../core/branchNameRule'; +import { PromptEngine } from '../ui/promptEngine'; +import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; +import type { ElicitationForm } from '../ui/elicitation'; +import { createProjectDisplay } from '../ui/elicitationDisplay'; +import { CapyError, ERROR_CODES } from '../types/index'; +import { refuse, refusalFor, settle, type Refusal } from './projectLifecycleCommand'; + +export interface CreateProjectOpts { + json?: boolean; + /** `--non-tty`: treat stdin as not a terminal, so the name is never asked. */ + nonTty?: boolean; + /** The global `--dry-run`: check the name and the organization, create nothing. */ + dryRun?: boolean; + /** `--branch `: the project's first branch. */ + branch?: string; + /** `--protected yes|no` as typed: whether the first branch is invite-only. Checked before anything is sent. */ + protectedAnswer?: string; + apiUrl?: string; + devMode?: boolean; +} + +/** What commander hands an action's `command`: only the merged view is needed. */ +interface CommandWithGlobals { + optsWithGlobals(): Record; +} + +/** The options as typed, wherever commander put them (`--non-tty` is taken by `org`, `--dry-run` by the program). */ +export function createProjectOptsFrom(command: CommandWithGlobals): CreateProjectOpts { + const merged = command.optsWithGlobals(); + return { + // `--non-tty` is agent mode, and agent mode always answers in JSON: it means the same as `--json`. + json: merged.json === true || merged.nonTty === true, + nonTty: merged.nonTty === true, + dryRun: merged.dryRun === true, + ...(typeof merged.branch === 'string' ? { branch: merged.branch.trim() } : {}), + ...(typeof merged.protected === 'string' ? { protectedAnswer: merged.protected.trim().toLowerCase() } : {}), + }; +} + +interface OrgRef { + readonly id: string; + readonly name: string; +} + +interface ProjectRef { + readonly id: string; + readonly name: string; +} + +/** The first branch as it will be created. */ +interface FirstBranch { + readonly name: string; + readonly protected: boolean; +} + +type CreateOutcome = + | { readonly kind: 'needs-input'; readonly missing: 'name' | 'branch' | 'protected'; readonly elicitation: ElicitationForm; readonly org: OrgRef } + | { readonly kind: 'exists'; readonly existing: ProjectRef; readonly org: OrgRef } + | { readonly kind: 'dry-run'; readonly name: string; readonly branch: FirstBranch; readonly org: OrgRef } + | { readonly kind: 'branch-failed'; readonly project: ProjectRef; readonly branch: FirstBranch; readonly org: OrgRef } + | { readonly kind: 'created'; readonly project: ProjectRef; readonly branch: FirstBranch; readonly org: OrgRef }; + +/** `--protected` answers, as the elicitation's options carry them. */ +const PROTECTED_YES = 'yes'; +const PROTECTED_NO = 'no'; + +/** The answer typed with `--protected`, as a boolean; undefined when none was typed. Checked to be yes or no first. */ +function protectedFrom(answer: string | undefined): boolean | undefined { + if (answer === undefined) return undefined; + return answer === PROTECTED_YES; +} + +/** The yes/no question, worded as `capy checkout -b` asks it. Before the branch has a name it says "the first branch". */ +function protectedQuestion(branch: string | undefined): string { + return branch === undefined + ? 'Make the first branch a protected branch? (invite-only)' // COPY-FLAG: checkout's wording, adapted for an unnamed branch + : `Make "${branch}" a protected branch? (invite-only)`; +} + +/** + * The folder's name as a project name, when it is allowed as one and no live project in the org has it already. + * The init prompt offers the same name; a taken one would only be refused with PROJECT_NAME_EXISTS, so it is never offered. + */ +function suggestedName(isTaken: (name: string) => boolean): string | undefined { + const folderName = new ProjectManager().getDefaultProjectName(); + return projectNameProblem(folderName) === null && !isTaken(folderName) ? folderName : undefined; +} + +/** + * The form that asks for the name and the first branch. Both fields are always there, filled with what was already + * given, so the human sees and confirms both. `answer.rerun` keeps `` and `` as the places the answers go. + */ +function createForm( + org: OrgRef, + name: string | undefined, + branch: string | undefined, + isProtected: boolean | undefined, + isTaken: (name: string) => boolean, +): ElicitationForm { + const nameDefault = name ?? suggestedName(isTaken); + return { + kind: 'form', + question: name === undefined + ? 'What should the project and its first branch be called?' // COPY-FLAG + : 'What should the first branch be called?', // COPY-FLAG + details: { org: { id: org.id, name: org.name } }, + display: createProjectDisplay({ org, name, branch, isProtected, branchDescription: BRANCH_DESCRIPTION }), + fields: [ + { + name: 'name', + type: 'string', + pattern: PROJECT_NAME_PATTERN_SOURCE, + question: 'What should the project be called?', // COPY-FLAG + ...(nameDefault === undefined ? {} : { default: nameDefault }), + }, + { + name: 'branch', + type: 'string', + pattern: BRANCH_NAME_PATTERN_SOURCE, + question: FIRST_BRANCH_QUESTION, + default: branch ?? DEFAULT_FIRST_BRANCH, + description: BRANCH_DESCRIPTION, + }, + { + name: 'protected', + type: 'string', + pattern: `^(${PROTECTED_YES}|${PROTECTED_NO})$`, + question: protectedQuestion(branch), + default: isProtected === true ? PROTECTED_YES : PROTECTED_NO, + options: [ + { value: PROTECTED_YES, label: 'Yes' }, // COPY-FLAG + { value: PROTECTED_NO, label: 'No' }, // COPY-FLAG + ], + }, + ], + answer: { rerun: 'capy project create --branch --protected --json' }, + }; +} + +/** The name: as typed, else (in a terminal) as asked, else none. */ +async function chooseName(typed: string | undefined, agentMode: boolean): Promise { + if (typed !== undefined) return typed; + if (agentMode) return undefined; + return new PromptEngine().promptForProjectName(new ProjectManager().getDefaultProjectName()); +} + +/** The first branch: as typed, else (in a terminal) as asked, else none. */ +async function chooseBranch(typed: string | undefined, agentMode: boolean): Promise { + if (typed !== undefined) return typed; + if (agentMode) return undefined; + return new PromptEngine().promptForFirstBranch(); +} + +/** Whether the first branch is protected: as typed, else (in a terminal) as asked, else none. */ +async function chooseProtected(typed: boolean | undefined, branch: string, agentMode: boolean): Promise { + if (typed !== undefined) return typed; + if (agentMode) return undefined; + return new PromptEngine().promptForBranchProtected(branch); +} + +/** The client calls `createFlow` makes: resolving the org hands it over already signed in. */ +interface CreateClient { + listProjects(): Promise>; + initializeProject(name: string, orgId: string): Promise<{ readonly project_id: string; readonly project_name: string }>; + createBranch(projectId: string, name: string, isProtected?: boolean): Promise; +} + +/** + * A live project in `orgId` that already has this name, compared trimmed and ignoring case. The service itself + * allows two projects with one name, so this check is what stops a second one (an agent retrying, a typo in case). + * Deleted projects are not listed, so a deleted project's name can be used again. + */ +export function existingProjectNamed( + projects: ReadonlyArray<{ readonly id: string; readonly name: string; readonly organization_id: string }>, + orgId: string, + name: string, +): { readonly id: string; readonly name: string } | undefined { + const wanted = name.trim().toLowerCase(); + const match = projects.find((p) => p.organization_id === orgId && p.name.trim().toLowerCase() === wanted); + return match === undefined ? undefined : { id: match.id, name: match.name }; +} + +async function createFlow(typed: string | undefined, opts: CreateProjectOpts, agentMode: boolean): Promise { + // Agent mode never opens a prompt, not even to pick an organization. + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); + const org: OrgRef = { id: orgId, name: orgName }; + + const name = await chooseName(typed, agentMode); + const client: CreateClient = serviceClient; + const projects = await client.listProjects(); + const isTaken = (candidate: string) => existingProjectNamed(projects, orgId, candidate) !== undefined; + const typedProtected = protectedFrom(opts.protectedAnswer); + if (name === undefined) { + return { kind: 'needs-input', missing: 'name', elicitation: createForm(org, undefined, opts.branch, typedProtected, isTaken), org }; + } + + // The clash is checked before the branch is asked, so nobody picks a branch for a project that cannot be created. + const existing = existingProjectNamed(projects, orgId, name); + if (existing !== undefined) return { kind: 'exists', existing, org }; + + const branchName = await chooseBranch(opts.branch, agentMode); + if (branchName === undefined) { + return { kind: 'needs-input', missing: 'branch', elicitation: createForm(org, name, undefined, typedProtected, isTaken), org }; + } + const isProtected = await chooseProtected(typedProtected, branchName, agentMode); + if (isProtected === undefined) { + return { kind: 'needs-input', missing: 'protected', elicitation: createForm(org, name, branchName, undefined, isTaken), org }; + } + const branch: FirstBranch = { name: branchName, protected: isProtected }; + if (opts.dryRun === true) return { kind: 'dry-run', name, branch, org }; + + const created = await client.initializeProject(name, orgId); + const project: ProjectRef = { id: created.project_id, name: created.project_name }; + // The project exists from here on: a failed branch is reported with the project, never as "nothing happened". + const branchMade = await settle(() => client.createBranch(project.id, branch.name, branch.protected)); + return branchMade.ok + ? { kind: 'created', project, branch, org } + : { kind: 'branch-failed', project, branch, org }; +} + +/** The refusal for a failed create: a duplicate name by its HTTP status, everything else as the lifecycle commands refuse it. */ +function createRefusalFor(err: unknown): Refusal { + if (err instanceof CapyError && err.code === ERROR_CODES.SERVICE_ERROR && err.details?.status === 409) { + return { + code: ERROR_CODES.PROJECT_NAME_EXISTS, + message: 'This organization already has a project with that name.', // COPY-FLAG + exitCode: 1, + }; + } + return refusalFor(err); +} + +/** The refusal for each thing that can still be missing, with nobody to ask. */ +const NEEDS_INPUT: Readonly> = { + name: { + code: ERROR_CODES.PROJECT_NAME_REQUIRED, + message: '`capy project create` needs a project name. Pass it as an argument, or run it in a terminal.', // COPY-FLAG + }, + branch: { + code: ERROR_CODES.BRANCH_NAME_REQUIRED, + message: '`capy project create` needs a first branch. Pass it with --branch, or run it in a terminal.', // COPY-FLAG + }, + protected: { + code: ERROR_CODES.BRANCH_PROTECTION_REQUIRED, + message: '`capy project create` needs to know whether the first branch is protected. Pass --protected yes or no, or run it in a terminal.', // COPY-FLAG + }, +}; + +/** The branch as JSON reports it. */ +function branchJson(branch: FirstBranch): { readonly name: string; readonly protected: boolean } { + return { name: branch.name, protected: branch.protected }; +} + +function emitCreateOutcome(json: boolean, outcome: CreateOutcome): void { + if (outcome.kind === 'needs-input') { + const { code, message } = NEEDS_INPUT[outcome.missing]; + return refuse(json, { + code, + message, + exitCode: EXIT_NEEDS_INPUT, + extra: { elicitation: outcome.elicitation }, + lines: [` Organization: ${outcome.org.name}`], // COPY-FLAG + }); + } + if (outcome.kind === 'branch-failed') { + return refuse(json, { + code: ERROR_CODES.BRANCH_CREATE_FAILED, + message: `Created project ${outcome.project.name}, but not its first branch ${outcome.branch.name}.`, // COPY-FLAG + exitCode: 1, + extra: { project: outcome.project, branch: branchJson(outcome.branch), org: outcome.org }, + }); + } + if (outcome.kind === 'exists') { + return refuse(json, { + code: ERROR_CODES.PROJECT_NAME_EXISTS, + message: 'This organization already has a project with that name.', // COPY-FLAG + exitCode: 1, + extra: { project: outcome.existing, org: outcome.org }, + lines: [` ${outcome.existing.id} ${outcome.existing.name}`], + }); + } + if (outcome.kind === 'dry-run') { + if (json) { + console.log(JSON.stringify({ ok: true, dry_run: true, project: { name: outcome.name }, branch: branchJson(outcome.branch), org: outcome.org }, null, 2)); + return; + } + console.log(`Dry run: would create project ${outcome.name} with branch ${outcome.branch.name} in ${outcome.org.name}. Nothing was created.`); // COPY-FLAG + return; + } + if (json) { + console.log(JSON.stringify({ ok: true, project: outcome.project, branch: branchJson(outcome.branch), org: outcome.org }, null, 2)); + return; + } + console.log(`Created project ${outcome.project.name} with branch ${outcome.branch.name} in ${outcome.org.name}.`); // COPY-FLAG + if (outcome.branch.protected) console.log(`"${outcome.branch.name}" is a protected branch — access is invite-only`); +} + +/** `capy project create [name] [--dry-run] [--json]` */ +export async function createProjectCommand(name: string | undefined, opts: CreateProjectOpts): Promise { + const json = opts.json === true; + const agentMode = json || !isInteractive(opts.nonTty); + + // Before any network call: a name that breaks the rule never leaves this process. + const problem = name === undefined ? null : projectNameProblem(name); + if (problem !== null) { + return refuse(json, { + code: problem.code, + message: problem.message, + exitCode: 1, + extra: problem.code === ERROR_CODES.PROJECT_NAME_INVALID ? { pattern: PROJECT_NAME_PATTERN_SOURCE } : undefined, + }); + } + + const branchProblem = opts.branch === undefined ? null : branchNameProblem(opts.branch); + if (branchProblem !== null) { + return refuse(json, { + code: branchProblem.code, + message: branchProblem.message, + exitCode: 1, + extra: { pattern: BRANCH_NAME_PATTERN_SOURCE }, + }); + } + + if (opts.protectedAnswer !== undefined && opts.protectedAnswer !== PROTECTED_YES && opts.protectedAnswer !== PROTECTED_NO) { + return refuse(json, { + code: ERROR_CODES.BRANCH_PROTECTION_INVALID, + message: '--protected takes yes or no.', // COPY-FLAG + exitCode: 1, + extra: { options: [PROTECTED_YES, PROTECTED_NO] }, + }); + } + + // A dry run asks nothing, and in a terminal the only thing left to do without a name, branch or protection is ask. + const missingSomething = name === undefined || opts.branch === undefined || opts.protectedAnswer === undefined; + if (opts.dryRun === true && missingSomething && !agentMode) { + return refuse(json, { + code: ERROR_CODES.DRY_RUN_UNSUPPORTED, + message: '`capy project create` would ask for the name, branch or protection, so it cannot preview. Pass all three.', // COPY-FLAG + exitCode: 1, + }); + } + + const settled = await settle(() => createFlow(name, opts, agentMode)); + if (!settled.ok) return refuse(json, createRefusalFor(settled.error)); + emitCreateOutcome(json, settled.value); +} diff --git a/src/commands/projectLifecycleCommand.ts b/src/commands/projectLifecycleCommand.ts index 9acb1427..bb8f3482 100644 --- a/src/commands/projectLifecycleCommand.ts +++ b/src/commands/projectLifecycleCommand.ts @@ -1,5 +1,5 @@ /** - * `capy org delete-project` and `capy org restore-project` (CAP-729). + * `capy project delete` and `capy project restore` (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 @@ -12,6 +12,11 @@ * * `--dry-run` reads the project and its deploy tokens and stops. The service has * no dry run, so the DELETE is simply never sent. + * + * Without `--yes` and with nobody to ask (agent mode), `project delete` refuses with CONFIRMATION_REQUIRED (exit 3) + * and an `elicitation` (kind "confirm", `ui/elicitation.ts`): the project, the org, its branches, its variable + * NAMES (never a value or a hash) and its live deploy tokens, so the agent can put the question to the human. + * In a terminal the same summary is printed before the Yes/No question. */ import inquirer from 'inquirer'; import { resolveOrgContext } from '../core/orgContext'; @@ -19,6 +24,10 @@ 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'; +import type { ElicitationConfirm, ElicitationForm } from '../ui/elicitation'; +import { chooseProjectDisplay, deleteProjectDisplay, restoreProjectDisplay } from '../ui/elicitationDisplay'; +import { formatRelativeTime } from '../ui/relativeTime'; +import { variableBranchesFromKeep, type VariableBranches } from '../core/keepVariableNames'; export interface ProjectLifecycleOpts { json?: boolean; @@ -36,13 +45,18 @@ export interface DeleteProjectOpts extends ProjectLifecycleOpts { dryRun?: boolean; } +export interface RestoreProjectOpts extends ProjectLifecycleOpts { + /** The global `--dry-run`: show which project would be restored; 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. + * The options of `project delete` / `project restore` 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 @@ -54,7 +68,8 @@ export function lifecycleOptsFrom(command: CommandWithGlobals): DeleteProjectOpt force: merged.force === true, yes: merged.yes === true, nonTty: merged.nonTty === true, - json: merged.json === true, + // `--non-tty` is agent mode, and agent mode always answers in JSON: it means the same as `--json`. + json: merged.json === true || merged.nonTty === true, dryRun: merged.dryRun === true, }; } @@ -63,6 +78,8 @@ interface ProjectRef { id: string; name: string; deleted_at?: string | null; + /** Absent from services that predate it. */ + created_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`. */ @@ -71,7 +88,7 @@ const CODED_FORBIDDEN: ReadonlySet = new Set([ ERROR_CODES.SYSTEM_STORE_IMMUTABLE, ]); -interface Refusal { +export interface Refusal { readonly code: string; readonly message: string; readonly exitCode: number; @@ -98,7 +115,7 @@ function describeToken(t: LiveDeployTokenInfo): string { return `${t.deploy_id}${t.label ? ` ${t.label}` : ''} (created ${t.created_at})`; // COPY-FLAG } -function refusalFor(err: unknown): Refusal { +export 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 }; } @@ -116,21 +133,14 @@ function refusalFor(err: unknown): Refusal { ], }; } - 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}`), - }; + if (code === ERROR_CODES.ORG_SELECTION_REQUIRED) { + return { code, message: err.message, exitCode: EXIT_NEEDS_INPUT, extra: { orgs: Array.isArray(err.details?.orgs) ? err.details.orgs : [] } }; } 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 { +export 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 { @@ -140,10 +150,10 @@ function refuse(json: boolean, r: Refusal): never { return process.exit(r.exitCode); } -type Settled = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; +export 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> { +export async function settle(fn: () => Promise): Promise> { return fn().then( (value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }), @@ -178,72 +188,309 @@ function matchProjects(projects: readonly ProjectRef[], ref: string, preferDelet return preferDeleted && deleted.length > 0 ? deleted : matches; } +/** A project id as the service mints them. */ +const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type Resolved = + | { readonly kind: 'one'; readonly project: ProjectRef } + | { readonly kind: 'many'; readonly matches: readonly ProjectRef[] }; + /** - * 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". + * What the name or id the person typed points at. + * + * A name that matches nothing is refused here with PROJECT_NOT_FOUND, before anything is read or asked. An id that + * matches nothing still goes to the service, which answers for it: not found, or the owner-only refusal for a non-owner + * (whose listing hides deleted projects). */ -function pickOne(projects: readonly ProjectRef[], ref: string, preferDeleted: boolean): ProjectRef { +function resolveRef(projects: readonly ProjectRef[], ref: string, preferDeleted: boolean): Resolved { const matches = matchProjects(projects, ref, preferDeleted); - if (matches.length === 0) return { id: ref, name: ref }; - if (matches.length === 1) return matches[0]; + if (matches.length === 1) return { kind: 'one', project: matches[0] }; + if (matches.length > 1) return { kind: 'many', matches }; + if (PROJECT_ID_PATTERN.test(ref)) return { kind: 'one', project: { id: ref, name: ref } }; throw new CapyError( - `More than one project matches "${ref}". Use the id.`, // COPY-FLAG - ERROR_CODES.PROJECT_AMBIGUOUS, - { matches }, + `No project named ${ref} in this organization.`, // COPY-FLAG + ERROR_CODES.PROJECT_NOT_FOUND, + { name: ref }, ); } -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 +/** One of several projects with the same name, with what tells them apart. `variables` is undefined when it could not be read. */ +interface Candidate { + readonly project: ProjectRef; + readonly variables: number | undefined; +} + +/** Each match with its variable count, oldest first. Best effort: an unreadable count is shown as such, never as 0. */ +async function candidatesOf(client: DeleteDetailsClient, matches: readonly ProjectRef[]): Promise { + const candidates = await Promise.all(matches.map(async (project): Promise => { + const details = await readDeleteDetails(client, project.id); + return { project, variables: details.unavailable.includes('variables') ? undefined : details.variables.length }; + })); + // A fresh copy sorted as it is built (the lib target predates toSorted); `candidates` itself is never changed. + return [...candidates].sort((a, b) => (a.project.created_at ?? '').localeCompare(b.project.created_at ?? '')); +} + +/** "web · 45 variables · created 2 days ago": the name, the variable count, and how long ago it was created. */ +function candidateLabel(c: Candidate, now: Date): string { + const variables = c.variables === undefined + ? 'variables could not be read' // COPY-FLAG + : `${c.variables} ${c.variables === 1 ? 'variable' : 'variables'}`; // COPY-FLAG + const created = c.project.created_at ? [`created ${formatRelativeTime(c.project.created_at, now)}`] : []; // COPY-FLAG + return [c.project.name, variables, ...created].join(' · '); +} + +/** The pick-one elicitation: the answer is a project id, and `rerun` carries it back into the same command. */ +function chooseElicitation(org: OrgRef, ref: string, candidates: readonly Candidate[], rerun: string): ElicitationForm { + const now = new Date(); + const labels = candidates.map((c) => candidateLabel(c, now)); + return { + kind: 'form', + question: `Which ${ref}?`, // COPY-FLAG + details: { + org: { id: org.id, name: org.name }, + matches: candidates.map((c) => ({ + id: c.project.id, + name: c.project.name, + created_at: c.project.created_at ?? null, + variables: c.variables ?? null, + })), + }, + display: chooseProjectDisplay(labels), + fields: [{ + name: 'project', + type: 'string', + pattern: `^(${candidates.map((c) => c.project.id).join('|')})$`, + options: candidates.map((c, i) => ({ value: c.project.id, label: labels[i] })), + }], + answer: { rerun }, + }; +} + +type Picked = + | { readonly kind: 'picked'; readonly project: ProjectRef } + | { readonly kind: 'needs-choice'; readonly elicitation: ElicitationForm; readonly lines: readonly string[] }; + +interface PickContext { + readonly json: boolean; + readonly agentMode: boolean; + readonly org: OrgRef; + readonly ref: string; + readonly client: DeleteDetailsClient; + /** The command that answers the choice, with `` where the chosen id goes. */ + readonly rerun: string; +} + +/** + * The one project to act on. Several with the same name: in a terminal, an arrow-key list; with nobody to ask, the + * pick-one elicitation. Never a guess, and `--yes` never chooses. + */ +async function pickProject(resolved: Resolved, ctx: PickContext): Promise { + if (resolved.kind === 'one') return { kind: 'picked', project: resolved.project }; + const candidates = await candidatesOf(ctx.client, resolved.matches); + if (ctx.agentMode) { + const elicitation = chooseElicitation(ctx.org, ctx.ref, candidates, ctx.rerun); + return { kind: 'needs-choice', elicitation, lines: elicitation.fields[0].options?.map((o) => ` ${o.value} ${o.label}`) ?? [] }; + } + const now = new Date(); + const { projectId } = await promptModuleFor(ctx.json)([{ + type: 'list', + name: 'projectId', + message: `Which ${ctx.ref}?`, // COPY-FLAG + choices: candidates.map((c) => ({ name: candidateLabel(c, now), value: c.project.id })), + }]); + const chosen = candidates.find((c) => c.project.id === projectId); + if (chosen === undefined) throw new CapyError(`No project named ${ctx.ref} in this organization.`, ERROR_CODES.PROJECT_NOT_FOUND); // COPY-FLAG + return { kind: 'picked', project: chosen.project }; +} + +/** The refusal that hands the choice to the agent: PROJECT_AMBIGUOUS, exit 3, with the elicitation. */ +function choiceRefusal(picked: Extract, ref: string): Refusal { + return { + code: ERROR_CODES.PROJECT_AMBIGUOUS, + message: `More than one project is named ${ref}.`, // COPY-FLAG exitCode: EXIT_NEEDS_INPUT, - }); + extra: { elicitation: picked.elicitation }, + lines: picked.lines, + }; } function liveOnly(tokens: readonly T[]): readonly T[] { return tokens.filter((t) => t.revoked_at === null); } +/** What the person is shown, and the agent is told, about a project before it is deleted. Names only. */ +interface DeleteDetails { + readonly branches: readonly string[]; + readonly variables: readonly VariableBranches[]; + /** The parts that could not be read. Never "none": an unread part is not an empty one. */ + readonly unavailable: readonly ('branches' | 'variables')[]; +} + +type KeepRead = + | { readonly kind: 'keep'; readonly variables: readonly VariableBranches[] } + | { readonly kind: 'none' } + | { readonly kind: 'unavailable' }; + +/** The slice of the service client `readDeleteDetails` uses. */ +interface DeleteDetailsClient { + listBranches(projectId: string): Promise>; + getLatestSecrets(projectId: string, branch: string): Promise<{ readonly keep_hash: string; readonly keep_file?: string } | null>; +} + +/** + * The variable names of the project's latest keep.lock, as the service returns it (it is one file for every branch). + * Tries each branch in turn until one returns it. Only names and branches leave this function: the blob that comes + * with the keep.lock is never read. + */ +async function readKeepVariables( + client: DeleteDetailsClient, + projectId: string, + branches: readonly string[], + failedBefore: boolean, +): Promise { + if (branches.length === 0) return { kind: failedBefore ? 'unavailable' : 'none' }; + const [branch, ...rest] = branches; + const read = await settle(() => client.getLatestSecrets(projectId, branch)); + if (!read.ok) return readKeepVariables(client, projectId, rest, true); + const variables = read.value === null ? undefined : variableBranchesFromKeep(read.value.keep_file); + return variables === undefined ? readKeepVariables(client, projectId, rest, failedBefore) : { kind: 'keep', variables }; +} + +/** Best effort: a part that cannot be read is reported as `unavailable`, and the question is still asked. */ +async function readDeleteDetails(client: DeleteDetailsClient, projectId: string): Promise { + const listed = await settle(() => client.listBranches(projectId)); + if (!listed.ok || !Array.isArray(listed.value)) return { branches: [], variables: [], unavailable: ['branches', 'variables'] }; + const branches = listed.value.map((b) => b.name); + const keep = await readKeepVariables(client, projectId, branches, false); + return { + branches, + variables: keep.kind === 'keep' ? keep.variables : [], + unavailable: keep.kind === 'unavailable' ? ['variables'] : [], + }; +} + +interface OrgRef { + readonly id: string; + readonly name: string; +} + +/** The exact command that answers Yes: the project's id (never its name), and `--force` only if it was passed. */ +function deleteYesCommand(projectId: string, force: boolean): string { + return `capy project delete ${projectId} ${force ? '--force ' : ''}--yes --json`; +} + +/** The confirm elicitation for a delete. Names and ids only: a variable is its name and its branches, never more. */ +function deleteElicitation( + org: OrgRef, + project: ProjectRef, + tokens: readonly LiveDeployTokenInfo[], + details: DeleteDetails, + force: boolean, +): ElicitationConfirm { + return { + kind: 'confirm', + question: `Delete project ${project.name}?`, // COPY-FLAG + details: { + org: { id: org.id, name: org.name }, + project: { id: project.id, name: project.name }, + branches: details.branches, + variables: details.variables.map((v) => ({ name: v.name, branches: v.branches })), + live_deploy_tokens: tokens.map((t) => ({ deploy_id: t.deploy_id, label: t.label })), + ...(details.unavailable.length > 0 ? { unavailable: details.unavailable } : {}), + }, + display: deleteProjectDisplay({ + org, + project, + branches: details.branches, + variables: details.variables, + liveDeployTokens: tokens, + unavailable: details.unavailable, + }), + answer: { yes: deleteYesCommand(project.id, force) }, + }; +} + type DeleteOutcome = | { readonly kind: 'dry-run'; readonly project: ProjectRef; readonly tokens: readonly LiveDeployTokenInfo[] } + | { readonly kind: 'needs-confirmation'; readonly elicitation: ElicitationConfirm; readonly lines: readonly string[] } + | { readonly kind: 'needs-choice'; readonly refusal: Refusal } | { 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 +function summaryLines(org: OrgRef, details: DeleteDetails): readonly string[] { + return [ + ` Organization: ${org.name}`, // COPY-FLAG + details.unavailable.includes('branches') + ? ' Branches: could not be read.' // COPY-FLAG + : details.branches.length === 0 ? ' No branches.' : ` Branches: ${details.branches.join(', ')}`, // COPY-FLAG + details.unavailable.includes('variables') + ? ' Variables: could not be read.' // COPY-FLAG + : ` Variables: ${details.variables.length}`, // COPY-FLAG + ...details.variables.map((v) => ` ${v.name}${v.branches.length === 0 ? '' : ` (${v.branches.join(', ')})`}`), + ]; +} + +/** What the person is shown about the project. Without `summary` (a dry run) it is the short preview. */ +function previewLines( + project: ProjectRef, + tokens: readonly LiveDeployTokenInfo[], + force: boolean, + summary?: { readonly org: OrgRef; readonly details: DeleteDetails }, +): readonly string[] { + return [ + '', + ` Project: ${project.name} (${project.id})`, // COPY-FLAG + ...(summary === undefined ? [] : summaryLines(summary.org, summary.details)), + tokens.length === 0 ? ' No live deploy tokens.' : ` Live deploy tokens: ${tokens.length}`, // COPY-FLAG + ...tokens.map((t) => ` ${describeToken(t)}`), + ...(tokens.length === 0 ? [] : [force ? ' --force: those deploys stop on their next restart.' // COPY-FLAG - : ' Without --force the delete is refused while these exist.'); // COPY-FLAG - } - say(json, ''); + : ' Without --force the delete is refused while these exist.']), // COPY-FLAG + '', + ]; +} + +function showPreview(json: boolean, lines: readonly string[]): void { + lines.forEach((l) => say(json, l)); } 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); + const agentMode = json || !isInteractive(opts.nonTty); + // Agent mode never opens a prompt, not even to pick an organization. + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); + const org: OrgRef = { id: orgId, name: orgName }; + const picked = await pickProject(resolveRef(await serviceClient.listProjects(), ref, false), { + json, + agentMode, + org, + ref, + client: serviceClient, + rerun: `capy project delete ${force ? '--force ' : ''}--json`, + }); + if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; + const project = picked.project; // 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) : []; + const asks = opts.dryRun !== true && opts.yes !== true; + const tokens = opts.dryRun === true || asks ? 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 (asks) { + const details = await readDeleteDetails(serviceClient, project.id); + const lines = previewLines(project, tokens, force, { org, details }); + // Nobody to ask: hand the question to the agent that ran this, and write nothing. + if (agentMode) return { kind: 'needs-confirmation', elicitation: deleteElicitation(org, project, tokens, details, force), lines }; + showPreview(json, lines); 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}`; + return `capy project restore ${id}`; } function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome): void { @@ -264,10 +511,20 @@ function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome }, null, 2)); return; } - showPreview(false, project, tokens, force); + showPreview(false, previewLines(project, tokens, force)); console.log('Dry run: nothing was deleted.'); // COPY-FLAG return; } + if (outcome.kind === 'needs-choice') return refuse(json, outcome.refusal); + if (outcome.kind === 'needs-confirmation') { + return refuse(json, { + code: ERROR_CODES.CONFIRMATION_REQUIRED, + message: '`capy project delete` needs confirmation. Pass --yes, or run it in a terminal.', // COPY-FLAG + exitCode: EXIT_NEEDS_INPUT, + extra: { elicitation: outcome.elicitation }, + lines: outcome.lines, + }); + } const { project, live_deploy_tokens } = outcome.result; if (json) { console.log(JSON.stringify({ ok: true, project, live_deploy_tokens }, null, 2)); @@ -277,37 +534,89 @@ function emitDeleteOutcome(json: boolean, force: boolean, outcome: DeleteOutcome console.log(`An owner can restore it with: ${restoreHint(project.id)}`); // COPY-FLAG } -/** `capy org delete-project [--force] [--yes] [--dry-run] [--json]` */ +/** `capy project delete [--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'); - } - + // No terminal and no --yes means nobody can answer: the flow stops with the question as an elicitation (exit 3). + // It reads the project first, because the question has to say what would be deleted. A dry run asks nothing. 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: 'dry-run'; readonly project: ProjectRef } | { readonly kind: 'cancelled' } + | { readonly kind: 'needs-choice'; readonly refusal: Refusal } + | { readonly kind: 'needs-confirmation'; readonly elicitation: ElicitationConfirm } | { readonly kind: 'restored'; readonly project: { readonly id: string; readonly name: string } }; -async function restoreFlow(ref: string, opts: ProjectLifecycleOpts): Promise { +/** The confirm elicitation for a restore: the project, its org and when it was deleted. The yes command names the project by id. */ +function restoreElicitation(org: OrgRef, project: ProjectRef): ElicitationConfirm { + const deletedAt = project.deleted_at ?? null; + return { + kind: 'confirm', + question: `Restore project ${project.name}?`, // COPY-FLAG: the terminal question, word for word + details: { + org: { id: org.id, name: org.name }, + project: { id: project.id, name: project.name, deleted_at: deletedAt }, + }, + display: restoreProjectDisplay({ org, project: { id: project.id, name: project.name }, deletedAt }), + answer: { yes: `capy project restore ${project.id} --yes --json` }, + }; +} + +async function restoreFlow(ref: string, opts: RestoreProjectOpts): Promise { const json = opts.json === true; - const { orgId, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true); + const agentMode = json || !isInteractive(opts.nonTty); + // Agent mode never opens a prompt, not even to pick an organization. + const { orgId, orgName, serviceClient } = await resolveOrgContext(opts.apiUrl, opts.devMode === true, { promptForOrg: !agentMode }); + const org: OrgRef = { id: orgId, name: orgName }; // 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); + const picked = await pickProject(resolveRef(await serviceClient.listProjects({ includeDeleted: true }), ref, true), { + json, + agentMode, + org, + ref, + client: serviceClient, + rerun: `capy project restore ${opts.yes === true ? '--yes ' : ''}--json`, + }); + if (picked.kind === 'needs-choice') return { kind: 'needs-choice', refusal: choiceRefusal(picked, ref) }; + const project = picked.project; + // A dry run reads which project would come back and stops before anything that writes. + if (opts.dryRun === true) return { kind: 'dry-run', project }; - if (opts.yes !== true && !(await confirm(json, `Restore project ${project.name}?`))) return { kind: 'cancelled' }; // COPY-FLAG + if (opts.yes !== true) { + // Nobody to ask: hand the question to the agent that ran this, and restore nothing. + if (agentMode) return { kind: 'needs-confirmation', elicitation: restoreElicitation(org, project) }; + if (!(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 === 'dry-run') { + const { project } = outcome; + if (json) { + console.log(JSON.stringify({ ok: true, dry_run: true, project: { id: project.id, name: project.name } }, null, 2)); + return; + } + console.log(`Would restore project ${project.name} (${project.id}).`); // COPY-FLAG + console.log('Dry run: nothing was restored.'); // COPY-FLAG + return; + } + if (outcome.kind === 'needs-choice') return refuse(json, outcome.refusal); + if (outcome.kind === 'needs-confirmation') { + return refuse(json, { + code: ERROR_CODES.CONFIRMATION_REQUIRED, + message: '`capy project restore` needs confirmation. Pass --yes, or run it in a terminal.', // COPY-FLAG + exitCode: EXIT_NEEDS_INPUT, + extra: { elicitation: outcome.elicitation }, + }); + } 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 @@ -320,14 +629,12 @@ function emitRestoreOutcome(json: boolean, outcome: RestoreOutcome): void { console.log(`Restored project ${outcome.project.name}.`); // COPY-FLAG } -/** `capy org restore-project [--yes] [--json]` */ -export async function restoreProjectCommand(ref: string, opts: ProjectLifecycleOpts): Promise { +/** `capy project restore [--yes] [--dry-run] [--json]` */ +export async function restoreProjectCommand(ref: string, opts: RestoreProjectOpts): Promise { const json = opts.json === true; - if (opts.yes !== true && !isInteractive(opts.nonTty)) { - refuseWithoutTerminal(json, 'capy org restore-project'); - } - + // No terminal and no --yes means nobody can answer: the flow stops with the question as an elicitation (exit 3). + // It reads the project first, because the question has to say what would be restored. const settled = await settle(() => restoreFlow(ref, opts)); if (!settled.ok) return refuse(json, refusalFor(settled.error)); emitRestoreOutcome(json, settled.value); diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index 5939470f..71e0c7e1 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -25,9 +25,16 @@ export const AGENTS_BLOCK = [ '- Run `capy help --json` for every command, its options, and its error codes.', '- Always pass `--json` and branch on the `code` field, never on message text.', '- Run `capy pair --json` in an interactive terminal/PTY. After browser approval, it asks "Enable this location as [email]?" before installing the session or keys. Show the returned email to the human and obtain explicit Yes/No approval before answering. Never automatically confirm or pipe `yes`; Enter defaults to No. No upfront email is required.', + '- Before a command that writes, run `capy info --json` and check its `org`, `project` and `branch`. If they are not the ones the human means, stop and ask.', // COPY-FLAG + '- If a command starts a browser sign-in, this folder belongs to an organization this machine is not signed in to. Stop and tell the human. Never complete the sign-in yourself.', // COPY-FLAG + '- To find where a secret lives, run `capy secrets --json --name NAME`. It lists locations in the signed-in organization only, and never shows a value.', // COPY-FLAG '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool\'s own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', // COPY-FLAG + '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', // COPY-FLAG + '- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder\'s project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.', // COPY-FLAG + '- `PROJECT_DELETED`, or `project.state` `"deleted"` in `capy info --json`, means an org owner deleted this folder\'s project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/core/branchNameRule.ts b/src/core/branchNameRule.ts new file mode 100644 index 00000000..c09ad66d --- /dev/null +++ b/src/core/branchNameRule.ts @@ -0,0 +1,48 @@ +/** + * The rule for a branch name, as the service checks it on `POST /projects/:id/branches` (service + * `routes/projects.ts` `validateBranchName`). The service answers a bad name with a bare 400 and no code, so the CLI + * checks first and refuses with BRANCH_NAME_INVALID before any request. Change both together. + */ +import { ERROR_CODES } from '../types/index'; + +/** Starts with a letter or number; then letters, numbers, `.`, `_` and `-`. */ +export const BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +/** The same rule as a string, for JSON (`elicitation.fields[].pattern`). */ +export const BRANCH_NAME_PATTERN_SOURCE: string = BRANCH_NAME_PATTERN.source; + +export const BRANCH_NAME_MAX_LENGTH = 128; + +/** The branch a new project starts with unless someone picks another. Bare `capy` and `capy org` offer the same. */ +export const DEFAULT_FIRST_BRANCH = 'development'; + +/** What a Capy branch is. Approved wording (Vince, 2026-11-09): use it verbatim. */ +export const BRANCH_DESCRIPTION = 'A Capy branch works like a git branch. Capy branches can be used to set different variables for different environments.'; + +/** The question for the first branch. Approved wording (Vince, 2026-11-09): use it verbatim. */ +export const FIRST_BRANCH_QUESTION = 'What should its first branch be called?'; + +export interface BranchNameProblem { + readonly code: typeof ERROR_CODES.BRANCH_NAME_INVALID; + /** For a human to read; never branched on. These are the service's own sentences. */ + readonly message: string; +} + +/** Why `input` is not a branch name the service accepts, or null when it is. Same checks, same order as the service. */ +export function branchNameProblem(input: string): BranchNameProblem | null { + const message = branchNameMessage(input); + return message === null ? null : { code: ERROR_CODES.BRANCH_NAME_INVALID, message }; +} + +function branchNameMessage(input: string): string | null { + if (!input || input.length === 0) return 'Branch name cannot be empty'; + if (input.length > BRANCH_NAME_MAX_LENGTH) return `Branch name too long (max ${BRANCH_NAME_MAX_LENGTH})`; + if (input.includes('..')) return 'Branch name cannot contain ".."'; + if (!BRANCH_NAME_PATTERN.test(input)) return 'Branch name must start with a letter or number'; + return null; +} + +/** The shape an inquirer `validate` wants: `true`, or the sentence to show. */ +export function validateBranchNameInput(input: string): string | true { + return branchNameProblem(input.trim())?.message ?? true; +} diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 088268b3..44edc1a3 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -11,6 +11,7 @@ */ import type { Argument, Command, Option } from 'commander'; import { ERROR_CODES } from '../types/index'; +import { ELICITATION_HELP } from '../ui/elicitation'; import { DEPLOY_DOKPLOY_PLAN_SCHEMA, DEPLOY_VERCEL_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; export interface CliArgumentDoc { @@ -74,6 +75,8 @@ export interface CliHelpDoc { codes: string; /** What a command that writes from a folder does first, and how a difference is answered off a terminal (CAP-717). */ conflicts?: string; + /** What an `elicitation` in a refusal is and what to do with it (CAP-748). */ + elicitation?: string; }; } @@ -115,6 +118,9 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ 'connect', 'deploy', 'edit', + 'project create', + 'project delete', + 'project restore', 'remove', 'secrets', 'secrets set', @@ -183,6 +189,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { // COPY-FLAG: minimal and neutral; the contract is docs/conflict-resolution.md. conflicts: 'Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all.', + elicitation: ELICITATION_HELP, }, }; } diff --git a/src/core/cliReferenceMarkdown.ts b/src/core/cliReferenceMarkdown.ts index 93d1c374..16129f95 100644 --- a/src/core/cliReferenceMarkdown.ts +++ b/src/core/cliReferenceMarkdown.ts @@ -102,6 +102,7 @@ export function renderCliReferenceMarkdown(doc: CliHelpDoc): string { `- **JSON**: ${doc.conventions.json}`, `- **Codes**: ${doc.conventions.codes}`, ...(doc.conventions.conflicts === undefined ? [] : [`- **Conflicts**: ${doc.conventions.conflicts}`]), + ...(doc.conventions.elicitation === undefined ? [] : [`- **Elicitation**: ${doc.conventions.elicitation}`]), '', ]; return lines.join('\n'); diff --git a/src/core/keepVariableNames.ts b/src/core/keepVariableNames.ts new file mode 100644 index 00000000..719b66c2 --- /dev/null +++ b/src/core/keepVariableNames.ts @@ -0,0 +1,50 @@ +/** + * The variable NAMES in a keep.lock, and the branches each one is on. Nothing else survives. + * + * A keep.lock entry carries a resource id, a value hash, connector metadata and delivery records. A human asked + * "delete this project?" needs only the names and the branches, so this is the one place the rest is dropped: a + * caller that shows or emits the result cannot leak a hash, because the hash is never copied out. + */ + +export interface VariableBranches { + readonly name: string; + /** Sorted and unique. */ + readonly branches: readonly string[]; +} + +/** The branches of one keep.lock entry list; an entry with no branch name is skipped. */ +function branchesOf(entries: unknown): readonly string[] { + const names = (Array.isArray(entries) ? entries : []).flatMap((e: unknown) => { + const branch = typeof e === 'object' && e !== null ? (e as { branch?: unknown }).branch : undefined; + return typeof branch === 'string' && branch !== '' ? [branch] : []; + }); + return sortedCopy([...new Set(names)], compareStrings); +} + +const compareStrings = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** A sorted copy (`toSorted` is ES2023 and this package supports Node 18): the sorted array is built here and the input is never changed. */ +function sortedCopy(items: readonly T[], cmp: (a: T, b: T) => number): readonly T[] { + return Array.from(items).sort(cmp); +} + +/** + * Every variable name in `rawKeep` (the keep.lock exactly as the service returned it) with its branches, sorted by name. + * `undefined` when `rawKeep` is absent or is not a keep.lock: the caller must say "unknown", never "none". + */ +export function variableBranchesFromKeep(rawKeep: string | undefined): readonly VariableBranches[] | undefined { + if (rawKeep === undefined || rawKeep === '') return undefined; + const parsed = parseJson(rawKeep); + const variables = typeof parsed === 'object' && parsed !== null ? (parsed as { variables?: unknown }).variables : undefined; + if (typeof variables !== 'object' || variables === null) return undefined; + const rows = Object.entries(variables).map(([name, entries]) => ({ name, branches: branchesOf(entries) })); + return sortedCopy(rows, (a, b) => compareStrings(a.name, b.name)); +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} diff --git a/src/core/orgContext.ts b/src/core/orgContext.ts index 8d3eab26..310ab022 100644 --- a/src/core/orgContext.ts +++ b/src/core/orgContext.ts @@ -2,15 +2,80 @@ import inquirer from 'inquirer'; import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from './projectManager'; +import { CapyError, ERROR_CODES } from '../types/index'; +import type { AuthResult, Organization } from '../types/index'; export interface OrgContext { orgId: string; + /** The organization's name as the sign-in session lists it; the id when the session does not list it. */ + orgName: string; userId: string; userEmail?: string; authService: AuthService; serviceClient: ServiceClient; } +export interface OrgContextOptions { + /** + * Whether to ask which organization when this folder has none and the person belongs to several. Default true. + * `false` (agent mode): refuse with `ORG_SELECTION_REQUIRED` instead of opening a prompt. + */ + readonly promptForOrg?: boolean; +} + +/** Silent session scoped to `orgId`, else a silent session for any org, else a full sign-in scoped to `orgId`. */ +async function authenticateScoped(authService: AuthService, orgId: string | undefined): Promise { + const scopedSilent = await authService.authenticateSilent(orgId); + if (scopedSilent.success) return scopedSilent; + const plainSilent = await authService.authenticateSilent(); + if (plainSilent.success) return plainSilent; + return authService.authenticate(orgId); +} + +/** Silent session scoped to `orgId`, else a full sign-in scoped to `orgId`. */ +async function authenticateForOrg(authService: AuthService, orgId: string): Promise { + const scopedSilent = await authService.authenticateSilent(orgId); + if (scopedSilent.success) return scopedSilent; + return authService.authenticate(orgId); +} + +/** Exits with a message when the sign-in failed; otherwise returns the result. */ +function succeededOrExit(result: AuthResult, message: string): AuthResult { + if (result.success) return result; + console.error(message); + return process.exit(1); +} + +/** The one organization to use: the only one, or the one the person picks. Exits when there is none. */ +async function chooseOrg(orgs: readonly Organization[], promptForOrg: boolean): Promise { + if (orgs.length === 0) { + console.error('No organizations available. Run `capy` to create one.'); + return process.exit(1); + } + if (orgs.length === 1) return orgs[0].id; + if (!promptForOrg) { + throw new CapyError( + 'More than one organization is available and this folder has none. Run `capy` here, or run the command in a terminal.', // COPY-FLAG + ERROR_CODES.ORG_SELECTION_REQUIRED, + { orgs: orgs.map((o) => ({ id: o.id, name: o.name })) }, + ); + } + const { chosen } = await inquirer.prompt([{ + type: 'list', + name: 'chosen', + message: 'Select organization:', + choices: orgs.map(o => ({ name: o.name, value: o.id })), + }]); + return chosen; +} + +/** The org's name from whichever session result lists it; the id when none does. */ +function orgNameOf(orgId: string, results: readonly AuthResult[]): string { + const listed = results.flatMap((r) => r.organizations ?? []).find((o) => o.id === orgId)?.name; + const own = results.find((r) => r.organization_id === orgId)?.organization_name; + return listed ?? own ?? orgId; +} + /** * Resolve org context for org-level commands (invite, kick, users). * Prefers keep.lock when present; otherwise authenticates via cached session @@ -19,6 +84,7 @@ export interface OrgContext { export async function resolveOrgContext( apiUrl: string | undefined, devMode: boolean, + options: OrgContextOptions = {}, ): Promise { const pm = new ProjectManager(); const projectState = await pm.detectProjectState(); @@ -27,44 +93,23 @@ export async function resolveOrgContext( const serviceClient = new ServiceClient(apiUrl, devMode); serviceClient.setTokenProvider(() => authService.getValidToken()); - let orgId = projectState.organizationId; - let authResult = await authService.authenticateSilent(orgId); - if (!authResult.success) authResult = await authService.authenticateSilent(); - if (!authResult.success) authResult = await authService.authenticate(orgId); - if (!authResult.success) { - console.error('Authentication failed. Run `capy` to sign in.'); - process.exit(1); - } - - if (!orgId) { - const orgs = authResult.organizations || []; - if (orgs.length === 0) { - console.error('No organizations available. Run `capy` to create one.'); - process.exit(1); - } else if (orgs.length === 1) { - orgId = orgs[0].id; - } else { - const { chosen } = await inquirer.prompt([{ - type: 'list', - name: 'chosen', - message: 'Select organization:', - choices: orgs.map(o => ({ name: o.name, value: o.id })), - }]); - orgId = chosen; - } + const folderOrgId = projectState.organizationId; + const first = succeededOrExit( + await authenticateScoped(authService, folderOrgId), + 'Authentication failed. Run `capy` to sign in.', + ); - authResult = await authService.authenticateSilent(orgId); - if (!authResult.success) authResult = await authService.authenticate(orgId); - if (!authResult.success) { - console.error('Authentication failed for selected organization.'); - process.exit(1); - } - } + // The folder's own org wins; with none, the only org, or the one the person picks. + const orgId = folderOrgId ? folderOrgId : await chooseOrg(first.organizations || [], options.promptForOrg !== false); + const scoped = folderOrgId + ? first + : succeededOrExit(await authenticateForOrg(authService, orgId), 'Authentication failed for selected organization.'); return { - orgId: orgId!, - userId: authResult.user_id!, - userEmail: authResult.user_email, + orgId, + orgName: orgNameOf(orgId, [scoped, first]), + userId: scoped.user_id!, + userEmail: scoped.user_email, authService, serviceClient, }; diff --git a/src/core/projectNameRule.ts b/src/core/projectNameRule.ts new file mode 100644 index 00000000..2ac28907 --- /dev/null +++ b/src/core/projectNameRule.ts @@ -0,0 +1,43 @@ +/** + * The rule for a project name, in one place: the init prompt (`PromptEngine.promptForProjectName`) and + * `capy project create` both use it, so they only ever change together. + * + * Non-empty, letters/numbers/hyphens/underscores only, and not the reserved system store name. + */ +import { ERROR_CODES } from '../types/index'; +import { isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../system/reservedProjectName'; + +/** The allowed characters, as the regular expression the init prompt has always used. */ +export const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9-_]+$/; + +/** The same rule as a string, for JSON (`elicitation.fields[].pattern`). */ +export const PROJECT_NAME_PATTERN_SOURCE: string = PROJECT_NAME_PATTERN.source; + +export interface ProjectNameProblem { + /** The code to refuse with. */ + readonly code: typeof ERROR_CODES.PROJECT_NAME_INVALID | typeof ERROR_CODES.PROJECT_NAME_RESERVED; + /** For a human to read; never branched on. */ + readonly message: string; +} + +/** Why `input` is not an allowed project name, or null when it is. Checks in the order the init prompt always has. */ +export function projectNameProblem(input: string): ProjectNameProblem | null { + if (!input || input.trim().length === 0) { + return { code: ERROR_CODES.PROJECT_NAME_INVALID, message: 'Project name cannot be empty' }; + } + if (!PROJECT_NAME_PATTERN.test(input)) { + return { + code: ERROR_CODES.PROJECT_NAME_INVALID, + message: 'Project name can only contain letters, numbers, hyphens, and underscores', + }; + } + if (isReservedProjectName(input)) { + return { code: ERROR_CODES.PROJECT_NAME_RESERVED, message: PROJECT_NAME_RESERVED_MESSAGE }; + } + return null; +} + +/** The shape an inquirer `validate` wants: `true`, or the sentence to show. */ +export function validateProjectNameInput(input: string): string | true { + return projectNameProblem(input)?.message ?? true; +} diff --git a/src/errors/projectDeleted.ts b/src/errors/projectDeleted.ts index e2ceb336..5c97167b 100644 --- a/src/errors/projectDeleted.ts +++ b/src/errors/projectDeleted.ts @@ -31,5 +31,5 @@ export function keepLockProject(dir: string): { name?: string; id?: string } | u /** 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}`; + return id === undefined ? undefined : `capy project restore ${id}`; } diff --git a/src/index-dev.ts b/src/index-dev.ts index b39fce64..d3d405e8 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -745,29 +745,46 @@ const orgCmd = program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); -orgCmd - .command('delete-project ') +const projectCmd = program + .command('project') + .description('Create, delete or restore a project in the active organization'); // COPY-FLAG + +projectCmd + .command('create [name]') + .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG + .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG + .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (name: string | undefined, _options: any, command: any) => { + const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); + // Read from the merged view: the program's `--dry-run` takes that flag before this command sees it. + await createProjectCommand(name, { ...createProjectOptsFrom(command), apiUrl: process.env.CAPY_API_URL, devMode: true }); + }); + +projectCmd + .command('delete ') .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('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // 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. + // Read from the merged view: the program's `-f/--force` takes that flag before this command sees it. await deleteProjectCommand(ref, { ...lifecycleOptsFrom(command), apiUrl: process.env.CAPY_API_URL, devMode: true }); }); -orgCmd - .command('restore-project ') +projectCmd + .command('restore ') .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('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // 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 }); + const { yes, nonTty, json, dryRun } = lifecycleOptsFrom(command); + await restoreProjectCommand(ref, { yes, nonTty, json, dryRun, apiUrl: process.env.CAPY_API_URL, devMode: true }); }); orgCmd diff --git a/src/index.ts b/src/index.ts index 5f3327fc..00e6154e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -781,31 +781,49 @@ const orgCmd = program await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); -orgCmd - .command('delete-project ') +const projectCmd = program + .command('project') + .description('Create, delete or restore a project in the active organization'); // COPY-FLAG + +projectCmd + .command('create [name]') + .description('Create a project and its first branch in the active organization (owners and admins only; does not link this folder)') // COPY-FLAG + .option('--branch ', 'the first branch; a Capy branch works like a git branch (offered by default: development)') // COPY-FLAG + .option('--protected ', 'whether the first branch is protected (invite-only)') // COPY-FLAG + .option('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // COPY-FLAG + .option('--json', 'emit machine-readable JSON instead of the human UI') + .action(async (name: string | undefined, _options: any, command: any) => { + assertNotLocalOnly('project create'); + const { createProjectCommand, createProjectOptsFrom } = await import('./commands/projectCreateCommand'); + // Read from the merged view: the program's `--dry-run` takes that flag before this command sees it. + await createProjectCommand(name, createProjectOptsFrom(command)); + }); + +projectCmd + .command('delete ') .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('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // 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'); + assertNotLocalOnly('project delete'); 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. + // Read from the merged view: the program's `-f/--force` takes that flag before this command sees it. await deleteProjectCommand(ref, lifecycleOptsFrom(command)); }); -orgCmd - .command('restore-project ') +projectCmd + .command('restore ') .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('--non-tty', 'same as --json: never ask, and answer in JSON (agents/CI)') // 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'); + assertNotLocalOnly('project restore'); const { restoreProjectCommand, lifecycleOptsFrom } = await import('./commands/projectLifecycleCommand'); - const { yes, nonTty, json } = lifecycleOptsFrom(command); - await restoreProjectCommand(ref, { yes, nonTty, json }); + const { yes, nonTty, json, dryRun } = lifecycleOptsFrom(command); + await restoreProjectCommand(ref, { yes, nonTty, json, dryRun }); }); orgCmd diff --git a/src/service/serviceClient.ts b/src/service/serviceClient.ts index 5bf70cc3..8ab73c83 100644 --- a/src/service/serviceClient.ts +++ b/src/service/serviceClient.ts @@ -758,11 +758,12 @@ 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(options?: { includeDeleted?: boolean }): 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. + // `created_at` is absent from services that predate it; callers treat it as unknown. const query = options?.includeDeleted === true ? '?include_deleted=1' : ''; const data = await this.request<{ - projects: Array<{ id: string; name: string; organization_id: string; deleted_at?: string | null }>; + projects: Array<{ id: string; name: string; organization_id: string; deleted_at?: string | null; created_at?: string | null }>; }>('GET', `/projects${query}`); return data.projects; } diff --git a/src/types/index.ts b/src/types/index.ts index f11dbe3c..08f32798 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -897,10 +897,28 @@ export const ERROR_CODES = { 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. */ + /** `capy project delete` / `project restore`: the name matched more than one project and there is no terminal to ask which. Exit 3 with a pick-one `elicitation` (a form whose field has `options`). */ 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', + /** `capy project create` with no name and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + PROJECT_NAME_REQUIRED: 'PROJECT_NAME_REQUIRED', + /** `capy project create`: the name is empty or has characters other than letters, numbers, hyphens and underscores. Exit 1. */ + PROJECT_NAME_INVALID: 'PROJECT_NAME_INVALID', + /** `capy project create`: the organization already has a project with that name (the service answered 409). Exit 1. */ + PROJECT_NAME_EXISTS: 'PROJECT_NAME_EXISTS', + /** `capy project create` with a name but no `--branch`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + BRANCH_NAME_REQUIRED: 'BRANCH_NAME_REQUIRED', + /** `capy project create --branch`: a name the service would refuse (see `core/branchNameRule.ts`). Nothing is created. Exit 1. */ + BRANCH_NAME_INVALID: 'BRANCH_NAME_INVALID', + /** `capy project create`: the project was created but its first branch was not. The refusal carries the project. Exit 1. */ + BRANCH_CREATE_FAILED: 'BRANCH_CREATE_FAILED', + /** `capy project create` with a name and branch but no `--protected`, and nobody to ask (agent mode). The refusal carries an `elicitation` form. Exit 3. */ + BRANCH_PROTECTION_REQUIRED: 'BRANCH_PROTECTION_REQUIRED', + /** `capy project create --protected` with something other than yes or no. Nothing is created. Exit 1. */ + BRANCH_PROTECTION_INVALID: 'BRANCH_PROTECTION_INVALID', + /** A command that acts on "the active organization" has none (no keep.lock here) and the person belongs to several, with nobody to ask. Exit 3. */ + ORG_SELECTION_REQUIRED: 'ORG_SELECTION_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/elicitation.ts b/src/ui/elicitation.ts new file mode 100644 index 00000000..751781d3 --- /dev/null +++ b/src/ui/elicitation.ts @@ -0,0 +1,53 @@ +/** + * An elicitation: a question a command cannot answer, handed to the agent that ran it so the agent can put it + * to the human. It rides inside a `--json` refusal (exit 3) next to the refusal's `code`, and never replaces it. + * + * Two kinds, one shape each: + * - `confirm`: a yes/no. `answer.yes` is the exact command to run when the human says yes. + * - `form`: one or more values. `fields` describes each; `answer.rerun` is the command to run with the values filled in. + * + * `details` is what the human needs to decide. It is built from names and ids only: never a value, a hash or ciphertext. + * `display` is the same information as a markdown table (`ui/elicitationDisplay.ts`). The agent prints it exactly as + * given before asking `question`, so every human sees the same list. + */ + +export interface ElicitationField { + readonly name: string; + readonly type: 'string'; + /** A regular expression (as a string) the value must match. */ + readonly pattern: string; + /** A value the human can accept as is. Absent when there is no sensible one. */ + readonly default?: string; + /** The question to ask the human for this field. Ask it, then its `description`. */ + readonly question?: string; + /** What the field is, for the human. Absent when the name says it all. */ + readonly description?: string; + /** When present, the answer must be one of these `value`s; `label` is what the human is shown. */ + readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }>; +} + +export interface ElicitationConfirm { + readonly kind: 'confirm'; + readonly question: string; + readonly details: Readonly>; + /** Markdown. Print it exactly as given, then ask `question`. */ + readonly display: string; + readonly answer: { readonly yes: string }; +} + +export interface ElicitationForm { + readonly kind: 'form'; + readonly question: string; + readonly details: Readonly>; + /** Markdown. Print it exactly as given, then ask `question`. */ + readonly display: string; + readonly fields: readonly ElicitationField[]; + readonly answer: { readonly rerun: string }; +} + +export type Elicitation = ElicitationConfirm | ElicitationForm; + +/** What `capy help --json` says about elicitations (`conventions.elicitation`). */ +export const ELICITATION_HELP = + // COPY-FLAG: minimal and neutral. + 'A refusal with exit code 3 can carry an `elicitation` next to its `code`: a question for the human, never for you. Write `display` (a markdown table) in your reply exactly as given, without shortening or reordering it and never inside the question. Then ask the short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and its `options` or `default` as the choices; without a question tool, end your reply with the `question` and the human answers in chat. kind "confirm" is a yes/no ({kind, question, details, display, answer:{yes}}): ask the human; on yes, run the command in `answer.yes`; on no, stop. kind "form" asks for values ({kind, question, details, display, fields:[{name, type, pattern, question?, default?, description?, options?:[{value, label}]}], answer:{rerun}}): ask the human for each field, then run `answer.rerun` with the values filled in. A field with `options` is a choice: the human picks a `label`, and you fill in its `value`. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool\'s own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. `details` holds names and ids only, never a value. Today: `capy project restore` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name,deleted_at}}); `capy project delete` without --yes refuses with CONFIRMATION_REQUIRED and a confirm (details: {org:{id,name}, project:{id,name}, branches:[name], variables:[{name, branches:[name]}], live_deploy_tokens:[{deploy_id, label}], unavailable?:["branches"|"variables"]}); `capy project create` missing the name, `--branch` or `--protected` in agent mode refuses with PROJECT_NAME_REQUIRED, BRANCH_NAME_REQUIRED or BRANCH_PROTECTION_REQUIRED and a form (fields: name, branch, protected (options yes/no); details: {org:{id,name}}); `capy project delete` / `project restore` with a name several projects share refuses with PROJECT_AMBIGUOUS and a form with one `project` field whose options are the projects (details: {org:{id,name}, matches:[{id, name, created_at, variables}]}). Never answer it yourself and never add --yes on your own.'; diff --git a/src/ui/elicitationDisplay.ts b/src/ui/elicitationDisplay.ts new file mode 100644 index 00000000..7d2e4937 --- /dev/null +++ b/src/ui/elicitationDisplay.ts @@ -0,0 +1,111 @@ +/** + * The `display` of an elicitation: a markdown table the agent prints exactly as given, before it asks the question. + * One renderer per elicitation, so every agent shows the human the same thing and none can shorten or reorder it. + * Built from names and ids only, like `details`. + */ + +/** A table cell: pipes escaped and line breaks flattened, so a name can never break the table. */ +function cell(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +function table(headers: readonly [string, string], rows: readonly (readonly [string, string])[]): string { + return [ + `| ${cell(headers[0])} | ${cell(headers[1])} |`, + '| --- | --- |', + ...rows.map(([a, b]) => `| ${cell(a)} | ${cell(b)} |`), + ].join('\n'); +} + +export interface DeleteDisplayInput { + readonly org: { readonly id: string; readonly name: string }; + readonly project: { readonly id: string; readonly name: string }; + readonly branches: readonly string[]; + readonly variables: ReadonlyArray<{ readonly name: string; readonly branches: readonly string[] }>; + readonly liveDeployTokens: ReadonlyArray<{ readonly deploy_id: string; readonly label?: string | null }>; + readonly unavailable: readonly ('branches' | 'variables')[]; +} + +// COPY-FLAG: every heading and placeholder below is minimal and neutral, awaiting approval. +const NONE = 'none'; +const UNREADABLE = 'could not be read'; + +function branchesCell(input: DeleteDisplayInput): string { + if (input.unavailable.includes('branches')) return UNREADABLE; + return input.branches.length === 0 ? NONE : input.branches.join(', '); +} + +function tokensCell(input: DeleteDisplayInput): string { + if (input.liveDeployTokens.length === 0) return NONE; + return input.liveDeployTokens.map((t) => (t.label ? `${t.deploy_id} (${t.label})` : t.deploy_id)).join(', '); +} + +function variablesSection(input: DeleteDisplayInput): string { + if (input.unavailable.includes('variables')) return `Variables: ${UNREADABLE}`; + if (input.variables.length === 0) return `Variables: ${NONE}`; + return table( + ['Variable', 'Branches'], + input.variables.map((v) => [v.name, v.branches.length === 0 ? NONE : v.branches.join(', ')] as const), + ); +} + +/** The table for `capy project delete`: the project, then every variable, one row each, in keep.lock order. */ +export function deleteProjectDisplay(input: DeleteDisplayInput): string { + const summary = table(['', ''], [ + ['Org', input.org.name], + ['Project', `${input.project.name} (${input.project.id})`], + ['Branches', branchesCell(input)], + ['Variables', input.unavailable.includes('variables') ? UNREADABLE : String(input.variables.length)], + ['Live deploy tokens', tokensCell(input)], + ]); + return `${summary}\n\n${variablesSection(input)}`; +} + +/** The table for `capy project restore`: the org, the project and when it was deleted. */ +export function restoreProjectDisplay(input: { + readonly org: { readonly id: string; readonly name: string }; + readonly project: { readonly id: string; readonly name: string }; + readonly deletedAt: string | null; +}): string { + return table(['', ''], [ + ['Org', input.org.name], + ['Project', `${input.project.name} (${input.project.id})`], + ['Deleted', input.deletedAt ?? UNREADABLE], // COPY-FLAG + ]); +} + +/** The table for a name that matches several projects: one numbered row per project, labelled as the options are. */ +export function chooseProjectDisplay(labels: readonly string[]): string { + return table(['', 'Project'], labels.map((label, i) => [String(i + 1), label] as const)); // COPY-FLAG +} + +export interface CreateDisplayInput { + readonly org: { readonly id: string; readonly name: string }; + /** The name already given, if any. */ + readonly name?: string; + /** The branch already given, if any. */ + readonly branch?: string; + /** Whether the branch is protected, if already given. */ + readonly isProtected?: boolean; + /** What a branch is, shown under the table. */ + readonly branchDescription: string; +} + +// COPY-FLAG: placeholder for a value still to be chosen. +const TO_CHOOSE = 'to choose'; + +function protectedCell(isProtected: boolean | undefined): string { + if (isProtected === undefined) return TO_CHOOSE; + return isProtected ? 'yes (invite-only)' : 'no'; // COPY-FLAG +} + +/** The table for `capy project create` asking for its name and first branch: where it goes, what is chosen so far, and what a branch is. */ +export function createProjectDisplay(input: CreateDisplayInput): string { + const summary = table(['', ''], [ + ['Org', input.org.name], + ['Project', input.name ?? TO_CHOOSE], + ['First branch', input.branch ?? TO_CHOOSE], // COPY-FLAG + ['Protected', protectedCell(input.isProtected)], // COPY-FLAG + ]); + return `${summary}\n\n${input.branchDescription}`; +} diff --git a/src/ui/errorScreen.ts b/src/ui/errorScreen.ts index 49c4ae9f..da583457 100644 --- a/src/ui/errorScreen.ts +++ b/src/ui/errorScreen.ts @@ -236,7 +236,7 @@ function renderProjectDeleted(ctx: ErrorContext): string { id ? ` ${grey(`ID: ${id}`)}` : '', // COPY-FLAG '', ` To restore it, an organization owner runs:`, // COPY-FLAG - ` ${bold(`capy org restore-project ${target}`)}`, + ` ${bold(`capy project restore ${target}`)}`, '', ` ${grey('Only an owner can restore a project.')}`, // COPY-FLAG '', diff --git a/src/ui/promptEngine.ts b/src/ui/promptEngine.ts index 93b50798..5c54383e 100644 --- a/src/ui/promptEngine.ts +++ b/src/ui/promptEngine.ts @@ -8,7 +8,8 @@ import { EnvVariable, UserDecisions } from '../types/index'; -import { isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../system/reservedProjectName'; +import { validateProjectNameInput } from '../core/projectNameRule'; +import { BRANCH_DESCRIPTION, DEFAULT_FIRST_BRANCH, FIRST_BRANCH_QUESTION, validateBranchNameInput } from '../core/branchNameRule'; export class PromptEngine { async promptForProjectName(defaultName: string): Promise { @@ -18,24 +19,49 @@ export class PromptEngine { name: 'projectName', message: `📁 Project name (default: "${defaultName}"):`, default: defaultName, - validate: (input: string) => { - if (!input || input.trim().length === 0) { - return 'Project name cannot be empty'; - } - if (!/^[a-zA-Z0-9-_]+$/.test(input)) { - return 'Project name can only contain letters, numbers, hyphens, and underscores'; - } - if (isReservedProjectName(input)) { - return PROJECT_NAME_RESERVED_MESSAGE; - } - return true; - } + validate: validateProjectNameInput, } ]); return projectName; } + /** + * Asks for a new project's first branch with the approved question, the one the agent elicitation asks too, + * saying first what a branch is. `development` unless the person types another. + */ + async promptForFirstBranch(): Promise { + console.log(BRANCH_DESCRIPTION); + const { initialBranchChoice } = await inquirer.prompt([{ + type: 'list', + name: 'initialBranchChoice', + message: FIRST_BRANCH_QUESTION, + choices: [ + { name: `${DEFAULT_FIRST_BRANCH} (default)`, value: DEFAULT_FIRST_BRANCH }, + { name: 'another branch', value: 'other' }, + ], + }]); + if (initialBranchChoice !== 'other') return String(initialBranchChoice); + const { branchName } = await inquirer.prompt([{ + type: 'input', + name: 'branchName', + message: 'Branch name:', + validate: validateBranchNameInput, + }]); + return String(branchName).trim(); + } + + /** Asks whether a new branch is invite-only, with the same question `capy checkout -b` asks. Defaults to No. */ + async promptForBranchProtected(branchName: string): Promise { + const { protect } = await inquirer.prompt([{ + type: 'confirm', + name: 'protect', + message: `Make "${branchName}" a protected branch? \x1b[90m(invite-only)\x1b[0m`, + default: false, + }]); + return protect === true; + } + async promptForChanges(changeSet: ChangeSet, branch?: string): Promise { const decisions: UserDecisions = { pushVariables: [], diff --git a/tests/commands/agentsCommand.test.ts b/tests/commands/agentsCommand.test.ts index f9536d36..5244f618 100644 --- a/tests/commands/agentsCommand.test.ts +++ b/tests/commands/agentsCommand.test.ts @@ -138,7 +138,7 @@ describe('agentsCommand', () => { it('write plan reports the change and writes nothing', async () => { await withTempRoot((root) => { const files = writeAgentsBlock(root, false); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'created' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'created' }, { path: 'CLAUDE.md', action: 'created' }]); expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); }); }); @@ -148,37 +148,46 @@ describe('agentsCommand', () => { writeAgentsBlock(root); const before = readFileSync(join(root, 'AGENTS.md'), 'utf-8'); const files = removeAgentsBlockFromFiles(root, false); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }, { path: 'CLAUDE.md', action: 'removed' }]); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toBe(before); }); }); }); describe('writeAgentsBlock (file targeting)', () => { - it('neither AGENTS.md nor CLAUDE.md exists: creates AGENTS.md only', async () => { + it('neither AGENTS.md nor CLAUDE.md exists: creates both, so every agent sees the block', async () => { await withTempRoot((root) => { const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'created' }]); - expect(existsSync(join(root, 'CLAUDE.md'))).toBe(false); + expect(files).toEqual([ + { path: 'AGENTS.md', action: 'created' }, + { path: 'CLAUDE.md', action: 'created' }, + ]); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toContain(''); + expect(readFileSync(join(root, 'CLAUDE.md'), 'utf-8')).toContain(''); }); }); - it('only AGENTS.md exists: updates AGENTS.md only, never creates CLAUDE.md', async () => { + it('only AGENTS.md exists: updates it and creates CLAUDE.md (Claude Code reads CLAUDE.md)', async () => { await withTempRoot((root) => { writeFileSync(join(root, 'AGENTS.md'), '# Repo\n'); const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'updated' }]); - expect(existsSync(join(root, 'CLAUDE.md'))).toBe(false); + expect(files).toEqual([ + { path: 'AGENTS.md', action: 'updated' }, + { path: 'CLAUDE.md', action: 'created' }, + ]); + expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8').startsWith('# Repo\n')).toBe(true); }); }); - it('only CLAUDE.md exists: updates CLAUDE.md only, never creates AGENTS.md', async () => { + it('only CLAUDE.md exists: updates it and creates AGENTS.md', async () => { await withTempRoot((root) => { writeFileSync(join(root, 'CLAUDE.md'), '# Repo\n'); const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'CLAUDE.md', action: 'updated' }]); - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); + expect(files).toEqual([ + { path: 'AGENTS.md', action: 'created' }, + { path: 'CLAUDE.md', action: 'updated' }, + ]); + expect(readFileSync(join(root, 'CLAUDE.md'), 'utf-8').startsWith('# Repo\n')).toBe(true); }); }); @@ -201,7 +210,7 @@ describe('agentsCommand', () => { writeAgentsBlock(root); const before = readFileSync(join(root, 'AGENTS.md'), 'utf-8'); const files = writeAgentsBlock(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'unchanged' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'unchanged' }, { path: 'CLAUDE.md', action: 'unchanged' }]); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toBe(before); }); }); @@ -276,7 +285,7 @@ describe('agentsCommand', () => { writeAgentsBlock(root); // AGENTS.md now holds ONLY the block expect(existsSync(join(root, 'AGENTS.md'))).toBe(true); const files = removeAgentsBlockFromFiles(root); - expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }]); + expect(files).toEqual([{ path: 'AGENTS.md', action: 'removed' }, { path: 'CLAUDE.md', action: 'removed' }]); expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); }); }); @@ -399,7 +408,7 @@ describe('agentsCommand', () => { const { exitCode, stdout } = await capture(() => agentsCommand({ json: true })); expect(exitCode).toBeUndefined(); const payload = JSON.parse(stdout); - expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }] }); + expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }, { path: 'CLAUDE.md', action: 'created' }] }); expect(existsSync(join(root, 'AGENTS.md'))).toBe(true); // --json keeps stdout pure JSON, so the confirm prompt must not render there. expect(createPromptModuleMock.mock.calls.length).toBeGreaterThan(0); @@ -433,7 +442,7 @@ describe('agentsCommand', () => { fakePrompt.mockImplementationOnce(async () => ({ confirmed: true })); const { stdout } = await capture(() => agentsCommand({ remove: true, json: true })); const payload = JSON.parse(stdout); - expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'removed' }] }); + expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'removed' }, { path: 'CLAUDE.md', action: 'removed' }] }); expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); }); }); @@ -551,13 +560,13 @@ describe('agentsCommand', () => { expect(first.exitCode).toBeUndefined(); expect(fakePrompt).not.toHaveBeenCalled(); const payload = JSON.parse(first.stdout.trim()); // throws if stdout carries anything but one JSON value - expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }] }); + expect(payload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'created' }, { path: 'CLAUDE.md', action: 'created' }] }); expect(existsSync(join(root, 'AGENTS.md'))).toBe(true); const before = readFileSync(join(root, 'AGENTS.md'), 'utf-8'); const second = await runCli(['agents', '--json', '--yes']); const secondPayload = JSON.parse(second.stdout.trim()); - expect(secondPayload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'unchanged' }] }); + expect(secondPayload).toEqual({ ok: true, files: [{ path: 'AGENTS.md', action: 'unchanged' }, { path: 'CLAUDE.md', action: 'unchanged' }] }); expect(readFileSync(join(root, 'AGENTS.md'), 'utf-8')).toBe(before); }); }); diff --git a/tests/commands/infoProjectState.test.ts b/tests/commands/infoProjectState.test.ts new file mode 100644 index 00000000..3dccf033 --- /dev/null +++ b/tests/commands/infoProjectState.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test'; +import { projectStateFor } from '../../src/commands/infoCommand'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const client = (fail?: unknown) => ({ + listBranches: async () => { + if (fail !== undefined) throw fail; + return [{ name: 'main' }]; + }, +}); + +describe('capy info: project state', () => { + it('a branch list means the project is live', async () => { + expect(await projectStateFor(client(), 'proj_1')).toBe('live'); + }); + + it('the PROJECT_DELETED code means it was deleted', async () => { + const err = new CapyError('gone', ERROR_CODES.PROJECT_DELETED); + expect(await projectStateFor(client(err), 'proj_1')).toBe('deleted'); + }); + + it('any other failure is unknown, never live or deleted', async () => { + expect(await projectStateFor(client(new CapyError('down', ERROR_CODES.SERVICE_ERROR)), 'proj_1')).toBe('unknown'); + expect(await projectStateFor(client(new Error('network')), 'proj_1')).toBe('unknown'); + }); +}); diff --git a/tests/commands/projectCreateCommand.test.ts b/tests/commands/projectCreateCommand.test.ts new file mode 100644 index 00000000..2ce3a942 --- /dev/null +++ b/tests/commands/projectCreateCommand.test.ts @@ -0,0 +1,389 @@ +import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } from 'bun:test'; + +/** + * `capy project create` (CAP-748). + * + * The REAL `ServiceClient` talks HTTP to a local fake of the service, so "creates nothing" is asserted as + * "no POST /projects ever arrived". 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'; +import { ProjectManager } from '../../src/core/projectManager'; + +type Reply = { readonly status: number; readonly body: unknown }; +const reply = (status: number, body: unknown): Reply => ({ status, body }); + +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', + orgName: 'Acme', + userId: 'user_1', + userEmail: 'owner@example.com', + authService: {}, + serviceClient: client, + })), +})); + +/** What the person "types" in a terminal, keyed by the prompt's `name`. */ +const TERMINAL_ANSWERS: Readonly> = { + projectName: 'typed-in-terminal', + initialBranchChoice: 'development', + branchName: 'typed-branch', + protect: false, +}; +const answerFor = (questions: unknown, answers: Readonly>) => { + const name = (questions as ReadonlyArray<{ readonly name: string }>)[0].name; + return { [name]: answers[name] }; +}; +const fakePrompt = mock(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); +mock.module('inquirer', () => ({ + default: { prompt: fakePrompt, createPromptModule: mock(() => fakePrompt) }, +})); + +afterAll(() => { + server.stop(true); + mock.restore(); +}); + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +interface Captured { + readonly exitCode?: number; + readonly stdout: string; + readonly stderr: string; +} + +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); + + 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 creates = () => requests().filter((r) => r.method === 'POST' && r.path === '/projects'); +const branchCreates = () => requests() + .filter((r) => r.method === 'POST' && r.path === '/projects/proj_new/branches') + .map((r) => JSON.parse(r.body) as { name: string; is_protected: boolean }); +const BRANCH_DESCRIPTION = 'A Capy branch works like a git branch. Capy branches can be used to set different variables for different environments.'; + +const LIVE = [ + { id: 'proj_web', name: 'web', organization_id: 'org_1' }, + { id: 'proj_other_org', name: 'api', organization_id: 'org_2' }, +]; + +/** A service that lists `projects`, answers a create with `onCreate` and a branch create with `onBranch`. */ +function service(opts: { projects?: readonly object[]; onCreate?: (body: string) => Reply; onBranch?: (body: string) => Reply }) { + const { projects = LIVE, onCreate = (body: string) => { + const { name } = JSON.parse(body) as { name: string }; + return reply(201, { id: 'proj_new', name, organization_id: 'org_1', s3_prefix: 'org_1/proj_new' }); + }, onBranch = (body: string) => { + const { name, is_protected } = JSON.parse(body) as { name: string; is_protected: boolean }; + return reply(201, { id: 'br_new', project_id: 'proj_new', name, is_protected }); + } } = opts; + respond.mockImplementation((method, path, body) => { + if (method === 'GET' && path.startsWith('/projects')) return reply(200, { projects }); + if (method === 'POST' && path === '/projects') return onCreate(body); + if (method === 'POST' && path === '/projects/proj_new/branches') return onBranch(body); + return reply(500, { error: 'unexpected request' }); + }); +} + +const { createProjectCommand, createProjectOptsFrom, existingProjectNamed } = await import('../../src/commands/projectCreateCommand'); + +describe('capy project create', () => { + beforeEach(() => { + respond.mockReset(); + fakePrompt.mockClear(); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); + setTTY(false); + }); + afterEach(() => setTTY(false)); + + it('with no name in agent mode: PROJECT_NAME_REQUIRED (exit 3) and a form asking for name, branch and protection, creating nothing', async () => { + service({}); + const out = await capture(() => createProjectCommand(undefined, { json: true })); + + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'PROJECT_NAME_REQUIRED', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields[0]).toMatchObject({ name: 'name', type: 'string', pattern: '^[a-zA-Z0-9-_]+$' }); + expect(body.elicitation.fields[0].question).toBe('What should the project be called?'); + expect(body.elicitation.fields[1]).toEqual({ + name: 'branch', + type: 'string', + pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$', + question: 'What should its first branch be called?', + default: 'development', + description: BRANCH_DESCRIPTION, + }); + expect(body.elicitation.fields[2]).toEqual({ + name: 'protected', + type: 'string', + pattern: '^(yes|no)$', + question: 'Make the first branch a protected branch? (invite-only)', + default: 'no', + options: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }], + }); + expect(body.elicitation.answer).toEqual({ rerun: 'capy project create --branch --protected --json' }); + expect(body.elicitation.display).toContain('| Protected | to choose |'); + expect(body.elicitation.details).toEqual({ org: { id: 'org_1', name: 'Acme' } }); + expect(body.elicitation.display).toContain('| Org | Acme |'); + expect(body.elicitation.display).toContain(BRANCH_DESCRIPTION); + expect(creates()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); + }); + + it('the folder name is suggested only when no live project in the org has it', async () => { + service({}); + const folder = spyOn(ProjectManager.prototype, 'getDefaultProjectName'); + + folder.mockReturnValue('web'); + const taken = JSON.parse((await capture(() => createProjectCommand(undefined, { json: true }))).stdout); + folder.mockReturnValue('fresh-folder'); + const free = JSON.parse((await capture(() => createProjectCommand(undefined, { json: true }))).stdout); + folder.mockRestore(); + + expect(taken.elicitation.fields[0].default).toBeUndefined(); + expect(free.elicitation.fields[0].default).toBe('fresh-folder'); + }); + + it('with a name but no --branch in agent mode: BRANCH_NAME_REQUIRED (exit 3), the form keeps the name, creating nothing', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true })); + + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'BRANCH_NAME_REQUIRED', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields.map((f: { name: string; default?: string }) => [f.name, f.default])).toEqual([ + ['name', 'new-app'], + ['branch', 'development'], + ['protected', 'no'], + ]); + expect(body.elicitation.display).toContain('| Project | new-app |'); + expect(creates()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); + }); + + it('with a name and branch but no --protected in agent mode: BRANCH_PROTECTION_REQUIRED (exit 3), the question names the branch', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging' })); + + expect(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'BRANCH_PROTECTION_REQUIRED', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields[2].question).toBe('Make "staging" a protected branch? (invite-only)'); + expect(body.elicitation.display).toContain('| First branch | staging |'); + expect(creates()).toEqual([]); + }); + + it('--protected yes creates the first branch protected', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'production', protectedAnswer: 'yes' })); + + expect(JSON.parse(out.stdout).branch).toEqual({ name: 'production', protected: true }); + expect(branchCreates()).toEqual([{ name: 'production', is_protected: true }]); + }); + + it('--protected with anything but yes or no: BRANCH_PROTECTION_INVALID before any request', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging', protectedAnswer: 'maybe' })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ code: 'BRANCH_PROTECTION_INVALID', options: ['yes', 'no'] }); + expect(requests()).toEqual([]); + }); + + it('creates the project, then its first branch, unprotected', async () => { + service({}); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'staging', protectedAnswer: 'no' })); + + expect(out.exitCode).toBeUndefined(); + expect(JSON.parse(out.stdout)).toEqual({ + ok: true, + project: { id: 'proj_new', name: 'new-app' }, + branch: { name: 'staging', protected: false }, + org: { id: 'org_1', name: 'Acme' }, + }); + expect(creates().map((r) => JSON.parse(r.body))).toEqual([{ name: 'new-app', organization_id: 'org_1' }]); + expect(branchCreates()).toEqual([{ name: 'staging', is_protected: false }]); + }); + + it('the project is made but its branch is not: BRANCH_CREATE_FAILED with the project, so nobody thinks nothing happened', async () => { + service({ onBranch: () => reply(500, { error: 'boom' }) }); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development', protectedAnswer: 'no' })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ + ok: false, + code: 'BRANCH_CREATE_FAILED', + project: { id: 'proj_new', name: 'new-app' }, + branch: { name: 'development', protected: false }, + }); + }); + + it('a branch name the service would refuse: BRANCH_NAME_INVALID before any request', async () => { + service({}); + const bad = ['-dev', 'a..b', 'has space', 'x'.repeat(129)]; + const outs = await bad.reduce>( + async (acc, branch) => [...(await acc), await capture(() => createProjectCommand('new-app', { json: true, branch }))], + Promise.resolve([]), + ); + + expect(outs.map((o) => [o.exitCode, JSON.parse(o.stdout).code])).toEqual(bad.map(() => [1, 'BRANCH_NAME_INVALID'])); + expect(requests()).toEqual([]); + }); + + it('a name a live project in the org already has: PROJECT_NAME_EXISTS, creating nothing (ignores case and spaces)', async () => { + service({}); + const out = await capture(() => createProjectCommand('WEB', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NAME_EXISTS', project: { id: 'proj_web', name: 'web' } }); + expect(creates()).toEqual([]); + }); + + it('a name used only in another org is not a clash', async () => { + service({}); + const out = await capture(() => createProjectCommand('api', { json: true, branch: 'development', protectedAnswer: 'no' })); + + expect(out.exitCode).toBeUndefined(); + expect(creates()).toHaveLength(1); + }); + + it('a name that breaks the rule: PROJECT_NAME_INVALID before any request', async () => { + service({}); + const out = await capture(() => createProjectCommand('has space', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NAME_INVALID', pattern: '^[a-zA-Z0-9-_]+$' }); + expect(requests()).toEqual([]); + }); + + it('the reserved system store name: PROJECT_NAME_RESERVED before any request', async () => { + service({}); + const out = await capture(() => createProjectCommand('_System', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NAME_RESERVED'); + expect(requests()).toEqual([]); + }); + + it('--dry-run checks the name (including clashes) and creates nothing', async () => { + service({}); + const ok = await capture(() => createProjectCommand('new-app', { json: true, dryRun: true, branch: 'development', protectedAnswer: 'no' })); + expect(JSON.parse(ok.stdout)).toMatchObject({ ok: true, dry_run: true, project: { name: 'new-app' }, branch: { name: 'development', protected: false } }); + + const clash = await capture(() => createProjectCommand('web', { json: true, dryRun: true, branch: 'development', protectedAnswer: 'no' })); + expect(JSON.parse(clash.stdout).code).toBe('PROJECT_NAME_EXISTS'); + expect(creates()).toEqual([]); + expect(branchCreates()).toEqual([]); + }); + + it('the free-tier limit keeps its code', async () => { + service({ onCreate: () => reply(402, { error: 'limit', code: 'QUOTA_EXCEEDED', kind: 'project', limit: 3 }) }); + const out = await capture(() => createProjectCommand('new-app', { json: true, branch: 'development', protectedAnswer: 'no' })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout).code).toBe('QUOTA_EXCEEDED'); + expect(branchCreates()).toEqual([]); + }); + + it('in a terminal with nothing given: asks the name, says what a branch is, asks the branch and whether it is protected, then creates both', async () => { + setTTY(true); + service({}); + const out = await capture(() => createProjectCommand(undefined, {})); + + expect(fakePrompt.mock.calls.map(([q]) => (q as ReadonlyArray<{ name: string }>)[0].name)).toEqual(['projectName', 'initialBranchChoice', 'protect']); + expect(out.stdout).toContain(BRANCH_DESCRIPTION); + expect(out.exitCode).toBeUndefined(); + expect(creates().map((r) => JSON.parse(r.body).name)).toEqual(['typed-in-terminal']); + expect(branchCreates()).toEqual([{ name: 'development', is_protected: false }]); + }); + + it('in a terminal, "another branch" asks for its name', async () => { + setTTY(true); + service({}); + fakePrompt.mockImplementation(async (questions: unknown) => + answerFor(questions, { ...TERMINAL_ANSWERS, initialBranchChoice: 'other' })); + const out = await capture(() => createProjectCommand('new-app', {})); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); + + expect(out.exitCode).toBeUndefined(); + expect(branchCreates()).toEqual([{ name: 'typed-branch', is_protected: false }]); + }); + + it('in a terminal, yes to the protected question creates the branch protected', async () => { + setTTY(true); + service({}); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, { ...TERMINAL_ANSWERS, protect: true })); + await capture(() => createProjectCommand('new-app', {})); + fakePrompt.mockImplementation(async (questions: unknown) => answerFor(questions, TERMINAL_ANSWERS)); + + expect(branchCreates()).toEqual([{ name: 'development', is_protected: true }]); + }); + + it('in a terminal, a name that clashes is refused before the branch is asked', async () => { + setTTY(true); + service({}); + const out = await capture(() => createProjectCommand('web', {})); + + expect(out.exitCode).toBe(1); + expect(fakePrompt).not.toHaveBeenCalled(); + expect(creates()).toEqual([]); + }); +}); + +describe('existingProjectNamed', () => { + it('matches only in the same org, trimmed and ignoring case', () => { + expect(existingProjectNamed(LIVE, 'org_1', ' Web ')).toEqual({ id: 'proj_web', name: 'web' }); + expect(existingProjectNamed(LIVE, 'org_1', 'api')).toBeUndefined(); + expect(existingProjectNamed(LIVE, 'org_1', 'webs')).toBeUndefined(); + }); +}); + +describe('createProjectOptsFrom', () => { + const merged = (opts: Record) => createProjectOptsFrom({ optsWithGlobals: () => opts }); + + it('--non-tty means --json: agent mode always answers in JSON', () => { + expect(merged({ nonTty: true }).json).toBe(true); + expect(merged({ json: true }).json).toBe(true); + expect(merged({}).json).toBe(false); + }); +}); diff --git a/tests/commands/projectDeletedCli.test.ts b/tests/commands/projectDeletedCli.test.ts index a16f3463..cbe17b5c 100644 --- a/tests/commands/projectDeletedCli.test.ts +++ b/tests/commands/projectDeletedCli.test.ts @@ -2,8 +2,8 @@ * 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. + * 1. `project delete --force` reaches the command. The program defines `-f/--force`, so commander gives that flag to + * the outer command and the subcommand's own options never hold it. * 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. */ @@ -15,7 +15,7 @@ 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 RESTORE = `capy project restore ${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/')); @@ -28,12 +28,12 @@ async function deletedFolder(h: Harness): Promise { h.markProjectDeleted(); } -describe('org delete-project / restore-project / projects: flags the program or `org` also defines', () => { +describe('project delete / project restore / org projects: flags the program 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']); + const forced = await h.run(['project', 'delete', PROJECT_ID, '--force', '--yes', '--json']); expect(forced.code).toBe(0); - const without = await h.run(['org', 'delete-project', PROJECT_ID, '--yes', '--json']); + const without = await h.run(['project', 'delete', PROJECT_ID, '--yes', '--json']); expect(without.code).toBe(0); const deletes = requests(h, 'DELETE'); @@ -43,24 +43,24 @@ describe('org delete-project / restore-project / projects: flags the program or 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']); + await h.run(['-f', 'project', 'delete', PROJECT_ID, '--yes', '--json']); + await h.run(['project', 'delete', 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']); + const out = await h.run(['project', 'delete', 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 () => { + test('project restore --yes --json posts the restore', async () => { await withHarness(async (h) => { - const out = await h.run(['org', 'restore-project', PROJECT_ID, '--yes', '--json']); + const out = await h.run(['project', 'restore', 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`]); @@ -78,13 +78,13 @@ describe('org delete-project / restore-project / projects: flags the program or }); 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. */ + /** The same shape as src/index.ts: the program has -f/--force and -d/--dry-run; `project` has no options of its own. */ 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 ') + const project = program.command('project'); + project + .command('delete ') .option('--force') .option('--yes') .option('--non-tty') @@ -94,18 +94,22 @@ describe('lifecycleOptsFrom: --non-tty and --force, wherever the outer command t 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('--non-tty reaches the command', () => { + expect(parse(['project', 'delete', 'web', '--non-tty'])?.nonTty).toBe(true); + }); + + test('--non-tty means --json: agent mode always answers in JSON', () => { + expect(parse(['project', 'delete', 'web', '--non-tty'])?.json).toBe(true); + expect(parse(['project', 'delete', 'web'])?.json).toBe(false); }); 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); + expect(parse(['project', 'delete', 'web', '--force'])?.force).toBe(true); + expect(parse(['project', 'delete', 'web'])?.force).toBe(false); }); test('--yes, --json and --dry-run come through too', () => { - expect(parse(['org', 'delete-project', 'web', '--yes', '--json', '--dry-run'])).toEqual({ + expect(parse(['project', 'delete', 'web', '--yes', '--json', '--dry-run'])).toEqual({ force: false, yes: true, nonTty: false, diff --git a/tests/commands/projectLifecycleCommand.test.ts b/tests/commands/projectLifecycleCommand.test.ts index 5e60e677..61a15692 100644 --- a/tests/commands/projectLifecycleCommand.test.ts +++ b/tests/commands/projectLifecycleCommand.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, spyOn, mock, afterAll, beforeEach, afterEach } from 'bun:test'; /** - * `capy org delete-project` and `capy org restore-project` (CAP-729). + * `capy project delete` and `capy project restore` (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 @@ -33,15 +33,19 @@ const server = Bun.serve({ 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, - })), +/** The org context every test gets. Its calls show whether a command allowed the organization picker. */ +const resolveOrgContext = mock(async (..._args: unknown[]) => ({ + orgId: 'org_1', + orgName: 'Acme', + userId: 'user_1', + userEmail: 'owner@example.com', + authService: {}, + serviceClient: client, })); +mock.module('../../src/core/orgContext', () => ({ resolveOrgContext })); + +/** The options the last command passed to resolveOrgContext. */ +const lastOrgContextOptions = (): unknown => resolveOrgContext.mock.calls.at(-1)?.[2]; /** What the person answers when asked. Replaced per test. */ const fakePrompt = mock(async (_questions: unknown) => ({ confirmed: true })); @@ -106,13 +110,31 @@ const TOKEN = { deploy_id: 'dep_1', label: 'prod web', created_at: '2026-10-01T0 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`. */ +/** A keep.lock with `count` variables, each on `branch`. Names only matter; nothing else is read. */ +const keepWith = (count: number, branch: string) => JSON.stringify({ + version: '3.0', + variables: Object.fromEntries(Array.from({ length: count }, (_, i) => [`VAR_${i}`, [{ branch }]])), +}); + function service(opts: { projects?: readonly object[]; tokens?: readonly object[]; + /** Per project id: its branches and how many variables its keep.lock has. Absent = unreadable. */ + contents?: Readonly>; onWrite?: (method: string, path: string, body: string) => Reply; }) { - const { projects = LIVE, tokens = [], onWrite = () => reply(500, { error: 'no write expected' }) } = opts; + const { projects = LIVE, tokens = [], contents = {}, onWrite = () => reply(500, { error: 'no write expected' }) } = opts; respond.mockImplementation((method, path, body) => { + const branchList = method === 'GET' ? /^\/projects\/([^/?]+)\/branches$/.exec(path) : null; + if (branchList) { + const c = contents[branchList[1]]; + return c ? reply(200, { branches: c.branches.map((name) => ({ name })) }) : reply(500, { error: 'unreadable' }); + } + const latest = method === 'GET' ? /^\/secrets\/([^/?]+)\?branch=([^&]+)$/.exec(path) : null; + if (latest) { + const c = contents[latest[1]]; + return c ? reply(200, { env_file: '', keep_hash: 'h', keep_file: keepWith(c.variables, decodeURIComponent(latest[2])) }) : reply(500, { error: 'unreadable' }); + } 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 })) }); @@ -127,7 +149,7 @@ const deleted = (id: string, name: string) => // 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', () => { +describe('capy project delete', () => { beforeEach(() => { respond.mockReset(); fakePrompt.mockReset(); @@ -138,14 +160,14 @@ describe('capy org delete-project', () => { afterEach(() => setTTY(false)); describe('confirmation', () => { - it('off a terminal without --yes: refuses with CONFIRMATION_REQUIRED (exit 3) before any request', async () => { + it('off a terminal without --yes: refuses with CONFIRMATION_REQUIRED (exit 3) and a confirm elicitation, writing nothing', 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(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED', elicitation: { kind: 'confirm' } }); + expect(writes()).toEqual([]); expect(fakePrompt).not.toHaveBeenCalled(); }); @@ -157,7 +179,8 @@ describe('capy org delete-project', () => { expect(out.exitCode).toBe(3); expect(JSON.parse(out.stdout).code).toBe('CONFIRMATION_REQUIRED'); - expect(requests()).toEqual([]); + expect(writes()).toEqual([]); + expect(fakePrompt).not.toHaveBeenCalled(); }); it('the prompt defaults to no, and shows the project and its live tokens first', async () => { @@ -178,16 +201,15 @@ describe('capy org delete-project', () => { expect(deletes()).toEqual([]); }); - it('answering no with --json says CANCELLED on stdout, deletes nothing, and asks on stderr', async () => { + it('--json in a terminal is agent mode, as for create: the confirm elicitation, no prompt, nothing deleted', 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(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED', elicitation: { kind: 'confirm' } }); + expect(fakePrompt).not.toHaveBeenCalled(); expect(deletes()).toEqual([]); }); @@ -199,7 +221,7 @@ describe('capy org delete-project', () => { 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'); + expect(out.stdout).toContain('capy project restore proj_web'); }); it('--yes skips the question and the token read, even off a terminal', async () => { @@ -371,36 +393,99 @@ describe('capy org delete-project', () => { 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 () => { + it('a name that matches no project: PROJECT_NOT_FOUND before anything is read, asked or sent', async () => { service({ onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); - const out = await capture(() => deleteProjectCommand('nope', { yes: true, json: true })); + const out = await capture(() => deleteProjectCommand('nope', { json: true })); + + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'PROJECT_NOT_FOUND' }); + expect(JSON.parse(out.stdout).elicitation).toBeUndefined(); + expect(requests().filter((r) => r.path !== '/projects')).toEqual([]); + }); - expect(deletes()[0].path).toBe('/orgs/org_1/projects/nope'); + it('an id that matches no listed project still goes to the service, which answers for it', async () => { + const id = '5b0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3'; + service({ onWrite: () => reply(404, { error: 'Project not found', code: 'PROJECT_NOT_FOUND' }) }); + + const out = await capture(() => deleteProjectCommand(id, { yes: true, json: true })); + + expect(deletes()[0].path).toBe(`/orgs/org_1/projects/${id}`); 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 DAY = 24 * 60 * 60 * 1000; + const daysAgo = (n: number) => new Date(Date.now() - n * DAY).toISOString(); + const TWINS = [ + { id: 'proj_new', name: 'web', organization_id: 'org_1', created_at: daysAgo(1) }, + { id: 'proj_old', name: 'web', organization_id: 'org_1', created_at: daysAgo(3) }, + ]; + const TWIN_CONTENTS = { + proj_old: { branches: ['development'], variables: 45 }, + proj_new: { branches: ['development'], variables: 1 }, + }; + + it('an ambiguous name, agent: PROJECT_AMBIGUOUS (exit 3) with a pick-one elicitation, oldest first, nothing deleted even with --yes', async () => { + service({ projects: TWINS, contents: TWIN_CONTENTS, onWrite: () => deleted('proj_old', '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(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'PROJECT_AMBIGUOUS', elicitation: { kind: 'form', question: 'Which web?' } }); + expect(body.elicitation.fields).toEqual([{ + name: 'project', + type: 'string', + pattern: '^(proj_old|proj_new)$', + options: [ + { value: 'proj_old', label: 'web · 45 variables · created 3 days ago' }, + { value: 'proj_new', label: 'web · 1 variable · created 1 day ago' }, + ], + }]); + expect(body.elicitation.answer).toEqual({ rerun: 'capy project delete --json' }); + expect(body.elicitation.display).toContain('| 1 | web · 45 variables · created 3 days ago |'); expect(deletes()).toEqual([]); }); + it('an ambiguous name keeps --force in the rerun, and an unreadable count is said, never 0', async () => { + service({ projects: TWINS, contents: { proj_old: TWIN_CONTENTS.proj_old } }); + + const out = await capture(() => deleteProjectCommand('web', { force: true, json: true })); + + const body = JSON.parse(out.stdout); + expect(body.elicitation.answer.rerun).toBe('capy project delete --force --json'); + expect(body.elicitation.fields[0].options[1].label).toBe('web · variables could not be read · created 1 day ago'); + expect(body.elicitation.details.matches[1]).toMatchObject({ id: 'proj_new', variables: null }); + }); + + it('an ambiguous name with no creation dates (an older service) still asks, without the dates', async () => { + service({ projects: TWINS.map(({ created_at: _c, ...p }) => p), contents: TWIN_CONTENTS }); + + const out = await capture(() => deleteProjectCommand('web', { json: true })); + + expect(JSON.parse(out.stdout).elicitation.fields[0].options.map((o: { label: string }) => o.label)) + .toEqual(['web · 1 variable', 'web · 45 variables']); + }); + + it('an ambiguous name, terminal: an arrow-key list, then the usual yes/no, then the chosen one is deleted', async () => { + setTTY(true); + service({ projects: TWINS, contents: TWIN_CONTENTS, onWrite: () => deleted('proj_old', 'web') }); + fakePrompt.mockImplementation(async (questions: unknown) => { + const q = (questions as ReadonlyArray<{ name: string; choices?: ReadonlyArray<{ name: string; value: string }> }>)[0]; + return q.name === 'projectId' ? { projectId: q.choices?.[0].value } : { confirmed: true }; + }); + + await capture(() => deleteProjectCommand('web', {})); + + const asked = fakePrompt.mock.calls.map(([q]) => (q as ReadonlyArray<{ name: string; message: string; choices?: ReadonlyArray<{ name: string }> }>)[0]); + expect(asked.map((q) => q.name)).toEqual(['projectId', 'confirmed']); + expect(asked[0].message).toBe('Which web?'); + expect(asked[0].choices?.map((c) => c.name)).toEqual(['web · 45 variables · created 3 days ago', 'web · 1 variable · created 1 day ago']); + expect(deletes().map((d) => d.path)).toEqual(['/orgs/org_1/projects/proj_old']); + }); + it('an ambiguous name, human: both ids on stderr', async () => { service({ projects: [ @@ -427,18 +512,18 @@ describe('capy org delete-project', () => { expect(deletes()[0].path).toBe('/orgs/org_1/projects/proj_web'); }); - it('the org system store is never a match', async () => { + it('the org system store is never a match: its name is not found, and nothing is sent', 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 })); + const out = 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'); + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NOT_FOUND'); + expect(deletes()).toEqual([]); }); }); }); -describe('capy org restore-project', () => { +describe('capy project restore', () => { beforeEach(() => { respond.mockReset(); fakePrompt.mockReset(); @@ -493,14 +578,60 @@ describe('capy org restore-project', () => { expect(writes()).toEqual([]); }); - it('off a terminal without --yes: CONFIRMATION_REQUIRED before any request', async () => { + it('off a terminal without --yes: CONFIRMATION_REQUIRED (exit 3) with a confirm elicitation, restoring nothing', 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([]); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ ok: false, code: 'CONFIRMATION_REQUIRED' }); + expect(body.elicitation).toMatchObject({ + kind: 'confirm', + question: 'Restore project old?', + details: { org: { id: 'org_1' }, project: { id: 'proj_old', name: 'old', deleted_at: '2026-11-05T12:00:00.000Z' } }, + answer: { yes: 'capy project restore proj_old --yes --json' }, + }); + expect(body.elicitation.display).toContain('| Project | old (proj_old) |'); + expect(body.elicitation.display).toContain('| Deleted | 2026-11-05T12:00:00.000Z |'); + expect(fakePrompt).not.toHaveBeenCalled(); + expect(writes()).toEqual([]); + }); + + it('--dry-run, even with --yes: names the project and sends nothing that writes', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { yes: true, dryRun: true, json: true })); + expect(out.exitCode).toBeUndefined(); + expect(JSON.parse(out.stdout)).toEqual({ ok: true, dry_run: true, project: { id: 'proj_old', name: 'old' } }); + expect(writes()).toEqual([]); + + const text = await capture(() => restoreProjectCommand('old', { yes: true, dryRun: true })); + expect(text.stdout).toContain('Dry run: nothing was restored.'); + expect(writes()).toEqual([]); + }); + + it('agent mode never allows the organization picker; a terminal does', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + await capture(() => restoreProjectCommand('old', { json: true })); + expect(lastOrgContextOptions()).toEqual({ promptForOrg: false }); + + await capture(() => restoreProjectCommand('old', { yes: true, nonTty: true })); + expect(lastOrgContextOptions()).toEqual({ promptForOrg: false }); + + setTTY(true); + await capture(() => restoreProjectCommand('old', { yes: true })); + expect(lastOrgContextOptions()).toEqual({ promptForOrg: true }); + }); + + it('two deleted projects with one name, no --yes: the pick-one rerun has no --yes, so the yes/no comes next', async () => { + service({ projects: [DELETED, { ...DELETED, id: 'proj_old2' }], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('old', { json: true })); + + expect(JSON.parse(out.stdout).elicitation.answer.rerun).toBe('capy project restore --json'); + expect(writes()).toEqual([]); }); it('a project that is not deleted: 409 PROJECT_NOT_DELETED', async () => { @@ -516,11 +647,12 @@ describe('capy org restore-project', () => { }); it('owner only: PROJECT_DELETE_OWNER_ONLY, and an id still reaches the service when the listing hides deleted projects', async () => { + const id = '9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a'; 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 })); + const out = await capture(() => restoreProjectCommand(id, { yes: true, json: true })); - expect(writes()[0].path).toBe('/orgs/org_1/projects/proj_old/restore'); + expect(writes()[0].path).toBe(`/orgs/org_1/projects/${id}/restore`); expect(JSON.parse(out.stdout).code).toBe('PROJECT_DELETE_OWNER_ONLY'); }); @@ -535,12 +667,25 @@ describe('capy org restore-project', () => { 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 () => { + it('two deleted projects with the same name: PROJECT_AMBIGUOUS (exit 3) with a pick-one elicitation whose rerun keeps --yes', 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(out.exitCode).toBe(3); + const body = JSON.parse(out.stdout); + expect(body).toMatchObject({ code: 'PROJECT_AMBIGUOUS', elicitation: { kind: 'form' } }); + expect(body.elicitation.fields[0].options.map((o: { value: string }) => o.value)).toEqual(['proj_old', 'proj_old2']); + expect(body.elicitation.answer.rerun).toBe('capy project restore --yes --json'); + expect(writes()).toEqual([]); + }); + + it('a name that matches no project: PROJECT_NOT_FOUND, nothing sent', async () => { + service({ projects: [DELETED], onWrite: () => restored }); + + const out = await capture(() => restoreProjectCommand('nope', { yes: true, json: true })); + + expect(JSON.parse(out.stdout).code).toBe('PROJECT_NOT_FOUND'); expect(writes()).toEqual([]); }); }); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index 3f29d27c..76257223 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -33,9 +33,16 @@ describe('AGENTS_BLOCK', () => { '- Run `capy help --json` for every command, its options, and its error codes.', '- Always pass `--json` and branch on the `code` field, never on message text.', '- Run `capy pair --json` in an interactive terminal/PTY. After browser approval, it asks "Enable this location as [email]?" before installing the session or keys. Show the returned email to the human and obtain explicit Yes/No approval before answering. Never automatically confirm or pipe `yes`; Enter defaults to No. No upfront email is required.', + '- Before a command that writes, run `capy info --json` and check its `org`, `project` and `branch`. If they are not the ones the human means, stop and ask.', + '- If a command starts a browser sign-in, this folder belongs to an organization this machine is not signed in to. Stop and tell the human. Never complete the sign-in yourself.', + '- To find where a secret lives, run `capy secrets --json --name NAME`. It lists locations in the signed-in organization only, and never shows a value.', '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', + '- If a JSON result has an `elicitation`, write its `display` table in your reply exactly as given (never shorten or reorder it, never put it inside the question). Then ask its short `question` as an elicitation with your question tool: one question per field, using the field\'s own `question` followed by its `description` when it has them, and offering its `options` (or its `default`) as the choices. A field without `options` is typed in. With a `default`, offer exactly two choices, the `default` and "Cancel"; the human types any other answer in your question tool\'s own free-text answer. With no `default`, leave it out of the question tool: after the other questions, ask its `question` in chat and wait for the human to type the answer. On "Cancel", stop. Without a question tool, end your reply with the `question` and wait for the human to answer in chat. Then run the elicitation\'s answer command with what the human chose. Never answer it yourself and never add `--yes` on your own.', + '- Only when the human asks to create a project, run `capy project create --json`, adding the name, `--branch ` and `--protected yes|no` only if the human already gave them. It creates nothing until it has all three and returns an `elicitation` asking for the rest: that is how you ask the human, so do not ask your own question first.', + "- Only when the human asks to delete a project, run `capy project delete --json` with the name they gave and without `--yes`, even if this folder's project is deleted or is a different one: it looks the name up in the organization, not in this folder. It changes nothing and returns an `elicitation`: that is how you ask the human, so do not ask your own question first. Only an org owner can delete or restore a project.", + "- `PROJECT_DELETED`, or `project.state` `\"deleted\"` in `capy info --json`, means an org owner deleted this folder's project. Stop and tell the human. An org owner can restore it with `capy project restore ` (the id is in `keep.lock`). Never create a new project and never edit `keep.lock`. This is about this folder only: it never stops a delete or restore the human asked for by name.", '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', diff --git a/tests/core/branchNameRule.test.ts b/tests/core/branchNameRule.test.ts new file mode 100644 index 00000000..d98e7f40 --- /dev/null +++ b/tests/core/branchNameRule.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'bun:test'; +import { branchNameProblem, validateBranchNameInput } from '../../src/core/branchNameRule'; + +/** The service's rule (routes/projects.ts validateBranchName): the CLI must refuse exactly what the service refuses. */ +describe('branchNameProblem', () => { + it('accepts what the service accepts', () => { + const ok = ['development', 'staging', 'production', 'main', 'v1.2', 'feature_x', 'a-b', '9lives', 'x'.repeat(128)]; + expect(ok.map((name) => [name, branchNameProblem(name)])).toEqual(ok.map((name) => [name, null])); + }); + + it('refuses what the service refuses, with BRANCH_NAME_INVALID', () => { + const bad = ['', '-dev', '.dev', '_dev', 'a..b', 'has space', 'a/b', 'x'.repeat(129)]; + expect(bad.map((name) => branchNameProblem(name)?.code)).toEqual(bad.map(() => 'BRANCH_NAME_INVALID')); + }); + + it('the prompt validator trims, then answers true or the sentence', () => { + expect(validateBranchNameInput(' staging ')).toBe(true); + expect(validateBranchNameInput('a..b')).toBe('Branch name cannot contain ".."'); + }); +}); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 19d7367e..052e16ca 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -77,6 +77,7 @@ ISOLATED_FILES=( tests/commands/projectsCommand.test.ts tests/commands/projectsDeleted.test.ts tests/commands/projectLifecycleCommand.test.ts + tests/commands/projectCreateCommand.test.ts tests/commands/secretsCommand.test.ts tests/commands/runCommandSystemStore.test.ts tests/commands/connectDiscoveryRouting.test.ts diff --git a/tests/ui/elicitationDisplay.test.ts b/tests/ui/elicitationDisplay.test.ts new file mode 100644 index 00000000..8d416aeb --- /dev/null +++ b/tests/ui/elicitationDisplay.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { createProjectDisplay, deleteProjectDisplay } from '../../src/ui/elicitationDisplay'; + +const base = { + org: { id: 'org_1', name: 'Demos' }, + project: { id: 'proj_1', name: 'web' }, + branches: ['main', 'development'], + variables: [ + { name: 'B_VAR', branches: ['main'] }, + { name: 'A_VAR', branches: ['main', 'development'] }, + ], + liveDeployTokens: [], + unavailable: [], +} as const; + +describe('deleteProjectDisplay', () => { + it('lists every variable, one row each, in the order given', () => { + const out = deleteProjectDisplay(base); + const rows = out.split('\n').filter((l) => l.startsWith('| ') && l.includes('_VAR')); + expect(rows).toEqual(['| B_VAR | main |', '| A_VAR | main, development |']); + expect(out).toContain('| Variables | 2 |'); + expect(out).toContain('| Project | web (proj_1) |'); + expect(out).toContain('| Live deploy tokens | none |'); + }); + + it('never shortens a long list', () => { + const variables = Array.from({ length: 45 }, (_, i) => ({ name: `V_${i}`, branches: ['development'] })); + const out = deleteProjectDisplay({ ...base, variables }); + expect(variables.every((v) => out.includes(`| ${v.name} | development |`))).toBe(true); + }); + + it('says a part could not be read instead of calling it empty', () => { + const out = deleteProjectDisplay({ ...base, unavailable: ['branches', 'variables'] }); + expect(out).toContain('| Branches | could not be read |'); + expect(out).toContain('Variables: could not be read'); + expect(out).not.toContain('B_VAR'); + }); + + it('a pipe or line break in a label cannot break the table', () => { + const out = deleteProjectDisplay({ ...base, liveDeployTokens: [{ deploy_id: 'dep_1', label: 'a|b\nc' }] }); + expect(out).toContain('| Live deploy tokens | dep_1 (a\\|b c) |'); + }); +}); + +describe('createProjectDisplay', () => { + const org = { id: 'org_1', name: 'Demos' }; + const branchDescription = 'A Capy branch works like a git branch. Capy branches can be used to set different variables for different environments.'; + + it('shows the org, what is still to choose, and what a branch is', () => { + const out = createProjectDisplay({ org, branchDescription }); + expect(out).toContain('| Org | Demos |'); + expect(out).toContain('| Project | to choose |'); + expect(out).toContain('| First branch | to choose |'); + expect(out).toContain('| Protected | to choose |'); + expect(out.endsWith(`\n\n${branchDescription}`)).toBe(true); + }); + + it('shows a name already given', () => { + expect(createProjectDisplay({ org, name: 'web', branchDescription })).toContain('| Project | web |'); + }); +}); diff --git a/tests/ui/projectDeletedFolder.test.ts b/tests/ui/projectDeletedFolder.test.ts index ffa5b05b..0a29228a 100644 --- a/tests/ui/projectDeletedFolder.test.ts +++ b/tests/ui/projectDeletedFolder.test.ts @@ -117,7 +117,7 @@ describe('a folder whose project was deleted', () => { 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).toContain('capy project restore proj_web'); expect(screen.toLowerCase()).toContain('only an owner'); }); @@ -129,7 +129,7 @@ describe('a folder whose project was deleted', () => { expect(out.stdout).toContain('Project deleted'); expect(out.stdout).toContain('"web"'); - expect(out.stdout).toContain('capy org restore-project proj_web'); + expect(out.stdout).toContain('capy project restore proj_web'); rmSync(dir, { recursive: true, force: true }); }); @@ -208,7 +208,7 @@ describe('a folder whose project was deleted', () => { expect(out.exitCode).toBe(1); expect(out.stdout).toContain('Project deleted'); - expect(out.stdout).toContain('capy org restore-project proj_web'); + expect(out.stdout).toContain('capy project restore proj_web'); expect(out.stdout.toLowerCase()).not.toContain('recreate'); rmSync(dir, { recursive: true, force: true }); });