From f13c735f5970d4882fe9300afa6dae91a271c382 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 5 Oct 2026 17:42:11 -0700 Subject: [PATCH 1/3] feat(secrets): batch deploy to Dokploy targets from capy secrets - Ctrl+D in the secrets list (d in details) deploys the chosen variables to every Dokploy target that uses them; Enter confirms, targets are selectable, values show masked with r to reveal. - capy secrets deploy with --row/--all-rows/--exclude/--confirm, --dry-run and --json for agents. - Variables in deploy.json with no value in Capy are left out and reported. - Deploy and edit result screens close on Esc only, so PR links can be copied. - CI-mode delivery records are built from the server keep. --- docs/cli-reference.md | 27 +- src/commands/deployCommand.ts | 241 ++---- src/commands/keepLockPr.ts | 18 +- src/commands/secretsCommand.ts | 2 + src/commands/secretsDeployActions.ts | 82 ++ src/commands/secretsDeployCommand.ts | 268 +++++++ src/commands/secretsDeployText.ts | 211 ++++++ src/commands/secretsSetCommand.ts | 24 +- src/core/cliHelpDoc.ts | 1 + src/deploy/adapter.ts | 8 +- src/deploy/adapters/dokploy.ts | 18 +- src/deploy/batchDeploy.ts | 757 +++++++++++++++++++ src/deploy/configuredTargets.ts | 3 +- src/deploy/deliveryRecord.ts | 134 ++++ src/deploy/deployPrBody.ts | 75 ++ src/deploy/dokployApiKeyOnce.ts | 57 ++ src/deploy/keepGate.ts | 43 +- src/index-dev.ts | 39 + src/index.ts | 40 + src/types/index.ts | 11 + src/ui/pickerTable.ts | 8 +- src/ui/secretsDeployFlow.ts | 307 ++++++++ src/ui/secretsEditFlow.ts | 7 +- src/ui/secretsScreen.ts | 143 ++-- src/ui/secretsScreenDriver.ts | 67 +- src/ui/valueDisplay.ts | 66 ++ tests/commands/deployCiTargetsRecord.test.ts | 32 + tests/commands/secretsDeployCommand.test.ts | 307 ++++++++ tests/commands/secretsDeployHelp.test.ts | 43 ++ tests/deploy/batchDeploy.test.ts | 519 +++++++++++++ tests/deploy/deliveryRecord.test.ts | 54 ++ tests/deploy/keepGateFromHashes.test.ts | 39 + tests/helpers/batchDeployWorld.ts | 333 ++++++++ tests/ui/secretsBasesLoading.test.ts | 6 +- tests/ui/secretsCancelTui.test.ts | 4 +- tests/ui/secretsDeployFlow.test.ts | 644 ++++++++++++++++ tests/ui/secretsDryRunTui.test.ts | 6 +- tests/ui/secretsEditFlow.test.ts | 11 +- tests/ui/secretsScreenDriverEdit.test.ts | 10 +- 39 files changed, 4368 insertions(+), 297 deletions(-) create mode 100644 src/commands/secretsDeployActions.ts create mode 100644 src/commands/secretsDeployCommand.ts create mode 100644 src/commands/secretsDeployText.ts create mode 100644 src/deploy/batchDeploy.ts create mode 100644 src/deploy/deliveryRecord.ts create mode 100644 src/deploy/deployPrBody.ts create mode 100644 src/deploy/dokployApiKeyOnce.ts create mode 100644 src/ui/secretsDeployFlow.ts create mode 100644 src/ui/valueDisplay.ts create mode 100644 tests/commands/secretsDeployCommand.test.ts create mode 100644 tests/commands/secretsDeployHelp.test.ts create mode 100644 tests/deploy/batchDeploy.test.ts create mode 100644 tests/deploy/deliveryRecord.test.ts create mode 100644 tests/deploy/keepGateFromHashes.test.ts create mode 100644 tests/helpers/batchDeployWorld.ts create mode 100644 tests/ui/secretsDeployFlow.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 97494089..b5ce7758 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.10`. Generated from `capy help --json`. +Version `0.9.11`. Generated from `capy help --json`. ## Commands @@ -556,6 +556,26 @@ JSON support: yes (`--json`) Dry run: yes (`--dry-run`) +### `capy secrets deploy` + +Deploy the Dokploy targets of one or more secrets with no project folder, and open one keep.lock PR per target. Never prompts. + +``` +capy secrets deploy [options] +``` + +| Option | Description | Default | +|---|---|---| +| `--json` | emit machine-readable JSON instead of the human UI | | +| `--row ` | deploy this row of a name (repeatable; ids from `capy secrets --name NAME --json`) | `[]` | +| `--all-rows` | deploy every row of those names | | +| `--exclude ` | leave this location out (repeatable) | `[]` | +| `--confirm ` | run the plan that --dry-run printed (required for a real run) | | + +JSON support: yes (`--json`) + +Dry run: yes (`--dry-run`) + ## `capy grant-branch` Grant a member wildcard access to a protected branch @@ -901,6 +921,11 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `PLAN_REQUIRED` - `DISCOVER_UNSUPPORTED_TARGET` - `DISCOVER_PARTIAL` +- `DEPLOY_BATCH_PARTIAL` +- `DEPLOY_NOTHING_TO_DEPLOY` +- `DEPLOY_VARS_MISSING` +- `DEPLOY_PREFLIGHT_FAILED` +- `DEPLOY_PUSH_FAILED` ## Conventions diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index ecf7bff5..41d6710b 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -83,6 +83,9 @@ import { createDokployClient, } from '../deploy/dokployApi'; import { classify, isBuildTime } from '../deploy/classify'; +import { buildDeployPrBody } from '../deploy/deployPrBody'; +import { resolveDokployApiKeyOnce } from '../deploy/dokployApiKeyOnce'; +import { deliveryFor, recordDeliveriesOnServer, targetRefFor } from '../deploy/deliveryRecord'; import { deployPlan, unansweredDeployStops, type DeployStopId } from '../core/deployPlan'; import type { DeployPlanConfirmStop } from '../ui/screens/contract'; import { CHECKBOX_INSTRUCTIONS, CHECKBOX_THEME, LIST_THEME } from '../ui/promptStyle'; @@ -138,53 +141,6 @@ export interface DeployCliOptions { noDeploy?: boolean; } -/** - * Dokploy only: resolves the org system store's API key ONCE for this whole - * command, wiring the REAL `system/systemStore.ts#getConnectorSecret` — this - * is the production entry point for CAP-664. The result is threaded into - * every `preflight`/`deploy`/`onRemove` call via `ctx.resolvedApiKey`, so the - * store is asked (and an admin prompted) at most once per command no matter - * how many of those run. `undefined` for every other adapter — they never - * read that field. - * - * Never resolves (never prompts, never touches the store) when the target - * can't even be reached — `dokployConnectionProblem` (an unusable `baseUrl`, - * a missing `applicationId`, or a malformed `tokenEnv`): `preflight()` fails - * on that regardless of any token, so asking for (or prompting to save) a - * key first would be wasted at best and a needless prompt at worst. - * Deliberately NOT the full `optionsProblem` — `onRemove` has no vars to - * ship and must still reach a target that check would otherwise reject. - */ -async function resolveDokployApiKeyOnce( - adapter: DeployAdapter, - target: TargetConfig, - orgId: string | undefined, - devMode: boolean | undefined, - interactive: boolean, -): Promise { - if (adapter.id !== 'dokploy') return undefined; - if (dokployConnectionProblem(target)) return undefined; - const { getDirectionalConnectorSecret } = await import('../system/systemStore'); - // CAP-679 follow-up: deploy asks for `_TARGET_DOKPLOY_API_KEY` first, and - // — only when that's missing — offers to reuse (or shadow-refuse without a - // TTY) `_CONNECTOR_DOKPLOY_API_KEY`, the import-side key. See - // `system/systemStore.ts#getDirectionalConnectorSecret`'s own doc. - const getConnectorSecret = (name: string, opts: DokploySystemStoreCallOptions) => - getDirectionalConnectorSecret(name, DOKPLOY_CONNECTOR_SECRET_NAME, { - ...opts, - missingWithFallbackCode: ERROR_CODES.DOKPLOY_TARGET_KEY_MISSING, - }); - return resolveDokployApiKey({ - tokenEnv: (target.options as { tokenEnv?: string }).tokenEnv, - env: process.env, - interactive, - orgId, - devMode, - storeName: DOKPLOY_TARGET_SECRET_NAME, - deps: { getConnectorSecret }, - }); -} - /** * `authenticateSilent`'s common "org-scoped, then unscoped" probe as one * value rather than a reassigned local — the org-scoped attempt wins @@ -305,14 +261,10 @@ async function decryptCurrentBranch( const fm = new FileManager(cwd); const envFromFile = fm.readEnvFile(); - const out: Record = {}; - const toDecrypt: Array<[string, string]> = []; - for (const [k, v] of Object.entries(envFromFile)) { - if (typeof v !== 'string') continue; - if (fm.isEncrypted(v)) toDecrypt.push([k, v]); - else out[k] = v; - } - if (toDecrypt.length === 0) return out; + const stringEntries = Object.entries(envFromFile).filter((e): e is [string, string] => typeof e[1] === 'string'); + const plain: Record = Object.fromEntries(stringEntries.filter(([, v]) => !fm.isEncrypted(v))); + const toDecrypt = stringEntries.filter(([, v]) => fm.isEncrypted(v)); + if (toDecrypt.length === 0) return plain; const keep = readKeep(cwd); if (!keep) { @@ -342,10 +294,7 @@ async function decryptCurrentBranch( result.user_id, keyServiceOps, ); - for (const [k, v] of toDecrypt) { - out[k] = fm.decryptValue(v, projectKeyHex); - } - return out; + return { ...plain, ...Object.fromEntries(toDecrypt.map(([k, v]) => [k, fm.decryptValue(v, projectKeyHex)])) }; } /** @@ -448,19 +397,6 @@ async function loadDeploySecrets( // ── Targets recording (CAP-679) ───────────────────────────────────────────── -/** - * 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` — - * there is no spec'd `ref` shape for them yet. - */ -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 }; - return undefined; -} - /** * Read → transform → push keep.lock through the existing sync path, for a * pure `KeepFile → KeepFile` change that isn't a value edit (targets @@ -657,35 +593,6 @@ function warnIfTokenUntracked(result: { ok: boolean }, deployId: string | undefi ); } -/** - * The delivery descriptor + delivered-values pair shared by every "record - * this target's delivery" caller below (direct mode, CI mode, and - * `buildFinalCiKeep`'s own PR-content version) — same shape, same - * `noDeploy` → `deployed: false` rule (CAP-679 follow-up, "pending"; see - * `targetsGate.ts#upsertTargetElement`'s doc for why absent/false OMITS the - * field instead of writing `deployed: true`, and how this also clears a - * PRIOR pending element once a real deploy follows it). - */ -function deliveryFor( - target: TargetConfig, - adapter: DeployAdapter, - deployId: string | undefined, - noDeploy: boolean, - valueHashes: Record, -): { delivery: TargetDeliveryDescriptor; values: readonly VarDelivery[] } { - const delivery: TargetDeliveryDescriptor = { - provider: adapter.id, - target: target.name, - ref: targetRefFor(target), - deployId, - ...(noDeploy ? { deployed: false } : {}), - }; - const values = target.vars - .filter((v) => valueHashes[v] !== undefined) - .map((v) => ({ name: v, valueHash: valueHashes[v] })); - return { delivery, values }; -} - /** * Direct-mode-only: after a verified successful deploy (or, when `noDeploy`, * after the config write `--no-deploy` still performs), record this @@ -735,12 +642,12 @@ async function recordDeployTargets( * CI-mode target's delivery never shows up in `capy secrets` — the bug this * function fixes. * - * Server-only: `pushKeepTransform`'s `writeLocal: false` means this call - * pushes exactly like direct mode's `recordDeployTargets` (same server - * snapshot fetch, same re-sent-unchanged blob — see `pushKeepTransform`'s - * own doc) but never writes keep.lock to disk and never auto-commits. CI - * mode must NEVER touch the user's working tree — see `openCiDeployPr`'s - * own doc for why. + * Server-only: the pushed keep is built from the SERVER's own keep and the + * branch's blob is re-sent unchanged (`recordCiDeliveryOnServer` below) — it + * never reads, writes or commits the local keep.lock. CI mode must NEVER + * touch the user's working tree — see `openCiDeployPr`'s own doc for why. + * (Until this was fixed it built the keep from the LOCAL keep.lock, so a second + * CI deploy could erase another target's record the local copy did not have.) * * Best-effort, same contract as `recordDeployTargets`: the CI delivery * already succeeded by the time this runs, so a failure here is warned @@ -759,15 +666,48 @@ async function recordDeployTargetsCi( const deliveredAt = new Date().toISOString(); const { delivery, values } = deliveryFor(target, adapter, deployId, noDeploy, valueHashes); if (values.length === 0) return; - const result = await pushKeepTransform( - cwd, - target.branch, - (keep) => recordTargetDeliveries(keep, target.branch, delivery, deliveredAt, values), - devMode, - 'record deploy targets', - { writeLocal: false, warnCode: ERROR_CODES.CI_DEPLOY_TARGETS_RECORD_FAILED }, - ); - warnIfTokenUntracked(result, deployId, target.name); + const outcome = await recordCiDeliveryOnServer(cwd, target.branch, [{ delivery, values, deliveredAt }], devMode); + if (!outcome.ok) { + console.error( + ` ${YELLOW('!')} could not record deploy targets in keep.lock: ${ERROR_CODES.CI_DEPLOY_TARGETS_RECORD_FAILED}: ${outcome.detail}`, + ); + } + warnIfTokenUntracked(outcome, deployId, target.name); +} + +/** + * The server-side half of `recordDeployTargetsCi`: authenticates (never + * prompting), then builds the pushed keep FROM THE SERVER'S keep + * (`recordDeliveriesOnServer`), never from the local keep.lock. A local copy + * can lag the server — it may lack another target's CI record — and a keep + * pushed from it erased that record. The local keep.lock is not read, written + * or compared at all. Never throws. + */ +async function recordCiDeliveryOnServer( + cwd: string, + branch: string, + deliveries: Parameters[3], + devMode: boolean | undefined, +): Promise<{ ok: true } | { ok: false; detail: string }> { + try { + const projectState = await new ProjectManager(cwd).detectProjectState(); + if (!projectState.initialized || !projectState.organizationId || !projectState.projectId) { + return { ok: false, detail: ERROR_CODES.NO_KEEP_FILE }; + } + const { AuthService, silentAuthFailureMessage } = await import('../auth/authService'); + const { ServiceClient } = await import('../service/serviceClient'); + const authService = new AuthService(undefined, devMode, projectState.userId); + const serviceClient = new ServiceClient(undefined, devMode); + serviceClient.setTokenProvider(() => authService.getValidToken()); + const authResult = await authenticateSilentWithFallback(authService, projectState.organizationId); + if (!authResult.success || !authResult.user_id) { + return { ok: false, detail: silentAuthFailureMessage(authResult) }; + } + const recorded = await recordDeliveriesOnServer(serviceClient, projectState.projectId, branch, deliveries); + return recorded.ok ? { ok: true } : { ok: false, detail: recorded.code }; + } catch (err: any) { + return { ok: false, detail: String(err?.message ?? err) }; + } } /** @@ -2855,71 +2795,4 @@ async function unwindGitState( } } -export function buildDeployPrBody(target: TargetConfig): string { - const adapter = getAdapter(target.kind); - const adapterLabel = adapter ? adapter.label : target.kind; - const optionsTable = Object.entries(target.options) - .map(([k, v]) => `- \`${k}\`: \`${String(v)}\``) - .join('\n'); - const baseLine = target.gitBaseBranch - ? `- **Git base:** \`${target.gitBaseBranch}\`` - : ''; - - // Secret-delivery wording depends on the adapter. Blob adapters (Vercel) push - // SECRETS_BLOB + PROJECT_KEY and let the build decrypt via `capy run`; others - // push the individual secrets into the vendor's store. - const varsSection = adapter?.needsDeployToken - ? [ - `Delivered to ${adapterLabel} as \`SECRETS_BLOB\` + \`PROJECT_KEY\` **before** this`, - `PR was opened — the encrypted bundle of your secrets plus its build-time`, - `key. Your individual secret values stay encrypted in the bundle and never`, - `appear in git history; the build decrypts them with \`capy run\`:`, - ``, - `- \`SECRETS_BLOB\``, - `- \`PROJECT_KEY\``, - ].join('\n') - : [ - `Already delivered to the vendor's secret store **before** this PR was`, - `opened (e.g. \`wrangler secret bulk\` for cf-worker). Names only — values`, - `stay in the vendor's store and never appear in git history:`, - ``, - target.vars.map((v) => `- \`${v}\``).join('\n'), - ].join('\n'); - - // CAP-702: capy does not track releases. The values are already in the - // vendor's store; merging records them in keep.lock and starts a release - // only where the vendor builds on merge. Wording approved by Vince - // (2026-10-04), verbatim from the ticket. - const mergeSection = [ - `The new values are already in ${adapterLabel}. The next release of this branch will use them.`, - `Merging this PR records them in keep.lock, and it starts a release if ${adapterLabel} builds on merge.`, - ].join(' '); - - return [ - `Automated deploy PR opened by \`capy deploy\`.`, - ``, - `## What this ships`, - ``, - `- **Target:** \`${target.name}\``, - `- **Adapter:** ${adapterLabel} (\`${target.kind}\`)`, - `- **Capy branch:** \`${target.branch}\` — secrets snapshot pinned by \`keep.lock\` in this commit.`, - baseLine, - optionsTable, - ``, - `## Vars pushed`, - ``, - varsSection, - ``, - `## What happens on merge`, - ``, - mergeSection, - ``, - `## Diff scope`, - ``, - `This PR touches at most one file: \`keep.lock\`. An empty diff means the`, - `pinned snapshot already matched and this is a forced redeploy. Other`, - `working-tree changes on the author's machine were not picked up.`, - ``, - `_Generated by \`capy deploy\`._`, - ].join('\n'); -} +export { buildDeployPrBody }; diff --git a/src/commands/keepLockPr.ts b/src/commands/keepLockPr.ts index aff75787..1339ffc0 100644 --- a/src/commands/keepLockPr.ts +++ b/src/commands/keepLockPr.ts @@ -65,7 +65,7 @@ const ERROR_MESSAGES: Readonly> = { // Public types // --------------------------------------------------------------------------- -export type KeepLockCommandName = 'add' | 'edit' | 'remove' | 'secrets'; +export type KeepLockCommandName = 'add' | 'edit' | 'remove' | 'secrets' | 'deploy'; export interface PrFlags { /** `--pr` */ @@ -376,6 +376,11 @@ export interface KeepLockFileSpec { readonly path: string; readonly records: readonly EditSaveRecord[]; readonly localKeep: KeepFile; + /** + * Replaces folding `records`: the caller works out the new keep.lock from the base's own + * (the batch deploy writes a delivery record, which `records` cannot say). Absent: `records` are folded. + */ + readonly fold?: (base: KeepFile) => KeepFile; } /** What a base branch holds at one keep.lock path. */ @@ -427,7 +432,7 @@ async function planCommit(target: Target, base: string, specs: readonly KeepLock const changed = specs.flatMap((spec, i) => { const before = bases.value[i]; - const folded = spec.records.reduce(foldEditSaveIntoKeep, before.keep); + const folded = spec.fold === undefined ? spec.records.reduce(foldEditSaveIntoKeep, before.keep) : spec.fold(before.keep); const content = serializeKeep(folded); const noDiff = before.existed ? content === serializeKeep(before.keep) : Object.keys(folded.variables).length === 0; return noDiff ? [] : [{ path: spec.path, content }]; @@ -439,7 +444,7 @@ async function planCommit(target: Target, base: string, specs: readonly KeepLock } /** What a pull request that writes files says about itself. Names only: never a value. */ -interface PullWording { +export interface PullWording { readonly commitMessage: string; readonly title: string; readonly body: string; @@ -529,6 +534,8 @@ export interface KeepLockPullRequest { readonly files: readonly KeepLockFileSpec[]; /** The PR base. Absent: the repo's default branch. */ readonly base?: string; + /** The commit message, title and body to use instead of the keep.lock wording (names only, never a value). */ + readonly wording?: PullWording; } export type KeepLockPullRequestResult = @@ -593,7 +600,10 @@ export async function openKeepLockPullRequest( const branch = deps.branchName(); if (!isValidBranchName(branch)) return { ok: false, code: ERROR_CODES.KEEP_PR_BRANCH_FAILED }; const records = req.files.flatMap((f) => f.records); - const report = await publish(target, base.base, plan.value, branch, { command: req.command, records }); + const report = + req.wording === undefined + ? await publish(target, base.base, plan.value, branch, { command: req.command, records }) + : await publishFiles(target, base.base, plan.value, branch, req.wording); if (report.changed && report.committed) { return { ok: true, pr_url: report.pr_url, base: base.base, paths: plan.value.files.map((f) => f.path), bases: plan.value.bases }; } diff --git a/src/commands/secretsCommand.ts b/src/commands/secretsCommand.ts index b9504ff9..b4110749 100644 --- a/src/commands/secretsCommand.ts +++ b/src/commands/secretsCommand.ts @@ -91,12 +91,14 @@ export class SecretsCommand { const { createLocationDecryptor } = await import('./secretsValueDecryptor'); const { runSecretsScreen } = await import('../ui/secretsScreenDriver'); const { createEditActions } = await import('./secretsEditActions'); + const { createDeployActions } = await import('./secretsDeployActions'); spinner?.stop(); await runSecretsScreen( rows, createLocationDecryptor(orgId, userId, serviceClient), createEditActions(orgId, userId, serviceClient, opts.dryRun === true), opts.dryRun === true, + createDeployActions(orgId, userId, serviceClient, opts.dryRun === true, this.devMode), ); return; } diff --git a/src/commands/secretsDeployActions.ts b/src/commands/secretsDeployActions.ts new file mode 100644 index 00000000..f9319a91 --- /dev/null +++ b/src/commands/secretsDeployActions.ts @@ -0,0 +1,82 @@ +// What the `capy secrets` TUI's deploy flow (CAP-704) does outside the screen: the effects +// `ui/secretsDeployFlow.ts` asks for. Both are the engine `capy secrets deploy` uses +// (deploy/batchDeploy.ts), so the two cannot drift. Neither ever rejects: a failure comes +// back as a code. +// +// DRY RUN (`capy --dry-run secrets`): `loadPlan` only READS (GitHub's default branch and each +// project's deploy.json). `run` is never reached by the screen, and if it ever were it +// refuses with `DRY_RUN_UNSUPPORTED` instead of running: a dry run never runs for real. + +import type { OrgRepoLink, SecretIndexRow, ServiceClient } from '../service/serviceClient'; +import type { SecretsDeployActions } from '../ui/secretsScreenDriver'; +import { BatchEnv, createBatchEnv, deployLocationsOf, planBatchDeploy, runBatchDeploy } from '../deploy/batchDeploy'; +import { CapyError, ERROR_CODES } from '../types/index'; + +const codeOf = (err: unknown): string => (err instanceof CapyError ? err.code : ERROR_CODES.SERVICE_ERROR); + +type CancelPhase = Parameters[0]; + +/** + * The deploy flow's side effects. `cancel(phase)` aborts what is in flight for that phase. The + * signals are made fresh for each operation and listen on one `EventTarget`, so a cancel + * reaches exactly the operations running now. + */ +export function createDeployActionsWith( + client: Pick, + orgId: string, + env: BatchEnv, + dryRun: boolean = false, +): SecretsDeployActions { + const control = new EventTarget(); + const begin = (phase: CancelPhase): AbortSignal => { + const controller = new AbortController(); + control.addEventListener(phase, () => controller.abort(), { once: true }); + return controller.signal; + }; + const linksOf = async (): Promise => { + try { + return (await client.getOrgRepos(orgId)).repos; + } catch (err) { + // A service that predates repo links has none to give: the plan then says there is nothing to deploy. + if (err instanceof CapyError && err.code === ERROR_CODES.REPO_LINKS_UNSUPPORTED) return []; + throw err; + } + }; + return { + loadPlan: async (row: SecretIndexRow) => { + try { + const plan = await planBatchDeploy( + env.github(), + { names: [row.name], locations: deployLocationsOf(row) }, + await linksOf(), + begin('planning'), + ); + return { ok: true, plan }; + } catch (err) { + return { ok: false, code: codeOf(err) }; + } + }, + run: async (plan, onProgress) => { + if (dryRun) return { ok: false, code: ERROR_CODES.DRY_RUN_UNSUPPORTED }; + try { + return { ok: true, result: await runBatchDeploy(plan, env, { onProgress, stopPushes: begin('pushing'), stopPrs: begin('prs') }) }; + } catch (err) { + return { ok: false, code: codeOf(err) }; + } + }, + cancel: (phase) => { + control.dispatchEvent(new Event(phase)); + }, + }; +} + +/** The real actions for a signed-in session. */ +export function createDeployActions( + orgId: string, + userId: string, + client: ServiceClient, + dryRun: boolean = false, + devMode: boolean = false, +): SecretsDeployActions { + return createDeployActionsWith(client, orgId, createBatchEnv(orgId, userId, client, devMode), dryRun); +} diff --git a/src/commands/secretsDeployCommand.ts b/src/commands/secretsDeployCommand.ts new file mode 100644 index 00000000..3d1d359d --- /dev/null +++ b/src/commands/secretsDeployCommand.ts @@ -0,0 +1,268 @@ +/** + * `capy secrets deploy NAME...` (CAP-704, agent mode): deploy the Dokploy targets of the + * chosen secrets, with no project folder, and open one keep.lock PR per target. It mirrors + * `capy secrets set`: it NEVER prompts, never opens a browser, and takes no value (it + * deploys what Capy holds now). + * + * capy secrets deploy NAME --all-rows --dry-run --json # plan, changes nothing + * capy secrets deploy NAME --all-rows --confirm --json + * + * The flow an agent follows: look the rows up (`capy secrets --name NAME --json`), show the + * human a table, let the HUMAN pick the row(s) (never pick one yourself), dry run, get + * approval, then run with `--confirm `. + * + * What "deploy" means: exactly what `capy deploy ` does for a Dokploy target in CI + * mode (see `deploy/batchDeploy.ts`). Other targets are listed in `skipped` with a code. + * + * Selection + * Each NAME matches rows (one per distinct value). A NAME with several rows and neither + * `--row ` (repeatable) nor `--all-rows`: refused `SECRET_AMBIGUOUS` (exit 3) with the + * candidates in `unanswered`. `--exclude :` (repeatable) drops locations. + * + * The plan and `plan_id` + * `--dry-run` prints the plan and changes nothing (it reads the service and GitHub only, + * and unlocks nothing). `plan_id` is a hash of the exact (row_id, project, branch) set and + * of the targets (project, repo, folder, PR base, target, variables). A real run REQUIRES + * `--confirm `; without it the refusal (exit 3) carries the plan, and a plan that + * moved since is refused `PLAN_CHANGED` (exit 1) with nothing done. + * + * Output never carries a value or any hash of one. Failures are codes. + */ +import { createHash } from 'crypto'; +import type { OrgRepoLink, SecretIndexRow, ServiceClient } from '../service/serviceClient'; +import { ERROR_CODES } from '../types/index'; +import { isValidVarName } from './pipedWrite'; +import { refuseInvalidName } from './pipedValue'; +import { rowIdOf } from './secretsRowId'; +import { + BatchEnv, + BatchPlan, + BatchResult, + DeployLocation, + batchSucceeded, + createBatchEnv, + deployLocationsOf, + planBatchDeploy, + runBatchDeploy, +} from '../deploy/batchDeploy'; +import { targetRefFor } from '../deploy/deliveryRecord'; +import type { SecretsSetIo } from './secretsSetCommand'; +import { + byText, + candidatesOf, + excludeLocations, + loadLinks, + readSecretIndex, + refuse, + silentContext, + withSigintControl, +} from './secretsSetCommand'; +import { renderBatchResult, renderDeployPlanText, planJsonOf, resultJsonOf } from './secretsDeployText'; +import { TERMINAL_STYLE, stoppingAfter } from './secretsSetText'; + +export interface SecretsDeployOpts { + readonly json?: boolean; + /** `--dry-run` (the program-level flag): print the plan, change nothing. */ + readonly dryRun?: boolean; + /** `--confirm ` */ + readonly confirm?: string; + /** `--row ` (repeatable) */ + readonly row?: readonly string[]; + /** `--all-rows` */ + readonly allRows?: boolean; + /** `--exclude :` (repeatable) */ + readonly exclude?: readonly string[]; +} + +/** Injectable seam: tests pass fakes for the service, GitHub and Dokploy. */ +export interface SecretsDeployIo { + readonly orgId: string; + readonly client: Pick; + readonly env: BatchEnv; + /** Cancel signals (SIGINT in the real command), the same three the edit has. */ + readonly control?: SecretsSetIo['control']; +} + +// ── Selection ─────────────────────────────────────────────────────────────── + +interface Located extends DeployLocation { + readonly row_id: string; +} + +const locatedOf = (row: SecretIndexRow): readonly Located[] => { + const row_id = rowIdOf(row.name, row.value_hash); + return deployLocationsOf(row).map((l) => ({ ...l, row_id })); +}; + +const ambiguousStop = (named: readonly SecretIndexRow[]) => [ + { id: 'row', flag: '--row', alternative: '--all-rows', candidates: candidatesOf(named) }, +]; + +/** The rows the run is about, or a refusal. Several names: each name is chosen on its own. */ +function selectRows(rows: readonly SecretIndexRow[], names: readonly string[], opts: SecretsDeployOpts, json: boolean): readonly SecretIndexRow[] { + const byName = names.map((name) => ({ name, rows: rows.filter((r) => r.name === name) })); + const missing = byName.find((n) => n.rows.length === 0); + if (missing !== undefined) refuse(json, ERROR_CODES.SECRET_NOT_FOUND, 'No secret has that name.'); // COPY-FLAG + const wanted = opts.row ?? []; + if (wanted.length > 0 && opts.allRows === true) { + refuse(json, ERROR_CODES.INVALID_FORMAT, '--row and --all-rows cannot be used together.'); // COPY-FLAG + } + if (opts.allRows === true) return byName.flatMap((n) => n.rows); + const named = byName.flatMap((n) => n.rows); + const known = new Set(named.map((r) => rowIdOf(r.name, r.value_hash))); + if (wanted.some((id) => !known.has(id))) { + refuse(json, ERROR_CODES.SECRET_NOT_FOUND, 'A --row id matches no row of those names.', { unanswered: ambiguousStop(named) }); // COPY-FLAG + } + const chosen = byName.map((n) => { + const picked = n.rows.filter((r) => wanted.includes(rowIdOf(r.name, r.value_hash))); + return { ...n, picked: picked.length > 0 ? picked : n.rows.length === 1 ? n.rows : [] }; + }); + const unresolved = chosen.filter((c) => c.picked.length === 0); + if (unresolved.length > 0) { + refuse( + json, + ERROR_CODES.SECRET_AMBIGUOUS, + 'A name has more than one value. Choose --row (repeatable) or --all-rows.', // COPY-FLAG + { unanswered: ambiguousStop(unresolved.flatMap((u) => u.rows)) }, + ); + } + return chosen.flatMap((c) => c.picked); +} + +// ── The plan ──────────────────────────────────────────────────────────────── + +const PLAN_DOMAIN = 'capy:secrets:deploy-plan:v1'; + +/** + * A hash over the exact (row_id, project, branch) set and the targets to deploy + * (project, repo, folder, PR base, target, kind, Capy branch, service, variables), each sorted + * so order never matters. Opaque: it carries no value and no value hash. + */ +export function deployPlanIdOf( + names: readonly string[], + locations: ReadonlyArray<{ readonly row_id: string; readonly project: string; readonly branch: string }>, + plan: BatchPlan, +): string { + const canonical = JSON.stringify({ + names: [...names].sort(byText), + locations: locations.map((l) => [l.row_id, l.project, l.branch].join('\u0000')).sort(byText), + targets: plan.targets + .map((t) => + [ + t.project_id, + `${t.repo.owner}/${t.repo.name}`.toLowerCase(), + t.path, + t.base, + t.config.name, + t.config.kind, + t.branch, + JSON.stringify(targetRefFor(t.config) ?? {}), + [...t.config.vars].sort(byText).join('\u0001'), + ].join('\u0000'), + ) + .sort(byText), + }); + return createHash('sha256').update(`${PLAN_DOMAIN}\u0000${canonical}`).digest('hex').slice(0, 16); +} + +interface Selection { + readonly names: readonly string[]; + readonly located: readonly Located[]; +} + +const planIdFor = (sel: Selection, plan: BatchPlan): string => + deployPlanIdOf( + sel.names, + sel.located.map((l) => ({ row_id: l.row_id, project: l.project_name, branch: l.branch })), + plan, + ); + +async function readPlan(sel: Selection, io: SecretsDeployIo, links: readonly OrgRepoLink[]): Promise { + return planBatchDeploy(io.env.github(), { names: sel.names, locations: sel.located }, links, io.control?.planning); +} + +// ── The command ───────────────────────────────────────────────────────────── + +/** + * Runs `capy secrets deploy NAME...`. Returns the exit code (0 when nothing failed, 1 on a + * partial failure); a refusal exits the process itself. + */ +export async function runSecretsDeploy(names: readonly string[], opts: SecretsDeployOpts, io: SecretsDeployIo): Promise { + const json = opts.json === true; + if (names.length === 0 || !names.every(isValidVarName)) return refuseInvalidName(json); + + const rows = selectRows(await readSecretIndex(io, json), names, opts, json); + const located = excludeLocations(rows.flatMap(locatedOf), opts.exclude ?? [], json); + if (located.length === 0) { + refuse(json, ERROR_CODES.SECRETS_NOTHING_SELECTED, 'No location is left to deploy.'); // COPY-FLAG + } + const loaded = await loadLinks(io, json); + const sel: Selection = { names, located }; + const plan = await readPlan(sel, io, loaded.links); + const planId = planIdFor(sel, plan); + const planBody = { names, ...planJsonOf(plan), ...(loaded.unavailable === undefined ? {} : { repos_unavailable: loaded.unavailable }) }; + + if (opts.dryRun === true) { + if (json) console.log(JSON.stringify({ ok: true, dry_run: true, plan_id: planId, ...planBody }, null, 2)); + else console.log(renderDeployPlanText(plan, names, planId)); + return 0; + } + + if (plan.targets.length === 0) { + return refuse(json, ERROR_CODES.DEPLOY_NOTHING_TO_DEPLOY, 'No Dokploy target is left to deploy.', { plan_id: planId, plan: planBody }); // COPY-FLAG + } + if (opts.confirm === undefined) { + return refuse( + json, + ERROR_CODES.PLAN_CONFIRM_REQUIRED, + `A real run needs --confirm ${planId}. Run with --dry-run to see the plan.`, // COPY-FLAG + { plan_id: planId, plan: planBody, unanswered: [{ id: 'confirm', flag: '--confirm', value: planId }] }, + ); + } + if (opts.confirm !== planId) { + return refuse( + json, + ERROR_CODES.PLAN_CHANGED, + 'The plan is not the one that was confirmed. Nothing was changed.', // COPY-FLAG + { plan_id: planId, plan: planBody }, + ); + } + + const result: BatchResult = await runBatchDeploy(plan, io.env, { + stopPushes: io.control?.pushes, + stopPrs: io.control?.prs, + // Said once per phase when a stop is noticed with items still running; silent when nothing is in progress. + onStopping: ({ inFlight }) => (json || inFlight === 0 ? undefined : console.error(stoppingAfter(inFlight))), + }); + const ok = batchSucceeded(result); + + if (json) { + console.log( + JSON.stringify( + { + ok, + ...(ok ? {} : { code: ERROR_CODES.DEPLOY_BATCH_PARTIAL }), + plan_id: planId, + names, + ...resultJsonOf(result), + ...(loaded.unavailable === undefined ? {} : { repos_unavailable: loaded.unavailable }), + }, + null, + 2, + ), + ); + } else { + console.log(renderBatchResult(result, process.stdout.isTTY ? TERMINAL_STYLE : undefined)); + } + return ok ? 0 : 1; +} + +/** `capy secrets deploy NAME...`: builds the real service client, `gh`, key store and Dokploy adapter, then runs. */ +export async function secretsDeployCommand(names: readonly string[], opts: SecretsDeployOpts, devMode: boolean = false): Promise { + const json = opts.json === true; + // Checked before any auth or network call. + if (names.length === 0 || !names.every(isValidVarName)) return refuseInvalidName(json); + const { orgId, userId, client } = await silentContext(devMode, json); + const io: SecretsDeployIo = { orgId, client, env: createBatchEnv(orgId, userId, client, devMode) }; + return withSigintControl((control) => runSecretsDeploy(names, opts, { ...io, control })); +} diff --git a/src/commands/secretsDeployText.ts b/src/commands/secretsDeployText.ts new file mode 100644 index 00000000..a3e28285 --- /dev/null +++ b/src/commands/secretsDeployText.ts @@ -0,0 +1,211 @@ +/** + * The words and the JSON of a `capy secrets` batch deploy. Pure: a plan or a result + * in, text or data out. Names, counts, URLs and codes only; never a value, never a hash. + * + * The confirmation follows the multi-edit's look (`secretsSetText.ts`): a header, then one + * block per pull request, then what was skipped. Where `capy deploy` already has the + * words, they are its own ("Review and merge to deploy:") and the approved PR sentences. + */ +import { keepPrErrorMessage } from './keepLockPr'; +import { BatchPlan, BatchResult, PlanSkipped, TargetRef, TargetResult } from '../deploy/batchDeploy'; +import type { BatchTarget } from '../deploy/batchDeploy'; +import { ConfirmationStyle } from './secretsSetText'; + +// COPY-FLAG: every string below is minimal and neutral unless it says it is existing copy. +const REVIEW_AND_MERGE = 'Review and merge to deploy:'; // existing: `capy deploy` +/** The two approved sentences `capy deploy` writes in a deploy PR. */ +const CLOSING = [ + 'The new values are already in Dokploy. The next release of this branch will use them.', + 'Merging the PR records them in keep.lock, and it starts a release if Dokploy builds on merge.', +]; // approved by Vince 2026-10-04, from the deploy PR body +const NOTHING_TO_DEPLOY = 'Nothing to deploy.'; // COPY-FLAG +const SKIPPED_HEADING = 'Skipped'; // COPY-FLAG +const FAILED_HEADING = 'Failed'; // COPY-FLAG +const CANCELLED_HEADING = 'Cancelled'; // COPY-FLAG +const NO_PR_NEEDED = 'No pull request needed: keep.lock is already up to date.'; // COPY-FLAG +const VALUES_IN_DOKPLOY = 'The values are already in Dokploy.'; // COPY-FLAG +export const DRY_RUN_DEPLOY_NOTE = 'Dry run: nothing was changed.'; // COPY-FLAG (same words as the edit's dry run) + +const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`; + +/** Rows as aligned columns, two spaces apart, the header first. The last column is not padded. */ +export function renderTable(headings: readonly string[], rows: readonly (readonly string[])[]): readonly string[] { + const all = [headings, ...rows]; + const widths = headings.map((_, c) => Math.max(...all.map((r) => r[c].length))); + return all.map((r) => r.map((cell, c) => (c === r.length - 1 ? cell : cell.padEnd(widths[c]))).join(' ').trimEnd()); +} + +// ── The plan ───────────────────────────────────────────────────────────────── + +const location = (project: string, branch: string): string => `${project} · ${branch}`; + +/** `5 vars` or `skipped NO_TARGET`, the last column of a plan line. */ +const deployCell = (t: BatchTarget): string => plural(t.config.vars.length, 'var'); // COPY-FLAG +const skipCell = (s: PlanSkipped): string => `skipped ${s.code}`; // COPY-FLAG + +export const PLAN_HEADINGS: readonly string[] = ['LOCATION', 'TARGET', 'PROVIDER', 'DEPLOY']; // COPY-FLAG + +/** One row per target: location, target, provider, vars. */ +export const targetRows = (plan: BatchPlan): readonly (readonly string[])[] => + plan.targets.map((t) => [location(t.project_name, t.branch), t.config.name, t.config.kind, deployCell(t)]); + +/** One row per skipped location, with its reason, in the same columns as `targetRows`. */ +export const skippedRows = (plan: BatchPlan): readonly (readonly string[])[] => + plan.skipped.map((s) => [location(s.project, s.branch), s.target ?? '—', s.provider ?? '—', skipCell(s)]); // COPY-FLAG + +/** One row per target (location, target, provider, vars) and one per skipped location (with its reason). */ +export const planTableRows = (plan: BatchPlan): readonly (readonly string[])[] => [...targetRows(plan), ...skippedRows(plan)]; + +/** `Deploy API_KEY to 2 targets`; `Nothing to deploy.` when the plan has no target at all (`total`). */ +export const deployTitle = (names: readonly string[], selected: number, total: number): string => + total === 0 ? NOTHING_TO_DEPLOY : `Deploy ${names.join(', ')} to ${plural(selected, 'target')}`; // COPY-FLAG + +/** Which repos could not be read. */ +export const unreadableLines = (plan: BatchPlan): readonly string[] => + plan.read_failed.map((f) => `Could not read ${f.repo} (${f.code}).`); // COPY-FLAG + +/** The plan as lines: its table, and which repos could not be read. */ +export function planLines(plan: BatchPlan, names: readonly string[]): readonly string[] { + const rows = planTableRows(plan); + const unreadable = unreadableLines(plan); + return [ + deployTitle(names, plan.targets.length, plan.targets.length), + ...(rows.length === 0 ? [] : ['', ...renderTable(PLAN_HEADINGS, rows)]), + ...(unreadable.length === 0 ? [] : ['', ...unreadable]), + ]; +} + +/** Human text for `capy secrets deploy --dry-run` without `--json`. */ +export function renderDeployPlanText(plan: BatchPlan, names: readonly string[], planId: string): string { + return [`Plan ${planId}`, ...planLines(plan, names)].join('\n'); // COPY-FLAG +} + +/** The plan as the JSON an agent reads. Counts and names only. */ +export function planJsonOf(plan: BatchPlan) { + return { + targets: plan.targets.map((t) => ({ + project: t.project_name, + branch: t.branch, + target: t.config.name, + provider: t.config.kind, + repo: `${t.repo.owner}/${t.repo.name}`, + path: t.path, + base: t.base, + vars: t.config.vars.length, + })), + skipped: plan.skipped, + read_failed: plan.read_failed, + }; +} + +// ── The result ─────────────────────────────────────────────────────────────── + +const PLAIN: ConfirmationStyle = { muted: (text) => text }; + +/** `✗ message (CODE)`, or `✗ CODE` for a code with no message. */ +function reasonLine(code: string): string { + const message = keepPrErrorMessage(code); + return message === code ? `✗ ${code}` : `✗ ${message} (${code})`; // COPY-FLAG +} + +const refLabel = (t: TargetRef): string => `${location(t.project, t.branch)} ${t.target}`; + +const deliveredOf = (r: BatchResult): readonly Extract[] => + r.targets.flatMap((t) => (t.kind === 'delivered' ? [t] : [])); + +/** Every skip: the plan's, then those found while running (nothing to deploy), as one list. */ +export function skippedOf(r: BatchResult): readonly { readonly project: string; readonly branch: string; readonly target: string | null; readonly provider: string | null; readonly code: string }[] { + return [ + ...r.skipped, + ...r.targets.flatMap((t) => + t.kind === 'skipped' ? [{ project: t.target.project, branch: t.target.branch, target: t.target.target, provider: t.target.provider, code: t.code }] : [], + ), + ]; +} + +/** The whole confirmation, without a trailing newline. `style` greys the skipped lines in a terminal. */ +export function renderBatchResult(result: BatchResult, style: ConfirmationStyle = PLAIN): string { + const delivered = deliveredOf(result); + const failed = result.targets.flatMap((t) => (t.kind === 'failed' ? [t] : [])); + const cancelled = result.targets.flatMap((t) => (t.kind === 'cancelled' ? [t] : [])); + const skipped = skippedOf(result); + const header = + delivered.length > 0 + ? `✓ ${plural(delivered.length, 'target')} deployed${cancelled.length > 0 ? ' (cancelled)' : ''}.` // COPY-FLAG + : failed.length > 0 + ? '✗ Nothing was deployed.' // COPY-FLAG + : cancelled.length > 0 + ? 'Cancelled. Nothing was changed.' // existing: the edit's cancel line + : NOTHING_TO_DEPLOY; + const failedSection = + failed.length === 0 + ? [] + : [ + '', + FAILED_HEADING, + ...failed.map((f) => ` ${refLabel(f.target)} ${reasonLine(f.code)}${f.values_pushed ? ` ${VALUES_IN_DOKPLOY}` : ''}`), + ]; + const prSection = + delivered.length === 0 + ? [] + : [ + '', + REVIEW_AND_MERGE, + ...delivered.flatMap((d) => [ + '', + ` ${d.target.repo} · ${d.target.target}`, + ` ${d.pr_url ?? NO_PR_NEEDED}`, + ...(d.recorded ? [] : [` Not recorded on the Capy server (${d.record_code}).`]), // COPY-FLAG + ...(d.missing_vars && d.missing_vars.length > 0 ? [` Not in Capy, left out: ${d.missing_vars.join(', ')}`] : []), // COPY-FLAG + ]), + ]; + const skippedSection = + skipped.length === 0 + ? [] + : ['', SKIPPED_HEADING, ...skipped.map((s) => style.muted(` ${location(s.project, s.branch)} ${s.target ?? '—'} ${s.code}`))]; + const cancelledSection = + cancelled.length === 0 + ? [] + : [ + '', + CANCELLED_HEADING, + ...cancelled.map((c) => ` ${refLabel(c.target)} ${c.stage === 'push' ? 'not started' : 'no PR opened'}`), // COPY-FLAG + ]; + const unreadable = result.read_failed.map((f) => `Could not read ${f.repo} (${f.code}).`); // COPY-FLAG + return [ + header, + ...failedSection, + ...prSection, + ...skippedSection, + ...cancelledSection, + ...(unreadable.length === 0 ? [] : ['', ...unreadable]), + ...(delivered.length > 0 ? ['', ...CLOSING] : []), + ].join('\n'); +} + +/** The result as the JSON an agent reads. Counts, names, URLs and codes only. */ +export function resultJsonOf(result: BatchResult) { + const ref = (t: TargetRef) => ({ + project: t.project, + branch: t.branch, + target: t.target, + provider: t.provider, + repo: t.repo, + path: t.path, + }); + return { + delivered: deliveredOf(result).map((d) => ({ + ...ref(d.target), + pr_url: d.pr_url, + base: d.base, + recorded: d.recorded, + ...(d.record_code === undefined ? {} : { record_code: d.record_code }), + })), + skipped: skippedOf(result), + failed: result.targets.flatMap((t) => + t.kind === 'failed' ? [{ ...ref(t.target), code: t.code, stage: t.stage, values_pushed: t.values_pushed }] : [], + ), + cancelled: result.targets.flatMap((t) => (t.kind === 'cancelled' ? [{ ...ref(t.target), stage: t.stage, values_pushed: t.values_pushed }] : [])), + read_failed: result.read_failed, + }; +} diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index 06c43548..8ca6006f 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -127,13 +127,13 @@ const CODE_EXIT: Readonly> = { }; /** Refuses and exits: pure JSON on stdout under `--json`, the sentence on stderr otherwise. Never carries a value. */ -function refuse(json: boolean, code: string, error: string, extra: object = {}): never { +export function refuse(json: boolean, code: string, error: string, extra: object = {}): never { if (json) console.log(JSON.stringify({ ok: false, code, error, ...extra }, null, 2)); else console.error(error); return process.exit(CODE_EXIT[code] ?? exitCodeForRefusal(code)); } -function describe(err: unknown): { readonly code: string; readonly message: string } { +export function describe(err: unknown): { readonly code: string; readonly message: string } { return err instanceof CapyError ? { code: err.code, message: err.message } : { code: ERROR_CODES.SERVICE_ERROR, message: 'The service request failed.' }; // COPY-FLAG @@ -156,7 +156,7 @@ const withRowId = (row: SecretIndexRow): readonly Located[] => { })); }; -function candidatesOf(rows: readonly SecretIndexRow[]) { +export function candidatesOf(rows: readonly SecretIndexRow[]) { return rows.map((r) => ({ row_id: rowIdOf(r.name, r.value_hash), locations: r.locations.map((l) => ({ project: l.project_name, branch: l.branch, protected: l.protected })), @@ -196,16 +196,16 @@ const ambiguousStop = (named: readonly SecretIndexRow[]) => [ { id: 'row', flag: '--row', alternative: '--all-rows', candidates: candidatesOf(named) }, ]; -function excludeLocations( - located: readonly Located[], +export function excludeLocations( + located: readonly L[], exclude: readonly string[], json: boolean, -): readonly Located[] { +): readonly L[] { const parsed = exclude.map((token) => { const at = token.indexOf(':'); return { token, project: at === -1 ? token : token.slice(0, at), branch: at === -1 ? '' : token.slice(at + 1) }; }); - const matches = (l: Located, e: { project: string; branch: string }) => l.project_name === e.project && l.branch === e.branch; + const matches = (l: L, e: { project: string; branch: string }) => l.project_name === e.project && l.branch === e.branch; const unmatched = parsed.filter((e) => !located.some((l) => matches(l, e))); if (unmatched.length > 0) { refuse(json, ERROR_CODES.INVALID_FORMAT, `--exclude names no selected location: ${unmatched.map((e) => e.token).join(', ')}`); // COPY-FLAG @@ -233,7 +233,7 @@ function narrowRepos( const PLAN_DOMAIN = 'capy:secrets:plan:v1'; -const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +export const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); /** * A hash over the exact (row_id, project, branch) set and the per-repo PR set @@ -342,13 +342,13 @@ export function renderPlanText(plan: SetPlan): string { // ── The command ───────────────────────────────────────────────────────────── -interface LoadedLinks { +export interface LoadedLinks { readonly links: readonly OrgRepoLink[]; /** Set when the service has no repo links to give (it predates them): a reason, not a failure. */ readonly unavailable?: string; } -async function loadLinks(io: SecretsSetIo, json: boolean): Promise { +export async function loadLinks(io: Pick, json: boolean): Promise { try { return { links: (await io.client.getOrgRepos(io.orgId)).repos }; } catch (err) { @@ -357,7 +357,7 @@ async function loadLinks(io: SecretsSetIo, json: boolean): Promise } } -async function readSecretIndex(io: SecretsSetIo, json: boolean): Promise { +export async function readSecretIndex(io: Pick, json: boolean): Promise { try { return (await io.client.getSecretIndex(io.orgId)).rows; } catch (err) { @@ -512,7 +512,7 @@ export async function resolveSilentContext(devMode: boolean): Promise { +export async function silentContext(devMode: boolean, json: boolean): Promise { const resolved = await resolveSilentContext(devMode); return resolved.ok ? resolved.context : refuse(json, resolved.code, resolved.message); } diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index e69e987f..406efc7c 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -116,6 +116,7 @@ export const DRY_RUN_COMMANDS: ReadonlySet = new Set([ 'remove', 'secrets', 'secrets set', + 'secrets deploy', ]); /** The JSON Schemas published in `capy help --json`. */ diff --git a/src/deploy/adapter.ts b/src/deploy/adapter.ts index c19324e7..440063a2 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -263,8 +263,12 @@ export interface DeployAdapter { }; /** Sniff the user's cwd for config files; pre-fill picker defaults. */ detect(cwd: string): Promise; - /** Validate config + binaries + auth without performing the deploy. */ - preflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise; + /** + * Validate config + binaries + auth without performing the deploy. + * `keepLockPath`: keep.lock's path relative to the repo root, for a caller with no + * checkout (the batch deploy). Absent: worked out from `cwd`, as `capy deploy` does. + */ + preflight(config: TargetConfig, ctx: { cwd: string; keepLockPath?: string } & AdapterCallContext): Promise; /** Push secrets + deploy code. Adapter prints its own progress. */ deploy(config: TargetConfig, ctx: DeployContext): Promise; /** diff --git a/src/deploy/adapters/dokploy.ts b/src/deploy/adapters/dokploy.ts index 4134e0e2..685ff4fe 100644 --- a/src/deploy/adapters/dokploy.ts +++ b/src/deploy/adapters/dokploy.ts @@ -536,7 +536,8 @@ export function optionsProblem(config: TargetConfig): PreflightResult | null { */ export function dokployCiPreflightProblem( config: TargetConfig, - cwd: string, + /** keep.lock's path relative to the repo root (`keep.lock`, or `/keep.lock`): what the service's watch paths must cover. */ + relKeep: string, entity: { autoDeploy?: boolean | null; branch?: string; customGitBranch?: string | null; watchPaths?: readonly string[] | null }, ): PreflightResult | null { if (config.mode !== 'ci') return null; @@ -563,7 +564,6 @@ export function dokployCiPreflightProblem( hint, }; } - const relKeep = repoRelPath(cwd, 'keep.lock'); if (watchPathsExcludeKeep(entity.watchPaths, relKeep)) { return { ok: false, @@ -576,6 +576,14 @@ export function dokployCiPreflightProblem( return null; } +/** + * keep.lock's repo-relative path for a preflight: the caller's own (`keepLockPath`, a caller with no + * checkout such as the batch deploy), else worked out from `cwd` with git, as `capy deploy` always did. + */ +function keepLockPathFor(ctx: { cwd: string; keepLockPath?: string }): string { + return ctx.keepLockPath ?? repoRelPath(ctx.cwd, 'keep.lock'); +} + /** A failed Dokploy call as a reason (and fix-it hint) a person can act on. */ export function explainApiError( err: unknown, @@ -1107,7 +1115,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt return {}; }, - async preflight(config: TargetConfig, ctx: { cwd: string } & AdapterCallContext): Promise { + async preflight(config: TargetConfig, ctx: { cwd: string; keepLockPath?: string } & AdapterCallContext): Promise { const shape = optionsProblem(config); if (shape) return shape; const opts = config.options as unknown as DokployOptions; @@ -1129,7 +1137,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt // active line for a delivered var gets COMMENTED OUT at deploy time // (see `mergeManagedValuesBlock`), not silently shadowed, so warning // about it here would be actively misleading. - const ciProblem = dokployCiPreflightProblem(config, ctx.cwd, compose.value); + const ciProblem = dokployCiPreflightProblem(config, keepLockPathFor(ctx), compose.value); if (ciProblem) return ciProblem; const stackWarning = await composeStackVersionWarning(client, compose.value); return { ok: true, ...(stackWarning ? { warnings: [stackWarning] } : {}) }; @@ -1138,7 +1146,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt if (!app.ok) return { ok: false, ...explainApiError(app.error, 'application.one', opts) }; const problem = envProblems(app.value.env); if (problem) return { ok: false, ...describeEnvProblem(problem) }; - const ciProblem = dokployCiPreflightProblem(config, ctx.cwd, app.value); + const ciProblem = dokployCiPreflightProblem(config, keepLockPathFor(ctx), app.value); if (ciProblem) return ciProblem; return { ok: true }; }, diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts new file mode 100644 index 00000000..a0e63ad3 --- /dev/null +++ b/src/deploy/batchDeploy.ts @@ -0,0 +1,757 @@ +/** + * Batch deploy (CAP-704), from `capy secrets`: deploy the Dokploy targets of the + * selected secrets WITHOUT a project checkout. + * + * "Deploy" is exactly what `capy deploy ` does for a Dokploy target in CI + * mode: push the current values into the Dokploy service's env (Capy's managed + * block, plain KEY=value lines), record the delivery on the Capy server, and open + * the keep.lock PR whose merge triggers Dokploy's own auto-deploy. Capy never + * triggers a deploy itself here. Everything else is skipped with a coded reason: + * a target that is not Dokploy, not CI mode, or not on the default branch. + * + * Nothing here needs a clone: + * - TARGET CONFIG is read from `.capy/deploy.json` on the repo's DEFAULT branch + * through the GitHub API. A target only in an unmerged discover PR does not count. + * - VALUES are decrypted with the one run-wide key resolver (one unlock per run) + * from the Capy service's blob for the target's project and branch. + * - PREFLIGHT is the adapter's own, given keep.lock's repo-relative path. + * - The RECORD on the Capy server is built from the SERVER's keep + * (`deliveryRecord.ts`), never from a local keep.lock. + * - The PR is opened through the GitHub API (`openKeepLockPullRequest`), with the + * same description `capy deploy` writes for the target. + * + * A run has three phases, each stoppable the way the multi-edit is: values are + * pushed (targets run `BATCH_DEPLOY_CONCURRENCY` at a time, results in input + * order), then every delivery is recorded (one server write per project and + * branch, so two targets of one branch never overwrite each other's record), then + * one PR is opened per target (one Dokploy service is one target; two targets in + * one repo are two PRs). + * + * A value is never printed, returned, logged or put in an error: it lives inside + * `pushTarget` and is gone when it returns. Failures are codes. + */ +import { FileManager } from '../files/fileManager'; +import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; +import type { DeployAdapter, TargetConfig } from './adapter'; +import type { OrgRepoLink, SecretIndexLocation, ServiceClient } from '../service/serviceClient'; +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 { buildDeployPrBody } from './deployPrBody'; +import { foldDeployKeep, hashValue } from './keepGate'; +import { parseTargets } from './configuredTargets'; +import { TargetDeliveryDescriptor, VarDelivery, deliveryWorthGating } from './targetsGate'; +import { callWithBackoff, retryOnRateLimit } from '../service/capyCalls'; +import { PACING_RESERVE, PoolItem, RateInfo, runPool } from '../utils/pool'; +import type { Sleep } from '../utils/backoff'; +import { KeepLockFileSpec, codeForApiFailure, newPrBranchName, openKeepLockPullRequest, readKeepLockBases } from '../commands/keepLockPr'; +import { GITHUB_HOST, KeyResolver, RepoTarget, keepLockPathOf, realGithub, repoKey, repoLabel, resolveBases } from '../commands/secretsSet'; +import { createRunKeyResolver } from '../crypto/keyResolver'; +import { deployJsonPathOf } from '../commands/deployDiscover/facts'; + +// --------------------------------------------------------------------------- +// Constants and codes +// --------------------------------------------------------------------------- + +/** Targets pushed to Dokploy at once, and PRs opened at once. */ +export const BATCH_DEPLOY_CONCURRENCY = 4; +/** `.capy/deploy.json` files read from GitHub at once. */ +export const CONFIG_READ_CONCURRENCY = 4; + +/** Why a target was not deployed. A reason, not a failure. */ +export const SKIP_CODES = { + /** The location has no Capy deploy target at all. */ + NO_TARGET: 'NO_TARGET', + /** Capy knows the target (it delivered to it), but `.capy/deploy.json` on the default branch does not list it. */ + TARGET_NOT_ON_DEFAULT_BRANCH: 'TARGET_NOT_ON_DEFAULT_BRANCH', + /** The target is not a Dokploy target. */ + NOT_DOKPLOY: 'NOT_DOKPLOY', + /** The target is a direct-mode target. */ + NOT_CI_MODE: 'NOT_CI_MODE', + /** Capy's keep.lock on the PR base already records exactly these values for this target. */ + NOTHING_TO_DEPLOY: 'NOTHING_TO_DEPLOY', +} as const; +export type SkipCode = (typeof SKIP_CODES)[keyof typeof SKIP_CODES]; + +/** Where a failure happened. `pr` and `record` come after the values reached Dokploy. */ +export type FailStage = 'github' | 'preflight' | 'values' | 'push' | 'pr'; + +// --------------------------------------------------------------------------- +// The plan: what would be deployed +// --------------------------------------------------------------------------- + +/** One (project, branch) a selected secret lives in. */ +export interface DeployLocation { + readonly project_id: string; + readonly project_name: string; + readonly branch: string; + /** The (provider, target) pairs the Capy server lists for this location: targets Capy has delivered to. */ + readonly known: ReadonlyArray<{ readonly provider: string; readonly target: string }>; +} + +/** The locations of one `capy secrets` row, with the targets Capy has delivered to at each. */ +export const deployLocationsOf = (row: { readonly locations: readonly SecretIndexLocation[] }): readonly DeployLocation[] => + row.locations.map((l) => ({ + project_id: l.project_id, + project_name: l.project_name, + branch: l.branch, + known: (l.targets ?? []).map((t) => ({ provider: t.provider, target: t.target })), + })); + +export interface DeployRequest { + /** The selected secret names: a target is relevant when it ships at least one of them. */ + readonly names: readonly string[]; + readonly locations: readonly DeployLocation[]; +} + +/** One Dokploy CI target that can be deployed. */ +export interface BatchTarget { + readonly project_id: string; + readonly project_name: string; + /** The Capy branch. */ + readonly branch: string; + readonly repo: RepoRef; + /** The project's folder in the repo (`.` for the root). */ + readonly path: string; + /** The PR base: the target's `gitBaseBranch`, else the repo's default branch. */ + readonly base: string; + readonly config: TargetConfig; +} + +export interface PlanSkipped { + readonly project: string; + readonly branch: string; + readonly target: string | null; + readonly provider: string | null; + readonly code: SkipCode; +} + +export interface PlanReadFailure { + readonly repo: string; + readonly code: string; +} + +export interface BatchPlan { + readonly targets: readonly BatchTarget[]; + readonly skipped: readonly PlanSkipped[]; + /** Repos whose default branch or `.capy/deploy.json` could not be read: their locations are not decided either way. */ + readonly read_failed: readonly PlanReadFailure[]; +} + +export const EMPTY_PLAN: BatchPlan = { targets: [], skipped: [], read_failed: [] }; + +const unique = (items: readonly T[]): readonly T[] => [...new Set(items)]; + +/** The same target reached twice (two selected rows, two locations) is one target. */ +export const targetKeyOf = (t: BatchTarget): string => [t.project_id, repoKey({ host: GITHUB_HOST, ...t.repo }), t.path, t.config.name].join('\u0000'); + +/** + * The plan restricted to the targets whose key (`targetKeyOf`) is in `selectedKeys`. The skipped + * locations and the repos that could not be read stay: they were never deployable, and the result + * still lists them. Pure; the plan it is given is not changed. + */ +export const restrictPlan = (plan: BatchPlan, selectedKeys: readonly string[]): BatchPlan => ({ + ...plan, + targets: plan.targets.filter((t) => selectedKeys.includes(targetKeyOf(t))), +}); + +const refOf = (repo: RepoRef): RepoTarget => ({ host: GITHUB_HOST, owner: repo.owner, name: repo.name, files: [] }); + +interface LinkRead { + readonly link: OrgRepoLink; + /** The targets in the project's deploy.json on the default branch, or why they could not be read. */ + readonly read: { readonly ok: true; readonly targets: readonly TargetConfig[]; readonly base: string } | { readonly ok: false; readonly code: string }; +} + +/** What one location comes to, given what its links' deploy.json files hold. */ +function classifyLocation( + loc: DeployLocation, + reads: readonly LinkRead[], + names: readonly string[], +): { readonly targets: readonly BatchTarget[]; readonly skipped: readonly PlanSkipped[] } { + const skip = (target: string | null, provider: string | null, code: SkipCode): PlanSkipped => ({ + project: loc.project_name, + branch: loc.branch, + target, + provider, + code, + }); + const readable = reads.flatMap((r) => (r.read.ok ? [{ link: r.link, targets: r.read.targets, base: r.read.base }] : [])); + // A location whose deploy.json could not be read is not decided: it is in `read_failed`, not here. + if (readable.length < reads.length) return { targets: [], skipped: [] }; + + const found = readable.flatMap(({ link, targets, base }) => + targets + .filter((t) => t.branch === loc.branch && t.vars.some((v) => names.includes(v))) + .map((config) => ({ link, config, base })), + ); + const notOnDefault = loc.known + .filter((k) => !found.some((f) => f.config.kind === k.provider && f.config.name === k.target)) + .map((k) => skip(k.target, k.provider, SKIP_CODES.TARGET_NOT_ON_DEFAULT_BRANCH)); + const foundSkips = found.flatMap(({ config }): PlanSkipped[] => { + if (config.kind !== 'dokploy') return [skip(config.name, config.kind, SKIP_CODES.NOT_DOKPLOY)]; + // `capy deploy` treats a target saved without a mode as direct. + return (config.mode ?? 'direct') === 'ci' ? [] : [skip(config.name, config.kind, SKIP_CODES.NOT_CI_MODE)]; + }); + const targets = found + .filter(({ config }) => config.kind === 'dokploy' && (config.mode ?? 'direct') === 'ci') + .map(({ link, config, base }): BatchTarget => ({ + project_id: loc.project_id, + project_name: loc.project_name, + branch: loc.branch, + repo: { owner: link.owner, name: link.name }, + path: link.path, + base: config.gitBaseBranch ?? base, + config, + })); + const skipped = [...notOnDefault, ...foundSkips]; + return { + targets, + skipped: targets.length === 0 && skipped.length === 0 ? [skip(null, null, SKIP_CODES.NO_TARGET)] : skipped, + }; +} + +/** + * What a batch deploy of `req` would do. Reads only: the repos' default branches and each + * project's `.capy/deploy.json` on that branch, from GitHub. It decrypts nothing and writes + * nothing, so a dry run is this and nothing else. `links` are the org's project to repo + * links; only GitHub repos count. Never throws. + */ +export async function planBatchDeploy( + github: GithubApi | undefined, + req: DeployRequest, + links: readonly OrgRepoLink[], + signal?: AbortSignal, +): Promise { + const linksOf = (loc: DeployLocation): readonly OrgRepoLink[] => + links.filter((l) => l.project_id === loc.project_id && l.host.toLowerCase() === GITHUB_HOST); + const used = req.locations.flatMap(linksOf); + const repos = unique(used.map((l) => repoKey(l))).map((k) => refOf(used.find((l) => repoKey(l) === k) as OrgRepoLink)); + if (repos.length > 0 && github === undefined) { + return { ...EMPTY_PLAN, read_failed: [{ repo: GITHUB_HOST, code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }] }; + } + + const bases = await resolveBases(github, repos, signal); + const fileKeys = unique(used.map((l) => `${repoKey(l)}\u0000${l.path}`)); + const files = await runPool({ + items: fileKeys, + limit: CONFIG_READ_CONCURRENCY, + run: async (key): Promise> => { + const link = used.find((l) => `${repoKey(l)}\u0000${l.path}` === key) as OrgRepoLink; + const base = bases[repoKey(link)]; + if (github === undefined || base === undefined) { + return { result: [key, { ok: false, code: ERROR_CODES.KEEP_PR_BASE_UNRESOLVED }] }; + } + const got = await github.getFile({ owner: link.owner, name: link.name }, deployJsonPathOf(link.path), base); + if (!got.ok) return { result: [key, { ok: false, code: codeForApiFailure(got, ERROR_CODES.KEEP_PR_READ_FAILED) }] }; + return { result: [key, { ok: true, targets: got.value === null ? [] : parseTargets(got.value), base }] }; + }, + }); + const readByKey = Object.fromEntries(files.results); + + const perLocation = req.locations.map((loc) => + classifyLocation( + loc, + linksOf(loc).map((link): LinkRead => ({ link, read: readByKey[`${repoKey(link)}\u0000${link.path}`] })), + req.names, + ), + ); + const allTargets = perLocation.flatMap((p) => p.targets); + const targets = allTargets.filter((t, i) => allTargets.findIndex((o) => targetKeyOf(o) === targetKeyOf(t)) === i); + const failures = used + .flatMap((l) => { + const read = readByKey[`${repoKey(l)}\u0000${l.path}`]; + return read !== undefined && !read.ok ? [{ repo: repoLabel(l), code: read.code }] : []; + }) + .filter((f, i, all) => all.findIndex((o) => o.repo === f.repo && o.code === f.code) === i); + return { targets, skipped: perLocation.flatMap((p) => p.skipped), read_failed: failures }; +} + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +/** The part of a target a person or an agent reads. */ +export interface TargetRef { + readonly project: string; + readonly branch: string; + readonly target: string; + readonly provider: string; + readonly repo: string; + readonly path: string; + readonly vars: number; +} + +export const targetRefOf = (t: BatchTarget): TargetRef => ({ + project: t.project_name, + branch: t.branch, + target: t.config.name, + provider: t.config.kind, + repo: repoLabel(t.repo), + path: t.path, + vars: t.config.vars.length, +}); + +export type TargetResult = + | { + readonly kind: 'delivered'; + readonly target: TargetRef; + /** `null`: the PR base already carried this keep.lock, so none was needed. */ + readonly pr_url: string | null; + readonly base: string; + /** `false`: the values are in Dokploy but Capy's server could not record the delivery (`record_code` says why). */ + readonly recorded: boolean; + readonly record_code?: string; + /** Names the target lists that had no value in Capy, so were not delivered. Absent when none. */ + readonly missing_vars?: readonly string[]; + } + | { readonly kind: 'skipped'; readonly target: TargetRef; readonly code: SkipCode } + | { + readonly kind: 'failed'; + readonly target: TargetRef; + readonly code: string; + readonly stage: FailStage; + /** `true`: the values are already in Dokploy (only the PR failed). */ + readonly values_pushed: boolean; + } + | { readonly kind: 'cancelled'; readonly target: TargetRef; readonly stage: 'push' | 'pr'; readonly values_pushed: boolean }; + +export interface BatchResult { + /** One per planned target, in plan order. */ + readonly targets: readonly TargetResult[]; + /** The plan's own skips (no target, not on the default branch, not Dokploy, not CI). */ + readonly skipped: readonly PlanSkipped[]; + readonly read_failed: readonly PlanReadFailure[]; +} + +/** `true` when every target was delivered or skipped and none was cancelled or failed. */ +export const batchSucceeded = (result: BatchResult): boolean => + result.targets.every((t) => t.kind === 'delivered' || t.kind === 'skipped'); + +// --------------------------------------------------------------------------- +// Environment +// --------------------------------------------------------------------------- + +/** Everything external, injected: tests pass fakes; `createBatchEnv` builds the real one. */ +export interface BatchEnv { + readonly client: Pick & RecordClient; + readonly orgId: string; + /** Unlocks ONCE for one run and returns the project-key resolver for it. Called once per run, and only when a key will be needed. */ + readonly openKeys: () => KeyResolver; + /** `undefined` when `gh` is not installed. */ + readonly github: () => GithubApi | undefined; + readonly branchName: () => string; + /** The Dokploy adapter: its own preflight and delivery. */ + readonly adapter: DeployAdapter; + /** The Dokploy API key for a target, from the org system store (never prompts). `undefined`: the adapter finds it itself. */ + readonly resolveApiKey: (target: TargetConfig) => Promise; + readonly devMode?: boolean; + readonly sleep?: Sleep; + /** The clock, epoch ms (also the pacing clock). */ + readonly now?: () => number; +} + +export type BatchPhase = 'pushing' | 'recording' | 'prs'; + +export interface BatchProgress { + readonly phase: BatchPhase; + readonly done: number; + readonly inFlight: number; + readonly total: number; +} + +/** + * Cooperative cancel, checked BETWEEN items so a Dokploy write or a PR call is never cut in half. + * `stopPushes`: push no further target. `stopPrs`: open no further PR. Every item already running + * finishes. (A stop of the pushes still records and opens the PRs for what WAS pushed.) + */ +export interface BatchHooks { + readonly onProgress?: (progress: BatchProgress) => void; + readonly onStopping?: (stopping: { readonly phase: BatchPhase; readonly inFlight: number }) => void; + readonly stopPushes?: AbortSignal; + readonly stopPrs?: AbortSignal; +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +const codeOf = (err: unknown): string => (err instanceof CapyError ? err.code : ERROR_CODES.SERVICE_ERROR); + +type Tried = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; + +const settled = (promise: Promise): Promise> => + promise.then( + (value): Tried => ({ ok: true, value }), + (error: unknown): Tried => ({ ok: false, error }), + ); + +function attempt(f: () => T): Tried { + try { + return { ok: true, value: f() }; + } catch (error) { + return { ok: false, error }; + } +} + +const withRate = (result: R, rate: RateInfo | undefined): PoolItem => ({ result, ...(rate === undefined ? {} : { rate }) }); + +/** Never called: stands in for the resolver of a run that pushes nothing. */ +const NO_KEYS: KeyResolver = async () => { + throw new CapyError('no keys were opened', ERROR_CODES.SERVICE_ERROR); +}; + +const parseKeep = (raw: string | undefined): KeepFile | undefined => { + if (raw === undefined || raw === '') return undefined; + try { + const parsed: unknown = JSON.parse(raw); + const obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : undefined; + const vars = obj?.variables; + return typeof vars === 'object' && vars !== null && !Array.isArray(vars) ? (parsed as KeepFile) : undefined; + } catch { + return undefined; + } +}; + +/** + * The target's variables, decrypted from the branch's blob. A listed name with no value on the + * branch is left out and named in `missing`, the way `capy deploy` drops it; only a target with + * NO deliverable variable at all fails. Throws nothing; the value is never in a failure. + */ +function decryptVars( + blob: string, + vars: readonly string[], + projectKey: string, +): + | { readonly ok: true; readonly values: Readonly>; readonly missing: readonly string[] } + | { readonly ok: false; readonly code: string } { + const fm = new FileManager(); + const stored = fm.parseEnvContent(blob); + const present = vars.filter((v) => stored[v] !== undefined); + const missing = vars.filter((v) => stored[v] === undefined); + if (present.length === 0) return { ok: false, code: ERROR_CODES.DEPLOY_VARS_MISSING }; + const decrypted = attempt(() => Object.fromEntries(present.map((v) => [v, fm.decryptValue(stored[v], projectKey)]))); + return decrypted.ok ? { ok: true, values: decrypted.value, missing } : { ok: false, code: codeOf(decrypted.error) }; +} + +/** The Dokploy API keys of a run, resolved once per distinct `tokenEnv` (a target without one uses the org store's). */ +export interface ApiKeys { + readonly forTarget: (target: TargetConfig) => ResolveDokployApiKeyResult | undefined; +} + +const tokenEnvOf = (t: TargetConfig): string => String((t.options as { tokenEnv?: unknown }).tokenEnv ?? ''); + +async function resolveApiKeys(env: BatchEnv, targets: readonly BatchTarget[]): Promise { + const groups = unique(targets.map((t) => tokenEnvOf(t.config))); + const resolved = await Promise.all( + groups.map(async (group): Promise => { + const representative = targets.find((t) => tokenEnvOf(t.config) === group) as BatchTarget; + const key = await settled(env.resolveApiKey(representative.config)); + return [group, key.ok ? key.value : undefined]; + }), + ); + const byGroup = Object.fromEntries(resolved); + return { forTarget: (target) => byGroup[tokenEnvOf(target)] }; +} + +const NO_API_KEYS: ApiKeys = { forTarget: () => undefined }; + +// --------------------------------------------------------------------------- +// Phase 1: one target's values reach Dokploy +// --------------------------------------------------------------------------- + +/** What a pushed target carries to the record and PR phases. Hashes only: no value. */ +export interface Pushed { + readonly kind: 'pushed'; + readonly item: BatchTarget; + readonly delivery: TargetDeliveryDescriptor; + readonly values: readonly VarDelivery[]; + readonly hashes: Readonly>; + readonly deliveredAt: string; + /** keep.lock as the Capy server has it, with no variables: the identity a missing keep.lock on the base is scaffolded from. */ + readonly scaffold: KeepFile; + /** Names the target lists but the branch has no value for: left out of this delivery. */ + readonly missing: readonly string[]; +} + +/** The target with only the names that have a value; unchanged when nothing is missing. */ +const effectiveTarget = (item: BatchTarget, missing: readonly string[]): BatchTarget => + missing.length === 0 ? item : { ...item, config: { ...item.config, vars: item.config.vars.filter((v) => !missing.includes(v)) } }; + +export type PushOutcome = + | Pushed + | { readonly kind: 'skipped'; readonly item: BatchTarget; readonly code: SkipCode } + | { readonly kind: 'failed'; readonly item: BatchTarget; readonly code: string; readonly stage: FailStage }; + +const failedPush = (item: BatchTarget, code: string, stage: FailStage, rate?: RateInfo): PoolItem => + withRate({ kind: 'failed', item, code, stage }, rate); + +/** The keep.lock spec for a target's PR: its delivery folded into whatever the base holds. */ +function specFor(p: Pushed): KeepLockFileSpec { + return { + path: keepLockPathOf(p.item.path), + records: [], + localKeep: p.scaffold, + fold: (base) => foldDeployKeep(base, p.hashes, p.item.config.vars, p.item.branch, p.delivery, p.deliveredAt), + }; +} + +/** + * One target, up to and including the delivery to Dokploy: preflight, read and decrypt its + * variables, check against the PR base's keep.lock that something would change, push. + * Never throws; reports the rate-limit reading it saw. + */ +export async function pushTarget(env: BatchEnv, keys: KeyResolver, apiKeys: ApiKeys, item: BatchTarget): Promise> { + const config = item.config; + const github = env.github(); + if (github === undefined) return failedPush(item, ERROR_CODES.KEEP_PR_GH_UNAVAILABLE, 'github'); + const callCtx = { orgId: env.orgId, devMode: env.devMode, interactive: false, resolvedApiKey: apiKeys.forTarget(config) }; + + // Fail before anything is decrypted, like `capy deploy`. + const preflight = await settled( + env.adapter.preflight(config, { cwd: process.cwd(), keepLockPath: keepLockPathOf(item.path), ...callCtx }), + ); + if (!preflight.ok) return failedPush(item, codeOf(preflight.error), 'preflight'); + if (!preflight.value.ok) return failedPush(item, preflight.value.code ?? ERROR_CODES.DEPLOY_PREFLIGHT_FAILED, 'preflight'); + + const key = await settled(keys(item.project_id)); + if (!key.ok) return failedPush(item, codeOf(key.error), 'values'); + const read = await callWithBackoff((onRate) => env.client.getDecryptData(item.project_id, item.branch, undefined, true, onRate), env.sleep); + if (!read.ok) return failedPush(item, codeOf(read.error), 'values', read.rate); + const serverKeep = parseKeep(read.value.keep_file); + if (serverKeep === undefined) return failedPush(item, ERROR_CODES.NO_KEEP_FILE, 'values', read.rate); + const decrypted = decryptVars(read.value.env_content ?? '', config.vars, key.value); + if (!decrypted.ok) return failedPush(item, decrypted.code, 'values', read.rate); + // From here on the target carries only the names that have a value (`capy deploy` does the same). + const effective = effectiveTarget(item, decrypted.missing); + const hashes = Object.fromEntries(effective.config.vars.map((v) => [v, hashValue(decrypted.values[v])])); + const { delivery, values } = deliveryFor(effective.config, env.adapter, undefined, false, hashes); + + // Nothing is pushed unless the PR can be opened afterwards: the base exists, and its keep.lock would change. + const scaffold: KeepFile = { ...serverKeep, variables: {} }; + const repo = item.repo; + const head = await github.getBranchHead(repo, item.base); + if (!head.ok) { + return failedPush( + item, + codeForApiFailure(head, head.kind === 'NOT_FOUND' ? ERROR_CODES.KEEP_PR_BASE_UNRESOLVED : ERROR_CODES.KEEP_PR_READ_FAILED), + 'github', + read.rate, + ); + } + const base = await readKeepLockBases(github, repo, item.base, [{ path: keepLockPathOf(item.path), records: [], localKeep: scaffold }]); + if (!base.ok) return failedPush(item, base.code, 'github', read.rate); + if (!deliveryWorthGating(base.bases[0].keep, item.branch, delivery.provider, config.name, undefined, values)) { + return withRate({ kind: 'skipped', item, code: SKIP_CODES.NOTHING_TO_DEPLOY }, read.rate); + } + + // CI mode: secrets only. Merging the PR is what triggers Dokploy's own auto-deploy. + const delivered = await settled( + env.adapter.deploy(effective.config, { env: decrypted.values, dryRun: false, secretsOnly: true, noDeploy: false, cwd: process.cwd(), ...callCtx }), + ); + if (!delivered.ok) return failedPush(item, codeOf(delivered.error), 'push', read.rate); + if (!delivered.value.ok) { + const stepCode = delivered.value.steps.find((s) => s.status === 'fail' && s.code !== undefined)?.code; + return failedPush(item, stepCode ?? ERROR_CODES.DEPLOY_PUSH_FAILED, 'push', read.rate); + } + return withRate( + { kind: 'pushed', item: effective, delivery, values, hashes, deliveredAt: new Date((env.now ?? Date.now)()).toISOString(), scaffold, missing: decrypted.missing }, + read.rate, + ); +} + +// --------------------------------------------------------------------------- +// Phase 2: the delivery is recorded on the Capy server +// --------------------------------------------------------------------------- + +const groupKey = (p: Pushed): string => `${p.item.project_id}\u0000${p.item.branch}`; + +/** The recorder's client, with a 429 waited out (bounded) like every other bulk call. */ +const patientClient = (env: BatchEnv): RecordClient => ({ + getLatestSecrets: (projectId, branch) => retryOnRateLimit(() => env.client.getLatestSecrets(projectId, branch), env.sleep), + pushSecrets: (projectId, keepFile, envBlob, branch) => retryOnRateLimit(() => env.client.pushSecrets(projectId, keepFile, envBlob, branch), env.sleep), +}); + +/** Records every pushed target of ONE (project, branch) with ONE server write. */ +async function recordGroup(env: BatchEnv, group: readonly Pushed[]): Promise { + return recordDeliveriesOnServer( + patientClient(env), + group[0].item.project_id, + group[0].item.branch, + group.map((p) => ({ delivery: p.delivery, values: p.values, deliveredAt: p.deliveredAt })), + ); +} + +/** + * Records the deliveries on the Capy server, built from the SERVER's keep. Targets of one + * project and branch share one read-modify-write (two writers would drop each other's record); + * different (project, branch) groups run `BATCH_DEPLOY_CONCURRENCY` at a time. The result of + * each target is its group's, in `pushed` order. + */ +export async function recordPushed( + env: BatchEnv, + pushed: readonly Pushed[], + onChange?: (progress: { readonly done: number; readonly inFlight: number; readonly total: number }) => void, +): Promise { + const groups = unique(pushed.map(groupKey)).map((k) => pushed.filter((p) => groupKey(p) === k)); + const recorded = await runPool({ + items: groups, + limit: BATCH_DEPLOY_CONCURRENCY, + run: async (group): Promise> => ({ result: await recordGroup(env, group) }), + onChange, + }); + const byGroup = Object.fromEntries(groups.map((g, i) => [groupKey(g[0]), recorded.results[i]])); + return pushed.map((p) => byGroup[groupKey(p)]); +} + +// --------------------------------------------------------------------------- +// Phase 3: the PR +// --------------------------------------------------------------------------- + +type PrOutcome = + | { readonly ok: true; readonly pr_url: string | null } + | { readonly ok: false; readonly code: string }; + +/** + * Opens the target's keep.lock PR through the GitHub API, on the target's base, with the + * description `capy deploy` writes for it. One PR per target. + */ +export async function openTargetPr(env: BatchEnv, pushed: Pushed): Promise { + const github = env.github(); + if (github === undefined) return { ok: false, code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }; + const { config, branch } = pushed.item; + const label = `${config.name} → ${branch} (${config.kind})`; + const opened = await openKeepLockPullRequest( + { + command: 'deploy', + repo: pushed.item.repo, + files: [specFor(pushed)], + base: pushed.item.base, + wording: { commitMessage: `chore(deploy): ${label}`, title: `deploy: ${label}`, body: buildDeployPrBody(config) }, + }, + { github, branchName: env.branchName }, + ); + if (opened.ok) return { ok: true, pr_url: opened.pr_url }; + // The base already carries this keep.lock: the values are in Dokploy and there is nothing to merge. + return opened.reason === 'NO_DIFF_VS_BASE' ? { ok: true, pr_url: null } : { ok: false, code: opened.code }; +} + +// --------------------------------------------------------------------------- +// One target, whole +// --------------------------------------------------------------------------- + +const deliveredResult = (p: Pushed, pr: Extract, recorded: RecordResult): TargetResult => ({ + kind: 'delivered', + target: targetRefOf(p.item), + pr_url: pr.pr_url, + base: p.item.base, + recorded: recorded.ok, + ...(recorded.ok ? {} : { record_code: recorded.code }), + ...(p.missing.length > 0 ? { missing_vars: p.missing } : {}), +}); + +/** + * ONE target, whole and with no checkout: push its values to Dokploy, record the delivery + * on the Capy server, open its PR. `capy secrets deploy` runs many of these in phases + * (`runBatchDeploy`); this is the same three steps for one. Never throws. + */ +export async function deployTarget(env: BatchEnv, keys: KeyResolver, apiKeys: ApiKeys, item: BatchTarget): Promise { + const pushed = (await pushTarget(env, keys, apiKeys, item)).result; + if (pushed.kind === 'skipped') return { kind: 'skipped', target: targetRefOf(item), code: pushed.code }; + if (pushed.kind === 'failed') { + return { kind: 'failed', target: targetRefOf(item), code: pushed.code, stage: pushed.stage, values_pushed: false }; + } + const [recorded] = await recordPushed(env, [pushed]); + const pr = await openTargetPr(env, pushed); + return pr.ok + ? deliveredResult(pushed, pr, recorded) + : { kind: 'failed', target: targetRefOf(item), code: pr.code, stage: 'pr', values_pushed: true }; +} + +// --------------------------------------------------------------------------- +// The run +// --------------------------------------------------------------------------- + +/** + * Deploys every target of `plan`: pushes `BATCH_DEPLOY_CONCURRENCY` at a time after ONE + * unlock, records the deliveries, then opens one PR per pushed target. One failure never + * blocks another, and nothing waits forever (every GitHub call and every service call has a + * timeout; a rate limit is waited out a bounded number of times). `hooks` reports progress + * and lets the caller stop: a stop starts nothing further and lets every item already running + * finish; what it left unstarted is `cancelled`. Everything is in plan order. + */ +export async function runBatchDeploy(plan: BatchPlan, env: BatchEnv, hooks: BatchHooks = {}): Promise { + const stoppedAtStart = hooks.stopPushes?.aborted === true; + const keys = plan.targets.length === 0 || stoppedAtStart ? NO_KEYS : env.openKeys(); + const apiKeys = plan.targets.length === 0 || stoppedAtStart ? NO_API_KEYS : await resolveApiKeys(env, plan.targets); + + const pushedPhase = await runPool({ + items: plan.targets, + limit: BATCH_DEPLOY_CONCURRENCY, + run: (item) => pushTarget(env, keys, apiKeys, item), + stop: hooks.stopPushes, + reserve: PACING_RESERVE, + now: env.now, + sleep: env.sleep, + onChange: (p) => hooks.onProgress?.({ phase: 'pushing', ...p }), + onStopping: (inFlight) => hooks.onStopping?.({ phase: 'pushing', inFlight }), + }); + const outcomes = pushedPhase.results; + const pushed = outcomes.filter((o): o is Pushed => o.kind === 'pushed'); + + const recorded = await recordPushed(env, pushed, (p) => hooks.onProgress?.({ phase: 'recording', ...p })); + const recordedFor = (p: Pushed): RecordResult => recorded[pushed.indexOf(p)]; + + const opened = await runPool({ + items: pushed, + limit: BATCH_DEPLOY_CONCURRENCY, + run: async (p): Promise> => ({ result: await openTargetPr(env, p) }), + stop: hooks.stopPrs, + onChange: (p) => hooks.onProgress?.({ phase: 'prs', ...p }), + onStopping: (inFlight) => hooks.onStopping?.({ phase: 'prs', inFlight }), + }); + const prOf = (p: Pushed): PrOutcome | undefined => opened.results[pushed.indexOf(p)]; + + const targets = plan.targets.map((item, i): TargetResult => { + const ref = targetRefOf(item); + const outcome = outcomes[i]; + if (outcome === undefined) return { kind: 'cancelled', target: ref, stage: 'push', values_pushed: false }; + if (outcome.kind === 'skipped') return { kind: 'skipped', target: ref, code: outcome.code }; + if (outcome.kind === 'failed') return { kind: 'failed', target: ref, code: outcome.code, stage: outcome.stage, values_pushed: false }; + const pr = prOf(outcome); + if (pr === undefined) return { kind: 'cancelled', target: ref, stage: 'pr', values_pushed: true }; + return pr.ok + ? deliveredResult(outcome, pr, recordedFor(outcome)) + : { kind: 'failed', target: ref, code: pr.code, stage: 'pr', values_pushed: true }; + }); + return { targets, skipped: plan.skipped, read_failed: plan.read_failed }; +} + +// --------------------------------------------------------------------------- +// Real environment +// --------------------------------------------------------------------------- + +/** The Dokploy adapter, with its progress lines turned off: stdout belongs to the caller. */ +export const quietDokployAdapter = (): DeployAdapter => createDokployAdapter({ log: () => undefined }); + +/** The real `BatchEnv`: one unlock per run through the same co-decrypt path `capy edit` uses, the real `gh`, the org system store for the Dokploy key. */ +export function createBatchEnv(orgId: string, userId: string, client: ServiceClient, devMode: boolean = false): BatchEnv { + const adapter = quietDokployAdapter(); + return { + client, + orgId, + openKeys: () => + createRunKeyResolver(orgId, userId, { + // The unlock is one call: a 429 on it is waited out (bounded) before every target fails with the same code. + coDecrypt: (oid, ct) => retryOnRateLimit(() => client.coDecrypt(oid, ct).then((r) => r.plaintext)), + wrapOuterLayer: (oid, pt) => client.wrapOuterLayer(oid, pt).then((r) => r.ciphertext), + }), + github: realGithub, + branchName: () => newPrBranchName(undefined, undefined, 'deploy'), + adapter, + resolveApiKey: (target) => resolveDokployApiKeyOnce(adapter, target, orgId, devMode, false), + devMode, + }; +} diff --git a/src/deploy/configuredTargets.ts b/src/deploy/configuredTargets.ts index 8a648a6e..8dea201a 100644 --- a/src/deploy/configuredTargets.ts +++ b/src/deploy/configuredTargets.ts @@ -33,7 +33,8 @@ export function readDefaultBranchTargets(cwd: string): readonly TargetConfig[] { } } -function parseTargets(raw: string): readonly TargetConfig[] { +/** The targets of a `.capy/deploy.json` text: `[]` when it is not a version-1 file or cannot be parsed. */ +export function parseTargets(raw: string): readonly TargetConfig[] { try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed) || parsed.version !== '1' || !isRecord(parsed.targets)) return []; diff --git a/src/deploy/deliveryRecord.ts b/src/deploy/deliveryRecord.ts new file mode 100644 index 00000000..e46367f9 --- /dev/null +++ b/src/deploy/deliveryRecord.ts @@ -0,0 +1,134 @@ +/** + * What a `capy deploy` delivery looks like as a keep.lock record, and how it is + * recorded on the Capy SERVER. + * + * `recordDeliveriesOnServer` builds the pushed keep from the SERVER's own keep, + * never from a local keep.lock. A local copy can lag the server (another + * target's CI record, or a record a teammate's deploy added, may not be in it + * yet), and a keep pushed from it would erase those records. The branch's blob + * is re-sent byte for byte, so nothing about a secret value can change here. + * + * Shared by `capy deploy` (CI mode) and the repo-free batch deploy + * (`batchDeploy.ts`). Never carries a value: names, hashes and codes only. + */ +import type { DeployAdapter, TargetConfig } from './adapter'; +import { TargetDeliveryDescriptor, VarDelivery, recordTargetDeliveries } from './targetsGate'; +import { CapyError, ERROR_CODES, 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` — + * 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 }; + return undefined; +} + +/** + * The delivery descriptor + delivered-values pair shared by every "record + * this target's delivery" caller (direct mode, CI mode, the batch deploy, and + * the PR's own keep.lock content) — same shape, same `noDeploy` → + * `deployed: false` rule (CAP-679 follow-up, "pending"; see + * `targetsGate.ts#upsertTargetElement`'s doc for why absent/false OMITS the + * field instead of writing `deployed: true`). + */ +export function deliveryFor( + target: TargetConfig, + adapter: Pick, + deployId: string | undefined, + noDeploy: boolean, + valueHashes: Record, +): { delivery: TargetDeliveryDescriptor; values: readonly VarDelivery[] } { + const delivery: TargetDeliveryDescriptor = { + provider: adapter.id, + target: target.name, + ref: targetRefFor(target), + deployId, + ...(noDeploy ? { deployed: false } : {}), + }; + const values = target.vars + .filter((v) => valueHashes[v] !== undefined) + .map((v) => ({ name: v, valueHash: valueHashes[v] })); + return { delivery, values }; +} + +/** 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>; + pushSecrets(projectId: string, keepFile: string, envBlob: string, branch: string): Promise; +} + +export type RecordResult = + | { readonly ok: true; /** `false`: every delivery was already recorded, so nothing was pushed. */ readonly pushed: boolean } + | { readonly ok: false; readonly code: string }; + +function parseServerKeep(raw: string | undefined): KeepFile | undefined { + if (raw === undefined || raw === '') return undefined; + try { + const parsed: unknown = JSON.parse(raw); + const obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : undefined; + const vars = obj?.variables; + return typeof vars === 'object' && vars !== null && !Array.isArray(vars) ? (parsed as KeepFile) : undefined; + } catch { + return undefined; + } +} + +const codeOf = (err: unknown): string => (err instanceof CapyError ? err.code : ERROR_CODES.SERVICE_ERROR); + +/** The server's keep and blob for `branch`, read in one call so the two always belong together. */ +async function readServerSnapshot( + client: RecordClient, + projectId: string, + branch: string, +): Promise<{ readonly ok: true; readonly keep: KeepFile; readonly envFile: string } | { readonly ok: false; readonly code: string }> { + try { + const latest = await client.getLatestSecrets(projectId, branch); + const keep = parseServerKeep(latest?.keep_file); + return latest === null || keep === undefined + ? { ok: false, code: ERROR_CODES.NO_KEEP_FILE } + : { ok: true, keep, envFile: latest.env_file }; + } catch (err) { + return { ok: false, code: codeOf(err) }; + } +} + +/** + * Fetches the server's keep for (project, branch), applies every delivery in + * `deliveries` to it, and pushes the result back server-only with the blob + * unchanged. Nothing is pushed when every delivery is already recorded. + * Never throws; a failure is a code. + * + * Two callers must not run this for the SAME (project, branch) at once: each + * would read the same keep and the later push would drop the earlier record. + * Put every delivery of one (project, branch) in one call instead. + */ +export async function recordDeliveriesOnServer( + client: RecordClient, + projectId: string, + branch: string, + deliveries: ReadonlyArray<{ + readonly delivery: TargetDeliveryDescriptor; + readonly values: readonly VarDelivery[]; + /** ISO time the values reached the platform. */ + readonly deliveredAt: string; + }>, +): Promise { + const snapshot = await readServerSnapshot(client, projectId, branch); + if (!snapshot.ok) return snapshot; + const next = deliveries.reduce( + (keep, d) => (d.values.length === 0 ? keep : recordTargetDeliveries(keep, branch, d.delivery, d.deliveredAt, d.values)), + snapshot.keep, + ); + if (next === snapshot.keep) return { ok: true, pushed: false }; + try { + await client.pushSecrets(projectId, JSON.stringify(next), snapshot.envFile, branch); + return { ok: true, pushed: true }; + } catch (err) { + return { ok: false, code: codeOf(err) }; + } +} diff --git a/src/deploy/deployPrBody.ts b/src/deploy/deployPrBody.ts new file mode 100644 index 00000000..0a7b192f --- /dev/null +++ b/src/deploy/deployPrBody.ts @@ -0,0 +1,75 @@ +/** + * The deploy PR's description, shared by `capy deploy` (CI mode) and the batch + * deploy (`batchDeploy.ts`). Names only: never a value. + */ +import type { TargetConfig } from './adapter'; +import { getAdapter } from './registry'; + +export function buildDeployPrBody(target: TargetConfig): string { + const adapter = getAdapter(target.kind); + const adapterLabel = adapter ? adapter.label : target.kind; + const optionsTable = Object.entries(target.options) + .map(([k, v]) => `- \`${k}\`: \`${String(v)}\``) + .join('\n'); + const baseLine = target.gitBaseBranch + ? `- **Git base:** \`${target.gitBaseBranch}\`` + : ''; + + // Secret-delivery wording depends on the adapter. Blob adapters (Vercel) push + // SECRETS_BLOB + PROJECT_KEY and let the build decrypt via `capy run`; others + // push the individual secrets into the vendor's store. + const varsSection = adapter?.needsDeployToken + ? [ + `Delivered to ${adapterLabel} as \`SECRETS_BLOB\` + \`PROJECT_KEY\` **before** this`, + `PR was opened — the encrypted bundle of your secrets plus its build-time`, + `key. Your individual secret values stay encrypted in the bundle and never`, + `appear in git history; the build decrypts them with \`capy run\`:`, + ``, + `- \`SECRETS_BLOB\``, + `- \`PROJECT_KEY\``, + ].join('\n') + : [ + `Already delivered to the vendor's secret store **before** this PR was`, + `opened (e.g. \`wrangler secret bulk\` for cf-worker). Names only — values`, + `stay in the vendor's store and never appear in git history:`, + ``, + target.vars.map((v) => `- \`${v}\``).join('\n'), + ].join('\n'); + + // CAP-702: capy does not track releases. The values are already in the + // vendor's store; merging records them in keep.lock and starts a release + // only where the vendor builds on merge. Wording approved by Vince + // (2026-10-04), verbatim from the ticket. + const mergeSection = [ + `The new values are already in ${adapterLabel}. The next release of this branch will use them.`, + `Merging this PR records them in keep.lock, and it starts a release if ${adapterLabel} builds on merge.`, + ].join(' '); + + return [ + `Automated deploy PR opened by \`capy deploy\`.`, + ``, + `## What this ships`, + ``, + `- **Target:** \`${target.name}\``, + `- **Adapter:** ${adapterLabel} (\`${target.kind}\`)`, + `- **Capy branch:** \`${target.branch}\` — secrets snapshot pinned by \`keep.lock\` in this commit.`, + baseLine, + optionsTable, + ``, + `## Vars pushed`, + ``, + varsSection, + ``, + `## What happens on merge`, + ``, + mergeSection, + ``, + `## Diff scope`, + ``, + `This PR touches at most one file: \`keep.lock\`. An empty diff means the`, + `pinned snapshot already matched and this is a forced redeploy. Other`, + `working-tree changes on the author's machine were not picked up.`, + ``, + `_Generated by \`capy deploy\`._`, + ].join('\n'); +} diff --git a/src/deploy/dokployApiKeyOnce.ts b/src/deploy/dokployApiKeyOnce.ts new file mode 100644 index 00000000..d521eec7 --- /dev/null +++ b/src/deploy/dokployApiKeyOnce.ts @@ -0,0 +1,57 @@ +/** + * Dokploy only: the org system store's API key for a target, resolved once and + * handed to `preflight` / `deploy` as `ctx.resolvedApiKey`. + */ +import type { DeployAdapter, TargetConfig } from './adapter'; +import { ERROR_CODES } from '../types/index'; +import { dokployConnectionProblem, resolveDokployApiKey } from './adapters/dokploy'; +import type { ResolveDokployApiKeyResult } from './adapters/dokploy'; +import { DOKPLOY_CONNECTOR_SECRET_NAME, DOKPLOY_TARGET_SECRET_NAME, DokploySystemStoreCallOptions } from './dokployApi'; + +/** + * Dokploy only: resolves the org system store's API key ONCE for this whole + * command, wiring the REAL `system/systemStore.ts#getConnectorSecret` — this + * is the production entry point for CAP-664. The result is threaded into + * every `preflight`/`deploy`/`onRemove` call via `ctx.resolvedApiKey`, so the + * store is asked (and an admin prompted) at most once per command no matter + * how many of those run. `undefined` for every other adapter — they never + * read that field. + * + * Never resolves (never prompts, never touches the store) when the target + * can't even be reached — `dokployConnectionProblem` (an unusable `baseUrl`, + * a missing `applicationId`, or a malformed `tokenEnv`): `preflight()` fails + * on that regardless of any token, so asking for (or prompting to save) a + * key first would be wasted at best and a needless prompt at worst. + * Deliberately NOT the full `optionsProblem` — `onRemove` has no vars to + * ship and must still reach a target that check would otherwise reject. + */ +export async function resolveDokployApiKeyOnce( + adapter: DeployAdapter, + target: TargetConfig, + orgId: string | undefined, + devMode: boolean | undefined, + interactive: boolean, +): Promise { + if (adapter.id !== 'dokploy') return undefined; + if (dokployConnectionProblem(target)) return undefined; + const { getDirectionalConnectorSecret } = await import('../system/systemStore'); + // CAP-679 follow-up: deploy asks for `_TARGET_DOKPLOY_API_KEY` first, and + // — only when that's missing — offers to reuse (or shadow-refuse without a + // TTY) `_CONNECTOR_DOKPLOY_API_KEY`, the import-side key. See + // `system/systemStore.ts#getDirectionalConnectorSecret`'s own doc. + const getConnectorSecret = (name: string, opts: DokploySystemStoreCallOptions) => + getDirectionalConnectorSecret(name, DOKPLOY_CONNECTOR_SECRET_NAME, { + ...opts, + missingWithFallbackCode: ERROR_CODES.DOKPLOY_TARGET_KEY_MISSING, + }); + return resolveDokployApiKey({ + tokenEnv: (target.options as { tokenEnv?: string }).tokenEnv, + env: process.env, + interactive, + orgId, + devMode, + storeName: DOKPLOY_TARGET_SECRET_NAME, + deps: { getConnectorSecret }, + }); +} + diff --git a/src/deploy/keepGate.ts b/src/deploy/keepGate.ts index 029c1185..f7742a65 100644 --- a/src/deploy/keepGate.ts +++ b/src/deploy/keepGate.ts @@ -64,12 +64,45 @@ export function buildDeployKeep( delivery?: TargetDeliveryDescriptor, deliveredAt: string = new Date().toISOString(), ): DeployKeep { + const hashes = Object.fromEntries( + vars.filter((name) => envValues[name] !== undefined).map((name) => [name, hashValue(envValues[name])]), + ); + return buildDeployKeepFromHashes(baseKeep, hashes, vars, branch, delivery, deliveredAt); +} + +/** + * `buildDeployKeep` for a caller that only holds the hashes of the values (the + * repo-free batch deploy keeps no plaintext past the push). `hashes` is by + * variable name; a name in `vars` with no hash is skipped, exactly like a name + * with no value above. + */ +export function buildDeployKeepFromHashes( + baseKeep: KeepFile, + hashes: Readonly>, + vars: readonly string[], + branch: string, + delivery?: TargetDeliveryDescriptor, + deliveredAt: string = new Date().toISOString(), +): DeployKeep { + const keep = foldDeployKeep(baseKeep, hashes, vars, branch, delivery, deliveredAt); + const content = serializeKeep(keep); + return { content, changed: content !== serializeKeep(baseKeep) }; +} + +/** `buildDeployKeepFromHashes`, as the keep itself (what a caller that writes it through `serializeKeep` itself needs). */ +export function foldDeployKeep( + baseKeep: KeepFile, + hashes: Readonly>, + vars: readonly string[], + branch: string, + delivery?: TargetDeliveryDescriptor, + deliveredAt: string = new Date().toISOString(), +): KeepFile { const validDelivery = isDeliveryDescriptor(delivery) ? delivery : undefined; const nextVariables = vars.reduce>((acc, name) => { - const value = envValues[name]; - if (value === undefined) return acc; // missing var — var-set reconcile handles it - const hash = hashValue(value); + const hash = hashes[name]; + if (hash === undefined) return acc; // missing var — var-set reconcile handles it const existingEntries = acc[name] ?? (baseKeep.variables[name] as Entry[] | undefined) ?? []; const entry = entryFor(existingEntries, branch); const withHash: Entry = entry @@ -92,9 +125,7 @@ export function buildDeployKeep( return { ...acc, [name]: nextEntries }; }, { ...baseKeep.variables } as Record); - const keep: KeepFile = { ...baseKeep, variables: nextVariables }; - const content = serializeKeep(keep); - return { content, changed: content !== serializeKeep(baseKeep) }; + return { ...baseKeep, variables: nextVariables }; } /** diff --git a/src/index-dev.ts b/src/index-dev.ts index 040c588e..a2027246 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -827,6 +827,45 @@ secretsCmd process.exit(code); }); +secretsCmd + .command('deploy ') + // COPY-FLAG: minimal-neutral. Agent mode: never prompts; takes no value. + .description('Deploy the Dokploy targets of one or more secrets with no project folder, and open one keep.lock PR per target. Never prompts.') + .option('--json', 'emit machine-readable JSON instead of the human UI') + // COPY-FLAG: the option descriptions of `secrets deploy` are minimal and neutral. + .option('--row ', 'deploy this row of a name (repeatable; ids from `capy secrets --name NAME --json`)', collectRepeatable, []) + .option('--all-rows', 'deploy every row of those names') + .option('--exclude ', 'leave this location out (repeatable)', collectRepeatable, []) + .option('--confirm ', 'run the plan that --dry-run printed (required for a real run)') + .addHelpText( + 'after', + // COPY-FLAG + '\n' + + 'Agents: look the rows up, show the human a table, let the human pick the row(s), dry run, get approval, then run with --confirm. Never pick a row yourself.\n' + + ' capy secrets --name NAME --json\n' + + ' capy secrets deploy NAME --row --dry-run --json\n' + + ' capy secrets deploy NAME --row --confirm --json\n', + ) + .action(async (names: string[], options: any, command: any) => { + // `--json` is also a `capy secrets` option, which Commander lets the parent claim; + // `--dry-run` is the program-level flag. Read both from the merged options. + const merged = command.optsWithGlobals(); + const { secretsDeployCommand } = await import('./commands/secretsDeployCommand'); + const code = await secretsDeployCommand( + names, + { + json: merged.json === true, + dryRun: merged.dryRun === true, + confirm: options.confirm, + row: options.row, + allRows: options.allRows === true, + exclude: options.exclude, + }, + true, + ); + process.exit(code); + }); + program .command('grant-branch ') .description('Grant a member wildcard access to a protected branch') diff --git a/src/index.ts b/src/index.ts index 1b505202..89671a12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -874,6 +874,46 @@ secretsCmd process.exit(code); }); +secretsCmd + .command('deploy ') + // COPY-FLAG: minimal-neutral. Agent mode: never prompts; takes no value. + .description('Deploy the Dokploy targets of one or more secrets with no project folder, and open one keep.lock PR per target. Never prompts.') + .option('--json', 'emit machine-readable JSON instead of the human UI') + // COPY-FLAG: the option descriptions of `secrets deploy` are minimal and neutral. + .option('--row ', 'deploy this row of a name (repeatable; ids from `capy secrets --name NAME --json`)', collectRepeatable, []) + .option('--all-rows', 'deploy every row of those names') + .option('--exclude ', 'leave this location out (repeatable)', collectRepeatable, []) + .option('--confirm ', 'run the plan that --dry-run printed (required for a real run)') + .addHelpText( + 'after', + // COPY-FLAG + '\n' + + 'Agents: look the rows up, show the human a table, let the human pick the row(s), dry run, get approval, then run with --confirm. Never pick a row yourself.\n' + + ' capy secrets --name NAME --json\n' + + ' capy secrets deploy NAME --row --dry-run --json\n' + + ' capy secrets deploy NAME --row --confirm --json\n', + ) + .action(async (names: string[], options: any, command: any) => { + assertNotLocalOnly('secrets deploy'); + // `--json` is also a `capy secrets` option, which Commander lets the parent claim; + // `--dry-run` is the program-level flag. Read both from the merged options. + const merged = command.optsWithGlobals(); + const { secretsDeployCommand } = await import('./commands/secretsDeployCommand'); + const code = await secretsDeployCommand( + names, + { + json: merged.json === true, + dryRun: merged.dryRun === true, + confirm: options.confirm, + row: options.row, + allRows: options.allRows === true, + exclude: options.exclude, + }, + false, + ); + process.exit(code); + }); + program .command('grant-branch ') .description('Grant a member wildcard access to a protected branch') diff --git a/src/types/index.ts b/src/types/index.ts index 361135d8..76420b00 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -748,6 +748,17 @@ export const ERROR_CODES = { DISCOVER_UNSUPPORTED_TARGET: 'DISCOVER_UNSUPPORTED_TARGET', /** `--confirm` wrote some repos' deploy.json PRs and at least one repo failed; the refusal lists `failed: [{ repo, code }]`. Exit 1. */ DISCOVER_PARTIAL: 'DISCOVER_PARTIAL', + // --- `capy secrets deploy` and the `capy secrets` deploy key (CAP-704) --- + /** `capy secrets deploy`: the run finished but at least one target failed, or a stop left some undone. Exit 1. */ + DEPLOY_BATCH_PARTIAL: 'DEPLOY_BATCH_PARTIAL', + /** `capy secrets deploy`: the selection has no Dokploy CI-mode target on the default branch to deploy. */ + DEPLOY_NOTHING_TO_DEPLOY: 'DEPLOY_NOTHING_TO_DEPLOY', + /** A batch deploy target ships a variable that is not in its Capy branch. Nothing was pushed to the platform. */ + DEPLOY_VARS_MISSING: 'DEPLOY_VARS_MISSING', + /** A batch deploy target's preflight refused and gave no code of its own. Nothing was pushed to the platform. */ + DEPLOY_PREFLIGHT_FAILED: 'DEPLOY_PREFLIGHT_FAILED', + /** A batch deploy target's delivery to the platform failed and gave no code of its own. */ + DEPLOY_PUSH_FAILED: 'DEPLOY_PUSH_FAILED', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/src/ui/pickerTable.ts b/src/ui/pickerTable.ts index 4520e36b..bc66649b 100644 --- a/src/ui/pickerTable.ts +++ b/src/ui/pickerTable.ts @@ -111,6 +111,8 @@ export interface PickerTable { readonly size: number; /** No line is wider than this. */ readonly maxWidth: number; + /** Rows listed under the choices, greyed and without a checkbox: not selectable, never filtered. Their cells count toward the column widths. */ + readonly inert?: readonly (readonly string[])[]; } function pageOf(count: number, active: number, size: number): readonly [number, number] { @@ -120,7 +122,8 @@ function pageOf(count: number, active: number, size: number): readonly [number, /** The filter line (while searching), the headings and the visible rows. */ export function pickerTableLines(t: PickerTable): readonly string[] { - const widths = columnWidths(t.columns, t.rows, t.maxWidth); + const inert = t.inert ?? []; + const widths = columnWidths(t.columns, [...t.rows, ...inert], t.maxWidth); const line = (box: string, cells: readonly string[]): string => `${box}${GAP}${cells.map((c, i) => padCell(c, widths[i])).join(GAP)}`.trimEnd(); @@ -133,5 +136,6 @@ export function pickerTableLines(t: PickerTable): readonly string[] { }); const heading = `${DIM}${clipLine(line(' '.repeat(BOX_WIDTH), t.columns.map((c) => c.heading)), t.maxWidth)}${RESET}`; const search = t.box.searching ? [`${DIM}search:${RESET} ${t.box.query}▏`] : []; - return [...search, heading, ...(body.length === 0 ? [`${DIM}No matches.${RESET}`] : body)]; + const greyed = inert.map((cells) => `${DIM}${clipLine(line(' '.repeat(BOX_WIDTH), cells), t.maxWidth)}${RESET}`); + return [...search, heading, ...(body.length === 0 ? [`${DIM}No matches.${RESET}`] : body), ...greyed]; } diff --git a/src/ui/secretsDeployFlow.ts b/src/ui/secretsDeployFlow.ts new file mode 100644 index 00000000..fea805ed --- /dev/null +++ b/src/ui/secretsDeployFlow.ts @@ -0,0 +1,307 @@ +// The deploy flow of the `capy secrets` TUI (CAP-704): press `ctrl+d` on a row (or `d` in its +// details), see what would be deployed, confirm, watch it run, then see what happened. +// +// Pure, like the edit flow (`secretsEditFlow.ts`): `stepDeploy(flow, key, dryRun)` takes the +// flow state and one key token and returns the next state plus (at most) one effect for the +// driver to perform; `renderDeploy` draws a state. Nothing here touches stdin, stdout, the +// network or git. A deploy takes no value (it ships what Capy holds); the plan step does hold the +// row's value, only to show it (masked, `r` reveals) on that one screen. It is dropped when the +// run starts or the flow ends, and it is never in the exit text, a note, a log or any JSON. +// +// Steps: planning -> plan -> running -> done +// planning reads the repos' deploy.json from GitHub (no key is opened, nothing is written). +// Esc cancels. +// plan the row's value (masked), then a checkbox list of the targets (location, target, +// provider, vars), all ticked at first, and the skipped locations greyed below it +// (with their reason). Space ticks, `a` ticks all / none, `r` shows or hides the +// value, Enter deploys ONLY the ticked targets (nothing ticked: Enter does nothing), +// Esc / `n` cancels. A plan with nothing to deploy says so and only Esc works. +// Under `--dry-run` Enter shows what would happen for the ticked targets and +// changes nothing: no effect is ever started. +// running Ctrl+C / Esc stop: no further target is started, every one in flight finishes. +// done the result; only Esc leaves it, so the PR links can be selected and copied first. + +import type { SecretIndexRow } from '../service/serviceClient'; +import { BatchPhase, BatchPlan, BatchProgress, BatchResult, restrictPlan, targetKeyOf } from '../deploy/batchDeploy'; +import { + DRY_RUN_DEPLOY_NOTE, + PLAN_HEADINGS, + deployTitle, + planLines, + renderBatchResult, + skippedRows, + targetRows, + unreadableLines, +} from '../commands/secretsDeployText'; +import { CANCELLED_NOTHING, STOPPING_NOW, stoppingAfter, TERMINAL_STYLE } from '../commands/secretsSetText'; +import { TableColumn, clipLine, pickerTableLines } from './pickerTable'; +import { CheckboxState, initialCheckboxState, stepCheckboxKey } from './searchableCheckbox'; +import { toCheckboxKey } from './secretsEditFlow'; +import { ValueState, renderValueLine } from './valueDisplay'; + +const ESC = '\x1b'; +const RESET = `${ESC}[0m`; +const DIM = `${ESC}[90m`; +const BOLD = `${ESC}[1m`; +const RED = `${ESC}[31m`; + +const KEY_ESC = ESC; +const KEY_ESC_ESC = `${ESC}${ESC}`; +const KEY_CTRL_C = '\x03'; + +// ── State ──────────────────────────────────────────────────────────────────── + +export type DeployFlow = + | { readonly step: 'planning'; readonly row: SecretIndexRow } + | { + readonly step: 'plan'; + readonly row: SecretIndexRow; + readonly plan: BatchPlan; + /** Which targets (indices into `plan.targets`) are ticked, and where the cursor is. All ticked at first. */ + readonly box: CheckboxState; + /** The row's value, for the `value` line only. Fetched once the plan is shown (the details view's own fetch). */ + readonly value: ValueState; + /** The value is shown in full (`r`); masked otherwise. */ + readonly revealed: boolean; + } + | { + readonly step: 'running'; + readonly row: SecretIndexRow; + readonly plan: BatchPlan; + /** Live progress of the run (set as items complete). */ + readonly progress?: BatchProgress; + /** A stop was asked for in this phase, this many times (Ctrl+C / Esc). */ + readonly stop?: { readonly phase: BatchPhase; readonly count: number }; + } + | { readonly step: 'done'; readonly text: string }; + +export type DeployEffect = + | { readonly type: 'loadDeployPlan'; readonly row: SecretIndexRow } + /** Stop: `planning` kills the GitHub reads; `pushing` / `prs` start nothing new and wait for every item in flight. */ + | { readonly type: 'cancelDeploy'; readonly phase: 'planning' | BatchPhase } + | { readonly type: 'runDeploy'; readonly plan: BatchPlan }; + +export interface DeployStep { + /** `null`: the flow is over and the list is back. */ + readonly flow: DeployFlow | null; + readonly effect: DeployEffect | null; + /** A short dim note for the list the flow left (e.g. that it was cancelled). */ + readonly note?: string; + /** Leave the whole screen and print this after the alt screen is gone. */ + readonly exitText?: string; +} + +export function startDeploy(row: SecretIndexRow): { readonly flow: DeployFlow; readonly effect: DeployEffect } { + return { flow: { step: 'planning', row }, effect: { type: 'loadDeployPlan', row } }; +} + +export const isDeployRunning = (flow: DeployFlow | null): boolean => flow !== null && flow.step === 'running'; + +// ── Keys ───────────────────────────────────────────────────────────────────── + +const isEsc = (token: string): boolean => token === KEY_ESC || token === KEY_ESC_ESC; +const stay = (flow: DeployFlow): DeployStep => ({ flow, effect: null }); + +/** What a dry run would do, on screen. Counts and names only. */ +export function dryRunDeployText(plan: BatchPlan): string { + return [ + DRY_RUN_DEPLOY_NOTE, + `Would deploy to ${plan.targets.length} target${plan.targets.length === 1 ? '' : 's'}.`, // COPY-FLAG + ...plan.targets.map((t) => ` ${t.repo.owner}/${t.repo.name} (${t.base}) · ${t.config.name}`), // COPY-FLAG + ].join('\n'); +} + +/** The plan restricted to the ticked targets: what Enter deploys. */ +export const selectedPlan = (flow: Extract): BatchPlan => + restrictPlan(flow.plan, flow.box.checked.map((i) => targetKeyOf(flow.plan.targets[i]))); + +/** The keys of the list: up / down, space and `a` (the edit flow's own, same select-all rule). No filter: nothing else types. */ +const LIST_KEYS: readonly string[] = ['up', 'down', 'space', 'a']; + +function stepList(flow: Extract, key: string): DeployStep { + const ckey = toCheckboxKey(key, false); + if (ckey === null || !LIST_KEYS.includes(ckey.name)) return stay(flow); + const step = stepCheckboxKey(flow.box, flow.plan.targets.map(targetKeyOf), ckey); + return stay(step.kind === 'state' ? { ...flow, box: step.state } : flow); +} + +function stepPlan(flow: Extract, key: string, dryRun: boolean): DeployStep { + if (isEsc(key) || key === 'n' || key === 'N') return { flow: null, effect: null, note: CANCELLED_NOTHING }; + if (flow.plan.targets.length === 0) return stay(flow); + if (key === 'r' || key === 'R') return stay({ ...flow, revealed: !flow.revealed }); + if (key !== '\r') return stepList(flow, key); + if (flow.box.checked.length === 0) return stay(flow); + const plan = selectedPlan(flow); + // A dry run never starts the run: it shows what would happen. + if (dryRun) return stay({ step: 'done', text: dryRunDeployText(plan) }); + return { flow: { step: 'running', row: flow.row, plan }, effect: { type: 'runDeploy', plan } }; +} + +/** + * Ctrl+C and Esc work while running (they never leave the screen hanging): + * - pushing: start no further target and wait for every push in flight (a Dokploy write is never cut in half); + * - opening PRs: start no further PR and wait for every PR in flight. + * Every other key is ignored. + */ +function stepRunning(flow: Extract, key: string): DeployStep { + if (!isEsc(key) && key !== KEY_CTRL_C) return stay(flow); + const phase = flow.progress?.phase ?? 'pushing'; + const count = (flow.stop !== undefined && flow.stop.phase === phase ? flow.stop.count : 0) + 1; + return { flow: { ...flow, stop: { phase, count } }, effect: { type: 'cancelDeploy', phase } }; +} + +/** One key token applied to the flow. */ +export function stepDeploy(flow: DeployFlow, key: string, dryRun: boolean = false): DeployStep { + if (flow.step === 'planning') { + return isEsc(key) ? { flow: null, effect: { type: 'cancelDeploy', phase: 'planning' }, note: CANCELLED_NOTHING } : stay(flow); + } + if (flow.step === 'plan') return stepPlan(flow, key, dryRun); + if (flow.step === 'running') return stepRunning(flow, key); + // The result screen stays until Esc, so its PR links can be selected and copied. + return isEsc(key) ? { flow: null, effect: null, exitText: flow.text } : { flow, effect: null }; +} + +// ── Results coming back ───────────────────────────────────────────────────── + +export type DeployPlanLoaded = + | { readonly ok: true; readonly plan: BatchPlan } + | { readonly ok: false; readonly code: string }; + +/** The plan arrived. Dropped (the flow is returned unchanged) unless the flow is still waiting for it. */ +export function applyDeployPlanLoaded(flow: DeployFlow | null, loaded: DeployPlanLoaded): DeployFlow | null { + if (flow === null || flow.step !== 'planning') return flow; + return loaded.ok + ? { + step: 'plan', + row: flow.row, + plan: loaded.plan, + box: initialCheckboxState(loaded.plan.targets.map(() => ({ checked: true }))), + value: { status: 'loading' }, + revealed: false, + } + : { step: 'done', text: `✗ Could not read the deploy targets. (${loaded.code})` }; // COPY-FLAG +} + +/** The plan step has targets: the row's value is worth fetching, to show it on this screen. */ +export const wantsValue = (flow: DeployFlow | null): flow is Extract => + flow !== null && flow.step === 'plan' && flow.plan.targets.length > 0; + +/** + * The row's value arrived (or could not be read). Dropped (the flow is returned unchanged) unless + * the flow is on the plan step for that same row (name and value hash): the guard the details + * view and the edit dialog use. + */ +export function applyDeployValue( + flow: DeployFlow | null, + forRow: { readonly name: string; readonly value_hash: string }, + result: ValueState, +): DeployFlow | null { + if (flow === null || flow.step !== 'plan') return flow; + if (flow.row.name !== forRow.name || flow.row.value_hash !== forRow.value_hash) return flow; + return { ...flow, value: result }; +} + +/** The run reported progress. Dropped unless the flow is still running. A stop asked for in an earlier phase does not carry over. */ +export function applyDeployProgress(flow: DeployFlow | null, progress: BatchProgress): DeployFlow | null { + if (flow === null || flow.step !== 'running') return flow; + const stop = flow.stop !== undefined && flow.stop.phase === progress.phase ? flow.stop : undefined; + return { ...flow, progress, stop }; +} + +export type DeployFinished = + | { readonly ok: true; readonly result: BatchResult } + | { readonly ok: false; readonly code: string }; + +/** The run finished. Dropped unless the flow is still running. */ +export function applyDeployFinished(flow: DeployFlow | null, finished: DeployFinished): DeployFlow | null { + if (flow === null || flow.step !== 'running') return flow; + return { + step: 'done', + text: finished.ok ? renderBatchResult(finished.result, TERMINAL_STYLE) : `✗ ${flow.row.name} was not deployed. (${finished.code})`, // COPY-FLAG + }; +} + +// ── Rendering ──────────────────────────────────────────────────────────────── + +const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`; + +/** The one live line of the running step. */ +export function deployRunningLine(flow: Extract): string { + const p = flow.progress; + if (flow.stop !== undefined) return p !== undefined && p.inFlight > 0 ? stoppingAfter(p.inFlight) : STOPPING_NOW; + if (p === undefined) return `Deploying ${flow.row.name} to ${plural(flow.plan.targets.length, 'target')}…`; // COPY-FLAG + if (p.phase === 'pushing') return `Deploying ${flow.row.name}… ${p.done} of ${plural(p.total, 'target')}`; // COPY-FLAG + if (p.phase === 'recording') return 'Recording deliveries…'; // COPY-FLAG + return `Opening pull requests… ${p.done} of ${p.total}`; // COPY-FLAG +} + +const hint = (...pairs: ReadonlyArray): string => + pairs.map(([key, what]) => `${BOLD}${key}${RESET}${DIM} ${what}${RESET}`).join(`${DIM} · ${RESET}`); + +// COPY-FLAG: the table headings are the plan's own; only the two long ones may be cut when narrow. +const PLAN_COLUMNS: readonly TableColumn[] = PLAN_HEADINGS.map((heading, i) => ({ heading, shrink: i < 2 })); +const VALUE_LABEL = 'value'; // COPY-FLAG (the details view's word) + +/** The plan step: title, value line, the checkbox list with the skipped rows greyed under it, and its keys. */ +function renderPlan( + flow: Extract, + width: number, + height: number, +): { readonly lines: readonly string[]; readonly footer: string } { + const maxWidth = Math.max(20, width - 3); + const names = [flow.row.name]; + if (flow.plan.targets.length === 0) { + const lines = planLines(flow.plan, names).map((l) => clipLine(l, maxWidth)); + return { lines: [`${BOLD}${lines[0]}${RESET}`, ...lines.slice(1)], footer: hint(['esc', 'back']) }; // COPY-FLAG + } + const skipped = skippedRows(flow.plan); + const unreadable = unreadableLines(flow.plan); + const title = clipLine(deployTitle(names, flow.box.checked.length, flow.plan.targets.length), maxWidth); + const labelWidth = VALUE_LABEL.length + 2; + const valueLine = `${DIM}${VALUE_LABEL}${RESET} ${renderValueLine(flow.value, flow.revealed, 0, Math.max(10, maxWidth - labelWidth))}`; + // Screen chrome: header and its gap, title, value, gap, heading, gap and footer; then what sits under the list. + const size = Math.max(3, height - 9 - skipped.length - (unreadable.length === 0 ? 0 : unreadable.length + 1)); + return { + lines: [ + `${BOLD}${title}${RESET}`, + clipLine(valueLine, maxWidth), + '', + ...pickerTableLines({ + columns: PLAN_COLUMNS, + rows: targetRows(flow.plan), + labels: flow.plan.targets.map(targetKeyOf), + box: flow.box, + size, + maxWidth, + inert: skipped, + }), + ...(unreadable.length === 0 ? [] : ['', ...unreadable.map((l) => clipLine(l, maxWidth))]), + ], + footer: hint( + ['space', 'select'], + ['a', 'all'], + ['r', flow.revealed ? 'hide' : 'reveal'], + ...(flow.box.checked.length === 0 ? [] : ([['enter', 'deploy']] as const)), + ['esc', 'cancel'], + ), // COPY-FLAG + }; +} + +/** Body lines (no margin) and the footer for the flow, for `width` x `height`. */ +export function renderDeploy( + flow: DeployFlow, + width: number, + height: number = 24, +): { readonly lines: readonly string[]; readonly footer: string } { + if (flow.step === 'planning') { + return { lines: [`${DIM}Reading deploy targets…${RESET}`], footer: hint(['esc', 'cancel']) }; // COPY-FLAG + } + if (flow.step === 'plan') return renderPlan(flow, width, height); + if (flow.step === 'running') { + return { lines: [`${DIM}${deployRunningLine(flow)}${RESET}`], footer: flow.stop === undefined ? hint(['ctrl+c', 'stop']) : '' }; // COPY-FLAG + } + return { + lines: flow.text.split('\n').map((l) => (l.startsWith('✗') ? `${RED}${l}${RESET}` : l)), + footer: hint(['esc', 'exit']), // COPY-FLAG + }; +} diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index 14d174b6..c858b362 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -17,7 +17,7 @@ // space, a (toggle all), i (invert), / (filter), enter, and Esc back. // repos the repos to open a PR in, with the approved prompt, all selected. // running keys are ignored: a push in flight is never abandoned halfway. -// done the confirmation; any key leaves the screen. +// done the confirmation; only Esc leaves it, so the PR links can be selected and copied first. import { CheckboxKey, CheckboxState, initialCheckboxState, stepCheckboxKey } from './searchableCheckbox'; import { isRevealKey, stepEditBuffer } from './editBuffer'; @@ -363,7 +363,8 @@ export function stepEdit(flow: EditFlow, key: string): EditStep { if (flow.step === 'loading') return stepLoading(flow, key); if (flow.step === 'repos') return stepRepos(flow, key); if (flow.step === 'running') return stepRunning(flow, key); - return { flow: null, effect: null, exitText: flow.text }; + // The result screen stays until Esc, so its PR links can be selected and copied. + return isEsc(key) ? { flow: null, effect: null, exitText: flow.text } : { flow, effect: null }; } // ── Results coming back ───────────────────────────────────────────────────── @@ -519,7 +520,7 @@ export function renderEdit( } return { lines: flow.text.split('\n').map((l) => (l.startsWith('✗') ? `${RED}${l}${RESET}` : l)), - footer: hint(['any key', 'exit']), // COPY-FLAG + footer: hint(['esc', 'exit']), // COPY-FLAG }; } diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index eec94351..6316f1a6 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -17,7 +17,7 @@ import type { SecretIndexLocation, SecretIndexRow, SecretIndexTarget } from '../service/serviceClient'; import { formatRelativeTime } from './relativeTime'; -import { renderInlineValue } from './editScreen'; +import { ValueState, maskSecretValue, renderValueLine } from './valueDisplay'; import { hashValue } from '../commands/statusCommand'; import { ACCENT } from './colors'; import { normalizeQuery, textMatches } from './searchMatch'; @@ -42,6 +42,22 @@ import { startEdit, stepEdit, } from './secretsEditFlow'; +import { + DeployEffect, + DeployFinished, + DeployFlow, + DeployPlanLoaded, + applyDeployFinished, + applyDeployPlanLoaded, + applyDeployProgress, + applyDeployValue, + isDeployRunning, + renderDeploy, + startDeploy, + stepDeploy, + wantsValue, +} from './secretsDeployFlow'; +import type { BatchProgress } from '../deploy/batchDeploy'; // ── ANSI (mirrors EditScreen's palette/look-and-feel) ─────────────────────── @@ -95,6 +111,8 @@ const KEY_ESC_ESC = `${ESC}${ESC}`; const KEY_CTRL_C = '\x03'; /** Ctrl+E: edit the selected row's value, from the list while no filter is typed. The plain `e` is only a hotkey in the details view, where nothing types. */ 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'; const KEY_BACKSPACE = '\x7f'; const KEY_BACKSPACE2 = '\b'; @@ -205,10 +223,8 @@ function nextColumn(column: ColumnMode, dir: 1 | -1): ColumnMode { // ── State ──────────────────────────────────────────────────────────────────── -export type ValueState = - | { readonly status: 'loading' } - | { readonly status: 'ok'; readonly value: string } - | { readonly status: 'unavailable'; readonly code: string }; +export type { ValueState }; +export { maskSecretValue }; export interface PopupState { /** Identifies which (name, value_hash) row this popup belongs to, so a value that resolves after the popup moved on (or closed) is dropped instead of applied to the wrong row. */ @@ -242,6 +258,8 @@ export interface SecretsScreenState { readonly popup: PopupState | null; /** The edit flow (CAP-698), open while a value is being changed; `null` otherwise. */ readonly edit: EditFlow | null; + /** The deploy flow (CAP-704), open while a batch deploy is planned or running; `null` otherwise. */ + readonly deploy: DeployFlow | null; /** `capy --dry-run secrets`: the edit flow only plans, and every screen says so. */ readonly dryRun: boolean; readonly quit: boolean; @@ -259,6 +277,7 @@ export function initialSecretsScreenState(rows: readonly SecretIndexRow[], dryRu search: { query: '' }, popup: null, edit: null, + deploy: null, dryRun, quit: false, exitText: null, @@ -375,7 +394,7 @@ function clampIndex(index: number, length: number): number { // ── Effects (data describing a side effect the driver must perform — the // reducer itself performs none) ───────────────────────────────────────────── -export type SecretsScreenEffect = { readonly type: 'fetchValue'; readonly row: SecretIndexRow } | EditEffect | null; +export type SecretsScreenEffect = { readonly type: 'fetchValue'; readonly row: SecretIndexRow } | EditEffect | DeployEffect | null; export interface ReduceResult { readonly state: SecretsScreenState; @@ -391,11 +410,13 @@ export function handleKey(state: SecretsScreenState, rawKey: string): ReduceResu if (state.note !== null) return handleKey({ ...state, note: null }, rawKey); // While an edit runs, Ctrl-C and Esc STOP it (never leave the screen hanging, never cut a push in half). - if (key === KEY_CTRL_C && !isRunning(state.edit)) return noEffect({ ...state, quit: true }); + if (key === KEY_CTRL_C && !isRunning(state.edit) && !isDeployRunning(state.deploy)) return noEffect({ ...state, quit: true }); if (state.edit) return handleEditFlowKey(state, state.edit, key); + if (state.deploy) return handleDeployFlowKey(state, state.deploy, 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)); } @@ -413,6 +434,45 @@ function openEditOnCursor(state: SecretsScreenState): ReduceResult { return row ? { state: { ...state, popup: null, edit: startEdit(row) }, effect: { type: 'fetchValue', row } } : noEffect(state); } +/** The row under the cursor, as the deploy flow's starting point: its Dokploy targets are read from GitHub next (reads only). */ +function openDeployOnCursor(state: SecretsScreenState): ReduceResult { + const rows = filteredRows(state); + const row = rows[clampIndex(state.cursorIndex, rows.length)]; + if (!row) return noEffect(state); + const started = startDeploy(row); + return { state: { ...state, popup: null, deploy: started.flow }, effect: started.effect }; +} + +function handleDeployFlowKey(state: SecretsScreenState, flow: DeployFlow, key: string): ReduceResult { + const step = stepDeploy(flow, key, state.dryRun); + if (step.exitText !== undefined) return noEffect({ ...state, deploy: null, quit: true, exitText: step.exitText }); + return { state: { ...state, deploy: step.flow, note: step.note ?? null }, effect: step.effect }; +} + +/** The deploy plan the `loadDeployPlan` effect asked for. */ +export function applyDeployPlan(state: SecretsScreenState, loaded: DeployPlanLoaded): SecretsScreenState { + return { ...state, deploy: applyDeployPlanLoaded(state.deploy, loaded) }; +} + +/** + * After the plan arrived: the effect that fetches the row's value for the plan's `value` line, the + * same `fetchValue` (and so the same decrypt path) the details view uses. `null` unless the flow + * is on a plan step that has targets. + */ +export function pendingDeployValueEffect(state: SecretsScreenState): SecretsScreenEffect { + return wantsValue(state.deploy) ? { type: 'fetchValue', row: state.deploy.row } : null; +} + +/** The deploy run reported progress (a single re-render). */ +export function applyDeployRunProgress(state: SecretsScreenState, progress: BatchProgress): SecretsScreenState { + return { ...state, deploy: applyDeployProgress(state.deploy, progress) }; +} + +/** The run the `runDeploy` effect started. */ +export function applyDeployRunDone(state: SecretsScreenState, finished: DeployFinished): SecretsScreenState { + return { ...state, deploy: applyDeployFinished(state.deploy, finished) }; +} + function handleEditFlowKey(state: SecretsScreenState, flow: EditFlow, key: string): ReduceResult { // A dry run only plans (reads): stopping it is immediate and nothing was ever going to change. if (state.dryRun && flow.step === 'running' && (key === KEY_CTRL_C || key === KEY_ESC || key === KEY_ESC_ESC)) { @@ -515,6 +575,7 @@ function handleListKey(state: SecretsScreenState, key: string): ReduceResult { // Never while a filter is typed: the search owns the keyboard then. Enter (details), then `e`, edits a filtered row. if (key === KEY_CTRL_E && state.search.query === '') return openEditOnCursor(state); + if (key === KEY_CTRL_D && state.search.query === '') return openDeployOnCursor(state); if (key === KEY_TAB) { return noEffect({ ...state, column: nextColumn(state.column, 1) }); @@ -585,8 +646,8 @@ export function applyValueResult( state.popup !== null && state.popup.rowName === forRow.name && state.popup.rowHash === forRow.value_hash ? { ...state.popup, value: result } : state.popup; - // The edit dialog's Old value row is fed by the same fetch (and dropped by the same row guard). - return { ...state, popup, edit: applyOldValue(state.edit, forRow, result) }; + // The edit dialog's Old value row and the deploy plan's value line are fed by the same fetch (and dropped by the same row guard). + return { ...state, popup, edit: applyOldValue(state.edit, forRow, result), deploy: applyDeployValue(state.deploy, forRow, result) }; } // ── Value resolution (pure given an injected decryptor — see module doc) ──── @@ -795,29 +856,6 @@ export function formatUpdatedCell(row: SecretIndexRow, now?: Date): string { return ts ? formatRelativeTime(ts, now) : '—'; } -// ── Masking (security-critical — see module doc and CAP-675's spec) ───────── - -const FULL_MASK = '••••••••'; - -/** - * Never reveals a value ≤8 chars (always the same fixed-width mask, so - * length itself isn't leaked either). For longer values, shows at most 4 - * characters total, split as a prefix and a suffix, and never more than a - * third of the value's length — deliberately weaker than - * `formatSnippet`, which shows values ≤6 chars verbatim; that behavior is - * not reused here on purpose. - */ -export function maskSecretValue(value: string): string { - if (value.length === 0) return '(empty)'; - if (value.length <= 8) return FULL_MASK; - const maxShown = Math.min(4, Math.floor(value.length / 3)); - const prefixLen = Math.ceil(maxShown / 2); - const suffixLen = maxShown - prefixLen; - const prefix = value.slice(0, prefixLen); - const suffix = suffixLen > 0 ? value.slice(value.length - suffixLen) : ''; - return `${prefix}...${suffix}`; -} - // ── Rendering (pure) ───────────────────────────────────────────────────────── const MARGIN = 2; @@ -927,6 +965,7 @@ export function mainTableLayout(available: number): { readonly nameW: number; re /** Pure render — a total function of state + terminal size. Never mutates `state`; any "clamping" of a display-only quantity (e.g. panning past the end of a value) is a local `const`, never written back. */ 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); const m = ' '.repeat(MARGIN); const available = Math.max(40, termWidth - MARGIN * 2); const matched = filteredRowsWithReasons(state); @@ -1006,6 +1045,21 @@ function renderEditScreen(flow: EditFlow, termWidth: number, termHeight: number, return out.map((l) => clipLine(l, termWidth) + CLEAR_EOL).join('\n'); } +/** The deploy flow's screen: a header, the step's body, and its key hints. */ +function renderDeployScreen(flow: DeployFlow, termWidth: number, termHeight: number, dryRun: boolean): string { + const m = ' '.repeat(MARGIN); + const { lines, footer } = renderDeploy(flow, termWidth, termHeight); + const out: readonly string[] = [ + `${m}${BOLD}capy secrets${RESET}${dryRunTag(dryRun)}`, + '', + ...lines.map((l) => m + l), + '', + m + footer, + ]; + // No line is ever wider than the terminal (a wrapped line would break the layout). + return out.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)]; } @@ -1038,7 +1092,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): '', `${indent}${inner}${BOLD}${truncate(row.name, contentWidth)}${RESET}`, '', - field('value', renderPopupValueLine(popup, valueWidth)), + field('value', renderValueLine(popup.value, popup.revealed, popup.panOffset, valueWidth)), field('updated', formatUpdatedCell(row)), field(TARGET_STATUS_HEADING.toLowerCase(), secretRowStatusBadge(row)), '', @@ -1131,31 +1185,12 @@ function fitLocation(project: string, tails: readonly string[], width: number): return rest.length > 0 ? fitLocation(project, rest, width) : truncate(full, width); } -function renderPopupValueLine(popup: PopupState, width: number): string { - if (popup.value.status === 'loading') return `${DIM}loading…${RESET}`; - if (popup.value.status === 'unavailable') return `${RED}unavailable ${DIM}(${popup.value.code})${RESET}`; - - const raw = popup.value.value; - if (!popup.revealed) return maskSecretValue(raw); - if (raw === '') return `${DIM}(empty)${RESET}`; - - const value = renderInlineValue(raw); - if (value.length <= width) return value; - - const visibleWidth = Math.max(1, width - 4); - const maxOffset = Math.max(0, value.length - visibleWidth); - const offset = Math.min(Math.max(0, popup.panOffset), maxOffset); - const leftIndicator = offset > 0 ? `${DIM}◂${RESET} ` : ' '; - const rightIndicator = offset < maxOffset ? ` ${DIM}▸${RESET}` : ' '; - return leftIndicator + value.slice(offset, offset + visibleWidth) + rightIndicator; -} - function footerLine(state: SecretsScreenState): 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}esc${RESET}${DIM}/${RESET}${BOLD}q${RESET}${DIM} close${RESET}`; // COPY-FLAG + 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}` : ''; + 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 } diff --git a/src/ui/secretsScreenDriver.ts b/src/ui/secretsScreenDriver.ts index 94d99856..26ab4c05 100644 --- a/src/ui/secretsScreenDriver.ts +++ b/src/ui/secretsScreenDriver.ts @@ -27,7 +27,11 @@ import { applyBases, applyRepos, applyRunProgress, + applyDeployPlan, + applyDeployRunProgress, + applyDeployRunDone, pendingBasesEffect, + pendingDeployValueEffect, applyRunDone, resolveSecretValue, render, @@ -38,6 +42,8 @@ import { import type { SecretIndexRow } from '../service/serviceClient'; import { isRunning, type EditEffect, type ReposLoaded, type RunFinished } from './secretsEditFlow'; import type { RunProgress } from '../commands/secretsSet'; +import { isDeployRunning, type DeployEffect, type DeployFinished, type DeployPlanLoaded } from './secretsDeployFlow'; +import type { BatchPlan, BatchProgress } from '../deploy/batchDeploy'; const { HIDE_CURSOR, @@ -68,6 +74,19 @@ export interface SecretsEditActions { readonly cancel: (phase: Extract['phase']) => void; } +/** + * What the deploy flow (CAP-704) needs from the outside world. Optional: without it + * the deploy keys do nothing useful. A dry run only ever calls `loadPlan` (reads). + */ +export interface SecretsDeployActions { + /** The Dokploy targets of this row, from GitHub (reads only: nothing is unlocked or written). Never rejects. */ + readonly loadPlan: (row: SecretIndexRow) => Promise; + /** Pushes the values, records the deliveries and opens the PRs, reporting progress as items complete. Never rejects. */ + readonly run: (plan: BatchPlan, onProgress?: (progress: BatchProgress) => void) => Promise; + /** Stops what is in flight: `planning` kills the GitHub reads; `pushing` / `prs` start nothing new and wait for every item in flight. */ + readonly cancel: (phase: Extract['phase']) => void; +} + type DriverAction = | { readonly kind: 'key'; readonly key: string } | { readonly kind: 'resize' } @@ -76,7 +95,10 @@ type DriverAction = | { readonly kind: 'repos'; readonly result: ReposLoaded } | { readonly kind: 'bases'; readonly bases: Readonly> } | { readonly kind: 'ran'; readonly finished: RunFinished } - | { readonly kind: 'progress'; readonly progress: RunProgress }; + | { readonly kind: 'progress'; readonly progress: RunProgress } + | { readonly kind: 'deployPlan'; readonly loaded: DeployPlanLoaded } + | { readonly kind: 'deployProgress'; readonly progress: BatchProgress } + | { readonly kind: 'deployRan'; readonly finished: DeployFinished }; function draw(state: SecretsScreenState): void { const width = process.stdout.columns || 80; @@ -96,22 +118,32 @@ async function loop( decryptAt: LocationDecryptor, edit: SecretsEditActions | undefined, bus: EventEmitter, + deploy?: SecretsDeployActions, ): Promise { if (state.quit) return state.exitText; draw(state); const { value } = await actions.next(); const [action] = value; - const next = (s: SecretsScreenState) => loop(s, actions, decryptAt, edit, bus); + const next = (s: SecretsScreenState) => loop(s, actions, decryptAt, edit, bus, deploy); // A push in flight is never abandoned halfway: a quit signal waits for it. if (action.kind === 'quit') { // A quit signal while an edit runs is a stop request, like Ctrl-C: it never cuts a push in half. - if (!isRunning(state.edit)) return next({ ...state, quit: true }); + if (!isRunning(state.edit) && !isDeployRunning(state.deploy)) return next({ ...state, quit: true }); const stopped = handleKey(state, '\x03'); - if (stopped.effect) perform(stopped.effect, decryptAt, edit, bus); + if (stopped.effect) perform(stopped.effect, decryptAt, edit, bus, deploy); return next(stopped.state); } + if (action.kind === 'deployPlan') { + // The plan is up; the row's value is fetched next (the details view's own fetch) for its `value` line. + const planned = applyDeployPlan(state, action.loaded); + const valueEffect = pendingDeployValueEffect(planned); + if (valueEffect) perform(valueEffect, decryptAt, edit, bus, deploy); + return next(planned); + } + if (action.kind === 'deployProgress') return next(applyDeployRunProgress(state, action.progress)); + if (action.kind === 'deployRan') return next(applyDeployRunDone(state, action.finished)); if (action.kind === 'progress') return next(applyRunProgress(state, action.progress)); if (action.kind === 'resize') return next(state); if (action.kind === 'value') return next(applyValueResult(state, action.row, action.result)); @@ -119,18 +151,18 @@ async function loop( // The table is up now; BASE is read next, and the keys already work. const shown = applyRepos(state, action.result); const basesEffect = pendingBasesEffect(shown); - if (basesEffect) perform(basesEffect, decryptAt, edit, bus); + if (basesEffect) perform(basesEffect, decryptAt, edit, bus, deploy); return next(shown); } if (action.kind === 'bases') { const filled = applyBases(state, action.bases); - if (filled.effect) perform(filled.effect, decryptAt, edit, bus); + if (filled.effect) perform(filled.effect, decryptAt, edit, bus, deploy); return next(filled.state); } if (action.kind === 'ran') return next(applyRunDone(state, action.finished)); const { state: nextState, effect } = handleKey(state, action.key); - if (effect) perform(effect, decryptAt, edit, bus); + if (effect) perform(effect, decryptAt, edit, bus, deploy); return next(nextState); } @@ -144,7 +176,24 @@ function perform( decryptAt: LocationDecryptor, edit: SecretsEditActions | undefined, bus: EventEmitter, + deploy?: SecretsDeployActions, ): void { + if (effect.type === 'loadDeployPlan') { + const loaded = deploy ? deploy.loadPlan(effect.row) : Promise.resolve({ ok: false, code: 'UNAVAILABLE' }); + void loaded.then((result) => bus.emit('action', { kind: 'deployPlan', loaded: result })); + return; + } + if (effect.type === 'cancelDeploy') { + deploy?.cancel(effect.phase); + return; + } + if (effect.type === 'runDeploy') { + const ran = deploy + ? deploy.run(effect.plan, (progress) => bus.emit('action', { kind: 'deployProgress', progress })) + : Promise.resolve({ ok: false, code: 'UNAVAILABLE' }); + void ran.then((finished) => bus.emit('action', { kind: 'deployRan', finished })); + return; + } if (effect.type === 'fetchValue') { void resolveSecretValue(effect.row, decryptAt).then((result) => { bus.emit('action', { kind: 'value', row: effect.row, result }); @@ -182,6 +231,8 @@ export async function runSecretsScreen( edit?: SecretsEditActions, /** `capy --dry-run secrets`: every screen is marked and the edit flow only plans. */ dryRun: boolean = false, + /** The deploy flow's side effects (CAP-704); absent: the deploy keys do nothing useful. */ + deploy?: SecretsDeployActions, ): Promise { const bus = new EventEmitter(); // A single `data` chunk can carry more than one keypress (a paste, fast @@ -206,7 +257,7 @@ export async function runSecretsScreen( const exitText = await (async () => { try { - return await loop(initialSecretsScreenState(rows, dryRun), actions, decryptAt, edit, bus); + return await loop(initialSecretsScreenState(rows, dryRun), actions, decryptAt, edit, bus, deploy); } finally { process.stdout.write(DISABLE_BRACKETED_PASTE + SHOW_CURSOR + EXIT_ALT_SCREEN); if (process.stdin.isTTY) process.stdin.setRawMode(false); diff --git a/src/ui/valueDisplay.ts b/src/ui/valueDisplay.ts new file mode 100644 index 00000000..fa3e3519 --- /dev/null +++ b/src/ui/valueDisplay.ts @@ -0,0 +1,66 @@ +// How a secret's value is shown on screen: the one place the details view and the deploy +// flow (CAP-704) get their `value` line from. Pure: a value state in, one line out. The +// value is only ever drawn into a frame; it goes nowhere else. +// +// It lives apart from `secretsScreen.ts` so the deploy flow can use it without importing +// the screen (which imports the flow). `secretsScreen.ts` re-exports it, so nothing else +// changes. + +import { renderInlineValue } from './valueDialog'; + +const ESC = '\x1b'; +const RESET = `${ESC}[0m`; +const DIM = `${ESC}[90m`; +const RED = `${ESC}[31m`; + +export type ValueState = + | { readonly status: 'loading' } + | { readonly status: 'ok'; readonly value: string } + | { readonly status: 'unavailable'; readonly code: string }; + +// ── Masking (security-critical — see secretsScreen.ts and CAP-675's spec) ─── + +const FULL_MASK = '••••••••'; + +/** + * Never reveals a value ≤8 chars (always the same fixed-width mask, so + * length itself isn't leaked either). For longer values, shows at most 4 + * characters total, split as a prefix and a suffix, and never more than a + * third of the value's length — deliberately weaker than + * `formatSnippet`, which shows values ≤6 chars verbatim; that behavior is + * not reused here on purpose. + */ +export function maskSecretValue(value: string): string { + if (value.length === 0) return '(empty)'; + if (value.length <= 8) return FULL_MASK; + const maxShown = Math.min(4, Math.floor(value.length / 3)); + const prefixLen = Math.ceil(maxShown / 2); + const suffixLen = maxShown - prefixLen; + const prefix = value.slice(0, prefixLen); + const suffix = suffixLen > 0 ? value.slice(value.length - suffixLen) : ''; + return `${prefix}...${suffix}`; +} + +/** + * The `value` line: `loading…`, `unavailable (CODE)`, the masked value, or (revealed) the value on + * one line. A revealed value wider than `width` shows a window of it, `panOffset` characters in, + * with ◂ / ▸ where there is more. + */ +export function renderValueLine(value: ValueState, revealed: boolean, panOffset: number, width: number): string { + if (value.status === 'loading') return `${DIM}loading…${RESET}`; + if (value.status === 'unavailable') return `${RED}unavailable ${DIM}(${value.code})${RESET}`; + + const raw = value.value; + if (!revealed) return maskSecretValue(raw); + if (raw === '') return `${DIM}(empty)${RESET}`; + + const text = renderInlineValue(raw); + if (text.length <= width) return text; + + const visibleWidth = Math.max(1, width - 4); + const maxOffset = Math.max(0, text.length - visibleWidth); + const offset = Math.min(Math.max(0, panOffset), maxOffset); + const leftIndicator = offset > 0 ? `${DIM}◂${RESET} ` : ' '; + const rightIndicator = offset < maxOffset ? ` ${DIM}▸${RESET}` : ' '; + return leftIndicator + text.slice(offset, offset + visibleWidth) + rightIndicator; +} diff --git a/tests/commands/deployCiTargetsRecord.test.ts b/tests/commands/deployCiTargetsRecord.test.ts index 7755973f..c38b6cfb 100644 --- a/tests/commands/deployCiTargetsRecord.test.ts +++ b/tests/commands/deployCiTargetsRecord.test.ts @@ -493,6 +493,38 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { }); }, 30_000); + test('a second CI deploy no longer erases another target\'s record: the pushed keep is built from the SERVER\'s keep, not the local keep.lock (CAP-704)', async () => { + setUpRepo(); + process.chdir(REPO); + // Another target's CI record is already on the SERVER (its own deploy recorded it), but this + // checkout's keep.lock has never seen it: the local copy lags the server. + const otherRecord = { + provider: 'dokploy', + target: 'someone-elses-target', + deployed_value_hash: hashValue(STRIPE_KEY_VALUE), + deployed_at: '2026-01-02T03:04:05.000Z', + }; + const serverKeep = baseKeep(); + writeServerSnapshot('main', { + keep: { + ...serverKeep, + variables: { STRIPE_KEY: [{ ...serverKeep.variables.STRIPE_KEY[0], targets: [otherRecord] }] }, + } as unknown as KeepFile, + }); + expect(readFileSync(join(REPO, 'keep.lock'), 'utf-8')).not.toContain('someone-elses-target'); + + const code = await runScriptedDeploy('dokploy-ci', dokployFetchMock(), { yes: true }); + + expect(code).toBe(0); + expect(pushSecretsMock).toHaveBeenCalledTimes(1); + const [, keepFileJson, envBlob] = pushSecretsMock.mock.calls[0]; + const targets = JSON.parse(keepFileJson).variables.STRIPE_KEY[0].targets as Array<{ target: string }>; + // Both records: the one already on the server survives, and this deploy's is added. + expect(targets.map((t) => t.target).sort()).toEqual(['dokploy-ci', 'someone-elses-target']); + expect(targets.find((t) => t.target === 'someone-elses-target')).toEqual(otherRecord); + expect(envBlob).toBe(SERVER_ENV_FILE); + }, 30_000); + test('a dry run never pushes to the server', async () => { setUpRepo(); process.chdir(REPO); diff --git a/tests/commands/secretsDeployCommand.test.ts b/tests/commands/secretsDeployCommand.test.ts new file mode 100644 index 00000000..51b56734 --- /dev/null +++ b/tests/commands/secretsDeployCommand.test.ts @@ -0,0 +1,307 @@ +/** + * `capy secrets deploy NAME...` (agent mode, CAP-704): selection, the plan and `plan_id`, + * `--confirm`, the result shape, a dry run that changes nothing, and no value ever in the + * output. The service, GitHub, Dokploy and the key unlock are all injected: no network, no `gh`. + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { ERROR_CODES } from '../../src/types/index'; +import { deployPlanIdOf, runSecretsDeploy, type SecretsDeployIo, type SecretsDeployOpts } from '../../src/commands/secretsDeployCommand'; +import { rowIdOf } from '../../src/commands/secretsRowId'; +import { EXIT_NEEDS_INPUT } from '../../src/ui/interactive'; +import { DRY_RUN_COMMANDS } from '../../src/core/cliHelpDoc'; +import { + LINKS, + NAME, + VALUES, + everythingSentTo, + fakeAdapter, + fakeGithub, + fakeService, + hashOf, + indexRows, + makeEnv, + standardRepos, + type AdapterScript, +} from '../helpers/batchDeployWorld'; +import type { SecretIndexRow } from '../../src/service/serviceClient'; + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +interface Captured { + readonly exitCode: number | undefined; + readonly returned: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +/** Runs `fn`, capturing stdout/stderr and the exit code; `process.exit` never really exits. */ +async function capture(fn: () => Promise): Promise { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + const outcome = await fn().then( + (returned) => ({ returned, thrown: null as unknown }), + (thrown: unknown) => ({ returned: undefined, thrown }), + ); + const stdout = logSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'); + const stderr = errSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'); + const exits = exitSpy.mock.calls; + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + if (outcome.thrown !== null && !(outcome.thrown instanceof ExitSignal)) throw outcome.thrown; + return { + exitCode: exits.length > 0 ? (exits[exits.length - 1][0] as number | undefined) : undefined, + returned: outcome.returned, + stdout, + stderr, + }; +} + +function rig(over: { rows?: SecretIndexRow[]; adapter?: AdapterScript; control?: SecretsDeployIo['control'] } = {}) { + const service = fakeService(); + const github = fakeGithub(standardRepos()); + const adapter = fakeAdapter(over.adapter); + const { env, openKeys } = makeEnv(service, github, adapter); + const rows = over.rows ?? indexRows(); + const io: SecretsDeployIo = { + orgId: 'org1', + client: { + getSecretIndex: async () => ({ org_id: 'org1', rows, skipped: [] }), + getOrgRepos: async () => ({ org_id: 'org1', repos: [...LINKS] }), + }, + env, + control: over.control, + }; + return { io, service, github, adapter, openKeys }; +} + +const noWrites = (r: ReturnType) => { + expect(r.openKeys.mock.calls).toHaveLength(0); + expect(r.service.getDecryptData.mock.calls).toHaveLength(0); + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + expect(r.adapter.preflight.mock.calls).toHaveLength(0); + expect(r.adapter.deploy.mock.calls).toHaveLength(0); + expect(r.github.createBlob.mock.calls).toHaveLength(0); + expect(r.github.createRef.mock.calls).toHaveLength(0); + expect(r.github.createPull.mock.calls).toHaveLength(0); +}; + +const json = (c: Captured) => JSON.parse(c.stdout); +const run = (r: ReturnType, opts: SecretsDeployOpts, names: readonly string[] = [NAME]) => + capture(() => runSecretsDeploy(names, { json: true, ...opts }, r.io)); + +/** Two rows of NAME: the usual value, and a different one on mono-backend/staging. */ +const TWO_ROWS: SecretIndexRow[] = (() => { + const [first] = indexRows(); + const other = { ...first, value_hash: 'f'.repeat(16), locations: [first.locations[1]] }; + return [{ ...first, locations: [first.locations[0], first.locations[2]] }, other]; +})(); +const rowIds = (rows: readonly SecretIndexRow[]) => rows.map((r) => rowIdOf(r.name, r.value_hash)); + +describe('selecting rows', () => { + test('several rows and neither --row nor --all-rows: SECRET_AMBIGUOUS, exit 3, candidates in `unanswered`, nothing done', async () => { + const r = rig({ rows: TWO_ROWS }); + const out = await run(r, {}); + expect(out.exitCode).toBe(EXIT_NEEDS_INPUT); + const body = json(out); + expect(body).toMatchObject({ ok: false, code: ERROR_CODES.SECRET_AMBIGUOUS }); + expect(body.unanswered[0].flag).toBe('--row'); + expect(body.unanswered[0].candidates.map((c: { row_id: string }) => c.row_id).toSorted()).toEqual([...rowIds(TWO_ROWS)].toSorted()); + noWrites(r); + }); + + test('--all-rows takes every row; --row takes one; an unknown --row is SECRET_NOT_FOUND', async () => { + const all = await run(rig({ rows: TWO_ROWS }), { allRows: true, dryRun: true }); + expect(json(all).targets.map((t: { target: string }) => t.target)).toEqual(['api', 'worker', 'site']); + + const [mono] = rowIds(TWO_ROWS); + const one = await run(rig({ rows: TWO_ROWS }), { row: [mono], dryRun: true }); + expect(json(one).ok).toBe(true); + + const unknown = await run(rig({ rows: TWO_ROWS }), { row: ['000000000000'] }); + expect(json(unknown)).toMatchObject({ ok: false, code: ERROR_CODES.SECRET_NOT_FOUND }); + }); + + test('--row and --all-rows together are refused; an unknown name is SECRET_NOT_FOUND; a bad name is INVALID_FORMAT', async () => { + expect(json(await run(rig(), { row: ['x'], allRows: true })).code).toBe(ERROR_CODES.INVALID_FORMAT); + expect(json(await run(rig(), {}, ['NO_SUCH_NAME'])).code).toBe(ERROR_CODES.SECRET_NOT_FOUND); + expect(json(await run(rig(), {}, ['not a name'])).code).toBe(ERROR_CODES.INVALID_FORMAT); + }); + + test('--exclude drops a location; naming one that is not selected is refused', async () => { + const r = rig(); + const out = await run(r, { dryRun: true, exclude: ['solo-server:staging'] }); + expect(json(out).targets.map((t: { target: string }) => t.target)).toEqual(['api', 'worker']); + expect(json(await run(rig(), { exclude: ['nope:nothing'] })).code).toBe(ERROR_CODES.INVALID_FORMAT); + }); +}); + +describe('--dry-run: the plan, and nothing changes', () => { + test('prints a JSON plan with a plan_id, the targets with their var counts, and the skips with codes', async () => { + const r = rig(); + const out = await run(r, { dryRun: true }); + expect(out.returned).toBe(0); + const body = json(out); + expect(body).toMatchObject({ ok: true, dry_run: true, names: [NAME] }); + expect(body.plan_id).toMatch(/^[0-9a-f]{16}$/); + expect(body.targets).toEqual([ + { project: 'mono-backend', branch: 'production', target: 'api', provider: 'dokploy', repo: 'Acme/mono', path: 'backend', base: 'main', vars: 2 }, + { project: 'mono-backend', branch: 'production', target: 'worker', provider: 'dokploy', repo: 'Acme/mono', path: 'backend', base: 'main', vars: 1 }, + { project: 'solo-server', branch: 'staging', target: 'site', provider: 'dokploy', repo: 'Acme/solo', path: '.', base: 'staging', vars: 1 }, + ]); + expect(body.skipped.map((s: { code: string }) => s.code).toSorted()).toEqual( + ['NOT_CI_MODE', 'NOT_DOKPLOY', 'NO_TARGET', 'TARGET_NOT_ON_DEFAULT_BRANCH'].toSorted(), + ); + }); + + test('changes nothing: no unlock, no decrypt, no Dokploy call, no server write, no GitHub write', async () => { + const r = rig(); + await run(r, { dryRun: true }); + noWrites(r); + }); + + test('stdout is pure JSON and stderr says nothing', async () => { + const out = await run(rig(), { dryRun: true }); + expect(() => JSON.parse(out.stdout)).not.toThrow(); + expect(out.stderr).toBe(''); + }); + + test('plan_id is stable for the same selection and moves when the plan moves', async () => { + const a = json(await run(rig(), { dryRun: true })).plan_id; + const b = json(await run(rig(), { dryRun: true })).plan_id; + const c = json(await run(rig(), { dryRun: true, exclude: ['solo-server:staging'] })).plan_id; + expect(a).toBe(b); + expect(c).not.toBe(a); + }); + + test('the plan_id is order-free and carries no value hash', () => { + const plan = { targets: [], skipped: [], read_failed: [] }; + const one = deployPlanIdOf(['A', 'B'], [{ row_id: 'r1', project: 'p', branch: 'b' }, { row_id: 'r2', project: 'q', branch: 'b' }], plan); + const two = deployPlanIdOf(['B', 'A'], [{ row_id: 'r2', project: 'q', branch: 'b' }, { row_id: 'r1', project: 'p', branch: 'b' }], plan); + expect(one).toBe(two); + expect(one.includes(hashOf('API_KEY'))).toBe(false); + }); + + test('without --json it prints the plan as text', async () => { + const r = rig(); + const out = await capture(() => runSecretsDeploy([NAME], { dryRun: true }, r.io)); + expect(out.stdout).toMatch(/^Plan [0-9a-f]{16}/); + expect(out.stdout).toContain('mono-backend · production'); + expect(out.stdout).toContain('skipped NOT_DOKPLOY'); + noWrites(r); + }); + + test('the command is listed as one that honours --dry-run', () => { + expect(DRY_RUN_COMMANDS.has('secrets deploy')).toBe(true); + }); +}); + +describe('a real run needs --confirm ', () => { + test('without it: PLAN_CONFIRM_REQUIRED, exit 3, the plan and the id to confirm; nothing done', async () => { + const r = rig(); + const planId = json(await run(rig(), { dryRun: true })).plan_id; + const out = await run(r, {}); + expect(out.exitCode).toBe(EXIT_NEEDS_INPUT); + const body = json(out); + expect(body).toMatchObject({ ok: false, code: ERROR_CODES.PLAN_CONFIRM_REQUIRED, plan_id: planId }); + expect(body.unanswered).toEqual([{ id: 'confirm', flag: '--confirm', value: planId }]); + expect(body.plan.targets).toHaveLength(3); + noWrites(r); + }); + + test('a plan that moved: PLAN_CHANGED, exit 1, nothing done', async () => { + const r = rig(); + const out = await run(r, { confirm: '0123456789abcdef' }); + expect(out.exitCode).toBe(1); + expect(json(out)).toMatchObject({ ok: false, code: ERROR_CODES.PLAN_CHANGED }); + noWrites(r); + }); + + test('nothing to deploy: DEPLOY_NOTHING_TO_DEPLOY with the plan (the location has no target), nothing done', async () => { + const r = rig(); + const out = await run(r, { exclude: ['mono-backend:production', 'solo-server:staging'] }); + expect(out.exitCode).toBe(1); + const body = json(out); + expect(body).toMatchObject({ ok: false, code: ERROR_CODES.DEPLOY_NOTHING_TO_DEPLOY }); + expect(body.plan.skipped).toEqual([{ project: 'mono-backend', branch: 'staging', target: null, provider: null, code: 'NO_TARGET' }]); + noWrites(r); + }); + + test('the dry-run -> confirm round trip deploys exactly the planned targets and reports their PRs', async () => { + const planId = json(await run(rig(), { dryRun: true })).plan_id; + const r = rig(); + const out = await run(r, { confirm: planId }); + + expect(out.returned).toBe(0); + expect(out.stderr).toBe(''); + const body = json(out); + expect(body).toMatchObject({ ok: true, plan_id: planId, names: [NAME], failed: [], cancelled: [] }); + expect(body.delivered.map((d: { target: string; base: string }) => `${d.target}@${d.base}`)).toEqual(['api@main', 'worker@main', 'site@staging']); + expect(body.delivered.every((d: { pr_url: string; recorded: boolean }) => d.pr_url.startsWith('https://github.com/') && d.recorded)).toBe(true); + expect(r.adapter.deploy.mock.calls.map(([c]) => c.name)).toEqual(['api', 'worker', 'site']); + expect(r.github.createPull.mock.calls).toHaveLength(3); + expect(r.openKeys.mock.calls).toHaveLength(1); + }); + + test('a target that fails: ok false, DEPLOY_BATCH_PARTIAL, exit status 1, the failure is a code, the others are delivered', async () => { + const planId = json(await run(rig(), { dryRun: true })).plan_id; + const r = rig({ + adapter: { preflight: (config) => (config.name === 'worker' ? { ok: false, code: ERROR_CODES.DOKPLOY_AUTODEPLOY_OFF, reason: 'r' } : { ok: true }) }, + }); + const out = await run(r, { confirm: planId }); + expect(out.returned).toBe(1); + const body = json(out); + expect(body).toMatchObject({ ok: false, code: ERROR_CODES.DEPLOY_BATCH_PARTIAL }); + expect(body.failed).toEqual([ + { project: 'mono-backend', branch: 'production', target: 'worker', provider: 'dokploy', repo: 'Acme/mono', path: 'backend', code: ERROR_CODES.DOKPLOY_AUTODEPLOY_OFF, stage: 'preflight', values_pushed: false }, + ]); + expect(body.delivered.map((d: { target: string }) => d.target)).toEqual(['api', 'site']); + }); + + test('a stop (Ctrl+C) before the pushes: nothing runs, everything is reported cancelled, exit status 1', async () => { + const planId = json(await run(rig(), { dryRun: true })).plan_id; + const stop = new AbortController(); + stop.abort(); + const r = rig({ control: { pushes: stop.signal } }); + const out = await run(r, { confirm: planId }); + expect(out.returned).toBe(1); + const body = json(out); + expect(body.code).toBe(ERROR_CODES.DEPLOY_BATCH_PARTIAL); + expect(body.cancelled.map((c: { target: string; stage: string }) => `${c.target}:${c.stage}`)).toEqual(['api:push', 'worker:push', 'site:push']); + expect(r.adapter.deploy.mock.calls).toHaveLength(0); + }); + + test('without --json: the confirmation reads like the multi-edit\'s, and a skipped target is listed', async () => { + const planId = json(await run(rig(), { dryRun: true })).plan_id; + const r = rig(); + const out = await capture(() => runSecretsDeploy([NAME], { confirm: planId }, r.io)); + expect(out.stdout).toContain('✓ 3 targets deployed.'); + expect(out.stdout).toContain('Review and merge to deploy:'); + expect(out.stdout).toContain('Acme/mono · api'); + expect(out.stdout).toContain('Skipped'); + expect(out.stdout).toContain('The new values are already in Dokploy.'); + }); +}); + +describe('no value in any output', () => { + test('refusals, plans and results never carry a value, a value hash, or a plan built from one', async () => { + const planRun = await run(rig(), { dryRun: true }); + const planId = json(planRun).plan_id; + const r = rig(); + const real = await run(r, { confirm: planId }); + const noConfirm = await run(rig(), {}); + const everything = [planRun.stdout, planRun.stderr, real.stdout, real.stderr, noConfirm.stdout, noConfirm.stderr].join('\n'); + const leaks = [...Object.values(VALUES), ...Object.keys(VALUES).map(hashOf)].filter((v) => everything.includes(v)); + expect(leaks).toEqual([]); + // What was written elsewhere carries no plaintext either. + const written = everythingSentTo(r.github.createBlob, r.github.createCommit, r.github.createPull, r.service.pushSecrets); + expect(Object.values(VALUES).filter((v) => written.includes(v))).toEqual([]); + }); +}); diff --git a/tests/commands/secretsDeployHelp.test.ts b/tests/commands/secretsDeployHelp.test.ts new file mode 100644 index 00000000..bb287271 --- /dev/null +++ b/tests/commands/secretsDeployHelp.test.ts @@ -0,0 +1,43 @@ +/** + * `capy secrets deploy` in `capy help --json` (CAP-704), against the real BUILT CLI, both + * entrypoints — needs `bun run build` first, like the other tests that spawn `dist/`. + * It is listed the way `secrets set` is: a path, a variadic name argument, `--json`, + * `--dry-run`, and its new error codes. + */ +import { describe, test, expect } from 'bun:test'; +import { join } from 'path'; +import { spawnSync } from 'child_process'; + +const CLIS = { prod: join(__dirname, '../../dist/index.js'), dev: join(__dirname, '../../dist/index-dev.js') } as const; + +function help(cliPath: string) { + const r = spawnSync('node', [cliPath, 'help', '--json'], { encoding: 'utf-8' }); + expect(r.status).toBe(0); + return JSON.parse(r.stdout); +} + +const flatten = (cmds: any[]): any[] => cmds.flatMap((c) => [c, ...flatten(c.subcommands ?? [])]); + +describe.each(Object.entries(CLIS))('capy help --json (%s)', (_label, cli) => { + const doc = help(cli); + const byPath = Object.fromEntries(flatten(doc.commands).map((c: any) => [c.path, c])); + + test('lists `secrets deploy` next to `secrets set`, with a variadic name, --json and --dry-run', () => { + expect(byPath['secrets set']).toBeDefined(); + const deploy = byPath['secrets deploy']; + expect(deploy).toBeDefined(); + expect(deploy.arguments).toEqual([{ name: 'names', required: true, variadic: true }]); + expect(deploy.supportsJson).toBe(true); + expect(deploy.supportsDryRun).toBe(true); + const flags = deploy.options.map((o: any) => o.long); + for (const flag of ['--json', '--row', '--all-rows', '--exclude', '--confirm']) expect(flags).toContain(flag); + // It takes no value (it deploys what Capy holds) and has no PR switches. + expect(flags).not.toContain('--no-pr'); + }); + + test('carries its error codes', () => { + for (const code of ['DEPLOY_BATCH_PARTIAL', 'DEPLOY_NOTHING_TO_DEPLOY', 'DEPLOY_VARS_MISSING', 'DEPLOY_PREFLIGHT_FAILED', 'DEPLOY_PUSH_FAILED']) { + expect(doc.errorCodes).toContain(code); + } + }); +}); diff --git a/tests/deploy/batchDeploy.test.ts b/tests/deploy/batchDeploy.test.ts new file mode 100644 index 00000000..d5ba5a84 --- /dev/null +++ b/tests/deploy/batchDeploy.test.ts @@ -0,0 +1,519 @@ +/** + * Batch deploy (CAP-704, src/deploy/batchDeploy.ts): the Dokploy CI deploy of many + * targets with no project folder. Config comes from the default branch's + * `.capy/deploy.json` through the GitHub API, values from one unlock, the record is built + * from the server's keep, the PR goes through the GitHub API. Everything external is + * injected (tests/helpers/batchDeployWorld.ts): no network, no `gh`, no git. What the engine + * did is read back from `mock` call logs, so no test keeps a mutable counter. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { + BATCH_DEPLOY_CONCURRENCY, + SKIP_CODES, + batchSucceeded, + deployLocationsOf, + deployTarget, + planBatchDeploy, + pushTarget, + runBatchDeploy, + type BatchPlan, + type BatchProgress, +} from '../../src/deploy/batchDeploy'; +import { createDokployAdapter } from '../../src/deploy/adapters/dokploy'; +import { buildDeployPrBody } from '../../src/deploy/deployPrBody'; +import { renderBatchResult, resultJsonOf } from '../../src/commands/secretsDeployText'; +import { CapyError, ERROR_CODES, KeepFile } from '../../src/types/index'; +import { + KEYS, + LEGACY_RECORD, + LINKS, + NAME, + VALUES, + batchTarget, + dokployTarget, + everythingSentTo, + fakeAdapter, + fakeGithub, + fakeService, + hashOf, + indexRows, + makeEnv, + standardRepos, + type WorldOpts, +} from '../helpers/batchDeployWorld'; + +const request = { names: [NAME], locations: deployLocationsOf(indexRows()[0]) }; + +function world(opts: WorldOpts = {}, adapter = fakeAdapter(), serviceOpts: Parameters[0] = {}) { + const service = fakeService(serviceOpts); + const github = fakeGithub(standardRepos(opts)); + const { env, openKeys } = makeEnv(service, github, adapter); + return { service, github, adapter, env, openKeys }; +} + +const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The most `+` not yet closed by a `-` at any point of the log. */ +const maxOpen = (events: readonly string[]): number => + events.reduce( + (acc, e) => { + const open = acc.open + (e.startsWith('+') ? 1 : -1); + return { open, max: Math.max(acc.max, open) }; + }, + { open: 0, max: 0 }, + ).max; + +const skipPairs = (plan: BatchPlan) => plan.skipped.map((s) => `${s.project}/${s.branch}/${s.target ?? '-'}:${s.code}`).toSorted(); + +describe('the plan: which targets would be deployed', () => { + test('reads each project\'s deploy.json from the repo DEFAULT branch only, and an unmerged target does not count', async () => { + const { github } = world(); + const plan = await planBatchDeploy(github, request, LINKS); + + const reads = github.getFile.mock.calls.filter((c) => String(c[1]).endsWith('deploy.json')); + expect(reads.map((c) => `${c[0].name}:${c[1]}@${c[2]}`).toSorted()).toEqual(['mono:backend/.capy/deploy.json@main', 'solo:.capy/deploy.json@trunk']); + // `unmerged` is only on mono's `develop`: Capy knows it delivered there, but it is not a target. + expect(plan.targets.map((t) => t.config.name)).not.toContain('unmerged'); + expect(skipPairs(plan)).toContain(`mono-backend/production/unmerged:${SKIP_CODES.TARGET_NOT_ON_DEFAULT_BRANCH}`); + }); + + test('Dokploy CI targets are planned; other providers, direct mode and locations with no target are skipped with codes', async () => { + const { github } = world(); + const plan = await planBatchDeploy(github, request, LINKS); + + expect(plan.targets.map((t) => `${t.project_name}/${t.branch}/${t.config.name}`)).toEqual([ + 'mono-backend/production/api', + 'mono-backend/production/worker', + 'solo-server/staging/site', + ]); + expect(skipPairs(plan)).toEqual([ + `mono-backend/production/cf:${SKIP_CODES.NOT_DOKPLOY}`, + `mono-backend/production/direct:${SKIP_CODES.NOT_CI_MODE}`, + `mono-backend/production/unmerged:${SKIP_CODES.TARGET_NOT_ON_DEFAULT_BRANCH}`, + `mono-backend/staging/-:${SKIP_CODES.NO_TARGET}`, + ]); + expect(plan.read_failed).toEqual([]); + }); + + test('a target is deployed from the target\'s PR base: its gitBaseBranch, else the repo\'s default branch', async () => { + const { github } = world({ monoTargets: [dokployTarget('nobase', { gitBaseBranch: undefined })] }); + const plan = await planBatchDeploy(github, request, LINKS); + const bases = Object.fromEntries(plan.targets.map((t) => [t.config.name, t.base])); + expect(bases).toEqual({ nobase: 'main', site: 'staging' }); + }); + + test('a target that ships none of the selected names is not planned', async () => { + const { github } = world(); + const plan = await planBatchDeploy(github, { ...request, names: ['DB_URL'] }, LINKS); + expect(plan.targets.map((t) => t.config.name)).toEqual(['api']); // `worker` and `site` ship API_KEY only + }); + + test('two rows reaching the same target make it one target', async () => { + const { github } = world(); + const twice = { names: [NAME, 'DB_URL'], locations: [...request.locations, ...request.locations] }; + const plan = await planBatchDeploy(github, twice, LINKS); + expect(plan.targets.map((t) => t.config.name)).toEqual(['api', 'worker', 'site']); + }); + + test('a repo whose deploy.json cannot be read is reported with a code and its locations are not decided', async () => { + const { github } = world({ monoFail: ['getFile'] }); + const plan = await planBatchDeploy(github, request, LINKS); + expect(plan.read_failed).toEqual([{ repo: 'Acme/mono', code: ERROR_CODES.KEEP_PR_READ_FAILED }]); + expect(plan.targets.map((t) => t.config.name)).toEqual(['site']); + expect(skipPairs(plan).filter((s) => s.startsWith('mono-backend'))).toEqual([]); + }); + + test('no gh: a code, nothing planned', async () => { + const plan = await planBatchDeploy(undefined, request, LINKS); + expect(plan.targets).toEqual([]); + expect(plan.read_failed).toEqual([{ repo: 'github.com', code: ERROR_CODES.KEEP_PR_GH_UNAVAILABLE }]); + }); + + test('a location whose project has no repo link has no target (or only ones Capy knows from before)', async () => { + const { github } = world(); + const plan = await planBatchDeploy(github, request, []); + expect(plan.targets).toEqual([]); + expect(skipPairs(plan)).toEqual([ + `mono-backend/production/api:${SKIP_CODES.TARGET_NOT_ON_DEFAULT_BRANCH}`, + `mono-backend/production/unmerged:${SKIP_CODES.TARGET_NOT_ON_DEFAULT_BRANCH}`, + `mono-backend/staging/-:${SKIP_CODES.NO_TARGET}`, + `solo-server/staging/site:${SKIP_CODES.TARGET_NOT_ON_DEFAULT_BRANCH}`, + ]); + }); + + test('planning reads only: it writes nothing and unlocks nothing', async () => { + const { github, service, adapter, openKeys } = world(); + await planBatchDeploy(github, request, LINKS); + expect(openKeys.mock.calls).toHaveLength(0); + expect(service.getDecryptData.mock.calls).toHaveLength(0); + expect(service.pushSecrets.mock.calls).toHaveLength(0); + expect(adapter.deploy.mock.calls).toHaveLength(0); + expect(github.createBlob.mock.calls).toHaveLength(0); + expect(github.createPull.mock.calls).toHaveLength(0); + }); +}); + +describe('a run: values, preflight, record and PR', () => { + const plannedWorld = async (opts: WorldOpts = {}, adapter = fakeAdapter(), serviceOpts: Parameters[0] = {}) => { + const w = world(opts, adapter, serviceOpts); + const plan = await planBatchDeploy(w.github, request, LINKS); + return { ...w, plan }; + }; + + test('one unlock for the whole run, and each target\'s own variables (and only those) reach the adapter, decrypted', async () => { + const { plan, env, openKeys, adapter, service } = await plannedWorld(); + const result = await runBatchDeploy(plan, env); + + expect(openKeys.mock.calls).toHaveLength(1); + expect(service.getDecryptData.mock.calls.map((c) => `${c[0]}/${c[1]}`).toSorted()).toEqual(['pA/production', 'pA/production', 'pB/staging']); + const sent = Object.fromEntries(adapter.deploy.mock.calls.map(([config, ctx]) => [config.name, ctx.env])); + expect(sent).toEqual({ + api: { API_KEY: VALUES.API_KEY, DB_URL: VALUES.DB_URL }, + worker: { API_KEY: VALUES.API_KEY }, + site: { API_KEY: VALUES.API_KEY }, + }); + // CI mode: secrets only. Capy never triggers a deploy; merging the PR does. + expect(adapter.deploy.mock.calls.every(([, ctx]) => ctx.secretsOnly === true && ctx.noDeploy === false && ctx.dryRun === false)).toBe(true); + expect(result.targets.map((t) => t.kind)).toEqual(['delivered', 'delivered', 'delivered']); + expect(batchSucceeded(result)).toBe(true); + }); + + test('preflight is given keep.lock\'s repo-relative path, per target, and runs before anything is decrypted', async () => { + const { plan, env, adapter, service } = await plannedWorld(); + await runBatchDeploy(plan, env); + + const paths = Object.fromEntries(adapter.preflight.mock.calls.map(([config, ctx]) => [config.name, (ctx as { keepLockPath: string }).keepLockPath])); + expect(paths).toEqual({ api: 'backend/keep.lock', worker: 'backend/keep.lock', site: 'keep.lock' }); + // A refused preflight never reads or decrypts a value. + const refusing = fakeAdapter({ preflight: () => ({ ok: false, code: ERROR_CODES.DOKPLOY_AUTODEPLOY_OFF, reason: 'r' }) }); + const w = await plannedWorld({}, refusing); + const out = await runBatchDeploy(w.plan, w.env); + expect(w.service.getDecryptData.mock.calls).toHaveLength(0); + expect(refusing.deploy.mock.calls).toHaveLength(0); + expect(out.targets.every((t) => t.kind === 'failed' && t.stage === 'preflight' && t.code === ERROR_CODES.DOKPLOY_AUTODEPLOY_OFF && !t.values_pushed)).toBe(true); + expect(service.getDecryptData.mock.calls.length).toBeGreaterThan(0); + }); + + test('the REAL Dokploy preflight uses the passed keep.lock path, not the working directory', async () => { + const appJson = (watchPaths: readonly string[]) => ({ + applicationId: 'app-api', + name: 'demo', + env: 'NODE_ENV=production', + buildArgs: null, + buildSecrets: null, + createEnvFile: true, + autoDeploy: true, + branch: 'main', + watchPaths, + }); + const fetchFor = (watchPaths: readonly string[]) => + mock(async (url: string) => { + if (!new URL(url).pathname.endsWith('application.one')) throw new Error('unscripted request'); + return { status: 200, ok: true, text: async () => JSON.stringify(appJson(watchPaths)) }; + }); + const preflight = async (watchPaths: readonly string[]) => { + const adapter = createDokployAdapter({ fetch: fetchFor(watchPaths) as never, log: () => undefined }); + return adapter.preflight(dokployTarget('api'), { + cwd: process.cwd(), // not a project folder: only the passed path can say where keep.lock is + keepLockPath: 'backend/keep.lock', + resolvedApiKey: { ok: true, value: 'dk', source: 'env' }, + interactive: false, + }); + }; + expect((await preflight(['backend/**'])).ok).toBe(true); + const refused = await preflight(['frontend/**']); + expect(refused.ok).toBe(false); + expect(refused.code).toBe(ERROR_CODES.DOKPLOY_WATCH_PATHS_EXCLUDE_KEEP); + }); + + test('the record is built from the SERVER\'s keep: another target\'s record survives, the blob is re-sent unchanged, and a branch gets ONE write', async () => { + const { plan, env, service } = await plannedWorld(); + await runBatchDeploy(plan, env); + + const writes = service.pushSecrets.mock.calls.map((c) => `${c[0]}/${c[3]}`).toSorted(); + expect(writes).toEqual(['pA/production', 'pB/staging']); // api and worker share pA/production: one write, no lost update + const [, keepJson, blob] = service.pushSecrets.mock.calls.find((c) => c[0] === 'pA/production'.split('/')[0] && c[3] === 'production') as string[]; + const keep = JSON.parse(keepJson) as KeepFile; + const apiKey = keep.variables.API_KEY[0] as unknown as { targets: Array<{ target: string; deployed_value_hash: string }> }; + expect(apiKey.targets.map((t) => t.target).toSorted()).toEqual(['api', 'legacy', 'worker']); + expect(apiKey.targets.find((t) => t.target === 'legacy')).toEqual(LEGACY_RECORD); + expect(apiKey.targets.find((t) => t.target === 'api')?.deployed_value_hash).toBe(hashOf('API_KEY')); + const dbUrl = keep.variables.DB_URL[0] as unknown as { targets: Array<{ target: string }> }; + expect(dbUrl.targets.map((t) => t.target)).toEqual(['api']); // `worker` does not ship DB_URL + expect(blob).toBe(service.blobs['pA/production']); + // The pin itself is untouched: only `targets` changed. + expect(keep.variables.API_KEY[0].value_hash).toBe(hashOf('API_KEY')); + }); + + test('one PR per target, through the GitHub API, on the target\'s base, with the deploy PR\'s own title and body', async () => { + const { plan, env, github } = await plannedWorld(); + await runBatchDeploy(plan, env); + + expect(github.createPull.mock.calls).toHaveLength(3); + const pulls = github.createPull.mock.calls.map(([repo, params]) => ({ repo: repo.name, ...params })); + const api = pulls.find((p) => p.title === 'deploy: api → production (dokploy)'); + const site = pulls.find((p) => p.title === 'deploy: site → staging (dokploy)'); + expect(api?.repo).toBe('mono'); + expect(api?.base).toBe('main'); + expect(api?.body).toBe(buildDeployPrBody(dokployTarget('api'))); + expect(site?.repo).toBe('solo'); + expect(site?.base).toBe('staging'); // gitBaseBranch, not the repo's default branch `trunk` + expect(site?.body).toBe(buildDeployPrBody(dokployTarget('site', { branch: 'staging', gitBaseBranch: 'staging', vars: ['API_KEY'] }))); + // The keep.lock each PR carries is the base's own with this delivery folded in. + const blobs = github.createBlob.mock.calls.map((c) => JSON.parse(String(c[1])) as KeepFile); + const apiBlob = blobs.find((k) => k.variables.API_KEY[0].targets?.some((t) => t.target === 'api')) as KeepFile; + expect(apiBlob.variables.API_KEY[0].targets?.map((t) => t.target)).toEqual(['api']); + expect(apiBlob.variables.API_KEY[0].targets?.[0].deployed_value_hash).toBe(hashOf('API_KEY')); + expect(apiBlob.variables.DB_URL[0].targets?.[0].deployed_value_hash).toBe(hashOf('DB_URL')); + }); + + test('every PR is read from the target\'s base, and the delivered result carries its URL', async () => { + const { plan, env, github } = await plannedWorld(); + const result = await runBatchDeploy(plan, env); + const keepReads = github.getFile.mock.calls.filter((c) => String(c[1]).endsWith('keep.lock')).map((c) => `${c[0].name}:${c[1]}@${c[2]}`); + expect(keepReads).toContain('solo:keep.lock@staging'); + expect(keepReads).toContain('mono:backend/keep.lock@main'); + const urls = result.targets.map((t) => (t.kind === 'delivered' ? t.pr_url : null)); + expect(urls.every((u) => typeof u === 'string' && u.startsWith('https://github.com/'))).toBe(true); + expect(result.targets.every((t) => t.kind === 'delivered' && t.recorded)).toBe(true); + }); + + test('a target whose keep.lock on the base already records these values is skipped NOTHING_TO_DEPLOY: no push, no record, no PR', async () => { + const { plan, env, adapter, service, github } = await plannedWorld({ recordedOnMain: ['api', 'worker'] }); + const result = await runBatchDeploy(plan, env); + + expect(result.targets.map((t) => (t.kind === 'skipped' ? t.code : t.kind))).toEqual([SKIP_CODES.NOTHING_TO_DEPLOY, SKIP_CODES.NOTHING_TO_DEPLOY, 'delivered']); + expect(adapter.deploy.mock.calls.map(([c]) => c.name)).toEqual(['site']); + expect(service.pushSecrets.mock.calls.map((c) => c[0])).toEqual(['pB']); + expect(github.createPull.mock.calls).toHaveLength(1); + }); + + test('a run that changes nothing is a success', async () => { + const { plan, env } = await plannedWorld({ recordedOnMain: ['api', 'worker'] }); + const only = { ...plan, targets: plan.targets.filter((t) => t.config.name !== 'site') }; + const result = await runBatchDeploy(only, env); + expect(batchSucceeded(result)).toBe(true); + }); + + test('failures are codes and one never blocks another', async () => { + const adapter = fakeAdapter({ + deploy: (config) => (config.name === 'worker' ? { ok: false, steps: [{ label: 'application.saveEnvironment', status: 'fail', detail: 'x' }] } : { ok: true, steps: [] }), + }); + const w = await plannedWorld({}, adapter); + const result = await runBatchDeploy(w.plan, w.env); + + expect(result.targets.map((t) => t.kind)).toEqual(['delivered', 'failed', 'delivered']); + const failed = result.targets[1]; + expect(failed).toMatchObject({ kind: 'failed', code: ERROR_CODES.DEPLOY_PUSH_FAILED, stage: 'push', values_pushed: false }); + expect(batchSucceeded(result)).toBe(false); + // The failed target is not recorded and has no PR. + const keep = JSON.parse(String(w.service.pushSecrets.mock.calls.find((c) => c[0] === 'pA')?.[1])) as KeepFile; + expect(keep.variables.API_KEY[0].targets?.map((t) => t.target).toSorted()).toEqual(['api', 'legacy']); + expect(w.github.createPull.mock.calls).toHaveLength(2); + }); + + test('a variable missing from the Capy branch is left out and named, like capy deploy; the rest are delivered', async () => { + const w = await plannedWorld({}, fakeAdapter(), { vars: ['API_KEY'] }); // DB_URL is not in any branch + const result = await runBatchDeploy(w.plan, w.env); + expect(result.targets[0]).toMatchObject({ kind: 'delivered', missing_vars: ['DB_URL'] }); + const apiCall = w.adapter.deploy.mock.calls.find(([c]) => c.name === 'api'); + expect(apiCall?.[0].vars).toEqual(['API_KEY']); + expect(Object.keys(apiCall?.[1].env ?? {})).toEqual(['API_KEY']); + expect(w.adapter.deploy.mock.calls.map(([c]) => c.name).toSorted()).toEqual(['api', 'site', 'worker']); + expect(renderBatchResult(result)).toContain('Not in Capy, left out: DB_URL'); + }); + + test('a PR base that does not exist fails BEFORE the push: nothing reaches Dokploy', async () => { + const w = world(); + const result = await deployTarget(w.env, async (p) => KEYS[p as 'pA'], { forTarget: () => undefined }, batchTarget('pA', dokployTarget('api', { gitBaseBranch: 'gone' }))); + expect(result).toMatchObject({ kind: 'failed', code: ERROR_CODES.KEEP_PR_BASE_UNRESOLVED, stage: 'github', values_pushed: false }); + expect(w.adapter.deploy.mock.calls).toHaveLength(0); + expect(w.service.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('no gh: every target fails KEEP_PR_GH_UNAVAILABLE and nothing is pushed', async () => { + const w = await plannedWorld(); + const result = await runBatchDeploy(w.plan, { ...w.env, github: () => undefined }); + expect(result.targets.every((t) => t.kind === 'failed' && t.code === ERROR_CODES.KEEP_PR_GH_UNAVAILABLE)).toBe(true); + expect(w.adapter.deploy.mock.calls).toHaveLength(0); + }); + + test('a PR that fails after the push is `failed` with values_pushed, and the record was still made', async () => { + const w = await plannedWorld({ soloFail: ['createPull'] }); + const result = await runBatchDeploy(w.plan, w.env); + expect(result.targets[2]).toMatchObject({ kind: 'failed', code: ERROR_CODES.KEEP_PR_CREATE_FAILED, stage: 'pr', values_pushed: true }); + expect(w.service.pushSecrets.mock.calls.map((c) => c[0]).toSorted()).toEqual(['pA', 'pB']); + }); + + test('a record the server refuses is reported on the delivered target, and the PR is still opened', async () => { + const w = await plannedWorld({}, fakeAdapter(), { failPush: { 'pA/production': ERROR_CODES.SERVICE_ERROR } }); + const result = await runBatchDeploy(w.plan, w.env); + expect(result.targets[0]).toMatchObject({ kind: 'delivered', recorded: false, record_code: ERROR_CODES.SERVICE_ERROR }); + expect(result.targets[1]).toMatchObject({ kind: 'delivered', recorded: false }); + expect(result.targets[2]).toMatchObject({ kind: 'delivered', recorded: true }); + expect(w.github.createPull.mock.calls).toHaveLength(3); + }); + + test('the record uses the server\'s keep: a server with no keep for the branch is a coded record failure, never a keep rebuilt from anything local', async () => { + const w = await plannedWorld({}, fakeAdapter(), { failLatest: { 'pB/staging': ERROR_CODES.SERVICE_ERROR } }); + const result = await runBatchDeploy(w.plan, w.env); + expect(result.targets[2]).toMatchObject({ kind: 'delivered', recorded: false, record_code: ERROR_CODES.SERVICE_ERROR }); + expect(w.service.pushSecrets.mock.calls.map((c) => c[0])).toEqual(['pA']); + }); + + test('a key that cannot be opened fails every target with its code', async () => { + const w = await plannedWorld(); + const broken = { ...w.env, openKeys: () => async () => { throw new CapyError('locked', ERROR_CODES.PERMISSION_DENIED); } }; + const result = await runBatchDeploy(w.plan, broken); + expect(result.targets.every((t) => t.kind === 'failed' && t.code === ERROR_CODES.PERMISSION_DENIED && t.stage === 'values')).toBe(true); + }); +}); + +describe('one target, whole', () => { + test('deployTarget needs no checkout: it pushes, records and opens the PR by itself', async () => { + const w = world(); + const result = await deployTarget(w.env, async (p) => KEYS[p as 'pA'], { forTarget: () => undefined }, batchTarget('pA', dokployTarget('api'))); + expect(result).toMatchObject({ kind: 'delivered', recorded: true, base: 'main', target: { target: 'api', repo: 'Acme/mono', project: 'mono-backend' } }); + expect(w.adapter.deploy.mock.calls).toHaveLength(1); + expect(w.service.pushSecrets.mock.calls).toHaveLength(1); + expect(w.github.createPull.mock.calls).toHaveLength(1); + }); + + test('pushTarget alone changes nothing on the server and opens nothing', async () => { + const w = world(); + const pushed = await pushTarget(w.env, async (p) => KEYS[p as 'pA'], { forTarget: () => undefined }, batchTarget('pA', dokployTarget('api'))); + expect(pushed.result.kind).toBe('pushed'); + expect(w.service.pushSecrets.mock.calls).toHaveLength(0); + expect(w.github.createPull.mock.calls).toHaveLength(0); + }); +}); + +describe('many targets: concurrency, order, progress, stop', () => { + const many = (n: number) => Array.from({ length: n }, (_, i) => batchTarget('pA', dokployTarget(`t${String(i).padStart(2, '0')}`, { vars: ['API_KEY'] }))); + const planOf = (targets: ReturnType): BatchPlan => ({ targets, skipped: [], read_failed: [] }); + + /** An adapter that takes `delayOf(name)` ms and logs `+name` / `-name`. */ + const gauged = (delayOf: (name: string) => number) => { + const log = mock((_e: string) => undefined); + const adapter = fakeAdapter({ + deploy: async (config) => { + log(`+${config.name}`); + await sleepMs(delayOf(config.name)); + log(`-${config.name}`); + return { ok: true, steps: [] }; + }, + }); + return { adapter, events: () => log.mock.calls.map((c) => c[0]) }; + }; + + test('the bound is a named constant, 4, and no more than 4 targets are pushed at once', async () => { + expect(BATCH_DEPLOY_CONCURRENCY).toBe(4); + const { adapter, events } = gauged(() => 8); + const w = world({}, adapter); + const result = await runBatchDeploy(planOf(many(10)), w.env); + expect(result.targets).toHaveLength(10); + expect(maxOpen(events())).toBe(BATCH_DEPLOY_CONCURRENCY); + // Ten targets of one project and branch: still ONE unlock and ONE record write. + expect(w.openKeys.mock.calls).toHaveLength(1); + expect(w.service.pushSecrets.mock.calls).toHaveLength(1); + }); + + test('results come back in INPUT order when targets finish in a shuffled order', async () => { + const targets = many(8); + const { adapter, events } = gauged((name) => 5 + 3 * (targets.length - targets.findIndex((t) => t.config.name === name))); + const w = world({}, adapter); + const result = await runBatchDeploy(planOf(targets), w.env); + const finishOrder = events().filter((e) => e.startsWith('-')).map((e) => e.slice(1)); + expect(finishOrder).not.toEqual(targets.map((t) => t.config.name)); // completion really was shuffled + expect(result.targets.map((t) => t.target.target)).toEqual(targets.map((t) => t.config.name)); + }); + + test('progress: pushing counts finished targets, then recording, then PRs', async () => { + const onProgress = mock((_p: BatchProgress) => undefined); + const w = world(); + await runBatchDeploy(planOf(many(5)), w.env, { onProgress }); + const events = onProgress.mock.calls.map((c) => c[0]); + const pushing = events.filter((e) => e.phase === 'pushing'); + expect(pushing.map((e) => e.done)).toEqual([0, 1, 2, 3, 4, 5]); + expect(pushing.every((e) => e.total === 5 && e.inFlight <= BATCH_DEPLOY_CONCURRENCY)).toBe(true); + const phases = events.map((e) => e.phase).filter((p, i, all) => i === 0 || all[i - 1] !== p); + expect(phases).toEqual(['pushing', 'recording', 'prs']); + expect(events.filter((e) => e.phase === 'prs').at(-1)).toMatchObject({ done: 5, total: 5, inFlight: 0 }); + }); + + test('a stop of the pushes starts no further target, waits for those in flight, and still records and opens their PRs', async () => { + const stop = new AbortController(); + const adapter = fakeAdapter({ + deploy: async (config) => { + if (config.name === 't00') stop.abort(); + await sleepMs(10); + return { ok: true, steps: [] }; + }, + }); + const onStopping = mock((_s: unknown) => undefined); + const w = world({}, adapter); + const result = await runBatchDeploy(planOf(many(10)), w.env, { stopPushes: stop.signal, onStopping }); + + expect(result.targets.map((t) => t.kind)).toEqual([...Array(4).fill('delivered'), ...Array(6).fill('cancelled')]); + expect(result.targets.slice(4).every((t) => t.kind === 'cancelled' && t.stage === 'push' && !t.values_pushed)).toBe(true); + expect(adapter.deploy.mock.calls).toHaveLength(4); + expect(w.service.pushSecrets.mock.calls).toHaveLength(1); + expect(w.github.createPull.mock.calls).toHaveLength(4); + expect(onStopping.mock.calls[0][0]).toEqual({ phase: 'pushing', inFlight: 4 }); + expect(batchSucceeded(result)).toBe(false); + }); + + test('a stop before the run starts opens no key, pushes nothing and reports everything cancelled', async () => { + const stop = new AbortController(); + stop.abort(); + const w = world(); + const result = await runBatchDeploy(planOf(many(3)), w.env, { stopPushes: stop.signal }); + expect(result.targets.every((t) => t.kind === 'cancelled' && t.stage === 'push')).toBe(true); + expect(w.openKeys.mock.calls).toHaveLength(0); + expect(w.adapter.deploy.mock.calls).toHaveLength(0); + }); + + test('a stop of the PRs opens none, but what was pushed stays recorded and is reported as cancelled with its values already in Dokploy', async () => { + const stop = new AbortController(); + stop.abort(); + const w = world(); + const result = await runBatchDeploy(planOf(many(3)), w.env, { stopPrs: stop.signal }); + expect(result.targets.every((t) => t.kind === 'cancelled' && t.stage === 'pr' && t.values_pushed)).toBe(true); + expect(w.adapter.deploy.mock.calls).toHaveLength(3); + expect(w.service.pushSecrets.mock.calls).toHaveLength(1); + expect(w.github.createPull.mock.calls).toHaveLength(0); + }); + + test('two targets in one repo are two PRs (one Dokploy service is one target)', async () => { + const w = world(); + await runBatchDeploy(planOf(many(2)), w.env); + expect(w.github.createPull.mock.calls.map(([r]) => r.name)).toEqual(['mono', 'mono']); + }); +}); + +describe('no secret ever appears in anything the run reports or writes', () => { + test('result, text, JSON, the GitHub writes and the server record carry no value', async () => { + const w = world(); + const plan = await planBatchDeploy(w.github, request, LINKS); + const result = await runBatchDeploy(plan, w.env); + const everything = [ + JSON.stringify(result), + renderBatchResult(result), + JSON.stringify(resultJsonOf(result)), + everythingSentTo(w.github.createBlob, w.github.createTree, w.github.createCommit, w.github.createRef, w.github.createPull), + // The server record carries the keep and the server's own (encrypted) blob. + everythingSentTo(w.service.pushSecrets), + ].join('\n'); + expect(Object.values(VALUES).filter((v) => everything.includes(v))).toEqual([]); + }); + + test('a failure carries a code, never a value', async () => { + const adapter = fakeAdapter({ deploy: () => ({ ok: false, steps: [{ label: 'x', status: 'fail', detail: `rejected ${VALUES.API_KEY}` }] }) }); + const w = world({}, adapter); + const plan = await planBatchDeploy(w.github, request, LINKS); + const result = await runBatchDeploy(plan, w.env); + const everything = JSON.stringify(result) + renderBatchResult(result) + JSON.stringify(resultJsonOf(result)); + expect(everything.includes(VALUES.API_KEY)).toBe(false); + expect(everything).toContain(ERROR_CODES.DEPLOY_PUSH_FAILED); + }); +}); diff --git a/tests/deploy/deliveryRecord.test.ts b/tests/deploy/deliveryRecord.test.ts new file mode 100644 index 00000000..30579e16 --- /dev/null +++ b/tests/deploy/deliveryRecord.test.ts @@ -0,0 +1,54 @@ +/** + * Recording a delivery on the Capy server (src/deploy/deliveryRecord.ts): the pushed keep is the + * SERVER's keep with the delivery applied, the blob is re-sent unchanged, and a delivery that is + * already recorded pushes nothing. Fakes only. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { recordDeliveriesOnServer, deliveryFor } from '../../src/deploy/deliveryRecord'; +import { CapyError, ERROR_CODES, KeepFile } from '../../src/types/index'; +import { dokployTarget, hashOf, serverKeepFor } from '../helpers/batchDeployWorld'; + +const AT = '2026-10-04T10:00:00.000Z'; +const { delivery, values } = deliveryFor(dokployTarget('api', { vars: ['API_KEY'] }), { id: 'dokploy' }, undefined, false, { API_KEY: hashOf('API_KEY') }); + +function client(keepFile: string | undefined, opts: { pushFails?: boolean; none?: boolean } = {}) { + const getLatestSecrets = mock(async () => (opts.none ? null : { env_file: 'BLOB-AS-STORED', keep_hash: 'h', ...(keepFile === undefined ? {} : { keep_file: keepFile }) })); + const pushSecrets = mock(async (..._a: unknown[]) => { + if (opts.pushFails) throw new CapyError('no', ERROR_CODES.SERVICE_ERROR); + return {}; + }); + return { getLatestSecrets, pushSecrets }; +} + +describe('recordDeliveriesOnServer', () => { + test('builds from the server keep (another target\'s record is kept) and re-sends the stored blob unchanged', async () => { + const c = client(JSON.stringify(serverKeepFor('pA', 'production'))); + const out = await recordDeliveriesOnServer(c, 'pA', 'production', [{ delivery, values, deliveredAt: AT }]); + expect(out).toEqual({ ok: true, pushed: true }); + const [project, keepJson, blob, branch] = c.pushSecrets.mock.calls[0]; + expect([project, blob, branch]).toEqual(['pA', 'BLOB-AS-STORED', 'production']); + const targets = (JSON.parse(String(keepJson)) as KeepFile).variables.API_KEY[0].targets as Array<{ target: string }>; + expect(targets.map((t) => t.target).toSorted()).toEqual(['api', 'legacy']); + }); + + test('a delivery that is already recorded pushes nothing', async () => { + const first = client(JSON.stringify(serverKeepFor('pA', 'staging'))); + await recordDeliveriesOnServer(first, 'pA', 'staging', [{ delivery, values, deliveredAt: AT }]); + const recorded = String(first.pushSecrets.mock.calls[0][1]); + const again = client(recorded); + expect(await recordDeliveriesOnServer(again, 'pA', 'staging', [{ delivery, values, deliveredAt: '2027-01-01T00:00:00.000Z' }])).toEqual({ ok: true, pushed: false }); + expect(again.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('no server keep is a code, and nothing is pushed', async () => { + for (const c of [client(undefined), client('not json'), client(undefined, { none: true })]) { + expect(await recordDeliveriesOnServer(c, 'pA', 'production', [{ delivery, values, deliveredAt: AT }])).toEqual({ ok: false, code: ERROR_CODES.NO_KEEP_FILE }); + expect(c.pushSecrets.mock.calls).toHaveLength(0); + } + }); + + test('a refused push is a code, never a throw', async () => { + const c = client(JSON.stringify(serverKeepFor('pA', 'production')), { pushFails: true }); + expect(await recordDeliveriesOnServer(c, 'pA', 'production', [{ delivery, values, deliveredAt: AT }])).toEqual({ ok: false, code: ERROR_CODES.SERVICE_ERROR }); + }); +}); diff --git a/tests/deploy/keepGateFromHashes.test.ts b/tests/deploy/keepGateFromHashes.test.ts new file mode 100644 index 00000000..29aada72 --- /dev/null +++ b/tests/deploy/keepGateFromHashes.test.ts @@ -0,0 +1,39 @@ +/** + * `buildDeployKeepFromHashes` / `foldDeployKeep` (CAP-704): the batch deploy keeps only the hashes + * of the values it pushed, and must write exactly the keep.lock `buildDeployKeep` writes from the + * values themselves. + */ +import { describe, test, expect } from 'bun:test'; +import { buildDeployKeep, buildDeployKeepFromHashes, foldDeployKeep, hashValue } from '../../src/deploy/keepGate'; +import { serializeKeep } from '../../src/files/fileManager'; +import { dokployTarget, serverKeepFor } from '../helpers/batchDeployWorld'; +import { deliveryFor } from '../../src/deploy/deliveryRecord'; + +const AT = '2026-10-04T10:00:00.000Z'; +const values = { API_KEY: 'value-one', DB_URL: 'value-two' }; +const hashes = { API_KEY: hashValue(values.API_KEY), DB_URL: hashValue(values.DB_URL) }; +const target = dokployTarget('api'); +const { delivery } = deliveryFor(target, { id: 'dokploy' }, undefined, false, hashes); +const base = serverKeepFor('pA', 'staging'); + +describe('the hash-only fold writes the same keep.lock as the value fold', () => { + test('with a delivery', () => { + const fromValues = buildDeployKeep(base, values, target.vars, 'staging', delivery, AT); + const fromHashes = buildDeployKeepFromHashes(base, hashes, target.vars, 'staging', delivery, AT); + expect(fromHashes).toEqual(fromValues); + expect(fromHashes.changed).toBe(true); + }); + + test('without a delivery, and a variable with no hash is skipped like one with no value', () => { + const fromValues = buildDeployKeep(base, { API_KEY: values.API_KEY }, target.vars, 'staging', undefined, AT); + const fromHashes = buildDeployKeepFromHashes(base, { API_KEY: hashes.API_KEY }, target.vars, 'staging', undefined, AT); + expect(fromHashes).toEqual(fromValues); + }); + + test('foldDeployKeep is the keep itself, and serializes to the same text', () => { + const keep = foldDeployKeep(base, hashes, target.vars, 'staging', delivery, AT); + expect(serializeKeep(keep)).toBe(buildDeployKeepFromHashes(base, hashes, target.vars, 'staging', delivery, AT).content); + // The base is never modified. + expect(JSON.stringify(base)).toBe(JSON.stringify(serverKeepFor('pA', 'staging'))); + }); +}); diff --git a/tests/helpers/batchDeployWorld.ts b/tests/helpers/batchDeployWorld.ts new file mode 100644 index 00000000..4a42ce2d --- /dev/null +++ b/tests/helpers/batchDeployWorld.ts @@ -0,0 +1,333 @@ +/** + * A fake Capy + GitHub + Dokploy world for the batch deploy tests (CAP-704): projects with + * encrypted branches, repos whose `.capy/deploy.json` and keep.lock differ per branch, and a + * Dokploy adapter whose calls are read back from `mock` logs. Everything is injected into the + * engine (`BatchEnv`); nothing here touches the network, `gh`, git or a home directory. + * + * Stateless on purpose: answers are a pure function of the fixtures, and what the engine did + * is read back from the call logs, so no test keeps a mutable fake. + */ +import { createHash } from 'crypto'; +import { mock } from 'bun:test'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { deriveResourceId } from '../../src/crypto/resourceId'; +import { serializeKeep } from '../../src/files/fileManager'; +import { CapyError, KeepFile } from '../../src/types/index'; +import type { ApiResult, GithubApi } from '../../src/deploy/githubApi'; +import type { DeployAdapter, DeployContext, DeployResult, PreflightResult, TargetConfig } from '../../src/deploy/adapter'; +import type { BatchEnv, BatchTarget } from '../../src/deploy/batchDeploy'; +import type { OrgRepoLink, SecretIndexRow } from '../../src/service/serviceClient'; + +/** Values no output may ever contain. */ +export const VALUES: Readonly> = { + API_KEY: 'SENTINEL-api-key-7c1d0e5a', + DB_URL: 'SENTINEL-db-url-2b9e44f1', +}; +export const NAME = 'API_KEY'; + +const hash16 = (v: string): string => createHash('sha256').update(v).digest('hex').slice(0, 16); +export const hashOf = (name: string): string => hash16(VALUES[name]); + +export const PROJECTS = ['pA', 'pB', 'pC'] as const; +export type ProjectId = (typeof PROJECTS)[number]; +export const PROJECT_NAMES: Readonly> = { pA: 'mono-backend', pB: 'solo-server', pC: 'unlinked' }; +export const KEYS: Readonly> = { + pA: Encryptor.generateKey(), + pB: Encryptor.generateKey(), + pC: Encryptor.generateKey(), +}; + +export const LEGACY_RECORD = { provider: 'dokploy', target: 'legacy', deployed_value_hash: 'abc123abc123abcd', deployed_at: '2026-01-01T00:00:00.000Z' }; + +/** The branches the server holds, by `project/branch`. */ +export const SERVER_BRANCHES: ReadonlyArray<{ readonly project: ProjectId; readonly branch: string }> = [ + { project: 'pA', branch: 'production' }, + { project: 'pA', branch: 'staging' }, + { project: 'pB', branch: 'staging' }, + { project: 'pC', branch: 'development' }, +]; + +const entryFor = (branch: string, name: string, extra: object = {}) => ({ + resource_id: deriveResourceId(branch, name), + branch, + value_hash: hashOf(name), + changed_at: '2026-01-01T00:00:00.000Z', + ...extra, +}); + +/** The server's keep for one (project, branch): API_KEY and DB_URL pinned; pA/production's API_KEY already carries ANOTHER target's record. */ +export function serverKeepFor(project: ProjectId, branch: string, vars: readonly string[] = ['API_KEY', 'DB_URL']): KeepFile { + const extra = project === 'pA' && branch === 'production' ? { targets: [LEGACY_RECORD] } : {}; + return { + version: '3.0', + org_id: 'org1', + project_id: project, + project_name: PROJECT_NAMES[project], + variables: Object.fromEntries(vars.map((v) => [v, [entryFor(branch, v, v === 'API_KEY' ? extra : {})]])), + } as unknown as KeepFile; +} + +/** The server's blob for one (project, branch). Built once per call site so the ciphertext is stable. */ +export const blobFor = (project: ProjectId, branch: string, vars: readonly string[] = ['API_KEY', 'DB_URL']): string => + vars.map((v) => `${v}=capy:${deriveResourceId(branch, v)}:${Encryptor.encrypt(VALUES[v], KEYS[project])}`).join('\n'); + +export interface ServiceOpts { + /** The vars every branch holds (default API_KEY and DB_URL). */ + readonly vars?: readonly string[]; + readonly failRead?: Readonly>; + readonly failPush?: Readonly>; + readonly failLatest?: Readonly>; + /** Wait this long in each read (for concurrency tests). */ + readonly delayMs?: (key: string) => number; +} + +const sleepMs = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export function fakeService(opts: ServiceOpts = {}) { + const vars = opts.vars ?? ['API_KEY', 'DB_URL']; + const blobs = Object.fromEntries(SERVER_BRANCHES.map((b) => [`${b.project}/${b.branch}`, blobFor(b.project, b.branch, vars)])); + const find = (project: string, branch: string) => SERVER_BRANCHES.find((b) => b.project === project && b.branch === branch); + const getDecryptData = mock(async (projectId: string, branch?: string) => { + const key = `${projectId}/${branch}`; + const failure = opts.failRead?.[key]; + if (failure) throw new CapyError('read failed', failure); + await sleepMs(opts.delayMs?.(key) ?? 0); + const b = find(projectId, branch ?? ''); + if (!b) throw new CapyError('no such branch', 'BRANCH_NOT_FOUND'); + return { + env_content: blobs[key], + decrypt_key: '', + expires_at: '', + keep_hash: 'h', + keep_file: JSON.stringify(serverKeepFor(b.project, b.branch, vars)), + }; + }); + const getLatestSecrets = mock(async (projectId: string, branch: string) => { + const key = `${projectId}/${branch}`; + const failure = opts.failLatest?.[key]; + if (failure) throw new CapyError('read failed', failure); + const b = find(projectId, branch); + if (!b) return null; + return { env_file: blobs[key], keep_hash: 'h', keep_file: JSON.stringify(serverKeepFor(b.project, b.branch, vars)) }; + }); + const pushSecrets = mock(async (projectId: string, _keep: string, _blob: string, branch: string) => { + const failure = opts.failPush?.[`${projectId}/${branch}`]; + if (failure) throw new CapyError('push failed', failure); + return { keep_hash: 'h2' }; + }); + return { getDecryptData, getLatestSecrets, pushSecrets, blobs }; +} + +// ── GitHub ────────────────────────────────────────────────────────────────── + +export interface RepoFixture { + readonly owner: string; + readonly name: string; + readonly defaultBranch: string; + /** Files by ref (a branch), then by repo path. A missing path is a missing file. */ + readonly files: Readonly>>>; + /** Calls that fail with REQUEST_FAILED: `getFile`, `createPull`, ... */ + readonly fail?: readonly string[]; +} + +const ok = (value: T): ApiResult => ({ ok: true, value }); +const failed = (): ApiResult => ({ ok: false, kind: 'REQUEST_FAILED', status: 500 }); + +export function fakeGithub(repos: readonly RepoFixture[]) { + const find = (r: { owner: string; name: string }) => + repos.find((x) => x.owner.toLowerCase() === r.owner.toLowerCase() && x.name.toLowerCase() === r.name.toLowerCase()); + const guard = (r: { owner: string; name: string }, call: string, value: () => T): ApiResult => + find(r)?.fail?.includes(call) ? failed() : ok(value()); + const sha = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 10); + const api = { + getRepo: mock(async (r) => { + const repo = find(r); + return repo ? ok({ defaultBranch: repo.defaultBranch }) : failed(); + }), + getDefaultBranches: mock(async (rs: ReadonlyArray<{ owner: string; name: string }>) => ok(rs.map((r) => find(r)?.defaultBranch ?? null))), + listBranches: mock(async () => ok([] as readonly string[])), + getBranchHead: mock(async (r, branch: string) => { + const repo = find(r); + if (!repo || !(branch in repo.files)) return { ok: false as const, kind: 'NOT_FOUND' as const, status: 404 }; + return guard(r, 'getBranchHead', () => ({ commitSha: `head-${branch}`, treeSha: `tree-${branch}` })); + }), + getFile: mock(async (r, path: string, ref: string) => guard(r, 'getFile', () => find(r)?.files[ref]?.[path] ?? null)), + createBlob: mock(async (r, content: string) => guard(r, 'createBlob', () => ({ sha: `blob-${sha(content)}` }))), + createTree: mock(async (r) => guard(r, 'createTree', () => ({ sha: `tree-new-${r.name}` }))), + createCommit: mock(async (r) => guard(r, 'createCommit', () => ({ sha: `commit-${r.name}` }))), + createRef: mock(async (r, branch: string) => guard(r, 'createRef', () => ({ ref: `refs/heads/${branch}` }))), + createPull: mock(async (r, params: { head: string }) => + guard(r, 'createPull', () => ({ url: `https://github.com/${r.owner}/${r.name}/pull/${params.head.length}` })), + ), + } satisfies Record; + return api as unknown as GithubApi & typeof api; +} + +// ── Targets ───────────────────────────────────────────────────────────────── + +export const dokployTarget = (name: string, over: Partial = {}): TargetConfig => ({ + name, + kind: 'dokploy', + branch: 'production', + vars: ['API_KEY', 'DB_URL'], + mode: 'ci', + gitBaseBranch: 'main', + options: { baseUrl: 'https://dokploy.example.com', applicationId: `app-${name}` }, + ...over, +}); + +const deployJson = (targets: readonly TargetConfig[]): string => + JSON.stringify({ version: '1', targets: Object.fromEntries(targets.map((t) => [t.name, t])) }); + +/** pA's targets on mono's default branch. */ +export const MONO_TARGETS: readonly TargetConfig[] = [ + dokployTarget('api'), + dokployTarget('worker', { vars: ['API_KEY'] }), + { ...dokployTarget('cf'), kind: 'cf-worker', vars: ['API_KEY'], options: {} }, + dokployTarget('direct', { mode: 'direct', vars: ['API_KEY'] }), +]; +export const SOLO_TARGETS: readonly TargetConfig[] = [dokployTarget('site', { branch: 'staging', gitBaseBranch: 'staging', vars: ['API_KEY'] })]; +/** A target that exists only on mono's `develop` branch: an unmerged discover PR. */ +export const UNMERGED_TARGET: TargetConfig = dokployTarget('unmerged', { vars: ['API_KEY'] }); + +/** A keep.lock as GitHub holds it for a (project, branch), optionally with the targets already recorded. */ +export function githubKeepFor(project: ProjectId, branch: string, recorded: readonly string[] = []): string { + const keep = serverKeepFor(project, branch); + const withTargets = (name: string): KeepFile['variables'][string] => + keep.variables[name].map((e) => ({ + ...e, + targets: recorded.map((t) => ({ provider: 'dokploy', target: t, deployed_value_hash: hashOf(name), deployed_at: '2026-01-02T00:00:00.000Z' })), + })); + return serializeKeep({ ...keep, variables: { API_KEY: withTargets('API_KEY'), DB_URL: withTargets('DB_URL') } }); +} + +export interface WorldOpts { + /** Targets already recorded in mono's keep.lock on main, so the gate sees nothing to do. */ + readonly recordedOnMain?: readonly string[]; + readonly monoFail?: readonly string[]; + readonly soloFail?: readonly string[]; + readonly monoTargets?: readonly TargetConfig[]; +} + +export function standardRepos(opts: WorldOpts = {}): readonly RepoFixture[] { + const recorded = opts.recordedOnMain ?? []; + return [ + { + owner: 'Acme', + name: 'mono', + defaultBranch: 'main', + fail: opts.monoFail, + files: { + main: { 'backend/.capy/deploy.json': deployJson(opts.monoTargets ?? MONO_TARGETS), 'backend/keep.lock': githubKeepFor('pA', 'production', recorded) }, + develop: { 'backend/.capy/deploy.json': deployJson([...(opts.monoTargets ?? MONO_TARGETS), UNMERGED_TARGET]) }, + }, + }, + { + owner: 'Acme', + name: 'solo', + defaultBranch: 'trunk', + fail: opts.soloFail, + files: { + trunk: { '.capy/deploy.json': deployJson(SOLO_TARGETS), 'keep.lock': githubKeepFor('pB', 'staging') }, + staging: { 'keep.lock': githubKeepFor('pB', 'staging') }, + }, + }, + ]; +} + +export const LINKS: readonly OrgRepoLink[] = [ + { project_id: 'pA', project_name: PROJECT_NAMES.pA, host: 'github.com', owner: 'Acme', name: 'mono', path: 'backend', github_repo_id: 1, last_seen_at: '' }, + { project_id: 'pB', project_name: PROJECT_NAMES.pB, host: 'github.com', owner: 'Acme', name: 'solo', path: '.', github_repo_id: 2, last_seen_at: '' }, +]; + +// ── The secrets index ─────────────────────────────────────────────────────── + +const loc = (project: ProjectId, branch: string, targets: ReadonlyArray<{ provider: string; target: string }> = []) => ({ + project_id: project, + project_name: PROJECT_NAMES[project], + branch, + protected: false, + service: null, + ...(targets.length === 0 ? {} : { targets: targets.map((t) => ({ ...t, stale: true })) }), +}); + +/** One row of API_KEY: three locations, with the targets Capy has delivered to (one of them, `ghost`, is no longer in any deploy.json on the default branch). */ +export function indexRows(): SecretIndexRow[] { + return [ + { + name: NAME, + value_hash: hashOf(NAME), + locations: [ + loc('pA', 'production', [ + { provider: 'dokploy', target: 'api' }, + { provider: 'dokploy', target: 'unmerged' }, + ]), + loc('pA', 'staging'), + loc('pB', 'staging', [{ provider: 'dokploy', target: 'site' }]), + ], + users: [], + } as SecretIndexRow, + ]; +} + +// ── Dokploy adapter and env ───────────────────────────────────────────────── + +export interface AdapterScript { + readonly preflight?: (config: TargetConfig) => PreflightResult; + readonly deploy?: (config: TargetConfig, ctx: DeployContext) => DeployResult | Promise; +} + +export function fakeAdapter(script: AdapterScript = {}) { + const preflight = mock(async (config: TargetConfig, _ctx: unknown) => script.preflight?.(config) ?? ({ ok: true } as PreflightResult)); + const deploy = mock( + async (config: TargetConfig, ctx: DeployContext) => + script.deploy?.(config, ctx) ?? ({ ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] } as DeployResult), + ); + return { + id: 'dokploy', + label: 'Dokploy', + description: '', + varKind: 'runtime', + defaultMode: 'ci', + requires: { binaries: [] }, + detect: async () => ({}), + preflight, + deploy, + } as unknown as DeployAdapter & { readonly preflight: typeof preflight; readonly deploy: typeof deploy }; +} + +export function makeEnv( + service: ReturnType, + github: GithubApi | undefined, + adapter: DeployAdapter, + over: Partial = {}, +) { + const openKeys = mock(() => async (projectId: string) => KEYS[projectId as ProjectId]); + const env: BatchEnv = { + client: service as unknown as BatchEnv['client'], + orgId: 'org1', + openKeys, + github: () => github, + branchName: () => 'capy/deploy-test-branch', + adapter, + resolveApiKey: async () => undefined, + ...over, + }; + return { env, openKeys }; +} + +/** Every string argument of every call of these mocks, JSON-flattened: what a leak test searches. */ +export function everythingSentTo(...mocks: ReadonlyArray<{ readonly mock: { readonly calls: readonly unknown[][] } }>): string { + return mocks.map((m) => JSON.stringify(m.mock.calls)).join('\n'); +} + +export const batchTarget = (project: ProjectId, config: TargetConfig, over: Partial = {}): BatchTarget => ({ + project_id: project, + project_name: PROJECT_NAMES[project], + branch: config.branch, + repo: project === 'pA' ? { owner: 'Acme', name: 'mono' } : { owner: 'Acme', name: 'solo' }, + path: project === 'pA' ? 'backend' : '.', + base: config.gitBaseBranch ?? 'main', + config, + ...over, +}); diff --git a/tests/ui/secretsBasesLoading.test.ts b/tests/ui/secretsBasesLoading.test.ts index eb801f84..6a604d27 100644 --- a/tests/ui/secretsBasesLoading.test.ts +++ b/tests/ui/secretsBasesLoading.test.ts @@ -158,7 +158,7 @@ describe('through the real driver: one read, table first', () => { await wait(150); return BASES; }); - // Ctrl+E, value, Enter, Enter (locations), 40ms: table up (read in flight), 250ms: read done, Enter (run), 40, any key. + // Ctrl+E, value, Enter, Enter (locations), 40ms: table up (read in flight), 250ms: read done, Ctrl+C. const frames = await drive(loadBases, ['\x05', 'v', '\r', '\r', 40, 250, '\x03']); const repoFrames = frames.filter((f) => f.includes('REPO') && f.includes('Acme/web-app')); expect(repoFrames.length).toBeGreaterThanOrEqual(2); @@ -173,8 +173,8 @@ describe('through the real driver: one read, table first', () => { await wait(150); return BASES; }); - // Ctrl+E, value, Enter, Enter (locations), then Enter AT ONCE on the table (read still in flight), wait, then any key to leave. - await drive(loadBases, ['\x05', 'v', '\r', '\r', 30, '\r', 400, 'x']); + // Ctrl+E, value, Enter, Enter (locations), then Enter AT ONCE on the table (read still in flight), wait, then Esc to leave. + await drive(loadBases, ['\x05', 'v', '\r', '\r', 30, '\r', 400, '\x1b']); expect(loadBases.mock.calls).toHaveLength(1); expect(run.mock.calls).toHaveLength(1); expect(run.mock.calls[0][0].bases).toEqual(BASES); diff --git a/tests/ui/secretsCancelTui.test.ts b/tests/ui/secretsCancelTui.test.ts index 2f04f686..bd149258 100644 --- a/tests/ui/secretsCancelTui.test.ts +++ b/tests/ui/secretsCancelTui.test.ts @@ -192,8 +192,8 @@ describe('through the real driver: a run stopped part way', () => { const logSpy = spyOn(console, 'log').mockImplementation(() => {}); const done = runSecretsScreen(rows, async () => ({ ok: false, code: 'NO' }), actions); await wait(); - // Ctrl+E, value, Enter, Enter (all locations), wait for the table and its bases, Enter (run), 60ms into the 1st push: Ctrl+C, wait, any key. - const keys: ReadonlyArray = ['\x05', SENTINEL, '\r', '\r', 200, '\r', 60, CTRL_C, 900, 'x']; + // Ctrl+E, value, Enter, Enter (all locations), wait for the table and its bases, Enter (run), 60ms into the 1st push: Ctrl+C, wait, Esc. + const keys: ReadonlyArray = ['\x05', SENTINEL, '\r', '\r', 200, '\r', 60, CTRL_C, 900, ESC]; await keys.reduce>(async (prev, k) => { await prev; if (typeof k === 'number') return wait(k); diff --git a/tests/ui/secretsDeployFlow.test.ts b/tests/ui/secretsDeployFlow.test.ts new file mode 100644 index 00000000..62391f59 --- /dev/null +++ b/tests/ui/secretsDeployFlow.test.ts @@ -0,0 +1,644 @@ +/** + * The deploy flow of the `capy secrets` TUI (CAP-704): the pure reducer, the screen's keys, the + * rendered plan and result, the dry run that only plans, and the real driver with injected actions. + * The service, GitHub and Dokploy are the fakes of tests/helpers/batchDeployWorld.ts (no network, no gh). + */ +import { describe, test, expect, mock, spyOn } from 'bun:test'; +import { + applyDeployFinished, + applyDeployPlanLoaded, + applyDeployProgress, + applyDeployValue, + deployRunningLine, + dryRunDeployText, + isDeployRunning, + renderDeploy, + selectedPlan, + startDeploy, + stepDeploy, + type DeployFlow, +} from '../../src/ui/secretsDeployFlow'; +import { + applyDeployPlan, + applyDeployRunDone, + applyDeployRunProgress, + applyValueResult, + handleKey, + initialSecretsScreenState, + maskSecretValue, + pendingDeployValueEffect, + render, + type SecretsScreenState, +} from '../../src/ui/secretsScreen'; +import { runSecretsScreen, type SecretsDeployActions } from '../../src/ui/secretsScreenDriver'; +import { createDeployActionsWith } from '../../src/commands/secretsDeployActions'; +import { deployLocationsOf, planBatchDeploy, restrictPlan, targetKeyOf, type BatchPlan, type BatchResult } from '../../src/deploy/batchDeploy'; +import { CANCELLED_NOTHING } from '../../src/commands/secretsSetText'; +import { ERROR_CODES } from '../../src/types/index'; +import { + LINKS, + VALUES, + everythingSentTo, + fakeAdapter, + fakeGithub, + fakeService, + indexRows, + makeEnv, + standardRepos, +} from '../helpers/batchDeployWorld'; + +const ESC = '\x1b'; +const CTRL_D = '\x04'; +const strip = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); +const wait = (ms = 15) => new Promise((resolve) => setTimeout(resolve, ms)); + +const [row] = indexRows(); + +async function realPlan(): Promise { + return planBatchDeploy(fakeGithub(standardRepos()), { names: [row.name], locations: deployLocationsOf(row) }, LINKS); +} + +const EMPTY: BatchPlan = { + targets: [], + skipped: [{ project: 'mono-backend', branch: 'staging', target: null, provider: null, code: 'NO_TARGET' }], + read_failed: [], +}; + +const RESULT: BatchResult = { + targets: [ + { + kind: 'delivered', + target: { project: 'mono-backend', branch: 'production', target: 'api', provider: 'dokploy', repo: 'Acme/mono', path: 'backend', vars: 2 }, + pr_url: 'https://github.com/Acme/mono/pull/12', + base: 'main', + recorded: true, + }, + ], + skipped: [{ project: 'mono-backend', branch: 'staging', target: null, provider: null, code: 'NO_TARGET' }], + read_failed: [], +}; + +describe('the flow, step by step', () => { + test('starting reads the plan (an effect) and shows that it is reading', () => { + const started = startDeploy(row); + expect(started.flow.step).toBe('planning'); + expect(started.effect).toEqual({ type: 'loadDeployPlan', row }); + expect(strip(renderDeploy(started.flow, 100).lines.join('\n'))).toContain('Reading'); + }); + + test('Esc while reading goes back, stops the reads, and says nothing was changed', () => { + const step = stepDeploy(startDeploy(row).flow, ESC); + expect(step.flow).toBeNull(); + expect(step.effect).toEqual({ type: 'cancelDeploy', phase: 'planning' }); + expect(step.note).toBe(CANCELLED_NOTHING); + }); + + test('the plan arrives: one line per target (location, target, provider, vars) and one per skipped location with its code', async () => { + const plan = await realPlan(); + const flow = applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow; + expect(flow.step).toBe('plan'); + const lines = strip(renderDeploy(flow, 120).lines.join('\n')); + expect(lines).toContain('Deploy API_KEY to 3 targets'); + expect(lines).toMatch(/mono-backend · production\s+api\s+dokploy\s+2 vars/); + expect(lines).toMatch(/mono-backend · production\s+worker\s+dokploy\s+1 var\b/); + expect(lines).toMatch(/solo-server · staging\s+site\s+dokploy\s+1 var\b/); + expect(lines).toMatch(/mono-backend · production\s+cf\s+cf-worker\s+skipped NOT_DOKPLOY/); + expect(lines).toMatch(/mono-backend · production\s+direct\s+dokploy\s+skipped NOT_CI_MODE/); + expect(lines).toMatch(/mono-backend · staging\s+—\s+—\s+skipped NO_TARGET/); + expect(strip(renderDeploy(flow, 120).footer)).toContain('enter deploy'); + }); + + test('a plan that cannot be read ends in a code, never a crash', () => { + const flow = applyDeployPlanLoaded(startDeploy(row).flow, { ok: false, code: ERROR_CODES.SERVICE_ERROR }) as DeployFlow; + expect(flow).toMatchObject({ step: 'done' }); + expect((flow as { text: string }).text).toContain(`(${ERROR_CODES.SERVICE_ERROR})`); + }); + + test('a late plan is dropped once the flow has moved on', () => { + const plan: BatchPlan = EMPTY; + expect(applyDeployPlanLoaded(null, { ok: true, plan })).toBeNull(); + const running = { step: 'running', row, plan } as DeployFlow; + expect(applyDeployPlanLoaded(running, { ok: true, plan })).toBe(running); + }); + + test('Enter confirms and starts the run with that plan; y does nothing; Esc (or n) cancels with nothing changed', async () => { + const plan = await realPlan(); + const planned = applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow; + const step = stepDeploy(planned, '\r'); + expect(step.flow?.step).toBe('running'); + expect(step.effect).toEqual({ type: 'runDeploy', plan }); + for (const key of ['y', 'Y']) { + const ignored = stepDeploy(planned, key); + expect(ignored.flow).toBe(planned); + expect(ignored.effect).toBeNull(); + } + for (const key of [ESC, 'n']) { + const step = stepDeploy(planned, key); + expect(step.flow).toBeNull(); + expect(step.effect).toBeNull(); + expect(step.note).toBe(CANCELLED_NOTHING); + } + expect(stepDeploy(planned, 'x').effect).toBeNull(); // any other key does nothing + }); + + test('a plan with nothing to deploy says so, and Enter does nothing', () => { + const planned = applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan: EMPTY }) as DeployFlow; + const view = renderDeploy(planned, 100); + expect(strip(view.lines.join('\n'))).toContain('Nothing to deploy.'); + expect(strip(view.lines.join('\n'))).toContain('skipped NO_TARGET'); + const step = stepDeploy(planned, '\r'); + expect(step.flow).toBe(planned); + expect(step.effect).toBeNull(); + }); + + test('--dry-run: Enter shows what would happen and starts NOTHING', async () => { + const plan = await realPlan(); + const planned = applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow; + const step = stepDeploy(planned, '\r', true); + expect(step.effect).toBeNull(); + expect(step.flow).toMatchObject({ step: 'done' }); + const text = (step.flow as { text: string }).text; + expect(text).toContain('Dry run: nothing was changed.'); + expect(text).toContain('Would deploy to 3 targets.'); + expect(text).toBe(dryRunDeployText(plan)); + }); + + test('running: progress is shown per phase; Esc / Ctrl+C stop (once, then again), other keys are ignored', async () => { + const plan = await realPlan(); + const running = stepDeploy(applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow, '\r').flow as Extract; + expect(isDeployRunning(running)).toBe(true); + expect(deployRunningLine(running)).toBe('Deploying API_KEY to 3 targets…'); + + const pushing = applyDeployProgress(running, { phase: 'pushing', done: 1, inFlight: 2, total: 3 }) as Extract; + expect(deployRunningLine(pushing)).toBe('Deploying API_KEY… 1 of 3 targets'); + expect(deployRunningLine(applyDeployProgress(pushing, { phase: 'recording', done: 0, inFlight: 1, total: 1 }) as Extract)).toBe('Recording deliveries…'); + expect(deployRunningLine(applyDeployProgress(pushing, { phase: 'prs', done: 2, inFlight: 1, total: 3 }) as Extract)).toBe('Opening pull requests… 2 of 3'); + + expect(stepDeploy(pushing, 'x').effect).toBeNull(); + const stopped = stepDeploy(pushing, '\x03'); + expect(stopped.effect).toEqual({ type: 'cancelDeploy', phase: 'pushing' }); + const stoppedFlow = stopped.flow as Extract; + expect(deployRunningLine(stoppedFlow)).toBe('Stopping after the 2 in progress…'); + expect(stepDeploy(stoppedFlow, ESC).flow).toMatchObject({ stop: { phase: 'pushing', count: 2 } }); + // A stop asked for in the pushing phase does not carry into the PR phase. + expect((applyDeployProgress(stoppedFlow, { phase: 'prs', done: 0, inFlight: 1, total: 1 }) as { stop?: unknown }).stop).toBeUndefined(); + }); + + test('the result: PR links, then skipped; only Esc leaves the screen and the text is printed after it', async () => { + const plan = await realPlan(); + const running = stepDeploy(applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow, '\r').flow as DeployFlow; + const done = applyDeployFinished(running, { ok: true, result: RESULT }) as Extract; + const text = strip(done.text); + expect(text).toContain('✓ 1 target deployed.'); + expect(text).toContain('Review and merge to deploy:'); + expect(text).toContain('https://github.com/Acme/mono/pull/12'); + expect(text.indexOf('Review and merge')).toBeLessThan(text.indexOf('Skipped')); + expect(strip(renderDeploy(done, 120).footer)).toContain('esc exit'); + // Any other key leaves the result on screen, so the links can be selected and copied. + for (const key of ['x', '\r', ' ', '\x03']) { + const stays = stepDeploy(done, key); + expect(stays.flow).toBe(done); + expect(stays.effect).toBeNull(); + expect(stays.exitText).toBeUndefined(); + } + const left = stepDeploy(done, ESC); + expect(left.flow).toBeNull(); + expect(stepDeploy(done, `${ESC}${ESC}`).flow).toBeNull(); + expect(left.exitText).toBe(done.text); + // A run that failed as a whole ends in a code. + expect((applyDeployFinished(running, { ok: false, code: 'UNAVAILABLE' }) as { text: string }).text).toContain('(UNAVAILABLE)'); + }); +}); + +describe('the screen: keys, footer, dry run', () => { + const state = (dryRun = false): SecretsScreenState => initialSecretsScreenState([row], dryRun); + const frame = (s: SecretsScreenState): string => strip(render(s, 120, 30)); + + test('ctrl+d on the list starts the deploy flow for the row under the cursor; the footer names the key', () => { + expect(frame(state())).toContain('ctrl+d deploy'); + const next = handleKey(state(), CTRL_D); + expect(next.state.deploy?.step).toBe('planning'); + expect(next.effect).toEqual({ type: 'loadDeployPlan', row }); + expect(frame(next.state)).toContain('Reading deploy targets'); + }); + + test('with a filter typed ctrl+d does nothing (the search owns the keyboard), like ctrl+e', () => { + const typed = handleKey(state(), 'a').state; + expect(handleKey(typed, CTRL_D).state.deploy).toBeNull(); + expect(frame(typed)).not.toContain('ctrl+d deploy'); + }); + + test('in the details view `d` starts the deploy flow and the footer names it', () => { + const popup = handleKey(state(), '\r').state; + expect(frame(popup)).toContain('d deploy'); + const next = handleKey(popup, 'd'); + expect(next.state.deploy?.step).toBe('planning'); + expect(next.state.popup).toBeNull(); + expect(next.effect).toEqual({ type: 'loadDeployPlan', row }); + }); + + test('the whole path through the screen reducer: plan, confirm, progress, result', async () => { + const plan = await realPlan(); + const planned = applyDeployPlan(handleKey(state(), CTRL_D).state, { ok: true, plan }); + expect(frame(planned)).toContain('Deploy API_KEY to 3 targets'); + const confirmed = handleKey(planned, '\r'); + expect(confirmed.effect).toEqual({ type: 'runDeploy', plan }); + const midway = applyDeployRunProgress(confirmed.state, { phase: 'pushing', done: 1, inFlight: 1, total: 3 }); + expect(frame(midway)).toContain('1 of 3 targets'); + const finished = applyDeployRunDone(midway, { ok: true, result: RESULT }); + expect(frame(finished)).toContain('✓ 1 target deployed.'); + const stays = handleKey(finished, 'x'); + expect(stays.state.quit).toBe(false); + expect(stays.state.deploy?.step).toBe('done'); + const left = handleKey(finished, ESC).state; + expect(left.quit).toBe(true); + expect(left.exitText).toContain('https://github.com/Acme/mono/pull/12'); + }); + + test('Ctrl+C while the deploy runs stops it, it does not quit the screen', async () => { + const plan = await realPlan(); + const running = handleKey(applyDeployPlan(handleKey(state(), CTRL_D).state, { ok: true, plan }), '\r').state; + const stopped = handleKey(running, '\x03'); + expect(stopped.state.quit).toBe(false); + expect(stopped.effect).toEqual({ type: 'cancelDeploy', phase: 'pushing' }); + }); + + test('a --dry-run session: every screen carries the marker and Enter on the plan starts no run', async () => { + const plan = await realPlan(); + const planned = applyDeployPlan(handleKey(state(true), CTRL_D).state, { ok: true, plan }); + expect(frame(planned)).toContain('DRY RUN'); + const confirmed = handleKey(planned, '\r'); + expect(confirmed.effect).toBeNull(); + expect(frame(confirmed.state)).toContain('DRY RUN'); + expect(frame(confirmed.state)).toContain('Dry run: nothing was changed.'); + }); +}); + +describe('the driver, with injected actions', () => { + async function drive(actions: SecretsDeployActions | undefined, keys: ReadonlyArray, dryRun = false) { + const outSpy = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const done = runSecretsScreen([row], async () => ({ ok: false, code: 'NO' }), undefined, dryRun, actions); + await wait(); + for (const k of keys) { + if (typeof k === 'number') await wait(k); + else { + process.stdin.emit('data', Buffer.from(k)); + await wait(); + } + } + await done; + const screen = outSpy.mock.calls.map((c) => String(c[0])).join(''); + const logged = logSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + outSpy.mockRestore(); + logSpy.mockRestore(); + return { screen, logged }; + } + + test('ctrl+d, wait for the plan, Enter, wait: the effects are performed and the confirmation is printed after the screen is left', async () => { + const plan = await realPlan(); + const loadPlan = mock(async () => ({ ok: true as const, plan })); + const run = mock(async (_plan: BatchPlan, onProgress?: Parameters[1]) => { + onProgress?.({ phase: 'pushing', done: 1, inFlight: 0, total: 3 }); + return { ok: true as const, result: RESULT }; + }); + const { screen, logged } = await drive({ loadPlan, run, cancel: () => undefined }, [CTRL_D, 40, '\r', 40, ESC]); + expect(loadPlan.mock.calls).toHaveLength(1); + expect(run.mock.calls).toHaveLength(1); + expect(run.mock.calls[0][0]).toEqual(plan); + expect(screen).toContain('Deploy API_KEY to 3 targets'); + expect(logged).toContain('✓ 1 target deployed.'); + expect(logged).toContain('https://github.com/Acme/mono/pull/12'); + }); + + test('Esc on the plan cancels: nothing is run', async () => { + const plan = await realPlan(); + const run = mock(async () => ({ ok: true as const, result: RESULT })); + const { logged } = await drive({ loadPlan: async () => ({ ok: true, plan }), run, cancel: () => undefined }, [CTRL_D, 40, ESC, 20, '\x03']); + expect(run.mock.calls).toHaveLength(0); + expect(logged).toBe(''); + }); + + test('with no actions wired the flow ends in a code, not a crash', async () => { + const { logged } = await drive(undefined, [CTRL_D, 40, ESC]); + expect(logged).toContain('UNAVAILABLE'); + }); + + test('Ctrl+C while running reaches the actions as a stop of the pushes', async () => { + const plan = await realPlan(); + const cancel = mock((_phase: string) => undefined); + const run = mock(async () => { + await wait(60); + return { ok: true as const, result: RESULT }; + }); + await drive({ loadPlan: async () => ({ ok: true, plan }), run, cancel }, [CTRL_D, 40, '\r', 10, '\x03', 120, ESC]); + expect(cancel.mock.calls.map((c) => c[0])).toEqual(['pushing']); + }); +}); + +describe('--dry-run through the real actions: it reads, and nothing is written, unlocked or deployed', () => { + function rig() { + const service = fakeService(); + const github = fakeGithub(standardRepos()); + const adapter = fakeAdapter(); + const { env, openKeys } = makeEnv(service, github, adapter); + const actions = createDeployActionsWith({ getOrgRepos: async () => ({ org_id: 'org1', repos: [...LINKS] }) }, 'org1', env, true); + return { service, github, adapter, openKeys, actions }; + } + + test('the plan is read from GitHub, the screen shows what would happen, and no write call was made', async () => { + const r = rig(); + const { screen, logged } = await (async () => { + const outSpy = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const done = runSecretsScreen([row], async () => ({ ok: false, code: 'NO' }), undefined, true, r.actions); + await wait(); + for (const k of [CTRL_D, 60, '\r', 20, ESC]) { + if (typeof k === 'number') await wait(k); + else { + process.stdin.emit('data', Buffer.from(k)); + await wait(); + } + } + await done; + const out = outSpy.mock.calls.map((c) => String(c[0])).join(''); + const log = logSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + outSpy.mockRestore(); + logSpy.mockRestore(); + return { screen: out, logged: log }; + })(); + + expect(screen).toContain('DRY RUN'); + expect(logged).toContain('Dry run: nothing was changed.'); + expect(logged).toContain('Would deploy to 3 targets.'); + expect(r.github.getFile.mock.calls.length).toBeGreaterThan(0); + expect(r.openKeys.mock.calls).toHaveLength(0); + expect(r.service.getDecryptData.mock.calls).toHaveLength(0); + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + expect(r.adapter.preflight.mock.calls).toHaveLength(0); + expect(r.adapter.deploy.mock.calls).toHaveLength(0); + expect(r.github.createBlob.mock.calls).toHaveLength(0); + expect(r.github.createPull.mock.calls).toHaveLength(0); + }); + + test('even called directly, `run` of a dry-run session refuses with DRY_RUN_UNSUPPORTED and runs nothing', async () => { + const r = rig(); + const plan = await realPlan(); + const finished = await r.actions.run(plan); + expect(finished).toEqual({ ok: false, code: ERROR_CODES.DRY_RUN_UNSUPPORTED }); + expect(r.adapter.deploy.mock.calls).toHaveLength(0); + expect(r.openKeys.mock.calls).toHaveLength(0); + }); +}); + +describe('the real (non dry-run) actions run the engine', () => { + test('loadPlan reads the plan; run deploys it; no value is in what the screen would print', async () => { + const service = fakeService(); + const github = fakeGithub(standardRepos()); + const adapter = fakeAdapter(); + const { env } = makeEnv(service, github, adapter); + const actions = createDeployActionsWith({ getOrgRepos: async () => ({ org_id: 'org1', repos: [...LINKS] }) }, 'org1', env, false); + + const loaded = await actions.loadPlan(row); + expect(loaded.ok).toBe(true); + const plan = (loaded as { plan: BatchPlan }).plan; + expect(plan.targets).toHaveLength(3); + const finished = await actions.run(plan); + expect(finished.ok).toBe(true); + expect(adapter.deploy.mock.calls).toHaveLength(3); + const printed = JSON.stringify(finished) + everythingSentTo(github.createPull, github.createBlob); + expect(Object.values(VALUES).filter((v) => printed.includes(v))).toEqual([]); + }); + + test('a service that cannot list the repos ends the plan in a code', async () => { + const { env } = makeEnv(fakeService(), fakeGithub(standardRepos()), fakeAdapter()); + const actions = createDeployActionsWith({ getOrgRepos: async () => { throw new Error('down'); } }, 'org1', env, false); + expect(await actions.loadPlan(row)).toEqual({ ok: false, code: ERROR_CODES.SERVICE_ERROR }); + }); +}); + +// ── CAP-704: the value line and the choice of targets on the plan step ────── + +describe('the plan step: the value, masked, and `r` to reveal', () => { + const SECRET = VALUES.API_KEY; + const MASKED = maskSecretValue(SECRET); + const SPACE = ' '; + const DOWN = `${ESC}[B`; + const UP = `${ESC}[A`; + type Planned = Extract; + const planned = async (): Promise => + applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan: await realPlan() }) as Planned; + const withValue = (flow: DeployFlow | null): Planned => applyDeployValue(flow, row, { status: 'ok', value: SECRET }) as Planned; + const view = (flow: DeployFlow): string => strip(renderDeploy(flow, 120).lines.join('\n')); + const press = (flow: DeployFlow, ...keys: readonly string[]): Planned => + keys.reduce((f, k) => stepDeploy(f, k).flow as DeployFlow, flow) as Planned; + + test('the plan starts with the value loading, hidden; the value line sits directly under the title', async () => { + const flow = await planned(); + expect(flow.value).toEqual({ status: 'loading' }); + expect(flow.revealed).toBe(false); + const lines = view(flow).split('\n'); + expect(lines[0]).toContain('Deploy API_KEY to 3 targets'); + expect(lines[1]).toMatch(/^value\s+loading…/); + }); + + test('masked by default; `r` reveals it and `r` hides it again; the footer says which', async () => { + const flow = withValue(await planned()); + expect(view(flow)).toContain(MASKED); + expect(view(flow)).not.toContain(SECRET); + expect(strip(renderDeploy(flow, 120).footer)).toContain('r reveal'); + + const shown = press(flow, 'r'); + expect(shown.revealed).toBe(true); + expect(view(shown)).toContain(SECRET); + expect(strip(renderDeploy(shown, 120).footer)).toContain('r hide'); + + const hidden = press(shown, 'R'); + expect(view(hidden)).toContain(MASKED); + expect(view(hidden)).not.toContain(SECRET); + }); + + test('a value that cannot be read says so with its code', async () => { + const flow = applyDeployValue(await planned(), row, { status: 'unavailable', code: 'HASH_MISMATCH' }) as DeployFlow; + expect(view(flow)).toMatch(/value\s+unavailable \(HASH_MISMATCH\)/); + }); + + test('a value for another row (name or hash) is dropped; so is one that arrives after the plan step', async () => { + const flow = await planned(); + expect(applyDeployValue(flow, { name: 'OTHER', value_hash: row.value_hash }, { status: 'ok', value: SECRET })).toBe(flow); + expect(applyDeployValue(flow, { name: row.name, value_hash: 'old-hash' }, { status: 'ok', value: SECRET })).toBe(flow); + const running = { step: 'running', row, plan: flow.plan } as DeployFlow; + expect(applyDeployValue(running, row, { status: 'ok', value: SECRET })).toBe(running); + expect(applyDeployValue(null, row, { status: 'ok', value: SECRET })).toBeNull(); + }); + + test('the value never reaches the exit text, a note, or the running / dry-run / result text', async () => { + const flow = press(withValue(await planned()), 'r'); + expect(JSON.stringify(stepDeploy(flow, ESC))).not.toContain(SECRET); // the cancel note + expect(JSON.stringify(stepDeploy(flow, '\r', true))).not.toContain(SECRET); // the dry-run text + const started = stepDeploy(flow, '\r'); // the run: neither its state nor its effect holds it + expect(JSON.stringify(started)).not.toContain(SECRET); + expect(view(started.flow as DeployFlow)).not.toContain(SECRET); + const done = applyDeployFinished(started.flow, { ok: true, result: RESULT }) as Extract; + expect(done.text).not.toContain(SECRET); + expect(JSON.stringify(stepDeploy(done, ESC))).not.toContain(SECRET); // the exit text + }); + + test('all targets start ticked; Space ticks / unticks the one under the cursor; the cursor moves with up and down', async () => { + const flow = await planned(); + expect(flow.box.checked).toEqual([0, 1, 2]); + const text = view(flow); + expect(text.match(/◉/g)).toHaveLength(3); + expect(text).not.toContain('◯'); + + expect(press(flow, SPACE).box.checked).toEqual([1, 2]); + const second = press(flow, SPACE, DOWN, SPACE); + expect(second.box).toMatchObject({ checked: [2], active: 1 }); + expect(view(second).match(/◯/g)).toHaveLength(2); + expect(press(second, UP, SPACE).box.checked).toEqual([0, 2]); + }); + + test('`a` clears all when all are ticked, and ticks all when any is not', async () => { + const flow = await planned(); + const none = press(flow, 'a'); + expect(none.box.checked).toEqual([]); + expect(press(none, 'a').box.checked).toEqual([0, 1, 2]); + expect(press(flow, SPACE, 'a').box.checked).toEqual([0, 1, 2]); + }); + + test('nothing else types into the list: letters, digits and `/` are ignored', async () => { + const flow = await planned(); + for (const key of ['/', 'i', '1', 'x', 'k', 'j', 'y']) { + const step = stepDeploy(flow, key); + expect(step.flow).toBe(flow); + expect(step.effect).toBeNull(); + } + }); + + test('Enter deploys ONLY the ticked targets: the run gets the restricted plan', async () => { + const flow = await planned(); + const step = stepDeploy(press(flow, SPACE), '\r'); // the first one unticked + expect(step.effect?.type).toBe('runDeploy'); + const sent = (step.effect as Extract, { type: 'runDeploy' }>).plan; + expect(sent.targets.map((t) => t.config.name)).toEqual(['worker', 'site']); + expect(sent).toEqual(restrictPlan(flow.plan, flow.plan.targets.slice(1).map(targetKeyOf))); + expect(sent.skipped).toEqual(flow.plan.skipped); + expect(step.flow).toMatchObject({ step: 'running', plan: sent }); + expect(deployRunningLine(step.flow as Extract)).toBe('Deploying API_KEY to 2 targets…'); + }); + + test('with nothing ticked Enter stays, and the footer no longer offers `enter deploy`', async () => { + const flow = await planned(); + expect(strip(renderDeploy(flow, 120).footer)).toBe('space select · a all · r reveal · enter deploy · esc cancel'); + const none = press(flow, 'a'); + for (const dry of [false, true]) { + const step = stepDeploy(none, '\r', dry); + expect(step.flow).toBe(none); + expect(step.effect).toBeNull(); + } + expect(strip(renderDeploy(none, 120).footer)).toBe('space select · a all · r reveal · esc cancel'); + }); + + test('skipped locations stay listed under the list, greyed, with no checkbox, and are never selectable', async () => { + const flow = await planned(); + const { lines } = renderDeploy(flow, 120); + const skipped = lines.filter((l) => strip(l).includes('skipped ')); + expect(skipped).toHaveLength(flow.plan.skipped.length); + for (const l of skipped) { + expect(l.startsWith(`${ESC}[90m`)).toBe(true); + expect(l).not.toMatch(/[◉◯]/); + } + expect(lines.findIndex((l) => strip(l).includes('NOT_DOKPLOY'))).toBeGreaterThan(lines.findIndex((l) => strip(l).includes('solo-server'))); + // The cursor only visits the three targets, however far it goes, and ticks never name a skipped row. + const moved = press(flow, ...Array.from({ length: 7 }, () => DOWN)); + expect(moved.box.active).toBe(1); + expect(press(moved, SPACE, 'a').box.checked.every((i) => i < flow.plan.targets.length)).toBe(true); + }); + + test('--dry-run with a partial selection lists only the ticked targets and starts nothing', async () => { + const flow = await planned(); + const partial = press(flow, SPACE); // the first one unticked + const step = stepDeploy(partial, '\r', true); + expect(step.effect).toBeNull(); + const text = (step.flow as { text: string }).text; + expect(text).toContain('Would deploy to 2 targets.'); + expect(text).toContain('· worker'); + expect(text).toContain('· site'); + expect(text).not.toContain('· api'); + expect(text).toBe(dryRunDeployText(selectedPlan(partial))); + }); +}); + +describe('the plan step through the screen and the driver', () => { + const SECRET = VALUES.API_KEY; + const state = (dryRun = false): SecretsScreenState => initialSecretsScreenState([row], dryRun); + const frame = (s: SecretsScreenState): string => strip(render(s, 120, 30)); + + test("once the plan has loaded the value is fetched (the details view's effect) and shows masked; a result for another row is dropped", async () => { + const plan = await realPlan(); + const started = handleKey(state(), CTRL_D).state; + expect(pendingDeployValueEffect(started)).toBeNull(); // still reading the plan + const planned = applyDeployPlan(started, { ok: true, plan }); + expect(pendingDeployValueEffect(planned)).toEqual({ type: 'fetchValue', row }); + const shown = applyValueResult(planned, row, { status: 'ok', value: SECRET }); + expect(frame(shown)).toContain(maskSecretValue(SECRET)); + expect(frame(shown)).not.toContain(SECRET); + const stale = applyValueResult(planned, { name: 'OTHER', value_hash: row.value_hash }, { status: 'ok', value: SECRET }); + expect(stale.deploy).toBe(planned.deploy); + expect(frame(handleKey(shown, 'r').state)).toContain(SECRET); // `r` reveals it, on screen + }); + + test('a plan with nothing to deploy fetches no value', () => { + const planned = applyDeployPlan(handleKey(state(), CTRL_D).state, { ok: true, plan: EMPTY }); + expect(pendingDeployValueEffect(planned)).toBeNull(); + }); + + async function drive(actions: SecretsDeployActions, decryptAt: Parameters[1], keys: ReadonlyArray) { + const outSpy = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const done = runSecretsScreen([row], decryptAt, undefined, false, actions); + await wait(); + for (const k of keys) { + if (typeof k === 'number') await wait(k); + else { + process.stdin.emit('data', Buffer.from(k)); + await wait(); + } + } + await done; + const screen = outSpy.mock.calls.map((c) => String(c[0])).join(''); + const logged = logSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + outSpy.mockRestore(); + logSpy.mockRestore(); + return { screen, logged }; + } + + test('the value is fetched ONCE per plan and shown masked; it is on screen only after `r`; the run gets the ticked targets; the printed text never holds it', async () => { + const plan = await realPlan(); + const decryptAt = mock(async () => ({ ok: true as const, plaintext: SECRET })); + const run = mock(async (_plan: BatchPlan) => ({ ok: true as const, result: RESULT })); + const actions: SecretsDeployActions = { loadPlan: async () => ({ ok: true, plan }), run, cancel: () => undefined }; + + const hidden = await drive(actions, decryptAt, [CTRL_D, 40, ESC, 20, '\x03']); + expect(hidden.screen).toContain(maskSecretValue(SECRET)); + expect(hidden.screen).not.toContain(SECRET); + expect(decryptAt.mock.calls).toHaveLength(1); + + const revealed = await drive(actions, decryptAt, [CTRL_D, 40, 'r', ' ', '\r', 40, ESC]); + expect(revealed.screen).toContain(SECRET); // on the screen, after `r` + expect(revealed.logged).not.toContain(SECRET); + expect(run.mock.calls).toHaveLength(1); + expect(run.mock.calls[0][0].targets.map((t) => t.config.name)).toEqual(['worker', 'site']); + expect(decryptAt.mock.calls).toHaveLength(2); // once per opened plan, never again + }); + + test('a value that cannot be decrypted shows its code and the deploy still works', async () => { + const plan = await realPlan(); + const run = mock(async (_plan: BatchPlan) => ({ ok: true as const, result: RESULT })); + const { screen } = await drive({ loadPlan: async () => ({ ok: true, plan }), run, cancel: () => undefined }, async () => ({ ok: false, code: 'NO_KEY' }), [CTRL_D, 40, '\r', 40, ESC]); + expect(screen).toContain('unavailable'); + expect(screen).toContain('NO_KEY'); + expect(run.mock.calls).toHaveLength(1); + expect(run.mock.calls[0][0].targets).toHaveLength(3); + }); +}); diff --git a/tests/ui/secretsDryRunTui.test.ts b/tests/ui/secretsDryRunTui.test.ts index 07ec0a57..56b73a31 100644 --- a/tests/ui/secretsDryRunTui.test.ts +++ b/tests/ui/secretsDryRunTui.test.ts @@ -86,7 +86,7 @@ async function walk(w: ReturnType, locs: readonly Loc[] = LOCATION const done = applyRunDone(running.state, await w.actions.run(request)); return { frames: { list, value: frame(typed), locations: frame(locations), loading: loadingFrame, repos: reposFrame, running: runningFrame, done: frame(done) }, - left: press(done, 'x'), + left: press(done, ESC), request, }; } @@ -239,8 +239,8 @@ describe('through the real driver', () => { const wait = (ms = 15) => new Promise((r) => setTimeout(r, ms)); const done = runSecretsScreen(indexRows(), async () => ({ ok: false, code: 'NO' }), w.actions, true); await wait(); - // Ctrl+E, the value as a bracketed paste, Enter, Enter (all locations), wait for repos, Enter (plan), wait, any key. - await [`\x05`, `${ESC}[200~${SENTINEL}${ESC}[201~`, '\r', '\r', 40, '\r', 60, 'x'].reduce>(async (prev, k) => { + // Ctrl+E, the value as a bracketed paste, Enter, Enter (all locations), wait for repos, Enter (plan), wait, Esc. + await [`\x05`, `${ESC}[200~${SENTINEL}${ESC}[201~`, '\r', '\r', 40, '\r', 60, ESC].reduce>(async (prev, k) => { await prev; if (typeof k === 'number') return wait(k); process.stdin.emit('data', Buffer.from(k)); diff --git a/tests/ui/secretsEditFlow.test.ts b/tests/ui/secretsEditFlow.test.ts index 6938e1d7..475e2145 100644 --- a/tests/ui/secretsEditFlow.test.ts +++ b/tests/ui/secretsEditFlow.test.ts @@ -370,7 +370,7 @@ until its PR is merged and pulled.`); expect(text).toContain(`web · production ✗ ${ERROR_CODES.PERMISSION_DENIED}`); }); - test('through the screen: the run finishing shows the confirmation, any key leaves and hands it to stdout', () => { + test('through the screen: the run finishing shows the confirmation, only Esc leaves and hands it to stdout', () => { const running = handleKey( applyRepos(handleKey(atLocations(), ENTER).state, { ok: true, links: LINKS, bases: {} }), ENTER, @@ -379,7 +379,14 @@ until its PR is merged and pulled.`); expect(flowOf(done).step).toBe('done'); expect(frame(done)).toContain('✓ ANTHROPIC_API_KEY updated in 4 locations.'); expect(frame(done)).not.toContain(VALUE); - const left = press(done, 'x'); + expect(frame(done)).toContain('esc exit'); + // Any other key leaves the result on screen, so the links can be selected and copied. + for (const key of ['x', ENTER, ' ']) { + const stays = press(done, key); + expect(stays.quit).toBe(false); + expect(flowOf(stays).step).toBe('done'); + } + const left = press(done, ESC); expect(left.quit).toBe(true); expect(left.exitText).toContain('https://github.com/SlideSpeak/slidespeak-monorepo/pull/1'); expect(left.exitText).not.toContain(VALUE); diff --git a/tests/ui/secretsScreenDriverEdit.test.ts b/tests/ui/secretsScreenDriverEdit.test.ts index 8cf6063b..a297f6a1 100644 --- a/tests/ui/secretsScreenDriverEdit.test.ts +++ b/tests/ui/secretsScreenDriverEdit.test.ts @@ -80,8 +80,8 @@ describe('runSecretsScreen with the edit flow', () => { }; }); - // Ctrl+E, the value as one bracketed paste, Enter, Enter (all locations), wait for repos, Enter (run), wait, any key. - const { screen, logged } = await drive({ loadRepos, loadBases, run }, ['\x05', `${ESC}[200~${VALUE}${ESC}[201~`, '\r', '\r', 40, '\r', 40, 'x']); + // Ctrl+E, the value as one bracketed paste, Enter, Enter (all locations), wait for repos, Enter (run), wait, Esc. + const { screen, logged } = await drive({ loadRepos, loadBases, run }, ['\x05', `${ESC}[200~${VALUE}${ESC}[201~`, '\r', '\r', 40, '\r', 40, ESC]); const requests = run.mock.calls.map((c) => c[0]); expect(requests).toHaveLength(1); @@ -103,7 +103,7 @@ describe('runSecretsScreen with the edit flow', () => { const cancelled = await drive(undefined, ['\x05', 'ab', ESC, 20, '\x03']); expect(cancelled.logged).toEqual([]); - const unwired = await drive(undefined, ['\x05', 'v', '\r', '\r', 30, '\r', 30, 'x']); + const unwired = await drive(undefined, ['\x05', 'v', '\r', '\r', 30, '\r', 30, ESC]); expect(unwired.logged.join('\n')).toContain('(UNAVAILABLE)'); }); }); @@ -122,8 +122,8 @@ describe('Ctrl+R in the dialog, through the real driver', () => { result: { name: request.name, updated: [], unchanged: [], prs: [], no_pr: [], failed: [] }, }), }; - // Ctrl+E, wait for the value to be read, Ctrl+R, type, Enter, Enter, wait, Enter (run), wait, any key. - const { screen, logged } = await drive(actions, ['\x05', 40, '\x12', VALUE, '\r', '\r', 40, '\r', 40, 'x'], oldRows, decrypt); + // Ctrl+E, wait for the value to be read, Ctrl+R, type, Enter, Enter, wait, Enter (run), wait, Esc. + const { screen, logged } = await drive(actions, ['\x05', 40, '\x12', VALUE, '\r', '\r', 40, '\r', 40, ESC], oldRows, decrypt); expect(screen).toContain(OLD); // visible while the dialog is open expect(screen).toContain(VALUE); // revealed by the same Ctrl+R, typed after it const exitAt = screen.lastIndexOf(`${ESC}[?1049l`); From eb8f944330047cf90e068be25345b170930b32a3 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 5 Oct 2026 17:42:46 -0700 Subject: [PATCH 2/3] chore(release): v0.9.11 --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 56f31511..8225a4ba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.10 +0.9.11 diff --git a/package.json b/package.json index dfca0d43..1157f150 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.10", + "version": "0.9.11", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": { From bd38af25ae704c05675a1d417446c341138dcd54 Mon Sep 17 00:00:00 2001 From: cvince Date: Mon, 5 Oct 2026 17:55:12 -0700 Subject: [PATCH 3/3] feat(secrets): copy PR links with c; write plain Dokploy values unquoted - Batch deploy and batch edit result screens: c copies the PR links, one per line; the screen stays open until Esc. - Dokploy delivery writes a value bare when it is only plain characters (letters, digits, _ - . / : @ , + =) and quotes it only when needed ($, #, whitespace, quotes, backslash, newline, empty). --- src/deploy/dokployApi.ts | 26 ++++- src/ui/copyPrLinks.ts | 30 +++++ src/ui/secretsDeployFlow.ts | 47 ++++++-- src/ui/secretsEditFlow.ts | 44 +++++-- src/ui/secretsScreen.ts | 7 ++ src/ui/secretsScreenDriver.ts | 39 +++++-- tests/deploy/dokploy.test.ts | 3 +- tests/deploy/dokployPlainDelivery.test.ts | 46 ++++++++ tests/ui/secretsDeployFlow.test.ts | 135 ++++++++++++++++++++++ tests/ui/secretsEditFlow.test.ts | 65 +++++++++++ tests/ui/secretsScreenDriverEdit.test.ts | 47 +++++++- 11 files changed, 456 insertions(+), 33 deletions(-) create mode 100644 src/ui/copyPrLinks.ts diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index 251076d8..2f514e7e 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -1110,6 +1110,14 @@ export function stripManagedBlock(split: EnvSplit): string { // COPY-FLAG: new on-disk marker text (not user-facing prose, but visible in the Dokploy dashboard). export const CAPY_OFF_MARKER = '# capy:off '; +/** Characters that survive unquoted through both `dotenv` and Docker Compose unchanged. */ +const BARE_SAFE_VALUE = /^[A-Za-z0-9_\-./:@,+=]+$/; + +/** True when `value` is non-empty and made only of bare-safe characters. */ +export function isBareSafeValue(value: string): boolean { + return BARE_SAFE_VALUE.test(value); +} + export type DotenvValueProblem = 'DOKPLOY_VALUE_UNREPRESENTABLE' | 'DOKPLOY_VALUE_HAS_REFERENCE'; /** @@ -1118,11 +1126,18 @@ export type DotenvValueProblem = 'DOKPLOY_VALUE_UNREPRESENTABLE' | 'DOKPLOY_VALU * exactly `value` — see `tests/deploy/dokployPlainDelivery.test.ts` for the * adversarial property test (and seeded fuzz test) this is built to satisfy. * - * ALWAYS quotes (never emits an unquoted value): dotenv trims an unquoted - * value's surrounding whitespace and cuts it at the first `#`, and — subtler - * — a value that itself happens to start and end with the same quote - * character would be re-stripped by `dotenv.parse`'s own quote-removal step - * if left unquoted. Quoting unconditionally sidesteps both. + * Bare when the value is only plain characters: a non-empty value made + * entirely of `[A-Za-z0-9_\-./:@,+=]` is written UNQUOTED (`KEY=180`, + * `KEY=https://x.y/z`) — nothing in it can be misread by `dotenv` or by + * Docker Compose, so the Dokploy dashboard shows the value exactly as it is. + * + * Quoted otherwise, because each of these breaks an unquoted value: `#` + * starts a comment (dotenv cuts the value there); leading/trailing + * whitespace is trimmed; a value that itself starts and ends with the same + * quote character is re-stripped by `dotenv.parse`'s own quote-removal step; + * and `$` is interpolated by Docker Compose in an unquoted value (so `$` is + * deliberately NOT in the bare set). An empty value is also quoted, so the + * line never reads as "no value". * * FIRST, independent of quoting: `dotenv.parse` normalizes every `\r\n` (and * a lone `\r`) to `\n` on the WHOLE input text before any quote-aware parsing @@ -1169,6 +1184,7 @@ export type DotenvValueProblem = 'DOKPLOY_VALUE_UNREPRESENTABLE' | 'DOKPLOY_VALU export function formatDotenvValue(value: string): { ok: true; rendered: string } | { ok: false; code: DotenvValueProblem } { if (value.includes('${{')) return { ok: false, code: 'DOKPLOY_VALUE_HAS_REFERENCE' }; if (value.includes('\r')) return { ok: false, code: 'DOKPLOY_VALUE_UNREPRESENTABLE' }; + if (isBareSafeValue(value)) return { ok: true, rendered: value }; if (!value.includes("'")) return { ok: true, rendered: `'${value}'` }; if (!value.includes('`')) return { ok: true, rendered: `\`${value}\`` }; // Both literal two-character sequences `dotenv.parse` decodes inside a diff --git a/src/ui/copyPrLinks.ts b/src/ui/copyPrLinks.ts new file mode 100644 index 00000000..854d42cc --- /dev/null +++ b/src/ui/copyPrLinks.ts @@ -0,0 +1,30 @@ +// The `c` key on the two batch result screens (the deploy result and the edit result): copy the +// PR links shown there to the clipboard. Pure: this file only builds the effect, the outcome and +// the screen line; the screen driver performs the copy (`copyToClipboard`, see `clipboard.ts`). +// +// The links come from the structured result the flow already holds (a PR's url field), never from +// the rendered text, and nothing here branches on a string. + +export type CopyEffect = { readonly type: 'copyToClipboard'; readonly text: string }; + +/** What the last copy did: `ok`, and how many links it held. Absent on the flow until `c` was pressed. */ +export interface CopyOutcome { + readonly ok: boolean; + readonly count: number; +} + +/** The footer's pair for the key; only offered when there is something to copy. */ +export const COPY_HINT_PAIR = ['c', 'copy PRs'] as const; // COPY-FLAG + +/** The links, once each, in the order they were found. */ +export const uniqueUrls = (urls: readonly string[]): readonly string[] => [...new Set(urls)]; + +export const isCopyKey = (token: string): boolean => token === 'c' || token === 'C'; + +/** One link per line, nothing else. */ +export const copyEffectFor = (urls: readonly string[]): CopyEffect => ({ type: 'copyToClipboard', text: urls.join('\n') }); + +export const copiedLine = (outcome: CopyOutcome): string => + outcome.ok + ? `Copied ${outcome.count} PR link${outcome.count === 1 ? '' : 's'}` // COPY-FLAG + : 'Could not copy to clipboard'; // COPY-FLAG diff --git a/src/ui/secretsDeployFlow.ts b/src/ui/secretsDeployFlow.ts index fea805ed..b04d80b0 100644 --- a/src/ui/secretsDeployFlow.ts +++ b/src/ui/secretsDeployFlow.ts @@ -19,7 +19,8 @@ // Under `--dry-run` Enter shows what would happen for the ticked targets and // changes nothing: no effect is ever started. // running Ctrl+C / Esc stop: no further target is started, every one in flight finishes. -// done the result; only Esc leaves it, so the PR links can be selected and copied first. +// done the result; only Esc leaves it. `c` copies the PR links to the clipboard (and the screen +// stays), so they can also be selected and copied by hand first. import type { SecretIndexRow } from '../service/serviceClient'; import { BatchPhase, BatchPlan, BatchProgress, BatchResult, restrictPlan, targetKeyOf } from '../deploy/batchDeploy'; @@ -38,6 +39,7 @@ import { TableColumn, clipLine, pickerTableLines } from './pickerTable'; import { CheckboxState, initialCheckboxState, stepCheckboxKey } from './searchableCheckbox'; import { toCheckboxKey } from './secretsEditFlow'; import { ValueState, renderValueLine } from './valueDisplay'; +import { COPY_HINT_PAIR, CopyEffect, CopyOutcome, copiedLine, copyEffectFor, isCopyKey, uniqueUrls } from './copyPrLinks'; const ESC = '\x1b'; const RESET = `${ESC}[0m`; @@ -73,9 +75,18 @@ export type DeployFlow = /** A stop was asked for in this phase, this many times (Ctrl+C / Esc). */ readonly stop?: { readonly phase: BatchPhase; readonly count: number }; } - | { readonly step: 'done'; readonly text: string }; + | { + readonly step: 'done'; + readonly text: string; + /** The PR links of the result, from its own url fields (never read back from `text`). Empty: `c` does nothing. */ + readonly prUrls: readonly string[]; + /** What the last `c` did; absent until it was pressed. */ + readonly copied?: CopyOutcome; + }; export type DeployEffect = + /** Copy the PR links (newline-joined) to the clipboard; the driver reports back with `applyDeployCopied`. */ + | CopyEffect | { readonly type: 'loadDeployPlan'; readonly row: SecretIndexRow } /** Stop: `planning` kills the GitHub reads; `pushing` / `prs` start nothing new and wait for every item in flight. */ | { readonly type: 'cancelDeploy'; readonly phase: 'planning' | BatchPhase } @@ -133,7 +144,7 @@ function stepPlan(flow: Extract, key: string, dryR if (flow.box.checked.length === 0) return stay(flow); const plan = selectedPlan(flow); // A dry run never starts the run: it shows what would happen. - if (dryRun) return stay({ step: 'done', text: dryRunDeployText(plan) }); + if (dryRun) return stay({ step: 'done', text: dryRunDeployText(plan), prUrls: [] }); return { flow: { step: 'running', row: flow.row, plan }, effect: { type: 'runDeploy', plan } }; } @@ -157,8 +168,14 @@ export function stepDeploy(flow: DeployFlow, key: string, dryRun: boolean = fals } if (flow.step === 'plan') return stepPlan(flow, key, dryRun); if (flow.step === 'running') return stepRunning(flow, key); - // The result screen stays until Esc, so its PR links can be selected and copied. - return isEsc(key) ? { flow: null, effect: null, exitText: flow.text } : { flow, effect: null }; + return stepDone(flow, key); +} + +/** The result screen: Esc leaves it; `c` copies the PR links and stays (no links: nothing to copy); every other key does nothing. */ +function stepDone(flow: Extract, key: string): DeployStep { + if (isEsc(key)) return { flow: null, effect: null, exitText: flow.text }; + if (isCopyKey(key) && flow.prUrls.length > 0) return { flow, effect: copyEffectFor(flow.prUrls) }; + return stay(flow); } // ── Results coming back ───────────────────────────────────────────────────── @@ -179,7 +196,7 @@ export function applyDeployPlanLoaded(flow: DeployFlow | null, loaded: DeployPla value: { status: 'loading' }, revealed: false, } - : { step: 'done', text: `✗ Could not read the deploy targets. (${loaded.code})` }; // COPY-FLAG + : { step: 'done', text: `✗ Could not read the deploy targets. (${loaded.code})`, prUrls: [] }; // COPY-FLAG } /** The plan step has targets: the row's value is worth fetching, to show it on this screen. */ @@ -208,6 +225,10 @@ export function applyDeployProgress(flow: DeployFlow | null, progress: BatchProg return { ...flow, progress, stop }; } +/** The PR links of a finished batch: every delivered target's own `pr_url` (a target that needed no PR has none), once each. */ +export const prUrlsOf = (result: BatchResult): readonly string[] => + uniqueUrls(result.targets.flatMap((t) => (t.kind === 'delivered' && t.pr_url !== null ? [t.pr_url] : []))); + export type DeployFinished = | { readonly ok: true; readonly result: BatchResult } | { readonly ok: false; readonly code: string }; @@ -218,9 +239,16 @@ export function applyDeployFinished(flow: DeployFlow | null, finished: DeployFin return { step: 'done', text: finished.ok ? renderBatchResult(finished.result, TERMINAL_STYLE) : `✗ ${flow.row.name} was not deployed. (${finished.code})`, // COPY-FLAG + prUrls: finished.ok ? prUrlsOf(finished.result) : [], }; } +/** The copy the `copyToClipboard` effect started finished. Dropped unless the flow is still on the result. */ +export function applyDeployCopied(flow: DeployFlow | null, ok: boolean): DeployFlow | null { + if (flow === null || flow.step !== 'done') return flow; + return { ...flow, copied: { ok, count: flow.prUrls.length } }; +} + // ── Rendering ──────────────────────────────────────────────────────────────── const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`; @@ -301,7 +329,10 @@ export function renderDeploy( return { lines: [`${DIM}${deployRunningLine(flow)}${RESET}`], footer: flow.stop === undefined ? hint(['ctrl+c', 'stop']) : '' }; // COPY-FLAG } return { - lines: flow.text.split('\n').map((l) => (l.startsWith('✗') ? `${RED}${l}${RESET}` : l)), - footer: hint(['esc', 'exit']), // COPY-FLAG + lines: [ + ...flow.text.split('\n').map((l) => (l.startsWith('✗') ? `${RED}${l}${RESET}` : l)), + ...(flow.copied === undefined ? [] : ['', `${flow.copied.ok ? DIM : RED}${copiedLine(flow.copied)}${RESET}`]), + ], + footer: hint(...(flow.prUrls.length > 0 ? [COPY_HINT_PAIR] : []), ['esc', 'exit']), // COPY-FLAG }; } diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index c858b362..23933249 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -17,7 +17,8 @@ // space, a (toggle all), i (invert), / (filter), enter, and Esc back. // repos the repos to open a PR in, with the approved prompt, all selected. // running keys are ignored: a push in flight is never abandoned halfway. -// done the confirmation; only Esc leaves it, so the PR links can be selected and copied first. +// done the confirmation; only Esc leaves it. `c` copies the PR links to the clipboard (and the +// screen stays), so they can also be selected and copied by hand first. import { CheckboxKey, CheckboxState, initialCheckboxState, stepCheckboxKey } from './searchableCheckbox'; import { isRevealKey, stepEditBuffer } from './editBuffer'; @@ -28,6 +29,7 @@ import { CONFIRM_MESSAGE } from '../commands/keepLockPr'; import { CANCELLED_NOTHING, STOPPING_AGAIN, STOPPING_NOW, stoppingAfter, DryRunView, renderDryRunPlan, renderSecretSetConfirmation, TERMINAL_STYLE } from '../commands/secretsSetText'; import { RepoTarget, RunProgress, SecretSetResult, SetLocation, planRepos, repoKey, repoLabel } from '../commands/secretsSet'; import { TableColumn, clipLine, pickerTableLines } from './pickerTable'; +import { COPY_HINT_PAIR, CopyEffect, CopyOutcome, copiedLine, copyEffectFor, isCopyKey, uniqueUrls } from './copyPrLinks'; import type { OrgRepoLink, SecretIndexRow } from '../service/serviceClient'; const ESC = '\x1b'; @@ -105,7 +107,14 @@ export type EditFlow = /** A stop was asked for in this phase, this many times (Ctrl+C / Esc). */ readonly stop?: { readonly phase: RunProgress['phase']; readonly count: number }; } & Carried) - | { readonly step: 'done'; readonly text: string }; + | { + readonly step: 'done'; + readonly text: string; + /** The PR links of the result, from its own url fields (never read back from `text`). Empty: `c` does nothing. */ + readonly prUrls: readonly string[]; + /** What the last `c` did; absent until it was pressed. */ + readonly copied?: CopyOutcome; + }; export interface RunRequest { readonly name: string; @@ -119,6 +128,8 @@ export interface RunRequest { } export type EditEffect = + /** Copy the PR links (newline-joined) to the clipboard; the driver reports back with `applyEditCopied`. */ + | CopyEffect | { readonly type: 'loadRepos'; readonly projectIds: readonly string[] } /** Read the default branches of these repos (one batched call): the BASE column. */ | { readonly type: 'loadBases'; readonly targets: readonly RepoTarget[] } @@ -363,8 +374,14 @@ export function stepEdit(flow: EditFlow, key: string): EditStep { if (flow.step === 'loading') return stepLoading(flow, key); if (flow.step === 'repos') return stepRepos(flow, key); if (flow.step === 'running') return stepRunning(flow, key); - // The result screen stays until Esc, so its PR links can be selected and copied. - return isEsc(key) ? { flow: null, effect: null, exitText: flow.text } : { flow, effect: null }; + return stepDone(flow, key); +} + +/** The result screen: Esc leaves it; `c` copies the PR links and stays (no links: nothing to copy); every other key does nothing. */ +function stepDone(flow: Extract, key: string): EditStep { + if (isEsc(key)) return { flow: null, effect: null, exitText: flow.text }; + if (isCopyKey(key) && flow.prUrls.length > 0) return { flow, effect: copyEffectFor(flow.prUrls) }; + return stay(flow); } // ── Results coming back ───────────────────────────────────────────────────── @@ -401,6 +418,10 @@ export type RunFinished = | { readonly ok: true; readonly plan: DryRunView } | { readonly ok: false; readonly code: string }; +/** The PR links of a finished run: each PR's own `url` (a dry run, a failure and a repo that needed no PR have none), once each. */ +export const prUrlsOf = (finished: RunFinished): readonly string[] => + finished.ok && 'result' in finished ? uniqueUrls(finished.result.prs.map((p) => p.url)) : []; + function finishedText(name: string, finished: RunFinished): string { if (!finished.ok) return `✗ ${name} was not updated. (${finished.code})`; // COPY-FLAG return 'plan' in finished ? renderDryRunPlan(finished.plan) : renderSecretSetConfirmation(finished.result, TERMINAL_STYLE); @@ -409,7 +430,13 @@ function finishedText(name: string, finished: RunFinished): string { /** The run finished. Dropped unless the flow is still running. */ export function applyRunFinished(flow: EditFlow | null, finished: RunFinished): EditFlow | null { if (flow === null || flow.step !== 'running') return flow; - return { step: 'done', text: finishedText(flow.name, finished) }; + return { step: 'done', text: finishedText(flow.name, finished), prUrls: prUrlsOf(finished) }; +} + +/** The copy the `copyToClipboard` effect started finished. Dropped unless the flow is still on the result. */ +export function applyEditCopied(flow: EditFlow | null, ok: boolean): EditFlow | null { + if (flow === null || flow.step !== 'done') return flow; + return { ...flow, copied: { ok, count: flow.prUrls.length } }; } // ── Rendering ──────────────────────────────────────────────────────────────── @@ -519,8 +546,11 @@ export function renderEdit( return { lines: [`${DIM}${runningLine(flow, dryRun)}${RESET}`], footer: flow.stop === undefined ? hint(['ctrl+c', 'stop']) : '' }; // COPY-FLAG } return { - lines: flow.text.split('\n').map((l) => (l.startsWith('✗') ? `${RED}${l}${RESET}` : l)), - footer: hint(['esc', 'exit']), // COPY-FLAG + lines: [ + ...flow.text.split('\n').map((l) => (l.startsWith('✗') ? `${RED}${l}${RESET}` : l)), + ...(flow.copied === undefined ? [] : ['', `${flow.copied.ok ? DIM : RED}${copiedLine(flow.copied)}${RESET}`]), + ], + footer: hint(...(flow.prUrls.length > 0 ? [COPY_HINT_PAIR] : []), ['esc', 'exit']), // COPY-FLAG }; } diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index 6316f1a6..d15c6640 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -36,6 +36,7 @@ import { applyReposLoaded, basesEffectFor, applyOldValue, + applyEditCopied, applyRunFinished, isRunning, renderEdit, @@ -47,6 +48,7 @@ import { DeployFinished, DeployFlow, DeployPlanLoaded, + applyDeployCopied, applyDeployFinished, applyDeployPlanLoaded, applyDeployProgress, @@ -468,6 +470,11 @@ export function applyDeployRunProgress(state: SecretsScreenState, progress: Batc return { ...state, deploy: applyDeployProgress(state.deploy, progress) }; } +/** The clipboard copy the `copyToClipboard` effect started finished (on whichever result screen is open). */ +export function applyCopied(state: SecretsScreenState, ok: boolean): SecretsScreenState { + return { ...state, edit: applyEditCopied(state.edit, ok), deploy: applyDeployCopied(state.deploy, ok) }; +} + /** The run the `runDeploy` effect started. */ export function applyDeployRunDone(state: SecretsScreenState, finished: DeployFinished): SecretsScreenState { return { ...state, deploy: applyDeployFinished(state.deploy, finished) }; diff --git a/src/ui/secretsScreenDriver.ts b/src/ui/secretsScreenDriver.ts index 26ab4c05..46456001 100644 --- a/src/ui/secretsScreenDriver.ts +++ b/src/ui/secretsScreenDriver.ts @@ -33,6 +33,7 @@ import { pendingBasesEffect, pendingDeployValueEffect, applyRunDone, + applyCopied, resolveSecretValue, render, tokenizeKeys, @@ -43,6 +44,7 @@ import type { SecretIndexRow } from '../service/serviceClient'; import { isRunning, type EditEffect, type ReposLoaded, type RunFinished } from './secretsEditFlow'; import type { RunProgress } from '../commands/secretsSet'; import { isDeployRunning, type DeployEffect, type DeployFinished, type DeployPlanLoaded } from './secretsDeployFlow'; +import { copyToClipboard } from './clipboard'; import type { BatchPlan, BatchProgress } from '../deploy/batchDeploy'; const { @@ -98,7 +100,11 @@ type DriverAction = | { readonly kind: 'progress'; readonly progress: RunProgress } | { readonly kind: 'deployPlan'; readonly loaded: DeployPlanLoaded } | { readonly kind: 'deployProgress'; readonly progress: BatchProgress } - | { readonly kind: 'deployRan'; readonly finished: DeployFinished }; + | { readonly kind: 'deployRan'; readonly finished: DeployFinished } + | { readonly kind: 'copied'; readonly ok: boolean }; + +/** Puts text on the clipboard; true when it got there. Injected so tests never touch the real one. */ +export type CopyText = (text: string) => Promise; function draw(state: SecretsScreenState): void { const width = process.stdout.columns || 80; @@ -118,32 +124,34 @@ async function loop( decryptAt: LocationDecryptor, edit: SecretsEditActions | undefined, bus: EventEmitter, - deploy?: SecretsDeployActions, + deploy: SecretsDeployActions | undefined, + copy: CopyText, ): Promise { if (state.quit) return state.exitText; draw(state); const { value } = await actions.next(); const [action] = value; - const next = (s: SecretsScreenState) => loop(s, actions, decryptAt, edit, bus, deploy); + const next = (s: SecretsScreenState) => loop(s, actions, decryptAt, edit, bus, deploy, copy); // A push in flight is never abandoned halfway: a quit signal waits for it. if (action.kind === 'quit') { // A quit signal while an edit runs is a stop request, like Ctrl-C: it never cuts a push in half. if (!isRunning(state.edit) && !isDeployRunning(state.deploy)) return next({ ...state, quit: true }); const stopped = handleKey(state, '\x03'); - if (stopped.effect) perform(stopped.effect, decryptAt, edit, bus, deploy); + if (stopped.effect) perform(stopped.effect, decryptAt, edit, bus, deploy, copy); return next(stopped.state); } if (action.kind === 'deployPlan') { // The plan is up; the row's value is fetched next (the details view's own fetch) for its `value` line. const planned = applyDeployPlan(state, action.loaded); const valueEffect = pendingDeployValueEffect(planned); - if (valueEffect) perform(valueEffect, decryptAt, edit, bus, deploy); + if (valueEffect) perform(valueEffect, decryptAt, edit, bus, deploy, copy); return next(planned); } if (action.kind === 'deployProgress') return next(applyDeployRunProgress(state, action.progress)); if (action.kind === 'deployRan') return next(applyDeployRunDone(state, action.finished)); + if (action.kind === 'copied') return next(applyCopied(state, action.ok)); if (action.kind === 'progress') return next(applyRunProgress(state, action.progress)); if (action.kind === 'resize') return next(state); if (action.kind === 'value') return next(applyValueResult(state, action.row, action.result)); @@ -151,18 +159,18 @@ async function loop( // The table is up now; BASE is read next, and the keys already work. const shown = applyRepos(state, action.result); const basesEffect = pendingBasesEffect(shown); - if (basesEffect) perform(basesEffect, decryptAt, edit, bus, deploy); + if (basesEffect) perform(basesEffect, decryptAt, edit, bus, deploy, copy); return next(shown); } if (action.kind === 'bases') { const filled = applyBases(state, action.bases); - if (filled.effect) perform(filled.effect, decryptAt, edit, bus, deploy); + if (filled.effect) perform(filled.effect, decryptAt, edit, bus, deploy, copy); return next(filled.state); } if (action.kind === 'ran') return next(applyRunDone(state, action.finished)); const { state: nextState, effect } = handleKey(state, action.key); - if (effect) perform(effect, decryptAt, edit, bus, deploy); + if (effect) perform(effect, decryptAt, edit, bus, deploy, copy); return next(nextState); } @@ -176,8 +184,17 @@ function perform( decryptAt: LocationDecryptor, edit: SecretsEditActions | undefined, bus: EventEmitter, - deploy?: SecretsDeployActions, + deploy: SecretsDeployActions | undefined, + copy: CopyText, ): void { + if (effect.type === 'copyToClipboard') { + // A rejected copy is a failed copy: the screen shows it, it never crashes the loop. + void copy(effect.text).then( + (ok) => bus.emit('action', { kind: 'copied', ok }), + () => bus.emit('action', { kind: 'copied', ok: false }), + ); + return; + } if (effect.type === 'loadDeployPlan') { const loaded = deploy ? deploy.loadPlan(effect.row) : Promise.resolve({ ok: false, code: 'UNAVAILABLE' }); void loaded.then((result) => bus.emit('action', { kind: 'deployPlan', loaded: result })); @@ -233,6 +250,8 @@ export async function runSecretsScreen( dryRun: boolean = false, /** The deploy flow's side effects (CAP-704); absent: the deploy keys do nothing useful. */ deploy?: SecretsDeployActions, + /** Puts text on the clipboard (the `c` key of the result screens); the real clipboard by default. */ + copy: CopyText = copyToClipboard, ): Promise { const bus = new EventEmitter(); // A single `data` chunk can carry more than one keypress (a paste, fast @@ -257,7 +276,7 @@ export async function runSecretsScreen( const exitText = await (async () => { try { - return await loop(initialSecretsScreenState(rows, dryRun), actions, decryptAt, edit, bus, deploy); + return await loop(initialSecretsScreenState(rows, dryRun), actions, decryptAt, edit, bus, deploy, copy); } finally { process.stdout.write(DISABLE_BRACKETED_PASTE + SHOW_CURSOR + EXIT_ALT_SCREEN); if (process.stdin.isTTY) process.stdin.setRawMode(false); diff --git a/tests/deploy/dokploy.test.ts b/tests/deploy/dokploy.test.ts index 7469bfa9..e14d490f 100644 --- a/tests/deploy/dokploy.test.ts +++ b/tests/deploy/dokploy.test.ts @@ -680,7 +680,8 @@ describe('dokploy — deploy', () => { expect(shadowedEnv.split('\n')).toContain(`${CAPY_OFF_MARKER}STRIPE_KEY=stale`); expect(shadowedEnv).not.toContain('\nSTRIPE_KEY=stale\n'); // Capy's own block carries the live value - expect(shadowedEnv).toContain(`STRIPE_KEY='${VALUES.STRIPE_KEY}'`); + // a plain-characters value is written bare (no quotes) + expect(shadowedEnv.split('\n')).toContain(`STRIPE_KEY=${VALUES.STRIPE_KEY}`); }); test('an existing Capy block is replaced, not duplicated', async () => { diff --git a/tests/deploy/dokployPlainDelivery.test.ts b/tests/deploy/dokployPlainDelivery.test.ts index e51e329d..03c99758 100644 --- a/tests/deploy/dokployPlainDelivery.test.ts +++ b/tests/deploy/dokployPlainDelivery.test.ts @@ -120,6 +120,52 @@ describe('formatDotenvValue — property: dotenv.parse(render(x)) === x', () => } }); + test('a value made only of plain characters is emitted bare, unquoted', () => { + const cases: ReadonlyArray = [ + ['180', '180'], + ['https://x.y/z', 'https://x.y/z'], + ['a=b', 'a=b'], + ['user@host:5432,other+1_2-3./x', 'user@host:5432,other+1_2-3./x'], + ]; + for (const [value, expected] of cases) { + const r = formatDotenvValue(value); + expect(r).toEqual({ ok: true, rendered: expected }); + expect(parseDotenv(`V=${expected}`).V).toBe(value); + } + }); + + test('a value that needs protection is still quoted: #, leading space, $, quote characters, empty', () => { + const cases: ReadonlyArray = [ + ['a#b', "'a#b'"], + [' lead', "' lead'"], + ['trail ', "'trail '"], + ['$HOME', "'$HOME'"], + ["'x'", '`\'x\'`'], + ['"x"', `'"x"'`], + ['a b', "'a b'"], + ['a\\b', "'a\\b'"], + ['a\nb', "'a\nb'"], + ['', "''"], + ]; + for (const [value, expected] of cases) { + const r = formatDotenvValue(value); + expect(r).toEqual({ ok: true, rendered: expected }); + expect(parseDotenv(`V=${r.ok ? r.rendered : ''}`).V).toBe(value); + } + }); + + test('property: any rendered output that does not start with a quote character matches the safe charset', () => { + const SAFE = /^[A-Za-z0-9_\-./:@,+=]+$/; + const values = [...ADVERSARIAL_VALUES, ...fuzzCases(0xbeef, 2000), '180', 'https://x.y/z', 'a=b', 'a#b', ' lead', '$HOME']; + const rendered = values.flatMap((v) => { + const r = formatDotenvValue(v); + return r.ok ? [r.rendered] : []; + }); + const bare = rendered.filter((r) => !["'", '`', '"'].includes(r[0] ?? '')); + expect(bare.length).toBeGreaterThan(0); + expect(bare.filter((r) => !SAFE.test(r))).toEqual([]); + }); + test('a value using all three quote characters, with a double-quote AND a literal backslash-n, refuses rather than writing lossy', () => { const value = "has ' and " + '\u0060' + ' and " and a literal \\n sequence'; const r = formatDotenvValue(value); diff --git a/tests/ui/secretsDeployFlow.test.ts b/tests/ui/secretsDeployFlow.test.ts index 62391f59..20b5ff7f 100644 --- a/tests/ui/secretsDeployFlow.test.ts +++ b/tests/ui/secretsDeployFlow.test.ts @@ -5,6 +5,7 @@ */ import { describe, test, expect, mock, spyOn } from 'bun:test'; import { + applyDeployCopied, applyDeployFinished, applyDeployPlanLoaded, applyDeployProgress, @@ -19,6 +20,7 @@ import { type DeployFlow, } from '../../src/ui/secretsDeployFlow'; import { + applyCopied, applyDeployPlan, applyDeployRunDone, applyDeployRunProgress, @@ -642,3 +644,136 @@ describe('the plan step through the screen and the driver', () => { expect(run.mock.calls[0][0].targets).toHaveLength(3); }); }); + +describe('the result screen: `c` copies the PR links', () => { + const target = (name: string) => ({ project: 'mono-backend', branch: 'production', target: name, provider: 'dokploy', repo: 'Acme/mono', path: 'backend', vars: 2 }); + const delivered = (name: string, prUrl: string | null) => ({ kind: 'delivered' as const, target: target(name), pr_url: prUrl, base: 'main', recorded: true }); + const URL_A = 'https://github.com/Acme/mono/pull/12'; + const URL_B = 'https://github.com/Acme/web/pull/7'; + // Two PRs, one target that needed none, one that failed (no link), and a repeat of the first PR. + const WITH_PRS: BatchResult = { + targets: [ + delivered('api', URL_A), + delivered('worker', null), + { kind: 'failed', target: target('cron'), code: 'UNAVAILABLE', stage: 'push', values_pushed: false }, + delivered('web', URL_B), + delivered('api-2', URL_A), + ], + skipped: [], + read_failed: [], + }; + const NO_PRS: BatchResult = { targets: [delivered('worker', null)], skipped: [], read_failed: [] }; + + const runningFlow = async (): Promise => { + const plan = await realPlan(); + return stepDeploy(applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow, '\r').flow as DeployFlow; + }; + async function doneWith(result: BatchResult): Promise> { + return applyDeployFinished(await runningFlow(), { ok: true, result }) as Extract; + } + + test('`c` asks for a copy of exactly the PR urls (newline-joined, once each) and keeps the screen; the footer offers it', async () => { + const done = await doneWith(WITH_PRS); + expect(done.prUrls).toEqual([URL_A, URL_B]); + for (const key of ['c', 'C']) { + const step = stepDeploy(done, key); + expect(step.effect).toEqual({ type: 'copyToClipboard', text: `${URL_A}\n${URL_B}` }); + expect(step.flow).toBe(done); + expect(step.exitText).toBeUndefined(); + } + expect(strip(renderDeploy(done, 120).footer)).toBe('c copy PRs · esc exit'); + }); + + test('Esc is still the only way out, and any other key still does nothing', async () => { + const done = await doneWith(WITH_PRS); + const left = stepDeploy(done, ESC); + expect(left.flow).toBeNull(); + expect(left.exitText).toBe(done.text); + for (const key of ['x', '\r', ' ', '\x03', 'q']) { + const stays = stepDeploy(done, key); + expect(stays.flow).toBe(done); + expect(stays.effect).toBeNull(); + } + }); + + test('with no PR: `c` does nothing and the footer does not offer it', async () => { + const done = await doneWith(NO_PRS); + expect(done.prUrls).toEqual([]); + const step = stepDeploy(done, 'c'); + expect(step.flow).toBe(done); + expect(step.effect).toBeNull(); + expect(strip(renderDeploy(done, 120).footer)).not.toContain('copy PRs'); + expect(strip(renderDeploy(done, 120).footer)).toContain('esc exit'); + // A failed run and a dry run have no links either. + const failed = applyDeployFinished(await runningFlow(), { ok: false, code: 'UNAVAILABLE' }) as DeployFlow; + expect(stepDeploy(failed, 'c').effect).toBeNull(); + expect(strip(renderDeploy(failed, 120).footer)).not.toContain('copy PRs'); + const plan = await realPlan(); + const dry = stepDeploy(applyDeployPlanLoaded(startDeploy(row).flow, { ok: true, plan }) as DeployFlow, '\r', true).flow as DeployFlow; + expect(stepDeploy(dry, 'c').effect).toBeNull(); + }); + + test('the outcome is shown on the screen: copied (with the count) or could not copy; the screen stays open', async () => { + const done = await doneWith(WITH_PRS); + const ok = applyDeployCopied(done, true) as Extract; + expect(strip(renderDeploy(ok, 120).lines.join('\n'))).toContain('Copied 2 PR links'); + const bad = applyDeployCopied(done, false) as Extract; + expect(strip(renderDeploy(bad, 120).lines.join('\n'))).toContain('Could not copy to clipboard'); + expect(bad.step).toBe('done'); + const one = applyDeployCopied(await doneWith(RESULT), true) as DeployFlow; + expect(strip(renderDeploy(one, 120).lines.join('\n'))).toContain('Copied 1 PR link'); + expect(strip(renderDeploy(one, 120).lines.join('\n'))).not.toContain('Copied 1 PR links'); + // The exit text is the result alone: the copy line is not part of what is printed after the screen. + expect(stepDeploy(ok, ESC).exitText).toBe(done.text); + // A late answer is dropped once the flow has moved on. + expect(applyDeployCopied(null, true)).toBeNull(); + }); + + test('through the screen reducer: `c` on the result gives the effect and the screen stays; the answer lands on the result', async () => { + const plan = await realPlan(); + const planned = applyDeployPlan(handleKey(initialSecretsScreenState([row], false), CTRL_D).state, { ok: true, plan }); + const finished = applyDeployRunDone(handleKey(planned, '\r').state, { ok: true, result: WITH_PRS }); + const pressed = handleKey(finished, 'c'); + expect(pressed.effect).toEqual({ type: 'copyToClipboard', text: `${URL_A}\n${URL_B}` }); + expect(pressed.state.quit).toBe(false); + expect(pressed.state.deploy?.step).toBe('done'); + expect(strip(render(applyCopied(pressed.state, true), 120, 30))).toContain('Copied 2 PR links'); + }); + + async function driveCopy(copy: (text: string) => Promise) { + const plan = await realPlan(); + const actions: SecretsDeployActions = { loadPlan: async () => ({ ok: true, plan }), run: async () => ({ ok: true, result: WITH_PRS }), cancel: () => undefined }; + const outSpy = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const finished = runSecretsScreen([row], async () => ({ ok: false, code: 'NO' }), undefined, false, actions, copy); + await wait(); + for (const k of [CTRL_D, 40, '\r', 40, 'c', 30, ESC]) { + if (typeof k === 'number') await wait(k); + else { + process.stdin.emit('data', Buffer.from(k)); + await wait(); + } + } + await finished; + const screen = outSpy.mock.calls.map((c) => String(c[0])).join(''); + outSpy.mockRestore(); + logSpy.mockRestore(); + return { screen }; + } + + test('the driver performs the effect with the injected clipboard function (never the real one) and draws the outcome', async () => { + const copy = mock(async (_text: string) => true); + const ok = await driveCopy(copy); + expect(copy.mock.calls.map((c) => c[0])).toEqual([`${URL_A}\n${URL_B}`]); + expect(strip(ok.screen)).toContain('Copied 2 PR links'); + + const failing = mock(async (_text: string) => false); + expect(strip((await driveCopy(failing)).screen)).toContain('Could not copy to clipboard'); + expect(failing.mock.calls).toHaveLength(1); + + const throwing = mock(async (_text: string): Promise => { + throw new Error('boom'); + }); + expect(strip((await driveCopy(throwing)).screen)).toContain('Could not copy to clipboard'); + }); +}); diff --git a/tests/ui/secretsEditFlow.test.ts b/tests/ui/secretsEditFlow.test.ts index 475e2145..1232dc6f 100644 --- a/tests/ui/secretsEditFlow.test.ts +++ b/tests/ui/secretsEditFlow.test.ts @@ -7,6 +7,7 @@ import { describe, test, expect } from 'bun:test'; import { SecretsScreenState, + applyCopied, applyRepos, applyRunDone, handleKey, @@ -401,6 +402,70 @@ until its PR is merged and pulled.`); expect(frame(done)).toContain('(UNAVAILABLE)'); expect(frame(done)).not.toContain(VALUE); }); + + describe('`c` copies the PR links from the result', () => { + const runningState = () => + handleKey(applyRepos(handleKey(atLocations(), ENTER).state, { ok: true, links: LINKS, bases: {} }), ENTER).state; + const URL_A = 'https://github.com/SlideSpeak/slidespeak-monorepo/pull/1'; + const URL_B = 'https://github.com/SlideSpeak/swot-analysis-generator-server/pull/15'; + const doneWith = (result: SecretSetResult) => applyRunDone(runningState(), { ok: true, result }); + + test('`c` asks for a copy of exactly the PR urls (newline-joined, once each), the screen stays, and the footer offers it', () => { + const done = doneWith({ + ...base, + prs: [pr('SlideSpeak/slidespeak-monorepo', 1), pr('SlideSpeak/swot-analysis-generator-server', 15), pr('SlideSpeak/slidespeak-monorepo', 1)], + failed: [{ kind: 'repo', repo: 'SlideSpeak/other', code: ERROR_CODES.KEEP_PR_CREATE_FAILED }], + }); + expect((flowOf(done) as { prUrls: readonly string[] }).prUrls).toEqual([URL_A, URL_B]); + expect(frame(done)).toContain('c copy PRs · esc exit'); + for (const key of ['c', 'C']) { + const pressed = handleKey(done, key); + expect(pressed.effect).toEqual({ type: 'copyToClipboard', text: `${URL_A}\n${URL_B}` }); + expect(pressed.state.quit).toBe(false); + expect(flowOf(pressed.state)).toBe(flowOf(done)); + } + }); + + test('Esc is still the only way out, and any other key still does nothing', () => { + const done = doneWith({ ...base, prs: [pr('SlideSpeak/slidespeak-monorepo', 1)] }); + for (const key of ['x', ENTER, ' ', 'q']) { + const stays = handleKey(done, key); + expect(stays.effect).toBeNull(); + expect(stays.state.quit).toBe(false); + expect(flowOf(stays.state).step).toBe('done'); + } + const left = press(done, ESC); + expect(left.quit).toBe(true); + expect(left.exitText).toContain(URL_A); + }); + + test('with no PR: `c` does nothing and the footer does not offer it (also after a failed run)', () => { + const none = doneWith({ ...base, prs: [], no_pr: [{ repo: 'SlideSpeak/slidespeak-monorepo', reason: 'NO_DIFF_VS_BASE' }] }); + const failed = applyRunDone(runningState(), { ok: false, code: 'UNAVAILABLE' }); + for (const done of [none, failed]) { + const pressed = handleKey(done, 'c'); + expect(pressed.effect).toBeNull(); + expect(pressed.state.quit).toBe(false); + expect(flowOf(pressed.state)).toBe(flowOf(done)); + expect(frame(done)).not.toContain('copy PRs'); + expect(frame(done)).toContain('esc exit'); + } + }); + + test('the outcome is shown on the screen: copied (with the count) or could not copy; the screen stays open', () => { + const done = doneWith({ ...base, prs: [pr('SlideSpeak/slidespeak-monorepo', 1), pr('SlideSpeak/swot-analysis-generator-server', 15)] }); + expect(frame(applyCopied(done, true))).toContain('Copied 2 PR links'); + expect(frame(applyCopied(done, false))).toContain('Could not copy to clipboard'); + expect(flowOf(applyCopied(done, false)).step).toBe('done'); + const one = doneWith({ ...base, prs: [pr('SlideSpeak/slidespeak-monorepo', 1)] }); + expect(frame(applyCopied(one, true))).toContain('Copied 1 PR link'); + expect(frame(applyCopied(one, true))).not.toContain('Copied 1 PR links'); + // The exit text is the confirmation alone: the copy line is not part of what is printed after the screen. + expect(press(applyCopied(one, true), ESC).exitText).toBe(press(one, ESC).exitText); + // A late answer on another screen is dropped. + expect(applyCopied(start(), true).edit).toBeNull(); + }); + }); }); describe('existing hotkeys are unchanged', () => { diff --git a/tests/ui/secretsScreenDriverEdit.test.ts b/tests/ui/secretsScreenDriverEdit.test.ts index a297f6a1..be0e0169 100644 --- a/tests/ui/secretsScreenDriverEdit.test.ts +++ b/tests/ui/secretsScreenDriverEdit.test.ts @@ -4,7 +4,7 @@ * `runSet`) performed by injected actions. Nothing real: no TTY, no service, no gh. */ import { describe, test, expect, mock, spyOn } from 'bun:test'; -import { runSecretsScreen, type SecretsEditActions } from '../../src/ui/secretsScreenDriver'; +import { runSecretsScreen, type CopyText, type SecretsEditActions } from '../../src/ui/secretsScreenDriver'; import type { SecretIndexRow } from '../../src/service/serviceClient'; import { hashValue } from '../../src/commands/statusCommand'; import type { LocationDecryptor } from '../../src/ui/secretsScreen'; @@ -30,10 +30,11 @@ async function drive( keys: ReadonlyArray, inRows: readonly SecretIndexRow[] = rows, decrypt: LocationDecryptor = async () => ({ ok: false, code: 'NO' }), + copy?: CopyText, ) { const outSpy = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); const logSpy = spyOn(console, 'log').mockImplementation(() => {}); - const done = runSecretsScreen(inRows, decrypt, actions); + const done = runSecretsScreen(inRows, decrypt, actions, false, undefined, copy); await wait(); for (const k of keys) { if (typeof k === 'number') await wait(k); @@ -108,6 +109,48 @@ describe('runSecretsScreen with the edit flow', () => { }); }); +describe('`c` on the result screen, through the real driver', () => { + const URL = 'https://github.com/Acme/repo-p1/pull/3'; + const actions: SecretsEditActions = { + loadRepos: async (ids) => ({ + ok: true as const, + links: ids.map((id) => ({ project_id: id, project_name: id, host: 'github.com', owner: 'Acme', name: `repo-${id}`, path: '.', github_repo_id: 1, last_seen_at: '' })), + }), + loadBases: async () => ({ 'github.com/acme/repo-p1': 'main', 'github.com/acme/repo-p2': 'main' }), + run: async (request) => ({ + ok: true, + result: { + name: request.name, + updated: request.locations.map((l) => ({ project: l.project_name, branch: l.branch, protected: l.protected })), + unchanged: [], + prs: [{ repo: 'Acme/repo-p1', url: URL, base: 'main', keep_lock_paths: ['keep.lock'], keep_lock_diverged: false, locations: [] }], + no_pr: [], + failed: [], + }, + }), + }; + // Ctrl+E, the value, Enter, Enter (all locations), wait for repos, Enter (run), wait, `c`, wait, Esc. + const keys = ['\x05', 'v', '\r', '\r', 40, '\r', 40, 'c', 30, ESC] as const; + + test('the effect calls the injected clipboard function with the PR urls; the screen stays open and draws the outcome', async () => { + const copy = mock(async (_text: string) => true); + const { screen } = await drive(actions, keys, rows, undefined, copy); + expect(copy.mock.calls.map((c) => c[0])).toEqual([URL]); + expect(screen.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')).toContain('Copied 1 PR link'); + }); + + test('a clipboard that fails (or throws) is shown as could-not-copy, never a crash', async () => { + const failing = mock(async (_text: string) => false); + const failed = await drive(actions, keys, rows, undefined, failing); + expect(failing.mock.calls).toHaveLength(1); + expect(failed.screen).toContain('Could not copy to clipboard'); + const throwing = mock(async (_text: string): Promise => { + throw new Error('boom'); + }); + expect((await drive(actions, keys, rows, undefined, throwing)).screen).toContain('Could not copy to clipboard'); + }); +}); + describe('Ctrl+R in the dialog, through the real driver', () => { const OLD = 'SENTINEL-driver-old-value-77'; const oldRows: readonly SecretIndexRow[] = [{ ...rows[0], value_hash: hashValue(OLD) }];