diff --git a/VERSION b/VERSION index 62ea2590..6d44d227 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.13 +0.9.14 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ec960961..13e4bcc4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.13`. Generated from `capy help --json`. +Version `0.9.14`. Generated from `capy help --json`. ## Commands @@ -34,7 +34,7 @@ JSON support: yes (`--json`) ## `capy edit` -Inspect and edit secrets in an interactive TUI, or set one variable from a piped value +Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value ``` capy edit [name] [options] @@ -42,7 +42,7 @@ capy edit [name] [options] | Option | Description | Default | |---|---|---| -| `--no-push` | piped value: write .env only; do not push to Capy | | +| `--no-push` | with a name: write .env only; do not push to Capy | | | `--json` | emit machine-readable JSON instead of the human UI (piped value) | | | `--non-tty` | treat stdin as not a terminal; never prompt (agents/CI) | | | `--pr` | create a PR with the keep.lock change (answers the prompt) | | @@ -127,10 +127,11 @@ capy deploy [target] [options] | `--scope ` | gh-actions: "repo" or "env" | | | `--env-name ` | gh-actions: env name when --scope env | | | `--no-deploy` | write and verify the target, but skip the platform deploy/redeploy (target mode) | | +| `--replace-sensitive` | vercel: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking | | | `--json` | describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it | | -| `--discover` | dokploy: find the services that match Capy projects and print JSON (never prompts) | | -| `--plan ` | dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`) | | -| `--confirm ` | dokploy --discover: write the plan that --dry-run printed (one PR per repo) | | +| `--discover` | dokploy/vercel: find the services that match Capy projects and print JSON (never prompts) | | +| `--plan ` | dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`) | | +| `--confirm ` | dokploy/vercel --discover: write the plan that --dry-run printed (one PR per repo) | | | `--base-url ` | dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL) | | | `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | | `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | @@ -728,13 +729,15 @@ capy connect [provider] [options] | `--base-url ` | dokploy import: dashboard URL | | | `--application ` | dokploy import: Application id (mutually exclusive with --compose) | | | `--compose ` | dokploy import: Compose service id (mutually exclusive with --application) | | -| `--token-env ` | dokploy import: env var holding the API token | | +| `--token-env ` | dokploy/vercel import: env var holding the API token | | +| `--project ` | vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision) | | +| `--team ` | vercel import/discover: Vercel team slug or id (default: the token's only team) | | | `--json` | emit machine-readable JSON instead of the human UI (import connectors) | | | `--dry-run` | dokploy import/discover: preview the plan only — resolve settings + read Dokploy, write/push nothing | | -| `--discover` | dokploy: find every Dokploy service matching a repo under cwd, instead of one named --application/--compose | | +| `--discover` | dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project | | | `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask) — discover never prompts and needs --yes to write | | | `--overwrite` | dokploy import: set the branch's vars to EXACTLY Dokploy's set — clear names not in Dokploy, replace differing values, import new ones | | -| `--environment ` | dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview) | | +| `--environment ` | dokploy/vercel discover: restrict the plan to these environment names, comma-separated (e.g. staging,preview); vercel import: which Vercel environment to read (default: the current branch's name) | | | `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | | `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | @@ -845,6 +848,72 @@ JSON Schemas of the files commands read, as published under `schemas` in `capy h } ``` +### `deploy_vercel_plan` + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "capy deploy vercel --discover plan", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "entries" + ], + "properties": { + "version": { + "const": 1 + }, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "project_id", + "branch", + "service_id", + "git_branch", + "vars" + ], + "properties": { + "project_id": { + "type": "string", + "minLength": 1, + "description": "Capy project id" + }, + "branch": { + "type": "string", + "minLength": 1, + "description": "Capy branch the target ships from" + }, + "service_id": { + "type": "string", + "minLength": 1, + "description": "Vercel environment id from the discovery facts (a project plus one of its environments)" + }, + "git_branch": { + "type": "string", + "minLength": 1, + "description": "git branch the Vercel project deploys from (the CI deploy PR base)" + }, + "vars": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "description": "Variable names the target delivers; each must exist on the Capy branch" + } + } + } + } + } +} +``` + ## Error codes Every refusal carries a stable `code` — branch on it, never on message text. @@ -931,6 +1000,50 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `CI_DEPLOY_TARGETS_RECORD_FAILED` - `DEPLOY_STALE_KEEP` - `DEPLOY_TOKEN_UNTRACKED` +- `VERCEL_TOKEN_MISSING` +- `VERCEL_CONNECTOR_KEY_MISSING` +- `VERCEL_AUTH_FAILED` +- `VERCEL_API_ERROR` +- `VERCEL_RATE_LIMITED` +- `VERCEL_TEAM_REQUIRED` +- `VERCEL_TEAM_NOT_FOUND` +- `VERCEL_PROJECT_REQUIRED` +- `VERCEL_PROJECT_NOT_FOUND` +- `VERCEL_ENVIRONMENT_REQUIRED` +- `VERCEL_ENVIRONMENT_NOT_FOUND` +- `VERCEL_CONFIRMATION_REQUIRED` +- `VERCEL_MAPPING_COLLISION` +- `VERCEL_TARGET_KEY_MISSING` +- `VERCEL_TOKEN_REJECTED` +- `VERCEL_NO_ACCESS` +- `VERCEL_NETWORK_ERROR` +- `VERCEL_VERIFY_MISMATCH` +- `VERCEL_OPTIONS_INVALID` +- `VERCEL_SPLIT_NOT_RESTORED` +- `VERCEL_URL_INVALID` +- `VERCEL_SENSITIVE_REPLACE_UNCONFIRMED` +- `VERCEL_PROJECT_NOT_GITHUB` +- `VERCEL_ENV_LIST_TRUNCATED` +- `VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE` +- `CAPY_VALUE_KEPT` +- `CONNECTOR_PUSH_FAILED` +- `CONNECTOR_FOLDER_NOT_INITIALIZED` +- `CONNECTOR_PROJECT_LOOKUP_FAILED` +- `CONNECTOR_PROJECT_NAME_EXISTS` +- `CONNECTOR_PROJECT_NOT_FOUND` +- `CONNECTOR_INIT_FAILED` +- `CONNECTOR_PROJECT_NAME_TOO_LONG` +- `CONNECTOR_KEY_RESOLUTION_FAILED` +- `CONNECTOR_FOLDER_DIRTY` +- `CONNECTOR_BRANCH_PROTECTED` +- `CONNECTOR_KEEP_MISSING` +- `CONNECTOR_CHECKOUT_FAILED` +- `CONNECTOR_REMOTE_UNREADABLE` +- `CONNECTOR_COMMIT_WOULD_MIX` +- `CONNECTOR_COMMIT_BRANCH_INVALID` +- `CONNECTOR_COMMIT_BRANCH_EXISTS` +- `CONNECTOR_GIT_IDENTITY_MISSING` +- `CONNECTOR_COMMIT_FAILED` - `EDIT_NEEDS_TTY` - `EDIT_STDIN_LOCAL_ONLY` - `STDIN_EMPTY` diff --git a/package.json b/package.json index cff19a88..03d3ce89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.13", + "version": "0.9.14", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": { diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index ff3cc3a2..7eb482a4 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -9,6 +9,7 @@ import { refuseBadPrFlags, reportKeepLockHuman, runKeepLockPrStep, + type KeepLockCommandName, type PrFlags, } from './keepLockPr'; @@ -74,6 +75,93 @@ async function writeInOrder( return writeInOrder({ ...ctx, localPlaintext: { ...ctx.localPlaintext, [name]: value } }, rest, push); } +/** What the prompted set needs beyond the flags: who is asking, and what a dry run says. */ +export interface PromptedSetArgs { + readonly names: readonly string[]; + readonly opts: AddOpts; + readonly devMode: boolean; + /** Which command is writing: named in the PR body. */ + readonly command: KeepLockCommandName; + /** The `DRY_RUN_UNSUPPORTED` sentence for this command. */ + readonly dryRunMessage: string; +} + +/** + * The hidden terminal prompt and the one write path behind it, shared by `capy add NAME` + * and `capy edit NAME` on a terminal. Asks for each value (masked), writes, syncs, runs the + * keep.lock PR step. With `opts.force` an existing name is overwritten without a question. + */ +export async function runPromptedSet(args: PromptedSetArgs): Promise { + const { names, opts, devMode, command, dryRunMessage } = args; + + // The prompts would really save: a dry run never runs for real. Before any prompt. + if (opts.dryRun === true) return refuseDryRunUnsupported(opts.json === true, dryRunMessage); + + if (names.length === 0) { + throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT); + } + for (const name of names) { + if (!VAR_RE.test(name)) { + throw new CapyError(`"${name}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT); + } + } + + // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). + const gate = gateOptionsOf(opts, devMode); + conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); + await gateFirst(gate); + + const ctx = await resolveContext({ devMode, gate }); + const push = opts.noPush !== true; + + const existing = names.filter((n) => n in ctx.localPlaintext); + if (existing.length > 0 && !opts.force && !opts.nonTty) { + const inquirer = (await import('inquirer')).default; + const { ok } = await inquirer.prompt([ + { type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false }, + ]); + if (!ok) { + console.log('Aborted.'); + return; + } + } + + const savedNames = await collectViaTerminal(ctx, names, opts, push); + + const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)'; + console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`); + + const outcome = await runKeepLockPrStep({ + command, + cwd: process.cwd(), + records: recordsForWrite(ctx.keep, ctx.pm.readKeepFile(), ctx.branch, savedNames), + localKeep: ctx.keep, + flags: opts.pr ?? {}, + json: opts.json === true, + nonTty: opts.nonTty, + }); + reportKeepLockHuman(outcome, { successTo: 'stdout', noteUnanswered: false }); +} + +/** The hidden terminal prompt, one value per name. */ +async function collectViaTerminal( + ctx: ResolvedContext, + names: readonly string[], + opts: AddOpts, + push: boolean, +): Promise { + if (opts.nonTty) { + throw new CapyError( + // COPY-FLAG + 'Non-interactive add reads the value from stdin: | capy add NAME', + ERROR_CODES.INVALID_FORMAT, + ); + } + const pairs = await promptForValues(names); + await writeInOrder(ctx, pairs, push); + return pairs.map((p) => p.name); +} + export class AddCommand { constructor(private readonly devMode: boolean = false) {} @@ -86,53 +174,13 @@ export class AddCommand { // ` | capy add NAME`: the value comes from stdin and nothing prompts. if (isPipedAdd()) return this.executePiped(names, opts); - // The prompts would really save: a dry run never runs for real. Before any prompt. - if (opts.dryRun === true) return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.add); - - if (names.length === 0) { - throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT); - } - for (const name of names) { - if (!VAR_RE.test(name)) { - throw new CapyError(`"${name}" is not a valid environment variable name.`, ERROR_CODES.INVALID_FORMAT); - } - } - - // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). - const gate = gateOptionsOf(opts, this.devMode); - conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); - await gateFirst(gate); - - const ctx = await resolveContext({ devMode: this.devMode, gate }); - const push = opts.noPush !== true; - - const existing = names.filter((n) => n in ctx.localPlaintext); - if (existing.length > 0 && !opts.force && !opts.nonTty) { - const inquirer = (await import('inquirer')).default; - const { ok } = await inquirer.prompt([ - { type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false }, - ]); - if (!ok) { - console.log('Aborted.'); - return; - } - } - - const savedNames = await this.collectViaTerminal(ctx, names, opts, push); - - const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)'; - console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`); - - const outcome = await runKeepLockPrStep({ + return runPromptedSet({ + names, + opts, + devMode: this.devMode, command: 'add', - cwd: process.cwd(), - records: recordsForWrite(ctx.keep, ctx.pm.readKeepFile(), ctx.branch, savedNames), - localKeep: ctx.keep, - flags: opts.pr ?? {}, - json: opts.json === true, - nonTty: opts.nonTty, + dryRunMessage: DRY_RUN_UNSUPPORTED_MESSAGES.add, }); - reportKeepLockHuman(outcome, { successTo: 'stdout', noteUnanswered: false }); } /** ` | capy add NAME`. One name, one pipe; an existing name needs `--force`. */ @@ -174,23 +222,4 @@ export class AddCommand { : undefined, }); } - - /** The hidden terminal prompt, one value per name. */ - private async collectViaTerminal( - ctx: ResolvedContext, - names: readonly string[], - opts: AddOpts, - push: boolean, - ): Promise { - if (opts.nonTty) { - throw new CapyError( - // COPY-FLAG - 'Non-interactive add reads the value from stdin: | capy add NAME', - ERROR_CODES.INVALID_FORMAT, - ); - } - const pairs = await promptForValues(names); - await writeInOrder(ctx, pairs, push); - return pairs.map((p) => p.name); - } } diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index dd66202a..aedb0ed5 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,5 +1,7 @@ import { resolveContext, writeAndSync, writeImportOutcome, ResolvedContext } from './connectors/shared'; -import { listProviders, loadProvider, ConnectOpts, ConnectorModule } from './connectors/registry'; +import { keptReport, placeholderSetCommand, placeholdersReport } from '../core/placeholder'; +import { listProviders, loadProvider, ConnectFailure, ConnectOpts, ConnectorModule } from './connectors/registry'; +import { formatKeyHelp } from '../deploy/keyHelp'; import { EXIT_NEEDS_INPUT, isInteractive } from '../ui/interactive'; import { resolveOrgContext } from '../core/orgContext'; import { conflictFlagsOrExit } from '../sync/conflicts'; @@ -8,6 +10,32 @@ import type { DiscoveryContext } from './connectors/dokployDiscovery'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; +/** + * A refusal, in the terminal or as one JSON object. `link` + `remedy` (where to + * get a missing key, and what to do) and `unanswered` are added only when the + * connector set them. Human text goes to stderr, never stdout. + */ +function printFailure(outcome: ConnectFailure, json: boolean): void { + if (json) { + console.log( + JSON.stringify({ + ok: false, + code: outcome.code, + message: outcome.message, + ...(outcome.unanswered === undefined ? {} : { unanswered: outcome.unanswered }), + ...(outcome.link ? { link: outcome.link } : {}), + ...(outcome.remedy ? { remedy: outcome.remedy } : {}), + }), + ); + return; + } + console.error(`\n ${outcome.message}`); + // The refusal says what is still needed, as flags: print each hint beside it. + (outcome.unanswered ?? []).forEach((u) => console.error(` ${u.flag}: ${u.hint}`)); + console.error(''); + if (outcome.remedy) console.error(formatKeyHelp({ remedy: outcome.remedy, lines: outcome.remedy.split('\n') }) + '\n'); +} + /** A refusal that only a flag (or a person) can answer exits `EXIT_NEEDS_INPUT`, so an agent branches on it; every other refusal exits 1. */ function exitCodeForRefusalCode(code: string): number { return code === 'DOKPLOY_SETTINGS_MISSING' ? EXIT_NEEDS_INPUT : 1; @@ -214,23 +242,12 @@ export class ConnectCommand { opts: ConnectOpts, ): Promise<{ linked: boolean }> { const outcome = await mod.import!(ctx, opts); + // "from Dokploy" / "from Vercel": omitted `displayName` is Dokploy, the + // only import connector that predates the field (its output is unchanged). + const label = mod.displayName ?? 'Dokploy'; if (!outcome.ok) { - if (opts.json) { - console.log( - JSON.stringify({ - ok: false, - code: outcome.code, - message: outcome.message, - ...(outcome.unanswered === undefined ? {} : { unanswered: outcome.unanswered }), - }), - ); - } else { - console.error(`\n ${outcome.message}`); - // The refusal says what is still needed, as flags: print each hint beside it. - (outcome.unanswered ?? []).forEach((u) => console.error(` ${u.flag}: ${u.hint}`)); - console.error(''); - } + printFailure(outcome, !!opts.json); process.exitCode = exitCodeForRefusalCode(outcome.code); return { linked: false }; } @@ -247,7 +264,9 @@ export class ConnectCommand { const { wrote } = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }); const pushed = !opts.noPush && wrote; const cleared = outcome.cleared ?? []; - const hasChange = outcome.imported.length > 0 || cleared.length > 0; + const placeholders = outcome.placeholders ?? []; + const kept = outcome.kept ?? []; + const hasChange = outcome.imported.length > 0 || cleared.length > 0 || placeholders.length > 0; if (opts.json) { console.log( @@ -264,6 +283,10 @@ export class ConnectCommand { unchanged: outcome.unchanged, ...(outcome.wouldAsk ? { wouldAsk: outcome.wouldAsk } : {}), skipped: outcome.skipped, + // Names Vercel has but will not give back: recorded with no value (names and codes only). + ...(outcome.placeholders ? { placeholders: placeholdersReport(outcome.placeholders) } : {}), + // Names Vercel will not give back where Capy already has a value: left untouched (names and codes only). + ...(outcome.kept ? { kept: keptReport(outcome.kept) } : {}), warnings: outcome.warnings, pushed, }), @@ -277,7 +300,7 @@ export class ConnectCommand { console.log(dryRun ? ' Nothing to import.' : ' Nothing new to import.'); } else if (dryRun) { if (outcome.imported.length > 0) { - console.log(` Would import ${outcome.imported.map((e) => B(e.varName)).join(', ')} from Dokploy.`); + console.log(` Would import ${outcome.imported.map((e) => B(e.varName)).join(', ')} from ${label}.`); } if (cleared.length > 0) { console.log(` Would clear: ${cleared.join(', ')}`); @@ -285,11 +308,11 @@ export class ConnectCommand { } else { if (outcome.imported.length > 0) { console.log( - ` ✓ Imported ${outcome.imported.map((e) => B(e.varName)).join(', ')} from Dokploy` + + ` ✓ Imported ${outcome.imported.map((e) => B(e.varName)).join(', ')} from ${label}` + `${pushed ? ' and pushed' : ' (not pushed)'}.`, ); - } else { - console.log(` ✓ Cleared from Dokploy${pushed ? ' and pushed' : ' (not pushed)'}.`); + } else if (cleared.length > 0) { + console.log(` ✓ Cleared from ${label}${pushed ? ' and pushed' : ' (not pushed)'}.`); } if (cleared.length > 0) { console.log(` Cleared: ${cleared.join(', ')}`); @@ -304,6 +327,15 @@ export class ConnectCommand { if (outcome.wouldAsk && outcome.wouldAsk.length > 0) { console.log(` Conflicts (would ask): ${outcome.wouldAsk.join(', ')}`); } + if (placeholders.length > 0) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` ${dryRun ? 'Would record' : 'Recorded'} with no value yet: ${placeholders.map((p) => `${p.name} (${p.code})`).join(', ')}`); + console.log(` Set one with: ${placeholderSetCommand('NAME')}`); // COPY-FLAG + } + if (kept.length > 0) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` Kept the Capy value for ${kept.length} name(s) Vercel will not return: ${kept.map((k) => `${k.name} (${k.reason})`).join(', ')}`); + } if (outcome.skipped.length > 0) { console.log(` Skipped: ${outcome.skipped.map((s) => `${s.name} (${s.code})`).join(', ')}`); } @@ -328,22 +360,14 @@ export class ConnectCommand { const outcome = await mod.discover!(ctx, opts); if (!outcome.ok) { - if (opts.json) { - console.log( - JSON.stringify({ - ok: false, - code: outcome.code, - message: outcome.message, - ...(outcome.unanswered === undefined ? {} : { unanswered: outcome.unanswered }), - }), - ); - } else { - console.error(`\n ${outcome.message}\n`); - } + printFailure(outcome, !!opts.json); process.exitCode = exitCodeForRefusalCode(outcome.code); return { linked: false }; } + // A connector whose plan has its own shape (Vercel) reports it itself. + if (mod.reportDiscovery) return mod.reportDiscovery(outcome, opts, provider); + const planForOutput = { ...(outcome.plan.environmentFilter ? { environmentFilter: outcome.plan.environmentFilter } : {}), folders: outcome.plan.folders.map((f) => ({ @@ -398,6 +422,8 @@ export class ConnectCommand { ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, + ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), + ...(e.outcome.kept ? { kept: keptReport(e.outcome.kept) } : {}), warnings: e.outcome.warnings, })), } @@ -418,6 +444,8 @@ export class ConnectCommand { ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), unchanged: e.outcome.unchanged, skipped: e.outcome.skipped, + ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), + ...(e.outcome.kept ? { kept: keptReport(e.outcome.kept) } : {}), warnings: e.outcome.warnings, })), }, @@ -432,7 +460,7 @@ export class ConnectCommand { const linked = !!outcome.applied && outcome.applied.some((f) => - f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0), + f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0 || (e.outcome.placeholders?.length ?? 0) > 0), ); // A per-folder failure still lets the OTHER folders' steps run (see // `runDiscoverySequences`) — but the run as a whole did not fully diff --git a/src/commands/connectors/discoveryCodes.ts b/src/commands/connectors/discoveryCodes.ts new file mode 100644 index 00000000..b70c2ba2 --- /dev/null +++ b/src/commands/connectors/discoveryCodes.ts @@ -0,0 +1,84 @@ +/** + * The refusal codes of the steps every connector's `--discover` shares + * (`discoverySteps.ts`, `dokployDiscovery.ts#findCandidateReposResult`, + * `git/discoveryCommit.ts`). + * + * Each step is the same for every provider, but a provider decides how its + * codes are spelled: Dokploy keeps the `DOKPLOY_*` spellings it has always + * reported (its output does not change), and every other provider reports the + * provider-neutral `CONNECTOR_*` ones. The table is passed in; a code is only + * ever compared, never parsed. + */ +import { ERROR_CODES } from '../../types/index'; + +export interface DiscoveryCodes { + /** + * Whether a typed error from the service (for example `PERMISSION_DENIED`) that stops a folder's upload keeps its own code, + * instead of becoming `pushFailed`. Dokploy has always reported `pushFailed` for it, so it stays `false` there. + */ + readonly keepsServiceCode: boolean; + readonly pushFailed: string; + readonly folderNotInitialized: string; + readonly projectLookupFailed: string; + readonly projectNameExists: string; + readonly projectNotFound: string; + readonly initFailed: string; + readonly projectNameTooLong: string; + readonly keyResolutionFailed: string; + readonly folderDirty: string; + readonly branchProtected: string; + readonly keepMissing: string; + readonly checkoutFailed: string; + readonly remoteUnreadable: string; + readonly commitWouldMix: string; + readonly commitBranchInvalid: string; + readonly commitBranchExists: string; + readonly gitIdentityMissing: string; + readonly commitFailed: string; +} + +/** Dokploy's spellings: exactly the strings its discovery has always reported. */ +export const DOKPLOY_DISCOVERY_CODES: DiscoveryCodes = { + keepsServiceCode: false, + pushFailed: 'DOKPLOY_PUSH_FAILED', + folderNotInitialized: 'DOKPLOY_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'DOKPLOY_PROJECT_LOOKUP_FAILED', + projectNameExists: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNotFound: 'DOKPLOY_PROJECT_NOT_FOUND', + initFailed: 'DOKPLOY_INIT_FAILED', + projectNameTooLong: 'DOKPLOY_PROJECT_NAME_TOO_LONG', + keyResolutionFailed: 'DOKPLOY_KEY_RESOLUTION_FAILED', + folderDirty: 'DOKPLOY_FOLDER_DIRTY', + branchProtected: 'DOKPLOY_BRANCH_PROTECTED', + keepMissing: 'DOKPLOY_KEEP_MISSING', + checkoutFailed: 'DOKPLOY_CHECKOUT_FAILED', + remoteUnreadable: 'DOKPLOY_REMOTE_UNREADABLE', + commitWouldMix: 'DOKPLOY_COMMIT_WOULD_MIX', + commitBranchInvalid: 'DOKPLOY_COMMIT_BRANCH_INVALID', + commitBranchExists: 'DOKPLOY_COMMIT_BRANCH_EXISTS', + gitIdentityMissing: 'DOKPLOY_GIT_IDENTITY_MISSING', + commitFailed: 'DOKPLOY_COMMIT_FAILED', +}; + +/** The provider-neutral spellings, for every provider other than Dokploy. */ +export const CONNECTOR_DISCOVERY_CODES: DiscoveryCodes = { + keepsServiceCode: true, + pushFailed: ERROR_CODES.CONNECTOR_PUSH_FAILED, + folderNotInitialized: ERROR_CODES.CONNECTOR_FOLDER_NOT_INITIALIZED, + projectLookupFailed: ERROR_CODES.CONNECTOR_PROJECT_LOOKUP_FAILED, + projectNameExists: ERROR_CODES.CONNECTOR_PROJECT_NAME_EXISTS, + projectNotFound: ERROR_CODES.CONNECTOR_PROJECT_NOT_FOUND, + initFailed: ERROR_CODES.CONNECTOR_INIT_FAILED, + projectNameTooLong: ERROR_CODES.CONNECTOR_PROJECT_NAME_TOO_LONG, + keyResolutionFailed: ERROR_CODES.CONNECTOR_KEY_RESOLUTION_FAILED, + folderDirty: ERROR_CODES.CONNECTOR_FOLDER_DIRTY, + branchProtected: ERROR_CODES.CONNECTOR_BRANCH_PROTECTED, + keepMissing: ERROR_CODES.CONNECTOR_KEEP_MISSING, + checkoutFailed: ERROR_CODES.CONNECTOR_CHECKOUT_FAILED, + remoteUnreadable: ERROR_CODES.CONNECTOR_REMOTE_UNREADABLE, + commitWouldMix: ERROR_CODES.CONNECTOR_COMMIT_WOULD_MIX, + commitBranchInvalid: ERROR_CODES.CONNECTOR_COMMIT_BRANCH_INVALID, + commitBranchExists: ERROR_CODES.CONNECTOR_COMMIT_BRANCH_EXISTS, + gitIdentityMissing: ERROR_CODES.CONNECTOR_GIT_IDENTITY_MISSING, + commitFailed: ERROR_CODES.CONNECTOR_COMMIT_FAILED, +}; diff --git a/src/commands/connectors/discoverySteps.ts b/src/commands/connectors/discoverySteps.ts new file mode 100644 index 00000000..31e99152 --- /dev/null +++ b/src/commands/connectors/discoverySteps.ts @@ -0,0 +1,886 @@ +/** + * The discovery steps every connector's `--discover` shares (`capy connect + * dokploy --discover`, `capy connect vercel --discover`): bare `capy` in each + * folder, init or pick the folder's project, the dirty-folder guard, checkout + * of each environment's branch, the per-environment import and its write, and + * the one discovery commit per repo. They were written for Dokploy first and + * lived in `dokploy.ts`; they are moved here unchanged except that nothing in + * them names a provider: + * + * - the refusal codes come in as a `DiscoveryCodes` table (`discoveryCodes.ts`): + * Dokploy passes its own `DOKPLOY_*` spellings, so its output is exactly what + * it was; the other providers pass the neutral `CONNECTOR_*` ones; + * - the options of each step's import come from the provider (`importOptsFor`). + * + * The sequence itself (`runDiscoverySequences`) and the plan types live in + * `dokployDiscovery.ts`. + */ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { CapyError, ERROR_CODES, KeepFile } from '../../types/index'; +import { isStaleBase } from '../../sync/commit'; +import { ProjectManager } from '../../core/projectManager'; +import { FileManager } from '../../files/fileManager'; +import { resolveProjectKey } from '../../crypto/keyResolver'; +import { SyncEngine } from '../../sync/syncEngine'; +import { assertProjectNameAllowed, isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../../system/reservedProjectName'; +import { findDirtyBranchIssue } from '../checkoutCommand'; +import { listOrgProjectsOrUnavailable } from '../capyCommand'; +import { commitDiscoveryChanges, isValidDiscoveryCommitBranchName } from '../../git/discoveryCommit'; +import type { FolderSync, GateOptions } from '../../sync/gate'; +import { isConflictRefusal } from '../../sync/conflicts'; +import { writeImportOutcome, ResolvedContext } from './shared'; +import { ConnectOpts, ImportOutcome } from './registry'; +import { discoveryProjectQuestion } from '../../ui/projectQuestions'; +import type { DiscoveryCodes } from './discoveryCodes'; +import { + DISCOVERY_PROJECT_NAME_MAX_LENGTH, + DiscoveryCollision, + DiscoveryContext, + DiscoveryFolderResult, + DiscoveryPlanEnv, + DiscoveryPlanFolder, + DiscoveryRepoCommitResult, + DiscoverySequenceDeps, + GitRemoteRef, + defaultDiscoveryProjectName, +} from './dokployDiscovery'; + +export async function defaultConfirm(message: string, defaultValue: boolean): Promise { + const inquirer = (await import('inquirer')).default; + const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: defaultValue }]); + return ok; +} + +/** Sentinel `askExistingOrNewProject` returns to mean "create a new project", never a real project id. */ +export const DISCOVERY_NEW_PROJECT = '__capy_discovery_new_project__'; + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export async function defaultAskExistingOrNewProject( + existing: ReadonlyArray<{ id: string; name: string }>, + defaultName: string, +): Promise { + const inquirer = (await import('inquirer')).default; + const { projectChoice } = await inquirer.prompt([ + discoveryProjectQuestion(existing, defaultName, DISCOVERY_NEW_PROJECT), + ]); + return projectChoice; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +/** + * Validates a discovery project-name answer: non-empty, at most + * `DISCOVERY_PROJECT_NAME_MAX_LENGTH` chars, not the reserved `_system` name + * (Vince, 2026-09-26, decision #4). Deliberately no charset restriction — + * `/` (and anything else `defaultDiscoveryProjectName` might produce) must + * stay allowed here; that's a separate concern from this ticket's scope. + */ +function discoveryProjectNameProblem(trimmed: string): string | null { + if (!trimmed) return 'A project name is required.'; + if (trimmed.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { + return `Project name must be ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters or fewer.`; + } + if (isReservedProjectName(trimmed)) return PROJECT_NAME_RESERVED_MESSAGE; + return null; +} + +export async function defaultAskDiscoveryProjectName(defaultName: string): Promise { + const inquirer = (await import('inquirer')).default; + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'New Capy project name:', + default: defaultName, + filter: (v: string) => v.trim(), + // Re-asks automatically (inquirer's own validate loop) rather than + // ever reaching `ensureProjectSafe` with a name it would refuse + // the project-name-too-long refusal / `PROJECT_NAME_RESERVED` for. + validate: (v: string) => discoveryProjectNameProblem(v.trim()) ?? true, + }, + ]); + return name; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export async function defaultAskDiscoveryCommitBranchName(defaultName: string): Promise { + const inquirer = (await import('inquirer')).default; + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'Commit the import to which git branch:', + default: defaultName, + filter: (v: string) => v.trim(), + // Re-asks automatically (inquirer's own validate loop) rather than + // ever reaching `commitDiscoveryChanges` with a name it would refuse + // the commit-branch-invalid refusal for — that refusal is still the + // authoritative, zero-mutation-guaranteed check for every OTHER + // path into this function (non-interactive, an injected test dep). + validate: (v: string) => (isValidDiscoveryCommitBranchName(v.trim()) ? true : 'Not a valid git branch name.'), + }, + ]); + return name; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export async function defaultPickCollisionWinner(collision: DiscoveryCollision): Promise { + const inquirer = (await import('inquirer')).default; + const { winner } = await inquirer.prompt([ + { + type: 'list', + name: 'winner', + message: `${collision.folder || '.'} / ${collision.environmentName}: which service feeds this branch?`, + choices: collision.candidates.map((c) => ({ name: `${c.projectName} / ${c.serviceName}`, value: c.serviceId })), + }, + ]); + return winner; +} + +// ── --overwrite ────────────────────────────────────────────────────────────── + +export interface OverwritePlan { + /** Local names the provider no longer has, and that aren't a reference value — removed entirely. */ + toClear: readonly string[]; + /** Present in both with a DIFFERENT value — overwritten with the provider's. */ + toReplace: ReadonlyArray<{ name: string; value: string }>; + /** Only at the provider — a plain new write. */ + toImport: ReadonlyArray<{ name: string; value: string }>; + /** Present in both with the SAME value — no-op. */ + unchanged: readonly string[]; +} + +/** + * `--overwrite`'s own rule: the branch's vars become EXACTLY the provider's + * importable set. `localPlaintext` names absent from the provider's set are + * cleared — EXCEPT a reference-valued name (`${{...}}`, `referenceNames`): + * there is no resolved value to replace it with, so it is left alone rather + * than cleared (the caller's reference-value warning already says + * so). `sourceCandidates` must be the provider's FULL importable set, never one + * already narrowed by `--var` — narrowing it here would make a restricted + * overwrite clear a name the provider still has, just outside the restriction + * (see the call site in `import()`, which computes this from the + * unrestricted list on purpose). + */ +export function computeOverwritePlan( + sourceCandidates: ReadonlyArray<{ name: string; value: string }>, + referenceNames: readonly string[], + localPlaintext: Readonly>, +): OverwritePlan { + const sourceNames = new Set(sourceCandidates.map((c) => c.name)); + const referenceSet = new Set(referenceNames); + return { + toClear: Object.keys(localPlaintext).filter((k) => !sourceNames.has(k) && !referenceSet.has(k)), + toReplace: sourceCandidates.filter((c) => localPlaintext[c.name] !== undefined && localPlaintext[c.name] !== c.value), + toImport: sourceCandidates.filter((c) => localPlaintext[c.name] === undefined), + unchanged: sourceCandidates.filter((c) => localPlaintext[c.name] === c.value).map((c) => c.name), + }; +} + +/** Plan-time-only local keep.lock read (no auth) — see `PeekLocalKeep`'s own doc. */ +export function peekLocalKeepForFolder(repoDir: string, folder: string): KeepFile | null { + return new ProjectManager(folder ? join(repoDir, folder) : repoDir).readKeepFile(); +} + +const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); + +/** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ +export function discoveryCommitPathsFor(folder: string): readonly string[] { + const prefix = folder ? `${folder}/` : ''; + return [`${prefix}keep.lock`, `${prefix}.gitignore`]; +} + +/** + * Every REPO the plan touches, and the branch/files a real run WOULD commit + * for it — dry-run preview only, never runs a git-mutating command (see + * `commitDiscoveryChanges`'s own `dryRun` handling). + * + * `paths` is de-duplicated per repo (cosmetic defect fix): two DISTINCT + * `DiscoveryPlanFolder` entries can share the same (repoDir, folder) — e.g. + * two colliding services that both resolve to the same folder, still shown + * as separate candidates in the pre-resolution `plan.folders` a dry run + * previews (a dry run never asks for a collision winner) — and + * `discoveryCommitPathsFor` would otherwise be counted once per entry, + * reporting `keep.lock`/`.gitignore` twice for what is really one write. + */ +export function commitPreviewForFolders( + folders: readonly DiscoveryPlanFolder[], + branchName: string, + /** How this provider spells the commit refusals. */ + codes: DiscoveryCodes, + /** The commit subject. */ + commitMessage?: string, +): readonly DiscoveryRepoCommitResult[] { + const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; + return repoDirs.map((repoDir) => { + const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; + const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [], codes, ...(commitMessage ? { commitMessage } : {}) }); + return { repoDir, ...outcome }; + }); +} + +/** + * The commit step's real run: per repo touched by `applied`, commits ONLY + * the SUCCESSFULLY-applied folders' keep.lock/.gitignore (see + * `commitDiscoveryChanges`'s own doc for the refusal codes) — a repo with + * zero successful folders is skipped entirely (nothing to commit, no + * refusal either). + */ +export function commitForAppliedRepos( + applied: readonly DiscoveryFolderResult[], + beforeStatusByRepo: ReadonlyMap>, + branchName: string, + /** How this provider spells the commit refusals. */ + codes: DiscoveryCodes, + /** The commit subject. */ + commitMessage?: string, +): readonly DiscoveryRepoCommitResult[] { + const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; + return repoDirs.flatMap((repoDir) => { + const okFolders = applied.filter((f) => f.repoDir === repoDir && f.ok); + if (okFolders.length === 0) return []; + const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder)); + const summaryLines = okFolders.map((f) => `- ${f.folder || '.'}: ${(f as Extract).activeBranch}`); + const outcome = commitDiscoveryChanges(repoDir, paths, beforeStatusByRepo.get(repoDir) ?? new Set(), { + branchName, + dryRun: false, + summaryLines, + codes, + ...(commitMessage ? { commitMessage } : {}), + }); + return [{ repoDir, ...outcome }]; + }); +} + +/** What `listOrgProjectsOrUnavailable` returns — named here so the non-interactive `--yes` snapshot (see `ensureProjectSafe`'s doc) and the interactive per-folder lookup share one type. */ +export type ExistingProjectsSnapshot = { + existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; + projectsUnavailable: boolean; +}; + +/** A project name, normalized for EQUALITY comparison only (never for storage/display) — trimmed, then lower-cased, so `" Widgets "` and `widgets` compare equal (Vince, 2026-09-26 decision). */ +function normalizeProjectName(name: string): string { + return name.trim().toLowerCase(); +} + +/** The project-name-exists refusal's message — shared by the org-snapshot collision and the same-run collision below, since a human reads the same instruction either way. */ +// COPY-FLAG: new user-facing string, minimal/neutral wording. +function projectNameExistsMessage(folder: string, name: string): string { + return `${folder || '.'}: a project named "${name}" already exists — reuse or duplicate is a human choice; run interactively or \`capy\` in ${folder || '.'}.`; +} + +/** Stable per-(repoDir, folder) key — mirrors `dokployDiscovery.ts`'s own `groupKey` shape (`\u0000`-joined, so neither part can collide with the separator). */ +function sameRunFolderKey(repoDir: string, folder: string): string { + return `${repoDir}\u0000${folder}`; +} + +/** + * Pure, run-once precomputation of "two folders in this SAME run would try + * to create a project under the SAME default name" (Vince, 2026-09-26 + * decision) — computed from `resolvedFolders` alone, entirely before any + * folder's own `ensureProject` call runs, so no mutable "names created so + * far" state needs to be threaded through the sequential per-folder loop + * (`runDiscoverySequences`'s `ensureProject` dependency has no such + * accumulator parameter, and this file's own immutability rule forbids + * inventing one via a mutated `Set`/`Map`). + * + * Only folders WITHOUT a local keep.lock are candidates (`!f.initialized`) — + * an already-initialized folder never creates anything, so it can never + * collide with, or be collided into by, another folder's create. Folded over + * `folders` in their OWN array order — the exact order `runDiscoverySequences` + * processes them in — so "the first one wins" here means the same thing it + * means at run time. Returns the set of `sameRunFolderKey`s for every folder + * that is NOT the first candidate to claim its (normalized) default name; + * the very first claimant of a name is never in the returned set. + */ +export function computeSameRunNameCollisions(folders: readonly DiscoveryPlanFolder[]): ReadonlySet { + const candidates = folders + .filter((f) => !f.initialized) + .map((f) => ({ key: sameRunFolderKey(f.repoDir, f.folder), normalizedName: normalizeProjectName(defaultDiscoveryProjectName(f.repoDir, f.folder, f.remote)) })); + + return candidates.reduce( + (acc, c) => + acc.seenNames.has(c.normalizedName) + ? { seenNames: acc.seenNames, collisionKeys: new Set([...acc.collisionKeys, c.key]) } + : { seenNames: new Set([...acc.seenNames, c.normalizedName]), collisionKeys: acc.collisionKeys }, + { seenNames: new Set(), collisionKeys: new Set() }, + ).collisionKeys; +} + +/** + * No keep.lock at (repoDir, folder) → interactive: reuses `capy`'s own + * existing-project-picking (list the org's projects, ask new-vs-existing — + * see `defaultAskExistingOrNewProject`), never invents its own project flow. + * A keep.lock already there is just read for its project id — no network + * call. + * + * Non-interactive WITHOUT `--yes` refuses `codes.folderNotInitialized` + * naming the folder and never auto-creates — unchanged. + * + * Non-interactive WITH `--yes` (Vince, 2026-09-26 decision, SUPERSEDING the + * original "org has none" rule): creates a new project named `defaultName` + * UNLESS a project with that EXACT name (trimmed, compared case-insensitively + * — see `normalizeProjectName`) already exists — an UNRELATED existing + * project in the org (e.g. a stray empty `root`) no longer blocks anything. + * A same-name collision refuses `codes.projectNameExists`, naming both + * the project and the folder — reusing or duplicating an existing project is + * a genuine human choice with no safe default, same reasoning the old rule + * used for "org has ANY project", now narrowed to "org has THIS name". + * `opts.existingProjectsSnapshot` is this run's OWN snapshot (see + * `buildRealDiscoverySequenceDeps`'s doc): taken ONCE, before any folder in + * this run starts, so a project an EARLIER folder in this same run created + * is never counted as "existing" when deciding a LATER folder's own + * name-collision answer. A snapshot that failed to load is + * `codes.projectLookupFailed`, same as the interactive branch below and + * same as before this decision. + * + * The snapshot alone cannot catch two DIFFERENT folders in this SAME run + * both defaulting to the same name (neither one is "existing" yet when the + * snapshot was taken) — `opts.sameRunNameCollision` is `ensureProjectSafe`'s + * OWN pure precomputed answer to exactly that (see + * `computeSameRunNameCollisions`), and refuses the SAME + * `codes.projectNameExists` code for whichever folder is the SECOND (or + * later) occurrence in run order; the first occurrence proceeds normally. + * + * Deliberately NOT a call into `CapyCommand` itself: that class calls + * `process.exit()` on several failure paths and always builds its own + * `ProjectManager()`/`FileManager()` against `process.cwd()`, neither of + * which a multi-folder run can tolerate (one folder's exit would kill every + * other folder still to run, and there is no way to point it at a folder + * other than `cwd`). This reuses the SAME underlying `ServiceClient` calls + * (`listProjects`, `initializeProject`) and mirrors `capyCommand.ts`'s own + * `bootstrapExistingProject` for the existing-project branch (see + * `bootstrapExistingProjectSafe`, below), scoped to one folder and returning + * a coded outcome instead of exiting. + */ +async function ensureProjectSafe( + ctx: DiscoveryContext, + repoDir: string, + folder: string, + /** This folder's repo's own parsed `origin`, when known — see `defaultDiscoveryProjectName`'s decision #2 doc; passed through rather than re-read here. */ + remote: GitRemoteRef | undefined, + opts: { + interactive: boolean; + /** Discovery's OWN `--yes` — skips the new-project NAME prompt (defaulting to `defaultName`) the same way it skips every other confirmation, never the existing-vs-new picker itself (that one isn't a confirmation, it's a genuine choice with no safe default) — EXCEPT non-interactively, where a same-name collision has no choice left to make (see this function's own doc). */ + yes: boolean; + askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; + askProjectName: (defaultName: string) => Promise; + /** This run's ONE pre-apply snapshot of the org's existing projects — only consulted on the non-interactive `--yes` auto-create path; `undefined` there means the snapshot itself failed to load. */ + existingProjectsSnapshot?: ExistingProjectsSnapshot; + /** This folder's own precomputed answer to "does an EARLIER folder in this SAME run already claim this default name" (see `computeSameRunNameCollisions`) — only consulted on the non-interactive `--yes` auto-create path. */ + sameRunNameCollision: boolean; + /** How this provider spells the refusals. */ + codes: DiscoveryCodes; + }, +): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const existing = pm.readKeepFile(); + if (existing) { + return { ok: true, projectId: existing.project_id, created: false }; + } + const defaultName = defaultDiscoveryProjectName(repoDir, folder, remote); + + if (!opts.interactive) { + if (!opts.yes) { + return { + ok: false, + code: opts.codes.folderNotInitialized, + message: `${folder || '.'} has no keep.lock. Run \`capy\` there to initialize it, then re-run discovery.`, + }; + } + const snapshot = opts.existingProjectsSnapshot; + if (!snapshot || snapshot.projectsUnavailable) { + return { + ok: false, + code: opts.codes.projectLookupFailed, + message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, + }; + } + const sameNameProject = snapshot.existingProjects.find((p) => normalizeProjectName(p.name) === normalizeProjectName(defaultName)); + if (sameNameProject || opts.sameRunNameCollision) { + return { + ok: false, + code: opts.codes.projectNameExists, + message: projectNameExistsMessage(folder, defaultName), + }; + } + return createNewProjectSafe(ctx, path, folder, defaultName, opts.codes); + } + + // A lookup failure is NOT "this org has no projects" — offering only + // "create new" on a transient network/auth error risks a duplicate + // project the human never asked for. Abort THIS folder instead (a + // follow-up defect fix) — the SAME distinction `capy`'s own init makes + // (`listOrgProjectsOrUnavailable`, extracted from `capyCommand.ts`). + const { existingProjects, projectsUnavailable } = await listOrgProjectsOrUnavailable(ctx.serviceClient); + if (projectsUnavailable) { + return { + ok: false, + code: opts.codes.projectLookupFailed, + message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, + }; + } + const picked = await opts.askExistingOrNewProject(existingProjects.map((p) => ({ id: p.id, name: p.name })), defaultName); + + if (picked !== DISCOVERY_NEW_PROJECT) { + const project = existingProjects.find((p) => p.id === picked); + if (!project) { + return { ok: false, code: opts.codes.projectNotFound, message: `No project ${picked} in this org.` }; + } + try { + await bootstrapExistingProjectSafe(ctx, pm, new FileManager(path), project); + return { ok: true, projectId: project.id, created: false }; + } catch (err) { + return { ok: false, code: opts.codes.initFailed, message: err instanceof Error ? err.message : String(err) }; + } + } + // Mirrors `capyCommand.ts#resolveProjectName`: ask for the new project's + // name (default = `defaultName`). `--yes` skips the ask — same "proceed + // without asking" contract as every other discovery confirmation — and + // uses the default outright. + const chosenName = opts.yes ? defaultName : await opts.askProjectName(defaultName); + return createNewProjectSafe(ctx, path, folder, chosenName, opts.codes); +} + +/** + * Creates a brand-new project named `chosenName` and initializes (repoDir, + * folder) onto it — the exact write sequence the interactive "New project" + * choice runs (`ensureProjectSafe`'s own interactive branch), reused + * verbatim by the non-interactive `--yes` auto-create path (Vince, + * 2026-09-26 decision) so the two can never drift apart. + */ +async function createNewProjectSafe( + ctx: DiscoveryContext, + path: string, + folder: string, + chosenName: string, + codes: DiscoveryCodes, +): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { + // Vince, 2026-09-26, decision #4: refuse before any write when the name + // (default or human-typed) is over 255 chars — never truncated. The real + // `defaultAskDiscoveryProjectName` prompt validates this itself and + // re-asks (so an interactive human self-corrects before ever returning + // here); this is the zero-write guarantee for `--yes` and for any + // injected `askProjectName` test double that skips that validation. + if (chosenName.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { + return { + ok: false, + code: codes.projectNameTooLong, + message: `${folder || '.'}: project name is longer than ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters.`, + }; + } + try { + assertProjectNameAllowed(chosenName); + const initResult = await ctx.serviceClient.initializeProject(chosenName, ctx.orgId); + const keep: KeepFile = { + version: '3.0', + org_id: initResult.org_id, + project_id: initResult.project_id, + project_name: initResult.project_name, + variables: {}, + }; + // A discovered folder several levels deep (e.g. `backend/deployment`) + // may not exist yet under `repoDir` — `writeKeepFile` writes directly, + // with no directory creation of its own (unlike `writeEncryptedEnvFile`, + // which does). + mkdirSync(path, { recursive: true }); + const fm = new FileManager(path); + fm.writeKeepFile(keep); + fm.ensureCapyGitignore(); + return { ok: true, projectId: initResult.project_id, created: true }; + } catch (err) { + return { ok: false, code: codes.initFailed, message: err instanceof Error ? err.message : String(err) }; + } +} + +/** The entries of `encrypted` that `encryptionKey` decrypts; the rest (the person lacks variable-level permission) are skipped. */ +function decryptableEntries(fm: FileManager, encrypted: Readonly>, encryptionKey: string): Record { + return Object.fromEntries( + Object.entries(encrypted).flatMap(([k, v]): ReadonlyArray => { + try { + return [[k, fm.decryptValue(v, encryptionKey)]]; + } catch { + return []; + } + }), + ); +} + +/** + * Bootstrap an EXISTING project into (repoDir, folder) — mirrors + * `capyCommand.ts#bootstrapExistingProject` exactly (pull `development`'s + * keep.json + env blob, decrypt, write keep.lock + `.env`), scoped to one + * folder's own `pm`/`fm` rather than `process.cwd()`, and — unlike the + * original — branching on `err.details?.status === 404` alone rather than + * ALSO matching a `/No secrets/i` message (Rule 5: never branch on + * human-readable text; the status code alone already says "no snapshot"). + */ +async function bootstrapExistingProjectSafe( + ctx: DiscoveryContext, + pm: ProjectManager, + fm: FileManager, + project: { id: string; name: string; organization_id: string }, +): Promise { + const branch = 'development'; + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const encryptionKey = await resolveProjectKey(project.organization_id, project.id, ctx.userId, keyOps); + + const decryptData = await ctx.serviceClient.getDecryptData(project.id, branch, undefined, true).catch((err) => { + if ((err as { details?: { status?: number } })?.details?.status === 404) { + return { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }; + } + throw err; + }); + + const stub: KeepFile = { version: '3.0', org_id: project.organization_id, project_id: project.id, project_name: project.name, variables: {} }; + const serverKeep: KeepFile = decryptData.keep_file + ? { ...(JSON.parse(decryptData.keep_file) as KeepFile), org_id: project.organization_id, project_id: project.id, project_name: project.name } + : stub; + + const plaintext: Record = decryptData.env_content + ? decryptableEntries(fm, fm.parseEnvContent(decryptData.env_content), encryptionKey) + : {}; + + fm.writeKeepFile(serverKeep); + pm.writeActiveBranch(branch); + fm.ensureCapyGitignore(); + fm.writeEncryptedEnvFile(plaintext, encryptionKey, undefined, serverKeep, branch); +} + +/** + * The SAME dirty-working-tree guard `capy checkout` enforces + * (`checkoutCommand.ts#findDirtyBranchIssue`), run ONCE per folder before + * its FIRST checkout — never per environment, and never for a folder whose + * project was just created this run (nothing to be dirty about yet). A + * dirty folder aborts with zero writes; every later checkout in the SAME + * folder just switched away from a branch this run itself wrote, which is + * clean by construction, so re-checking per environment would be pure + * overhead for a result that can't change mid-run. + */ +async function checkFolderDirtySafe( + ctx: DiscoveryContext, + repoDir: string, + folder: string, + projectId: string, + codes: DiscoveryCodes, +): Promise<{ ok: true } | { ok: false; code: string; message: string }> { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const fm = new FileManager(path); + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const tryResolveKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { + try { + return { ok: true, value: await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps) }; + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + }; + const keyResult = await tryResolveKey(); + if (!keyResult.ok) { + return { ok: false, code: codes.keyResolutionFailed, message: keyResult.message }; + } + const issue = findDirtyBranchIssue(pm, fm, keyResult.value); + if (!issue) return { ok: true }; + const detail = issue.code === 'UNCOMMITTED_CHANGES' ? `an uncommitted change (${issue.varName})` : 'unpushed changes'; + return { + ok: false, + code: codes.folderDirty, + message: `${folder || '.'} has ${detail} on "${issue.branch}". Run \`capy\` there to push, or discard the change, then re-run discovery.`, + }; +} + +/** + * Switches (repoDir, folder) onto `branchName` — pulls it if it already + * exists on the server (same as `capy checkout `), or creates it, + * seeded from the folder's CURRENT `.env` (same as `capy checkout -b + * ` — the git-model copy), otherwise. Mirrors + * `checkoutCommand.ts#_execute`'s core — including its dirty-working-tree + * guard, run ONCE per folder before the first call to this function (see + * `DiscoverySequenceDeps.checkFolderDirty`; every LATER checkout in the same + * folder just switched away from a branch this run itself wrote, which is + * clean by construction) — scoped to ONE folder and never `process.exit`s; + * a failure comes back as `{ ok: false }` so the caller can abort just this + * folder. `createBranch(..., false)`: a discovery-created branch is never + * protected — there is no flow yet for discovery to ask "should this be + * invite-only", so it always defaults to the same open access every other + * branch this run creates gets. + */ +async function checkoutBranchSafe( + ctx: DiscoveryContext, + repoDir: string, + folder: string, + projectId: string, + branchName: string, + codes: DiscoveryCodes, +): Promise<{ ok: true; created: boolean } | { ok: false; code: string; message: string }> { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const fm = new FileManager(path); + try { + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const encryptionKey = await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps); + + const branches = await ctx.serviceClient.listBranches(projectId); + const created = !branches.some((b) => b.name === branchName); + if (created) { + await ctx.serviceClient.createBranch(projectId, branchName, false); + } + + const decryptResult = await (async (): Promise< + | { ok: true; data: Awaited> } + | { ok: false; code: string; message: string } + > => { + try { + return { ok: true, data: await ctx.serviceClient.getDecryptData(projectId, branchName, undefined, true) }; + } catch (err) { + const status = (err as { details?: { status?: number } })?.details?.status; + if (status === 404) { + return { ok: true, data: { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() } }; + } + if (status === 403) { + return { ok: false, code: codes.branchProtected, message: `"${branchName}" is a protected branch — access is invite-only.` }; + } + throw err; // an anomaly — let the outer catch report it as `codes.checkoutFailed`. + } + })(); + if (!decryptResult.ok) return decryptResult; + const decryptData = decryptResult.data; + + const existingKeep = pm.readKeepFile(); + if (!existingKeep) { + return { ok: false, code: codes.keepMissing, message: `${folder || '.'} has no keep.lock.` }; + } + // Splice in only THIS branch's server pins — keep.lock holds every + // branch's metadata and is git-owned; a sibling branch's pins must not + // move just because this one was checked out. + const keepForWrite: KeepFile = decryptData.keep_file + ? SyncEngine.spliceKeepBranch(existingKeep, JSON.parse(decryptData.keep_file) as KeepFile, branchName) + : existingKeep; + if (keepForWrite !== existingKeep) fm.writeKeepFile(keepForWrite); + + if (decryptData.env_content) { + const decrypted = decryptableEntries(fm, fm.parseEnvContent(decryptData.env_content), encryptionKey); + fm.writeEncryptedEnvFile(decrypted, encryptionKey, undefined, keepForWrite, branchName); + } else if (created) { + // Git model: a brand-new branch inherits the folder's CURRENT `.env` — + // the exact seed `capy checkout -b` performs. Unreadable current + // `.env` (or none yet) falls through to an empty seed. + const tryReadCurrentEnv = (): Record => { + try { + return fm.readEncryptedEnvFile(encryptionKey); + } catch { + return {}; + } + }; + fm.writeEncryptedEnvFile(tryReadCurrentEnv(), encryptionKey, undefined, keepForWrite, branchName); + } else { + // An EXISTING branch with no snapshot yet: stamp an empty file for it. + fm.writeEncryptedEnvFile({}, encryptionKey, undefined, keepForWrite, branchName); + } + + pm.writeActiveBranch(branchName); + return { ok: true, created }; + } catch (err) { + return { ok: false, code: codes.checkoutFailed, message: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Runs the single-service import itself (`importFn` — the very function + * `import()` above defines, reused directly rather than going through + * `ConnectCommand`: that class's `executeImport` prints its own terminal/ + * JSON lines as a side effect, which would corrupt discovery's OWN single + * JSON line under `--json`, and `resolveContext()` re-authenticates and + * re-reads `process.cwd()` rather than accepting an already-resolved + * folder + session) against the branch `checkoutBranchSafe` just switched + * onto, then writes the result via the SAME `writeImportOutcome` helper + * `executeImport` uses — same push, same skip-auto-commit, same + * clear-pruning for `--overwrite`. + */ +async function importIntoBranchSafe( + ctx: DiscoveryContext, + importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, + repoDir: string, + folder: string, + env: DiscoveryPlanEnv, + overwrite: boolean, + runOpts: { + noPush: boolean; + interactive: boolean; + json: boolean; + yes: boolean; + gate: GateOptions; + /** The provider supplies the per-step import options itself (its source, its token, the names already known from its plan). */ + importOptsFor: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; + /** How this provider spells the refusals. */ + codes: DiscoveryCodes; + }, + /** This environment's provider PROJECT name — already known from the plan's own read, threaded through so `import()` never re-fetches it. */ + projectName: string, +): Promise { + const path = folderPath(repoDir, folder); + const pm = new ProjectManager(path); + const fm = new FileManager(path); + + const projectState = await pm.detectProjectState(); + if (!projectState.initialized || !projectState.organizationId || !projectState.projectId || !projectState.activeBranch) { + return { ok: false, code: runOpts.codes.keepMissing, message: `${folder || '.'} is not on a branch after checkout.` }; + } + const keep = pm.readKeepFile(); + if (!keep) { + return { ok: false, code: runOpts.codes.keepMissing, message: `${folder || '.'} has no keep.lock.` }; + } + + const keyOps = { + coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }; + const tryResolveProjectKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { + try { + return { ok: true, value: await resolveProjectKey(projectState.organizationId!, projectState.projectId!, ctx.userId, keyOps) }; + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + }; + const projectKeyResult = await tryResolveProjectKey(); + if (!projectKeyResult.ok) { + return { ok: false, code: runOpts.codes.keyResolutionFailed, message: projectKeyResult.message }; + } + const projectKey = projectKeyResult.value; + + const localPlaintext: Record = Object.fromEntries( + Object.entries(fm.readEnvFile()).flatMap(([k, v]): ReadonlyArray => { + if (!v.startsWith('capy:')) return [[k, v]]; + try { + return [[k, fm.decryptValue(v, projectKey)]]; + } catch { + // Skip undecryptable. + return []; + } + }), + ); + + const resolvedCtx: ResolvedContext = { + pm, + fileManager: fm, + authService: ctx.authService, + serviceClient: ctx.serviceClient, + orgId: projectState.organizationId, + projectId: projectState.projectId, + branch: projectState.activeBranch, + userId: ctx.userId, + projectKey, + keep, + localPlaintext, + // This folder's own gate: if Capy refuses the upload as stale, bare `capy` runs again IN THIS FOLDER and the write retries. + gate: { ...runOpts.gate, cwd: path, throwConflicts: true }, + }; + const sharedImportOpts = { + overwrite, + noPush: runOpts.noPush, + nonTty: !runOpts.interactive, + json: runOpts.json, + yes: runOpts.yes, + }; + const importOpts: ConnectOpts = { ...sharedImportOpts, ...runOpts.importOptsFor(env, projectName, path) }; + + const outcome = await importFn(resolvedCtx, importOpts); + if (!outcome.ok) return outcome; + + // A push failure here must become THIS folder's coded failure, never an + // uncaught rejection — `runDiscoverySequences` awaits one folder's + // sequence at a time, so letting this throw would abort every folder + // still queued behind this one, not just this one. + try { + await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); + } catch (err) { + // A refused-as-stale upload keeps its own code; anything else is the push failing. + return { ok: false, code: pushFailureCode(err, runOpts.codes), message: err instanceof Error ? err.message : String(err) }; + } + return outcome; +} + +/** + * The coded reason a folder's upload failed: a difference found by the retry's bare `capy` and a second stale refusal keep + * their own codes. For a provider that reports the neutral codes (`DiscoveryCodes.keepsServiceCode`), a typed error from + * the service (`PERMISSION_DENIED`, `QUOTA_EXCEEDED`, …) keeps its own code too, instead of being flattened to the push code; + * the generic `SERVICE_ERROR` and an untyped error are the push code. The decision reads the code, never the message. + */ +function pushFailureCode(err: unknown, codes: DiscoveryCodes): string { + if (isConflictRefusal(err)) return err.code; + if (isStaleBase(err)) return ERROR_CODES.SECRETS_STALE_BASE; + if (codes.keepsServiceCode && err instanceof CapyError && err.code !== ERROR_CODES.SERVICE_ERROR) return err.code; + return codes.pushFailed; +} + +/** + * The REAL `DiscoverySequenceDeps`, wired to an already-authenticated `ctx` + * — the discovery scan's own org/session, reused for every folder rather + * than re-authenticating per folder. + */ +export function buildRealDiscoverySequenceDeps( + ctx: DiscoveryContext, + importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, + runOpts: { + noPush: boolean; + interactive: boolean; + json: boolean; + yes: boolean; + /** The provider's per-step import options (see `importIntoBranchSafe`). */ + importOptsFor: (env: DiscoveryPlanEnv, projectName: string, folderPath: string) => ConnectOpts; + /** How this provider spells the refusals. */ + codes: DiscoveryCodes; + /** How bare `capy` runs in each discovered folder (discovery never prompts: it answers by protocol). */ + gate: GateOptions; + /** Runs bare `capy` in one folder: the real `syncFolder` unless a test supplies its own. */ + syncFolder: (dir: string, gate: GateOptions) => Promise; + askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; + askProjectName: (defaultName: string) => Promise; + /** + * The non-interactive `--yes` auto-create snapshot (see + * `ensureProjectSafe`'s doc) — taken ONCE by `discover()` itself, before + * `runDiscoverySequences` starts, and handed to EVERY folder's own + * `ensureProject` call unchanged, so a project one folder creates this + * run never alters a later folder's "org has none" answer. `undefined` + * when this run's mode never needs it (interactive, or non-interactive + * without `--yes`). + */ + existingProjectsSnapshot?: ExistingProjectsSnapshot; + /** + * This run's OWN pure precomputation (see `computeSameRunNameCollisions`) + * of "which candidate folders' default project name collides with an + * EARLIER folder in this same run" — keyed by `sameRunFolderKey`. Passed + * through unchanged to every folder's own `ensureProject` call, same + * shape as `existingProjectsSnapshot` above. + */ + sameRunNameCollisions: ReadonlySet; + }, +): DiscoverySequenceDeps { + return { + syncFolder: (repoDir, folder) => runOpts.syncFolder(folderPath(repoDir, folder), runOpts.gate), + ensureProject: (repoDir, folder, remote) => + ensureProjectSafe(ctx, repoDir, folder, remote, { + interactive: runOpts.interactive, + yes: runOpts.yes, + askExistingOrNewProject: runOpts.askExistingOrNewProject, + askProjectName: runOpts.askProjectName, + existingProjectsSnapshot: runOpts.existingProjectsSnapshot, + sameRunNameCollision: runOpts.sameRunNameCollisions.has(sameRunFolderKey(repoDir, folder)), + codes: runOpts.codes, + }), + checkFolderDirty: (repoDir, folder, projectId) => checkFolderDirtySafe(ctx, repoDir, folder, projectId, runOpts.codes), + checkoutBranch: (repoDir, folder, projectId, branchName) => checkoutBranchSafe(ctx, repoDir, folder, projectId, branchName, runOpts.codes), + importIntoBranch: (repoDir, folder, env, overwrite, projectName) => + importIntoBranchSafe(ctx, importFn, repoDir, folder, env, overwrite, runOpts, projectName), + }; +} diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index c03d2e07..5421b2e3 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -32,9 +32,8 @@ * how you refresh it, and `capy rotate` refuses on a dokploy-managed var * (see `rotateCommand.ts`). */ -import { mkdirSync } from 'node:fs'; -import { join } from 'node:path'; import { TargetConfig } from '../../deploy/adapter'; +import { dokployKeyHelp, keyHelpFields } from '../../deploy/keyHelp'; import { baseUrlProblem } from '../../deploy/adapters/dokploy'; import { DEFAULT_TOKEN_ENV, @@ -52,39 +51,39 @@ import { listImportableEntries, resolveDokployApiKey, } from '../../deploy/dokployApi'; -import { ConnectorMetadata, ERROR_CODES, KeepFile } from '../../types/index'; -import { isStaleBase } from '../../sync/commit'; +import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; import { isInteractive } from '../../ui/interactive'; -import { ProjectManager } from '../../core/projectManager'; -import { FileManager } from '../../files/fileManager'; -import { resolveProjectKey } from '../../crypto/keyResolver'; -import { SyncEngine } from '../../sync/syncEngine'; -import { assertProjectNameAllowed, isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../../system/reservedProjectName'; -import { findDirtyBranchIssue } from '../checkoutCommand'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; -import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; +import { defaultDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { BaseUrlStore, Unanswered, offerSaveAskedBaseUrl, realBaseUrlStoreOpener, resolveDokployBaseUrl, settingsUnanswered } from '../../deploy/dokployBaseUrl'; import { syncFolder, type FolderSync, type GateOptions } from '../../sync/gate'; -import { isConflictRefusal } from '../../sync/conflicts'; -import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; +import { fingerprint, ResolvedContext } from './shared'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; -import { discoveryProjectQuestion } from '../../ui/projectQuestions'; +import { DOKPLOY_DISCOVERY_CODES } from './discoveryCodes'; +import { + ExistingProjectsSnapshot, + OverwritePlan, + buildRealDiscoverySequenceDeps, + commitForAppliedRepos, + commitPreviewForFolders, + computeOverwritePlan, + computeSameRunNameCollisions, + defaultAskDiscoveryCommitBranchName, + defaultAskDiscoveryProjectName, + defaultAskExistingOrNewProject, + defaultConfirm, + defaultPickCollisionWinner, + discoveryCommitPathsFor, + peekLocalKeepForFolder, +} from './discoverySteps'; import { - CandidateRepo, - DISCOVERY_PROJECT_NAME_MAX_LENGTH, DiscoveryCollision, DiscoveryContext, - DiscoveryFolderResult, DiscoveryOutcome, DiscoveryPlan, - DiscoveryPlanEnv, DiscoveryPlanFolder, - DiscoveryRepoCommitResult, - DiscoverySequenceDeps, - GitRemoteRef, applyCollisionResolutions, buildDiscoveryPlan, - defaultDiscoveryProjectName, findCandidateReposResult, orderEnvironments, resolveCollisions, @@ -442,12 +441,6 @@ export interface DokployConnectorDeps { getConnectorSecret?: (name: string, opts: DokploySystemStoreCallOptions) => Promise; } -async function defaultConfirm(message: string, defaultValue: boolean): Promise { - const inquirer = (await import('inquirer')).default; - const { ok } = await inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: defaultValue }]); - return ok; -} - async function defaultSelectVars(candidates: readonly string[]): Promise { if (candidates.length === 0) return []; const inquirer = (await import('inquirer')).default; @@ -543,91 +536,6 @@ async function defaultAskDiscoveryBaseUrl(): Promise { return baseUrl; } -/** Sentinel `askExistingOrNewProject` returns to mean "create a new project", never a real project id. */ -export const DISCOVERY_NEW_PROJECT = '__capy_discovery_new_project__'; - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -export async function defaultAskExistingOrNewProject( - existing: ReadonlyArray<{ id: string; name: string }>, - defaultName: string, -): Promise { - const inquirer = (await import('inquirer')).default; - const { projectChoice } = await inquirer.prompt([ - discoveryProjectQuestion(existing, defaultName, DISCOVERY_NEW_PROJECT), - ]); - return projectChoice; -} - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -/** - * Validates a discovery project-name answer: non-empty, at most - * `DISCOVERY_PROJECT_NAME_MAX_LENGTH` chars, not the reserved `_system` name - * (Vince, 2026-09-26, decision #4). Deliberately no charset restriction — - * `/` (and anything else `defaultDiscoveryProjectName` might produce) must - * stay allowed here; that's a separate concern from this ticket's scope. - */ -function discoveryProjectNameProblem(trimmed: string): string | null { - if (!trimmed) return 'A project name is required.'; - if (trimmed.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { - return `Project name must be ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters or fewer.`; - } - if (isReservedProjectName(trimmed)) return PROJECT_NAME_RESERVED_MESSAGE; - return null; -} - -async function defaultAskDiscoveryProjectName(defaultName: string): Promise { - const inquirer = (await import('inquirer')).default; - const { name } = await inquirer.prompt([ - { - type: 'input', - name: 'name', - message: 'New Capy project name:', - default: defaultName, - filter: (v: string) => v.trim(), - // Re-asks automatically (inquirer's own validate loop) rather than - // ever reaching `ensureProjectSafe` with a name it would refuse - // `DOKPLOY_PROJECT_NAME_TOO_LONG`/`PROJECT_NAME_RESERVED` for. - validate: (v: string) => discoveryProjectNameProblem(v.trim()) ?? true, - }, - ]); - return name; -} - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -async function defaultAskDiscoveryCommitBranchName(defaultName: string): Promise { - const inquirer = (await import('inquirer')).default; - const { name } = await inquirer.prompt([ - { - type: 'input', - name: 'name', - message: 'Commit the import to which git branch:', - default: defaultName, - filter: (v: string) => v.trim(), - // Re-asks automatically (inquirer's own validate loop) rather than - // ever reaching `commitDiscoveryChanges` with a name it would refuse - // `DOKPLOY_COMMIT_BRANCH_INVALID` for — that refusal is still the - // authoritative, zero-mutation-guaranteed check for every OTHER - // path into this function (non-interactive, an injected test dep). - validate: (v: string) => (isValidDiscoveryCommitBranchName(v.trim()) ? true : 'Not a valid git branch name.'), - }, - ]); - return name; -} - -// COPY-FLAG: new user-facing string, minimal/neutral wording. -async function defaultPickCollisionWinner(collision: DiscoveryCollision): Promise { - const inquirer = (await import('inquirer')).default; - const { winner } = await inquirer.prompt([ - { - type: 'list', - name: 'winner', - message: `${collision.folder || '.'} / ${collision.environmentName}: which service feeds this branch?`, - choices: collision.candidates.map((c) => ({ name: `${c.projectName} / ${c.serviceName}`, value: c.serviceId })), - }, - ]); - return winner; -} - /** `--var` on `capy connect dokploy`: comma-separated names, restricting the import. */ function parseVarRestriction(raw: string | undefined): readonly string[] | null { if (!raw) return null; @@ -638,46 +546,6 @@ function parseVarRestriction(raw: string | undefined): readonly string[] | null return names.length > 0 ? names : null; } -// ── --overwrite ────────────────────────────────────────────────────────────── - -export interface OverwritePlan { - /** Local names Dokploy no longer has, and that aren't a reference value — removed entirely. */ - toClear: readonly string[]; - /** Present in both with a DIFFERENT value — overwritten with Dokploy's. */ - toReplace: ReadonlyArray<{ name: string; value: string }>; - /** Only in Dokploy — a plain new write. */ - toImport: ReadonlyArray<{ name: string; value: string }>; - /** Present in both with the SAME value — no-op. */ - unchanged: readonly string[]; -} - -/** - * `--overwrite`'s own rule: the branch's vars become EXACTLY Dokploy's - * importable set. `localPlaintext` names absent from Dokploy's set are - * cleared — EXCEPT a reference-valued name (`${{...}}`, `referenceNames`): - * there is no resolved value to replace it with, so it is left alone rather - * than cleared (the caller's `DOKPLOY_REFERENCE_VALUE` warning already says - * so). `dokployCandidates` must be Dokploy's FULL importable set, never one - * already narrowed by `--var` — narrowing it here would make a restricted - * overwrite clear a name Dokploy still has, just outside the restriction - * (see the call site in `import()`, which computes this from the - * unrestricted list on purpose). - */ -export function computeOverwritePlan( - dokployCandidates: ReadonlyArray<{ name: string; value: string }>, - referenceNames: readonly string[], - localPlaintext: Readonly>, -): OverwritePlan { - const dokployNames = new Set(dokployCandidates.map((c) => c.name)); - const referenceSet = new Set(referenceNames); - return { - toClear: Object.keys(localPlaintext).filter((k) => !dokployNames.has(k) && !referenceSet.has(k)), - toReplace: dokployCandidates.filter((c) => localPlaintext[c.name] !== undefined && localPlaintext[c.name] !== c.value), - toImport: dokployCandidates.filter((c) => localPlaintext[c.name] === undefined), - unchanged: dokployCandidates.filter((c) => localPlaintext[c.name] === c.value).map((c) => c.name), - }; -} - // COPY-FLAG: new user-facing string, minimal/neutral wording. function overwriteConfirmMessage( plan: OverwritePlan, @@ -934,12 +802,13 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect interactive: secretsInteractive, orgId: ctx.orgId, devMode: opts.devMode, + helpBaseUrl: baseUrl, storeName: DOKPLOY_CONNECTOR_SECRET_NAME, deps: deps.getConnectorSecret ? { getConnectorSecret: deps.getConnectorSecret } : undefined, }); if (!resolved.ok) { const { reason } = describeDokployTokenProblem(resolved.code, tokenEnv, DOKPLOY_CONNECTOR_SECRET_NAME); - return { ok: false, code: resolved.code, message: `${reason}.` }; + return { ok: false, code: resolved.code, message: `${reason}.`, ...keyHelpFields(dokployKeyHelp(DOKPLOY_CONNECTOR_SECRET_NAME, baseUrl)) }; } const token = resolved.value; @@ -948,7 +817,15 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect source.kind === 'compose' ? await fetchComposeEnv(client, source.id) : await fetchApplicationEnv(client, source.id); - if (!envResult.ok) return { ok: false, code: envResult.code, message: envResult.message }; + if (!envResult.ok) { + // A rejected key says where to get another. + return { + ok: false, + code: envResult.code, + message: envResult.message, + ...(envResult.code === 'DOKPLOY_AUTH_FAILED' ? keyHelpFields(dokployKeyHelp(DOKPLOY_CONNECTOR_SECRET_NAME, baseUrl)) : {}), + }; + } // CAP-703: a URL typed at a prompt is offered to the org once Dokploy has answered with it - saved only when the // variable is unset and the caller is an org admin (never overwritten), a dry run saves nothing. if (settings.baseUrlAsked === true && !dryRun) { @@ -1104,16 +981,17 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect interactive: secretsInteractive, orgId: ctx.orgId, devMode: opts.devMode, + helpBaseUrl: resolvedBaseUrl, storeName: DOKPLOY_CONNECTOR_SECRET_NAME, deps: deps.getConnectorSecret ? { getConnectorSecret: deps.getConnectorSecret } : undefined, }); if (!resolved.ok) { const { reason } = describeDokployTokenProblem(resolved.code, tokenEnv, DOKPLOY_CONNECTOR_SECRET_NAME); - return { ok: false, code: resolved.code, message: `${reason}.` }; + return { ok: false, code: resolved.code, message: `${reason}.`, ...keyHelpFields(dokployKeyHelp(DOKPLOY_CONNECTOR_SECRET_NAME, resolvedBaseUrl)) }; } const client = createDokployClient(resolvedBaseUrl, resolved.value, deps.fetch); - const scan = findCandidateReposResult(cwd()); + const scan = findCandidateReposResult(cwd(), DOKPLOY_DISCOVERY_CODES); const environmentFilter = parseVarRestriction(opts.environment) ?? undefined; const planCore = await buildDiscoveryPlan( client, @@ -1140,7 +1018,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect if (dryRun) { // Vince's rule: a dry run changes nothing — never asks for a // branch name either; the DEFAULT is what it previews. - const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName()); + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(), DOKPLOY_DISCOVERY_CODES); return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}), ...baseUrlReport }; } @@ -1249,8 +1127,15 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect interactive: promptable, json: !!opts.json, yes: !!opts.yes, - baseUrl: resolvedBaseUrl, - tokenEnv, + codes: DOKPLOY_DISCOVERY_CODES, + importOptsFor: (env, projectName) => ({ + ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), + baseUrl: resolvedBaseUrl, + tokenEnv, + // Already known from the plan — see `dokployProjectName`'s doc. + dokployProjectName: projectName, + dokployEnvironmentName: env.environmentName, + }), // Discovery never prompts: a folder that differs from Capy is reported (its rows and `conflict_id`), not asked. gate: { devMode: opts.devMode, json: true, nonTty: true, conflictFlags: opts.conflictFlags }, syncFolder: deps.syncFolder ?? syncFolder, @@ -1268,7 +1153,7 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect // whether or not `--no-push` was passed — keep.lock/.env are written // locally either way (see `writeImportOutcome`), so there is always // something local for this step to pin. - const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, DOKPLOY_DISCOVERY_CODES); return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}), ...baseUrlReport }; }, @@ -1276,11 +1161,6 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect return connector; } -/** Plan-time-only local keep.lock read (no auth) — see `PeekLocalKeep`'s own doc. */ -function peekLocalKeepForFolder(repoDir: string, folder: string): KeepFile | null { - return new ProjectManager(folder ? join(repoDir, folder) : repoDir).readKeepFile(); -} - /** * The plan-level "proceed at all?" prompt's own message — the EXACT command * sequence per folder (never a clear/replace/import COUNT: discovery itself @@ -1308,680 +1188,6 @@ function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwr return [header, ...rows].join('\n'); } -const folderPath = (repoDir: string, folder: string): string => (folder ? join(repoDir, folder) : repoDir); - -/** keep.lock and .gitignore, relative to `repoDir` — the ONLY paths discovery's own commit step ever touches. */ -function discoveryCommitPathsFor(folder: string): readonly string[] { - const prefix = folder ? `${folder}/` : ''; - return [`${prefix}keep.lock`, `${prefix}.gitignore`]; -} - -/** - * Every REPO the plan touches, and the branch/files a real run WOULD commit - * for it — dry-run preview only, never runs a git-mutating command (see - * `commitDiscoveryChanges`'s own `dryRun` handling). - * - * `paths` is de-duplicated per repo (cosmetic defect fix): two DISTINCT - * `DiscoveryPlanFolder` entries can share the same (repoDir, folder) — e.g. - * two colliding services that both resolve to the same folder, still shown - * as separate candidates in the pre-resolution `plan.folders` a dry run - * previews (a dry run never asks for a collision winner) — and - * `discoveryCommitPathsFor` would otherwise be counted once per entry, - * reporting `keep.lock`/`.gitignore` twice for what is really one write. - */ -function commitPreviewForFolders(folders: readonly DiscoveryPlanFolder[], branchName: string): readonly DiscoveryRepoCommitResult[] { - const repoDirs = [...new Set(folders.map((f) => f.repoDir))]; - return repoDirs.map((repoDir) => { - const paths = [...new Set(folders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)))]; - const outcome = commitDiscoveryChanges(repoDir, paths, new Set(), { branchName, dryRun: true, summaryLines: [] }); - return { repoDir, ...outcome }; - }); -} - -/** - * The commit step's real run: per repo touched by `applied`, commits ONLY - * the SUCCESSFULLY-applied folders' keep.lock/.gitignore (see - * `commitDiscoveryChanges`'s own doc for the refusal codes) — a repo with - * zero successful folders is skipped entirely (nothing to commit, no - * refusal either). - */ -function commitForAppliedRepos( - applied: readonly DiscoveryFolderResult[], - beforeStatusByRepo: ReadonlyMap>, - branchName: string, -): readonly DiscoveryRepoCommitResult[] { - const repoDirs = [...new Set(applied.map((f) => f.repoDir))]; - return repoDirs.flatMap((repoDir) => { - const okFolders = applied.filter((f) => f.repoDir === repoDir && f.ok); - if (okFolders.length === 0) return []; - const paths = okFolders.flatMap((f) => discoveryCommitPathsFor(f.folder)); - const summaryLines = okFolders.map((f) => `- ${f.folder || '.'}: ${(f as Extract).activeBranch}`); - const outcome = commitDiscoveryChanges(repoDir, paths, beforeStatusByRepo.get(repoDir) ?? new Set(), { - branchName, - dryRun: false, - summaryLines, - }); - return [{ repoDir, ...outcome }]; - }); -} - -/** What `listOrgProjectsOrUnavailable` returns — named here so the non-interactive `--yes` snapshot (see `ensureProjectSafe`'s doc) and the interactive per-folder lookup share one type. */ -type ExistingProjectsSnapshot = { - existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; - projectsUnavailable: boolean; -}; - -/** A project name, normalized for EQUALITY comparison only (never for storage/display) — trimmed, then lower-cased, so `" Widgets "` and `widgets` compare equal (Vince, 2026-09-26 decision). */ -function normalizeProjectName(name: string): string { - return name.trim().toLowerCase(); -} - -/** `DOKPLOY_PROJECT_NAME_EXISTS`'s message — shared by the org-snapshot collision and the same-run collision below, since a human reads the same instruction either way. */ -// COPY-FLAG: new user-facing string, minimal/neutral wording. -function dokployProjectNameExistsMessage(folder: string, name: string): string { - return `${folder || '.'}: a project named "${name}" already exists — reuse or duplicate is a human choice; run interactively or \`capy\` in ${folder || '.'}.`; -} - -/** Stable per-(repoDir, folder) key — mirrors `dokployDiscovery.ts`'s own `groupKey` shape (`\u0000`-joined, so neither part can collide with the separator). */ -function sameRunFolderKey(repoDir: string, folder: string): string { - return `${repoDir}\u0000${folder}`; -} - -/** - * Pure, run-once precomputation of "two folders in this SAME run would try - * to create a project under the SAME default name" (Vince, 2026-09-26 - * decision) — computed from `resolvedFolders` alone, entirely before any - * folder's own `ensureProject` call runs, so no mutable "names created so - * far" state needs to be threaded through the sequential per-folder loop - * (`runDiscoverySequences`'s `ensureProject` dependency has no such - * accumulator parameter, and this file's own immutability rule forbids - * inventing one via a mutated `Set`/`Map`). - * - * Only folders WITHOUT a local keep.lock are candidates (`!f.initialized`) — - * an already-initialized folder never creates anything, so it can never - * collide with, or be collided into by, another folder's create. Folded over - * `folders` in their OWN array order — the exact order `runDiscoverySequences` - * processes them in — so "the first one wins" here means the same thing it - * means at run time. Returns the set of `sameRunFolderKey`s for every folder - * that is NOT the first candidate to claim its (normalized) default name; - * the very first claimant of a name is never in the returned set. - */ -function computeSameRunNameCollisions(folders: readonly DiscoveryPlanFolder[]): ReadonlySet { - const candidates = folders - .filter((f) => !f.initialized) - .map((f) => ({ key: sameRunFolderKey(f.repoDir, f.folder), normalizedName: normalizeProjectName(defaultDiscoveryProjectName(f.repoDir, f.folder, f.remote)) })); - - return candidates.reduce( - (acc, c) => - acc.seenNames.has(c.normalizedName) - ? { seenNames: acc.seenNames, collisionKeys: new Set([...acc.collisionKeys, c.key]) } - : { seenNames: new Set([...acc.seenNames, c.normalizedName]), collisionKeys: acc.collisionKeys }, - { seenNames: new Set(), collisionKeys: new Set() }, - ).collisionKeys; -} - -/** - * No keep.lock at (repoDir, folder) → interactive: reuses `capy`'s own - * existing-project-picking (list the org's projects, ask new-vs-existing — - * see `defaultAskExistingOrNewProject`), never invents its own project flow. - * A keep.lock already there is just read for its project id — no network - * call. - * - * Non-interactive WITHOUT `--yes` refuses `DOKPLOY_FOLDER_NOT_INITIALIZED` - * naming the folder and never auto-creates — unchanged. - * - * Non-interactive WITH `--yes` (Vince, 2026-09-26 decision, SUPERSEDING the - * original "org has none" rule): creates a new project named `defaultName` - * UNLESS a project with that EXACT name (trimmed, compared case-insensitively - * — see `normalizeProjectName`) already exists — an UNRELATED existing - * project in the org (e.g. a stray empty `root`) no longer blocks anything. - * A same-name collision refuses `DOKPLOY_PROJECT_NAME_EXISTS`, naming both - * the project and the folder — reusing or duplicating an existing project is - * a genuine human choice with no safe default, same reasoning the old rule - * used for "org has ANY project", now narrowed to "org has THIS name". - * `opts.existingProjectsSnapshot` is this run's OWN snapshot (see - * `buildRealDiscoverySequenceDeps`'s doc): taken ONCE, before any folder in - * this run starts, so a project an EARLIER folder in this same run created - * is never counted as "existing" when deciding a LATER folder's own - * name-collision answer. A snapshot that failed to load is - * `DOKPLOY_PROJECT_LOOKUP_FAILED`, same as the interactive branch below and - * same as before this decision. - * - * The snapshot alone cannot catch two DIFFERENT folders in this SAME run - * both defaulting to the same name (neither one is "existing" yet when the - * snapshot was taken) — `opts.sameRunNameCollision` is `ensureProjectSafe`'s - * OWN pure precomputed answer to exactly that (see - * `computeSameRunNameCollisions`), and refuses the SAME - * `DOKPLOY_PROJECT_NAME_EXISTS` code for whichever folder is the SECOND (or - * later) occurrence in run order; the first occurrence proceeds normally. - * - * Deliberately NOT a call into `CapyCommand` itself: that class calls - * `process.exit()` on several failure paths and always builds its own - * `ProjectManager()`/`FileManager()` against `process.cwd()`, neither of - * which a multi-folder run can tolerate (one folder's exit would kill every - * other folder still to run, and there is no way to point it at a folder - * other than `cwd`). This reuses the SAME underlying `ServiceClient` calls - * (`listProjects`, `initializeProject`) and mirrors `capyCommand.ts`'s own - * `bootstrapExistingProject` for the existing-project branch (see - * `bootstrapExistingProjectSafe`, below), scoped to one folder and returning - * a coded outcome instead of exiting. - */ -async function ensureProjectSafe( - ctx: DiscoveryContext, - repoDir: string, - folder: string, - /** This folder's repo's own parsed `origin`, when known — see `defaultDiscoveryProjectName`'s decision #2 doc; passed through rather than re-read here. */ - remote: GitRemoteRef | undefined, - opts: { - interactive: boolean; - /** Discovery's OWN `--yes` — skips the new-project NAME prompt (defaulting to `defaultName`) the same way it skips every other confirmation, never the existing-vs-new picker itself (that one isn't a confirmation, it's a genuine choice with no safe default) — EXCEPT non-interactively, where a same-name collision has no choice left to make (see this function's own doc). */ - yes: boolean; - askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; - askProjectName: (defaultName: string) => Promise; - /** This run's ONE pre-apply snapshot of the org's existing projects — only consulted on the non-interactive `--yes` auto-create path; `undefined` there means the snapshot itself failed to load. */ - existingProjectsSnapshot?: ExistingProjectsSnapshot; - /** This folder's own precomputed answer to "does an EARLIER folder in this SAME run already claim this default name" (see `computeSameRunNameCollisions`) — only consulted on the non-interactive `--yes` auto-create path. */ - sameRunNameCollision: boolean; - }, -): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const existing = pm.readKeepFile(); - if (existing) { - return { ok: true, projectId: existing.project_id, created: false }; - } - const defaultName = defaultDiscoveryProjectName(repoDir, folder, remote); - - if (!opts.interactive) { - if (!opts.yes) { - return { - ok: false, - code: 'DOKPLOY_FOLDER_NOT_INITIALIZED', - message: `${folder || '.'} has no keep.lock. Run \`capy\` there to initialize it, then re-run discovery.`, - }; - } - const snapshot = opts.existingProjectsSnapshot; - if (!snapshot || snapshot.projectsUnavailable) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_LOOKUP_FAILED', - message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, - }; - } - const sameNameProject = snapshot.existingProjects.find((p) => normalizeProjectName(p.name) === normalizeProjectName(defaultName)); - if (sameNameProject || opts.sameRunNameCollision) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_NAME_EXISTS', - message: dokployProjectNameExistsMessage(folder, defaultName), - }; - } - return createNewProjectSafe(ctx, path, folder, defaultName); - } - - // A lookup failure is NOT "this org has no projects" — offering only - // "create new" on a transient network/auth error risks a duplicate - // project the human never asked for. Abort THIS folder instead (CAP-657 - // follow-up defect fix) — the SAME distinction `capy`'s own init makes - // (`listOrgProjectsOrUnavailable`, extracted from `capyCommand.ts`). - const { existingProjects, projectsUnavailable } = await listOrgProjectsOrUnavailable(ctx.serviceClient); - if (projectsUnavailable) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_LOOKUP_FAILED', - message: `${folder || '.'}: could not list this org's existing projects — refusing to offer "create new" blind.`, - }; - } - const picked = await opts.askExistingOrNewProject(existingProjects.map((p) => ({ id: p.id, name: p.name })), defaultName); - - if (picked !== DISCOVERY_NEW_PROJECT) { - const project = existingProjects.find((p) => p.id === picked); - if (!project) { - return { ok: false, code: 'DOKPLOY_PROJECT_NOT_FOUND', message: `No project ${picked} in this org.` }; - } - try { - await bootstrapExistingProjectSafe(ctx, pm, new FileManager(path), project); - return { ok: true, projectId: project.id, created: false }; - } catch (err) { - return { ok: false, code: 'DOKPLOY_INIT_FAILED', message: err instanceof Error ? err.message : String(err) }; - } - } - // Mirrors `capyCommand.ts#resolveProjectName`: ask for the new project's - // name (default = `defaultName`). `--yes` skips the ask — same "proceed - // without asking" contract as every other discovery confirmation — and - // uses the default outright. - const chosenName = opts.yes ? defaultName : await opts.askProjectName(defaultName); - return createNewProjectSafe(ctx, path, folder, chosenName); -} - -/** - * Creates a brand-new project named `chosenName` and initializes (repoDir, - * folder) onto it — the exact write sequence the interactive "New project" - * choice runs (`ensureProjectSafe`'s own interactive branch), reused - * verbatim by the non-interactive `--yes` auto-create path (Vince, - * 2026-09-26 decision) so the two can never drift apart. - */ -async function createNewProjectSafe( - ctx: DiscoveryContext, - path: string, - folder: string, - chosenName: string, -): Promise<{ ok: true; projectId: string; created: boolean } | { ok: false; code: string; message: string }> { - // Vince, 2026-09-26, decision #4: refuse before any write when the name - // (default or human-typed) is over 255 chars — never truncated. The real - // `defaultAskDiscoveryProjectName` prompt validates this itself and - // re-asks (so an interactive human self-corrects before ever returning - // here); this is the zero-write guarantee for `--yes` and for any - // injected `askProjectName` test double that skips that validation. - if (chosenName.length > DISCOVERY_PROJECT_NAME_MAX_LENGTH) { - return { - ok: false, - code: 'DOKPLOY_PROJECT_NAME_TOO_LONG', - message: `${folder || '.'}: project name is longer than ${DISCOVERY_PROJECT_NAME_MAX_LENGTH} characters.`, - }; - } - try { - assertProjectNameAllowed(chosenName); - const initResult = await ctx.serviceClient.initializeProject(chosenName, ctx.orgId); - const keep: KeepFile = { - version: '3.0', - org_id: initResult.org_id, - project_id: initResult.project_id, - project_name: initResult.project_name, - variables: {}, - }; - // A discovered folder several levels deep (e.g. `backend/deployment`) - // may not exist yet under `repoDir` — `writeKeepFile` writes directly, - // with no directory creation of its own (unlike `writeEncryptedEnvFile`, - // which does). - mkdirSync(path, { recursive: true }); - const fm = new FileManager(path); - fm.writeKeepFile(keep); - fm.ensureCapyGitignore(); - return { ok: true, projectId: initResult.project_id, created: true }; - } catch (err) { - return { ok: false, code: 'DOKPLOY_INIT_FAILED', message: err instanceof Error ? err.message : String(err) }; - } -} - -/** - * Bootstrap an EXISTING project into (repoDir, folder) — mirrors - * `capyCommand.ts#bootstrapExistingProject` exactly (pull `development`'s - * keep.json + env blob, decrypt, write keep.lock + `.env`), scoped to one - * folder's own `pm`/`fm` rather than `process.cwd()`, and — unlike the - * original — branching on `err.details?.status === 404` alone rather than - * ALSO matching a `/No secrets/i` message (Rule 5: never branch on - * human-readable text; the status code alone already says "no snapshot"). - */ -async function bootstrapExistingProjectSafe( - ctx: DiscoveryContext, - pm: ProjectManager, - fm: FileManager, - project: { id: string; name: string; organization_id: string }, -): Promise { - const branch = 'development'; - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const encryptionKey = await resolveProjectKey(project.organization_id, project.id, ctx.userId, keyOps); - - const decryptData = await ctx.serviceClient.getDecryptData(project.id, branch, undefined, true).catch((err) => { - if ((err as { details?: { status?: number } })?.details?.status === 404) { - return { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }; - } - throw err; - }); - - const stub: KeepFile = { version: '3.0', org_id: project.organization_id, project_id: project.id, project_name: project.name, variables: {} }; - const serverKeep: KeepFile = decryptData.keep_file - ? { ...(JSON.parse(decryptData.keep_file) as KeepFile), org_id: project.organization_id, project_id: project.id, project_name: project.name } - : stub; - - const plaintext: Record = {}; - if (decryptData.env_content) { - const encrypted = fm.parseEnvContent(decryptData.env_content); - for (const [k, v] of Object.entries(encrypted)) { - try { - plaintext[k] = fm.decryptValue(v, encryptionKey); - } catch { - // Skip undecryptable (user lacks variable-level permission). - } - } - } - - fm.writeKeepFile(serverKeep); - pm.writeActiveBranch(branch); - fm.ensureCapyGitignore(); - fm.writeEncryptedEnvFile(plaintext, encryptionKey, undefined, serverKeep, branch); -} - -/** - * The SAME dirty-working-tree guard `capy checkout` enforces - * (`checkoutCommand.ts#findDirtyBranchIssue`), run ONCE per folder before - * its FIRST checkout — never per environment, and never for a folder whose - * project was just created this run (nothing to be dirty about yet). A - * dirty folder aborts with zero writes; every later checkout in the SAME - * folder just switched away from a branch this run itself wrote, which is - * clean by construction, so re-checking per environment would be pure - * overhead for a result that can't change mid-run. - */ -async function checkFolderDirtySafe( - ctx: DiscoveryContext, - repoDir: string, - folder: string, - projectId: string, -): Promise<{ ok: true } | { ok: false; code: string; message: string }> { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const fm = new FileManager(path); - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const tryResolveKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { - try { - return { ok: true, value: await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps) }; - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : String(err) }; - } - }; - const keyResult = await tryResolveKey(); - if (!keyResult.ok) { - return { ok: false, code: 'DOKPLOY_KEY_RESOLUTION_FAILED', message: keyResult.message }; - } - const issue = findDirtyBranchIssue(pm, fm, keyResult.value); - if (!issue) return { ok: true }; - const detail = issue.code === 'UNCOMMITTED_CHANGES' ? `an uncommitted change (${issue.varName})` : 'unpushed changes'; - return { - ok: false, - code: 'DOKPLOY_FOLDER_DIRTY', - message: `${folder || '.'} has ${detail} on "${issue.branch}". Run \`capy\` there to push, or discard the change, then re-run discovery.`, - }; -} - -/** - * Switches (repoDir, folder) onto `branchName` — pulls it if it already - * exists on the server (same as `capy checkout `), or creates it, - * seeded from the folder's CURRENT `.env` (same as `capy checkout -b - * ` — the git-model copy), otherwise. Mirrors - * `checkoutCommand.ts#_execute`'s core — including its dirty-working-tree - * guard, run ONCE per folder before the first call to this function (see - * `DiscoverySequenceDeps.checkFolderDirty`; every LATER checkout in the same - * folder just switched away from a branch this run itself wrote, which is - * clean by construction) — scoped to ONE folder and never `process.exit`s; - * a failure comes back as `{ ok: false }` so the caller can abort just this - * folder. `createBranch(..., false)`: a discovery-created branch is never - * protected — there is no flow yet for discovery to ask "should this be - * invite-only", so it always defaults to the same open access every other - * branch this run creates gets. - */ -async function checkoutBranchSafe( - ctx: DiscoveryContext, - repoDir: string, - folder: string, - projectId: string, - branchName: string, -): Promise<{ ok: true; created: boolean } | { ok: false; code: string; message: string }> { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const fm = new FileManager(path); - try { - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const encryptionKey = await resolveProjectKey(ctx.orgId, projectId, ctx.userId, keyOps); - - const branches = await ctx.serviceClient.listBranches(projectId); - const created = !branches.some((b) => b.name === branchName); - if (created) { - await ctx.serviceClient.createBranch(projectId, branchName, false); - } - - const decryptResult = await (async (): Promise< - | { ok: true; data: Awaited> } - | { ok: false; code: string; message: string } - > => { - try { - return { ok: true, data: await ctx.serviceClient.getDecryptData(projectId, branchName, undefined, true) }; - } catch (err) { - const status = (err as { details?: { status?: number } })?.details?.status; - if (status === 404) { - return { ok: true, data: { env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() } }; - } - if (status === 403) { - return { ok: false, code: 'DOKPLOY_BRANCH_PROTECTED', message: `"${branchName}" is a protected branch — access is invite-only.` }; - } - throw err; // an anomaly — let the outer catch report it as DOKPLOY_CHECKOUT_FAILED. - } - })(); - if (!decryptResult.ok) return decryptResult; - const decryptData = decryptResult.data; - - const existingKeep = pm.readKeepFile(); - if (!existingKeep) { - return { ok: false, code: 'DOKPLOY_KEEP_MISSING', message: `${folder || '.'} has no keep.lock.` }; - } - // Splice in only THIS branch's server pins — keep.lock holds every - // branch's metadata and is git-owned; a sibling branch's pins must not - // move just because this one was checked out (CAP-303). - const keepForWrite: KeepFile = decryptData.keep_file - ? SyncEngine.spliceKeepBranch(existingKeep, JSON.parse(decryptData.keep_file) as KeepFile, branchName) - : existingKeep; - if (keepForWrite !== existingKeep) fm.writeKeepFile(keepForWrite); - - if (decryptData.env_content) { - const remoteEnv = fm.parseEnvContent(decryptData.env_content); - const decrypted: Record = {}; - for (const [k, v] of Object.entries(remoteEnv)) { - try { - decrypted[k] = fm.decryptValue(v, encryptionKey); - } catch { - // Skip undecryptable. - } - } - fm.writeEncryptedEnvFile(decrypted, encryptionKey, undefined, keepForWrite, branchName); - } else if (created) { - // Git model: a brand-new branch inherits the folder's CURRENT `.env` — - // the exact seed `capy checkout -b` performs. Unreadable current - // `.env` (or none yet) falls through to an empty seed. - const tryReadCurrentEnv = (): Record => { - try { - return fm.readEncryptedEnvFile(encryptionKey); - } catch { - return {}; - } - }; - fm.writeEncryptedEnvFile(tryReadCurrentEnv(), encryptionKey, undefined, keepForWrite, branchName); - } else { - // An EXISTING branch with no snapshot yet: stamp an empty file for it. - fm.writeEncryptedEnvFile({}, encryptionKey, undefined, keepForWrite, branchName); - } - - pm.writeActiveBranch(branchName); - return { ok: true, created }; - } catch (err) { - return { ok: false, code: 'DOKPLOY_CHECKOUT_FAILED', message: err instanceof Error ? err.message : String(err) }; - } -} - -/** - * Runs the single-service import itself (`importFn` — the very function - * `import()` above defines, reused directly rather than going through - * `ConnectCommand`: that class's `executeImport` prints its own terminal/ - * JSON lines as a side effect, which would corrupt discovery's OWN single - * JSON line under `--json`, and `resolveContext()` re-authenticates and - * re-reads `process.cwd()` rather than accepting an already-resolved - * folder + session) against the branch `checkoutBranchSafe` just switched - * onto, then writes the result via the SAME `writeImportOutcome` helper - * `executeImport` uses — same push, same skip-auto-commit, same - * clear-pruning for `--overwrite`. - */ -async function importIntoBranchSafe( - ctx: DiscoveryContext, - importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, - repoDir: string, - folder: string, - env: DiscoveryPlanEnv, - overwrite: boolean, - runOpts: { noPush: boolean; interactive: boolean; json: boolean; yes: boolean; baseUrl: string; tokenEnv: string; gate: GateOptions }, - /** CAP-673: this environment's Dokploy PROJECT name — already known from the plan's own `project.all` read, threaded through so `import()` never re-fetches it (see `ConnectOpts.dokployProjectName`'s doc). */ - projectName: string, -): Promise { - const path = folderPath(repoDir, folder); - const pm = new ProjectManager(path); - const fm = new FileManager(path); - - const projectState = await pm.detectProjectState(); - if (!projectState.initialized || !projectState.organizationId || !projectState.projectId || !projectState.activeBranch) { - return { ok: false, code: 'DOKPLOY_KEEP_MISSING', message: `${folder || '.'} is not on a branch after checkout.` }; - } - const keep = pm.readKeepFile(); - if (!keep) { - return { ok: false, code: 'DOKPLOY_KEEP_MISSING', message: `${folder || '.'} has no keep.lock.` }; - } - - const keyOps = { - coDecrypt: (oid: string, ct: string) => ctx.serviceClient.coDecrypt(oid, ct).then((r) => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => ctx.serviceClient.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), - }; - const tryResolveProjectKey = async (): Promise<{ ok: true; value: string } | { ok: false; message: string }> => { - try { - return { ok: true, value: await resolveProjectKey(projectState.organizationId!, projectState.projectId!, ctx.userId, keyOps) }; - } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : String(err) }; - } - }; - const projectKeyResult = await tryResolveProjectKey(); - if (!projectKeyResult.ok) { - return { ok: false, code: 'DOKPLOY_KEY_RESOLUTION_FAILED', message: projectKeyResult.message }; - } - const projectKey = projectKeyResult.value; - - const localPlaintext: Record = {}; - for (const [k, v] of Object.entries(fm.readEnvFile())) { - if (!v.startsWith('capy:')) { - localPlaintext[k] = v; - continue; - } - try { - localPlaintext[k] = fm.decryptValue(v, projectKey); - } catch { - // Skip undecryptable. - } - } - - const resolvedCtx: ResolvedContext = { - pm, - fileManager: fm, - authService: ctx.authService, - serviceClient: ctx.serviceClient, - orgId: projectState.organizationId, - projectId: projectState.projectId, - branch: projectState.activeBranch, - userId: ctx.userId, - projectKey, - keep, - localPlaintext, - // This folder's own gate: if Capy refuses the upload as stale, bare `capy` runs again IN THIS FOLDER and the write retries. - gate: { ...runOpts.gate, cwd: path, throwConflicts: true }, - }; - const importOpts: ConnectOpts = { - ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), - overwrite, - noPush: runOpts.noPush, - nonTty: !runOpts.interactive, - json: runOpts.json, - yes: runOpts.yes, - baseUrl: runOpts.baseUrl, - tokenEnv: runOpts.tokenEnv, - // CAP-673: already known from the plan — see `dokployProjectName`'s doc. - dokployProjectName: projectName, - dokployEnvironmentName: env.environmentName, - }; - - const outcome = await importFn(resolvedCtx, importOpts); - if (!outcome.ok) return outcome; - - // A push failure here must become THIS folder's coded failure, never an - // uncaught rejection — `runDiscoverySequences` awaits one folder's - // sequence at a time, so letting this throw would abort every folder - // still queued behind this one, not just this one. - try { - await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); - } catch (err) { - // A refused-as-stale upload keeps its own code; anything else is the push failing. - return { ok: false, code: stalePushCode(err), message: err instanceof Error ? err.message : String(err) }; - } - return outcome; -} - -/** The coded reason a folder's upload failed: a difference found by the retry's bare `capy` and a second stale refusal keep their own codes. */ -function stalePushCode(err: unknown): string { - if (isConflictRefusal(err)) return err.code; - return isStaleBase(err) ? ERROR_CODES.SECRETS_STALE_BASE : 'DOKPLOY_PUSH_FAILED'; -} - -/** - * The REAL `DiscoverySequenceDeps`, wired to an already-authenticated `ctx` - * — the discovery scan's own org/session, reused for every folder rather - * than re-authenticating per folder. - */ -function buildRealDiscoverySequenceDeps( - ctx: DiscoveryContext, - importFn: (ctx: ResolvedContext, opts: ConnectOpts) => Promise, - runOpts: { - noPush: boolean; - interactive: boolean; - json: boolean; - yes: boolean; - baseUrl: string; - tokenEnv: string; - /** How bare `capy` runs in each discovered folder (discovery never prompts: it answers by protocol). */ - gate: GateOptions; - /** Runs bare `capy` in one folder: the real `syncFolder` unless a test supplies its own. */ - syncFolder: (dir: string, gate: GateOptions) => Promise; - askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; - askProjectName: (defaultName: string) => Promise; - /** - * The non-interactive `--yes` auto-create snapshot (see - * `ensureProjectSafe`'s doc) — taken ONCE by `discover()` itself, before - * `runDiscoverySequences` starts, and handed to EVERY folder's own - * `ensureProject` call unchanged, so a project one folder creates this - * run never alters a later folder's "org has none" answer. `undefined` - * when this run's mode never needs it (interactive, or non-interactive - * without `--yes`). - */ - existingProjectsSnapshot?: ExistingProjectsSnapshot; - /** - * This run's OWN pure precomputation (see `computeSameRunNameCollisions`) - * of "which candidate folders' default project name collides with an - * EARLIER folder in this same run" — keyed by `sameRunFolderKey`. Passed - * through unchanged to every folder's own `ensureProject` call, same - * shape as `existingProjectsSnapshot` above. - */ - sameRunNameCollisions: ReadonlySet; - }, -): DiscoverySequenceDeps { - return { - syncFolder: (repoDir, folder) => runOpts.syncFolder(folderPath(repoDir, folder), runOpts.gate), - ensureProject: (repoDir, folder, remote) => - ensureProjectSafe(ctx, repoDir, folder, remote, { - interactive: runOpts.interactive, - yes: runOpts.yes, - askExistingOrNewProject: runOpts.askExistingOrNewProject, - askProjectName: runOpts.askProjectName, - existingProjectsSnapshot: runOpts.existingProjectsSnapshot, - sameRunNameCollision: runOpts.sameRunNameCollisions.has(sameRunFolderKey(repoDir, folder)), - }), - checkFolderDirty: (repoDir, folder, projectId) => checkFolderDirtySafe(ctx, repoDir, folder, projectId), - checkoutBranch: (repoDir, folder, projectId, branchName) => checkoutBranchSafe(ctx, repoDir, folder, projectId, branchName), - importIntoBranch: (repoDir, folder, env, overwrite, projectName) => - importIntoBranchSafe(ctx, importFn, repoDir, folder, env, overwrite, runOpts, projectName), - }; -} - /** * The real org system store read for the Dokploy IMPORT key (`_CONNECTOR_DOKPLOY_API_KEY`). * Shared by `capy connect dokploy` and `capy deploy dokploy --discover`, which read the same diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 53feeb61..01fdcf0a 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -26,7 +26,7 @@ * clear/replace/import plan itself, and it never touches Dokploy vars past * what `--compose`/`--application`'s own single-service import (below, and * in `dokploy.ts`) already does. The only new behaviour that import gained - * for this is `--overwrite` (see `dokploy.ts#computeOverwritePlan`) — a + * for this is `--overwrite` (see `discoverySteps.ts#computeOverwritePlan`) — a * flag usable standalone too, not something discovery invented for itself. * Discovery DOES decide, per step, whether to pass `--overwrite` * automatically — see "Auto-overwrite", below — but the actual clear/ @@ -118,10 +118,11 @@ import { isReservedProjectName } from '../../system/reservedProjectName'; import { KeepFile } from '../../types/index'; import type { AuthService } from '../../auth/authService'; import type { ServiceClient } from '../../service/serviceClient'; -import type { ImportOutcome } from './registry'; +import type { ConnectFailure, ImportOutcome } from './registry'; import type { FolderSync } from '../../sync/gate'; import type { ConflictPayload } from '../../sync/conflicts'; import type { DiscoveryCommitOutcome } from '../../git/discoveryCommit'; +import { DOKPLOY_DISCOVERY_CODES, type DiscoveryCodes } from './discoveryCodes'; /** * The context `capy connect dokploy --discover` runs with — deliberately @@ -256,8 +257,8 @@ export interface CandidateRepo { export interface DiscoveryRemoteReadError { repoDir: string; - /** Stable refusal code — branch on this, never on `message`. */ - code: 'DOKPLOY_REMOTE_UNREADABLE'; + /** Stable refusal code — branch on this, never on `message`. `DOKPLOY_REMOTE_UNREADABLE` for Dokploy, `CONNECTOR_REMOTE_UNREADABLE` for the other providers. */ + code: string; /** git's own stderr (or a fallback naming its exit code) — for display only. */ message: string; } @@ -276,10 +277,10 @@ export interface CandidateReposResult { * defect a live sandbox run hit: git's "dubious ownership" refusal made * every single service come back `no_remote_match`). */ -export function findCandidateReposResult(cwd: string): CandidateReposResult { +export function findCandidateReposResult(cwd: string, codes: Pick = DOKPLOY_DISCOVERY_CODES): CandidateReposResult { const self = probeRepoDir(cwd); if (self.kind === 'unreadable') { - return { repos: [], unreadable: [{ repoDir: cwd, code: 'DOKPLOY_REMOTE_UNREADABLE', message: self.message }] }; + return { repos: [], unreadable: [{ repoDir: cwd, code: codes.remoteUnreadable, message: self.message }] }; } if (self.kind === 'ok') { const remote = readOriginRemote(self.repoRoot); @@ -293,7 +294,7 @@ export function findCandidateReposResult(cwd: string): CandidateReposResult { return remote ? [{ repoDir: probe.repoRoot, remote }] : []; }), unreadable: childProbes.flatMap(({ dir, probe }) => - probe.kind === 'unreadable' ? [{ repoDir: dir, code: 'DOKPLOY_REMOTE_UNREADABLE' as const, message: probe.message }] : [], + probe.kind === 'unreadable' ? [{ repoDir: dir, code: codes.remoteUnreadable, message: probe.message }] : [], ), }; } @@ -334,7 +335,7 @@ export function deepestCommonDir(dirs: readonly string[]): string { } /** A Compose service's folder is its compose file's directory; an Application's is the repo root (`''`). */ -export function serviceFolder(service: { serviceKind: 'application' | 'compose'; composePath?: string }): string { +export function serviceFolder(service: { serviceKind: DiscoveryServiceKind; composePath?: string }): string { if (service.serviceKind === 'application' || !service.composePath) return ''; return normalizeRelativeDir(posix.dirname(normalizeRelativeDir(service.composePath))); } @@ -413,7 +414,8 @@ export const DISCOVERY_PROJECT_NAME_MAX_LENGTH = 255; // ── Matching Dokploy services to repos ────────────────────────────────────── -export type DiscoveryServiceKind = 'application' | 'compose'; +/** `'vercel'` (CAP-696): a Vercel project, whose id rides in `serviceId` — see `vercelDiscovery.ts`. */ +export type DiscoveryServiceKind = 'application' | 'compose' | 'vercel'; export interface ServiceDetail { /** Dokploy PROJECT id (CAP-703); `projectName` alone is not unique. */ @@ -436,12 +438,20 @@ export interface ServiceDetail { export type DiscoveryUnmatchedReason = 'no_git_source' | 'no_remote_match'; +/** Why a Vercel project was not matched to a local repo (CAP-696). */ +export type VercelUnmatchedReason = + | 'no_git_link' + | 'unsupported_git_provider' + | 'no_remote_match' + | 'root_directory_missing' + | 'no_variables'; + export interface DiscoveryUnmatched { projectName: string; serviceName: string; serviceKind: DiscoveryServiceKind; serviceId: string; - reason: DiscoveryUnmatchedReason; + reason: DiscoveryUnmatchedReason | VercelUnmatchedReason; } interface MatchedService extends ServiceDetail { @@ -582,6 +592,18 @@ export interface DiscoveryPlanEnv { gitBranch?: string; /** Already-fetched (`application.one`/`compose.one`) env — plan preview reads this; the real per-environment import step re-fetches fresh rather than trusting a plan-time snapshot that may be stale by the time it runs. */ rawEnv: string | null; + /** + * Vercel plan only (CAP-696): the names this environment will NOT import and + * the stable reason code for each (`gitBranch` for a branch-scoped preview + * record). Names only. Absent for Dokploy. + */ + skipped?: ReadonlyArray<{ name: string; code: string; gitBranch?: string }>; + /** + * Vercel plan only: names Vercel will not give back where this environment's branch already holds a Capy + * value (known from the local keep.lock). The value stays untouched, so they are not in `skipped` or counted + * in `skippedCount`. A branch the run will create is not known yet: its unreadable names stay in `skipped`. + */ + kept?: ReadonlyArray<{ name: string; code: string; reason: string }>; /** * PREVIEW ONLY, and only known when `DiscoveryPlanFolder.initialized` — * whether this environment's branch already exists on the server, i.e. @@ -807,7 +829,7 @@ export interface DiscoveryCollision { } /** Two or more distinct (project, service) groups mapping the same (repoDir, folder, environment). */ -function detectCollisions(folders: readonly DiscoveryPlanFolder[]): readonly DiscoveryCollision[] { +export function detectCollisions(folders: readonly DiscoveryPlanFolder[]): readonly DiscoveryCollision[] { interface Cell { repoDir: string; folder: string; @@ -865,6 +887,8 @@ export interface DiscoveryPlan { allEnvironmentNames: readonly string[]; /** The `--environment` names this plan was filtered to, if any (CAP-657 follow-up) — for the plan header ("filtered to: staging"). Absent when no filter was passed. */ environmentFilter?: readonly string[]; + /** Vercel plan only (CAP-696): the team scope the plan read. Absent for Dokploy and for a personal-account Vercel plan. */ + team?: { id: string; slug?: string }; } /** @@ -1001,7 +1025,7 @@ export function orderEnvironments(environ * ever call `process.exit`: a multi-folder run must be able to abort ONE * folder on a coded failure and continue with the next, which the CLI's own * command classes (`CapyCommand`, `CheckoutCommand`) cannot do as they - * stand today (see `dokploy.ts`'s own doc on `buildRealDiscoverySequenceDeps` + * stand today (see `discoverySteps.ts`'s own doc on `buildRealDiscoverySequenceDeps` * for exactly where this deviates from literally invoking those classes, and * why). */ @@ -1239,11 +1263,4 @@ export type DiscoveryOutcome = /** CAP-703: `BASE_URL_NOT_SAVED` / `BASE_URL_DIFFERS_FROM_STORED`, as codes. */ notices?: ReadonlyArray<{ readonly code: string; readonly reason?: string; readonly stored?: string; readonly used?: string }>; } - | { - ok: false; - /** Stable refusal code — branch on this, never on `message`. */ - code: string; - message: string; - /** CAP-703: what a caller must still supply, as flags (`DOKPLOY_SETTINGS_MISSING`). */ - unanswered?: ReadonlyArray<{ readonly id: string; readonly flag: string; readonly hint: string }>; - }; + | ConnectFailure; diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index 1bc7e0d5..4fbe3e0c 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -1,6 +1,7 @@ import { ConnectorMetadata } from '../../types/index'; import type { Blocked } from '../../ui/screens/contract'; import type { DokployImportSource } from '../../deploy/dokployApi'; +import type { VercelImportSource } from '../../deploy/vercelApi'; import type { DiscoveryContext, DiscoveryOutcome } from './dokployDiscovery'; import { ResolvedContext } from './shared'; import type { RawConflictFlags } from '../../sync/conflicts'; @@ -159,6 +160,28 @@ export interface ConnectOpts { * the connector entry's `environment` field (CAP-673). */ dokployEnvironmentName?: string; + /** + * Vercel import only (CAP-696): the project to import from (flag + * `--project`, id or name). Under `--discover` it is instead the + * collision-winner hint, the same way `application`/`compose` are for + * Dokploy. Other connectors never read this. + */ + project?: string; + /** Vercel import only (CAP-696): team slug or id (flag `--team`). Other connectors never read this. */ + team?: string; + /** + * Vercel DISCOVERY only, set INTERNALLY by `discover()`'s own per-environment + * step — never a CLI flag. The team scope discovery already resolved + * (`id` undefined = the personal account), so the single import it runs + * does not list teams again. + */ + vercelTeam?: { id?: string }; + /** + * Vercel DISCOVERY only, set INTERNALLY alongside `vercelTeam`: the project + * facts the plan already read, so the single import does not fetch the + * project again. + */ + vercelProject?: { id: string; name: string; productionBranch?: string }; } export interface RotateOpts { @@ -222,6 +245,29 @@ export interface ImportedVarEntry { export interface ImportSkip { name: string; code: string; + /** Vercel import only, for `git_branch_scoped`: the branch the record is scoped to — a name, never a value. */ + gitBranch?: string; +} + +/** + * A name the provider has but will not give back, recorded as a placeholder on the branch: it exists + * in Capy with no value until one is set (core/placeholder.ts). `code` is the provider's stable + * reason code; `provider` is the connector that could not read it. + */ +export interface ImportPlaceholder { + name: string; + code: string; + provider: string; +} + +/** + * A name the provider has but will not give back, where Capy already has a value: the Capy value was + * left untouched. `code` is the stable `CAPY_VALUE_KEPT`; `reason` is the provider's own reason code. + */ +export interface ImportKept { + name: string; + code: string; + reason: string; } /** A non-blocking heads-up about the import, grouped by code like `DeployWarning`. */ @@ -243,10 +289,20 @@ export type ImportOutcome = */ applicationId?: string; /** Dokploy import only: which Dokploy object this import read from. */ - source?: DokployImportSource; + source?: DokployImportSource | VercelImportSource; imported: readonly ImportedVarEntry[]; unchanged: readonly string[]; skipped: readonly ImportSkip[]; + /** + * Vercel import only: names Vercel holds but will not give back. `writeImportOutcome` records each + * as a placeholder on the branch; none is in `skipped`. Absent when there are none. + */ + placeholders?: readonly ImportPlaceholder[]; + /** + * Vercel import only: names Vercel holds but will not give back, where Capy already has a value. The value is + * left untouched; none of these is in `skipped` or `placeholders`. Absent when there are none. + */ + kept?: readonly ImportKept[]; warnings: readonly ImportWarning[]; /** * Dokploy import only: names with a different local value that a real @@ -284,14 +340,22 @@ export type ImportOutcome = */ unchangedEntries?: readonly ImportedVarEntry[]; } - | { - ok: false; - /** Stable refusal code — branch on this, never on `message`. */ - code: string; - message: string; - /** CAP-703: what a caller must still supply, as flags (`DOKPLOY_SETTINGS_MISSING`). */ - unanswered?: ReadonlyArray<{ readonly id: string; readonly flag: string; readonly hint: string; readonly alternative?: string }>; - }; + | ConnectFailure; + +/** + * A refusal. `link` + `remedy` (same field names as `Blocked`) say where to get + * a missing or rejected API key and what the human does next. Additive. + */ +export interface ConnectFailure { + ok: false; + /** Stable refusal code — branch on this, never on `message`. */ + code: string; + message: string; + link?: { label: string; url: string }; + remedy?: string; + /** CAP-703: what a caller must still supply, as flags (`DOKPLOY_SETTINGS_MISSING`). */ + unanswered?: ReadonlyArray<{ readonly id: string; readonly flag: string; readonly hint: string; readonly alternative?: string }>; +} export interface ConnectorModule { name: string; @@ -310,6 +374,12 @@ export interface ConnectorModule { * the refusal is not `=== 'dokploy'` reasoning about a human-readable id. */ kind?: 'link' | 'import'; + /** + * The provider's display name in import/discovery output ("from Dokploy", + * "from Vercel"). Omitted means `Dokploy` — the only import connector that + * existed before this field, whose output stays byte-identical. + */ + displayName?: string; /** * Set when rotating runs an interactive auth step the user must complete by * hand (e.g. Stripe shells out to `stripe login`, which opens a browser). @@ -360,6 +430,13 @@ export interface ConnectorModule { * that implement it — no other provider does today. */ discover?(ctx: DiscoveryContext, opts: ConnectOpts): Promise; + /** + * A connector whose discovery plan has its own shape (Vercel: project name + * + id, skipped names grouped by reason) prints and emits it here instead + * of through `ConnectCommand`'s built-in Dokploy-shaped report. Returns + * whether anything was linked, exactly like `executeDiscovery` does. + */ + reportDiscovery?(outcome: Extract, opts: ConnectOpts, provider: string): { linked: boolean }; } /** Registered providers, keyed by name (matches `connector.provider` on each keep.lock entry). */ @@ -367,6 +444,7 @@ export const providers: Record Promise> = { stripe: async () => (await import('./stripe')).stripeConnector, workos: async () => (await import('./workos')).workosConnector, dokploy: async () => (await import('./dokploy')).dokployConnector, + vercel: async () => (await import('./vercel')).vercelConnector, }; export function listProviders(): { name: string; description: string }[] { @@ -375,6 +453,8 @@ export function listProviders(): { name: string; description: string }[] { { name: 'workos', description: 'WorkOS environment API key (sandbox or production)' }, // COPY-FLAG: new user-facing string, minimal/neutral wording. { name: 'dokploy', description: 'One-time import of env vars from a Dokploy Application or Compose service' }, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + { name: 'vercel', description: 'One-time import of env vars from a Vercel project' }, ]; } diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index 448a823f..0323590a 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -7,6 +7,7 @@ import { commitGated, type GateOptions } from '../../sync/gate'; import { KeepFile, ConnectorMetadata, CapyError, ERROR_CODES, AuthResult } from '../../types/index'; import type { ImportOutcome } from './registry'; import { reportRepoLinkForCommand, type RepoLinkWarning } from '../../core/repoLinkReporter'; +import { clearedPlaceholders, withPlaceholders, type PlaceholderRequest } from '../../core/placeholder'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -217,7 +218,9 @@ export async function writeAndSync( }, ): Promise { const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; - const attach = (k: KeepFile): KeepFile => applyConnectors(k, branch, varName, opts.connector, opts.alsoConnect); + // A value is being written for `varName`: it is no longer a placeholder. + const attach = (k: KeepFile): KeepFile => + clearedPlaceholders(applyConnectors(k, branch, varName, opts.connector, opts.alsoConnect), branch, value === undefined ? [] : [varName]); if (!opts.push) { // Local-only path. Even though we're not hitting the service, we still @@ -254,6 +257,8 @@ export async function writeImportedAndSync( entries: ReadonlyArray<{ varName: string; value: string; entry: ConnectorMetadata }>, opts: { push: boolean; + /** Names to record as placeholders on the branch (no value). Written in the same save as `entries`. */ + placeholders?: readonly PlaceholderRequest[]; /** * `--overwrite` clear-only writes: proceed even when `entries` is empty. * Clearing removes names (`opts.clear`) rather than adding an entry, so a @@ -266,7 +271,8 @@ export async function writeImportedAndSync( clear?: readonly string[]; }, ): Promise { - if (entries.length === 0 && !opts.forceWrite) return; + const placeholders = opts.placeholders ?? []; + if (entries.length === 0 && placeholders.length === 0 && !opts.forceWrite) return; const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; const clear = opts.clear ?? []; const also = entries.map((e) => ({ varName: e.varName, entry: e.entry })); @@ -275,14 +281,19 @@ export async function writeImportedAndSync( // (always `undefined` here) is set — so on a clear-only call (`entries` // empty, `forceWrite: true`) this placeholder is never actually consulted, // but `entries[0].varName` would still throw evaluating it eagerly. - const attachAll = (k: KeepFile): KeepFile => applyConnectors(k, branch, entries[0]?.varName ?? '', undefined, also); + const attachAll = (k: KeepFile): KeepFile => + withPlaceholders( + clearedPlaceholders(applyConnectors(k, branch, entries[0]?.varName ?? '', undefined, also), branch, entries.map((e) => e.varName)), + branch, + placeholders, + ); const set = Object.fromEntries(entries.map((e) => [e.varName, e.value])); if (!opts.push) { const finalEnv = { ...Object.fromEntries(Object.entries(localPlaintext).filter(([k]) => !clear.includes(k))), ...set }; const merged = attachAll(keep); if (merged !== keep) fileManager.writeKeepFile(merged); - fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, merged, branch); + fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, merged, branch, clear); return; } @@ -319,13 +330,16 @@ export async function writeImportOutcome( // caller (link-kind connectors don't set it; hand-built outcomes in // existing tests don't either), so this is a strict no-op there. const unchangedEntries = outcome.unchangedEntries ?? []; - const hasWrite = outcome.imported.length > 0 || cleared.length > 0 || unchangedEntries.length > 0; + // A placeholder is a keep.lock entry with no value: recorded in the same save as the imported values. + const placeholders = (outcome.placeholders ?? []).map((p): PlaceholderRequest => ({ name: p.name, marker: { provider: p.provider, reason: p.code } })); + const hasWrite = outcome.imported.length > 0 || cleared.length > 0 || unchangedEntries.length > 0 || placeholders.length > 0; if (!hasWrite) return { wrote: false }; await writeImportedAndSync(ctx, [...outcome.imported, ...unchangedEntries], { push: opts.push, - forceWrite: cleared.length > 0 || unchangedEntries.length > 0, + forceWrite: cleared.length > 0 || unchangedEntries.length > 0 || placeholders.length > 0, clear: cleared, + placeholders, }); return { wrote: true }; } diff --git a/src/commands/connectors/vercel.ts b/src/commands/connectors/vercel.ts new file mode 100644 index 00000000..132503d9 --- /dev/null +++ b/src/commands/connectors/vercel.ts @@ -0,0 +1,901 @@ +/** + * `capy connect vercel` — a ONE-TIME IMPORT of a Vercel project's environment + * variables into this project's `.env`, then encrypted + synced the same way + * every other connector's write lands (CAP-696). It mirrors + * `capy connect dokploy` (`./dokploy.ts`): same flags, same outcomes, same + * safety rules, same discovery sequence. + * + * Import is READ-ONLY on the Vercel side — see `deploy/vercelApi.ts`, whose + * client type has no way to write. The Vercel token is never stored by THIS + * file: it resolves from the org system store's `_CONNECTOR_VERCEL_TOKEN` + * (which the store may prompt for and save, once, on an admin's first use), + * or the variable named by `--token-env` / `$VERCEL_TOKEN`. It is resolved + * before any Vercel request and never prompted for under `--dry-run`, + * `--json` or `--web`. + * + * Environment → Capy branch: `production`, `preview`, `development`, or a + * custom environment's slug. One Vercel record that targets several + * environments counts in each. What is NOT imported is reported by stable + * reason, names only: `git_branch_scoped` (a preview record scoped to one git + * branch — it is not an all-branches preview var), `vercel_system`, + * `unsupported_type`, `duplicate_key`, `empty_value`. + * + * A value Vercel has but will not give back (`sensitive_unreadable`, + * `legacy_secret_unreadable`, or a value route answer with `decrypted: false`, + * `value_not_decrypted`, or a 404 on that one + * record's value route, `value_not_found`) is not skipped: it becomes a PLACEHOLDER on the Capy + * branch of every environment it is in. A placeholder is a flag on the + * variable's keep.lock entry, with no value (`core/placeholder.ts`). A name + * that already has a value in Capy keeps it, untouched, and is reported in + * `kept` (code `CAPY_VALUE_KEPT`), not in `skipped`. + * + * Every refusal for a missing or rejected token carries the Vercel token page + * (`link`) and what to do (`remedy`), with the team's slug when `--team` names it. + * + * "Connect is for retrieval, target is for deploy": this connector imports + * values and NEVER creates, offers or writes a deploy target. + * `capy deploy vercel --discover` is what creates targets, and it pushes no values. + */ +import { syncFolder, type FolderSync, type GateOptions } from '../../sync/gate'; +import { classifyImportCandidates } from '../../deploy/dokployApi'; +import { + DEFAULT_VERCEL_TOKEN_ENV, + VERCEL_TARGET_SECRET_NAME, + VercelApiError, + VercelClient, + VercelCustomEnvironment, + VercelEnvRecord, + VercelEnvironmentInfo, + VercelFetchLike, + VercelImportSource, + VercelProject, + VercelSkippedRecord, + VercelSystemStoreCallOptions, + VercelTeam, + VercelTokenSource, + availableEnvironments, + isPlaceholderReason, + buildEnvironmentPlans, + createVercelClient, + describeVercelTokenProblem, + mapVercelApiError, + parseVarList, + resolveEnvValues, + resolveTeam, + resolveVercelToken, + vercelSecretsMayPrompt, +} from '../../deploy/vercelApi'; +import { formatKeyHelp, keyHelpFields, teamSlugOf, vercelKeyHelp, vercelTokenProblemHelp } from '../../deploy/keyHelp'; +import { createInterface } from 'node:readline'; +import { ConnectorMetadata, ERROR_CODES } from '../../types/index'; +import { isInteractive } from '../../ui/interactive'; +import { listOrgProjectsOrUnavailable } from '../capyCommand'; +import { defaultDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; +import { fingerprint, ResolvedContext } from './shared'; +import { + ConnectFailure, + ConnectOpts, + ConnectorModule, + ConnectResult, + ImportOutcome, + ImportKept, + ImportPlaceholder, + ImportSkip, + ImportWarning, + RotateResult, +} from './registry'; +import { + CandidateRepo, + DiscoveryCollision, + DiscoveryContext, + DiscoveryOutcome, + DiscoveryPlan, + DiscoveryPlanFolder, + applyCollisionResolutions, + findCandidateReposResult, + orderEnvironments, + resolveCollisions, + runDiscoverySequences, +} from './dokployDiscovery'; +import { CONNECTOR_DISCOVERY_CODES } from './discoveryCodes'; +import { + ExistingProjectsSnapshot, + buildRealDiscoverySequenceDeps, + commitForAppliedRepos, + commitPreviewForFolders, + computeOverwritePlan, + computeSameRunNameCollisions, + OverwritePlan, + defaultAskDiscoveryCommitBranchName, + defaultAskDiscoveryProjectName, + defaultAskExistingOrNewProject, + defaultConfirm, + defaultPickCollisionWinner, + discoveryCommitPathsFor, + peekLocalKeepForFolder, +} from './discoverySteps'; +import { buildVercelDiscoveryPlan, reportVercelDiscovery } from './vercelDiscovery'; +import { VERCEL_CONNECTOR_SECRET_NAME } from '../../deploy/vercelApi'; + +const VERCEL_COMMIT_MESSAGE = 'chore(capy): import Vercel environments'; + +type Failure = ConnectFailure; + +// ── Prompts (real `inquirer`; every one is injectable) ────────────────────── + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultSelectVars(candidates: readonly string[]): Promise { + if (candidates.length === 0) return []; + const inquirer = (await import('inquirer')).default; + const { picked } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'picked', + message: 'Which variables to import from Vercel:', + choices: candidates.map((name) => ({ name, value: name, checked: true })), + }, + ]); + return picked; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultPickTeam(teams: readonly VercelTeam[]): Promise { + const inquirer = (await import('inquirer')).default; + const { picked } = await inquirer.prompt([ + { type: 'list', name: 'picked', message: 'Which Vercel team:', choices: teams.map((t) => ({ name: t.slug, value: t.id })) }, + ]); + return picked; +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +async function defaultAskProject(): Promise { + const inquirer = (await import('inquirer')).default; + const { project } = await inquirer.prompt([ + { + type: 'input', + name: 'project', + message: 'Vercel project (id or name):', + validate: (v: string) => (v.trim() ? true : 'required'), + filter: (v: string) => v.trim(), + }, + ]); + return project; +} + +export interface VercelConnectorDeps { + /** Discovery mode only: bare `capy` in one discovered folder (sync/gate.ts `syncFolder`). Injectable for tests. */ + syncFolder?: (dir: string, gate: GateOptions) => Promise; + fetch?: VercelFetchLike; + env?: Record; + cwd?: string; + now?: () => Date; + /** Injectable so a 429 test never really waits. */ + sleep?: (ms: number) => Promise; + confirm?: (message: string, defaultValue: boolean) => Promise; + selectVars?: (candidates: readonly string[]) => Promise; + pickTeam?: (teams: readonly VercelTeam[]) => Promise; + askProject?: () => Promise; + pickCollisionWinner?: (collision: DiscoveryCollision) => Promise; + askExistingOrNewProject?: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; + askDiscoveryProjectName?: (defaultName: string) => Promise; + askDiscoveryCommitBranchName?: (defaultName: string) => Promise; + /** + * Reads the org system store's `_CONNECTOR_VERCEL_TOKEN` entry. Defaults to + * a no-op that never touches the network; the exported `vercelConnector` + * singleton wires the real `system/systemStore.ts` lookup. + */ + getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; + /** + * Asks for a NEW value of the system store entry (hidden input) and saves it + * over the old one. Absent (the default): a rejected token only shows the + * steps and stops; the exported `vercelConnector` singleton wires the real one. + */ + replaceConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; + /** Waits for the person to press Enter; `false` when they abort (Ctrl-C). Used only when `replaceConnectorSecret` is set. */ + waitForEnter?: () => Promise; +} + +/** Waits for Enter on the terminal. Ctrl-C (or a closed input) is `false`. */ +function defaultWaitForEnter(): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + rl.once('SIGINT', () => { + rl.close(); + resolve(false); + }); + rl.once('close', () => resolve(false)); + rl.question('', () => { + resolve(true); + rl.close(); + }); + }); +} + +// ── Connector entry (keep.lock metadata) ──────────────────────────────────── + +/** + * The keep.lock connector entry for one vercel-imported value. Additive: + * `vercel_project_id`, `vercel_project`, `team_id`, `env_id`, + * `configuration_id` (when Vercel set one), plus the generic `provider`, + * `source`, `environment`, `imported_at`, `fingerprint`. + */ +export function buildVercelConnectorEntry( + record: VercelEnvRecord, + value: string, + meta: { project: { id: string; name: string }; teamId?: string; environment: string; createdAtSec: number; importedAt: string }, +): ConnectorMetadata { + return { + provider: 'vercel', + source: 'import', + created_at: meta.createdAtSec, + fingerprint: fingerprint(value), + imported_at: meta.importedAt, + vercel_project_id: meta.project.id, + vercel_project: meta.project.name, + ...(meta.teamId ? { team_id: meta.teamId } : {}), + environment: meta.environment, + env_id: record.id, + ...(record.configurationId ? { configuration_id: record.configurationId } : {}), + }; +} + +// ── Source resolution (token, team, project, environment, records) ────────── + +interface SourceRead { + teamId: string | undefined; + project: { id: string; name: string; productionBranch?: string }; + environment: VercelEnvironmentInfo; + importable: readonly VercelEnvRecord[]; + skipped: readonly VercelSkippedRecord[]; + truncated: boolean; +} + +/** Runs a Vercel read; a failure becomes a coded refusal. `discovery` makes a 403 on the team or project list say discovery needs a token scoped to the whole team. */ +async function guard(fn: () => Promise, discovery: boolean = false): Promise<{ ok: true; value: T } | Failure> { + try { + return { ok: true, value: await fn() }; + } catch (err) { + return { ok: false, ...mapVercelApiError(err, { discovery }) }; + } +} + +/** `getProject` with a 404 as `VERCEL_PROJECT_NOT_FOUND` (checked on the typed error's code, never its text). */ +async function fetchProject(client: VercelClient, idOrName: string, teamId: string | undefined): Promise<{ ok: true; project: VercelProject } | Failure> { + try { + return { ok: true, project: await client.getProject(idOrName, teamId) }; + } catch (err) { + if (err instanceof VercelApiError && err.code === 'not_found') { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_PROJECT_NOT_FOUND, message: `No Vercel project ${idOrName}.` }; + } + return { ok: false, ...mapVercelApiError(err) }; + } +} + +/** Which environment a single import reads: `--environment`, else the one named like the current Capy branch. */ +function chooseEnvironment( + available: readonly VercelEnvironmentInfo[], + requested: string | undefined, + branch: string, +): { ok: true; environment: VercelEnvironmentInfo } | Failure { + const names = available.map((e) => e.name).join(', '); + const want = requested?.trim(); + if (want) { + const hit = available.find((e) => e.name === want); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return hit + ? { ok: true, environment: hit } + : { ok: false, code: ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, message: `No Vercel environment "${want}". Environments: ${names}.` }; + } + const byBranch = available.find((e) => e.name === branch); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return byBranch + ? { ok: true, environment: byBranch } + : { + ok: false, + code: ERROR_CODES.VERCEL_ENVIRONMENT_REQUIRED, + message: `The current branch "${branch}" is not a Vercel environment. Pass --environment . Environments: ${names}.`, + }; +} + +async function pickProjectArg(opts: ConnectOpts, promptable: boolean, askProject: () => Promise): Promise { + const given = opts.project?.trim(); + if (given) return given; + return promptable ? (await askProject()).trim() || null : null; +} + +// ── --overwrite ────────────────────────────────────────────────────────────── + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +function overwriteConfirmMessage(plan: OverwritePlan): string { + const lines = [ + 'Overwrite local vars to match Vercel exactly?', + plan.toImport.length > 0 ? ` Import (${plan.toImport.length}): ${plan.toImport.map((e) => e.name).join(', ')}` : null, + plan.toReplace.length > 0 ? ` Replace (${plan.toReplace.length}): ${plan.toReplace.map((e) => e.name).join(', ')}` : null, + plan.toClear.length > 0 ? ` Clear (${plan.toClear.length}): ${plan.toClear.join(', ')}` : null, + ].filter((l): l is string => l !== null); + return lines.join('\n'); +} + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +const keptNote = (kept: readonly unknown[] | undefined): string => (kept && kept.length > 0 ? `, ${kept.length} kept` : ''); + +// COPY-FLAG: new user-facing string, minimal/neutral wording. +export function discoveryConfirmMessage(folders: readonly DiscoveryPlanFolder[], overwrite: boolean, environmentFilter?: readonly string[]): string { + const totalSteps = folders.reduce((n, f) => n + f.environments.length, 0); + const filterNote = environmentFilter && environmentFilter.length > 0 ? ` (--environment ${environmentFilter.join(',')})` : ''; + const header = `Run ${totalSteps} step(s) across ${folders.length} folder(s) from Vercel${overwrite ? ' (--overwrite)' : ''}${filterNote}?`; + const rows = folders.flatMap((f) => [ + ` ${f.folder || '.'} (${f.projectName})${f.initialized ? '' : ' — capy (init)'}`, + ...orderEnvironments(f.environments).map((e) => { + const checkoutCmd = e.branchExists === true ? `capy checkout ${e.environmentName}` : `capy checkout -b ${e.environmentName}`; + const importCmd = `capy connect vercel --project ${e.serviceId} --environment ${e.environmentName}${overwrite || e.willAutoOverwrite ? ' --overwrite' : ''}`; + return ` ${checkoutCmd} && ${importCmd} (${e.variableCount} var(s), ${e.skippedCount} skipped${keptNote(e.kept)})${e.willAutoOverwrite && !overwrite ? ' [auto-overwrite]' : ''}`; + }), + ]); + return [header, ...rows].join('\n'); +} + +// ── Connector ──────────────────────────────────────────────────────────────── + +export function createVercelConnector(deps: VercelConnectorDeps = {}): ConnectorModule { + const cwd = () => deps.cwd ?? process.cwd(); + const now = deps.now ?? (() => new Date()); + const env = () => deps.env ?? process.env; + const confirm = deps.confirm ?? defaultConfirm; + const selectVars = deps.selectVars ?? defaultSelectVars; + const pickTeam = deps.pickTeam ?? defaultPickTeam; + const askProject = deps.askProject ?? defaultAskProject; + const pickCollisionWinner = deps.pickCollisionWinner ?? defaultPickCollisionWinner; + const askExistingOrNewProject = deps.askExistingOrNewProject ?? defaultAskExistingOrNewProject; + const askDiscoveryProjectName = deps.askDiscoveryProjectName ?? defaultAskDiscoveryProjectName; + const askDiscoveryCommitBranchName = deps.askDiscoveryCommitBranchName ?? defaultAskDiscoveryCommitBranchName; + + /** Where to get a token: the page, the Scope instruction, and the team's slug when `--team` names it. */ + const tokenHelp = (opts: ConnectOpts) => keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME, { team: opts.team })); + + /** The token, then a client — or the coded refusal. Resolved BEFORE any Vercel request, so a missing token means zero requests. */ + const openClient = async ( + orgId: string, + opts: ConnectOpts, + interactive: boolean, + ): Promise<{ ok: true; client: VercelClient; tokenEnv: string; tokenSource: VercelTokenSource } | Failure> => { + const tokenEnv = opts.tokenEnv?.trim() || DEFAULT_VERCEL_TOKEN_ENV; + // `--json` and `--dry-run` each suppress ONLY the system store's hidden + // prompt: a raw terminal prompt must not be interleaved with machine + // output, or save a key as a preview's side effect. + const mayPrompt = vercelSecretsMayPrompt(interactive, !!opts.json || !!opts.dryRun); + const resolved = await resolveVercelToken({ + tokenEnv, + env: env(), + interactive: mayPrompt, + orgId, + devMode: opts.devMode, + getConnectorSecret: deps.getConnectorSecret, + ...(teamSlugOf(opts.team) ? { helpTeam: teamSlugOf(opts.team) } : {}), + }); + if (!resolved.ok) { + const { reason } = describeVercelTokenProblem(resolved.code, tokenEnv); + return { ok: false, code: resolved.code, message: `${reason}.`, ...tokenHelp(opts) }; + } + return { + ok: true, + tokenEnv, + tokenSource: resolved.source, + client: createVercelClient(resolved.value, { fetch: deps.fetch, sleep: deps.sleep }), + }; + }; + + /** Whether a rejected token can be replaced right here: a person at a terminal, a store-held token, and the means to ask. */ + const canReplaceToken = (opts: ConnectOpts, interactive: boolean, tokenSource: VercelTokenSource): boolean => + !!deps.replaceConnectorSecret && + tokenSource === 'system' && + vercelSecretsMayPrompt(interactive, !!opts.json || !!opts.dryRun); + + /** + * A token problem stops the run. At a terminal: say why and list the two + * steps, wait for Enter, ask for a new token (saved over the old one) and + * run again from the token step. One press, one retry: a token that fails + * again shows the steps again and waits again. Anywhere else (or Ctrl-C): + * the same coded refusal, with the steps in `remedy`. + */ + const withTokenRecovery = async ( + orgId: string, + opts: ConnectOpts, + interactive: boolean, + run: (opened: { client: VercelClient; tokenEnv: string }) => Promise, + ): Promise => { + const opened = await openClient(orgId, opts, interactive); + if (!opened.ok) return opened as T; + const outcome = await run(opened); + if (outcome.ok || !isTokenProblem(outcome.code) || !canReplaceToken(opts, interactive, opened.tokenSource)) return outcome; + + // The reason line is the refusal's own message; the two steps are approved copy. + console.error(`\n ${outcome.message}`); + console.error(formatKeyHelp(vercelTokenProblemHelp(VERCEL_CONNECTOR_SECRET_NAME, { pressEnter: true }))); + if (!(await (deps.waitForEnter ?? defaultWaitForEnter)())) return outcome; + + const replaced = await replaceToken(orgId, opts); + if (!replaced.ok) return replaced as T; + return withTokenRecovery(orgId, opts, interactive, run); + }; + + /** The store's hidden prompt for a new token. A refusal (not an admin, …) is returned with its own code; an empty answer saves nothing and stops. */ + const replaceToken = async (orgId: string, opts: ConnectOpts): Promise<{ ok: true } | Failure> => { + try { + const saved = await deps.replaceConnectorSecret!(VERCEL_CONNECTOR_SECRET_NAME, { + orgId, + interactive: true, + devMode: opts.devMode, + ...(teamSlugOf(opts.team) ? { helpTeam: teamSlugOf(opts.team) } : {}), + }); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return saved ? { ok: true } : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, message: 'No token was entered. Nothing was changed.' }; + } catch (err) { + return { ok: false, code: storeRefusalCode(err), message: err instanceof Error ? err.message : String(err) }; + } + }; + + /** Team scope: `--team`, the token's only team, a pick (only when this run can ask), or a coded refusal. */ + const chooseTeam = async ( + client: VercelClient, + opts: ConnectOpts, + promptable: boolean, + discovery: boolean = false, + ): Promise<{ ok: true; teamId: string | undefined; teamSlug?: string } | Failure> => { + const teams = await guard(() => client.listTeams(), discovery); + if (!teams.ok) return teams; + const team = await resolveTeam(teams.value, opts.team, promptable ? pickTeam : null); + return team; + }; + + const readSource = async ( + client: VercelClient, + ctx: ResolvedContext, + opts: ConnectOpts, + promptable: boolean, + ): Promise<({ ok: true } & SourceRead) | Failure> => { + // Discovery's per-environment call hands over the team and project it + // already resolved; a plain import resolves both here. + const team = opts.vercelTeam ? { ok: true as const, teamId: opts.vercelTeam.id } : await chooseTeam(client, opts, promptable); + if (!team.ok) return team; + + const projectInfo = opts.vercelProject + ? ({ ok: true, project: opts.vercelProject } as const) + : await resolveProjectInfo(client, opts, promptable, team.teamId); + if (!projectInfo.ok) return projectInfo; + + const custom = await guard(() => client.listCustomEnvironments(projectInfo.project.id, team.teamId)); + if (!custom.ok) return custom; + const chosen = chooseEnvironment(availableEnvironments(custom.value), opts.environment, ctx.branch); + if (!chosen.ok) return chosen; + const list = await guard(() => client.listEnvs(projectInfo.project.id, team.teamId)); + if (!list.ok) return list; + + const plan = buildEnvironmentPlans(list.value.envs, custom.value).find((p) => p.environment.name === chosen.environment.name); + return { + ok: true, + teamId: team.teamId, + project: projectInfo.project, + environment: chosen.environment, + importable: plan?.importable ?? [], + skipped: plan?.skipped ?? [], + truncated: list.value.truncated, + }; + }; + + const resolveProjectInfo = async ( + client: VercelClient, + opts: ConnectOpts, + promptable: boolean, + teamId: string | undefined, + ): Promise<{ ok: true; project: { id: string; name: string; productionBranch?: string } } | Failure> => { + const arg = await pickProjectArg(opts, promptable, askProject); + if (!arg) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_PROJECT_REQUIRED, message: 'No Vercel project. Pass --project , or run this interactively.' }; + } + const found = await fetchProject(client, arg, teamId); + if (!found.ok) return found; + const productionBranch = found.project.link?.productionBranch; + return { ok: true, project: { id: found.project.id, name: found.project.name, ...(productionBranch ? { productionBranch } : {}) } }; + }; + + /** `--overwrite`'s own run: plan from Vercel's FULL readable set (never `--var`-restricted), confirm, return the outcome for the caller to write. */ + const runOverwriteImport = async (a: { + ctx: ResolvedContext; + opts: ConnectOpts; + source: SourceRead; + importSource: VercelImportSource; + client: VercelClient; + promptable: boolean; + dryRun: boolean; + }): Promise => { + const { ctx, opts, source, importSource, client, promptable, dryRun } = a; + const values = await guard(() => resolveEnvValues(client, source.project.id, source.teamId, source.importable)); + if (!values.ok) return values; + const fullCandidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); + // A name Vercel HAS but will not show us (sensitive, scoped, empty, …) is + // never cleared locally just because it is unreadable. + const unreadable = [...source.skipped, ...values.value.empty, ...values.value.undecrypted, ...values.value.notFound]; + const protectedNames = unreadable.map((s) => s.name); + const plan = computeOverwritePlan(fullCandidates, protectedNames, ctx.localPlaintext); + const warnings = truncationWarnings(source); + + const hasWrite = plan.toClear.length > 0 || plan.toReplace.length > 0 || plan.toImport.length > 0; + if (!dryRun && hasWrite) { + if (promptable && !opts.yes) { + const proceed = await confirm(overwriteConfirmMessage(plan), false); + if (!proceed) { + return { ok: true, source: importSource, imported: [], cleared: [], replacedNames: [], unchanged: plan.unchanged, skipped: [], warnings }; + } + } else if (!promptable && !opts.yes) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED, message: 'Pass --yes to overwrite non-interactively, or run this interactively.' }; + } + } + + const importedAt = now().toISOString(); + const createdAtSec = Math.floor(now().getTime() / 1000); + const recordByName = new Map(values.value.entries.map((e) => [e.record.key, e.record] as const)); + const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); + const written = [...plan.toImport, ...plan.toReplace].map(({ name, value }) => entryFor(name, value)); + const unchangedEntries = plan.unchanged.map((name) => entryFor(name, fullCandidates.find((c) => c.name === name)!.value)); + // A name Vercel will not give back, with no value here, is a placeholder; one that has a local value keeps it and is reported as kept. + const split = splitPlaceholders(unreadable, new Set(Object.keys(ctx.localPlaintext)), new Set(fullCandidates.map((c) => c.name))); + return { + ok: true, + source: importSource, + imported: written, + cleared: plan.toClear, + replacedNames: plan.toReplace.map((e) => e.name), + unchanged: plan.unchanged, + unchangedEntries, + // The names Vercel has but will not show are reported too (`kept`, or + // `placeholders` when Capy has no value): an overwrite leaves them alone. + skipped: split.skips.map(toImportSkip), + placeholders: split.placeholders, + kept: split.kept, + warnings, + }; + }; + + /** Everything after the client exists: read the source, plan, classify, return the outcome. */ + const importFrom = async (ctx: ResolvedContext, opts: ConnectOpts, client: VercelClient, interactive: boolean): Promise => { + // A dry run changes nothing; `--json` never has a prompt interleaved. + const dryRun = !!opts.dryRun; + const promptable = interactive && !opts.json; + + const source = await readSource(client, ctx, opts, promptable); + if (!source.ok) return source; + + const importSource: VercelImportSource = { + kind: 'vercel', + projectId: source.project.id, + projectName: source.project.name, + ...(source.teamId ? { teamId: source.teamId } : {}), + environment: source.environment.name, + }; + + if (opts.overwrite) { + return runOverwriteImport({ ctx, opts, source, importSource, client, promptable, dryRun }); + } + + const restrict = parseVarList(opts.var); + const restricted = source.importable.filter((r) => !restrict || restrict.includes(r.key)); + const values = await guard(() => resolveEnvValues(client, source.project.id, source.teamId, restricted)); + if (!values.ok) return values; + const candidates = values.value.entries.map((e) => ({ name: e.record.key, value: e.value })); + + // A dry run previews the WHOLE plan rather than a hand-picked subset. + const selectedNames = restrict || !promptable || dryRun ? candidates.map((c) => c.name) : await selectVars(candidates.map((c) => c.name)); + const selected = candidates.filter((c) => selectedNames.includes(c.name)); + + const { unchanged, skipped: conflictSkipped, toImport, wouldAsk } = await classifyImportCandidates(selected, ctx.localPlaintext, { + dryRun, + interactive: promptable, + confirm, + providerLabel: 'Vercel', + }); + + const importedAt = now().toISOString(); + const createdAtSec = Math.floor(now().getTime() / 1000); + const recordByName = new Map(values.value.entries.map((e) => [e.record.key, e.record] as const)); + const entryFor = (name: string, value: string) => importedEntry(recordByName.get(name)!, name, value, source, createdAtSec, importedAt); + const imported = toImport.map(({ name, value }) => entryFor(name, value)); + // A re-import over a name whose value already matches still refreshes + // its connector metadata — never populated under a dry run. + const unchangedEntries = dryRun ? [] : unchanged.map((name) => entryFor(name, selected.find((c) => c.name === name)!.value)); + + const inScope = (name: string): boolean => !restrict || restrict.includes(name); + // A name Vercel will not give back, with no value here, is a placeholder on this branch; one that has a value keeps it and is reported as kept. + const split = splitPlaceholders( + [...source.skipped, ...values.value.empty, ...values.value.undecrypted, ...values.value.notFound].filter((s) => inScope(s.name)), + new Set(Object.keys(ctx.localPlaintext)), + new Set(candidates.map((c) => c.name)), + ); + const skipped: readonly ImportSkip[] = [...split.skips.map(toImportSkip), ...conflictSkipped]; + + return { + ok: true, + source: importSource, + imported, + unchanged, + unchangedEntries, + skipped, + placeholders: split.placeholders, + kept: split.kept, + warnings: truncationWarnings(source), + ...(dryRun ? { wouldAsk } : {}), + }; + }; + + /** Everything after the client exists: resolve the team, build the plan, apply it. */ + const discoverFrom = async ( + ctx: DiscoveryContext, + opts: ConnectOpts, + client: VercelClient, + tokenEnv: string, + interactive: boolean, + ): Promise => { + const dryRun = !!opts.dryRun; + const promptable = interactive && !opts.json; + + const team = await chooseTeam(client, opts, promptable, true); + if (!team.ok) return team; + + const scan = findCandidateReposResult(cwd(), CONNECTOR_DISCOVERY_CODES); + const environmentFilter = parseVarList(opts.environment) ?? undefined; + const built = await guard(() => + buildVercelDiscoveryPlan( + client, + team.teamId, + scan.repos as readonly CandidateRepo[], + { peekLocalKeep: peekLocalKeepForFolder, listServerBranches: (projectId) => ctx.serviceClient.listBranches(projectId) }, + environmentFilter, + ), + true, + ); + if (!built.ok) return built; + const plan: DiscoveryPlan = { + ...built.value, + unreadableRepos: scan.unreadable, + ...(team.teamId ? { team: { id: team.teamId, ...(team.teamSlug ? { slug: team.teamSlug } : {}) } } : {}), + }; + + if (environmentFilter) { + const unknown = environmentFilter.filter((name) => !plan.allEnvironmentNames.includes(name)); + if (unknown.length > 0) { + return { + ok: false, + code: ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `No such environment(s): ${unknown.join(', ')}. Environments found: ${plan.allEnvironmentNames.join(', ') || '(none)'}.`, + }; + } + } + + if (dryRun) { + // A dry run never asks for a branch name either; the DEFAULT is what it previews. + const commits = commitPreviewForFolders(plan.folders, defaultDiscoveryCommitBranchName(new Date(), 'vercel'), CONNECTOR_DISCOVERY_CODES, VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: true, plan, ...(commits.length > 0 ? { commits } : {}) }; + } + + // `--project` under `--discover` is the collision-winner hint (an id or a name), not an import source. + const preferredServiceId = preferredProjectId(plan, opts.project); + const resolution = await resolveCollisions(orderEnvironments(plan.collisions), { + preferredServiceId, + interactive: promptable, + askWinner: pickCollisionWinner, + }); + if (!resolution.ok) { + return { + ok: false, + code: ERROR_CODES.VERCEL_MAPPING_COLLISION, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: 'Several Vercel projects map to the same folder + branch. Pass --project to pick one, or run this interactively.', + }; + } + + const resolvedFolders = applyCollisionResolutions(plan.folders, resolution.resolutions); + if (resolvedFolders.length === 0) return { ok: true, dryRun: false, plan, applied: [] }; + + // A real run never writes without a yes: `--yes` skips the ask regardless of TTY. + if (promptable && !opts.yes) { + const proceed = await confirm(discoveryConfirmMessage(resolvedFolders, !!opts.overwrite, environmentFilter), false); + if (!proceed) return { ok: true, dryRun: false, plan, applied: [], cancelled: true }; + } else if (!promptable && !opts.yes) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return { ok: false, code: ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED, message: 'Pass --yes to apply non-interactively, or run this interactively.' }; + } + + const commitBranchName = + promptable && !opts.yes + ? await askDiscoveryCommitBranchName(defaultDiscoveryCommitBranchName(new Date(), 'vercel')) + : defaultDiscoveryCommitBranchName(new Date(), 'vercel'); + + // Snapshotted BEFORE any write, per repo, so the commit step can tell + // "discovery made this change" from "already dirty before the run". + const beforeStatusByRepo = new Map( + [...new Set(resolvedFolders.map((f) => f.repoDir))].map((repoDir) => { + const paths = resolvedFolders.filter((f) => f.repoDir === repoDir).flatMap((f) => discoveryCommitPathsFor(f.folder)); + return [repoDir, snapshotPathStatus(repoDir, paths)] as const; + }), + ); + + // Same snapshot-once rule as Dokploy's non-interactive `--yes` auto-create. + const existingProjectsSnapshot: ExistingProjectsSnapshot | undefined = + !promptable && !!opts.yes ? await listOrgProjectsOrUnavailable(ctx.serviceClient) : undefined; + const sameRunNameCollisions: ReadonlySet = !promptable && !!opts.yes ? computeSameRunNameCollisions(resolvedFolders) : new Set(); + + const sequenceDeps = buildRealDiscoverySequenceDeps(ctx, connector.import!, { + noPush: !!opts.noPush, + interactive: promptable, + json: !!opts.json, + yes: !!opts.yes, + codes: CONNECTOR_DISCOVERY_CODES, + // Discovery never prompts: a folder that differs from Capy is reported (its rows and `conflict_id`), not asked. + gate: { devMode: opts.devMode, json: true, nonTty: true, conflictFlags: opts.conflictFlags }, + syncFolder: deps.syncFolder ?? syncFolder, + importOptsFor: (env, projectName, folderPath) => ({ + project: env.serviceId, + vercelProject: { id: env.serviceId, name: projectName, ...(env.gitBranch ? { productionBranch: env.gitBranch } : {}) }, + vercelTeam: { id: team.teamId }, + environment: env.environmentName, + tokenEnv, + }), + askExistingOrNewProject, + askProjectName: askDiscoveryProjectName, + existingProjectsSnapshot, + sameRunNameCollisions, + }); + const applied = await runDiscoverySequences(resolvedFolders, { overwrite: !!opts.overwrite }, sequenceDeps); + const commits = commitForAppliedRepos(applied, beforeStatusByRepo, commitBranchName, CONNECTOR_DISCOVERY_CODES, VERCEL_COMMIT_MESSAGE); + return { ok: true, dryRun: false, plan, applied, ...(commits.length > 0 ? { commits } : {}) }; + }; + + const connector: ConnectorModule = { + name: 'vercel', + // COPY-FLAG: new user-facing string, minimal/neutral wording. + description: 'One-time import of env vars from a Vercel project', + kind: 'import', + displayName: 'Vercel', + + // Never reached: `connectCommand.ts` routes `kind === 'import'` to import(). + async connect(): Promise { + throw new Error('vercel is import-only; connectCommand should have routed to import() instead.'); + }, + async rotate(): Promise { + throw new Error('vercel is import-only; rotateCommand should have refused before calling rotate().'); + }, + + async import(ctx: ResolvedContext, opts: ConnectOpts): Promise { + const interactive = isInteractive(opts.nonTty); + + return withTokenRecovery(ctx.orgId, opts, interactive, (opened) => importFrom(ctx, opts, opened.client, interactive)); + }, + + /** + * `capy connect vercel --discover`: find every Vercel project whose git + * link matches a repo reachable from `cwd`, then run Dokploy discovery's + * own sequence (`runDiscoverySequences`) over the plan. See + * `vercelDiscovery.ts`. + */ + async discover(ctx: DiscoveryContext, opts: ConnectOpts): Promise { + const interactive = isInteractive(opts.nonTty); + + return withTokenRecovery(ctx.orgId, opts, interactive, (opened) => discoverFrom(ctx, opts, opened.client, opened.tokenEnv, interactive)); + }, + + reportDiscovery: reportVercelDiscovery, + }; + return connector; +} + +// ── small helpers ─────────────────────────────────────────────────────────── + +/** A token problem the person can fix with a new token: rejected (401), or valid without access (403). */ +function isTokenProblem(code: string): boolean { + return code === ERROR_CODES.VERCEL_AUTH_FAILED || code === ERROR_CODES.VERCEL_NO_ACCESS; +} + +/** A typed store error (`CapyError`-shaped: `{code: string}`) as its code, never its text. */ +function storeRefusalCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; +} + +/** + * Splits what Vercel would not give back. A name whose reason is "has a value, not readable" is: + * - a placeholder, when Capy has no value for it; + * - kept (the Capy value is left untouched), when Capy has one and it is not being replaced; + * everything else stays a skip. `local` is every name with a value in Capy; `importing` is every name that is + * being given a value by this run (a name in both is not "kept": its value is not guaranteed to stay). + */ +function splitPlaceholders( + unreadable: readonly VercelSkippedRecord[], + local: ReadonlySet, + importing: ReadonlySet, +): { placeholders: readonly ImportPlaceholder[]; kept: readonly ImportKept[]; skips: readonly VercelSkippedRecord[] } { + const outcomeOf = (s: VercelSkippedRecord): 'placeholder' | 'kept' | 'skip' => { + if (!isPlaceholderReason(s.reason)) return 'skip'; + if (importing.has(s.name)) return 'skip'; + return local.has(s.name) ? 'kept' : 'placeholder'; + }; + const firstOfName = (s: VercelSkippedRecord, i: number, all: readonly VercelSkippedRecord[]): boolean => all.findIndex((o) => o.name === s.name) === i; + const inGroup = (group: 'placeholder' | 'kept' | 'skip') => unreadable.filter((s) => outcomeOf(s) === group); + return { + placeholders: inGroup('placeholder') + .filter(firstOfName) + .map((s): ImportPlaceholder => ({ name: s.name, code: s.reason, provider: 'vercel' })), + kept: inGroup('kept') + .filter(firstOfName) + .map((s): ImportKept => ({ name: s.name, code: ERROR_CODES.CAPY_VALUE_KEPT, reason: s.reason })), + skips: inGroup('skip'), + }; +} + +const toImportSkip = (s: VercelSkippedRecord): ImportSkip => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }); + +function truncationWarnings(source: SourceRead): readonly ImportWarning[] { + // The env list documents no paging parameter, so a "next page" it reports cannot be fetched: say so rather than import silently partial. + return source.truncated ? [{ code: 'VERCEL_ENV_LIST_TRUNCATED', names: [] }] : []; +} + +function importedEntry( + record: VercelEnvRecord, + name: string, + value: string, + source: SourceRead, + createdAtSec: number, + importedAt: string, +): { varName: string; value: string; entry: ConnectorMetadata } { + return { + varName: name, + value, + entry: buildVercelConnectorEntry(record, value, { + project: source.project, + teamId: source.teamId, + environment: source.environment.name, + createdAtSec, + importedAt, + }), + }; +} + +/** `--project` (id or name) → the id of a project in the plan, else the raw value. */ +function preferredProjectId(plan: DiscoveryPlan, project: string | undefined): string | undefined { + const want = project?.trim(); + if (!want) return undefined; + const ids = plan.folders.flatMap((f) => f.environments.map((e) => ({ id: e.serviceId, name: f.projectName }))); + return ids.find((p) => p.id === want || p.name === want)?.id ?? want; +} + +/** + * The real system-store read for the import direction: `_CONNECTOR_VERCEL_TOKEN`, + * borrowing from `_TARGET_VERCEL_TOKEN` (the deploy side) the same way Dokploy's does. + * Also what `capy deploy vercel --discover` reads, since discovery only reads Vercel. + */ +export async function vercelConnectorSecret(name: string, opts: VercelSystemStoreCallOptions): Promise { + const { getDirectionalConnectorSecret } = await import('../../system/systemStore'); + return getDirectionalConnectorSecret(name, VERCEL_TARGET_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING, + }); +} + +/** The real store prompt that replaces `_CONNECTOR_VERCEL_TOKEN` with a new token (owners/admins only). */ +export async function vercelReplaceConnectorSecret(name: string, opts: VercelSystemStoreCallOptions): Promise { + const { replaceConnectorSecret } = await import('../../system/systemStore'); + return replaceConnectorSecret(name, opts); +} + +/** + * The `capy connect vercel` production instance — the only place this file + * wires the org system store for real (every other construction, including + * every test, gets the safe env-only default — see `VercelConnectorDeps`). + */ +export const vercelConnector: ConnectorModule = createVercelConnector({ + getConnectorSecret: vercelConnectorSecret, + replaceConnectorSecret: vercelReplaceConnectorSecret, +}); diff --git a/src/commands/connectors/vercelDiscovery.ts b/src/commands/connectors/vercelDiscovery.ts new file mode 100644 index 00000000..06208b53 --- /dev/null +++ b/src/commands/connectors/vercelDiscovery.ts @@ -0,0 +1,399 @@ +/** + * Discovery mode for `capy connect vercel --discover` (CAP-696): find every + * Vercel project whose git link matches a repo reachable from `cwd`, then run + * the SAME init → per-environment checkout → import sequence Dokploy + * discovery runs (`dokployDiscovery.ts#runDiscoverySequences`, reused + * unchanged), ending in the same local, never-pushed discovery commit. + * + * What differs from Dokploy, and why: + * - Matching. A Vercel project carries `link: { type: 'github', org, repo }` + * and a `rootDirectory`. The repo is matched on `origin` owner/repo exactly + * as Dokploy's is; the folder is the normalized `rootDirectory` (null or + * '' = the repo root). Only GitHub links are matched: the docs show no + * GitLab/Bitbucket link shape, so those projects are REPORTED + * (`unsupported_git_provider`), never guessed at. + * - Nesting. A Vercel project has exactly one folder, so there is no nested- + * folder folding: two projects on the same repo + folder are a collision, + * handled by the same `detectCollisions` / `resolveCollisions` Dokploy + * uses. + * - Names only. The plan reads each project's env LIST (no values) and + * counts what is readable by type. No secret value is read, held, or + * printed until a real run's per-environment import fetches it. + * + * READ-ONLY on the Vercel side: `listProjects`, `listCustomEnvironments`, + * `listEnvs` — see `deploy/vercelApi.ts`. + */ +import { existsSync } from 'node:fs'; +import { sortedCopy } from '../../deploy/dokployApi'; +import { hasValueOnBranch, keptReport, placeholdersReport } from '../../core/placeholder'; +import { join } from 'node:path'; +import { + VercelClient, + VercelProject, + VercelEnvironmentPlan, + PLACEHOLDER_SKIP_REASONS, + buildEnvironmentPlans, + groupSkipped, +} from '../../deploy/vercelApi'; +import { ERROR_CODES, type KeepFile } from '../../types/index'; +import { + CandidateRepo, + DiscoveryFolderResult, + DiscoveryOutcome, + DiscoveryPlan, + DiscoveryPlanEnv, + DiscoveryPlanFolder, + DiscoveryUnmatched, + PeekLocalKeep, + ListServerBranches, + VercelUnmatchedReason, + detectCollisions, + normalizeRelativeDir, + orderEnvironments, +} from './dokployDiscovery'; +import type { ConnectOpts } from './registry'; + +/** `listProjects`/`listCustomEnvironments`/`listEnvs` only — the read-only surface planning needs. */ +export type VercelDiscoveryClient = Pick; + +interface MatchedProject { + project: VercelProject; + repoDir: string; + remote: CandidateRepo['remote']; + /** Relative to `repoDir`; '' = repo root. */ + folder: string; + productionBranch?: string; +} + +const unmatchedFrom = (project: VercelProject, reason: VercelUnmatchedReason): DiscoveryUnmatched => ({ + projectName: project.name, + serviceName: project.name, + serviceKind: 'vercel', + serviceId: project.id, + reason, +}); + +/** + * One Vercel project → a local repo + folder, or the reason it did not match. + * Checked in this order so each project gets its most specific reason: + * no link at all, a link this connector cannot match, no local repo with that + * `origin`, a `rootDirectory` that does not exist in the matched repo. + */ +export function matchVercelProject( + project: VercelProject, + repos: readonly CandidateRepo[], + folderExists: (repoDir: string, folder: string) => boolean, +): { kind: 'matched'; value: MatchedProject } | { kind: 'unmatched'; value: DiscoveryUnmatched } { + const link = project.link; + if (!link) return { kind: 'unmatched', value: unmatchedFrom(project, 'no_git_link') }; + if (link.kind !== 'github') return { kind: 'unmatched', value: unmatchedFrom(project, 'unsupported_git_provider') }; + const hit = repos.find((r) => r.remote.owner === link.org.toLowerCase() && r.remote.repo === link.repo.toLowerCase()); + if (!hit) return { kind: 'unmatched', value: unmatchedFrom(project, 'no_remote_match') }; + const folder = normalizeRelativeDir(project.rootDirectory ?? ''); + if (!folderExists(hit.repoDir, folder)) return { kind: 'unmatched', value: unmatchedFrom(project, 'root_directory_missing') }; + return { + kind: 'matched', + value: { project, repoDir: hit.repoDir, remote: hit.remote, folder, ...(link.productionBranch ? { productionBranch: link.productionBranch } : {}) }, + }; +} + +const defaultFolderExists = (repoDir: string, folder: string): boolean => folder === '' || existsSync(join(repoDir, folder)); + +function envFromPlan(plan: VercelEnvironmentPlan, m: MatchedProject): Omit { + return { + environmentName: plan.environment.name, + serviceId: m.project.id, + serviceKind: 'vercel', + variableCount: plan.importable.length, + skippedCount: plan.skipped.length, + // The project's production git branch — informational only, never the Capy + // branch (the Vercel environment is), same role as Dokploy's `gitBranch`. + ...(m.productionBranch ? { gitBranch: m.productionBranch } : {}), + rawEnv: null, + skipped: plan.skipped.map((s) => ({ name: s.name, code: s.reason, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) })), + }; +} + +interface ProjectPlan { + matched: MatchedProject; + envs: ReadonlyArray>; +} + +/** Sequential, one project at a time: deterministic call order, and gentle on Vercel's rate limit. */ +async function readProjectPlans( + client: VercelDiscoveryClient, + teamId: string | undefined, + matched: readonly MatchedProject[], +): Promise { + return matched.reduce>(async (accPromise, m) => { + const acc = await accPromise; + const custom = await client.listCustomEnvironments(m.project.id, teamId); + const list = await client.listEnvs(m.project.id, teamId); + const plans = buildEnvironmentPlans(list.envs, custom); + return [...acc, { matched: m, envs: plans.map((p) => envFromPlan(p, m)) }]; + }, Promise.resolve([])); +} + +type PlanEnvDraft = Omit; + +/** + * Moves the names Vercel will not give back out of `skipped` into `kept` when the folder's local keep.lock + * already holds a value for them on this environment's branch (the import leaves that value alone). + */ +function withKeptSplit(localKeep: KeepFile | null): (e: PlanEnvDraft) => PlanEnvDraft { + return (e) => { + const isKept = (s: { name: string; code: string }): boolean => + PLACEHOLDER_SKIP_REASONS.some((r) => r === s.code) && hasValueOnBranch(localKeep, s.name, e.environmentName); + const skipped = e.skipped ?? []; + const keptRecords = skipped.filter(isKept); + if (keptRecords.length === 0) return e; + const kept = keptRecords + .filter((s, i) => keptRecords.findIndex((o) => o.name === s.name) === i) + .map((s) => ({ name: s.name, code: ERROR_CODES.CAPY_VALUE_KEPT, reason: s.code })); + const remaining = skipped.filter((s) => !isKept(s)); + return { ...e, skipped: remaining, skippedCount: remaining.length, kept }; + }; +} + +async function toPlanFolder( + pp: ProjectPlan, + environmentFilter: ReadonlySet | undefined, + peek: { peekLocalKeep: PeekLocalKeep; listServerBranches: ListServerBranches }, +): Promise { + const envs = environmentFilter ? pp.envs.filter((e) => environmentFilter.has(e.environmentName)) : pp.envs; + if (envs.length === 0) return null; + const { matched } = pp; + const localKeep = peek.peekLocalKeep(matched.repoDir, matched.folder); + const initialized = !!localKeep; + const serverBranchNames = localKeep ? new Set((await peek.listServerBranches(localKeep.project_id)).map((b) => b.name)) : null; + const firstName = orderEnvironments(envs)[0]?.environmentName; + const environments: readonly DiscoveryPlanEnv[] = envs.map(withKeptSplit(localKeep)).map((e) => { + const branchExists = serverBranchNames ? serverBranchNames.has(e.environmentName) : undefined; + const willCreateBranch = branchExists === false || !initialized; + const willAutoOverwrite = willCreateBranch && !(!initialized && firstName === e.environmentName); + return { ...e, ...(branchExists !== undefined ? { branchExists } : {}), willAutoOverwrite }; + }); + return { + repoDir: matched.repoDir, + folder: matched.folder, + projectName: matched.project.name, + serviceName: matched.project.name, + environments, + initialized, + remote: matched.remote, + }; +} + +/** + * Reads every project, matches against `repos`, plans a folder per matched + * project, flags collisions. Apart from the injected client/peek this never + * writes anything — safe under `--dry-run` as-is. + */ +export async function buildVercelDiscoveryPlan( + client: VercelDiscoveryClient, + teamId: string | undefined, + repos: readonly CandidateRepo[], + peek: { peekLocalKeep: PeekLocalKeep; listServerBranches: ListServerBranches }, + environmentFilter?: readonly string[], + folderExists: (repoDir: string, folder: string) => boolean = defaultFolderExists, +): Promise { + const projects = await client.listProjects(teamId); + const results = projects.map((p) => matchVercelProject(p, repos, folderExists)); + const matched = results.flatMap((r) => (r.kind === 'matched' ? [r.value] : [])); + const unmatchedByLink = results.flatMap((r) => (r.kind === 'unmatched' ? [r.value] : [])); + + const projectPlans = await readProjectPlans(client, teamId, matched); + const withVariables = projectPlans.filter((pp) => pp.envs.length > 0); + const noVariables = projectPlans.filter((pp) => pp.envs.length === 0).map((pp) => unmatchedFrom(pp.matched.project, 'no_variables')); + const allEnvironmentNames = sortedCopy([...new Set(withVariables.flatMap((pp) => pp.envs.map((e) => e.environmentName)))]); + const filterSet = environmentFilter && environmentFilter.length > 0 ? new Set(environmentFilter) : undefined; + + const maybeFolders = await Promise.all(withVariables.map((pp) => toPlanFolder(pp, filterSet, peek))); + const folders = maybeFolders.filter((f): f is DiscoveryPlanFolder => f !== null); + return { + folders, + collisions: detectCollisions(folders), + unmatched: [...unmatchedByLink, ...noVariables], + unreadableRepos: [], + allEnvironmentNames, + ...(filterSet ? { environmentFilter: [...filterSet] } : {}), + }; +} + +// ── Reporting (names and codes only — never a value) ─────────────────────── + +const B = (s: string) => `\x1b[1m${s}\x1b[0m`; + +const projectIdOf = (f: DiscoveryPlanFolder): string => f.environments[0]?.serviceId ?? ''; + +function envReport(e: Extract['environments'][number]) { + return { + branch: e.environmentName, + branchCreated: e.branchCreated, + overwrote: e.overwrote, + imported: e.outcome.imported.map((i) => i.varName), + ...(e.outcome.replacedNames ? { replacedNames: e.outcome.replacedNames } : {}), + ...(e.outcome.cleared ? { cleared: e.outcome.cleared } : {}), + unchanged: e.outcome.unchanged, + skipped: e.outcome.skipped, + ...(e.outcome.placeholders ? { placeholders: placeholdersReport(e.outcome.placeholders) } : {}), + ...(e.outcome.kept ? { kept: keptReport(e.outcome.kept) } : {}), + warnings: e.outcome.warnings, + }; +} + +function planReport(plan: DiscoveryPlan) { + return { + ...(plan.team ? { team: plan.team } : {}), + ...(plan.environmentFilter ? { environmentFilter: plan.environmentFilter } : {}), + folders: plan.folders.map((f) => ({ + repoDir: f.repoDir, + folder: f.folder || '.', + vercelProject: f.projectName, + vercelProjectId: projectIdOf(f), + initialized: f.initialized, + environments: f.environments.map((e) => ({ + branch: e.environmentName, + variableCount: e.variableCount, + skippedCount: e.skippedCount, + ...(e.kept && e.kept.length > 0 ? { keptCount: e.kept.length, kept: keptReport(e.kept) } : {}), + skipped: groupSkipped((e.skipped ?? []).map((s) => ({ name: s.name, reason: s.code, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) }))), + ...(e.branchExists !== undefined ? { branchExists: e.branchExists } : {}), + willAutoOverwrite: e.willAutoOverwrite, + })), + })), + collisions: plan.collisions.map((c) => ({ + folder: c.folder || '.', + branch: c.environmentName, + candidates: c.candidates.map((cand) => ({ vercelProject: cand.projectName, vercelProjectId: cand.serviceId })), + })), + unmatched: plan.unmatched.map((u) => ({ vercelProject: u.projectName, vercelProjectId: u.serviceId, reason: u.reason })), + unreadableRepos: plan.unreadableRepos.map((r) => ({ repoDir: r.repoDir, code: r.code, message: r.message })), + }; +} + +function appliedReport(applied: readonly DiscoveryFolderResult[]) { + return applied.map((f) => + f.ok + ? { repoDir: f.repoDir, folder: f.folder || '.', ok: true as const, projectCreated: f.projectCreated, activeBranch: f.activeBranch, environments: f.environments.map(envReport) } + : { repoDir: f.repoDir, folder: f.folder || '.', ok: false as const, code: f.code, message: f.message, environments: f.environments.map(envReport) }, + ); +} + +function commitsReport(commits: NonNullable['commits']>) { + return commits.map((c) => + c.ok + ? c.dryRun + ? { repoDir: c.repoDir, ok: true as const, dryRun: true as const, branch: c.branch, wouldCommitFiles: c.wouldCommitFiles } + : { repoDir: c.repoDir, ok: true as const, dryRun: false as const, branch: c.branch, sha: c.sha, committedFiles: c.committedFiles } + : { repoDir: c.repoDir, ok: false as const, code: c.code, message: c.message }, + ); +} + +/** + * `ConnectorModule.reportDiscovery` for Vercel: under `--json`, exactly ONE + * JSON object on stdout (nothing else, from anything on this path); otherwise + * the human plan. Names and codes only. + */ +export function reportVercelDiscovery( + outcome: Extract, + opts: ConnectOpts, + provider: string, +): { linked: boolean } { + const plan = planReport(outcome.plan); + const applied = outcome.applied ? appliedReport(outcome.applied) : undefined; + const commits = outcome.commits ? commitsReport(outcome.commits) : undefined; + const linked = + !!outcome.applied && + outcome.applied.some((f) => + f.environments.some((e) => e.outcome.imported.length > 0 || (e.outcome.cleared?.length ?? 0) > 0 || (e.outcome.placeholders?.length ?? 0) > 0), + ); + // One folder failing still lets the others run, but the run as a whole did + // not fully succeed — CI watching the exit code must see that. + if (outcome.applied?.some((f) => !f.ok)) process.exitCode = 1; + + if (opts.json) { + console.log( + JSON.stringify({ + ok: true, + provider, + dryRun: outcome.dryRun, + cancelled: !!outcome.cancelled, + plan, + ...(applied ? { applied } : {}), + ...(commits ? { commits } : {}), + }), + ); + return { linked }; + } + + const lines = humanLines(outcome, plan, applied, commits); + lines.forEach((l) => console.log(l)); + return { linked }; +} + +// COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. +function humanLines( + outcome: Extract, + plan: ReturnType, + applied: ReturnType | undefined, + commits: ReturnType | undefined, +): readonly string[] { + const filterNote = plan.environmentFilter ? ` (--environment ${plan.environmentFilter.join(',')})` : ''; + return [ + '', + outcome.dryRun ? ` Dry run — nothing written.${filterNote}` : ` Discovery plan:${filterNote}`, + ...(plan.unreadableRepos.length > 0 + ? [' ⚠ Repos git could not read:', ...plan.unreadableRepos.map((r) => ` ${r.repoDir} (${r.code}): ${r.message}`)] + : []), + ...(plan.folders.length === 0 ? [' No matching Vercel projects found.'] : []), + ...plan.folders.flatMap((f) => [ + ` ${B(f.folder)} (${f.vercelProject})${f.initialized ? '' : ' — capy (init)'}`, + ...f.environments.flatMap((e) => [ + ` ${e.branchExists === true ? 'checkout' : 'checkout -b'} ${e.branch}: ${e.variableCount} var(s), ${e.skippedCount} skipped${e.keptCount ? `, ${e.keptCount} kept` : ''}${e.willAutoOverwrite ? ' [auto-overwrite]' : ''}`, // COPY-FLAG: new user-facing string, minimal/neutral wording. + ...(e.kept ? [` kept (Capy value untouched): ${e.kept.map((k) => `${k.name} (${k.reason})`).join(', ')}`] : []), // COPY-FLAG: new user-facing string, minimal/neutral wording. + ...Object.entries(e.skipped).map( + ([reason, items]) => ` skipped ${reason}: ${items.map((i) => (i.gitBranch ? `${i.name} (${i.gitBranch})` : i.name)).join(', ')}`, + ), + ]), + ]), + ...(plan.collisions.length > 0 + ? [' Collisions:', ...plan.collisions.map((c) => ` ${c.folder} / ${c.branch}: ${c.candidates.map((x) => x.vercelProject).join(', ')}`)] + : []), + ...(plan.unmatched.length > 0 ? [' Unmatched:', ...plan.unmatched.map((u) => ` ${u.vercelProject} (${u.reason})`)] : []), + ...appliedLines(outcome, applied), + ...(commits && commits.length > 0 ? [' Commit:', ...commits.map(commitLine)] : []), + '', + ]; +} + +// COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. +function appliedLines(outcome: Extract, applied: ReturnType | undefined): readonly string[] { + if (outcome.cancelled) return [' Cancelled — nothing written.']; + if (!applied) return []; + return applied.flatMap((f) => [ + ` ${B(f.folder)}${f.ok && f.projectCreated ? ' (new project)' : ''}`, + ...f.environments.flatMap((e) => { + const clearedCount = e.cleared?.length ?? 0; + const placeholderNames = (e.placeholders ?? []).map((p) => p.name); + const keptNames = (e.kept ?? []).map((k) => k.name); + return [ + ` ${e.branch}${e.branchCreated ? ' (new branch)' : ''}${e.overwrote ? ' (--overwrite)' : ''}: ` + + `imported ${e.imported.length}, unchanged ${e.unchanged.length}, skipped ${e.skipped.length}` + + (placeholderNames.length > 0 ? `, no value yet ${placeholderNames.length}` : '') + // COPY-FLAG + (keptNames.length > 0 ? `, kept ${keptNames.length}` : '') + // COPY-FLAG + (clearedCount > 0 ? `, cleared ${clearedCount}` : ''), + ...(placeholderNames.length > 0 ? [` no value yet: ${placeholderNames.join(', ')}`] : []), // COPY-FLAG + ...(keptNames.length > 0 ? [` kept (Capy value untouched): ${keptNames.join(', ')}`] : []), // COPY-FLAG + ]; + }), + ...(f.ok ? (f.activeBranch ? [` folder is on branch ${f.activeBranch}`] : []) : [` ✗ ${f.code}: ${f.message}`]), + ]); +} + +// COPY-FLAG: report lines mirror Dokploy's discovery report word for word, with Vercel substituted; nothing new invented. +function commitLine(c: ReturnType[number]): string { + if (!c.ok) return ` ${c.repoDir}: ✗ ${c.code}: ${c.message}`; + if (c.dryRun) return ` ${c.repoDir}: would commit ${c.wouldCommitFiles.length} file(s) onto ${c.branch}`; + return ` ${c.repoDir}: committed ${c.committedFiles.length} file(s) onto ${c.branch} (${c.sha.slice(0, 8)})`; +} diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 32451983..6615d4a6 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -13,6 +13,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join, basename, dirname } from 'path'; import inquirer from 'inquirer'; +import { formatKeyHelp } from '../deploy/keyHelp'; import { FileManager } from '../files/fileManager'; import { DeployAdapter, @@ -65,6 +66,7 @@ import { SyncEngine } from '../sync/syncEngine'; import type { AuthService } from '../auth/authService'; import type { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from '../core/projectManager'; +import { placeholdersOnBranch } from '../core/placeholder'; import { tmpdir } from 'os'; import { ALL_ADAPTERS, getAdapter, listPlanned } from '../deploy/registry'; import { detectAwsRegion, leafFor } from '../deploy/adapters/awsSsm'; @@ -88,7 +90,11 @@ import { import { classify, isBuildTime } from '../deploy/classify'; import { buildDeployPrBody } from '../deploy/deployPrBody'; import { resolveDokployApiKeyOnce } from '../deploy/dokployApiKeyOnce'; -import { deliveryFor, recordDeliveriesOnServer, targetRefFor } from '../deploy/deliveryRecord'; +import { resolveVercelTokenOnce } from '../deploy/vercelTokenOnce'; +import { resolveVercelSetup } from '../deploy/vercelSetup'; +import { isVercelApiMode } from '../deploy/vercelTargetApi'; +import type { AdapterCallContext } from '../deploy/adapter'; +import { deliveredBefore, deliveryFor, recordDeliveriesOnServer, targetRefFor } from '../deploy/deliveryRecord'; import { deployPlan, unansweredDeployStops, type DeployStopId } from '../core/deployPlan'; import { conflictFlagsOrExit, @@ -151,6 +157,11 @@ export interface DeployCliOptions { * ships it later. `targets` are still recorded — the write happened. */ noDeploy?: boolean; + /** + * `--replace-sensitive`: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking. + * Off a terminal this is the only yes; without it such a deploy is refused (`VERCEL_SENSITIVE_REPLACE_UNCONFIRMED`). + */ + replaceSensitive?: boolean; /** * `--resolve` / `--conflict` as typed (CAP-717): they decide the conflicts between the folder, Capy and the * platform off a terminal. Without them such a conflict stops the deploy (exit 3, the payload on stdout). @@ -412,6 +423,51 @@ async function loadDeploySecrets( } } +// ── Names with no value yet, and replacing a sensitive Vercel value ───────── + +/** The names the picker and the var-set check know on the active branch: the `.env` names, plus the placeholders (no value, but they exist). */ +function branchVarNames(cwd: string): ReadonlySet { + const pm = new ProjectManager(cwd); + const branch = pm.deriveActiveBranch(); + const placeholders = branch ? placeholdersOnBranch(pm.readKeepFile(), branch).map((p) => p.name) : []; + return new Set([...Object.keys(new FileManager(cwd).readEnvFile()), ...placeholders]); +} + +/** + * What the adapter needs to leave placeholders alone and to ask before replacing a sensitive Vercel value for the first time. + * Read from keep.lock; the answer to the question is recorded by the delivery itself (`deliveredBefore`). + */ +function placeholderDeliveryContext( + cwd: string, + target: TargetConfig, + adapter: DeployAdapter, + options: DeployCliOptions, +): Pick { + const keep = new ProjectManager(cwd).readKeepFile(); + const canAsk = process.stdin.isTTY === true && !options.yes && !options.json && !options.dryRun; + return { + placeholders: placeholdersOnBranch(keep, target.branch).map((p) => p.name), + sensitiveReplace: { + delivered: deliveredBefore(keep, target, adapter), + allowed: options.replaceSensitive === true, + ...(canAsk ? { ask: askToReplaceSensitive } : {}), + }, + }; +} + +async function askToReplaceSensitive(names: readonly string[]): Promise { + const ans = await inquirer.prompt([ + { + type: 'confirm', + name: 'yes', + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `Replace the value Vercel holds as sensitive for ${names.join(', ')}? Capy cannot read it, so it cannot tell whether it differs.`, + default: false, + }, + ]); + return !!ans.yes; +} + // ── Targets recording (CAP-679) ───────────────────────────────────────────── /** @@ -1159,6 +1215,8 @@ export async function resolveAdapterOptions( * existing caller that never had an org id to pass, is unaffected. */ orgId?: string, + /** `readOnly`: a dry run — the Vercel setup may read the token from the store but never asks for or saves one. */ + setup?: { readOnly?: boolean }, ): Promise> { if (adapter.id === 'cf-worker') { return await inquirer.prompt([ @@ -1179,41 +1237,10 @@ export async function resolveAdapterOptions( ]); } if (adapter.id === 'vercel') { - // Vercel: code ships via the keep.lock PR (Vercel git CI builds on merge), - // but capy pushes each var as a plaintext Environment Variable into the - // chosen Vercel environment via the vercel CLI — so the build reads them - // natively with no `capy run` decrypt step. Capture the app dir, which - // Vercel environment these vars go to, and — for Preview — exactly which - // git branch that Preview env is wired to. The Preview scope is a GIT - // branch Vercel knows about, which is NOT a capy branch name nor necessarily - // the branch you're checked out on, so we pick from the repo's real branches. - const ans = await inquirer.prompt([ - { - type: 'input', - name: 'projectDir', - message: 'Project directory (contains .vercel/project.json or package.json):', - default: existingOpts.projectDir ?? detectedOpts.projectDir ?? '.', - validate: (v: string) => (v.trim() ? true : 'required'), - }, - { - type: 'list', - name: 'vercelEnv', - message: 'Which Vercel environment should these secrets go to?', - choices: [ - { name: 'Preview — scoped to a specific git branch', value: 'preview' }, - { name: 'Production', value: 'production' }, - ], - default: existingOpts.vercelEnv ?? 'preview', - }, - ]); - // Drop gitBranch entirely for production — it has no meaning there. - return ans.vercelEnv === 'preview' - ? { - projectDir: ans.projectDir, - vercelEnv: 'preview', - gitBranch: await promptVercelGitBranch(cwd, existingOpts.gitBranch), - } - : { projectDir: ans.projectDir, vercelEnv: 'production' }; + // API mode only: the project's dashboard link (or name), checked live + // against Vercel. No `vercel` program, no `.vercel/project.json`, no + // `vercel link`. See `deploy/vercelSetup.ts`. + return (await resolveVercelSetup(existingOpts, orgId, { readOnly: setup?.readOnly })).options; } if (adapter.id === 'cf-pages') { return await inquirer.prompt([ @@ -1364,10 +1391,13 @@ async function resolveGitBaseBranch( cwd: string, mode: DeployMode, existing: TargetConfig | undefined, + /** A branch the platform itself names (Vercel's production branch): it leads the defaults. */ + platformBranch?: string, ): Promise { if (mode !== 'ci') return undefined; const local = listLocalBranches(cwd); const fallback = + platformBranch ?? currentBranch(cwd) ?? existing?.gitBaseBranch ?? (local.includes('main') ? 'main' : local.includes('master') ? 'master' : 'main'); @@ -1417,12 +1447,14 @@ async function pickNewVars(target: TargetConfig, added: readonly string[]): Prom return ans.vars; } -async function runPicker( +export async function runPicker( cwd: string, keep: KeepInfo, existing?: TargetConfig, /** When set, skip adapter-selection (caller has already picked). */ preselectedAdapterId?: string, + /** `readOnly`: a dry run — a setup question that would save a key to the store does not ask. */ + setup?: { readOnly?: boolean }, ): Promise { // Scope the picker to the ACTIVE branch's vars. keep.lock's `variables` is the // union across EVERY branch, so it would offer vars that only exist on @@ -1434,7 +1466,7 @@ async function runPicker( // off a different directory is how the picker ends up ticking a variable the // target does not have. Identical whenever the two agree, which is every // production call. - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); + const branchVarSet = branchVarNames(cwd); const branchVars = keep.variables.filter((v) => branchVarSet.has(v)); // 1. Pick adapter (when not pre-selected). Real adapters are selectable; @@ -1469,7 +1501,12 @@ async function runPicker( // 4. Adapter-specific options. const detectedOpts = (detected.options ?? {}) as Record; const existingOpts = (existing?.options ?? {}) as Record; - const options = await resolveAdapterOptions(adapter, cwd, branchVars, detectedOpts, existingOpts, keep.orgId); + // Vercel's setup also learns the project's production branch, which becomes + // the default for the PR base branch below. + const vercelSetup = adapter.id === 'vercel' ? await resolveVercelSetup(existingOpts, keep.orgId, { readOnly: setup?.readOnly }) : undefined; + const options = vercelSetup + ? vercelSetup.options + : await resolveAdapterOptions(adapter, cwd, branchVars, detectedOpts, existingOpts, keep.orgId, setup); // 5. Var picking — show every var in keep.lock and pre-select the ones // most likely to be relevant for this adapter (runtime for cf-worker, @@ -1525,7 +1562,7 @@ async function runPicker( // shape. Text entry defaulting to the current branch (you usually open the // PR against the branch you're on), then the existing target's saved value, // then main/master. - const gitBaseBranch = await resolveGitBaseBranch(cwd, mode, existing); + const gitBaseBranch = await resolveGitBaseBranch(cwd, mode, existing, vercelSetup?.productionBranch); // 7. Target name. const defaultName = existing?.name ?? `${adapter.id}-${branch}`; @@ -1579,6 +1616,11 @@ export function pushedLine(target: TargetConfig, noDeploy: boolean): string { return noDeploy ? 'pushed, release not triggered' : `pushed to ${target.name}`; } +/** A key refusal says where to get a key and what to do (stderr). Prints nothing for any other refusal. */ +function printKeyHelp(r: { link?: { label: string; url: string }; remedy?: string }): void { + if (r.link && r.remedy) console.error('\n' + formatKeyHelp({ remedy: r.remedy }) + '\n'); +} + function renderResult(result: DeployResult): void { console.log(''); for (const step of result.steps) { @@ -1587,6 +1629,7 @@ function renderResult(result: DeployResult): void { const detail = step.detail ? ` ${DIM(step.detail)}` : ''; const url = step.url ? ` ${step.url}` : ''; console.log(` ${mark} ${step.label}${detail}${url}`); + if (step.status === 'fail') printKeyHelp(step); } // `result.warnings` (e.g. Dokploy's DOKPLOY_SHADOWED_VAR) is printed once, // right after preflight (see the `preflight.warnings` loop above this @@ -2163,8 +2206,10 @@ async function buildAndSaveTarget( existing: TargetConfig | undefined, preselectedAdapterId: string | undefined, announceSaved: boolean, + /** `readOnly`: a dry run — a setup question that would save a key to the store does not ask. */ + setup?: { readOnly?: boolean }, ): Promise> { - const built = await runPicker(cwd, keep, existing, preselectedAdapterId); + const built = await runPicker(cwd, keep, existing, preselectedAdapterId, setup); if (!built) { console.log('Cancelled.'); return stepStop(0); @@ -2261,7 +2306,7 @@ async function resolveTargetById( const sameKind = listTargets(cwd).filter((t) => t.kind === adapter.id); const chosen = await chooseSavedTarget(sameKind, adapter); if (chosen !== '__new__') return stepOk(chosen); - return buildAndSaveTarget(cwd, keep, undefined, adapter.id, true); + return buildAndSaveTarget(cwd, keep, undefined, adapter.id, true, { readOnly: !!options.dryRun }); } /** No name, no `--target`: picker on first use or `--edit`, the only saved target, or a list. */ @@ -2272,7 +2317,7 @@ async function resolveTargetInteractively( ): Promise> { const targets = listTargets(cwd); if (targets.length === 0 || options.edit) { - return buildAndSaveTarget(cwd, keep, options.edit && targets.length === 1 ? targets[0] : undefined, undefined, true); + return buildAndSaveTarget(cwd, keep, options.edit && targets.length === 1 ? targets[0] : undefined, undefined, true, { readOnly: !!options.dryRun }); } if (targets.length === 1) return stepOk(targets[0]); @@ -2292,7 +2337,7 @@ async function resolveTargetInteractively( ], }, ]); - if (ans.name === '__new__') return buildAndSaveTarget(cwd, keep, undefined, undefined, false); + if (ans.name === '__new__') return buildAndSaveTarget(cwd, keep, undefined, undefined, false, { readOnly: !!options.dryRun }); return stepOk(targets.find((t) => t.name === ans.name)!); } @@ -2327,7 +2372,9 @@ async function healVercelGitBranch( options: DeployCliOptions, ): Promise> { const targetOpts = target.options as Record; - if (!(target.kind === 'vercel' && targetOpts.vercelEnv === 'preview' && !targetOpts.gitBranch)) { + // API-mode targets (CAP-696) do not need a git branch: without one, the + // vars apply to every Preview branch — so they are never healed here. + if (!(target.kind === 'vercel' && !isVercelApiMode(targetOpts) && targetOpts.vercelEnv === 'preview' && !targetOpts.gitBranch)) { return stepOk(target); } if (options.yes) { @@ -2362,7 +2409,7 @@ async function reconcileTargetVars( target: TargetConfig, options: DeployCliOptions, ): Promise> { - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); + const branchVarSet = branchVarNames(cwd); const currentVars = keep.variables.filter((v) => branchVarSet.has(v)); // Legacy targets have no `knownVars` baseline; treat current as known so we // don't false-flag intentionally-unselected vars as "newly added". @@ -2408,7 +2455,9 @@ async function confirmOrEdit( target: TargetConfig, adapter: DeployAdapter, mode: DeployMode, - adapterCallCtx: { orgId: string | undefined; devMode: boolean | undefined; interactive: boolean; resolvedApiKey: ResolveDokployApiKeyResult | undefined }, + adapterCallCtx: AdapterCallContext, + /** A dry run: the edit picker's setup may read but never save a key. */ + dryRun: boolean = false, ): Promise> { const summary = mode === 'ci' @@ -2432,7 +2481,7 @@ async function confirmOrEdit( return stepStop(0); } if (action === 'edit') { - const edited = await runPicker(cwd, keep, target); + const edited = await runPicker(cwd, keep, target, undefined, { readOnly: !!dryRun }); if (!edited) { console.log('Cancelled.'); return stepStop(0); @@ -2448,12 +2497,13 @@ async function confirmOrEdit( if (!recheck.ok) { console.error(`${RED('✗')} preflight: ${recheck.reason}`); if (recheck.hint) console.error('\n' + recheck.hint); + printKeyHelp(recheck); return stepStop(1); } // Loop back to the confirm prompt with the edited target. - return confirmOrEdit(cwd, keep, edited, adapter, mode, adapterCallCtx); + return confirmOrEdit(cwd, keep, edited, adapter, mode, adapterCallCtx, dryRun); } - return confirmOrEdit(cwd, keep, target, adapter, mode, adapterCallCtx); + return confirmOrEdit(cwd, keep, target, adapter, mode, adapterCallCtx, dryRun); } // ── Main: capy deploy [name] ─────────────────────────────────────────────── @@ -2559,7 +2609,15 @@ export async function deployCommand( !!options.yes || !!options.dryRun, ); const dokployApiKey = await resolveDokployApiKeyOnce(adapter, reconciled.value, keep.orgId, options.devMode, secretsInteractive); - const adapterCallCtx = { orgId: keep.orgId, devMode: options.devMode, interactive: secretsInteractive, resolvedApiKey: dokployApiKey }; + // Vercel API mode only (CAP-696) — same once-per-command resolution, same prompt rules. + const vercelToken = await resolveVercelTokenOnce(adapter, reconciled.value, keep.orgId, options.devMode, secretsInteractive); + const adapterCallCtx: AdapterCallContext = { + orgId: keep.orgId, + devMode: options.devMode, + interactive: secretsInteractive, + resolvedApiKey: dokployApiKey, + resolvedVercelToken: vercelToken, + }; // Preflight (fail BEFORE decryption). At a terminal, a failed preflight is // not a dead end: the user can edit the target (e.g. a wrong composeId) and @@ -2574,12 +2632,13 @@ export async function deployCommand( if (result.ok) return { ok: true, target: t, preflight: result }; console.error(`${RED('✗')} preflight: ${result.reason}`); if (result.hint) console.error('\n' + result.hint); + printKeyHelp(result); if (!canFixInteractively) return { ok: false }; // COPY-FLAG: minimal neutral wording, pending Vince's approval. const action = await keypressConfirm({ message: 'Preflight failed. Press e to edit this target, c to check again, esc to cancel.' }); if (action === 'confirm') return preflightOrEdit(t); if (action !== 'edit') return { ok: false }; - const edited = await runPicker(cwd, keep, t); + const edited = await runPicker(cwd, keep, t, undefined, { readOnly: !!options.dryRun }); if (!edited) return { ok: false }; upsertTarget(cwd, edited); console.log(GREEN(`✓ Saved target "${edited.name}" to .capy/deploy.json`)); @@ -2605,7 +2664,7 @@ export async function deployCommand( // `capy deploy --edit`, then re-run. const confirmed: StepResult = !options.yes && !options.dryRun - ? await confirmOrEdit(cwd, keep, preflightOutcome.target, adapter, mode, adapterCallCtx) + ? await confirmOrEdit(cwd, keep, preflightOutcome.target, adapter, mode, adapterCallCtx, !!options.dryRun) : { ok: true, value: preflightOutcome.target }; if (!confirmed.ok) return confirmed.code; const target = confirmed.value; @@ -2761,6 +2820,7 @@ export async function deployCommand( secretsOnly: mode === 'ci', noDeploy: !!options.noDeploy, cwd, + ...(adapter.id === 'vercel' ? placeholderDeliveryContext(cwd, target, adapter, options) : {}), ...adapterCallCtx, // A dry run's delivery stays a plain dry run (it reads nothing): the preview of conflicts was the step before. ...(settled.targetCheck === undefined || options.dryRun ? {} : { targetCheck: settled.targetCheck }), diff --git a/src/commands/deployDiscover/apply.ts b/src/commands/deployDiscover/apply.ts index e513c099..e7b99c5c 100644 --- a/src/commands/deployDiscover/apply.ts +++ b/src/commands/deployDiscover/apply.ts @@ -20,7 +20,8 @@ import { ERROR_CODES } from '../../types/index'; import type { TargetConfig } from '../../deploy/adapter'; import type { GithubApi, RepoRef } from '../../deploy/githubApi'; import { codeForApiFailure, openFilesPullRequest, resolveDefaultBase } from '../keepLockPr'; -import { ExistingTarget, ParsedDeployJson, byText, configuredTarget, parseDeployJson, uniqueSorted } from './facts'; +import { ExistingTarget, ParsedDeployJson, ServiceFacts, byText, configuredTarget, parseDeployJson, uniqueSorted } from './facts'; +import type { KindOverrides } from './kind'; import { TargetToWrite, uniqueName } from './evaluate'; import { sortedCopy } from '../../deploy/dokployApi'; @@ -58,6 +59,8 @@ export interface ApplyResult { export interface ApplyDeps { readonly github: GithubApi | undefined; readonly branchName: () => string; + /** The provider's overrides; absent: Dokploy. */ + readonly kind?: KindOverrides; } const repoLabel = (repo: RepoRef): string => `${repo.owner}/${repo.name}`; @@ -72,8 +75,7 @@ export function mergeDeployJson(parsed: Extract, interface WrittenTarget { readonly target: TargetConfig; - readonly serviceKind: 'application' | 'compose'; - readonly serviceId: string; + readonly service: ServiceFacts; } interface FilePlan { @@ -90,26 +92,28 @@ async function planFile( base: string, path: string, targets: readonly TargetToWrite[], + kind?: KindOverrides, ): Promise<{ readonly ok: true; readonly plan: FilePlan } | { readonly ok: false; readonly code: string }> { + const configured = (existing: readonly ExistingTarget[], t: TargetToWrite): ExistingTarget | undefined => + kind === undefined ? configuredTarget(existing, t.service.service_id, t.entry.branch) : kind.configured(existing, t.service, t.entry.branch); const read = await github.getFile(repo, path, base); if (!read.ok) return { ok: false, code: codeForApiFailure(read, ERROR_CODES.KEEP_PR_READ_FAILED) }; const parsed = parseDeployJson(read.value); if (!parsed.ok) return { ok: false, code: ERROR_CODES.KEEP_PR_READ_FAILED }; const already = targets.flatMap((t): AppliedAlready[] => { - const hit = configuredTarget(parsed.targets, t.service.service_id, t.entry.branch); + const hit = configured(parsed.targets, t); return hit === undefined ? [] : [{ repo: repoLabel(repo), path, branch: t.entry.branch, service_id: t.service.service_id, target_name: hit.name }]; }); - const fresh = targets.filter((t) => configuredTarget(parsed.targets, t.service.service_id, t.entry.branch) === undefined); + const fresh = targets.filter((t) => configured(parsed.targets, t) === undefined); const named = fresh.reduce((acc, t) => { const taken = new Set([...parsed.targets.map((x: ExistingTarget) => x.name), ...acc.map((x) => x.target.name)]); return [ ...acc, { target: { ...t.target, name: uniqueName(t.target.name, taken) }, - serviceKind: t.service.kind, - serviceId: t.service.service_id, + service: t.service, }, ]; }, []); @@ -125,19 +129,21 @@ async function planFile( } // COPY-FLAG: new user-facing strings (the commit message and the PR body), names and ids only. -function wording(repo: RepoRef, files: readonly FilePlan[]) { +function wording(repo: RepoRef, files: readonly FilePlan[], kind?: KindOverrides) { + const title = kind?.pr.title ?? PR_TITLE; const lines = files.flatMap((f) => - f.written.map( - (w) => - `- \`${w.target.name}\` in \`${f.path}\`: Capy branch \`${w.target.branch}\`, Dokploy ${w.serviceKind} \`${w.serviceId}\`, git branch \`${w.target.gitBaseBranch ?? ''}\``, + f.written.map((w) => + kind !== undefined + ? kind.pr.line({ target: w.target, path: f.path, service: w.service }) + : `- \`${w.target.name}\` in \`${f.path}\`: Capy branch \`${w.target.branch}\`, Dokploy ${w.service.kind} \`${w.service.service_id}\`, git branch \`${w.target.gitBaseBranch ?? ''}\``, ), ); const names = files.flatMap((f) => f.written.map((w) => w.target.name)); return { - commitMessage: [PR_TITLE, '', `Targets: ${names.join(', ')}`].join('\n'), - title: PR_TITLE, + commitMessage: [title, '', `Targets: ${names.join(', ')}`].join('\n'), + title, body: [ - 'Adds Dokploy deploy targets to `.capy/deploy.json`.', + kind?.pr.intro ?? 'Adds Dokploy deploy targets to `.capy/deploy.json`.', '', ...lines, '', @@ -163,7 +169,7 @@ async function applyRepo(repo: RepoRef, targets: readonly TargetToWrite[], deps: const paths = uniqueSorted(targets.map((t) => t.file)); const planned = await Promise.all( paths.map((path) => - planFile(github, repo, base.base, path, targets.filter((t) => t.file === path)), + planFile(github, repo, base.base, path, targets.filter((t) => t.file === path), deps.kind), ), ); const failedRead = planned.find((p) => !p.ok); @@ -173,7 +179,7 @@ async function applyRepo(repo: RepoRef, targets: readonly TargetToWrite[], deps: const changed = files.filter((f) => f.written.length > 0); if (changed.length === 0) return { kind: 'unchanged', already }; - const words = wording(repo, changed); + const words = wording(repo, changed, deps.kind); const opened = await openFilesPullRequest( { repo, diff --git a/src/commands/deployDiscover/command.ts b/src/commands/deployDiscover/command.ts index 902557db..024670be 100644 --- a/src/commands/deployDiscover/command.ts +++ b/src/commands/deployDiscover/command.ts @@ -89,6 +89,7 @@ import { summaryLine, } from './plan'; import { sortedCopy } from '../../deploy/dokployApi'; +import type { KindOverrides } from './kind'; // ── Seams ──────────────────────────────────────────────────────────────────── @@ -109,21 +110,14 @@ export type ContextResult = | { readonly ok: true; readonly orgId: string; readonly client: CapyReads } | { readonly ok: false; readonly code: string; readonly message: string }; -/** Everything external, injected: tests pass fakes; `realDeployDiscoverIo` builds the real one. */ -export interface DeployDiscoverIo { +/** What every provider's discovery needs from the outside, injected: tests pass fakes. */ +export interface CommonIo { readonly devMode: boolean; readonly env: Readonly>; - /** The base URL of a Dokploy target already saved in this folder's deploy.json (the third place the shared resolver looks). */ - readonly savedBaseUrl: () => string | undefined; - /** Opens the org system store for the Dokploy base URL variable. Absent: the variable is not consulted. Rejects with a coded error for a non-admin. */ - readonly openBaseUrlStore?: (orgId: string) => Promise; /** Reads the plan file; throws when it cannot. */ readonly readFile: (path: string) => string; /** Silent auth only: never a prompt, never a browser. */ readonly context: () => Promise; - /** The org system store read for the Dokploy key. */ - readonly getConnectorSecret: (name: string, opts: DokploySystemStoreCallOptions) => Promise; - readonly dokploy: (baseUrl: string, token: string) => DiscoveryDokployClient; /** `undefined` when `gh` is not installed. */ readonly github: () => GithubApi | undefined; readonly branchName: () => string; @@ -131,6 +125,17 @@ export interface DeployDiscoverIo { readonly progress: (line: string) => void; } +/** Everything external, injected: tests pass fakes; `realDeployDiscoverIo` builds the real one. */ +export interface DeployDiscoverIo extends CommonIo { + /** The base URL of a Dokploy target already saved in this folder's deploy.json (the third place the shared resolver looks). */ + readonly savedBaseUrl: () => string | undefined; + /** Opens the org system store for the Dokploy base URL variable. Absent: the variable is not consulted. Rejects with a coded error for a non-admin. */ + readonly openBaseUrlStore?: (orgId: string) => Promise; + /** The org system store read for the Dokploy key. */ + readonly getConnectorSecret: (name: string, opts: DokploySystemStoreCallOptions) => Promise; + readonly dokploy: (baseUrl: string, token: string) => DiscoveryDokployClient; +} + export interface DeployDiscoverResult { readonly exitCode: number; /** The one JSON document the command prints. */ @@ -140,17 +145,17 @@ export interface DeployDiscoverResult { /** Exit code for a refusal that needs a different invocation (the plan to confirm), not a retry. */ const EXIT_NEEDS_INPUT = 3; -function refuse(code: string, error: string, extra: Readonly> = {}): DeployDiscoverResult { +export function refuse(code: string, error: string, extra: Readonly> = {}): DeployDiscoverResult { return { exitCode: code === ERROR_CODES.PLAN_CONFIRM_REQUIRED || code === 'DOKPLOY_SETTINGS_MISSING' ? EXIT_NEEDS_INPUT : 1, body: { ok: false, code, error, ...extra }, }; } -type Step = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly result: DeployDiscoverResult }; +export type Step = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly result: DeployDiscoverResult }; -const done = (value: T): Step => ({ ok: true, value }); -const stop = (result: DeployDiscoverResult): Step => ({ ok: false, result }); +export const done = (value: T): Step => ({ ok: true, value }); +export const stop = (result: DeployDiscoverResult): Step => ({ ok: false, result }); const codeOfError = (err: unknown): { readonly code: string; readonly message: string } => err instanceof CapyError @@ -191,13 +196,27 @@ const extrasBody = (extras: BaseUrlExtras): Readonly> => // ── The world: Dokploy, Capy ───────────────────────────────────────────────── -interface World { +export interface World { readonly orgId: string; readonly services: readonly ServiceFacts[]; readonly links: readonly OrgRepoLink[]; readonly projects: ReadonlyMap; } +/** The Capy side of every provider's world: ONE call each for the repo links and the secret NAME index, however many projects there are (the service limits /orgs). */ +export async function loadCapySide( + io: Pick, + client: CapyReads, + orgId: string, +): Promise }>> { + io.progress('Reading Capy projects…'); // COPY-FLAG + const repos = await capyCall(() => client.getOrgRepos(orgId)); + if (!repos.ok) return repos; + const index = await capyCall(() => client.getSecretIndex(orgId)); + if (!index.ok) return index; + return done({ links: repos.value.repos, projects: capyProjectsOf(repos.value.repos, index.value.rows) }); +} + async function dokployClient(io: DeployDiscoverIo, baseUrl: string, orgId: string): Promise> { const resolved = await resolveDokployApiKey({ tokenEnv: DEFAULT_TOKEN_ENV, @@ -226,7 +245,7 @@ function dokployFailure(err: unknown): DeployDiscoverResult { } /** A Capy call that waits out a 429 (bounded) and reports a failure as its code. */ -async function capyCall(call: () => Promise): Promise> { +export async function capyCall(call: () => Promise): Promise> { const answer = await callWithBackoff(() => call()); if (answer.ok) return done(answer.value); const { code, message } = codeOfError(answer.error); @@ -253,18 +272,9 @@ async function loadWorld( ); if (!details.ok) return stop(dokployFailure(details.err)); - // ONE call each, however many projects there are: the service limits /orgs. - io.progress('Reading Capy projects…'); // COPY-FLAG - const repos = await capyCall(() => client.getOrgRepos(orgId)); - if (!repos.ok) return repos; - const index = await capyCall(() => client.getSecretIndex(orgId)); - if (!index.ok) return index; - return done({ - orgId, - services: details.value.map(serviceFactsOf), - links: repos.value.repos, - projects: capyProjectsOf(repos.value.repos, index.value.rows), - }); + const capy = await loadCapySide(io, client, orgId); + if (!capy.ok) return capy; + return done({ orgId, services: details.value.map(serviceFactsOf), ...capy.value }); } // ── GitHub reads ───────────────────────────────────────────────────────────── @@ -272,12 +282,12 @@ async function loadWorld( const GITHUB_CONCURRENCY = 4; /** `fn` over `items`, a few at a time, results in input order. `fn` must not reject. */ -async function readEach(items: readonly T[], fn: (item: T) => Promise): Promise { +export async function readEach(items: readonly T[], fn: (item: T) => Promise): Promise { const pooled = await runPool({ items, limit: GITHUB_CONCURRENCY, run: async (item) => ({ result: await fn(item) }) }); return pooled.results; } -const refKey = (repo: RepoRef): string => `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}`; +export const refKey = (repo: RepoRef): string => `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}`; /** The default branch of each repo, or the code of why they could not be read. Keyed by `refKey`. */ async function readDefaultBranches( @@ -308,8 +318,8 @@ const isFailure = (v: readonly ExistingTarget[] | ReadFailure): v is ReadFailure // ── Discovery ──────────────────────────────────────────────────────────────── /** Default branches and deploy.json targets for the repos some Dokploy service tracks. */ -async function discoveryGithubFacts( - io: DeployDiscoverIo, +export async function discoveryGithubFacts( + io: Pick, world: World, ): Promise<{ readonly defaultBranches: Readonly>; @@ -376,12 +386,12 @@ async function runDiscovery(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Pro // ── The plan ───────────────────────────────────────────────────────────────── -interface ParsedPlan { +export interface ParsedPlan { readonly entries: readonly PlanEntry[]; readonly plan: Plan; } -function readPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Step { +export function readPlan(opts: Pick, io: Pick): Step { const text = (() => { try { return { ok: true as const, text: io.readFile(opts.plan as string) }; @@ -404,12 +414,12 @@ function readPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo): Step> = {}): DeployDiscoverResult { +export function invalidPlan(errors: readonly PlanError[], extra: Readonly> = {}): DeployDiscoverResult { return refuse(ERROR_CODES.PLAN_INVALID, 'The plan has errors. Nothing was changed.', { errors, ...extra }); // COPY-FLAG } /** GitHub reads a plan needs: each repo's branches, each deploy.json at its repo's default branch. */ -async function planGithubReads(io: DeployDiscoverIo, needs: ReturnType): Promise }> { +export async function planGithubReads(io: Pick, needs: ReturnType): Promise }> { const github = io.github(); const unavailable: ReadFailure = { code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }; if (github === undefined || needs.repos.length === 0) { @@ -437,7 +447,7 @@ async function planGithubReads(io: DeployDiscoverIo, needs: ReturnType @@ -450,7 +460,9 @@ function summaryOf(evaluated: readonly EvaluatedEntry[]): readonly string[] { const normalized: PlanEntry = { ...entry, vars: uniqueSorted(entry.vars) }; return e.status === 'already_configured' ? alreadyConfiguredLine({ project_name: project.project_name, entry: normalized, target_name: e.existingTargetName ?? '' }) - : summaryLine({ project_name: project.project_name, kind: service.kind, entry: normalized }); + : kind === undefined + ? summaryLine({ project_name: project.project_name, kind: service.kind, entry: normalized }) + : kind.summaryLine({ project_name: project.project_name, service, entry: normalized }); }); } @@ -475,13 +487,36 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P const world = await loadWorld(io, ctx.client, ctx.orgId, dokploy.value, new Set(entries.map((e) => e.service_id))); if (!world.ok) return world.result; - const resolved = resolveEntries(entries, new Map(world.value.services.map((s) => [s.service_id, s])), world.value.projects); + return finishPlan({ opts, io, parsed, planId, world: world.value, scope: baseUrl, extras: extrasBody(extras) }); +} + +/** + * Everything after the world is read, for every provider: check the plan against GitHub and Capy, then print the + * dry run, refuse a real run without its `--confirm`, or write the targets (one PR per repo). + */ +export async function finishPlan(args: { + readonly opts: Pick; + readonly io: Pick; + readonly parsed: ParsedPlan; + readonly planId: string; + readonly world: World; + /** What the targets are written for (Dokploy: the base URL). */ + readonly scope: string; + /** Fields merged into the output after the plan's own (Dokploy: the saved-base-URL report and notices). */ + readonly extras: Readonly>; + readonly kind?: KindOverrides; +}): Promise { + const { opts, io, parsed, planId, world, scope, extras, kind } = args; + const { entries, plan } = parsed; + const confirming = opts.confirm !== undefined && opts.dryRun !== true; + + const resolved = resolveEntries(entries, new Map(world.services.map((s) => [s.service_id, s])), world.projects, kind); const needs = githubNeeds(resolved); const reads = await planGithubReads(io, needs); - const evaluation = evaluatePlan(resolved, reads); + const evaluation = evaluatePlan(resolved, reads, kind); if (evaluation.errors.length > 0) return invalidPlan(evaluation.errors, { plan_id: planId }); - const toWrite = targetsToWrite(evaluation.entries, reads, baseUrl); + const toWrite = targetsToWrite(evaluation.entries, reads, scope, kind); const configured = evaluation.entries .filter((e) => e.status === 'already_configured') .map((e) => ({ @@ -490,7 +525,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P service_id: e.resolved.entry.service_id, target_name: e.existingTargetName ?? '', })); - const summary = summaryOf(evaluation.entries); + const summary = summaryOf(evaluation.entries, kind); const prPreview = uniqueSorted(toWrite.map((t) => t.repoKey)).map((k) => { const mine = toWrite.filter((t) => t.repoKey === k); return { @@ -500,7 +535,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P targets: mine.map((t) => t.target.name), }; }); - const dryBody = { plan, plan_id: planId, summary_lines: summary, already_configured: configured, prs: prPreview, ...extrasBody(extras) }; + const dryBody = { plan, plan_id: planId, summary_lines: summary, already_configured: configured, prs: prPreview, ...extras }; if (!confirming) { if (opts.dryRun === true) { @@ -513,7 +548,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P next_steps: [ { action: 'relay', field: 'summary_lines' }, { action: 'get_approval' }, - { run: `capy deploy dokploy --discover --plan ${opts.plan} --confirm ${planId}` }, + { run: `${kind?.command ?? 'capy deploy dokploy --discover'} --plan ${opts.plan} --confirm ${planId}` }, ], }, }; @@ -525,7 +560,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P ); } - const applied = await applyTargets(toWrite, { github: io.github(), branchName: io.branchName }); + const applied = await applyTargets(toWrite, { github: io.github(), branchName: io.branchName, ...(kind === undefined ? {} : { kind }) }); const failed = applied.failed.length > 0; return { exitCode: failed ? 1 : 0, @@ -537,7 +572,7 @@ async function runPlan(opts: DeployDiscoverOpts, io: DeployDiscoverIo, parsed: P prs: applied.prs, failed: applied.failed, already_configured: configured, - ...extrasBody(extras), + ...extras, }, }; } diff --git a/src/commands/deployDiscover/discovery.ts b/src/commands/deployDiscover/discovery.ts index c769db15..b83e389f 100644 --- a/src/commands/deployDiscover/discovery.ts +++ b/src/commands/deployDiscover/discovery.ts @@ -23,6 +23,7 @@ import { createHash } from 'crypto'; import type { OrgRepoLink } from '../../service/serviceClient'; import { sortedCopy } from '../../deploy/dokployApi'; import { PLAN_SCHEMA_REF } from './schema'; +import type { KindOverrides, SchemaRef } from './kind'; import { CapyProjectFacts, ExistingTarget, @@ -49,7 +50,9 @@ export type UnresolvedReason = | 'AMBIGUOUS' | 'BRANCH_UNMAPPED' | 'NO_REPO_LINK' - | 'SERVICE_HAS_NO_GIT_SOURCE'; + | 'SERVICE_HAS_NO_GIT_SOURCE' + /** A Vercel preview scoped to one git branch whose variables match none of the Capy branch's: evidence for the preview's branch, not a target. */ + | 'BRANCH_SCOPED_NO_VARIABLES'; /** One entry of the plan file (`--plan`). IDs and names only. */ export interface PlanEntry { @@ -85,7 +88,8 @@ export interface Proposal { /** The Capy branch. */ readonly branch: string; readonly git_branch: string; - readonly target: ProposalTarget; + /** Dokploy: a `ProposalTarget`. Another provider shows its own shape. */ + readonly target: ProposalTarget | Readonly>; readonly evidence: { readonly repo_match: boolean; readonly path_match: boolean; @@ -93,6 +97,8 @@ export interface Proposal { readonly var_overlap: VarOverlap; /** The name of an existing Dokploy target on this project branch that points at a DIFFERENT service (a plan entry for it is refused `TARGET_EXISTS`), else `null`. */ readonly conflicting_target: string | null; + /** Vercel: how the service's git branch was settled, and every signal read for it (CAP-730). Absent for a service that is not judged. */ + readonly git_branch?: Readonly>; }; readonly confidence: { readonly project: Confidence; @@ -140,7 +146,7 @@ export interface ProjectOut { export type NextStep = | { readonly action: 'review'; readonly proposal_ids: readonly string[] } | { readonly action: 'resolve'; readonly service_ids: readonly string[] } - | { readonly action: 'write_plan'; readonly schema: typeof PLAN_SCHEMA_REF } + | { readonly action: 'write_plan'; readonly schema: SchemaRef } | { readonly run: string }; export interface Notice { @@ -149,20 +155,24 @@ export interface Notice { } export interface DeployDiscovery { - readonly base_url: string; + /** Dokploy. Another provider puts its own header fields here instead (`DiscoveryInput.header`). */ + readonly base_url?: string; + readonly teams?: readonly unknown[]; readonly services: readonly ServiceFacts[]; readonly projects: readonly ProjectOut[]; readonly proposals: readonly Proposal[]; readonly already_configured: readonly AlreadyConfigured[]; readonly unresolved: readonly Unresolved[]; readonly next_steps: readonly NextStep[]; - readonly plan_schema_ref: typeof PLAN_SCHEMA_REF; + readonly plan_schema_ref: SchemaRef; readonly notices: readonly Notice[]; } /** Everything discovery reads, already read. */ export interface DiscoveryInput { readonly baseUrl: string; + /** Replaces `base_url` in the document, for a provider that has none. Absent: `{ base_url }`. */ + readonly header?: Readonly>; readonly services: readonly ServiceFacts[]; readonly projects: ReadonlyMap; /** By `repoKey`: the repo's default branch. Absent / `null`: not known. */ @@ -303,10 +313,14 @@ function forProject( service: ServiceFacts, project: CapyProjectFacts, matched: Matched, + kind?: KindOverrides, ): ForProject { const repo = service.repo as RepoOut; const defaultBranch = input.defaultBranches[repoKey(repo)]; - const choice = chooseCapyBranch(service.branch, defaultBranch, service.environment, project.branches); + const choice = + kind === undefined + ? chooseCapyBranch(service.branch, defaultBranch, service.environment, project.branches) + : kind.chooseCapyBranch(service, defaultBranch, project.branches); if (choice === null || service.branch === null) { return { kind: 'unresolved', @@ -319,34 +333,50 @@ function forProject( }; } const gitBranch = service.branch; - const existing = configuredTarget(input.existingTargets[project.project_id] ?? [], service.service_id, choice.branch); + const projectTargets = input.existingTargets[project.project_id] ?? []; + const existing = kind === undefined ? configuredTarget(projectTargets, service.service_id, choice.branch) : kind.configured(projectTargets, service, choice.branch); if (existing !== undefined) { return { kind: 'configured', configured: { project_id: project.project_id, branch: choice.branch, service_id: service.service_id, target_name: existing.name }, }; } - const conflict = conflictingTarget(input.existingTargets[project.project_id] ?? [], service.service_id, choice.branch); + const conflict = kind === undefined ? conflictingTarget(projectTargets, service.service_id, choice.branch) : kind.conflicting(projectTargets, service, choice.branch); const projectNames = project.var_names_by_branch[choice.branch] ?? []; const overlap = overlapOf(service.env_var_names, projectNames); const vars = sharedNames(service.env_var_names, projectNames); + const emptyReason = vars.length === 0 ? kind?.emptyProposalReason?.(service) ?? null : null; + if (emptyReason !== null) { + return { + kind: 'unresolved', + unresolved: { + reason: emptyReason, + service_id: service.service_id, + project_id: project.project_id, + candidates: [{ project_id: project.project_id, project_name: project.project_name, branch: choice.branch }], + }, + }; + } + const gitBranchEvidence = kind?.gitBranchEvidence?.(service); + const branchConfidence = kind?.branchConfidence?.(service, choice, defaultBranch); const draft: Draft = { proposal_id: proposalIdOf(service.service_id, project.project_id, choice.branch), service_id: service.service_id, project_id: project.project_id, branch: choice.branch, git_branch: gitBranch, - target: targetOf(input.baseUrl, service, choice.branch, gitBranch, vars), + target: kind === undefined ? targetOf(input.baseUrl, service, choice.branch, gitBranch, vars) : kind.proposalTarget(service, choice.branch, gitBranch, vars), evidence: { repo_match: true, path_match: matched.exactPath, branch_match: choice.standard, var_overlap: overlap, conflicting_target: conflict?.name ?? null, + ...(gitBranchEvidence === undefined ? {} : { git_branch: gitBranchEvidence }), }, confidence: { project: matched.exactPath && matched.projects.length === 1 && conflict === undefined ? 'certain' : 'needs_review', - branch: choice.standard && typeof defaultBranch === 'string' ? 'certain' : 'needs_review', + branch: branchConfidence ?? (choice.standard && typeof defaultBranch === 'string' ? 'certain' : 'needs_review'), vars: varsConfidence(overlap, service.env_var_names.length), }, plan_entry: { project_id: project.project_id, branch: choice.branch, service_id: service.service_id, git_branch: gitBranch, vars }, @@ -360,11 +390,11 @@ interface ServiceResult { readonly unresolved: readonly Unresolved[]; } -function resolveService(input: DiscoveryInput, service: ServiceFacts, links: readonly OrgRepoLink[]): ServiceResult { +function resolveService(input: DiscoveryInput, service: ServiceFacts, links: readonly OrgRepoLink[], kind?: KindOverrides): ServiceResult { const match = matchService(service, links, input.projects); if (match.kind === 'unresolved') return { drafts: [], configured: [], unresolved: [match.unresolved] }; const { matched } = match; - const perProject = matched.projects.map((project) => forProject(input, service, project, matched)); + const perProject = matched.projects.map((project) => forProject(input, service, project, matched, kind)); const ambiguous: readonly Unresolved[] = matched.projects.length > 1 ? [ @@ -403,14 +433,14 @@ function withCompeting(drafts: readonly Draft[]): readonly Proposal[] { const needsReview = (p: Proposal): boolean => p.confidence.project === 'needs_review' || p.confidence.branch === 'needs_review' || p.confidence.vars === 'needs_review'; -function nextStepsOf(proposals: readonly Proposal[], unresolved: readonly Unresolved[]): readonly NextStep[] { +function nextStepsOf(proposals: readonly Proposal[], unresolved: readonly Unresolved[], kind?: KindOverrides): readonly NextStep[] { const review = proposals.filter(needsReview).map((p) => p.proposal_id); const unresolvedServices = uniqueSorted(unresolved.flatMap((u) => (u.service_id === undefined ? [] : [u.service_id]))); return [ ...(review.length > 0 ? [{ action: 'review' as const, proposal_ids: review }] : []), ...(unresolvedServices.length > 0 ? [{ action: 'resolve' as const, service_ids: unresolvedServices }] : []), - { action: 'write_plan' as const, schema: PLAN_SCHEMA_REF }, - { run: 'capy deploy dokploy --discover --plan --dry-run' }, + { action: 'write_plan' as const, schema: kind?.schemaRef ?? PLAN_SCHEMA_REF }, + { run: `${kind?.command ?? 'capy deploy dokploy --discover'} --plan --dry-run` }, ]; } @@ -428,13 +458,13 @@ function projectOut(project: CapyProjectFacts, input: DiscoveryInput): ProjectOu } /** The whole discovery document. Pure. */ -export function buildDeployDiscovery(input: DiscoveryInput): DeployDiscovery { +export function buildDeployDiscovery(input: DiscoveryInput, kind?: KindOverrides): DeployDiscovery { const links = [...input.projects.values()].flatMap((p) => p.links); - const results = input.services.map((service) => resolveService(input, service, links)); + const results = input.services.map((service) => resolveService(input, service, links, kind)); const proposals = withCompeting(results.flatMap((r) => r.drafts)); const unresolved = results.flatMap((r) => r.unresolved); return { - base_url: input.baseUrl, + ...(input.header ?? { base_url: input.baseUrl }), services: input.services, projects: sortedCopy( [...input.projects.values()].filter((p) => p.links.length > 0).map((p) => projectOut(p, input)), @@ -443,8 +473,8 @@ export function buildDeployDiscovery(input: DiscoveryInput): DeployDiscovery { proposals, already_configured: results.flatMap((r) => r.configured), unresolved, - next_steps: nextStepsOf(proposals, unresolved), - plan_schema_ref: PLAN_SCHEMA_REF, + next_steps: nextStepsOf(proposals, unresolved, kind), + plan_schema_ref: kind?.schemaRef ?? PLAN_SCHEMA_REF, notices: input.notices ?? [], }; } diff --git a/src/commands/deployDiscover/evaluate.ts b/src/commands/deployDiscover/evaluate.ts index 51158da7..1a6d3fb3 100644 --- a/src/commands/deployDiscover/evaluate.ts +++ b/src/commands/deployDiscover/evaluate.ts @@ -36,6 +36,7 @@ import { uniqueSorted, } from './facts'; import { PlanError } from './plan'; +import type { KindOverrides } from './kind'; export interface ResolvedEntry { readonly index: number; @@ -96,10 +97,12 @@ export function resolveEntries( entries: readonly PlanEntry[], services: ReadonlyMap, projects: ReadonlyMap, + kind?: KindOverrides, ): readonly ResolvedEntry[] { + const duplicateKey = kind?.duplicateKey ?? ((e: PlanEntry): string => `${e.project_id}\u0000${e.branch}`); return entries.map((entry, index) => { const resolved = resolveEntry(entry, index, services, projects); - const duplicate = entries.slice(0, index).some((e) => e.project_id === entry.project_id && e.branch === entry.branch); + const duplicate = entries.slice(0, index).some((e) => duplicateKey(e) === duplicateKey(entry)); return duplicate ? { ...resolved, errors: [...resolved.errors, err(at(index), ERROR_CODES.DUPLICATE_ENTRY)] } : resolved; }); } @@ -153,7 +156,7 @@ export interface Evaluation { readonly entries: readonly EvaluatedEntry[]; } -function githubErrors(r: ResolvedEntry, reads: GithubReads): readonly PlanError[] { +function githubErrors(r: ResolvedEntry, reads: GithubReads, kind?: KindOverrides): readonly PlanError[] { if (r.link === undefined) return []; const branches = reads.branches.get(repoKey(r.link)); const branchErrors: readonly PlanError[] = @@ -170,19 +173,29 @@ function githubErrors(r: ResolvedEntry, reads: GithubReads): readonly PlanError[ ? [err(at(r.index, 'project_id'), ERROR_CODES.KEEP_PR_READ_FAILED)] : isFailure(targets) ? [err(at(r.index, 'project_id'), targets.code)] - : conflictingTarget(targets, r.entry.service_id, r.entry.branch) !== undefined + : conflictOf(targets, r, kind) !== undefined ? [err(at(r.index, 'branch'), ERROR_CODES.TARGET_EXISTS)] : []; return [...branchErrors, ...targetErrors]; } +function conflictOf(targets: readonly ExistingTarget[], r: ResolvedEntry, kind?: KindOverrides): ExistingTarget | undefined { + if (kind === undefined) return conflictingTarget(targets, r.entry.service_id, r.entry.branch); + return r.service === undefined ? undefined : kind.conflicting(targets, r.service, r.entry.branch); +} + +function configuredOf(targets: readonly ExistingTarget[], r: ResolvedEntry, kind?: KindOverrides): ExistingTarget | undefined { + if (kind === undefined) return configuredTarget(targets, r.entry.service_id, r.entry.branch); + return r.service === undefined ? undefined : kind.configured(targets, r.service, r.entry.branch); +} + /** Step 2: the plan against GitHub's branches and the repos' deploy.json files. */ -export function evaluatePlan(resolved: readonly ResolvedEntry[], reads: GithubReads): Evaluation { - const errors = resolved.flatMap((r) => [...r.errors, ...githubErrors(r, reads)]); +export function evaluatePlan(resolved: readonly ResolvedEntry[], reads: GithubReads, kind?: KindOverrides): Evaluation { + const errors = resolved.flatMap((r) => [...r.errors, ...githubErrors(r, reads, kind)]); const entries = resolved.flatMap((r): EvaluatedEntry[] => { if (r.service === undefined || r.project === undefined || r.link === undefined) return []; const targets = reads.deployJson.get(fileKeyOf(r.link)); - const existing = targets === undefined || isFailure(targets) ? undefined : configuredTarget(targets, r.entry.service_id, r.entry.branch); + const existing = targets === undefined || isFailure(targets) ? undefined : configuredOf(targets, r, kind); return [ { resolved: { ...r, service: r.service, project: r.project, link: r.link }, @@ -196,14 +209,14 @@ export function evaluatePlan(resolved: readonly ResolvedEntry[], reads: GithubRe // ── The targets to write ───────────────────────────────────────────────────── -/** `dokploy-`, restricted to the characters a target name allows. */ -export function targetBaseName(branch: string): string { +/** `-` (`dokploy-…` unless asked), restricted to the characters a target name allows. */ +export function targetBaseName(branch: string, provider: string = 'dokploy'): string { const cleaned = branch .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); - return cleaned === '' ? 'dokploy' : `dokploy-${cleaned}`; + return cleaned === '' ? provider : `${provider}-${cleaned}`; } /** `base`, or `base-2`, `base-3`, … when `taken` already has it. */ @@ -255,6 +268,7 @@ export function targetsToWrite( evaluated: readonly EvaluatedEntry[], reads: GithubReads, baseUrl: string, + kind?: KindOverrides, ): readonly TargetToWrite[] { const pending = sortedCopy( evaluated.filter((e) => e.status === 'write'), @@ -271,14 +285,13 @@ export function targetsToWrite( return read === undefined || isFailure(read) ? [] : read.map((t) => t.name); })(); const taken = new Set([...existingNames, ...acc.filter((t) => t.fileKey === key).map((t) => t.target.name)]); - const name = uniqueName(targetBaseName(entry.branch), taken); - const target = dokployTargetOf({ - name, - entry: { ...entry, vars: uniqueSorted(entry.vars) }, - service, - baseUrl, - knownVars: project.var_names_by_branch[entry.branch] ?? [], - }); + const normalizedEntry = { ...entry, vars: uniqueSorted(entry.vars) }; + const knownVars = project.var_names_by_branch[entry.branch] ?? []; + const name = uniqueName(kind === undefined ? targetBaseName(entry.branch) : kind.baseName(normalizedEntry, service), taken); + const target = + kind === undefined + ? dokployTargetOf({ name, entry: normalizedEntry, service, baseUrl, knownVars }) + : kind.targetFor({ name, entry: normalizedEntry, service, link, knownVars }); return [ ...acc, { diff --git a/src/commands/deployDiscover/facts.ts b/src/commands/deployDiscover/facts.ts index 775e080f..94529e90 100644 --- a/src/commands/deployDiscover/facts.ts +++ b/src/commands/deployDiscover/facts.ts @@ -57,7 +57,7 @@ export interface RepoOut { readonly name: string; } -export interface ServiceFacts { +export interface DokployServiceFacts { readonly service_id: string; readonly kind: 'application' | 'compose'; readonly name: string; @@ -71,6 +71,97 @@ export interface ServiceFacts { readonly env_var_names: readonly string[]; } +/** + * What points at a git branch for a Vercel environment (CAP-730). `RECENT_PREVIEW_DEPLOYMENTS`: the branches the + * latest preview deployments built from; `BRANCH_SCOPED_VARIABLES`: preview variables scoped to a branch; + * `GITHUB_DEFAULT_BRANCH`: the repo's default branch, when it is not the production branch; + * `CUSTOM_ENVIRONMENT_RULE`: a custom environment's branch matcher. + */ +export type BranchSignalCode = + | 'RECENT_PREVIEW_DEPLOYMENTS' + | 'BRANCH_SCOPED_VARIABLES' + | 'GITHUB_DEFAULT_BRANCH' + | 'CUSTOM_ENVIRONMENT_RULE'; + +export interface BranchSignal { + readonly code: BranchSignalCode; + /** The branches this signal names, strongest first: `weight` is the deployment count, the variable count, or 1. */ + readonly branches: ReadonlyArray<{ + readonly branch: string; + readonly weight: number; + /** `BRANCH_SCOPED_VARIABLES`: the names of the variables scoped to this branch. Never a value. */ + readonly names?: readonly string[]; + /** `BRANCH_SCOPED_VARIABLES`: those of `names` the all-branches preview also has (Vercel reads the scoped one on that branch). */ + readonly shadows?: readonly string[]; + }>; + /** `RECENT_PREVIEW_DEPLOYMENTS`, in the output: the deployments on branches that are not candidates (feature branches, one-off import branches), as one total. */ + readonly other_branches?: number; + /** `CUSTOM_ENVIRONMENT_RULE`: the matcher as Vercel holds it. */ + readonly rule?: { readonly type: string; readonly pattern: string }; +} + +/** How the git branch of a Vercel service was settled. */ +export type GitBranchBasis = + /** No long-lived branch other than the production branch: the production branch. */ + | 'production_branch_only' + | 'signals_agree' + | 'strongest_signal' + | 'no_signals' + | 'custom_environment_rule' + | 'custom_environment_pattern'; + +/** A Vercel environment of a project that holds variables (CAP-696): one deploy target is one of these. */ +export interface VercelServiceFacts { + /** `:`; see `vercelServiceId`. Opaque to the caller: copy it into a plan. */ + readonly service_id: string; + readonly kind: 'vercel'; + /** The Vercel project's name. */ + readonly name: string; + readonly vercel_project_id: string; + /** The team that owns the project; `null` for a personal-account project. */ + readonly team: { readonly id: string; readonly slug?: string } | null; + /** `production`, `preview`, `development`, or a custom environment's slug. */ + readonly environment: string; + readonly custom_environment_id: string | null; + /** A preview environment scoped to one git branch (its records carry that `gitBranch`); `null`: every preview branch. */ + readonly git_branch_scope: string | null; + /** The project's root directory as Vercel holds it; `null`: the repo root. */ + readonly root_directory: string | null; + readonly repo: RepoOut | null; + /** + * The git branch the service deploys from (the deploy PR base). Production, `development` and a branch-scoped preview: the + * project's production branch. The all-branches preview and a custom environment: judged from `git_branch_signals` (CAP-730), + * the production branch when nothing points elsewhere. `null` when Vercel names none. + */ + readonly branch: string | null; + /** All-branches preview and custom environments only: every signal read for the branch (names and counts, never a value). */ + readonly git_branch_signals?: readonly BranchSignal[]; + /** Set with `git_branch_signals` once the repo's default branch is known: how `branch` was settled. */ + readonly git_branch_basis?: GitBranchBasis; + /** Set with `git_branch_basis`: `certain` only when the signals agree (or a custom environment's rule names one branch). */ + readonly git_branch_confidence?: 'certain' | 'needs_review'; + /** The REPO-RELATIVE folder of the project; `null` when it has no repo. */ + readonly path: string | null; + /** Names only: never a value. */ + readonly env_var_names: readonly string[]; +} + +/** What matching, planning and writing read from a service. */ +export type ServiceFacts = DokployServiceFacts | VercelServiceFacts; + +const VERCEL_STANDARD_ENVS: readonly string[] = ['production', 'preview', 'development']; + +/** + * The service id of a Vercel project + environment: `:production`, + * `:preview`, `:preview@` (a preview scoped + * to one branch), `:custom:`. The same id is read back off an + * existing target's options, so "already configured" is a plain comparison. + */ +export function vercelServiceId(projectId: string, vercelEnv: string, gitBranch?: string | null): string { + const env = VERCEL_STANDARD_ENVS.includes(vercelEnv) ? vercelEnv : `custom:${vercelEnv}`; + return `${projectId}:${env}${vercelEnv === 'preview' && gitBranch ? `@${gitBranch}` : ''}`; +} + /** The NAMES in a Dokploy env text (the Capy-managed block and Capy's own runtime pair left out). The parsed values are dropped on the spot. */ export function envVarNamesOf(rawEnv: string | null): readonly string[] { return uniqueSorted(listImportableEntries(rawEnv).map((entry) => entry.name)); @@ -82,11 +173,12 @@ export function envVarNamesOf(rawEnv: string | null): readonly string[] { * (`owner`, `repository`, `branch`), the same ones `capy connect dokploy * --discover` already matches on. */ -export function serviceFactsOf(detail: ServiceDetail): ServiceFacts { +export function serviceFactsOf(detail: ServiceDetail): DokployServiceFacts { const git = hasGitSource(detail); return { service_id: detail.serviceId, - kind: detail.serviceKind, + // A Dokploy service is an application or a compose (the connect-side `vercel` kind never reaches this). + kind: detail.serviceKind === 'compose' ? 'compose' : 'application', name: detail.serviceName, dokploy_project: detail.projectName, environment: detail.environmentName, @@ -162,7 +254,7 @@ export interface ExistingTarget { readonly name: string; readonly kind: string; readonly branch: string; - /** The Dokploy service the target points at (`composeId` / `applicationId`), when it is a Dokploy target. */ + /** The service the target points at: a Dokploy `composeId` / `applicationId`, or a Vercel project + environment (`vercelServiceId`). */ readonly service_id: string | null; } @@ -187,7 +279,10 @@ export function parseDeployJson(content: string | null): ParsedDeployJson { const target = asObject(value); if (target === undefined) return []; const options = asObject(target.options) ?? {}; - const serviceId = options.composeId ?? options.applicationId; + const vercelProject = target.kind === 'vercel' && typeof options.projectId === 'string' && typeof options.vercelEnv === 'string'; + const serviceId = vercelProject + ? vercelServiceId(options.projectId as string, options.vercelEnv as string, typeof options.gitBranch === 'string' ? options.gitBranch : null) + : (options.composeId ?? options.applicationId); return [ { name, diff --git a/src/commands/deployDiscover/index.ts b/src/commands/deployDiscover/index.ts index fb9969c0..7ca35646 100644 --- a/src/commands/deployDiscover/index.ts +++ b/src/commands/deployDiscover/index.ts @@ -8,10 +8,13 @@ import { readFileSync } from 'node:fs'; import { createDokployClient } from '../../deploy/dokployApi'; import { realBaseUrlStoreOpener, savedDokployTargetUrl } from '../../deploy/dokployBaseUrl'; import { dokployConnectorSecret } from '../connectors/dokploy'; +import { vercelConnectorSecret } from '../connectors/vercel'; import { newPrBranchName } from '../keepLockPr'; import { realGithub } from '../secretsSet'; import { resolveSilentContext } from '../secretsSetCommand'; +import { createVercelClient } from '../../deploy/vercelApi'; import { DeployDiscoverIo, DeployDiscoverOpts, runDeployDokployDiscover } from './command'; +import { DeployVercelDiscoverIo, DeployVercelDiscoverOpts, runDeployVercelDiscover } from './vercel'; export async function realDeployDiscoverIo(devMode: boolean, cwd: string = process.cwd()): Promise { const savedBaseUrl = await savedDokployTargetUrl(cwd); @@ -50,3 +53,41 @@ export async function deployDokployDiscoverCommand( console.log(JSON.stringify(body, null, 2)); return exitCode; } + +/** The real wiring of `capy deploy vercel --discover` (CAP-696): silent auth, the org system store, the real (read-only) Vercel API, the user's own `gh` login. */ +export async function realVercelDiscoverIo(devMode: boolean): Promise { + return { + devMode, + env: process.env, + readFile: (path) => readFileSync(path, 'utf-8'), + context: async () => { + const resolved = await resolveSilentContext(devMode); + return resolved.ok + ? { ok: true, orgId: resolved.context.orgId, client: resolved.context.client } + : { ok: false, code: resolved.code, message: resolved.message }; + }, + // Discovery only reads Vercel: the import-side token, like Dokploy's discovery. + getSecret: vercelConnectorSecret, + // Names only: the client drops any value a response carries. + vercel: (token) => createVercelClient(token, { dropValues: true }), + github: realGithub, + branchName: () => newPrBranchName(new Date(), undefined, 'vercel-targets'), + progress: (line) => { + process.stderr.write(`${line}\n`); + }, + }; +} + +/** + * `capy deploy vercel --discover`: prints ONE JSON document on stdout, in a terminal or not, and returns the exit code. + * `io` is the seam tests use; the real command passes none. + */ +export async function deployVercelDiscoverCommand( + opts: DeployVercelDiscoverOpts, + devMode: boolean = false, + io?: DeployVercelDiscoverIo, +): Promise { + const { exitCode, body } = await runDeployVercelDiscover(opts, io ?? (await realVercelDiscoverIo(devMode))); + console.log(JSON.stringify(body, null, 2)); + return exitCode; +} diff --git a/src/commands/deployDiscover/kind.ts b/src/commands/deployDiscover/kind.ts new file mode 100644 index 00000000..be4ef466 --- /dev/null +++ b/src/commands/deployDiscover/kind.ts @@ -0,0 +1,73 @@ +/** + * What differs between the providers `capy deploy --discover` + * serves, as one set of overrides (CAP-703 Dokploy, CAP-696 Vercel). + * + * The shared modules (`discovery.ts`, `plan.ts`, `evaluate.ts`, `apply.ts`, + * `command.ts`) behave exactly as they always did for Dokploy when no `kind` + * is passed. A provider that passes one replaces ONLY the points below; the + * matching of services to Capy projects, the plan checks, the GitHub reads + * and the one-PR-per-repo write are the same code for both. + * + * Types only: this file imports nothing at runtime, so any module may import it. + */ +import type { TargetConfig } from '../../deploy/adapter'; +import type { OrgRepoLink } from '../../service/serviceClient'; +import type { Confidence, PlanEntry, UnresolvedReason } from './discovery'; +import type { ExistingTarget, ServiceFacts } from './facts'; + +/** A pointer into the `capy help --json` document. */ +export interface SchemaRef { + readonly command: string; + readonly pointer: string; +} + +export interface BranchChoice { + readonly branch: string; + /** The standard mapping, as opposed to a fallback. */ + readonly standard: boolean; +} + +export interface KindOverrides { + /** `capy deploy --discover`: what a next step tells the agent to run, up to the flags. */ + readonly command: string; + readonly schemaRef: SchemaRef; + /** Mixed into `plan_id`, so a Dokploy plan and a Vercel plan never share one. */ + readonly planDomain: string; + /** The Capy branch for a service, or `null` when the project has none that fits. */ + readonly chooseCapyBranch: (service: ServiceFacts, defaultBranch: string | null | undefined, projectBranches: readonly string[]) => BranchChoice | null; + /** The existing target that already is this service's target on this Capy branch. */ + readonly configured: (targets: readonly ExistingTarget[], service: ServiceFacts, capyBranch: string) => ExistingTarget | undefined; + /** An existing target that would clash with a new one for this service on this Capy branch. */ + readonly conflicting: (targets: readonly ExistingTarget[], service: ServiceFacts, capyBranch: string) => ExistingTarget | undefined; + /** Replaces the branch confidence of a proposal (`undefined`: the shared rule stands). */ + readonly branchConfidence?: (service: ServiceFacts, choice: BranchChoice, defaultBranch: string | null | undefined) => Confidence | undefined; + /** What a proposal's `evidence.git_branch` shows: how the service's git branch was settled (names, ids and counts only). */ + readonly gitBranchEvidence?: (service: ServiceFacts) => Readonly> | undefined; + /** + * A service that would be proposed with NO variable is not proposed when this returns a reason: it stays unresolved with that + * reason instead, so an empty proposal never competes with a real one. `null`: propose as usual. + */ + readonly emptyProposalReason?: (service: ServiceFacts) => UnresolvedReason | null; + /** A plan entry's identity: two entries with the same key are `DUPLICATE_ENTRY`. */ + readonly duplicateKey: (entry: PlanEntry) => string; + /** The `target` a proposal shows. */ + readonly proposalTarget: (service: ServiceFacts, branch: string, gitBranch: string, vars: readonly string[]) => Readonly>; + /** The first part of a target's name (made unique per deploy.json by the shared code). */ + readonly baseName: (entry: PlanEntry, service: ServiceFacts) => string; + /** The target `.capy/deploy.json` gets. */ + readonly targetFor: (args: { + readonly name: string; + readonly entry: PlanEntry; + readonly service: ServiceFacts; + readonly link: OrgRepoLink; + readonly knownVars: readonly string[]; + }) => TargetConfig; + /** One line a person reads, for an entry that will be written. */ + readonly summaryLine: (args: { readonly project_name: string; readonly service: ServiceFacts; readonly entry: PlanEntry }) => string; + /** The pull request. Names and ids only. */ + readonly pr: { + readonly title: string; + readonly intro: string; + readonly line: (args: { readonly target: TargetConfig; readonly path: string; readonly service: ServiceFacts }) => string; + }; +} diff --git a/src/commands/deployDiscover/plan.ts b/src/commands/deployDiscover/plan.ts index cebc4269..c2f3fcaa 100644 --- a/src/commands/deployDiscover/plan.ts +++ b/src/commands/deployDiscover/plan.ts @@ -108,9 +108,9 @@ const PLAN_DOMAIN = 'capy:deploy-dokploy:plan:v1'; * with. Opaque, and the same for the same plan however its entries or vars * were ordered. It carries no value: a plan has none. */ -export function planIdOf(plan: Plan, baseUrl: string): string { +export function planIdOf(plan: Plan, baseUrl: string, domain: string = PLAN_DOMAIN): string { const canonical = JSON.stringify({ base_url: baseUrl, version: plan.version, entries: plan.entries }); - return createHash('sha256').update(`${PLAN_DOMAIN}\u0000${canonical}`).digest('hex').slice(0, 16); + return createHash('sha256').update(`${domain}\u0000${canonical}`).digest('hex').slice(0, 16); } /** First four characters and an ellipsis, so a person can tell services apart at a glance. */ @@ -119,7 +119,7 @@ const shortId = (id: string): string => (id.length > 8 ? `${id.slice(0, 4)}…` /** One line a person reads. The agent relays these verbatim. */ export function summaryLine(args: { readonly project_name: string; - readonly kind: 'application' | 'compose'; + readonly kind: string; readonly entry: PlanEntry; }): string { const { project_name, kind, entry } = args; diff --git a/src/commands/deployDiscover/route.ts b/src/commands/deployDiscover/route.ts index af89472a..7b023acb 100644 --- a/src/commands/deployDiscover/route.ts +++ b/src/commands/deployDiscover/route.ts @@ -4,7 +4,7 @@ * * `--discover` is an option of `capy deploy`, not a sub-command: a sub-command named * `dokploy` would take `capy deploy dokploy` away from the target flow. Only the `dokploy` - * target has a discovery; any other target is refused with a code. + * and `vercel` targets have a discovery; any other target is refused with a code. */ import { ERROR_CODES } from '../../types/index'; import { conflictFlagsOrExit, type RawConflictFlags } from '../../sync/conflicts'; @@ -36,8 +36,12 @@ export async function routeDeployDiscover( if (!discovering) { return printRefusal(ERROR_CODES.INVALID_FORMAT, '--plan, --confirm and --base-url need --discover.'); // COPY-FLAG } - if (target !== 'dokploy') { - return printRefusal(ERROR_CODES.DISCOVER_UNSUPPORTED_TARGET, '--discover is only available for the dokploy target: capy deploy dokploy --discover.'); // COPY-FLAG + if (target !== 'dokploy' && target !== 'vercel') { + return printRefusal(ERROR_CODES.DISCOVER_UNSUPPORTED_TARGET, '--discover is only available for the dokploy and vercel targets: capy deploy dokploy --discover, capy deploy vercel --discover.'); // COPY-FLAG + } + // Vercel has no dashboard URL to name: `--base-url` is a Dokploy flag. + if (target === 'vercel' && flags.baseUrl !== undefined) { + return printRefusal(ERROR_CODES.INVALID_FORMAT, '--base-url is only for the dokploy target.'); // COPY-FLAG } // Bare `capy` runs first, before anything is discovered, planned or written (docs/conflict-resolution.md). This run // writes no env: its only writes are `.capy/deploy.json` PRs into the discovered repos, through the GitHub API, so no @@ -45,7 +49,10 @@ export async function routeDeployDiscover( // (exit 3, the payload alone on stdout) and answered by a rerun with `--resolve` + `--conflict`. conflictFlagsOrExit(conflictFlags, true); await gateFirst({ devMode, json: true, nonTty: true, conflictFlags, dryRun }); - const { deployDokployDiscoverCommand } = await import('./index'); + const { deployDokployDiscoverCommand, deployVercelDiscoverCommand } = await import('./index'); + if (target === 'vercel') { + return deployVercelDiscoverCommand({ dryRun, plan: flags.plan, confirm: flags.confirm }, devMode); + } return deployDokployDiscoverCommand( { dryRun, plan: flags.plan, confirm: flags.confirm, baseUrl: flags.baseUrl }, devMode, diff --git a/src/commands/deployDiscover/schema.ts b/src/commands/deployDiscover/schema.ts index 798bb44f..0c0b5689 100644 --- a/src/commands/deployDiscover/schema.ts +++ b/src/commands/deployDiscover/schema.ts @@ -41,3 +41,30 @@ export const DEPLOY_DOKPLOY_PLAN_SCHEMA = { }, }, } as const; + +/** Where the Vercel plan's schema is published (`capy deploy vercel --discover`). */ +export const VERCEL_PLAN_SCHEMA_REF = { command: 'capy help --json', pointer: '/schemas/deploy_vercel_plan' } as const; + +/** + * The plan `capy deploy vercel --discover --plan ` reads: the same shape as the Dokploy plan, with the + * service named by the `service_id` of a Vercel environment (project + environment) from the discovery facts, + * and `git_branch` the project's production branch (the deploy PR base). IDs and names only. + */ +export const DEPLOY_VERCEL_PLAN_SCHEMA = { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA, + title: 'capy deploy vercel --discover plan', // COPY-FLAG + properties: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties, + entries: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties.entries, + items: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties.entries.items, + properties: { + ...DEPLOY_DOKPLOY_PLAN_SCHEMA.properties.entries.items.properties, + service_id: { type: 'string', minLength: 1, description: 'Vercel environment id from the discovery facts (a project plus one of its environments)' }, // COPY-FLAG + git_branch: { type: 'string', minLength: 1, description: 'git branch the Vercel project deploys from (the CI deploy PR base)' }, // COPY-FLAG + }, + }, + }, + }, +} as const; diff --git a/src/commands/deployDiscover/vercel.ts b/src/commands/deployDiscover/vercel.ts new file mode 100644 index 00000000..29698233 --- /dev/null +++ b/src/commands/deployDiscover/vercel.ts @@ -0,0 +1,526 @@ +/** + * `capy deploy vercel --discover` (CAP-696): create Vercel deploy targets for + * the Vercel projects that match Capy projects, and push NO values. + * + * The same command as `capy deploy dokploy --discover`, with Vercel for the + * provider: it ALWAYS prints one JSON document on stdout, never prompts and + * never opens a browser; three modes (facts, `--plan f --dry-run`, + * `--plan f --confirm ID`); ONE pull request per repo. The shared modules do + * the matching, the plan checks, the GitHub reads and the PR writing; this + * file is what is Vercel's own: reading the projects, the service facts, and + * the `KindOverrides` that turn a plan entry into a Vercel target. + * + * The git branch of a service (the deploy PR base): production's is the project's production branch. The all-branches + * preview's is one of the long-lived branches (production, the GitHub default branch, branches with scoped variables), + * ranked by recent preview deployments; a custom environment's is its branch rule (CAP-730, see `vercelBranch.ts`). + * The signals are listed in the proposal's evidence. + * + * A "service" is one ENVIRONMENT of one Vercel project that holds variables: + * production, preview, each custom environment, and `development` only when + * it is the project's only environment with variables. A preview whose + * records are scoped to a git branch is its own service per branch. The + * deploy target is one per service. + * + * Names and ids only. The env list is asked for WITHOUT `decrypt` and the + * client drops any `value` the response carries (`dropValues`); the per-id + * value route is never called. The token is read from the org system store + * (`_TARGET_VERCEL_TOKEN`, which may be a reference to + * `_CONNECTOR_VERCEL_TOKEN`) or `$VERCEL_TOKEN`, and never asked for. + * + * Vercel REST endpoints (docs checked 2026-11-02, see `deploy/vercelApi.ts`): + * GET /v2/teams teams[].{id,slug,name}, pagination.next (`until`) + * GET /v10/projects?teamId= projects[].{id,name,rootDirectory,link}, pagination.next (`from`) + * GET /v9/projects/{id}/custom-environments environments[].{id,slug} + * GET /v10/projects/{id}/env?teamId= envs[].{key,type,target,gitBranch,customEnvironmentIds} + * GET /v6/deployments?projectId=&target=preview&limit= deployments[].{uid,meta.githubCommitRef} + */ +import { posix } from 'node:path'; +import type { TargetConfig } from '../../deploy/adapter'; +import { + VercelClient, + VercelCustomEnvironment, + VercelDeployment, + VercelEnvRecord, + VercelProject, + VercelTeam, + mapVercelApiError, +} from '../../deploy/vercelApi'; +import { + DEFAULT_VERCEL_TOKEN_ENV, + VERCEL_CONNECTOR_SECRET_NAME, + describeVercelTokenProblem, + resolveVercelToken, +} from '../../deploy/vercelApi'; +import { keyHelpFields, vercelKeyHelp } from '../../deploy/keyHelp'; +import type { GetVercelStoreSecret } from '../../deploy/vercelTargetApi'; +import { ERROR_CODES } from '../../types/index'; +import { runPool } from '../../utils/pool'; +import { GITHUB_HOST } from '../secretsSet'; +import { + CommonIo, + DeployDiscoverResult, + Step, + World, + discoveryGithubFacts, + done, + finishPlan, + loadCapySide, + readPlan, + refuse, + stop, +} from './command'; +import { DiscoveryInput, PlanEntry, buildDeployDiscovery } from './discovery'; +import { targetBaseName } from './evaluate'; +import { + BranchSignal, + ExistingTarget, + RepoOut, + ServiceFacts, + VercelServiceFacts, + relPathOf, + repoKey, + uniqueSorted, + vercelServiceId, +} from './facts'; +import type { BranchChoice, KindOverrides } from './kind'; +import { planIdOf } from './plan'; +import { DEPLOY_VERCEL_PLAN_SCHEMA, VERCEL_PLAN_SCHEMA_REF } from './schema'; +import { customRuleSignal, deploymentsSignal, scopedVariablesSignal, withGitBranch } from './vercelBranch'; + +export { DEPLOY_VERCEL_PLAN_SCHEMA }; + +// ── Seams ──────────────────────────────────────────────────────────────────── + +export interface DeployVercelDiscoverOpts { + /** The program-level `--dry-run`. */ + readonly dryRun?: boolean; + /** `--plan ` */ + readonly plan?: string; + /** `--confirm ` */ + readonly confirm?: string; +} + +/** Everything external, injected: tests pass fakes; `realVercelDiscoverIo` builds the real one. */ +export interface DeployVercelDiscoverIo extends CommonIo { + /** The org system store read for the Vercel token. Never asked to prompt. */ + readonly getSecret: GetVercelStoreSecret; + /** The READ-ONLY Vercel client for a token. Built with `dropValues: true`. */ + readonly vercel: (token: string) => VercelClient; +} + +// ── Facts: Vercel projects → services ──────────────────────────────────────── + +/** One project, everything read for it. Names and ids only: the env records carry no value. */ +export interface VercelProjectScan { + readonly project: VercelProject; + readonly team: { readonly id: string; readonly slug?: string } | null; + readonly customEnvironments: readonly VercelCustomEnvironment[]; + readonly envs: readonly VercelEnvRecord[]; + /** The project's recent preview deployments; absent when they were not read, or could not be (CAP-730). */ + readonly previewDeployments?: readonly VercelDeployment[]; +} + +const namesOf = (records: readonly VercelEnvRecord[]): readonly string[] => uniqueSorted(records.map((r) => r.key)); + +/** The repo a project is connected to, when it is a GitHub one. */ +function repoOfProject(project: VercelProject): RepoOut | null { + return project.link?.kind === 'github' ? { host: GITHUB_HOST, owner: project.link.org, name: project.link.repo } : null; +} + +interface EnvSlice { + readonly environment: string; + readonly customEnvironmentId: string | null; + readonly gitBranchScope: string | null; + readonly names: readonly string[]; +} + +/** The environments of a project that hold variables. `system` records (Vercel's own) are never a variable of the project. */ +function slicesOf(scan: VercelProjectScan): readonly EnvSlice[] { + const records = scan.envs.filter((r) => r.type !== 'system'); + const standard = (target: string, keep: (r: VercelEnvRecord) => boolean = () => true): readonly VercelEnvRecord[] => + records.filter((r) => r.targets.includes(target) && keep(r)); + const production = standard('production'); + const previewAll = standard('preview', (r) => !r.gitBranch); + const previewScoped = standard('preview', (r) => !!r.gitBranch); + const scopes = uniqueSorted(previewScoped.map((r) => r.gitBranch as string)); + const custom = scan.customEnvironments.map((c) => ({ c, records: records.filter((r) => r.customEnvironmentIds.includes(c.id)) })); + const slices: readonly EnvSlice[] = [ + { environment: 'production', customEnvironmentId: null, gitBranchScope: null, names: namesOf(production) }, + { environment: 'preview', customEnvironmentId: null, gitBranchScope: null, names: namesOf(previewAll) }, + ...scopes.map((scope): EnvSlice => ({ + environment: 'preview', + customEnvironmentId: null, + gitBranchScope: scope, + names: namesOf(previewScoped.filter((r) => r.gitBranch === scope)), + })), + ...custom.map(({ c, records: mine }): EnvSlice => ({ environment: c.slug, customEnvironmentId: c.id, gitBranchScope: null, names: namesOf(mine) })), + ].filter((slice) => slice.names.length > 0); + // `development` is a target only when it is the one environment with variables. + const development: readonly EnvSlice[] = + slices.length === 0 + ? [{ environment: 'development', customEnvironmentId: null, gitBranchScope: null, names: namesOf(standard('development')) }].filter((s) => s.names.length > 0) + : []; + return [...slices, ...development]; +} + +/** The preview variables scoped to a branch of the repo, by branch: names only. */ +function scopedPreviewNames(scan: VercelProjectScan): ReadonlyArray<{ readonly branch: string; readonly names: readonly string[] }> { + const scoped = scan.envs.filter((r) => r.type !== 'system' && r.targets.includes('preview') && !!r.gitBranch); + return uniqueSorted(scoped.map((r) => r.gitBranch as string)).map((branch) => ({ + branch, + names: namesOf(scoped.filter((r) => r.gitBranch === branch)), + })); +} + +/** What points at a branch for a slice, before the repo's default branch is known: the all-branches preview's two signals, a custom environment's rule. `undefined`: the slice is not judged. */ +function branchSignalsOf(scan: VercelProjectScan, slice: EnvSlice): readonly BranchSignal[] | undefined { + if (slice.environment === 'preview' && slice.gitBranchScope === null && slice.customEnvironmentId === null) { + return [deploymentsSignal(scan.previewDeployments ?? []), scopedVariablesSignal(scopedPreviewNames(scan), slice.names)].flatMap((s) => (s === null ? [] : [s])); + } + const custom = scan.customEnvironments.find((c) => c.id === slice.customEnvironmentId); + const rule = custom === undefined ? null : customRuleSignal(custom.branchMatcher); + return rule === null ? undefined : [rule]; +} + +/** The services of one project: one per environment that has variables. */ +export function vercelServicesOf(scan: VercelProjectScan): readonly VercelServiceFacts[] { + const { project } = scan; + const repo = repoOfProject(project); + const root = project.rootDirectory; + return slicesOf(scan).map((slice): VercelServiceFacts => { + const signals = branchSignalsOf(scan, slice); + return { + service_id: vercelServiceId(project.id, slice.environment, slice.gitBranchScope), + kind: 'vercel', + name: project.name, + vercel_project_id: project.id, + team: scan.team, + environment: slice.environment, + custom_environment_id: slice.customEnvironmentId, + git_branch_scope: slice.gitBranchScope, + root_directory: root, + repo, + // The production branch; `withGitBranch` replaces it for a service the signals judge, once the repo's default branch is read. + branch: project.link?.productionBranch ?? null, + path: repo === null ? null : relPathOf(root), + env_var_names: slice.names, + ...(signals === undefined ? {} : { git_branch_signals: signals }), + }; + }); +} + +// ── Reading Vercel ─────────────────────────────────────────────────────────── + +const SCAN_CONCURRENCY = 3; + +type Scope = { readonly team: { readonly id: string; readonly slug?: string } | null; readonly projects: readonly VercelProject[] }; + +/** Every team the token sees, then the token's own scope; a project listed twice counts once (the first listing wins). */ +async function readScopes(client: VercelClient): Promise { + const teams = await client.listTeams(); + const scopes = [...teams.map((t: VercelTeam) => ({ id: t.id as string | undefined, team: { id: t.id, slug: t.slug } as Scope['team'] })), { id: undefined, team: null as Scope['team'] }]; + const read = await scopes.reduce>(async (accPromise, scope) => { + const acc = await accPromise; + const seen = new Set(acc.flatMap((s) => s.projects.map((p) => p.id))); + const projects = (await client.listProjects(scope.id)).filter((p) => !seen.has(p.id)); + return [...acc, { team: scope.team, projects }]; + }, Promise.resolve([])); + // The token's own scope is only worth a mention when it holds a project no team listed. + return read.filter((s) => s.team !== null || s.projects.length > 0); +} + +interface ScanOutcome { + readonly scan?: VercelProjectScan; + readonly truncated: boolean; + /** The recent preview deployments were asked for and could not be read: no signal, a notice (CAP-730). */ + readonly deploymentsUnavailable?: boolean; + readonly error?: unknown; +} + +/** Does the project have an all-branches preview with variables (the only service the deployments judge)? */ +const hasAllBranchesPreview = (scan: VercelProjectScan): boolean => + slicesOf(scan).some((slice) => slice.environment === 'preview' && slice.gitBranchScope === null && slice.customEnvironmentId === null); + +type DeploymentsRead = { readonly ok: true; readonly deployments: readonly VercelDeployment[] } | { readonly ok: false }; + +/** The recent preview deployments. A failure of any kind (no access, rate limit, an unreadable answer) is no signal, never an error. */ +async function readPreviewDeployments(client: VercelClient, projectId: string, teamId: string | undefined): Promise { + try { + return { ok: true, deployments: await client.listPreviewDeployments(projectId, teamId) }; + } catch { + return { ok: false }; + } +} + +async function scanProject(client: VercelClient, project: VercelProject, team: Scope['team'], readDeployments: boolean): Promise { + try { + const teamId = team?.id; + const customEnvironments = await client.listCustomEnvironments(project.id, teamId); + const list = await client.listEnvs(project.id, teamId); + const scan: VercelProjectScan = { project, team, customEnvironments, envs: list.envs }; + if (!readDeployments || !hasAllBranchesPreview(scan)) return { scan, truncated: list.truncated }; + const read = await readPreviewDeployments(client, project.id, teamId); + return read.ok + ? { scan: { ...scan, previewDeployments: read.deployments }, truncated: list.truncated } + : { scan, truncated: list.truncated, deploymentsUnavailable: true }; + } catch (error) { + return { truncated: false, error }; + } +} + +function vercelFailure(err: unknown): DeployDiscoverResult { + const { code, message, link, remedy } = mapVercelApiError(err, { discovery: true }); + // A rejected token, or one without access, says where to get another and what to do. + return refuse(code, message, { ...(link ? { link } : {}), ...(remedy ? { remedy } : {}) }); +} + +interface VercelWorld { + readonly services: readonly ServiceFacts[]; + readonly teams: ReadonlyArray<{ readonly id: string; readonly slug: string }>; + readonly notices: ReadonlyArray<{ readonly code: string; readonly detail?: string }>; +} + +/** + * The Vercel side. `only` (plan mode): the project ids the plan names, the only + * ones whose environments are read; every project is listed (the list carries + * the git link and root directory). + */ +async function loadVercel( + io: Pick, + client: VercelClient, + only?: ReadonlySet, +): Promise> { + io.progress('Reading Vercel projects…'); // COPY-FLAG + const listed = await readScopes(client).then( + (value) => ({ ok: true as const, value }), + (err: unknown) => ({ ok: false as const, err }), + ); + if (!listed.ok) return stop(vercelFailure(listed.err)); + const scopes = listed.value; + const withScope = scopes.flatMap((scope) => scope.projects.map((project) => ({ project, team: scope.team }))); + const wanted = withScope.filter(({ project }) => (only === undefined ? true : only.has(project.id))); + const readable = wanted.filter(({ project }) => repoOfProject(project) !== null); + const notLinked = wanted.filter(({ project }) => repoOfProject(project) === null); + const pooled = await runPool({ + items: readable, + limit: SCAN_CONCURRENCY, + // A plan names its branches itself: the deployments are read only for discovery (`only` is plan mode). + run: async ({ project, team }) => ({ result: await scanProject(client, project, team, only === undefined) }), + onChange: (p) => { + if (p.done === p.total || p.done % 10 === 0) io.progress(`Read ${p.done} of ${p.total} Vercel projects`); // COPY-FLAG + }, + }); + const failed = pooled.results.find((r) => r.error !== undefined); + if (failed !== undefined) return stop(vercelFailure(failed.error)); + const scans = pooled.results.flatMap((r) => (r.scan === undefined ? [] : [r.scan])); + return done({ + services: scans.flatMap(vercelServicesOf), + teams: scopes.flatMap((s) => (s.team === null ? [] : [{ id: s.team.id, slug: s.team.slug as string }])), + notices: [ + ...notLinked.map(({ project }) => ({ code: ERROR_CODES.VERCEL_PROJECT_NOT_GITHUB, detail: project.name })), + ...pooled.results.flatMap((r) => (r.truncated && r.scan !== undefined ? [{ code: ERROR_CODES.VERCEL_ENV_LIST_TRUNCATED, detail: r.scan.project.name }] : [])), + ...pooled.results.flatMap((r) => (r.deploymentsUnavailable === true && r.scan !== undefined ? [{ code: ERROR_CODES.VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE, detail: r.scan.project.name }] : [])), + ], + }); +} + +// ── The provider's overrides ───────────────────────────────────────────────── + +const asVercel = (service: ServiceFacts): VercelServiceFacts => service as VercelServiceFacts; + +/** The Capy branch named like the environment (a scoped preview: like its git branch first); production falls back to its production branch. */ +function chooseVercelBranch( + service: ServiceFacts, + _defaultBranch: string | null | undefined, + projectBranches: readonly string[], +): BranchChoice | null { + const v = asVercel(service); + const candidates: ReadonlyArray = [ + ...(v.git_branch_scope === null ? [] : [{ branch: v.git_branch_scope, standard: true }]), + { branch: v.environment, standard: v.git_branch_scope === null }, + ...(v.environment === 'production' && v.branch !== null ? [{ branch: v.branch, standard: false }] : []), + ]; + return candidates.find((c) => projectBranches.includes(c.branch)) ?? null; +} + +/** An existing Vercel target for this project + environment (+ preview git branch). The Capy branch it ships is not part of the question. */ +function vercelConfigured(targets: readonly ExistingTarget[], service: ServiceFacts): ExistingTarget | undefined { + return targets.find((t) => t.kind === 'vercel' && t.service_id === service.service_id); +} + +/** A project's folder relative to the folder the deploy.json lives in. */ +function projectDirFor(linkPath: string, servicePath: string | null): string { + const from = relPathOf(linkPath); + const to = servicePath === null ? '.' : relPathOf(servicePath); + return posix.relative(from === '.' ? '' : from, to === '.' ? '' : to) || '.'; +} + +const envLabel = (v: VercelServiceFacts): string => (v.git_branch_scope === null ? v.environment : `${v.environment}@${v.git_branch_scope}`); + +function vercelTargetOf(args: { + readonly name: string; + readonly entry: PlanEntry; + readonly service: ServiceFacts; + readonly link: { readonly path: string }; + readonly knownVars: readonly string[]; +}): TargetConfig { + const { name, entry, knownVars } = args; + const v = asVercel(args.service); + return { + name, + kind: 'vercel', + branch: entry.branch, + vars: [...uniqueSorted(entry.vars)], + knownVars: [...knownVars], + options: { + projectDir: projectDirFor(args.link.path, v.path), + vercelEnv: v.environment, + projectId: v.vercel_project_id, + ...(v.team === null ? {} : { teamId: v.team.id }), + ...(v.custom_environment_id === null ? {} : { customEnvironmentId: v.custom_environment_id }), + ...(v.git_branch_scope === null ? {} : { gitBranch: v.git_branch_scope }), + }, + mode: 'ci', + gitBaseBranch: entry.git_branch, + }; +} + +// COPY-FLAG: new user-facing strings below (summary line, PR title/body), minimal and neutral. +export const VERCEL_KIND: KindOverrides = { + command: 'capy deploy vercel --discover', + schemaRef: VERCEL_PLAN_SCHEMA_REF, + planDomain: 'capy:deploy-vercel:plan:v1', + chooseCapyBranch: chooseVercelBranch, + // The branch of a preview or a custom environment with a rule is judged from its signals (CAP-730); everything else keeps the shared rule. + branchConfidence: (service, choice) => { + const confidence = asVercel(service).git_branch_confidence; + return confidence === undefined ? undefined : choice.standard ? confidence : 'needs_review'; + }, + gitBranchEvidence: (service) => { + const v = asVercel(service); + return v.git_branch_basis === undefined ? undefined : { basis: v.git_branch_basis, signals: v.git_branch_signals ?? [] }; + }, + // A preview scoped to one branch is evidence for that branch's preview, never an empty target of its own. + emptyProposalReason: (service) => (asVercel(service).git_branch_scope === null ? null : 'BRANCH_SCOPED_NO_VARIABLES'), + configured: (targets, service) => vercelConfigured(targets, service), + // Several Vercel projects (a monorepo) may ship one Capy branch: a target for another project is not a clash. + conflicting: () => undefined, + duplicateKey: (e) => [e.project_id, e.branch, e.service_id].join('\u0000'), + proposalTarget: (service, branch, gitBranch, vars) => { + const v = asVercel(service); + return { + kind: 'vercel', + projectId: v.vercel_project_id, + ...(v.team === null ? {} : { teamId: v.team.id }), + vercelEnv: v.environment, + ...(v.custom_environment_id === null ? {} : { customEnvironmentId: v.custom_environment_id }), + ...(v.git_branch_scope === null ? {} : { gitBranch: v.git_branch_scope }), + branch, + gitBaseBranch: gitBranch, + mode: 'ci', + vars, + }; + }, + baseName: (entry) => targetBaseName(entry.branch, 'vercel'), + targetFor: vercelTargetOf, + summaryLine: ({ project_name, service, entry }) => { + const v = asVercel(service); + return `${project_name} · ${entry.branch} → Vercel ${v.name} ${envLabel(v)} (${entry.git_branch}), ${entry.vars.length} vars`; + }, + pr: { + title: 'chore(capy): add Vercel deploy targets', + intro: 'Adds Vercel deploy targets to `.capy/deploy.json`.', + line: ({ target, path, service }) => { + const v = asVercel(service); + return `- \`${target.name}\` in \`${path}\`: Capy branch \`${target.branch}\`, Vercel project \`${v.vercel_project_id}\` (${envLabel(v)}), git branch \`${target.gitBaseBranch ?? ''}\``; + }, + }, +}; + +// ── The command ────────────────────────────────────────────────────────────── + +/** Where to get a Vercel token, for discovery's refusals: the import-side name, since discovery only reads. */ +function vercelTokenHelp(): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelKeyHelp(VERCEL_CONNECTOR_SECRET_NAME)); +} + +/** + * The token, never asked for: discovery only reads Vercel, so it uses the + * import-side token (`_CONNECTOR_VERCEL_TOKEN`), as Dokploy's discovery does, + * then `$VERCEL_TOKEN`. + */ +async function vercelClient(io: DeployVercelDiscoverIo, orgId: string): Promise> { + const resolved = await resolveVercelToken({ + env: io.env, + // Never a prompt: a missing token is a coded refusal that says where to get it. + interactive: false, + orgId, + devMode: io.devMode, + getConnectorSecret: io.getSecret, + }); + if (!resolved.ok) { + const { reason, hint } = describeVercelTokenProblem(resolved.code, DEFAULT_VERCEL_TOKEN_ENV); + return stop(refuse(resolved.code, `${reason}.`, { hint, ...vercelTokenHelp() })); + } + return done(io.vercel(resolved.value)); +} + +async function runDiscovery(io: DeployVercelDiscoverIo): Promise { + const ctx = await io.context(); + if (!ctx.ok) return refuse(ctx.code, ctx.message); + const client = await vercelClient(io, ctx.orgId); + if (!client.ok) return client.result; + const vercel = await loadVercel(io, client.value); + if (!vercel.ok) return vercel.result; + const capy = await loadCapySide(io, ctx.client, ctx.orgId); + if (!capy.ok) return capy.result; + const world: World = { orgId: ctx.orgId, services: vercel.value.services, ...capy.value }; + const github = await discoveryGithubFacts(io, world); + // The repo's default branch is read after the Vercel side: it joins the signals that settle a preview's branch now. + const services = world.services.map((service) => withGitBranch(service, service.repo === null ? null : github.defaultBranches[repoKey(service.repo)])); + const input: DiscoveryInput = { + baseUrl: '', + header: { teams: vercel.value.teams }, + services, + projects: world.projects, + defaultBranches: github.defaultBranches, + existingTargets: github.existingTargets, + notices: [...vercel.value.notices, ...github.notices], + }; + return { exitCode: 0, body: { ok: true, ...buildDeployDiscovery(input, VERCEL_KIND) } }; +} + +/** The project ids a plan names: a service id is `:`. */ +const projectIdsOfPlan = (entries: readonly PlanEntry[]): ReadonlySet => + new Set(entries.map((e) => e.service_id.slice(0, Math.max(0, e.service_id.indexOf(':'))))); + +async function runPlan(opts: DeployVercelDiscoverOpts, io: DeployVercelDiscoverIo): Promise { + const parsed = readPlan(opts, io); + if (!parsed.ok) return parsed.result; + const { entries, plan } = parsed.value; + const confirming = opts.confirm !== undefined && opts.dryRun !== true; + + const ctx = await io.context(); + if (!ctx.ok) return refuse(ctx.code, ctx.message); + const planId = planIdOf(plan, '', VERCEL_KIND.planDomain); + // A plan that moved since it was confirmed: nothing more is looked up, nothing is done. + if (confirming && opts.confirm !== planId) { + return refuse(ERROR_CODES.PLAN_CHANGED, 'The plan is not the one that was confirmed. Nothing was changed.', { plan_id: planId, plan }); // COPY-FLAG + } + const client = await vercelClient(io, ctx.orgId); + if (!client.ok) return client.result; + const vercel = await loadVercel(io, client.value, projectIdsOfPlan(entries)); + if (!vercel.ok) return vercel.result; + const capy = await loadCapySide(io, ctx.client, ctx.orgId); + if (!capy.ok) return capy.result; + const world: World = { orgId: ctx.orgId, services: vercel.value.services, ...capy.value }; + return finishPlan({ opts, io, parsed: parsed.value, planId, world, scope: '', extras: {}, kind: VERCEL_KIND }); +} + +/** Runs the command and returns what it prints. Never prints, never exits, never prompts. */ +export async function runDeployVercelDiscover(opts: DeployVercelDiscoverOpts, io: DeployVercelDiscoverIo): Promise { + if (opts.confirm !== undefined && opts.plan === undefined) { + return refuse(ERROR_CODES.PLAN_REQUIRED, '--confirm needs --plan .'); // COPY-FLAG + } + return opts.plan === undefined ? runDiscovery(io) : runPlan(opts, io); +} diff --git a/src/commands/deployDiscover/vercelBranch.ts b/src/commands/deployDiscover/vercelBranch.ts new file mode 100644 index 00000000..7eab3641 --- /dev/null +++ b/src/commands/deployDiscover/vercelBranch.ts @@ -0,0 +1,208 @@ +/** + * Which git branch a Vercel environment deploys from (CAP-730), as pure functions. + * + * Production is the project's production branch, always. The all-branches + * PREVIEW environment's branch is the deploy PR base, so it must be a + * LONG-LIVED branch. The candidates are ONLY: + * + * - the project's production branch + * - the repo's GitHub default branch + * - a branch that preview variables are scoped to + * + * (a custom environment's `equals` rule names its own branch). Recent preview + * deployments never add a candidate: a feature branch, or a one-off import + * branch, was deployed once and is gone. Deployments only RANK the candidates. + * + * With one candidate (the production branch) that is the answer, `certain`. + * With several, they are ranked by deployments on each, then by scoped + * variables, then by being the default branch (then by name). The winner is + * `certain` only when every signal that names a candidate names the winner and + * only the winner; otherwise it is `needs_review`. Every signal is listed. + * + * Names, ids and counts only. Nothing in here ever holds a variable value. + */ +import { sortedCopy } from '../../deploy/dokployApi'; +import type { VercelBranchMatcher, VercelDeployment } from '../../deploy/vercelApi'; +import type { BranchSignal, BranchSignalCode, GitBranchBasis, ServiceFacts, VercelServiceFacts } from './facts'; +import { byText, uniqueSorted } from './facts'; + +/** The order the criteria rank the candidates in (see `inferPreviewBranch`). */ +export const BRANCH_SIGNAL_ORDER: readonly BranchSignalCode[] = ['RECENT_PREVIEW_DEPLOYMENTS', 'BRANCH_SCOPED_VARIABLES', 'GITHUB_DEFAULT_BRANCH']; + +export interface BranchInference { + readonly branch: string | null; + readonly confidence: 'certain' | 'needs_review'; + readonly basis: GitBranchBasis; +} + +const byWeightThenName = (a: { readonly branch: string; readonly weight: number }, b: { readonly branch: string; readonly weight: number }): number => + b.weight - a.weight || byText(a.branch, b.branch); + +/** A signal's branches, strongest first. */ +const rankedBranches = (branches: BranchSignal['branches']): BranchSignal['branches'] => sortedCopy(branches, byWeightThenName); + +/** The branches tied for the strongest weight in a signal (one branch when there is a clear leader). */ +export function leadersOf(signal: BranchSignal): readonly string[] { + const top = Math.max(0, ...signal.branches.map((b) => b.weight)); + return uniqueSorted(signal.branches.filter((b) => b.weight === top).map((b) => b.branch)); +} + +// ── The signals ────────────────────────────────────────────────────────────── + +/** Branch counts over a project's recent preview deployments. A deployment with no git branch (a CLI deploy) counts for none. */ +export function deploymentsSignal(deployments: readonly VercelDeployment[]): BranchSignal | null { + const branches = uniqueSorted(deployments.flatMap((d) => (d.gitBranch === undefined ? [] : [d.gitBranch]))); + return branches.length === 0 + ? null + : { + code: 'RECENT_PREVIEW_DEPLOYMENTS', + branches: rankedBranches(branches.map((branch) => ({ branch, weight: deployments.filter((d) => d.gitBranch === branch).length }))), + }; +} + +/** The branches preview variables are scoped to, with the names under each (and the ones the all-branches preview shadows). */ +export function scopedVariablesSignal( + scoped: ReadonlyArray<{ readonly branch: string; readonly names: readonly string[] }>, + allBranchesNames: readonly string[], +): BranchSignal | null { + return scoped.length === 0 + ? null + : { + code: 'BRANCH_SCOPED_VARIABLES', + branches: rankedBranches( + scoped.map(({ branch, names: scopedNames }) => ({ + branch, + weight: scopedNames.length, + names: scopedNames, + shadows: scopedNames.filter((n) => allBranchesNames.includes(n)), + })), + ), + }; +} + +/** The repo's default branch, but only when it says something the production branch does not. */ +export function defaultBranchSignal(defaultBranch: string | null | undefined, productionBranch: string | null): BranchSignal | null { + return typeof defaultBranch === 'string' && defaultBranch !== '' && defaultBranch !== productionBranch + ? { code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: defaultBranch, weight: 1 }] } + : null; +} + +/** A custom environment's branch matcher. Only `equals` names one branch; a prefix or suffix rule is carried as the rule alone. */ +export function customRuleSignal(matcher: VercelBranchMatcher | undefined): BranchSignal | null { + return matcher === undefined + ? null + : { + code: 'CUSTOM_ENVIRONMENT_RULE', + branches: matcher.type === 'equals' ? [{ branch: matcher.pattern, weight: 1 }] : [], + rule: { type: matcher.type, pattern: matcher.pattern }, + }; +} + +// ── The choice ─────────────────────────────────────────────────────────────── + +const signalOf = (signals: readonly BranchSignal[], code: BranchSignalCode): BranchSignal | undefined => signals.find((s) => s.code === code); + +const weightOf = (signals: readonly BranchSignal[], code: BranchSignalCode, branch: string): number => + signalOf(signals, code)?.branches.find((b) => b.branch === branch)?.weight ?? 0; + +/** The long-lived branches a preview may deploy from: production, the default branch, the scoped branches. Never a deployed-from branch. */ +export function previewCandidates(signals: readonly BranchSignal[], productionBranch: string | null): readonly string[] { + const named = [signalOf(signals, 'BRANCH_SCOPED_VARIABLES'), signalOf(signals, 'GITHUB_DEFAULT_BRANCH')].flatMap((s) => + s === undefined ? [] : s.branches.map((b) => b.branch), + ); + return uniqueSorted([...(productionBranch === null ? [] : [productionBranch]), ...named]); +} + +/** Candidates best first: deployments, then scoped variables, then the default branch, then name. */ +function rankCandidates(candidates: readonly string[], signals: readonly BranchSignal[]): readonly string[] { + const score = (branch: string): readonly number[] => [ + weightOf(signals, 'RECENT_PREVIEW_DEPLOYMENTS', branch), + weightOf(signals, 'BRANCH_SCOPED_VARIABLES', branch), + weightOf(signals, 'GITHUB_DEFAULT_BRANCH', branch), + ]; + return sortedCopy(candidates, (a, b) => { + const [sa, sb] = [score(a), score(b)]; + return sb[0] - sa[0] || sb[1] - sa[1] || sb[2] - sa[2] || byText(a, b); + }); +} + +/** What a signal names among the candidates: its strongest candidate(s); none when it names no candidate. */ +function votesOf(signal: BranchSignal, candidates: readonly string[]): readonly string[] { + const mine = signal.branches.filter((b) => candidates.includes(b.branch) && b.weight > 0); + const top = Math.max(0, ...mine.map((b) => b.weight)); + return uniqueSorted(mine.filter((b) => b.weight === top).map((b) => b.branch)); +} + +export interface PreviewInference extends BranchInference { + readonly candidates: readonly string[]; +} + +/** The git branch of an all-branches preview from its signals. */ +export function inferPreviewBranch(signals: readonly BranchSignal[], productionBranch: string | null): PreviewInference { + const candidates = previewCandidates(signals, productionBranch); + if (candidates.length === 0) return { branch: productionBranch, confidence: 'needs_review', basis: 'no_signals', candidates }; + if (candidates.length === 1 && candidates[0] === productionBranch) { + return { branch: productionBranch, confidence: 'certain', basis: 'production_branch_only', candidates }; + } + const winner = rankCandidates(candidates, signals)[0]; + const votes = signals.map((s) => votesOf(s, candidates)).filter((v) => v.length > 0); + const agreed = votes.every((v) => v.length === 1 && v[0] === winner); + return { branch: winner, confidence: agreed ? 'certain' : 'needs_review', basis: agreed ? 'signals_agree' : 'strongest_signal', candidates }; +} + +/** + * The deployments signal for output: counts for the candidate branches only, and one total for every other branch, so + * the evidence is not a list of every feature branch that was ever deployed. + */ +export function deploymentsForOutput(signal: BranchSignal, candidates: readonly string[]): BranchSignal { + const mine = signal.branches.filter((b) => candidates.includes(b.branch)); + return { + ...signal, + branches: mine, + other_branches: signal.branches.filter((b) => !candidates.includes(b.branch)).reduce((total, b) => total + b.weight, 0), + }; +} + +/** The git branch of a custom environment from its rule: an exact rule names the branch; a pattern names none. */ +export function inferCustomBranch(signals: readonly BranchSignal[], productionBranch: string | null): BranchInference | null { + const rule = signals.find((s) => s.code === 'CUSTOM_ENVIRONMENT_RULE'); + if (rule === undefined) return null; + const exact = rule.branches[0]; + return exact === undefined + ? { branch: productionBranch, confidence: 'needs_review', basis: 'custom_environment_pattern' } + : { branch: exact.branch, confidence: 'certain', basis: 'custom_environment_rule' }; +} + +// ── Applying it to a service ───────────────────────────────────────────────── + +const asVercel = (service: ServiceFacts): VercelServiceFacts => service as VercelServiceFacts; + +const isAllBranchesPreview = (v: VercelServiceFacts): boolean => v.environment === 'preview' && v.git_branch_scope === null; + +/** + * A service with its git branch settled. Only the all-branches preview and a + * custom environment with a rule are judged; every other service (production, + * `development`, a branch-scoped preview) is returned as it is. The default + * branch is the repo's, from GitHub, and joins the signals here because it is + * read after the Vercel side. + */ +export function withGitBranch(service: ServiceFacts, defaultBranch: string | null | undefined): ServiceFacts { + const v = asVercel(service); + if (v.git_branch_signals === undefined) return service; + if (isAllBranchesPreview(v)) { + const defaultSignal = defaultBranchSignal(defaultBranch, v.branch); + const signals = [...v.git_branch_signals, ...(defaultSignal === null ? [] : [defaultSignal])]; + const inferred = inferPreviewBranch(signals, v.branch); + return { + ...v, + branch: inferred.branch, + git_branch_signals: signals.map((s) => (s.code === 'RECENT_PREVIEW_DEPLOYMENTS' ? deploymentsForOutput(s, inferred.candidates) : s)), + git_branch_basis: inferred.basis, + git_branch_confidence: inferred.confidence, + }; + } + const custom = inferCustomBranch(v.git_branch_signals, v.branch); + return custom === null + ? service + : { ...v, branch: custom.branch, git_branch_basis: custom.basis, git_branch_confidence: custom.confidence }; +} diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index e733f121..74b5649d 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -7,7 +7,7 @@ import { readSecretsLocal, LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { hashValue } from './statusCommand'; -import { EditScreen, EditRow, EditState, EditLoaded, classifiedRow, focusedOn, withPendingEdits } from '../ui/editScreen'; +import { EditScreen, EditRow, EditState, EditLoaded, classifiedRow, withPendingEdits } from '../ui/editScreen'; import { deployedHashesFor } from '../core/deployStatus'; import { readDefaultBranchTargets } from '../deploy/configuredTargets'; import { KeepFile } from '../types/index'; @@ -16,7 +16,8 @@ import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; import { gateFirst, type GateOptions } from '../sync/gate'; import { reportKeepLockHuman, refuseBadPrFlags, runKeepLockPrStep, type PrFlags } from './keepLockPr'; import { startSaveLog } from './sessionSaveLog'; -import { decideEditMode, editPipedCommand, refuseEditNeedsTty } from './editPiped'; +import { decideEditMode, editPipedCommand, refuseEditNeedsTty, refuseEditPromptLocalOnly } from './editPiped'; +import { runPromptedSet } from './addCommand'; import { DRY_RUN_UNSUPPORTED_MESSAGES, refuseDryRunUnsupported, refuseInvalidName } from './pipedValue'; import { isValidVarName } from './pipedWrite'; import { printRepoLinkWarnings, reportRepoLink } from '../core/repoLinkReporter'; @@ -218,9 +219,9 @@ export async function saveEdits( export interface EditOpts { /** - * The variable to work on (`capy edit NAME`). With a terminal: the TUI opens - * with the cursor on it, and an unknown name opens its new-variable entry. - * Without one: the value is read from stdin (piped mode). + * The variable to work on (`capy edit NAME`). With a terminal: the hidden value prompt of + * `capy add NAME --force` (a new name is created, an existing one overwritten). Without one: + * the value is read from stdin (piped mode). */ name?: string; /** Piped mode: pure JSON on stdout. */ @@ -231,7 +232,7 @@ export interface EditOpts { nonTty?: boolean; /** `--pr` / `--no-pr` / `--pr-base`: answers the keep.lock PR step. */ pr?: PrFlags; - /** The global `--dry-run`: piped mode previews; the TUI refuses (`DRY_RUN_UNSUPPORTED`). */ + /** The global `--dry-run`: piped mode previews; the TUI and the value prompt refuse (`DRY_RUN_UNSUPPORTED`). */ dryRun?: boolean; /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ conflictFlags?: RawConflictFlags; @@ -309,6 +310,10 @@ export class EditCommand { if (mode === 'tui' && opts.dryRun === true) { return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.edit); } + // The value prompt would really save: a dry run never runs for real. Before any prompt. + if (mode === 'prompt' && opts.dryRun === true) { + return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt); + } if (opts.name !== undefined && !isValidVarName(opts.name)) return refuseInvalidName(opts.json === true); // A bad flag is refused here, before stdin is read or anything is authenticated. conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); @@ -324,6 +329,26 @@ export class EditCommand { }); } + // `capy edit NAME` on a terminal is `capy add NAME --force`: the same hidden prompt and the same write path. + if (mode === 'prompt' && opts.name !== undefined) { + if (isLocalOnly()) return refuseEditPromptLocalOnly(opts.json === true); + return runPromptedSet({ + names: [opts.name], + opts: { + force: true, + noPush: opts.noPush, + nonTty: opts.nonTty, + json: opts.json, + pr: opts.pr, + dryRun: opts.dryRun, + conflictFlags: opts.conflictFlags, + }, + devMode: this.devMode, + command: 'edit', + dryRunMessage: DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt, + }); + } + // Bare `capy` runs before the screen opens: the folder and Capy must agree before anything here can write. await gateFirst(gate); @@ -376,10 +401,6 @@ export class EditCommand { localMode, seen: loaded.seen, }; - // `capy edit NAME` on a terminal: the cursor starts on NAME (a new name opens - // its value entry). - const state = opts.name !== undefined ? focusedOn(baseState, opts.name) : baseState; - const screen = new EditScreen(); const printExpiryAfter = async () => { const { printExpiryWarnings } = await import('./connectors/shared'); @@ -410,7 +431,7 @@ export class EditCommand { const fresh = await load(); return session(withPendingEdits({ ...baseState, rows: fresh.rows, remoteAvailable: fresh.remoteAvailable, seen: fresh.seen }, exit.pending)); }; - await session(state); + await session(baseState); if (repoLinkPending) printRepoLinkWarnings(await repoLinkPending); // The TUI has already released stdin (raw mode off, its key listener // removed) by the time `run` resolves, so the prompts in keepLockPr.ts start diff --git a/src/commands/editPiped.ts b/src/commands/editPiped.ts index 14a542d5..27bf77b2 100644 --- a/src/commands/editPiped.ts +++ b/src/commands/editPiped.ts @@ -9,10 +9,13 @@ * * | name | stdin | mode | * |------|---------|----------| - * | any | TTY | `tui` | + * | no | TTY | `tui` | + * | yes | TTY | `prompt` | * | yes | not TTY | `piped` | * | no | not TTY | `refuse` | * + * `prompt` is `capy add NAME --force`: the hidden value prompt, then the one write path. + * * `--non-tty` takes the TTY off the table. A name with `--non-tty` on a real * terminal has nothing piped to read, so it refuses rather than block on the * keyboard. @@ -24,7 +27,7 @@ import { isValidVarName, runPipedWrite } from './pipedWrite'; import type { PrFlags } from './keepLockPr'; import type { GateOptions } from '../sync/gate'; -export type EditMode = 'tui' | 'piped' | 'refuse'; +export type EditMode = 'tui' | 'prompt' | 'piped' | 'refuse'; export interface EditModeFacts { readonly hasName: boolean; @@ -34,7 +37,7 @@ export interface EditModeFacts { export function decideEditMode(facts: EditModeFacts): EditMode { const { hasName, stdinIsTTY, nonTty } = facts; - if (stdinIsTTY && !nonTty) return 'tui'; + if (stdinIsTTY && !nonTty) return hasName ? 'prompt' : 'tui'; const somethingIsPiped = !stdinIsTTY; if (hasName) return somethingIsPiped ? 'piped' : 'refuse'; return 'refuse'; @@ -61,6 +64,15 @@ export interface EditPipedOpts { readonly gate: GateOptions; } +/** `EDIT_STDIN_LOCAL_ONLY`, exit 1: `capy edit NAME` on a terminal writes through Capy, which a local-only profile has no organization for. */ +export function refuseEditPromptLocalOnly(json: boolean): never { + return refusePiped( + json, + ERROR_CODES.EDIT_STDIN_LOCAL_ONLY, + 'Setting a value by name needs an organization. This profile is local-only. Run capy edit to use the editor.', // COPY-FLAG + ); +} + /** ` | capy edit NAME`: read stdin, set the variable, report. Never prompts. */ export async function editPipedCommand(name: string, opts: EditPipedOpts): Promise { if (!isValidVarName(name)) return refuseInvalidName(opts.json); diff --git a/src/commands/listCommand.ts b/src/commands/listCommand.ts index a1bbf429..3cdb6c3d 100644 --- a/src/commands/listCommand.ts +++ b/src/commands/listCommand.ts @@ -1,5 +1,6 @@ import { ProjectManager } from '../core/projectManager'; import { listAllVarsOnBranch, listManagedKeys, findManagedConnector } from './connectors/shared'; +import { placeholderLabel, placeholderOf, placeholderSetCommand } from '../core/placeholder'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; const DIM = '\x1b[90m'; @@ -45,9 +46,12 @@ export class ListCommand { const managed = new Set(listManagedKeys(keep, branch).map((m) => m.varName)); const variables = listAllVarsOnBranch(keep, branch).map((name) => { const c = managed.has(name) ? findManagedConnector(keep, name, branch) : undefined; + const marker = placeholderOf(keep, name, branch); return { name, managed: !!c, + // No value yet (a placeholder): the provider and its reason code, and the command that sets one. + placeholder: marker ? { provider: marker.provider, reason: marker.reason, setWith: placeholderSetCommand(name) } : null, connector: c ? { provider: c.provider, @@ -72,7 +76,10 @@ export class ListCommand { console.log(` ${keep.project_name} ${DIM}·${RESET} ${branch} ${DIM}(${variables.length})${RESET}`); for (const v of variables) { const tag = v.connector ? ` ${DIM}(${v.connector.provider})${RESET}` : ''; - console.log(` ${v.name}${tag}`); + const marker = placeholderOf(keep, v.name, branch); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const note = marker ? ` ${DIM}${placeholderLabel(marker)} — set it with: ${placeholderSetCommand(v.name)}${RESET}` : ''; + console.log(` ${v.name}${tag}${note}`); } console.log(''); } diff --git a/src/commands/pipedValue.ts b/src/commands/pipedValue.ts index 43884df2..c18f456a 100644 --- a/src/commands/pipedValue.ts +++ b/src/commands/pipedValue.ts @@ -159,6 +159,7 @@ export function refusePiped(json: boolean, code: string, error: string): never { /** Why each interactive path cannot preview. Minimal and neutral. */ export const DRY_RUN_UNSUPPORTED_MESSAGES = { edit: "--dry-run isn't available in the interactive editor. Pipe a value with capy edit NAME --dry-run.", // COPY-FLAG + editPrompt: "--dry-run isn't available for an interactive edit. Pipe a value with: | capy edit NAME --dry-run", // COPY-FLAG add: "--dry-run isn't available for an interactive add. Pipe a value with: | capy add NAME --dry-run", // COPY-FLAG remove: "--dry-run isn't available at the confirmation prompt. Add --yes, --json or --non-tty to preview.", // COPY-FLAG } as const; diff --git a/src/commands/secretsCommand.ts b/src/commands/secretsCommand.ts index b4110749..cc4ec368 100644 --- a/src/commands/secretsCommand.ts +++ b/src/commands/secretsCommand.ts @@ -3,6 +3,7 @@ import { resolveOrgContext } from '../core/orgContext'; import { Spinner } from '../ui/spinner'; import { CapyError, ERROR_CODES } from '../types/index'; import { rowIdOf } from './secretsRowId'; +import { placeholderSetCommand } from '../core/placeholder'; import { secretRowStatus, secretRowStatusJson, type SecretRowStatus } from '../core/deployStatus'; /** @@ -130,6 +131,8 @@ export class SecretsCommand { const withIds = rows.map((row) => ({ ...row, row_id: rowIdOf(row.name, row.value_hash), + // An empty value hash is a name with no value yet (a placeholder, or a connector entry before its first push). + ...(row.value_hash === '' ? { no_value: true, set_with: placeholderSetCommand(row.name) } : {}), ...statusFields(secretRowStatus(row)), locations: row.locations.map((loc) => loc.targets === undefined ? loc : { ...loc, targets: loc.targets.map((t) => ({ ...t, up_to_date: !t.stale })) }, diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index 7931309c..8838811e 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -8,6 +8,8 @@ import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; import { fetchSecretsWithCache, readSecretsLocal } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; +import { pinnedHashes } from '../sync/commit'; +import { placeholdersOnBranch, placeholderSetCommand } from '../core/placeholder'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -210,12 +212,9 @@ export class StatusCommand { const branchLabel = branch; // Build pinned hashes from keep.lock for active branch - const pinned: Record = Object.fromEntries( - Object.entries(keep.variables).flatMap(([varName, entries]) => { - const entry = entries.find(e => e.branch === branch); - return entry ? [[varName, entry.value_hash] as const] : []; - }), - ); + // A placeholder pins no value, so it is never compared; it is listed on its own. + const pinned = pinnedHashes(keep, branch); + const placeholders = placeholdersOnBranch(keep, branch).map((p) => p.name); // Build local hashes from .env. Two facts, deliberately separate: the // sentence a person reads, and the verdict the report branches on. @@ -260,6 +259,8 @@ export class StatusCommand { remoteMatchesPinned: !showRemote, remoteFailure: remoteFailure ?? null, diffs, + // Names with no value yet (see `capy list`); they are not differences. + placeholders, }; if (opts.json) { @@ -279,6 +280,11 @@ export class StatusCommand { // Full output console.log(`${B('capy')}: ${keep.project_name} (${branchLabel})`); console.log(''); + if (placeholders.length > 0) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + console.log(` No value yet: ${placeholders.join(', ')}. Set one with: ${placeholderSetCommand('NAME')}`); + console.log(''); + } if (diffs.length === 0) { console.log(`> ${totalSecrets} secret${totalSecrets !== 1 ? 's' : ''} match pinned branch.`); diff --git a/src/commands/systemCommand.ts b/src/commands/systemCommand.ts index ed48e9e9..3524dacc 100644 --- a/src/commands/systemCommand.ts +++ b/src/commands/systemCommand.ts @@ -16,6 +16,7 @@ import inquirer from 'inquirer'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { openSystemStore, assertValidConnectorName } from '../system/systemStore'; import { CapyError, ERROR_CODES } from '../types/index'; +import { formatKeyHelp, keyHelpFields, keyHelpForName, KeyHelp } from '../deploy/keyHelp'; export interface SystemCommandOpts { org?: string; @@ -29,20 +30,22 @@ export interface SystemRmOpts extends SystemCommandOpts { } /** Pure JSON refusal on stdout — never on stderr, so `--json` output stays parseable. */ -function refuseJson(code: string, error: string, exitCode: number): never { - console.log(JSON.stringify({ ok: false, code, error }, null, 2)); +function refuseJson(code: string, error: string, exitCode: number, help: KeyHelp | null): never { + console.log(JSON.stringify({ ok: false, code, error, ...(help ? keyHelpFields(help) : {}) }, null, 2)); process.exit(exitCode); } /** Prose refusal on stderr — human mode only. */ -function refuseHuman(message: string, exitCode: number): never { +function refuseHuman(message: string, exitCode: number, help: KeyHelp | null): never { console.error(message); + if (help) console.error(formatKeyHelp(help)); process.exit(exitCode); } -function refuse(json: boolean, code: string, message: string, exitCode: number): never { - if (json) refuseJson(code, message, exitCode); - refuseHuman(message, exitCode); +/** `help`: where to get the key, for a refusal about one of the four known key names (additive; absent for every other refusal). */ +function refuse(json: boolean, code: string, message: string, exitCode: number, help: KeyHelp | null = null): never { + if (json) refuseJson(code, message, exitCode, help); + refuseHuman(message, exitCode, help); } function exitCodeFor(code: string): number { @@ -68,9 +71,9 @@ function requireValidName(name: string, json: boolean): void { } /** Exits with `SYSTEM_STORE_NEEDS_TTY` when there is no terminal to ask a human. */ -function requireTty(json: boolean, message: string): void { +function requireTty(json: boolean, message: string, help: KeyHelp | null = null): void { if (!isInteractive()) { - refuse(json, ERROR_CODES.SYSTEM_STORE_NEEDS_TTY, message, EXIT_NEEDS_INPUT); + refuse(json, ERROR_CODES.SYSTEM_STORE_NEEDS_TTY, message, EXIT_NEEDS_INPUT, help); } } @@ -115,7 +118,12 @@ export async function systemSetCommand(name: string, opts: SystemCommandOpts): P // Before any network call — see docs/org-system-store.md Proof 3. requireValidName(name, json); - requireTty(json, `\`capy system set\` needs a terminal to prompt for the value — there is no --value flag.`); // COPY-FLAG + // For the four known key names: where to get the key. Without a terminal it is part of the refusal. + requireTty(json, `\`capy system set\` needs a terminal to prompt for the value — there is no --value flag.`, keyHelpForName(name)); // COPY-FLAG + + // With a terminal the hidden prompt follows right here, so the help ends "paste it here". Stderr, so `--json` stays pure. + const help = keyHelpForName(name, { mode: 'paste' }); + if (help) console.error(formatKeyHelp(help)); const value = await promptForHiddenValue(name, json); diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index acaa0606..088268b3 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -11,7 +11,7 @@ */ import type { Argument, Command, Option } from 'commander'; import { ERROR_CODES } from '../types/index'; -import { DEPLOY_DOKPLOY_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; +import { DEPLOY_DOKPLOY_PLAN_SCHEMA, DEPLOY_VERCEL_PLAN_SCHEMA } from '../commands/deployDiscover/schema'; export interface CliArgumentDoc { name: string; @@ -66,7 +66,7 @@ export interface CliHelpDoc { name: string; version: string; commands: CliCommandDoc[]; - /** JSON Schemas of the files commands read, by name (`deploy_dokploy_plan`). */ + /** JSON Schemas of the files commands read, by name (`deploy_dokploy_plan`, `deploy_vercel_plan`). */ schemas: Record; errorCodes: string[]; conventions: { @@ -124,9 +124,14 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ /** The JSON Schemas published in `capy help --json`. */ export const HELP_SCHEMAS: Readonly> = { deploy_dokploy_plan: DEPLOY_DOKPLOY_PLAN_SCHEMA, + deploy_vercel_plan: DEPLOY_VERCEL_PLAN_SCHEMA, }; -/** Modes that are flags of a command rather than sub-commands, by command path. */ +/** + * Modes that are flags of a command rather than sub-commands, by command path. + * `capy deploy vercel --discover` is the same mode with another target: it is not listed + * separately, and its plan schema is published under `schemas.deploy_vercel_plan`. + */ export const COMMAND_MODES: Readonly> = { deploy: [ { diff --git a/src/core/placeholder.ts b/src/core/placeholder.ts new file mode 100644 index 00000000..2f09ce59 --- /dev/null +++ b/src/core/placeholder.ts @@ -0,0 +1,156 @@ +/** + * Placeholders: a name that exists on a branch with NO value yet. + * + * An import connector that cannot read a value (Vercel never returns a sensitive one) + * still records that the name exists. That record is a typed flag on the variable's + * keep.lock entry for the branch (`KeepVariableEntry.placeholder`), with an empty + * `value_hash`. It is NOT a value: nothing is encrypted, nothing is pushed as a secret, + * and no sentinel string stands in for one. + * + * Placeholder state comes from keep.lock and nowhere else. The `.env` comment line below + * is written for a person to read; nothing parses it back. + * + * Writing a real value for the branch removes the flag (`SyncEngine.mergeWithKeep`). + */ +import { deriveResourceId } from '../crypto/resourceId'; +import type { KeepFile, KeepVariableEntry, PlaceholderMarker } from '../types/index'; + +/** One name to record as a placeholder on a branch. */ +export interface PlaceholderRequest { + readonly name: string; + readonly marker: PlaceholderMarker; +} + +/** The placeholder marker on `name`'s entry for `branch`, if it has one. */ +export function placeholderOf(keep: KeepFile | null | undefined, name: string, branch: string): PlaceholderMarker | undefined { + return keep?.variables[name]?.find((e) => e.branch === branch)?.placeholder; +} + +/** Every placeholder on `branch`, by name, in keep.lock order. */ +export function placeholdersOnBranch(keep: KeepFile | null | undefined, branch: string): ReadonlyArray<{ readonly name: string; readonly marker: PlaceholderMarker }> { + return Object.entries(keep?.variables ?? {}).flatMap(([name, entries]) => { + const marker = entries.find((e) => e.branch === branch)?.placeholder; + return marker === undefined ? [] : [{ name, marker }]; + }); +} + +/** True when `name` holds a real value (a non-empty hash, no placeholder flag) on `branch` in this keep.lock. */ +export function hasValueOnBranch(keep: KeepFile | null | undefined, name: string, branch: string): boolean { + return hasValue(keep?.variables[name]?.find((e) => e.branch === branch)); +} + +/** True when the entry holds a real value (a non-empty hash). */ +const hasValue = (entry: KeepVariableEntry | undefined): boolean => entry !== undefined && entry.value_hash !== '' && entry.placeholder === undefined; + +function sameMarker(a: PlaceholderMarker | undefined, b: PlaceholderMarker): boolean { + return a !== undefined && a.provider === b.provider && a.reason === b.reason; +} + +function withPlaceholder(keep: KeepFile, branch: string, request: PlaceholderRequest): KeepFile { + const entries = keep.variables[request.name] ?? []; + const current = entries.find((e) => e.branch === branch); + // A name that already holds a real value on this branch keeps it: a placeholder is only ever the absence of one. + if (hasValue(current)) return keep; + if (current !== undefined && current.value_hash === '' && sameMarker(current.placeholder, request.marker)) return keep; + const next: KeepVariableEntry = { + ...(current ?? { resource_id: deriveResourceId(branch, request.name) }), + branch, + value_hash: '', + placeholder: request.marker, + }; + const updated = current === undefined ? [...entries, next] : entries.map((e) => (e === current ? next : e)); + return { ...keep, variables: { ...keep.variables, [request.name]: updated } }; +} + +/** + * `keep` with each request recorded as a placeholder on `branch`. Returns `keep` itself when + * nothing changes, so a repeat import writes nothing. A name that already has a value on the + * branch is left alone. + */ +export function withPlaceholders(keep: KeepFile, branch: string, requests: readonly PlaceholderRequest[]): KeepFile { + return requests.reduce((acc, request) => withPlaceholder(acc, branch, request), keep); +} + +/** The entry with its placeholder flag removed (a real value is being written for it). */ +export function withoutPlaceholder(entry: KeepVariableEntry): KeepVariableEntry { + const { placeholder: _removed, ...rest } = entry; + return rest; +} + +/** + * `keep` with the flag removed from `names`' entries on `branch`: a value is being written locally for them + * (the pin itself moves on the next push). Returns `keep` itself when none of them is a placeholder. + */ +export function clearedPlaceholders(keep: KeepFile, branch: string, names: readonly string[]): KeepFile { + const flagged = names.filter((name) => placeholderOf(keep, name, branch) !== undefined); + if (flagged.length === 0) return keep; + return { + ...keep, + variables: { + ...keep.variables, + ...Object.fromEntries(flagged.map((name) => [name, keep.variables[name].map((e) => (e.branch === branch ? withoutPlaceholder(e) : e))] as const)), + }, + }; +} + +// ── Words ─────────────────────────────────────────────────────────────────── + +const PROVIDER_LABELS: Readonly> = { vercel: 'Vercel' }; + +const providerLabel = (provider: string): string => PROVIDER_LABELS[provider] ?? provider; + +/** What the provider holds the value as, for the "no value yet" line. A code in, words out. */ +function reasonPhrase(marker: PlaceholderMarker): string { + const provider = providerLabel(marker.provider); + if (marker.reason === 'sensitive_unreadable') return `sensitive in ${provider}`; // COPY-FLAG + if (marker.reason === 'legacy_secret_unreadable') return `legacy secret in ${provider}`; // COPY-FLAG + if (marker.reason === 'value_not_decrypted') return `not decrypted by ${provider}`; // COPY-FLAG + return `not readable from ${provider}`; // COPY-FLAG +} + +/** `no value yet (sensitive in Vercel)`. */ +export function placeholderLabel(marker: PlaceholderMarker): string { + return `no value yet (${reasonPhrase(marker)})`; // COPY-FLAG +} + +/** How to give the name a value. */ +export function placeholderSetCommand(name: string): string { + return `capy edit ${name}`; +} + +/** + * The `.env` comment for a placeholder. A comment line only, never a value. It is written for a + * person to read; nothing in Capy reads it back. + */ +export function placeholderEnvNote(name: string, marker: PlaceholderMarker): string { + return `# ${name} — not readable from ${providerLabel(marker.provider)}; set it with: ${placeholderSetCommand(name)}`; // COPY-FLAG +} + +/** + * Every note Capy has ever written for `name`, whatever provider it named: the exact lines to + * remove once the name has a value or is gone. Whole-line equality, not a pattern. + */ +export function placeholderEnvNoteVariants(name: string): readonly string[] { + return Object.keys(PROVIDER_LABELS).map((provider) => placeholderEnvNote(name, { provider, reason: '' })); +} + +/** Placeholders as reported (human and JSON): the name and the stable code, nothing else. */ +export function placeholdersReport(placeholders: ReadonlyArray<{ readonly name: string; readonly code: string }>): ReadonlyArray<{ name: string; code: string }> { + return placeholders.map(({ name, code }) => ({ name, code })); +} + +/** Kept names as reported (human and JSON): the name, the stable code and the provider's reason, nothing else. */ +export function keptReport(kept: ReadonlyArray<{ readonly name: string; readonly code: string; readonly reason: string }>): ReadonlyArray<{ name: string; code: string; reason: string }> { + return kept.map(({ name, code, reason }) => ({ name, code, reason })); +} + +/** + * `capy secrets` only: the org-wide index carries each location's value hash and not the placeholder + * flag, so a row with an empty hash is the one it can call "no value yet" (the reason is not in the index). + */ +export const NO_VALUE_YET = 'no value yet'; // COPY-FLAG + +/** The line a `capy secrets` row with no value shows where its value would be. */ +export function noValueYetLine(name: string): string { + return `${NO_VALUE_YET} — set it with: ${placeholderSetCommand(name)}`; // COPY-FLAG +} diff --git a/src/deploy/adapter.ts b/src/deploy/adapter.ts index cc1d4bac..09ee4f99 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -12,6 +12,7 @@ import type { Classification } from './classify'; import type { ResolveDokployApiKeyResult, TargetCheck } from './dokployApi'; +import type { ResolveVercelTokenResult } from './vercelTargetApi'; import type { TargetConflict } from '../sync/conflicts'; import type { ErrorCode } from '../types/index'; @@ -37,6 +38,12 @@ export interface AdapterCallContext { interactive?: boolean; /** A Dokploy API key pre-resolved by the caller — see `AdapterCallContext`'s own doc. */ resolvedApiKey?: ResolveDokployApiKeyResult; + /** + * A Vercel token pre-resolved by the caller, once per command — set only + * for a Vercel target in API mode (CAP-696). Same idea as `resolvedApiKey`: + * the system store is asked at most once, and an admin prompted at most once. + */ + resolvedVercelToken?: ResolveVercelTokenResult; } export interface TargetConfig { @@ -109,6 +116,10 @@ export interface PreflightResult { * set both. */ code?: ErrorCode; + /** Where to get a missing or rejected API key (same shape as `Blocked.link`). Additive; set only by key refusals. */ + link?: { label: string; url: string }; + /** One plain instruction for the human that goes with `link`. */ + remedy?: string; /** Non-blocking heads-up(s), shown whether or not ok is true. */ warnings?: readonly DeployWarning[]; } @@ -121,6 +132,10 @@ export interface DeployStep { url?: string; /** Machine-readable companion to `detail` on a `'fail'` step — see `PreflightResult.code`'s own doc. */ code?: ErrorCode; + /** Where to get a missing or rejected API key — see `PreflightResult.link`. */ + link?: { label: string; url: string }; + /** See `PreflightResult.remedy`. */ + remedy?: string; } export interface DeployResult { @@ -179,6 +194,21 @@ export interface DeployContext extends AdapterCallContext { * `conflicts` and writes nothing. */ targetCheck?: TargetCheck; + /** + * Names on the branch that exist with no value yet (placeholders, core/placeholder.ts). A target var in this list + * is never written: the platform keeps its own value. Vercel only; absent: none. + */ + placeholders?: readonly string[]; + /** + * Vercel API mode: replacing a value Vercel holds as sensitive. `delivered` are the target vars Capy has delivered to + * this target before (a delivery record in keep.lock): the first replacement of the others needs a yes. + * `allowed` is `--replace-sensitive`. `ask` is set only where a person can answer; it resolves the answer. + */ + sensitiveReplace?: { + readonly delivered: readonly string[]; + readonly allowed: boolean; + readonly ask?: (names: readonly string[]) => Promise; + }; /** * Deliver only the names in `config.vars`, leaving every other line Capy already manages on the platform exactly * as it is (the batch deploy of picked variables, CAP-715). Absent: the managed block holds the target's vars. diff --git a/src/deploy/adapters/dokploy.ts b/src/deploy/adapters/dokploy.ts index ee147bfe..eff15745 100644 --- a/src/deploy/adapters/dokploy.ts +++ b/src/deploy/adapters/dokploy.ts @@ -36,6 +36,7 @@ * the NAME of the environment variable that holds it (default * DOKPLOY_API_KEY), read at deploy time. */ +import { dokployKeyHelp, keyHelpFields } from '../keyHelp'; import { AdapterCallContext, DeployAdapter, @@ -596,7 +597,7 @@ export function explainApiError( err: unknown, what: string, opts: DokployOptions, -): { reason: string; hint?: string } { +): { reason: string; hint?: string; link?: { label: string; url: string }; remedy?: string } { if (!(err instanceof DokployApiError)) { return { reason: `${what}: ${err instanceof Error ? err.message : String(err)}` }; } @@ -605,6 +606,7 @@ export function explainApiError( return { reason: `${what}: Dokploy rejected the API token in $${opts.tokenEnv} (HTTP ${err.status})`, hint: `Create an API token in the Dokploy dashboard and export it as ${opts.tokenEnv}.`, + ...keyHelpFields(dokployKeyHelp(DOKPLOY_TARGET_SECRET_NAME, opts.baseUrl)), }; case 'not_found': return opts.composeId @@ -1171,6 +1173,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt interactive: ctx.interactive ?? false, orgId: ctx.orgId, devMode: ctx.devMode, + helpBaseUrl: opts.baseUrl, storeName: DOKPLOY_TARGET_SECRET_NAME, deps: deps.getConnectorSecret ? { getConnectorSecret: deps.getConnectorSecret } : undefined, }); @@ -1206,7 +1209,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt const resolved = await resolveApiKeyFor(opts, ctx); if (!resolved.ok) { const { reason, hint } = describeDokployTokenProblem(resolved.code, opts.tokenEnv ?? DEFAULT_TOKEN_ENV, DOKPLOY_TARGET_SECRET_NAME); - return { ok: false, reason, hint }; + return { ok: false, reason, hint, ...keyHelpFields(dokployKeyHelp(DOKPLOY_TARGET_SECRET_NAME, opts.baseUrl)) }; } const client = createDokployClient(opts.baseUrl, resolved.value, deps.fetch); if (opts.composeId) { @@ -1271,7 +1274,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt const resolved = await resolveApiKeyFor(opts, ctx); if (!resolved.ok) { const { reason } = describeDokployTokenProblem(resolved.code, opts.tokenEnv ?? DEFAULT_TOKEN_ENV, DOKPLOY_TARGET_SECRET_NAME); - return fail([], { label: 'dokploy auth', status: 'fail', detail: reason }); + return fail([], { label: 'dokploy auth', status: 'fail', detail: reason, ...keyHelpFields(dokployKeyHelp(DOKPLOY_TARGET_SECRET_NAME, opts.baseUrl)) }); } const client = createDokployClient(opts.baseUrl, resolved.value, deps.fetch); diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index 1989d14e..41d8dd49 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -22,6 +22,12 @@ * Required for preview targets — deployCommand heals * targets saved before `options.gitBranch` existed by * prompting once and persisting. + * + * API MODE (CAP-696): a target whose options carry a `projectId` skips the + * `vercel` binary, the CLI login and `.vercel/project.json` entirely and talks + * to Vercel's REST API with the token in the org system store + * (`_TARGET_VERCEL_TOKEN`) — see `../vercelTargetApi.ts`. Targets without a + * `projectId` take the CLI path above, unchanged. */ import { appendFileSync, @@ -31,12 +37,14 @@ import { writeFileSync, } from 'fs'; import { spawnSync, spawn } from 'child_process'; +import { text as streamText } from 'stream/consumers'; import { homedir } from 'os'; import { basename, join } from 'path'; import inquirer from 'inquirer'; import { searchableSelectQuestion } from '../../ui/searchPrompts'; import { sortedCopy } from '../dokployApi'; import { + AdapterCallContext, DeployAdapter, DeployContext, DeployResult, @@ -45,9 +53,58 @@ import { PreflightResult, TargetConfig, } from '../adapter'; +import { ERROR_CODES } from '../../types/index'; +import { + ApplyResult, + FetchLike, + GetVercelStoreSecret, + ResolveVercelTokenResult, + VarPlan, + VercelApiOptions, + VercelClient, + applyPlans, + asKnownErrorCode, + checkVercelTarget, + createVercelClient, + describeScope, + describeVercelFailure, + describeVercelTokenProblem, + dryRunSummary, + failureOf, + getVercelTargetSecretFromStore, + isVercelApiMode, + parseVercelApiOptions, + planAll, + plansReplacingSensitive, + resolveEnvScope, + sensitiveReplacements, + resolveVercelTargetToken, + sortedNames, + verifyApplied, + vercelTargetKeyHelp, + vercelTargetTokenProblemHelp, +} from '../vercelTargetApi'; type VercelEnv = 'production' | 'preview'; +const KEPT_LABEL = 'kept Vercel value'; // COPY-FLAG + +/** + * The target vars that have no value in Capy yet (placeholders): never written, so Vercel keeps its own. + * A name that does have a value in the decrypted env is not one. + */ +function keptNames(config: TargetConfig, ctx: Pick): readonly string[] { + const placeholders = ctx.placeholders ?? []; + return config.vars.filter((name) => placeholders.includes(name) && !(name in ctx.env)); +} + +/** The step that names what was left alone, or none. */ +function keptSteps(kept: readonly string[]): readonly DeployStep[] { + if (kept.length === 0) return []; + // COPY-FLAG: names only, never a value. + return [{ label: KEPT_LABEL, status: 'skip', detail: `kept Vercel's value (no value in Capy yet): ${sortedNames(kept).join(', ')}` }]; +} + interface VercelOptions { /** Directory holding the Vercel app (package.json / .vercel). Relative to root. */ projectDir: string; @@ -75,9 +132,17 @@ function setVercelEnv( gitBranch: string | undefined, cwd: string, ): { ok: boolean; error?: string } { - const args = ['env', 'add', name, vercelEnv]; - if (vercelEnv === 'preview' && gitBranch) args.push(gitBranch); - args.push('--force', '--yes', '--cwd', cwd); + const args = [ + 'env', + 'add', + name, + vercelEnv, + ...(vercelEnv === 'preview' && gitBranch ? [gitBranch] : []), + '--force', + '--yes', + '--cwd', + cwd, + ]; const r = spawnSync('vercel', args, { input: value, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }); if (r.status !== 0) { return { ok: false, error: extractVercelError(r.stderr, r.stdout) }; @@ -97,7 +162,7 @@ function extractVercelError(stderr: string, stdout: string): string { .map((l) => l.trim()) .filter((l) => l && !/^[╭╰│─╮╯\s]+$/.test(l)); const errLine = lines.find((l) => /error/i.test(l)); - return errLine ?? lines.pop() ?? 'vercel env add failed'; + return errLine ?? lines[lines.length - 1] ?? 'vercel env add failed'; } function readPackageJson(dir: string): any | null { @@ -148,12 +213,9 @@ function spawnAsync( env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (d) => (stdout += d.toString())); - child.stderr.on('data', (d) => (stderr += d.toString())); - child.on('close', (code) => - resolve({ stdout, stderr, code: code ?? 1 }), + const closed = new Promise((done) => child.on('close', (code) => done(code ?? 1))); + void Promise.all([streamText(child.stdout), streamText(child.stderr), closed]).then( + ([stdout, stderr, code]) => resolve({ stdout, stderr, code }), ); }); } @@ -377,58 +439,315 @@ async function runVercelLink(projectDir: string): Promise { }); } -export const vercelAdapter: DeployAdapter = { - id: 'vercel', - label: 'Vercel', - description: 'Pushes plaintext env vars; opens a keep.lock PR Vercel CI deploys on merge', - // Vercel's env store is consumed at both build and runtime, so we treat it - // as runtime ("push to Vercel") for picker copy, but pre-select ALL vars via - // presumeVars below — public-prefixed ones still get inlined by Vercel itself. - varKind: 'runtime', - // CI-only for CODE: capy never runs `vercel deploy`. The deploy PR is the - // deploy signal; Vercel's git integration builds and ships when it merges. - // (We do use the `vercel` CLI here, but only to set env vars, not to deploy.) - defaultMode: 'ci', - ciOnly: true, - // Pre-check every var: Vercel's single env store holds both build-time public - // vars (Vercel inlines them into the bundle by prefix) and runtime secrets. - presumeVars: (cls) => [...cls.buildTime, ...cls.runtime].sort(), - requires: { - // Code deploy is CI (the PR), but we push the env vars via the vercel CLI. - binaries: ['vercel'], - env: [], - }, - - async detect(cwd: string): Promise { - const projectDir = findProjectDir(cwd); - if (!projectDir) return {}; - const pkg = readPackageJson(projectDir); - const framework = detectFramework(pkg); - const rel = projectDir === cwd ? '.' : projectDir.slice(cwd.length + 1); +// ── CLI mode (legacy: the `vercel` binary + .vercel/project.json) ────────── + +async function legacyPreflight(config: TargetConfig, ctx: { cwd: string }): Promise { + // CI-only: no vendor checks at all (no vercel binary, project linkage, or + // login) — the build runs in the user's CI when the deploy PR merges. We + // only validate that the config is coherent and there's something to ship. + const opts = config.options as Partial; + if (!opts.projectDir) { return { - options: { projectDir: rel }, - summary: framework ? `${framework} app in ${rel}` : `app in ${rel}`, + ok: false, + reason: 'vercel target missing projectDir', + hint: `Run \`capy deploy --edit ${config.name}\` to fix.`, }; - }, - - async preflight(config: TargetConfig, ctx: { cwd: string }): Promise { - // CI-only: no vendor checks at all (no vercel binary, project linkage, or - // login) — the build runs in the user's CI when the deploy PR merges. We - // only validate that the config is coherent and there's something to ship. - const opts = config.options as Partial; - if (!opts.projectDir) { - return { - ok: false, - reason: 'vercel target missing projectDir', - hint: `Run \`capy deploy --edit ${config.name}\` to fix.`, - }; - } - const projectDir = join(ctx.cwd, opts.projectDir); - if (!existsSync(projectDir)) { - return { - ok: false, - reason: `project directory ${opts.projectDir} not found`, - }; + } + const projectDir = join(ctx.cwd, opts.projectDir); + if (!existsSync(projectDir)) { + return { + ok: false, + reason: `project directory ${opts.projectDir} not found`, + }; + } + if (config.vars.length === 0) { + return { + ok: false, + reason: 'vercel target has no vars to push', + hint: 'Re-run with `--edit` and select at least one var.', + }; + } + if (opts.vercelEnv !== 'production' && opts.vercelEnv !== 'preview') { + return { + ok: false, + reason: `vercel target missing vercelEnv (got ${JSON.stringify(opts.vercelEnv)})`, + hint: `Set options.vercelEnv to "production" or "preview" (run \`capy deploy --edit ${config.name}\`).`, + }; + } + if (opts.vercelEnv === 'preview' && !opts.gitBranch) { + return { + ok: false, + reason: 'vercel preview target missing options.gitBranch', + hint: + `Preview env vars are scoped to a GIT branch Vercel knows about ` + + `(not a capy branch). Run \`capy deploy --edit ${config.name}\` to pick one.`, + }; + } + // Linkage: either .vercel/project.json exists OR VERCEL_PROJECT_ID + + // VERCEL_ORG_ID are in env (CI). Either is sufficient. If neither holds + // AND we're sitting at an interactive TTY, auto-run `vercel link` so the + // user doesn't have to break flow. In CI/non-TTY we keep the original + // hard fail with the install hint. + const alreadyLinked = readVercelProjectId(projectDir); + const hasEnvIds = + !!process.env.VERCEL_PROJECT_ID && !!process.env.VERCEL_ORG_ID; + if (alreadyLinked.projectId || hasEnvIds) return { ok: true }; + + const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY; + if (!interactive) { + return { + ok: false, + reason: `${opts.projectDir} is not linked to a Vercel project`, + hint: + `Link the project once:\n` + + ` cd ${opts.projectDir} && vercel link\n` + + `Or in CI, set VERCEL_PROJECT_ID + VERCEL_ORG_ID + VERCEL_TOKEN.`, + }; + } + // List the user's Vercel projects and ask which one this is, writing + // .vercel/project.json directly — the whole of what linking produces. + // + // `vercel link` is reached only where it can do something this cannot: + // create a project. Its wizard offers to pull the project's environment + // variables into `.env.local`, and capy owns those, so escalating every + // failure to it — which is what this used to do — risked that pull for + // reasons the wizard could not have fixed anyway. A missing credential + // and a failing API break `vercel link` in exactly the same way. + const picked = await linkByProjectPicker(projectDir); + if (picked.kind === 'no-token') { + return { + ok: false, + reason: 'not signed in to Vercel', + hint: + `Sign in once, then re-run:\n` + + ` vercel login\n` + + `Or set VERCEL_TOKEN in the environment.`, + }; + } + if (picked.kind === 'api-error') { + return { + ok: false, + reason: `could not list your Vercel projects (${picked.detail})`, + hint: + `Usually transient — re-run. If it persists, your saved Vercel\n` + + `credential may have expired: vercel login`, + }; + } + // Nothing to pick, or the user asked for the wizard. Creating a project + // is the one thing the picker cannot do. + const linkOk = picked.kind === 'linked' || (await runVercelLink(projectDir)); + if (!linkOk) { + return { + ok: false, + reason: 'vercel link did not complete', + hint: `Re-run, or link manually: cd ${opts.projectDir} && vercel link`, + }; + } + if (!readVercelProjectId(projectDir).projectId) { + return { + ok: false, + reason: `${opts.projectDir} is still not linked after vercel link`, + hint: `Try again: cd ${opts.projectDir} && vercel link`, + }; + } + return { ok: true }; +} + +async function legacyDeploy(config: TargetConfig, ctx: DeployContext): Promise { + const opts = config.options as Partial; + const vercelEnv = opts.vercelEnv as VercelEnv; + // Preview scope = the git branch the Preview env is wired to. Always the + // explicit per-target option — preflight rejects preview targets without + // it, because the old fallback (the capy branch name) is not a git branch + // and made `vercel env add` fail with "Branch not found". + const gitBranch = vercelEnv === 'preview' ? opts.gitBranch : undefined; + const projectDir = join(ctx.cwd, opts.projectDir ?? '.'); + const scope = vercelEnv === 'preview' ? `preview · branch=${gitBranch}` : 'production'; + + // A var with no value in Capy yet is left alone: Vercel keeps its own. (This path cannot read what Vercel holds, + // so it cannot tell a sensitive value from another: the replace confirmation is API mode only.) + const kept = keptNames(config, ctx); + const pushNames = config.vars.filter((name) => !kept.includes(name)); + + if (ctx.dryRun) { + return { + ok: true, + steps: [ + { + label: 'set Vercel env', + status: 'skip', + detail: `dry-run — would push ${pushNames.length} var(s) to ${scope}, then open the deploy PR`, + }, + ...keptSteps(kept), + ], + ...(kept.length > 0 ? { skipped: kept } : {}), + }; + } + + // Push each declared var as a plaintext Vercel Environment Variable, scoped + // to the chosen Vercel env. Filter the decrypted branch env to the declared + // names; fail loudly if any are missing rather than pushing a partial set. + const filtered: ReadonlyArray = pushNames + .filter((name) => name in ctx.env) + .map((name) => [name, ctx.env[name]] as const); + const missing = pushNames.filter((name) => !(name in ctx.env)); + if (missing.length > 0) { + return { + ok: false, + steps: [ + { + label: 'set Vercel env', + status: 'fail', + detail: `missing in branch ${config.branch}: ${missing.join(', ')}`, + }, + ], + }; + } + + const failed = filtered + .map(([name, value]) => ({ name, r: setVercelEnv(name, value, vercelEnv, gitBranch, projectDir) })) + .filter((x) => !x.r.ok) + .map((x) => `${x.name} (${x.r.error})`); + + if (failed.length > 0) { + return { + ok: false, + steps: [ + { + label: 'set Vercel env', + status: 'fail', + detail: `${scope}: ${failed.join(', ')}`, + }, + ], + }; + } + // Code ships via the keep.lock PR (opened by deployCommand); Vercel's git + // CI builds on merge and reads these env vars natively — no `capy run`. + return { + ok: true, + steps: [ + { + label: 'set Vercel env', + status: 'ok', + detail: `${filtered.length} var(s) pushed to ${scope}`, + }, + ...keptSteps(kept), + { + label: 'vercel build + deploy', + status: 'skip', + detail: 'CI mode — Vercel builds and deploys when the deploy PR merges', + }, + ], + ...(kept.length > 0 ? { skipped: kept } : {}), + }; +} + +// ── API mode (CAP-696) ───────────────────────────────────────────────────── + +export interface VercelAdapterDeps { + fetch?: FetchLike; + sleep?: (ms: number) => Promise; + now?: () => number; + env?: Record; + /** Reads the org system store. Defaults to a getter that never touches it (env-only), so tests and bare callers stay offline. */ + getTargetSecret?: GetVercelStoreSecret; +} + +const SET_ENV_LABEL = 'set Vercel env'; +const SENSITIVE_LABEL = 'replace sensitive value'; // COPY-FLAG + +/** + * The refusal (a failed step) when a plan would replace, for the first time for this target, a value Vercel holds as + * sensitive and nobody said yes: `--replace-sensitive`, or a person at a terminal. `undefined` when it may go on. + * Capy cannot read such a value, so it cannot know whether the one it holds differs. Nothing has been written yet. + */ +async function sensitiveReplaceRefusal(plans: readonly VarPlan[], ctx: DeployContext): Promise { + const delivered = ctx.sensitiveReplace?.delivered ?? []; + const names = plansReplacingSensitive(plans).filter((name) => !delivered.includes(name)); + if (names.length === 0 || ctx.sensitiveReplace?.allowed === true) return undefined; + const ask = ctx.sensitiveReplace?.ask; + if (ask !== undefined && (await ask(names))) return undefined; + return { + label: SENSITIVE_LABEL, + status: 'fail', + code: ERROR_CODES.VERCEL_SENSITIVE_REPLACE_UNCONFIRMED, + // COPY-FLAG: names only. + detail: + ask === undefined + ? `would replace a sensitive value in Vercel, not confirmed: ${sortedNames(names).join(', ')}. Pass --replace-sensitive to allow it.` + : `a sensitive value in Vercel was not replaced (not confirmed): ${sortedNames(names).join(', ')}`, + }; +} +const VERIFY_LABEL = 'verify Vercel env'; + +/** A rejected token (HTTP 401) or one without access (HTTP 403) says where to get another; any other failure adds nothing. */ +function rejectedKeyHelp(code: string, team: string | undefined): { link?: { label: string; url: string }; remedy?: string } { + if (code === ERROR_CODES.VERCEL_TOKEN_REJECTED) return vercelTargetKeyHelp(team); + if (code === ERROR_CODES.VERCEL_NO_ACCESS) return vercelTargetTokenProblemHelp(); + return {}; +} + +function failResult(step: DeployStep): DeployResult { + return { ok: false, steps: [step] }; +} + +function countOf(applied: ApplyResult['applied'], outcome: string): number { + return applied.filter((a) => a.outcome === outcome).length; +} + +function writeSummary(applied: ApplyResult['applied']): string { + return ( + `created ${countOf(applied, 'created')}, updated ${countOf(applied, 'updated')}, ` + + `split from a shared record ${countOf(applied, 'split')}, unchanged ${countOf(applied, 'unchanged')}` + ); +} + +function verifyStep(results: ReadonlyArray<{ name: string; state: string }>): DeployStep { + const names = (state: string) => sortedNames(results.filter((r) => r.state === state).map((r) => r.name)); + const mismatched = names('mismatch'); + if (mismatched.length > 0) { + return { + label: VERIFY_LABEL, + status: 'fail', + code: ERROR_CODES.VERCEL_VERIFY_MISMATCH, + detail: `Vercel holds a different value than was sent for: ${mismatched.join(', ')}`, // COPY-FLAG + }; + } + const unverified = names('unverified'); + const verifiedCount = names('verified').length; + return { + label: VERIFY_LABEL, + status: 'ok', + detail: + unverified.length > 0 + ? `${verifiedCount} verified; written but not readable back: ${unverified.join(', ')}` // COPY-FLAG + : `${verifiedCount} verified`, // COPY-FLAG + }; +} + +export function createVercelAdapter(deps: VercelAdapterDeps = {}): DeployAdapter { + const clientDeps = { fetch: deps.fetch, sleep: deps.sleep, now: deps.now }; + + /** The Vercel token for one call: the caller's single pre-resolution wins; else resolve here. */ + const resolveToken = (ctx: AdapterCallContext, team?: string): Promise => + ctx.resolvedVercelToken + ? Promise.resolve(ctx.resolvedVercelToken) + : resolveVercelTargetToken({ + env: deps.env ?? process.env, + interactive: ctx.interactive ?? false, + orgId: ctx.orgId, + devMode: ctx.devMode, + getSecret: deps.getTargetSecret, + ...(team ? { helpTeam: team } : {}), + }); + + const clientFor = (token: string, opts: VercelApiOptions): VercelClient => + createVercelClient(token, opts.projectId, opts.teamId, { ...clientDeps, ...(opts.teamSlug ? { teamSlug: opts.teamSlug } : {}) }); + + async function apiPreflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise { + const parsed = parseVercelApiOptions(config.options); + if (!parsed.ok) { + return { ok: false, reason: parsed.reason, hint: parsed.hint, code: ERROR_CODES.VERCEL_OPTIONS_INVALID }; } if (config.vars.length === 0) { return { @@ -437,161 +756,209 @@ export const vercelAdapter: DeployAdapter = { hint: 'Re-run with `--edit` and select at least one var.', }; } - if (opts.vercelEnv !== 'production' && opts.vercelEnv !== 'preview') { - return { - ok: false, - reason: `vercel target missing vercelEnv (got ${JSON.stringify(opts.vercelEnv)})`, - hint: `Set options.vercelEnv to "production" or "preview" (run \`capy deploy --edit ${config.name}\`).`, - }; - } - if (opts.vercelEnv === 'preview' && !opts.gitBranch) { - return { - ok: false, - reason: 'vercel preview target missing options.gitBranch', - hint: - `Preview env vars are scoped to a GIT branch Vercel knows about ` + - `(not a capy branch). Run \`capy deploy --edit ${config.name}\` to pick one.`, - }; + const token = await resolveToken(ctx, parsed.options.teamSlug); + if (!token.ok) { + const { reason, hint } = describeVercelTokenProblem(token.code); + return { ok: false, reason, hint, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp(parsed.options.teamSlug) }; } - // Linkage: either .vercel/project.json exists OR VERCEL_PROJECT_ID + - // VERCEL_ORG_ID are in env (CI). Either is sufficient. If neither holds - // AND we're sitting at an interactive TTY, auto-run `vercel link` so the - // user doesn't have to break flow. In CI/non-TTY we keep the original - // hard fail with the install hint. - let linked = readVercelProjectId(projectDir); - const hasEnvIds = - !!process.env.VERCEL_PROJECT_ID && !!process.env.VERCEL_ORG_ID; - if (!linked.projectId && !hasEnvIds) { - const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY; - if (!interactive) { - return { - ok: false, - reason: `${opts.projectDir} is not linked to a Vercel project`, - hint: - `Link the project once:\n` + - ` cd ${opts.projectDir} && vercel link\n` + - `Or in CI, set VERCEL_PROJECT_ID + VERCEL_ORG_ID + VERCEL_TOKEN.`, - }; - } - // List the user's Vercel projects and ask which one this is, writing - // .vercel/project.json directly — the whole of what linking produces. - // - // `vercel link` is reached only where it can do something this cannot: - // create a project. Its wizard offers to pull the project's environment - // variables into `.env.local`, and capy owns those, so escalating every - // failure to it — which is what this used to do — risked that pull for - // reasons the wizard could not have fixed anyway. A missing credential - // and a failing API break `vercel link` in exactly the same way. - const picked = await linkByProjectPicker(projectDir); - if (picked.kind === 'no-token') { - return { - ok: false, - reason: 'not signed in to Vercel', - hint: - `Sign in once, then re-run:\n` + - ` vercel login\n` + - `Or set VERCEL_TOKEN in the environment.`, - }; - } - if (picked.kind === 'api-error') { - return { - ok: false, - reason: `could not list your Vercel projects (${picked.detail})`, - hint: - `Usually transient — re-run. If it persists, your saved Vercel\n` + - `credential may have expired: vercel login`, - }; - } - // Nothing to pick, or the user asked for the wizard. Creating a project - // is the one thing the picker cannot do. - const linkOk = picked.kind === 'linked' || (await runVercelLink(projectDir)); - if (!linkOk) { - return { - ok: false, - reason: 'vercel link did not complete', - hint: `Re-run, or link manually: cd ${opts.projectDir} && vercel link`, - }; - } - linked = readVercelProjectId(projectDir); - if (!linked.projectId) { - return { - ok: false, - reason: `${opts.projectDir} is still not linked after vercel link`, - hint: `Try again: cd ${opts.projectDir} && vercel link`, - }; - } + const checked = await checkVercelTarget(clientFor(token.value, parsed.options), parsed.options); + if (!checked.ok) { + return { ok: false, ...describeVercelFailure(checked.code, checked.status, parsed.options.teamSlug), code: checked.code }; } return { ok: true }; - }, - - async deploy(config: TargetConfig, ctx: DeployContext): Promise { - const steps: DeployStep[] = []; - const opts = config.options as Partial; - const vercelEnv = opts.vercelEnv as VercelEnv; - // Preview scope = the git branch the Preview env is wired to. Always the - // explicit per-target option — preflight rejects preview targets without - // it, because the old fallback (the capy branch name) is not a git branch - // and made `vercel env add` fail with "Branch not found". - const gitBranch = vercelEnv === 'preview' ? opts.gitBranch : undefined; - const projectDir = join(ctx.cwd, opts.projectDir ?? '.'); - const scope = vercelEnv === 'preview' ? `preview · branch=${gitBranch}` : 'production'; - - if (ctx.dryRun) { - steps.push({ - label: 'set Vercel env', - status: 'skip', - detail: `dry-run — would push ${config.vars.length} var(s) to ${scope}, then open the deploy PR`, - }); - return { ok: true, steps }; + } + + async function apiDryRun(config: TargetConfig, ctx: DeployContext, opts: VercelApiOptions): Promise { + // Reads only — a dry run makes no write call of any kind. + const token = await resolveToken(ctx, opts.teamSlug); + if (!token.ok) { + const { reason } = describeVercelTokenProblem(token.code); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp(opts.teamSlug) }); + } + const client = clientFor(token.value, opts); + const scope = await resolveEnvScope(client, opts); + if (!scope.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code, opts.teamSlug) }); } + const listed = await client.listEnvs().then( + (records) => ({ ok: true as const, records }), + (err) => failureOf(err), + ); + if (!listed.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code, opts.teamSlug) }); + } + const kept = keptNames(config, ctx); + const pushNames = config.vars.filter((name) => !kept.includes(name)); + const plan = dryRunSummary(pushNames, listed.records, scope.scope); + const names = (xs: readonly string[]) => (xs.length > 0 ? ` [${sortedNames(xs).join(', ')}]` : ''); + // The first replacement of a value Vercel holds as sensitive asks (unless this target delivered the name before). + const delivered = ctx.sensitiveReplace?.delivered ?? []; + const needsConfirmation = sensitiveReplacements(pushNames, listed.records, scope.scope).filter((name) => !delivered.includes(name)); + return { + ok: true, + steps: [ + { + label: SET_ENV_LABEL, + status: 'skip', + // COPY-FLAG: names and counts only. + detail: + `dry-run — would push ${pushNames.length} var(s) to ${describeScope(scope.scope)}: ` + + `create ${plan.create.length}${names(plan.create)}, ` + + `update ${plan.update.length}${names(plan.update)}, ` + + `split from a shared record ${plan.split.length}${names(plan.split)}; then open the deploy PR`, + }, + ...keptSteps(kept), + ...(needsConfirmation.length > 0 + ? [ + { + label: SENSITIVE_LABEL, + status: 'skip' as const, + // COPY-FLAG: names only. + detail: `would replace a sensitive value (needs confirmation): ${sortedNames(needsConfirmation).join(', ')}`, + }, + ] + : []), + ], + ...(kept.length > 0 ? { skipped: kept } : {}), + }; + } - // Push each declared var as a plaintext Vercel Environment Variable, scoped - // to the chosen Vercel env. Filter the decrypted branch env to the declared - // names; fail loudly if any are missing rather than pushing a partial set. - const filtered: Array<[string, string]> = []; - const missing: string[] = []; - for (const name of config.vars) { - if (name in ctx.env) filtered.push([name, ctx.env[name]]); - else missing.push(name); + async function apiDeploy(config: TargetConfig, ctx: DeployContext): Promise { + const parsed = parseVercelApiOptions(config.options); + if (!parsed.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: ERROR_CODES.VERCEL_OPTIONS_INVALID, detail: parsed.reason }); } + if (ctx.dryRun) return apiDryRun(config, ctx, parsed.options); + + // A var with no value in Capy yet is never written: Vercel keeps its own value. It is not "missing". + const kept = keptNames(config, ctx); + const pushNames = config.vars.filter((name) => !kept.includes(name)); + if (pushNames.length === 0) { + // Nothing to write, so nothing is asked of Vercel at all. + return { ok: true, steps: [...keptSteps(kept)], skipped: kept }; + } + const missing = pushNames.filter((name) => !(name in ctx.env)); if (missing.length > 0) { - steps.push({ - label: 'set Vercel env', + return failResult({ + label: SET_ENV_LABEL, status: 'fail', detail: `missing in branch ${config.branch}: ${missing.join(', ')}`, }); - return { ok: false, steps }; } - - const failed: string[] = []; - for (const [name, value] of filtered) { - const r = setVercelEnv(name, value, vercelEnv, gitBranch, projectDir); - if (!r.ok) failed.push(`${name} (${r.error})`); + const token = await resolveToken(ctx, parsed.options.teamSlug); + if (!token.ok) { + const { reason } = describeVercelTokenProblem(token.code); + return failResult({ label: 'vercel auth', status: 'fail', detail: reason, code: asKnownErrorCode(token.code), ...vercelTargetKeyHelp(parsed.options.teamSlug) }); + } + const client = clientFor(token.value, parsed.options); + const scope = await resolveEnvScope(client, parsed.options); + if (!scope.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: scope.code, detail: describeVercelFailure(scope.code, scope.status).reason, ...rejectedKeyHelp(scope.code, parsed.options.teamSlug) }); + } + const where = describeScope(scope.scope); + const listed = await client.listEnvs().then( + (records) => ({ ok: true as const, records }), + (err) => failureOf(err), + ); + if (!listed.ok) { + return failResult({ label: SET_ENV_LABEL, status: 'fail', code: listed.code, detail: describeVercelFailure(listed.code, listed.status).reason, ...rejectedKeyHelp(listed.code, parsed.options.teamSlug) }); } - if (failed.length > 0) { - steps.push({ - label: 'set Vercel env', + const pairs = pushNames.map((name) => [name, ctx.env[name]] as const); + const plans: readonly VarPlan[] = planAll(pairs, listed.records, scope.scope); + + // Replacing a value Vercel holds as sensitive: the first time for this target needs a yes, before anything is written. + const refused = await sensitiveReplaceRefusal(plans, ctx); + if (refused) return failResult(refused); + + const applied = await applyPlans(client, plans, scope.scope); + if (!applied.ok) { + return failResult({ + label: SET_ENV_LABEL, status: 'fail', - detail: `${scope}: ${failed.join(', ')}`, + code: applied.code, + // COPY-FLAG: names and counts only — never a value. + detail: `${where}: stopped at ${applied.name} (${describeVercelFailure(applied.code, applied.status).reason}); ${applied.applied.length} var(s) written before it`, }); - return { ok: false, steps }; } - steps.push({ - label: 'set Vercel env', + + const verified = await verifyApplied(client, applied.applied, ctx.env); + const writeStep: DeployStep = { + label: SET_ENV_LABEL, status: 'ok', - detail: `${filtered.length} var(s) pushed to ${scope}`, - }); - // Code ships via the keep.lock PR (opened by deployCommand); Vercel's git - // CI builds on merge and reads these env vars natively — no `capy run`. - steps.push({ + detail: `${pushNames.length} var(s) pushed to ${where} (${writeSummary(applied.applied)})`, + }; + if (!verified.ok) { + return { + ok: false, + steps: [ + writeStep, + { label: VERIFY_LABEL, status: 'fail', code: verified.code, detail: describeVercelFailure(verified.code, verified.status).reason }, + ], + }; + } + const vStep = verifyStep(verified.results); + const buildStep: DeployStep = { label: 'vercel build + deploy', status: 'skip', detail: 'CI mode — Vercel builds and deploys when the deploy PR merges', - }); - return { ok: true, steps }; - }, -}; + }; + return vStep.status === 'fail' + ? { ok: false, steps: [writeStep, vStep] } + : { ok: true, steps: [writeStep, ...keptSteps(kept), vStep, buildStep], ...(kept.length > 0 ? { skipped: kept } : {}) }; + } + + return { + id: 'vercel', + label: 'Vercel', + description: 'Pushes plaintext env vars; opens a keep.lock PR Vercel CI deploys on merge', + // Vercel's env store is consumed at both build and runtime, so we treat it + // as runtime ("push to Vercel") for picker copy, but pre-select ALL vars via + // presumeVars below — public-prefixed ones still get inlined by Vercel itself. + varKind: 'runtime', + // CI-only for CODE: capy never runs `vercel deploy`. The deploy PR is the + // deploy signal; Vercel's git integration builds and ships when it merges. + // (CLI mode uses the `vercel` CLI here, but only to set env vars, not to deploy.) + defaultMode: 'ci', + ciOnly: true, + // Pre-check every var: Vercel's single env store holds both build-time public + // vars (Vercel inlines them into the bundle by prefix) and runtime secrets. + presumeVars: (cls) => sortedNames([...cls.buildTime, ...cls.runtime]), + requires: { + // CLI mode pushes the env vars via the vercel CLI. API-mode targets (a + // `projectId` in options) never spawn it — nothing in the deploy flow + // reads this list to refuse a run, so the static declaration is + // unchanged for existing callers and tests. + binaries: ['vercel'], + env: [], + }, + + async detect(cwd: string): Promise { + const projectDir = findProjectDir(cwd); + if (!projectDir) return {}; + const pkg = readPackageJson(projectDir); + const framework = detectFramework(pkg); + const rel = projectDir === cwd ? '.' : projectDir.slice(cwd.length + 1); + return { + options: { projectDir: rel }, + summary: framework ? `${framework} app in ${rel}` : `app in ${rel}`, + }; + }, + + async preflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise { + return isVercelApiMode(config.options) ? apiPreflight(config, ctx) : legacyPreflight(config, ctx); + }, + + async deploy(config: TargetConfig, ctx: DeployContext): Promise { + return isVercelApiMode(config.options) ? apiDeploy(config, ctx) : legacyDeploy(config, ctx); + }, + }; +} + +/** The registry's instance: API mode reads the real org system store. */ +export const vercelAdapter: DeployAdapter = createVercelAdapter({ + getTargetSecret: getVercelTargetSecretFromStore, +}); function detectFramework(pkg: any): string | null { if (!pkg?.dependencies && !pkg?.devDependencies) return null; diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts index 34e1a49b..3f259aa9 100644 --- a/src/deploy/batchDeploy.ts +++ b/src/deploy/batchDeploy.ts @@ -38,7 +38,7 @@ import type { GithubApi, RepoRef } from './githubApi'; import type { ResolveDokployApiKeyResult } from './adapters/dokploy'; import { createDokployAdapter } from './adapters/dokploy'; import { resolveDokployApiKeyOnce } from './dokployApiKeyOnce'; -import { RecordClient, RecordResult, deliveryFor, recordDeliveriesOnServer } from './deliveryRecord'; +import { RecordClient, RecordResult, deliveredBefore, deliveryFor, recordDeliveriesOnServer } from './deliveryRecord'; import { buildDeployPrBody } from './deployPrBody'; import { foldDeployKeep, hashValue } from './keepGate'; import { parseTargets } from './configuredTargets'; @@ -618,6 +618,8 @@ async function passOverTarget( noDeploy: false, keepOtherManaged: true, cwd: process.cwd(), + // Vercel API mode: a value Vercel holds as sensitive is replaced here only where Capy delivered it before (this pass asks nobody). + sensitiveReplace: { delivered: deliveredBefore(serverKeep, { ...effective.config, branch: item.branch }, env.adapter), allowed: false }, ...callCtx, ...(targetCheck === undefined ? {} : { targetCheck }), }; diff --git a/src/deploy/deliveryRecord.ts b/src/deploy/deliveryRecord.ts index 3cfa8155..65f4217b 100644 --- a/src/deploy/deliveryRecord.ts +++ b/src/deploy/deliveryRecord.ts @@ -12,20 +12,32 @@ * (`batchDeploy.ts`). Never carries a value: names, hashes and codes only. */ import type { DeployAdapter, TargetConfig } from './adapter'; +import { isVercelApiMode } from './vercelTargetApi'; import { TargetDeliveryDescriptor, VarDelivery, recordTargetDeliveries } from './targetsGate'; import { uploadKeepOnly } from '../sync/commit'; import type { RateInfo } from '../utils/pool'; +import type { KeepFile } from '../types/index'; /** * Adapter-specific handle for what a target actually points at, when one is * knowable from `target.options` alone. Only Dokploy defines this today - * (`composeId` / `applicationId`); every other adapter gets `undefined` — + * (`composeId` / `applicationId`) and Vercel API mode (`projectId`); every other adapter gets `undefined` — * there is no spec'd `ref` shape for them yet. */ export function targetRefFor(target: TargetConfig): Record | undefined { const opts = target.options as Record; if (typeof opts.composeId === 'string') return { composeId: opts.composeId }; if (typeof opts.applicationId === 'string') return { applicationId: opts.applicationId }; + // Vercel API mode (CAP-696): the project, plus the custom environment's id + // when the target points at one. + if (target.kind === 'vercel' && isVercelApiMode(opts)) { + return { + projectId: String(opts.projectId), + ...(typeof opts.customEnvironmentId === 'string' && opts.customEnvironmentId + ? { envId: opts.customEnvironmentId } + : {}), + }; + } return undefined; } @@ -57,6 +69,21 @@ export function deliveryFor( return { delivery, values }; } +/** + * The target vars Capy has delivered to this target before: a delivery record with a value hash on the branch's entry. + * (A target that is only configured, with no push yet, has a record without one.) This is how "the first replacement + * of a sensitive Vercel value was confirmed" is remembered: the delivery that followed it is the record. + */ +export function deliveredBefore(keep: KeepFile | null | undefined, target: Pick, adapter: Pick): readonly string[] { + return target.vars.filter((name) => + (keep?.variables[name] ?? []) + .filter((entry) => entry.branch === target.branch) + .some((entry) => + (entry.targets ?? []).some((t) => t.provider === adapter.id && t.target === target.name && t.deployed_value_hash !== undefined), + ), + ); +} + /** What the recorder needs from the service client. */ export interface RecordClient { getLatestSecrets(projectId: string, branch: string): Promise<{ env_file: string; keep_hash: string; keep_file?: string } | null>; diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index b24a5868..f4998efa 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -663,6 +663,8 @@ export interface DokploySystemStoreCallOptions { interactive: boolean; devMode?: boolean; apiUrl?: string; + /** The Dokploy dashboard URL, so the prompt can link to the page that makes the key. Absent = the docs link. */ + helpBaseUrl?: string; } export interface ResolveDokployApiKeyDeps { @@ -718,6 +720,8 @@ export interface ResolveDokployApiKeyOptions { orgId?: string; devMode?: boolean; apiUrl?: string; + /** The Dokploy dashboard URL, passed to the store so the key prompt can link to the page that makes the key. */ + helpBaseUrl?: string; deps?: ResolveDokployApiKeyDeps; /** * Which system store entry to ask for (CAP-679 follow-up: deploy and @@ -771,6 +775,7 @@ export async function resolveDokployApiKey( interactive: opts.interactive, devMode: opts.devMode, apiUrl: opts.apiUrl, + ...(opts.helpBaseUrl ? { helpBaseUrl: opts.helpBaseUrl } : {}), }, opts.storeName ?? DOKPLOY_CONNECTOR_SECRET_NAME, ); @@ -1796,6 +1801,8 @@ export async function classifyImportCandidates( interactive: boolean; /** Names only — never the value on either side. */ confirm: (message: string, defaultValue: boolean) => Promise; + /** The provider the value comes from, for the replace prompt. Default `Dokploy` — every existing caller's wording is unchanged. */ + providerLabel?: string; }, ): Promise { return candidates.reduce>(async (accPromise, e) => { @@ -1813,7 +1820,7 @@ export async function classifyImportCandidates( if (opts.interactive) { // COPY-FLAG: new user-facing string, minimal/neutral wording. const replace = await opts.confirm( - `${e.name} is already set locally with a different value. Replace it with the Dokploy value?`, + `${e.name} is already set locally with a different value. Replace it with the ${opts.providerLabel ?? 'Dokploy'} value?`, false, ); return replace diff --git a/src/deploy/dokployApiKeyOnce.ts b/src/deploy/dokployApiKeyOnce.ts index d521eec7..8f0beebe 100644 --- a/src/deploy/dokployApiKeyOnce.ts +++ b/src/deploy/dokployApiKeyOnce.ts @@ -50,6 +50,7 @@ export async function resolveDokployApiKeyOnce( interactive, orgId, devMode, + helpBaseUrl: (target.options as { baseUrl?: string }).baseUrl, storeName: DOKPLOY_TARGET_SECRET_NAME, deps: { getConnectorSecret }, }); diff --git a/src/deploy/keyHelp.ts b/src/deploy/keyHelp.ts new file mode 100644 index 00000000..9156f89b --- /dev/null +++ b/src/deploy/keyHelp.ts @@ -0,0 +1,127 @@ +/** + * Where to get an API key (Vince's rule: every time Capy asks for, or refuses + * for lack of, a Vercel token or a Dokploy API key, it says where to get one). + * + * One shape for every surface — the hidden prompt, human refusals on stderr, + * and the `--json` refusal objects — so an agent can relay `link` + `remedy` + * to the human. `link` and `remedy` are the same field names as + * `Blocked` in `ui/screens/contract.ts`. + * + * Pages (checked 2026-10-01): + * - Vercel: the dashboard's token page, `https://vercel.com/account/settings/tokens`. + * A `vercel login` session is OAuth and cannot create tokens, so the token + * must be made in the dashboard; Vercel has no org-owned key, but a token + * can be scoped to one team ("Scope"). + * - Dokploy: inside the user's OWN Dokploy, Settings → Profile → API/CLI + * section (docs: https://docs.dokploy.com/docs/api, "go to the + * /settings/profile page and go to API/CLI Section and generate the + * token"). In the dashboard that page is `/dashboard/settings/profile`. + * With no known base URL the docs page is the link. + */ + +export interface KeyHelp { + link: { label: string; url: string }; + /** The instructions for the human, as one string. */ + remedy: string; + /** The same instructions, one per terminal line (stderr). */ + lines: readonly string[]; +} + +export const VERCEL_TOKEN_PAGE_URL = 'https://vercel.com/account/settings/tokens'; +export const DOKPLOY_API_DOCS_URL = 'https://docs.dokploy.com/docs/api'; + +const VERCEL_SECRET_NAMES: readonly string[] = ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']; +const DOKPLOY_SECRET_NAMES: readonly string[] = ['_CONNECTOR_DOKPLOY_API_KEY', '_TARGET_DOKPLOY_API_KEY']; + +/** `save`: the key is saved with `capy system set` (refusals, non-interactive). `paste`: a hidden prompt follows right here. */ +export type KeyHelpMode = 'save' | 'paste'; + +export interface KeyHelpOptions { + /** Dokploy: the dashboard's base URL, so the link is that Dokploy's own page. */ + baseUrl?: string; + /** Vercel: the slug of the team that owns the project, when Capy knows it. */ + team?: string; + mode?: KeyHelpMode; +} + +/** + * A team id (`team_…`) is not a slug and is never shown as one; `--team` + * takes either, so this is the one place that tells them apart. + */ +export function teamSlugOf(team: string | undefined): string | undefined { + const trimmed = team?.trim(); + if (!trimmed || trimmed.startsWith('team_')) return undefined; + return trimmed; +} + +/** Where to get the Vercel token `secretName` holds. */ +export function vercelKeyHelp(secretName: string, opts: KeyHelpOptions = {}): KeyHelp { + const team = teamSlugOf(opts.team); + // COPY-FLAG (approved wording) + const first = `Create a token at ${VERCEL_TOKEN_PAGE_URL}. Set Scope to the team that owns the project${team ? ` (${team})` : ''}.`; + // COPY-FLAG (approved wording) + const second = opts.mode === 'paste' ? 'Then paste it here.' : `Then save it: capy system set ${secretName}`; + return { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, + remedy: `${first} ${second}`, + lines: [first, second], + }; +} + +export interface TokenProblemHelpOptions { + /** True when the run waits for Enter to replace the token right here; the second step then says so. */ + pressEnter?: boolean; +} + +/** + * What to do when a Vercel token stops a run (rejected, or valid but without + * access): two numbered steps. `remedy` is both, one per line. + */ +export function vercelTokenProblemHelp(secretName: string, opts: TokenProblemHelpOptions = {}): KeyHelp { + // Approved wording. + const first = `1) Create a token with team access at ${VERCEL_TOKEN_PAGE_URL}`; + // Approved wording. + const second = `2) Run capy system set ${secretName}${opts.pressEnter ? ' (or press enter to continue)' : ''}`; + return { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + link: { label: 'Vercel token page', url: VERCEL_TOKEN_PAGE_URL }, + remedy: `${first}\n${second}`, + lines: [first, second], + }; +} + +/** The Dokploy page that makes API keys: inside the user's own Dokploy when its address is known, else the docs. */ +export function dokployKeyPage(baseUrl: string | undefined): { label: string; url: string } { + const base = baseUrl?.trim().replace(/\/+$/, ''); + return base + ? // COPY-FLAG: new user-facing string, minimal/neutral wording. + { label: 'Dokploy API keys', url: `${base}/dashboard/settings/profile` } + : // COPY-FLAG: new user-facing string, minimal/neutral wording. + { label: 'Dokploy API docs', url: DOKPLOY_API_DOCS_URL }; +} + +/** Where to get the Dokploy API key `secretName` holds. */ +export function dokployKeyHelp(secretName: string, baseUrl?: string): KeyHelp { + const link = dokployKeyPage(baseUrl); + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const remedy = `Open ${link.url}, find the API/CLI section, generate a key, and save it with \`capy system set ${secretName}\`.`; + return { link, remedy, lines: [remedy] }; +} + +/** Help for one of the four known key names; `null` for any other name. */ +export function keyHelpForName(name: string, opts: KeyHelpOptions = {}): KeyHelp | null { + if (VERCEL_SECRET_NAMES.includes(name)) return vercelKeyHelp(name, opts); + if (DOKPLOY_SECRET_NAMES.includes(name)) return dokployKeyHelp(name, opts.baseUrl); + return null; +} + +/** The two JSON/object fields, to spread into a refusal. */ +export function keyHelpFields(help: KeyHelp): { link: { label: string; url: string }; remedy: string } { + return { link: help.link, remedy: help.remedy }; +} + +/** Human text for stderr: one line per instruction. A refusal that carries only `remedy` prints it as one line (the remedy already holds the link). */ +export function formatKeyHelp(help: { remedy: string; lines?: readonly string[] }): string { + return (help.lines ?? [help.remedy]).map((line) => ` ${line}`).join('\n'); +} diff --git a/src/deploy/vercelApi.ts b/src/deploy/vercelApi.ts new file mode 100644 index 00000000..ef0c870f --- /dev/null +++ b/src/deploy/vercelApi.ts @@ -0,0 +1,934 @@ +/** + * Vercel REST client + env-record primitives for `capy connect vercel` + * (CAP-696): a ONE-TIME, READ-ONLY import of a Vercel project's environment + * variables. + * + * READ-ONLY by construction: every request goes through `VercelFetchLike`, + * whose `method` is the literal type `'GET'`, and this file contains no other + * verb. The token is a header on every request and is never part of an error + * message, a URL, or a return value. + * + * Docs checked (2026-10-01), every path/version/field below: + * - Teams: https://vercel.com/docs/rest-api/teams/list-all-teams + * GET /v2/teams, `limit`, `until`; `teams[].{id,slug,name}`; + * `pagination.next` is a timestamp for `until`. + * - Projects: https://vercel.com/docs/rest-api/projects/retrieve-a-list-of-projects + * GET /v10/projects, `limit`, `from` (a "timestamp or + * continuation token" — the docs list `from`, not `until`, + * for this endpoint), `teamId`; `projects[].{id,name, + * rootDirectory,link}`, `pagination.next`. `link` is + * undocumented beyond the GitHub shape + * (`type:'github'`, `org`, `repo`, `productionBranch`). + * - One project: GET /v9/projects/{idOrName} (projects index page). + * - Env list: https://vercel.com/docs/rest-api/projects/retrieve-the-environment-variables-of-a-project-by-id-or-name + * GET /v10/projects/{idOrName}/env; `envs[].{id,key,type, + * target,gitBranch,customEnvironmentIds,configurationId, + * value}`; `type` is encrypted | plain | secret | sensitive + * | system; `target` is an array OR a string. + * - Env value: https://vercel.com/docs/rest-api/projects/retrieve-the-decrypted-value-of-an-environment-variable-of-a-project-by-id + * GET /v1/projects/{idOrName}/env/{id} — "Retrieve the + * decrypted value of an environment variable of a project + * by id". Neither this route nor the list's `decrypt=true` + * is marked deprecated; this connector uses the per-id + * route so the list (names + metadata) never carries + * values. + * - Custom envs: https://vercel.com/docs/rest-api/environment/retrieve-custom-environments + * GET /v9/projects/{idOrName}/custom-environments → + * `{ environments: [{ id, slug, type, ... }] }`. + * - Rate limits: 429, `X-RateLimit-Limit/Remaining/Reset` (reset in UTC + * epoch seconds); error body `error.code === 'rate_limited'` + * with `error.limit.{reset,resetMs}` + * (https://vercel.com/docs/rest-api/errors). + * - Team scope: query `teamId` (or `slug`) on every endpoint above. + */ +import { ERROR_CODES } from '../types/index'; +import { sortedCopy } from './dokployApi'; +import { keyHelpFields, vercelTokenProblemHelp } from './keyHelp'; + +// ── Names ──────────────────────────────────────────────────────────────────── + +/** Org system store entry that holds the Vercel token for IMPORT (`capy connect vercel`, inbound). */ +export const VERCEL_CONNECTOR_SECRET_NAME = '_CONNECTOR_VERCEL_TOKEN'; + +/** Org system store entry that holds the Vercel token for DEPLOY (`capy deploy`, outbound). A SEPARATE key on purpose — see `DOKPLOY_TARGET_SECRET_NAME`. */ +export const VERCEL_TARGET_SECRET_NAME = '_TARGET_VERCEL_TOKEN'; + +/** The env var read when no `--token-env` is given and the system store has no entry — mirrors Dokploy's `DOKPLOY_API_KEY` fallback. */ +export const DEFAULT_VERCEL_TOKEN_ENV = 'VERCEL_TOKEN'; + +export const VERCEL_API_BASE = 'https://api.vercel.com'; + +/** The three built-in Vercel environments; every project has them whether or not any variable targets them. */ +export const VERCEL_STANDARD_ENVIRONMENTS = ['production', 'preview', 'development'] as const; +export type VercelStandardEnvironment = (typeof VERCEL_STANDARD_ENVIRONMENTS)[number]; + +// ── Shapes (only the fields we read) ───────────────────────────────────────── + +export interface VercelTeam { + id: string; + slug: string; + name?: string; +} + +/** A project's git link. Only `github` is documented well enough to match on; everything else is carried as `unsupported` so it can be REPORTED, never guessed at. */ +export type VercelGitLink = + | { kind: 'github'; org: string; repo: string; productionBranch?: string } + | { kind: 'unsupported'; type: string; productionBranch?: string }; + +export interface VercelProject { + id: string; + name: string; + /** `null`/absent/'' all mean the repo root. */ + rootDirectory: string | null; + link: VercelGitLink | null; +} + +/** How a custom environment picks the git branches it deploys: Vercel's branch matcher. A type this client does not know is dropped, never guessed at. */ +export type VercelBranchMatcherType = 'equals' | 'startsWith' | 'endsWith'; + +export interface VercelBranchMatcher { + readonly type: VercelBranchMatcherType; + readonly pattern: string; +} + +export interface VercelCustomEnvironment { + id: string; + slug: string; + /** The environment's branch rule; absent when it has none. */ + branchMatcher?: VercelBranchMatcher; +} + +/** One preview deployment, as far as discovery reads it: the git branch it built from (`meta.githubCommitRef`), when Vercel names one. A name only. */ +export interface VercelDeployment { + readonly id: string; + readonly gitBranch?: string; +} + +export interface VercelEnvRecord { + id: string; + key: string; + /** `system` | `secret` | `encrypted` | `plain` | `sensitive` — kept as a plain string so a type Vercel adds later is `unsupported_type`, never a crash. */ + type: string; + /** `target` normalized: the array form, the string form wrapped, absent → `[]` (custom-environment-only records). */ + targets: readonly string[]; + /** Preview-only branch scoping. Absent/'' = all branches. */ + gitBranch?: string; + customEnvironmentIds: readonly string[]; + configurationId?: string; + /** Present on the list response for `plain` records; never for `sensitive`; ciphertext-or-absent for `encrypted` (which is why values come from the per-id route). */ + value?: string; +} + +/** Which Vercel object an import read from — the `source` in the import's JSON, parallel to `DokployImportSource`. */ +export interface VercelImportSource { + kind: 'vercel'; + projectId: string; + projectName: string; + /** Absent for a personal-account project. */ + teamId?: string; + /** The Vercel environment: `production` | `preview` | `development` | a custom slug. */ + environment: string; +} + +// ── Errors ─────────────────────────────────────────────────────────────────── + +export type VercelErrorCode = + | 'unreachable' + | 'unauthorized' + | 'forbidden' + | 'not_found' + | 'bad_request' + | 'rate_limited' + | 'server_error' + | 'bad_response'; + +/** + * What a request was reading, set by the client method that made it. A 403 + * carries it so the refusal can name what the token could not access: it is a + * structured field, never parsed out of a message. + */ +export type VercelResource = + | { readonly kind: 'teams' } + | { readonly kind: 'projects' } + | { readonly kind: 'project'; readonly project: string } + | { readonly kind: 'project_envs'; readonly project: string } + | { readonly kind: 'project_deployments'; readonly project: string }; + +export class VercelApiError extends Error { + constructor( + public readonly code: VercelErrorCode, + public readonly status: number | null, + message: string, + public readonly resource?: VercelResource, + ) { + super(message); + this.name = 'VercelApiError'; + } +} + +function codeForStatus(status: number): VercelErrorCode { + if (status === 401) return 'unauthorized'; + if (status === 403) return 'forbidden'; + if (status === 404) return 'not_found'; + if (status === 429) return 'rate_limited'; + if (status >= 400 && status < 500) return 'bad_request'; + return 'server_error'; +} + +/** A failed Vercel call as a refusal: a stable code and a message, plus where to get a better token when the token is the problem. */ +export interface MappedVercelError { + code: string; + message: string; + link?: { label: string; url: string }; + remedy?: string; +} + +export interface MapVercelApiErrorOptions { + /** Discovery reads every team and project: a 403 on those lists says the token must cover the whole team. */ + discovery?: boolean; +} + +function deniedResource(resource: VercelResource | undefined): string { + if (!resource) return 'the request'; + if (resource.kind === 'teams') return 'the team list'; + if (resource.kind === 'projects') return 'the project list'; + if (resource.kind === 'project') return `project ${resource.project}`; + if (resource.kind === 'project_deployments') return `the deployments of project ${resource.project}`; + return `the environment variables of project ${resource.project}`; +} + +function forbiddenMessage(err: VercelApiError, discovery: boolean): string { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + const denied = `Vercel denied access to ${deniedResource(err.resource)} (HTTP ${err.status}).`; + const kind = err.resource?.kind; + if (discovery && (kind === 'teams' || kind === 'projects')) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + return `${denied} Discovery needs a token scoped to the whole team.`; + } + return denied; +} + +/** + * A failed Vercel call as a stable refusal code + message. Never a value. + * Branches on the typed error's `code`, never on its text. A token problem + * (401 rejected, 403 no access) carries the token page and the two steps. + */ +export function mapVercelApiError(err: unknown, options: MapVercelApiErrorOptions = {}): MappedVercelError { + if (err instanceof VercelApiError) { + if (err.code === 'unauthorized') { + return { + code: ERROR_CODES.VERCEL_AUTH_FAILED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `Vercel rejected the token (HTTP ${err.status}).`, + ...keyHelpFields(vercelTokenProblemHelp(VERCEL_CONNECTOR_SECRET_NAME)), + }; + } + if (err.code === 'forbidden') { + return { + code: ERROR_CODES.VERCEL_NO_ACCESS, + message: forbiddenMessage(err, options.discovery === true), + ...keyHelpFields(vercelTokenProblemHelp(VERCEL_CONNECTOR_SECRET_NAME)), + }; + } + if (err.code === 'rate_limited') { + return { code: ERROR_CODES.VERCEL_RATE_LIMITED, message: err.message }; + } + return { code: ERROR_CODES.VERCEL_API_ERROR, message: err.message }; + } + return { code: ERROR_CODES.VERCEL_API_ERROR, message: err instanceof Error ? err.message : String(err) }; +} + +// ── HTTP client ────────────────────────────────────────────────────────────── + +/** + * `fetch`'s shape, narrowed: GET only (read-only is a type, not a promise), + * and the response carries `headers` — the rate-limit wait is read from them. + * The global `fetch` satisfies it. + */ +export type VercelFetchLike = ( + url: string, + init: { + method: 'GET'; + headers: Record; + /** Always `'error'`: a redirect must fail rather than re-send the bearer token to whatever host a 30x names. */ + redirect?: 'error' | 'manual' | 'follow'; + }, +) => Promise<{ + status: number; + ok: boolean; + headers?: { get(name: string): string | null }; + text(): Promise; +}>; + +export interface VercelClientOptions { + fetch?: VercelFetchLike; + baseUrl?: string; + /** Injectable so a 429 test never really waits. */ + sleep?: (ms: number) => Promise; + now?: () => number; + /** How many times one request is retried after a 429. Default 3. */ + maxRetries?: number; + /** A requested wait longer than this is refused rather than slept. Default 60 000 ms. */ + maxWaitMs?: number; + /** + * Never keep an env record's `value`: the list response may carry one for a + * plain record, and a caller that only needs NAMES (`capy deploy vercel + * --discover`) must not hold it, even in memory. + */ + dropValues?: boolean; +} + +export interface VercelEnvList { + envs: readonly VercelEnvRecord[]; + /** True when Vercel said there is a next page this client cannot ask for (the env list documents no paging parameter). */ + truncated: boolean; +} + +/** The read-only surface the connector needs. No method here can write. */ +export interface VercelClient { + listTeams(): Promise; + listProjects(teamId?: string): Promise; + getProject(idOrName: string, teamId?: string): Promise; + listCustomEnvironments(projectId: string, teamId?: string): Promise; + listEnvs(projectId: string, teamId?: string): Promise; + /** The project's most recent preview deployments (one page, newest first, at most `limit`). */ + listPreviewDeployments(projectId: string, teamId?: string, limit?: number): Promise; + /** + * The decrypted value of one env record, `undefined` when Vercel returned none, or + * `{ decrypted: false }` when Vercel says it did not decrypt the value (its structured + * `decrypted: false` field; never judged by the value's text). + */ + getEnvValue(projectId: string, envId: string, teamId?: string): Promise; +} + +/** Vercel answered the per-id route but did not decrypt the value (for instance a role that cannot read production values). */ +export interface VercelUndecryptedValue { + readonly decrypted: false; +} + +/** What the per-id value route gave back: the value, nothing, or "not decrypted". */ +export type VercelEnvValue = string | undefined | VercelUndecryptedValue; + +export const isUndecrypted = (value: VercelEnvValue): value is VercelUndecryptedValue => typeof value === 'object'; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_MAX_WAIT_MS = 60_000; +const DEFAULT_RETRY_WAIT_MS = 1_000; +const MAX_PAGES = 100; +const PAGE_LIMIT = '100'; +/** How many recent preview deployments discovery counts branches over (Vercel's maximum page). */ +export const PREVIEW_DEPLOYMENTS_LIMIT = 100; + +const asRecord = (v: unknown): Record | null => + v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; + +const nonEmptyString = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined); + +const numberOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); + +/** A pagination cursor: a finite number (a timestamp) or a non-empty string (a continuation token), as text. */ +const cursorOf = (v: unknown): string | null => { + const n = numberOrNull(v); + if (n !== null) return String(n); + return typeof v === 'string' && v !== '' ? v : null; +}; + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('bad_response', null, 'Vercel returned a response that is not JSON.'); + } +} + +/** Milliseconds to wait before retrying a 429, from `Retry-After` (seconds), else `X-RateLimit-Reset` (epoch seconds), else the error body's `limit.resetMs`, else a short default. */ +export function rateLimitWaitMs( + headers: { get(name: string): string | null } | undefined, + bodyText: string, + nowMs: number, +): number { + const retryAfter = Number(headers?.get('retry-after')); + if (headers?.get('retry-after') != null && Number.isFinite(retryAfter)) return Math.max(0, retryAfter * 1000); + const reset = Number(headers?.get('x-ratelimit-reset')); + if (headers?.get('x-ratelimit-reset') != null && Number.isFinite(reset)) return Math.max(0, reset * 1000 - nowMs); + const resetMs = readBodyResetMs(bodyText); + return resetMs === null ? DEFAULT_RETRY_WAIT_MS : Math.max(0, resetMs - nowMs); +} + +function readBodyResetMs(bodyText: string): number | null { + try { + const body = asRecord(JSON.parse(bodyText)); + const limit = asRecord(asRecord(body?.error)?.limit); + return numberOrNull(limit?.resetMs); + } catch { + return null; + } +} + +export function createVercelClient(token: string, options: VercelClientOptions = {}): VercelClient { + const fetchImpl: VercelFetchLike = options.fetch ?? ((url, init) => fetch(url, init)); + const base = (options.baseUrl ?? VERCEL_API_BASE).replace(/\/+$/, ''); + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const now = options.now ?? (() => Date.now()); + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS; + + const urlFor = (path: string, query: Readonly>): string => { + const qs = new URLSearchParams( + Object.entries(query).flatMap(([k, v]) => (v === undefined ? [] : [[k, v] as [string, string]])), + ).toString(); + return `${base}${path}${qs ? `?${qs}` : ''}`; + }; + + const attempt = async (url: string, retriesLeft: number, resource: VercelResource): Promise => { + const res = await fetchImpl(url, { + method: 'GET', + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + redirect: 'error', + }).catch((): never => { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('unreachable', null, 'Could not reach Vercel.'); + }); + const text = await res.text(); + if (res.status === 429) { + const waitMs = rateLimitWaitMs(res.headers, text, now()); + if (retriesLeft <= 0 || waitMs > maxWaitMs) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('rate_limited', 429, 'Vercel rate limit reached; try again later.'); + } + await sleep(waitMs); + return attempt(url, retriesLeft - 1, resource); + } + if (!res.ok) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError(codeForStatus(res.status), res.status, `Vercel returned HTTP ${res.status}.`, resource); + } + return parseJson(text); + }; + + const get = (path: string, resource: VercelResource, query: Readonly> = {}): Promise => + attempt(urlFor(path, query), maxRetries, resource); + + const enc = encodeURIComponent; + + return { + async listTeams() { + const collect = async (until: string | undefined, seen: readonly string[], page: number): Promise => { + const body = asRecord(await get('/v2/teams', { kind: 'teams' }, { limit: PAGE_LIMIT, until })); + const teams = (Array.isArray(body?.teams) ? body.teams : []).flatMap((t) => { + const parsed = parseTeam(t); + return parsed ? [parsed] : []; + }); + const fresh = teams.filter((t) => !seen.includes(t.id)); + const next = numberOrNull(asRecord(body?.pagination)?.next); + if (next === null || fresh.length === 0 || page >= MAX_PAGES) return fresh; + return [...fresh, ...(await collect(String(next), [...seen, ...fresh.map((t) => t.id)], page + 1))]; + }; + return collect(undefined, [], 1); + }, + + async listProjects(teamId) { + const collect = async (from: string | undefined, seen: readonly string[], page: number): Promise => { + const body = await get('/v10/projects', { kind: 'projects' }, { limit: PAGE_LIMIT, from, teamId }); + const list = Array.isArray(body) ? body : asRecord(body)?.projects; + const projects = (Array.isArray(list) ? list : []).flatMap((p) => { + const parsed = parseProject(p); + return parsed ? [parsed] : []; + }); + // A page with nothing new ends the walk: a server that ignores the + // continuation parameter would otherwise be asked for page 1 forever. + const fresh = projects.filter((p) => !seen.includes(p.id)); + // The docs call `from` a "timestamp or continuation token" and give `next` no type: take either. + const next = Array.isArray(body) ? null : cursorOf(asRecord(asRecord(body)?.pagination)?.next); + if (next === null || fresh.length === 0 || page >= MAX_PAGES) return fresh; + return [...fresh, ...(await collect(next, [...seen, ...fresh.map((p) => p.id)], page + 1))]; + }; + return collect(undefined, [], 1); + }, + + async getProject(idOrName, teamId) { + const parsed = parseProject(await get(`/v9/projects/${enc(idOrName)}`, { kind: 'project', project: idOrName }, { teamId })); + if (!parsed) { + // COPY-FLAG: new user-facing string, minimal/neutral wording. + throw new VercelApiError('bad_response', null, 'Vercel returned a project this client cannot read.'); + } + return parsed; + }, + + async listCustomEnvironments(projectId, teamId) { + const body = asRecord(await get(`/v9/projects/${enc(projectId)}/custom-environments`, { kind: 'project_envs', project: projectId }, { teamId })); + return (Array.isArray(body?.environments) ? body.environments : []).flatMap((e) => { + const rec = asRecord(e); + const id = nonEmptyString(rec?.id); + const slug = nonEmptyString(rec?.slug); + const branchMatcher = parseBranchMatcher(rec?.branchMatcher); + return id && slug ? [{ id, slug, ...(branchMatcher ? { branchMatcher } : {}) }] : []; + }); + }, + + async listPreviewDeployments(projectId, teamId, limit = PREVIEW_DEPLOYMENTS_LIMIT) { + const body = asRecord( + await get('/v6/deployments', { kind: 'project_deployments', project: projectId }, { projectId, target: 'preview', limit: String(limit), teamId }), + ); + return (Array.isArray(body?.deployments) ? body.deployments : []).flatMap((d): readonly VercelDeployment[] => { + const rec = asRecord(d); + const id = nonEmptyString(rec?.uid) ?? nonEmptyString(rec?.id); + const gitBranch = nonEmptyString(asRecord(rec?.meta)?.githubCommitRef); + return id ? [{ id, ...(gitBranch ? { gitBranch } : {}) }] : []; + }); + }, + + async listEnvs(projectId, teamId) { + const body = asRecord(await get(`/v10/projects/${enc(projectId)}/env`, { kind: 'project_envs', project: projectId }, { teamId })); + const envs = (Array.isArray(body?.envs) ? body.envs : []).flatMap((e) => { + const parsed = parseEnvRecord(e, options.dropValues === true); + return parsed ? [parsed] : []; + }); + const next = numberOrNull(asRecord(body?.pagination)?.next); + return { envs, truncated: next !== null }; + }, + + async getEnvValue(projectId, envId, teamId) { + const body = asRecord(await get(`/v1/projects/${enc(projectId)}/env/${enc(envId)}`, { kind: 'project_envs', project: projectId }, { teamId })); + if (body?.decrypted === false) return { decrypted: false }; + return typeof body?.value === 'string' ? body.value : undefined; + }, + }; +} + +function parseBranchMatcher(raw: unknown): VercelBranchMatcher | undefined { + const rec = asRecord(raw); + const type = rec?.type; + const pattern = nonEmptyString(rec?.pattern); + return pattern && (type === 'equals' || type === 'startsWith' || type === 'endsWith') ? { type, pattern } : undefined; +} + +function parseTeam(raw: unknown): VercelTeam | null { + const rec = asRecord(raw); + const id = nonEmptyString(rec?.id); + const slug = nonEmptyString(rec?.slug); + if (!id || !slug) return null; + const name = nonEmptyString(rec?.name); + return { id, slug, ...(name ? { name } : {}) }; +} + +/** `link` → a github link we can match on, or `unsupported` carrying the provider type so it can be reported. */ +export function parseGitLink(raw: unknown): VercelGitLink | null { + const rec = asRecord(raw); + if (!rec) return null; + const type = nonEmptyString(rec.type) ?? 'unknown'; + const productionBranch = nonEmptyString(rec.productionBranch); + const branch = productionBranch ? { productionBranch } : {}; + const org = nonEmptyString(rec.org); + const repo = nonEmptyString(rec.repo); + if (type === 'github' && org && repo) return { kind: 'github', org, repo, ...branch }; + return { kind: 'unsupported', type, ...branch }; +} + +function parseProject(raw: unknown): VercelProject | null { + const rec = asRecord(raw); + const id = nonEmptyString(rec?.id); + const name = nonEmptyString(rec?.name); + if (!rec || !id || !name) return null; + return { id, name, rootDirectory: nonEmptyString(rec.rootDirectory) ?? null, link: parseGitLink(rec.link) }; +} + +function parseTargets(raw: unknown): readonly string[] { + if (typeof raw === 'string') return [raw]; + return Array.isArray(raw) ? raw.filter((t): t is string => typeof t === 'string') : []; +} + +function parseEnvRecord(raw: unknown, dropValue: boolean = false): VercelEnvRecord | null { + const rec = asRecord(raw); + const id = nonEmptyString(rec?.id); + const key = nonEmptyString(rec?.key); + const type = nonEmptyString(rec?.type); + if (!rec || !id || !key || !type) return null; + const gitBranch = nonEmptyString(rec.gitBranch); + const configurationId = nonEmptyString(rec.configurationId); + return { + id, + key, + type, + targets: parseTargets(rec.target), + customEnvironmentIds: Array.isArray(rec.customEnvironmentIds) + ? rec.customEnvironmentIds.filter((c): c is string => typeof c === 'string') + : [], + ...(gitBranch ? { gitBranch } : {}), + ...(configurationId ? { configurationId } : {}), + ...(!dropValue && typeof rec.value === 'string' ? { value: rec.value } : {}), + }; +} + +// ── Token resolution ───────────────────────────────────────────────────────── + +export type VercelTokenSource = 'system' | 'env'; + +export type ResolveVercelTokenResult = + | { ok: true; value: string; source: VercelTokenSource } + | { ok: false; code: string }; + +/** What the system store's `getConnectorSecret` needs — same shape as `system/systemStore.ts#GetConnectorSecretOptions`. */ +export interface VercelSystemStoreCallOptions { + orgId?: string; + interactive: boolean; + devMode?: boolean; + apiUrl?: string; + /** The slug of the team that owns the project, when known: the hidden prompt's token instructions name it. */ + helpTeam?: string; +} + +export interface ResolveVercelTokenOptions { + /** `--token-env`. Wins outright when that variable is actually set. */ + tokenEnv?: string; + env: Record; + /** Whether this run may let the store prompt a human (hidden input + save). */ + interactive: boolean; + orgId?: string; + devMode?: boolean; + apiUrl?: string; + /** See `VercelSystemStoreCallOptions.helpTeam`. */ + helpTeam?: string; + /** + * Reads (and, when missing + interactive + admin, prompts for and saves) + * the org system store entry. Defaults to a function that never touches the + * network, so every test and every other caller gets zero store traffic + * unless it opts in — the `vercelConnector` singleton wires the real one. + */ + getConnectorSecret?: (name: string, opts: VercelSystemStoreCallOptions) => Promise; +} + +async function neverReachesTheStore(): Promise { + return null; +} + +/** A typed store error (`CapyError`-shaped: `{code: string}`) as its code — never its `.message`. */ +function storeErrorCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; +} + +type StoreOutcome = { kind: 'value'; value: string } | { kind: 'empty' } | { kind: 'error'; code: string }; + +async function readFromSystemStore( + getConnectorSecret: NonNullable, + callOpts: VercelSystemStoreCallOptions, +): Promise { + try { + const value = await getConnectorSecret(VERCEL_CONNECTOR_SECRET_NAME, callOpts); + return value ? { kind: 'value', value } : { kind: 'empty' }; + } catch (err) { + return { kind: 'error', code: storeErrorCode(err) }; + } +} + +/** + * The Vercel token for one command, in the order Dokploy's key resolves: + * + * 1. An explicit `--token-env` variable, when it is actually set. + * 2. The org system store's `_CONNECTOR_VERCEL_TOKEN` (missing + interactive + * + admin: the store itself asks, hidden input, and saves it). + * 3. `$VERCEL_TOKEN`. + * 4. Refused: the store's own coded error when it refused (non-admin, …), + * otherwise `VERCEL_TOKEN_MISSING`. + */ +export async function resolveVercelToken(opts: ResolveVercelTokenOptions): Promise { + const explicit = opts.tokenEnv?.trim(); + const fromExplicit = explicit ? opts.env[explicit] || null : null; + if (fromExplicit) return { ok: true, value: fromExplicit, source: 'env' }; + + const storeOutcome = await readFromSystemStore(opts.getConnectorSecret ?? neverReachesTheStore, { + orgId: opts.orgId, + interactive: opts.interactive, + devMode: opts.devMode, + apiUrl: opts.apiUrl, + ...(opts.helpTeam ? { helpTeam: opts.helpTeam } : {}), + }); + if (storeOutcome.kind === 'value') return { ok: true, value: storeOutcome.value, source: 'system' }; + + const fallback = opts.env[DEFAULT_VERCEL_TOKEN_ENV] || null; + if (fallback) return { ok: true, value: fallback, source: 'env' }; + + return storeOutcome.kind === 'error' + ? { ok: false, code: storeOutcome.code } + : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }; +} + +/** Whether a run may let the system store prompt for a missing token: a real TTY, and none of `--web`, `--json`, `--dry-run` (a preview must never save a key). */ +export function vercelSecretsMayPrompt(baseInteractive: boolean, suppressed: boolean): boolean { + return baseInteractive && !suppressed; +} + +/** A token-resolution refusal as a printable reason + hint — names only. */ +export function describeVercelTokenProblem(code: string, tokenEnv: string): { reason: string; hint: string } { + if (code === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `only an org owner or admin can set ${VERCEL_CONNECTOR_SECRET_NAME} in the system store, and $${tokenEnv} is not set`, + hint: `Ask an org owner/admin to run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\`, or export ${tokenEnv} yourself.`, + }; + } + if (code === ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `${VERCEL_CONNECTOR_SECRET_NAME} is not set, but ${VERCEL_TARGET_SECRET_NAME} already holds a Vercel token`, + hint: `Run this interactively to choose whether to reuse it, or run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\` to set a dedicated one.`, + }; + } + if (code === ERROR_CODES.VERCEL_TOKEN_MISSING) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `$${tokenEnv} is not set`, + hint: `Run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\`, or export ${tokenEnv}=… first.`, + }; + } + return { + // COPY-FLAG: minimal neutral wording. + reason: `could not resolve the Vercel token (${code})`, + hint: `Run \`capy system set ${VERCEL_CONNECTOR_SECRET_NAME}\`, or export ${tokenEnv}=… first.`, + }; +} + +// ── Teams ──────────────────────────────────────────────────────────────────── + +export type ResolveTeamResult = + | { ok: true; teamId: string | undefined; teamSlug?: string } + | { ok: false; code: string; message: string }; + +/** + * Which team scope to read: `--team ` when given; otherwise the + * token's only team, a pick when several and this run can ask, a coded + * refusal listing the slugs when it cannot; no teams at all means the + * personal account (no `teamId` query). + */ +export async function resolveTeam( + teams: readonly VercelTeam[], + requested: string | undefined, + pick: ((teams: readonly VercelTeam[]) => Promise) | null, +): Promise { + const want = requested?.trim(); + if (want) { + const hit = teams.find((t) => t.slug === want || t.id === want); + if (!hit) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TEAM_NOT_FOUND, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `No Vercel team "${want}". Teams this token sees: ${teams.map((t) => t.slug).join(', ') || '(none)'}.`, + }; + } + return { ok: true, teamId: hit.id, teamSlug: hit.slug }; + } + if (teams.length === 0) return { ok: true, teamId: undefined }; + if (teams.length === 1) return { ok: true, teamId: teams[0].id, teamSlug: teams[0].slug }; + if (!pick) { + return { + ok: false, + code: ERROR_CODES.VERCEL_TEAM_REQUIRED, + // COPY-FLAG: new user-facing string, minimal/neutral wording. + message: `This token sees several Vercel teams (${teams.map((t) => t.slug).join(', ')}). Pass --team .`, + }; + } + const pickedId = await pick(teams); + const hit = teams.find((t) => t.id === pickedId) ?? teams[0]; + return { ok: true, teamId: hit.id, teamSlug: hit.slug }; +} + +// ── Environments and importable records ───────────────────────────────────── + +export type VercelEnvironmentKind = VercelStandardEnvironment | 'custom'; + +export interface VercelEnvironmentInfo { + /** The Capy branch name: `production` | `preview` | `development`, or a custom environment's slug. */ + name: string; + kind: VercelEnvironmentKind; + customEnvironmentId?: string; +} + +/** Why a record is not imported. Stable codes — never a sentence. */ +export type VercelSkipReason = + | 'git_branch_scoped' + | 'sensitive_unreadable' + | 'vercel_system' + | 'legacy_secret_unreadable' + | 'unsupported_type' + | 'duplicate_key' + | 'empty_value' + /** Vercel returned the value route's answer with `decrypted: false`: it would not give the value back. */ + | 'value_not_decrypted' + /** Vercel answered the value route for this one record with HTTP 404: the record is listed but its value is not there to read. */ + | 'value_not_found'; + +/** + * The reasons that mean "Vercel has this value and will not give it back". Each becomes a placeholder + * on its Capy branch (core/placeholder.ts); every other reason stays a skip. + */ +export const PLACEHOLDER_SKIP_REASONS: readonly VercelSkipReason[] = [ + 'sensitive_unreadable', + 'legacy_secret_unreadable', + 'value_not_decrypted', + 'value_not_found', +]; + +export const isPlaceholderReason = (reason: VercelSkipReason): boolean => PLACEHOLDER_SKIP_REASONS.includes(reason); + +export interface VercelSkippedRecord { + name: string; + reason: VercelSkipReason; + /** Only for `git_branch_scoped`: the branch the record is scoped to (a name, never a value). */ + gitBranch?: string; +} + +export interface VercelEnvironmentPlan { + environment: VercelEnvironmentInfo; + /** Readable records, one per name. Values are NOT here — see `resolveEnvValues`. */ + importable: readonly VercelEnvRecord[]; + skipped: readonly VercelSkippedRecord[]; +} + +/** All environment names a project has: the three built-ins plus its custom environments' slugs. */ +export function availableEnvironments(custom: readonly VercelCustomEnvironment[]): readonly VercelEnvironmentInfo[] { + return [ + ...VERCEL_STANDARD_ENVIRONMENTS.map((name): VercelEnvironmentInfo => ({ name, kind: name })), + ...custom.map((c): VercelEnvironmentInfo => ({ name: c.slug, kind: 'custom', customEnvironmentId: c.id })), + ]; +} + +function skipReasonFor(rec: VercelEnvRecord, kind: VercelEnvironmentKind): VercelSkipReason | null { + if (rec.type === 'system') return 'vercel_system'; + const known = rec.type === 'plain' || rec.type === 'encrypted' || rec.type === 'sensitive' || rec.type === 'secret'; + if (!known) return 'unsupported_type'; + // A preview record scoped to one branch of the repo is out of scope whatever its type: it is never a placeholder on the all-branches preview. + if (kind === 'preview' && rec.gitBranch) return 'git_branch_scoped'; + if (rec.type === 'sensitive') return 'sensitive_unreadable'; + if (rec.type === 'secret') return 'legacy_secret_unreadable'; + return null; +} + +function recordTouchesEnvironment(rec: VercelEnvRecord, env: VercelEnvironmentInfo): boolean { + if (env.kind === 'custom') return !!env.customEnvironmentId && rec.customEnvironmentIds.includes(env.customEnvironmentId); + return rec.targets.includes(env.name); +} + +/** + * One plan per environment that at least one record references. A record + * targeting several environments counts in each. Names only: no value is + * read here, so a plan is safe to print. + */ +export function buildEnvironmentPlans( + envs: readonly VercelEnvRecord[], + custom: readonly VercelCustomEnvironment[], +): readonly VercelEnvironmentPlan[] { + return availableEnvironments(custom).flatMap((environment): readonly VercelEnvironmentPlan[] => { + const records = envs.filter((r) => recordTouchesEnvironment(r, environment)); + return records.length === 0 ? [] : [planFor(environment, records)]; + }); +} + +function planFor(environment: VercelEnvironmentInfo, records: readonly VercelEnvRecord[]): VercelEnvironmentPlan { + const classified = records.map((rec) => ({ rec, reason: skipReasonFor(rec, environment.kind) })); + const readable = classified.flatMap(({ rec, reason }) => (reason === null ? [rec] : [])); + // Two readable records for one name in one environment: the first wins, the + // rest are reported, so a name is never imported twice. + const firstIds = new Map( + readable.flatMap((rec, i) => (readable.findIndex((r) => r.key === rec.key) === i ? [[rec.key, rec.id] as const] : [])), + ); + const importable = readable.filter((rec) => firstIds.get(rec.key) === rec.id); + const duplicates = readable.filter((rec) => firstIds.get(rec.key) !== rec.id); + const skipped: readonly VercelSkippedRecord[] = [ + ...classified.flatMap(({ rec, reason }): readonly VercelSkippedRecord[] => + reason === null + ? [] + : [{ name: rec.key, reason, ...(reason === 'git_branch_scoped' && rec.gitBranch ? { gitBranch: rec.gitBranch } : {}) }], + ), + ...duplicates.map((rec): VercelSkippedRecord => ({ name: rec.key, reason: 'duplicate_key' })), + ]; + return { environment, importable, skipped }; +} + +export interface ResolvedEnvValues { + /** Name + plaintext, for records that came back with a real value. */ + entries: ReadonlyArray<{ record: VercelEnvRecord; value: string }>; + /** Records that came back empty/absent although typed readable — never imported as empty strings. */ + empty: readonly VercelSkippedRecord[]; + /** Records Vercel would not decrypt (`decrypted: false`): they have a value Capy cannot read, so they are placeholders, not empty. */ + undecrypted: readonly VercelSkippedRecord[]; + /** Records whose per-id value route answered HTTP 404: the run goes on without them, and they are placeholders like `undecrypted`. */ + notFound: readonly VercelSkippedRecord[]; +} + +/** One record's value read: a value (or none, or "not decrypted"), or the route's 404 for that record. */ +type FetchedValue = { readonly record: VercelEnvRecord; readonly value: VercelEnvValue } | { readonly record: VercelEnvRecord; readonly notFound: true }; + +/** The structured signal for "Vercel has no value route answer for this id": a Vercel 404, by class and status, never by message. */ +const isValueNotFound = (err: unknown): boolean => err instanceof VercelApiError && (err.status === 404 || err.code === 'not_found'); + +/** Reads one record's value; a 404 on the per-id route is that record's outcome, every other failure still throws. */ +async function fetchOneValue( + client: Pick, + projectId: string, + teamId: string | undefined, + record: VercelEnvRecord, +): Promise { + if (record.type === 'plain' && record.value !== undefined) return { record, value: record.value }; + try { + return { record, value: await client.getEnvValue(projectId, record.id, teamId) }; + } catch (err) { + if (isValueNotFound(err)) return { record, notFound: true }; + throw err; + } +} + +/** + * Reads the value of every importable record, sequentially (one request at a + * time keeps well inside the rate limit and gives a deterministic call + * order). A `plain` record's list value is used as-is; everything else + * (`encrypted`, or a `plain` record the list sent without a value) comes + * from the per-id decrypted-value route. + */ +export async function resolveEnvValues( + client: Pick, + projectId: string, + teamId: string | undefined, + records: readonly VercelEnvRecord[], +): Promise { + const all = await records.reduce>(async (accPromise, record) => { + const acc = await accPromise; + return [...acc, await fetchOneValue(client, projectId, teamId, record)]; + }, Promise.resolve([])); + const fetched = all.flatMap((f): ReadonlyArray<{ record: VercelEnvRecord; value: VercelEnvValue }> => ('notFound' in f ? [] : [f])); + return { + entries: fetched.flatMap(({ record, value }) => (value === undefined || value === '' || isUndecrypted(value) ? [] : [{ record, value }])), + empty: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => + value === undefined || value === '' ? [{ name: record.key, reason: 'empty_value' }] : [], + ), + undecrypted: fetched.flatMap(({ record, value }): readonly VercelSkippedRecord[] => + isUndecrypted(value) ? [{ name: record.key, reason: 'value_not_decrypted' }] : [], + ), + notFound: all.flatMap((f): readonly VercelSkippedRecord[] => ('notFound' in f ? [{ name: f.record.key, reason: 'value_not_found' }] : [])), + }; +} + +/** `--var a,b`: names to restrict the import to; `null` when absent. */ +export function parseVarList(raw: string | undefined): readonly string[] | null { + if (!raw) return null; + const names = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return names.length > 0 ? names : null; +} + +/** Skipped names grouped by reason — the shape a dry-run plan prints. Names only. */ +export function groupSkipped( + skipped: ReadonlyArray<{ name: string; reason: string; gitBranch?: string }>, +): Readonly>> { + const reasons = sortedCopy([...new Set(skipped.map((s) => s.reason))]); + return Object.fromEntries( + reasons.map((reason) => [ + reason, + skipped.filter((s) => s.reason === reason).map((s) => ({ name: s.name, ...(s.gitBranch ? { gitBranch: s.gitBranch } : {}) })), + ]), + ); +} diff --git a/src/deploy/vercelSetup.ts b/src/deploy/vercelSetup.ts new file mode 100644 index 00000000..bf2d4fab --- /dev/null +++ b/src/deploy/vercelSetup.ts @@ -0,0 +1,392 @@ +/** + * `capy deploy`'s Vercel target setup — API mode only, modeled on Dokploy's + * one-question setup (`resolveDokployServiceOptions` in `commands/deployCommand.ts`): + * + * 1. One question: the project's dashboard link (or its name). + * 2. The token resolves like Dokploy's key does (`_TARGET_VERCEL_TOKEN`, a + * one-level reference to `_CONNECTOR_VERCEL_TOKEN`, then `$VERCEL_TOKEN`). + * No token: say so, with the instructions for making one, and save the + * answer as entered. + * 3. The project is checked live. A 404 is a coded refusal + * (`VERCEL_PROJECT_NOT_FOUND`), shown, and the question is asked again. + * Any other API problem is shown with its code and the answer is saved as + * entered. + * 4. The project is named back for a yes/no; no asks again. + * 5. Environment: production, preview, development, plus the project's custom + * environments. Preview asks an optional git branch. + * + * It needs no `vercel` program, no `.vercel/project.json` and no `vercel link`: + * every request is the REST API with the token. + * + * Vercel dashboard links look like `https://vercel.com//` + * (more path may follow: `/settings`, `/deployments`, …). + */ +import inquirer from 'inquirer'; +import { ERROR_CODES } from '../types/index'; +import { + VercelApiError, + VercelFetchLike, + VercelProject, + VercelTeam, + availableEnvironments, + createVercelClient, + mapVercelApiError, + resolveTeam, +} from './vercelApi'; +import { + GetVercelStoreSecret, + VERCEL_TARGET_SECRET_NAME, + getVercelTargetSecretFromStore, + resolveVercelTargetToken, +} from './vercelTargetApi'; +import { formatKeyHelp, vercelKeyHelp } from './keyHelp'; + +const DIM = (s: string) => `\x1b[90m${s}\x1b[0m`; +const RED = (s: string) => `\x1b[31m${s}\x1b[0m`; + +// ── Parsing the one answer ────────────────────────────────────────────────── + +export type VercelProjectInput = + | { kind: 'link'; team: string; project: string } + | { kind: 'name'; project: string }; + +export type ParsedVercelProjectInput = + | { ok: true; input: VercelProjectInput } + | { ok: false; code: string; reason: string }; + +/** First path segments on vercel.com that are not a team's slug. */ +const NOT_A_TEAM_SEGMENT: ReadonlySet = new Set([ + 'account', + 'dashboard', + 'docs', + 'login', + 'new', + 'pricing', + 'signup', + 'teams', +]); + +const PROJECT_NAME = /^[A-Za-z0-9._-]+$/; + +function invalid(reason: string): ParsedVercelProjectInput { + return { ok: false, code: ERROR_CODES.VERCEL_URL_INVALID, reason }; +} + +function asUrl(answer: string): URL | null { + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(answer) ? answer : /^(www\.)?vercel\.com\//i.test(answer) ? `https://${answer}` : null; + if (withScheme === null) return null; + try { + return new URL(withScheme); + } catch { + return null; + } +} + +/** + * The answer as a dashboard link (`https://vercel.com//[/…]`) + * or a bare project name/id. A link that is not a project's dashboard link is + * a coded refusal, never guessed at. + */ +export function parseVercelProjectInput(raw: string): ParsedVercelProjectInput { + const answer = raw.trim(); + const url = asUrl(answer); + if (url) { + if (url.protocol !== 'https:' || (url.hostname !== 'vercel.com' && url.hostname !== 'www.vercel.com')) { + // COPY-FLAG: minimal neutral wording. + return invalid('not a vercel.com link.'); + } + const [team, project] = url.pathname.split('/').filter((segment) => segment !== ''); + if (!team || !project || NOT_A_TEAM_SEGMENT.has(team)) { + // COPY-FLAG: minimal neutral wording. + return invalid('that is not a project link (expected https://vercel.com//).'); + } + return { ok: true, input: { kind: 'link', team, project } }; + } + if (!PROJECT_NAME.test(answer)) { + // COPY-FLAG: minimal neutral wording. + return invalid('that is neither a project link nor a project name.'); + } + return { ok: true, input: { kind: 'name', project: answer } }; +} + +// ── The setup ─────────────────────────────────────────────────────────────── + +/** The terminal's question asker: `inquirer.prompt`'s shape, narrowed to what this setup uses. */ +export type SetupPrompt = (questions: ReadonlyArray>) => Promise>; + +const defaultPrompt: SetupPrompt = (questions) => inquirer.prompt(questions as never) as Promise>; + +export interface VercelSetupDeps { + /** Injectable so a test answers without a terminal. Defaults to `inquirer.prompt`. */ + prompt?: SetupPrompt; + fetch?: VercelFetchLike; + env?: Record; + /** Reads the org system store's `_TARGET_VERCEL_TOKEN`. Defaults to the real store (with its reuse offer and hidden prompt). */ + getSecret?: GetVercelStoreSecret; +} + +export interface VercelSetupFlags { + /** A dry run changes nothing: the token is only read from the store, never asked for or saved. */ + readOnly?: boolean; + devMode?: boolean; +} + +export interface VercelSetupResult { + /** The target's API-mode options. */ + options: Record; + /** The project's production branch, when Vercel said: the default for the PR base branch. */ + productionBranch?: string; +} + +interface Scope { + teamId?: string; + teamSlug?: string; +} + +type Verified = + | { kind: 'verified'; project: VercelProject; scope: Scope; client: ReturnType } + | { kind: 'not_found' } + | { kind: 'unverified'; code: string; scope: Scope }; + +const STANDARD_ENVIRONMENTS: ReadonlyArray<{ name: string; value: string }> = [ + { name: 'Production', value: 'production' }, + { name: 'Preview', value: 'preview' }, + { name: 'Development', value: 'development' }, +]; + +/** The code of a failed Vercel call — the typed error's own code, never its text. */ +function codeOf(err: unknown): string { + return mapVercelApiError(err).code; +} + +async function attempt(fn: () => Promise): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> { + try { + return { ok: true, value: await fn() }; + } catch (error) { + return { ok: false, error }; + } +} + +async function askProjectInput(ask: SetupPrompt, existingOpts: Record): Promise { + const ans = (await ask([ + { + type: 'input', + name: 'projectUrl', + // COPY-FLAG (approved wording) + message: 'Vercel project URL (paste the dashboard link for this project, or its name):', + default: existingOpts.projectId, + validate: (v: string) => (v.trim() ? true : 'required'), + filter: (v: string) => v.trim(), + }, + ])) as { projectUrl: string }; + return ans.projectUrl; +} + +async function pickTeamPrompt(ask: SetupPrompt, teams: readonly VercelTeam[]): Promise { + const { picked } = (await ask([ + // COPY-FLAG: minimal neutral wording. + { type: 'list', name: 'picked', message: 'Which Vercel team:', choices: teams.map((t) => ({ name: t.slug, value: t.id })) }, + ])) as { picked: string }; + return picked; +} + +/** Looks the project up. A 404 is `not_found`; any other problem is `unverified`, carrying whatever scope was learned. */ +async function verifyProject( + input: VercelProjectInput, + token: string, + deps: VercelSetupDeps, + ask: SetupPrompt, +): Promise { + const client = createVercelClient(token, { fetch: deps.fetch }); + const linkSlug = input.kind === 'link' ? { teamSlug: input.team } : {}; + const teams = await attempt(() => client.listTeams()); + if (!teams.ok) return { kind: 'unverified', code: codeOf(teams.error), scope: linkSlug }; + + const scope = await scopeFor(input, teams.value, ask); + if (!scope.ok) return { kind: 'unverified', code: scope.code, scope: linkSlug }; + + const project = await attempt(() => client.getProject(input.project, scope.scope.teamId)); + if (project.ok) return { kind: 'verified', project: project.value, scope: scope.scope, client }; + if (project.error instanceof VercelApiError && project.error.code === 'not_found') return { kind: 'not_found' }; + return { kind: 'unverified', code: codeOf(project.error), scope: scope.scope }; +} + +/** Which team the project is under: the link's own, or — for a bare name — the only team the token sees, else a pick. */ +async function scopeFor( + input: VercelProjectInput, + teams: readonly VercelTeam[], + ask: SetupPrompt, +): Promise<{ ok: true; scope: Scope } | { ok: false; code: string }> { + if (input.kind === 'link') { + const hit = teams.find((t) => t.slug === input.team); + return { ok: true, scope: hit ? { teamId: hit.id, teamSlug: hit.slug } : {} }; + } + const resolved = await resolveTeam(teams, undefined, (all) => pickTeamPrompt(ask, all)); + if (!resolved.ok) return { ok: false, code: resolved.code }; + return { + ok: true, + scope: { ...(resolved.teamId ? { teamId: resolved.teamId } : {}), ...(resolved.teamSlug ? { teamSlug: resolved.teamSlug } : {}) }, + }; +} + +interface EnvironmentChoice { + vercelEnv: string; + customEnvironmentId?: string; + gitBranch?: string; +} + +async function askEnvironment( + ask: SetupPrompt, + existingOpts: Record, + custom: ReadonlyArray<{ id: string; slug: string }>, +): Promise { + const environments = availableEnvironments(custom); + const choices = [ + ...STANDARD_ENVIRONMENTS, + ...environments.filter((e) => e.kind === 'custom').map((e) => ({ name: e.name, value: e.name })), + ]; + const { vercelEnv } = (await ask([ + { + type: 'list', + name: 'vercelEnv', + message: 'Which Vercel environment should these secrets go to?', + choices, + default: existingOpts.vercelEnv ?? 'preview', + }, + ])) as { vercelEnv: string }; + const customEnvironmentId = environments.find((e) => e.kind === 'custom' && e.name === vercelEnv)?.customEnvironmentId; + const gitBranch = vercelEnv === 'preview' ? await askPreviewBranch(ask, existingOpts.gitBranch) : undefined; + return { + vercelEnv, + ...(customEnvironmentId ? { customEnvironmentId } : {}), + ...(gitBranch ? { gitBranch } : {}), + }; +} + +async function askPreviewBranch(ask: SetupPrompt, preferred: string | undefined): Promise { + const { gitBranch } = (await ask([ + { + type: 'input', + name: 'gitBranch', + // COPY-FLAG: minimal neutral wording. + message: 'Git branch for Preview (leave blank for all preview branches):', + default: preferred, + filter: (v: string) => v.trim(), + }, + ])) as { gitBranch: string }; + return gitBranch === '' ? undefined : gitBranch; +} + +async function confirmProject(ask: SetupPrompt, project: VercelProject, teamSlug: string | undefined): Promise { + const where = teamSlug ? ` (${teamSlug})` : ''; + const { confirmed } = (await ask([ + { + type: 'confirm', + name: 'confirmed', + // COPY-FLAG (approved wording) + message: `Use "${project.name}"${where} as this target?`, + default: true, + }, + ])) as { confirmed: boolean }; + return confirmed; +} + +/** What is known without Vercel's answer: the project as typed, the team as far as it is known. */ +function unverifiedOptions(input: VercelProjectInput, scope: Scope, env: EnvironmentChoice): Record { + const team = scope.teamId ? { teamId: scope.teamId } : scope.teamSlug ? { teamSlug: scope.teamSlug } : {}; + return { + projectDir: '.', + vercelEnv: env.vercelEnv, + projectId: input.project, + ...team, + ...(env.gitBranch ? { gitBranch: env.gitBranch } : {}), + }; +} + +function printTokenInstructions(team: string | undefined): void { + console.error(formatKeyHelp(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME, { team, mode: 'save' }))); +} + +/** + * The whole setup: loops (a terminal only) until a project is confirmed, or + * the project could not be checked and is saved as entered. Each pass either + * returns or recurses on the next answer, so no loop state is reassigned. + */ +export async function resolveVercelSetup( + existingOpts: Record, + orgId: string | undefined, + flags: VercelSetupFlags = {}, + deps: VercelSetupDeps = {}, +): Promise { + const ask = deps.prompt ?? defaultPrompt; + const resolve = async (): Promise => { + const parsed = parseVercelProjectInput(await askProjectInput(ask, existingOpts)); + if (!parsed.ok) { + console.error(`${RED('✗')} ${parsed.code}: ${parsed.reason}`); + // COPY-FLAG: minimal neutral wording. + console.error(' Paste the Vercel dashboard link for this project, or just its name.'); + return resolve(); + } + const input = parsed.input; + const linkTeam = input.kind === 'link' ? input.team : undefined; + + const token = await resolveVercelTargetToken({ + env: deps.env ?? process.env, + interactive: !flags.readOnly, + orgId, + devMode: flags.devMode, + getSecret: deps.getSecret ?? getVercelTargetSecretFromStore, + ...(linkTeam ? { helpTeam: linkTeam } : {}), + }); + if (!token.ok) { + // COPY-FLAG: minimal neutral wording. + const why = token.code === ERROR_CODES.VERCEL_TOKEN_MISSING ? 'no token' : token.code; + console.log(` ${DIM(`Could not verify against Vercel yet (${why}) — saving as entered.`)}`); + printTokenInstructions(linkTeam); + return savedAsEntered(input, { ...(linkTeam ? { teamSlug: linkTeam } : {}) }); + } + + const verified = await verifyProject(input, token.value, deps, ask); + if (verified.kind === 'not_found') { + // COPY-FLAG: minimal neutral wording. + console.error(`${RED('✗')} ${ERROR_CODES.VERCEL_PROJECT_NOT_FOUND}: no Vercel project "${input.project}"${linkTeam ? ` in ${linkTeam}` : ''}.`); + console.error(' Check the link and try again.'); + return resolve(); + } + if (verified.kind === 'unverified') { + // COPY-FLAG: minimal neutral wording. + console.log(` ${DIM(`Could not verify against Vercel (${verified.code}) — saving as entered.`)}`); + if (verified.code === ERROR_CODES.VERCEL_AUTH_FAILED || verified.code === ERROR_CODES.VERCEL_NO_ACCESS) printTokenInstructions(verified.scope.teamSlug ?? linkTeam); + return savedAsEntered(input, verified.scope); + } + + if (!(await confirmProject(ask, verified.project, verified.scope.teamSlug))) return resolve(); + + const custom = await attempt(() => verified.client.listCustomEnvironments(verified.project.id, verified.scope.teamId)); + if (!custom.ok) { + // COPY-FLAG: minimal neutral wording. + console.log(` ${DIM(`Could not list custom environments (${codeOf(custom.error)}) — offering the standard ones.`)}`); + } + const env = await askEnvironment(ask, existingOpts, custom.ok ? custom.value : []); + const productionBranch = verified.project.link?.productionBranch; + return { + options: { + projectDir: '.', + vercelEnv: env.vercelEnv, + projectId: verified.project.id, + ...(verified.scope.teamId ? { teamId: verified.scope.teamId } : {}), + ...(env.customEnvironmentId ? { customEnvironmentId: env.customEnvironmentId } : {}), + ...(env.gitBranch ? { gitBranch: env.gitBranch } : {}), + }, + ...(productionBranch ? { productionBranch } : {}), + }; + }; + + /** Saved as typed: the standard environments only (custom ones cannot be listed without Vercel). */ + const savedAsEntered = async (input: VercelProjectInput, scope: Scope): Promise => { + const env = await askEnvironment(ask, existingOpts, []); + return { options: unverifiedOptions(input, scope, env) }; + }; + + return resolve(); +} diff --git a/src/deploy/vercelTargetApi.ts b/src/deploy/vercelTargetApi.ts new file mode 100644 index 00000000..c1e2c843 --- /dev/null +++ b/src/deploy/vercelTargetApi.ts @@ -0,0 +1,884 @@ +/** + * Vercel REST API client and env-var planner for the `vercel` deploy target's + * API mode (CAP-696). A target whose `options` carry a `projectId` talks to + * Vercel over HTTPS with a token from the org system store — no `vercel` + * binary, no `vercel login`, no `.vercel/project.json`. + * + * Routes used (each checked against Vercel's published API reference): + * - GET /v9/projects/{idOrName} — preflight: project exists, token has access + * https://vercel.com/docs/rest-api/projects/find-a-project-by-id-or-name + * - GET /v9/projects/{idOrName}/custom-environments/{environmentSlugOrId} + * https://vercel.com/docs/rest-api/environment/retrieve-a-custom-environment + * - GET /v10/projects/{idOrName}/env?decrypt=true — list the project's env records + * https://vercel.com/docs/rest-api/projects/retrieve-the-environment-variables-of-a-project-by-id-or-name + * - POST /v10/projects/{idOrName}/env — create ONE record + * https://vercel.com/docs/rest-api/projects/create-one-or-more-environment-variables + * - PATCH /v9/projects/{idOrName}/env/{id} — edit one record (value and/or target) + * https://vercel.com/docs/rest-api/projects/edit-an-environment-variable + * - GET /v1/projects/{idOrName}/env/{id} — read one record's decrypted value (verification) + * https://vercel.com/docs/rest-api/projects/retrieve-the-decrypted-value-of-an-environment-variable-of-a-project-by-id + * Every route takes `?teamId=` when the target has a `teamId`. Rate limiting + * and the `{ error: { code } }` body shape: https://vercel.com/docs/rest-api/errors + * + * Why this file never sends `upsert=true` and never deletes: one Vercel + * record can serve several environments at once (`target: ['production', + * 'preview']`). `upsert=true` would overwrite such a shared record's value for + * EVERY environment it serves, and a delete would remove it for all of them. + * Writing one environment must never change what another sees, so a shared + * record is split (this environment removed from it) and a fresh record is + * created for this environment alone — see `planVar`. + * + * Sensitive vars: Vercel never returns their value, so they cannot be read + * back; they are reported as written-unverified, never as verified. A team + * policy can turn every new Production/Preview var sensitive + * (https://vercel.com/docs/environment-variables/sensitive-environment-variables). + * The docs describe that as an automatic conversion, not an error code, so + * there is nothing to retry on: we send `encrypted` and accept whatever type + * Vercel stored. + * + * Nothing here ever logs or returns a token or a value in an error: failures + * carry a stable `code`, the HTTP status, and Vercel's own `error.code` field. + */ +import { createHash } from 'crypto'; +import { ERROR_CODES, ErrorCode } from '../types/index'; +import { keyHelpFields, vercelKeyHelp, vercelTokenProblemHelp } from './keyHelp'; + +// ── Token: system store, same shape as Dokploy's ─────────────────────────── + +// The store names and the env fallback are defined once, in `vercelApi.ts` +// (the import side); `_TARGET_VERCEL_TOKEN` may be a one-level reference to +// `_CONNECTOR_VERCEL_TOKEN`. +import { VERCEL_TARGET_SECRET_NAME, VERCEL_CONNECTOR_SECRET_NAME, DEFAULT_VERCEL_TOKEN_ENV } from './vercelApi'; +export { VERCEL_TARGET_SECRET_NAME, VERCEL_CONNECTOR_SECRET_NAME, DEFAULT_VERCEL_TOKEN_ENV }; + +export type VercelTokenSource = 'system' | 'env'; + +export type ResolveVercelTokenResult = + | { ok: true; value: string; source: VercelTokenSource } + | { ok: false; code: string }; + +/** What the system store's secret getter needs — same shape as Dokploy's. */ +export interface VercelSystemStoreCallOptions { + orgId?: string; + interactive: boolean; + devMode?: boolean; + apiUrl?: string; + /** The slug of the team that owns the project, when known: the hidden prompt's token instructions name it. */ + helpTeam?: string; +} + +export type GetVercelStoreSecret = ( + name: string, + opts: VercelSystemStoreCallOptions, +) => Promise; + +/** + * The real system-store read for the deploy direction: `_TARGET_VERCEL_TOKEN` + * first; when only `_CONNECTOR_VERCEL_TOKEN` exists, interactive runs are + * asked whether to reuse it and non-interactive runs are refused with + * `VERCEL_TARGET_KEY_MISSING`. Exactly Dokploy's behaviour — see + * `system/systemStore.ts#getDirectionalConnectorSecret`. + */ +export const getVercelTargetSecretFromStore: GetVercelStoreSecret = async (name, opts) => { + const { getDirectionalConnectorSecret } = await import('../system/systemStore'); + return getDirectionalConnectorSecret(name, VERCEL_CONNECTOR_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.VERCEL_TARGET_KEY_MISSING, + }); +}; + +/** A typed store error (`{ code: string }`) as its code — never its message. */ +function storeErrorCode(err: unknown): string { + const code = (err as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : ERROR_CODES.VERCEL_TOKEN_MISSING; +} + +type StoreOutcome = + | { kind: 'value'; value: string } + | { kind: 'empty' } + | { kind: 'error'; code: string }; + +async function readStore( + getSecret: GetVercelStoreSecret, + callOpts: VercelSystemStoreCallOptions, +): Promise { + try { + const value = await getSecret(VERCEL_TARGET_SECRET_NAME, callOpts); + return value ? { kind: 'value', value } : { kind: 'empty' }; + } catch (err) { + return { kind: 'error', code: storeErrorCode(err) }; + } +} + +export interface ResolveVercelTokenOptions { + env: Record; + interactive: boolean; + orgId?: string; + devMode?: boolean; + apiUrl?: string; + /** See `VercelSystemStoreCallOptions.helpTeam`. */ + helpTeam?: string; + /** Defaults to a getter that never touches the store (env-only). Real callers pass `getVercelTargetSecretFromStore`. */ + getSecret?: GetVercelStoreSecret; +} + +async function neverReachesTheStore(): Promise { + return null; +} + +/** + * The Vercel token for one command: the system store's + * `_TARGET_VERCEL_TOKEN` (a one-level reference to `_CONNECTOR_VERCEL_TOKEN` + * resolves inside the store), then `$VERCEL_TOKEN`. When nothing has a value + * the refusal carries the STORE's own code if the store refused (admin-only, + * connector-key-only, …), else `VERCEL_TOKEN_MISSING`. + */ +export async function resolveVercelTargetToken( + opts: ResolveVercelTokenOptions, +): Promise { + const outcome = await readStore(opts.getSecret ?? neverReachesTheStore, { + orgId: opts.orgId, + interactive: opts.interactive, + devMode: opts.devMode, + apiUrl: opts.apiUrl, + ...(opts.helpTeam ? { helpTeam: opts.helpTeam } : {}), + }); + if (outcome.kind === 'value') return { ok: true, value: outcome.value, source: 'system' }; + const fallback = opts.env[DEFAULT_VERCEL_TOKEN_ENV]; + if (fallback) return { ok: true, value: fallback, source: 'env' }; + return outcome.kind === 'error' + ? { ok: false, code: outcome.code } + : { ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }; +} + +/** Where to get the deploy-side Vercel token: the `link` + `remedy` fields of a key refusal. */ +export function vercelTargetKeyHelp(team?: string): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelKeyHelp(VERCEL_TARGET_SECRET_NAME, { team })); +} + +/** The two steps for a deploy token that is valid but has no access: the token page and `capy system set` for the deploy-side name. */ +export function vercelTargetTokenProblemHelp(): { link: { label: string; url: string }; remedy: string } { + return keyHelpFields(vercelTokenProblemHelp(VERCEL_TARGET_SECRET_NAME)); +} + +/** A token refusal as a printable reason + hint. Names only, never a value. */ +export function describeVercelTokenProblem(code: string): { reason: string; hint: string } { + if (code === ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY) { + return { + // COPY-FLAG: minimal neutral wording. + reason: `only an org owner or admin can set ${VERCEL_TARGET_SECRET_NAME} in the system store, and $${DEFAULT_VERCEL_TOKEN_ENV} is not set`, + // COPY-FLAG + hint: `Ask an org owner/admin to run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\`, or export ${DEFAULT_VERCEL_TOKEN_ENV} yourself.`, + }; + } + if (code === ERROR_CODES.VERCEL_TARGET_KEY_MISSING) { + return { + // COPY-FLAG + reason: `${VERCEL_TARGET_SECRET_NAME} is not set, but ${VERCEL_CONNECTOR_SECRET_NAME} already holds a Vercel token`, + // COPY-FLAG + hint: `Run this interactively to choose whether to reuse it, or run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\` to set a dedicated one.`, + }; + } + if (code === ERROR_CODES.VERCEL_TOKEN_MISSING) { + return { + // COPY-FLAG + reason: `${VERCEL_TARGET_SECRET_NAME} is not set`, + // COPY-FLAG + hint: `Run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\`, or export ${DEFAULT_VERCEL_TOKEN_ENV}=… first.`, + }; + } + return { + // COPY-FLAG + reason: `could not resolve the Vercel token (${code})`, + // COPY-FLAG + hint: `Run \`capy system set ${VERCEL_TARGET_SECRET_NAME}\`, or export ${DEFAULT_VERCEL_TOKEN_ENV}=… first.`, + }; +} + +// ── Options ──────────────────────────────────────────────────────────────── + +export type StandardVercelEnv = 'production' | 'preview' | 'development'; + +const STANDARD_ENVS: readonly string[] = ['production', 'preview', 'development']; + +/** The API-mode option set. `projectId` is what switches a target into API mode. */ +export interface VercelApiOptions { + projectDir?: string; + /** `production`, `preview`, `development`, or a custom environment's slug. */ + vercelEnv: string; + projectId: string; + teamId?: string; + /** + * The team's slug. Saved by `capy deploy`'s setup ONLY when the project could + * not be checked against Vercel (so no `teamId` was learned): requests then + * scope by slug. Also lets the token instructions name the team. + */ + teamSlug?: string; + /** Preview only. Absent means every preview branch. */ + gitBranch?: string; + customEnvironmentId?: string; +} + +/** Whether a target's options put it in API mode. The one switch; everything else follows it. */ +export function isVercelApiMode(options: Record): boolean { + return typeof options.projectId === 'string' && options.projectId.trim() !== ''; +} + +function nonEmptyString(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; +} + +export type ParsedVercelApiOptions = + | { ok: true; options: VercelApiOptions } + | { ok: false; reason: string; hint: string }; + +export function parseVercelApiOptions(raw: Record): ParsedVercelApiOptions { + const projectId = nonEmptyString(raw.projectId); + const vercelEnv = nonEmptyString(raw.vercelEnv); + if (!projectId || !vercelEnv) { + return { + ok: false, + // COPY-FLAG + reason: 'vercel API-mode target needs options.projectId and options.vercelEnv', + // COPY-FLAG + hint: 'Set vercelEnv to "production", "preview", "development" or a custom environment slug.', + }; + } + return { + ok: true, + options: { + projectDir: nonEmptyString(raw.projectDir), + vercelEnv, + projectId, + teamId: nonEmptyString(raw.teamId), + teamSlug: nonEmptyString(raw.teamSlug), + gitBranch: nonEmptyString(raw.gitBranch), + customEnvironmentId: nonEmptyString(raw.customEnvironmentId), + }, + }; +} + +// ── Typed errors ─────────────────────────────────────────────────────────── + +/** A failed Vercel call. `code` is ours; `vercelCode` is Vercel's own `error.code` field, when it sent one. */ +export class VercelApiError extends Error { + constructor( + readonly code: ErrorCode, + readonly status: number | undefined, + readonly vercelCode: string | undefined, + ) { + // The message is for logs: a code and a status, never a body (a body can echo a value). + super(`${code}${status !== undefined ? ` (HTTP ${status})` : ''}`); + this.name = 'VercelApiError'; + } +} + +// ── Client ───────────────────────────────────────────────────────────────── + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +const API_BASE = 'https://api.vercel.com'; +/** Retries after a 429. */ +export const MAX_RATE_LIMIT_RETRIES = 3; +/** A wait longer than this is not waited for: the call fails with VERCEL_RATE_LIMITED. */ +export const MAX_RATE_LIMIT_WAIT_MS = 60_000; +const DEFAULT_RATE_LIMIT_WAIT_MS = 1_000; + +/** One Vercel env record — only the fields we read. */ +export interface VercelEnvRecord { + id: string; + key: string; + value?: string; + type?: string; + target?: readonly string[] | string; + gitBranch?: string | null; + customEnvironmentIds?: readonly string[]; + decrypted?: boolean; +} + +export interface VercelClientDeps { + fetch?: FetchLike; + sleep?: (ms: number) => Promise; + /** Epoch milliseconds. */ + now?: () => number; + /** Scope requests by the team's slug (`?slug=`) when there is no `teamId` to scope by. */ + teamSlug?: string; +} + +export interface VercelClient { + getProject(): Promise; + getCustomEnvironment(slugOrId: string): Promise<{ id: string; slug?: string }>; + listEnvs(): Promise; + createEnv(body: Record): Promise; + patchEnv(envId: string, body: Record): Promise; + getEnv(envId: string): Promise; +} + +/** Seconds a 429 asks us to wait: `retry-after` (seconds) first, then `x-ratelimit-reset` (epoch seconds). */ +function rateLimitWaitMs(headers: Headers, nowMs: number): number { + const retryAfter = Number(headers.get('retry-after')); + if (headers.get('retry-after') !== null && Number.isFinite(retryAfter) && retryAfter >= 0) { + return Math.ceil(retryAfter * 1000); + } + const reset = Number(headers.get('x-ratelimit-reset')); + if (headers.get('x-ratelimit-reset') !== null && Number.isFinite(reset) && reset > 0) { + return Math.max(0, reset * 1000 - nowMs); + } + return DEFAULT_RATE_LIMIT_WAIT_MS; +} + +async function vercelErrorCodeOf(res: Response): Promise { + try { + const body = (await res.json()) as { error?: { code?: unknown } } | null; + const code = body?.error?.code; + return typeof code === 'string' ? code : undefined; + } catch { + return undefined; + } +} + +function codeForStatus(status: number, notFound: ErrorCode): ErrorCode { + if (status === 401) return ERROR_CODES.VERCEL_TOKEN_REJECTED; + if (status === 403) return ERROR_CODES.VERCEL_NO_ACCESS; + if (status === 404) return notFound; + if (status === 429) return ERROR_CODES.VERCEL_RATE_LIMITED; + return ERROR_CODES.VERCEL_API_ERROR; +} + +export function createVercelClient( + token: string, + projectId: string, + teamId: string | undefined, + deps: VercelClientDeps = {}, +): VercelClient { + const doFetch: FetchLike = deps.fetch ?? ((input, init) => fetch(input, init)); + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? (() => Date.now()); + const project = encodeURIComponent(projectId); + + const urlFor = (path: string, query: Readonly>): string => { + const scope: Record = teamId ? { teamId } : deps.teamSlug ? { slug: deps.teamSlug } : {}; + const params = new URLSearchParams({ ...query, ...scope }); + const qs = params.toString(); + return `${API_BASE}${path}${qs ? `?${qs}` : ''}`; + }; + + /** One request, retrying a 429 up to MAX_RATE_LIMIT_RETRIES times. Sequential by construction. */ + const request = async ( + method: string, + path: string, + query: Readonly>, + body: Record | undefined, + notFound: ErrorCode, + attempt: number = 0, + ): Promise => { + const res = await doFetch(urlFor(path, query), { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }).catch((): null => null); + if (res === null) throw new VercelApiError(ERROR_CODES.VERCEL_NETWORK_ERROR, undefined, undefined); + if (res.ok) return res.json(); + const vercelCode = await vercelErrorCodeOf(res); + if (res.status !== 429) { + throw new VercelApiError(codeForStatus(res.status, notFound), res.status, vercelCode); + } + const waitMs = rateLimitWaitMs(res.headers, now()); + if (attempt >= MAX_RATE_LIMIT_RETRIES || waitMs > MAX_RATE_LIMIT_WAIT_MS) { + throw new VercelApiError(ERROR_CODES.VERCEL_RATE_LIMITED, 429, vercelCode); + } + await sleep(waitMs); + return request(method, path, query, body, notFound, attempt + 1); + }; + + const notFoundProject = ERROR_CODES.VERCEL_PROJECT_NOT_FOUND; + + return { + async getProject() { + await request('GET', `/v9/projects/${project}`, {}, undefined, notFoundProject); + }, + async getCustomEnvironment(slugOrId) { + const got = (await request( + 'GET', + `/v9/projects/${project}/custom-environments/${encodeURIComponent(slugOrId)}`, + {}, + undefined, + ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND, + )) as { id?: unknown; slug?: unknown }; + if (typeof got?.id !== 'string') { + throw new VercelApiError(ERROR_CODES.VERCEL_API_ERROR, undefined, undefined); + } + return { id: got.id, slug: typeof got.slug === 'string' ? got.slug : undefined }; + }, + async listEnvs() { + const got = (await request('GET', `/v10/projects/${project}/env`, { decrypt: 'true' }, undefined, notFoundProject)) as + | { envs?: unknown } + | readonly unknown[] + | null; + const envs = Array.isArray(got) ? got : (got as { envs?: unknown } | null)?.envs; + return Array.isArray(envs) ? (envs as VercelEnvRecord[]) : []; + }, + async createEnv(body) { + const got = (await request('POST', `/v10/projects/${project}/env`, {}, body, notFoundProject)) as { + created?: VercelEnvRecord | readonly VercelEnvRecord[]; + failed?: readonly { error?: { code?: unknown } }[]; + }; + const failedCode = got?.failed?.[0]?.error?.code; + if (Array.isArray(got?.failed) && got.failed.length > 0) { + throw new VercelApiError( + ERROR_CODES.VERCEL_API_ERROR, + undefined, + typeof failedCode === 'string' ? failedCode : undefined, + ); + } + const created = Array.isArray(got?.created) ? got.created[0] : got?.created; + if (!created || typeof (created as VercelEnvRecord).id !== 'string') { + throw new VercelApiError(ERROR_CODES.VERCEL_API_ERROR, undefined, undefined); + } + return created as VercelEnvRecord; + }, + async patchEnv(envId, body) { + const got = (await request( + 'PATCH', + `/v9/projects/${project}/env/${encodeURIComponent(envId)}`, + {}, + body, + notFoundProject, + )) as VercelEnvRecord | null; + return got ?? { id: envId, key: '' }; + }, + async getEnv(envId) { + return (await request( + 'GET', + `/v1/projects/${project}/env/${encodeURIComponent(envId)}`, + {}, + undefined, + notFoundProject, + )) as VercelEnvRecord; + }, + }; +} + +// ── Environment scope ────────────────────────────────────────────────────── + +/** The one environment (and, for Preview, branch scope) a target writes to. */ +export type EnvScope = + | { kind: 'standard'; env: StandardVercelEnv; gitBranch?: string } + | { kind: 'custom'; id: string; slug: string }; + +/** `production` / `preview` / `development` / `custom:` — one string per environment a record can serve. */ +function scopeKey(scope: EnvScope): string { + return scope.kind === 'standard' ? scope.env : `custom:${scope.id}`; +} + +export function describeScope(scope: EnvScope): string { + if (scope.kind === 'custom') return `custom environment ${scope.slug}`; + if (scope.env === 'preview') { + return scope.gitBranch ? `preview · branch=${scope.gitBranch}` : 'preview · all branches'; + } + return scope.env; +} + +export type ScopeResolution = + | { ok: true; scope: EnvScope } + | { ok: false; code: ErrorCode; status?: number }; + +function isStandardEnv(v: string): v is StandardVercelEnv { + return STANDARD_ENVS.includes(v); +} + +/** The target's environment as an `EnvScope`, confirming a custom environment exists on the project. */ +export async function resolveEnvScope( + client: VercelClient, + opts: VercelApiOptions, +): Promise { + if (isStandardEnv(opts.vercelEnv)) { + const gitBranch = opts.vercelEnv === 'preview' ? opts.gitBranch : undefined; + return { ok: true, scope: { kind: 'standard', env: opts.vercelEnv, ...(gitBranch ? { gitBranch } : {}) } }; + } + try { + const found = await client.getCustomEnvironment(opts.customEnvironmentId ?? opts.vercelEnv); + return { ok: true, scope: { kind: 'custom', id: found.id, slug: found.slug ?? opts.vercelEnv } }; + } catch (err) { + return failureOf(err); + } +} + +/** A thrown error as `{ ok: false, code, status }`. Anything not a VercelApiError is an unexpected API error. */ +export function failureOf(err: unknown): { ok: false; code: ErrorCode; status?: number } { + if (err instanceof VercelApiError) return { ok: false, code: err.code, status: err.status }; + return { ok: false, code: ERROR_CODES.VERCEL_API_ERROR }; +} + +/** Preflight: the project exists, the token can see it, and (custom env) the environment exists. */ +export async function checkVercelTarget( + client: VercelClient, + opts: VercelApiOptions, +): Promise<{ ok: true; scope: EnvScope } | { ok: false; code: ErrorCode; status?: number }> { + try { + await client.getProject(); + } catch (err) { + return failureOf(err); + } + return resolveEnvScope(client, opts); +} + +// ── Planning ─────────────────────────────────────────────────────────────── + +function targetsOf(rec: VercelEnvRecord): readonly string[] { + if (Array.isArray(rec.target)) return rec.target as readonly string[]; + return typeof rec.target === 'string' ? [rec.target] : []; +} + +function customIdsOf(rec: VercelEnvRecord): readonly string[] { + return rec.customEnvironmentIds ?? []; +} + +/** Every environment this record serves, as scope keys. */ +function envKeysOf(rec: VercelEnvRecord): readonly string[] { + return [...targetsOf(rec), ...customIdsOf(rec).map((id) => `custom:${id}`)]; +} + +function branchOf(v: string | null | undefined): string { + return v ?? ''; +} + +/** Whether `rec` is a record for this var name that serves `scope` (and, for Preview, the same branch scope). */ +function covers(rec: VercelEnvRecord, name: string, scope: EnvScope): boolean { + if (rec.key !== name) return false; + if (!envKeysOf(rec).includes(scopeKey(scope))) return false; + if (scope.kind === 'standard' && scope.env === 'preview') { + return branchOf(rec.gitBranch) === branchOf(scope.gitBranch); + } + return true; +} + +/** The value Vercel will let us read, or undefined when it is unreadable (sensitive, or not decrypted). */ +function readableValue(rec: VercelEnvRecord): string | undefined { + if (typeof rec.value !== 'string') return undefined; + if (rec.type === 'sensitive') return undefined; + if (rec.type === 'plain' || rec.decrypted === true) return rec.value; + return undefined; +} + +export type NewRecordType = 'encrypted' | 'sensitive'; + +/** A new record keeps an existing record's type when that is sensitive or encrypted; else `encrypted`. */ +function typeLike(rec: VercelEnvRecord | undefined): NewRecordType { + return rec?.type === 'sensitive' ? 'sensitive' : 'encrypted'; +} + +/** What to do for one var. `record` is always an existing Vercel record. */ +export type VarPlan = + | { kind: 'create'; name: string; value: string; type: NewRecordType } + | { kind: 'update'; name: string; value: string; record: VercelEnvRecord } + | { kind: 'unchanged'; name: string; record: VercelEnvRecord } + | { kind: 'split'; name: string; value: string; shared: VercelEnvRecord; type: NewRecordType }; + +/** + * Decide how to write one var into one environment, from the current records: + * 1. a record serving exactly this environment → edit its value (skip when equal); + * 2. a record serving this environment AND others → equal value: nothing to do; + * otherwise split — this environment is removed from it and a new record + * for this environment alone is created. The shared record is never deleted; + * 3. no record → create one. + * Records for other keys are never looked at. + */ +export function planVar( + name: string, + value: string, + records: readonly VercelEnvRecord[], + scope: EnvScope, +): VarPlan { + const serving = records.filter((r) => covers(r, name, scope)); + const exact = serving.find((r) => envKeysOf(r).length === 1); + if (exact) { + return readableValue(exact) === value + ? { kind: 'unchanged', name, record: exact } + : { kind: 'update', name, value, record: exact }; + } + const shared = serving[0]; + if (shared) { + return readableValue(shared) === value + ? { kind: 'unchanged', name, record: shared } + : { kind: 'split', name, value, shared, type: typeLike(shared) }; + } + return { kind: 'create', name, value, type: 'encrypted' }; +} + +/** + * The record a plan would replace the value of, when Vercel holds it as sensitive. Capy cannot read such a + * value, so it cannot tell whether the new one differs: every deploy would replace it. + */ +function replacedRecord(plan: VarPlan): VercelEnvRecord | undefined { + if (plan.kind === 'update') return plan.record; + return plan.kind === 'split' ? plan.shared : undefined; +} + +/** The names whose plan replaces a value Vercel holds as sensitive. */ +export function plansReplacingSensitive(plans: readonly VarPlan[]): readonly string[] { + return plans.filter((plan) => replacedRecord(plan)?.type === 'sensitive').map((plan) => plan.name); +} + +/** Dry-run counterpart of `plansReplacingSensitive`: the names a deploy would replace a sensitive value of (names only, no values needed). */ +export function sensitiveReplacements(names: readonly string[], records: readonly VercelEnvRecord[], scope: EnvScope): readonly string[] { + const replaced = (name: string): VercelEnvRecord | undefined => { + const serving = records.filter((r) => covers(r, name, scope)); + return serving.find((r) => envKeysOf(r).length === 1) ?? serving[0]; + }; + return names.filter((name) => replaced(name)?.type === 'sensitive'); +} + +/** `planVar` for every name, in order. */ +export function planAll( + vars: ReadonlyArray, + records: readonly VercelEnvRecord[], + scope: EnvScope, +): readonly VarPlan[] { + return vars.map(([name, value]) => planVar(name, value, records, scope)); +} + +/** Names only, per kind — all a dry run may say. Without values, "unchanged" cannot be known. */ +export function dryRunSummary( + names: readonly string[], + records: readonly VercelEnvRecord[], + scope: EnvScope, +): { create: readonly string[]; update: readonly string[]; split: readonly string[] } { + const kindOf = (name: string): 'create' | 'update' | 'split' => { + const serving = records.filter((r) => covers(r, name, scope)); + if (serving.some((r) => envKeysOf(r).length === 1)) return 'update'; + return serving.length > 0 ? 'split' : 'create'; + }; + const kinds = names.map((name) => ({ name, kind: kindOf(name) })); + const pick = (k: 'create' | 'update' | 'split') => kinds.filter((x) => x.kind === k).map((x) => x.name); + return { create: pick('create'), update: pick('update'), split: pick('split') }; +} + +// ── Applying ─────────────────────────────────────────────────────────────── + +export type WriteOutcome = 'created' | 'updated' | 'split' | 'unchanged'; + +export interface AppliedVar { + name: string; + outcome: WriteOutcome; + /** The Vercel record now holding this environment's value. */ + envId: string; + /** The type Vercel reports for that record, when it said. */ + type?: string; +} + +export type ApplyResult = + | { ok: true; applied: readonly AppliedVar[] } + | { ok: false; code: ErrorCode; status?: number; name: string; applied: readonly AppliedVar[] }; + +function sortedCopy(xs: readonly T[], cmp?: (a: T, b: T) => number): T[] { + return Array.from(xs).sort(cmp); +} + +/** Body that removes `scope` from a shared record, leaving every other environment it serves exactly as it was. */ +function detachBody(shared: VercelEnvRecord, scope: EnvScope): Record { + const key = scopeKey(scope); + const remainingStandard = targetsOf(shared).filter((t) => t !== key); + const remainingCustom = customIdsOf(shared).filter((id) => `custom:${id}` !== key); + const lostBranchScope = scope.kind === 'standard' && scope.env === 'preview' && !!shared.gitBranch; + return { + target: remainingStandard, + ...(shared.customEnvironmentIds ? { customEnvironmentIds: remainingCustom } : {}), + // A git branch is only valid while the record still serves Preview. + ...(lostBranchScope && !remainingStandard.includes('preview') ? { gitBranch: null } : {}), + }; +} + +/** Body that puts a shared record back exactly as listed. */ +function restoreBody(shared: VercelEnvRecord): Record { + return { + target: targetsOf(shared), + ...(shared.customEnvironmentIds ? { customEnvironmentIds: customIdsOf(shared) } : {}), + ...(shared.gitBranch ? { gitBranch: shared.gitBranch } : {}), + }; +} + +/** POST body for a brand-new record serving only `scope`. */ +function createBody(name: string, value: string, type: NewRecordType, scope: EnvScope): Record { + if (scope.kind === 'custom') { + return { key: name, value, type, customEnvironmentIds: [scope.id] }; + } + return { + key: name, + value, + type, + target: [scope.env], + ...(scope.env === 'preview' && scope.gitBranch ? { gitBranch: scope.gitBranch } : {}), + }; +} + +type OneResult = + | { ok: true; applied: AppliedVar } + | { ok: false; code: ErrorCode; status?: number }; + +async function applyOne(client: VercelClient, plan: VarPlan, scope: EnvScope): Promise { + try { + if (plan.kind === 'unchanged') { + return { ok: true, applied: { name: plan.name, outcome: 'unchanged', envId: plan.record.id, type: plan.record.type } }; + } + if (plan.kind === 'update') { + const got = await client.patchEnv(plan.record.id, { value: plan.value }); + return { ok: true, applied: { name: plan.name, outcome: 'updated', envId: plan.record.id, type: got.type ?? plan.record.type } }; + } + if (plan.kind === 'create') { + const made = await client.createEnv(createBody(plan.name, plan.value, plan.type, scope)); + return { ok: true, applied: { name: plan.name, outcome: 'created', envId: made.id, type: made.type } }; + } + return await applySplit(client, plan, scope); + } catch (err) { + return failureOf(err); + } +} + +/** + * Detach this environment from the shared record, then create its own record. + * The order matters: while the shared record still serves this environment, a + * create would collide with it. If the create fails after the detach, the + * detach is undone so the environment does not lose the var; if even that + * fails the result says so (`VERCEL_SPLIT_NOT_RESTORED`). + */ +async function applySplit( + client: VercelClient, + plan: Extract, + scope: EnvScope, +): Promise { + await client.patchEnv(plan.shared.id, detachBody(plan.shared, scope)); + try { + const made = await client.createEnv(createBody(plan.name, plan.value, plan.type, scope)); + return { ok: true, applied: { name: plan.name, outcome: 'split', envId: made.id, type: made.type } }; + } catch (createErr) { + const restored = await client + .patchEnv(plan.shared.id, restoreBody(plan.shared)) + .then(() => true) + .catch(() => false); + if (!restored) return { ok: false, code: ERROR_CODES.VERCEL_SPLIT_NOT_RESTORED }; + return failureOf(createErr); + } +} + +/** Apply every plan, one request at a time, stopping at the first failure. */ +export async function applyPlans( + client: VercelClient, + plans: readonly VarPlan[], + scope: EnvScope, +): Promise { + const step = async (index: number, done: readonly AppliedVar[]): Promise => { + const plan = plans[index]; + if (!plan) return { ok: true, applied: done }; + const result = await applyOne(client, plan, scope); + if (!result.ok) return { ok: false, code: result.code, status: result.status, name: plan.name, applied: done }; + return step(index + 1, [...done, result.applied]); + }; + return step(0, []); +} + +// ── Verification ─────────────────────────────────────────────────────────── + +export type VerifyState = 'verified' | 'unverified' | 'mismatch'; + +export interface VerifiedVar { + name: string; + state: VerifyState; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Re-read what Vercel holds and compare SHA-256 hashes in memory (the values + * themselves are never printed or kept). Sensitive records cannot be read — + * they are `unverified`, never `verified`. A record Vercel will not decrypt + * is the same. + */ +export async function verifyApplied( + client: VercelClient, + applied: readonly AppliedVar[], + expected: Readonly>, +): Promise<{ ok: true; results: readonly VerifiedVar[] } | { ok: false; code: ErrorCode; status?: number }> { + const step = async ( + index: number, + done: readonly VerifiedVar[], + ): Promise<{ ok: true; results: readonly VerifiedVar[] } | { ok: false; code: ErrorCode; status?: number }> => { + const item = applied[index]; + if (!item) return { ok: true, results: done }; + if (item.outcome === 'unchanged') { + // Equal by comparison against the decrypted list read — already verified. + return step(index + 1, [...done, { name: item.name, state: 'verified' }]); + } + try { + const rec = await client.getEnv(item.envId); + const readable = readableValue(rec); + const state: VerifyState = + readable === undefined + ? 'unverified' + : sha256(readable) === sha256(expected[item.name] ?? '') + ? 'verified' + : 'mismatch'; + return step(index + 1, [...done, { name: item.name, state }]); + } catch (err) { + return failureOf(err); + } + }; + return step(0, []); +} + +/** Names in `names`, sorted — for stable, value-free messages. */ +export function sortedNames(names: readonly string[]): string[] { + return sortedCopy(names); +} + +// ── Messages ─────────────────────────────────────────────────────────────── + +/** A coded refusal as a printable reason + hint. Callers branch on `code`; this text is display only. */ +export function describeVercelFailure( + code: ErrorCode, + status?: number, + team?: string, +): { reason: string; hint?: string; link?: { label: string; url: string }; remedy?: string } { + const http = status !== undefined ? ` (HTTP ${status})` : ''; + if (code === ERROR_CODES.VERCEL_TOKEN_REJECTED) { + // COPY-FLAG + return { reason: `Vercel rejected the token${http}`, hint: `Replace it: capy system set ${VERCEL_TARGET_SECRET_NAME}`, ...vercelTargetKeyHelp(team) }; + } + if (code === ERROR_CODES.VERCEL_NO_ACCESS) { + // COPY-FLAG + return { reason: `the Vercel token has no access to this project${http}`, ...vercelTargetTokenProblemHelp() }; + } + if (code === ERROR_CODES.VERCEL_PROJECT_NOT_FOUND) { + // COPY-FLAG + return { reason: `Vercel project not found${http}`, hint: 'Check options.projectId and options.teamId.' }; + } + if (code === ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND) { + // COPY-FLAG + return { reason: `Vercel environment not found on the project${http}`, hint: 'Check options.vercelEnv / options.customEnvironmentId.' }; + } + if (code === ERROR_CODES.VERCEL_NETWORK_ERROR) { + // COPY-FLAG + return { reason: 'could not reach the Vercel API' }; + } + if (code === ERROR_CODES.VERCEL_RATE_LIMITED) { + // COPY-FLAG + return { reason: `Vercel rate limit reached${http}`, hint: 'Wait a minute and run again.' }; + } + if (code === ERROR_CODES.VERCEL_SPLIT_NOT_RESTORED) { + // COPY-FLAG + return { reason: 'a shared Vercel variable lost this environment and could not be restored', hint: 'Check the project’s Environment Variables in Vercel.' }; + } + // COPY-FLAG + return { reason: `Vercel API call failed (${code}${http})` }; +} + +/** Narrows a store-supplied code string to a known `ErrorCode`, else the generic token-missing code. */ +export function asKnownErrorCode(code: string): ErrorCode { + const known = Object.values(ERROR_CODES) as readonly string[]; + return known.includes(code) ? (code as ErrorCode) : ERROR_CODES.VERCEL_TOKEN_MISSING; +} diff --git a/src/deploy/vercelTokenOnce.ts b/src/deploy/vercelTokenOnce.ts new file mode 100644 index 00000000..1765c7a1 --- /dev/null +++ b/src/deploy/vercelTokenOnce.ts @@ -0,0 +1,34 @@ +/** + * Vercel API mode only (CAP-696): the org system store's `_TARGET_VERCEL_TOKEN` + * for a target, resolved once and handed to `preflight` / `deploy` as + * `ctx.resolvedVercelToken`. Same shape as `dokployApiKeyOnce.ts`. + */ +import type { DeployAdapter, TargetConfig } from './adapter'; +import { teamSlugOf } from './keyHelp'; +import { ResolveVercelTokenResult, getVercelTargetSecretFromStore, isVercelApiMode, resolveVercelTargetToken } from './vercelTargetApi'; + +/** + * Resolves the token ONCE for this command (the store is asked, and an admin + * prompted, at most once). `undefined` for any other adapter, and for a Vercel + * target still on the CLI path (no `projectId`) — those never read a token + * from the store. + */ +export async function resolveVercelTokenOnce( + adapter: DeployAdapter, + target: TargetConfig, + orgId: string | undefined, + devMode: boolean | undefined, + interactive: boolean, +): Promise { + if (adapter.id !== 'vercel') return undefined; + if (!isVercelApiMode(target.options)) return undefined; + const team = teamSlugOf(typeof target.options.teamSlug === 'string' ? target.options.teamSlug : undefined); + return resolveVercelTargetToken({ + env: process.env, + interactive, + orgId, + devMode, + getSecret: getVercelTargetSecretFromStore, + ...(team ? { helpTeam: team } : {}), + }); +} diff --git a/src/files/envUpsert.ts b/src/files/envUpsert.ts index e5d17fd3..3e903ee2 100644 --- a/src/files/envUpsert.ts +++ b/src/files/envUpsert.ts @@ -18,6 +18,18 @@ * server (the pushed blob and keep.lock are built from parsed values). */ +/** + * Whole comment lines Capy owns (never a variable). `ensure`: each appears exactly once (kept where it + * is when already there, appended otherwise). `drop`: each is removed wherever it appears. Matching is + * whole-line equality with a line Capy would itself write; nothing is interpreted. + */ +export interface EnvNotes { + readonly ensure: readonly string[]; + readonly drop: readonly string[]; +} + +const NO_NOTES: EnvNotes = { ensure: [], drop: [] }; + export interface EnvHeader { org_id?: string; project_id?: string; @@ -108,8 +120,15 @@ function headerLineKey(content: string): (typeof HEADER_KEYS)[number] | null { * `entries` is the complete desired set, in order, with each value already * rendered as it should appear after `=`. */ -export function upsertEnvText(existing: string, header: EnvHeader, entries: ReadonlyArray): string { +export function upsertEnvText( + existing: string, + header: EnvHeader, + entries: ReadonlyArray, + notes: EnvNotes = NO_NOTES, +): string { const lines = splitLines(existing); + const ensureSet = new Set(notes.ensure); + const dropSet = new Set(notes.drop.filter((l) => !ensureSet.has(l))); const wanted = new Map(entries); const headerValues = HEADER_KEYS.flatMap((k) => (header[k] ? [[k, header[k] as string] as const] : [])); @@ -117,7 +136,7 @@ export function upsertEnvText(existing: string, header: EnvHeader, entries: Read const headerText = headerValues.map(([k, v]) => `# capy:${k}=${v}`).join('\n'); const body = entries.map(([k, v]) => `${k}=${v}`).join('\n'); const joined = headerText ? `${headerText}\n\n${body}` : body; - return joined + '\n'; + return [joined, ...notes.ensure].filter((part) => part !== '').join('\n') + '\n'; } const eol = dominantEol(lines); @@ -138,11 +157,17 @@ export function upsertEnvText(existing: string, header: EnvHeader, entries: Read return [`${def.prefix}${def.key}=${wanted.get(def.key)}${def.inlineComment}${lastEol || (i === lines.length - 1 ? '' : eol)}`]; } if (coveredByDefinition.has(i)) return []; + if (dropSet.has(line.content)) return []; + // A note that appears twice keeps its first line. + if (ensureSet.has(line.content) && lines.findIndex((l) => l.content === line.content) !== i) return []; return [line.content + line.eol]; }); const missingHeader = headerValues.filter(([k]) => !presentHeaderKeys.has(k)).map(([k, v]) => `# capy:${k}=${v}${eol}`); - const newEntries = entries.filter(([k]) => !presentKeys.has(k)).map(([k, v]) => `${k}=${v}${eol}`); + const newEntries = [ + ...entries.filter(([k]) => !presentKeys.has(k)).map(([k, v]) => `${k}=${v}${eol}`), + ...notes.ensure.filter((note) => !lines.some((l) => l.content === note)).map((note) => `${note}${eol}`), + ]; const body = kept.join(''); const bodyWithTerminator = body.length > 0 && !/(\r\n|\n|\r)$/.test(body) && newEntries.length > 0 ? body + eol : body; const headerBlock = missingHeader.length > 0 diff --git a/src/files/fileManager.ts b/src/files/fileManager.ts index 0a600d6e..fd0867ac 100644 --- a/src/files/fileManager.ts +++ b/src/files/fileManager.ts @@ -5,7 +5,8 @@ import { parse as parseDotenv } from 'dotenv'; import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; import { debug } from '../ui/debug'; -import { upsertEnvText, EnvHeader } from './envUpsert'; +import { upsertEnvText, EnvHeader, type EnvNotes } from './envUpsert'; +import { placeholderEnvNote, placeholderEnvNoteVariants, placeholdersOnBranch } from '../core/placeholder'; const KEEP_COMMENT = 'Capy lockfile — pins env var identities and value hashes per branch. Safe to commit; no secrets here. Plaintext lives in .env (gitignored). Regenerated by the Capy CLI on sync/push. See https://capy.sc/keep-docs'; @@ -68,6 +69,20 @@ export function serializeKeep(keep: KeepFile): string { return JSON.stringify(sorted, null, 2) + '\n'; } +/** The comment lines a branch's placeholders get in `.env`, and the stale ones to remove. */ +function placeholderNotes( + variables: Readonly>, + retired: readonly string[], + keep: KeepFile | null | undefined, + branch: string | undefined, +): EnvNotes { + const open = branch === undefined ? [] : placeholdersOnBranch(keep, branch).filter(({ name }) => !Object.hasOwn(variables, name)); + return { + ensure: open.map(({ name, marker }) => placeholderEnvNote(name, marker)), + drop: [...Object.keys(variables), ...retired].flatMap(placeholderEnvNoteVariants), + }; +} + export class FileManager { private projectRoot: string; @@ -152,7 +167,18 @@ export class FileManager { } } - writeEncryptedEnvFile(variables: Record, encryptionKey: string, path?: string, keep?: KeepFile | null, branch?: string): void { + /** + * `retired`: names that are gone from the branch; the note a placeholder left for them is removed. + * A name in `variables` has a value now, so its note is removed too. + */ + writeEncryptedEnvFile( + variables: Record, + encryptionKey: string, + path?: string, + keep?: KeepFile | null, + branch?: string, + retired: readonly string[] = [], + ): void { const envPath = path || join(this.projectRoot, '.env'); debug(`Encrypting and writing ${Object.keys(variables).length} variables to ${envPath}`); const backup = this.createBackup(envPath); @@ -188,7 +214,7 @@ export class FileManager { ...(branch ? { branch } : {}), }; const existing = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; - const content = upsertEnvText(existing, header, encryptedEntries); + const content = upsertEnvText(existing, header, encryptedEntries, placeholderNotes(variables, retired, keep, branch)); this.ensureDirectoryExists(dirname(envPath)); writeFileSync(envPath, content, { encoding: 'utf-8', mode: 0o600 }); diff --git a/src/git/discoveryCommit.ts b/src/git/discoveryCommit.ts index bb4cd206..43f67c2e 100644 --- a/src/git/discoveryCommit.ts +++ b/src/git/discoveryCommit.ts @@ -51,6 +51,7 @@ import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { restorePathsToHead, syncTrackedKeepFromWorkingCopy } from '../deploy/git'; +import { DOKPLOY_DISCOVERY_CODES, type DiscoveryCodes } from '../commands/connectors/discoveryCodes'; function git(repoRoot: string, args: readonly string[]): string { // `env: process.env` explicitly: Bun's `execFileSync` (unlike Node's) @@ -91,10 +92,10 @@ export function snapshotPathStatus(repoRoot: string, paths: readonly string[]): } /** `capy/dokploy-import-` — the default branch name, used whenever nothing asked for (or answered) a different one. */ -export function defaultDiscoveryCommitBranchName(now: Date = new Date()): string { +export function defaultDiscoveryCommitBranchName(now: Date = new Date(), provider: string = 'dokploy'): string { const pad = (n: number): string => String(n).padStart(2, '0'); const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`; - return `capy/dokploy-import-${stamp}`; + return `capy/${provider}-import-${stamp}`; } /** @@ -120,12 +121,7 @@ export type DiscoveryCommitOutcome = | { ok: false; /** Stable refusal code — branch on this, never on `message`. */ - code: - | 'DOKPLOY_COMMIT_WOULD_MIX' - | 'DOKPLOY_COMMIT_BRANCH_INVALID' - | 'DOKPLOY_COMMIT_BRANCH_EXISTS' - | 'DOKPLOY_GIT_IDENTITY_MISSING' - | 'DOKPLOY_COMMIT_FAILED'; + code: string; message: string; }; @@ -143,8 +139,17 @@ export function commitDiscoveryChanges( repoRoot: string, paths: readonly string[], beforeStatus: ReadonlySet, - opts: { branchName: string; dryRun: boolean; summaryLines: readonly string[] }, + opts: { + branchName: string; + dryRun: boolean; + summaryLines: readonly string[]; + /** The commit subject. Default is the Dokploy one, so every existing caller's commit is unchanged. */ + commitMessage?: string; + /** How the refusals are spelled. Default is Dokploy's, so every existing caller's codes are unchanged. */ + codes?: Pick; + }, ): DiscoveryCommitOutcome { + const codes = opts.codes ?? DOKPLOY_DISCOVERY_CODES; if (paths.length === 0) { return { ok: true, dryRun: true, branch: opts.branchName, wouldCommitFiles: [] }; } @@ -153,13 +158,13 @@ export function commitDiscoveryChanges( if (mixedFiles.length > 0) { return { ok: false, - code: 'DOKPLOY_COMMIT_WOULD_MIX', + code: codes.commitWouldMix, message: `${mixedFiles.join(', ')} already had uncommitted changes before this run — refusing to mix them into an automatic commit.`, }; } if (!isValidDiscoveryCommitBranchName(opts.branchName)) { - return { ok: false, code: 'DOKPLOY_COMMIT_BRANCH_INVALID', message: `"${opts.branchName}" is not a valid git branch name.` }; + return { ok: false, code: codes.commitBranchInvalid, message: `"${opts.branchName}" is not a valid git branch name.` }; } if (opts.dryRun) { @@ -168,7 +173,7 @@ export function commitDiscoveryChanges( const branchExists = tryGit(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${opts.branchName}`]); if (branchExists.ok) { - return { ok: false, code: 'DOKPLOY_COMMIT_BRANCH_EXISTS', message: `Branch "${opts.branchName}" already exists.` }; + return { ok: false, code: codes.commitBranchExists, message: `Branch "${opts.branchName}" already exists.` }; } const identityName = tryGit(repoRoot, ['config', '--get', 'user.name']); @@ -176,14 +181,14 @@ export function commitDiscoveryChanges( if (!identityName.ok || !identityName.stdout.trim() || !identityEmail.ok || !identityEmail.stdout.trim()) { return { ok: false, - code: 'DOKPLOY_GIT_IDENTITY_MISSING', + code: codes.gitIdentityMissing, message: 'git has no configured identity in this repo — set user.name and user.email, then re-run.', }; } const created = tryGit(repoRoot, ['checkout', '-b', opts.branchName]); if (!created.ok) { - return { ok: false, code: 'DOKPLOY_COMMIT_FAILED', message: `Could not create branch "${opts.branchName}".` }; + return { ok: false, code: codes.commitFailed, message: `Could not create branch "${opts.branchName}".` }; } // The keep.lock path(s) among `paths` (one per folder this commit covers) @@ -194,7 +199,7 @@ export function commitDiscoveryChanges( keepPaths.forEach((relPath) => syncTrackedKeepFromWorkingCopy(join(repoRoot, dirname(relPath)))); const added = tryGit(repoRoot, ['add', '--', ...paths]); - const messageArgs = ['-m', 'chore(capy): import Dokploy environments', ...(opts.summaryLines.length > 0 ? ['-m', opts.summaryLines.join('\n')] : [])]; + const messageArgs = ['-m', opts.commitMessage ?? 'chore(capy): import Dokploy environments', ...(opts.summaryLines.length > 0 ? ['-m', opts.summaryLines.join('\n')] : [])]; const committed = added.ok ? tryGit(repoRoot, ['commit', ...messageArgs, '--', ...paths]) : { ok: false as const }; if (!committed.ok) { // Never leaves the repo stranded on a half-made branch after a failed @@ -213,7 +218,7 @@ export function commitDiscoveryChanges( if (keepPaths.length > 0) restorePathsToHead(repoRoot, [...keepPaths]); tryGit(repoRoot, ['checkout', '-']); tryGit(repoRoot, ['branch', '-D', opts.branchName]); - return { ok: false, code: 'DOKPLOY_COMMIT_FAILED', message: 'git commit failed.' }; + return { ok: false, code: codes.commitFailed, message: 'git commit failed.' }; } const sha = tryGit(repoRoot, ['rev-parse', 'HEAD']); diff --git a/src/index-dev.ts b/src/index-dev.ts index 339a1b25..7aff3ecb 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -345,9 +345,9 @@ const deploy = program .option('--scope ', 'gh-actions: "repo" or "env"') .option('--env-name ', 'gh-actions: env name when --scope env') // COPY-FLAG: the option descriptions of `deploy --discover` are minimal and neutral. - .option('--discover', 'dokploy: find the services that match Capy projects and print JSON (never prompts)') - .option('--plan ', 'dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`)') - .option('--confirm ', 'dokploy --discover: write the plan that --dry-run printed (one PR per repo)') + .option('--discover', 'dokploy/vercel: find the services that match Capy projects and print JSON (never prompts)') + .option('--plan ', 'dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`)') + .option('--confirm ', 'dokploy/vercel --discover: write the plan that --dry-run printed (one PR per repo)') .option('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') .addOption(resolveOption()) .addOption(conflictOption()) diff --git a/src/index.ts b/src/index.ts index 981d2a82..438f8f8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,9 +133,9 @@ program program .command('edit [name]') - // COPY-FLAG — minimal-neutral; names the two modes. - .description('Inspect and edit secrets in an interactive TUI, or set one variable from a piped value') - .option('--no-push', 'piped value: write .env only; do not push to Capy') + // COPY-FLAG — minimal-neutral; names the three modes. + .description('Inspect and edit secrets in an interactive TUI, or set one variable from a hidden prompt or a piped value') + .option('--no-push', 'with a name: write .env only; do not push to Capy') // COPY-FLAG .option('--json', 'emit machine-readable JSON instead of the human UI (piped value)') .option('--non-tty', 'treat stdin as not a terminal; never prompt (agents/CI)') .option('--pr', 'create a PR with the keep.lock change (answers the prompt)') // COPY-FLAG @@ -145,6 +145,7 @@ program 'after', // COPY-FLAG '\n' + + 'With a name on a terminal, asks for the value (hidden) and sets that variable, like: capy add NAME --force\n' + 'With a name and a piped value, sets that one variable. The value is read from stdin only:\n' + ' | capy edit NAME --json\n', ) @@ -380,11 +381,12 @@ const deploy = program .option('--scope ', 'gh-actions: "repo" or "env"') .option('--env-name ', 'gh-actions: env name when --scope env') .option('--no-deploy', 'write and verify the target, but skip the platform deploy/redeploy (target mode)') + .option('--replace-sensitive', 'vercel: allow replacing a value Vercel holds as sensitive, for the first time for a target, without asking') // COPY-FLAG .option('--json', 'describe the route (unanswered stops + any known branch problem) as JSON instead of travelling it') // COPY-FLAG: the option descriptions of `deploy --discover` are minimal and neutral. - .option('--discover', 'dokploy: find the services that match Capy projects and print JSON (never prompts)') - .option('--plan ', 'dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`)') - .option('--confirm ', 'dokploy --discover: write the plan that --dry-run printed (one PR per repo)') + .option('--discover', 'dokploy/vercel: find the services that match Capy projects and print JSON (never prompts)') + .option('--plan ', 'dokploy/vercel --discover: the plan file to check (see schemas.deploy_dokploy_plan and schemas.deploy_vercel_plan in `capy help --json`)') + .option('--confirm ', 'dokploy/vercel --discover: write the plan that --dry-run printed (one PR per repo)') .option('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') .addOption(resolveOption()) .addOption(conflictOption()) @@ -412,6 +414,7 @@ const deploy = program force: options.force, // commander negates `--no-deploy` onto the positive `deploy` property. noDeploy: options.deploy === false, + replaceSensitive: !!options.replaceSensitive, json: !!options.json, conflictFlags: rawConflictFlagsOf(cmd), }); @@ -1055,7 +1058,9 @@ program .option('--base-url ', 'dokploy import: dashboard URL') .option('--application ', 'dokploy import: Application id (mutually exclusive with --compose)') .option('--compose ', 'dokploy import: Compose service id (mutually exclusive with --application)') - .option('--token-env ', 'dokploy import: env var holding the API token') + .option('--token-env ', 'dokploy/vercel import: env var holding the API token') + .option('--project ', 'vercel import: Vercel project id or name (under --discover: the project that wins a mapping collision)') + .option('--team ', 'vercel import/discover: Vercel team slug or id (default: the token\'s only team)') .option('--json', 'emit machine-readable JSON instead of the human UI (import connectors)') .option( '--dry-run', @@ -1063,7 +1068,7 @@ program ) .option( '--discover', - 'dokploy: find every Dokploy service matching a repo under cwd, instead of one named --application/--compose', + 'dokploy/vercel: find every Dokploy service / Vercel project matching a repo under cwd, instead of one named --application/--compose/--project', ) .option( '-y, --yes', @@ -1076,7 +1081,7 @@ program ) .option( '--environment ', - 'dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview)', + 'dokploy/vercel discover: restrict the plan to these environment names, comma-separated (e.g. staging,preview); vercel import: which Vercel environment to read (default: the current branch\'s name)', ) .addOption(resolveOption()) .addOption(conflictOption()) @@ -1109,6 +1114,8 @@ program yes: options.yes === true, overwrite: options.overwrite === true, environment: options.environment, + project: options.project, + team: options.team, conflictFlags: rawConflictFlagsOf(command), }); }); diff --git a/src/sync/commit.ts b/src/sync/commit.ts index 29c09bc4..20c0f50d 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -27,6 +27,7 @@ import type { ProjectManager } from '../core/projectManager'; import type { ServiceClient } from '../service/serviceClient'; import { CapyError, ERROR_CODES, KeepFile, setSyncKeepHash } from '../types/index'; import { hashValue } from './compareSecrets'; +import { placeholderOf } from '../core/placeholder'; import { SyncEngine } from './syncEngine'; import type { RateInfo } from '../utils/pool'; @@ -61,7 +62,8 @@ export function pinnedHashes(keep: KeepFile | null | undefined, branch: string): return Object.fromEntries( Object.entries(keep?.variables ?? {}).flatMap(([name, entries]) => { const entry = entries.find((e) => e.branch === branch); - return entry ? ([[name, entry.value_hash]] as const) : []; + // A placeholder pins no value: it is neither compared, restored nor dropped with the pinned names. + return entry && entry.placeholder === undefined ? ([[name, entry.value_hash]] as const) : []; }), ); } @@ -238,10 +240,26 @@ export function keepWithChanges( return removed.length === 0 ? merged : dropBranchEntries(merged, branch, removed); } +/** + * What a branch with no variables is stored as. The blob is one `NAME=capy::` line per variable, so a + * branch with none would be an empty string, and the service refuses an empty `env_blob` (it answers 400 to `!env_blob`). + * A branch can have none and still need a save: an import whose names are all placeholders, a `--overwrite` that clears + * the last name. A comment line is a non-empty blob that every reader (`dotenv` parse) reads as no variables; there is + * no per-variable ciphertext to put in it, because there is no variable. + */ +export const EMPTY_ENV_BLOB = '# capy: no variables'; + export const envBlobOf = (lines: Readonly>): string => - Object.entries(lines) - .map(([name, line]) => `${name}=${line}`) - .join('\n'); + Object.keys(lines).length === 0 + ? EMPTY_ENV_BLOB + : Object.entries(lines) + .map(([name, line]) => `${name}=${line}`) + .join('\n'); + +/** The names `changes` removes that are placeholders on `branch` (and are not being given a value in the same change). */ +function removedPlaceholders(keep: KeepFile, branch: string, changes: Changes): readonly string[] { + return changes.remove.filter((name) => !has(changes.set, name) && placeholderOf(keep, name, branch) !== undefined); +} /** `keep` with this branch's slice rebuilt from `env`: every name set, every other pinned name dropped. */ export function keepForEnv(keep: KeepFile, branch: string, env: Readonly>): KeepFile { @@ -325,9 +343,11 @@ export async function uploadChanges(input: UploadInput): Promise { changes, encryptLine: (name, value) => `capy:${resourceIdOf(name)}:${Encryptor.encrypt(value, projectKey)}`, }); - const changed = keepWithChanges(latestKeep, branch, applied.written, applied.removed, resourceIdOf); + // A name that is only a placeholder has no blob line to remove, but removing it still drops its entry. + const removed = [...applied.removed, ...removedPlaceholders(latestKeep, branch, changes)]; + const changed = keepWithChanges(latestKeep, branch, applied.written, removed, resourceIdOf); const keep = transformKeep ? transformKeep(changed) : changed; - const base = { snapshot, keep, lines: applied.lines, env: applied.env, written: Object.keys(applied.written), removed: applied.removed }; + const base = { snapshot, keep, lines: applied.lines, env: applied.env, written: Object.keys(applied.written), removed }; const readRate = 'rate' in read && read.rate !== undefined ? { read: read.rate } : {}; if (keep === latestKeep) return { ...base, result: undefined, rates: readRate }; @@ -491,7 +511,7 @@ function commitLocalCopy(ctx: FolderContext, changes: Changes, transformKeep: Fo const lines = Object.fromEntries( Object.entries(env).map(([name, value]) => [name, `capy:${deriveResourceId(ctx.branch, name)}:${Encryptor.encrypt(value, ctx.projectKey)}`]), ); - const rebuilt = keepForEnv(ctx.keep, ctx.branch, env); + const rebuilt = keepWithChanges(keepForEnv(ctx.keep, ctx.branch, env), ctx.branch, {}, removedPlaceholders(ctx.keep, ctx.branch, changes), (name) => deriveResourceId(ctx.branch, name)); const keep = transformKeep ? transformKeep(rebuilt) : rebuilt; return { env, @@ -537,7 +557,7 @@ export async function commitFolder(ctx: FolderContext, changes: Changes, opts: F writeKeepCache(ctx.orgId, ctx.projectId, done.cacheHash, envBlobOf(done.lines)); ctx.fileManager.writeKeepFile(done.adopted); - ctx.fileManager.writeEncryptedEnvFile({ ...done.env }, ctx.projectKey, undefined, done.adopted, ctx.branch); + ctx.fileManager.writeEncryptedEnvFile({ ...done.env }, ctx.projectKey, undefined, done.adopted, ctx.branch, done.removed); const existingSyncState = ctx.pm.readSyncState(); ctx.fileManager.writeSyncState({ diff --git a/src/sync/syncEngine.ts b/src/sync/syncEngine.ts index 70979d8b..70655e15 100644 --- a/src/sync/syncEngine.ts +++ b/src/sync/syncEngine.ts @@ -9,6 +9,7 @@ import { KeepVariableEntry, SyncState, } from '../types/index'; +import { withoutPlaceholder } from '../core/placeholder'; export class SyncEngine { /** @@ -144,8 +145,11 @@ export class SyncEngine { // Preserve any extra fields on the existing entry (e.g. `connector` // metadata set by `capy connect`). Without the spread, every sync // would clobber them. + // A value is being written, so the name is no longer a placeholder (core/placeholder.ts). + const found: Partial = idx >= 0 ? entries[idx] : {}; + const existing = data.value_hash && idx >= 0 ? withoutPlaceholder(entries[idx]) : found; const entry: KeepVariableEntry = { - ...(idx >= 0 ? entries[idx] : {}), + ...existing, resource_id: data.resource_id, value_hash: data.value_hash || '', branch: targetBranch, diff --git a/src/system/systemStore.ts b/src/system/systemStore.ts index 1a4b1440..d49eec46 100644 --- a/src/system/systemStore.ts +++ b/src/system/systemStore.ts @@ -47,6 +47,7 @@ import { setSyncKeepHash, } from '../types/index'; import { SYSTEM_PROJECT_NAME } from './reservedProjectName'; +import { formatKeyHelp, keyHelpForName } from '../deploy/keyHelp'; export { SYSTEM_PROJECT_NAME } from './reservedProjectName'; @@ -403,6 +404,56 @@ export interface GetConnectorSecretOptions { devMode?: boolean; /** No terminal → never prompts; a missing entry resolves to `null`. */ interactive: boolean; + /** Where the caller's service lives (Dokploy's dashboard URL), so the key help can link to the page that makes the key. */ + helpBaseUrl?: string; + /** The slug of the Vercel team that owns the project, when known, so the token instructions can name it. */ + helpTeam?: string; +} + +/** Before a hidden key prompt: where to get that key, for the four known key names ("paste it here" — the prompt is right below). Stderr, so it never mixes with machine output. */ +function printKeyHelpBeforePrompt(name: string, helpBaseUrl: string | undefined, helpTeam: string | undefined): void { + const help = keyHelpForName(name, { baseUrl: helpBaseUrl, team: helpTeam, mode: 'paste' }); + if (help) console.error(formatKeyHelp(help)); +} + +/** + * The hidden prompt for one secret, then the save, then one line saying it was + * saved (the name only, never the value). The line goes to stderr with the key + * help, so it never mixes with machine output. An empty answer saves nothing. + */ +async function promptAndSaveSecret( + ctx: SystemStoreContext, + name: string, + help: { baseUrl: string | undefined; team: string | undefined }, +): Promise { + printKeyHelpBeforePrompt(name, help.baseUrl, help.team); + + const { value } = await inquirer.prompt([ + { + type: 'password', + name: 'value', + message: `Enter a value for ${name}:`, // COPY-FLAG + mask: '*', + }, + ]); + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (!trimmed) return null; + + await commitSystemStore(ctx, setting(name, trimmed)); + process.stderr.write(`Saved ${name} in the org system store.\n`); // COPY-FLAG + return trimmed; +} + +/** + * Asks for a NEW value of a connector's secret (hidden input) and saves it + * over the stored one, through the same save path as the first-use prompt. + * Opening the store requires org owner/admin: anyone else gets the store's own + * coded refusal. Returns `null` when nothing was entered (nothing is saved). + */ +export async function replaceConnectorSecret(name: string, opts: GetConnectorSecretOptions): Promise { + assertValidConnectorName(name); + const ctx = await openSystemStoreContext(opts); + return promptAndSaveSecret(ctx, name, { baseUrl: opts.helpBaseUrl, team: opts.helpTeam }); } /** @@ -422,19 +473,7 @@ export async function getConnectorSecret(name: string, opts: GetConnectorSecretO if (existing !== null) return existing; if (!opts.interactive) return null; - const { value } = await inquirer.prompt([ - { - type: 'password', - name: 'value', - message: `Enter a value for ${name}:`, // COPY-FLAG - mask: '*', - }, - ]); - const trimmed = typeof value === 'string' ? value.trim() : ''; - if (!trimmed) return null; - - await commitSystemStore(ctx, setting(name, trimmed)); - return trimmed; + return promptAndSaveSecret(ctx, name, { baseUrl: opts.helpBaseUrl, team: opts.helpTeam }); } export interface GetDirectionalSecretOptions { @@ -443,6 +482,10 @@ export interface GetDirectionalSecretOptions { devMode?: boolean; /** No terminal → never prompts; refuses instead when `fallbackName` has a value to offer (see below). */ interactive: boolean; + /** Where the caller's service lives (Dokploy's dashboard URL), so the key help can link to the page that makes the key. */ + helpBaseUrl?: string; + /** The slug of the Vercel team that owns the project, when known, so the token instructions can name it. */ + helpTeam?: string; /** * Code to refuse with when `primaryName` is missing, `fallbackName` DOES * have a value, and there is no terminal to ask which one the caller @@ -538,17 +581,5 @@ export async function getDirectionalConnectorSecret( // else "add new" — fall through to the same prompt case 2 uses. } - const { value } = await inquirer.prompt([ - { - type: 'password', - name: 'value', - message: `Enter a value for ${primaryName}:`, // COPY-FLAG - mask: '*', - }, - ]); - const trimmed = typeof value === 'string' ? value.trim() : ''; - if (!trimmed) return null; - - await commitSystemStore(ctx, setting(primaryName, trimmed)); - return trimmed; + return promptAndSaveSecret(ctx, primaryName, { baseUrl: opts.helpBaseUrl, team: opts.helpTeam }); } diff --git a/src/types/index.ts b/src/types/index.ts index 56d195dc..4874a847 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -93,6 +93,16 @@ export interface ConnectorMetadata { * availability rule as `dokploy_project`. */ environment?: string; + /** Vercel import only (CAP-696): the Vercel project's id. */ + vercel_project_id?: string; + /** Vercel import only (CAP-696): the Vercel project's name. */ + vercel_project?: string; + /** Vercel import only (CAP-696): the Vercel team id; absent for a personal-account project. */ + team_id?: string; + /** Vercel import only (CAP-696): the id of the Vercel env record the value came from (`environment`, above, names the Vercel environment). */ + env_id?: string; + /** Vercel import only (CAP-696): the integration configuration id on the env record, when Vercel set one. */ + configuration_id?: string; } /** @@ -147,6 +157,16 @@ export interface TargetDelivery { superseded_deploy_ids?: readonly string[]; } +/** + * Why a variable has no value yet. `provider` is the import connector that could not + * read it; `reason` is that connector's own stable skip code (e.g. `sensitive_unreadable`). + * Both are codes, never sentences. + */ +export interface PlaceholderMarker { + provider: string; + reason: string; +} + /** v3 keep.lock variable entry — per-branch value hashes */ export interface KeepVariableEntry { resource_id: string; @@ -162,6 +182,13 @@ export interface KeepVariableEntry { changed_at?: string; /** Set when this variable was provisioned by `capy connect `. */ connector?: ConnectorMetadata; + /** + * Set when the name exists on this branch but has NO value yet: the source it was + * imported from would not give the value back. `value_hash` is '' and the blob holds + * no line for it. A real value written for this branch removes the field. + * See `core/placeholder.ts`. + */ + placeholder?: PlaceholderMarker; /** * Every `capy deploy` target this variable has been delivered to (CAP-679). * Optional and additive — absent on every entry written before this field @@ -724,6 +751,104 @@ export const ERROR_CODES = { * to clean it up manually. */ DEPLOY_TOKEN_UNTRACKED: 'DEPLOY_TOKEN_UNTRACKED', + // CAP-696 Vercel import + /** No Vercel token anywhere and no way to ask — import (`_CONNECTOR_VERCEL_TOKEN`, `--token-env`, `$VERCEL_TOKEN`) or API-mode deploy (`_TARGET_VERCEL_TOKEN`, `$VERCEL_TOKEN`). */ + VERCEL_TOKEN_MISSING: 'VERCEL_TOKEN_MISSING', + /** `_CONNECTOR_VERCEL_TOKEN` is absent, `_TARGET_VERCEL_TOKEN` holds a token, and there is no TTY to ask which to use. */ + VERCEL_CONNECTOR_KEY_MISSING: 'VERCEL_CONNECTOR_KEY_MISSING', + /** Vercel answered 401 to the token: it is invalid or expired. */ + VERCEL_AUTH_FAILED: 'VERCEL_AUTH_FAILED', + /** Any other failed or unreadable Vercel response. */ + VERCEL_API_ERROR: 'VERCEL_API_ERROR', + /** Vercel kept answering 429 after the bounded retries, or asked for a wait longer than the cap. */ + VERCEL_RATE_LIMITED: 'VERCEL_RATE_LIMITED', + /** The token sees several teams and no `--team` was given, and this run cannot ask. */ + VERCEL_TEAM_REQUIRED: 'VERCEL_TEAM_REQUIRED', + /** `--team` matched none of the teams the token sees. */ + VERCEL_TEAM_NOT_FOUND: 'VERCEL_TEAM_NOT_FOUND', + /** Single import with no `--project`, and this run cannot ask. */ + VERCEL_PROJECT_REQUIRED: 'VERCEL_PROJECT_REQUIRED', + /** `--project` names no project Vercel can see. */ + VERCEL_PROJECT_NOT_FOUND: 'VERCEL_PROJECT_NOT_FOUND', + /** Single import: the current Capy branch is not a Vercel environment of the project, and no `--environment` was given. */ + VERCEL_ENVIRONMENT_REQUIRED: 'VERCEL_ENVIRONMENT_REQUIRED', + /** `--environment` names no environment of the project (single import), or none the plan has (discover). */ + VERCEL_ENVIRONMENT_NOT_FOUND: 'VERCEL_ENVIRONMENT_NOT_FOUND', + /** A real write needs a yes (`--yes`) and this run cannot ask. */ + VERCEL_CONFIRMATION_REQUIRED: 'VERCEL_CONFIRMATION_REQUIRED', + /** Several Vercel projects map to the same repo, folder and branch, and this run cannot ask which wins. */ + VERCEL_MAPPING_COLLISION: 'VERCEL_MAPPING_COLLISION', + // CAP-696 Vercel target (codes not already above) + /** `_TARGET_VERCEL_TOKEN` is absent but `_CONNECTOR_VERCEL_TOKEN` holds one, and there is no TTY to choose. */ + VERCEL_TARGET_KEY_MISSING: 'VERCEL_TARGET_KEY_MISSING', + /** Vercel answered 401: the token is not valid. */ + VERCEL_TOKEN_REJECTED: 'VERCEL_TOKEN_REJECTED', + /** Vercel answered 403: the token is valid but cannot access what was asked for (the team list, a project, or a project's environment variables). */ + VERCEL_NO_ACCESS: 'VERCEL_NO_ACCESS', + /** The request never got an answer from Vercel. */ + VERCEL_NETWORK_ERROR: 'VERCEL_NETWORK_ERROR', + /** After writing, the value Vercel holds hashes differently from the one sent. */ + VERCEL_VERIFY_MISMATCH: 'VERCEL_VERIFY_MISMATCH', + /** The API-mode target's options are incomplete or malformed. */ + VERCEL_OPTIONS_INVALID: 'VERCEL_OPTIONS_INVALID', + /** A shared record had this environment removed, but creating its replacement failed and the removal could not be undone. */ + VERCEL_SPLIT_NOT_RESTORED: 'VERCEL_SPLIT_NOT_RESTORED', + /** `capy deploy`'s setup was given something that is neither a Vercel dashboard link for a project nor a project name. */ + VERCEL_URL_INVALID: 'VERCEL_URL_INVALID', + /** + * A deploy would replace a value Vercel holds as sensitive (Capy cannot read it, so cannot tell whether it differs) for the first time + * for this target, and it was not confirmed: no terminal to ask on, and no `--replace-sensitive`; or the answer was no. Nothing was written. + */ + VERCEL_SENSITIVE_REPLACE_UNCONFIRMED: 'VERCEL_SENSITIVE_REPLACE_UNCONFIRMED', + // `capy deploy vercel --discover` + /** A Vercel project has no GitHub link, so no repo can be matched; it is skipped (a notice). */ + VERCEL_PROJECT_NOT_GITHUB: 'VERCEL_PROJECT_NOT_GITHUB', + /** Vercel said a project's env list has another page that this client cannot ask for; the names read may be incomplete (a notice). */ + VERCEL_ENV_LIST_TRUNCATED: 'VERCEL_ENV_LIST_TRUNCATED', + /** Vercel's recent preview deployments of a project could not be read (the call failed, or the token cannot see them); that project's preview branch is judged without them (a notice). Discovery goes on. */ + VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE: 'VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE', + // --- Import connectors --- + /** `capy connect vercel`: Vercel will not return this name's value, and Capy already has one. The Capy value was left untouched (reported, not an error). */ + CAPY_VALUE_KEPT: 'CAPY_VALUE_KEPT', + // --- Connector discovery, any provider (`capy connect --discover`) --- + // One per step a folder's run can stop on. Every provider but Dokploy reports these; + // Dokploy keeps its own `DOKPLOY_*` spellings of the same steps. + /** The folder's upload failed for a reason other than a stale base. That folder's run stopped; the other folders still ran. */ + CONNECTOR_PUSH_FAILED: 'CONNECTOR_PUSH_FAILED', + /** Non-interactive run without `--yes`: the folder has no keep.lock, and a person has to initialize it. */ + CONNECTOR_FOLDER_NOT_INITIALIZED: 'CONNECTOR_FOLDER_NOT_INITIALIZED', + /** The org's project list could not be read, so a new project is not offered blind. */ + CONNECTOR_PROJECT_LOOKUP_FAILED: 'CONNECTOR_PROJECT_LOOKUP_FAILED', + /** Non-interactive `--yes`: a project with the default name already exists (in the org, or created earlier in the same run). */ + CONNECTOR_PROJECT_NAME_EXISTS: 'CONNECTOR_PROJECT_NAME_EXISTS', + /** The picked project is not in this org. */ + CONNECTOR_PROJECT_NOT_FOUND: 'CONNECTOR_PROJECT_NOT_FOUND', + /** Creating or bootstrapping the folder's project failed. */ + CONNECTOR_INIT_FAILED: 'CONNECTOR_INIT_FAILED', + /** The new project's name is over the length limit. Nothing was created. */ + CONNECTOR_PROJECT_NAME_TOO_LONG: 'CONNECTOR_PROJECT_NAME_TOO_LONG', + /** The project key could not be resolved for the folder. */ + CONNECTOR_KEY_RESOLUTION_FAILED: 'CONNECTOR_KEY_RESOLUTION_FAILED', + /** The folder has an uncommitted or unpushed change; nothing was written into it. */ + CONNECTOR_FOLDER_DIRTY: 'CONNECTOR_FOLDER_DIRTY', + /** The branch is protected (invite-only). */ + CONNECTOR_BRANCH_PROTECTED: 'CONNECTOR_BRANCH_PROTECTED', + /** The folder has no keep.lock, or is not on a branch after checkout. */ + CONNECTOR_KEEP_MISSING: 'CONNECTOR_KEEP_MISSING', + /** Switching the folder onto the branch failed. */ + CONNECTOR_CHECKOUT_FAILED: 'CONNECTOR_CHECKOUT_FAILED', + /** git refused to read a repo's remote (for example "dubious ownership"); the repo is reported, not silently treated as unmatched. */ + CONNECTOR_REMOTE_UNREADABLE: 'CONNECTOR_REMOTE_UNREADABLE', + /** The discovery commit was refused: a path it would commit already had uncommitted changes before the run. */ + CONNECTOR_COMMIT_WOULD_MIX: 'CONNECTOR_COMMIT_WOULD_MIX', + /** The discovery commit was refused: the branch name is not a valid git branch name. */ + CONNECTOR_COMMIT_BRANCH_INVALID: 'CONNECTOR_COMMIT_BRANCH_INVALID', + /** The discovery commit was refused: the branch name is already taken. */ + CONNECTOR_COMMIT_BRANCH_EXISTS: 'CONNECTOR_COMMIT_BRANCH_EXISTS', + /** The discovery commit was refused: the repo has no git identity (user.name / user.email). */ + CONNECTOR_GIT_IDENTITY_MISSING: 'CONNECTOR_GIT_IDENTITY_MISSING', + /** The discovery commit failed in git and was rolled back. */ + CONNECTOR_COMMIT_FAILED: 'CONNECTOR_COMMIT_FAILED', // --- Piped values: ` | capy edit NAME`, ` | capy add NAME` --- /** * `capy edit` with no name and no terminal. There is nothing to draw the diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index d15c6640..18b6898a 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -25,6 +25,8 @@ import { CANCELLED_NOTHING, DRY_RUN_LABEL } from '../commands/secretsSetText'; import type { RunProgress } from '../commands/secretsSet'; import { clipLine } from './pickerTable'; import { BEHIND_LABEL, DEPLOYED_LABEL, NO_TARGET_LABEL, TARGET_STATUS_HEADING, formatSecretRowStatus, locationState, secretRowStatus } from '../core/deployStatus'; +import type { SecretRowStatus } from '../core/deployStatus'; +import { NO_VALUE_YET, noValueYetLine } from '../core/placeholder'; import { statusBadge, statusColor } from './statusBadge'; import { EditEffect, @@ -115,6 +117,8 @@ const KEY_CTRL_C = '\x03'; const KEY_CTRL_E = '\x05'; /** Ctrl+D: deploy the selected row's Dokploy targets, from the list while no filter is typed. The plain `d` is only a hotkey in the details view. */ const KEY_CTRL_D = '\x04'; +/** Ctrl+S: cycle the list's sort. Ignored while the details view (or an edit/deploy screen) is open. */ +const KEY_CTRL_S = '\x13'; const KEY_BACKSPACE = '\x7f'; const KEY_BACKSPACE2 = '\b'; @@ -223,6 +227,94 @@ function nextColumn(column: ColumnMode, dir: 1 | -1): ColumnMode { return COLUMN_ORDER[nextIdx]; } +// ── Sorting (CAP-731) ──────────────────────────────────────────────────────── +// +// Ctrl+S opens a sort menu listing SORT_ORDER; Enter applies the chosen sort and +// goes back to the top of the list. Search still decides +// WHICH rows show; the sort only orders them. Every comparison keys off a +// machine value (the name string, the status `kind`, a timestamp), never the +// words the screen prints. Ties always fall back to a deterministic key, so the +// order never depends on how the server (or a previous sort) happened to arrive. + +export type SortMode = 'name-asc' | 'name-desc' | 'status-asc' | 'status-desc' | 'updated-newest' | 'updated-oldest'; +/** The order the sort menu lists; the first entry is the default. */ +export const SORT_ORDER: readonly SortMode[] = ['name-asc', 'name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest']; + +/** What the footer and the sort menu say each sort is. */ +const SORT_LABELS: Readonly> = { + 'name-asc': 'name A-Z', // COPY-FLAG + 'name-desc': 'name Z-A', // COPY-FLAG + 'status-asc': 'deploy status A-Z', // COPY-FLAG + 'status-desc': 'deploy status Z-A', // COPY-FLAG + 'updated-newest': 'updated newest', // COPY-FLAG + 'updated-oldest': 'updated oldest', // COPY-FLAG +}; + +/** + * Deploy status order for the A-Z sort, keyed by `secretRowStatus(row).kind` + * (a code, not the displayed word). Z-A is this reversed. Typed as a full + * Record so a new kind will not compile until it has a rank. + */ +const STATUS_RANK: Readonly> = { + deployed: 0, + behind: 1, + unknown: 2, + 'no target': 3, +}; + +/** Case-insensitive, accent-insensitive, locale-aware ('en'): the same family of order the service uses for its own row order (`localeCompare`), but with a fixed locale so it does not change per machine. */ +const NAME_COLLATOR = new Intl.Collator('en', { sensitivity: 'base' }); + +const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** Name A-Z; a tie (same name ignoring case) breaks by value hash so the order is deterministic. */ +function compareByName(a: SecretIndexRow, b: SecretIndexRow): number { + return NAME_COLLATOR.compare(a.name, b.name) || compareText(a.name, b.name) || compareText(a.value_hash, b.value_hash); +} + +/** Epoch milliseconds of the row's most recent change, or `null` when it has none (or one that is not a date). */ +function changedAtMs(row: SecretIndexRow): number | null { + const ts = mostRecentChangedAt(row); + const ms = ts === undefined ? Number.NaN : new Date(ts).getTime(); + return Number.isNaN(ms) ? null : ms; +} + +/** Updated sort: rows with no time go last in BOTH directions; equal times fall back to name A-Z. */ +function compareByUpdated(direction: 1 | -1): (a: SecretIndexRow, b: SecretIndexRow) => number { + return (a, b) => { + const am = changedAtMs(a); + const bm = changedAtMs(b); + if (am === null && bm === null) return compareByName(a, b); + if (am === null) return 1; + if (bm === null) return -1; + return (direction === 1 ? bm - am : am - bm) || compareByName(a, b); + }; +} + +/** Deploy status sort: by rank (reversed for Z-A); a tie breaks by name A-Z in both directions. */ +function compareByStatus(direction: 1 | -1): (a: SecretIndexRow, b: SecretIndexRow) => number { + return (a, b) => direction * (STATUS_RANK[secretRowStatus(a).kind] - STATUS_RANK[secretRowStatus(b).kind]) || compareByName(a, b); +} + +function comparatorFor(sort: SortMode): (a: SecretIndexRow, b: SecretIndexRow) => number { + if (sort === 'name-desc') return (a, b) => compareByName(b, a); + if (sort === 'status-asc') return compareByStatus(1); + if (sort === 'status-desc') return compareByStatus(-1); + if (sort === 'updated-newest') return compareByUpdated(1); + if (sort === 'updated-oldest') return compareByUpdated(-1); + return compareByName; +} + +/** A sorted copy of `items` (`toSorted` is ES2023 and this package supports Node 18): the input is never changed. */ +function sortedCopy(items: readonly T[], cmp: (a: T, b: T) => number): readonly T[] { + return Array.from(items).sort(cmp); +} + +/** `rows` in `sort` order, as a new array. */ +export function sortRows(rows: readonly SecretIndexRow[], sort: SortMode): readonly SecretIndexRow[] { + return sortedCopy(rows, comparatorFor(sort)); +} + // ── State ──────────────────────────────────────────────────────────────────── export type { ValueState }; @@ -252,9 +344,17 @@ export interface SearchState { readonly query: string; } +export interface SortMenuState { + readonly cursor: number; +} + export interface SecretsScreenState { readonly rows: readonly SecretIndexRow[]; readonly column: ColumnMode; + /** How the list is ordered (chosen in the Ctrl+S menu); search decides which rows show, this decides their order. */ + readonly sort: SortMode; + /** The Ctrl+S sort menu, open while a sort is being chosen: `cursor` indexes `SORT_ORDER`. `null` when closed. */ + readonly sortMenu: SortMenuState | null; readonly cursorIndex: number; readonly search: SearchState; readonly popup: PopupState | null; @@ -275,6 +375,8 @@ export function initialSecretsScreenState(rows: readonly SecretIndexRow[], dryRu return { rows, column: COLUMN_ORDER[0], + sort: SORT_ORDER[0], + sortMenu: null, cursorIndex: 0, search: { query: '' }, popup: null, @@ -364,7 +466,13 @@ function rowMatchReasons(row: SecretIndexRow, qLower: string, rawHash: string, t * reasons" before any hashing happens (mirrors the old NAME-only filter's * behavior when the bar is empty). */ -function computeFilteredRows(rows: readonly SecretIndexRow[], query: string): readonly MatchedRow[] { +function computeFilteredRows(rows: readonly SecretIndexRow[], query: string, sort: SortMode): readonly MatchedRow[] { + const order = comparatorFor(sort); + return sortedCopy(matchRows(rows, query), (a, b) => order(a.row, b.row)); +} + +/** The rows that match `query`, each with why, in the order they arrived (`computeFilteredRows` applies the sort). */ +function matchRows(rows: readonly SecretIndexRow[], query: string): readonly MatchedRow[] { const trimmed = query.trim(); const qLower = normalizeQuery(query); if (!qLower) return rows.map((row) => ({ row, reasons: [] as const })); @@ -380,12 +488,12 @@ function computeFilteredRows(rows: readonly SecretIndexRow[], query: string): re /** Rows currently visible, plus why each one matched — see the module note above `computeFilteredRows`. */ export function filteredRowsWithReasons(state: SecretsScreenState): readonly MatchedRow[] { - return computeFilteredRows(state.rows, state.search.query); + return computeFilteredRows(state.rows, state.search.query, state.sort); } /** Rows currently visible, after the live filter (name, project, branch, connector, target, or exact value — see `computeFilteredRows`). */ export function filteredRows(state: SecretsScreenState): readonly SecretIndexRow[] { - return computeFilteredRows(state.rows, state.search.query).map((m) => m.row); + return computeFilteredRows(state.rows, state.search.query, state.sort).map((m) => m.row); } function clampIndex(index: number, length: number): number { @@ -417,6 +525,9 @@ export function handleKey(state: SecretsScreenState, rawKey: string): ReduceResu if (state.edit) return handleEditFlowKey(state, state.edit, key); if (state.deploy) return handleDeployFlowKey(state, state.deploy, key); + // The sort menu owns the keyboard: nothing reaches the search, edit, deploy or column keys. + if (state.sortMenu) return noEffect(handleSortMenuKey(state, state.sortMenu, key)); + if (state.popup) { if (key === 'd' || key === 'D' || key === KEY_CTRL_D) return openDeployOnCursor(state); return key === 'e' || key === 'E' || key === KEY_CTRL_E ? openEditOnCursor(state) : noEffect(handlePopupKey(state, key)); @@ -519,16 +630,39 @@ export function applyRunDone(state: SecretsScreenState, finished: RunFinished): return { ...state, edit: applyRunFinished(state.edit, finished) }; } -function filteredRowsFor(rows: readonly SecretIndexRow[], query: string): readonly SecretIndexRow[] { - return computeFilteredRows(rows, query).map((m) => m.row); +function filteredRowsFor(rows: readonly SecretIndexRow[], query: string, sort: SortMode): readonly SecretIndexRow[] { + return computeFilteredRows(rows, query, sort).map((m) => m.row); } /** Sets the search query and reclamps the cursor into the (possibly now-shorter) filtered set — never resets it to the top just because the query changed. */ function applyQuery(state: SecretsScreenState, query: string): SecretsScreenState { - const rows = filteredRowsFor(state.rows, query); + const rows = filteredRowsFor(state.rows, query, state.sort); return { ...state, search: { query }, cursorIndex: clampIndex(state.cursorIndex, rows.length) }; } +/** + * Switches to `sort` and goes back to the top: the first row of the new order is + * highlighted, so the list shows from its start (Vince, 2026-11-06). The scroll + * window follows the cursor, so a cursor of 0 is the top of the list. + */ +function applySort(state: SecretsScreenState, sort: SortMode): SecretsScreenState { + return { ...state, sort, cursorIndex: 0 }; +} + +/** Moves the sort menu's cursor by `step`, wrapping at both ends. */ +function stepSortMenu(menu: SortMenuState, step: 1 | -1): SortMenuState { + return { cursor: (menu.cursor + step + SORT_ORDER.length) % SORT_ORDER.length }; +} + +/** The sort menu's keys: ↑/↓ move (wrapping), Enter applies and closes, Esc or `q` close unchanged. Every other key does nothing. */ +function handleSortMenuKey(state: SecretsScreenState, menu: SortMenuState, key: string): SecretsScreenState { + if (key === KEY_UP) return { ...state, sortMenu: stepSortMenu(menu, -1) }; + if (key === KEY_DOWN) return { ...state, sortMenu: stepSortMenu(menu, 1) }; + if (key === '\r') return applySort({ ...state, sortMenu: null }, SORT_ORDER[menu.cursor]); + if (key === KEY_ESC || key === KEY_ESC_ESC || key === 'q' || key === 'Q') return { ...state, sortMenu: null }; + return state; +} + function handlePopupKey(state: SecretsScreenState, key: string): SecretsScreenState { const popup = state.popup; if (!popup) return state; @@ -584,6 +718,9 @@ function handleListKey(state: SecretsScreenState, key: string): ReduceResult { if (key === KEY_CTRL_E && state.search.query === '') return openEditOnCursor(state); if (key === KEY_CTRL_D && state.search.query === '') return openDeployOnCursor(state); + // Works with or without a typed filter: the sort only orders the rows the search shows. + if (key === KEY_CTRL_S) return noEffect({ ...state, sortMenu: { cursor: Math.max(0, SORT_ORDER.indexOf(state.sort)) } }); + if (key === KEY_TAB) { return noEffect({ ...state, column: nextColumn(state.column, 1) }); } @@ -973,6 +1110,7 @@ export function mainTableLayout(available: number): { readonly nameW: number; re export function render(state: SecretsScreenState, termWidth: number, termHeight: number): string { if (state.edit) return renderEditScreen(state.edit, termWidth, termHeight, state.dryRun); if (state.deploy) return renderDeployScreen(state.deploy, termWidth, termHeight, state.dryRun); + if (state.sortMenu) return renderSortMenuScreen(state, state.sortMenu, termWidth, termHeight); const m = ' '.repeat(MARGIN); const available = Math.max(40, termWidth - MARGIN * 2); const matched = filteredRowsWithReasons(state); @@ -1002,7 +1140,9 @@ export function render(state: SecretsScreenState, termWidth: number, termHeight: // empty query carries no reasons anyway, but this also guards // `matchActive` for callers that ever hand in a non-empty `reasons` // alongside a cleared query). - const tag = matchActive && reasons.length > 0 ? `${ACCENT}[${reasons[0]}]${RESET}` : ''; + const matchTag = matchActive && reasons.length > 0 ? `${ACCENT}[${reasons[0]}]${RESET}` : ''; + // A row with no value yet says so beside its name (a search-match tag takes the place when one is active). + const tag = matchTag !== '' || row.value_hash !== '' ? matchTag : `${DIM}${NO_VALUE_YET}${RESET}`; const nameCell = buildNameCell(pointer + row.name, tag, nameW); const middleCell = pad(formatMiddleCell(row, state.column, middleW), middleW); const updatedCell = pad(formatUpdatedCell(row), updatedW); @@ -1025,7 +1165,7 @@ export function render(state: SecretsScreenState, termWidth: number, termHeight: rows.length === 0 ? [`${m}${DIM}No secrets match.${RESET}`] : slice.map((line) => m + line); const noteLines: readonly string[] = state.note === null ? [] : [`${m}${DIM}${state.note}${RESET}`]; - const footerLines: readonly string[] = ['', ...noteLines, m + footerLine(state)]; + const footerLines: readonly string[] = ['', ...noteLines, m + clipLine(footerLine(state, termWidth - MARGIN), Math.max(1, termWidth - MARGIN))]; const lines: readonly string[] = [...preBodyLines, ...bodyOutputLines, ...footerLines]; @@ -1067,6 +1207,50 @@ function renderDeployScreen(flow: DeployFlow, termWidth: number, termHeight: num return out.map((l) => clipLine(l, termWidth) + CLEAR_EOL).join('\n'); } +const SORT_MENU_TITLE = 'sort by'; // COPY-FLAG +const SORT_MENU_CURRENT = '●'; // COPY-FLAG + +/** The sort menu's key hints. */ +const SORT_MENU_HINTS: readonly FooterHint[] = [ + { id: 'navigate', key: null, label: '↑↓ choose' }, // COPY-FLAG + { id: 'inspect', key: 'enter', label: 'sort' }, // COPY-FLAG + { id: 'esc', key: 'esc', label: 'cancel' }, // COPY-FLAG +]; + +/** The `size` menu entries to show around `cursor`: all of them when they fit, else a window that keeps the cursor in view. */ +function sortMenuWindowStart(cursor: number, size: number): number { + return Math.min(Math.max(0, cursor - size + 1), Math.max(0, SORT_ORDER.length - size)); +} + +/** + * The sort menu's screen, laid out like the edit and deploy screens: the header, + * then the menu, then its key hints. Each entry is drawn like a list row (`▶` and + * inverse on the cursor), and the sort now in use carries a `●`. On a short + * terminal only the entries around the cursor are shown, so it never overflows. + */ +function renderSortMenuScreen(state: SecretsScreenState, menu: SortMenuState, termWidth: number, termHeight: number): string { + const m = ' '.repeat(MARGIN); + const width = Math.max(1, termWidth - MARGIN); + const entryRoom = Math.max(1, termHeight - 5); // header, blank, title, blank, footer + const size = Math.min(SORT_ORDER.length, entryRoom); + const start = sortMenuWindowStart(menu.cursor, size); + const entries: readonly string[] = SORT_ORDER.slice(start, start + size).map((mode, i) => { + const selected = start + i === menu.cursor; + const text = `${selected ? '▶ ' : ' '}${mode === state.sort ? SORT_MENU_CURRENT : ' '} ${SORT_LABELS[mode]}`; + return selected ? INVERSE + padVis(truncate(text, width), Math.min(width, 32)) + RESET : text; + }); + const out: readonly string[] = [ + `${m}${BOLD}capy secrets${RESET}${dryRunTag(state.dryRun)}`, + '', + `${m}${BOLD}${SORT_MENU_TITLE}${RESET}`, + ...entries.map((l) => m + l), + '', + m + joinHints(SORT_MENU_HINTS), + ]; + // No line is ever wider than the terminal, and the whole screen never taller than it. + return out.slice(0, Math.max(1, termHeight)).map((l) => clipLine(l, termWidth) + CLEAR_EOL).join('\n'); +} + function spliceIn(bodyLines: readonly string[], afterIndex: number, insert: readonly string[]): string[] { return [...bodyLines.slice(0, afterIndex + 1), ...insert, ...bodyLines.slice(afterIndex + 1)]; } @@ -1099,7 +1283,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): '', `${indent}${inner}${BOLD}${truncate(row.name, contentWidth)}${RESET}`, '', - field('value', renderValueLine(popup.value, popup.revealed, popup.panOffset, valueWidth)), + field('value', row.value_hash === '' ? noValueYetLine(row.name) : renderValueLine(popup.value, popup.revealed, popup.panOffset, valueWidth)), field('updated', formatUpdatedCell(row)), field(TARGET_STATUS_HEADING.toLowerCase(), secretRowStatusBadge(row)), '', @@ -1192,12 +1376,51 @@ function fitLocation(project: string, tails: readonly string[], width: number): return rest.length > 0 ? fitLocation(project, rest, width) : truncate(full, width); } -function footerLine(state: SecretsScreenState): string { +/** One key hint of the list footer. `key` is `null` for a hint that is all label. */ +interface FooterHint { + readonly id: 'navigate' | 'column' | 'inspect' | 'edit' | 'deploy' | 'esc' | 'sort'; + readonly key: string | null; + readonly label: string; +} + +/** + * When the footer is wider than the terminal, whole hints are dropped in this + * order (never cut mid-word) until it fits. `esc` and `sort` are never dropped. + */ +const FOOTER_DROP_ORDER: readonly FooterHint['id'][] = ['deploy', 'edit', 'navigate', 'inspect', 'column']; + +const renderHint = (h: FooterHint): string => (h.key === null ? `${DIM}${h.label}${RESET}` : `${BOLD}${h.key}${RESET}${DIM} ${h.label}${RESET}`); +const joinHints = (hints: readonly FooterHint[]): string => hints.map(renderHint).join(`${DIM} · ${RESET}`); + +/** `hints` with the droppable ones removed, in `FOOTER_DROP_ORDER`, just until the joined line fits `width`. */ +function fitHints(hints: readonly FooterHint[], width: number): readonly FooterHint[] { + return FOOTER_DROP_ORDER.reduce( + (kept, id) => (visLen(joinHints(kept)) <= width ? kept : kept.filter((h) => h.id !== id)), + hints, + ); +} + +function footerLine(state: SecretsScreenState, width: number): string { if (state.popup) { const revealLabel = state.popup.revealed ? 'hide' : 'reveal'; const panHint = state.popup.revealed ? `${DIM} · ${RESET}${BOLD}←/→${RESET}${DIM} pan${RESET}` : ''; return `${BOLD}r${RESET}${DIM} ${revealLabel}${RESET}${panHint}${DIM} · ${RESET}${BOLD}tab${RESET}${DIM} column · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${DIM} · ${RESET}${BOLD}d${RESET}${DIM} deploy${RESET}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM}/${RESET}${BOLD}q${RESET}${DIM} close${RESET}`; // COPY-FLAG } - const editHint = state.search.query === '' ? `${BOLD}ctrl+e${RESET}${DIM} edit · ${RESET}${BOLD}ctrl+d${RESET}${DIM} deploy · ${RESET}` : ''; // COPY-FLAG - return `${DIM}↑↓ navigate · ${RESET}${BOLD}tab${RESET}${DIM} column · ${RESET}${BOLD}enter${RESET}${DIM} inspect · ${RESET}${editHint}${BOLD}esc${RESET}${DIM} clear/quit${RESET}`; // COPY-FLAG + // Edit and deploy are not offered while a filter is typed: the search owns the keyboard then. + const editHints: readonly FooterHint[] = + state.search.query === '' + ? [ + { id: 'edit', key: 'ctrl+e', label: 'edit' }, // COPY-FLAG + { id: 'deploy', key: 'ctrl+d', label: 'deploy' }, // COPY-FLAG + ] + : []; + const hints: readonly FooterHint[] = [ + { id: 'navigate', key: null, label: '↑↓ navigate' }, // COPY-FLAG + { id: 'column', key: 'tab', label: 'column' }, // COPY-FLAG + { id: 'inspect', key: 'enter', label: 'inspect' }, // COPY-FLAG + ...editHints, + { id: 'esc', key: 'esc', label: 'clear/quit' }, // COPY-FLAG + { id: 'sort', key: 'ctrl+s', label: `sort: ${SORT_LABELS[state.sort]}` }, // COPY-FLAG + ]; + return joinHints(fitHints(hints, width)); } diff --git a/tests/commands/connectDiscoveryNeutralCodes.test.ts b/tests/commands/connectDiscoveryNeutralCodes.test.ts new file mode 100644 index 00000000..07618efd --- /dev/null +++ b/tests/commands/connectDiscoveryNeutralCodes.test.ts @@ -0,0 +1,373 @@ +/** + * The steps `capy connect --discover` shares (commands/connectors/discoverySteps.ts) report their refusals + * with the codes of the provider that runs them. + * + * A Vercel run reports the neutral `CONNECTOR_*` codes and never a code or a string that names Dokploy. A Dokploy run + * reports exactly the `DOKPLOY_*` codes it always has. The same steps run under both tables here, and each table is + * compared with the full list of what it must say. + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { CONNECTOR_DISCOVERY_CODES, DOKPLOY_DISCOVERY_CODES, type DiscoveryCodes } from '../../src/commands/connectors/discoveryCodes'; +import { buildRealDiscoverySequenceDeps } from '../../src/commands/connectors/discoverySteps'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { reportVercelDiscovery } from '../../src/commands/connectors/vercelDiscovery'; +import { defaultDiscoveryProjectName, findCandidateReposResult, type DiscoveryContext, type DiscoveryPlanEnv } from '../../src/commands/connectors/dokployDiscovery'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import { FileManager } from '../../src/files/fileManager'; +import { wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; +import { commitDiscoveryChanges } from '../../src/git/discoveryCommit'; +import { CapyError, ERROR_CODES, type KeepFile } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, git, initRepo, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const ORG = 'org_codes'; +const USER = 'user_codes'; +const PROJECT = 'proj_codes'; + +const mentionsDokploy = (text: string): boolean => /dokploy/i.test(text); +const stringValues = (table: DiscoveryCodes): readonly string[] => Object.values(table).filter((v): v is string => typeof v === 'string'); + +// ── the tables ────────────────────────────────────────────────────────────── + +describe('the code tables', () => { + test('the neutral table names no provider, and every code in it is in ERROR_CODES', () => { + const codes = stringValues(CONNECTOR_DISCOVERY_CODES); + expect(codes).toHaveLength(18); + expect(codes.filter(mentionsDokploy)).toEqual([]); + codes.forEach((code) => expect(Object.values(ERROR_CODES)).toContain(code)); + expect(new Set(codes).size).toBe(codes.length); + }); + + test('Dokploy\'s table is exactly the strings its discovery has always reported', () => { + expect(DOKPLOY_DISCOVERY_CODES).toEqual({ + keepsServiceCode: false, + pushFailed: 'DOKPLOY_PUSH_FAILED', + folderNotInitialized: 'DOKPLOY_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'DOKPLOY_PROJECT_LOOKUP_FAILED', + projectNameExists: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNotFound: 'DOKPLOY_PROJECT_NOT_FOUND', + initFailed: 'DOKPLOY_INIT_FAILED', + projectNameTooLong: 'DOKPLOY_PROJECT_NAME_TOO_LONG', + keyResolutionFailed: 'DOKPLOY_KEY_RESOLUTION_FAILED', + folderDirty: 'DOKPLOY_FOLDER_DIRTY', + branchProtected: 'DOKPLOY_BRANCH_PROTECTED', + keepMissing: 'DOKPLOY_KEEP_MISSING', + checkoutFailed: 'DOKPLOY_CHECKOUT_FAILED', + remoteUnreadable: 'DOKPLOY_REMOTE_UNREADABLE', + commitWouldMix: 'DOKPLOY_COMMIT_WOULD_MIX', + commitBranchInvalid: 'DOKPLOY_COMMIT_BRANCH_INVALID', + commitBranchExists: 'DOKPLOY_COMMIT_BRANCH_EXISTS', + gitIdentityMissing: 'DOKPLOY_GIT_IDENTITY_MISSING', + commitFailed: 'DOKPLOY_COMMIT_FAILED', + }); + }); + + test('Vercel\'s discovery modules do not import the Dokploy connector', () => { + const importLines = (file: string): readonly string[] => + readFileSync(join(__dirname, '../../src/commands/connectors', file), 'utf-8') + .split('\n') + .filter((l) => /^\s*(import|\}\s*from)\b/.test(l) && /from\s+'/.test(l)); + ['vercel.ts', 'vercelDiscovery.ts'].forEach((file) => { + expect(importLines(file).filter((l) => /from\s+'\.\/dokploy'/.test(l))).toEqual([]); + }); + }); +}); + +// ── the shared steps under each table ─────────────────────────────────────── + +const planEnv = { environmentName: 'preview', serviceId: 'prj_x', serviceName: 'x', serviceKind: 'vercel' } as unknown as DiscoveryPlanEnv; + +type ServiceOverrides = Partial>; + +interface StepWorld { + readonly root: string; + readonly ensure: (opts: { interactive: boolean; yes: boolean; snapshot?: { existingProjects: ReadonlyArray<{ id: string; name: string; organization_id: string }>; projectsUnavailable: boolean }; pick?: string; sameRun?: boolean }) => Promise<{ ok: boolean; code?: string }>; + readonly checkout: () => Promise<{ ok: boolean; code?: string }>; + readonly importAndPush: (outcome: ImportOutcome) => Promise<{ ok: boolean; code?: string }>; +} + +/** A real folder (with or without a keep.lock) and the real step functions for it, under `codes`. */ +async function stepWorld(root: string, codes: DiscoveryCodes, opts: { keep: boolean; service?: ServiceOverrides }): Promise { + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, keyOps); + const keep: KeepFile = { version: '3.0', org_id: ORG, project_id: PROJECT, project_name: 'widgets', variables: {} }; + if (opts.keep) { + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + } + const serviceClient = { + listBranches: async () => [], + listProjects: async () => [], + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets: async () => ({ keep_hash: 'h'.repeat(16) }), + ...(opts.service ?? {}), + }; + const ctx = { orgId: ORG, userId: USER, authService: {}, serviceClient } as unknown as DiscoveryContext; + const depsFor = (over: { interactive: boolean; yes: boolean; snapshot?: Parameters[0]['snapshot']; pick?: string; sameRun?: boolean }, importFn: (c: never, o: ConnectOpts) => Promise) => + buildRealDiscoverySequenceDeps(ctx, importFn as never, { + noPush: false, + interactive: over.interactive, + json: true, + yes: over.yes, + importOptsFor: () => ({}), + codes, + gate: { json: true, nonTty: true }, + syncFolder: async () => ({ ok: true }), + askExistingOrNewProject: async () => over.pick ?? '__capy_discovery_new_project__', + askProjectName: async (name) => name, + existingProjectsSnapshot: over.snapshot, + sameRunNameCollisions: new Set(over.sameRun ? [`${root}\u0000`] : []), + }); + const noImport = async (): Promise => ({ ok: true, source: undefined, imported: [], unchanged: [], skipped: [], warnings: [] }) as unknown as ImportOutcome; + const codeOf = (r: { ok: boolean; code?: string }) => ({ ok: r.ok, ...(r.ok ? {} : { code: (r as { code: string }).code }) }); + return { + root, + ensure: async (o) => codeOf(await depsFor(o, noImport).ensureProject(root, '', undefined)), + checkout: async () => codeOf(await depsFor({ interactive: false, yes: true }, noImport).checkoutBranch(root, '', PROJECT, 'preview')), + importAndPush: async (outcome) => { + const deps = depsFor({ interactive: false, yes: true }, async () => outcome); + await deps.checkoutBranch(root, '', PROJECT, 'preview'); + return codeOf(await deps.importIntoBranch(root, '', planEnv, false, 'x')); + }, + }; +} + +const placeholderOutcome: ImportOutcome = { + ok: true, + source: undefined, + imported: [], + unchanged: [], + skipped: [], + warnings: [], + placeholders: [{ name: 'HIDDEN', code: 'sensitive_unreadable', provider: 'vercel' }], +} as unknown as ImportOutcome; + +/** Every step that can refuse, run once under `codes`, as the code each one reported. */ +async function observe(codes: DiscoveryCodes): Promise> { + const run = (keep: boolean, service: ServiceOverrides | undefined, fn: (w: StepWorld) => Promise): Promise => + withTempDir('capy-codes', async (root) => fn(await stepWorld(root, codes, { keep, service }))); + const codeOf = (r: { ok: boolean; code?: string }) => r.code; + const defaultName = (root: string) => defaultDiscoveryProjectName(root, '', undefined); + return { + folderNotInitialized: await run(false, undefined, async (w) => codeOf(await w.ensure({ interactive: false, yes: false }))), + projectLookupFailed: await run(false, undefined, async (w) => + codeOf(await w.ensure({ interactive: false, yes: true, snapshot: { existingProjects: [], projectsUnavailable: true } })), + ), + projectNameExists: await run(false, undefined, async (w) => + codeOf( + await w.ensure({ + interactive: false, + yes: true, + snapshot: { existingProjects: [{ id: 'p', name: defaultName(w.root), organization_id: ORG }], projectsUnavailable: false }, + }), + ), + ), + projectNameExistsSameRun: await run(false, undefined, async (w) => + codeOf(await w.ensure({ interactive: false, yes: true, sameRun: true, snapshot: { existingProjects: [], projectsUnavailable: false } })), + ), + projectNotFound: await run(false, { listProjects: async () => [{ id: 'p1', name: 'a', organization_id: ORG }] }, async (w) => + codeOf(await w.ensure({ interactive: true, yes: false, pick: 'nope' })), + ), + initFailed: await run(false, { listProjects: async () => [{ id: 'p1', name: 'a', organization_id: ORG }], getDecryptData: async () => { throw new Error('refused'); } }, async (w) => + codeOf(await w.ensure({ interactive: true, yes: false, pick: 'p1' })), + ), + branchProtected: await run(true, { getDecryptData: async () => { throw new CapyError('no', ERROR_CODES.PERMISSION_DENIED, { status: 403 }); } }, async (w) => codeOf(await w.checkout())), + checkoutFailed: await run(true, { getDecryptData: async () => { throw new Error('boom'); } }, async (w) => codeOf(await w.checkout())), + keepMissing: await run(false, undefined, async (w) => codeOf(await w.checkout())), + pushFailed: await run(true, { pushSecrets: async () => { throw new Error('upload refused'); } }, async (w) => codeOf(await w.importAndPush(placeholderOutcome))), + }; +} + +const NEUTRAL_EXPECTED = { + folderNotInitialized: 'CONNECTOR_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'CONNECTOR_PROJECT_LOOKUP_FAILED', + projectNameExists: 'CONNECTOR_PROJECT_NAME_EXISTS', + projectNameExistsSameRun: 'CONNECTOR_PROJECT_NAME_EXISTS', + projectNotFound: 'CONNECTOR_PROJECT_NOT_FOUND', + initFailed: 'CONNECTOR_INIT_FAILED', + branchProtected: 'CONNECTOR_BRANCH_PROTECTED', + checkoutFailed: 'CONNECTOR_CHECKOUT_FAILED', + keepMissing: 'CONNECTOR_KEEP_MISSING', + pushFailed: 'CONNECTOR_PUSH_FAILED', +}; + +const DOKPLOY_EXPECTED = { + folderNotInitialized: 'DOKPLOY_FOLDER_NOT_INITIALIZED', + projectLookupFailed: 'DOKPLOY_PROJECT_LOOKUP_FAILED', + projectNameExists: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNameExistsSameRun: 'DOKPLOY_PROJECT_NAME_EXISTS', + projectNotFound: 'DOKPLOY_PROJECT_NOT_FOUND', + initFailed: 'DOKPLOY_INIT_FAILED', + branchProtected: 'DOKPLOY_BRANCH_PROTECTED', + checkoutFailed: 'DOKPLOY_CHECKOUT_FAILED', + keepMissing: 'DOKPLOY_KEEP_MISSING', + pushFailed: 'DOKPLOY_PUSH_FAILED', +}; + +describe('the shared steps, run under each provider\'s table', () => { + test('neutral table: every refusal is the CONNECTOR_* code, and none names Dokploy', async () => { + await withIsolatedGlobalDir(async () => { + const seen = await observe(CONNECTOR_DISCOVERY_CODES); + expect(seen).toEqual(NEUTRAL_EXPECTED as never); + Object.values(seen).forEach((code) => expect(mentionsDokploy(String(code))).toBe(false)); + }); + }, 60_000); + + test('Dokploy table: every refusal is exactly the DOKPLOY_* code it has always been (output unchanged)', async () => { + await withIsolatedGlobalDir(async () => { + expect(await observe(DOKPLOY_DISCOVERY_CODES)).toEqual(DOKPLOY_EXPECTED as never); + }); + }, 60_000); +}); + +describe('repo scan and discovery commit', () => { + test('a repo git cannot read is CONNECTOR_REMOTE_UNREADABLE for Vercel; DOKPLOY_REMOTE_UNREADABLE by default and for Dokploy', async () => { + await withTempDir('capy-unreadable', async (root) => { + mkdirSync(join(root, '.git'), { recursive: true }); // a .git that is not a repository + expect(findCandidateReposResult(root, CONNECTOR_DISCOVERY_CODES).unreadable.map((u) => u.code)).toEqual(['CONNECTOR_REMOTE_UNREADABLE']); + expect(findCandidateReposResult(root, DOKPLOY_DISCOVERY_CODES).unreadable.map((u) => u.code)).toEqual(['DOKPLOY_REMOTE_UNREADABLE']); + expect(findCandidateReposResult(root).unreadable.map((u) => u.code)).toEqual(['DOKPLOY_REMOTE_UNREADABLE']); + }); + }); + + test('the commit refusals: neutral for Vercel, Dokploy\'s by default and when asked', async () => { + await withTempDir('capy-commit-codes', async (root) => { + initRepo(root); + const refusal = (codes: DiscoveryCodes | undefined, over: { branchName?: string; before?: ReadonlySet }) => + commitDiscoveryChanges(root, ['keep.lock'], over.before ?? new Set(), { + branchName: over.branchName ?? 'capy/x', + dryRun: false, + summaryLines: [], + ...(codes ? { codes } : {}), + }); + const codeOf = (r: ReturnType) => (r.ok ? 'ok' : r.code); + + expect(codeOf(refusal(CONNECTOR_DISCOVERY_CODES, { before: new Set(['keep.lock']) }))).toBe('CONNECTOR_COMMIT_WOULD_MIX'); + expect(codeOf(refusal(CONNECTOR_DISCOVERY_CODES, { branchName: 'bad name' }))).toBe('CONNECTOR_COMMIT_BRANCH_INVALID'); + git(root, ['branch', 'taken']); + expect(codeOf(refusal(CONNECTOR_DISCOVERY_CODES, { branchName: 'taken' }))).toBe('CONNECTOR_COMMIT_BRANCH_EXISTS'); + + expect(codeOf(refusal(undefined, { before: new Set(['keep.lock']) }))).toBe('DOKPLOY_COMMIT_WOULD_MIX'); + expect(codeOf(refusal(DOKPLOY_DISCOVERY_CODES, { branchName: 'bad name' }))).toBe('DOKPLOY_COMMIT_BRANCH_INVALID'); + expect(codeOf(refusal(undefined, { branchName: 'taken' }))).toBe('DOKPLOY_COMMIT_BRANCH_EXISTS'); + }); + }); +}); + +// ── a Vercel run, end to end ───────────────────────────────────────────────── + +const e = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const vercelData = (): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: [e({ id: 'p1', key: 'A', type: 'plain', listValue: 'a-value-SENT-11aa' })] }, + custom: {}, + values: {}, +}); + +describe('a Vercel run whose upload fails', () => { + /** A repo with a committed keep.lock, a Vercel connector over a fake Vercel, and a service whose upload fails with `uploadError`. */ + const runWithUploadError = (uploadError: unknown) => + withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-push-fail', async (root) => { + initRepo(root, 'git@github.com:acme/widgets.git'); + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, keyOps); + const keep: KeepFile = { version: '3.0', org_id: ORG, project_id: PROJECT, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + + const ctx = { + orgId: ORG, + userId: USER, + authService: {}, + serviceClient: { + listBranches: async () => [], + listProjects: async () => [], + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets: mock(async () => { + throw uploadError; + }), + }, + } as unknown as DiscoveryContext; + const connector = createVercelConnector({ + fetch: fakeVercelFetch(vercelData()) as unknown as VercelFetchLike, + env: { T: 'tok_fake' }, + cwd: root, + sleep: async () => {}, + syncFolder: async () => ({ ok: true }), + }); + const run = await captureConsole(async () => { + const outcome = await connector.discover!(ctx, { nonTty: true, tokenEnv: 'T', yes: true, json: true } as ConnectOpts); + if (outcome.ok) await reportVercelDiscovery(outcome, { nonTty: true, tokenEnv: 'T', yes: true, json: true } as ConnectOpts, 'vercel'); + return outcome; + }); + return { outcome: run.result, printed: [...run.stdout, ...run.stderr].join('\n') }; + }), + ), + ); + + const failedFolder = (outcome: Awaited>['outcome']) => { + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + const folder = outcome.applied?.[0]; + expect(folder?.ok).toBe(false); + if (folder?.ok !== false) throw new Error('unreachable'); + return folder; + }; + + test('the incident: the service\'s "keep_file and env_blob are required" 400 reports CONNECTOR_PUSH_FAILED, never a code that names Dokploy', async () => { + const { outcome, printed } = await runWithUploadError(new CapyError('keep_file and env_blob are required', ERROR_CODES.SERVICE_ERROR, { status: 400 })); + const folder = failedFolder(outcome); + expect(folder.code).toBe('CONNECTOR_PUSH_FAILED'); + expect(folder.message).toBe('keep_file and env_blob are required'); + expect(mentionsDokploy(JSON.stringify(outcome))).toBe(false); + expect(mentionsDokploy(printed)).toBe(false); + expect(printed).toContain('CONNECTOR_PUSH_FAILED'); + }, 30_000); + + test('an untyped failure is CONNECTOR_PUSH_FAILED too', async () => { + const { outcome, printed } = await runWithUploadError(new Error('socket hang up')); + expect(failedFolder(outcome).code).toBe('CONNECTOR_PUSH_FAILED'); + expect(mentionsDokploy(printed)).toBe(false); + }, 30_000); + + test('a typed error from the service keeps its own code through the re-wrap (decided on the code, not the message)', async () => { + const { outcome, printed } = await runWithUploadError(new CapyError('Members cannot write to protected branches', ERROR_CODES.PERMISSION_DENIED, { status: 403 })); + const folder = failedFolder(outcome); + expect(folder.code).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(mentionsDokploy(printed)).toBe(false); + }, 30_000); + +}); + +describe('Dokploy keeps its code for a typed service error (its output does not change)', () => { + test('a typed PERMISSION_DENIED on the upload is still DOKPLOY_PUSH_FAILED', async () => { + await withIsolatedGlobalDir(async () => { + const seen = await withTempDir('capy-dokploy-push', async (root) => { + const w = await stepWorld(root, DOKPLOY_DISCOVERY_CODES, { + keep: true, + service: { pushSecrets: async () => { throw new CapyError('no', ERROR_CODES.PERMISSION_DENIED, { status: 403 }); } }, + }); + return w.importAndPush(placeholderOutcome); + }); + expect(seen).toEqual({ ok: false, code: 'DOKPLOY_PUSH_FAILED' }); + }); + }, 30_000); +}); diff --git a/tests/commands/connectDokployDiscovery.test.ts b/tests/commands/connectDokployDiscovery.test.ts index a6c19f38..0c8ffe2f 100644 --- a/tests/commands/connectDokployDiscovery.test.ts +++ b/tests/commands/connectDokployDiscovery.test.ts @@ -56,7 +56,8 @@ import { type DiscoveryPlanEnv, type DiscoveryPlanFolder, } from '../../src/commands/connectors/dokployDiscovery'; -import { createDokployConnector as createRealDokployConnector, computeOverwritePlan, DISCOVERY_NEW_PROJECT } from '../../src/commands/connectors/dokploy'; +import { createDokployConnector as createRealDokployConnector } from '../../src/commands/connectors/dokploy'; +import { computeOverwritePlan, DISCOVERY_NEW_PROJECT } from '../../src/commands/connectors/discoverySteps'; import type { DokployConnectorDeps } from '../../src/commands/connectors/dokploy'; // These tests drive discovery over throwaway folders with a fake service and no session; bare `capy` in each folder diff --git a/tests/commands/connectVercel.test.ts b/tests/commands/connectVercel.test.ts new file mode 100644 index 00000000..625e9278 --- /dev/null +++ b/tests/commands/connectVercel.test.ts @@ -0,0 +1,517 @@ +/** + * `capy connect vercel --project …` — the single-project import (CAP-696). + * + * Covers: token resolution before any request, team resolution (0/1/many), + * the environment → Capy branch mapping (production/preview/development and a + * custom environment's slug), one record targeting several environments, the + * skip reasons (git-branch-scoped preview, sensitive, system, legacy secret, + * empty value), conflicts / unchanged / `--var` / `--overwrite` / `--dry-run`, + * the keep.lock connector metadata shape, the deploy-target offer, and — end + * to end through the REAL `ConnectCommand.executeImport` + a REAL + * `FileManager` — that the `.env` values equal the mocked Vercel values and + * that no value appears on any output surface. + * + * Every Vercel call goes through a scripted fake that throws on any non-GET, + * so "this connector never writes to Vercel" is proven by running it. No + * `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createVercelConnector, buildVercelConnectorEntry } from '../../src/commands/connectors/vercel'; +import { listProviders, loadProvider } from '../../src/commands/connectors/registry'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { listTargets, upsertTarget } from '../../src/deploy/config'; +import { ERROR_CODES, type ConnectorMetadata, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, withExitCodeRestored, withIsolatedGlobalDir, withTTY, withTempDir } from '../helpers/testSandbox'; + +// ── fixtures (every value is a fake sentinel) ─────────────────────────────── + +const V = { + plainProd: 'plain-prod-SENT-1a2b', + encAll: 'enc-all-SENT-3c4d', + scoped: 'scoped-SENT-5e6f', + prevAll: 'prev-all-SENT-7a8b', + stage: 'stage-SENT-9c0d', + sensitive: 'sensitive-SENT-must-never-be-read', +} as const; +const ALL_SENTINELS = Object.values(V); + +const env = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const ENVS: ReadonlyArray = [ + env({ id: 'env_1', key: 'PLAIN_PROD', type: 'plain', listValue: V.plainProd }), + env({ id: 'env_2', key: 'ENC_ALL', type: 'encrypted', target: ['production', 'preview', 'development'], listValue: 'opaque-ciphertext' }), + env({ id: 'env_3', key: 'SENS', type: 'sensitive' }), + env({ id: 'env_4', key: 'SYS', type: 'system' }), + env({ id: 'env_5', key: 'OLD', type: 'secret' }), + env({ id: 'env_6', key: 'SCOPED', type: 'encrypted', target: ['preview'], gitBranch: 'feature/x', listValue: 'opaque' }), + env({ id: 'env_7', key: 'PREV_ALL', type: 'plain', target: ['preview'], listValue: V.prevAll }), + env({ id: 'env_8', key: 'STAGE_ONLY', type: 'encrypted', target: [], customEnvironmentIds: ['env_c1'], configurationId: 'icfg_9', listValue: 'opaque' }), + env({ id: 'env_9', key: 'EMPTY_ENC', type: 'encrypted', listValue: 'opaque' }), +]; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: ENVS }, + custom: { prj_web: [{ id: 'env_c1', slug: 'staging' }] }, + // env_9 has NO value: Vercel returned none although the type is readable. + values: { env_2: V.encAll, env_6: V.scoped, env_8: V.stage, env_3: V.sensitive }, +}; + +const BASE_OPTS: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; + +function ctxWith(over: Partial & { localPlaintext?: Record } = {}): ResolvedContext { + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + return { keep, branch: 'production', orgId: 'org_1', localPlaintext: {}, ...over } as unknown as ResolvedContext; +} + +function makeConnector(data: FakeVercelData = DATA, extra: Partial[0]> = {}) { + const fetchMock = mock(fakeVercelFetch(data)); + const connector = createVercelConnector({ fetch: fetchMock as unknown as VercelFetchLike, env: { T: 'tok_fake_value' }, sleep: async () => {}, ...extra }); + return { connector, fetchMock }; +} + +const okOutcome = (o: ImportOutcome): Extract => { + if (!o.ok) throw new Error(`expected ok, got ${o.code}: ${o.message}`); + return o; +}; + +const valuesByName = (o: Extract): Record => + Object.fromEntries(o.imported.map((i) => [i.varName, i.value])); + +// ── tokens, zero requests, read-only ──────────────────────────────────────── + +describe('vercel import — token and request shape', () => { + test('no token anywhere: VERCEL_TOKEN_MISSING and ZERO Vercel requests', async () => { + const { connector, fetchMock } = makeConnector(DATA, { env: {} }); + const out = await connector.import!(ctxWith(), BASE_OPTS); + expect(out.ok).toBe(false); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(fetchMock.mock.calls.length).toBe(0); + }); + + test('the token resolves from the org system store entry, with no prompt under --json', async () => { + const getConnectorSecret = mock(async (_n: string, _o: { interactive: boolean }) => 'tok_from_store'); + const { connector, fetchMock } = makeConnector(DATA, { env: {}, getConnectorSecret }); + const out = await connector.import!(ctxWith(), { ...BASE_OPTS, tokenEnv: undefined, json: true }); + expect(out.ok).toBe(true); + expect(getConnectorSecret.mock.calls[0][0]).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(getConnectorSecret.mock.calls[0][1].interactive).toBe(false); + expect(callsOf(fetchMock)[0].authorization).toBe('Bearer tok_from_store'); + }); + + test('a run only ever GETs, with the bearer token, and the token is never in a URL', async () => { + const { connector, fetchMock } = makeConnector(); + okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + const calls = callsOf(fetchMock); + expect(calls.length).toBeGreaterThan(3); + expect(calls.every((c) => c.method === 'GET')).toBe(true); + expect(calls.every((c) => c.authorization === 'Bearer tok_fake_value')).toBe(true); + expect(calls.some((c) => c.url.includes('tok_fake_value'))).toBe(false); + }); + + test('401 → VERCEL_AUTH_FAILED; an unknown project → VERCEL_PROJECT_NOT_FOUND', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const a = createVercelConnector({ fetch: unauthorized, env: { T: 'x' }, sleep: async () => {} }); + expect(((await a.import!(ctxWith(), BASE_OPTS)) as { code: string }).code).toBe(ERROR_CODES.VERCEL_AUTH_FAILED); + + const { connector } = makeConnector(); + const missing = await connector.import!(ctxWith(), { ...BASE_OPTS, project: 'nope' }); + expect((missing as { code: string }).code).toBe(ERROR_CODES.VERCEL_PROJECT_NOT_FOUND); + }); + + test('no --project, non-interactive: VERCEL_PROJECT_REQUIRED, and the project is never asked for', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith(), { nonTty: true, tokenEnv: 'T' }); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_PROJECT_REQUIRED); + }); +}); + +// ── teams ─────────────────────────────────────────────────────────────────── + +describe('vercel import — team resolution', () => { + test('one team: used automatically, every project request is scoped to it', async () => { + const { connector, fetchMock } = makeConnector(); + okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + const scoped = callsOf(fetchMock).filter((c) => !c.url.includes('/v2/teams')); + expect(scoped.every((c) => new URL(c.url).searchParams.get('teamId') === 'team_acme')).toBe(true); + }); + + test('no teams: the personal account — no teamId on any request, and no team_id recorded', async () => { + const { connector, fetchMock } = makeConnector({ ...DATA, teams: [], projects: { personal: [project('prj_web', 'web')] } }); + const out = okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + expect(callsOf(fetchMock).every((c) => new URL(c.url).searchParams.get('teamId') === null)).toBe(true); + expect(out.imported[0].entry.team_id).toBeUndefined(); + }); + + test('several teams, non-interactive, no --team: VERCEL_TEAM_REQUIRED listing slugs; nothing but the team list was requested', async () => { + const { connector, fetchMock } = makeConnector({ ...DATA, teams: [{ id: 'team_acme', slug: 'acme' }, { id: 'team_beta', slug: 'beta' }] }); + const out = await connector.import!(ctxWith(), BASE_OPTS); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TEAM_REQUIRED); + expect((out as { message: string }).message).toContain('acme'); + expect((out as { message: string }).message).toContain('beta'); + expect(callsOf(fetchMock).map((c) => new URL(c.url).pathname)).toEqual(['/v2/teams']); + }); + + test('several teams + --team : that team; --team nope: VERCEL_TEAM_NOT_FOUND', async () => { + const data = { ...DATA, teams: [{ id: 'team_beta', slug: 'beta' }, { id: 'team_acme', slug: 'acme' }] }; + const { connector } = makeConnector(data); + okOutcome(await connector.import!(ctxWith(), { ...BASE_OPTS, team: 'acme' })); + const bad = await connector.import!(ctxWith(), { ...BASE_OPTS, team: 'nope' }); + expect((bad as { code: string }).code).toBe(ERROR_CODES.VERCEL_TEAM_NOT_FOUND); + }); + + test('several teams, interactive: asks which team', async () => { + const pickTeam = mock(async () => 'team_acme'); + const { connector } = makeConnector({ ...DATA, teams: [{ id: 'team_beta', slug: 'beta' }, { id: 'team_acme', slug: 'acme' }] }, { pickTeam, selectVars: async (c) => c, confirm: async () => false }); + const out = await withTTY(() => connector.import!(ctxWith(), { ...BASE_OPTS, nonTty: false })); + okOutcome(out); + expect(pickTeam.mock.calls.length).toBe(1); + }); +}); + +// ── environment → branch, and what is skipped ────────────────────────────── + +describe('vercel import — environment mapping and skips', () => { + test('production (the current branch): plain from the list, encrypted from the per-id route; values equal Vercel\'s', async () => { + const { connector, fetchMock } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), BASE_OPTS)); + expect(valuesByName(out)).toEqual({ PLAIN_PROD: V.plainProd, ENC_ALL: V.encAll }); + // the sensitive value is NEVER requested, whatever the fake would answer + expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_3'))).toBe(false); + // plain needs no per-id request + expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_1'))).toBe(false); + }); + + test('skipped names carry stable reasons: system, empty value; sensitive and legacy secret are placeholders, not skips', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), BASE_OPTS)); + expect(Object.fromEntries(out.skipped.map((s) => [s.name, s.code]))).toEqual({ + SYS: 'vercel_system', + EMPTY_ENC: 'empty_value', + }); + expect(Object.fromEntries((out.placeholders ?? []).map((p) => [p.name, p.code]))).toEqual({ + SENS: 'sensitive_unreadable', + OLD: 'legacy_secret_unreadable', + }); + expect(out.imported.map((i) => i.varName)).not.toContain('EMPTY_ENC'); + }); + + test('preview: the git-branch-scoped record is NOT imported — skipped with its name and branch; the unscoped one is', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'preview' }), BASE_OPTS)); + expect(valuesByName(out)).toEqual({ ENC_ALL: V.encAll, PREV_ALL: V.prevAll }); + expect(out.skipped).toEqual([{ name: 'SCOPED', code: 'git_branch_scoped', gitBranch: 'feature/x' }]); + }); + + test('one record targeting production, preview and development counts in each', async () => { + const { connector } = makeConnector(); + const results = await Promise.all(['production', 'preview', 'development'].map((b) => connector.import!(ctxWith({ branch: b }), BASE_OPTS))); + expect(results.map((r) => okOutcome(r).imported.some((i) => i.varName === 'ENC_ALL' && i.value === V.encAll))).toEqual([true, true, true]); + }); + + test('a custom environment maps to its slug: --environment staging reads the record that names env_c1', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'development' }), { ...BASE_OPTS, environment: 'staging' })); + expect(valuesByName(out)).toEqual({ STAGE_ONLY: V.stage }); + expect(out.imported[0].entry.environment).toBe('staging'); + expect(out.source).toEqual({ kind: 'vercel', projectId: 'prj_web', projectName: 'web', teamId: 'team_acme', environment: 'staging' }); + }); + + test('a branch named like a custom environment picks it without --environment', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'staging' }), BASE_OPTS)); + expect(Object.keys(valuesByName(out))).toEqual(['STAGE_ONLY']); + }); + + test('current branch matches no environment and no --environment: VERCEL_ENVIRONMENT_REQUIRED', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith({ branch: 'main' }), BASE_OPTS); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_REQUIRED); + }); + + test('--environment that does not exist: VERCEL_ENVIRONMENT_NOT_FOUND, listing what does', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith(), { ...BASE_OPTS, environment: 'qa' }); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND); + expect((out as { message: string }).message).toContain('staging'); + }); + + test('--environment overrides the branch name: main branch, development environment', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'main' }), { ...BASE_OPTS, environment: 'development' })); + expect(Object.keys(valuesByName(out))).toEqual(['ENC_ALL']); + }); + + test('a list that says there is a next page it cannot fetch is flagged, never imported as if complete', async () => { + const { connector } = makeConnector({ ...DATA, envListNext: 123 }); + const out = okOutcome(await connector.import!(ctxWith(), BASE_OPTS)); + expect(out.warnings.map((w) => w.code)).toContain('VERCEL_ENV_LIST_TRUNCATED'); + }); +}); + +// ── conflicts, --var, --overwrite, --dry-run ─────────────────────────────── + +describe('vercel import — conflicts, --var, --overwrite, --dry-run', () => { + test('same local value: unchanged (and its metadata is refreshed); a different local value: kept, skipped IMPORT_CONFLICT_SKIPPED', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { PLAIN_PROD: V.plainProd, ENC_ALL: 'different-local' } }), BASE_OPTS)); + expect(out.unchanged).toEqual(['PLAIN_PROD']); + expect(out.unchangedEntries?.map((e) => e.varName)).toEqual(['PLAIN_PROD']); + expect(out.imported.map((i) => i.varName)).toEqual([]); + expect(out.skipped.filter((s) => s.code === 'IMPORT_CONFLICT_SKIPPED').map((s) => s.name)).toEqual(['ENC_ALL']); + }); + + test('--var restricts the import, and the unrelated records are not even fetched', async () => { + const { connector, fetchMock } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith(), { ...BASE_OPTS, var: 'PLAIN_PROD' })); + expect(Object.keys(valuesByName(out))).toEqual(['PLAIN_PROD']); + expect(callsOf(fetchMock).some((c) => c.url.includes('/env/env_2'))).toBe(false); + expect(out.skipped).toEqual([]); + }); + + test('--dry-run: no prompt, conflicts reported as wouldAsk, no unchangedEntries, no deploy target', async () => { + const confirm = mock(async () => true); + const { connector } = makeConnector(DATA, { confirm }); + const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { ENC_ALL: 'different-local' } }), { ...BASE_OPTS, dryRun: true })); + expect(out.wouldAsk).toEqual(['ENC_ALL']); + expect(out.unchangedEntries).toEqual([]); + expect(out).not.toHaveProperty('deployTargetSaved'); + expect(confirm.mock.calls.length).toBe(0); + }); + + test('--overwrite non-interactive without --yes: VERCEL_CONFIRMATION_REQUIRED, nothing computed to write', async () => { + const { connector } = makeConnector(); + const out = await connector.import!(ctxWith({ localPlaintext: { LOCAL_ONLY: 'x' } }), { ...BASE_OPTS, overwrite: true }); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED); + }); + + test('--overwrite --yes: clears local-only names, replaces differing ones, imports new ones — and never clears a name Vercel has but will not show', async () => { + const { connector } = makeConnector(); + const out = okOutcome( + await connector.import!( + ctxWith({ localPlaintext: { LOCAL_ONLY: 'x', PLAIN_PROD: 'old', SENS: 'keep-me-sensitive-in-vercel', EMPTY_ENC: 'keep-me-too' } }), + { ...BASE_OPTS, overwrite: true, yes: true }, + ), + ); + expect(out.cleared).toEqual(['LOCAL_ONLY']); + expect(out.replacedNames).toEqual(['PLAIN_PROD']); + expect(valuesByName(out)).toEqual({ PLAIN_PROD: V.plainProd, ENC_ALL: V.encAll }); + }); + + test('--overwrite --dry-run needs no --yes and plans without writing', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ localPlaintext: { LOCAL_ONLY: 'x' } }), { ...BASE_OPTS, overwrite: true, dryRun: true })); + expect(out.cleared).toEqual(['LOCAL_ONLY']); + }); + + test('--overwrite interactive: declining the confirmation writes nothing', async () => { + const { connector } = makeConnector(DATA, { confirm: async () => false }); + const out = okOutcome(await withTTY(() => connector.import!(ctxWith({ localPlaintext: { LOCAL_ONLY: 'x' } }), { ...BASE_OPTS, nonTty: false, overwrite: true }))); + expect(out.imported).toEqual([]); + expect(out.cleared).toEqual([]); + }); +}); + +// ── keep.lock connector metadata ──────────────────────────────────────────── + +describe('vercel import — keep.lock connector metadata', () => { + test('each imported entry records provider, source, project, team, environment, env id, time and fingerprint', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, var: 'ENC_ALL' })); + const entry = out.imported[0].entry as ConnectorMetadata; + expect(entry).toMatchObject({ + provider: 'vercel', + source: 'import', + vercel_project_id: 'prj_web', + vercel_project: 'web', + team_id: 'team_acme', + environment: 'production', + env_id: 'env_2', + fingerprint: `${V.encAll.slice(0, 3)}…${V.encAll.slice(-3)}`, + }); + expect(typeof entry.imported_at).toBe('string'); + expect(typeof entry.created_at).toBe('number'); + expect(entry.configuration_id).toBeUndefined(); + // the entry never carries the value itself + expect(JSON.stringify(entry)).not.toContain(V.encAll); + }); + + test('configuration_id is recorded when Vercel set one', async () => { + const { connector } = makeConnector(); + const out = okOutcome(await connector.import!(ctxWith({ branch: 'staging' }), BASE_OPTS)); + expect(out.imported[0].entry.configuration_id).toBe('icfg_9'); + }); + + test('buildVercelConnectorEntry is additive: no team_id for a personal project', () => { + const entry = buildVercelConnectorEntry( + { id: 'e', key: 'K', type: 'plain', targets: ['production'], customEnvironmentIds: [] }, + 'a-long-enough-value', + { project: { id: 'prj', name: 'p' }, environment: 'production', createdAtSec: 1, importedAt: '2026-01-01T00:00:00.000Z' }, + ); + expect('team_id' in entry).toBe(false); + }); +}); + +// ── connect writes no deploy target ────────────────────────────────────────── + +describe('vercel import — "connect is for retrieval, target is for deploy"', () => { + const interactiveDeps = (confirm: () => Promise) => ({ confirm, selectVars: async (c: readonly string[]) => c }); + + test('an interactive import never offers a deploy target, and writes no .capy/deploy.json', async () => { + await withTempDir('capy-vercel-no-target', async (dir) => { + const confirm = mock(async () => true); + const out = okOutcome( + await withTTY(() => makeConnector(DATA, { ...interactiveDeps(confirm), cwd: dir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, nonTty: false })), + ); + expect(out.imported.length).toBeGreaterThan(0); + expect(confirm).not.toHaveBeenCalled(); + expect(out).not.toHaveProperty('deployTargetSaved'); + expect(out).not.toHaveProperty('deployTarget'); + expect(out).not.toHaveProperty('deployTargetToWrite'); + expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); + expect(listTargets(dir)).toEqual([]); + }); + }); + + test('--json and --dry-run report no target either', async () => { + await withTempDir('capy-vercel-no-target-json', async (dir) => { + const json = okOutcome(await makeConnector(DATA, { cwd: dir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, json: true })); + const dry = okOutcome(await makeConnector(DATA, { cwd: dir }).connector.import!(ctxWith({ branch: 'production' }), { ...BASE_OPTS, dryRun: true })); + [json, dry].forEach((out) => { + expect(out).not.toHaveProperty('deployTargetSaved'); + expect(out).not.toHaveProperty('deployTarget'); + }); + expect(existsSync(join(dir, '.capy', 'deploy.json'))).toBe(false); + }); + }); + + test('--targets is not a flag of connect any more (the deploy side owns targets: `capy deploy vercel --discover`)', () => { + expect(listProviders().map((p) => p.name)).toContain('vercel'); + const src = readFileSync(join(import.meta.dir, '../../src/commands/connectors/vercel.ts'), 'utf-8'); + expect(src).not.toContain('upsertTarget'); + expect(src).not.toContain('deployTargetToWrite'); + }); +}); + +// ── end to end: real FileManager, real executeImport, no value on any surface ─ + +describe('vercel import — end to end through ConnectCommand.executeImport', () => { + const PROJECT_KEY = 'vercel-e2e-project-key-0123456789'; + + const realCtx = (dir: string, branch: string): ResolvedContext => { + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + return { + pm: { readSyncState: () => null }, + fileManager: new FileManager(dir), + serviceClient: { + pushSecrets: () => { + throw new Error('must not push — these runs pass noPush'); + }, + }, + orgId: 'org_1', + projectId: 'p', + branch, + userId: 'u', + projectKey: PROJECT_KEY, + keep, + localPlaintext: {}, + } as unknown as ResolvedContext; + }; + + test('--json: stdout is ONE JSON object, the .env holds the mocked Vercel values (decrypt-verified), and no value is printed anywhere', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-e2e', async (dir) => { + const { connector } = makeConnector(); + const ctx = realCtx(dir, 'production'); + const { stdout, stderr } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', ctx, { ...BASE_OPTS, json: true, noPush: true }), + ); + + expect(stdout.length).toBe(1); + const parsed = JSON.parse(stdout[0]); + expect(parsed).toMatchObject({ + ok: true, + provider: 'vercel', + dryRun: false, + imported: ['PLAIN_PROD', 'ENC_ALL'], + source: { kind: 'vercel', projectId: 'prj_web', environment: 'production' }, + }); + expect(parsed.placeholders).toContainEqual({ name: 'SENS', code: 'sensitive_unreadable' }); + + // the property: decrypting what landed on disk gives Vercel's values + const decrypted = new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY); + expect(decrypted).toEqual({ PLAIN_PROD: V.plainProd, ENC_ALL: V.encAll }); + expect(readFileSync(join(dir, '.env'), 'utf-8')).toMatch(/ENC_ALL=capy:/); + + const everything = [...stdout, ...stderr].join('\n') + readFileSync(join(dir, '.env'), 'utf-8'); + ALL_SENTINELS.forEach((s) => expect(everything).not.toContain(s)); + }), + ), + ); + }); + + test('--dry-run --json: pure JSON, nothing written to disk, no value anywhere', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-e2e-dry', async (dir) => { + const { connector } = makeConnector(); + const { stdout, stderr } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, json: true, dryRun: true }), + ); + expect(stdout.length).toBe(1); + expect(JSON.parse(stdout[0])).toMatchObject({ ok: true, dryRun: true, imported: ['PLAIN_PROD', 'ENC_ALL'], pushed: false }); + expect(existsSync(join(dir, '.env'))).toBe(false); + ALL_SENTINELS.forEach((s) => expect([...stdout, ...stderr].join('\n')).not.toContain(s)); + }), + ), + ); + }); + + test('the human output names Vercel, never Dokploy, and prints no value', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-e2e-human', async (dir) => { + const { connector } = makeConnector(); + const { stdout } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: true }), + ); + const text = stdout.join('\n'); + expect(text).toContain('from Vercel'); + expect(text).not.toContain('Dokploy'); + ALL_SENTINELS.forEach((s) => expect(text).not.toContain(s)); + }), + ); + }); + + test('a coded refusal under --json is one JSON object with the code', async () => { + await withExitCodeRestored(async () => { + const { connector } = makeConnector(DATA, { env: {} }); + const { stdout } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...BASE_OPTS, json: true }), + ); + expect(JSON.parse(stdout[0])).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + }); + }); +}); + +// ── registration ──────────────────────────────────────────────────────────── + +describe('vercel is a registered import connector', () => { + test('listed beside the others, loadable, import-kind, with discover and its own discovery report', async () => { + expect(listProviders().map((p) => p.name)).toEqual(['stripe', 'workos', 'dokploy', 'vercel']); + const mod = await loadProvider('vercel'); + expect(mod.name).toBe('vercel'); + expect(mod.kind).toBe('import'); + expect(typeof mod.import).toBe('function'); + expect(typeof mod.discover).toBe('function'); + expect(typeof mod.reportDiscovery).toBe('function'); + }); +}); diff --git a/tests/commands/connectVercelDiscovery.test.ts b/tests/commands/connectVercelDiscovery.test.ts new file mode 100644 index 00000000..f2057914 --- /dev/null +++ b/tests/commands/connectVercelDiscovery.test.ts @@ -0,0 +1,693 @@ +/** + * `capy connect vercel --discover` (CAP-696). + * + * Covers: matching each Vercel project to a local repo by git `origin` and to + * a folder by `rootDirectory` (null/''/'./x/'/'/x' all normalized), every + * unmatched reason, collisions handled like Dokploy's, the `--environment` + * filter, a plan that holds no value, the dry-run JSON (pure, names only, no + * value anywhere), the coded refusals, and — through the REAL sequencer, a + * REAL `FileManager` and real git repos — that a real run pushes each branch + * exactly Vercel's set for that environment and ends in the local discovery + * commit (never pushed). + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { buildVercelDiscoveryPlan, matchVercelProject, reportVercelDiscovery } from '../../src/commands/connectors/vercelDiscovery'; +import { discoveryConfirmMessage } from '../../src/commands/connectors/vercel'; +import { findCandidateReposResult, type DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { listTargets, upsertTarget } from '../../src/deploy/config'; +import { resolveProjectKey, wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts, ConnectorModule } from '../../src/commands/connectors/registry'; +import { createVercelClient, type VercelFetchLike, type VercelProject } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, git, initRepo, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const SECRET = { + aProd: 'a-prod-SENT-11aa', + sharedProd: 'shared-prod-SENT-22bb', + bStage: 'b-stage-SENT-33cc', + sharedStage: 'shared-stage-SENT-44dd', + hidden: 'hidden-SENT-55ee', +} as const; +const ALL_SECRETS = Object.values(SECRET); + +const e = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const WEB_ENVS: ReadonlyArray = [ + e({ id: 'w1', key: 'A', type: 'plain', listValue: SECRET.aProd }), + e({ id: 'w2', key: 'SHARED', type: 'encrypted', listValue: 'opaque' }), + e({ id: 'w3', key: 'B', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 'w4', key: 'SHARED', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 'w5', key: 'HIDDEN', type: 'sensitive' }), + e({ id: 'w6', key: 'BRANCHY', type: 'encrypted', target: ['preview'], gitBranch: 'dev', listValue: 'opaque' }), + e({ id: 'w7', key: 'P', type: 'plain', target: ['preview'], listValue: 'preview-plain-SENT-66ff' }), +]; +const WEB_VALUES: Record = { w2: SECRET.sharedProd, w3: SECRET.bStage, w4: SECRET.sharedStage, w5: SECRET.hidden }; + +const gh = (org: string, repo: string) => ({ type: 'github', org, repo, productionBranch: 'main' }); + +// ── matchVercelProject ────────────────────────────────────────────────────── + +describe('matchVercelProject — repo by origin, folder by rootDirectory, every unmatched reason', () => { + const repos = [{ repoDir: '/r/widgets', remote: { host: 'github.com', owner: 'acme', repo: 'widgets' } }]; + const asProject = (raw: Record): VercelProject => { + const link = raw.link as Record | undefined; + return { + id: raw.id as string, + name: raw.name as string, + rootDirectory: (raw.rootDirectory as string | null) ?? null, + link: !link ? null : link.type === 'github' ? { kind: 'github', org: link.org as string, repo: link.repo as string, productionBranch: link.productionBranch as string } : { kind: 'unsupported', type: String(link.type) }, + }; + }; + const exists = (_r: string, folder: string) => folder === '' || folder === 'apps/web'; + + test('root project: matched, folder is the repo root', () => { + const r = matchVercelProject(asProject(project('p1', 'web')), repos, exists); + expect(r.kind).toBe('matched'); + if (r.kind === 'matched') expect([r.value.repoDir, r.value.folder, r.value.productionBranch]).toEqual(['/r/widgets', '', 'main']); + }); + + test('rootDirectory is normalized: "apps/web", "./apps/web/", "/apps/web" are the same folder; "" and "." are the root', () => { + const folders = ['apps/web', './apps/web/', '/apps/web', 'apps\\web'].map((rd) => { + const r = matchVercelProject(asProject(project('p', 'x', { rootDirectory: rd })), repos, exists); + return r.kind === 'matched' ? r.value.folder : `unmatched:${r.value.reason}`; + }); + expect(folders).toEqual(['apps/web', 'apps/web', 'apps/web', 'apps/web']); + const roots = ['', '.'].map((rd) => { + const r = matchVercelProject(asProject(project('p', 'x', { rootDirectory: rd })), repos, exists); + return r.kind === 'matched' ? r.value.folder : 'unmatched'; + }); + expect(roots).toEqual(['', '']); + }); + + test('origin match is case-insensitive', () => { + const r = matchVercelProject(asProject(project('p', 'x', { link: gh('ACME', 'Widgets') })), repos, exists); + expect(r.kind).toBe('matched'); + }); + + test.each([ + ['no link at all', { link: null }, 'no_git_link'], + ['a gitlab link', { link: { type: 'gitlab', projectId: 5 } }, 'unsupported_git_provider'], + ['a bitbucket link', { link: { type: 'bitbucket' } }, 'unsupported_git_provider'], + ['a github repo that is not cloned here', { link: gh('acme', 'other') }, 'no_remote_match'], + ['a rootDirectory that does not exist locally', { rootDirectory: 'apps/ghost' }, 'root_directory_missing'], + ] as const)('%s → %s', (_label, over, reason) => { + const r = matchVercelProject(asProject(project('p', 'x', over as never)), repos, exists); + expect(r.kind).toBe('unmatched'); + if (r.kind === 'unmatched') expect(r.value.reason).toBe(reason); + }); +}); + +// ── buildVercelDiscoveryPlan against real repos ───────────────────────────── + +const noPeek = { peekLocalKeep: () => null, listServerBranches: async () => [] as ReadonlyArray<{ name: string }> }; + +const planData = (over: Partial = {}): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { + team_acme: [ + project('prj_web', 'web'), + project('prj_app', 'app', { rootDirectory: 'apps/web' }), + project('prj_ghost', 'ghost', { rootDirectory: 'apps/ghost' }), + project('prj_nolink', 'nolink', { link: null }), + project('prj_lab', 'lab', { link: { type: 'gitlab', projectId: 1 } }), + project('prj_other', 'other', { link: gh('someone', 'else') }), + project('prj_empty', 'empty', { rootDirectory: 'apps/empty' }), + ], + }, + envs: { + prj_web: WEB_ENVS, + prj_app: [e({ id: 'a1', key: 'APP_ONLY', type: 'plain', listValue: 'app-plain-SENT-77aa' })], + prj_ghost: [e({ id: 'g1', key: 'G', type: 'plain', listValue: 'x' })], + prj_empty: [], + }, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, + values: WEB_VALUES, + ...over, +}); + +async function inRepo(fn: (root: string) => Promise): Promise { + return withTempDir('capy-vercel-discover', async (root) => { + initRepo(root, 'git@github.com:acme/widgets.git'); + mkdirSync(join(root, 'apps/web'), { recursive: true }); + mkdirSync(join(root, 'apps/empty'), { recursive: true }); + return fn(root); + }); +} + +describe('buildVercelDiscoveryPlan', () => { + test('folders, environments, counts, and every unmatched reason', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const scan = findCandidateReposResult(root); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', scan.repos, noPeek); + + expect(plan.folders.map((f) => [f.folder || '.', f.projectName])).toEqual([['.', 'web'], ['apps/web', 'app']]); + const web = plan.folders[0]; + // production, preview, then the custom one — keyed by name; `staging` is a custom environment slug + expect(Object.fromEntries(web.environments.map((env) => [env.environmentName, [env.variableCount, env.skippedCount]]))).toEqual({ + production: [2, 1], // A, SHARED; HIDDEN skipped + preview: [1, 1], // P; BRANCHY skipped + staging: [2, 0], // B, SHARED + }); + expect(web.environments.every((env) => env.serviceId === 'prj_web' && env.serviceKind === 'vercel')).toBe(true); + expect(plan.unmatched.map((u) => [u.serviceId, u.reason]).toSorted()).toEqual( + [ + ['prj_ghost', 'root_directory_missing'], + ['prj_nolink', 'no_git_link'], + ['prj_lab', 'unsupported_git_provider'], + ['prj_other', 'no_remote_match'], + ['prj_empty', 'no_variables'], + ].toSorted(), + ); + expect(plan.allEnvironmentNames).toEqual(['preview', 'production', 'staging']); + expect(plan.collisions).toEqual([]); + }); + }); + + test('the plan lists skipped names with reasons (and the scoped branch) — and holds no value at all', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, noPeek); + const web = plan.folders[0]; + expect(web.environments.find((env) => env.environmentName === 'production')!.skipped).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable' }]); + expect(web.environments.find((env) => env.environmentName === 'preview')!.skipped).toEqual([{ name: 'BRANCHY', code: 'git_branch_scoped', gitBranch: 'dev' }]); + const serialized = JSON.stringify(plan); + [...ALL_SECRETS, 'preview-plain-SENT-66ff', 'app-plain-SENT-77aa'].forEach((v) => expect(serialized).not.toContain(v)); + }); + }); + + test('the plan reads only env LISTS — no per-id value request is made while planning', async () => { + await inRepo(async (root) => { + const fetchMock = mock(fakeVercelFetch(planData())); + await buildVercelDiscoveryPlan(createVercelClient('tok', { fetch: fetchMock }), 'team_acme', findCandidateReposResult(root).repos, noPeek); + expect(callsOf(fetchMock).some((c) => /\/v1\/projects\/[^/]+\/env\//.test(c.url))).toBe(false); + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + }); + }); + + test('--environment filters before collision detection, and a folder left with nothing drops out', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, noPeek, ['staging']); + expect(plan.folders.map((f) => f.projectName)).toEqual(['web']); + expect(plan.folders[0].environments.map((env) => env.environmentName)).toEqual(['staging']); + expect(plan.environmentFilter).toEqual(['staging']); + expect(plan.allEnvironmentNames).toEqual(['preview', 'production', 'staging']); + }); + }); + + test('two projects on the same repo + folder + environment are a collision, naming both', async () => { + await inRepo(async (root) => { + const data = planData({ + projects: { team_acme: [project('prj_a', 'alpha'), project('prj_b', 'beta')] }, + envs: { prj_a: [e({ id: 'x1', key: 'K', type: 'plain', listValue: 'v' })], prj_b: [e({ id: 'y1', key: 'K', type: 'plain', listValue: 'v' })] }, + custom: {}, + }); + const plan = await buildVercelDiscoveryPlan(createVercelClient('tok', { fetch: fakeVercelFetch(data) }), 'team_acme', findCandidateReposResult(root).repos, noPeek); + expect(plan.collisions.length).toBe(1); + expect(plan.collisions[0]).toMatchObject({ folder: '', environmentName: 'production' }); + expect(plan.collisions[0].candidates.map((c) => c.serviceId).toSorted()).toEqual(['prj_a', 'prj_b']); + }); + }); + + test('an initialized folder previews checkout vs checkout -b from the server\'s branches; a new project previews no auto-overwrite on its first step', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const repos = findCandidateReposResult(root).repos; + const fresh = await buildVercelDiscoveryPlan(client, 'team_acme', repos, noPeek); + const freshWeb = fresh.folders[0]; + // order: staging, preview, production — staging is first in a brand-new project + expect(freshWeb.initialized).toBe(false); + expect(Object.fromEntries(freshWeb.environments.map((env) => [env.environmentName, env.willAutoOverwrite]))).toEqual({ staging: false, preview: true, production: true }); + + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'proj_x', project_name: 'widgets', variables: {} }; + const existing = await buildVercelDiscoveryPlan(client, 'team_acme', repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const web = existing.folders[0]; + expect(Object.fromEntries(web.environments.map((env) => [env.environmentName, env.branchExists]))).toEqual({ staging: false, preview: false, production: true }); + }); + }); +}); + +// A keep.lock whose `production` branch already holds a value for HIDDEN (hash only; the plan reads no plaintext). +const keepWithHidden = (placeholder: boolean): KeepFile => ({ + version: '3.0', + org_id: 'o', + project_id: 'proj_x', + project_name: 'widgets', + variables: { + HIDDEN: [ + { + resource_id: 'r_hidden', + branch: 'production', + value_hash: placeholder ? '' : 'hash-of-a-real-value', + ...(placeholder ? { placeholder: { provider: 'vercel', reason: 'sensitive_unreadable' } } : {}), + }, + ], + }, +}); + +describe('buildVercelDiscoveryPlan — names Vercel will not return where Capy has a value are kept', () => { + test('a name with a value on that branch moves from skipped to kept; the counts follow; other environments are unchanged', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const keep = keepWithHidden(false); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const web = plan.folders[0]; + const prod = web.environments.find((env) => env.environmentName === 'production')!; + expect(prod.kept).toEqual([{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(prod.skipped).toEqual([]); + expect([prod.variableCount, prod.skippedCount]).toEqual([2, 0]); + const preview = web.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.kept).toBeUndefined(); + expect(preview.skippedCount).toBe(1); + }); + }); + + test('a placeholder (no value) on that branch is not kept: it stays in the unreadable list as before', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const keep = keepWithHidden(true); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const prod = plan.folders[0].environments.find((env) => env.environmentName === 'production')!; + expect(prod.kept).toBeUndefined(); + expect([prod.skippedCount, prod.skipped]).toEqual([1, [{ name: 'HIDDEN', code: 'sensitive_unreadable' }]]); + }); + }); + + test('the confirm message counts them as kept, not skipped', async () => { + await inRepo(async (root) => { + const client = createVercelClient('tok', { fetch: fakeVercelFetch(planData()) }); + const keep = keepWithHidden(false); + const plan = await buildVercelDiscoveryPlan(client, 'team_acme', findCandidateReposResult(root).repos, { peekLocalKeep: () => keep, listServerBranches: async () => [{ name: 'production' }] }); + const message = discoveryConfirmMessage(plan.folders, false); + expect(message).toContain('(2 var(s), 0 skipped, 1 kept)'); + expect(message).toContain('(1 var(s), 1 skipped)'); // preview: no kept suffix when there are none + }); + }); + + test('--dry-run --json and the human preview show kept (names, code, reason), never a value', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keepWithHidden(false))); + const { connector } = connectorFor(root, planData()); + const json = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true }))); + const out = JSON.parse(json.stdout[0]); + const web = out.plan.folders.find((f: { vercelProjectId: string }) => f.vercelProjectId === 'prj_web'); + const prod = web.environments.find((env: { branch: string }) => env.branch === 'production'); + expect(prod).toMatchObject({ variableCount: 2, skippedCount: 0, keptCount: 1, kept: [{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }], skipped: {} }); + + const human = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true }))); + const text = human.stdout.join('\n'); + expect(text).toContain('2 var(s), 0 skipped, 1 kept'); + expect(text).toContain('kept (Capy value untouched): HIDDEN (sensitive_unreadable)'); + expect(text).not.toContain('skipped sensitive_unreadable: HIDDEN'); + ALL_SECRETS.forEach((v) => expect([...json.stdout, ...text].join('\n')).not.toContain(v)); + }), + ); + }); +}); + +describe('reportVercelDiscovery — the applied per-environment result carries kept', () => { + const outcomeWithKept = { + ok: true as const, + dryRun: false, + plan: { folders: [], collisions: [], unmatched: [], unreadableRepos: [], allEnvironmentNames: [] }, + applied: [ + { + ok: true as const, + repoDir: '/r', + folder: '', + projectCreated: false, + activeBranch: 'production', + environments: [ + { + environmentName: 'production', + branchCreated: false, + overwrote: false, + outcome: { + ok: true as const, + imported: [], + unchanged: [], + skipped: [], + placeholders: [], + kept: [{ name: 'HIDDEN', code: ERROR_CODES.CAPY_VALUE_KEPT, reason: 'sensitive_unreadable' }], + warnings: [], + }, + }, + ], + }, + ], + } as unknown as Parameters[0]; + + test('--json: kept sits beside placeholders, and it is not a change (nothing linked)', async () => { + const run = await captureConsole(async () => reportVercelDiscovery(outcomeWithKept, { json: true } as ConnectOpts, 'vercel')); + const env0 = JSON.parse(run.stdout[0]).applied[0].environments[0]; + expect(env0.kept).toEqual([{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(env0.skipped).toEqual([]); + expect(run.result.linked).toBe(false); + }); + + test('human: the per-environment line counts kept and names them', async () => { + const run = await captureConsole(async () => reportVercelDiscovery(outcomeWithKept, {} as ConnectOpts, 'vercel')); + const text = run.stdout.join('\n'); + expect(text).toContain('imported 0, unchanged 0, skipped 0, kept 1'); + expect(text).toContain('kept (Capy value untouched): HIDDEN'); + }); +}); + +// ── connector.discover / ConnectCommand.executeDiscovery ──────────────────── + +const fakeDiscoveryCtx = (over: Record = {}): DiscoveryContext => ({ + orgId: 'org_v', + userId: 'user_v', + authService: {} as unknown as DiscoveryContext['authService'], + serviceClient: { + listBranches: async () => [], + listProjects: async () => [], + ...over, + } as unknown as DiscoveryContext['serviceClient'], +}); + +const discoverOpts = (over: Partial = {}): ConnectOpts => ({ nonTty: true, tokenEnv: 'T', ...over }); + +// Bare `capy` in each discovered folder (sync/gate.ts) is the real thing unless a test supplies its own `syncFolder`; +// the default here reports every folder in sync (the gate itself is tested in tests/sync/gate.test.ts). +const connectorFor = (root: string, data: FakeVercelData, extra: Partial[0]> = {}) => { + const fetchMock = mock(fakeVercelFetch(data)); + const connector = createVercelConnector({ fetch: fetchMock as unknown as VercelFetchLike, env: { T: 'tok_fake' }, cwd: root, sleep: async () => {}, syncFolder: async () => ({ ok: true }), ...extra }); + return { connector, fetchMock }; +}; + +describe('connector.discover --dry-run', () => { + test('--json: ONE pure JSON object — folders with repo/folder/project name+id/environments/skipped-by-reason, plus unmatched, collisions, unreadableRepos; no value anywhere; nothing written', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector, fetchMock } = connectorFor(root, planData()); + const command = new ConnectCommand(false); + const run = await captureConsole(() => command['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true }))); + expect(run.stdout.length).toBe(1); + const out = JSON.parse(run.stdout[0]); + expect(out).toMatchObject({ ok: true, provider: 'vercel', dryRun: true, cancelled: false }); + expect(out.plan.team).toEqual({ id: 'team_acme', slug: 'acme' }); + + const web = out.plan.folders.find((f: { vercelProjectId: string }) => f.vercelProjectId === 'prj_web'); + expect(web).toMatchObject({ repoDir: root, folder: '.', vercelProject: 'web', vercelProjectId: 'prj_web', initialized: false }); + const prod = web.environments.find((env: { branch: string }) => env.branch === 'production'); + expect(prod).toMatchObject({ variableCount: 2, skippedCount: 1, skipped: { sensitive_unreadable: [{ name: 'HIDDEN' }] } }); + const preview = web.environments.find((env: { branch: string }) => env.branch === 'preview'); + expect(preview.skipped).toEqual({ git_branch_scoped: [{ name: 'BRANCHY', gitBranch: 'dev' }] }); + + expect(out.plan.unmatched.map((u: { vercelProjectId: string; reason: string }) => [u.vercelProjectId, u.reason]).toSorted()).toEqual( + [['prj_ghost', 'root_directory_missing'], ['prj_nolink', 'no_git_link'], ['prj_lab', 'unsupported_git_provider'], ['prj_other', 'no_remote_match'], ['prj_empty', 'no_variables']].toSorted(), + ); + expect(out.plan.collisions).toEqual([]); + expect(out.plan.unreadableRepos).toEqual([]); + // the local discovery commit is previewed, never made + expect(out.commits[0]).toMatchObject({ ok: true, dryRun: true }); + expect(out.commits[0].branch).toMatch(/^capy\/vercel-import-\d{8}-\d{4}$/); + + // the property: not one value on any surface + const everything = [...run.stdout, ...run.stderr].join('\n'); + [...ALL_SECRETS, 'preview-plain-SENT-66ff', 'app-plain-SENT-77aa', 'tok_fake'].forEach((v) => expect(everything).not.toContain(v)); + // read-only, no per-id value requests, nothing written to the repo + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + expect(callsOf(fetchMock).some((c) => /\/v1\/projects\/[^/]+\/env\//.test(c.url))).toBe(false); + expect(git(root, ['status', '--porcelain']).trim()).toBe(''); + expect(existsSync(join(root, 'keep.lock'))).toBe(false); + }), + ); + }); + + test('human output names Vercel projects and reasons, never a value', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector } = connectorFor(root, planData()); + const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true }))); + const text = run.stdout.join('\n'); + expect(text).toContain('Dry run'); + expect(text).toContain('web'); + expect(text).toContain('skipped sensitive_unreadable: HIDDEN'); + expect(text).toContain('skipped git_branch_scoped: BRANCHY (dev)'); + expect(text).toContain('root_directory_missing'); + expect(text).not.toContain('Dokploy'); + ALL_SECRETS.forEach((v) => expect(text).not.toContain(v)); + }), + ); + }); +}); + +describe('connector.discover — refusals carry codes', () => { + test('no token: VERCEL_TOKEN_MISSING, zero requests', async () => { + await inRepo(async (root) => { + const { connector, fetchMock } = connectorFor(root, planData(), { env: {} }); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true })); + expect(out.ok).toBe(false); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(fetchMock.mock.calls.length).toBe(0); + }); + }); + + test('several teams, non-interactive: VERCEL_TEAM_REQUIRED; --team picks one', async () => { + await inRepo(async (root) => { + const data = planData({ teams: [{ id: 'team_acme', slug: 'acme' }, { id: 'team_beta', slug: 'beta' }] }); + const { connector } = connectorFor(root, data); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true })); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_TEAM_REQUIRED); + const ok = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true, team: 'acme' })); + expect(ok.ok).toBe(true); + }); + }); + + test('--environment naming nothing the plan has: VERCEL_ENVIRONMENT_NOT_FOUND, listing what exists', async () => { + await inRepo(async (root) => { + const { connector } = connectorFor(root, planData()); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ dryRun: true, environment: 'qa' })); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND); + expect((out as { message: string }).message).toContain('staging'); + }); + }); + + test('a collision with no --project hint and no TTY: VERCEL_MAPPING_COLLISION, zero writes; with --project the named one wins', async () => { + await inRepo(async (root) => { + const data = planData({ + projects: { team_acme: [project('prj_a', 'alpha'), project('prj_b', 'beta')] }, + envs: { prj_a: [e({ id: 'x1', key: 'K', type: 'plain', listValue: 'va' })], prj_b: [e({ id: 'y1', key: 'K', type: 'plain', listValue: 'vb' })] }, + custom: {}, + }); + const { connector } = connectorFor(root, data); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts({ yes: true })); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_MAPPING_COLLISION); + expect(existsSync(join(root, 'keep.lock'))).toBe(false); + }); + }); + + test('with --project the named project wins the collision and is the one imported', async () => { + await withIsolatedGlobalDir(() => + inRepo(async (root) => { + const orgId = 'org_v_hint'; + const userId = 'user_v_hint'; + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, keyOps); + writeFileSync(join(root, 'keep.lock'), JSON.stringify({ version: '3.0', org_id: orgId, project_id: 'proj_hint', project_name: 'widgets', variables: {} })); + const data = planData({ + projects: { team_acme: [project('prj_a', 'alpha'), project('prj_b', 'beta')] }, + envs: { prj_a: [e({ id: 'x1', key: 'K', type: 'plain', listValue: 'va' })], prj_b: [e({ id: 'y1', key: 'K', type: 'plain', listValue: 'vb' })] }, + custom: {}, + }); + const ctx = { + ...fakeDiscoveryCtx({ + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + }), + orgId, + userId, + } as DiscoveryContext; + const { connector } = connectorFor(root, data); + const out = await connector.discover!(ctx, discoverOpts({ yes: true, noPush: true, project: 'beta' })); + expect(out.ok).toBe(true); + if (!out.ok) return; + const applied = (out.applied ?? []).filter((f) => f.ok); + expect(applied.map((f) => f.ok && f.environments.map((env) => env.outcome.imported.map((i) => i.value)))).toEqual([[['vb']]]); + }), + ); + }); + + test('a real run non-interactively without --yes: VERCEL_CONFIRMATION_REQUIRED, zero writes', async () => { + await inRepo(async (root) => { + const { connector } = connectorFor(root, planData()); + const out = await connector.discover!(fakeDiscoveryCtx(), discoverOpts()); + expect((out as { code: string }).code).toBe(ERROR_CODES.VERCEL_CONFIRMATION_REQUIRED); + expect(git(root, ['status', '--porcelain']).trim()).toBe(''); + }); + }); +}); + +// ── a real run: sequencer + FileManager + real git ────────────────────────── + +describe('connector.discover — a real run, end to end', () => { + const E2E_ENVS: ReadonlyArray = [ + e({ id: 'p1', key: 'A', type: 'plain', listValue: SECRET.aProd }), + e({ id: 'p2', key: 'SHARED', type: 'encrypted', listValue: 'opaque' }), + e({ id: 's1', key: 'B', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 's2', key: 'SHARED', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + e({ id: 'h1', key: 'HIDDEN', type: 'sensitive' }), + ]; + const e2eData = (): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: E2E_ENVS }, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, + values: { p2: SECRET.sharedProd, s1: SECRET.bStage, s2: SECRET.sharedStage, h1: SECRET.hidden }, + }); + + /** An initialized, committed, clean repo + the fake service the real sequencer talks to. */ + const prepareRealRun = async (root: string, orgId: string) => { + const userId = `${orgId}_user`; + const projectId = `${orgId}_proj`; + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, keyOps); + const keep: KeepFile = { version: '3.0', org_id: orgId, project_id: projectId, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + const pushSecrets = mock(async (_p: string, _keep: string, _env: string, _branch: string) => ({ keep_hash: 'h'.repeat(16) })); + const ctx = { + ...fakeDiscoveryCtx({ + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets, + }), + orgId, + userId, + } as DiscoveryContext; + return { ctx, pushSecrets, mainBranch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(), mainHead: git(root, ['rev-parse', 'HEAD']).trim() }; + }; + + test('--yes: staging then production; each branch is pushed EXACTLY its Vercel environment\'s set (decrypt-verified), the discovery commit is local on its own branch, nothing is pushed to git, and no value is printed', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const orgId = 'org_v_e2e'; + const userId = 'user_v_e2e'; + const projectId = 'proj_v_e2e'; + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, keyOps); + const projectKey = await resolveProjectKey(orgId, projectId, userId, keyOps); + + // An existing project whose folder is already committed and clean. + const keep: KeepFile = { version: '3.0', org_id: orgId, project_id: projectId, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + // `capy`'s own init also writes the ignore rules; a clean, committed pair is what a real initialized repo looks like. + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + const mainHead = git(root, ['rev-parse', 'HEAD']).trim(); + const mainBranch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); + + const pushSecrets = mock(async (_p: string, _keep: string, _env: string, _branch: string) => ({ keep_hash: 'h'.repeat(16) })); + const ctx = fakeDiscoveryCtx({ + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch: async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets, + }); + const ctxWithIds = { ...ctx, orgId, userId } as DiscoveryContext; + + const { connector, fetchMock } = connectorFor(root, e2eData()); + const run = await captureConsole(() => connector.discover!(ctxWithIds, discoverOpts({ yes: true }))); + const outcome = run.result; + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + const folder = outcome.applied?.[0]; + expect(folder?.ok).toBe(true); + if (!folder?.ok) return; + expect(folder.environments.map((env) => env.environmentName)).toEqual(['staging', 'production']); + expect(folder.activeBranch).toBe('production'); + + // every push, decrypted with the project key: branch → name/value set + const fm = new FileManager(root); + const pushed = Object.fromEntries( + pushSecrets.mock.calls.map(([, , envBlob, branch]) => [ + branch, + Object.fromEntries(Object.entries(fm.parseEnvContent(envBlob)).map(([k, v]) => [k, fm.decryptValue(v, projectKey)])), + ]), + ); + expect(pushed.staging).toEqual({ B: SECRET.bStage, SHARED: SECRET.sharedStage }); + // production was created from staging, then overwritten with ITS set: nothing of staging's B survives + expect(pushed.production).toEqual({ A: SECRET.aProd, SHARED: SECRET.sharedProd }); + expect(fm.readEncryptedEnvFile(projectKey)).toEqual({ A: SECRET.aProd, SHARED: SECRET.sharedProd }); + + // each imported entry's connector metadata names the project, team and environment + const stagingEntry = folder.environments.find((env) => env.environmentName === 'staging')!.outcome.imported.find((i) => i.varName === 'B')!.entry; + expect(stagingEntry).toMatchObject({ provider: 'vercel', vercel_project_id: 'prj_web', vercel_project: 'web', team_id: 'team_acme', environment: 'staging', env_id: 's1' }); + expect(outcome.applied?.[0].ok && (outcome.applied[0].environments[1].outcome.skipped)).toEqual([]); + expect(outcome.applied?.[0].ok && (outcome.applied[0].environments[1].outcome.placeholders)).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable', provider: 'vercel' }]); + + // the local discovery commit: its own branch, the Vercel message, never pushed + expect(outcome.commits?.[0]).toMatchObject({ ok: true, dryRun: false }); + const commit = outcome.commits?.[0]; + if (commit?.ok && !commit.dryRun) { + expect(commit.branch).toMatch(/^capy\/vercel-import-/); + expect(git(root, ['log', '-1', '--format=%s', commit.branch]).trim()).toBe('chore(capy): import Vercel environments'); + } + expect(git(root, ['rev-parse', mainBranch]).trim()).toBe(mainHead); + expect(git(root, ['remote']).trim()).toBe('origin'); // a remote exists, and nothing pushed to it: this run has no push step for git at all + + // no deploy target is created non-interactively (Dokploy discovery doesn't either) + expect(existsSync(join(root, '.capy', 'deploy.json'))).toBe(false); + + // read-only on Vercel; values only ever read via the per-id route + expect(callsOf(fetchMock).every((c) => c.method === 'GET')).toBe(true); + const printed = [...run.stdout, ...run.stderr].join('\n'); + ALL_SECRETS.forEach((v) => expect(printed).not.toContain(v)); + // the working tree has the `.env` ciphertext only + expect(readFileSync(join(root, '.env'), 'utf-8')).not.toContain(SECRET.aProd); + }), + ), + ); + }, 30_000); + + test('a run writes no deploy.json (connect writes no target) and the commit holds keep.lock and .gitignore only', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + inRepo(async (root) => { + const { ctx } = await prepareRealRun(root, 'org_v_notargets'); + const outcome = await connectorFor(root, e2eData()).connector.discover!(ctx, discoverOpts({ yes: true })); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(existsSync(join(root, '.capy', 'deploy.json'))).toBe(false); + const commit = outcome.commits?.[0]; + if (commit?.ok && !commit.dryRun) expect([...commit.committedFiles].toSorted()).toEqual(['.gitignore', 'keep.lock']); + }), + ), + ); + }, 30_000); + + test('--dry-run reports no targets and previews no deploy.json', async () => { + await withExitCodeRestored(() => + inRepo(async (root) => { + const { connector } = connectorFor(root, e2eData()); + const run = await captureConsole(() => new ConnectCommand(false)['executeDiscovery'](connector, 'vercel', fakeDiscoveryCtx(), discoverOpts({ dryRun: true, json: true }))); + const out = JSON.parse(run.stdout[0]); + expect(out.deployTargets).toBeUndefined(); + expect(out.commits[0].wouldCommitFiles).not.toContain('.capy/deploy.json'); + }), + ); + }); + +}); diff --git a/tests/commands/connectVercelEmptyBranch.test.ts b/tests/commands/connectVercelEmptyBranch.test.ts new file mode 100644 index 00000000..3f2a8744 --- /dev/null +++ b/tests/commands/connectVercelEmptyBranch.test.ts @@ -0,0 +1,203 @@ +/** + * `capy connect vercel --discover` on a Vercel environment that leaves its Capy branch with no value. + * + * Two shapes, both of which used to upload an empty `env_blob` and be refused by the service (400 + * "keep_file and env_blob are required"): + * - a placeholder-only branch: every name in the environment is one Vercel will not return (sensitive), so keep.lock + * gets the placeholders and the blob holds no line; + * - an emptied branch: `--overwrite` on a branch that had values, in an environment with nothing readable left. + * + * The service here is the in-memory one of tests/helpers/fakeCapyServer.ts, which refuses an empty payload the way the + * real one does, and keeps what was saved so that a second run pulls it back (checkout of an existing branch). + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { randomBytes } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import type { DiscoveryContext, DiscoveryOutcome } from '../../src/commands/connectors/dokployDiscovery'; +import type { ConnectOpts } from '../../src/commands/connectors/registry'; +import { placeholdersOnBranch } from '../../src/core/placeholder'; +import { FileManager } from '../../src/files/fileManager'; +import { resolveProjectKey, wrapAndSaveMasterKey } from '../../src/crypto/keyResolver'; +import { ProjectManager } from '../../src/core/projectManager'; +import { EMPTY_ENV_BLOB } from '../../src/sync/commit'; +import type { KeepFile } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeCapyServer, serverValues } from '../helpers/fakeCapyServer'; +import { fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, git, initRepo, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const ORG = 'org_empty'; +const USER = 'user_empty'; +const PROJECT = 'proj_empty'; + +const e = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +/** staging has B, production has A; `preview` is whatever the test puts there. */ +const vercelData = (preview: readonly RawEnv[]): FakeVercelData => ({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { + prj_web: [ + e({ id: 'p1', key: 'A', type: 'plain', listValue: 'a-value-SENT-11aa' }), + e({ id: 's1', key: 'B', type: 'encrypted', target: [], customEnvironmentIds: ['c_stage'], listValue: 'opaque' }), + ...preview, + ], + }, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, + values: { s1: 'b-value-SENT-22bb' }, +}); + +const PLACEHOLDER_PREVIEW = [e({ id: 'h1', key: 'HIDDEN', type: 'sensitive', target: ['preview'] })]; +const VALUE_PREVIEW = [e({ id: 'x1', key: 'X', type: 'plain', target: ['preview'], listValue: 'x-value-SENT-33cc' })]; +const NOTHING_READABLE_PREVIEW = [e({ id: 'bs1', key: 'BRANCHY', type: 'encrypted', target: ['preview'], gitBranch: 'dev', listValue: 'opaque' })]; + +type Outcome = DiscoveryOutcome; + +/** A repo with a committed keep.lock, and the service the real sequencer talks to; `run` discovers against a given Vercel. */ +async function inWorld( + fn: (w: { + root: string; + server: ReturnType; + projectKey: string; + run: (data: FakeVercelData, opts?: Partial) => Promise; + }) => Promise, +): Promise { + return withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-empty', async (root) => { + initRepo(root, 'git@github.com:acme/widgets.git'); + const keyOps = { coDecrypt: async (_o: string, ct: string) => ct, wrapOuterLayer: async (_o: string, pt: string) => pt }; + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, keyOps); + const projectKey = await resolveProjectKey(ORG, PROJECT, USER, keyOps); + const keep: KeepFile = { version: '3.0', org_id: ORG, project_id: PROJECT, project_name: 'widgets', variables: {} }; + writeFileSync(join(root, 'keep.lock'), JSON.stringify(keep)); + new FileManager(root).ensureCapyGitignore(); + git(root, ['add', 'keep.lock', '.gitignore']); + git(root, ['commit', '-q', '-m', 'add keep.lock']); + + const server = fakeCapyServer(); + /** Branches the service was asked to create: the record of the mock is the registry. */ + const createBranch = mock(async (pid: string, name: string) => ({ id: 'b', name, project_id: pid, is_protected: false })); + const discover = async (data: FakeVercelData, opts: Partial = {}): Promise => { + const ctx = { + orgId: ORG, + userId: USER, + authService: {}, + serviceClient: { + ...server.client, + listBranches: async () => [...new Set(createBranch.mock.calls.map(([, name]) => name))].map((name) => ({ name })), + listProjects: async () => [], + coDecrypt: async (_o: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_o: string, pt: string) => ({ ciphertext: pt }), + createBranch, + }, + } as unknown as DiscoveryContext; + const connector = createVercelConnector({ + fetch: fakeVercelFetch(data) as unknown as VercelFetchLike, + env: { T: 'tok_fake' }, + cwd: root, + sleep: async () => {}, + syncFolder: async () => ({ ok: true }), + }); + const run = await captureConsole(() => connector.discover!(ctx, { nonTty: true, tokenEnv: 'T', yes: true, ...opts } as ConnectOpts)); + return run.result; + }; + return fn({ root, server, projectKey, run: discover }); + }), + ), + ); +} + +const appliedFolder = (outcome: Outcome) => { + expect(outcome.ok).toBe(true); + if (!outcome.ok) throw new Error('unreachable'); + const folder = outcome.applied?.[0]; + if (folder === undefined) throw new Error('no folder applied'); + return folder; +}; + +describe('a placeholder-only branch (every name in the environment is one Vercel will not return)', () => { + test('the upload is accepted: a non-empty env_blob and a keep_file carrying the placeholder; no value anywhere', async () => { + await inWorld(async ({ server, projectKey, run }) => { + const folder = appliedFolder(await run(vercelData(PLACEHOLDER_PREVIEW))); + expect(folder).toMatchObject({ ok: true }); + if (!folder.ok) return; + expect(folder.environments.map((env) => env.environmentName)).toEqual(['staging', 'preview', 'production']); + + const preview = folder.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.outcome.imported).toEqual([]); + expect(preview.outcome.placeholders).toEqual([{ name: 'HIDDEN', code: 'sensitive_unreadable', provider: 'vercel' }]); + + const pushes = server.accepted().filter((c) => c.branch === 'preview'); + expect(pushes.length).toBeGreaterThan(0); + pushes.forEach((c) => { + expect(c.envBlob.length).toBeGreaterThan(0); + expect(c.keepFile.length).toBeGreaterThan(0); + expect(() => JSON.parse(c.keepFile)).not.toThrow(); + }); + const lastPreview = pushes[pushes.length - 1]; + expect(lastPreview.envBlob).toBe(EMPTY_ENV_BLOB); + expect(placeholdersOnBranch(JSON.parse(lastPreview.keepFile) as KeepFile, 'preview').map((p) => p.name)).toEqual(['HIDDEN']); + + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({}); + expect(server.tape.mock.calls.every(([c]) => (c as { envBlob: string }).envBlob.length > 0)).toBe(true); + }); + }, 60_000); + + test('round-trips: a second run checks the saved branch out (a pull of an empty-env blob) without an error, and the placeholder is still there', async () => { + await inWorld(async ({ root, server, projectKey, run }) => { + const first = appliedFolder(await run(vercelData(PLACEHOLDER_PREVIEW))); + expect(first.ok).toBe(true); + + const second = appliedFolder(await run(vercelData(PLACEHOLDER_PREVIEW))); + expect(second).toMatchObject({ ok: true }); + if (!second.ok) return; + const preview = second.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.branchCreated).toBe(false); // pulled from the server, not made again + + // checking `preview` out again reads its saved blob: the folder holds no variable and the placeholder + const checkedOut = await run(vercelData(PLACEHOLDER_PREVIEW), { environment: 'preview' }); + expect(appliedFolder(checkedOut)).toMatchObject({ ok: true, activeBranch: 'preview' }); + expect(new FileManager(root).readEncryptedEnvFile(projectKey)).toEqual({}); + expect(placeholdersOnBranch(new ProjectManager(root).readKeepFile(), 'preview').map((p) => p.name)).toEqual(['HIDDEN']); + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({}); + }); + }, 60_000); +}); + +describe('an emptied branch (--overwrite on a branch that had values, in an environment with nothing readable left)', () => { + test('the upload is accepted: a non-empty env_blob, and the branch reads back with no variable', async () => { + await inWorld(async ({ server, projectKey, run }) => { + const first = appliedFolder(await run(vercelData(VALUE_PREVIEW))); + expect(first.ok).toBe(true); + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({ X: 'x-value-SENT-33cc' }); + + const second = appliedFolder(await run(vercelData(NOTHING_READABLE_PREVIEW), { overwrite: true })); + expect(second).toMatchObject({ ok: true }); + if (!second.ok) return; + const preview = second.environments.find((env) => env.environmentName === 'preview')!; + expect(preview.outcome.cleared).toEqual(['X']); + + const lastPreview = server.accepted().filter((c) => c.branch === 'preview').pop()!; + expect(lastPreview.envBlob).toBe(EMPTY_ENV_BLOB); + expect(lastPreview.keepFile.length).toBeGreaterThan(0); + expect(serverValues(server.state(), 'preview', projectKey)).toEqual({}); + expect(server.tape.mock.calls.every(([c]) => (c as { envBlob: string }).envBlob.length > 0)).toBe(true); + }); + }, 60_000); + + test('round-trips: checking the emptied branch out again pulls an empty env with no error', async () => { + await inWorld(async ({ root, projectKey, run }) => { + await run(vercelData(VALUE_PREVIEW)); + await run(vercelData(NOTHING_READABLE_PREVIEW), { overwrite: true }); + + const again = appliedFolder(await run(vercelData(NOTHING_READABLE_PREVIEW), { environment: 'preview' })); + expect(again).toMatchObject({ ok: true, activeBranch: 'preview' }); + expect(new FileManager(root).readEncryptedEnvFile(projectKey)).toEqual({}); + }); + }, 60_000); +}); diff --git a/tests/commands/connectVercelFlags.test.ts b/tests/commands/connectVercelFlags.test.ts new file mode 100644 index 00000000..f97c96bb --- /dev/null +++ b/tests/commands/connectVercelFlags.test.ts @@ -0,0 +1,52 @@ +/** + * The `capy connect vercel` flags, through the real CLI program: + * - `--create-token` and `--token-expires-days` are gone — an unknown option + * gets the CLI's normal refusal (exit 1), and neither is in `--help`; + * - `--targets` is gone too: connect writes no deploy target (`capy deploy vercel --discover` does). + * + * Spawns `src/index.ts` with an empty HOME, so nothing on this machine is read. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const ENTRY = join(import.meta.dir, '..', '..', 'src', 'index.ts'); + +const capy = (args: readonly string[]) => { + const home = mkdtempSync(join(tmpdir(), 'capy-flags-home-')); + try { + const r = spawnSync(process.execPath, ['run', ENTRY, ...args], { + encoding: 'utf-8', + env: { PATH: process.env.PATH ?? '', HOME: home }, + }); + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; + } finally { + rmSync(home, { recursive: true, force: true }); + } +}; + +describe('capy connect vercel — flags', () => { + test.each([['--create-token'], ['--token-expires-days']])('%s is an unknown option: the normal refusal, exit 1', (flag) => { + const r = capy(['connect', 'vercel', flag, ...(flag === '--create-token' ? [] : ['3'])]); + expect(r.status).toBe(1); + expect(r.stderr).toContain(flag); + expect(r.stdout).toBe(''); + }); + + test('--targets is an unknown option too: connect imports values and writes no deploy target', () => { + const r = capy(['connect', 'vercel', '--targets']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('--targets'); + expect(r.stdout).toBe(''); + }); + + test('--help lists none of the removed flags', () => { + const r = capy(['connect', '--help']); + expect(r.status).toBe(0); + expect(r.stdout).not.toContain('--targets'); + expect(r.stdout).not.toContain('--create-token'); + expect(r.stdout).not.toContain('--token-expires-days'); + }); +}); diff --git a/tests/commands/connectVercelTokenProblem.test.ts b/tests/commands/connectVercelTokenProblem.test.ts new file mode 100644 index 00000000..fe0d0bec --- /dev/null +++ b/tests/commands/connectVercelTokenProblem.test.ts @@ -0,0 +1,351 @@ +/** + * A Vercel token problem stops a run with the real reason and the next step. + * + * - 401 stays `VERCEL_AUTH_FAILED` (the token is invalid or expired). + * - 403 is `VERCEL_NO_ACCESS` and names what the token could not access: the + * team list, a project, or a project's environment variables. + * - Both carry the token page and two numbered steps (`link` + `remedy`). + * - At a terminal the steps end "(or press enter to continue)": Enter asks + * for a new token (saved over the old one) and runs again from the token + * step; one press is one retry, so a token that fails again shows the steps + * again and waits again. Anywhere else nothing waits. + * - `--discover` needs a token scoped to the whole team. + * + * Fake tokens only. No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { createVercelClient, type VercelFetchLike } from '../../src/deploy/vercelApi'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { ERROR_CODES, CapyError, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData } from '../helpers/fakeVercel'; +import { makeVercelWorld } from '../helpers/deployDiscoverVercelWorld'; +import { describeVercelFailure } from '../../src/deploy/vercelTargetApi'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { captureConsole, withExitCodeRestored, withTTY } from '../helpers/testSandbox'; + +const PAGE = 'https://vercel.com/account/settings/tokens'; +const STEP_1 = `1) Create a token with team access at ${PAGE}`; +const STEP_2 = '2) Run capy system set _CONNECTOR_VERCEL_TOKEN'; +const STEP_2_ENTER = `${STEP_2} (or press enter to continue)`; +const OLD_TOKEN = 'tok_old_fake_value'; +const NEW_TOKEN = 'tok_new_fake_value'; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: [{ id: 'env_1', key: 'PLAIN_PROD', type: 'plain', target: ['production'], listValue: 'v-1' }] }, + custom: { prj_web: [] }, +}; + +const ctxWith = (): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} } as KeepFile, + branch: 'production', + orgId: 'org_1', + localPlaintext: {}, + }) as unknown as ResolvedContext; + +const denied = { status: 403, ok: false, headers: { get: () => null }, text: async () => '{}' }; + +/** The fake Vercel, except that every path matching `deny` answers 403. */ +const denying = (deny: RegExp): VercelFetchLike => { + const inner = fakeVercelFetch(DATA); + return (async (url: string, init: never) => (deny.test(new URL(url).pathname) ? denied : inner(url, init))) as VercelFetchLike; +}; + +/** Accepts only `accepted` as the bearer token; any other token is `status`. */ +const acceptingOnly = (accepted: string, status: 401 | 403): VercelFetchLike => { + const inner = fakeVercelFetch(DATA); + return (async (url: string, init: { headers: Record }) => + init.headers.Authorization === `Bearer ${accepted}` ? inner(url, init as never) : { ...denied, status }) as VercelFetchLike; +}; + +const BASE: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; +const failure = (o: ImportOutcome): Extract => { + if (o.ok) throw new Error('expected a refusal'); + return o; +}; + +describe('a token problem names the reason and carries the two steps', () => { + test('401 stays VERCEL_AUTH_FAILED: same code and message as before, plus the link and the two steps', async () => { + const connector = createVercelConnector({ fetch: acceptingOnly('other', 401), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_AUTH_FAILED); + expect(out.message).toBe('Vercel rejected the token (HTTP 401).'); + expect(out.link?.url).toBe(PAGE); + expect(out.remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test('403 on the team list: VERCEL_NO_ACCESS naming the team list', async () => { + const connector = createVercelConnector({ fetch: denying(/^\/v2\/teams$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(out.message).toBe('Vercel denied access to the team list (HTTP 403).'); + expect(out.link?.url).toBe(PAGE); + expect(out.remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test('403 on the project: VERCEL_NO_ACCESS naming the project', async () => { + const connector = createVercelConnector({ fetch: denying(/^\/v9\/projects\/web$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(out.message).toBe('Vercel denied access to project web (HTTP 403).'); + expect(out.remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test("403 on the project's environment variables: VERCEL_NO_ACCESS naming them, for the list and for a value", async () => { + const list = createVercelConnector({ fetch: denying(/^\/v10\/projects\/prj_web\/env$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const listed = failure(await list.import!(ctxWith(), BASE)); + expect(listed.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(listed.message).toBe('Vercel denied access to the environment variables of project prj_web (HTTP 403).'); + + const withEncrypted: FakeVercelData = { ...DATA, envs: { prj_web: [{ id: 'env_2', key: 'ENC', type: 'encrypted', target: ['production'], listValue: 'opaque' }] } }; + const inner = fakeVercelFetch(withEncrypted); + const valueDenied = (async (url: string, init: never) => (/\/v1\/projects\//.test(url) ? denied : inner(url, init))) as VercelFetchLike; + const value = createVercelConnector({ fetch: valueDenied, env: { T: OLD_TOKEN }, sleep: async () => {} }); + const read = failure(await value.import!(ctxWith(), BASE)); + expect(read.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(read.message).toBe('Vercel denied access to the environment variables of project prj_web (HTTP 403).'); + }); + + test('a 500 is still VERCEL_API_ERROR, with no steps', async () => { + const down: VercelFetchLike = async () => ({ status: 500, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: down, env: { T: OLD_TOKEN }, sleep: async () => {} }); + const out = failure(await connector.import!(ctxWith(), BASE)); + expect(out.code).toBe(ERROR_CODES.VERCEL_API_ERROR); + expect(out.remedy).toBeUndefined(); + }); + + test('--discover: a 403 on the team list or the project list says discovery needs a token scoped to the whole team', async () => { + const teamsDenied = createVercelConnector({ fetch: denying(/^\/v2\/teams$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const a = await teamsDenied.discover!({ orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, { nonTty: true, discover: true, tokenEnv: 'T' }); + expect(a).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + message: 'Vercel denied access to the team list (HTTP 403). Discovery needs a token scoped to the whole team.', + }); + + const projectsDenied = createVercelConnector({ fetch: denying(/^\/v10\/projects$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const b = await projectsDenied.discover!( + { orgId: 'o', userId: 'u', authService: {}, serviceClient: { listBranches: async () => [] } } as never, + { nonTty: true, discover: true, tokenEnv: 'T', yes: true }, + ); + expect(b).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + message: 'Vercel denied access to the project list (HTTP 403). Discovery needs a token scoped to the whole team.', + }); + expect((b as { remedy?: string }).remedy).toBe(`${STEP_1}\n${STEP_2}`); + }); + + test('capy deploy vercel --discover: a 403 uses the same code, the whole-team message, and the steps', async () => { + const world = makeVercelWorld({ + io: { vercel: (token) => createVercelClient(token, { fetch: (async () => denied) as never, sleep: async () => {}, dropValues: true }) }, + }); + const result = await runDeployVercelDiscover({}, world.io); + expect(result.exitCode).toBe(1); + expect(result.body).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + error: 'Vercel denied access to the team list (HTTP 403). Discovery needs a token scoped to the whole team.', + link: { url: PAGE }, + remedy: `${STEP_1}\n${STEP_2}`, + }); + }); + + test('capy deploy vercel --discover: a 401 stays VERCEL_AUTH_FAILED', async () => { + const world = makeVercelWorld({ acceptedToken: 'something-else' }); + const result = await runDeployVercelDiscover({}, world.io); + expect(result.body).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_AUTH_FAILED, remedy: `${STEP_1}\n${STEP_2}` }); + }); +}); + +describe('at a terminal: Enter replaces the token and runs again', () => { + /** A connector whose store hands out `OLD_TOKEN` then `tokens`, one per read, and a Vercel that accepts only `accepted`. */ + function interactive(args: { accepted: string; status?: 401 | 403; reads: readonly string[]; presses: readonly boolean[]; replaced?: string | null }) { + const reads = mock(async (_n: string, _o: unknown) => null as string | null); + args.reads.slice(0, -1).forEach((t) => reads.mockResolvedValueOnce(t)); + reads.mockResolvedValue(args.reads[args.reads.length - 1]); + const presses = mock(async () => false); + args.presses.forEach((p) => presses.mockResolvedValueOnce(p)); + const replace = mock(async (_n: string, _o: unknown) => (args.replaced === undefined ? NEW_TOKEN : args.replaced)); + const fetchMock = mock(acceptingOnly(args.accepted, args.status ?? 403)); + const connector = createVercelConnector({ + fetch: fetchMock as unknown as VercelFetchLike, + env: {}, + sleep: async () => {}, + getConnectorSecret: reads, + replaceConnectorSecret: replace, + waitForEnter: presses, + selectVars: async (names) => names, + confirm: async () => false, + }); + return { connector, reads, presses, replace, fetchMock }; + } + const run = (connector: ReturnType, over: Partial = {}) => + withTTY(() => captureConsole(() => connector.import!(ctxWith(), { nonTty: false, project: 'web', ...over }))); + + test('the reason, then the two steps in order with the link and the press-enter clause, then waits', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [false] }); + const { result, stderr } = await run(t.connector); + const text = stderr.join('\n'); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(text).toContain('Vercel denied access to the team list (HTTP 403).'); + expect(text.indexOf(STEP_1)).toBeGreaterThan(text.indexOf('Vercel denied access')); + expect(text.indexOf(STEP_2_ENTER)).toBeGreaterThan(text.indexOf(STEP_1)); + expect(text).toContain(PAGE); + expect(t.presses).toHaveBeenCalledTimes(1); + }); + + test('Enter → hidden prompt for a new token → saved → run again with it, and the run succeeds', async () => { + const t = interactive({ accepted: NEW_TOKEN, reads: [OLD_TOKEN, NEW_TOKEN], presses: [true] }); + const { result, stderr } = await run(t.connector); + expect(result.ok).toBe(true); + expect(t.presses).toHaveBeenCalledTimes(1); + expect(t.replace).toHaveBeenCalledTimes(1); + expect(t.replace.mock.calls[0][0]).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(t.replace.mock.calls[0][1]).toMatchObject({ orgId: 'org_1', interactive: true }); + const bearers = callsOf(t.fetchMock).map((c) => c.authorization); + expect(bearers[0]).toBe(`Bearer ${OLD_TOKEN}`); + expect(bearers[bearers.length - 1]).toBe(`Bearer ${NEW_TOKEN}`); + expect(stderr.join('\n')).not.toContain(NEW_TOKEN); + }); + + test('Enter → the new token fails too → the steps are shown again and it waits again; no loop without another press', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN, NEW_TOKEN], presses: [true, false] }); + const { result, stderr } = await run(t.connector); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(t.presses).toHaveBeenCalledTimes(2); + expect(t.replace).toHaveBeenCalledTimes(1); + expect(stderr.filter((l) => l.includes(STEP_2_ENTER))).toHaveLength(2); + expect(stderr.filter((l) => l.includes('Vercel denied access to the team list')).length).toBe(2); + }); + + test('every press is one more retry: two presses, two replacements', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN, 'tok_b', 'tok_c'], presses: [true, true, false] }); + await run(t.connector); + expect(t.presses).toHaveBeenCalledTimes(3); + expect(t.replace).toHaveBeenCalledTimes(2); + }); + + test('a 401 offers the same Enter', async () => { + const t = interactive({ accepted: NEW_TOKEN, status: 401, reads: [OLD_TOKEN, NEW_TOKEN], presses: [true] }); + const { result, stderr } = await run(t.connector); + expect(result.ok).toBe(true); + expect(stderr.join('\n')).toContain('Vercel rejected the token (HTTP 401).'); + expect(stderr.join('\n')).toContain(STEP_2_ENTER); + }); + + test('abort (Ctrl-C): the same coded refusal, nothing replaced', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [false] }); + const { result } = await run(t.connector); + expect(failure(result)).toMatchObject({ code: ERROR_CODES.VERCEL_NO_ACCESS, link: { url: PAGE }, remedy: `${STEP_1}\n${STEP_2}` }); + expect(t.replace).not.toHaveBeenCalled(); + }); + + test('an empty answer at the new-token prompt stops with a refusal and does not retry', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [true], replaced: null }); + const { result } = await run(t.connector); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(callsOf(t.fetchMock).every((c) => c.authorization === `Bearer ${OLD_TOKEN}`)).toBe(true); + }); + + test("a non-admin: the store's own coded refusal is returned, not silence", async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [true] }); + const refusing = createVercelConnector({ + fetch: t.fetchMock as unknown as VercelFetchLike, + env: {}, + getConnectorSecret: t.reads, + waitForEnter: t.presses, + replaceConnectorSecret: async () => { + throw new CapyError('owners/admins only', ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY); + }, + }); + const { result } = await run(refusing); + expect(failure(result)).toMatchObject({ code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY, message: 'owners/admins only' }); + }); + + test('--discover offers the same Enter and retries the whole discovery', async () => { + const t = interactive({ accepted: 'never', reads: [OLD_TOKEN], presses: [false] }); + const { result, stderr } = await withTTY(() => + captureConsole(() => + t.connector.discover!({ orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, { nonTty: false, discover: true }), + ), + ); + expect(result).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_NO_ACCESS }); + expect(stderr.join('\n')).toContain('Discovery needs a token scoped to the whole team.'); + expect(stderr.join('\n')).toContain(STEP_2_ENTER); + expect(t.presses).toHaveBeenCalledTimes(1); + }); +}); + +describe('anywhere else: nothing waits, and the steps have no press-enter clause', () => { + const never = mock(async () => true); + const replace = mock(async () => NEW_TOKEN); + const withDeps = (env: Record) => + createVercelConnector({ + fetch: acceptingOnly('never', 403), + env, + sleep: async () => {}, + getConnectorSecret: async () => OLD_TOKEN, + replaceConnectorSecret: replace, + waitForEnter: never, + }); + + test('--non-tty', async () => { + const { result, stderr } = await captureConsole(() => withDeps({}).import!(ctxWith(), { nonTty: true, project: 'web' })); + expect(failure(result).remedy).toBe(`${STEP_1}\n${STEP_2}`); + expect(failure(result).remedy).not.toContain('press enter'); + expect(stderr).toEqual([]); + expect(never).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + }); + + test('--json and --dry-run at a terminal', async () => { + const json = await withTTY(() => captureConsole(() => withDeps({}).import!(ctxWith(), { nonTty: false, project: 'web', json: true }))); + const dry = await withTTY(() => captureConsole(() => withDeps({}).import!(ctxWith(), { nonTty: false, project: 'web', dryRun: true }))); + expect(failure(json.result).remedy).toBe(`${STEP_1}\n${STEP_2}`); + expect(failure(dry.result).remedy).toBe(`${STEP_1}\n${STEP_2}`); + expect([...json.stderr, ...dry.stderr]).toEqual([]); + expect(never).not.toHaveBeenCalled(); + }); + + test('a token that came from the environment, not the store, is not replaceable here', async () => { + const connector = createVercelConnector({ + fetch: acceptingOnly('never', 403), + env: { T: OLD_TOKEN }, + sleep: async () => {}, + replaceConnectorSecret: replace, + waitForEnter: never, + }); + const { result } = await withTTY(() => captureConsole(() => connector.import!(ctxWith(), { nonTty: false, project: 'web', tokenEnv: 'T' }))); + expect(failure(result).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(never).not.toHaveBeenCalled(); + }); +}); + +describe('the same steps, printed', () => { + test('connect: JSON carries the link and the two steps in `remedy`; the terminal prints one indented line per step', async () => { + const connector = createVercelConnector({ fetch: denying(/^\/v2\/teams$/), env: { T: OLD_TOKEN }, sleep: async () => {} }); + const opts: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; + const json = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...opts, json: true }))); + expect(JSON.parse(json.stdout[0])).toMatchObject({ + ok: false, + code: ERROR_CODES.VERCEL_NO_ACCESS, + link: { url: PAGE }, + remedy: `${STEP_1}\n${STEP_2}`, + }); + const human = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, 'vercel', ctxWith(), { ...opts, json: false }))); + expect(human.stderr.join('\n')).toContain(` ${STEP_1}\n ${STEP_2}`); + }); + + test('deploy: a 403 on the deploy token names the deploy-side secret in step 2 and carries the link', () => { + const described = describeVercelFailure(ERROR_CODES.VERCEL_NO_ACCESS, 403); + expect(described.reason).toBe('the Vercel token has no access to this project (HTTP 403)'); + expect(described.link?.url).toBe(PAGE); + expect(described.remedy).toBe(`${STEP_1}\n2) Run capy system set _TARGET_VERCEL_TOKEN`); + }); +}); diff --git a/tests/commands/deployDiscoverVercelConfirm.test.ts b/tests/commands/deployDiscoverVercelConfirm.test.ts new file mode 100644 index 00000000..24c94ba2 --- /dev/null +++ b/tests/commands/deployDiscoverVercelConfirm.test.ts @@ -0,0 +1,298 @@ +/** + * `capy deploy vercel --discover --plan --confirm ` (CAP-696): writing a confirmed plan. + * + * The properties under test: a plan that moved since it was confirmed does NOTHING (`PLAN_CHANGED`, not + * even a request to Vercel); each Capy project's `.capy/deploy.json` target is exactly the shape the Vercel + * setup saves, merged into the repo's file without touching its other targets, in ONE PR per repo; a + * monorepo's root directory shows up in `projectDir`; target names are unique per file; a failure in one + * repo does not stop the others; running the plan again after the PRs merged opens nothing; and no value + * reaches GitHub or stdout. + * + * Fakes only (`tests/helpers/deployDiscoverVercelWorld.ts`): the "GitHub" records what it was asked to write. + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { writtenBy } from '../helpers/deployDiscoverWorld'; +import { SENTINEL_CAPY } from '../helpers/deployDiscoverWorld'; +import { + ENTRY_CFG, + ENTRY_DUO_A, + ENTRY_DUO_B, + ENTRY_MADMIN, + ENTRY_MWEB, + ENTRY_PERSONAL, + ENTRY_SHOP, + ENTRY_SHOP_FEATURE, + ENTRY_SHOP_PREVIEW, + ENTRY_SHOP_STAGING, + EXISTING_MONO_DEPLOY, + GITHUB_REPOS, + SENTINEL_VERCEL, + VERCEL_TOKEN, + VercelWorld, + makeVercelWorld, + planFile, +} from '../helpers/deployDiscoverVercelWorld'; + +const PLAN = 'plan.json'; +const ENTRIES = [ENTRY_SHOP, ENTRY_SHOP_PREVIEW, ENTRY_SHOP_FEATURE, ENTRY_SHOP_STAGING, ENTRY_MWEB, ENTRY_MADMIN, ENTRY_DUO_A, ENTRY_DUO_B, ENTRY_PERSONAL]; + +async function run(text: string, opts: { dryRun?: boolean; confirm?: string }, world: VercelWorld = makeVercelWorld({ files: { [PLAN]: text } })) { + const result = await runDeployVercelDiscover( + { plan: PLAN, ...(opts.dryRun === undefined ? {} : { dryRun: opts.dryRun }), ...(opts.confirm === undefined ? {} : { confirm: opts.confirm }) }, + world.io, + ); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +/** The plan_id a dry run prints: what a human approved. */ +async function approvedId(text: string, world?: VercelWorld): Promise { + return (await run(text, { dryRun: true }, world)).body.plan_id; +} + +const nothingWritten = (world: VercelWorld): void => { + expect(world.github.createBlob).not.toHaveBeenCalled(); + expect(world.github.createTree).not.toHaveBeenCalled(); + expect(world.github.createCommit).not.toHaveBeenCalled(); + expect(world.github.createRef).not.toHaveBeenCalled(); + expect(world.github.createPull).not.toHaveBeenCalled(); +}; + +const fileOf = (world: VercelWorld, repo: string, path: string) => JSON.parse(writtenBy(world.github).commits.find((c) => c.repo === repo)?.files[path] as string); + +describe('confirm: a plan that moved is refused, and nothing is done', () => { + test('a wrong plan_id: PLAN_CHANGED, no request to Vercel, Capy or GitHub at all', async () => { + const world = makeVercelWorld({ files: { [PLAN]: planFile(ENTRIES) } }); + const { body, exitCode } = await run(planFile(ENTRIES), { confirm: 'not-the-id' }, world); + expect(exitCode).toBe(1); + expect(body.code).toBe('PLAN_CHANGED'); + expect(typeof body.plan_id).toBe('string'); + expect(world.fetchMock).not.toHaveBeenCalled(); + expect(world.getSecret).not.toHaveBeenCalled(); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + expect(world.github.getDefaultBranches).not.toHaveBeenCalled(); + nothingWritten(world); + }); + + test('a plan edited after the dry run (one variable dropped): the old plan_id no longer matches', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const edited = planFile([...ENTRIES.slice(0, 4), { ...ENTRY_MWEB, vars: ['WEB_KEY'] }, ...ENTRIES.slice(5)].map((e) => (e === ENTRY_SHOP ? { ...e, vars: ['DB_URL'] } : e))); + const world = makeVercelWorld({ files: { [PLAN]: edited } }); + const { body } = await run(edited, { confirm: approved }, world); + expect(body.code).toBe('PLAN_CHANGED'); + nothingWritten(world); + }); + + test('the same plan in another order still matches: ordering is not a change', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { body } = await run(planFile([...ENTRIES].reverse()), { confirm: approved }); + expect(body.ok).toBe(true); + }); +}); + +describe('confirm: deploy.json is written, one PR per repo', () => { + test('one PR per repo; a monorepo\'s several deploy.json paths go in ONE commit; the base is the default branch', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world, body, exitCode } = await run(planFile(ENTRIES), { confirm: approved }); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.plan_id).toBe(approved); + expect(body.failed).toEqual([]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/duo', 'acme/mono', 'acme/personal', 'acme/shop']); + expect(body.prs.find((p: any) => p.repo === 'acme/mono')).toMatchObject({ base: 'main', paths: ['apps/admin/.capy/deploy.json', 'apps/web/.capy/deploy.json'] }); + expect(body.prs.find((p: any) => p.repo === 'acme/personal')).toMatchObject({ base: 'trunk' }); + + const written = writtenBy(world.github); + expect(world.github.createPull).toHaveBeenCalledTimes(4); + expect(world.github.createCommit).toHaveBeenCalledTimes(4); + expect(Object.keys(written.commits.find((c) => c.repo === 'acme/mono')?.files ?? {})).toEqual(['apps/admin/.capy/deploy.json', 'apps/web/.capy/deploy.json']); + expect(written.pulls.map((p) => [p.repo, p.base])).toEqual([['acme/duo', 'main'], ['acme/mono', 'main'], ['acme/personal', 'trunk'], ['acme/shop', 'main']]); + }); + + test('every environment of a project is a target of its own: production, preview, a scoped preview, a custom environment', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const shop = fileOf(world, 'acme/shop', '.capy/deploy.json'); + expect(Object.keys(shop.targets)).toEqual(['vercel-feature-x', 'vercel-preview', 'vercel-production', 'vercel-staging']); + expect(shop.targets['vercel-production']).toEqual({ + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'], + knownVars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'], + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_shop', teamId: 'team_1' }, + mode: 'ci', + gitBaseBranch: 'main', + }); + // Same key order as the Vercel setup's own target. + expect(Object.keys(shop.targets['vercel-production'])).toEqual(['name', 'kind', 'branch', 'vars', 'knownVars', 'options', 'mode', 'gitBaseBranch']); + expect(Object.keys(shop.targets['vercel-production'].options)).toEqual(['projectDir', 'vercelEnv', 'projectId', 'teamId']); + }); + + test('a preview that is not scoped has no gitBranch; a scoped one has it; a custom environment has its id; the Capy branch is each target\'s own', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const shop = fileOf(world, 'acme/shop', '.capy/deploy.json').targets; + expect(shop['vercel-preview'].options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_shop', teamId: 'team_1' }); + expect(shop['vercel-feature-x'].options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_shop', teamId: 'team_1', gitBranch: 'feature-x' }); + expect(shop['vercel-feature-x'].branch).toBe('feature-x'); + expect(shop['vercel-staging'].options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_shop', teamId: 'team_1', customEnvironmentId: 'env_stg' }); + expect(shop['vercel-staging'].branch).toBe('staging'); + }); + + test('knownVars is the project\'s whole variable set on that branch; vars is what the plan picked', async () => { + const approved = await approvedId(planFile([{ ...ENTRY_MWEB, vars: ['WEB_KEY'] }])); + const { world } = await run(planFile([{ ...ENTRY_MWEB, vars: ['WEB_KEY'] }]), { confirm: approved }); + const web = fileOf(world, 'acme/mono', 'apps/web/.capy/deploy.json').targets['vercel-production']; + expect(web.vars).toEqual(['WEB_KEY']); + expect(web.knownVars).toEqual(['WEB_KEY', 'WEB_OTHER']); + }); + + test('a project in a monorepo: projectDir is its root directory relative to the folder the deploy.json lives in', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + // The Capy project is linked at apps/web, and so is the Vercel project's root directory: the same folder. + expect(fileOf(world, 'acme/mono', 'apps/web/.capy/deploy.json').targets['vercel-production'].options.projectDir).toBe('.'); + // One Capy project at the repo root, two Vercel projects in folders below it: each target names its folder. + const duo = fileOf(world, 'acme/duo', '.capy/deploy.json').targets; + expect(duo['vercel-production'].options).toMatchObject({ projectDir: 'apps/a', projectId: 'prj_a' }); + expect(duo['vercel-production-2'].options).toMatchObject({ projectDir: 'apps/b', projectId: 'prj_b' }); + }); + + test('a target name is unique in its deploy.json: a second Vercel project on the same Capy branch gets a numeric suffix', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { body } = await run(planFile(ENTRIES), { confirm: approved }); + expect(body.prs.find((p: any) => p.repo === 'acme/duo').targets).toEqual(['vercel-production', 'vercel-production-2']); + }); + + test('a personal-account project has no teamId in its target', async () => { + const approved = await approvedId(planFile([ENTRY_PERSONAL])); + const { world } = await run(planFile([ENTRY_PERSONAL]), { confirm: approved }); + const target = fileOf(world, 'acme/personal', '.capy/deploy.json').targets['vercel-production']; + expect(target.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_personal' }); + expect(target.gitBaseBranch).toBe('trunk'); + }); + + test('the file is MERGED: another target already in deploy.json is left exactly as it was', async () => { + const approved = await approvedId(planFile([ENTRY_MWEB])); + const { world } = await run(planFile([ENTRY_MWEB]), { confirm: approved }); + const web = fileOf(world, 'acme/mono', 'apps/web/.capy/deploy.json'); + expect(Object.keys(web.targets)).toEqual(['ssm-prod', 'vercel-production']); + expect(web.targets['ssm-prod']).toEqual(JSON.parse(EXISTING_MONO_DEPLOY).targets['ssm-prod']); + expect(web.version).toBe('1'); + }); + + test('a target name already in the file is never reused: the new one gets a numeric suffix', async () => { + const taken = JSON.stringify({ version: '1', targets: { 'vercel-production': { name: 'vercel-production', kind: 'vercel', branch: 'other', vars: [], options: { projectId: 'prj_elsewhere', vercelEnv: 'production' } } } }, null, 2); + const repos = { ...GITHUB_REPOS, 'acme/shop': { ...GITHUB_REPOS['acme/shop'], files: { '.capy/deploy.json': taken } } }; + const text = planFile([ENTRY_SHOP]); + const world = makeVercelWorld({ files: { [PLAN]: text }, repos }); + const approved = await approvedId(text, world); + const { body } = await run(text, { confirm: approved }, world); + expect(body.prs[0].targets).toEqual(['vercel-production-2']); + const written = fileOf(world, 'acme/shop', '.capy/deploy.json'); + expect(Object.keys(written.targets)).toEqual(['vercel-production', 'vercel-production-2']); + expect(written.targets['vercel-production'].options).toEqual({ projectId: 'prj_elsewhere', vercelEnv: 'production' }); + }); + + test('the PR says what it adds, by name and id only', async () => { + const approved = await approvedId(planFile([ENTRY_SHOP])); + const { world } = await run(planFile([ENTRY_SHOP]), { confirm: approved }); + const written = writtenBy(world.github); + expect(written.pulls[0].title).toBe('chore(capy): add Vercel deploy targets'); + expect(written.pulls[0].body).toContain('vercel-production'); + expect(written.pulls[0].body).toContain('prj_shop'); + expect(written.pulls[0].head).toBe('capy/vercel-targets-test-0000'); + }); + + test('nothing is recorded or pushed on the Capy side, and nothing is written to Vercel: every request is a read', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + expect(new Set(Object.keys(world.capy.client))).toEqual(new Set(['getOrgRepos', 'getSecretIndex'])); + expect(world.vercelCalls().every((c) => c.method === 'GET')).toBe(true); + }); +}); + +describe('confirm: repos fail independently', () => { + test('a failure in one repo is a coded result for that repo; the others still open their PR', async () => { + const repos = { ...GITHUB_REPOS, 'acme/duo': { ...GITHUB_REPOS['acme/duo'], failing: ['createRef' as const] } }; + const text = planFile(ENTRIES); + const world = makeVercelWorld({ files: { [PLAN]: text }, repos }); + const approved = await approvedId(text, world); + const { body, exitCode } = await run(text, { confirm: approved }, world); + expect(exitCode).toBe(1); + expect(body.ok).toBe(false); + expect(body.code).toBe('DISCOVER_PARTIAL'); + expect(body.failed).toEqual([{ repo: 'acme/duo', code: 'KEEP_PR_BRANCH_FAILED' }]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/mono', 'acme/personal', 'acme/shop']); + }); +}); + +describe('confirm: idempotent', () => { + test('after the PRs merge, the same plan reports already_configured and opens nothing', async () => { + const text = planFile(ENTRIES); + const approved = await approvedId(text); + const first = await run(text, { confirm: approved }); + const written = writtenBy(first.world.github); + + // The merged world: each written deploy.json is now on the default branch. + const merged = Object.fromEntries( + written.commits.map((c) => [c.repo, { ...GITHUB_REPOS[c.repo], files: { ...(GITHUB_REPOS[c.repo].files ?? {}), ...c.files } }] as const), + ); + const world = makeVercelWorld({ files: { [PLAN]: text }, repos: { ...GITHUB_REPOS, ...merged } }); + const { body, exitCode } = await run(text, { confirm: approved }, world); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.prs).toEqual([]); + expect(body.already_configured).toHaveLength(ENTRIES.length); + nothingWritten(world); + }); + + test('an environment that already has a target is skipped without a PR, even next to ones that are written', async () => { + const text = planFile([ENTRY_CFG, ENTRY_SHOP]); + const approved = await approvedId(text); + const { body, world } = await run(text, { confirm: approved }); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/shop']); + expect(body.already_configured).toEqual([{ project_id: ENTRY_CFG.project_id, branch: 'production', service_id: ENTRY_CFG.service_id, target_name: 'vercel-production' }]); + expect(world.github.createPull).toHaveBeenCalledTimes(1); + }); + + test('--dry-run with --confirm changes nothing: the dry run wins', async () => { + const world = makeVercelWorld({ files: { [PLAN]: planFile(ENTRIES) } }); + const approved = await approvedId(planFile(ENTRIES), world); + const { body } = await run(planFile(ENTRIES), { dryRun: true, confirm: approved }, world); + expect(body.dry_run).toBe(true); + nothingWritten(world); + }); +}); + +describe('confirm: no value is read, printed, written or sent', () => { + test('no sentinel and no token is in the output, the progress lines, any blob, the commit message or the PR', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world, body } = await run(planFile(ENTRIES), { confirm: approved }); + const written = writtenBy(world.github); + const everything = [ + JSON.stringify(body), + world.progress.mock.calls.map(([l]) => String(l)).join('\n'), + world.github.createBlob.mock.calls.map(([, content]) => String(content)).join('\n'), + written.commitMessages.join('\n'), + written.pulls.map((p) => `${p.title}\n${p.body}`).join('\n'), + world.github.createCommit.mock.calls.map(([, params]) => JSON.stringify(params)).join('\n'), + ].join('\n'); + expect(everything).not.toContain(SENTINEL_VERCEL); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).not.toContain(VERCEL_TOKEN); + // The check bites: names ARE written, so an empty capture cannot pass this. + expect(everything).toContain('STRIPE_KEY'); + expect(world.github.createBlob.mock.calls.length).toBeGreaterThan(0); + }); + + test('the env lists are read without decrypt and the per-id value route is never called, across a whole confirm run', async () => { + const approved = await approvedId(planFile(ENTRIES)); + const { world } = await run(planFile(ENTRIES), { confirm: approved }); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.some((u) => u.includes('/env'))).toBe(true); + expect(urls.some((u) => u.includes('decrypt'))).toBe(false); + expect(urls.some((u) => /\/v1\/projects\//.test(u) || /\/env\/[^?]/.test(u))).toBe(false); + }); +}); diff --git a/tests/commands/deployDiscoverVercelFacts.test.ts b/tests/commands/deployDiscoverVercelFacts.test.ts new file mode 100644 index 00000000..1176e0bf --- /dev/null +++ b/tests/commands/deployDiscoverVercelFacts.test.ts @@ -0,0 +1,369 @@ +/** + * `capy deploy vercel --discover` (CAP-696): the facts document. + * + * The properties under test: a Vercel project becomes one service per ENVIRONMENT that holds variables + * (production, preview, a preview scoped to a git branch, each custom environment; `development` only when + * it is the only one); a project is matched to a Capy project by its GitHub link and root directory; the + * proposal's target is exactly the shape the Vercel setup saves; an existing target for the same project + + * environment is `already_configured`; the token is read from the system store, never asked for; and no + * value is ever read, asked for or printed. + * + * Fakes only (`tests/helpers/deployDiscoverVercelWorld.ts`). + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { routeDeployDiscover } from '../../src/commands/deployDiscover/route'; +import { + P_CONF, + P_DUO, + P_MADMIN, + P_MWEB, + P_SHOP, + SENTINEL_VERCEL, + SVC, + VERCEL_DATA, + VERCEL_TOKEN, + makeVercelWorld, +} from '../helpers/deployDiscoverVercelWorld'; +import { SENTINEL_CAPY } from '../helpers/deployDiscoverWorld'; + +async function facts(world = makeVercelWorld()) { + const result = await runDeployVercelDiscover({}, world.io); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +describe('facts: services are the environments that hold variables', () => { + test('one service per environment with variables: production, preview, a branch-scoped preview, a custom environment; development is left out when others exist', async () => { + const { body, exitCode } = await facts(); + expect(exitCode).toBe(0); + const ids = body.services.map((s: any) => s.service_id); + expect(ids).toEqual( + expect.arrayContaining([SVC.shopProd, SVC.shopPreview, SVC.shopPreviewFeature, SVC.shopStaging, SVC.mwebProd, SVC.madmProd, SVC.aProd, SVC.bProd, SVC.cfgProd, SVC.personalProd]), + ); + // prj_shop also has DB_URL on `development`, but it has other environments: no development service. + expect(ids).not.toContain('prj_shop:development'); + }); + + test('development is a service when it is the project\'s only environment with variables', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.devOnly)).toMatchObject({ environment: 'development', env_var_names: ['DEV_KEY'] }); + }); + + test('a service carries names only: sorted, de-duplicated, Vercel\'s own `system` variables left out, a sensitive one still named', async () => { + const { body } = await facts(); + const prod = body.services.find((s: any) => s.service_id === SVC.shopProd); + expect(prod.env_var_names).toEqual(['API_KEY', 'DB_URL', 'STRIPE_KEY']); + expect(prod.env_var_names).not.toContain('VERCEL_URL'); + }); + + test('the branch-scoped preview and the custom environment are their own services with their own names', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.shopPreviewFeature)).toMatchObject({ + environment: 'preview', + git_branch_scope: 'feature-x', + env_var_names: ['DB_URL'], + }); + expect(body.services.find((s: any) => s.service_id === SVC.shopPreview)).toMatchObject({ git_branch_scope: null, env_var_names: ['API_KEY', 'DB_URL'] }); + expect(body.services.find((s: any) => s.service_id === SVC.shopStaging)).toMatchObject({ + environment: 'staging', + custom_environment_id: 'env_stg', + env_var_names: ['DB_URL'], + }); + }); + + test('a service names its repo, production branch, team, and its folder relative to the repo root', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.mwebProd)).toMatchObject({ + kind: 'vercel', + name: 'mono-web', + vercel_project_id: 'prj_mweb', + team: { id: 'team_1', slug: 'acme-team' }, + root_directory: 'apps/web', + path: 'apps/web', + branch: 'main', + repo: { host: 'github.com', owner: 'acme', name: 'mono' }, + }); + expect(body.services.find((s: any) => s.service_id === SVC.shopProd)).toMatchObject({ root_directory: null, path: '.' }); + // A personal-account project has no team. + expect(body.services.find((s: any) => s.service_id === SVC.personalProd)).toMatchObject({ team: null, branch: 'trunk' }); + }); + + test('a project that is not connected to GitHub is skipped and reported by name; its environments are never read', async () => { + const { body, world } = await facts(); + expect(body.services.some((s: any) => s.vercel_project_id === 'prj_nolink')).toBe(false); + expect(body.notices).toContainEqual({ code: 'VERCEL_PROJECT_NOT_GITHUB', detail: 'nolink' }); + expect(world.vercelCalls().some((c) => c.url.includes('prj_nolink'))).toBe(false); + }); + + test('the teams the token sees are listed', async () => { + const { body } = await facts(); + expect(body.teams).toEqual([{ id: 'team_1', slug: 'acme-team' }]); + }); +}); + +describe('facts: matching a service to a Capy project', () => { + test('a root directory matches the Capy project linked at that folder of the repo', async () => { + const { body } = await facts(); + const web = body.proposals.find((p: any) => p.service_id === SVC.mwebProd); + const admin = body.proposals.find((p: any) => p.service_id === SVC.madmProd); + expect(web).toMatchObject({ project_id: P_MWEB, branch: 'production', git_branch: 'main' }); + expect(admin).toMatchObject({ project_id: P_MADMIN, branch: 'production' }); + expect(web.evidence).toMatchObject({ repo_match: true, path_match: true }); + expect(web.confidence).toEqual({ project: 'certain', branch: 'certain', vars: 'certain' }); + }); + + test('several Vercel projects (one per root directory) under ONE Capy project are two proposals, each needs_review, each naming the other', async () => { + const { body } = await facts(); + const a = body.proposals.find((p: any) => p.service_id === SVC.aProd); + const b = body.proposals.find((p: any) => p.service_id === SVC.bProd); + expect(a.project_id).toBe(P_DUO); + expect(b.project_id).toBe(P_DUO); + expect(a.competing_proposal_ids).toEqual([b.proposal_id]); + expect(b.competing_proposal_ids).toEqual([a.proposal_id]); + expect(a.confidence.project).toBe('needs_review'); + expect(a.evidence.path_match).toBe(false); + }); + + test('the Capy branch is the one named like the environment; a scoped preview uses its git branch', async () => { + const { body } = await facts(); + const byService = (id: string) => body.proposals.find((p: any) => p.service_id === id); + expect(byService(SVC.shopProd).branch).toBe('production'); + expect(byService(SVC.shopPreview).branch).toBe('preview'); + expect(byService(SVC.shopPreviewFeature).branch).toBe('feature-x'); + expect(byService(SVC.shopStaging).branch).toBe('staging'); + expect(byService(SVC.devOnly).branch).toBe('development'); + }); + + test('a service whose environment has no Capy branch is unresolved BRANCH_UNMAPPED, naming the branches the project has', async () => { + const projects = [{ id: P_SHOP, name: 'shop', repos: [{ owner: 'acme', name: 'shop', path: '.' }], branches: { production: ['DB_URL'] } }]; + const { body } = await facts(makeVercelWorld({ projects })); + const unmapped = body.unresolved.filter((u: any) => u.reason === 'BRANCH_UNMAPPED' && u.project_id === P_SHOP); + expect(unmapped.map((u: any) => u.service_id).sort()).toEqual([SVC.shopPreview, SVC.shopPreviewFeature, SVC.shopStaging].sort()); + expect(unmapped[0].candidates).toEqual([{ project_id: P_SHOP, branch: 'production' }]); + }); + + test('a repo no Capy project is linked to is unresolved NO_REPO_LINK', async () => { + const { body } = await facts(makeVercelWorld({ projects: [] })); + expect(body.proposals).toEqual([]); + expect(body.unresolved.some((u: any) => u.reason === 'NO_REPO_LINK' && u.service_id === SVC.shopProd)).toBe(true); + }); +}); + +describe('facts: the proposal\'s target is the shape the Vercel setup saves', () => { + test('production: project, team, environment, the production branch as the PR base, CI mode, the shared names', async () => { + const { body } = await facts(); + const p = body.proposals.find((x: any) => x.service_id === SVC.shopProd); + expect(p.target).toEqual({ + kind: 'vercel', + projectId: 'prj_shop', + teamId: 'team_1', + vercelEnv: 'production', + branch: 'production', + gitBaseBranch: 'main', + mode: 'ci', + vars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'], + }); + expect(p.plan_entry).toEqual({ project_id: P_SHOP, branch: 'production', service_id: SVC.shopProd, git_branch: 'main', vars: ['API_KEY', 'DB_URL', 'STRIPE_KEY'] }); + }); + + test('custom environment adds customEnvironmentId; a scoped preview adds gitBranch; a personal project has no teamId; its PR base is its own production branch', async () => { + const { body } = await facts(); + const byService = (id: string) => body.proposals.find((p: any) => p.service_id === id).target; + expect(byService(SVC.shopStaging)).toMatchObject({ vercelEnv: 'staging', customEnvironmentId: 'env_stg' }); + expect(byService(SVC.shopPreviewFeature)).toMatchObject({ vercelEnv: 'preview', gitBranch: 'feature-x' }); + expect(byService(SVC.shopPreview)).not.toHaveProperty('gitBranch'); + expect(byService(SVC.personalProd)).not.toHaveProperty('teamId'); + expect(byService(SVC.personalProd)).toMatchObject({ gitBaseBranch: 'trunk' }); + }); + + test('only variables the Capy branch also holds are proposed; the rest are overlap evidence', async () => { + const { body } = await facts(); + const web = body.proposals.find((p: any) => p.service_id === SVC.mwebProd); + expect(web.target.vars).toEqual(['WEB_KEY', 'WEB_OTHER']); + expect(web.evidence.var_overlap).toEqual({ shared: 2, only_in_service: [], only_in_project: [] }); + }); +}); + +describe('facts: already configured', () => { + test('a deploy.json target with the same project and environment is already_configured and gets no proposal', async () => { + const { body } = await facts(); + expect(body.already_configured).toEqual([{ project_id: P_CONF, branch: 'production', service_id: SVC.cfgProd, target_name: 'vercel-production' }]); + expect(body.proposals.some((p: any) => p.service_id === SVC.cfgProd)).toBe(false); + }); + + test('another environment of the same project is not covered by it', async () => { + const existing = JSON.stringify({ + version: '1', + targets: { 'vercel-preview': { name: 'vercel-preview', kind: 'vercel', branch: 'preview', vars: [], options: { projectId: 'prj_shop', vercelEnv: 'preview', gitBranch: 'feature-x' } } }, + }); + const repos = { 'acme/shop': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': existing } } }; + const { body } = await facts(makeVercelWorld({ repos })); + // Only the branch-scoped preview is configured: the unscoped preview and production are not. + expect(body.already_configured.map((a: any) => a.service_id)).toEqual([SVC.shopPreviewFeature]); + expect(body.proposals.some((p: any) => p.service_id === SVC.shopPreview)).toBe(true); + }); + + test('a custom environment target is matched by its slug', async () => { + const existing = JSON.stringify({ + version: '1', + targets: { 'vercel-staging': { name: 'vercel-staging', kind: 'vercel', branch: 'staging', vars: [], options: { projectId: 'prj_shop', vercelEnv: 'staging', customEnvironmentId: 'env_stg' } } }, + }); + const repos = { 'acme/shop': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': existing } } }; + const { body } = await facts(makeVercelWorld({ repos })); + expect(body.already_configured.map((a: any) => a.service_id)).toEqual([SVC.shopStaging]); + }); +}); + +describe('facts: what is asked of Vercel', () => { + test('teams, then each team\'s projects page by page (`from` continues), then the token\'s own scope; team-scoped calls carry teamId, the personal ones none', async () => { + const { world } = await facts(); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls[0]).toBe('https://api.vercel.com/v2/teams?limit=100'); + expect(urls.filter((u) => u.includes('/v10/projects?'))).toEqual([ + 'https://api.vercel.com/v10/projects?limit=100&teamId=team_1', + 'https://api.vercel.com/v10/projects?limit=100&from=3&teamId=team_1', + 'https://api.vercel.com/v10/projects?limit=100&from=6&teamId=team_1', + 'https://api.vercel.com/v10/projects?limit=100', + ]); + // Every environment read of a team project says which team; the personal project's says none. + expect(urls.filter((u) => u.includes('prj_shop') || u.includes('prj_mweb')).every((u) => u.includes('teamId=team_1'))).toBe(true); + expect(urls.filter((u) => u.includes('prj_personal')).every((u) => !u.includes('teamId'))).toBe(true); + }); + + test('every request is a GET with the token as a bearer; the project, env and custom-environment routes are the documented ones', async () => { + const { world } = await facts(); + const calls = world.vercelCalls(); + expect(calls.every((c) => c.method === 'GET' && c.authorization === `Bearer ${VERCEL_TOKEN}`)).toBe(true); + const paths = new Set(calls.map((c) => new URL(c.url).pathname.replace(/prj_[a-z]+/, 'PRJ'))); + // CAP-730 adds the recent preview deployments (read-only, `target=preview`) to the routes asked. + expect(paths).toEqual(new Set(['/v2/teams', '/v10/projects', '/v9/projects/PRJ/custom-environments', '/v10/projects/PRJ/env', '/v6/deployments'])); + }); + + test('a project\'s variables are listed WITHOUT decrypt, and the per-id value route is never called', async () => { + const { world } = await facts(); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.some((u) => u.includes('decrypt'))).toBe(false); + expect(urls.some((u) => /\/v1\/projects\//.test(u))).toBe(false); + expect(urls.some((u) => /\/env\/[^?]/.test(u))).toBe(false); + }); + + test('an env list Vercel says has another page is reported: the names read may be incomplete', async () => { + const data = { ...VERCEL_DATA, envListNext: 1700000000000 }; + const { body } = await facts(makeVercelWorld({ data })); + expect(body.notices).toContainEqual({ code: 'VERCEL_ENV_LIST_TRUNCATED', detail: 'shop-web' }); + }); + + test('GitHub is read for the repos some Vercel project tracks; Capy is asked ONCE for the links and ONCE for the name index', async () => { + const { world } = await facts(); + expect(world.capy.getOrgRepos).toHaveBeenCalledTimes(1); + expect(world.capy.getSecretIndex).toHaveBeenCalledTimes(1); + expect(world.github.getDefaultBranches).toHaveBeenCalledTimes(1); + }); + + test('the output carries the plan schema ref and the next step is a dry run of a vercel plan', async () => { + const { body } = await facts(); + expect(body.plan_schema_ref).toEqual({ command: 'capy help --json', pointer: '/schemas/deploy_vercel_plan' }); + expect(body.next_steps.at(-1)).toEqual({ run: 'capy deploy vercel --discover --plan --dry-run' }); + expect(body).not.toHaveProperty('base_url'); + }); +}); + +describe('facts: no value is read, asked for, printed or sent', () => { + test('neither the Vercel nor the Capy sentinel is in the output or the progress lines', async () => { + const { body, world } = await facts(); + const everything = [JSON.stringify(body), world.progress.mock.calls.map(([l]) => String(l)).join('\n')].join('\n'); + expect(everything).not.toContain(SENTINEL_VERCEL); + expect(everything).not.toContain(SENTINEL_CAPY); + expect(everything).not.toContain(VERCEL_TOKEN); + // The check bites: names ARE printed. + expect(everything).toContain('STRIPE_KEY'); + }); + + test('a sensitive record (no value to give) and an encrypted one are named the same way as a plain one', async () => { + const { body } = await facts(); + expect(body.services.find((s: any) => s.service_id === SVC.shopProd).env_var_names).toEqual(expect.arrayContaining(['STRIPE_KEY', 'API_KEY', 'DB_URL'])); + }); +}); + +describe('facts: the token', () => { + test('is read from the org system store, by the connector key name (discovery only reads), and never asked for', async () => { + const { world } = await facts(); + expect(world.getSecret).toHaveBeenCalledTimes(1); + const [name, opts] = world.getSecret.mock.calls[0] as [string, Record]; + expect(name).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(opts).toMatchObject({ interactive: false, orgId: 'org_1' }); + }); + + test('missing everywhere: VERCEL_TOKEN_MISSING with where to get one (link, remedy), zero requests to Vercel, Capy or GitHub', async () => { + const world = makeVercelWorld({ token: null }); + const { body, exitCode } = await facts(world); + expect(exitCode).toBe(1); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_TOKEN_MISSING' }); + expect(body.link.url).toContain('vercel.com'); + expect(body.remedy).toContain('_CONNECTOR_VERCEL_TOKEN'); + expect(world.fetchMock).not.toHaveBeenCalled(); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + expect(world.github.getDefaultBranches).not.toHaveBeenCalled(); + }); + + test('a store that refuses with a code is that code, with the same help', async () => { + const world = makeVercelWorld({ io: { getSecret: (async () => Promise.reject(Object.assign(new Error('x'), { code: 'VERCEL_CONNECTOR_KEY_MISSING' }))) as never } }); + const { body } = await facts(world); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_CONNECTOR_KEY_MISSING' }); + expect(body.link).toBeDefined(); + expect(body.remedy).toBeDefined(); + expect(world.fetchMock).not.toHaveBeenCalled(); + }); + + test('$VERCEL_TOKEN is used when the store has none', async () => { + const world = makeVercelWorld({ token: null, io: { env: { VERCEL_TOKEN } as never } }); + const { body, exitCode } = await facts(world); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + }); + + test('a token Vercel rejects (401): VERCEL_AUTH_FAILED with where to get another; the token is not in the output', async () => { + const world = makeVercelWorld({ acceptedToken: 'some-other-token' }); + const { body, exitCode } = await facts(world); + expect(exitCode).toBe(1); + expect(body).toMatchObject({ ok: false, code: 'VERCEL_AUTH_FAILED' }); + expect(body.link.url).toContain('vercel.com'); + expect(body.remedy).toContain('_CONNECTOR_VERCEL_TOKEN'); + expect(JSON.stringify(body)).not.toContain(VERCEL_TOKEN); + expect(world.capy.getOrgRepos).not.toHaveBeenCalled(); + }); +}); + +describe('facts: routing from `capy deploy vercel --discover`', () => { + test('--base-url is a Dokploy flag: refused with a code', async () => { + const out = spyOn(console, 'log').mockImplementation((() => undefined) as never); + try { + const code = await routeDeployDiscover('vercel', { discover: true, baseUrl: 'https://x.example.com' }, false, false); + expect(code).toBe(1); + expect(JSON.parse(String(out.mock.calls[0][0])).code).toBe('INVALID_FORMAT'); + } finally { + out.mockRestore(); + } + }); +}); + +describe('facts: several teams', () => { + test('every team the token sees is read with its own teamId; a project is attributed to the team that lists it; a project listed twice counts once', async () => { + const data = { + ...VERCEL_DATA, + teams: [VERCEL_DATA.teams![0], { id: 'team_2', slug: 'other-team' }], + projects: { + ...VERCEL_DATA.projects, + team_2: [VERCEL_DATA.projects.team_1[0], { ...VERCEL_DATA.projects.personal[0], id: 'prj_t2', name: 'team-two-site' }], + }, + envs: { ...VERCEL_DATA.envs, prj_t2: VERCEL_DATA.envs.prj_personal }, + }; + const { body, world } = await facts(makeVercelWorld({ data })); + expect(body.teams.map((t: any) => t.slug)).toEqual(['acme-team', 'other-team']); + expect(body.services.filter((s: any) => s.vercel_project_id === 'prj_shop' && s.environment === 'production')).toHaveLength(1); + expect(body.services.find((s: any) => s.vercel_project_id === 'prj_t2').team).toEqual({ id: 'team_2', slug: 'other-team' }); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.filter((u) => u.includes('/v10/projects?') && u.includes('teamId=team_2'))).toHaveLength(1); + expect(urls.filter((u) => u.includes('prj_t2')).every((u) => u.includes('teamId=team_2'))).toBe(true); + }); +}); diff --git a/tests/commands/deployDiscoverVercelPlan.test.ts b/tests/commands/deployDiscoverVercelPlan.test.ts new file mode 100644 index 00000000..88e4f212 --- /dev/null +++ b/tests/commands/deployDiscoverVercelPlan.test.ts @@ -0,0 +1,180 @@ +/** + * `capy deploy vercel --discover --plan --dry-run` (CAP-696): validating a plan, changing nothing. + * + * The properties under test: a plan that is valid prints the lines a person approves and the `plan_id` + * that ties `--confirm` to exactly this plan; every error is path-precise and coded; several Vercel + * projects may ship one Capy branch (a monorepo) but the same environment twice is a duplicate; only the + * projects a plan names have their environments read; nothing is written anywhere. + * + * Fakes only (`tests/helpers/deployDiscoverVercelWorld.ts`). + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { + ENTRY_CFG, + ENTRY_DUO_A, + ENTRY_DUO_B, + ENTRY_MADMIN, + ENTRY_MWEB, + ENTRY_SHOP, + ENTRY_SHOP_FEATURE, + ENTRY_SHOP_PREVIEW, + ENTRY_SHOP_STAGING, + P_MWEB, + P_SHOP, + SENTINEL_VERCEL, + SVC, + VercelWorld, + makeVercelWorld, + planFile, +} from '../helpers/deployDiscoverVercelWorld'; + +const PLAN = 'plan.json'; + +async function dryRun(text: string, world: VercelWorld = makeVercelWorld({ files: { [PLAN]: text } })) { + const result = await runDeployVercelDiscover({ plan: PLAN, dryRun: true }, world.io); + return { world, exitCode: result.exitCode, body: result.body as any }; +} + +const nothingWritten = (world: VercelWorld): void => { + expect(world.github.createBlob).not.toHaveBeenCalled(); + expect(world.github.createTree).not.toHaveBeenCalled(); + expect(world.github.createCommit).not.toHaveBeenCalled(); + expect(world.github.createRef).not.toHaveBeenCalled(); + expect(world.github.createPull).not.toHaveBeenCalled(); +}; + +const ENTRIES = [ENTRY_SHOP, ENTRY_SHOP_PREVIEW, ENTRY_SHOP_FEATURE, ENTRY_SHOP_STAGING, ENTRY_MWEB, ENTRY_MADMIN, ENTRY_DUO_A, ENTRY_DUO_B]; + +describe('dry run: a valid plan', () => { + test('prints the plan, its plan_id, the lines to relay, the PRs it would open, and the confirm command; writes nothing', async () => { + const { body, exitCode, world } = await dryRun(planFile(ENTRIES)); + expect(exitCode).toBe(0); + expect(body).toMatchObject({ ok: true, dry_run: true }); + expect(body.plan_id).toMatch(/^[0-9a-f]{16}$/); + expect(body.plan.version).toBe(1); + expect(body.plan.entries).toHaveLength(8); + expect(body.summary_lines).toHaveLength(8); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/duo', 'acme/mono', 'acme/shop']); + expect(body.prs.find((p: any) => p.repo === 'acme/mono')).toMatchObject({ base: 'main', files: ['apps/admin/.capy/deploy.json', 'apps/web/.capy/deploy.json'] }); + expect(body.next_steps.at(-1)).toEqual({ run: `capy deploy vercel --discover --plan ${PLAN} --confirm ${body.plan_id}` }); + nothingWritten(world); + }); + + test('a summary line names the Capy project and branch, the Vercel project and environment, the PR base and the number of variables', async () => { + const { body } = await dryRun(planFile([ENTRY_SHOP_FEATURE, ENTRY_SHOP_STAGING])); + expect(body.summary_lines).toEqual([ + 'shop · feature-x → Vercel shop-web preview@feature-x (main), 1 vars', + 'shop · staging → Vercel shop-web staging (main), 1 vars', + ]); + }); + + test('the plan_id does not depend on the order of entries or of variables, and changes when an entry does', async () => { + const a = await dryRun(planFile(ENTRIES)); + const b = await dryRun(planFile([...ENTRIES].reverse().map((e) => ({ ...e, vars: [...e.vars].reverse() })))); + expect(b.body.plan_id).toBe(a.body.plan_id); + const c = await dryRun(planFile(ENTRIES.slice(1))); + expect(c.body.plan_id).not.toBe(a.body.plan_id); + }); + + test('two Vercel projects of one monorepo may ship the same Capy branch of the same Capy project', async () => { + const { body, exitCode } = await dryRun(planFile([ENTRY_DUO_A, ENTRY_DUO_B])); + expect(exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.prs).toHaveLength(1); + expect(body.prs[0].targets).toEqual(['vercel-production', 'vercel-production-2']); + }); + + test('an entry whose environment already has a target is reported already_configured and is not in the PR preview', async () => { + const { body } = await dryRun(planFile([ENTRY_CFG, ENTRY_SHOP])); + expect(body.already_configured).toEqual([{ project_id: ENTRY_CFG.project_id, branch: 'production', service_id: SVC.cfgProd, target_name: 'vercel-production' }]); + expect(body.prs.map((p: any) => p.repo)).toEqual(['acme/shop']); + expect(body.summary_lines).toContain('configured · production: already configured (vercel-production), nothing to do'); + }); + + test('only the projects the plan names have their environments read', async () => { + const { world } = await dryRun(planFile([ENTRY_SHOP])); + const urls = world.vercelCalls().map((c) => c.url); + expect(urls.filter((u) => u.includes('/env')).every((u) => u.includes('prj_shop'))).toBe(true); + expect(urls.some((u) => u.includes('prj_mweb'))).toBe(false); + }); + + test('no value anywhere in the output', async () => { + const { body } = await dryRun(planFile(ENTRIES)); + expect(JSON.stringify(body)).not.toContain(SENTINEL_VERCEL); + }); +}); + +describe('dry run: errors are path-precise and coded, and nothing is changed', () => { + test('an unknown service id', async () => { + const { body, exitCode, world } = await dryRun(planFile([{ ...ENTRY_SHOP, service_id: 'prj_shop:custom:nope' }])); + expect(exitCode).toBe(1); + expect(body.code).toBe('PLAN_INVALID'); + expect(body.errors).toContainEqual({ path: 'entries[0].service_id', code: 'SERVICE_NOT_FOUND' }); + nothingWritten(world); + }); + + test('the same environment twice is DUPLICATE_ENTRY, at the later entry', async () => { + const { body } = await dryRun(planFile([ENTRY_SHOP, ENTRY_SHOP])); + expect(body.errors).toEqual([{ path: 'entries[1]', code: 'DUPLICATE_ENTRY' }]); + }); + + test('a variable the Capy branch does not hold', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, vars: ['DB_URL', 'NOPE'] }])); + expect(body.errors).toEqual([{ path: 'entries[0].vars[1]', code: 'VAR_NOT_FOUND' }]); + }); + + test('a Capy branch the project does not have', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, branch: 'nope' }])); + expect(body.errors).toContainEqual({ path: 'entries[0].branch', code: 'BRANCH_NOT_FOUND' }); + }); + + test('a PR base the repo does not have', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, git_branch: 'nope' }])); + expect(body.errors).toEqual([{ path: 'entries[0].git_branch', code: 'BRANCH_NOT_FOUND' }]); + }); + + test('a Capy project that is not linked to the Vercel project\'s repo', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, project_id: P_MWEB, vars: ['WEB_KEY'], branch: 'production' }])); + expect(body.errors).toContainEqual({ path: 'entries[0].service_id', code: 'REPO_MISMATCH' }); + }); + + test('a project that does not exist (or the caller cannot see)', async () => { + const { body } = await dryRun(planFile([{ ...ENTRY_SHOP, project_id: 'proj_nope' }])); + expect(body.errors).toContainEqual({ path: 'entries[0].project_id', code: 'PROJECT_NOT_FOUND' }); + }); + + test('a plan file that is not a plan: the shape errors come first, before anything is looked up', async () => { + const { body, world } = await dryRun(JSON.stringify({ version: 1, entries: [{ project_id: P_SHOP }] })); + expect(body.code).toBe('PLAN_INVALID'); + expect(body.errors.map((e: any) => e.path)).toEqual(expect.arrayContaining(['entries[0].branch', 'entries[0].service_id', 'entries[0].git_branch', 'entries[0].vars'])); + expect(world.fetchMock).not.toHaveBeenCalled(); + }); + + test('an unreadable plan file is PLAN_FILE_UNREADABLE', async () => { + const world = makeVercelWorld(); + const result = await runDeployVercelDiscover({ plan: 'missing.json', dryRun: true }, world.io); + expect((result.body as any).code).toBe('PLAN_FILE_UNREADABLE'); + }); + + test('--confirm without --plan is PLAN_REQUIRED', async () => { + const result = await runDeployVercelDiscover({ confirm: 'abc' }, makeVercelWorld().io); + expect((result.body as any).code).toBe('PLAN_REQUIRED'); + }); + + test('a real run without --confirm is refused with the id to confirm (exit 3), and writes nothing', async () => { + const world = makeVercelWorld({ files: { [PLAN]: planFile([ENTRY_SHOP]) } }); + const result = await runDeployVercelDiscover({ plan: PLAN }, world.io); + expect(result.exitCode).toBe(3); + expect((result.body as any).code).toBe('PLAN_CONFIRM_REQUIRED'); + expect((result.body as any).unanswered[0]).toMatchObject({ id: 'confirm', flag: '--confirm' }); + nothingWritten(world); + }); + + test('the token is checked before anything is read: missing, and nothing is requested', async () => { + const world = makeVercelWorld({ token: null, files: { [PLAN]: planFile([ENTRY_SHOP]) } }); + const { body } = await dryRun(planFile([ENTRY_SHOP]), world); + expect(body.code).toBe('VERCEL_TOKEN_MISSING'); + expect(world.fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/deployDiscoverVercelPreviewBranch.test.ts b/tests/commands/deployDiscoverVercelPreviewBranch.test.ts new file mode 100644 index 00000000..56a3cb15 --- /dev/null +++ b/tests/commands/deployDiscoverVercelPreviewBranch.test.ts @@ -0,0 +1,475 @@ +/** + * `capy deploy vercel --discover` (CAP-730): the branch of a preview environment. + * + * Production keeps the project's production branch. The all-branches preview's branch is the deploy PR base, so it is + * a LONG-LIVED branch: the candidates are only the production branch, the GitHub default branch and the branches + * preview variables are scoped to. Recent preview deployments never add a candidate; they rank the ones there are. + * One candidate: it, certain. Several: ranked by deployments, scoped variables, default branch; certain only when every + * signal that names a candidate names the winner. A custom environment uses its branch rule. A preview scoped to a + * branch never becomes an empty proposal. The deployments call failing is a coded notice, never a failed discovery. + * Names, ids and counts only. + */ +import { describe, test, expect } from 'bun:test'; +import { runDeployVercelDiscover } from '../../src/commands/deployDiscover/vercel'; +import { inferPreviewBranch, leadersOf } from '../../src/commands/deployDiscover/vercelBranch'; +import type { BranchSignal } from '../../src/commands/deployDiscover/facts'; +import { createVercelClient } from '../../src/deploy/vercelApi'; +import type { FakeProject } from '../helpers/deployDiscoverWorld'; +import type { FakeVercelData, RawEnv } from '../helpers/fakeVercel'; +import { fakeVercelFetch, project as vercelProject } from '../helpers/fakeVercel'; +import { SENTINEL_VERCEL, makeVercelWorld } from '../helpers/deployDiscoverVercelWorld'; + +const PRJ = 'prj_ss'; +const SVC_PREVIEW = `${PRJ}:preview`; +const SVC_SCOPED = `${PRJ}:preview@develop`; + +const env = (id: string, key: string, over: Partial = {}): RawEnv => ({ + id, + key, + type: 'plain', + target: ['preview'], + listValue: SENTINEL_VERCEL, + ...over, +}); + +interface Setup { + readonly production?: string; + readonly defaultBranch?: string; + readonly deployments?: ReadonlyArray; + readonly deploymentsStatus?: number; + readonly scoped?: ReadonlyArray<{ branch: string; names: readonly string[] }>; + readonly custom?: FakeVercelData['custom']; + readonly extraEnvs?: readonly RawEnv[]; + readonly capyBranches?: Readonly>; +} + +const capyProject = (branches: Readonly>): readonly FakeProject[] => [ + { id: 'proj_ss', name: 'slidespeak', repos: [{ owner: 'acme', name: 'ss', path: '.' }], branches: branches as Record }, +]; + +function worldOf(setup: Setup = {}) { + const scopedEnvs = (setup.scoped ?? []).flatMap(({ branch, names }) => names.map((n, i) => env(`s_${branch}_${i}`, n, { gitBranch: branch }))); + const data: FakeVercelData = { + teams: [{ id: 'team_1', slug: 'acme-team' }], + projects: { + team_1: [vercelProject(PRJ, 'slidespeak-app', { link: { type: 'github', org: 'acme', repo: 'ss', productionBranch: setup.production ?? 'main' } })], + personal: [], + }, + envs: { + [PRJ]: [ + env('p1', 'API_KEY', { target: ['production', 'preview'] }), + env('p2', 'DB_URL', { target: ['production', 'preview'] }), + ...scopedEnvs, + ...(setup.extraEnvs ?? []), + ], + }, + ...(setup.custom === undefined ? {} : { custom: setup.custom }), + deployments: { [PRJ]: (setup.deployments ?? []).map((branch) => ({ branch })) }, + ...(setup.deploymentsStatus === undefined ? {} : { deploymentsStatus: { [PRJ]: setup.deploymentsStatus } }), + }; + return makeVercelWorld({ + data, + projects: capyProject(setup.capyBranches ?? { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'] }), + repos: { 'acme/ss': { defaultBranch: setup.defaultBranch ?? setup.production ?? 'main', branches: ['main', 'develop', 'staging', 'release'] } }, + }); +} + +async function discover(setup: Setup = {}) { + const world = worldOf(setup); + const result = await runDeployVercelDiscover({}, world.io); + const body = result.body as any; + const previewService = body.services.find((s: any) => s.service_id === SVC_PREVIEW); + const previewProposal = body.proposals.find((p: any) => p.service_id === SVC_PREVIEW); + return { world, result, body, previewService, previewProposal }; +} + +const deploys = (branch: string, n: number): readonly string[] => Array.from({ length: n }, () => branch); + +describe('preview branch: candidates are long-lived branches; deployments only rank them', () => { + test('(a) default branch = production = main, deployments spread over feature branches: main, certain; the feature branches are one total, not a list', async () => { + const { previewProposal, previewService, body } = await discover({ + deployments: [...deploys('feat/SLI-191', 3), ...deploys('fix/replicate-output-shape', 3), ...deploys('feature-b', 2)], + }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewService.branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch).toEqual({ + basis: 'production_branch_only', + signals: [{ code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [], other_branches: 8 }], + }); + expect(JSON.stringify(body)).not.toContain('SLI-191'); + }); + + test('(b) only a one-off capy/* import branch was deployed, once: main, certain', async () => { + const { previewProposal } = await discover({ deployments: ['capy/vercel-import-20261104-2058'] }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch.basis).toBe('production_branch_only'); + }); + + test('(c) the slidespeak-app shape (production main, default develop, scoped develop, develop 25 deploys, many features, main 0): develop, certain, counts only for candidates', async () => { + const features = Array.from({ length: 20 }, (_, i) => `feature-${i}`); + const { previewProposal, previewService, body } = await discover({ + defaultBranch: 'develop', + scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], + deployments: [...deploys('develop', 25), ...features], + }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewService.branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch.basis).toBe('signals_agree'); + expect(previewProposal.evidence.git_branch.signals).toEqual([ + { code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'develop', weight: 25 }], other_branches: 20 }, + { code: 'BRANCH_SCOPED_VARIABLES', branches: [{ branch: 'develop', weight: 1, names: ['NEW_FLAG'], shadows: [] }] }, + { code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: 'develop', weight: 1 }] }, + ]); + expect(JSON.stringify(body)).not.toContain('feature-7'); + }); + + test('(d) default develop but the deployments are mostly on main: main wins the ranking, needs_review, every signal listed', async () => { + const { previewProposal } = await discover({ defaultBranch: 'develop', deployments: [...deploys('main', 6), ...deploys('develop', 1), 'feature-a'] }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(previewProposal.evidence.git_branch.basis).toBe('strongest_signal'); + expect(previewProposal.evidence.git_branch.signals).toEqual([ + { code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [{ branch: 'main', weight: 6 }, { branch: 'develop', weight: 1 }], other_branches: 1 }, + { code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: 'develop', weight: 1 }] }, + ]); + }); + + test('a branch that was only deployed from never becomes a candidate, however many deployments it has', async () => { + const { previewProposal } = await discover({ defaultBranch: 'develop', deployments: deploys('feat/x', 40) }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('certain'); + }); + + test('deployments with no git branch (a CLI deploy) count for no branch', async () => { + const { previewProposal } = await discover({ deployments: [null, null, 'develop'] }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.evidence.git_branch.signals).toEqual([{ code: 'RECENT_PREVIEW_DEPLOYMENTS', branches: [], other_branches: 1 }]); + }); +}); + +describe('preview git branch: scoped variables and the default branch', () => { + test('branch-scoped variables: the branch with the most scoped variables, with their names and the names the all-branches preview shadows', async () => { + const { previewProposal } = await discover({ scoped: [{ branch: 'develop', names: ['DB_URL', 'NEW_FLAG'] }, { branch: 'staging', names: ['ONLY_STAGING'] }] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals).toEqual([ + { + code: 'BRANCH_SCOPED_VARIABLES', + branches: [ + { branch: 'develop', weight: 2, names: ['DB_URL', 'NEW_FLAG'], shadows: ['DB_URL'] }, + { branch: 'staging', weight: 1, names: ['ONLY_STAGING'], shadows: [] }, + ], + }, + ]); + expect(previewProposal.confidence.branch).toBe('certain'); + }); + + test('the GitHub default branch, when it differs from the production branch', async () => { + const { previewProposal } = await discover({ defaultBranch: 'develop' }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals).toEqual([{ code: 'GITHUB_DEFAULT_BRANCH', branches: [{ branch: 'develop', weight: 1 }] }]); + expect(previewProposal.confidence.branch).toBe('certain'); + }); + + test('a default branch equal to the production branch is not a signal', async () => { + const { previewProposal } = await discover({ defaultBranch: 'main', production: 'main' }); + expect(previewProposal.evidence.git_branch.signals).toEqual([]); + }); +}); + +describe('preview git branch: ranking and disagreement', () => { + test('all three signals name the same branch: that branch, certain, every signal listed', async () => { + const { previewProposal } = await discover({ + defaultBranch: 'develop', + deployments: deploys('develop', 3), + scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], + }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch.basis).toBe('signals_agree'); + expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual([ + 'RECENT_PREVIEW_DEPLOYMENTS', + 'BRANCH_SCOPED_VARIABLES', + 'GITHUB_DEFAULT_BRANCH', + ]); + }); + + test('deployments outrank scoped variables and the default branch among the candidates: needs_review, every signal listed', async () => { + const { previewProposal } = await discover({ + defaultBranch: 'release', + deployments: deploys('release', 4), + scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], + }); + expect(previewProposal.git_branch).toBe('release'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + expect(previewProposal.evidence.git_branch.basis).toBe('strongest_signal'); + expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual([ + 'RECENT_PREVIEW_DEPLOYMENTS', + 'BRANCH_SCOPED_VARIABLES', + 'GITHUB_DEFAULT_BRANCH', + ]); + }); + + test('scoped variables outrank the default branch when no candidate was deployed', async () => { + const { previewProposal } = await discover({ defaultBranch: 'release', scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('a tie in deployments between candidates is broken by the next criterion, and is not agreement', async () => { + const { previewProposal } = await discover({ deployments: ['main', 'staging'], defaultBranch: 'staging' }); + expect(previewProposal.git_branch).toBe('staging'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('a tie nothing else breaks is settled by branch name, and needs_review', async () => { + const { previewProposal } = await discover({ scoped: [{ branch: 'staging', names: ['A'] }, { branch: 'develop', names: ['B'] }] }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.confidence.branch).toBe('needs_review'); + }); + + test('no other long-lived branch: the production branch, certain, whatever else was read', async () => { + const { previewProposal, previewService } = await discover({ production: 'main', defaultBranch: 'main' }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewService.branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(previewProposal.evidence.git_branch).toEqual({ basis: 'production_branch_only', signals: [] }); + }); + + test('a proposal that needs review is in the next step\'s review list', async () => { + const { body, previewProposal } = await discover({ defaultBranch: 'release', scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); + expect(body.next_steps.find((s: any) => s.action === 'review').proposal_ids).toContain(previewProposal.proposal_id); + }); + + test('production keeps the production branch whatever the signals say', async () => { + const { body } = await discover({ defaultBranch: 'develop', deployments: deploys('develop', 9), scoped: [{ branch: 'develop', names: ['X'] }] }); + const production = body.proposals.find((p: any) => p.service_id === `${PRJ}:production`); + expect(production.git_branch).toBe('main'); + expect(production.evidence.git_branch).toBeUndefined(); + expect(body.services.find((s: any) => s.service_id === `${PRJ}:production`).git_branch_signals).toBeUndefined(); + }); +}); + +describe('preview git branch: the deployments call', () => { + test('asks for preview deployments of the project, with a limit, team-scoped, by GET', async () => { + const { world } = await discover({ deployments: deploys('develop', 1) }); + const call = world.vercelCalls().find((c) => new URL(c.url).pathname === '/v6/deployments'); + expect(call).toBeDefined(); + const url = new URL(call!.url); + expect(call!.method).toBe('GET'); + expect(Object.fromEntries(url.searchParams)).toEqual({ projectId: PRJ, target: 'preview', limit: '100', teamId: 'team_1' }); + }); + + test.each([403, 500, 404])('HTTP %i: no signal and a coded notice; discovery still succeeds and the other signals are used', async (status) => { + const { result, body, previewProposal } = await discover({ deploymentsStatus: status, defaultBranch: 'develop' }); + expect(result.exitCode).toBe(0); + expect(body.ok).toBe(true); + expect(body.notices).toContainEqual({ code: 'VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE', detail: 'slidespeak-app' }); + expect(previewProposal.git_branch).toBe('develop'); + expect(previewProposal.evidence.git_branch.signals.map((s: any) => s.code)).toEqual(['GITHUB_DEFAULT_BRANCH']); + }); + + test('a failed call with no other long-lived branch: the production branch, plus the notice (deployments never decide alone)', async () => { + const { body, previewProposal } = await discover({ deploymentsStatus: 403 }); + expect(previewProposal.git_branch).toBe('main'); + expect(previewProposal.confidence.branch).toBe('certain'); + expect(body.notices.map((n: any) => n.code)).toContain('VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE'); + }); + + test('a call that works adds no notice', async () => { + const { body } = await discover({ deployments: deploys('develop', 1) }); + expect(body.notices.map((n: any) => n.code)).not.toContain('VERCEL_PREVIEW_DEPLOYMENTS_UNAVAILABLE'); + }); + + test('not asked at all for a project with no all-branches preview variables', async () => { + const world = makeVercelWorld({ + data: { + teams: [{ id: 'team_1', slug: 'acme-team' }], + projects: { team_1: [vercelProject(PRJ, 'slidespeak-app', { link: { type: 'github', org: 'acme', repo: 'ss', productionBranch: 'main' } })], personal: [] }, + envs: { [PRJ]: [env('p1', 'API_KEY', { target: ['production'] })] }, + }, + projects: capyProject({ production: ['API_KEY'] }), + repos: { 'acme/ss': { defaultBranch: 'main', branches: ['main'] } }, + }); + await runDeployVercelDiscover({}, world.io); + expect(world.vercelCalls().some((c) => new URL(c.url).pathname === '/v6/deployments')).toBe(false); + }); +}); + +describe('plan mode', () => { + test('does not read deployments: the plan names its own git branch', async () => { + const plan = JSON.stringify({ version: 1, entries: [{ project_id: 'proj_ss', branch: 'preview', service_id: SVC_PREVIEW, git_branch: 'develop', vars: ['API_KEY'] }] }); + const base = worldOf({ deployments: deploys('develop', 1) }); + const result = await runDeployVercelDiscover({ plan: 'plan.json', dryRun: true }, { ...base.io, readFile: () => plan }); + expect((result.body as any).ok).toBe(true); + expect(base.vercelCalls().some((c) => new URL(c.url).pathname === '/v6/deployments')).toBe(false); + }); +}); + +describe('preview git branch: records scoped to a git branch', () => { + const scoped = [{ branch: 'develop', names: ['NEW_FLAG'] }]; + + test('a scoped preview with no variable in common with its Capy branch is NOT a proposal: it stays unresolved with a coded reason, and the real preview proposal is the only one', async () => { + const { body, previewProposal } = await discover({ + scoped, + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'], develop: ['SOMETHING_ELSE'] }, + }); + expect(body.proposals.map((p: any) => p.service_id)).not.toContain(SVC_SCOPED); + expect(body.proposals.filter((p: any) => p.service_id.startsWith(`${PRJ}:preview`))).toHaveLength(1); + expect(previewProposal.competing_proposal_ids).toEqual([]); + expect(body.unresolved).toContainEqual({ + reason: 'BRANCH_SCOPED_NO_VARIABLES', + service_id: SVC_SCOPED, + project_id: 'proj_ss', + candidates: [{ project_id: 'proj_ss', project_name: 'slidespeak', branch: 'develop' }], + }); + expect(body.next_steps.find((s: any) => s.action === 'resolve').service_ids).toContain(SVC_SCOPED); + }); + + test('the scoped variables are evidence on the preview proposal and are not among its variables', async () => { + const { previewProposal } = await discover({ + scoped, + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL', 'NEW_FLAG'], develop: ['SOMETHING_ELSE'] }, + }); + expect(previewProposal.plan_entry.vars).toEqual(['API_KEY', 'DB_URL']); + expect(previewProposal.evidence.git_branch.signals[0]).toMatchObject({ code: 'BRANCH_SCOPED_VARIABLES', branches: [{ branch: 'develop', names: ['NEW_FLAG'] }] }); + }); + + test('the scoped service is still listed in the facts, with its names, and keeps the production branch as its PR base', async () => { + const { body } = await discover({ scoped, defaultBranch: 'develop' }); + expect(body.services.find((s: any) => s.service_id === SVC_SCOPED)).toMatchObject({ git_branch_scope: 'develop', env_var_names: ['NEW_FLAG'], branch: 'main' }); + }); + + test('a scoped preview that does share variables with its Capy branch is still proposed, aimed at its own git-branch-scoped records', async () => { + const { body } = await discover({ + scoped: [{ branch: 'develop', names: ['DB_URL'] }], + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'], develop: ['DB_URL'] }, + }); + const proposal = body.proposals.find((p: any) => p.service_id === SVC_SCOPED); + expect(proposal.plan_entry.vars).toEqual(['DB_URL']); + expect(proposal.target).toMatchObject({ vercelEnv: 'preview', gitBranch: 'develop', branch: 'develop' }); + }); +}); + +describe('custom environments with a branch rule', () => { + const customEnvs = (matcher: { type: string; pattern: string } | undefined): Pick => ({ + custom: { [PRJ]: [{ id: 'env_stg', slug: 'staging', ...(matcher === undefined ? {} : { branchMatcher: matcher }) }] }, + extraEnvs: [env('c1', 'DB_URL', { target: [], customEnvironmentIds: ['env_stg'] })], + capyBranches: { production: ['API_KEY', 'DB_URL'], preview: ['API_KEY', 'DB_URL'], staging: ['DB_URL'] }, + }); + const stagingProposal = (body: any) => body.proposals.find((p: any) => p.service_id === `${PRJ}:custom:staging`); + + test('an exact rule names the branch: certain', async () => { + const { body } = await discover(customEnvs({ type: 'equals', pattern: 'staging' })); + expect(stagingProposal(body).git_branch).toBe('staging'); + expect(stagingProposal(body).confidence.branch).toBe('certain'); + expect(stagingProposal(body).evidence.git_branch).toEqual({ + basis: 'custom_environment_rule', + signals: [{ code: 'CUSTOM_ENVIRONMENT_RULE', branches: [{ branch: 'staging', weight: 1 }], rule: { type: 'equals', pattern: 'staging' } }], + }); + }); + + test('a prefix rule names no single branch: the production branch, needs_review, the rule listed', async () => { + const { body } = await discover(customEnvs({ type: 'startsWith', pattern: 'release/' })); + expect(stagingProposal(body).git_branch).toBe('main'); + expect(stagingProposal(body).confidence.branch).toBe('needs_review'); + expect(stagingProposal(body).evidence.git_branch).toEqual({ + basis: 'custom_environment_pattern', + signals: [{ code: 'CUSTOM_ENVIRONMENT_RULE', branches: [], rule: { type: 'startsWith', pattern: 'release/' } }], + }); + }); + + test('no rule: as before, the production branch with no judgement', async () => { + const { body } = await discover(customEnvs(undefined)); + expect(stagingProposal(body).git_branch).toBe('main'); + expect(stagingProposal(body).evidence.git_branch).toBeUndefined(); + }); + + test('a rule type the client does not know is dropped, not guessed', async () => { + const { body } = await discover(customEnvs({ type: 'regex', pattern: '.*' })); + expect(stagingProposal(body).evidence.git_branch).toBeUndefined(); + }); +}); + +describe('preview git branch: names only', () => { + test('no value, and no value-route call, appears anywhere', async () => { + const { body, world } = await discover({ deployments: deploys('develop', 2), scoped: [{ branch: 'develop', names: ['NEW_FLAG'] }], defaultBranch: 'develop' }); + expect(JSON.stringify(body)).not.toContain(SENTINEL_VERCEL); + expect(world.vercelCalls().every((c) => !c.url.includes('/v1/projects/') && !c.url.includes('decrypt'))).toBe(true); + }); +}); + +describe('inferPreviewBranch (pure)', () => { + const signal = (code: BranchSignal['code'], ...branches: ReadonlyArray): BranchSignal => ({ + code, + branches: branches.map(([branch, weight]) => ({ branch, weight })), + }); + + test('leadersOf lists every branch tied for the top weight', () => { + expect(leadersOf(signal('RECENT_PREVIEW_DEPLOYMENTS', ['b', 3], ['a', 3], ['c', 1]))).toEqual(['a', 'b']); + }); + + test('deployments alone add no candidate: the production branch, certain', () => { + expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS', ['feat/x', 9], ['fix/y', 3])], 'main')).toMatchObject({ + branch: 'main', + confidence: 'certain', + basis: 'production_branch_only', + candidates: ['main'], + }); + }); + + test('candidates are the production branch, the default branch and the scoped branches only', () => { + const inferred = inferPreviewBranch( + [signal('RECENT_PREVIEW_DEPLOYMENTS', ['feat/x', 9]), signal('BRANCH_SCOPED_VARIABLES', ['staging', 2]), signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], + 'main', + ); + expect(inferred.candidates).toEqual(['develop', 'main', 'staging']); + }); + + test('the default branch alone beats the production branch, and everyone naming it is agreement', () => { + expect(inferPreviewBranch([signal('RECENT_PREVIEW_DEPLOYMENTS', ['feat/x', 9]), signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], 'main')).toMatchObject({ + branch: 'develop', + confidence: 'certain', + basis: 'signals_agree', + }); + }); + + test('a signal that disagrees with itself (a tie between candidates) is not agreement', () => { + expect(inferPreviewBranch([signal('BRANCH_SCOPED_VARIABLES', ['a', 1], ['b', 1])], 'main')).toMatchObject({ branch: 'a', confidence: 'needs_review' }); + }); + + test('without a production branch and without candidates the answer is null, needs_review', () => { + expect(inferPreviewBranch([], null)).toMatchObject({ branch: null, confidence: 'needs_review', basis: 'no_signals' }); + }); + + test('without a production branch, a single named candidate is the answer', () => { + expect(inferPreviewBranch([signal('GITHUB_DEFAULT_BRANCH', ['develop', 1])], null)).toMatchObject({ branch: 'develop', confidence: 'certain' }); + }); +}); + +describe('the Vercel client: preview deployments and branch rules', () => { + const client = (fake: FakeVercelData) => ({ api: createVercelClient('t', { fetch: fakeVercelFetch(fake), sleep: async () => {} }) }); + const data = (over: Partial): FakeVercelData => ({ projects: {}, envs: {}, ...over }); + + test('reads githubCommitRef only; a deployment without one still comes back, with no branch', async () => { + const { api } = client(data({ deployments: { p: [{ branch: 'develop' }, { branch: null }] } })); + expect(await api.listPreviewDeployments('p', 'team_1', 10)).toEqual([{ id: 'dpl_p_0', gitBranch: 'develop' }, { id: 'dpl_p_1' }]); + }); + + test('a custom environment\'s branch matcher is parsed; an unknown type is dropped', async () => { + const { api } = client( + data({ + custom: { + p: [ + { id: 'a', slug: 'a', branchMatcher: { type: 'endsWith', pattern: '-stg' } }, + { id: 'b', slug: 'b', branchMatcher: { type: 'glob', pattern: '*' } }, + { id: 'c', slug: 'c' }, + ], + }, + }), + ); + expect(await api.listCustomEnvironments('p')).toEqual([{ id: 'a', slug: 'a', branchMatcher: { type: 'endsWith', pattern: '-stg' } }, { id: 'b', slug: 'b' }, { id: 'c', slug: 'c' }]); + }); +}); diff --git a/tests/commands/deployVercelApiMode.test.ts b/tests/commands/deployVercelApiMode.test.ts new file mode 100644 index 00000000..f79b46fd --- /dev/null +++ b/tests/commands/deployVercelApiMode.test.ts @@ -0,0 +1,324 @@ +/** + * A REAL `deployCommand()` run against a Vercel target in API mode (CAP-696), + * against a real git repo + bare origin (same vehicle as + * `deployCiTargetsRecord.test.ts`) and an in-memory Vercel + * (`tests/deploy/vercelApiModeFake.ts`) standing in for `fetch`. + * + * What it proves, end to end: + * - the token is asked of the org system store ONCE, by the documented name, + * and never printed; + * - a preview target with no gitBranch deploys under `--yes` (the CLI path's + * "missing gitBranch" refusal does not apply in API mode); + * - the keep.lock PR still opens, and the delivery is recorded on keep.lock + * (`provider: 'vercel'`, target name, `ref.projectId`, deployed hash); + * - a dry run reads but never writes, and records nothing; + * - a refused preflight / failed delivery sends no value and records nothing; + * - a target WITHOUT projectId never asks the store or Vercel for anything. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, spyOn, afterEach } from 'bun:test'; +import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { spawnSync } from 'child_process'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; +import { stubbedGate } from '../helpers/gateMock'; + +// Bare `capy` (a deploy's first step) is tested in tests/sync/gate.test.ts and tests/commands/gateFirst.test.ts. +mock.module('../../src/sync/gate', () => stubbedGate()); + +const TMP = join(tmpdir(), `capy-deploy-vercel-api-${process.pid}`); +const REPO = join(TMP, 'repo'); +const ORIGIN = join(TMP, 'origin.git'); +const SERVER_SNAPSHOTS_PATH = join(TMP, 'server-snapshots.json'); + +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: 'user_1' }; + } + async getValidToken() { + return 'fake-session-token'; + } + }, + silentAuthFailureMessage: () => 'auth failed', +})); + +const pushSecretsMock = mock(async (_projectId: string, keepFileJson: string, _envBlob: string, _branch: string) => ({ + keep_hash: 'h'.repeat(16), + keep_file: keepFileJson, +})); +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async pushSecrets(projectId: string, keepFileJson: string, envBlob: string, branch: string) { + const pushed = await pushSecretsMock(projectId, keepFileJson, envBlob, branch); + // The server keeps what it accepted: the next read (a later push's base) is the pushed state. + const existing = existsSync(SERVER_SNAPSHOTS_PATH) + ? (JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record) + : {}; + writeFileSync(SERVER_SNAPSHOTS_PATH, JSON.stringify({ ...existing, [branch]: { keepFile: keepFileJson, envFile: envBlob } })); + return pushed; + } + // The same snapshot as `getLatestSecrets`, for the commit path (which reads the server's latest before it writes). + async getDecryptData(_projectId: string, branch: string) { + const snap = existsSync(SERVER_SNAPSHOTS_PATH) + ? (JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record)[branch] + : undefined; + if (!snap) throw new Error('no server snapshot written for this branch'); + return agreeingDecryptData(JSON.parse(snap.keepFile), branch, snap.envFile); + } + async getLatestSecrets(_projectId: string, branch: string) { + if (!existsSync(SERVER_SNAPSHOTS_PATH)) return null; + const snapshots = JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record; + const snap = snapshots[branch]; + if (!snap) return null; + return { + env_file: snap.envFile, + keep_hash: SyncEngine.computeKeepHash(JSON.parse(snap.keepFile), branch), + keep_file: snap.keepFile, + }; + } + }, +})); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); +mock.module('../../src/utils/gh', () => ({ + resolveGh: () => null, + GH_SEARCHED: ['PATH'], +})); + +afterEach(() => { + mock.restore(); + pushSecretsMock.mockClear(); +}); + +import { deployCommand } from '../../src/commands/deployCommand'; +import { hashValue } from '../../src/deploy/keepGate'; +import { serializeKeep } from '../../src/files/fileManager'; +import type { KeepFile } from '../../src/types/index'; +import { createFakeVercel, type FakeRecord, type FakeVercelOptions } from '../deploy/vercelApiModeFake'; + +function git(args: string[], cwd: string) { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8' }); + return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + +const TOKEN = 'tok_fake_value_never_print'; +const STRIPE_VALUE = 'same-value'; + +function baseKeep(): KeepFile { + return { + version: '3.0', + org_id: 'org_1', + project_id: 'proj_1', + project_name: 'demo', + variables: { + STRIPE_KEY: [{ resource_id: 'r-1', branch: 'main', value_hash: hashValue(STRIPE_VALUE) }], + }, + } as unknown as KeepFile; +} + +function writeServerSnapshot(branch: string): void { + writeFileSync( + SERVER_SNAPSHOTS_PATH, + JSON.stringify({ [branch]: { keepFile: JSON.stringify(baseKeep()), envFile: 'STRIPE_KEY=SERVER_STORED_BLOB_UNCHANGED' } }), + ); +} + +const API_OPTIONS = { projectDir: 'web', projectId: 'prj_1', teamId: 'team_1' }; + +function setUpRepo(targetOptions: Record): void { + rmSync(TMP, { recursive: true, force: true }); + mkdirSync(TMP, { recursive: true }); + git(['init', '--bare', '-b', 'main', ORIGIN], TMP); + git(['init', '-q', '-b', 'main', REPO], TMP); + git(['config', 'user.email', 't@e.com'], REPO); + git(['config', 'user.name', 'T'], REPO); + git(['config', 'commit.gpgsign', 'false'], REPO); + git(['remote', 'add', 'origin', ORIGIN], REPO); + mkdirSync(join(REPO, '.capy'), { recursive: true }); + writeFileSync(join(REPO, 'keep.lock'), serializeKeep(baseKeep())); + writeFileSync(join(REPO, '.env'), `STRIPE_KEY=${STRIPE_VALUE}\n`); + writeFileSync( + join(REPO, '.capy', 'deploy.json'), + JSON.stringify({ + version: '1', + targets: { + 'vercel-api': { + name: 'vercel-api', + kind: 'vercel', + branch: 'main', + vars: ['STRIPE_KEY'], + mode: 'ci', + gitBaseBranch: 'main', + options: targetOptions, + }, + }, + }), + ); + git(['add', '.'], REPO); + git(['commit', '-q', '-m', 'base'], REPO); + git(['push', '-q', 'origin', 'main'], REPO); + writeServerSnapshot('main'); +} + +interface Run { + code: number; + lines: readonly string[]; + vercelWrites: number; + vercelCalls: number; + fake: ReturnType; + storeAsks: ReadonlyArray; +} + +/** + * Runs the deploy with console, Vercel and the system store faked. `store` + * is what `getDirectionalConnectorSecret` answers (the real one is mocked + * out: this file is about the wiring, `vercelApiModeTokenStore.test.ts` is + * about the store itself). Every count is read off bun's own spies. + */ +async function run( + options: Parameters[1], + fakeOpts: FakeVercelOptions = {}, + store: string | null = TOKEN, +): Promise { + const fake = createFakeVercel({ projectId: 'prj_1', teamId: 'team_1', token: TOKEN, ...fakeOpts }); + const getDirectional = mock(async (..._args: unknown[]) => store); + mock.module('../../src/system/systemStore', () => ({ getDirectionalConnectorSecret: getDirectional })); + const logMock = mock((..._a: unknown[]) => {}); + const errMock = mock((..._a: unknown[]) => {}); + const logSpy = spyOn(console, 'log').mockImplementation(logMock as never); + const errSpy = spyOn(console, 'error').mockImplementation(errMock as never); + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(((input: string, init?: RequestInit) => fake.fetch(String(input), init)) as never); + try { + const code = await deployCommand('vercel-api', options, REPO); + return { + code, + lines: [...logMock.mock.calls, ...errMock.mock.calls].map((args) => args.map(String).join(' ')), + vercelWrites: fake.writeCalls().length, + vercelCalls: fake.calls().length, + fake, + storeAsks: getDirectional.mock.calls, + }; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + fetchSpy.mockRestore(); + } +} + +function withCwd(fn: () => Promise): Promise { + const original = process.cwd(); + process.chdir(REPO); + return fn().finally(() => process.chdir(original)); +} + +const rec = (over: Partial & Pick): FakeRecord => ({ type: 'encrypted', ...over }); + +describe('capy deploy — Vercel API mode, end to end (CAP-696)', () => { + afterEach(() => rmSync(TMP, { recursive: true, force: true })); + + test('preview target with NO gitBranch deploys under --yes: values land, the PR branch is pushed, the delivery is recorded', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'preview' }); + const result = await withCwd(() => + run( + { yes: true }, + { records: [rec({ id: 'env_shared', key: 'STRIPE_KEY', value: 'old', target: ['production', 'preview'] })] }, + ), + ); + expect(result.code).toBe(0); + // Property: preview got the new value, production did not move. + expect(result.fake.seenBy('preview', 'STRIPE_KEY')).toBe(STRIPE_VALUE); + expect(result.fake.seenBy('production', 'STRIPE_KEY')).toBe('old'); + // The system store was asked once, for the documented name, with the connector as the fallback. + expect(result.storeAsks).toHaveLength(1); + const [name, fallback, opts] = result.storeAsks[0] as [string, string, Record]; + expect(name).toBe('_TARGET_VERCEL_TOKEN'); + expect(fallback).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(opts).toMatchObject({ interactive: false, missingWithFallbackCode: 'VERCEL_TARGET_KEY_MISSING' }); + // Never printed. + expect(result.lines.some((l) => l.includes(TOKEN))).toBe(false); + // The keep.lock PR branch exists on origin: the deploy PR still opens in API mode. + expect(git(['branch', '--list', 'capy-deploy-*'], ORIGIN).stdout).toContain('capy-deploy-'); + // Recorded on the server's keep.lock. + expect(pushSecretsMock).toHaveBeenCalledTimes(1); + const pushedKeep = JSON.parse(pushSecretsMock.mock.calls[0][1]); + const targets = pushedKeep.variables.STRIPE_KEY[0].targets; + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + provider: 'vercel', + target: 'vercel-api', + ref: { projectId: 'prj_1' }, + deployed_value_hash: hashValue(STRIPE_VALUE), + }); + expect(targets[0].ref.envId).toBeUndefined(); + }, 30_000); + + test('a custom environment target records its environment id as ref.envId', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'staging', customEnvironmentId: 'env_stg' }); + const result = await withCwd(() => run({ yes: true }, { customEnvs: [{ id: 'env_stg', slug: 'staging' }] })); + expect(result.code).toBe(0); + expect(result.fake.seenBy('custom:env_stg', 'STRIPE_KEY')).toBe(STRIPE_VALUE); + const targets = JSON.parse(pushSecretsMock.mock.calls[0][1]).variables.STRIPE_KEY[0].targets; + expect(targets[0].ref).toEqual({ projectId: 'prj_1', envId: 'env_stg' }); + }, 30_000); + + test('--dry-run reads but writes nothing: no Vercel write, no push to Capy, no PR branch', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true, dryRun: true })); + expect(result.code).toBe(0); + expect(result.vercelWrites).toBe(0); + expect(result.fake.records()).toEqual([]); + expect(pushSecretsMock).not.toHaveBeenCalled(); + expect(git(['branch', '--list', 'capy-deploy-*'], ORIGIN).stdout.trim()).toBe(''); + expect(result.lines.some((l) => l.includes('would push 1 var(s) to production'))).toBe(true); + }, 30_000); + + test('no token anywhere + no TTY: refused with the key named, zero Vercel requests, nothing recorded', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true }, {}, null)); + expect(result.code).toBe(1); + expect(result.vercelCalls).toBe(0); + expect(result.lines.some((l) => l.includes('_TARGET_VERCEL_TOKEN'))).toBe(true); + expect(pushSecretsMock).not.toHaveBeenCalled(); + // The refusal says how to make a token, in the approved words (stderr). + const text = result.lines.join('\n'); + expect(text).toContain('Create a token at https://vercel.com/account/settings/tokens. Set Scope to the team that owns the project.'); + expect(text).toContain('Then save it: capy system set _TARGET_VERCEL_TOKEN'); + expect(text).not.toContain(TOKEN); + }, 30_000); + + test('the same refusal names the team when the saved target knows its slug', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production', teamSlug: 'acme' }); + const result = await withCwd(() => run({ yes: true }, {}, null)); + expect(result.code).toBe(1); + expect(result.lines.join('\n')).toContain('Set Scope to the team that owns the project (acme).'); + }, 30_000); + + test('a token Vercel rejects: refused at preflight before any value is sent, nothing recorded', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true }, { token: 'a-different-token' })); + expect(result.code).toBe(1); + expect(result.vercelWrites).toBe(0); + expect(pushSecretsMock).not.toHaveBeenCalled(); + }, 30_000); + + test('a failed delivery fails the run and records nothing', async () => { + setUpRepo({ ...API_OPTIONS, vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true }, { failPostFor: ['STRIPE_KEY'] })); + expect(result.code).toBe(1); + expect(pushSecretsMock).not.toHaveBeenCalled(); + expect(git(['branch', '--list', 'capy-deploy-*'], ORIGIN).stdout.trim()).toBe(''); + }, 30_000); + + test('a target WITHOUT projectId never asks the store for a token and never calls Vercel', async () => { + // The old path: no projectId. The project dir is absent, so its own preflight refuses — before anything else. + setUpRepo({ projectDir: 'web', vercelEnv: 'production' }); + const result = await withCwd(() => run({ yes: true })); + expect(result.code).toBe(1); + expect(result.storeAsks).toHaveLength(0); + expect(result.vercelCalls).toBe(0); + }, 30_000); +}); diff --git a/tests/commands/deployVercelSetupPicker.test.ts b/tests/commands/deployVercelSetupPicker.test.ts new file mode 100644 index 00000000..512a7aba --- /dev/null +++ b/tests/commands/deployVercelSetupPicker.test.ts @@ -0,0 +1,149 @@ +/** + * `capy deploy`'s target picker for Vercel (the wiring around + * `deploy/vercelSetup.ts`, which `tests/deploy/vercelSetup.test.ts` covers on + * its own): the target that gets saved, the PR base branch defaulting to the + * project's production branch, a legacy target coming out in API mode when + * re-edited, the setup staying in the terminal, and no `.vercel/project.json` + * or `vercel` anywhere. + * + * `mock.module('inquirer')` and `mock.module('…/systemStore')` are + * process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, afterEach, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync, existsSync, realpathSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; + +const promptMock = mock(async (_questions: unknown) => ({})); +mock.module('inquirer', () => ({ + default: { prompt: promptMock, Separator: class {} }, +})); + +const getSecret = mock(async (..._args: unknown[]) => 'vcp_FAKE_PICKER_TOKEN' as string | null); +mock.module('../../src/system/systemStore', () => ({ getDirectionalConnectorSecret: getSecret })); + +import { runPicker, resolveAdapterOptions } from '../../src/commands/deployCommand'; +import { getAdapter } from '../../src/deploy/registry'; +import type { TargetConfig } from '../../src/deploy/adapter'; +import { fakeVercelFetch, project } from '../helpers/fakeVercel'; +import { captureConsole } from '../helpers/testSandbox'; + +afterEach(() => { + promptMock.mockReset(); + getSecret.mockClear(); +}); + +const DATA = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web', { link: { type: 'github', org: 'acme', repo: 'widgets', productionBranch: 'trunk' } })] }, + envs: {}, + custom: {}, +}; + +/** A throwaway git repo on branch `feature-x` with a `.env` holding two variables. */ +function repo(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'capy-vercel-picker-'))); + const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }); + git('init', '-q', '-b', 'feature-x'); + git('config', 'user.email', 't@e.com'); + git('config', 'user.name', 'T'); + git('config', 'commit.gpgsign', 'false'); + writeFileSync(join(dir, '.env'), 'API_KEY=v1\nDB_URL=v2\n'); + writeFileSync(join(dir, 'README.md'), 'x'); + git('add', 'README.md'); + git('commit', '-q', '-m', 'init'); + return dir; +} + +const KEEP = { orgId: 'org_1', projectId: 'proj_1', variables: ['API_KEY', 'DB_URL'], branches: ['production', 'development'] }; + +/** Answers each question by its name: `script[name]` (or its default when the script has none). */ +function answerBy(script: Record) { + promptMock.mockImplementation(async (questions: unknown) => { + const q = (questions as ReadonlyArray<{ name: string; default?: unknown }>)[0]; + return { [q.name]: q.name in script ? script[q.name] : q.default }; + }); +} + +const questionsAsked = () => promptMock.mock.calls.map(([qs]) => (qs as ReadonlyArray<{ name: string; default?: unknown; message?: string }>)[0]); + +const withFakeVercel = async (fn: () => Promise): Promise => { + const fake = fakeVercelFetch(DATA); + const spy = spyOn(globalThis, 'fetch').mockImplementation(((url: string, init: never) => fake(url, init)) as never); + try { + return await fn(); + } finally { + spy.mockRestore(); + } +}; + +describe('capy deploy picker — Vercel (API mode)', () => { + test('a NEW Vercel target: one link question, checked live, saved in API mode; the PR base defaults to the project\'s production branch; no .vercel/project.json', async () => { + const cwd = repo(); + try { + answerBy({ branch: 'production', projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production', vars: ['API_KEY'], name: 'vercel-production' }); + const saved = await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, undefined, 'vercel'))); + const target = saved.result as TargetConfig; + expect(target).toMatchObject({ + name: 'vercel-production', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY'], + mode: 'ci', + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }); + // The question asked about the base branch defaults to Vercel's production branch, not the checked-out branch (feature-x). + const baseQuestion = questionsAsked().find((q) => q.name === 'gitBaseBranch'); + expect(baseQuestion?.default).toBe('trunk'); + expect(target.gitBaseBranch).toBe('trunk'); + // None of the old CLI-path questions exist. + expect(questionsAsked().map((q) => q.name)).not.toContain('projectDir'); + expect(questionsAsked().map((q) => q.message).join('\n')).not.toContain('.vercel/project.json'); + expect(existsSync(join(cwd, '.vercel'))).toBe(false); + expect(saved.stdout.join('\n') + saved.stderr.join('\n')).not.toContain('vcp_FAKE_PICKER_TOKEN'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('re-editing a LEGACY target (no projectId) goes through the new setup and is saved in API mode', async () => { + const cwd = repo(); + try { + const legacy: TargetConfig = { + name: 'old-vercel', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY'], + mode: 'ci', + gitBaseBranch: 'main', + options: { projectDir: 'web', vercelEnv: 'preview', gitBranch: 'develop' }, + }; + answerBy({ branch: 'production', projectUrl: 'web', confirmed: true, vercelEnv: 'preview', gitBranch: 'develop', vars: ['API_KEY'], name: 'old-vercel' }); + const saved = await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, legacy))); + expect((saved.result as TargetConfig).options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_web', teamId: 'team_acme', gitBranch: 'develop' }); + expect(questionsAsked().find((q) => q.name === 'gitBranch')?.default).toBe('develop'); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('a dry run (readOnly) never lets the store ask for or save a token', async () => { + const cwd = repo(); + try { + answerBy({ branch: 'production', projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production', vars: ['API_KEY'], name: 'v' }); + await withFakeVercel(() => captureConsole(() => runPicker(cwd, KEEP, undefined, 'vercel', { readOnly: true }))); + expect(getSecret.mock.calls[0][2]).toMatchObject({ interactive: false }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('resolveAdapterOptions for Vercel returns the same API-mode options (what a caller saves verbatim)', async () => { + answerBy({ projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production' }); + const adapter = getAdapter('vercel'); + if (!adapter) throw new Error('vercel adapter not registered'); + const options = await withFakeVercel(() => resolveAdapterOptions(adapter, '/tmp', [], {}, {}, 'org_1')); + expect(options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }); + }); +}); diff --git a/tests/commands/dryRunInteractive.test.ts b/tests/commands/dryRunInteractive.test.ts index 9b809da1..3012f455 100644 --- a/tests/commands/dryRunInteractive.test.ts +++ b/tests/commands/dryRunInteractive.test.ts @@ -78,12 +78,12 @@ describe('interactive paths refuse under --dry-run, before anything else', () => expect(out.raw).not.toContain('\x1b[?1049h'); // the alt screen was never entered }); - test('the same with a name (the TUI would open on it), in human mode: the sentence on stderr', async () => { + test('the same with a name (the hidden value prompt would ask), in human mode: the sentence on stderr', async () => { setTTY(true); const out = await capture(() => new EditCommand().execute({ name: 'SOME_VAR', dryRun: true })); expect(out.exitCode).toBe(1); expect(out.stdout).toBe(''); - expect(out.stderr).toBe("--dry-run isn't available in the interactive editor. Pipe a value with capy edit NAME --dry-run."); + expect(out.stderr).toBe(DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt); expect(out.raw).not.toContain('\x1b[?1049h'); }); diff --git a/tests/commands/editMode.test.ts b/tests/commands/editMode.test.ts index 2439a454..ca4f097c 100644 --- a/tests/commands/editMode.test.ts +++ b/tests/commands/editMode.test.ts @@ -23,8 +23,8 @@ describe('decideEditMode', () => { expect(decideEditMode(facts({ stdinIsTTY: true }))).toBe('tui'); }); - test('2. a terminal and a name is still the TUI (focused on NAME)', () => { - expect(decideEditMode(facts({ stdinIsTTY: true, hasName: true }))).toBe('tui'); + test('2. a terminal and a name is the hidden value prompt (capy add NAME --force), not the TUI', () => { + expect(decideEditMode(facts({ stdinIsTTY: true, hasName: true }))).toBe('prompt'); }); test('a name with stdin not a terminal is piped mode', () => { diff --git a/tests/commands/editNamePrompt.test.ts b/tests/commands/editNamePrompt.test.ts new file mode 100644 index 00000000..c4814461 --- /dev/null +++ b/tests/commands/editNamePrompt.test.ts @@ -0,0 +1,247 @@ +/** + * `capy edit NAME` on a terminal is `capy add NAME --force`: the same hidden prompt, the same write path. + * + * - it asks for the value once, with the masked password prompt `capy add` uses, and never asks "overwrite?"; + * - a new name is created, an existing one overwritten, through `writeAndSync` (encrypt + sync + push); + * - `--no-push` writes `.env` only (the real `writeAndSync`, a real `FileManager` in a temp folder); + * - `--dry-run` refuses with DRY_RUN_UNSUPPORTED before any prompt, any read or any write; + * - no terminal (or `--non-tty`) and no piped value is a coded refusal with no prompt; + * - `capy edit` with no name does not take this path. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). All values are fake. + */ +import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; +import { mkdirSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { stubbedGate, gateRan } from '../helpers/gateMock'; +import * as realShared from '../../src/commands/connectors/shared'; +import * as realKeepLockPr from '../../src/commands/keepLockPr'; +import * as realProfileConfig from '../../src/config/profileConfig'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { KeepFile } from '../../src/types/index'; + +const TEST_DIR = join(tmpdir(), `capy-edit-name-prompt-${process.pid}`); +const ORIGINAL_CWD = process.cwd(); +const PROJECT_KEY = 'test-project-key'; +const FAKE_VALUE = 'fake-value-for-test-only'; + +mock.module('../../src/sync/gate', () => stubbedGate()); + +/** The questions each `inquirer.prompt` call was given. */ +const promptSpy = mock(async (_questions: readonly unknown[]) => ({ value: FAKE_VALUE, ok: true })); +mock.module('inquirer', () => ({ default: { prompt: promptSpy } })); + +// Captured BEFORE mock.module: bun rewrites the already-imported namespace in place, so `realShared.writeAndSync` +// would afterwards be the wrapper below, and the wrapper calling it would recurse forever. +const realWriteAndSync = realShared.writeAndSync; + +const contextSpy = mock(async (_opts: unknown): Promise => { + throw new Error('contextSpy: no context set for this test'); +}); +const writeSpy = mock(async (_ctx: ResolvedContext, _name: string, _value: string | undefined, _opts: { push: boolean }) => undefined); +mock.module('../../src/commands/connectors/shared', () => ({ + ...realShared, + resolveContext: contextSpy, + // The pushing path is the server's (tested elsewhere): it is recorded. The `.env`-only path is the real one. + writeAndSync: async (ctx: ResolvedContext, name: string, value: string | undefined, opts: { push: boolean }) => { + await writeSpy(ctx, name, value, opts); + if (!opts.push) await realWriteAndSync(ctx, name, value, opts); + }, +})); + +const prStepSpy = mock(async (_req: unknown) => ({ keep_lock: { changed: false } })); +mock.module('../../src/commands/keepLockPr', () => ({ ...realKeepLockPr, runKeepLockPrStep: prStepSpy })); + +const localOnlySpy = mock(() => false); +mock.module('../../src/config/profileConfig', () => ({ ...realProfileConfig, isLocalOnly: localOnlySpy })); + +import { EditCommand } from '../../src/commands/editCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { ProjectManager } from '../../src/core/projectManager'; +import { readLocalPlaintext } from '../../src/sync/commit'; +import { ERROR_CODES } from '../../src/types/index'; +import { DRY_RUN_UNSUPPORTED_MESSAGES } from '../../src/commands/pipedValue'; + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('process.exit called'); + } +} + +const ORIGINAL_STDIN_TTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); +const ORIGINAL_STDOUT_TTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + +function setTTY(on: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { value: on, configurable: true, writable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: on, configurable: true, writable: true }); +} + +function restoreTTY(stream: NodeJS.WriteStream | NodeJS.ReadStream, original: PropertyDescriptor | undefined): void { + if (original === undefined) { + Reflect.deleteProperty(stream, 'isTTY'); + return; + } + Object.defineProperty(stream, 'isTTY', original); +} + +function renderCalls(calls: readonly unknown[][]): string { + return calls.map((args) => args.map(String).join(' ')).join('\n'); +} + +/** Runs `fn`, capturing stdout/stderr and the exit code; `process.exit` never exits the runner. */ +async function capture(fn: () => 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, (e: unknown) => e); + const stdout = renderCalls(logSpy.mock.calls); + const stderr = renderCalls(errSpy.mock.calls); + const exits = exitSpy.mock.calls; + [exitSpy, logSpy, errSpy].forEach((s) => s.mockRestore()); + return { exitCode: exits.length > 0 ? (exits[exits.length - 1][0] as number | undefined) : undefined, stdout, stderr, thrown: thrown instanceof ExitSignal ? null : thrown }; +} + +function fakeContext(localPlaintext: Record): ResolvedContext { + const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + return { + pm: new ProjectManager(TEST_DIR), + fileManager: new FileManager(TEST_DIR), + orgId: 'o', + projectId: 'p', + branch: 'development', + userId: 'u', + projectKey: PROJECT_KEY, + keep, + localPlaintext, + } as unknown as ResolvedContext; +} + +const questionsOf = (callIndex: number): readonly Record[] => promptSpy.mock.calls[callIndex][0] as readonly Record[]; + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.chdir(TEST_DIR); + setTTY(true); + [promptSpy, contextSpy, writeSpy, prStepSpy, localOnlySpy, gateRan].forEach((m) => m.mockClear()); + localOnlySpy.mockImplementation(() => false); + contextSpy.mockImplementation(async () => fakeContext({})); +}); + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + rmSync(TEST_DIR, { recursive: true, force: true }); + restoreTTY(process.stdin, ORIGINAL_STDIN_TTY); + restoreTTY(process.stdout, ORIGINAL_STDOUT_TTY); +}); + +describe('capy edit NAME on a terminal', () => { + test('asks once, with the masked password prompt capy add uses, and sets a new name', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'NEW_NAME' })); + expect(out.exitCode).toBeUndefined(); + expect(out.thrown).toBeNull(); + expect(promptSpy.mock.calls.length).toBe(1); + const [question] = questionsOf(0); + expect(question).toMatchObject({ type: 'password', mask: '*' }); + expect(String(question.message)).toContain('NEW_NAME'); + expect(writeSpy.mock.calls.length).toBe(1); + const [, name, value, opts] = writeSpy.mock.calls[0]; + expect([name, value, opts]).toEqual(['NEW_NAME', FAKE_VALUE, { push: true }]); + expect(gateRan.mock.calls.length).toBe(1); // bare `capy` ran first, like add + expect(out.stdout).toContain('Saved 1 variable(s): NEW_NAME'); + }); + + test('overwrites an existing name without asking "overwrite?"', async () => { + contextSpy.mockImplementation(async () => fakeContext({ EXISTING: 'old-fake-value' })); + const out = await capture(() => new EditCommand().execute({ name: 'EXISTING' })); + expect(out.exitCode).toBeUndefined(); + expect(out.thrown).toBeNull(); + // One question in total: the value. No confirm question was ever asked. + expect(promptSpy.mock.calls.length).toBe(1); + expect(questionsOf(0).map((q) => q.type)).toEqual(['password']); + expect(writeSpy.mock.calls.map((c) => [c[1], c[2]])).toEqual([['EXISTING', FAKE_VALUE]]); + }); + + test('an empty answer is refused with INVALID_FORMAT and writes nothing', async () => { + promptSpy.mockImplementationOnce(async () => ({ value: '', ok: true })); + const out = await capture(() => new EditCommand().execute({ name: 'EMPTY_ONE' })); + expect(out.thrown).toMatchObject({ code: ERROR_CODES.INVALID_FORMAT }); + expect(out.exitCode).toBeUndefined(); + expect(writeSpy.mock.calls.length).toBe(0); + }); + + test('--no-push writes .env only: the value is in .env (encrypted), and nothing is pushed', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'LOCAL_ONLY_WRITE', noPush: true })); + expect(out.exitCode).toBeUndefined(); + expect(out.thrown).toBeNull(); + expect(writeSpy.mock.calls.map((c) => c[3])).toEqual([{ push: false }]); + const held = readLocalPlaintext(new FileManager(TEST_DIR), PROJECT_KEY); + expect(held.LOCAL_ONLY_WRITE).toBe(FAKE_VALUE); + expect(out.stdout).toContain('.env only'); + }); + + test('--pr / --no-pr / --pr-base and --resolve/--conflict reach the same steps add uses, and the PR step is named edit', async () => { + const pr = { prBase: 'trunk' }; + const conflictFlags = { resolve: ['LEFT=local'], conflict: 'c_0123456789ab' }; + await capture(() => new EditCommand().execute({ name: 'WITH_FLAGS', pr, conflictFlags: conflictFlags as never })); + const [req] = prStepSpy.mock.calls[0] as [{ command: string; flags: unknown; records: readonly unknown[] }]; + expect(req.command).toBe('edit'); + expect(req.flags).toEqual(pr); + const [gateOpts] = gateRan.mock.calls[0] as [{ conflictFlags: unknown }]; + expect(gateOpts.conflictFlags).toEqual(conflictFlags); + }); + + test('--dry-run: DRY_RUN_UNSUPPORTED before any prompt, any read and any write', async () => { + const json = await capture(() => new EditCommand().execute({ name: 'DRY_ONE', dryRun: true, json: true })); + expect(json.exitCode).toBe(1); + expect(JSON.parse(json.stdout)).toEqual({ ok: false, code: ERROR_CODES.DRY_RUN_UNSUPPORTED, error: DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt }); + const human = await capture(() => new EditCommand().execute({ name: 'DRY_ONE', dryRun: true })); + expect(human.exitCode).toBe(1); + expect(human.stderr).toBe(DRY_RUN_UNSUPPORTED_MESSAGES.editPrompt); + expect(promptSpy.mock.calls.length).toBe(0); + expect(contextSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + expect(gateRan.mock.calls.length).toBe(0); + }); + + test('no terminal and no piped value (--non-tty on a terminal): EDIT_NEEDS_TTY, no prompt', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'NO_TTY', nonTty: true, json: true })); + expect(out.exitCode).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: ERROR_CODES.EDIT_NEEDS_TTY }); + expect(promptSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + }); + + test('a bad name is refused with INVALID_FORMAT, not echoed, before any prompt', async () => { + const out = await capture(() => new EditCommand().execute({ name: 'BAD-NAME=hunter2', json: true })); + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: ERROR_CODES.INVALID_FORMAT }); + expect(out.stdout).not.toContain('hunter2'); + expect(promptSpy.mock.calls.length).toBe(0); + }); + + test('a local-only profile is refused with a code (it has no organization to write to), before any prompt', async () => { + localOnlySpy.mockImplementation(() => true); + const out = await capture(() => new EditCommand().execute({ name: 'LOCAL_PROFILE', json: true })); + expect(out.exitCode).toBe(1); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: ERROR_CODES.EDIT_STDIN_LOCAL_ONLY }); + expect(promptSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + }); +}); + +describe('capy edit with no name still opens the editor', () => { + test('it does not prompt, resolve a write context or write: it goes on to the editor\'s own setup (keep.lock check)', async () => { + const out = await capture(() => new EditCommand().execute({})); + // The temp folder has no keep.lock: the editor's setup is what answers. + expect(out.exitCode).toBe(1); + expect(out.stderr).toContain('No keep.lock found'); + expect(gateRan.mock.calls.length).toBe(1); + expect(promptSpy.mock.calls.length).toBe(0); + expect(contextSpy.mock.calls.length).toBe(0); + expect(writeSpy.mock.calls.length).toBe(0); + }); +}); diff --git a/tests/commands/keyHelpRefusals.test.ts b/tests/commands/keyHelpRefusals.test.ts new file mode 100644 index 00000000..6c34a860 --- /dev/null +++ b/tests/commands/keyHelpRefusals.test.ts @@ -0,0 +1,249 @@ +/** + * Every ask for, and refusal for lack of, a Vercel token or a Dokploy API key + * says where to get one: `link: { label, url }` + `remedy` on the JSON + * refusal (the `Blocked` field names), and the URL in the human text on + * stderr. Additive: the pre-existing fields are asserted still present. + * + * No `mock.module()`: this file runs in the normal batch. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { createDokployConnector } from '../../src/commands/connectors/dokploy'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { createDokployAdapter } from '../../src/deploy/adapters/dokploy'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import { dokployKeyHelp, formatKeyHelp, keyHelpForName, vercelKeyHelp } from '../../src/deploy/keyHelp'; +import type { TargetConfig } from '../../src/deploy/adapter'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import type { ConnectOpts } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { captureConsole, withExitCodeRestored } from '../helpers/testSandbox'; + +const VERCEL_PAGE = 'https://vercel.com/account/settings/tokens'; +/** Vince's approved wording, verbatim. */ +const CREATE = `Create a token at ${VERCEL_PAGE}. Set Scope to the team that owns the project`; +const sentence = (team?: string) => `${CREATE}${team ? ` (${team})` : ''}.`; +const SAVE = (name: string) => `Then save it: capy system set ${name}`; +const FAKE_TOKEN = 'vcp_FAKE_TOKEN_VALUE_123'; +const DOKPLOY_BASE = 'https://deploy.example.test'; + +const ctxWith = (): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} } as KeepFile, + branch: 'production', + orgId: 'org_1', + localPlaintext: {}, + }) as unknown as ResolvedContext; + +const unreachable: VercelFetchLike = async () => { + throw new Error('no request expected'); +}; + +/** Runs a refusal through the REAL `ConnectCommand` printing, once as JSON and once as human text. */ +async function printed(connector: ReturnType, provider: string, opts: ConnectOpts) { + const json = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, provider, ctxWith(), { ...opts, json: true }))); + const human = await withExitCodeRestored(() => captureConsole(() => new ConnectCommand(false)['executeImport'](connector, provider, ctxWith(), { ...opts, json: false }))); + return { json, human }; +} + +describe('keyHelp — the four known names', () => { + test('Vercel: the approved two sentences, for both names — one string in `remedy`, one line each for the terminal', () => { + for (const name of ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']) { + const help = keyHelpForName(name); + expect(help?.link).toEqual({ label: 'Vercel token page', url: VERCEL_PAGE }); + expect(help?.remedy).toBe(`${sentence()} ${SAVE(name)}`); + expect(help?.lines).toEqual([sentence(), SAVE(name)]); + expect(help?.remedy).not.toContain('--create-token'); + } + }); + + test('Vercel: the team slug is named when known; a team id is never shown as a slug', () => { + expect(vercelKeyHelp('_TARGET_VERCEL_TOKEN', { team: 'acme' }).lines[0]).toBe(sentence('acme')); + expect(vercelKeyHelp('_TARGET_VERCEL_TOKEN', { team: 'team_abc123' }).lines[0]).toBe(sentence()); + expect(keyHelpForName('_CONNECTOR_VERCEL_TOKEN', { team: 'acme' })?.remedy).toContain('(acme)'); + }); + + test('Vercel at a hidden prompt: "Then paste it here." instead of the save line', () => { + const help = vercelKeyHelp('_TARGET_VERCEL_TOKEN', { team: 'acme', mode: 'paste' }); + expect(help.lines).toEqual([sentence('acme'), 'Then paste it here.']); + expect(formatKeyHelp(help)).not.toContain('capy system set'); + }); + + test('Dokploy: the page inside the user\'s own Dokploy when the base URL is known (trailing slashes ignored), else the docs', () => { + expect(dokployKeyHelp('_CONNECTOR_DOKPLOY_API_KEY', `${DOKPLOY_BASE}///`).link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); + expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY')?.link.url).toBe('https://docs.dokploy.com/docs/api'); + expect(keyHelpForName('_TARGET_DOKPLOY_API_KEY', { baseUrl: DOKPLOY_BASE })?.link.url).toBe(`${DOKPLOY_BASE}/dashboard/settings/profile`); + }); + + test('any other name has no help', () => { + expect(keyHelpForName('_CONNECTOR_STRIPE_API_KEY')).toBeNull(); + expect(keyHelpForName('_CONNECTOR_WORKOS_API_KEY')).toBeNull(); + }); + + test('the human text carries the URL and the remedy', () => { + const text = formatKeyHelp(vercelKeyHelp('_CONNECTOR_VERCEL_TOKEN')); + expect(text).toBe(` ${sentence()}\n ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + expect(formatKeyHelp(dokployKeyHelp('_CONNECTOR_DOKPLOY_API_KEY', DOKPLOY_BASE))).toContain(`${DOKPLOY_BASE}/dashboard/settings/profile`); + }); +}); + +describe('capy connect vercel — key refusals', () => { + const opts: ConnectOpts = { nonTty: true, project: 'web' }; + + test('no token anywhere: VERCEL_TOKEN_MISSING carries link.url + remedy in JSON, and the URL on stderr; old fields intact', async () => { + const connector = createVercelConnector({ fetch: unreachable, env: {} }); + const { json, human } = await printed(connector, 'vercel', opts); + expect(json.stdout.length).toBe(1); + const parsed = JSON.parse(json.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); + expect(typeof parsed.message).toBe('string'); + expect(parsed.link.label).toBeTruthy(); + expect(parsed.remedy).toBe(`${sentence()} ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + expect(human.stdout.join('')).toBe(''); + expect(human.stderr.join('\n')).toContain(sentence()); + expect(human.stderr.join('\n')).toContain(SAVE('_CONNECTOR_VERCEL_TOKEN')); + expect([...json.stdout, ...json.stderr, ...human.stdout, ...human.stderr].join('\n')).not.toContain('--create-token'); + }); + + test('--team is named in the instructions (JSON remedy and stderr); a team id is not', async () => { + const connector = createVercelConnector({ fetch: unreachable, env: {} }); + const named = await printed(connector, 'vercel', { ...opts, team: 'acme' }); + expect(JSON.parse(named.json.stdout[0]).remedy).toBe(`${sentence('acme')} ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + expect(named.human.stderr.join('\n')).toContain(sentence('acme')); + const byId = await printed(connector, 'vercel', { ...opts, team: 'team_abc123' }); + expect(JSON.parse(byId.json.stdout[0]).remedy).toContain(`${sentence()} `); + }); + + test('--discover with no token carries the same instructions (team slug included)', async () => { + const connector = createVercelConnector({ fetch: unreachable, env: {} }); + const out = await connector.discover!( + { orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, + { nonTty: true, discover: true, team: 'acme' }, + ); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); + expect((out as { remedy?: string }).remedy).toBe(`${sentence('acme')} ${SAVE('_CONNECTOR_VERCEL_TOKEN')}`); + }); + + test('a token value is never printed: the token env var holds a fake value and it appears nowhere', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: unauthorized, env: { T: FAKE_TOKEN }, sleep: async () => {} }); + const { json, human } = await printed(connector, 'vercel', { ...opts, tokenEnv: 'T' }); + expect([...json.stdout, ...json.stderr, ...human.stdout, ...human.stderr].join('\n')).not.toContain(FAKE_TOKEN); + }); + + test('the store refusing (not an admin / the other direction already has a key) carries it too', async () => { + for (const code of [ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY, ERROR_CODES.VERCEL_CONNECTOR_KEY_MISSING]) { + const getConnectorSecret = async () => { + throw Object.assign(new Error('x'), { code }); + }; + const connector = createVercelConnector({ fetch: unreachable, env: {}, getConnectorSecret }); + const out = await connector.import!(ctxWith(), opts); + expect(out).toMatchObject({ ok: false, code, link: { url: VERCEL_PAGE } }); + expect((out as { remedy?: string }).remedy).toBeTruthy(); + } + }); + + test('a rejected token (HTTP 401): VERCEL_AUTH_FAILED carries the link', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: unauthorized, env: { T: 'x' }, sleep: async () => {} }); + const { json, human } = await printed(connector, 'vercel', { ...opts, tokenEnv: 'T' }); + expect(JSON.parse(json.stdout[0])).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_AUTH_FAILED, link: { url: VERCEL_PAGE } }); + expect(human.stderr.join('\n')).toContain(VERCEL_PAGE); + }); + + test('a refusal that is not about a key adds no link', async () => { + const down: VercelFetchLike = async () => ({ status: 500, ok: false, text: async () => '{}' }); + const connector = createVercelConnector({ fetch: down, env: { T: 'x' }, sleep: async () => {} }); + const out = await connector.import!(ctxWith(), { nonTty: true, project: 'web', tokenEnv: 'T' }); + expect(out).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_API_ERROR }); + expect((out as { link?: unknown }).link).toBeUndefined(); + expect((out as { remedy?: unknown }).remedy).toBeUndefined(); + }); +}); + +describe('capy connect dokploy — key refusals', () => { + const opts: ConnectOpts = { nonTty: true, baseUrl: DOKPLOY_BASE, application: 'app_1', tokenEnv: 'MISSING_TOKEN' }; + + test('missing key with a known base URL: link is that Dokploy\'s API-key page; JSON + stderr; old fields intact', async () => { + const connector = createDokployConnector({ fetch: unreachable as never, env: {} }); + const { json, human } = await printed(connector as never, 'dokploy', opts); + const parsed = JSON.parse(json.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: 'DOKPLOY_TOKEN_MISSING', link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + expect(typeof parsed.message).toBe('string'); + expect(parsed.remedy).toContain('capy system set _CONNECTOR_DOKPLOY_API_KEY'); + expect(human.stderr.join('\n')).toContain(`${DOKPLOY_BASE}/dashboard/settings/profile`); + }); + + test('a rejected key (401): DOKPLOY_AUTH_FAILED carries the same link', async () => { + const unauthorized = (async () => ({ status: 401, ok: false, text: async () => '{}', json: async () => ({}) })) as never; + const connector = createDokployConnector({ fetch: unauthorized, env: { TOK: 'x' } }); + const out = await connector.import!(ctxWith(), { ...opts, tokenEnv: 'TOK' }); + expect(out).toMatchObject({ ok: false, code: 'DOKPLOY_AUTH_FAILED', link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + }); + + test('--discover with a missing key carries the link too', async () => { + const connector = createDokployConnector({ fetch: unreachable as never, env: {} }); + const out = await connector.discover!( + { orgId: 'o', userId: 'u', authService: {}, serviceClient: {} } as never, + { nonTty: true, baseUrl: DOKPLOY_BASE, tokenEnv: 'MISSING_TOKEN', discover: true }, + ); + expect(out).toMatchObject({ ok: false, code: 'DOKPLOY_TOKEN_MISSING', link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + }); +}); + +describe('capy deploy — the preflight refusal for a missing or rejected key carries the link', () => { + const dokployTarget: TargetConfig = { + name: 'dk', + kind: 'dokploy', + branch: 'production', + vars: ['A'], + mode: 'ci', + options: { baseUrl: DOKPLOY_BASE, applicationId: 'app_1' }, + }; + + test('Dokploy, no key anywhere', async () => { + const adapter = createDokployAdapter({ env: {}, fetch: unreachable as never }); + const r = await adapter.preflight(dokployTarget, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + expect(r.remedy).toContain('capy system set _TARGET_DOKPLOY_API_KEY'); + expect(r.reason).toBeTruthy(); + expect(r.hint).toBeTruthy(); + }); + + test('Dokploy, key rejected by Dokploy', async () => { + const unauthorized = (async () => ({ status: 401, ok: false, text: async () => '{}', json: async () => ({}) })) as never; + const adapter = createDokployAdapter({ env: { DOKPLOY_API_KEY: 'x' }, fetch: unauthorized }); + const r = await adapter.preflight({ ...dokployTarget, options: { ...dokployTarget.options, tokenEnv: 'DOKPLOY_API_KEY' } }, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, link: { url: `${DOKPLOY_BASE}/dashboard/settings/profile` } }); + }); + + const vercelTarget: TargetConfig = { + name: 'vc', + kind: 'vercel', + branch: 'production', + vars: ['A'], + mode: 'ci', + options: { projectDir: 'web', projectId: 'prj_1', vercelEnv: 'production' }, + }; + + test('Vercel API mode, no token anywhere', async () => { + const adapter = createVercelAdapter({ env: {}, fetch: unreachable as never, getTargetSecret: async () => null }); + const r = await adapter.preflight(vercelTarget, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING, link: { url: VERCEL_PAGE } }); + expect(r.remedy).toBe(`${sentence()} ${SAVE('_TARGET_VERCEL_TOKEN')}`); + }); + + test('Vercel API mode, no token, target saved with its team slug: the slug is named', async () => { + const adapter = createVercelAdapter({ env: {}, fetch: unreachable as never, getTargetSecret: async () => null }); + const r = await adapter.preflight({ ...vercelTarget, options: { ...vercelTarget.options, teamSlug: 'acme' } }, { cwd: '/nonexistent-cwd' }); + expect(r.remedy).toBe(`${sentence('acme')} ${SAVE('_TARGET_VERCEL_TOKEN')}`); + }); + + test('Vercel API mode, token rejected (401)', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const adapter = createVercelAdapter({ env: {}, fetch: unauthorized as never, getTargetSecret: async () => 'x', sleep: mock(async () => {}) }); + const r = await adapter.preflight(vercelTarget, { cwd: '/nonexistent-cwd' }); + expect(r).toMatchObject({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_REJECTED, link: { url: VERCEL_PAGE } }); + }); +}); diff --git a/tests/commands/projectPickerWiring.test.ts b/tests/commands/projectPickerWiring.test.ts index d83192e8..b9bef5a0 100644 --- a/tests/commands/projectPickerWiring.test.ts +++ b/tests/commands/projectPickerWiring.test.ts @@ -21,7 +21,7 @@ afterAll(() => { import { defaultAskExistingOrNewProject, DISCOVERY_NEW_PROJECT, -} from '../../src/commands/connectors/dokploy'; +} from '../../src/commands/connectors/discoverySteps'; import { orderProjectsForPicker, vercelProjectQuestion, diff --git a/tests/commands/removeCommand.test.ts b/tests/commands/removeCommand.test.ts index 1bef860f..0da00f00 100644 --- a/tests/commands/removeCommand.test.ts +++ b/tests/commands/removeCommand.test.ts @@ -43,6 +43,7 @@ import { hashValue } from '../../src/commands/statusCommand'; import { Encryptor } from '../../src/crypto/encryptor'; import { SyncEngine } from '../../src/sync/syncEngine'; import { fakeCapyServer } from '../helpers/fakeCapyServer'; +import { EMPTY_ENV_BLOB } from '../../src/sync/commit'; import { FileManager } from '../../src/files/fileManager'; import { ProjectManager } from '../../src/core/projectManager'; import { upsertTarget } from '../../src/deploy/config'; @@ -291,7 +292,8 @@ describe('removeAndSync', () => { await removeAndSync(ctx, ['ONLY_ONE']); expect(server.pushSecrets.mock.calls.length).toBe(1); - expect(server.pushSecrets.mock.calls[0][2]).toBe(''); + // A branch with no variables is stored as a comment line: the service refuses an empty env_blob. + expect(server.pushSecrets.mock.calls[0][2]).toBe(EMPTY_ENV_BLOB); const pushedKeep = JSON.parse(server.pushSecrets.mock.calls[0][1] as string) as KeepFile; expect(Object.keys(pushedKeep.variables)).toEqual([]); }); diff --git a/tests/commands/secretsCommand.test.ts b/tests/commands/secretsCommand.test.ts index 414a6966..bee76fcd 100644 --- a/tests/commands/secretsCommand.test.ts +++ b/tests/commands/secretsCommand.test.ts @@ -383,6 +383,19 @@ describe('SecretsCommand', () => { expect(noTargets.status).toBe('no target'); expect(noTargets.status_locations).toBeUndefined(); }); + + it('a row with an empty value hash (a name with no value yet) says so and how to set it; a row with a value does not', async () => { + getSecretIndexImpl.mockImplementation(async () => ({ + org_id: 'org_1', + rows: [row({ name: 'NO_VALUE', value_hash: '' }), row({ name: 'HAS_VALUE', value_hash: 'abc123' })], + skipped: [], + })); + const { stdout } = await capture(() => new SecretsCommand().execute({ json: true })); + const [noValue, hasValue] = JSON.parse(stdout).rows; + expect(noValue).toMatchObject({ no_value: true, set_with: 'capy edit NO_VALUE' }); + expect(hasValue).not.toHaveProperty('no_value'); + expect(hasValue).not.toHaveProperty('set_with'); + }); }); describe('SecretsCommand — interactive launch (CAP-680: full TTY, no --json)', () => { diff --git a/tests/commands/systemSetKeyHelp.test.ts b/tests/commands/systemSetKeyHelp.test.ts new file mode 100644 index 00000000..68c69918 --- /dev/null +++ b/tests/commands/systemSetKeyHelp.test.ts @@ -0,0 +1,114 @@ +/** + * `capy system set ` shows where to get the key, for the four known key + * names — and nothing new for any other name. + * + * - With a terminal: before the hidden prompt, ending "Then paste it here." + * (Vercel) — stderr, so `--json` stdout stays one pure JSON object. + * - Without one: part of the `SYSTEM_STORE_NEEDS_TTY` refusal — `link` + + * `remedy` in the JSON, the instructions on stderr in human mode. + * + * Command layer only: the store and `inquirer` are mocked (`mock.module`), so + * this file runs on its own (see ISOLATED_FILES in tests/run-tests.sh). + */ +import { describe, test, expect, mock, afterAll } from 'bun:test'; +import { captureConsole, withTTY } from '../helpers/testSandbox'; + +const setStore = mock(async (_name: string, _value: string) => {}); + +mock.module('../../src/system/systemStore', () => ({ + assertValidConnectorName: () => {}, + openSystemStore: async () => ({ orgId: 'org_x', userId: 'u', listNames: () => [], get: () => null, set: setStore, remove: async () => {} }), +})); + +const typed = mock(async (_questions: unknown) => ({ value: 'typed-fake-value' })); +mock.module('inquirer', () => ({ default: { prompt: typed, createPromptModule: () => typed } })); + +afterAll(() => mock.restore()); + +const VERCEL_PAGE = 'https://vercel.com/account/settings/tokens'; +const VERCEL_SENTENCE = `Create a token at ${VERCEL_PAGE}. Set Scope to the team that owns the project.`; + +const run = async (name: string, json: boolean) => { + const { systemSetCommand } = await import('../../src/commands/systemCommand'); + return withTTY(() => captureConsole(() => systemSetCommand(name, { json }))); +}; + +/** `systemSetCommand` ends a refusal with `process.exit`; turn that into a throw so the output can be read. */ +const refused = async (name: string, json: boolean) => { + const { systemSetCommand } = await import('../../src/commands/systemCommand'); + const exit = mock((code?: number) => { + throw new Error(`exit:${code}`); + }); + const original = process.exit; + Object.defineProperty(process, 'exit', { value: exit, configurable: true }); + const saved = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + try { + const out = await captureConsole(() => systemSetCommand(name, { json }).catch((err: Error) => err.message)); + return { ...out, exitCodes: exit.mock.calls.map((c) => c[0]) }; + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: saved, configurable: true }); + Object.defineProperty(process, 'exit', { value: original, configurable: true }); + } +}; + +describe('capy system set — key help before the hidden prompt', () => { + for (const name of ['_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN']) { + test(`${name}: the approved sentence and "Then paste it here." are on stderr; the typed value is saved and never echoed`, async () => { + const out = await run(name, false); + const stderr = out.stderr.join('\n'); + expect(stderr).toContain(VERCEL_SENTENCE); + expect(stderr).toContain('Then paste it here.'); + expect(stderr).not.toContain('capy system set'); + expect(setStore.mock.calls.at(-1)).toEqual([name, 'typed-fake-value']); + expect([...out.stdout, ...out.stderr].join('\n')).not.toContain('typed-fake-value'); + }); + } + + for (const name of ['_CONNECTOR_DOKPLOY_API_KEY', '_TARGET_DOKPLOY_API_KEY']) { + test(`${name}: the Dokploy docs link is on stderr (no base URL is known here)`, async () => { + const out = await run(name, false); + expect(out.stderr.join('\n')).toContain('https://docs.dokploy.com/docs/api'); + expect(setStore.mock.calls.at(-1)).toEqual([name, 'typed-fake-value']); + }); + } + + test('--json: stdout is exactly one JSON object; the help is on stderr only', async () => { + const out = await run('_CONNECTOR_VERCEL_TOKEN', true); + expect(out.stdout.length).toBe(1); + expect(JSON.parse(out.stdout[0])).toEqual({ ok: true, name: '_CONNECTOR_VERCEL_TOKEN' }); + expect(out.stderr.join('\n')).toContain(VERCEL_PAGE); + }); + + test('any other name: nothing new is printed', async () => { + const out = await run('_CONNECTOR_STRIPE_API_KEY', false); + expect(out.stderr).toEqual([]); + expect(out.stdout).toEqual(['Saved _CONNECTOR_STRIPE_API_KEY.']); + }); +}); + +describe('capy system set — no terminal: the refusal carries the instructions', () => { + test('--json: SYSTEM_STORE_NEEDS_TTY with link + remedy (the same two sentences as one string); stdout is one JSON object', async () => { + const out = await refused('_TARGET_VERCEL_TOKEN', true); + expect(out.stdout.length).toBe(1); + const parsed = JSON.parse(out.stdout[0]); + expect(parsed).toMatchObject({ ok: false, code: 'SYSTEM_STORE_NEEDS_TTY', link: { url: VERCEL_PAGE } }); + expect(parsed.link.label).toBeTruthy(); + expect(parsed.remedy).toBe(`${VERCEL_SENTENCE} Then save it: capy system set _TARGET_VERCEL_TOKEN`); + expect(out.exitCodes).toEqual([3]); + }); + + test('human: the instructions are on stderr, nothing on stdout', async () => { + const out = await refused('_CONNECTOR_VERCEL_TOKEN', false); + expect(out.stdout).toEqual([]); + expect(out.stderr.join('\n')).toContain(VERCEL_SENTENCE); + expect(out.stderr.join('\n')).toContain('Then save it: capy system set _CONNECTOR_VERCEL_TOKEN'); + }); + + test('another name: the refusal is what it always was, with no link', async () => { + const out = await refused('_CONNECTOR_STRIPE_API_KEY', true); + const parsed = JSON.parse(out.stdout[0]); + expect(parsed.link).toBeUndefined(); + expect(parsed.remedy).toBeUndefined(); + }); +}); diff --git a/tests/commands/vercelPlaceholders.test.ts b/tests/commands/vercelPlaceholders.test.ts new file mode 100644 index 00000000..ab5468a6 --- /dev/null +++ b/tests/commands/vercelPlaceholders.test.ts @@ -0,0 +1,467 @@ +/** + * `capy connect vercel`: a value Vercel will not give back becomes a PLACEHOLDER on the Capy branch it maps to, + * in every environment (production, preview, development, custom). + * + * "Will not give back" is `sensitive_unreadable`, `legacy_secret_unreadable`, or a value route that answers + * with the structured field `decrypted: false` (`value_not_decrypted`). A value that really is an empty string + * (`decrypted: true`, value '') stays an `empty_value` skip, and a git-branch-scoped record stays a skip. + * + * A placeholder is a flag on the variable's keep.lock entry (core/placeholder.ts), never a value; the run, list and + * status surfaces are checked here too. Every value in here is a fake sentinel; nothing reaches the network. + */ +import { describe, test, expect } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createVercelConnector } from '../../src/commands/connectors/vercel'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { ListCommand } from '../../src/commands/listCommand'; +import { FileManager } from '../../src/files/fileManager'; +import { placeholderEnvNote, placeholderOf } from '../../src/core/placeholder'; +import type { ConnectOpts, ImportOutcome } from '../../src/commands/connectors/registry'; +import type { ResolvedContext } from '../../src/commands/connectors/shared'; +import type { KeepFile } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeVercelFetch, project, type FakeVercelData, type RawEnv } from '../helpers/fakeVercel'; +import { captureConsole, withExitCodeRestored, withIsolatedGlobalDir, withTempDir } from '../helpers/testSandbox'; + +const ALL = ['production', 'preview', 'development']; +const SENTINEL = { + plain: 'plain-SENT-1a2b', + sensitive: 'sensitive-SENT-must-never-be-read', + legacy: 'legacy-SENT-3c4d', + hidden: 'hidden-SENT-5e6f', + custom: 'custom-SENT-7a8b', +} as const; + +const env = (over: Partial & { id: string; key: string; type: string }): RawEnv => ({ target: ['production'], ...over }); + +const ENVS: ReadonlyArray = [ + env({ id: 'e_plain', key: 'PLAIN', type: 'plain', target: ALL, listValue: SENTINEL.plain }), + env({ id: 'e_sens', key: 'SENS', type: 'sensitive', target: ALL }), + env({ id: 'e_old', key: 'OLD', type: 'secret', target: ['preview'] }), + env({ id: 'e_hidden', key: 'HIDDEN', type: 'encrypted', target: ['development'], listValue: 'opaque' }), + env({ id: 'e_empty', key: 'GENUINELY_EMPTY', type: 'encrypted', target: ['production'], listValue: 'opaque' }), + env({ id: 'e_scoped', key: 'SCOPED', type: 'sensitive', target: ['preview'], gitBranch: 'feature/x' }), + env({ id: 'e_custom', key: 'CUSTOM_SENS', type: 'sensitive', target: [], customEnvironmentIds: ['c1'] }), +]; + +const DATA: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: { prj_web: ENVS }, + custom: { prj_web: [{ id: 'c1', slug: 'staging' }] }, + // e_hidden: Vercel answers `decrypted: false`; e_empty: it decrypted a value that really is ''. + values: { e_hidden: SENTINEL.hidden, e_empty: '', e_sens: SENTINEL.sensitive }, + undecrypted: ['e_hidden'], +}; + +const BASE_OPTS: ConnectOpts = { nonTty: true, project: 'web', tokenEnv: 'T' }; + +function connector(data: FakeVercelData = DATA) { + return createVercelConnector({ fetch: fakeVercelFetch(data) as unknown as VercelFetchLike, env: { T: 'tok_fake_value' }, sleep: async () => {} }); +} + +const ctxWith = (branch: string, localPlaintext: Record = {}): ResolvedContext => + ({ + keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }, + branch, + orgId: 'org_1', + localPlaintext, + }) as unknown as ResolvedContext; + +const okOutcome = (o: ImportOutcome): Extract => { + if (!o.ok) throw new Error(`expected ok, got ${o.code}: ${o.message}`); + return o; +}; + +const codes = (xs: ReadonlyArray<{ name: string; code: string }> | undefined): Record => + Object.fromEntries((xs ?? []).map((x) => [x.name, x.code])); + +describe('import: what becomes a placeholder, in every environment', () => { + test('production: the sensitive value is a placeholder; a genuine empty string is an empty_value skip', async () => { + const out = okOutcome(await connector().import!(ctxWith('production'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable' }); + expect(codes(out.skipped)).toEqual({ GENUINELY_EMPTY: 'empty_value' }); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('preview: sensitive and legacy secret are placeholders; the git-branch-scoped one stays a skip', async () => { + const out = okOutcome(await connector().import!(ctxWith('preview'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', OLD: 'legacy_secret_unreadable' }); + expect(out.skipped).toEqual([{ name: 'SCOPED', code: 'git_branch_scoped', gitBranch: 'feature/x' }]); + }); + + test('development: a value Vercel answers with decrypted:false is a placeholder, whatever the answer carries', async () => { + const out = okOutcome(await connector().import!(ctxWith('development'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', HIDDEN: 'value_not_decrypted' }); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('a custom environment: its sensitive value is a placeholder on the branch named by its slug', async () => { + const out = okOutcome(await connector().import!(ctxWith('staging'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ CUSTOM_SENS: 'sensitive_unreadable' }); + }); + + test('a name that already has a value here keeps it: it is reported as kept, not skipped, and not a placeholder', async () => { + const out = okOutcome(await connector().import!(ctxWith('production', { SENS: 'mine-already' }), BASE_OPTS)); + expect(out.placeholders).toEqual([]); + expect(out.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(codes(out.skipped)).toEqual({ GENUINELY_EMPTY: 'empty_value' }); + // the value is untouched: nothing is imported, cleared or replaced for it + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + expect(out.cleared).toBeUndefined(); + expect(out.unchangedEntries ?? []).toEqual([]); + }); + + test('a value_not_decrypted name with a local value is kept too, with its own reason', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { HIDDEN: 'mine-already', SENS: 'also-mine' }), BASE_OPTS)); + expect(out.placeholders).toEqual([]); + expect(out.kept).toEqual([ + { name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }, + { name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'value_not_decrypted' }, + ]); + expect(out.skipped).toEqual([]); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('a legacy secret with a local value is kept', async () => { + const out = okOutcome(await connector().import!(ctxWith('preview', { OLD: 'mine-already' }), BASE_OPTS)); + expect(out.kept).toEqual([{ name: 'OLD', code: 'CAPY_VALUE_KEPT', reason: 'legacy_secret_unreadable' }]); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable' }); + }); + + test('--var keeps only the in-scope names', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { HIDDEN: 'h', SENS: 's' }), { ...BASE_OPTS, var: 'HIDDEN' })); + expect(out.kept?.map((k) => k.name)).toEqual(['HIDDEN']); + }); + + test('no kept list entries when nothing has a local value', async () => { + const out = okOutcome(await connector().import!(ctxWith('production'), BASE_OPTS)); + expect(out.kept).toEqual([]); + }); + + test('--var restricts the placeholders to the named variables', async () => { + const out = okOutcome(await connector().import!(ctxWith('development'), { ...BASE_OPTS, var: 'HIDDEN' })); + expect(codes(out.placeholders)).toEqual({ HIDDEN: 'value_not_decrypted' }); + }); + + test('--overwrite --yes: a placeholder is reported, and a name with a local value is never cleared or turned into one', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { SENS: 'mine-already' }), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(codes(out.placeholders)).toEqual({ HIDDEN: 'value_not_decrypted' }); + expect(out.cleared).toEqual([]); + expect(out.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(out.skipped).toEqual([]); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + }); + + test('--overwrite --yes: a value_not_decrypted name with a local value is kept, never cleared, not skipped', async () => { + const out = okOutcome(await connector().import!(ctxWith('development', { HIDDEN: 'mine-already', STALE: 'gone-from-vercel' }), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(out.kept).toEqual([{ name: 'HIDDEN', code: 'CAPY_VALUE_KEPT', reason: 'value_not_decrypted' }]); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable' }); + expect(out.cleared).toEqual(['STALE']); + expect(out.skipped).toEqual([]); + }); + + test('no value is read for a sensitive record, and none is in the outcome', async () => { + const out = okOutcome(await connector().import!(ctxWith('production'), BASE_OPTS)); + expect(JSON.stringify(out.placeholders)).not.toContain(SENTINEL.sensitive); + expect(JSON.stringify(out)).not.toContain(SENTINEL.sensitive); + }); +}); + +// ── end to end: a real folder, the real report, keep.lock, .env, list, run ──── + +const PROJECT_KEY = 'placeholder-e2e-project-key-0123456789'; + +const realCtx = (dir: string, branch: string, keep?: KeepFile, localPlaintext: Record = {}): ResolvedContext => + ({ + pm: { readSyncState: () => null }, + fileManager: new FileManager(dir), + serviceClient: { + pushSecrets: () => { + throw new Error('must not push — these runs pass noPush'); + }, + }, + orgId: 'org_1', + projectId: 'p', + branch, + userId: 'u', + projectKey: PROJECT_KEY, + keep: keep ?? { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }, + localPlaintext, + }) as unknown as ResolvedContext; + +const importInto = (dir: string, branch: string, opts: ConnectOpts, keep?: KeepFile, local?: Record) => + captureConsole(() => new ConnectCommand(false)['executeImport'](connector(), 'vercel', realCtx(dir, branch, keep, local), { ...BASE_OPTS, noPush: true, ...opts })); + +const readKeep = (dir: string): KeepFile => JSON.parse(readFileSync(join(dir, '.capy', 'keep.lock'), 'utf-8')) as KeepFile; +const SENS_NOTE = placeholderEnvNote('SENS', { provider: 'vercel', reason: 'sensitive_unreadable' }); + +describe('import end to end (real FileManager, real report)', () => { + test('--json: the placeholders are their own list, names and codes only; no value anywhere', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ph-json', async (dir) => { + const { stdout, stderr } = await importInto(dir, 'production', { json: true }); + expect(stdout).toHaveLength(1); + const parsed = JSON.parse(stdout[0]); + expect(parsed.placeholders).toEqual([{ name: 'SENS', code: 'sensitive_unreadable' }]); + expect(parsed.skipped).toEqual([{ name: 'GENUINELY_EMPTY', code: 'empty_value' }]); + const everything = [...stdout, ...stderr, readFileSync(join(dir, '.env'), 'utf-8'), readFileSync(join(dir, '.capy', 'keep.lock'), 'utf-8')].join('\n'); + Object.values(SENTINEL).forEach((s) => expect(everything).not.toContain(s)); + }), + ), + ); + }); + + test('human output names the placeholders and how to set one, without a value', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-human', async (dir) => { + const { stdout } = await importInto(dir, 'production', {}); + const text = stdout.join('\n'); + expect(text).toContain('SENS (sensitive_unreadable)'); + expect(text).toContain('capy edit'); + expect(text).not.toContain('Skipped: SENS'); + }), + ); + }); + + test('keep.lock: a typed flag with an empty hash and no connector; the .env has no variable for it, only a comment', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-keep', async (dir) => { + await importInto(dir, 'production', { json: true }); + const entry = readKeep(dir).variables.SENS?.find((e) => e.branch === 'production'); + expect(entry).toMatchObject({ branch: 'production', value_hash: '', placeholder: { provider: 'vercel', reason: 'sensitive_unreadable' } }); + expect(entry).not.toHaveProperty('connector'); + expect(placeholderOf(readKeep(dir), 'SENS', 'production')).toEqual({ provider: 'vercel', reason: 'sensitive_unreadable' }); + + const dotenv = readFileSync(join(dir, '.env'), 'utf-8'); + expect(dotenv.split('\n').filter((l) => l === SENS_NOTE)).toHaveLength(1); + expect(dotenv).not.toMatch(/^SENS=/m); + expect(new FileManager(dir).readEnvFile()).not.toHaveProperty('SENS'); + expect(new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY)).toEqual({ PLAIN: SENTINEL.plain }); + }), + ); + }); + + test('importing again writes the comment once and leaves the person\'s own lines where they were', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-again', async (dir) => { + await importInto(dir, 'production', { json: true }); + writeFileSync(join(dir, '.env'), `# mine\n${readFileSync(join(dir, '.env'), 'utf-8')}`); + const keep = readKeep(dir); + await importInto(dir, 'production', { json: true }, keep, new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY)); + const dotenv = readFileSync(join(dir, '.env'), 'utf-8'); + expect(dotenv.startsWith('# mine\n')).toBe(true); + expect(dotenv.split('\n').filter((l) => l === SENS_NOTE)).toHaveLength(1); + expect(placeholderOf(readKeep(dir), 'SENS', 'production')).toBeDefined(); + }), + ); + }); + + test('--dry-run lists the placeholders and writes nothing', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ph-dry', async (dir) => { + const { stdout } = await importInto(dir, 'production', { json: true, dryRun: true }); + expect(JSON.parse(stdout[0])).toMatchObject({ dryRun: true, placeholders: [{ name: 'SENS', code: 'sensitive_unreadable' }] }); + expect(existsSync(join(dir, '.env'))).toBe(false); + expect(existsSync(join(dir, '.capy', 'keep.lock'))).toBe(false); + }), + ), + ); + }); + + test('--json: a name with a local value is under kept (code and reason), not skipped or a placeholder, and its value is untouched', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-kept-json', async (dir) => { + const { stdout } = await importInto(dir, 'production', { json: true }, undefined, { SENS: 'mine-already' }); + expect(stdout).toHaveLength(1); + const parsed = JSON.parse(stdout[0]); + expect(parsed.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(parsed.placeholders).toEqual([]); + expect(parsed.skipped).toEqual([{ name: 'GENUINELY_EMPTY', code: 'empty_value' }]); + expect(parsed.imported).toEqual(['PLAIN']); + // SENS is not a placeholder (no flag, no comment) and its Capy value is still the one it had + expect(placeholderOf(readKeep(dir), 'SENS', 'production')).toBeUndefined(); + expect(readFileSync(join(dir, '.env'), 'utf-8')).not.toContain(SENS_NOTE); + expect(new FileManager(dir).readEncryptedEnvFile(PROJECT_KEY)).toEqual({ SENS: 'mine-already', PLAIN: SENTINEL.plain }); + const everything = [...stdout, readFileSync(join(dir, '.capy', 'keep.lock'), 'utf-8')].join('\n'); + Object.values(SENTINEL).forEach((s) => expect(everything).not.toContain(s)); + }), + ), + ); + }); + + test('human output: one neutral line for the kept names, and they are not listed as skipped', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-kept-human', async (dir) => { + const { stdout } = await importInto(dir, 'development', {}, undefined, { SENS: 'mine', HIDDEN: 'mine-too' }); + const text = stdout.join('\n'); + expect(text).toContain('Kept the Capy value for 2 name(s) Vercel will not return: SENS (sensitive_unreadable), HIDDEN (value_not_decrypted)'); + expect(text).not.toContain('Skipped: SENS'); + expect(text).not.toContain('Skipped: HIDDEN'); + expect(text).not.toContain('with no value yet'); + }), + ); + }); + + test('--overwrite --json: the kept list is there, and the kept name is neither cleared nor skipped', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-kept-overwrite', async (dir) => { + const { stdout } = await importInto(dir, 'production', { json: true, overwrite: true, yes: true }, undefined, { SENS: 'mine-already' }); + const parsed = JSON.parse(stdout[0]); + expect(parsed.kept).toEqual([{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }]); + expect(parsed.cleared ?? []).not.toContain('SENS'); + expect(parsed.skipped.map((s: { name: string }) => s.name)).not.toContain('SENS'); + expect(parsed.placeholders).toEqual([]); + }), + ), + ); + }); + + test('--dry-run shows kept (JSON and human) and writes nothing', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-kept-dry', async (dir) => { + const json = await importInto(dir, 'production', { json: true, dryRun: true }, undefined, { SENS: 'mine-already' }); + expect(JSON.parse(json.stdout[0])).toMatchObject({ dryRun: true, kept: [{ name: 'SENS', code: 'CAPY_VALUE_KEPT', reason: 'sensitive_unreadable' }], placeholders: [] }); + expect(JSON.parse(json.stdout[0]).skipped.map((s: { name: string }) => s.name)).not.toContain('SENS'); + const human = await importInto(dir, 'production', { dryRun: true }, undefined, { SENS: 'mine-already' }); + expect(human.stdout.join('\n')).toContain('Kept the Capy value for 1 name(s) Vercel will not return: SENS (sensitive_unreadable)'); + expect(existsSync(join(dir, '.env'))).toBe(false); + expect(existsSync(join(dir, '.capy', 'keep.lock'))).toBe(false); + }), + ), + ); + }); + + test('another branch gets its own placeholders (preview has two, production one)', async () => { + await withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-branches', async (dir) => { + await importInto(dir, 'production', { json: true }); + await importInto(dir, 'preview', { json: true }, readKeep(dir)); + const keep = readKeep(dir); + expect(placeholderOf(keep, 'OLD', 'preview')?.reason).toBe('legacy_secret_unreadable'); + expect(placeholderOf(keep, 'OLD', 'production')).toBeUndefined(); + expect(placeholderOf(keep, 'SENS', 'preview')).toBeDefined(); + expect(placeholderOf(keep, 'SENS', 'production')).toBeDefined(); + }), + ); + }); +}); + +describe('surfaces', () => { + const withImportedFolder = async (fn: (dir: string) => Promise): Promise => + withIsolatedGlobalDir(() => + withTempDir('capy-vercel-ph-surface', async (dir) => { + await importInto(dir, 'production', { json: true }); + await fn(dir); + }), + ); + + /** `fn` with the process working directory in `dir`, put back afterwards. */ + const inDir = async (dir: string, fn: () => Promise): Promise => { + const original = process.cwd(); + process.chdir(dir); + try { + return await fn(); + } finally { + process.chdir(original); + } + }; + + test('capy list --json: the placeholder, its reason and how to set it', async () => { + await withImportedFolder(async (dir) => { + const { stdout } = await inDir(dir, () => captureConsole(() => new ListCommand().execute({ json: true }))); + const listed = JSON.parse(stdout.join('\n')); + const sens = listed.variables.find((v: { name: string }) => v.name === 'SENS'); + expect(sens.placeholder).toEqual({ provider: 'vercel', reason: 'sensitive_unreadable', setWith: 'capy edit SENS' }); + expect(listed.variables.find((v: { name: string }) => v.name === 'PLAIN').placeholder).toBeNull(); + }); + }); + + test('capy list: "no value yet (sensitive in Vercel)" and the command', async () => { + await withImportedFolder(async (dir) => { + const { stdout } = await inDir(dir, () => captureConsole(() => new ListCommand().execute({}))); + const line = stdout.find((l) => l.includes('SENS')) ?? ''; + expect(line).toContain('no value yet (sensitive in Vercel)'); + expect(line).toContain('capy edit SENS'); + expect(stdout.find((l) => l.includes('PLAIN')) ?? '').not.toContain('no value yet'); + }); + }); + + test('capy run: the variable is not set (the comment is not a variable)', async () => { + await withImportedFolder(async (dir) => { + const dotenv = readFileSync(join(dir, '.env'), 'utf-8'); + // `capy run` needs no key when no value in the file is encrypted: keep the placeholder's file and one plain variable. + writeFileSync(join(dir, '.env'), `${dotenv.split('\n').filter((l) => !l.startsWith('PLAIN=')).join('\n')}\nOTHER=here\n`); + const home = mkdtempSync(join(tmpdir(), 'capy-ph-run-home-')); + mkdirSync(join(home, '.capy'), { recursive: true }); + const { SENS: _inherited, ...cleanEnv } = process.env; + const cli = join(__dirname, '../../dist/index.js'); + const printed = execFileSync('node', [cli, 'run', '--', 'node', '-e', 'console.log(JSON.stringify({ has: "SENS" in process.env, other: process.env.OTHER }))'], { + cwd: dir, + env: { ...cleanEnv, HOME: home, USERPROFILE: home }, + encoding: 'utf-8', + }); + rmSync(home, { recursive: true, force: true }); + expect(JSON.parse(printed.trim())).toEqual({ has: false, other: 'here' }); + }); + }); +}); + +// ── one variable's value route answers 404: that name is a placeholder, the rest still import ── + +const GONE_ENV = env({ id: 'e_gone', key: 'GONE', type: 'encrypted', target: ['production'], listValue: 'opaque' }); +const GONE_DATA = (status: number): FakeVercelData => ({ + ...DATA, + envs: { prj_web: [...ENVS, GONE_ENV] }, + valueStatus: { e_gone: status }, +}); + +describe('import: a 404 on one variable\'s value route', () => { + test('production: the 404 name is a placeholder with reason value_not_found; the other names import', async () => { + const out = okOutcome(await connector(GONE_DATA(404)).import!(ctxWith('production'), BASE_OPTS)); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', GONE: 'value_not_found' }); + expect(out.imported.map((i) => i.varName)).toEqual(['PLAIN']); + expect(codes(out.skipped)).toEqual({ GENUINELY_EMPTY: 'empty_value' }); + }); + + test('--overwrite --yes: the 404 name is a placeholder and is never cleared when it has a local value', async () => { + const out = okOutcome(await connector(GONE_DATA(404)).import!(ctxWith('production'), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(codes(out.placeholders)).toEqual({ SENS: 'sensitive_unreadable', GONE: 'value_not_found' }); + const kept = okOutcome(await connector(GONE_DATA(404)).import!(ctxWith('production', { GONE: 'mine-already' }), { ...BASE_OPTS, overwrite: true, yes: true })); + expect(kept.kept).toEqual([{ name: 'GONE', code: 'CAPY_VALUE_KEPT', reason: 'value_not_found' }]); + expect(kept.cleared).toEqual([]); + }); + + test('--json: the 404 name is in placeholders with its reason; keep.lock holds a flag, no value', async () => { + await withIsolatedGlobalDir(() => + withExitCodeRestored(() => + withTempDir('capy-vercel-ph-404', async (dir) => { + const { stdout } = await captureConsole(() => + new ConnectCommand(false)['executeImport'](connector(GONE_DATA(404)), 'vercel', realCtx(dir, 'production'), { ...BASE_OPTS, noPush: true, json: true }), + ); + const parsed = JSON.parse(stdout[0]); + expect(parsed.placeholders).toEqual([ + { name: 'SENS', code: 'sensitive_unreadable' }, + { name: 'GONE', code: 'value_not_found' }, + ]); + expect(parsed.imported).toEqual(['PLAIN']); + expect(placeholderOf(readKeep(dir), 'GONE', 'production')).toEqual({ provider: 'vercel', reason: 'value_not_found' }); + }), + ), + ); + }); + + test('a 500 on the same route still fails the run, with the Vercel error code', async () => { + const out = await connector(GONE_DATA(500)).import!(ctxWith('production'), BASE_OPTS); + expect(out.ok).toBe(false); + expect(out.ok === false && out.code).toBe('VERCEL_API_ERROR'); + }); +}); diff --git a/tests/deploy/vercelApi.test.ts b/tests/deploy/vercelApi.test.ts new file mode 100644 index 00000000..20b1ec83 --- /dev/null +++ b/tests/deploy/vercelApi.test.ts @@ -0,0 +1,575 @@ +/** + * `deploy/vercelApi.ts` — the READ-ONLY Vercel client and the env-record + * primitives behind `capy connect vercel` (CAP-696). + * + * Every test drives a scripted `fetch`; nothing here touches the network. The + * fake (`tests/helpers/fakeVercel.ts`) throws on any non-GET, so "the client + * never writes" is proven by running real calls through it, not by reading + * the source. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + DEFAULT_VERCEL_TOKEN_ENV, + VERCEL_CONNECTOR_SECRET_NAME, + VERCEL_TARGET_SECRET_NAME, + VercelApiError, + isPlaceholderReason, + buildEnvironmentPlans, + createVercelClient, + groupSkipped, + mapVercelApiError, + parseGitLink, + rateLimitWaitMs, + resolveEnvValues, + resolveTeam, + resolveVercelToken, + vercelSecretsMayPrompt, + type VercelEnvRecord, + type VercelFetchLike, +} from '../../src/deploy/vercelApi'; +import { ERROR_CODES } from '../../src/types/index'; +import { callsOf, fakeVercelFetch, project, type RawEnv } from '../helpers/fakeVercel'; + +const TOKEN = 'vcl_fake_token_do_not_leak_8d2f'; + +const rec = (over: Partial & { id: string; key: string }): VercelEnvRecord => ({ + type: 'encrypted', + targets: ['production'], + customEnvironmentIds: [], + ...over, +}); + +describe('names', () => { + test('the system store entry names the deploy side will also use', () => { + expect(VERCEL_CONNECTOR_SECRET_NAME).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(VERCEL_TARGET_SECRET_NAME).toBe('_TARGET_VERCEL_TOKEN'); + expect(DEFAULT_VERCEL_TOKEN_ENV).toBe('VERCEL_TOKEN'); + }); +}); + +describe('createVercelClient — read-only, bearer auth, team scope', () => { + test('exposes no method that could write', () => { + const client = createVercelClient(TOKEN, { fetch: fakeVercelFetch({ projects: {}, envs: {} }) }); + // CAP-730 adds `listPreviewDeployments`: a GET, like the rest. + expect(Object.keys(client).toSorted()).toEqual( + ['getEnvValue', 'getProject', 'listCustomEnvironments', 'listEnvs', 'listPreviewDeployments', 'listProjects', 'listTeams'].toSorted(), + ); + }); + + test('the source contains no write verb at all', () => { + const src = readFileSync(join(import.meta.dir, '../../src/deploy/vercelApi.ts'), 'utf-8'); + expect(src).not.toMatch(/method:\s*['"](POST|PATCH|PUT|DELETE)['"]/); + }); + + test('a full read pass makes only GETs, each with the bearer token, and scopes by teamId', async () => { + const data = { + teams: [{ id: 'team_1', slug: 'acme' }], + projects: { team_1: [project('prj_1', 'web')] }, + envs: { prj_1: [{ id: 'env_1', key: 'A', type: 'plain', target: ['production'], listValue: 'a-value' } as RawEnv] }, + custom: { prj_1: [{ id: 'env_c1', slug: 'staging' }] }, + values: { env_1: 'a-value' }, + }; + const fetchMock = mock(fakeVercelFetch(data)); + const client = createVercelClient(TOKEN, { fetch: fetchMock }); + await client.listTeams(); + await client.listProjects('team_1'); + await client.getProject('web', 'team_1'); + await client.listCustomEnvironments('prj_1', 'team_1'); + await client.listEnvs('prj_1', 'team_1'); + await client.getEnvValue('prj_1', 'env_1', 'team_1'); + const calls = callsOf(fetchMock); + expect(calls.length).toBe(6); + expect(calls.every((c) => c.method === 'GET')).toBe(true); + expect(calls.every((c) => c.authorization === `Bearer ${TOKEN}`)).toBe(true); + // team scope on every request after the team list itself + expect(calls.slice(1).every((c) => new URL(c.url).searchParams.get('teamId') === 'team_1')).toBe(true); + // the token is a header, never part of any URL + expect(calls.some((c) => c.url.includes(TOKEN))).toBe(false); + }); + + test('endpoint paths and versions match the docs', async () => { + const data = { + teams: [], + projects: { personal: [project('prj_1', 'web')] }, + envs: { prj_1: [{ id: 'env_1', key: 'A', type: 'plain', target: 'production', listValue: 'v' } as RawEnv] }, + values: { env_1: 'v' }, + }; + const fetchMock = mock(fakeVercelFetch(data)); + const client = createVercelClient(TOKEN, { fetch: fetchMock }); + await client.listTeams(); + await client.listProjects(); + await client.getProject('prj_1'); + await client.listCustomEnvironments('prj_1'); + await client.listEnvs('prj_1'); + await client.getEnvValue('prj_1', 'env_1'); + expect(callsOf(fetchMock).map((c) => new URL(c.url).pathname)).toEqual([ + '/v2/teams', + '/v10/projects', + '/v9/projects/prj_1', + '/v9/projects/prj_1/custom-environments', + '/v10/projects/prj_1/env', + '/v1/projects/prj_1/env/env_1', + ]); + }); + + test('a redirect is refused rather than followed (the bearer token must not go to another host)', async () => { + const fetchMock = mock(async () => ({ status: 200, ok: true, text: async () => '{"teams":[]}' })); + await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listTeams(); + expect(fetchMock.mock.calls.map(([, init]) => (init as { redirect?: string }).redirect)).toEqual(['error']); + }); +}); + +describe('pagination', () => { + test('projects: follows pagination.next via `from` until it is null', async () => { + const all = Array.from({ length: 5 }, (_, i) => project(`prj_${i}`, `p${i}`)); + const fetchMock = mock(fakeVercelFetch({ projects: { personal: all }, envs: {}, pageSize: 2 })); + const client = createVercelClient(TOKEN, { fetch: fetchMock }); + const projects = await client.listProjects(); + const calls = callsOf(fetchMock); + expect(projects.map((p) => p.id)).toEqual(['prj_0', 'prj_1', 'prj_2', 'prj_3', 'prj_4']); + expect(calls.length).toBe(3); + expect(calls.map((c) => new URL(c.url).searchParams.get('from'))).toEqual([null, '2', '4']); + expect(calls.every((c) => new URL(c.url).searchParams.get('limit') === '100')).toBe(true); + }); + + test('projects: a server that ignores the continuation cannot loop us forever', async () => { + const page = { projects: [project('prj_a', 'a')], pagination: { count: 1, next: 123, prev: null } }; + const fetchMock = mock(async () => ({ status: 200, ok: true, text: async () => JSON.stringify(page) })); + const projects = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listProjects(); + expect(projects.map((p) => p.id)).toEqual(['prj_a']); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + test('projects: a bare-array response (the documented alternative shape) is read too', async () => { + const fetchImpl: VercelFetchLike = async () => ({ status: 200, ok: true, text: async () => JSON.stringify([project('prj_1', 'a')]) }); + const projects = await createVercelClient(TOKEN, { fetch: fetchImpl }).listProjects(); + expect(projects.map((p) => p.name)).toEqual(['a']); + }); + + test('projects: a continuation TOKEN (a string `next`) is followed as it is, not mistaken for the end', async () => { + const reply = (body: unknown) => ({ status: 200, ok: true, text: async () => JSON.stringify(body) }); + const fetchMock = mock() + .mockResolvedValueOnce(reply({ projects: [project('prj_a', 'a')], pagination: { count: 1, next: 'tok_abc' } })) + .mockResolvedValueOnce(reply({ projects: [project('prj_b', 'b')], pagination: { count: 1, next: null } })); + const projects = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listProjects('team_1'); + expect(projects.map((p) => p.id)).toEqual(['prj_a', 'prj_b']); + expect(new URL(callsOf(fetchMock)[1].url).searchParams.get('from')).toBe('tok_abc'); + expect(new URL(callsOf(fetchMock)[1].url).searchParams.get('teamId')).toBe('team_1'); + }); + + test('teams: follows pagination.next via `until`', async () => { + const reply = (body: unknown) => ({ status: 200, ok: true, text: async () => JSON.stringify(body) }); + const fetchMock = mock() + .mockResolvedValueOnce(reply({ teams: [{ id: 't1', slug: 'one' }], pagination: { count: 1, next: 1700, prev: null } })) + .mockResolvedValueOnce(reply({ teams: [{ id: 't2', slug: 'two' }], pagination: { count: 1, next: null, prev: null } })); + const teams = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike }).listTeams(); + expect(teams.map((t) => t.slug)).toEqual(['one', 'two']); + expect(new URL(callsOf(fetchMock)[1].url).searchParams.get('until')).toBe('1700'); + }); +}); + +describe('rate limits — 429', () => { + const limited = (headers: Record) => ({ + status: 429, + ok: false, + headers: { get: (n: string) => headers[n.toLowerCase()] ?? null }, + text: async () => JSON.stringify({ error: { code: 'rate_limited', message: 'slow down' } }), + }); + const okTeams = { status: 200, ok: true, headers: { get: () => null }, text: async () => '{"teams":[]}' }; + const waitsOf = (sleep: { mock: { calls: ReadonlyArray } }) => sleep.mock.calls.map(([ms]) => ms); + + test('waits per Retry-After, then succeeds', async () => { + const fetchMock = mock().mockResolvedValueOnce(limited({ 'retry-after': '2' })).mockResolvedValueOnce(okTeams); + const sleep = mock(async (_ms: number) => {}); + const teams = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep }).listTeams(); + expect(teams).toEqual([]); + expect(waitsOf(sleep)).toEqual([2000]); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + test('waits per X-RateLimit-Reset (epoch seconds) when there is no Retry-After', async () => { + const fetchMock = mock().mockResolvedValueOnce(limited({ 'x-ratelimit-reset': '1700000005' })).mockResolvedValueOnce(okTeams); + const sleep = mock(async (_ms: number) => {}); + await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep, now: () => 1_700_000_000_000 }).listTeams(); + expect(waitsOf(sleep)).toEqual([5000]); + }); + + test('rateLimitWaitMs: header precedence, body fallback, short default', () => { + const h = (m: Record) => ({ get: (n: string) => m[n.toLowerCase()] ?? null }); + expect(rateLimitWaitMs(h({ 'retry-after': '3', 'x-ratelimit-reset': '9999999999' }), '', 0)).toBe(3000); + expect(rateLimitWaitMs(h({ 'x-ratelimit-reset': '10' }), '', 4000)).toBe(6000); + expect(rateLimitWaitMs(h({}), JSON.stringify({ error: { limit: { resetMs: 9000 } } }), 4000)).toBe(5000); + expect(rateLimitWaitMs(h({}), 'not json', 0)).toBe(1000); + expect(rateLimitWaitMs(undefined, '', 0)).toBe(1000); + }); + + test('keeps answering 429: bounded retries, then a coded refusal', async () => { + const fetchMock = mock(async () => limited({ 'retry-after': '1' })); + const sleep = mock(async (_ms: number) => {}); + const err = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep, maxRetries: 3 }) + .listTeams() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(VercelApiError); + expect((err as VercelApiError).code).toBe('rate_limited'); + expect(fetchMock.mock.calls.length).toBe(4); // first try + 3 retries + expect(waitsOf(sleep)).toEqual([1000, 1000, 1000]); + expect(mapVercelApiError(err).code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + }); + + test('a requested wait longer than the cap is refused without sleeping', async () => { + const fetchMock = mock(async () => limited({ 'retry-after': '604800' })); // 7 days + const sleep = mock(async (_ms: number) => {}); + const err = await createVercelClient(TOKEN, { fetch: fetchMock as unknown as VercelFetchLike, sleep }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(err).code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + expect(sleep.mock.calls.length).toBe(0); + }); +}); + +describe('errors map to stable codes and never carry the token', () => { + const status = (s: number): VercelFetchLike => async () => ({ status: s, ok: false, text: async () => JSON.stringify({ error: { message: TOKEN } }) }); + + const STEPS = [ + '1) Create a token with team access at https://vercel.com/account/settings/tokens', + '2) Run capy system set _CONNECTOR_VERCEL_TOKEN', + ].join('\n'); + + test('401 → VERCEL_AUTH_FAILED, with the token page and the two steps', async () => { + const err = await createVercelClient(TOKEN, { fetch: status(401) }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(err)).toMatchObject({ + code: ERROR_CODES.VERCEL_AUTH_FAILED, + message: 'Vercel rejected the token (HTTP 401).', + link: { url: 'https://vercel.com/account/settings/tokens' }, + remedy: STEPS, + }); + }); + + test('403 → VERCEL_NO_ACCESS, naming what could not be accessed, with the same two steps', async () => { + const client = createVercelClient(TOKEN, { fetch: status(403) }); + const caught = (p: Promise) => p.catch((e: unknown) => e); + const cases: ReadonlyArray = [ + ['teams', await caught(client.listTeams()), 'the team list'], + ['projects', await caught(client.listProjects('team_a')), 'the project list'], + ['project', await caught(client.getProject('web', 'team_a')), 'project web'], + ['custom environments', await caught(client.listCustomEnvironments('prj_web', 'team_a')), 'the environment variables of project prj_web'], + ['envs', await caught(client.listEnvs('prj_web', 'team_a')), 'the environment variables of project prj_web'], + ['env value', await caught(client.getEnvValue('prj_web', 'env_1', 'team_a')), 'the environment variables of project prj_web'], + ]; + cases.forEach(([, err, denied]) => { + const mapped = mapVercelApiError(err); + expect(mapped.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(mapped.message).toBe(`Vercel denied access to ${denied} (HTTP 403).`); + expect(mapped.link?.url).toBe('https://vercel.com/account/settings/tokens'); + expect(mapped.remedy).toBe(STEPS); + expect(`${mapped.message}${mapped.remedy}`).not.toContain(TOKEN); + }); + }); + + test('403 under discovery on the team or project list: discovery needs a token scoped to the whole team', async () => { + const client = createVercelClient(TOKEN, { fetch: status(403) }); + const teams = mapVercelApiError(await client.listTeams().catch((e: unknown) => e), { discovery: true }); + const projects = mapVercelApiError(await client.listProjects().catch((e: unknown) => e), { discovery: true }); + expect(teams.code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + expect(teams.message).toBe('Vercel denied access to the team list (HTTP 403). Discovery needs a token scoped to the whole team.'); + expect(projects.message).toBe('Vercel denied access to the project list (HTTP 403). Discovery needs a token scoped to the whole team.'); + // a project's own variables: the plain message, even under discovery + const envs = mapVercelApiError(await client.listEnvs('prj_web').catch((e: unknown) => e), { discovery: true }); + expect(envs.message).toBe('Vercel denied access to the environment variables of project prj_web (HTTP 403).'); + }); + + test('404 → not_found; 500 → VERCEL_API_ERROR; neither message contains the token', async () => { + const nf = await createVercelClient(TOKEN, { fetch: status(404) }).getProject('x').catch((e: unknown) => e); + expect((nf as VercelApiError).code).toBe('not_found'); + const se = await createVercelClient(TOKEN, { fetch: status(500) }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(se).code).toBe(ERROR_CODES.VERCEL_API_ERROR); + expect((nf as Error).message).not.toContain(TOKEN); + expect(mapVercelApiError(se).message).not.toContain(TOKEN); + }); + + test('a network failure and a non-JSON body are VERCEL_API_ERROR, token never echoed', async () => { + const down: VercelFetchLike = async () => { + throw new Error(`connect ECONNREFUSED ${TOKEN}`); + }; + const e1 = await createVercelClient(TOKEN, { fetch: down }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(e1).code).toBe(ERROR_CODES.VERCEL_API_ERROR); + expect((e1 as Error).message).not.toContain(TOKEN); + const html: VercelFetchLike = async () => ({ status: 200, ok: true, text: async () => '' }); + const e2 = await createVercelClient(TOKEN, { fetch: html }).listTeams().catch((e: unknown) => e); + expect(mapVercelApiError(e2).code).toBe(ERROR_CODES.VERCEL_API_ERROR); + }); +}); + +describe('resolveTeam — 0 / 1 / many', () => { + const teams = [ + { id: 'team_a', slug: 'alpha' }, + { id: 'team_b', slug: 'beta' }, + ]; + + test('no teams: the personal account (no teamId)', async () => { + expect(await resolveTeam([], undefined, null)).toEqual({ ok: true, teamId: undefined }); + }); + + test('exactly one team: used without asking', async () => { + expect(await resolveTeam([teams[0]], undefined, null)).toEqual({ ok: true, teamId: 'team_a', teamSlug: 'alpha' }); + }); + + test('several, non-interactive: VERCEL_TEAM_REQUIRED listing the slugs', async () => { + const r = await resolveTeam(teams, undefined, null); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.code).toBe(ERROR_CODES.VERCEL_TEAM_REQUIRED); + expect(r.message).toContain('alpha'); + expect(r.message).toContain('beta'); + }); + + test('several, interactive: the picked team', async () => { + expect(await resolveTeam(teams, undefined, async () => 'team_b')).toEqual({ ok: true, teamId: 'team_b', teamSlug: 'beta' }); + }); + + test('--team by slug or by id; an unknown one is VERCEL_TEAM_NOT_FOUND', async () => { + expect(await resolveTeam(teams, 'beta', null)).toEqual({ ok: true, teamId: 'team_b', teamSlug: 'beta' }); + expect(await resolveTeam(teams, 'team_a', null)).toEqual({ ok: true, teamId: 'team_a', teamSlug: 'alpha' }); + const r = await resolveTeam(teams, 'gamma', null); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe(ERROR_CODES.VERCEL_TEAM_NOT_FOUND); + }); +}); + +describe('project links', () => { + test('github link → org/repo/productionBranch', () => { + expect(parseGitLink({ type: 'github', org: 'acme', repo: 'widgets', productionBranch: 'main' })).toEqual({ + kind: 'github', + org: 'acme', + repo: 'widgets', + productionBranch: 'main', + }); + }); + + test('gitlab / bitbucket / anything else is carried as unsupported (reported, never guessed)', () => { + expect(parseGitLink({ type: 'gitlab', projectId: 5 })).toEqual({ kind: 'unsupported', type: 'gitlab' }); + expect(parseGitLink({ type: 'bitbucket', slug: 'x' })).toEqual({ kind: 'unsupported', type: 'bitbucket' }); + }); + + test('no link at all is null', () => { + expect(parseGitLink(undefined)).toBeNull(); + expect(parseGitLink(null)).toBeNull(); + }); +}); + +describe('buildEnvironmentPlans — type → skip mapping, scoping, targets', () => { + const plan = (envs: readonly VercelEnvRecord[], custom: ReadonlyArray<{ id: string; slug: string }> = []) => + buildEnvironmentPlans(envs, custom); + const byName = (plans: ReturnType, name: string) => plans.find((p) => p.environment.name === name); + + test('plain and encrypted are importable; sensitive, system, legacy secret each skip with their own reason', () => { + const p = plan([ + rec({ id: '1', key: 'PLAIN', type: 'plain' }), + rec({ id: '2', key: 'ENC', type: 'encrypted' }), + rec({ id: '3', key: 'SENS', type: 'sensitive' }), + rec({ id: '4', key: 'SYS', type: 'system' }), + rec({ id: '5', key: 'OLD', type: 'secret' }), + rec({ id: '6', key: 'WEIRD', type: 'quantum' }), + ]); + const prod = byName(p, 'production')!; + expect(prod.importable.map((r) => r.key)).toEqual(['PLAIN', 'ENC']); + expect(Object.fromEntries(prod.skipped.map((s) => [s.name, s.reason]))).toEqual({ + SENS: 'sensitive_unreadable', + SYS: 'vercel_system', + OLD: 'legacy_secret_unreadable', + WEIRD: 'unsupported_type', + }); + }); + + test('a preview record scoped to a git branch is skipped (git_branch_scoped, name + branch); an unscoped one is imported', () => { + const p = plan([ + rec({ id: '1', key: 'SCOPED', targets: ['preview'], gitBranch: 'feature/x' }), + rec({ id: '2', key: 'ALL_BRANCHES', targets: ['preview'] }), + ]); + const preview = byName(p, 'preview')!; + expect(preview.importable.map((r) => r.key)).toEqual(['ALL_BRANCHES']); + expect(preview.skipped).toEqual([{ name: 'SCOPED', reason: 'git_branch_scoped', gitBranch: 'feature/x' }]); + }); + + test('one record targeting several environments counts in each', () => { + const p = plan([rec({ id: '1', key: 'SHARED', targets: ['production', 'preview', 'development'] })]); + expect(p.map((x) => x.environment.name)).toEqual(['production', 'preview', 'development']); + expect(p.every((x) => x.importable.map((r) => r.key).join() === 'SHARED')).toBe(true); + }); + + test('a target given as a bare string works (the docs allow string or array)', () => { + const p = plan([rec({ id: '1', key: 'ONE', targets: ['development'] })]); + expect(byName(p, 'development')!.importable.length).toBe(1); + expect(byName(p, 'production')).toBeUndefined(); + }); + + test('custom environments map to their slug via customEnvironmentIds', () => { + const p = plan( + [rec({ id: '1', key: 'FOR_STAGING', targets: [], customEnvironmentIds: ['env_c1'] }), rec({ id: '2', key: 'BOTH', targets: ['production'], customEnvironmentIds: ['env_c1'] })], + [{ id: 'env_c1', slug: 'staging' }], + ); + const staging = byName(p, 'staging')!; + expect(staging.environment).toEqual({ name: 'staging', kind: 'custom', customEnvironmentId: 'env_c1' }); + expect(staging.importable.map((r) => r.key)).toEqual(['FOR_STAGING', 'BOTH']); + expect(byName(p, 'production')!.importable.map((r) => r.key)).toEqual(['BOTH']); + }); + + test('a git-branch scope only matters in preview', () => { + const p = plan([rec({ id: '1', key: 'K', targets: ['production'], gitBranch: 'main' })]); + expect(byName(p, 'production')!.importable.length).toBe(1); + }); + + test('the same name twice in one environment: first wins, the other is reported duplicate_key', () => { + const p = plan([rec({ id: '1', key: 'DUP' }), rec({ id: '2', key: 'DUP' })]); + const prod = byName(p, 'production')!; + expect(prod.importable.map((r) => r.id)).toEqual(['1']); + expect(prod.skipped).toEqual([{ name: 'DUP', reason: 'duplicate_key' }]); + }); + + test('groupSkipped groups names by reason, branch kept for scoped ones', () => { + expect( + groupSkipped([ + { name: 'A', reason: 'sensitive_unreadable' }, + { name: 'B', reason: 'git_branch_scoped', gitBranch: 'dev' }, + { name: 'C', reason: 'sensitive_unreadable' }, + ]), + ).toEqual({ + git_branch_scoped: [{ name: 'B', gitBranch: 'dev' }], + sensitive_unreadable: [{ name: 'A' }, { name: 'C' }], + }); + }); +}); + +describe('resolveEnvValues', () => { + test('plain uses the list value (no extra request); encrypted comes from the per-id route', async () => { + const getEnvValue = mock(async (_p: string, envId: string, _t?: string) => `decrypted-${envId}`); + const client = { getEnvValue }; + const r = await resolveEnvValues(client, 'prj', undefined, [ + rec({ id: 'e1', key: 'P', type: 'plain', value: 'plain-value' }), + rec({ id: 'e2', key: 'E', type: 'encrypted', value: 'ciphertext-from-list' }), + ]); + expect(getEnvValue.mock.calls.map(([, envId]) => envId)).toEqual(['e2']); + expect(r.entries.map((e) => [e.record.key, e.value])).toEqual([ + ['P', 'plain-value'], + ['E', 'decrypted-e2'], + ]); + expect(r.empty).toEqual([]); + }); + + test('an empty or absent value although typed readable is skipped as empty_value, never imported as ""', async () => { + const client = { getEnvValue: async (_p: string, envId: string) => (envId === 'e1' ? '' : undefined) }; + const r = await resolveEnvValues(client, 'prj', undefined, [ + rec({ id: 'e1', key: 'EMPTY', type: 'encrypted' }), + rec({ id: 'e2', key: 'ABSENT', type: 'encrypted' }), + rec({ id: 'e3', key: 'PLAIN_EMPTY', type: 'plain', value: '' }), + ]); + expect(r.entries).toEqual([]); + expect(r.empty.map((s) => [s.name, s.reason])).toEqual([ + ['EMPTY', 'empty_value'], + ['ABSENT', 'empty_value'], + ['PLAIN_EMPTY', 'empty_value'], + ]); + }); +}); + +describe('resolveEnvValues: a failed value read for one record', () => { + const records = [ + rec({ id: 'e1', key: 'A', type: 'encrypted' }), + rec({ id: 'e2', key: 'B', type: 'encrypted' }), + rec({ id: 'e3', key: 'C', type: 'encrypted' }), + ]; + + test('a 404 on one record becomes value_not_found; the others resolve normally and all are asked for', async () => { + const getEnvValue = mock(async (_p: string, envId: string, _t?: string) => { + if (envId === 'e2') throw new VercelApiError('not_found', 404, 'Vercel returned HTTP 404.'); + return `decrypted-${envId}`; + }); + const r = await resolveEnvValues({ getEnvValue }, 'prj', undefined, records); + expect(getEnvValue.mock.calls.map(([, envId]) => envId)).toEqual(['e1', 'e2', 'e3']); + expect(r.entries.map((e) => [e.record.key, e.value])).toEqual([ + ['A', 'decrypted-e1'], + ['C', 'decrypted-e3'], + ]); + expect(r.notFound).toEqual([{ name: 'B', reason: 'value_not_found' }]); + expect(r.empty).toEqual([]); + expect(r.undecrypted).toEqual([]); + expect(isPlaceholderReason('value_not_found')).toBe(true); + }); + + test('a 500 still throws, as does a non-VercelApiError', async () => { + const serverError = { getEnvValue: async () => { throw new VercelApiError('server_error', 500, 'Vercel returned HTTP 500.'); } }; + await expect(resolveEnvValues(serverError, 'prj', undefined, records)).rejects.toMatchObject({ code: 'server_error', status: 500 }); + const plain = { getEnvValue: async () => { throw new Error('boom'); } }; + await expect(resolveEnvValues(plain, 'prj', undefined, records)).rejects.toThrow('boom'); + }); +}); + +describe('resolveVercelToken', () => { + const base = { env: {} as Record, interactive: false }; + + test('an explicit --token-env that is set wins, with zero store traffic', async () => { + const getConnectorSecret = mock(async (_n: string, _o: unknown) => 'from-store'); + const r = await resolveVercelToken({ + ...base, + tokenEnv: 'MY_VERCEL', + env: { MY_VERCEL: 'from-env', VERCEL_TOKEN: 'other' }, + getConnectorSecret, + }); + expect(r).toEqual({ ok: true, value: 'from-env', source: 'env' }); + expect(getConnectorSecret.mock.calls.length).toBe(0); + }); + + test('then the system store entry _CONNECTOR_VERCEL_TOKEN', async () => { + const getConnectorSecret = mock(async (_n: string, _o: unknown) => 'from-store'); + const r = await resolveVercelToken({ ...base, env: { VERCEL_TOKEN: 'fallback' }, getConnectorSecret }); + expect(r).toEqual({ ok: true, value: 'from-store', source: 'system' }); + expect(getConnectorSecret.mock.calls.map(([n]) => n)).toEqual(['_CONNECTOR_VERCEL_TOKEN']); + }); + + test('then $VERCEL_TOKEN', async () => { + expect(await resolveVercelToken({ ...base, env: { VERCEL_TOKEN: 'fallback' } })).toEqual({ ok: true, value: 'fallback', source: 'env' }); + }); + + test('nothing anywhere: VERCEL_TOKEN_MISSING', async () => { + expect(await resolveVercelToken(base)).toEqual({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + }); + + test('a store refusal carries the store\'s own code (never its message)', async () => { + const r = await resolveVercelToken({ + ...base, + getConnectorSecret: async () => { + throw Object.assign(new Error('admins only, honest'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }, + }); + expect(r).toEqual({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }); + + test('the store is told whether it may prompt, and a hidden prompt is suppressed under --json/--dry-run/--web', async () => { + const getConnectorSecret = mock(async (_n: string, _o: { interactive: boolean }) => null); + await resolveVercelToken({ ...base, interactive: true, getConnectorSecret }); + expect(getConnectorSecret.mock.calls.map(([, o]) => o.interactive)).toEqual([true]); + expect(vercelSecretsMayPrompt(true, false)).toBe(true); + expect(vercelSecretsMayPrompt(true, true)).toBe(false); + expect(vercelSecretsMayPrompt(false, false)).toBe(false); + }); +}); + +describe('dropValues — a client that only needs names never holds a value', () => { + const data = { + projects: {}, + envs: { prj_1: [{ id: 'e1', key: 'A', type: 'plain', target: ['production'], listValue: 'SECRET-LIST-VALUE' }] }, + }; + + test('by default the list keeps a plain record\'s value (the import needs it)', async () => { + const client = createVercelClient(TOKEN, { fetch: fakeVercelFetch(data) }); + expect((await client.listEnvs('prj_1')).envs[0].value).toBe('SECRET-LIST-VALUE'); + }); + + test('with dropValues no record carries a value, and the request does not ask for decrypt', async () => { + const fetchMock = mock(fakeVercelFetch(data)); + const list = await createVercelClient(TOKEN, { fetch: fetchMock, dropValues: true }).listEnvs('prj_1', 'team_1'); + expect(list.envs.map((e) => e.key)).toEqual(['A']); + expect(JSON.stringify(list)).not.toContain('SECRET-LIST-VALUE'); + expect(callsOf(fetchMock).map((c) => c.url)).toEqual(['https://api.vercel.com/v10/projects/prj_1/env?teamId=team_1']); + }); +}); diff --git a/tests/deploy/vercelApiMode.test.ts b/tests/deploy/vercelApiMode.test.ts new file mode 100644 index 00000000..ffb5f73e --- /dev/null +++ b/tests/deploy/vercelApiMode.test.ts @@ -0,0 +1,551 @@ +/** + * Vercel deploy target, API mode (CAP-696): a target whose options carry a + * `projectId` writes env vars through Vercel's REST API with a token — no + * `vercel` binary, no login, no `.vercel/project.json`. + * + * Every write test asserts the PROPERTY, against an in-memory Vercel + * (`vercelApiModeFake.ts`) that applies the POSTs and PATCHes it receives: + * "what does each environment see afterwards?", never "was this request sent". + * `fetch` is injected, so nothing here reaches the network, and every token + * and value is a fake. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import type { DeployAdapter, DeployContext, TargetConfig } from '../../src/deploy/adapter'; +import { ERROR_CODES } from '../../src/types/index'; +import { createFakeVercel, type FakeRecord, type FakeVercelOptions } from './vercelApiModeFake'; + +const TOKEN = 'tok_fake_value'; +const PROJECT = 'prj_1'; +const TEAM = 'team_1'; + +function rec(over: Partial & Pick): FakeRecord { + return { type: 'encrypted', ...over }; +} + +function setUp(fakeOpts: FakeVercelOptions = {}) { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN, ...fakeOpts }); + const sleep = mock(async (_ms: number) => {}); + const adapter: DeployAdapter = createVercelAdapter({ + fetch: fake.fetch, + sleep, + now: () => 1_000_000_000_000, + env: {}, + getTargetSecret: async () => TOKEN, + }); + return { fake, sleep, adapter }; +} + +function target(options: Record, vars: readonly string[] = ['API_KEY']): TargetConfig { + return { + name: 'vercel-api', + kind: 'vercel', + branch: 'production', + vars: [...vars], + mode: 'ci', + options: { projectDir: 'web', projectId: PROJECT, teamId: TEAM, ...options }, + }; +} + +function ctx(env: Record, over: Partial = {}): DeployContext { + return { env, dryRun: false, secretsOnly: true, cwd: '/nonexistent-cwd', interactive: false, ...over }; +} + +describe('vercel API mode — write plan, shared records', () => { + test('writing production NEVER changes what preview or development sees (shared record is split, not overwritten)', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old-shared', target: ['production', 'preview', 'development'] })], + }); + const before = { + preview: fake.seenBy('preview', 'API_KEY'), + development: fake.seenBy('development', 'API_KEY'), + }; + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new-prod' })); + + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('new-prod'); + expect(fake.seenBy('preview', 'API_KEY')).toBe(before.preview); + expect(fake.seenBy('development', 'API_KEY')).toBe(before.development); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old-shared'); + // The shared record is still there, now serving only the others; nothing was deleted or upserted. + const shared = fake.records().find((r) => r.id === 'env_shared'); + expect(shared?.target).toEqual(['preview', 'development']); + expect(shared?.value).toBe('old-shared'); + expect(fake.writeCalls().some((c) => c.method === 'DELETE')).toBe(false); + expect(fake.calls().some((c) => c.query.upsert !== undefined)).toBe(false); + }); + + test('writing preview leaves production and development exactly as they were', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old-shared', target: ['production', 'preview', 'development'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'preview' }), ctx({ API_KEY: 'new-preview' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('preview', 'API_KEY')).toBe('new-preview'); + expect(fake.seenBy('production', 'API_KEY')).toBe('old-shared'); + expect(fake.seenBy('development', 'API_KEY')).toBe('old-shared'); + }); + + test('a shared record whose value already equals ours is left alone', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'same', target: ['production', 'preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'same' })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + expect(fake.records().map((r) => r.target)).toEqual([['production', 'preview']]); + }); + + test('if creating the replacement record fails after the split, the shared record is put back', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old-shared', target: ['production', 'preview'] })], + failPostFor: ['API_KEY'], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0].code).toBe(ERROR_CODES.VERCEL_API_ERROR); + // Production did not lose its variable: the shared record serves both again. + expect(fake.seenBy('production', 'API_KEY')).toBe('old-shared'); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old-shared'); + expect(fake.records().find((r) => r.id === 'env_shared')?.target).toEqual(['production', 'preview']); + }); +}); + +describe('vercel API mode — write plan, other cases', () => { + test('a record for exactly this environment gets its value edited in place (value only)', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.records().map((r) => [r.id, r.value])).toEqual([['env_prod', 'new']]); + const writes = fake.writeCalls(); + expect(writes).toHaveLength(1); + expect(writes[0].method).toBe('PATCH'); + expect(writes[0].body).toEqual({ value: 'new' }); + }); + + test('no record yet: creates one for this environment only, as an encrypted var', async () => { + const { fake, adapter } = setUp(); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'v1' })); + expect(result.ok).toBe(true); + expect(fake.records()).toHaveLength(1); + expect(fake.records()[0]).toMatchObject({ key: 'API_KEY', value: 'v1', type: 'encrypted', target: ['production'] }); + expect(fake.seenBy('preview', 'API_KEY')).toBeUndefined(); + expect(fake.seenBy('development', 'API_KEY')).toBeUndefined(); + }); + + test('value already equal: nothing is written', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'same', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'same' })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + }); + + test('a sensitive record stays sensitive, and is reported written-but-not-readable-back, never verified', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production'] })], + }); + // Replacing a sensitive value needs a yes the first time (see vercelPlaceholderDeploy.test.ts); here it is given. + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: true } })); + expect(result.ok).toBe(true); + expect(fake.records()[0]).toMatchObject({ type: 'sensitive', value: 'new' }); + // The PATCH never sends a type, so it cannot downgrade the record. + expect(fake.writeCalls()[0].body).toEqual({ value: 'new' }); + const verify = result.steps.find((s) => s.label === 'verify Vercel env'); + expect(verify?.detail).toContain('0 verified'); + expect(verify?.detail).toContain('API_KEY'); + expect(verify?.detail).toContain('not readable back'); + }); + + test('a split off a sensitive shared record creates the new record as sensitive too', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production', 'preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: true } })); + expect(result.ok).toBe(true); + const created = fake.records().find((r) => r.id !== 'env_shared'); + expect(created).toMatchObject({ type: 'sensitive', target: ['production'], value: 'new' }); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old'); + }); + + test('a team that turns every new production var sensitive: the write succeeds and is reported unverified', async () => { + const { fake, adapter } = setUp({ enforceSensitive: true }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'v1' })); + expect(result.ok).toBe(true); + expect(fake.records()[0].type).toBe('sensitive'); + expect(result.steps.find((s) => s.label === 'verify Vercel env')?.detail).toContain('not readable back'); + }); + + test('development is allowed in API mode and is written on its own', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', target: ['production', 'development'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'development' }), ctx({ API_KEY: 'dev-only' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('development', 'API_KEY')).toBe('dev-only'); + expect(fake.seenBy('production', 'API_KEY')).toBe('old'); + }); + + test('keys that are not in config.vars are never touched, not even read back', async () => { + const others = [ + rec({ id: 'env_other', key: 'OTHER', value: 'keep-me', target: ['production'] }), + rec({ id: 'env_other2', key: 'ANOTHER', value: 'keep-me-too', target: ['production', 'preview'] }), + ]; + const { fake, adapter } = setUp({ + records: [...others, rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new', OTHER: 'ignored', ANOTHER: 'ignored' })); + expect(result.ok).toBe(true); + expect(fake.records().filter((r) => r.key !== 'API_KEY')).toEqual(others); + expect(fake.calls().filter((c) => c.path.includes('env_other'))).toEqual([]); + }); + + test('several vars: each is planned on its own', async () => { + const { fake, adapter } = setUp({ + records: [ + rec({ id: 'e1', key: 'A', value: 'old-a', target: ['production'] }), + rec({ id: 'e2', key: 'B', value: 'same-b', target: ['production'] }), + rec({ id: 'e3', key: 'C', value: 'old-c', target: ['production', 'preview'] }), + ], + }); + const result = await adapter.deploy( + target({ vercelEnv: 'production' }, ['A', 'B', 'C', 'D']), + ctx({ A: 'new-a', B: 'same-b', C: 'new-c', D: 'new-d' }), + ); + expect(result.ok).toBe(true); + expect(['A', 'B', 'C', 'D'].map((k) => fake.seenBy('production', k))).toEqual(['new-a', 'same-b', 'new-c', 'new-d']); + expect(fake.seenBy('preview', 'C')).toBe('old-c'); + expect(result.steps[0].detail).toContain('created 1, updated 1, split from a shared record 1, unchanged 1'); + }); +}); + +describe('vercel API mode — preview scope', () => { + test('preview with a gitBranch writes a branch-scoped record and leaves the all-branches one alone', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_all', key: 'API_KEY', value: 'for-all-branches', target: ['preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'preview', gitBranch: 'staging' }), ctx({ API_KEY: 'staging-value' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('preview', 'API_KEY', 'staging')).toBe('staging-value'); + expect(fake.seenBy('preview', 'API_KEY', 'some-other-branch')).toBe('for-all-branches'); + expect(fake.records().find((r) => r.id !== 'env_all')).toMatchObject({ gitBranch: 'staging', target: ['preview'] }); + }); + + test('preview WITHOUT a gitBranch applies to all preview branches: the record carries no gitBranch', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_branch', key: 'API_KEY', value: 'staging-only', target: ['preview'], gitBranch: 'staging' })], + }); + const preflight = await adapter.preflight(target({ vercelEnv: 'preview' }), { cwd: '/nonexistent-cwd' }); + expect(preflight.ok).toBe(true); + const result = await adapter.deploy(target({ vercelEnv: 'preview' }), ctx({ API_KEY: 'everywhere' })); + expect(result.ok).toBe(true); + const created = fake.records().find((r) => r.id !== 'env_branch'); + expect(created?.gitBranch).toBeUndefined(); + expect(fake.writeCalls()[0].body).not.toHaveProperty('gitBranch'); + expect(fake.seenBy('preview', 'API_KEY', 'any-branch')).toBe('everywhere'); + // The branch-specific record is a different scope and keeps its own value. + expect(fake.seenBy('preview', 'API_KEY', 'staging')).toBe('staging-only'); + }); + + test('updating an existing all-branches preview record patches that record', async () => { + const { fake, adapter } = setUp({ + records: [rec({ id: 'env_all', key: 'API_KEY', value: 'old', target: ['preview'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'preview' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.records().map((r) => [r.id, r.value])).toEqual([['env_all', 'new']]); + }); +}); + +describe('vercel API mode — custom environments', () => { + const customEnvs = [{ id: 'env_stg', slug: 'staging' }, { id: 'env_qa', slug: 'qa' }]; + + test('a custom environment slug is resolved to its id; the record serves only that environment', async () => { + const { fake, adapter } = setUp({ customEnvs }); + const result = await adapter.deploy(target({ vercelEnv: 'staging' }), ctx({ API_KEY: 'stg-value' })); + expect(result.ok).toBe(true); + const created = fake.records()[0]; + expect(created.customEnvironmentIds).toEqual(['env_stg']); + expect(created.target).toEqual([]); + expect(fake.seenBy('custom:env_stg', 'API_KEY')).toBe('stg-value'); + expect(fake.seenBy('production', 'API_KEY')).toBeUndefined(); + }); + + test('customEnvironmentId in the options is used as given', async () => { + const { fake, adapter } = setUp({ customEnvs }); + const result = await adapter.deploy(target({ vercelEnv: 'staging', customEnvironmentId: 'env_stg' }), ctx({ API_KEY: 'x' })); + expect(result.ok).toBe(true); + expect(fake.records()[0].customEnvironmentIds).toEqual(['env_stg']); + }); + + test('writing one custom environment leaves the other custom environment on a shared record untouched', async () => { + const { fake, adapter } = setUp({ + customEnvs, + records: [rec({ id: 'env_shared', key: 'API_KEY', value: 'old', target: [], customEnvironmentIds: ['env_stg', 'env_qa'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'staging' }), ctx({ API_KEY: 'stg-only' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('custom:env_stg', 'API_KEY')).toBe('stg-only'); + expect(fake.seenBy('custom:env_qa', 'API_KEY')).toBe('old'); + }); + + test('a custom environment that does not exist is refused at preflight, before any value is sent', async () => { + const { fake, adapter } = setUp({ customEnvs }); + const result = await adapter.preflight(target({ vercelEnv: 'nope' }), { cwd: '/nonexistent-cwd' }); + expect(result.ok).toBe(false); + expect(result.code).toBe(ERROR_CODES.VERCEL_ENVIRONMENT_NOT_FOUND); + expect(fake.writeCalls()).toEqual([]); + }); +}); + +describe('vercel API mode — rate limits', () => { + test('a 429 is waited out (retry-after, in seconds) and the write then lands', async () => { + const { fake, adapter, sleep } = setUp({ + rateLimit: { count: 2, headers: { 'retry-after': '3' } }, + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('new'); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([3000, 3000]); + }); + + test('x-ratelimit-reset (epoch seconds) is used when there is no retry-after', async () => { + // now() is fixed at 1_000_000_000_000 ms; a reset 7s later means a 7000 ms wait. + const { adapter, sleep } = setUp({ + rateLimit: { count: 1, headers: { 'x-ratelimit-reset': String(1_000_000_000 + 7) } }, + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([7000]); + }); + + test('429 forever: bounded retries, then a coded failure', async () => { + const { fake, adapter, sleep } = setUp({ rateLimit: { count: 99, headers: { 'retry-after': '1' } } }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0].code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + expect(sleep).toHaveBeenCalledTimes(3); + expect(fake.records()).toEqual([]); + }); + + test('a wait longer than the cap is not waited for', async () => { + const { adapter, sleep } = setUp({ rateLimit: { count: 1, headers: { 'retry-after': '3600' } } }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0].code).toBe(ERROR_CODES.VERCEL_RATE_LIMITED); + expect(sleep).not.toHaveBeenCalled(); + }); +}); + +describe('vercel API mode — preflight', () => { + const prefl = (adapter: DeployAdapter, options: Record = { vercelEnv: 'production' }) => + adapter.preflight(target(options), { cwd: '/nonexistent-cwd' }); + + test('good token, existing project: ok — with no .vercel/project.json and no project directory on disk', async () => { + const { adapter } = setUp(); + expect((await prefl(adapter)).ok).toBe(true); + }); + + test('401 → VERCEL_TOKEN_REJECTED', async () => { + const { adapter, fake } = setUp({ token: 'a-different-token' }); + const r = await prefl(adapter); + expect(r.ok).toBe(false); + expect(r.code).toBe(ERROR_CODES.VERCEL_TOKEN_REJECTED); + expect(fake.writeCalls()).toEqual([]); + }); + + test('403 → VERCEL_NO_ACCESS', async () => { + const { adapter } = setUp({ projectStatus: 403 }); + expect((await prefl(adapter)).code).toBe(ERROR_CODES.VERCEL_NO_ACCESS); + }); + + test('404 → VERCEL_PROJECT_NOT_FOUND', async () => { + const { adapter } = setUp(); + const r = await prefl(adapter, { vercelEnv: 'production', projectId: 'prj_gone' }); + expect(r.code).toBe(ERROR_CODES.VERCEL_PROJECT_NOT_FOUND); + }); + + test('a dropped connection → VERCEL_NETWORK_ERROR', async () => { + const { adapter } = setUp({ dropConnection: true }); + expect((await prefl(adapter)).code).toBe(ERROR_CODES.VERCEL_NETWORK_ERROR); + }); + + test('a failed preflight means deploy was never asked to send a value: preflight itself makes no write', async () => { + const { adapter, fake } = setUp({ projectStatus: 403 }); + await prefl(adapter); + expect(fake.writeCalls()).toEqual([]); + }); + + test('a target saved with only the team\'s slug (setup could not check the project) scopes every request by ?slug=, never ?teamId=', async () => { + const { adapter, fake } = setUp({ teamId: undefined }); + const r = await prefl(adapter, { vercelEnv: 'production', teamId: undefined, teamSlug: 'acme' }); + expect(r.ok).toBe(true); + expect(fake.calls().length).toBeGreaterThan(0); + expect(fake.calls().every((c) => c.query.slug === 'acme' && c.query.teamId === undefined)).toBe(true); + }); + + test('a teamId wins over a slug when both are present', async () => { + const { adapter, fake } = setUp(); + await prefl(adapter, { vercelEnv: 'production', teamSlug: 'acme' }); + expect(fake.calls().every((c) => c.query.teamId === TEAM && c.query.slug === undefined)).toBe(true); + }); + + test('an empty vars list is refused', async () => { + const { adapter } = setUp(); + const r = await adapter.preflight(target({ vercelEnv: 'production' }, []), { cwd: '/nonexistent-cwd' }); + expect(r.ok).toBe(false); + }); + + test('missing vercelEnv is a coded options refusal', async () => { + const { adapter } = setUp(); + const r = await adapter.preflight(target({}), { cwd: '/nonexistent-cwd' }); + expect(r.ok).toBe(false); + expect(r.code).toBe(ERROR_CODES.VERCEL_OPTIONS_INVALID); + }); +}); + +describe('vercel API mode — dry run', () => { + test('zero write calls; says what it would do with names and counts, never a value', async () => { + const { fake, adapter } = setUp({ + records: [ + rec({ id: 'e1', key: 'A', value: 'secret-a', target: ['production'] }), + rec({ id: 'e2', key: 'C', value: 'secret-c', target: ['production', 'preview'] }), + ], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }, ['A', 'B', 'C']), ctx({}, { dryRun: true })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + const detail = result.steps[0].detail ?? ''; + expect(detail).toContain('would push 3 var(s) to production'); + expect(detail).toContain('create 1 [B]'); + expect(detail).toContain('update 1 [A]'); + expect(detail).toContain('split from a shared record 1 [C]'); + expect(detail).not.toContain('secret-a'); + expect(detail).not.toContain('secret-c'); + expect(fake.records().map((r) => r.value)).toEqual(['secret-a', 'secret-c']); + }); +}); + +describe('vercel API mode — read-back verification', () => { + test('verified: values read back hash to what was sent', async () => { + const { adapter } = setUp({ + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(true); + expect(result.steps.find((s) => s.label === 'verify Vercel env')).toMatchObject({ status: 'ok', detail: '1 verified' }); + }); + + test('mismatch: Vercel holding another value is a coded failure naming the var, never the values', async () => { + const { adapter } = setUp({ + corruptWrites: true, + records: [rec({ id: 'env_prod', key: 'API_KEY', value: 'old', target: ['production'] })], + }); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'new-secret-value' })); + expect(result.ok).toBe(false); + const step = result.steps.find((s) => s.label === 'verify Vercel env'); + expect(step?.status).toBe('fail'); + expect(step?.code).toBe(ERROR_CODES.VERCEL_VERIFY_MISMATCH); + expect(step?.detail).toContain('API_KEY'); + expect(JSON.stringify(result)).not.toContain('new-secret-value'); + expect(JSON.stringify(result)).not.toContain('corrupted'); + }); +}); + +describe('vercel API mode — token handling', () => { + test('the token is only ever sent as a bearer header, and never appears in any result', async () => { + const { fake, adapter } = setUp(); + const result = await adapter.deploy(target({ vercelEnv: 'production' }), ctx({ API_KEY: 'v' })); + expect(fake.calls().every((c) => c.authorization === `Bearer ${TOKEN}`)).toBe(true); + expect(JSON.stringify(result)).not.toContain(TOKEN); + expect(fake.calls().every((c) => !JSON.stringify(c.query).includes(TOKEN))).toBe(true); + }); + + test('a pre-resolved token from the caller wins, and the store is not asked again', async () => { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN }); + const getTargetSecret = mock(async () => 'should-not-be-used'); + const adapter = createVercelAdapter({ fetch: fake.fetch, env: {}, getTargetSecret }); + const r = await adapter.preflight(target({ vercelEnv: 'production' }), { + cwd: '/nonexistent-cwd', + resolvedVercelToken: { ok: true, value: TOKEN, source: 'system' }, + }); + expect(r.ok).toBe(true); + expect(getTargetSecret).not.toHaveBeenCalled(); + }); + + test('a refused token resolution is a coded preflight refusal and nothing is sent to Vercel', async () => { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN }); + const adapter = createVercelAdapter({ fetch: fake.fetch, env: {}, getTargetSecret: async () => null }); + const r = await adapter.preflight(target({ vercelEnv: 'production' }), { cwd: '/nonexistent-cwd' }); + expect(r.ok).toBe(false); + expect(r.code).toBe(ERROR_CODES.VERCEL_TOKEN_MISSING); + expect(fake.calls()).toEqual([]); + }); + + test('a system-store refusal passes its own code through', async () => { + const fake = createFakeVercel({ projectId: PROJECT, token: TOKEN }); + const adapter = createVercelAdapter({ + fetch: fake.fetch, + env: {}, + getTargetSecret: async () => { + throw Object.assign(new Error('x'), { code: ERROR_CODES.VERCEL_TARGET_KEY_MISSING }); + }, + }); + const r = await adapter.preflight(target({ vercelEnv: 'production' }), { cwd: '/nonexistent-cwd' }); + expect(r.code).toBe(ERROR_CODES.VERCEL_TARGET_KEY_MISSING); + }); +}); + +describe('vercel — CLI path is unchanged when projectId is absent', () => { + test('a target without projectId never touches fetch, and keeps its CLI-path refusals', async () => { + const fetchSpy = mock(async () => new Response('{}')); + const adapter = createVercelAdapter({ fetch: fetchSpy as never, env: {}, getTargetSecret: async () => TOKEN }); + const legacy: TargetConfig = { + name: 'v', + kind: 'vercel', + branch: 'production', + vars: ['A'], + mode: 'ci', + options: { projectDir: 'web', vercelEnv: 'production' }, + }; + // Not linked, no TTY: exactly the old refusal (the project dir is checked first). + const pre = await adapter.preflight(legacy, { cwd: '/nonexistent-cwd' }); + expect(pre.ok).toBe(false); + expect(pre.reason).toMatch(/not found/); + const dep = await adapter.deploy(legacy, ctx({})); + expect(dep.ok).toBe(false); + expect(dep.steps[0].detail).toMatch(/missing in branch production: A/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('legacy dry run keeps its exact wording', async () => { + const { adapter } = setUp(); + const legacy: TargetConfig = { + name: 'v', + kind: 'vercel', + branch: 'production', + vars: ['A', 'B'], + mode: 'ci', + options: { projectDir: 'web', vercelEnv: 'preview', gitBranch: 'dev' }, + }; + const r = await adapter.deploy(legacy, ctx({}, { dryRun: true })); + expect(r.steps).toEqual([ + { label: 'set Vercel env', status: 'skip', detail: 'dry-run — would push 2 var(s) to preview · branch=dev, then open the deploy PR' }, + ]); + }); + + test('an empty or blank projectId does not switch a target into API mode', async () => { + const fetchSpy = mock(async () => new Response('{}')); + const adapter = createVercelAdapter({ fetch: fetchSpy as never, env: {}, getTargetSecret: async () => TOKEN }); + const r = await adapter.preflight( + { name: 'v', kind: 'vercel', branch: 'production', vars: ['A'], mode: 'ci', options: { projectDir: 'web', vercelEnv: 'production', projectId: ' ' } }, + { cwd: '/nonexistent-cwd' }, + ); + expect(r.ok).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/deploy/vercelApiModeFake.ts b/tests/deploy/vercelApiModeFake.ts new file mode 100644 index 00000000..59afbc88 --- /dev/null +++ b/tests/deploy/vercelApiModeFake.ts @@ -0,0 +1,212 @@ +/** + * An in-memory stand-in for Vercel's REST API, for the API-mode Vercel deploy + * tests (CAP-696). It APPLIES the calls it receives — POST creates a record, + * PATCH edits one — so a test can assert the property that matters ("what does + * each environment see afterwards?") instead of the shape of a request. + * + * It models only what Vercel documents and the deploy code relies on: + * - one record may serve several environments (`target`, `customEnvironmentIds`); + * - a Preview record may be scoped to one git branch; + * - a POST that collides with an existing record is refused (403), and + * `upsert=true` would overwrite it — recorded so a test can prove the + * deploy code never sends it; + * - sensitive records never reveal their value; encrypted ones do when + * `decrypt=true`; + * - 429 with rate-limit headers, 401/403/404 and a dropped connection on demand. + * + * The store inside is genuinely mutable — it stands in for Vercel's own + * database — and is confined to this file. Tests only ever read immutable + * snapshots from it. + */ +import type { FetchLike } from '../../src/deploy/vercelTargetApi'; + +export interface FakeRecord { + id: string; + key: string; + value: string; + type: 'encrypted' | 'sensitive' | 'plain'; + target: readonly string[]; + gitBranch?: string; + customEnvironmentIds?: readonly string[]; +} + +export interface FakeCall { + method: string; + path: string; + query: Readonly>; + body?: Record; + authorization: string | null; +} + +export interface FakeVercelOptions { + projectId?: string; + teamId?: string; + token?: string; + records?: readonly FakeRecord[]; + customEnvs?: ReadonlyArray<{ id: string; slug: string }>; + /** New Production/Preview records come back `sensitive` (the team policy). */ + enforceSensitive?: boolean; + /** Store a different value than the one sent — what a failed write would look like. */ + corruptWrites?: boolean; + /** Status the project GET answers with instead of 200. */ + projectStatus?: number; + /** Every request fails like a dropped connection. */ + dropConnection?: boolean; + /** The first `count` write requests (POST/PATCH) answer 429 with these headers. */ + rateLimit?: { count: number; headers: Readonly> }; + /** POSTs for these keys answer 500. */ + failPostFor?: readonly string[]; +} + +export type StandardEnv = 'production' | 'preview' | 'development'; + +export interface FakeVercel { + fetch: FetchLike; + /** Every request received, in order (a snapshot). */ + calls(): readonly FakeCall[]; + /** Calls that could change something. */ + writeCalls(): readonly FakeCall[]; + /** Every record now stored (a snapshot, ids and all). */ + records(): readonly FakeRecord[]; + /** The value an environment actually sees for `key`. */ + seenBy(env: StandardEnv | `custom:${string}`, key: string, gitBranch?: string): string | undefined; +} + +const SERVES = (rec: FakeRecord, env: string): boolean => + rec.target.includes(env) || + (env.startsWith('custom:') && (rec.customEnvironmentIds ?? []).includes(env.slice('custom:'.length))); + +function envKeys(rec: FakeRecord): readonly string[] { + return [...rec.target, ...(rec.customEnvironmentIds ?? []).map((id) => `custom:${id}`)]; +} + +function json(status: number, body: unknown, headers: Readonly> = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +function asRecordJson(rec: FakeRecord, reveal: boolean): Record { + const readable = reveal && rec.type !== 'sensitive'; + return { + id: rec.id, + key: rec.key, + type: rec.type, + target: [...rec.target], + ...(rec.gitBranch ? { gitBranch: rec.gitBranch } : {}), + ...(rec.customEnvironmentIds ? { customEnvironmentIds: [...rec.customEnvironmentIds] } : {}), + ...(readable ? { value: rec.value } : rec.type === 'sensitive' ? {} : { value: `ciphertext(${rec.id})` }), + decrypted: readable && rec.type === 'encrypted', + }; +} + +export function createFakeVercel(opts: FakeVercelOptions = {}): FakeVercel { + const projectId = opts.projectId ?? 'prj_1'; + const token = opts.token ?? 'tok_fake_value'; + const store = new Map((opts.records ?? []).map((r) => [r.id, r])); + const log: FakeCall[] = []; + const state = { nextId: 1, rateLimited: 0 }; + + const fetchFake: FetchLike = async (input, init) => { + const url = new URL(input); + const method = (init?.method ?? 'GET').toUpperCase(); + const headers = (init?.headers ?? {}) as Record; + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : undefined; + const query = Object.fromEntries(url.searchParams.entries()); + log.push({ method, path: url.pathname, query, body, authorization: headers.Authorization ?? null }); + + if (opts.dropConnection) throw new TypeError('connection dropped'); + if (headers.Authorization !== `Bearer ${token}`) return json(401, { error: { code: 'forbidden' } }); + if (opts.teamId !== undefined && query.teamId !== opts.teamId) return json(403, { error: { code: 'forbidden' } }); + + const isWrite = method === 'POST' || method === 'PATCH' || method === 'DELETE'; + if (isWrite && opts.rateLimit && state.rateLimited < opts.rateLimit.count) { + state.rateLimited += 1; + return json(429, { error: { code: 'rate_limited' } }, opts.rateLimit.headers); + } + + const project = url.pathname.match(/^\/v9\/projects\/([^/]+)$/); + if (project && method === 'GET') { + if (opts.projectStatus && opts.projectStatus !== 200) return json(opts.projectStatus, { error: { code: 'x' } }); + return decodeURIComponent(project[1]) === projectId ? json(200, { id: projectId, name: 'demo' }) : json(404, { error: { code: 'not_found' } }); + } + const custom = url.pathname.match(/^\/v9\/projects\/([^/]+)\/custom-environments\/([^/]+)$/); + if (custom && method === 'GET') { + const wanted = decodeURIComponent(custom[2]); + const found = (opts.customEnvs ?? []).find((e) => e.id === wanted || e.slug === wanted); + return found ? json(200, { id: found.id, slug: found.slug, type: 'preview' }) : json(404, { error: { code: 'not_found' } }); + } + if (decodeURIComponent(url.pathname.split('/')[3] ?? '') !== projectId) return json(404, { error: { code: 'not_found' } }); + + const list = url.pathname.match(/^\/v10\/projects\/[^/]+\/env$/); + if (list && method === 'GET') { + const reveal = query.decrypt === 'true'; + return json(200, { envs: [...store.values()].map((r) => asRecordJson(r, reveal)) }); + } + if (list && method === 'POST') return createRecord(body ?? {}, query); + + const one = url.pathname.match(/^\/v(?:9|1)\/projects\/[^/]+\/env\/([^/]+)$/); + if (one) { + const rec = store.get(decodeURIComponent(one[1])); + if (!rec) return json(404, { error: { code: 'not_found' } }); + if (method === 'GET') return json(200, asRecordJson(rec, true)); + if (method === 'PATCH') return patchRecord(rec, body ?? {}); + } + return json(405, { error: { code: 'method_unknown' } }); + }; + + function createRecord(body: Record, query: Readonly>): Response { + const key = String(body.key); + if (opts.failPostFor?.includes(key)) return json(500, { error: { code: 'internal_server_error' } }); + const target = (body.target as string[] | undefined) ?? []; + const custom = (body.customEnvironmentIds as string[] | undefined) ?? []; + if (target.length === 0 && custom.length === 0) return json(400, { error: { code: 'bad_request' } }); + const draft: FakeRecord = { + id: `env_${state.nextId}`, + key, + value: opts.corruptWrites ? `${String(body.value)}-corrupted` : String(body.value), + type: opts.enforceSensitive && target.some((t) => t === 'production' || t === 'preview') + ? 'sensitive' + : (body.type as FakeRecord['type']), + target, + ...(body.gitBranch ? { gitBranch: String(body.gitBranch) } : {}), + ...(custom.length > 0 ? { customEnvironmentIds: custom } : {}), + }; + const collides = [...store.values()].some( + (r) => + r.key === key && + envKeys(r).some((e) => envKeys(draft).includes(e)) && + (!envKeys(r).includes('preview') || (r.gitBranch ?? '') === (draft.gitBranch ?? '')), + ); + if (collides && query.upsert !== 'true') return json(403, { error: { code: 'ENV_ALREADY_EXISTS' } }); + state.nextId += 1; + store.set(draft.id, draft); + return json(201, { created: asRecordJson(draft, false), failed: [] }); + } + + function patchRecord(rec: FakeRecord, body: Record): Response { + const next: FakeRecord = { + ...rec, + ...(typeof body.value === 'string' ? { value: opts.corruptWrites ? `${body.value}-corrupted` : body.value } : {}), + ...(Array.isArray(body.target) ? { target: body.target as string[] } : {}), + ...(Array.isArray(body.customEnvironmentIds) ? { customEnvironmentIds: body.customEnvironmentIds as string[] } : {}), + ...(body.gitBranch === null ? { gitBranch: undefined } : typeof body.gitBranch === 'string' ? { gitBranch: body.gitBranch } : {}), + }; + store.set(rec.id, next); + return json(200, asRecordJson(next, false)); + } + + return { + fetch: fetchFake, + calls: () => [...log], + writeCalls: () => log.filter((c) => c.method !== 'GET'), + records: () => [...store.values()].map((r) => ({ ...r })), + seenBy(env, key, gitBranch) { + const serving = [...store.values()].filter((r) => r.key === key && SERVES(r, env)); + if (env !== 'preview') return serving[0]?.value; + const branchSpecific = serving.find((r) => gitBranch !== undefined && r.gitBranch === gitBranch); + return (branchSpecific ?? serving.find((r) => !r.gitBranch))?.value; + }, + }; +} diff --git a/tests/deploy/vercelApiModeNoBinary.test.ts b/tests/deploy/vercelApiModeNoBinary.test.ts new file mode 100644 index 00000000..d6dca025 --- /dev/null +++ b/tests/deploy/vercelApiModeNoBinary.test.ts @@ -0,0 +1,17 @@ +/** + * API mode needs no `vercel` binary (CAP-696): proven in a child process whose + * PATH contains nothing, so a stray spawn of `vercel` would fail the run. + */ +import { test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +test('preflight and deploy succeed with no vercel binary on PATH', () => { + const child = spawnSync(process.execPath, ['run', join(import.meta.dir, 'vercelApiModeNoBinaryChild.ts')], { + encoding: 'utf-8', + env: { PATH: '/nonexistent-bin-dir', HOME: process.env.HOME ?? '/nonexistent-home' }, + }); + expect(child.status).toBe(0); + const out = JSON.parse(child.stdout.trim().split('\n').pop() ?? '{}'); + expect(out).toEqual({ binaryAbsent: true, preflightOk: true, deployOk: true, seen: 'v1' }); +}); diff --git a/tests/deploy/vercelApiModeNoBinaryChild.ts b/tests/deploy/vercelApiModeNoBinaryChild.ts new file mode 100644 index 00000000..3a4c8099 --- /dev/null +++ b/tests/deploy/vercelApiModeNoBinaryChild.ts @@ -0,0 +1,31 @@ +/** + * Child process for `vercelApiModeNoBinary.test.ts`: runs an API-mode Vercel + * deploy in a process whose PATH holds no `vercel` binary at all, and prints + * one JSON line. Not a test file itself. + */ +import { spawnSync } from 'child_process'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import { createFakeVercel } from './vercelApiModeFake'; + +const fake = createFakeVercel({ projectId: 'prj_1', teamId: 'team_1', token: 'tok_fake_value' }); +const adapter = createVercelAdapter({ fetch: fake.fetch, env: {}, getTargetSecret: async () => 'tok_fake_value' }); +const target = { + name: 'v', + kind: 'vercel', + branch: 'production', + vars: ['API_KEY'], + mode: 'ci' as const, + options: { projectDir: 'web', projectId: 'prj_1', teamId: 'team_1', vercelEnv: 'production' }, +}; + +const probe = spawnSync('vercel', ['--version'], { encoding: 'utf-8' }); +const pre = await adapter.preflight(target, { cwd: '/nonexistent-cwd' }); +const dep = await adapter.deploy(target, { env: { API_KEY: 'v1' }, dryRun: false, cwd: '/nonexistent-cwd' }); +console.log( + JSON.stringify({ + binaryAbsent: probe.error !== undefined, + preflightOk: pre.ok, + deployOk: dep.ok, + seen: fake.seenBy('production', 'API_KEY'), + }), +); diff --git a/tests/deploy/vercelApiModeTokenStore.test.ts b/tests/deploy/vercelApiModeTokenStore.test.ts new file mode 100644 index 00000000..8e991b15 --- /dev/null +++ b/tests/deploy/vercelApiModeTokenStore.test.ts @@ -0,0 +1,182 @@ +/** + * The Vercel API-mode token comes from the org system store (CAP-696): + * `_TARGET_VERCEL_TOKEN`, which may be a one-level reference to + * `_CONNECTOR_VERCEL_TOKEN`. Run against the REAL system store (same fake + * service + on-disk master key as `tests/system/systemStore.test.ts`), so the + * reference is saved and followed by the real code, not by a stand-in. + * + * mock.module is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { mock, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; + +const tempHome = mkdtempSync(join(require('os').tmpdir(), 'capy-vercel-token-store-')); +mock.module('os', () => { + const actual = require('os'); + return { ...actual, homedir: () => tempHome }; +}); + +const ORG = 'org_a'; +const USER = 'user_a'; +const KMS_PREFIX = 'KMS1.'; + +interface FakeServer { + projectId: string; + role: 'owner' | 'member'; + envBlob: string; + keepFile?: string; + keepHash: string; + pushCount: number; +} +const servers = new Map(); +const serverFor = (orgId: string): FakeServer => + servers.get(orgId) ?? servers.set(orgId, { projectId: `proj_${orgId}`, role: 'owner', envBlob: '', keepHash: 'h0', pushCount: 0 }).get(orgId)!; +const serverForProject = (projectId: string): FakeServer => { + const found = [...servers.values()].find((s) => s.projectId === projectId); + if (!found) throw new Error(`no fake server for project ${projectId}`); + return found; +}; +function refuseNonAdmin(s: FakeServer): void { + if (s.role === 'owner') return; + const { CapyError, ERROR_CODES } = require('../../src/types/index'); + throw new CapyError('owners/admins only', ERROR_CODES.PERMISSION_DENIED, { status: 403, code: 'SYSTEM_STORE_ADMIN_ONLY' }); +} + +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async getOrCreateSystemStore(orgId: string) { + const s = serverFor(orgId); + refuseNonAdmin(s); + return { project_id: s.projectId, branch: 'system' }; + } + async getDecryptData(projectId: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + return { env_content: s.envBlob, decrypt_key: '', expires_at: new Date().toISOString(), keep_file: s.keepFile, keep_hash: s.keepHash }; + } + async pushSecrets(projectId: string, keepFile: string, envBlob: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + s.envBlob = envBlob; + s.keepFile = keepFile; + s.pushCount += 1; + s.keepHash = `h${s.pushCount}`; + return { keep_hash: s.keepHash, keep_file: s.keepFile }; + } + async coDecrypt(_orgId: string, ciphertext: string) { + return { plaintext: ciphertext.slice(KMS_PREFIX.length) }; + } + async wrapOuterLayer(_orgId: string, plaintext: string) { + return { ciphertext: KMS_PREFIX + plaintext }; + } + }, +})); +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async authenticate() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async getValidToken() { + return { access_token: 'tok', expires_at: Date.now() + 999999, organization_id: ORG, user_id: USER }; + } + }, +})); +const promptAnswers = new Map(); +mock.module('inquirer', () => ({ + default: { prompt: mock(async (questions: Array<{ name: string }>) => ({ [questions[0].name]: promptAnswers.get(questions[0].name) })) }, +})); + +afterAll(() => { + mock.restore(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +let openSystemStore: typeof import('../../src/system/systemStore').openSystemStore; +let resolveVercelTargetToken: typeof import('../../src/deploy/vercelTargetApi').resolveVercelTargetToken; +let getVercelTargetSecretFromStore: typeof import('../../src/deploy/vercelTargetApi').getVercelTargetSecretFromStore; +let VERCEL_TARGET_SECRET_NAME: string; +let VERCEL_CONNECTOR_SECRET_NAME: string; +let ERROR_CODES: typeof import('../../src/types/index').ERROR_CODES; + +beforeAll(async () => { + openSystemStore = (await import('../../src/system/systemStore')).openSystemStore; + const api = await import('../../src/deploy/vercelTargetApi'); + resolveVercelTargetToken = api.resolveVercelTargetToken; + getVercelTargetSecretFromStore = api.getVercelTargetSecretFromStore; + VERCEL_TARGET_SECRET_NAME = api.VERCEL_TARGET_SECRET_NAME; + VERCEL_CONNECTOR_SECRET_NAME = api.VERCEL_CONNECTOR_SECRET_NAME; + ERROR_CODES = (await import('../../src/types/index')).ERROR_CODES; + const { wrapAndSaveMasterKey } = await import('../../src/crypto/keyResolver'); + await wrapAndSaveMasterKey(randomBytes(32), ORG, USER, { + coDecrypt: async (_o: string, ct: string) => ct.slice(KMS_PREFIX.length), + wrapOuterLayer: async (_o: string, pt: string) => KMS_PREFIX + pt, + }); +}); + +beforeEach(() => { + servers.clear(); + promptAnswers.clear(); +}); + +const setValue = async (name: string, value: string): Promise => { + const store = await openSystemStore({ orgId: ORG, devMode: true }); + await store.set(name, value); +}; + +const resolve = (over: { interactive?: boolean; env?: Record } = {}) => + resolveVercelTargetToken({ + env: over.env ?? {}, + interactive: over.interactive ?? false, + orgId: ORG, + devMode: true, + getSecret: getVercelTargetSecretFromStore, + }); + +describe('Vercel target token — org system store', () => { + it('the names are the documented ones', () => { + expect(VERCEL_TARGET_SECRET_NAME).toBe('_TARGET_VERCEL_TOKEN'); + expect(VERCEL_CONNECTOR_SECRET_NAME).toBe('_CONNECTOR_VERCEL_TOKEN'); + }); + + it('a literal _TARGET_VERCEL_TOKEN resolves, from the system store', async () => { + await setValue(VERCEL_TARGET_SECRET_NAME, 'tok_literal_fake'); + expect(await resolve()).toEqual({ ok: true, value: 'tok_literal_fake', source: 'system' }); + }); + + it('a one-level reference to _CONNECTOR_VERCEL_TOKEN resolves to the connector value, and follows it when it changes', async () => { + await setValue(VERCEL_CONNECTOR_SECRET_NAME, 'tok_connector_fake'); + // Interactive run: choose to reuse the connector token — the store saves a REFERENCE. + promptAnswers.set('choice', 'use'); + expect(await resolve({ interactive: true })).toEqual({ ok: true, value: 'tok_connector_fake', source: 'system' }); + + const reopened = await openSystemStore({ orgId: ORG, devMode: true }); + expect(reopened.listNames().find((e) => e.name === VERCEL_TARGET_SECRET_NAME)?.referencesName).toBe(VERCEL_CONNECTOR_SECRET_NAME); + + // Later, non-interactive (the sandbox case): the reference resolves with no prompt. + expect(await resolve()).toEqual({ ok: true, value: 'tok_connector_fake', source: 'system' }); + // It is a reference, not a copy: rotating the connector token rotates what deploy uses. + await setValue(VERCEL_CONNECTOR_SECRET_NAME, 'tok_rotated_fake'); + expect(await resolve()).toEqual({ ok: true, value: 'tok_rotated_fake', source: 'system' }); + }); + + it('only the connector token exists + non-interactive: a coded refusal, nothing borrowed silently', async () => { + await setValue(VERCEL_CONNECTOR_SECRET_NAME, 'tok_connector_fake'); + expect(await resolve()).toEqual({ ok: false, code: ERROR_CODES.VERCEL_TARGET_KEY_MISSING }); + }); + + it('nothing anywhere: VERCEL_TOKEN_MISSING; $VERCEL_TOKEN is the last resort', async () => { + expect(await resolve()).toEqual({ ok: false, code: ERROR_CODES.VERCEL_TOKEN_MISSING }); + expect(await resolve({ env: { VERCEL_TOKEN: 'tok_env_fake' } })).toEqual({ ok: true, value: 'tok_env_fake', source: 'env' }); + }); + + it('a non-admin caller: the store refuses with its own code, passed through', async () => { + serverFor(ORG).role = 'member'; + expect(await resolve()).toEqual({ ok: false, code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }); +}); diff --git a/tests/deploy/vercelPlaceholderDeploy.test.ts b/tests/deploy/vercelPlaceholderDeploy.test.ts new file mode 100644 index 00000000..99ef803f --- /dev/null +++ b/tests/deploy/vercelPlaceholderDeploy.test.ts @@ -0,0 +1,231 @@ +/** + * Deploying to Vercel with placeholders (names with no value in Capy yet) and sensitive Vercel values. + * + * Properties, asserted on an in-memory Vercel (`vercelApiModeFake.ts`): "what does Vercel hold afterwards?" + * - a placeholder is never written: Vercel keeps its own value, and the deploy reports it as kept (not "missing in branch"); + * a var that is truly missing still fails; + * - replacing a value Vercel holds as `sensitive` (Capy cannot read it, so cannot tell whether it differs) needs a yes the + * FIRST time for a target: a person at a terminal, or `--replace-sensitive`; otherwise a coded refusal naming the + * vars and nothing is written. The delivery that follows is the record that it was confirmed: later deploys do not ask; + * - a dry run says so without writing anything. + * The CLI-mode (binary) path leaves placeholders alone too. Every token and value is a fake. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createVercelAdapter } from '../../src/deploy/adapters/vercel'; +import type { DeployAdapter, DeployContext, TargetConfig } from '../../src/deploy/adapter'; +import { deliveredBefore, deliveryFor } from '../../src/deploy/deliveryRecord'; +import { recordTargetDeliveries } from '../../src/deploy/targetsGate'; +import { ERROR_CODES, type KeepFile } from '../../src/types/index'; +import { createFakeVercel, type FakeRecord, type FakeVercelOptions } from './vercelApiModeFake'; + +const TOKEN = 'tok_fake_value'; +const PROJECT = 'prj_1'; +const TEAM = 'team_1'; + +const rec = (over: Partial & Pick): FakeRecord => ({ type: 'encrypted', ...over }); + +function setUp(fakeOpts: FakeVercelOptions = {}) { + const fake = createFakeVercel({ projectId: PROJECT, teamId: TEAM, token: TOKEN, ...fakeOpts }); + const adapter: DeployAdapter = createVercelAdapter({ + fetch: fake.fetch, + sleep: mock(async (_ms: number) => {}), + now: () => 1_000_000_000_000, + env: {}, + getTargetSecret: async () => TOKEN, + }); + return { fake, adapter }; +} + +const target = (vars: readonly string[], options: Record = {}): TargetConfig => ({ + name: 'vercel-api', + kind: 'vercel', + branch: 'production', + vars: [...vars], + mode: 'ci', + options: { projectDir: 'web', projectId: PROJECT, teamId: TEAM, vercelEnv: 'production', ...options }, +}); + +const ctx = (env: Record, over: Partial = {}): DeployContext => ({ + env, + dryRun: false, + secretsOnly: true, + cwd: '/nonexistent-cwd', + interactive: false, + ...over, +}); + +const SENSITIVE = rec({ id: 'env_sens', key: 'API_KEY', value: 'vercel-only', type: 'sensitive', target: ['production'] }); + +describe('a placeholder is never written', () => { + test('Vercel keeps its value, the other var is written, and the deploy reports the placeholder as kept', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE, rec({ id: 'env_db', key: 'DB_URL', value: 'old-db', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY', 'DB_URL']), ctx({ DB_URL: 'new-db' }, { placeholders: ['API_KEY'] })); + + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('vercel-only'); + expect(fake.seenBy('production', 'DB_URL')).toBe('new-db'); + expect(fake.writeCalls().every((c) => !c.path.includes('env_sens'))).toBe(true); + expect(result.skipped).toEqual(['API_KEY']); + const kept = result.steps.find((s) => s.label === 'kept Vercel value'); + expect(kept?.detail).toContain('API_KEY'); + expect(result.steps.find((s) => s.label === 'set Vercel env')?.detail).toContain('1 var(s) pushed'); + expect(JSON.stringify(result)).not.toContain('vercel-only'); + }); + + test('a var that is truly missing still fails the deploy, and nothing is written', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY', 'GONE']), ctx({}, { placeholders: ['API_KEY'] })); + expect(result.ok).toBe(false); + expect(result.steps[0].detail).toContain('missing in branch production: GONE'); + expect(result.steps[0].detail).not.toContain('API_KEY'); + expect(fake.writeCalls()).toEqual([]); + }); + + test('a target of nothing but placeholders asks Vercel nothing at all', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({}, { placeholders: ['API_KEY'] })); + expect(result.ok).toBe(true); + expect(result.skipped).toEqual(['API_KEY']); + expect(fake.calls()).toEqual([]); + }); + + test('a name that has a value in the env is written even if it is also listed as a placeholder', async () => { + const { fake, adapter } = setUp({ records: [rec({ id: 'e', key: 'API_KEY', value: 'old', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'now-real' }, { placeholders: ['API_KEY'] })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('now-real'); + expect(result.skipped).toBeUndefined(); + }); + + test('the CLI-mode path (no projectId) leaves a placeholder alone and still fails on a truly missing var', async () => { + const { adapter } = setUp(); + const legacy: TargetConfig = { name: 'v', kind: 'vercel', branch: 'production', vars: ['API_KEY'], options: { projectDir: 'web', vercelEnv: 'production' } }; + const kept = await adapter.deploy(legacy, ctx({}, { placeholders: ['API_KEY'] })); + expect(kept.ok).toBe(true); + expect(kept.skipped).toEqual(['API_KEY']); + expect(kept.steps.find((s) => s.label === 'kept Vercel value')?.detail).toContain('API_KEY'); + + const missing = await adapter.deploy({ ...legacy, vars: ['API_KEY', 'GONE'] }, ctx({}, { placeholders: ['API_KEY'] })); + expect(missing.ok).toBe(false); + expect(missing.steps[0].detail).toContain('missing in branch production: GONE'); + }); +}); + +describe('replacing a value Vercel holds as sensitive', () => { + test('off a terminal, without the flag: a coded refusal naming the vars; nothing is written', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' })); + expect(result.ok).toBe(false); + expect(result.steps[0]).toMatchObject({ status: 'fail', code: ERROR_CODES.VERCEL_SENSITIVE_REPLACE_UNCONFIRMED }); + expect(result.steps[0].detail).toContain('API_KEY'); + expect(fake.writeCalls()).toEqual([]); + expect(fake.seenBy('production', 'API_KEY')).toBe('vercel-only'); + }); + + test('with the flag: it proceeds', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: true } })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('new'); + }); + + test('at a terminal it asks once, naming the vars; yes proceeds, no writes nothing', async () => { + const yes = mock(async (_names: readonly string[]) => true); + const a = setUp({ records: [SENSITIVE] }); + const ok = await a.adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: false, ask: yes } })); + expect(ok.ok).toBe(true); + expect(yes.mock.calls).toEqual([[['API_KEY']]]); + expect(a.fake.seenBy('production', 'API_KEY')).toBe('new'); + + const no = mock(async (_names: readonly string[]) => false); + const b = setUp({ records: [SENSITIVE] }); + const refused = await b.adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' }, { sensitiveReplace: { delivered: [], allowed: false, ask: no } })); + expect(refused.ok).toBe(false); + expect(refused.steps[0]).toMatchObject({ code: ERROR_CODES.VERCEL_SENSITIVE_REPLACE_UNCONFIRMED }); + expect(b.fake.writeCalls()).toEqual([]); + }); + + test('a var this target delivered before is not asked about again', async () => { + const ask = mock(async (_names: readonly string[]) => false); + const { fake, adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'newer' }, { sensitiveReplace: { delivered: ['API_KEY'], allowed: false, ask } })); + expect(result.ok).toBe(true); + expect(ask).not.toHaveBeenCalled(); + expect(fake.seenBy('production', 'API_KEY')).toBe('newer'); + }); + + test('only the sensitive vars are asked about; a readable record is replaced as it always was', async () => { + const ask = mock(async (_names: readonly string[]) => true); + const { adapter } = setUp({ records: [SENSITIVE, rec({ id: 'env_db', key: 'DB_URL', value: 'old', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY', 'DB_URL']), ctx({ API_KEY: 'a', DB_URL: 'b' }, { sensitiveReplace: { delivered: [], allowed: false, ask } })); + expect(result.ok).toBe(true); + expect(ask.mock.calls).toEqual([[['API_KEY']]]); + }); + + test('a split off a sensitive shared record counts as replacing a sensitive value', async () => { + const shared = rec({ id: 'env_shared', key: 'API_KEY', value: 'old', type: 'sensitive', target: ['production', 'preview'] }); + const { fake, adapter } = setUp({ records: [shared] }); + const refused = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'new' })); + expect(refused.ok).toBe(false); + expect(fake.writeCalls()).toEqual([]); + expect(fake.seenBy('preview', 'API_KEY')).toBe('old'); + }); + + test('a var with no record on Vercel yet is created without asking', async () => { + const { fake, adapter } = setUp(); + const result = await adapter.deploy(target(['API_KEY']), ctx({ API_KEY: 'first' })); + expect(result.ok).toBe(true); + expect(fake.seenBy('production', 'API_KEY')).toBe('first'); + }); +}); + +describe('dry run', () => { + test('says which sensitive values would need confirmation and which placeholders are kept; writes nothing', async () => { + const { fake, adapter } = setUp({ records: [SENSITIVE, rec({ id: 'env_keep', key: 'KEPT', value: 'v', type: 'sensitive', target: ['production'] })] }); + const result = await adapter.deploy(target(['API_KEY', 'KEPT']), ctx({}, { dryRun: true, placeholders: ['KEPT'] })); + expect(result.ok).toBe(true); + expect(fake.writeCalls()).toEqual([]); + expect(result.steps.find((s) => s.label === 'replace sensitive value')?.detail).toBe('would replace a sensitive value (needs confirmation): API_KEY'); + expect(result.steps.find((s) => s.label === 'kept Vercel value')?.detail).toContain('KEPT'); + expect(result.steps[0].detail).toContain('would push 1 var(s)'); + }); + + test('a sensitive var delivered before is not listed as needing confirmation', async () => { + const { adapter } = setUp({ records: [SENSITIVE] }); + const result = await adapter.deploy(target(['API_KEY']), ctx({}, { dryRun: true, sensitiveReplace: { delivered: ['API_KEY'], allowed: false } })); + expect(result.steps.find((s) => s.label === 'replace sensitive value')).toBeUndefined(); + }); +}); + +describe('the record that a replacement was confirmed is the delivery that followed it', () => { + const delivery = (over: Record) => ({ provider: 'vercel', target: 'vercel-api', ...over }); + const keepWith = (entry: Record): KeepFile => ({ + version: '3.0', + org_id: 'o', + project_id: 'p', + project_name: 'demo', + variables: { API_KEY: [{ resource_id: 'r', branch: 'production', value_hash: 'h', ...entry }] } as KeepFile['variables'], + }); + const t = { name: 'vercel-api', branch: 'production', vars: ['API_KEY', 'OTHER'] }; + const adapter = { id: 'vercel' }; + + test('a delivery with a value hash on this branch and target counts', () => { + expect(deliveredBefore(keepWith({ targets: [delivery({ deployed_value_hash: 'abc' })] }), t, adapter)).toEqual(['API_KEY']); + }); + + test('end to end: a keep with no delivery asks; the same keep after the delivery is recorded does not', () => { + const before = keepWith({}); + const { delivery, values } = deliveryFor({ ...t, kind: 'vercel', options: {} } as TargetConfig, adapter, undefined, false, { API_KEY: 'abc' }); + const after = recordTargetDeliveries(before, 'production', delivery, '2026-11-02T00:00:00.000Z', values); + expect(deliveredBefore(before, t, adapter)).toEqual([]); + expect(deliveredBefore(after, t, adapter)).toEqual(['API_KEY']); + }); + + test('a target that is only configured (no push yet), another target, another provider or another branch do not', () => { + expect(deliveredBefore(keepWith({ targets: [delivery({})] }), t, adapter)).toEqual([]); + expect(deliveredBefore(keepWith({ targets: [delivery({ target: 'other', deployed_value_hash: 'abc' })] }), t, adapter)).toEqual([]); + expect(deliveredBefore(keepWith({ targets: [delivery({ provider: 'dokploy', deployed_value_hash: 'abc' })] }), t, adapter)).toEqual([]); + expect(deliveredBefore(keepWith({ targets: [delivery({ deployed_value_hash: 'abc' })] }), { ...t, branch: 'preview' }, adapter)).toEqual([]); + expect(deliveredBefore(null, t, adapter)).toEqual([]); + }); +}); diff --git a/tests/deploy/vercelSetup.test.ts b/tests/deploy/vercelSetup.test.ts new file mode 100644 index 00000000..48b0ae7e --- /dev/null +++ b/tests/deploy/vercelSetup.test.ts @@ -0,0 +1,344 @@ +/** + * `capy deploy`'s Vercel target setup (API mode, modeled on Dokploy's one + * question): the link/name question, the live check, the name-back, the + * environment questions, and exactly what gets saved. + * + * Drives `resolveVercelSetup` with injected questions (`deps.prompt`), an + * in-memory Vercel (`fakeVercelFetch`: GET only — a stray write fails the test) + * and an injected token source. No `mock.module()`: this file runs in the + * normal batch. The real system store is covered in + * `vercelSetupTokenStore.test.ts`. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { parseVercelProjectInput, resolveVercelSetup, type SetupPrompt, type VercelSetupDeps } from '../../src/deploy/vercelSetup'; +import { ERROR_CODES } from '../../src/types/index'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { callsOf, fakeVercelFetch, project, type FakeVercelData } from '../helpers/fakeVercel'; +import { captureConsole } from '../helpers/testSandbox'; + +const TOKEN = 'vcp_FAKE_SETUP_TOKEN_9f3'; +const PAGE = 'https://vercel.com/account/settings/tokens'; +const CREATE = `Create a token at ${PAGE}. Set Scope to the team that owns the project`; + +const ONE_TEAM: FakeVercelData = { + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')], personal: [project('prj_mine', 'mine')] }, + envs: {}, + custom: { prj_web: [{ id: 'c_stage', slug: 'staging' }] }, +}; + +const TWO_TEAMS: FakeVercelData = { + ...ONE_TEAM, + teams: [{ id: 'team_acme', slug: 'acme' }, { id: 'team_beta', slug: 'beta' }], + projects: { team_acme: [project('prj_web', 'web')], team_beta: [project('prj_other', 'web')] }, +}; + +type Script = Readonly>>; + +/** + * A scripted asker: the Nth time a question named `X` is asked it answers + * `script.X[N]`. The count is read off the mock's own call record. + */ +function scripted(script: Script) { + const prompt = mock(async (questions: ReadonlyArray>) => { + const name = String(questions[0].name); + const askedBefore = prompt.mock.calls.filter(([qs]) => String(qs[0].name) === name).length - 1; + const answers = script[name] ?? []; + if (askedBefore >= answers.length) throw new Error(`unscripted question: ${name} (#${askedBefore + 1})`); + return { [name]: answers[askedBefore] }; + }); + const questionsNamed = (name: string) => prompt.mock.calls.map(([qs]) => qs[0]).filter((q) => q.name === name); + return { prompt: prompt as unknown as SetupPrompt, questionsNamed, count: (name: string) => questionsNamed(name).length }; +} + +const withToken = (data: FakeVercelData, over: Partial = {}) => { + const fetchMock = mock(fakeVercelFetch(data)); + const getSecret = mock(async (_name: string, _opts: unknown) => TOKEN as string | null); + const deps: VercelSetupDeps = { fetch: fetchMock as unknown as VercelFetchLike, env: {}, getSecret: getSecret as never, ...over }; + return { deps, fetchMock, getSecret }; +}; + +const run = (existing: Record, script: Script, deps: VercelSetupDeps, flags = {}) => { + const asker = scripted(script); + return captureConsole(() => resolveVercelSetup(existing, 'org_1', flags, { ...deps, prompt: asker.prompt })).then((out) => ({ ...out, asker })); +}; + +const text = (o: { stdout: readonly string[]; stderr: readonly string[] }) => [...o.stdout, ...o.stderr].join('\n'); + +describe('parseVercelProjectInput — a dashboard link or a name, nothing else', () => { + test.each([ + ['https://vercel.com/acme/web', 'acme', 'web'], + ['https://vercel.com/acme/web/', 'acme', 'web'], + ['https://vercel.com/acme/web/settings/environment-variables', 'acme', 'web'], + ['https://vercel.com/acme/web?tab=x#frag', 'acme', 'web'], + ['https://www.vercel.com/acme/web', 'acme', 'web'], + ['vercel.com/acme/web', 'acme', 'web'], + [' https://vercel.com/acme/web ', 'acme', 'web'], + ])('%s → team %s, project %s', (answer, team, name) => { + expect(parseVercelProjectInput(answer)).toEqual({ ok: true, input: { kind: 'link', team, project: name } }); + }); + + test.each([['web'], ['my-app.v2'], ['prj_AbC123']])('a bare name or id: %s', (answer) => { + expect(parseVercelProjectInput(answer)).toEqual({ ok: true, input: { kind: 'name', project: answer } }); + }); + + test.each([ + ['http://vercel.com/acme/web'], + ['https://example.com/acme/web'], + ['https://vercel.com/acme'], + ['https://vercel.com/'], + ['https://vercel.com/account/settings/tokens'], + ['https://vercel.com/new/acme'], + ['acme/web'], + ['has a space'], + ])('refused with the coded VERCEL_URL_INVALID: %s', (answer) => { + const r = parseVercelProjectInput(answer); + expect(r.ok).toBe(false); + expect(!r.ok && r.code).toBe(ERROR_CODES.VERCEL_URL_INVALID); + }); +}); + +describe('resolveVercelSetup — the questions, in order', () => { + test('a dashboard link, a token, a live check, a yes, an environment: asks exactly these, with the approved wording', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.asker.prompt.mock.calls.map(([qs]) => qs[0].name)).toEqual(['projectUrl', 'confirmed', 'vercelEnv']); + expect(out.asker.questionsNamed('projectUrl')[0].message).toBe('Vercel project URL (paste the dashboard link for this project, or its name):'); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "web" (acme) as this target?'); + }); + + test('saved options are exactly the verified project: projectDir ".", the environment, the project ID and the team ID', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.result).toEqual({ + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + productionBranch: 'main', + }); + }); + + test('it reads only: the teams, then the one project in its team, then custom environments — GET only, the token as the bearer, never anywhere else', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + const calls = callsOf(fetchMock); + expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}${new URL(c.url).search}`)).toEqual([ + 'GET /v2/teams?limit=100', + 'GET /v9/projects/web?teamId=team_acme', + 'GET /v9/projects/prj_web/custom-environments?teamId=team_acme', + ]); + expect(calls.every((c) => c.authorization === `Bearer ${TOKEN}`)).toBe(true); + expect(text(out)).not.toContain(TOKEN); + }); + + test('a bare name with ONE team: no team question; that team is used and named back', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: [''] }, deps); + expect(out.asker.count('picked')).toBe(0); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "web" (acme) as this target?'); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_web', teamId: 'team_acme' }); + }); + + test('a bare name with SEVERAL teams: asks which, lists every team, and looks the project up in the chosen one', async () => { + const { deps, fetchMock } = withToken(TWO_TEAMS); + const out = await run({}, { projectUrl: ['web'], picked: ['team_beta'], confirmed: [true], vercelEnv: ['production'] }, deps); + const teamQuestion = out.asker.questionsNamed('picked')[0]; + expect((teamQuestion.choices as ReadonlyArray<{ name: string; value: string }>).map((c) => c.name)).toEqual(['acme', 'beta']); + expect(callsOf(fetchMock).some((c) => c.url.includes('/v9/projects/web?teamId=team_beta'))).toBe(true); + expect(out.result.options).toMatchObject({ projectId: 'prj_other', teamId: 'team_beta' }); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "web" (beta) as this target?'); + }); + + test('a link names its team, so several teams are never asked about', async () => { + const { deps } = withToken(TWO_TEAMS); + const out = await run({}, { projectUrl: ['https://vercel.com/beta/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.asker.count('picked')).toBe(0); + expect(out.result.options).toMatchObject({ projectId: 'prj_other', teamId: 'team_beta' }); + }); + + test('a personal-account project (the link\'s slug is no team): looked up with no team, saved with no teamId, named back without a team', async () => { + const { deps } = withToken({ ...ONE_TEAM, teams: [] }); + const out = await run({}, { projectUrl: ['https://vercel.com/me/mine'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_mine' }); + expect(out.asker.questionsNamed('confirmed')[0].message).toBe('Use "mine" as this target?'); + }); +}); + +describe('resolveVercelSetup — refusals and re-asks', () => { + test('a project Vercel does not know: coded VERCEL_PROJECT_NOT_FOUND on stderr, the question is asked again, nothing is saved from the miss', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run( + {}, + { projectUrl: ['https://vercel.com/acme/ghost', 'https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, + deps, + ); + expect(out.stderr.join('\n')).toContain(ERROR_CODES.VERCEL_PROJECT_NOT_FOUND); + expect(out.asker.count('projectUrl')).toBe(2); + expect(out.asker.count('confirmed')).toBe(1); + expect(out.result.options).toMatchObject({ projectId: 'prj_web' }); + }); + + test('"no" at "Use … as this target?" asks the first question again', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run( + {}, + { projectUrl: ['https://vercel.com/acme/web', 'https://vercel.com/acme/web'], confirmed: [false, true], vercelEnv: ['production'] }, + deps, + ); + expect(out.asker.count('projectUrl')).toBe(2); + expect(out.asker.count('vercelEnv')).toBe(1); + }); + + test('something that is not a project link: coded VERCEL_URL_INVALID on stderr, asked again, no request made for it', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM); + const out = await run( + {}, + { projectUrl: ['https://example.com/x/y', 'web'], confirmed: [true], vercelEnv: ['production'] }, + deps, + ); + expect(out.stderr.join('\n')).toContain(ERROR_CODES.VERCEL_URL_INVALID); + expect(out.asker.count('projectUrl')).toBe(2); + expect(callsOf(fetchMock).every((c) => new URL(c.url).hostname !== 'example.com')).toBe(true); + }); + + test('no token: says so, shows the approved instructions (with the link\'s team), saves what was typed — and sends zero requests', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM, { getSecret: (async () => null) as never }); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], vercelEnv: ['production'] }, deps); + expect(fetchMock.mock.calls.length).toBe(0); + expect(text(out)).toContain('Could not verify against Vercel yet (no token) — saving as entered.'); + expect(out.stderr.join('\n')).toContain(`${CREATE} (acme).`); + expect(out.stderr.join('\n')).toContain('Then save it: capy system set _TARGET_VERCEL_TOKEN'); + expect(out.asker.count('confirmed')).toBe(0); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamSlug: 'acme' }); + }); + + test('no token and a bare name: the instructions name no team; the project is saved by name alone', async () => { + const { deps } = withToken(ONE_TEAM, { getSecret: (async () => null) as never }); + const out = await run({}, { projectUrl: ['web'], vercelEnv: ['production'] }, deps); + expect(out.stderr.join('\n')).toContain(`${CREATE}.`); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web' }); + }); + + test('the store refusing (not an admin, …): its own code is shown, saved as entered', async () => { + const refusing = async () => { + throw Object.assign(new Error('x'), { code: ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY }); + }; + const { deps } = withToken(ONE_TEAM, { getSecret: refusing as never }); + const out = await run({}, { projectUrl: ['web'], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(`(${ERROR_CODES.SYSTEM_STORE_ADMIN_ONLY})`); + expect(out.result.options).toMatchObject({ projectId: 'web' }); + }); + + test('a token Vercel rejects (401): the code is shown with the instructions, saved as entered', async () => { + const unauthorized: VercelFetchLike = async () => ({ status: 401, ok: false, text: async () => '{}' }); + const { deps } = withToken(ONE_TEAM, { fetch: unauthorized }); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(`(${ERROR_CODES.VERCEL_AUTH_FAILED})`); + expect(out.stderr.join('\n')).toContain(`${CREATE} (acme).`); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamSlug: 'acme' }); + expect(text(out)).not.toContain(TOKEN); + }); + + test('any other API problem (500) after the team is known: shown with its code, saved as entered with the team ID', async () => { + const real = fakeVercelFetch(ONE_TEAM); + const failing: VercelFetchLike = async (url, init) => + new URL(url).pathname.startsWith('/v9/projects') ? { status: 500, ok: false, text: async () => '{}' } : real(url, init); + const { deps } = withToken(ONE_TEAM, { fetch: failing }); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(`(${ERROR_CODES.VERCEL_API_ERROR})`); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamId: 'team_acme' }); + }); +}); + +describe('resolveVercelSetup — environments', () => { + test('lists production, preview, development and the project\'s custom environments; a custom one saves its ID', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['staging'] }, deps); + const choices = out.asker.questionsNamed('vercelEnv')[0].choices as ReadonlyArray<{ value: string }>; + expect(choices.map((c) => c.value)).toEqual(['production', 'preview', 'development', 'staging']); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'staging', projectId: 'prj_web', teamId: 'team_acme', customEnvironmentId: 'c_stage' }); + expect(out.asker.count('gitBranch')).toBe(0); + }); + + test('preview asks for an optional git branch: a branch is saved as gitBranch', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: ['develop'] }, deps); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'preview', projectId: 'prj_web', teamId: 'team_acme', gitBranch: 'develop' }); + expect(out.asker.questionsNamed('gitBranch')[0].message).toBe('Git branch for Preview (leave blank for all preview branches):'); + }); + + test('preview left blank means every preview branch: no gitBranch key at all', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: [''] }, deps); + expect(Object.keys(out.result.options)).not.toContain('gitBranch'); + }); + + test('production and development never ask for a branch', async () => { + const { deps } = withToken(ONE_TEAM); + const prod = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + const dev = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['development'] }, deps); + expect(prod.asker.count('gitBranch') + dev.asker.count('gitBranch')).toBe(0); + expect(dev.result.options).toMatchObject({ vercelEnv: 'development' }); + }); + + test('custom environments that cannot be listed: shown with the code, the standard ones are still offered', async () => { + const real = fakeVercelFetch(ONE_TEAM); + const failing: VercelFetchLike = async (url, init) => + new URL(url).pathname.endsWith('/custom-environments') ? { status: 500, ok: false, text: async () => '{}' } : real(url, init); + const { deps } = withToken(ONE_TEAM, { fetch: failing }); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(text(out)).toContain(ERROR_CODES.VERCEL_API_ERROR); + expect((out.asker.questionsNamed('vercelEnv')[0].choices as ReadonlyArray<{ value: string }>).map((c) => c.value)).toEqual(['production', 'preview', 'development']); + }); + + test('the project\'s production branch comes back for the PR base default (absent when Vercel has no git link)', async () => { + const { deps } = withToken(ONE_TEAM); + const withLink = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(withLink.result.productionBranch).toBe('main'); + const noLink = withToken({ ...ONE_TEAM, projects: { team_acme: [project('prj_web', 'web', { link: null })] } }); + const without = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, noLink.deps); + expect(without.result.productionBranch).toBeUndefined(); + }); +}); + +describe('resolveVercelSetup — re-editing a saved target', () => { + test('the question is prefilled with the saved project; the environment and branch defaults are the saved ones', async () => { + const { deps } = withToken(ONE_TEAM); + const existing = { projectDir: 'web', vercelEnv: 'preview', gitBranch: 'develop' }; + const legacy = await run(existing, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['preview'], gitBranch: ['develop'] }, deps); + expect(legacy.asker.questionsNamed('vercelEnv')[0].default).toBe('preview'); + expect(legacy.asker.questionsNamed('gitBranch')[0].default).toBe('develop'); + + const api = await run({ projectId: 'prj_web', teamId: 'team_acme', vercelEnv: 'production' }, { projectUrl: ['prj_web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(api.asker.questionsNamed('projectUrl')[0].default).toBe('prj_web'); + expect(api.asker.questionsNamed('vercelEnv')[0].default).toBe('production'); + }); + + test('a legacy target (no projectId) goes through this same setup and comes out in API mode with the legacy keys gone', async () => { + const { deps } = withToken(ONE_TEAM); + const out = await run({ projectDir: 'web', vercelEnv: 'preview', gitBranch: 'develop' }, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }); + }); +}); + +describe('resolveVercelSetup — the token', () => { + test('is asked for as _TARGET_VERCEL_TOKEN through the store, interactively, naming the link\'s team for the prompt', async () => { + const { deps, getSecret } = withToken(ONE_TEAM); + await run({}, { projectUrl: ['https://vercel.com/acme/web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(getSecret.mock.calls.length).toBe(1); + const [name, opts] = getSecret.mock.calls[0] as [string, Record]; + expect(name).toBe('_TARGET_VERCEL_TOKEN'); + expect(opts).toMatchObject({ interactive: true, orgId: 'org_1', helpTeam: 'acme' }); + }); + + test('a dry run only reads: the store is never allowed to ask or save', async () => { + const { deps, getSecret } = withToken(ONE_TEAM); + await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps, { readOnly: true }); + expect(getSecret.mock.calls[0][1]).toMatchObject({ interactive: false }); + }); + + test('$VERCEL_TOKEN is the last resort when the store has nothing', async () => { + const { deps, fetchMock } = withToken(ONE_TEAM, { getSecret: (async () => null) as never, env: { VERCEL_TOKEN: 'vcp_FROM_ENV' } }); + const out = await run({}, { projectUrl: ['web'], confirmed: [true], vercelEnv: ['production'] }, deps); + expect(callsOf(fetchMock)[0].authorization).toBe('Bearer vcp_FROM_ENV'); + expect(text(out)).not.toContain('vcp_FROM_ENV'); + }); +}); diff --git a/tests/deploy/vercelSetupNoBinary.test.ts b/tests/deploy/vercelSetupNoBinary.test.ts new file mode 100644 index 00000000..85172ba0 --- /dev/null +++ b/tests/deploy/vercelSetupNoBinary.test.ts @@ -0,0 +1,25 @@ +/** + * The Vercel setup needs no `vercel` binary, no `.vercel/project.json` and no + * `vercel link` (it is the REST API with a token): proven in a child process + * whose PATH contains nothing, in an empty directory, so a stray spawn of + * `vercel` would fail the run. + */ +import { test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +test('the setup completes with no vercel binary on PATH and no .vercel/project.json', () => { + const child = spawnSync(process.execPath, ['run', join(import.meta.dir, 'vercelSetupNoBinaryChild.ts')], { + encoding: 'utf-8', + env: { PATH: '/nonexistent-bin-dir', HOME: process.env.HOME ?? '/nonexistent-home' }, + }); + expect(child.status).toBe(0); + const out = JSON.parse(child.stdout.trim().split('\n').pop() ?? '{}'); + expect(out).toEqual({ + binaryAbsent: true, + noProjectFile: true, + options: { projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }, + }); + expect(child.stdout).not.toContain('vcp_FAKE_CHILD_TOKEN'); + expect(child.stderr).not.toContain('vcp_FAKE_CHILD_TOKEN'); +}); diff --git a/tests/deploy/vercelSetupNoBinaryChild.ts b/tests/deploy/vercelSetupNoBinaryChild.ts new file mode 100644 index 00000000..9ebad041 --- /dev/null +++ b/tests/deploy/vercelSetupNoBinaryChild.ts @@ -0,0 +1,43 @@ +/** + * Child process for `vercelSetupNoBinary.test.ts`: runs `capy deploy`'s Vercel + * setup in a process whose PATH holds no `vercel` binary, in an empty + * directory (no `.vercel/project.json`), and prints one JSON line. Not a test + * file itself. + */ +import { spawnSync } from 'child_process'; +import { existsSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { resolveVercelSetup, type SetupPrompt } from '../../src/deploy/vercelSetup'; +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; +import { fakeVercelFetch, project } from '../helpers/fakeVercel'; + +const dir = mkdtempSync(join(tmpdir(), 'capy-vercel-setup-nobin-')); +process.chdir(dir); + +const answers: Record = { projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production' }; +const prompt: SetupPrompt = async (questions) => ({ [String(questions[0].name)]: answers[String(questions[0].name)] }); + +const probe = spawnSync('vercel', ['--version'], { encoding: 'utf-8' }); +const setup = await resolveVercelSetup( + {}, + 'org_1', + {}, + { + prompt, + env: {}, + getSecret: async () => 'vcp_FAKE_CHILD_TOKEN', + fetch: fakeVercelFetch({ + teams: [{ id: 'team_acme', slug: 'acme' }], + projects: { team_acme: [project('prj_web', 'web')] }, + envs: {}, + }) as VercelFetchLike, + }, +); +console.log( + JSON.stringify({ + binaryAbsent: probe.error !== undefined, + noProjectFile: !existsSync(join(dir, '.vercel', 'project.json')), + options: setup.options, + }), +); diff --git a/tests/deploy/vercelSetupTokenStore.test.ts b/tests/deploy/vercelSetupTokenStore.test.ts new file mode 100644 index 00000000..694c58b6 --- /dev/null +++ b/tests/deploy/vercelSetupTokenStore.test.ts @@ -0,0 +1,205 @@ +/** + * `capy deploy`'s Vercel setup against the REAL org system store (same fake + * service + on-disk master key as `tests/system/systemStore.test.ts`): the + * token is `_TARGET_VERCEL_TOKEN`, a one-level reference to + * `_CONNECTOR_VERCEL_TOKEN` is offered and saved when only that one exists, + * and the hidden prompt carries the approved instructions ("Then paste it + * here.", naming the link's team). The setup's own questions are injected; + * Vercel is an in-memory fake. + * + * mock.module is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { mock, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { randomBytes } from 'crypto'; +import { project, fakeVercelFetch, callsOf } from '../helpers/fakeVercel'; +import { captureConsole } from '../helpers/testSandbox'; + +const tempHome = mkdtempSync(join(require('os').tmpdir(), 'capy-vercel-token-store-')); +mock.module('os', () => { + const actual = require('os'); + return { ...actual, homedir: () => tempHome }; +}); + +const ORG = 'org_a'; +const USER = 'user_a'; +const KMS_PREFIX = 'KMS1.'; + +interface FakeServer { + projectId: string; + role: 'owner' | 'member'; + envBlob: string; + keepFile?: string; + keepHash: string; + pushCount: number; +} +const servers = new Map(); +const serverFor = (orgId: string): FakeServer => + servers.get(orgId) ?? servers.set(orgId, { projectId: `proj_${orgId}`, role: 'owner', envBlob: '', keepHash: 'h0', pushCount: 0 }).get(orgId)!; +const serverForProject = (projectId: string): FakeServer => { + const found = [...servers.values()].find((s) => s.projectId === projectId); + if (!found) throw new Error(`no fake server for project ${projectId}`); + return found; +}; +function refuseNonAdmin(s: FakeServer): void { + if (s.role === 'owner') return; + const { CapyError, ERROR_CODES } = require('../../src/types/index'); + throw new CapyError('owners/admins only', ERROR_CODES.PERMISSION_DENIED, { status: 403, code: 'SYSTEM_STORE_ADMIN_ONLY' }); +} + +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async getOrCreateSystemStore(orgId: string) { + const s = serverFor(orgId); + refuseNonAdmin(s); + return { project_id: s.projectId, branch: 'system' }; + } + async getDecryptData(projectId: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + return { env_content: s.envBlob, decrypt_key: '', expires_at: new Date().toISOString(), keep_file: s.keepFile, keep_hash: s.keepHash }; + } + async pushSecrets(projectId: string, keepFile: string, envBlob: string) { + const s = serverForProject(projectId); + refuseNonAdmin(s); + s.envBlob = envBlob; + s.keepFile = keepFile; + s.pushCount += 1; + s.keepHash = `h${s.pushCount}`; + return { keep_hash: s.keepHash, keep_file: s.keepFile }; + } + async coDecrypt(_orgId: string, ciphertext: string) { + return { plaintext: ciphertext.slice(KMS_PREFIX.length) }; + } + async wrapOuterLayer(_orgId: string, plaintext: string) { + return { ciphertext: KMS_PREFIX + plaintext }; + } + }, +})); +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async authenticate() { + return { success: true, user_id: USER, user_email: 'a@capy.sc' }; + } + async getValidToken() { + return { access_token: 'tok', expires_at: Date.now() + 999999, organization_id: ORG, user_id: USER }; + } + }, +})); +const promptAnswers = new Map(); +mock.module('inquirer', () => ({ + default: { prompt: mock(async (questions: Array<{ name: string }>) => ({ [questions[0].name]: promptAnswers.get(questions[0].name) })) }, +})); + + +afterAll(() => { + mock.restore(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +let openSystemStore: typeof import('../../src/system/systemStore').openSystemStore; +let resolveVercelSetup: typeof import('../../src/deploy/vercelSetup').resolveVercelSetup; +let ERROR_CODES: typeof import('../../src/types/index').ERROR_CODES; + +beforeAll(async () => { + openSystemStore = (await import('../../src/system/systemStore')).openSystemStore; + resolveVercelSetup = (await import('../../src/deploy/vercelSetup')).resolveVercelSetup; + ERROR_CODES = (await import('../../src/types/index')).ERROR_CODES; +}); + +beforeEach(() => { + servers.clear(); + promptAnswers.clear(); +}); + +/** A new org per test, with its own master key, so no test inherits another's cached store. */ +const freshOrg = async (): Promise => { + const org = `org_${randomBytes(4).toString('hex')}`; + const { wrapAndSaveMasterKey } = await import('../../src/crypto/keyResolver'); + await wrapAndSaveMasterKey(randomBytes(32), org, USER, { + coDecrypt: async (_o: string, ct: string) => ct.slice(KMS_PREFIX.length), + wrapOuterLayer: async (_o: string, pt: string) => KMS_PREFIX + pt, + }); + return org; +}; + +const setValue = async (org: string, name: string, value: string): Promise => { + const store = await openSystemStore({ orgId: org, devMode: true }); + await store.set(name, value); +}; + +const DATA = { teams: [{ id: 'team_acme', slug: 'acme' }], projects: { team_acme: [project('prj_web', 'web')] }, envs: {} }; + +const answers = (script: Record) => async (questions: ReadonlyArray>) => ({ + [String(questions[0].name)]: script[String(questions[0].name)], +}); + +const run = async (org: string, readOnly: boolean) => { + const fetchMock = mock(fakeVercelFetch(DATA)); + const out = await captureConsole(() => + resolveVercelSetup( + {}, + org, + { readOnly, devMode: true }, + { + fetch: fetchMock as never, + env: {}, + prompt: answers({ projectUrl: 'https://vercel.com/acme/web', confirmed: true, vercelEnv: 'production' }) as never, + }, + ), + ); + return { ...out, fetchMock }; +}; + +const everything = (o: { stdout: readonly string[]; stderr: readonly string[] }) => [...o.stdout, ...o.stderr].join('\n'); + +describe('capy deploy Vercel setup — the token, through the real system store', () => { + it('only _CONNECTOR_VERCEL_TOKEN exists: the setup offers to reuse it, saves a ONE-LEVEL REFERENCE, and checks the project with it', async () => { + const org = await freshOrg(); + await setValue(org, '_CONNECTOR_VERCEL_TOKEN', 'tok_connector_fake'); + promptAnswers.set('choice', 'use'); + const out = await run(org, false); + expect(callsOf(out.fetchMock)[0].authorization).toBe('Bearer tok_connector_fake'); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'prj_web', teamId: 'team_acme' }); + const reopened = await openSystemStore({ orgId: org, devMode: true }); + expect(reopened.listNames().find((e) => e.name === '_TARGET_VERCEL_TOKEN')?.referencesName).toBe('_CONNECTOR_VERCEL_TOKEN'); + expect(everything(out)).not.toContain('tok_connector_fake'); + }); + + it('nothing saved anywhere: the hidden prompt carries the approved instructions ("Then paste it here.", the link\'s team), the typed token is saved and used, and never printed', async () => { + const org = await freshOrg(); + promptAnswers.set('value', 'tok_typed_fake'); + const out = await run(org, false); + const stderr = out.stderr.join('\n'); + expect(stderr).toContain('Create a token at https://vercel.com/account/settings/tokens. Set Scope to the team that owns the project (acme).'); + expect(stderr).toContain('Then paste it here.'); + expect(stderr).not.toContain('capy system set'); + expect(callsOf(out.fetchMock)[0].authorization).toBe('Bearer tok_typed_fake'); + const reopened = await openSystemStore({ orgId: org, devMode: true }); + expect(reopened.get('_TARGET_VERCEL_TOKEN')).toBe('tok_typed_fake'); + expect(everything(out)).not.toContain('tok_typed_fake'); + }); + + it('a dry run (read only): never asks and never saves — says there is no token, with the "save it" instructions, and sends no request', async () => { + const org = await freshOrg(); + const out = await run(org, true); + expect(out.fetchMock.mock.calls.length).toBe(0); + expect(out.stderr.join('\n')).toContain('Then save it: capy system set _TARGET_VERCEL_TOKEN'); + const reopened = await openSystemStore({ orgId: org, devMode: true }); + expect(reopened.listNames()).toEqual([]); + expect(out.result.options).toEqual({ projectDir: '.', vercelEnv: 'production', projectId: 'web', teamSlug: 'acme' }); + }); + + it('read only with only the connector token: its coded refusal is shown (never borrowed silently)', async () => { + const org = await freshOrg(); + await setValue(org, '_CONNECTOR_VERCEL_TOKEN', 'tok_connector_fake'); + const out = await run(org, true); + expect(out.fetchMock.mock.calls.length).toBe(0); + expect(everything(out)).toContain(ERROR_CODES.VERCEL_TARGET_KEY_MISSING); + }); +}); diff --git a/tests/helpers/deployDiscoverVercelWorld.ts b/tests/helpers/deployDiscoverVercelWorld.ts new file mode 100644 index 00000000..2e143742 --- /dev/null +++ b/tests/helpers/deployDiscoverVercelWorld.ts @@ -0,0 +1,215 @@ +/** + * A fake world for `capy deploy vercel --discover` tests (CAP-696): a Vercel + * account (teams, projects, environments), the Capy repo links and secret NAME + * index, and GitHub. Everything is an in-memory fake; nothing touches the + * network, `gh` or a real service. The Capy and GitHub fakes are + * `tests/helpers/deployDiscoverWorld.ts`'s own. + * + * SENTINEL VALUES are planted in every Vercel env record (as the list's + * `value` and as the per-id decrypted value) and in every Capy index row. The + * command must never print, write or send any of them, and must never call the + * per-id value route or ask for `decrypt`. + */ +import { mock } from 'bun:test'; +import { createVercelClient } from '../../src/deploy/vercelApi'; +import type { DeployVercelDiscoverIo } from '../../src/commands/deployDiscover/vercel'; +import { FakeProject, FakeRepo, capyFake, githubFake } from './deployDiscoverWorld'; +import { FakeVercelData, RawEnv, callsOf, fakeVercelFetch, project } from './fakeVercel'; + +export const SENTINEL_VERCEL = 'SENTINEL-VERCEL-VALUE-5d0c8e17'; +export const VERCEL_TOKEN = 'vercel-token-for-this-test-only'; + +const rawEnv = (id: string, key: string, over: Partial = {}): RawEnv => ({ + id, + key, + type: 'plain', + target: ['production'], + listValue: SENTINEL_VERCEL, + ...over, +}); + +// ── Capy ───────────────────────────────────────────────────────────────────── + +export const P_SHOP = 'proj_shop'; +export const P_MWEB = 'proj_mweb'; +export const P_MADMIN = 'proj_madmin'; +export const P_DUO = 'proj_duo'; +export const P_CONF = 'proj_conf'; +export const P_PERSONAL = 'proj_personal'; +export const P_DEVONLY = 'proj_devonly'; + +export const PROJECTS: readonly FakeProject[] = [ + { + id: P_SHOP, + name: 'shop', + repos: [{ owner: 'acme', name: 'shop', path: '.' }], + branches: { + production: ['DB_URL', 'API_KEY', 'STRIPE_KEY'], + preview: ['DB_URL', 'API_KEY'], + staging: ['DB_URL'], + 'feature-x': ['DB_URL'], + }, + }, + { id: P_MWEB, name: 'mono-web', repos: [{ owner: 'acme', name: 'mono', path: 'apps/web' }], branches: { production: ['WEB_KEY', 'WEB_OTHER'] } }, + { id: P_MADMIN, name: 'mono-admin', repos: [{ owner: 'acme', name: 'mono', path: 'apps/admin' }], branches: { production: ['ADM_KEY'] } }, + { id: P_DUO, name: 'duo', repos: [{ owner: 'acme', name: 'duo', path: '.' }], branches: { production: ['DUO_KEY'] } }, + { id: P_CONF, name: 'configured', repos: [{ owner: 'acme', name: 'conf', path: '.' }], branches: { production: ['C1'] } }, + { id: P_PERSONAL, name: 'personal', repos: [{ owner: 'acme', name: 'personal', path: '.' }], branches: { production: ['PKEY'] } }, + { id: P_DEVONLY, name: 'devonly', repos: [{ owner: 'acme', name: 'devonly', path: '.' }], branches: { development: ['DEV_KEY'] } }, +]; + +// ── Vercel ─────────────────────────────────────────────────────────────────── + +export const TEAM = { id: 'team_1', slug: 'acme-team', name: 'Acme' }; + +const link = (repo: string, productionBranch: string | undefined = 'main') => ({ type: 'github', org: 'acme', repo, ...(productionBranch ? { productionBranch } : {}) }); + +export const VERCEL_DATA: FakeVercelData = { + teams: [TEAM], + pageSize: 3, + projects: { + team_1: [ + project('prj_shop', 'shop-web', { link: link('shop') }), + project('prj_mweb', 'mono-web', { rootDirectory: 'apps/web', link: link('mono') }), + project('prj_madm', 'mono-admin', { rootDirectory: 'apps/admin', link: link('mono') }), + project('prj_a', 'duo-a', { rootDirectory: 'apps/a', link: link('duo') }), + project('prj_b', 'duo-b', { rootDirectory: 'apps/b', link: link('duo') }), + project('prj_nolink', 'nolink', { link: null }), + project('prj_dev', 'dev-only', { link: link('devonly') }), + project('prj_cfg', 'configured', { link: link('conf') }), + ], + personal: [project('prj_personal', 'personal-site', { link: link('personal', 'trunk') })], + }, + custom: { prj_shop: [{ id: 'env_stg', slug: 'staging' }] }, + envs: { + prj_shop: [ + rawEnv('e1', 'DB_URL', { target: ['production', 'preview', 'development'] }), + rawEnv('e2', 'API_KEY', { type: 'encrypted', target: ['production', 'preview'] }), + rawEnv('e3', 'STRIPE_KEY', { type: 'sensitive', target: ['production'], listValue: undefined }), + rawEnv('e4', 'VERCEL_URL', { type: 'system', target: ['production', 'preview'] }), + rawEnv('e5', 'DB_URL', { target: ['preview'], gitBranch: 'feature-x' }), + rawEnv('e6', 'DB_URL', { target: [], customEnvironmentIds: ['env_stg'] }), + ], + prj_mweb: [rawEnv('m1', 'WEB_KEY'), rawEnv('m2', 'WEB_OTHER')], + prj_madm: [rawEnv('m3', 'ADM_KEY')], + prj_a: [rawEnv('d1', 'DUO_KEY')], + prj_b: [rawEnv('d2', 'DUO_KEY')], + prj_nolink: [rawEnv('n1', 'N1')], + prj_dev: [rawEnv('v1', 'DEV_KEY', { target: ['development'] })], + prj_cfg: [rawEnv('c1', 'C1')], + prj_personal: [rawEnv('p1', 'PKEY')], + }, + values: Object.fromEntries( + ['e1', 'e2', 'e3', 'e5', 'e6', 'm1', 'm2', 'm3', 'd1', 'd2', 'n1', 'v1', 'c1', 'p1'].map((id) => [id, SENTINEL_VERCEL]), + ), +}; + +/** The ids a service gets: `:`. */ +export const SVC = { + shopProd: 'prj_shop:production', + shopPreview: 'prj_shop:preview', + shopPreviewFeature: 'prj_shop:preview@feature-x', + shopStaging: 'prj_shop:custom:staging', + mwebProd: 'prj_mweb:production', + madmProd: 'prj_madm:production', + aProd: 'prj_a:production', + bProd: 'prj_b:production', + devOnly: 'prj_dev:development', + cfgProd: 'prj_cfg:production', + personalProd: 'prj_personal:production', +} as const; + +// ── GitHub ─────────────────────────────────────────────────────────────────── + +const target = (name: string, kind: string, branch: string, options: Record) => ({ + name, + kind, + branch, + vars: ['X'], + options, + mode: 'ci', + gitBaseBranch: 'main', +}); + +/** A deploy.json that already holds a non-Vercel target (the merge must leave it exactly as it is). */ +export const EXISTING_MONO_DEPLOY = + JSON.stringify({ version: '1', targets: { 'ssm-prod': target('ssm-prod', 'aws-ssm', 'production', { prefix: '/app' }) } }, null, 2) + '\n'; + +/** A deploy.json that already holds the Vercel target for `prj_cfg` on production. */ +export const EXISTING_CONF_DEPLOY = + JSON.stringify( + { version: '1', targets: { 'vercel-production': target('vercel-production', 'vercel', 'production', { projectDir: '.', vercelEnv: 'production', projectId: 'prj_cfg', teamId: 'team_1' }) } }, + null, + 2, + ) + '\n'; + +export const GITHUB_REPOS: Readonly> = { + 'acme/shop': { defaultBranch: 'main', branches: ['main', 'develop', 'feature-x'] }, + 'acme/mono': { defaultBranch: 'main', branches: ['main'], files: { 'apps/web/.capy/deploy.json': EXISTING_MONO_DEPLOY } }, + 'acme/duo': { defaultBranch: 'main', branches: ['main'] }, + 'acme/conf': { defaultBranch: 'main', branches: ['main'], files: { '.capy/deploy.json': EXISTING_CONF_DEPLOY } }, + 'acme/personal': { defaultBranch: 'trunk', branches: ['trunk'] }, + 'acme/devonly': { defaultBranch: 'main', branches: ['main'] }, +}; + +// ── The io ─────────────────────────────────────────────────────────────────── + +export interface VercelWorldOptions { + readonly data?: FakeVercelData; + readonly projects?: readonly FakeProject[]; + readonly repos?: Readonly>; + /** Plan files by path. */ + readonly files?: Readonly>; + /** What the system store answers for the token (`null`: none). */ + readonly token?: string | null; + /** The token the fake Vercel accepts; anything else is a 401. */ + readonly acceptedToken?: string; + readonly io?: Partial; +} + +const json = (body: unknown, status: number) => ({ status, ok: false, headers: { get: () => null }, text: async () => JSON.stringify(body) }); + +export function makeVercelWorld(options: VercelWorldOptions = {}) { + const capy = capyFake(options.projects ?? PROJECTS); + const github = githubFake(options.repos ?? GITHUB_REPOS); + const progress = mock((_line: string) => undefined); + const getSecret = mock(async (_name: string, _opts: unknown) => (options.token === undefined ? VERCEL_TOKEN : options.token) as string | null); + const accepted = options.acceptedToken ?? VERCEL_TOKEN; + const inner = fakeVercelFetch(options.data ?? VERCEL_DATA); + const fetchMock = mock(async (url: string, init: { method: 'GET'; headers: Record }) => + init.headers.Authorization === `Bearer ${accepted}` ? inner(url, init as never) : json({ error: { code: 'forbidden', message: 'no' } }, 401), + ); + const files = options.files ?? {}; + const io: DeployVercelDiscoverIo = { + devMode: false, + env: {}, + readFile: (path: string) => { + const text = files[path]; + if (text === undefined) throw new Error('no such file'); + return text; + }, + context: async () => ({ ok: true, orgId: 'org_1', client: capy.client as never }), + getSecret: getSecret as never, + vercel: (token) => createVercelClient(token, { fetch: fetchMock as never, sleep: async () => {}, dropValues: true }), + github: () => github.api, + branchName: () => 'capy/vercel-targets-test-0000', + progress, + ...options.io, + }; + return { io, capy, github, progress, getSecret, fetchMock, vercelCalls: () => callsOf(fetchMock) }; +} + +export type VercelWorld = ReturnType; + +export const planFile = (entries: readonly unknown[]): string => JSON.stringify({ version: 1, entries }); + +export const ENTRY_SHOP = { project_id: P_SHOP, branch: 'production', service_id: SVC.shopProd, git_branch: 'main', vars: ['STRIPE_KEY', 'DB_URL', 'API_KEY'] }; +export const ENTRY_SHOP_PREVIEW = { project_id: P_SHOP, branch: 'preview', service_id: SVC.shopPreview, git_branch: 'main', vars: ['DB_URL', 'API_KEY'] }; +export const ENTRY_SHOP_FEATURE = { project_id: P_SHOP, branch: 'feature-x', service_id: SVC.shopPreviewFeature, git_branch: 'main', vars: ['DB_URL'] }; +export const ENTRY_SHOP_STAGING = { project_id: P_SHOP, branch: 'staging', service_id: SVC.shopStaging, git_branch: 'main', vars: ['DB_URL'] }; +export const ENTRY_MWEB = { project_id: P_MWEB, branch: 'production', service_id: SVC.mwebProd, git_branch: 'main', vars: ['WEB_KEY'] }; +export const ENTRY_MADMIN = { project_id: P_MADMIN, branch: 'production', service_id: SVC.madmProd, git_branch: 'main', vars: ['ADM_KEY'] }; +export const ENTRY_DUO_A = { project_id: P_DUO, branch: 'production', service_id: SVC.aProd, git_branch: 'main', vars: ['DUO_KEY'] }; +export const ENTRY_DUO_B = { project_id: P_DUO, branch: 'production', service_id: SVC.bProd, git_branch: 'main', vars: ['DUO_KEY'] }; +export const ENTRY_PERSONAL = { project_id: P_PERSONAL, branch: 'production', service_id: SVC.personalProd, git_branch: 'trunk', vars: ['PKEY'] }; +export const ENTRY_CFG = { project_id: P_CONF, branch: 'production', service_id: SVC.cfgProd, git_branch: 'main', vars: ['C1'] }; diff --git a/tests/helpers/fakeCapyServer.ts b/tests/helpers/fakeCapyServer.ts index 4e9c7edb..e90c77fb 100644 --- a/tests/helpers/fakeCapyServer.ts +++ b/tests/helpers/fakeCapyServer.ts @@ -149,6 +149,10 @@ export function fakeCapyServer(opts: FakeServerOpts = {}) { const pushSecrets = mock(async (projectId: string, keepFile: string, envBlob: string, branch: string, _onRate?: unknown, baseKeepHash?: string) => { const call: PushCall = { projectId, keepFile, envBlob, branch, baseKeepHash }; + // The real service's first check (service/src/routes/secrets.ts): an empty keep_file or env_blob is a 400, not a save. + if (!keepFile || !envBlob) { + throw new CapyError('keep_file and env_blob are required', ERROR_CODES.SERVICE_ERROR, { status: 400 }); + } const before = stateNow(); tape(call); if (staleAgainst(before, call)) { diff --git a/tests/helpers/fakeVercel.ts b/tests/helpers/fakeVercel.ts new file mode 100644 index 00000000..7d1810ca --- /dev/null +++ b/tests/helpers/fakeVercel.ts @@ -0,0 +1,158 @@ +/** + * A scripted fake of the Vercel REST API for the `capy connect vercel` tests. + * + * It answers ONLY the five read endpoints the connector uses and THROWS on + * any non-GET request or unknown path, so a stray write (or a call to a route + * the docs do not list) fails the test at the call site rather than being + * merely recorded. Every value in here is a fake sentinel. + */ +import type { VercelFetchLike } from '../../src/deploy/vercelApi'; + +export interface RawEnv { + id: string; + key: string; + type: string; + target?: string | string[]; + gitBranch?: string; + customEnvironmentIds?: string[]; + configurationId?: string | null; + /** What the LIST returns as `value` (plain: the real value; encrypted: opaque ciphertext). */ + listValue?: string; +} + +export interface FakeVercelData { + teams?: ReadonlyArray<{ id: string; slug: string; name?: string }>; + /** Projects per scope: a team id, or `personal`. */ + projects: Readonly>>>; + envs: Readonly>>; + custom?: Readonly>>; + /** + * Recent preview deployments per project id, newest first: the git branch each built from (`meta.githubCommitRef`; + * `null`: a deployment with no git branch, such as a CLI deploy). A project missing here has none. + */ + deployments?: Readonly>>; + /** Project ids whose deployments call answers with this HTTP status instead of a list (e.g. 403 for a token that cannot see deployments). */ + deploymentsStatus?: Readonly>; + /** Per-id decrypted values, keyed by env record id. A missing key = Vercel returned no `value`. */ + values?: Readonly>; + /** Env record ids whose per-id answer says `decrypted: false` (Vercel would not decrypt the value, e.g. a role that cannot read production values). */ + undecrypted?: readonly string[]; + /** Env record ids whose per-id value route answers with this HTTP status instead of a value (e.g. 404 or 500). */ + valueStatus?: Readonly>; + /** Project list page size, to exercise pagination. */ + pageSize?: number; + /** Set `pagination.next` on the env list, to exercise the truncation warning. */ + envListNext?: number; +} + +export interface FakeCall { + method: string; + url: string; + authorization?: string; +} + +/** + * Wrap a fake in `bun:test`'s `mock()` and read the calls back with this: the + * recording lives inside the test library's own mock object, so the tests + * never push into an array of their own. + */ +export function callsOf(fetchMock: { mock: { calls: ReadonlyArray } }): readonly FakeCall[] { + return fetchMock.mock.calls.map(([url, init]) => ({ + method: (init as { method: string }).method, + url: url as string, + authorization: (init as { headers: Record }).headers.Authorization, + })); +} + +const json = (body: unknown, status = 200) => ({ + status, + ok: status >= 200 && status < 300, + headers: { get: () => null }, + text: async () => JSON.stringify(body), +}); + +export function fakeVercelFetch(data: FakeVercelData): VercelFetchLike { + return (async (url: string, init: { method: string; headers: Record }) => { + if (init.method !== 'GET') throw new Error(`fakeVercel: unexpected non-GET ${init.method} ${url}`); + const u = new URL(url); + const teamId = u.searchParams.get('teamId') ?? undefined; + const path = u.pathname; + + if (path === '/v2/teams') { + return json({ teams: data.teams ?? [], pagination: { count: (data.teams ?? []).length, next: null, prev: null } }); + } + if (path === '/v10/projects') { + const all = data.projects[teamId ?? 'personal'] ?? []; + const size = data.pageSize ?? all.length; + const start = Number(u.searchParams.get('from') ?? '0'); + const page = all.slice(start, start + size); + const next = start + size < all.length ? start + size : null; + return json({ projects: page, pagination: { count: page.length, next, prev: null } }); + } + const projectRoute = /^\/v9\/projects\/([^/]+)$/.exec(path); + if (projectRoute) { + const idOrName = decodeURIComponent(projectRoute[1]); + const hit = (data.projects[teamId ?? 'personal'] ?? []).find((p) => p.id === idOrName || p.name === idOrName); + return hit ? json(hit) : json({ error: { code: 'not_found', message: 'nope' } }, 404); + } + const customRoute = /^\/v9\/projects\/([^/]+)\/custom-environments$/.exec(path); + if (customRoute) { + return json({ accountLimit: { total: 3 }, environments: data.custom?.[decodeURIComponent(customRoute[1])] ?? [] }); + } + const envListRoute = /^\/v10\/projects\/([^/]+)\/env$/.exec(path); + if (envListRoute) { + const records = data.envs[decodeURIComponent(envListRoute[1])] ?? []; + return json({ + envs: records.map(({ listValue, ...rec }) => ({ + ...rec, + ...(rec.target === undefined ? {} : { target: rec.target }), + ...(listValue === undefined ? {} : { value: listValue }), + decrypted: false, + })), + ...(data.envListNext === undefined ? {} : { pagination: { count: records.length, next: data.envListNext, prev: null } }), + }); + } + if (path === '/v6/deployments') { + const projectId = u.searchParams.get('projectId') ?? ''; + // The route is asked for preview deployments only, with a limit. + if (u.searchParams.get('target') !== 'preview' || !u.searchParams.get('limit')) { + return json({ error: { code: 'bad_request', message: 'target=preview and limit are required' } }, 400); + } + const forcedStatus = data.deploymentsStatus?.[projectId]; + if (forcedStatus !== undefined) return json({ error: { code: 'forced', message: 'forced' } }, forcedStatus); + const limit = Number(u.searchParams.get('limit')); + return json({ + deployments: (data.deployments?.[projectId] ?? []).slice(0, limit).map((d, i) => ({ + uid: `dpl_${projectId}_${i}`, + meta: d.branch === null ? {} : { githubCommitRef: d.branch }, + })), + pagination: { count: 0, next: null, prev: null }, + }); + } + const envValueRoute = /^\/v1\/projects\/([^/]+)\/env\/([^/]+)$/.exec(path); + if (envValueRoute) { + const projectId = decodeURIComponent(envValueRoute[1]); + const envId = decodeURIComponent(envValueRoute[2]); + const forcedStatus = data.valueStatus?.[envId]; + if (forcedStatus !== undefined) return json({ error: { code: 'forced', message: 'forced' } }, forcedStatus); + const rec = (data.envs[projectId] ?? []).find((e) => e.id === envId); + if (!rec) return json({ error: { code: 'not_found', message: 'nope' } }, 404); + const value = data.values?.[envId]; + const { listValue: _omit, ...meta } = rec; + return json({ ...meta, decrypted: !(data.undecrypted ?? []).includes(envId), ...(value === undefined ? {} : { value }) }); + } + throw new Error(`fakeVercel: unexpected path ${path}`); + }) as VercelFetchLike; +} + +/** A project row as Vercel returns it (only the fields the connector reads). */ +export const project = ( + id: string, + name: string, + over: { rootDirectory?: string | null; link?: Record | null } = {}, +): Record => ({ + id, + name, + rootDirectory: over.rootDirectory === undefined ? null : over.rootDirectory, + ...(over.link === null ? {} : { link: over.link ?? { type: 'github', org: 'acme', repo: 'widgets', productionBranch: 'main' } }), +}); diff --git a/tests/helpers/testSandbox.ts b/tests/helpers/testSandbox.ts new file mode 100644 index 00000000..34d01310 --- /dev/null +++ b/tests/helpers/testSandbox.ts @@ -0,0 +1,99 @@ +/** + * Process-level sandboxing for the connector tests. The few unavoidable + * writes to Node's own globals (`process.env`, `process.stdin.isTTY`, + * `process.exitCode`) live HERE, behind one function each, and every one is + * restored in a `finally` — so no test file touches a global or keeps a + * mutable binding of its own. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spyOn } from 'bun:test'; + +/** A throwaway directory (symlinks resolved), removed afterwards. */ +export async function withTempDir(prefix: string, fn: (dir: string) => Promise): Promise { + const dir = realpathSync(mkdtempSync(join(tmpdir(), `${prefix}-`))); + try { + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Points Capy's global config dir (`~/.capy` equivalent: session, wrapped + * master key, keep cache) at a throwaway name for the duration, so a test + * never reads or writes the developer's real one. + */ +export async function withIsolatedGlobalDir(fn: () => Promise): Promise { + const name = `.capy-test-vercel-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e9)}`; + const saved = process.env.CAPY_GLOBAL_DIR_NAME; + process.env.CAPY_GLOBAL_DIR_NAME = name; + try { + return await fn(); + } finally { + if (saved === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = saved; + rmSync(join(homedir(), name), { recursive: true, force: true }); + } +} + +/** + * `isInteractive()` also requires a real `process.stdin.isTTY`, which + * `bun test` never has — `nonTty: false` alone cannot reach the interactive + * branches. + */ +export async function withTTY(fn: () => Promise): Promise { + const saved = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + return await fn(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: saved, configurable: true }); + } +} + +/** A refusal sets `process.exitCode = 1` (right for the CLI); restore it so the test process itself still exits 0. */ +export async function withExitCodeRestored(fn: () => Promise): Promise { + const saved = process.exitCode; + try { + return await fn(); + } finally { + process.exitCode = saved ?? 0; + } +} + +/** + * Runs `fn` with `console.log` / `console.error` captured. Returns what each + * printed, one string per call — the spies' own call records, so nothing is + * pushed into a test-owned array. + */ +export async function captureConsole(fn: () => Promise): Promise<{ result: T; stdout: readonly string[]; stderr: readonly string[] }> { + const logSpy = spyOn(console, 'log').mockImplementation((() => {}) as never); + const errSpy = spyOn(console, 'error').mockImplementation((() => {}) as never); + try { + const result = await fn(); + const flat = (calls: ReadonlyArray): readonly string[] => calls.map((a) => a.map(String).join(' ')); + return { result, stdout: flat(logSpy.mock.calls), stderr: flat(errSpy.mock.calls) }; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + } +} + +export function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }); +} + +/** A git repo with one commit and (optionally) an `origin`. */ +export function initRepo(dir: string, remoteUrl?: string): void { + mkdirSync(dir, { recursive: true }); + git(dir, ['init', '-q']); + git(dir, ['config', 'user.email', 'test@example.com']); + git(dir, ['config', 'user.name', 'Test']); + if (remoteUrl) git(dir, ['remote', 'add', 'origin', remoteUrl]); + writeFileSync(join(dir, 'README.md'), 'x'); + git(dir, ['add', '.']); + git(dir, ['commit', '-q', '-m', 'init']); +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh index ef59e404..69b555fd 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -65,6 +65,7 @@ ISOLATED_FILES=( tests/commands/rotatePromotesThenRotates.test.ts tests/commands/rotateRefusals.test.ts tests/commands/removeCommand.test.ts + tests/commands/editNamePrompt.test.ts tests/commands/connectDokploy.test.ts tests/commands/gateFirst.test.ts tests/commands/bootstrapNoSecrets.test.ts @@ -80,6 +81,11 @@ ISOLATED_FILES=( tests/commands/agentsCommand.test.ts tests/commands/transportCommand.test.ts tests/commands/pairCommand.test.ts + tests/commands/deployVercelApiMode.test.ts + tests/deploy/vercelApiModeTokenStore.test.ts + tests/commands/systemSetKeyHelp.test.ts + tests/commands/deployVercelSetupPicker.test.ts + tests/deploy/vercelSetupTokenStore.test.ts # Reads process.stdout.isTTY/columns/rows directly; on CI's Linux runner an # earlier batch file left isTTY unassignable ("readonly property"). tests/ui/terminalQr.test.ts diff --git a/tests/sync/emptyEnvBlob.test.ts b/tests/sync/emptyEnvBlob.test.ts new file mode 100644 index 00000000..a4e14b53 --- /dev/null +++ b/tests/sync/emptyEnvBlob.test.ts @@ -0,0 +1,144 @@ +/** + * A branch with no variables must still save. + * + * The service answers 400 to an empty `keep_file` or `env_blob` (`!env_blob`). A branch can have no variable and still + * need a save: an import whose names are all placeholders (keep.lock holds them, the blob holds no line), or a + * `--overwrite` that clears the last name. The blob used to be '' then, and the save was refused. + * + * On the in-memory server of tests/helpers/fakeCapyServer.ts, which refuses an empty payload the way the service does: + * - an empty branch and a placeholder-only branch push a non-empty `env_blob` and a non-empty, valid `keep_file`; + * - the pushed state reads back as no variables, without an error, by every reader the CLI has; + * - encrypting an empty value gives a non-empty ciphertext that decrypts to empty (it is not what the blob holds, and why). + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'; +import { rmSync } from 'fs'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { writeImportedAndSync, type ResolvedContext } from '../../src/commands/connectors/shared'; +import { placeholdersOnBranch } from '../../src/core/placeholder'; +import { FileManager } from '../../src/files/fileManager'; +import { EMPTY_ENV_BLOB, commitFolder, envBlobOf, readSnapshot } from '../../src/sync/commit'; +import type { KeepFile } from '../../src/types/index'; +import { fakeCapyServer, seedServer, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const PREVIEW = 'preview'; +const MARKER = { provider: 'vercel', reason: 'sensitive_unreadable' } as const; +/** `preview` holds two values; `production` holds one. */ +const SEED = seedServer({ projectKey: KEY, values: { production: { X: 'x-prod' }, [PREVIEW]: { X: 'x-1', Y: 'y-1' } } }); +/** Only `production` exists on the server: `preview` is a branch with nothing saved yet. */ +const SEED_PRODUCTION_ONLY = seedServer({ projectKey: KEY, values: { production: { X: 'x-prod' } } }); + +const globalDirName = `.capy-test-empty-blob-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +const made = mock((_: Folder) => undefined); +afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + +/** A folder on `preview` holding `env`, and a server that already holds `seed`. */ +function world(env: Record, seed = SEED) { + const server = fakeCapyServer({ initial: seed }); + const folder = makeFolder({ projectKey: KEY, branch: PREVIEW, keep: seed.keep as KeepFile, env, client: server.client }); + made(folder); + return { server, folder }; +} + +const asContext = (folder: Folder, server: ReturnType): ResolvedContext => + ({ ...folder.ctx, serviceClient: server.client, keep: folder.keep(), localPlaintext: folder.env() }) as unknown as ResolvedContext; + +describe('the blob of a branch with no variables', () => { + test('is a non-empty comment line, and an empty set of lines is the only thing that makes it', () => { + expect(EMPTY_ENV_BLOB.length).toBeGreaterThan(0); + expect(envBlobOf({})).toBe(EMPTY_ENV_BLOB); + expect(envBlobOf({ A: 'capy:r1:abc', B: 'capy:r2:def' })).toBe('A=capy:r1:abc\nB=capy:r2:def'); + }); + + test('reads back as no variables by every reader: dotenv parse, the folder\'s own parse, and a line parser that skips what is not NAME=value', () => { + expect(new FileManager().parseEnvContent(EMPTY_ENV_BLOB)).toEqual({}); + const lineParsed = EMPTY_ENV_BLOB.split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith('#') && l.includes('=')); + expect(lineParsed).toEqual([]); + }); + + test('encrypting an empty value gives a non-empty ciphertext that decrypts to empty (the blob still cannot be one: it has no NAME to hang that line on)', () => { + const cipher = Encryptor.encrypt('', KEY); + expect(cipher.length).toBeGreaterThan(0); + expect(Encryptor.decrypt(cipher, KEY)).toBe(''); + }); +}); + +describe('an empty branch saves (clearing the last name)', () => { + test('pushes a non-empty env_blob and a valid keep_file; the server accepts it and the branch reads back empty', async () => { + const w = world({ X: 'x-1', Y: 'y-1' }); + await commitFolder(w.folder.ctx, { set: {}, remove: ['X', 'Y'] }); + + const pushed = w.server.accepted(); + expect(pushed).toHaveLength(1); + expect(pushed[0].envBlob).toBe(EMPTY_ENV_BLOB); + expect(pushed[0].keepFile.length).toBeGreaterThan(0); + const keep = JSON.parse(pushed[0].keepFile) as KeepFile; + expect(keep.project_id).toBe('proj'); + expect(Object.values(keep.variables).flat().filter((e) => e.branch === PREVIEW)).toEqual([]); + expect(Object.values(keep.variables).flat().filter((e) => e.branch === 'production')).toHaveLength(1); // the other branch is untouched + + expect(serverValues(w.server.state(), PREVIEW, KEY)).toEqual({}); + expect(w.folder.env()).toEqual({}); + }); + + test('round-trips: the next read of the branch decrypts to an empty env with no error, and a later write on it works', async () => { + const w = world({ X: 'x-1', Y: 'y-1' }); + await commitFolder(w.folder.ctx, { set: {}, remove: ['X', 'Y'] }); + + const snapshot = await readSnapshot(w.server.client, 'proj', PREVIEW, KEY); + expect(snapshot.entries).toEqual({}); + expect(new FileManager(w.folder.dir).readEncryptedEnvFile(KEY)).toEqual({}); + + await commitFolder({ ...w.folder.ctx, keep: w.folder.keep(), localPlaintext: w.folder.env() }, { set: { Z: 'z-1' }, remove: [] }); + expect(serverValues(w.server.state(), PREVIEW, KEY)).toEqual({ Z: 'z-1' }); + }); +}); + +describe('a placeholder-only branch saves', () => { + test('pushes a non-empty env_blob and a keep_file carrying the placeholder; the server accepts it', async () => { + const w = world({}, SEED_PRODUCTION_ONLY); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'HIDDEN', marker: MARKER }] }); + + const pushed = w.server.accepted(); + expect(pushed).toHaveLength(1); + expect(pushed[0].envBlob).toBe(EMPTY_ENV_BLOB); + const keep = JSON.parse(pushed[0].keepFile) as KeepFile; + expect(placeholdersOnBranch(keep, PREVIEW).map((p) => [p.name, p.marker])).toEqual([['HIDDEN', MARKER]]); + + expect(serverValues(w.server.state(), PREVIEW, KEY)).toEqual({}); + expect(placeholdersOnBranch(w.server.state().keep, PREVIEW).map((p) => p.name)).toEqual(['HIDDEN']); + }); + + test('round-trips: the folder and a fresh read both say no variables and one placeholder, with no error', async () => { + const w = world({}, SEED_PRODUCTION_ONLY); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'HIDDEN', marker: MARKER }] }); + + const snapshot = await readSnapshot(w.server.client, 'proj', PREVIEW, KEY); + expect(snapshot.entries).toEqual({}); + expect(placeholdersOnBranch(snapshot.keep, PREVIEW).map((p) => p.name)).toEqual(['HIDDEN']); + expect(w.folder.env()).toEqual({}); + expect(placeholdersOnBranch(w.folder.keep(), PREVIEW).map((p) => p.name)).toEqual(['HIDDEN']); + }); + + test('an empty set of values with an unrelated placeholder still keeps the branch\'s other values out of the blob (no stand-in line)', async () => { + const w = world({}, SEED_PRODUCTION_ONLY); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'HIDDEN', marker: MARKER }] }); + const blob = w.server.accepted()[0].envBlob; + expect(blob).not.toContain('HIDDEN'); + expect(blob.split('\n').filter((l) => l.includes('='))).toEqual([]); + }); +}); diff --git a/tests/sync/placeholders.test.ts b/tests/sync/placeholders.test.ts new file mode 100644 index 00000000..9818d4d9 --- /dev/null +++ b/tests/sync/placeholders.test.ts @@ -0,0 +1,258 @@ +/** + * Placeholders in keep.lock: a name on a branch with NO value yet (an import could not read it). + * + * The properties, on an in-memory server (tests/helpers/fakeCapyServer.ts) and a real folder on disk: + * - a placeholder is a typed flag on the branch's keep entry with an empty hash; the blob holds no line for it, + * and no value or stand-in string appears anywhere; + * - it round-trips: server keep, the folder's keep.lock, and a fresh read of the folder all say the same; + * - `.env` gets one comment line for it (idempotent, layout preserved), and no variable; + * - sync does not treat it as pinned, missing or conflicting, and rebuilding the branch's pins keeps it; + * - writing a real value by any path (`capy edit`, `capy add --force`, the editor screen) removes the flag and the note. + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'; +import { readFileSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { saveEdits } from '../../src/commands/editCommand'; +import { writeAndSync, writeImportedAndSync, type ResolvedContext } from '../../src/commands/connectors/shared'; +import { classifyPipedWrite, variableExists } from '../../src/commands/pipedWrite'; +import { placeholderEnvNote, placeholderOf, placeholdersOnBranch } from '../../src/core/placeholder'; +import { upsertEnvText } from '../../src/files/envUpsert'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { commitFolder, keepForEnv, pinnedHashes } from '../../src/sync/commit'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import type { KeepFile } from '../../src/types/index'; +import { fakeCapyServer, seedServer, serverLine, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const DEV = 'development'; +const MARKER = { provider: 'vercel', reason: 'sensitive_unreadable' } as const; +const SEED = seedServer({ projectKey: KEY, values: { [DEV]: { X: 'x-1', Y: 'y-1' }, production: { X: 'x-prod' } } }); +const NOTE = placeholderEnvNote('SECRET_KEY', MARKER); + +const globalDirName = `.capy-test-placeholders-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +const made = mock((_: Folder) => undefined); +afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + +function world() { + const server = fakeCapyServer({ initial: SEED }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: SEED.keep as KeepFile, env: { X: 'x-1', Y: 'y-1' }, client: server.client }); + made(folder); + return { server, folder }; +} + +/** The folder as a connector's write sees it. */ +const asContext = (folder: Folder, server: ReturnType): ResolvedContext => + ({ ...folder.ctx, serviceClient: server.client, keep: folder.keep(), localPlaintext: folder.env() }) as unknown as ResolvedContext; + +const envText = (folder: Folder): string => readFileSync(join(folder.dir, '.env'), 'utf-8'); +const noteLines = (text: string): readonly string[] => text.split('\n').filter((l) => l === NOTE); + +/** Records SECRET_KEY as a placeholder on the folder's branch, pushed. */ +async function importPlaceholder(w: ReturnType): Promise { + await writeImportedAndSync(asContext(w.folder, w.server), [], { + push: true, + placeholders: [{ name: 'SECRET_KEY', marker: MARKER }], + }); +} + +describe('recording a placeholder', () => { + test('is a typed flag with an empty hash on the branch entry; the blob has no line for it; nothing else moves', async () => { + const w = world(); + const before = w.server.state(); + await importPlaceholder(w); + const after = w.server.state(); + + const entry = after.keep?.variables.SECRET_KEY?.find((e) => e.branch === DEV); + expect(entry).toMatchObject({ branch: DEV, value_hash: '', placeholder: MARKER }); + expect(entry).not.toHaveProperty('connector'); + expect(serverValues(after, DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-1' }); + expect(serverLine(after, DEV, 'SECRET_KEY')).toBeUndefined(); + expect(serverLine(after, DEV, 'X')).toBe(serverLine(before, DEV, 'X')); + const entryOf = (state: typeof after, branch: string) => state.keep?.variables.X?.find((e) => e.branch === branch); + expect(entryOf(after, DEV)?.value_hash).toBe(entryOf(before, DEV)?.value_hash); + expect(entryOf(after, 'production')).toEqual(entryOf(before, 'production')); + }); + + test('round-trips: the folder\'s keep.lock equals the server\'s branch slice, and a fresh read still has the flag', async () => { + const w = world(); + await importPlaceholder(w); + const onServer = w.server.state().keep?.variables.SECRET_KEY?.find((e) => e.branch === DEV); + const onDisk = w.folder.keep().variables.SECRET_KEY?.find((e) => e.branch === DEV); + expect(onDisk).toEqual(onServer); + expect(placeholderOf(w.folder.pm.readKeepFile(), 'SECRET_KEY', DEV)).toEqual(MARKER); + expect(placeholdersOnBranch(w.folder.keep(), DEV).map((p) => p.name)).toEqual(['SECRET_KEY']); + }); + + test('never writes a value or a stand-in string: not in keep.lock, not in .env, not on the server', async () => { + const w = world(); + await importPlaceholder(w); + const everything = [ + readFileSync(join(w.folder.dir, '.capy', 'keep.lock'), 'utf-8'), + envText(w.folder), + JSON.stringify(w.server.state()), + ].join('\n'); + expect(everything).not.toMatch(/\[vercel-sensitive-value\]|\[.*sensitive.*value.*\]/i); + expect(w.folder.env()).toEqual({ X: 'x-1', Y: 'y-1' }); + }); + + test('.env gets exactly one comment line and no variable; a second import writes nothing and changes nothing', async () => { + const w = world(); + await importPlaceholder(w); + const first = envText(w.folder); + expect(noteLines(first)).toHaveLength(1); + expect(first).not.toMatch(/^SECRET_KEY=/m); + const pushesAfterFirst = w.server.tape.mock.calls.length; + + await importPlaceholder(w); + // (A value line is re-encrypted on every write, so its ciphertext differs; the shape and the rest do not.) + const shape = (text: string): string => text.replace(/=capy:([a-z0-9]+):[^\n]*/g, '=capy:$1:*'); + expect(shape(envText(w.folder))).toBe(shape(first)); + expect(w.server.tape.mock.calls.length).toBe(pushesAfterFirst); + }); + + test('a name that already has a value on the branch keeps it: the flag is never put over a value', async () => { + const w = world(); + await writeImportedAndSync(asContext(w.folder, w.server), [], { push: true, placeholders: [{ name: 'X', marker: MARKER }] }); + expect(placeholderOf(w.server.state().keep, 'X', DEV)).toBeUndefined(); + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-1'); + expect(w.server.state().keep?.variables.X?.find((e) => e.branch === DEV)?.value_hash).toBe(hashValue('x-1')); + }); + + test('the same name on another branch is its own entry: production keeps its value', async () => { + const w = world(); + await importPlaceholder(w); + expect(placeholderOf(w.server.state().keep, 'SECRET_KEY', 'production')).toBeUndefined(); + expect(w.server.state().keep?.variables.X?.find((e) => e.branch === 'production')?.value_hash).toBe(hashValue('x-prod')); + }); +}); + +describe('.env layout', () => { + const existing = ['# my notes', '', 'A=1', '', '# --- infra ---', 'B=2 # keep', ''].join('\n'); + + test('the note is appended once; every other line is byte for byte as it was, in the same order', () => { + const out = upsertEnvText(existing, {}, [['A', '1'], ['B', '2']], { ensure: [NOTE], drop: [] }); + expect(out.startsWith(existing)).toBe(true); + expect(out.slice(existing.length)).toBe(`${NOTE}\n`); + expect(upsertEnvText(out, {}, [['A', '1'], ['B', '2']], { ensure: [NOTE], drop: [] })).toBe(out); + }); + + test('a note that is already somewhere stays where it is (no duplicate, no move)', () => { + const text = ['A=1', NOTE, 'B=2', ''].join('\n'); + expect(upsertEnvText(text, {}, [['A', '1'], ['B', '2']], { ensure: [NOTE], drop: [] })).toBe(text); + }); + + test('a note to drop is removed and nothing else is', () => { + const text = ['A=1', NOTE, 'B=2', ''].join('\n'); + expect(upsertEnvText(text, {}, [['A', '1'], ['B', '2']], { ensure: [], drop: [NOTE] })).toBe('A=1\nB=2\n'); + }); + + test('a real file keeps the person\'s comments and order through an import', async () => { + const w = world(); + const before = envText(w.folder); + writeFileSync(join(w.folder.dir, '.env'), `# my notes\n${before}`); + await importPlaceholder(w); + const after = envText(w.folder); + expect(after.startsWith('# my notes\n')).toBe(true); + expect(noteLines(after)).toHaveLength(1); + expect(after.indexOf('X=')).toBeLessThan(after.indexOf('Y=')); + }); +}); + +describe('sync does not see a placeholder as a value', () => { + test('it is not pinned, so it is never compared, restored or dropped with the pins', async () => { + const w = world(); + await importPlaceholder(w); + expect(Object.keys(pinnedHashes(w.folder.keep(), DEV)).sort()).toEqual(['X', 'Y']); + expect(pinnedHashes(w.folder.keep(), DEV)).not.toHaveProperty('SECRET_KEY'); + }); + + test('rebuilding the branch\'s pins from an env (retrieve / row-by-row) keeps the placeholder', async () => { + const w = world(); + await importPlaceholder(w); + const rebuilt = keepForEnv(w.folder.keep(), DEV, { X: 'x-1' }); + expect(placeholderOf(rebuilt, 'SECRET_KEY', DEV)).toEqual(MARKER); + expect(rebuilt.variables.Y).toBeUndefined(); + }); + + test('committing the folder\'s values (bare `capy`, commit local) leaves the placeholder alone', async () => { + const w = world(); + await importPlaceholder(w); + await commitFolder({ ...asContext(w.folder, w.server) }, { set: { Y: 'y-2' }, remove: [] }); + expect(placeholderOf(w.server.state().keep, 'SECRET_KEY', DEV)).toEqual(MARKER); + expect(noteLines(envText(w.folder))).toHaveLength(1); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-2' }); + }); + + test('the keep hash agrees between client and the server\'s copy (the flag does not perturb it)', async () => { + const w = world(); + await importPlaceholder(w); + expect(SyncEngine.computeKeepHash(w.folder.keep(), DEV)).toBe(SyncEngine.computeKeepHash(w.server.state().keep as KeepFile, DEV)); + }); + + test('capy edit NAME / capy add NAME see it as existing (so --force is what writes it)', async () => { + const w = world(); + await importPlaceholder(w); + const ctx = asContext(w.folder, w.server); + expect(variableExists(ctx, 'SECRET_KEY')).toBe(true); + expect(classifyPipedWrite(ctx, 'SECRET_KEY', 'v')).toBe('updated'); + }); +}); + +describe('a real value clears it', () => { + const cleared = (w: ReturnType, value: string) => { + const server = w.server.state(); + expect(placeholderOf(server.keep, 'SECRET_KEY', DEV)).toBeUndefined(); + expect(server.keep?.variables.SECRET_KEY?.find((e) => e.branch === DEV)).toMatchObject({ value_hash: hashValue(value) }); + expect(placeholderOf(w.folder.keep(), 'SECRET_KEY', DEV)).toBeUndefined(); + expect(serverValues(server, DEV, KEY).SECRET_KEY).toBe(value); + expect(w.folder.env().SECRET_KEY).toBe(value); + expect(noteLines(envText(w.folder))).toHaveLength(0); + }; + + test('capy edit NAME (piped), capy edit NAME (prompt) and capy add NAME --force: the single-variable save', async () => { + const w = world(); + await importPlaceholder(w); + await writeAndSync(asContext(w.folder, w.server), 'SECRET_KEY', 'real-value-1', { push: true }); + cleared(w, 'real-value-1'); + }); + + test('the editor screen\'s save', async () => { + const w = world(); + await importPlaceholder(w); + const { keep: _keep, localPlaintext: _local, ...folder } = asContext(w.folder, w.server); + await saveEdits(folder, w.folder.keep(), { SECRET_KEY: 'real-value-2' }, { record: () => undefined }); + cleared(w, 'real-value-2'); + }); + + test('without pushing (--no-push): the folder\'s keep.lock and .env are cleared too', async () => { + const w = world(); + await importPlaceholder(w); + await writeAndSync({ ...asContext(w.folder, w.server) }, 'SECRET_KEY', 'real-value-3', { push: false }); + expect(placeholderOf(w.folder.keep(), 'SECRET_KEY', DEV)).toBeUndefined(); + expect(w.folder.env().SECRET_KEY).toBe('real-value-3'); + expect(noteLines(envText(w.folder))).toHaveLength(0); + }); + + test('removing the name (capy remove) drops the entry and the note', async () => { + const w = world(); + await importPlaceholder(w); + await commitFolder(asContext(w.folder, w.server), { set: {}, remove: ['SECRET_KEY'] }); + expect(w.server.state().keep?.variables.SECRET_KEY).toBeUndefined(); + expect(w.folder.keep().variables.SECRET_KEY).toBeUndefined(); + expect(noteLines(envText(w.folder))).toHaveLength(0); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-1' }); + }); +}); diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index b0e26edb..4e709b3a 100644 --- a/tests/system/systemStore.test.ts +++ b/tests/system/systemStore.test.ts @@ -6,7 +6,7 @@ * fake service, rather than checking that a function merely returned * something. Maps to docs/org-system-store.md's numbered Proofs where noted. */ -import { mock, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mock, spyOn, describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; import { mkdtempSync, rmSync, readdirSync, statSync, readFileSync } from 'fs'; import { join } from 'path'; import { randomBytes } from 'crypto'; @@ -141,6 +141,7 @@ afterAll(() => { let openSystemStore: typeof import('../../src/system/systemStore').openSystemStore; let getConnectorSecret: typeof import('../../src/system/systemStore').getConnectorSecret; let getDirectionalConnectorSecret: typeof import('../../src/system/systemStore').getDirectionalConnectorSecret; +let replaceConnectorSecret: typeof import('../../src/system/systemStore').replaceConnectorSecret; let assertValidConnectorName: typeof import('../../src/system/systemStore').assertValidConnectorName; let wrapAndSaveMasterKey: typeof import('../../src/crypto/keyResolver').wrapAndSaveMasterKey; let resolveProjectKey: typeof import('../../src/crypto/keyResolver').resolveProjectKey; @@ -157,6 +158,7 @@ beforeAll(async () => { openSystemStore = ss.openSystemStore; getConnectorSecret = ss.getConnectorSecret; getDirectionalConnectorSecret = ss.getDirectionalConnectorSecret; + replaceConnectorSecret = ss.replaceConnectorSecret; assertValidConnectorName = ss.assertValidConnectorName; const kr = await import('../../src/crypto/keyResolver'); @@ -439,6 +441,113 @@ describe('systemStore', () => { }); }); + // ── Key help before the hidden prompt ── + describe('key help (hidden-prompt path)', () => { + /** Runs `fn` with console.error captured; returns what it printed. */ + const stderrOf = async (fn: () => Promise): Promise => { + const spy = spyOn(console, 'error').mockImplementation((() => {}) as never); + try { + await fn(); + return spy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'); + } finally { + spy.mockRestore(); + } + }; + + it('a known key name: the approved instructions end "Then paste it here." (stderr), before the prompt', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })); + expect(text).toContain('Create a token at https://vercel.com/account/settings/tokens. Set Scope to the team that owns the project.'); + expect(text).toContain('Then paste it here.'); + expect(text).not.toContain('typed-secret'); + }); + + it('a known Vercel key name with a team slug: the slug is named', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true, helpTeam: 'acme' })); + expect(text).toContain('Set Scope to the team that owns the project (acme).'); + }); + + it('a known Dokploy key name with a base URL: the link is that Dokploy\'s page', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => + getConnectorSecret('_CONNECTOR_DOKPLOY_API_KEY', { orgId: ORG_A, devMode: true, interactive: true, helpBaseUrl: 'https://d.example.test' }), + ); + expect(text).toContain('https://d.example.test/dashboard/settings/profile'); + }); + + it('any other name: nothing is printed', async () => { + promptQueue.push({ value: 'typed-secret' }); + const text = await stderrOf(() => getConnectorSecret('_CONNECTOR_FOO_KEY', { orgId: ORG_A, devMode: true, interactive: true })); + expect(text).toBe(''); + }); + }); + + // ── The saved confirmation after the hidden prompt ── + describe('saved confirmation (hidden-prompt path)', () => { + /** Runs `fn` with stderr writes captured; returns what was written. */ + const stderrWritesOf = async (fn: () => Promise): Promise => { + const spy = spyOn(process.stderr, 'write').mockImplementation((() => true) as never); + try { + await fn(); + return spy.mock.calls.map((a) => String(a[0])).join(''); + } finally { + spy.mockRestore(); + } + }; + const SAVED = 'Saved _CONNECTOR_VERCEL_TOKEN in the org system store.\n'; + + it('getConnectorSecret: one line after the save, naming the secret and never its value', async () => { + promptQueue.push({ value: 'typed-secret-value' }); + const text = await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })); + expect(text).toBe(SAVED); + expect(text).not.toContain('typed-secret-value'); + }); + + it('getDirectionalConnectorSecret: the same single line', async () => { + promptQueue.push({ value: 'typed-secret-value' }); + const text = await stderrWritesOf(() => + getDirectionalConnectorSecret('_CONNECTOR_VERCEL_TOKEN', '_TARGET_VERCEL_TOKEN', { + orgId: ORG_A, + devMode: true, + interactive: true, + missingWithFallbackCode: 'X', + }), + ); + expect(text).toBe(SAVED); + }); + + it('an empty answer saves nothing and says nothing; an existing entry and a non-interactive call say nothing', async () => { + promptQueue.push({ value: ' ' }); + expect(await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true }))).toBe(''); + expect(await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: false }))).toBe(''); + const store = await openSystemStore({ orgId: ORG_A, devMode: true }); + await store.set('_CONNECTOR_VERCEL_TOKEN', 'present'); + expect(await stderrWritesOf(() => getConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true }))).toBe(''); + }); + + it('replaceConnectorSecret: prompts again over a saved value, replaces it, and confirms once', async () => { + const store = await openSystemStore({ orgId: ORG_A, devMode: true }); + await store.set('_CONNECTOR_VERCEL_TOKEN', 'old-value'); + promptQueue.push({ value: 'new-value' }); + const text = await stderrWritesOf(async () => { + expect(await replaceConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })).toBe('new-value'); + }); + expect(text).toBe(SAVED); + expect(promptedWith).toHaveLength(1); + expect(promptedWith[0][0].type).toBe('password'); + expect((await openSystemStore({ orgId: ORG_A, devMode: true })).get('_CONNECTOR_VERCEL_TOKEN')).toBe('new-value'); + }); + + it('replaceConnectorSecret: an empty answer keeps the old value', async () => { + const store = await openSystemStore({ orgId: ORG_A, devMode: true }); + await store.set('_CONNECTOR_VERCEL_TOKEN', 'old-value'); + promptQueue.push({ value: '' }); + expect(await replaceConnectorSecret('_CONNECTOR_VERCEL_TOKEN', { orgId: ORG_A, devMode: true, interactive: true })).toBeNull(); + expect((await openSystemStore({ orgId: ORG_A, devMode: true })).get('_CONNECTOR_VERCEL_TOKEN')).toBe('old-value'); + }); + }); + // ── CAP-679 follow-up: `getDirectionalConnectorSecret` — deploy's // `_TARGET_DOKPLOY_API_KEY` borrowing from import's // `_CONNECTOR_DOKPLOY_API_KEY` (or the reverse), and the one-level diff --git a/tests/ui/secretsScreen.test.ts b/tests/ui/secretsScreen.test.ts index 6602a4b1..270f5442 100644 --- a/tests/ui/secretsScreen.test.ts +++ b/tests/ui/secretsScreen.test.ts @@ -10,6 +10,7 @@ import { buildLocationTable, filteredRows, filteredRowsWithReasons, + sortRows, formatUsersCell, formatBranchCell, formatProjectCell, @@ -575,7 +576,7 @@ describe('search bar — always-on, fzf-style (no "/" mode)', () => { const s = pressKeys(type(initialSecretsScreenState(rows), 'db'), ESC); expect(s.search.query).toBe(''); expect(s.quit).toBe(false); - expect(filteredRows(s)).toEqual(rows); + expect(filteredRows(s)).toEqual(sortRows(rows, 'name-asc')); // CAP-731: the default sort is name A-Z, no longer arrival order }); test('Esc with an already-empty query quits', () => { @@ -1197,3 +1198,16 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po expect(stripAnsiForTest(frame)).not.toMatch(/\[[a-z-]+\]/); // no `[provider]` label anywhere }); }); + +describe('a row with no value yet', () => { + test('the list says "no value yet" beside the name; a row with a value does not', () => { + const frame = stripAnsiForTest(render(initialSecretsScreenState([row({ name: 'EMPTY', value_hash: '' }), row({ name: 'FULL', value_hash: 'abc' })]), 100, 20)); + expect(frame.split('\n').find((l) => l.includes('EMPTY'))).toContain('no value yet'); + expect(frame.split('\n').find((l) => l.includes('FULL'))).not.toContain('no value yet'); + }); + + test('the details view names the command that sets one, where the value would be', () => { + const frame = stripAnsiForTest(render(handleKey(initialSecretsScreenState([row({ name: 'EMPTY', value_hash: '' })]), ENTER).state, 100, 30)); + expect(frame).toContain('no value yet — set it with: capy edit EMPTY'); + }); +}); diff --git a/tests/ui/secretsScreenSort.test.ts b/tests/ui/secretsScreenSort.test.ts new file mode 100644 index 00000000..63bf483c --- /dev/null +++ b/tests/ui/secretsScreenSort.test.ts @@ -0,0 +1,456 @@ +import { describe, test, expect } from 'bun:test'; +import { + initialSecretsScreenState, + handleKey, + render, + filteredRows, + sortRows, + SORT_ORDER, + SecretsScreenState, + SortMode, +} from '../../src/ui/secretsScreen'; +import type { SecretIndexLocation, SecretIndexRow } from '../../src/service/serviceClient'; + +const ESC = '\x1b'; +const CTRL_S = '\x13'; +const ENTER = '\r'; +const KEY_DOWN = `${ESC}[B`; +const KEY_UP = `${ESC}[A`; +const CTRL_E = '\x05'; +const CTRL_D = '\x04'; +const KEY_TAB = '\t'; + +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*[mK]/g, ''); +} + +function loc(over: Partial = {}): SecretIndexLocation { + return { project_id: 'p1', project_name: 'web', branch: 'production', protected: false, service: null, ...over }; +} + +function row(over: Partial = {}): SecretIndexRow { + return { name: 'API_KEY', value_hash: 'hash1', locations: [loc()], users: [], ...over }; +} + +const target = (stale: boolean) => ({ provider: 'dokploy', target: 'app', stale }); +/** A row whose deploy status kind is `deployed`, `behind`, `unknown` (targets not sent) or `no target`. */ +function withStatus(name: string, kind: 'deployed' | 'behind' | 'unknown' | 'no target', over: Partial = {}): SecretIndexRow { + const location = + kind === 'deployed' ? loc({ targets: [target(false)] }) : kind === 'behind' ? loc({ targets: [target(true)] }) : kind === 'no target' ? loc({ targets: [] }) : loc(); + return row({ name, value_hash: `h-${name}`, locations: [location], ...over }); +} +function withTime(name: string, changedAt: string | undefined): SecretIndexRow { + return row({ name, value_hash: `h-${name}`, locations: [loc(changedAt === undefined ? {} : { changed_at: changedAt })] }); +} + +const press = (state: SecretsScreenState, ...keys: readonly string[]): SecretsScreenState => + keys.reduce((acc: SecretsScreenState, k) => handleKey(acc, k).state, state); +const typed = (state: SecretsScreenState, text: string): SecretsScreenState => press(state, ...text.split('')); +const withSort = (rows: readonly SecretIndexRow[], sort: SortMode): SecretsScreenState => ({ ...initialSecretsScreenState(rows), sort }); +const names = (state: SecretsScreenState): readonly string[] => filteredRows(state).map((r) => r.name); +/** Opens the sort menu, moves down to `mode`, and presses Enter. */ +const choose = (state: SecretsScreenState, mode: SortMode): SecretsScreenState => + press(state, CTRL_S, ...Array.from({ length: (SORT_ORDER.indexOf(mode) - SORT_ORDER.indexOf(state.sort) + SORT_ORDER.length) % SORT_ORDER.length }, () => KEY_DOWN), ENTER); +const cursorName = (state: SecretsScreenState): string | undefined => filteredRows(state)[state.cursorIndex]?.name; + +describe('the sort order', () => { + test('lists the six sorts in order, and the screen starts on name A-Z', () => { + expect(SORT_ORDER).toEqual(['name-asc', 'name-desc', 'status-asc', 'status-desc', 'updated-newest', 'updated-oldest']); + expect(initialSecretsScreenState([]).sort).toBe('name-asc'); + expect(initialSecretsScreenState([]).sortMenu).toBeNull(); + }); +}); + +describe('Ctrl+S opens the sort menu', () => { + const rows = [row({ name: 'B', value_hash: 'b' }), row({ name: 'C', value_hash: 'c' }), row({ name: 'A', value_hash: 'a' })]; + + test('it opens with its cursor on the current sort and changes nothing else', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + expect(open.sortMenu).toEqual({ cursor: 0 }); + expect(open.sort).toBe('name-asc'); + expect(open.search.query).toBe(''); + expect(press(withSort(rows, 'status-desc'), CTRL_S).sortMenu).toEqual({ cursor: 3 }); + }); + + test('Up and Down move in the menu and wrap at both ends', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + expect(press(open, KEY_DOWN).sortMenu).toEqual({ cursor: 1 }); + expect(press(open, KEY_UP).sortMenu).toEqual({ cursor: 5 }); + expect(press(open, ...Array.from({ length: 6 }, () => KEY_DOWN)).sortMenu).toEqual({ cursor: 0 }); + expect(press(open, KEY_DOWN, KEY_DOWN, KEY_UP).sortMenu).toEqual({ cursor: 1 }); + }); + + test('moving in the menu does not change the sort or the list cursor until Enter', () => { + const moved = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, KEY_DOWN, KEY_DOWN); + expect(moved.sort).toBe('name-asc'); + expect(moved.cursorIndex).toBe(1); + expect(names(moved)).toEqual(['A', 'B', 'C']); + }); + + test('Enter applies the highlighted sort, closes the menu, and puts the list cursor at 0', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, KEY_DOWN, CTRL_S, KEY_DOWN, ENTER); + expect(s.sort).toBe('name-desc'); + expect(s.sortMenu).toBeNull(); + expect(s.cursorIndex).toBe(0); + expect(names(s)).toEqual(['C', 'B', 'A']); + }); + + test('Enter on the current sort closes the menu and still goes to the top', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, ENTER); + expect(s.sort).toBe('name-asc'); + expect(s.sortMenu).toBeNull(); + expect(s.cursorIndex).toBe(0); + }); + + test('each of the six sorts can be chosen', () => { + expect(SORT_ORDER.map((mode) => choose(initialSecretsScreenState(rows), mode).sort)).toEqual([...SORT_ORDER]); + }); + + test('Esc closes the menu with no change', () => { + const before = press(initialSecretsScreenState(rows), KEY_DOWN); + expect(press(before, CTRL_S, KEY_DOWN, KEY_DOWN, ESC)).toEqual(before); + }); + + test('a double Esc and `q` also close it with no change', () => { + const before = press(initialSecretsScreenState(rows), KEY_DOWN); + expect(press(before, CTRL_S, KEY_DOWN, `${ESC}${ESC}`)).toEqual(before); + expect(press(before, CTRL_S, KEY_DOWN, 'q')).toEqual(before); + expect(press(before, CTRL_S, KEY_DOWN, 'Q')).toEqual(before); + }); + + test('Esc in the menu does not quit the screen or clear a typed search', () => { + const s = press(typed(initialSecretsScreenState(rows), 'a'), CTRL_S, ESC); + expect(s.quit).toBe(false); + expect(s.search.query).toBe('a'); + }); + + test('letters do not reach the search while the menu is open', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + const s = press(open, 'a', 'b', 'x', ' ', 'j', 'k', '/'); + expect(s).toEqual(open); + expect(s.search.query).toBe(''); + }); + + test('Tab, Shift+Tab, Ctrl+E, Ctrl+D, Ctrl+S, Backspace and paging do nothing in the menu', () => { + const open = press(initialSecretsScreenState(rows), CTRL_S); + [KEY_TAB, `${ESC}[Z`, CTRL_E, CTRL_D, CTRL_S, '\x7f', `${ESC}[5~`, `${ESC}[6~`].forEach((k) => { + const after = handleKey(open, k); + expect(after.state).toEqual(open); + expect(after.effect).toBeNull(); + }); + }); + + test('Ctrl+E and Ctrl+D do nothing in the menu even with the list on a row', () => { + const open = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S); + expect(handleKey(open, CTRL_E)).toEqual({ state: open, effect: null }); + expect(handleKey(open, CTRL_D)).toEqual({ state: open, effect: null }); + }); + + test('Enter in the menu applies the sort and does not open the details', () => { + const after = handleKey(press(initialSecretsScreenState(rows), CTRL_S, KEY_DOWN), ENTER); + expect(after.state.popup).toBeNull(); + expect(after.state.edit).toBeNull(); + expect(after.state.deploy).toBeNull(); + expect(after.effect).toBeNull(); + }); + + test('Ctrl+C still quits from the menu', () => { + expect(press(initialSecretsScreenState(rows), CTRL_S, '\x03').quit).toBe(true); + }); + + test('Tab and Shift+Tab still cycle columns in the list and leave the sort alone', () => { + const s = press(choose(initialSecretsScreenState(rows), 'name-desc'), KEY_TAB); + expect(s.column).toBe('branch'); + expect(s.sort).toBe('name-desc'); + expect(press(s, `${ESC}[Z`).column).toBe('project'); + }); + + test('Ctrl+S is not typed into the search bar', () => { + expect(press(initialSecretsScreenState(rows), CTRL_S, ENTER).search.query).toBe(''); + }); +}); + +describe('name sorts', () => { + test('are case-insensitive: lower and upper case interleave', () => { + const rows = [row({ name: 'beta', value_hash: '1' }), row({ name: 'Alpha', value_hash: '2' }), row({ name: 'ALPHA2', value_hash: '3' }), row({ name: 'Zed', value_hash: '4' })]; + expect(sortRows(rows, 'name-asc').map((r) => r.name)).toEqual(['Alpha', 'ALPHA2', 'beta', 'Zed']); + expect(sortRows(rows, 'name-desc').map((r) => r.name)).toEqual(['Zed', 'beta', 'ALPHA2', 'Alpha']); + }); + + test('names that differ only by case tie, and the value hash decides', () => { + const rows = [row({ name: 'key', value_hash: 'bbb' }), row({ name: 'KEY', value_hash: 'aaa' })]; + expect(sortRows(rows, 'name-asc').map((r) => r.value_hash)).toEqual(['aaa', 'bbb']); + expect(sortRows(rows.toReversed(), 'name-asc').map((r) => r.value_hash)).toEqual(['aaa', 'bbb']); + }); + + test('the same name with two values keeps a stable order whatever order they arrive in', () => { + const rows = [row({ name: 'DB', value_hash: 'z' }), row({ name: 'DB', value_hash: 'a' })]; + expect(sortRows(rows, 'name-asc').map((r) => r.value_hash)).toEqual(['a', 'z']); + expect(sortRows(rows.toReversed(), 'name-asc').map((r) => r.value_hash)).toEqual(['a', 'z']); + }); + + test('sorting never changes the input array', () => { + const rows = [row({ name: 'B' }), row({ name: 'A' })]; + sortRows(rows, 'name-asc'); + expect(rows.map((r) => r.name)).toEqual(['B', 'A']); + }); +}); + +describe('deploy status sorts (by the status kind, not the displayed words)', () => { + const rows = [ + withStatus('E_NONE', 'no target'), + withStatus('D_UNKNOWN', 'unknown'), + withStatus('C_BEHIND', 'behind'), + withStatus('B_DEPLOYED', 'deployed'), + ]; + + test('A-Z: deployed, behind, unknown, no target', () => { + expect(names(withSort(rows, 'status-asc'))).toEqual(['B_DEPLOYED', 'C_BEHIND', 'D_UNKNOWN', 'E_NONE']); + }); + + test('Z-A is the reverse: no target, unknown, behind, deployed', () => { + expect(names(withSort(rows, 'status-desc'))).toEqual(['E_NONE', 'D_UNKNOWN', 'C_BEHIND', 'B_DEPLOYED']); + }); + + test('rows of the same status break the tie by name A-Z in BOTH directions', () => { + const tied = [withStatus('Z', 'behind'), withStatus('A', 'behind'), withStatus('M', 'deployed'), withStatus('B', 'deployed')]; + expect(names(withSort(tied, 'status-asc'))).toEqual(['B', 'M', 'A', 'Z']); + expect(names(withSort(tied, 'status-desc'))).toEqual(['A', 'Z', 'B', 'M']); + }); + + test('a row used in 33 places with 1 behind still ranks as behind', () => { + const many = row({ name: 'MANY', value_hash: 'm', locations: [...Array.from({ length: 32 }, () => loc({ targets: [target(false)] })), loc({ targets: [target(true)] })] }); + expect(names(withSort([withStatus('A', 'deployed'), many], 'status-asc'))).toEqual(['A', 'MANY']); + expect(names(withSort([withStatus('A', 'unknown'), many], 'status-asc'))).toEqual(['MANY', 'A']); + }); +}); + +describe('updated sorts', () => { + const rows = [ + withTime('OLD', '2026-01-01T00:00:00.000Z'), + withTime('NEW', '2026-06-01T00:00:00.000Z'), + withTime('NONE', undefined), + withTime('MID', '2026-03-01T00:00:00.000Z'), + ]; + + test('newest first, rows with no time last', () => { + expect(names(withSort(rows, 'updated-newest'))).toEqual(['NEW', 'MID', 'OLD', 'NONE']); + }); + + test('oldest first, rows with no time STILL last', () => { + expect(names(withSort(rows, 'updated-oldest'))).toEqual(['OLD', 'MID', 'NEW', 'NONE']); + }); + + test('a row uses its most recent location time', () => { + const multi = row({ name: 'MULTI', value_hash: 'x', locations: [loc({ changed_at: '2026-01-01T00:00:00.000Z' }), loc({ changed_at: '2026-12-01T00:00:00.000Z' })] }); + expect(names(withSort([withTime('NEW', '2026-06-01T00:00:00.000Z'), multi], 'updated-newest'))).toEqual(['MULTI', 'NEW']); + }); + + test('equal times and several no-time rows break the tie by name A-Z', () => { + const tied = [ + withTime('Z', '2026-01-01T00:00:00.000Z'), + withTime('A', '2026-01-01T00:00:00.000Z'), + withTime('Y_NONE', undefined), + withTime('B_NONE', undefined), + ]; + expect(names(withSort(tied, 'updated-newest'))).toEqual(['A', 'Z', 'B_NONE', 'Y_NONE']); + expect(names(withSort(tied, 'updated-oldest'))).toEqual(['A', 'Z', 'B_NONE', 'Y_NONE']); + }); + + test('a time that is not a date counts as no time', () => { + expect(names(withSort([withTime('BAD', 'not-a-date'), withTime('OK', '2026-01-01T00:00:00.000Z')], 'updated-oldest'))).toEqual(['OK', 'BAD']); + }); +}); + +describe('a sort change goes back to the top', () => { + const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' }), row({ name: 'C', value_hash: 'c' }), row({ name: 'D', value_hash: 'd' })]; + + test('after each sort change the first row of the new order is highlighted', () => { + const onC = press(initialSecretsScreenState(rows), KEY_DOWN, KEY_DOWN); + expect(cursorName(onC)).toBe('C'); + const after = SORT_ORDER.map((mode) => choose(onC, mode)); + after.forEach((s) => { + expect(s.cursorIndex).toBe(0); + expect(cursorName(s)).toBe(filteredRows(s)[0].name); + }); + expect(cursorName(after[1])).toBe('D'); // name Z-A: D, C, B, A + }); + + test('the rendered list starts at the first row after a sort change', () => { + const many = Array.from({ length: 60 }, (_, i) => row({ name: `K${String(i).padStart(2, '0')}`, value_hash: `h${i}` })); + const scrolled = press(initialSecretsScreenState(many), ...Array.from({ length: 50 }, () => KEY_DOWN)); + expect(stripAnsi(render(scrolled, 120, 20))).not.toContain('K00'); + const sorted = choose(scrolled, 'name-asc'); + expect(sorted.cursorIndex).toBe(0); + const text = stripAnsi(render(sorted, 120, 20)); + expect(text).toContain('K00'); + expect(text).not.toContain('K59'); + }); + + test('Enter after a sort opens the details of the first row', () => { + const s = press(initialSecretsScreenState(rows), KEY_DOWN, CTRL_S, KEY_DOWN, ENTER, ENTER); + expect(s.popup?.rowName).toBe('D'); + }); +}); + +describe('sort and search together', () => { + const rows = [ + row({ name: 'API_ONE', value_hash: '1' }), + row({ name: 'DB_URL', value_hash: '2' }), + row({ name: 'API_TWO', value_hash: '3' }), + row({ name: 'API_THREE', value_hash: '4' }), + ]; + + test('search decides which rows show, the sort orders them', () => { + const s = typed(initialSecretsScreenState(rows), 'api'); + expect(names(s)).toEqual(['API_ONE', 'API_THREE', 'API_TWO']); + expect(names(choose(s, 'name-desc'))).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); + }); + + test('typing after a sort keeps that sort', () => { + const s = typed(choose(initialSecretsScreenState(rows), 'name-desc'), 'api'); + expect(s.sort).toBe('name-desc'); + expect(names(s)).toEqual(['API_TWO', 'API_THREE', 'API_ONE']); + }); + + test('choosing a sort with a filter typed goes back to the first match', () => { + const s = press(typed(initialSecretsScreenState(rows), 'api'), KEY_DOWN); + expect(cursorName(s)).toBe('API_THREE'); + expect(cursorName(choose(s, 'name-desc'))).toBe('API_TWO'); + }); + + test('a search that matches by value orders its matches by the sort too', () => { + const s = typed(initialSecretsScreenState(rows), 'zzzz-no-match'); + expect(names(s)).toEqual([]); + expect(choose(s, 'name-desc').cursorIndex).toBe(0); + }); +}); + +describe('Ctrl+S in the details view', () => { + test('is ignored while the popup is open', () => { + const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' })]; + const open = press(initialSecretsScreenState(rows), ENTER); + expect(open.popup).not.toBeNull(); + const after = handleKey(open, CTRL_S); + expect(after.state.sort).toBe('name-asc'); + expect(after.state.sortMenu).toBeNull(); + expect(after.state.popup).toEqual(open.popup); + expect(after.effect).toBeNull(); + }); +}); + +describe('footer hint', () => { + const rows = [row({ name: 'A', value_hash: 'a' })]; + const footer = (state: SecretsScreenState, width = 120): string => stripAnsi(render(state, width, 30)).split('\n').filter((l) => l.trim() !== '').slice(-1)[0]; + + test('shows the key and the current sort', () => { + expect(footer(initialSecretsScreenState(rows))).toContain('ctrl+s sort: name A-Z'); + }); + + test('follows the sort through the whole cycle', () => { + const labels = SORT_ORDER.map((mode) => footer(choose(initialSecretsScreenState(rows), mode))); + expect(labels.map((l) => l.match(/ctrl\+s sort: (.*)$/)?.[1])).toEqual([ + 'name A-Z', + 'name Z-A', + 'deploy status A-Z', + 'deploy status Z-A', + 'updated newest', + 'updated oldest', + ]); + }); + + test('is still shown while a search is typed', () => { + expect(footer(typed(initialSecretsScreenState(rows), 'a'))).toContain('ctrl+s sort: name A-Z'); + }); + + test('is not in the details view footer', () => { + expect(footer(press(initialSecretsScreenState(rows), ENTER))).not.toContain('ctrl+s'); + }); + + test('wide terminal: every hint is shown', () => { + const line = footer(initialSecretsScreenState(rows), 140); + ['↑↓ navigate', 'tab column', 'enter inspect', 'ctrl+e edit', 'ctrl+d deploy', 'esc clear/quit', 'ctrl+s sort: name A-Z'].forEach((hint) => expect(line).toContain(hint)); + }); + + test('narrower terminal: whole hints are dropped (deploy, then edit, ...) but esc and sort stay', () => { + const at100 = footer(initialSecretsScreenState(rows), 100); + expect(at100).not.toContain('ctrl+d deploy'); + expect(at100).toContain('ctrl+e edit'); + const at80 = footer(initialSecretsScreenState(rows), 80); + expect(at80).not.toContain('ctrl+e edit'); + expect(at80).toContain('esc clear/quit'); + expect(at80).toContain('ctrl+s sort: name A-Z'); + expect(at80).not.toContain('…'); + }); + + test('a very narrow terminal clips to one line within the width', () => { + [20, 40, 60, 80, 100].forEach((width) => { + expect(Array.from(footer(initialSecretsScreenState(rows), width)).length).toBeLessThanOrEqual(width); + }); + }); + + test('the longest sort label still fits at 80 columns', () => { + const s = { ...initialSecretsScreenState(rows), sort: 'status-desc' as const }; + expect(footer(s, 80)).toContain('ctrl+s sort: deploy status Z-A'); + }); +}); + +describe('the sort menu on screen', () => { + const rows = [row({ name: 'A', value_hash: 'a' }), row({ name: 'B', value_hash: 'b' })]; + const lines = (state: SecretsScreenState, width: number, height: number): readonly string[] => stripAnsi(render(state, width, height)).split('\n'); + const open = (mode: SortMode = 'name-asc'): SecretsScreenState => press(withSort(rows, mode), CTRL_S); + const LABELS = ['name A-Z', 'name Z-A', 'deploy status A-Z', 'deploy status Z-A', 'updated newest', 'updated oldest']; + + test('shows the title, the six labels, and the menu footer', () => { + const l = lines(open(), 100, 30); + LABELS.forEach((label) => expect(l.join('\n')).toContain(label)); + expect(l.join('\n')).toContain('sort by'); + expect(l.filter((x) => x.trim() !== '').slice(-1)[0]).toContain('↑↓ choose · enter sort · esc cancel'); + }); + + test('marks the current sort and puts the pointer on the menu cursor', () => { + const l = lines(press(open('status-asc'), KEY_DOWN), 100, 30); + const marked = l.filter((x) => x.includes('●')); + expect(marked.length).toBe(1); + expect(marked[0]).toContain('deploy status A-Z'); + const pointed = l.filter((x) => x.includes('▶')); + expect(pointed.length).toBe(1); + expect(pointed[0]).toContain('deploy status Z-A'); + }); + + test('the list and its footer are not drawn while the menu is open', () => { + const text = lines(open(), 100, 30).join('\n'); + expect(text).not.toContain('search:'); + expect(text).not.toContain('ctrl+s sort'); + }); + + test('the list is back, with the new sort in its footer, after Enter', () => { + const text = lines(choose(initialSecretsScreenState(rows), 'updated-newest'), 100, 30).join('\n'); + expect(text).toContain('search:'); + expect(text).toContain('ctrl+s sort: updated newest'); + expect(text).not.toContain('sort by'); + }); + + test('fits 60 columns by 12 lines: all six labels, no line wider or taller than the terminal', () => { + const l = lines(open(), 60, 12); + expect(l.length).toBeLessThanOrEqual(12); + l.forEach((x) => expect(Array.from(x).length).toBeLessThanOrEqual(60)); + LABELS.forEach((label) => expect(l.join('\n')).toContain(label)); + expect(l.filter((x) => x.trim() !== '').slice(-1)[0]).toContain('esc cancel'); + }); + + test('on a very short or narrow terminal it windows around the cursor and never overflows', () => { + [[40, 8], [30, 7], [20, 6], [80, 5]].forEach(([w, h]) => { + SORT_ORDER.forEach((_m, i) => { + const l = lines(press(open(), ...Array.from({ length: i }, () => KEY_DOWN)), w, h); + expect(l.length).toBeLessThanOrEqual(h); + l.forEach((x) => expect(Array.from(x).length).toBeLessThanOrEqual(w)); + expect(l.filter((x) => x.includes('▶')).length).toBe(1); + }); + }); + }); + + test('the dry-run tag is on the menu screen', () => { + expect(lines(press(initialSecretsScreenState(rows, true), CTRL_S), 100, 30).join('\n')).toContain('DRY RUN'); + }); +});