diff --git a/README.md b/README.md index c3b6056e..bc7a4c86 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Good when you want to see drift across the whole branch, edit one variable witho | [`capy run -- `](https://docs.capy.sc/cli/run) | Run a command with decrypted secrets injected. | | [`capy edit`](https://docs.capy.sc/cli/edit) | Inspect and edit secrets in an interactive TUI. | | [`capy status`](https://docs.capy.sc/cli/status) | Show drift between local, pinned, and remote. | -| [`capy push`](https://docs.capy.sc/cli/push) | Push local changes without pulling. | +| [`capy push`](https://docs.capy.sc/cli/push) | Push local changes. Runs the same sync as `capy`, so any difference is settled first. | | [`capy lock`](https://docs.capy.sc/cli/lock) | Lock the local key so the passphrase is required next time. | | [`capy deploy`](https://docs.capy.sc/cli/deploy) | Generate a deploy token and walk through platform setup. | | [`capy connect `](https://docs.capy.sc/cli/connect) | Pull a credential from a provider into `.env`. | @@ -177,6 +177,21 @@ Good when you want to see drift across the whole branch, edit one variable witho `capy` is a three-way diff between your local `.env`, the last pinned snapshot in `keep.lock`, and the latest on the service. Conflicts open an interactive resolver. See [docs.capy.sc/using/syncing-secrets](https://docs.capy.sc/using/syncing-secrets). +### Conflicts and agents + +No command overwrites a value someone else changed since you last saw it. Every command that writes from a folder (`capy edit`, `add`, `remove`, `connect`, `rotate`, `push`, `deploy`, `org`, and `checkout` of an existing branch) runs a plain `capy` first. It repeats until your folder and Capy agree on every variable. If you cancel, nothing is written. Only then does the command apply its own change on top of Capy's latest. Every difference blocks the write, including ones unrelated to the variable you are changing. Exceptions: `capy checkout -b`, `capy decrypt` (and checkout while recovering from a seed phrase), new projects, and the org system store. + +For each difference you choose one of four: `pinned` (the folder's `keep.lock`), `local` (your `.env`), `remote` (Capy's latest), or `delete`. + +Off a terminal (`--non-tty`, `--json`, an agent, CI) nobody can be asked. The command writes nothing, exits **3**, and prints JSON on stdout with `"code": "SECRETS_CONFLICT"`, a `conflict_id`, and one row per difference (name, project, branch, and fingerprints of the pinned, local and remote values, never the values). Ask the person, then rerun the same command once with `--resolve NAME=pinned|local|remote|delete` for every row and `--conflict `. A Dokploy platform value is answered as `NAME@TARGET=local|remote`. If anything moved in between, the command exits 3 again with `SECRETS_CONFLICT_STALE` and the fresh rows. There is no resolve-all, and an answer naming a source that has no value for that row is refused (only `delete` deletes). + +```bash +capy push --non-tty # exit 3: SECRETS_CONFLICT, conflict_id c_... +capy push --non-tty --resolve API_KEY=local --conflict c_9f2e41a07b3d +``` + +The `capy secrets` edit screens and `capy secrets set` do the same check against the value you were shown. Uploads also carry the version they started from, and the server refuses a stale one. The CLI syncs again and retries once. + ## Running your app `capy run -- ` decrypts `.env` in memory and spawns your command with the values set as environment variables. Works in any runtime that reads env vars. See [docs.capy.sc/using/running-your-app](https://docs.capy.sc/using/running-your-app). @@ -185,6 +200,8 @@ Good when you want to see drift across the whole branch, edit one variable witho `capy deploy` walks through Vercel, Cloudflare, Docker, Fly, Railway, Render, Heroku, GitHub Actions, and AWS Lambda. See [docs.capy.sc/using/deploying](https://docs.capy.sc/using/deploying). +`capy deploy` runs a plain `capy` first, so it only ships values your folder and Capy agree on. For Dokploy, if a service's live value was changed outside Capy since the last delivery, you choose `local` (deliver Capy's value) or `remote` (keep the platform's value and skip it). Batch deploy from `capy secrets` delivers only the variables you picked. + ## Team `capy invite ` to add a teammate; `capy kick ` to remove one. Invite codes travel out-of-band; kicks are O(1) with no key rotation. See [docs.capy.sc/using/team/inviting](https://docs.capy.sc/using/team/inviting). @@ -264,7 +281,7 @@ Not currently. The service component is closed. If self-hosting matters for your
Does it work in CI? -Yes. `capy deploy` generates `SECRETS_BLOB` and `PROJECT_KEY` to set as CI env vars; `capy run` in your build/test step does the rest. See the GitHub Actions guide at [docs.capy.sc/using/deploying/github-actions](https://docs.capy.sc/using/deploying/github-actions). +Yes. `capy deploy` generates `SECRETS_BLOB` and `PROJECT_KEY` to set as CI env vars; `capy run` in your build/test step does the rest. `capy deploy --yes` in CI exits 3 instead of proceeding if the folder and Capy differ on any variable; see [Conflicts and agents](#conflicts-and-agents). See the GitHub Actions guide at [docs.capy.sc/using/deploying/github-actions](https://docs.capy.sc/using/deploying/github-actions).
## Supply chain diff --git a/VERSION b/VERSION index 8225a4ba..583b27ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.11 +0.9.12 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b5ce7758..94f9aac0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,7 +2,7 @@ # capy CLI reference -Version `0.9.11`. Generated from `capy help --json`. +Version `0.9.12`. Generated from `capy help --json`. ## Commands @@ -48,6 +48,8 @@ capy edit [name] [options] | `--pr` | create a PR with the keep.lock change (answers the prompt) | | | `--no-pr` | do not create a PR with the keep.lock change | | | `--pr-base ` | base branch for the PR (answers the prompt) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -82,6 +84,9 @@ capy checkout [options] | `--protected` | Mark as a protected branch (invite-only) | | | `--no-protected` | Create it open to the project | | | `--json` | emit machine-readable JSON instead of the human UI | | +| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the checkout with the conflict_id (agents/CI) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -90,10 +95,14 @@ JSON support: yes (`--json`) Push encrypted values to Keep ``` -capy push +capy push [options] ``` -_No options._ +| Option | Description | Default | +|---|---|---| +| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the push with the conflict_id (agents/CI) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -108,7 +117,7 @@ capy deploy [target] [options] | Option | Description | Default | |---|---|---| | `--target ` | adapter id; requires --yes (CI mode) | | -| `--yes` | skip all prompts (CI) | | +| `--yes` | skip all prompts (CI); any difference between the folder and Capy stops the deploy with exit 3 | | | `--dry-run` | preflight + show plan, push nothing (target mode) | | | `--force` | redeploy even when keep.lock is unchanged — bumps keep.lock to trigger CI | | | `--edit` | re-enter the picker for an existing target | | @@ -123,6 +132,8 @@ capy deploy [target] [options] | `--plan ` | dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`) | | | `--confirm ` | dokploy --discover: write the plan that --dry-run printed (one PR per repo) | | | `--base-url ` | dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -142,10 +153,14 @@ Find the Dokploy services that match Capy projects, check a plan for them, and w Revoke a deploy token ``` -capy deploy revoke +capy deploy revoke [options] ``` -_No options._ +| Option | Description | Default | +|---|---|---| +| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the revoke with the conflict_id (agents/CI) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -184,6 +199,9 @@ capy deploy targets-remove [options] | Option | Description | Default | |---|---|---| | `--no-deploy` | strip the config but skip the redeploy that would apply the revert | | +| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the removal with the conflict_id (agents/CI) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -452,10 +470,14 @@ JSON support: yes (`--json`) Switch organization ``` -capy org +capy org [options] ``` -_No options._ +| Option | Description | Default | +|---|---|---| +| `--non-tty` | treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -551,6 +573,8 @@ capy secrets set [options] | `--no-pr-for ` | do not open a PR in this repo (repeatable) | `[]` | | `--no-pr` | do not open any PR | | | `--confirm ` | run the plan that --dry-run printed (required for a real run) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -571,6 +595,8 @@ capy secrets deploy [options] | `--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) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -653,6 +679,8 @@ capy add [options] | `--pr` | create a PR with the keep.lock change (answers the prompt) | | | `--no-pr` | do not create a PR with the keep.lock change | | | `--pr-base ` | base branch for the PR (answers the prompt) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -674,6 +702,8 @@ capy remove [options] | `--pr` | create a PR with the keep.lock change (answers the prompt) | | | `--no-pr` | do not create a PR with the keep.lock change | | | `--pr-base ` | base branch for the PR (answers the prompt) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -705,6 +735,8 @@ capy connect [provider] [options] | `-y, --yes` | dokploy import/discover: skip the confirmation prompt (import: --overwrite's clear/replace/import ask) — discover never prompts and needs --yes to write | | | `--overwrite` | dokploy import: set the branch's vars to EXACTLY Dokploy's set — clear names not in Dokploy, replace differing values, import new ones | | | `--environment ` | dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -726,6 +758,8 @@ capy rotate [var] [options] | `--skip-prompts` | alias for --yes | | | `--non-tty` | never prompt; resolve choices from flags or fail fast (agents/CI) | | | `--provider ` | integration to promote an unmanaged var through (non-interactive) | | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -830,6 +864,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `PROJECT_NOT_FOUND` - `BRANCH_NOT_FOUND` - `NO_ACTIVE_BRANCH` +- `BRANCH_STATE_INCONSISTENT` - `SNAPSHOT_NOT_FOUND` - `NO_SECRETS` - `DEPLOY_TOKEN_NOT_FOUND` @@ -878,8 +913,13 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `PAIR_NO_KEYS` - `PAIR_LOCAL_KEY_CONFLICT` - `VAR_NOT_FOUND` -- `REMOVE_LOCAL_DRIFT` - `REMOVE_NEEDS_TTY` +- `SECRETS_STALE_BASE` +- `SECRETS_CONFLICT` +- `SECRETS_CONFLICT_STALE` +- `SECRETS_RESOLVE_INVALID` +- `SECRETS_RESOLVE_NEEDS_CONFLICT` +- `REMOVE_LOCAL_DRIFT` - `KEEP_PR_NOT_GIT_REPO` - `KEEP_PR_NO_GITHUB_REMOTE` - `KEEP_PR_GH_UNAVAILABLE` @@ -931,3 +971,4 @@ Every refusal carries a stable `code` — branch on it, never on message text. - **JSON**: Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON. - **Codes**: Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice. +- **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all. diff --git a/package.json b/package.json index 1157f150..ecdebd9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@capysc/cli", - "version": "0.9.11", + "version": "0.9.12", "description": "Capy CLI - SecretOps for the AI age", "main": "dist/index.js", "bin": { diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index fcfd3513..ff3cc3a2 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -2,6 +2,8 @@ import { CapyError, ERROR_CODES } from '../types'; import { resolveContext, writeAndSync, type ResolvedContext } from './connectors/shared'; import { DRY_RUN_UNSUPPORTED_MESSAGES, MAX_PIPED_BYTES, readPipedValue, refuseDryRunUnsupported, refuseInvalidName, refusePiped } from './pipedValue'; import { runPipedWrite, variableExists } from './pipedWrite'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import { recordsForWrite, refuseBadPrFlags, @@ -26,10 +28,17 @@ export interface AddOpts { pr?: PrFlags; /** The global `--dry-run`: a piped add previews; an interactive add refuses (`DRY_RUN_UNSUPPORTED`). */ dryRun?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ + conflictFlags?: RawConflictFlags; } const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** What bare `capy` needs to run as this command's first step. */ +function gateOptionsOf(opts: AddOpts, devMode: boolean): GateOptions { + return { devMode, json: opts.json === true, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun === true }; +} + /** * Piped mode is "a value arrives on stdin". It is decided by stdin alone. * `--non-tty` with a real terminal on stdin is NOT piped (nothing was piped, @@ -89,7 +98,12 @@ export class AddCommand { } } - const ctx = await resolveContext({ devMode: this.devMode }); + // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). + const gate = gateOptionsOf(opts, this.devMode); + conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); + await gateFirst(gate); + + const ctx = await resolveContext({ devMode: this.devMode, gate }); const push = opts.noPush !== true; const existing = names.filter((n) => n in ctx.localPlaintext); @@ -137,6 +151,8 @@ export class AddCommand { const [name] = names; // The argument is not echoed: a `NAME=value` typed by mistake must not be printed back. if (!VAR_RE.test(name)) return refuseInvalidName(json); + // A bad flag is refused before stdin is read or anything is authenticated. + conflictFlagsOrExit(opts.conflictFlags ?? {}, json); const piped = await readPipedValue(process.stdin, MAX_PIPED_BYTES); if (!piped.ok) return refusePiped(json, piped.code, piped.error); @@ -148,7 +164,8 @@ export class AddCommand { command: 'add', pr: opts.pr, dryRun: opts.dryRun === true, - gate: (ctx) => + gate: gateOptionsOf(opts, this.devMode), + refuseWhen: (ctx) => variableExists(ctx, name) && opts.force !== true ? { code: ERROR_CODES.ADD_VAR_EXISTS, diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index c46d87ad..9062640c 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -1,7 +1,7 @@ import ora from '../ui/spinner'; import { ProjectManager } from '../core/projectManager'; import { FileManager } from '../files/fileManager'; -import { AuthService } from '../auth/authService'; +import { AuthService, silentAuthFailureMessage } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { SyncEngine } from '../sync/syncEngine'; import { PromptEngine } from '../ui/promptEngine'; @@ -43,7 +43,18 @@ import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { isMembershipRevokedError } from '../errors/membershipRevoked'; import { cleanupOrgData } from '../cleanup/orgCleanup'; -import { compareSecrets, hashValue, formatSnippet } from './statusCommand'; +import { formatSnippet } from './statusCommand'; +import { compareSecrets, hashValue, type DiffResult } from '../sync/compareSecrets'; +import { baseHashOf, changesToMatch, commitFolder, envBlobOf, isStaleBase, keepForEnv, pinnedHashes, snapshotFrom, type Snapshot } from '../sync/commit'; +import { + conflictFlagsOrExit, + conflictRefusalFor, + exitOnConflictRefusal, + gateConflicts, + parseConflictFlags, + sourceRowsOf, + type ConflictView, +} from '../sync/conflicts'; import { ACCENT } from '../ui/colors'; import { installSyncHooks } from '../git/syncHooks'; import { initProjectQuestion } from '../ui/projectQuestions'; @@ -82,6 +93,18 @@ export async function listOrgProjectsOrUnavailable( } } +/** The menu's stand-in when the answers come from `--resolve` instead of a person. */ +const PROTOCOL_ACTION = 'protocol'; + +/** What one pass of bare `capy`'s sync did. */ +export type SyncOutcome = + /** Folder, `keep.lock` and Capy agree on every variable: nothing to show, nothing moved. */ + | 'up-to-date' + /** The person (or the protocol's answers) settled the differences: the folder (and, for "commit", Capy) moved. */ + | 'changed' + /** The person cancelled or chose "Continue working": nothing was written. */ + | 'stopped'; + export class CapyCommand { private projectManager: ProjectManager; private fileManager: FileManager; @@ -143,28 +166,36 @@ export class CapyCommand { } } + /** + * Off a terminal (or given `--resolve`) nobody can answer the menu: the differences are reported with a + * `conflict_id` and the rerun's `--resolve` answers decide them (sync/conflicts.ts). Prose goes to stderr so + * stdout stays free for the payload. A caller at a terminal leaves this unset and gets the menu. + */ + private get protocol(): boolean { + return this.options.protocol === true; + } + + /** What bare `capy` says: stdout for a person, stderr when stdout belongs to the protocol's payload. */ + private say(line: string): void { + if (this.protocol) console.error(line); + else console.log(line); + } + async execute(): Promise { + // A bad --resolve / --conflict is refused before anything is read. + conflictFlagsOrExit(this.options.conflictFlags ?? {}, false); try { - // Detect project state - const projectState = await this.projectManager.detectProjectState(); - - if (!projectState.initialized) { - // Check if .env has metadata we can recover from (e.g. keep.lock was deleted) - const envMeta = this.fileManager.readEnvMeta(this.options.envPath); - if (envMeta.org_id && envMeta.project_id) { - projectState.initialized = true; - projectState.organizationId = envMeta.org_id; - projectState.projectId = envMeta.project_id; - projectState.activeBranch = envMeta.branch ?? null; - } else if (isLocalOnly()) { + const projectState = await this.detectOrRecoverProjectState(); + + if (projectState === undefined) { + if (isLocalOnly()) { // Local-only mode: bootstrap a project entirely on this machine // (synthetic org, generated projectId) instead of server onboarding. await this.initializeProjectLocal(); return; - } else { - await this.initializeProject(); - return; } + await this.initializeProject(); + return; } await this.syncProject(projectState); @@ -183,11 +214,38 @@ export class CapyCommand { printExpiryWarnings(); } catch (error: any) { this.debugError('execute caught error', error); + // A conflict off a terminal is the protocol's answer, not a failure: exit 3 with the payload on stdout. + exitOnConflictRefusal(error); const { displayErrorAndExit } = await import('../ui/errorScreen'); await displayErrorAndExit(error); } } + /** + * Bare `capy`'s sync as a callable step: the same code `capy` runs, not a copy (sync/gate.ts runs it as the + * first step of every command that writes from a folder). A folder that is not a Capy project yet is left + * alone: the command that called it refuses that on its own. + */ + async runSync(): Promise { + const projectState = await this.detectOrRecoverProjectState(); + return projectState === undefined ? 'up-to-date' : this.syncProject(projectState); + } + + /** + * The project this folder is: keep.lock's, or, when keep.lock was deleted, the one the `.env` metadata names + * (org, project, branch). `undefined`: not a Capy project yet. The one step `execute()` and `runSync()` share, so the + * gate syncs a folder exactly as bare `capy` would. + */ + private async detectOrRecoverProjectState(): Promise { + const detected = await this.projectManager.detectProjectState(); + if (detected.initialized) return detected; + // Check if .env has metadata we can recover from (e.g. keep.lock was deleted) + const envMeta = this.fileManager.readEnvMeta(this.options.envPath); + return envMeta?.org_id && envMeta.project_id + ? { ...detected, initialized: true, organizationId: envMeta.org_id, projectId: envMeta.project_id, activeBranch: envMeta.branch ?? null } + : undefined; + } + /** * Resolve the branch this run operates on. Local signals first — the .env * header (what the secrets on disk were actually encrypted for) outranks @@ -236,6 +294,14 @@ export class CapyCommand { listBranches: () => this.serviceClient.listBranches(projectState.projectId!), syncedBranches: syncedBranchNames(this.projectManager.readSyncState()), promptPick: async (branches, defaultName) => { + // Off a terminal nobody can pick: the run ends with a code and the command that records the choice. + if (this.protocol) { + throw new CapyError( + `No branch is checked out in this directory. Run \`capy checkout \` first (branches: ${branches.map((b) => b.name).join(', ')}).`, // COPY-FLAG + ERROR_CODES.NO_ACTIVE_BRANCH, + { branches: branches.map((b) => b.name) }, + ); + } const grey = (s: string) => `\x1b[90m${s}\x1b[0m`; console.log('\nNo branch is checked out in this directory yet.'); const { selected: pick } = await inquirer.prompt([{ @@ -272,18 +338,7 @@ export class CapyCommand { ...branchesFromKeep(this.safeReadKeep()), ...syncedBranchNames(this.projectManager.readSyncState()), ]); - let fileBranchIsReal = knownLocally.has(fileBranch); - if (!fileBranchIsReal && !localMode) { - try { - const branches = await this.serviceClient.listBranches(projectState.projectId!); - fileBranchIsReal = branches.some(b => b.name === fileBranch); - } catch (err) { - // Offline: can't verify. Both files exist, so treat the conflict as - // genuine rather than silently discarding one side. - this.debugError('listBranches failed during conflict reconciliation', err); - fileBranchIsReal = true; - } - } + const fileBranchIsReal = knownLocally.has(fileBranch) || (!localMode && (await this.branchExistsOnServer(projectState, fileBranch))); if (!fileBranchIsReal) { console.log(`Ignoring stale .capy/branch (${B(fileBranch)} is not a branch in this project); staying on ${B(envBranch)}.`); @@ -291,6 +346,18 @@ export class CapyCommand { return envBranch; } + // Bare `capy` as another command's first step must not end the process: `capy checkout ` is the recovery + // below, and it runs this first (so exiting would loop); and one folder of a multi-folder run must not end the + // run. The header outranks `.capy/branch`; a folder run reports it as a coded error instead. + if (this.options.onBranchMismatch === 'follow-env-header') return envBranch; + if (this.options.onBranchMismatch === 'throw') { + throw new CapyError( + `Local state is inconsistent: .capy/branch says ${fileBranch}, .env was encrypted for ${envBranch}. Run \`capy checkout ${envBranch}\` in this folder.`, // COPY-FLAG + ERROR_CODES.BRANCH_STATE_INCONSISTENT, + { env_branch: envBranch, file_branch: fileBranch }, + ); + } + console.error(`\nLocal state is inconsistent:`); console.error(` .capy/branch says ${B(fileBranch)}`); console.error(` .env was encrypted for ${B(envBranch)}`); @@ -300,6 +367,20 @@ export class CapyCommand { process.exit(1); } + /** + * Whether the server lists a branch called `name`. Offline it cannot be verified: both files exist, so the + * conflict is treated as genuine (true) rather than silently discarding one side. + */ + private async branchExistsOnServer(projectState: ProjectState, name: string): Promise { + try { + const branches = await this.serviceClient.listBranches(projectState.projectId!); + return branches.some(b => b.name === name); + } catch (err) { + this.debugError('listBranches failed during conflict reconciliation', err); + return true; + } + } + /** keep.lock contents, or null when absent or corrupt (corruption is reported by the paths that need it). */ private safeReadKeep(): KeepFile | null { try { @@ -912,17 +993,19 @@ export class CapyCommand { const fetchSpinner = ora(`Pulling ${project.name} (${branch})...`).start(); - let decryptData; - try { - decryptData = await this.serviceClient.getDecryptData( - project.id, - branch, - undefined, // ask for latest - true, - ); - } catch (err: any) { - // 404 with "No secrets" → empty project, write a stub keep.lock and exit - if (err instanceof CapyError && err.details?.status === 404 && /No secrets/i.test(err.message)) { + const fetched = await this.serviceClient.getDecryptData( + project.id, + branch, + undefined, // ask for latest + true, + ).then( + (data) => ({ ok: true as const, data }), + (err: any) => ({ ok: false as const, err }), + ); + if (!fetched.ok) { + const err = fetched.err; + // 404 coded NO_SECRETS (minted once, in serviceClient's classifyResponse) → empty project, write a stub keep.lock and exit + if (err instanceof CapyError && err.details?.status === 404 && err.code === ERROR_CODES.NO_SECRETS) { fetchSpinner.stop(); const stub: KeepFile = { version: '3.0', @@ -942,6 +1025,7 @@ export class CapyCommand { fetchSpinner.fail(`Failed to pull from ${B(project.name)}.`); throw err; } + const decryptData = fetched.data; if (!decryptData.keep_file) { // No keep_file means the project exists but has never been pushed to. @@ -964,25 +1048,26 @@ export class CapyCommand { } // Parse the keep.json the server sent us - const serverKeep = JSON.parse(decryptData.keep_file) as KeepFile; // Make sure project metadata is consistent (server's keep.json may have // been written before project_name existed in the schema) - serverKeep.org_id = orgId; - serverKeep.project_id = project.id; - serverKeep.project_name = project.name; + const serverKeep: KeepFile = { + ...(JSON.parse(decryptData.keep_file) as KeepFile), + org_id: orgId, + project_id: project.id, + project_name: project.name, + }; // Decrypt the env blob into plaintext - const plaintext: Record = {}; - if (decryptData.env_content) { - const encrypted = this.fileManager.parseEnvContent(decryptData.env_content); - for (const [key, value] of Object.entries(encrypted)) { + const plaintext: Record = Object.fromEntries( + Object.entries(decryptData.env_content ? this.fileManager.parseEnvContent(decryptData.env_content) : {}).flatMap(([key, value]) => { try { - plaintext[key] = this.fileManager.decryptValue(value, encryptionKey); + return [[key, this.fileManager.decryptValue(value, encryptionKey)] as const]; } catch { // Skip undecryptable (user lacks variable-level permission) + return []; } - } - } + }), + ); // Write keep.lock + encrypted .env locally this.fileManager.writeKeepFile(serverKeep); @@ -1053,9 +1138,9 @@ export class CapyCommand { [58, 85, 85], // #3a5555 ]; const len = s.replace(/ /g, '').length; - let charIdx = 0; - return s.split('').map((ch) => { + return s.split('').map((ch, position) => { if (ch === ' ') return ch; + const charIdx = s.slice(0, position).replace(/ /g, '').length; const t = len > 1 ? charIdx / (len - 1) : 0; // Interpolate between gradient stops const segment = t * (stops.length - 1); @@ -1066,7 +1151,6 @@ export class CapyCommand { const r = Math.round(a[0] + (b[0] - a[0]) * f); const g = Math.round(a[1] + (b[1] - a[1]) * f); const bl = Math.round(a[2] + (b[2] - a[2]) * f); - charIdx++; return `\x1b[38;2;${r};${g};${bl}m${ch}\x1b[0m`; }).join(''); }; @@ -1099,7 +1183,7 @@ export class CapyCommand { console.log(grey('\u250c' + '\u2500'.repeat(maxLen) + '\u2510')); const totalRows = Math.max(info.length, capy.length); - for (let i = 0; i < totalRows; i++) { + Array.from({ length: totalRows }, (_, i) => i).forEach((i) => { const left = i < info.length ? info[i] : ''; const right = i < capy.length ? capy[i] : ''; const leftPad = infoWidth - stripAnsi(left).length; @@ -1123,13 +1207,13 @@ export class CapyCommand { return `${bg}\x1b[38;2;${r};${g};${b}m${ch}\x1b[0m`; }).join(''); console.log(`${grey('\u2502')} ${left}${' '.repeat(leftPad)}${' '.repeat(gap)}${furry(right, i)}${' '.repeat(rightPad + 1)}${grey('\u2502')}`); - } + }); console.log(grey('\u2514' + '\u2500'.repeat(maxLen) + '\u2518')); console.log(''); } - private async syncProject(projectState: ProjectState): Promise { + private async syncProject(projectState: ProjectState, retried: boolean = false): Promise { this.debug('syncProject start', { initialized: projectState.initialized, organizationId: projectState.organizationId, @@ -1149,12 +1233,15 @@ export class CapyCommand { const { authResult, branch } = await (async (): Promise<{ authResult: AuthResult; branch: string }> => { if (localMode) { const localBranch = await this.resolveActiveBranch(projectState, true); - this.displayHeader( - projectState.projectName || 'local project', - 'local (this machine only)', - 'local', - localBranch, - ); + // The banner is for a person: answering by protocol leaves stdout free for the payload (CAP-717). + if (!this.protocol) { + this.displayHeader( + projectState.projectName || 'local project', + 'local (this machine only)', + 'local', + localBranch, + ); + } return { authResult: { success: true, user_id: LOCAL_USER_ID }, branch: localBranch }; } @@ -1164,7 +1251,7 @@ export class CapyCommand { } // Authenticate — try silent first, then interactive if needed. - const spinner = ora('Authenticating...').start(); + const spinner = ora('Authenticating...', this.options.quiet === true).start(); const result = await (async (): Promise => { const silent = await this.authService.authenticateSilent(projectState.organizationId); if (silent.success) return silent; @@ -1185,6 +1272,12 @@ export class CapyCommand { { detail: refreshFailure.detail } ); } + // A command that runs this as its first step and has no one to sign in (piped, --json, an agent) is told why instead + // of being sent to a browser. + if (this.options.silentAuthOnly === true) { + spinner.fail('Authentication failed'); + throw new CapyError(silentAuthFailureMessage(silentAny), ERROR_CODES.AUTH_FAILED); + } if (refreshFailure?.reason === 'session_ended') { // Say why the browser is about to open instead of silently bouncing. spinner.text = 'Session expired — opening your browser to sign in again...'; @@ -1208,8 +1301,8 @@ export class CapyCommand { ); } - // Persist user ID to sync state immediately - if (result.user_id) { + // Persist user ID to sync state immediately (a dry run changes nothing, not even that) + if (result.user_id && this.options.dryRun !== true) { this.projectManager.writeSyncStateUserId(result.user_id); } @@ -1224,12 +1317,14 @@ export class CapyCommand { || (result.organizations?.length === 0 ? 'not yet created' : result.organization_id) || 'not yet created'; - this.displayHeader( - projectState.projectName || 'not yet created', - orgName, - result.user_first_name || result.user_email || '', - resolvedBranch, - ); + if (!this.protocol) { + this.displayHeader( + projectState.projectName || 'not yet created', + orgName, + result.user_first_name || result.user_email || '', + resolvedBranch, + ); + } const token = this.authService.getToken(); if (!token) { @@ -1280,13 +1375,7 @@ export class CapyCommand { variables: Object.keys(initialKeep.variables), } : 'NOT FOUND'); - const rebuildPinned = (keep: KeepFile | null): Record => - Object.fromEntries( - Object.entries(keep?.variables ?? {}) - .map(([varName, entries]) => [varName, entries.find(e => e.branch === branch)?.value_hash]) - .filter((pair): pair is [string, string] => pair[1] !== undefined), - ); - const pinned = rebuildPinned(initialKeep); + const pinned = pinnedHashes(initialKeep, branch); this.debug('pinned', pinned); // Read local .env and compute hashes. A read/decrypt failure that isn't a @@ -1326,19 +1415,20 @@ export class CapyCommand { // file at all — see the comment above `initialKeep`) rather than // reassigning it, and `networkAvailable`/`remotePlaintext`/`remoteHashes` // likewise, in place of the `let`s and in-loop mutation this used to be. - const { currentKeep, networkAvailable, remotePlaintext, remoteHashes } = await (async (): Promise<{ + const { currentKeep, networkAvailable, remotePlaintext, remoteHashes, seenSnapshot } = await (async (): Promise<{ currentKeep: KeepFile | null; networkAvailable: boolean; remotePlaintext: Record; remoteHashes: Record; + /** The server read the user is shown (and decides against); the commit is planned against it. */ + seenSnapshot: Snapshot | undefined; }> => { - const remotePlaintext: Record = {}; - const remoteHashes: Record = {}; + const noRemote = { remotePlaintext: {}, remoteHashes: {}, seenSnapshot: undefined }; if (localMode) { - return { currentKeep: initialKeep, networkAvailable: false, remotePlaintext, remoteHashes }; + return { currentKeep: initialKeep, networkAvailable: false, ...noRemote }; } - const fetchSpinner = ora('Fetching remote secrets...').start(); + const fetchSpinner = ora('Fetching remote secrets...', this.options.quiet === true).start(); try { // Always ask for the latest remote blob for this branch (no keep_hash). // The server returns the env_blob AND the latest keep.json — used only @@ -1362,18 +1452,12 @@ export class CapyCommand { hasKeepFile: !!decryptData.keep_file, }); - if (decryptData.env_content) { - const encrypted = this.fileManager.parseEnvContent(decryptData.env_content); - for (const [key, value] of Object.entries(encrypted)) { - try { - const plaintext = this.fileManager.decryptValue(value, encryptionKey); - remotePlaintext[key] = plaintext; - remoteHashes[key] = hashValue(plaintext); - } catch (decryptErr) { - this.debugError(`remote decrypt failed for ${key}`, decryptErr); - } - } - } + // Lines this key cannot decrypt are left out of the comparison (and kept verbatim by a commit). + const snapshot = snapshotFrom(decryptData, branch, encryptionKey, this.fileManager); + const remotePlaintext = Object.fromEntries( + Object.entries(snapshot.entries).flatMap(([key, entry]) => (entry.plaintext === undefined ? [] : [[key, entry.plaintext] as const])), + ); + const remoteHashes = Object.fromEntries(Object.entries(remotePlaintext).map(([key, value]) => [key, hashValue(value)])); this.debug('remote hashes', remoteHashes); // Bootstrap only (CAP-303): an existing keep.lock is git-owned and is @@ -1389,7 +1473,7 @@ export class CapyCommand { return serverKeep; })(); fetchSpinner.stop(); - return { currentKeep: bootstrappedKeep, networkAvailable: true, remotePlaintext, remoteHashes }; + return { currentKeep: bootstrappedKeep, networkAvailable: true, remotePlaintext, remoteHashes, seenSnapshot: snapshot }; } catch (err: any) { this.debugError('remote fetch failed', err); // 403 may be one of two different cases: @@ -1435,7 +1519,7 @@ export class CapyCommand { } } fetchSpinner.fail('Cannot reach remote. Showing local changes only.'); - return { currentKeep: initialKeep, networkAvailable: false, remotePlaintext, remoteHashes }; + return { currentKeep: initialKeep, networkAvailable: false, ...noRemote }; } })(); @@ -1461,12 +1545,14 @@ export class CapyCommand { }); if (diffs.length === 0) { - console.log('Everything is up to date!'); - // Always re-encrypt local .env - const finalKeep = this.projectManager.readKeepFile(); - this.fileManager.writeEncryptedEnvFile(localPlaintext, encryptionKey, undefined, finalKeep, branch); - this.installGitHooks(); - return; + if (this.options.quiet !== true) this.say('Everything is up to date!'); + // Always re-encrypt local .env (a dry run changes nothing, not even that) + if (this.options.dryRun !== true) { + const finalKeep = this.projectManager.readKeepFile(); + this.fileManager.writeEncryptedEnvFile(localPlaintext, encryptionKey, undefined, finalKeep, branch); + this.installGitHooks(); + } + return 'up-to-date'; } // Onboarding detection: local .env is empty (or belongs to a different project) @@ -1529,14 +1615,32 @@ export class CapyCommand { })() : localMatches; + // The keep a commit starts from: the folder's, or (no keep.lock yet) a fresh one for this project. + const keep: KeepFile = currentKeep || { + version: '3.0', + org_id: projectState.organizationId!, + project_id: projectState.projectId!, + project_name: projectState.projectName!, + variables: {}, + }; + // Where these differences were found: what the protocol's conflict_id binds. + const view: ConflictView = { + project: keep.project_name, + branch, + serverKeepHash: (seenSnapshot === undefined ? undefined : baseHashOf(seenSnapshot, branch)) ?? '', + }; + const DIM = '\x1b[90m'; const RST = '\x1b[0m'; - console.log(` You have unsynced environment variables (${diffs.length} difference${diffs.length !== 1 ? 's' : ''} found).\n`); + // Answering by protocol: nobody is shown the table (the payload carries the same rows). + if (!this.protocol) { + console.log(` You have unsynced environment variables (${diffs.length} difference${diffs.length !== 1 ? 's' : ''} found).\n`); - // Display comparison table. - this.displayComparisonTable(diffs, effectiveShowLocal, showRemote, pinned, localHashes, remoteHashes, localPlaintext, remotePlaintext, pinnedPlaintext); - console.log(`\n ${DIM}← → select value ↑ ↓ move between rows Enter confirm q cancel${RST}\n`); + // Display comparison table. + this.displayComparisonTable(diffs, effectiveShowLocal, showRemote, pinned, localHashes, remoteHashes, localPlaintext, remotePlaintext, pinnedPlaintext); + console.log(`\n ${DIM}← → select value ↑ ↓ move between rows Enter confirm q cancel${RST}\n`); + } // Build menu options based on what columns are visible const hasPinned = Object.keys(pinned).length > 0; @@ -1621,12 +1725,14 @@ export class CapyCommand { const menuChoices: MenuChoice[] = [...stateMenuChoices, { name: 'Continue working', value: 'skip' }].map((c) => localMode && c.value === 'commit_local' ? { ...c, name: 'Commit all local values' } : c, ); - const { action } = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: 'What would you like to do?', - choices: menuChoices, - }]) as { action: string }; + const { action } = this.protocol + ? { action: PROTOCOL_ACTION } + : await inquirer.prompt([{ + type: 'list', + name: 'action', + message: 'What would you like to do?', + choices: menuChoices, + }]) as { action: string }; // Apply the chosen action. An `abort`-or-`proceed` result stands in for // `let finalEnv` plus a bare `return` from two of its branches @@ -1683,83 +1789,92 @@ export class CapyCommand { if (action === 'skip') { return { kind: 'abort' }; } + if (action === PROTOCOL_ACTION) { + // The answers of --resolve are bare `capy`'s individual resolution; with none, the differences are reported (exit 3). + const answers = gateConflicts( + parseConflictFlags(this.options.conflictFlags ?? {}), + [{ ...view, conflicts: sourceRowsOf(diffs, view) }], + { dryRun: this.options.dryRun === true }, + ); + const answered = this.mapResolveChoicesToEnv(answers, diffs, pinned, localPlaintext, remotePlaintext, pinnedPlaintext); + // An answer whose source has no value for that row would drop the variable (`pinned` with no pinned value, or one + // that cannot be read here; `local` with no local value; `remote` with no remote value; the table greys these + // out and never picks them): off a terminal that is refused before anything is written, not guessed. Only + // `delete` deletes. + const valueless = Object.entries(answers).filter(([name, choice]) => choice !== 'delete' && !Object.hasOwn(answered, name)); + if (valueless.length > 0) { + throw new CapyError( + `${valueless.map(([name, choice]) => `${name} (${choice})`).join(', ')}: that source has no value to keep, so the answer would delete the variable. Answer another source or delete.`, // COPY-FLAG + ERROR_CODES.SECRETS_RESOLVE_INVALID, + { names: valueless.map(([name]) => name) }, + ); + } + return { kind: 'proceed', finalEnv: answered }; + } // Individual resolution. - const resolved = await this.resolveIndividually(diffs, showLocal, showRemote, pinned, localPlaintext, remotePlaintext, pinnedPlaintext); + const resolved = await this.resolveIndividually(diffs, showLocal, showRemote, pinned, localPlaintext, remotePlaintext, pinnedPlaintext, view); if (!resolved) return { kind: 'abort' }; // Cancelled return { kind: 'proceed', finalEnv: resolved }; })(); - if (finalEnvDecision.kind === 'abort') return; + if (finalEnvDecision.kind === 'abort') return 'stopped'; const { finalEnv } = finalEnvDecision; - // Update keep.lock - const { createHash } = await import('crypto'); - const { deriveResourceId } = await import('../crypto/resourceId'); - - const keep = currentKeep || { - version: '3.0', - org_id: projectState.organizationId!, - project_id: projectState.projectId!, - project_name: projectState.projectName!, - variables: {}, - }; - - const pushedVars = Object.fromEntries( - Object.entries(finalEnv).map(([key, value]) => [ - key, - { - resource_id: deriveResourceId(branch, key), - value_hash: createHash('sha256').update(value).digest('hex').slice(0, 16), - }, - ]), - ); - - const mergedKeep = this.syncEngine.mergeWithKeep(keep, pushedVars, branch); - - // Remove variables not in finalEnv from keep (for this branch) — built as - // a new object rather than mutated in place (was a `for` loop doing - // `finalKeep.variables[varName] = entries` / `delete - // finalKeep.variables[varName]` on the value mergeWithKeep returned). - const finalKeep: KeepFile = { - ...mergedKeep, - variables: Object.fromEntries( - Object.entries(mergedKeep.variables).flatMap(([varName, entries]) => { - if (varName in finalEnv) return [[varName, entries]]; - const kept = entries.filter(e => e.branch !== branch); - return kept.length > 0 ? [[varName, kept]] : []; - }), - ), - }; - - this.fileManager.writeKeepFile(finalKeep); - - // Build the encrypted env blob (used for both push and local cache). - const { Encryptor } = await import('../crypto/encryptor'); - const cacheKeepHash = SyncEngine.computeKeepHash(finalKeep, branch); - const envBlob = Object.entries(finalEnv) - .map(([k, v]) => { - const resourceId = deriveResourceId(branch, k); - const enc = Encryptor.encrypt(v, encryptionKey); - return `${k}=capy:${resourceId}:${enc}`; - }) - .join('\n'); - // Commit + push are coupled: choosing "commit local" pushes too — except - // in local-only mode, where there is no remote. The local writes below - // (keep cache, encrypted .env, sync-state) ARE the commit. - if (action === 'commit_local' && !localMode) { - const pushResult = await this.serviceClient.pushSecrets( - projectState.projectId!, - JSON.stringify(finalKeep), - envBlob, - branch, + // in local-only mode, where there is no remote and the local writes ARE the commit. + // The protocol's answers are committed the same way, so that the folder and Capy agree afterwards. + if (action === 'commit_local' || action === PROTOCOL_ACTION) { + const pushedCount = await (async (): Promise => { + try { + // The shared upload (sync/commit.ts): `finalEnv` onto the latest this run read, with `base_keep_hash`. + await commitFolder( + { + pm: this.projectManager, + fileManager: this.fileManager, + serviceClient: localMode ? undefined : this.serviceClient, + orgId: projectState.organizationId!, + projectId: projectState.projectId!, + branch, + userId: authResult.user_id!, + projectKey: encryptionKey, + keep, + localPlaintext: finalEnv, + }, + localMode || seenSnapshot === undefined ? { set: {}, remove: [] } : changesToMatch(seenSnapshot.entries, finalEnv), + { seen: seenSnapshot }, + ); + return Object.keys(finalEnv).length; + } catch (err) { + // Someone saved since this run read Capy: nothing was written; the sync runs once more, from a fresh read. + if (isStaleBase(err) && !retried) return 'stale'; + throw err; + } + })(); + if (pushedCount === 'stale') return this.syncProject(projectState, true); + this.say(`\n> keep.lock updated (${diffs.length} changes)`); + this.say( + localMode + ? `\nStored ${pushedCount} change(s) locally (local-only mode).` + : `\nPushed ${pushedCount} change(s) to Keep.`, ); - // Re-write keep.lock with the server's copy — it carries the - // server-assigned changed_at timestamps for this push. - this.fileManager.writeKeepFile(SyncEngine.adoptServerKeep(pushResult.keep_file, finalKeep, branch)); + this.installGitHooks(); + return 'changed'; } - writeKeepCache(projectState.organizationId!, projectState.projectId!, cacheKeepHash, envBlob); + // Retrieving (pinned or remote values) or resolving row by row only moves this folder: + // keep.lock's pins and `.env` follow the chosen values, and nothing is pushed. + const finalKeep = keepForEnv(keep, branch, finalEnv); + this.fileManager.writeKeepFile(finalKeep); + + // Build the encrypted env blob (the local cache). + const { deriveResourceId } = await import('../crypto/resourceId'); + const { Encryptor } = await import('../crypto/encryptor'); + const envBlob = envBlobOf( + Object.fromEntries( + Object.entries(finalEnv).map(([k, v]) => [k, `capy:${deriveResourceId(branch, k)}:${Encryptor.encrypt(v, encryptionKey)}`]), + ), + ); + writeKeepCache(projectState.organizationId!, projectState.projectId!, SyncEngine.computeKeepHash(finalKeep, branch), envBlob); // Encrypt and write .env this.fileManager.writeEncryptedEnvFile(finalEnv, encryptionKey, undefined, finalKeep, branch); @@ -1774,19 +1889,11 @@ export class CapyCommand { keep_hash: setSyncKeepHash(existingSyncState, branch, SyncEngine.computeKeepHash(finalKeep, branch)), }); - const changeCount = Object.keys(pushedVars).length; - console.log(`\n> keep.lock updated (${diffs.length} changes)`); - - if (action === 'commit_local') { - console.log( - localMode - ? `\nStored ${changeCount} change(s) locally (local-only mode).` - : `\nPushed ${changeCount} change(s) to Keep.`, - ); - } + this.say(`\n> keep.lock updated (${diffs.length} changes)`); // Install hooks on every run (idempotent) this.installGitHooks(); + return 'changed'; } /** @@ -1808,7 +1915,7 @@ export class CapyCommand { } private displayComparisonTable( - diffs: { variable: string; type: string; pinned?: string; local?: string; remote?: string }[], + diffs: DiffResult[], showLocal: boolean, showRemote: boolean, pinned: Record, @@ -1835,32 +1942,24 @@ export class CapyCommand { const showPinned = diffs.some(diff => pinned[diff.variable] && pinnedPlaintext[diff.variable]); // Build header - const headers: string[] = ['Variable']; - if (showPinned) headers.push('Pinned'); - if (showLocal) headers.push('Local'); - if (showRemote) headers.push('Remote'); - - // Calculate column widths - const colWidths = headers.map(h => h.length); - for (const diff of diffs) { - const cols = [diff.variable]; - if (showPinned) { - const pinnedSnippet = pinnedSnippetFor(diff.variable); - cols.push(pinnedSnippet); - } - if (showLocal) { - cols.push(localPlaintext[diff.variable] ? formatSnippet(localPlaintext[diff.variable]) : '-'); - } - if (showRemote) { - cols.push(remotePlaintext[diff.variable] ? formatSnippet(remotePlaintext[diff.variable]) : '-'); - } - cols.forEach((c, i) => { - colWidths[i] = Math.max(colWidths[i] || 0, stripAnsi(c).length); - }); - } + const headers: string[] = [ + 'Variable', + ...(showPinned ? ['Pinned'] : []), + ...(showLocal ? ['Local'] : []), + ...(showRemote ? ['Remote'] : []), + ]; + + // One row's cells, in header order. + const cellsOf = (diff: { variable: string }): string[] => [ + diff.variable, + ...(showPinned ? [pinnedSnippetFor(diff.variable)] : []), + ...(showLocal ? [localPlaintext[diff.variable] ? formatSnippet(localPlaintext[diff.variable]) : '-'] : []), + ...(showRemote ? [remotePlaintext[diff.variable] ? formatSnippet(remotePlaintext[diff.variable]) : '-'] : []), + ]; - // Add padding - colWidths.forEach((w, i) => { colWidths[i] = w + 2; }); + // Calculate column widths (the widest cell of each column, header included) and add padding + const rows = diffs.map(cellsOf); + const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map((cols) => stripAnsi(cols[i]).length)) + 2); // Print header const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(''); @@ -1868,30 +1967,21 @@ export class CapyCommand { console.log(` ${'─'.repeat(colWidths.reduce((a, b) => a + b, 0))}`); // Print rows - for (const diff of diffs) { - const cols = [diff.variable]; - if (showPinned) { - cols.push(pinnedSnippetFor(diff.variable)); - } - if (showLocal) { - cols.push(localPlaintext[diff.variable] ? formatSnippet(localPlaintext[diff.variable]) : '-'); - } - if (showRemote) { - cols.push(remotePlaintext[diff.variable] ? formatSnippet(remotePlaintext[diff.variable]) : '-'); - } + for (const cols of rows) { const row = cols.map((c, i) => padCell(c, colWidths[i])).join(''); console.log(` ${row}`); } } private async resolveIndividually( - diffs: { variable: string; type: string; pinned?: string; local?: string; remote?: string }[], + diffs: DiffResult[], showLocal: boolean, showRemote: boolean, pinned: Record, localPlaintext: Record, remotePlaintext: Record, pinnedPlaintext: Record = {}, + view: ConflictView = { project: '', branch: '', serverKeepHash: '' }, ): Promise | null> { const { ResolveTable } = await import('../ui/resolveTable'); type Row = import('../ui/resolveTable').ResolveRow; @@ -1944,13 +2034,10 @@ export class CapyCommand { // both sides changed, and which one survives is a fact only the person // who made the changes holds. Off a TTY this used to apply the defaults // and carry on — a resolution written and reported as consent with - // nobody in the room. Exit 3 so a caller can tell "I need a human or a - // browser" apart from "this failed, retry". - const { refuseNonInteractive } = await import('../ui/interactive'); - refuseNonInteractive( - `${diffs.length} ${diffs.length === 1 ? 'variable has' : 'variables have'} changed on both sides and need a decision`, - 'Run `capy` in a terminal.', // COPY-FLAG - ); + // nobody in the room. It is the protocol's answer now (CAP-717): exit 3, + // every row's fingerprints in the payload, so a caller can tell "I need a + // human or a decision" apart from "this failed, retry". + throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ ...view, conflicts: sourceRowsOf(diffs, view) }]); } if (outcome === 'cancelled') { diff --git a/src/commands/checkoutCommand.ts b/src/commands/checkoutCommand.ts index 8ddb28ee..8bb4878e 100644 --- a/src/commands/checkoutCommand.ts +++ b/src/commands/checkoutCommand.ts @@ -8,6 +8,8 @@ import { CapyError, ERROR_CODES, getSyncKeepHash, KeepFile } from '../types/inde import { resolveProjectKey, KeyServiceOps } from '../crypto/keyResolver'; import { SyncEngine } from '../sync/syncEngine'; import { hashValue } from './statusCommand'; +import { gateFirst, type GateOptions } from '../sync/gate'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -114,6 +116,12 @@ export interface CheckoutOptions { create?: boolean; /** Settled by `--protected` / `--no-protected`; undefined means ask. */ protected?: boolean; + /** `--json`: differences from Capy are reported as JSON (stdout) instead of asked. */ + json?: boolean; + /** `--non-tty`: treat stdin as not a terminal. */ + nonTty?: boolean; + /** `--resolve` / `--conflict` as typed. */ + conflictFlags?: RawConflictFlags; } type DecryptData = Awaited>; @@ -136,11 +144,23 @@ export class CheckoutCommand { } async execute(branchName: string, options: CheckoutOptions = {}): Promise { + // Switching to an existing branch runs bare `capy` first, like every folder write (docs/conflict-resolution.md). + // The one exception is `-b`: creating a branch carries the folder's uncommitted values onto a brand-new branch + // nobody else has written to, and bare `capy` offers to push them there on the next sync (Vince, 2026-10-16). + // In seed-phrase recovery mode the gate is skipped too (as `capy decrypt` is): it needs the server, and the offline + // fallback below is the very case recovery mode exists for. + const { isRecoveryActive } = await import('../config/globalConfig'); + if (!options.create) { + conflictFlagsOrExit(options.conflictFlags ?? {}, options.json === true); + if (!isRecoveryActive()) { + const gate: GateOptions = { devMode: this.devMode, json: options.json, nonTty: options.nonTty, conflictFlags: options.conflictFlags }; + await gateFirst(gate); + } + } try { await this._execute(branchName, options); } catch (error: any) { // In recovery mode, fall back to offline branch switch - const { isRecoveryActive } = await import('../config/globalConfig'); if (isRecoveryActive()) { this.projectManager.writeActiveBranch(branchName); console.log(`\nSwitched to branch "${branchName}" (offline — recovery mode)`); diff --git a/src/commands/conflictPrompt.ts b/src/commands/conflictPrompt.ts new file mode 100644 index 00000000..29458142 --- /dev/null +++ b/src/commands/conflictPrompt.ts @@ -0,0 +1,33 @@ +/** + * The terminal's answer to a platform row (CAP-717): one question per Dokploy value that was changed outside Capy, + * `local` (deliver Capy's value) or `remote` (keep the platform's and skip it). A folder row is bare `capy`'s own + * table and menu (commands/capyCommand.ts), never asked here. Off a terminal nothing here runs: `--resolve` / + * `--conflict` answer (sync/conflicts.ts). Never shows a value, only the name and where. + */ +import { isTargetConflict, resolveKeyOf, type Conflict, type Resolutions, type TargetResolution } from '../sync/conflicts'; + +// COPY-FLAG: minimal, neutral wording; pending approval. +const questionFor = (conflict: Conflict): string => + isTargetConflict(conflict) ? `${conflict.name} on ${conflict.target} was changed outside Capy. Which value?` : `${conflict.name} differs from Capy. Which value?`; + +/** Asks about each platform row in turn (one prompt, then the rest) and returns the answers by decision key. */ +export async function askPlatformConflicts(conflicts: readonly Conflict[]): Promise { + const inquirer = (await import('inquirer')).default; + const ask = async (rest: readonly Conflict[], answered: Resolutions): Promise => { + if (rest.length === 0) return answered; + const [conflict, ...later] = rest; + const { choice } = (await inquirer.prompt([ + { + type: 'list', + name: 'choice', + message: questionFor(conflict), + choices: [ + { name: "Capy's value (local)", value: 'local' }, // COPY-FLAG + { name: "The platform's value (remote)", value: 'remote' }, // COPY-FLAG + ], + }, + ])) as { choice: TargetResolution }; + return ask(later, { ...answered, [resolveKeyOf(conflict)]: choice }); + }; + return ask(conflicts, {}); +} diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 7bd16bf1..dd66202a 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -2,6 +2,8 @@ import { resolveContext, writeAndSync, writeImportOutcome, ResolvedContext } fro import { listProviders, loadProvider, ConnectOpts, ConnectorModule } from './connectors/registry'; import { EXIT_NEEDS_INPUT, isInteractive } from '../ui/interactive'; import { resolveOrgContext } from '../core/orgContext'; +import { conflictFlagsOrExit } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import type { DiscoveryContext } from './connectors/dokployDiscovery'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -11,6 +13,17 @@ function exitCodeForRefusalCode(code: string): number { return code === 'DOKPLOY_SETTINGS_MISSING' ? EXIT_NEEDS_INPUT : 1; } +/** + * The exit code of a discovery run from its per-folder results: `undefined` when every folder was applied; `EXIT_NEEDS_INPUT` + * when a folder differed from Capy and nobody could answer (its rows and `conflict_id` are in the report, nothing was + * written into it); 1 for any other per-folder failure. The OTHER folders' steps still ran. + */ +export function discoveryExitCode(applied: ReadonlyArray<{ readonly ok: boolean; readonly conflict?: unknown }> | undefined): number | undefined { + const failed = (applied ?? []).filter((f) => !f.ok); + if (failed.length === 0) return undefined; + return failed.some((f) => f.conflict !== undefined) ? EXIT_NEEDS_INPUT : 1; +} + /** * Discovery's own context — org + auth + serviceClient, exactly what * `resolveOrgContext` already gives org-level commands (`invite`, `kick`, @@ -77,6 +90,22 @@ export class ConnectCommand { const mod = await this.resolveProvider(provider); + // A bad --resolve / --conflict is refused before anything is read. + conflictFlagsOrExit(effective.conflictFlags ?? {}, effective.json === true); + // Discovery never prompts (it always answers like `--json --non-tty`), so its gate answers by protocol too. + const discovering = effective.discover === true && mod.discover !== undefined; + const gate: GateOptions = { + devMode: this.devMode, + json: discovering || effective.json === true, + nonTty: discovering || effective.nonTty, + conflictFlags: effective.conflictFlags, + dryRun: effective.dryRun === true, + }; + // Bare `capy` runs before anything else, in every mode: the folder and Capy must agree before this writes + // (docs/conflict-resolution.md). Discovery writes into OTHER checkouts too: each of them is synced by bare `capy` + // before anything is written into it (dokployDiscovery.ts `syncFolder`), after this one in the working directory. + await gateFirst(gate); + if (mod.precheck) mod.precheck(); // Discovery mode (CAP-657 follow-up, `dokploy` only): finds every @@ -98,7 +127,7 @@ export class ConnectCommand { return await this.executeDiscovery(mod, provider, discoveryCtx, discoveryOpts); } - const ctx = await resolveContext({ devMode: this.devMode, dryRun: effective.dryRun === true }); + const ctx = await resolveContext({ devMode: this.devMode, dryRun: effective.dryRun === true, gate }); // Import-kind connectors (CAP-662: `dokploy`) pull MANY variables in one // run rather than linking one existing one, so they skip the var-picking @@ -378,6 +407,8 @@ export class ConnectCommand { ok: false as const, code: f.code, message: f.message, + // A folder that differs from Capy and could not be asked: its rows and id, answered by a rerun (fingerprints only). + ...(f.conflict === undefined ? {} : { conflict_id: f.conflict.conflict_id, conflicts: f.conflict.conflicts }), environments: f.environments.map((e) => ({ branch: e.environmentName, branchCreated: e.branchCreated, @@ -406,7 +437,8 @@ export class ConnectCommand { // A per-folder failure still lets the OTHER folders' steps run (see // `runDiscoverySequences`) — but the run as a whole did not fully // succeed, so CI watching the exit code still sees it. - if (outcome.applied?.some((f) => !f.ok)) process.exitCode = 1; + const failedExit = discoveryExitCode(outcome.applied); + if (failedExit !== undefined) process.exitCode = failedExit; if (opts.json) { console.log( diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index dd3dba97..c03d2e07 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -53,6 +53,7 @@ import { resolveDokployApiKey, } from '../../deploy/dokployApi'; import { ConnectorMetadata, ERROR_CODES, KeepFile } from '../../types/index'; +import { isStaleBase } from '../../sync/commit'; import { isInteractive } from '../../ui/interactive'; import { ProjectManager } from '../../core/projectManager'; import { FileManager } from '../../files/fileManager'; @@ -63,6 +64,8 @@ import { findDirtyBranchIssue } from '../checkoutCommand'; import { listOrgProjectsOrUnavailable } from '../capyCommand'; import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { BaseUrlStore, Unanswered, offerSaveAskedBaseUrl, realBaseUrlStoreOpener, resolveDokployBaseUrl, settingsUnanswered } from '../../deploy/dokployBaseUrl'; +import { syncFolder, type FolderSync, type GateOptions } from '../../sync/gate'; +import { isConflictRefusal } from '../../sync/conflicts'; import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; import { discoveryProjectQuestion } from '../../ui/projectQuestions'; @@ -369,6 +372,11 @@ async function fetchComposeEnv(client: DokployClient, composeId: string): Promis // ── Connector ──────────────────────────────────────────────────────────────── export interface DokployConnectorDeps { + /** + * Discovery mode only: bare `capy` in one discovered folder (sync/gate.ts `syncFolder`). Injectable for tests that drive + * discovery over throwaway folders with a fake service; the real sync otherwise. + */ + syncFolder?: (dir: string, gate: GateOptions) => Promise; fetch?: FetchLike; env?: Record; cwd?: string; @@ -1243,6 +1251,9 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect yes: !!opts.yes, baseUrl: resolvedBaseUrl, tokenEnv, + // Discovery never prompts: a folder that differs from Capy is reported (its rows and `conflict_id`), not asked. + gate: { devMode: opts.devMode, json: true, nonTty: true, conflictFlags: opts.conflictFlags }, + syncFolder: deps.syncFolder ?? syncFolder, askExistingOrNewProject, askProjectName: askDiscoveryProjectName, existingProjectsSnapshot, @@ -1814,7 +1825,7 @@ async function importIntoBranchSafe( folder: string, env: DiscoveryPlanEnv, overwrite: boolean, - runOpts: { noPush: boolean; interactive: boolean; json: boolean; yes: boolean; baseUrl: string; tokenEnv: string }, + runOpts: { noPush: boolean; interactive: boolean; json: boolean; yes: boolean; baseUrl: string; tokenEnv: string; gate: GateOptions }, /** CAP-673: this environment's Dokploy PROJECT name — already known from the plan's own `project.all` read, threaded through so `import()` never re-fetches it (see `ConnectOpts.dokployProjectName`'s doc). */ projectName: string, ): Promise { @@ -1873,6 +1884,8 @@ async function importIntoBranchSafe( projectKey, keep, localPlaintext, + // This folder's own gate: if Capy refuses the upload as stale, bare `capy` runs again IN THIS FOLDER and the write retries. + gate: { ...runOpts.gate, cwd: path, throwConflicts: true }, }; const importOpts: ConnectOpts = { ...(env.serviceKind === 'application' ? { application: env.serviceId } : { compose: env.serviceId }), @@ -1898,11 +1911,18 @@ async function importIntoBranchSafe( try { await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); } catch (err) { - return { ok: false, code: 'DOKPLOY_PUSH_FAILED', message: err instanceof Error ? err.message : String(err) }; + // A refused-as-stale upload keeps its own code; anything else is the push failing. + return { ok: false, code: stalePushCode(err), message: err instanceof Error ? err.message : String(err) }; } return outcome; } +/** The coded reason a folder's upload failed: a difference found by the retry's bare `capy` and a second stale refusal keep their own codes. */ +function stalePushCode(err: unknown): string { + if (isConflictRefusal(err)) return err.code; + return isStaleBase(err) ? ERROR_CODES.SECRETS_STALE_BASE : 'DOKPLOY_PUSH_FAILED'; +} + /** * The REAL `DiscoverySequenceDeps`, wired to an already-authenticated `ctx` * — the discovery scan's own org/session, reused for every folder rather @@ -1918,6 +1938,10 @@ function buildRealDiscoverySequenceDeps( yes: boolean; baseUrl: string; tokenEnv: string; + /** How bare `capy` runs in each discovered folder (discovery never prompts: it answers by protocol). */ + gate: GateOptions; + /** Runs bare `capy` in one folder: the real `syncFolder` unless a test supplies its own. */ + syncFolder: (dir: string, gate: GateOptions) => Promise; askExistingOrNewProject: (existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string) => Promise; askProjectName: (defaultName: string) => Promise; /** @@ -1941,6 +1965,7 @@ function buildRealDiscoverySequenceDeps( }, ): DiscoverySequenceDeps { return { + syncFolder: (repoDir, folder) => runOpts.syncFolder(folderPath(repoDir, folder), runOpts.gate), ensureProject: (repoDir, folder, remote) => ensureProjectSafe(ctx, repoDir, folder, remote, { interactive: runOpts.interactive, diff --git a/src/commands/connectors/dokployDiscovery.ts b/src/commands/connectors/dokployDiscovery.ts index 05e73992..53feeb61 100644 --- a/src/commands/connectors/dokployDiscovery.ts +++ b/src/commands/connectors/dokployDiscovery.ts @@ -119,6 +119,8 @@ import { KeepFile } from '../../types/index'; import type { AuthService } from '../../auth/authService'; import type { ServiceClient } from '../../service/serviceClient'; import type { ImportOutcome } from './registry'; +import type { FolderSync } from '../../sync/gate'; +import type { ConflictPayload } from '../../sync/conflicts'; import type { DiscoveryCommitOutcome } from '../../git/discoveryCommit'; /** @@ -1004,6 +1006,13 @@ export function orderEnvironments(environ * why). */ export interface DiscoverySequenceDeps { + /** + * Bare `capy` in (repoDir, folder), until it and Capy agree: the FIRST step for every folder, before anything is written + * into it (docs/conflict-resolution.md). A folder that is not a Capy project yet has nothing to compare. A difference + * nobody can answer (discovery never prompts), a stop or a failure skips the folder: nothing was written into it, and the + * result carries the folder's coded failure (and, for a difference, its `conflict_id` and rows). + */ + syncFolder: (repoDir: string, folder: string) => Promise; /** * No keep.lock at (repoDir, folder) → run the init flow (existing-project * picking, or create new, in the caller's org) — interactive only; a @@ -1086,6 +1095,8 @@ export type DiscoveryFolderResult = message: string; /** Environments this folder finished BEFORE the step that aborted it. */ environments: readonly DiscoverySequenceEnvResult[]; + /** The folder differed from Capy and nobody could answer: the rows and `conflict_id` for a rerun with `--resolve` + `--conflict`. */ + conflict?: ConflictPayload; }; interface SequenceAcc { @@ -1116,6 +1127,20 @@ export async function runDiscoverySequence( opts: { overwrite: boolean }, deps: DiscoverySequenceDeps, ): Promise { + // Bare `capy` first, in this folder, before anything is written into it. + const synced = await deps.syncFolder(folder.repoDir, folder.folder); + if (!synced.ok) { + return { + repoDir: folder.repoDir, + folder: folder.folder, + ok: false, + code: synced.code, + message: synced.message, + environments: [], + ...(synced.conflict === undefined ? {} : { conflict: synced.conflict }), + }; + } + const project = await deps.ensureProject(folder.repoDir, folder.folder, folder.remote); if (!project.ok) { return { repoDir: folder.repoDir, folder: folder.folder, ok: false, code: project.code, message: project.message, environments: [] }; diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index b7308b33..1bc7e0d5 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -3,6 +3,7 @@ import type { Blocked } from '../../ui/screens/contract'; import type { DokployImportSource } from '../../deploy/dokployApi'; import type { DiscoveryContext, DiscoveryOutcome } from './dokployDiscovery'; import { ResolvedContext } from './shared'; +import type { RawConflictFlags } from '../../sync/conflicts'; export interface ConnectOpts { live?: boolean; @@ -26,6 +27,11 @@ export interface ConnectOpts { subStep?: boolean; /** Disable interactive prompts: resolve every choice from flags or fail fast. */ nonTty?: boolean; + /** + * `--resolve` / `--conflict` as typed (CAP-717): they decide a conflict between what this + * run writes and what someone else saved to Capy since this folder last synced. + */ + conflictFlags?: RawConflictFlags; /** * Pair with the provider again even when a usable session already exists. * @@ -159,6 +165,11 @@ export interface RotateOpts { noPush?: boolean; /** Disable interactive prompts: resolve every choice from flags or fail fast. */ nonTty?: boolean; + /** + * `--resolve` / `--conflict` as typed (CAP-717). A rotation replaces a value with a key the provider + * only issues once, so a conflict on the variable is settled BEFORE the new key is issued. + */ + conflictFlags?: RawConflictFlags; } /** Result of provider.connect(): the provider hands us a value + the connector metadata to record on the keep.lock entry. */ diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index abb4d053..448a823f 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -1,13 +1,10 @@ -import { createHash } from 'crypto'; import { ProjectManager } from '../../core/projectManager'; import { FileManager } from '../../files/fileManager'; import { AuthService, silentAuthFailureMessage } from '../../auth/authService'; import { ServiceClient } from '../../service/serviceClient'; -import { SyncEngine } from '../../sync/syncEngine'; -import { Encryptor } from '../../crypto/encryptor'; -import { deriveResourceId } from '../../crypto/resourceId'; -import { writeKeepCache } from '../../config/globalConfig'; -import { setSyncKeepHash, KeepFile, ConnectorMetadata, CapyError, ERROR_CODES, AuthResult } from '../../types/index'; +import { readLocalPlaintext } from '../../sync/commit'; +import { commitGated, type GateOptions } from '../../sync/gate'; +import { KeepFile, ConnectorMetadata, CapyError, ERROR_CODES, AuthResult } from '../../types/index'; import type { ImportOutcome } from './registry'; import { reportRepoLinkForCommand, type RepoLinkWarning } from '../../core/repoLinkReporter'; @@ -27,6 +24,11 @@ export interface ResolvedContext { localPlaintext: Record; /** CAP-697: warnings from reporting this project's repo link (empty unless the keep.lock looks copied). */ repoLinkWarnings?: readonly RepoLinkWarning[]; + /** + * How bare `capy` is run again if an upload is refused as stale (someone saved in between): the same options the + * command's first-step gate ran with (sync/gate.ts). Absent: a terminal, no flags. + */ + gate?: GateOptions; } /** @@ -54,20 +56,6 @@ async function authenticateForOrg(authService: AuthService, orgId: string, inter return authService.authenticate(orgId); } -/** Decrypts `.env` (values the profile holds the key for; the rest are skipped). */ -function decryptLocalEnv(fileManager: FileManager, projectKey: string): Record { - return Object.fromEntries( - Object.entries(fileManager.readEnvFile()).flatMap(([k, v]) => { - if (!v.startsWith('capy:')) return [[k, v] as const]; - try { - return [[k, fileManager.decryptValue(v, projectKey)] as const]; - } catch { - return []; // skip undecryptable - } - }), - ); -} - /** * Run the standard "I'm an interactive command that needs to encrypt + push" * setup. Mirrors the front half of editCommand.ts. Exits the process on @@ -85,6 +73,8 @@ export async function resolveContext( json?: boolean; /** The caller is a dry run: the repo link is not reported. */ dryRun?: boolean; + /** The options the command's first-step gate ran with: kept on the context for a stale-upload retry. */ + gate?: GateOptions; } = {}, ): Promise { const refuse = opts.refuse ?? exitWithMessage; @@ -152,8 +142,9 @@ export async function resolveContext( userId, projectKey, keep, - localPlaintext: decryptLocalEnv(fileManager, projectKey), + localPlaintext: readLocalPlaintext(fileManager, projectKey), repoLinkWarnings: repoLink.warnings, + ...(opts.gate === undefined ? {} : { gate: opts.gate }), }; } @@ -195,100 +186,25 @@ async function resolveProjectKeyOrRefuse(args: { } /** - * Set `varName=value` in `.env`, encrypt + push to Keep, and update - * keep.lock + sync state. Mirrors the editCommand `saveLocalEdits` flow. + * Write a variable (when there is one to write), attach its connector, sync. + * + * `value === undefined` is the METADATA-ONLY mode, and it is what `connect` + * uses: no value changes and only keep.lock's connector entry moves. `rotate` + * is the caller that passes a value, because replacing a credential is what + * rotate is for. + * + * The save is `commitGated` (sync/gate.ts): it changes ONLY `varName` (plus the connector + * entries) on top of the server's latest, with `base_keep_hash`. The command ran bare `capy` before + * anything else, so the folder agreed with Capy when it started; if someone saved in between, the + * upload is refused as stale, bare `capy` runs again and the save is tried once more. * - * If `connector` is provided, the metadata is attached to the keep.lock entry - * for `varName` on the active branch. Survives future syncs because - * `mergeWithKeep` preserves extra fields on existing entries. + * If `connector` is provided, the metadata is attached to the keep.lock entry for + * `varName` on the active branch. * * If `push` is false, writes the encrypted snippet locally only — the next * `capy push` / `capy` will pick it up. Local-only mode skips the merge so * the connector field doesn't get attached until a real push. */ -/** - * Write a variable (when there is one to write), attach its connector, sync. - * - * `value === undefined` is the METADATA-ONLY mode, and it is what `connect` - * uses: the env map goes to the service unchanged and only keep.lock's - * connector entry moves. Everything downstream — the keep merge, the push, - * the cache, the sync state — is identical either way, which is why this is - * one function and not two. `rotate` is the caller that passes a value, - * because replacing a credential is what rotate is for. - */ -/** - * Shared tail of `writeAndSync`/`removeAndSync`: encrypt the full desired - * `finalEnv`, merge it into keep.lock for `branch` (dropping any entry for a - * name no longer in `finalEnv`), push, cache the pushed blob, adopt the - * server's copy, and write `.env` + sync state. Writes only the untracked - * working copy (`writeKeepFile`); it never auto-commits the tracked - * keep.lock onto whatever branch the caller happens to be on. - * - * `keepMutator` runs on the post-merge, post-drop `KeepFile` right before the - * push — `writeAndSync` uses it to attach connector metadata; a plain remove - * passes the identity function through. - */ -async function commitFinalEnv( - ctx: ResolvedContext, - finalEnv: Record, - keepMutator: (merged: KeepFile) => KeepFile, -): Promise { - const { pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey, keep } = ctx; - - const encrypted = Object.fromEntries( - Object.entries(finalEnv).map(([k, v]) => [ - k, - `capy:${deriveResourceId(branch, k)}:${Encryptor.encrypt(v, projectKey)}`, - ]), - ); - const envBlob = Object.entries(encrypted) - .map(([k, v]) => `${k}=${v}`) - .join('\n'); - - const pushedVars = Object.fromEntries( - Object.entries(finalEnv).map(([k, v]) => [ - k, - { - resource_id: deriveResourceId(branch, k), - value_hash: createHash('sha256').update(v).digest('hex').slice(0, 16), - }, - ]), - ); - - const syncEngine = new SyncEngine(); - const merged = syncEngine.mergeWithKeep(keep, pushedVars, branch); - // Drop entries for names no longer in `finalEnv` — a plain filter/flatMap - // over the merged map rather than a mutate-in-place loop, so `merged` - // itself is never touched after `mergeWithKeep` hands it back. - const dropped: KeepFile = { - ...merged, - variables: Object.fromEntries( - Object.entries(merged.variables).flatMap(([name, entries]) => { - if (name in finalEnv) return [[name, entries]] as const; - const kept = entries.filter((e) => e.branch !== branch); - return kept.length > 0 ? ([[name, kept]] as const) : []; - }), - ), - }; - const finalKeep = keepMutator(dropped); - - const result = await serviceClient.pushSecrets(projectId, JSON.stringify(finalKeep), envBlob, branch); - - writeKeepCache(orgId, projectId, result.keep_hash, envBlob); - // Prefer the server's copy — it carries server-assigned changed_at - fileManager.writeKeepFile(SyncEngine.adoptServerKeep(result.keep_file, finalKeep, branch)); - fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, finalKeep, branch); - - const existingSyncState = pm.readSyncState(); - fileManager.writeSyncState({ - ...existingSyncState, - last_sync: new Date().toISOString(), - synced_variables: Object.keys(finalEnv), - user_id: userId, - keep_hash: setSyncKeepHash(existingSyncState, branch, SyncEngine.computeKeepHash(finalKeep, branch)), - }); -} - export async function writeAndSync( ctx: ResolvedContext, varName: string, @@ -301,53 +217,37 @@ export async function writeAndSync( }, ): Promise { const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; - - const finalEnv: Record = - value === undefined ? { ...localPlaintext } : { ...localPlaintext, [varName]: value }; + const attach = (k: KeepFile): KeepFile => applyConnectors(k, branch, varName, opts.connector, opts.alsoConnect); if (!opts.push) { // Local-only path. Even though we're not hitting the service, we still // need to attach the connector marker to keep.lock so a follow-up `capy // push` (which will round-trip through mergeWithKeep) preserves it. - const merged = applyConnectors(keep, branch, varName, opts.connector, opts.alsoConnect); + const finalEnv = value === undefined ? { ...localPlaintext } : { ...localPlaintext, [varName]: value }; + const merged = attach(keep); if (merged !== keep) fileManager.writeKeepFile(merged); fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, merged, branch); return; } - await commitFinalEnv(ctx, finalEnv, (merged) => applyConnectors(merged, branch, varName, opts.connector, opts.alsoConnect)); + await commitGated(ctx, { set: value === undefined ? {} : { [varName]: value }, remove: [] }, { transformKeep: attach }); } /** - * `capy remove NAME...` — drop `names` from the active branch and push the - * remaining set through the exact same mechanics `writeAndSync` uses (encrypt - * full set, `mergeWithKeep`, drop entries for names no longer present, push, - * cache, adopt, write `.env` + sync state). - * - * Callers are expected to have already confirmed `names` are all present and - * that nothing ELSE in `ctx.localPlaintext` has drifted from the pinned - * baseline — this function does no such checking itself, it only performs - * the write. + * `capy remove NAME...` — drop `names` from the active branch. Only those names + * move: everything else on the server is left alone. */ export async function removeAndSync(ctx: ResolvedContext, names: readonly string[]): Promise { - const removed = new Set(names); - const finalEnv = Object.fromEntries(Object.entries(ctx.localPlaintext).filter(([k]) => !removed.has(k))); - await commitFinalEnv(ctx, finalEnv, (merged) => merged); + await commitGated(ctx, { set: {}, remove: names }); } /** * Write SEVERAL NEW variables at once — a `capy connect ` * import — and sync, in one push/commit rather than one per variable. * - * Mirrors `writeAndSync`, but that function's `(varName, value)` pair is for - * ONE variable's value; an import adds N brand-new key/value pairs - * simultaneously. Chaining `writeAndSync` calls per variable would each read - * `ctx.localPlaintext`/`ctx.keep` as they stood BEFORE the run started, so a - * second call would push a snapshot missing the first call's write — this - * function builds the one final `env`/`keep.lock` state and writes it once. - * - * Every entry becomes a managed connector on its own (varName, branch) entry, - * same as `writeAndSync`'s `alsoConnect`. + * `writeAndSync`'s `(varName, value)` pair is for ONE variable's value; an import adds + * N key/value pairs simultaneously, in one commit. Every entry becomes a managed + * connector on its own (varName, branch) entry, same as `writeAndSync`'s `alsoConnect`. */ export async function writeImportedAndSync( ctx: ResolvedContext, @@ -356,22 +256,19 @@ export async function writeImportedAndSync( push: boolean; /** * `--overwrite` clear-only writes: proceed even when `entries` is empty. - * Clearing removes names from `ctx.localPlaintext` (the caller passes a - * PRUNED context — see `writeImportOutcome`) rather than adding an - * entry, so a clear-only run has nothing in `entries` at all; without - * this flag that would hit the early return below and write nothing. - * Default false preserves every other caller's "nothing to do" no-op. + * Clearing removes names (`opts.clear`) rather than adding an entry, so a + * clear-only run has nothing in `entries` at all; without this flag that + * would hit the early return below and write nothing. Default false + * preserves every other caller's "nothing to do" no-op. */ forceWrite?: boolean; + /** `--overwrite`: names to drop from the branch in the same write. */ + clear?: readonly string[]; }, ): Promise { if (entries.length === 0 && !opts.forceWrite) return; - const { pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey, keep, localPlaintext } = ctx; - - const finalEnv: Record = { - ...localPlaintext, - ...Object.fromEntries(entries.map((e) => [e.varName, e.value])), - }; + const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; + const clear = opts.clear ?? []; const also = entries.map((e) => ({ varName: e.varName, entry: e.entry })); // `entries[0]?.varName ?? ''`: the positional `varName` argument below is // only ever READ by `applyConnectors` when its own `connector` argument @@ -379,59 +276,17 @@ export async function writeImportedAndSync( // empty, `forceWrite: true`) this placeholder is never actually consulted, // but `entries[0].varName` would still throw evaluating it eagerly. const attachAll = (k: KeepFile): KeepFile => applyConnectors(k, branch, entries[0]?.varName ?? '', undefined, also); + const set = Object.fromEntries(entries.map((e) => [e.varName, e.value])); if (!opts.push) { + const finalEnv = { ...Object.fromEntries(Object.entries(localPlaintext).filter(([k]) => !clear.includes(k))), ...set }; const merged = attachAll(keep); if (merged !== keep) fileManager.writeKeepFile(merged); fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, merged, branch); return; } - const encrypted: Record = Object.fromEntries( - Object.entries(finalEnv).map(([k, v]) => [ - k, - `capy:${deriveResourceId(branch, k)}:${Encryptor.encrypt(v, projectKey)}`, - ]), - ); - const envBlob = Object.entries(encrypted) - .map(([k, v]) => `${k}=${v}`) - .join('\n'); - - const pushedVars: Record = Object.fromEntries( - Object.entries(finalEnv).map(([k, v]) => [ - k, - { resource_id: deriveResourceId(branch, k), value_hash: createHash('sha256').update(v).digest('hex').slice(0, 16) }, - ]), - ); - - const syncEngine = new SyncEngine(); - const merged = syncEngine.mergeWithKeep(keep, pushedVars, branch); - const pruned: KeepFile = { - ...merged, - variables: Object.fromEntries( - Object.entries(merged.variables).flatMap(([name, varEntries]) => { - if (name in finalEnv) return [[name, varEntries]] as const; - const filtered = varEntries.filter((e) => e.branch !== branch); - return filtered.length > 0 ? ([[name, filtered]] as const) : []; - }), - ), - }; - const finalKeep = attachAll(pruned); - - const result = await serviceClient.pushSecrets(projectId, JSON.stringify(finalKeep), envBlob, branch); - - writeKeepCache(orgId, projectId, result.keep_hash, envBlob); - fileManager.writeKeepFile(SyncEngine.adoptServerKeep(result.keep_file, finalKeep, branch)); - fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, finalKeep, branch); - - const existingSyncState = pm.readSyncState(); - fileManager.writeSyncState({ - ...existingSyncState, - last_sync: new Date().toISOString(), - synced_variables: Object.keys(finalEnv), - user_id: userId, - keep_hash: setSyncKeepHash(existingSyncState, branch, SyncEngine.computeKeepHash(finalKeep, branch)), - }); + await commitGated(ctx, { set, remove: clear }, { transformKeep: attachAll }); } /** @@ -445,9 +300,9 @@ export async function writeImportedAndSync( * before anything is read even from `outcome`. Otherwise writes when there * is either something to import OR (an `--overwrite` run) something to * clear; a clear-only overwrite (nothing new or changed, only removals) has - * an empty `entries` array, so `ctx.localPlaintext` is pruned of the - * cleared names and `forceWrite` is set so `writeImportedAndSync` does not - * take its normal "nothing to do" early return. + * an empty `entries` array, so the cleared names are passed as `clear` and + * `forceWrite` is set so `writeImportedAndSync` does not take its normal + * "nothing to do" early return. */ export async function writeImportOutcome( ctx: ResolvedContext, @@ -467,14 +322,10 @@ export async function writeImportOutcome( const hasWrite = outcome.imported.length > 0 || cleared.length > 0 || unchangedEntries.length > 0; if (!hasWrite) return { wrote: false }; - const prunedCtx: ResolvedContext = - cleared.length > 0 - ? { ...ctx, localPlaintext: Object.fromEntries(Object.entries(ctx.localPlaintext).filter(([k]) => !cleared.includes(k))) } - : ctx; - - await writeImportedAndSync(prunedCtx, [...outcome.imported, ...unchangedEntries], { + await writeImportedAndSync(ctx, [...outcome.imported, ...unchangedEntries], { push: opts.push, forceWrite: cleared.length > 0 || unchangedEntries.length > 0, + clear: cleared, }); return { wrote: true }; } diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 41d6710b..32451983 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -59,6 +59,9 @@ import { VarDelivery, } from '../deploy/targetsGate'; import { KeepFile, ERROR_CODES, AuthResult, ErrorCode } from '../types/index'; +import { uploadKeepOnly } from '../sync/commit'; +import { gateFirst } from '../sync/gate'; +import { SyncEngine } from '../sync/syncEngine'; import type { AuthService } from '../auth/authService'; import type { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from '../core/projectManager'; @@ -87,6 +90,15 @@ 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 { + conflictFlagsOrExit, + conflictRefusalFor, + isConflictRefusal, + printConflictRefusal, + type RawConflictFlags, +} from '../sync/conflicts'; +import { reconcileDeployConflicts } from './deployConflicts'; +import { EXIT_NEEDS_INPUT } from '../ui/interactive'; import type { DeployPlanConfirmStop } from '../ui/screens/contract'; import { CHECKBOX_INSTRUCTIONS, CHECKBOX_THEME, LIST_THEME } from '../ui/promptStyle'; import { keypressConfirm } from '../ui/keypressConfirm'; @@ -139,6 +151,11 @@ export interface DeployCliOptions { * ships it later. `targets` are still recorded — the write happened. */ noDeploy?: boolean; + /** + * `--resolve` / `--conflict` as typed (CAP-717): they decide the conflicts between the folder, Capy and the + * platform off a terminal. Without them such a conflict stops the deploy (exit 3, the payload on stdout). + */ + conflictFlags?: RawConflictFlags; } /** @@ -398,70 +415,40 @@ async function loadDeploySecrets( // ── Targets recording (CAP-679) ───────────────────────────────────────────── /** - * Read → transform → push keep.lock through the existing sync path, for a - * pure `KeepFile → KeepFile` change that isn't a value edit (targets - * recording/stripping). Mirrors `capy connect`'s import write - * (`connectors/shared.ts#writeImportedAndSync`) — same push — but - * generalized over the transform instead of "add these new vars". + * Transform keep.lock on the Capy SERVER and push it, for a pure `KeepFile → KeepFile` change that isn't a + * value edit (targets recording/stripping). Mirrors `deployTokenCommand`'s keep-only writes. * - * Writes only the untracked working copy (`writeKeepFile`); it never - * auto-commits the tracked keep.lock onto whatever branch the caller - * happens to be on. Only `capy deploy`'s own explicit, isolated commit - * steps (direct mode's own-branch commit, CI mode's worktree PR) ever touch - * the tracked file. + * The write is the shared keep-only upload (sync/commit.ts `uploadKeepOnly`): it reads the server's latest for `branch`, + * applies `transform` to THAT keep, and uploads it with the branch's blob exactly as the server holds it + * (every ciphertext line verbatim) and the keep it was built from (`base_keep_hash`), re-reading and rebuilding + * once if someone saved in between. Nothing in this folder's `.env` or its keep.lock beyond `transform` reaches + * the server, so recording a target can never revert, add or drop a value (CAP-687: a keep-only write must not + * move a secret's value under an unchanged hash). * - * `transform` returning the SAME object (`===`) is treated as "nothing to - * do" and skips the network entirely. Best-effort: errors are logged, never - * thrown — the caller's own operation (a deploy, a remove, a revoke) already - * succeeded or is already committed to happening by the time this runs. + * Writes only the untracked working copy (`writeKeepFile`), and only the branch's slice of the server's keep + * (spliced into the local keep): it never auto-commits the tracked keep.lock onto whatever branch the caller happens to be + * on. Only `capy deploy`'s own explicit, isolated commit steps (direct mode's own-branch commit, CI mode's + * worktree PR) ever touch the tracked file. * - * CAP-687 (validator fix-first): this used to build the pushed env blob by - * decrypting the LOCAL `.env` and re-encrypting it fresh. That's wrong for - * EVERY caller here, not just CI mode — none of them are value edits, so - * NOTHING about the branch's secret values should ever be able to change as - * a side effect of "record a target" or "revoke a token". But a local `.env` - * can hold unpushed edits, or a variable that was never synced at all, and - * the server's `keep_hash` only covers `name:resource_id:value_hash` — not - * blob content — so a keep-only write that rebuilds the blob from local - * `.env` can silently overwrite the team's stored blob under an UNCHANGED - * hash: the next `capy` pull anyone runs decrypts the same pin to a - * different value, with no signal anything drifted. + * `transform` returning the SAME object (`===`) is treated as "nothing to do" and skips the network entirely. + * Best-effort: errors are logged, never thrown — the caller's own operation (a deploy, a remove, a revoke) + * already succeeded or is already committed to happening by the time this runs. * - * Fixed by never touching local `.env` here at all. Instead: fetch the - * server's OWN current snapshot for `branch` (`getLatestSecrets` — one read, - * so the returned `env_file` and `keep_hash` can never disagree with each - * other) and verify that hash against the LOCAL keep's branch entries - * (`SyncEngine.computeKeepHash`) — `transform` only ever touches `targets`/ - * `connector` metadata, never `resource_id`/`value_hash`, so the hash is the - * same whether computed before or after it runs. A match proves the local - * keep's pins for this branch are exactly what's already live, so it's safe - * to (a) push the fetched `env_file` straight through, byte-for-byte, as - * the SAME blob already stored, and (b) build `nextKeep` off the LOCAL keep - * (preserving every other branch's entries exactly as the local file has - * them, same as before). A mismatch means the local keep.lock is stale for - * this branch — skip the write with a warning rather than risk a - * keep/blob pair the server never actually produced together. + * Before anything is uploaded, `resolveFreshSnapshot` verifies the LOCAL keep's pins for this branch are what + * the server holds (a mismatch means the local keep.lock is stale: skip the write with a warning rather than + * record against a keep the caller never saw). * - * `opts.writeLocal` (default `true`) gates the LOCAL side effect below — - * writing the untracked working copy. CI mode passes `false` (CAP-687): it - * must NEVER write keep.lock to disk — CI mode must NEVER touch the user's - * working tree (see `openCiDeployPr`'s own doc for why). Every other - * caller keeps the default. + * `opts.writeLocal` (default `true`) gates the LOCAL side effect — writing the untracked working copy. CI mode + * passes `false` (CAP-687): it must NEVER write keep.lock to disk — CI mode must NEVER touch the user's working + * tree (see `openCiDeployPr`'s own doc for why). Every other caller keeps the default. * - * `opts.warnCode` prefixes every warning this function prints with a - * machine-readable code (Rule 4: never branch on prose, but a human reading - * the warning still gets a stable label for it). Omitted by callers that - * predate CAP-687, whose warning text for the branch-mismatch/auth-failure/ - * exception cases is therefore unchanged; the two NEW warning cases this fix - * introduces (no server snapshot yet, local keep out of sync with it) are - * new regardless, so there's no "unchanged text" to preserve for them. + * `opts.warnCode` prefixes every warning this function prints with a machine-readable code (Rule 4: never + * branch on prose, but a human reading the warning still gets a stable label for it). * - * Returns `{ ok: true }` on an actual push OR a genuine no-op (`transform` - * found nothing to change); `{ ok: false }` on ANY refusal or failure — - * callers that minted a deploy token before calling this (CAP-687 follow-up, - * "no untracked tokens") check `.ok` to decide whether the mint is now - * untracked, and `capy deploy targets-remove` checks it to decide whether - * revoking is still safe. Never throws. + * Returns `{ ok: true }` on an actual push OR a genuine no-op (`transform` found nothing to change); `{ ok: + * false }` on ANY refusal or failure — callers that minted a deploy token before calling this (CAP-687 + * follow-up, "no untracked tokens") check `.ok` to decide whether the mint is now untracked, and `capy deploy + * targets-remove` checks it to decide whether revoking is still safe. Never throws. */ async function pushKeepTransform( cwd: string, @@ -477,18 +464,20 @@ async function pushKeepTransform( const pm = new ProjectManager(cwd); const keep = pm.readKeepFile(); if (!keep) return { ok: false }; - const nextKeep = transform(keep); - if (nextKeep === keep) return { ok: true }; + if (transform(keep) === keep) return { ok: true }; const fresh = await resolveFreshSnapshot(cwd, branch, devMode, `${label} in keep.lock`, opts.warnCode); if (!fresh) return { ok: false }; - const pushed = await fresh.serviceClient.pushSecrets(fresh.projectId, JSON.stringify(nextKeep), fresh.latest.env_file, branch); + // The shared keep-only upload (sync/commit.ts): the server's latest keep and blob, `base_keep_hash`, one retry if stale. + const done = await uploadKeepOnly(fresh.serviceClient, fresh.projectId, branch, transform); + if (!done.ok) { + console.error(` ${YELLOW('!')} could not ${label} in keep.lock: ${codePrefix}${done.code}`); + return { ok: false }; + } if (writeLocal) { - const { SyncEngine } = await import('../sync/syncEngine'); - const fm = new FileManager(cwd); - fm.writeKeepFile(SyncEngine.adoptServerKeep(pushed.keep_file, nextKeep, branch)); + new FileManager(cwd).writeKeepFile(SyncEngine.spliceKeepBranch(keep, SyncEngine.adoptServerKeep(done.serverKeepFile, done.keep, branch), branch)); } return { ok: true }; } catch (err: any) { @@ -1567,18 +1556,18 @@ async function runPicker( // ── Plan rendering ───────────────────────────────────────────────────────── -function renderPlan(target: TargetConfig, adapter: DeployAdapter): void { - console.log(''); - console.log(` ${B('Target:')} ${target.name} ${DIM(`(${adapter.label})`)}`); - console.log(` ${B('Branch:')} ${target.branch}`); +function renderPlan(target: TargetConfig, adapter: DeployAdapter, out: (line: string) => void = console.log): void { + out(''); + out(` ${B('Target:')} ${target.name} ${DIM(`(${adapter.label})`)}`); + out(` ${B('Branch:')} ${target.branch}`); if (target.mode === 'ci' && target.gitBaseBranch) { - console.log(` ${B('PR base:')} ${target.gitBaseBranch}`); + out(` ${B('PR base:')} ${target.gitBaseBranch}`); } for (const [k, v] of Object.entries(target.options)) { - console.log(` ${DIM(k.padEnd(7))}: ${String(v)}`); + out(` ${DIM(k.padEnd(7))}: ${String(v)}`); } - console.log(` ${B('Vars:')} ${target.vars.join(', ')}`); - console.log(''); + out(` ${B('Vars:')} ${target.vars.join(', ')}`); + out(''); } /** @@ -1637,8 +1626,13 @@ export async function deployList(cwd: string = process.cwd()): Promise { export async function deployRemove( name: string, cwd: string = process.cwd(), - opts: { devMode?: boolean; noDeploy?: boolean } = {}, + opts: { devMode?: boolean; noDeploy?: boolean; nonTty?: boolean; conflictFlags?: RawConflictFlags } = {}, ): Promise { + // A bad --resolve / --conflict is refused before anything is read. + conflictFlagsOrExit(opts.conflictFlags ?? {}, false); + // Bare `capy` runs first (docs/conflict-resolution.md), before the platform cleanup, the keep.lock strip (a Keep-only + // upload) and the token revoke: removing a target writes from this folder like every other command does. + await gateFirst({ devMode: opts.devMode, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags }); // Best-effort cleanup for adapters that left something outside // `.capy/deploy.json` (Dokploy's Capy-managed env block). Never gates the // local removal below — it is an offer, not a precondition. @@ -2477,6 +2471,20 @@ export async function deployCommand( return 0; } + // A bad --resolve / --conflict is refused before anything is read. + const conflictFlags = conflictFlagsOrExit(options.conflictFlags ?? {}, false); + + // Bare `capy` runs before anything else: before the picker, the plan, the preflight, the confirm screen, a mint + // or a decrypt. The folder and Capy must agree before this delivers or records anything (docs/conflict-resolution.md); + // what is delivered is then the folder's values, which are Capy's. `--yes` and `--dry-run` runs are asked nothing, + // so a difference ends them with its payload alone on stdout. + await gateFirst({ + devMode: options.devMode, + nonTty: options.yes === true, + conflictFlags: options.conflictFlags, + dryRun: options.dryRun === true, + }); + const keep = readKeep(cwd); if (!keep) { console.error( @@ -2527,7 +2535,11 @@ export async function deployCommand( const reconciled = await reconcileTargetVars(cwd, keep, healed.value, options); if (!reconciled.ok) return reconciled.code; - renderPlan(reconciled.value, adapter); + // `--yes` / `--dry-run` runs (CI, an agent) are asked nothing, and a conflict ends them with its payload ALONE on + // stdout (CAP-717): the plan and the preflight heads-up are printed once the conflicts are settled, not before. + // A run that may ask a person shows them first, as it always did. + const deferPlan = options.yes === true || options.dryRun === true; + if (!deferPlan) renderPlan(reconciled.value, adapter); // CI-only adapters (Vercel) always take the CI/PR path, even if a legacy or // ad-hoc target carries a stale 'direct' mode — capy never runs their CLI. @@ -2577,9 +2589,12 @@ export async function deployCommand( const preflightOutcome = await preflightOrEdit(reconciled.value); if (!preflightOutcome.ok) return 1; const preflight = preflightOutcome.preflight; - for (const w of preflight.warnings ?? []) { - console.log(` ${YELLOW('!')} ${w.message}`); - } + const showPreflightWarnings = (): void => { + for (const w of preflight.warnings ?? []) { + console.log(` ${YELLOW('!')} ${w.message}`); + } + }; + if (!deferPlan) showPreflightWarnings(); // capy never blocks on uncommitted source changes. It only ever stages and // commits keep.lock — your work-in-progress is left exactly as it was. @@ -2598,6 +2613,47 @@ export async function deployCommand( const msg = `chore(deploy): ${target.name} → ${target.branch} (${target.kind})`; const baseBranch = target.gitBaseBranch ?? 'main'; + // ── Platform values (CAP-717) — before the CI gate, a mint, a decrypt or any write. The folder and Capy already + // agree (bare `capy` ran first); on Dokploy the values about to be delivered are compared with the service's + // live env, and every difference is answered (a question per variable in a terminal, --resolve off one). + // Nothing is written until then. + const settled = await (async (): Promise>> => { + try { + return await reconcileDeployConflicts({ + cwd, + target, + adapter, + devMode: options.devMode, + dryRun: !!options.dryRun, + interactive: process.stdin.isTTY === true && !options.yes && !options.json, + flags: conflictFlags, + adapterCtx: adapterCallCtx, + showFailure: renderResult, + }); + } catch (err: any) { + if (isConflictRefusal(err)) { + printConflictRefusal(err); + return { ok: false, code: EXIT_NEEDS_INPUT }; + } + // A preview that could not compare says nothing; a real deploy that could not compare does not deliver. + if (options.dryRun) return { ok: true, skipped: [] }; + console.error(`${RED('✗')} could not compare with Capy: ${err?.message ?? err}`); // COPY-FLAG + return { ok: false, code: 1 }; + } + })(); + if (!settled.ok) return settled.code; + if (deferPlan) { + renderPlan(target, adapter); + showPreflightWarnings(); + } + // What this delivery records: the target without the variables whose platform value was kept (`remote`). + const recorded: TargetConfig = + settled.skipped.length === 0 ? target : { ...target, vars: target.vars.filter((v) => !settled.skipped.includes(v)) }; + if (recorded.vars.length === 0) { + console.log(` ${DIM('·')} nothing to deliver — every value on the platform was kept.`); // COPY-FLAG + return 0; + } + // ── CI change-gate — BEFORE any mint (see resolveCiGateOutcome's own doc: // deciding "did anything change" must never itself require a token, or // every CI run of a token adapter mints and writes one regardless of @@ -2605,7 +2661,7 @@ export async function deployCommand( // — `gateOutcome` stays `null` and the fallback below covers them. const ciGated = gitOk && mode === 'ci' && !options.dryRun; const gateOutcome: CiGateOutcome | null = ciGated - ? await resolveCiGateOutcome(cwd, baseBranch, target, adapter, options) + ? await resolveCiGateOutcome(cwd, baseBranch, recorded, adapter, options) : null; if (gateOutcome?.kind === 'error') return 1; if (gateOutcome?.kind === 'unchanged') return 0; @@ -2633,7 +2689,7 @@ export async function deployCommand( // ── Decrypt the secrets we're about to push (and, for a token adapter, // mint) — only reached when direct mode, or CI mode just decided // something is actually worth shipping. - const secrets = await loadDeploySecrets(cwd, adapter, target, options); + const secrets = await loadDeploySecrets(cwd, adapter, recorded, options); if (!secrets) return 1; const { env, deployToken, valueHashes } = secrets; @@ -2641,7 +2697,7 @@ export async function deployCommand( // — see `buildFinalCiKeep`'s own doc. const changeGate: CiChangeGate = gateOutcome?.kind === 'proceed' - ? buildFinalCiKeep(gateOutcome.baseKeep, env, target, adapter, deployToken?.deployId, options) + ? buildFinalCiKeep(gateOutcome.baseKeep, env, recorded, adapter, deployToken?.deployId, options) : { ok: true, keepLockChanged: false, deployKeepContent: '' }; if (!changeGate.ok) return 1; const { keepLockChanged, deployKeepContent } = changeGate; @@ -2706,7 +2762,15 @@ export async function deployCommand( noDeploy: !!options.noDeploy, cwd, ...adapterCallCtx, + // A dry run's delivery stays a plain dry run (it reads nothing): the preview of conflicts was the step before. + ...(settled.targetCheck === undefined || options.dryRun ? {} : { targetCheck: settled.targetCheck }), }); + // The platform changed between the comparison and the write: the delivery was refused, nothing was written. + if (!result.ok && result.conflicts !== undefined && result.conflicts.length > 0 && settled.view !== undefined) { + await unwindGitState(cwd, null, directStashed); + printConflictRefusal(conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ ...settled.view, conflicts: result.conflicts }])); + return EXIT_NEEDS_INPUT; + } renderResult(result); if (!result.ok) { await unwindGitState(cwd, null, directStashed); @@ -2728,17 +2792,17 @@ export async function deployCommand( // same as the PR-open below it: only when the CI gate actually decided to // proceed is there a real delivery worth recording server-side. if (mode === 'direct' && !options.dryRun) { - await recordDeployTargets(cwd, target, adapter, valueHashes, deployToken?.deployId, options.devMode, !!options.noDeploy); + await recordDeployTargets(cwd, recorded, adapter, valueHashes, deployToken?.deployId, options.devMode, !!options.noDeploy); // "No untracked tokens" (CAP-679 follow-up): only once THIS deploy is a // REAL one (never `--no-deploy` — that config is pending, and its // predecessor may still be the one actually running) does it become safe // to revoke whatever `recordDeployTargets` just moved to // `superseded_deploy_ids` — see `revokeSupersededDeployTokens`'s own doc. if (!options.noDeploy) { - await revokeSupersededDeployTokens(cwd, target, adapter, options.devMode); + await revokeSupersededDeployTokens(cwd, recorded, adapter, options.devMode); } } else if (mode === 'ci' && !options.dryRun && keepLockChanged) { - await recordDeployTargetsCi(cwd, target, adapter, valueHashes, deployToken?.deployId, options.devMode, !!options.noDeploy); + await recordDeployTargetsCi(cwd, recorded, adapter, valueHashes, deployToken?.deployId, options.devMode, !!options.noDeploy); } // CI mode: open the keep.lock PR in an ISOLATED git worktree (see diff --git a/src/commands/deployConflicts.ts b/src/commands/deployConflicts.ts new file mode 100644 index 00000000..b245ee04 --- /dev/null +++ b/src/commands/deployConflicts.ts @@ -0,0 +1,138 @@ +/** + * Platform values for `capy deploy` (CAP-717; the contract is docs/conflict-resolution.md). + * + * The folder and Capy agree by the time a deploy gets here: bare `capy` ran as its very first step + * (sync/gate.ts), so what is delivered is the folder's values, which are Capy's. What remains is the one + * place Capy cannot see on its own, a Dokploy service's live env (`adapter.readsLiveValues`; other platforms + * cannot be read back and are unchanged): + * + * A value changed on the platform outside Capy since Capy last delivered it (or, on a first delivery, a + * different value already there) is a decision. `local` = deliver Capy's value; `remote` = keep the + * platform's value and skip that variable. + * + * In a terminal one question per variable; off a terminal the protocol (`sync/conflicts.ts`: nothing is + * written, exit 3, the rows with a `conflict_id` on stdout, `--resolve NAME@TARGET=local|remote` + + * `--conflict` answer them). This module writes nothing. Names and fingerprints only, never a value. + */ +import { ProjectManager } from '../core/projectManager'; +import { FileManager } from '../files/fileManager'; +import type { AdapterCallContext, DeployAdapter, DeployResult, TargetConfig } from '../deploy/adapter'; +import { deployedHashesFor } from '../deploy/targetsGate'; +import type { TargetCheck } from '../deploy/dokployApi'; +import { CapyError, ERROR_CODES } from '../types/index'; +import { baseHashOf, readLocalPlaintext, readSnapshot } from '../sync/commit'; +import { + conflictRefusalFor, + gateConflicts, + seenResolutions, + targetResolutions, + type Conflict, + type ConflictFlags, + type ConflictView, + type Resolutions, +} from '../sync/conflicts'; +import { askPlatformConflicts } from './conflictPrompt'; + +export interface ReconcileInput { + readonly cwd: string; + readonly target: TargetConfig; + readonly adapter: DeployAdapter; + readonly devMode?: boolean; + /** `--dry-run`: report the rows (the preview), write nothing, ask nothing. */ + readonly dryRun: boolean; + /** A person is at a terminal and can be asked. */ + readonly interactive: boolean; + readonly flags: ConflictFlags; + /** What the adapter needs to read the platform (the pre-resolved key, the org). */ + readonly adapterCtx: AdapterCallContext; + /** Prints a failed adapter result (the caller's renderer). */ + readonly showFailure: (result: DeployResult) => void; +} + +export type Reconciled = + | { + readonly ok: true; + /** Names whose platform value was kept (`remote`): not delivered, not recorded. */ + readonly skipped: readonly string[]; + /** Hand to the adapter: what Capy last delivered, and the answers. Absent: the adapter does not compare. */ + readonly targetCheck?: TargetCheck; + /** Where the rows were looked for (the id's scope); absent when nothing was compared. */ + readonly view?: ConflictView; + } + | { readonly ok: false; readonly code: number }; + +const NOTHING_TO_COMPARE: Reconciled = { ok: true, skipped: [] }; + +/** What a platform comparison needs of the folder: its keep, its `.env` values and Capy's keep hash for the branch. */ +async function openFolder(cwd: string, branch: string, devMode: boolean | undefined) { + const pm = new ProjectManager(cwd); + const state = await pm.detectProjectState(); + const keep = pm.readKeepFile(); + if (!state.initialized || !state.organizationId || !state.projectId || !keep) return undefined; + + const { AuthService, silentAuthFailureMessage } = await import('../auth/authService'); + const { ServiceClient } = await import('../service/serviceClient'); + const { resolveProjectKey } = await import('../crypto/keyResolver'); + + const auth = new AuthService(undefined, devMode, state.userId); + const scoped = await auth.authenticateSilent(state.organizationId); + const signedIn = scoped.success ? scoped : await auth.authenticateSilent(); + if (!signedIn.success || !signedIn.user_id) { + throw new CapyError(silentAuthFailureMessage(signedIn), ERROR_CODES.AUTH_FAILED); + } + const serviceClient = new ServiceClient(undefined, devMode); + serviceClient.setTokenProvider(() => auth.getValidToken()); + const projectKey = await resolveProjectKey(state.organizationId, state.projectId, signedIn.user_id, { + coDecrypt: (o, c) => serviceClient.coDecrypt(o, c).then((r) => r.plaintext), + wrapOuterLayer: (o, p) => serviceClient.wrapOuterLayer(o, p).then((r) => r.ciphertext), + }); + const snapshot = await readSnapshot(serviceClient, state.projectId, branch, projectKey).catch((err: unknown) => + err instanceof CapyError && err.details?.status === 404 ? { keep: undefined, entries: {} } : Promise.reject(err), + ); + return { + keep, + localPlaintext: readLocalPlaintext(new FileManager(cwd), projectKey), + view: { project: keep.project_name, branch, serverKeepHash: baseHashOf(snapshot, branch) ?? '' }, + }; +} + +/** + * Compares the values this delivery will write with the service's live env, and settles every difference + * (a question per variable in a terminal, `--resolve` off one). Throws a `ConflictRefusal` off a terminal when + * the flags do not answer every row (nothing has been written); any other error is a failure to compare, and the + * caller does not deliver. + */ +export async function reconcileDeployConflicts(input: ReconcileInput): Promise { + const { target, adapter } = input; + // A token adapter mints from the server's own blob: no folder value is delivered. Only Dokploy can be read back. + if (adapter.needsDeployToken || adapter.readsLiveValues !== true || target.vars.length === 0) return NOTHING_TO_COMPARE; + const folder = await openFolder(input.cwd, target.branch, input.devMode); + if (folder === undefined) return NOTHING_TO_COMPARE; + + const env = Object.fromEntries(target.vars.flatMap((v) => (folder.localPlaintext[v] === undefined ? [] : [[v, folder.localPlaintext[v]] as const]))); + const targetCheck: TargetCheck = { pinned: deployedHashesFor(folder.keep, target.branch, adapter.id, target.name), resolutions: {} }; + + const live = await adapter.deploy(target, { ...input.adapterCtx, env, dryRun: true, cwd: input.cwd, targetCheck }); + if (!live.ok) { + input.showFailure(live); + return { ok: false, code: 1 }; + } + const rows: readonly Conflict[] = live.conflicts ?? []; + const scopes = [{ ...folder.view, conflicts: rows }]; + + if (input.dryRun) { + if (rows.length > 0) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }); + return { ok: true, skipped: [], targetCheck, view: folder.view }; + } + + const answers: Resolutions = input.interactive ? await askPlatformConflicts(rows) : gateConflicts(input.flags, scopes); + + // The answers, each bound to the fingerprints they were made against (the adapter re-reads the service). + const platform = targetResolutions(seenResolutions(answers, rows), target.name); + return { + ok: true, + skipped: Object.entries(platform).flatMap(([name, seen]) => (seen.choice === 'remote' ? [name] : [])), + targetCheck: { ...targetCheck, resolutions: platform }, + view: folder.view, + }; +} diff --git a/src/commands/deployDiscover/route.ts b/src/commands/deployDiscover/route.ts index b1f178b2..af89472a 100644 --- a/src/commands/deployDiscover/route.ts +++ b/src/commands/deployDiscover/route.ts @@ -7,6 +7,8 @@ * target has a discovery; any other target is refused with a code. */ import { ERROR_CODES } from '../../types/index'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../../sync/conflicts'; +import { gateFirst } from '../../sync/gate'; export interface DeployDiscoverFlags { readonly discover?: boolean; @@ -26,6 +28,7 @@ export async function routeDeployDiscover( flags: DeployDiscoverFlags, dryRun: boolean, devMode: boolean, + conflictFlags: RawConflictFlags = {}, ): Promise { const discovering = flags.discover === true; const discoverOnly = flags.plan !== undefined || flags.confirm !== undefined || flags.baseUrl !== undefined; @@ -36,6 +39,12 @@ export async function routeDeployDiscover( if (target !== 'dokploy') { return printRefusal(ERROR_CODES.DISCOVER_UNSUPPORTED_TARGET, '--discover is only available for the dokploy target: capy deploy dokploy --discover.'); // COPY-FLAG } + // Bare `capy` runs first, before anything is discovered, planned or written (docs/conflict-resolution.md). This run + // writes no env: its only writes are `.capy/deploy.json` PRs into the discovered repos, through the GitHub API, so no + // per-repo sync is needed beyond this one in the working directory. It never prompts, so a difference is reported + // (exit 3, the payload alone on stdout) and answered by a rerun with `--resolve` + `--conflict`. + conflictFlagsOrExit(conflictFlags, true); + await gateFirst({ devMode, json: true, nonTty: true, conflictFlags, dryRun }); const { deployDokployDiscoverCommand } = await import('./index'); return deployDokployDiscoverCommand( { dryRun, plan: flags.plan, confirm: flags.confirm, baseUrl: flags.baseUrl }, diff --git a/src/commands/deployTokenCommand.ts b/src/commands/deployTokenCommand.ts index 65b0953a..6f9ed6a8 100644 --- a/src/commands/deployTokenCommand.ts +++ b/src/commands/deployTokenCommand.ts @@ -18,8 +18,12 @@ import inquirer from 'inquirer'; import { generateDeployHtml } from '../ui/deployPage/html'; import { hashValue } from '../deploy/keepGate'; import { stripTargetsForDeployId } from '../deploy/targetsGate'; +import { uploadKeepOnly } from '../sync/commit'; +import { SyncEngine } from '../sync/syncEngine'; import { ERROR_CODES } from '../types/index'; import type { ProjectState } from '../types/index'; +import { gateFirst } from '../sync/gate'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; /** * `deploy revoke ` (CAP-679): strip every `targets` element carrying @@ -64,24 +68,16 @@ async function stripRevokedTargets( return; } - const { resolveProjectKey: resolveKey } = await import('../crypto/keyResolver'); - const { Encryptor } = await import('../crypto/encryptor'); - const { deriveResourceId } = await import('../crypto/resourceId'); - const projectKey = await resolveKey(projectState.organizationId, projectState.projectId, userId, { - coDecrypt: (o, c) => serviceClient.coDecrypt(o, c).then((r) => r.plaintext), - wrapOuterLayer: (o, p) => serviceClient.wrapOuterLayer(o, p).then((r) => r.ciphertext), - }); - const fm = new FileManager(); - const rawLocal = fm.readEnvFile(); - const localPlaintext = Object.fromEntries( - Object.entries(rawLocal).map(([k, v]) => [k, v.startsWith('capy:') ? fm.decryptValue(v, projectKey) : v]), - ); - const envBlob = Object.entries(localPlaintext) - .map(([k, v]) => `${k}=capy:${deriveResourceId(branch, k)}:${Encryptor.encrypt(v, projectKey)}`) - .join('\n'); - const pushed = await serviceClient.pushSecrets(projectState.projectId, JSON.stringify(nextKeep), envBlob, branch); - const { SyncEngine } = await import('../sync/syncEngine'); - fm.writeKeepFile(SyncEngine.adoptServerKeep(pushed.keep_file, nextKeep, branch)); + // A keep-only change on the SERVER's latest, through the shared keep-only upload (sync/commit.ts): no value + // moves, so nothing in this folder's `.env` can leak into the blob or revert anyone's value. + const done = await uploadKeepOnly(serviceClient, projectState.projectId, branch, (latest) => stripTargetsForDeployId(latest, deployId)); + if (!done.ok) { + console.error(` \x1b[33m!\x1b[0m could not strip revoked deploy targets in keep.lock: ${done.code}`); + return; + } + if (done.pushed) { + new FileManager().writeKeepFile(SyncEngine.spliceKeepBranch(keep, SyncEngine.adoptServerKeep(done.serverKeepFile, done.keep, branch), branch)); + } } catch (err: any) { console.error(` \x1b[33m!\x1b[0m could not strip revoked deploy targets in keep.lock: ${err?.message ?? err}`); } @@ -336,6 +332,12 @@ export interface DeployCommandOptions { yes?: boolean; /** Forwarded to the connector flow: force a redeploy even if keep.lock is unchanged. */ force?: boolean; + /** `--json`: stdout belongs to JSON, so bare `capy`'s differences are reported, not asked. */ + json?: boolean; + /** `--dry-run`: bare `capy`'s differences are reported (with `dry_run`), never answered. */ + dryRun?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717). */ + conflictFlags?: RawConflictFlags; } /** org-scoped silent → unscoped silent → interactive: the first success, else the last attempt. */ @@ -489,6 +491,19 @@ export class DeployCommand { } async execute(): Promise { + // A bad --resolve / --conflict is refused before anything is read or authenticated. + conflictFlagsOrExit(this.options.conflictFlags ?? {}, this.options.json === true); + // Bare `capy` runs before anything else (docs/conflict-resolution.md): before detecting the project, signing in, the + // picker, a mint of deploy credentials, or the GitHub Actions connector. The folder and Capy must agree before this + // mints a token from the folder's `.env`; off a terminal (`--yes`, `--json`, `--dry-run`) a difference ends the run + // with its payload alone on stdout. + await gateFirst({ + devMode: this.devMode, + nonTty: this.options.yes === true, + json: this.options.json === true, + conflictFlags: this.options.conflictFlags, + dryRun: this.options.dryRun === true, + }); try { const pm = new ProjectManager(); const fm = new FileManager(); @@ -604,7 +619,12 @@ export class DeployRevokeCommand { this.devMode = devMode; } - async execute(deployIdPrefix: string): Promise { + async execute(deployIdPrefix: string, opts: { readonly nonTty?: boolean; readonly conflictFlags?: RawConflictFlags } = {}): Promise { + // A bad --resolve / --conflict is refused before anything is read or authenticated. + conflictFlagsOrExit(opts.conflictFlags ?? {}, false); + // Bare `capy` runs first (docs/conflict-resolution.md), before the revoke and the keep.lock strip that follows it + // (a Keep-only upload): revoking a deploy writes from this folder like every other command does. + await gateFirst({ devMode: this.devMode, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags }); try { const pm = new ProjectManager(); const projectState = await pm.detectProjectState(); diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index aa318077..e733f121 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -1,20 +1,19 @@ -import { createHash } from 'crypto'; import { ProjectManager } from '../core/projectManager'; import { FileManager } from '../files/fileManager'; import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { SyncEngine } from '../sync/syncEngine'; -import { fetchSecretsWithCache, readKeepCache, writeKeepCache, readSecretsLocal, LOCAL_USER_ID } from '../config/globalConfig'; +import { readSecretsLocal, LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { hashValue } from './statusCommand'; -import { EditScreen, EditRow, EditState, classifyLocalRow, focusedOn, withDeployStatus } from '../ui/editScreen'; +import { EditScreen, EditRow, EditState, EditLoaded, classifiedRow, focusedOn, withPendingEdits } from '../ui/editScreen'; import { deployedHashesFor } from '../core/deployStatus'; import { readDefaultBranchTargets } from '../deploy/configuredTargets'; -import { formatRelativeTime } from '../ui/relativeTime'; -import { Encryptor } from '../crypto/encryptor'; -import { deriveResourceId } from '../crypto/resourceId'; -import { setSyncKeepHash, KeepFile } from '../types/index'; +import { KeepFile } from '../types/index'; +import { commitFolder, pinnedHashes, readLocalPlaintext, readSnapshot, type FolderContext, type Snapshot } from '../sync/commit'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import { reportKeepLockHuman, refuseBadPrFlags, runKeepLockPrStep, type PrFlags } from './keepLockPr'; import { startSaveLog } from './sessionSaveLog'; import { decideEditMode, editPipedCommand, refuseEditNeedsTty } from './editPiped'; @@ -24,128 +23,197 @@ import { printRepoLinkWarnings, reportRepoLink } from '../core/repoLinkReporter' const B = (s: string) => `\x1b[1m${s}\x1b[0m`; -type RowStatus = EditRow['status']; - -function classifyStatus( - pinned: string | undefined, - local: string | undefined, - remote: string | undefined, - remoteAvailable: boolean, -): RowStatus { - if (!remoteAvailable) return 'unknown'; - if (pinned === local && pinned === remote) return 'in sync'; - const localDiffers = local !== pinned; - const remoteDiffers = remote !== pinned; - if (localDiffers && remoteDiffers && local !== remote) return 'conflict'; - if (localDiffers) return 'local'; - if (remoteDiffers) return 'remote'; - return 'in sync'; -} +const has = (record: object, name: string): boolean => Object.hasOwn(record, name); /** - * Decrypts `.env`. A value this profile holds no key for is skipped; - * plaintext values pass through. + * What the rows are compared against: the other side's value (and fingerprint) per variable. Server mode: the + * server's LATEST for the branch, the very read a save is built on (`seen`), never the folder's pinned + * snapshot. Local-only mode: the committed copy in the local cache, with no server. */ -function decryptLocalEnv( - fileManager: FileManager, - projectKey: string, -): { localPlaintext: Record } { - const decrypted = Object.entries(fileManager.readEnvFile()).map(([key, value]) => { - if (!value.startsWith('capy:')) return { key, plain: value as string | undefined }; - try { - return { key, plain: fileManager.decryptValue(value, projectKey) as string | undefined }; - } catch { - return { key, plain: undefined }; // Skip values we can't decrypt - } - }); - return { - localPlaintext: Object.fromEntries( - decrypted.flatMap(({ key, plain }) => (plain === undefined ? [] : [[key, plain] as const])), - ), - }; +export interface Baseline { + readonly entries: Readonly>; + /** The keep the UPDATED column and the deploy status read: the server's. */ + readonly keep: KeepFile | undefined; + readonly available: boolean; + /** The server read, handed to the commit. */ + readonly seen: Snapshot | undefined; } -interface Baseline { - remotePlaintext: Record; - remoteAvailable: boolean; -} +/** The server could not be read (or there is none): nothing to compare against, rows read `unknown`. */ +const BASELINE_UNAVAILABLE: Baseline = { entries: {}, keep: undefined, available: false, seen: undefined }; -/** Remote fetch failed (server mode), or nothing to compare against: fall back to pinned-only. */ -const BASELINE_UNAVAILABLE: Baseline = { remotePlaintext: {}, remoteAvailable: false }; - -async function loadBaseline(args: { - localMode: boolean; - serviceClient: ServiceClient | undefined; - fileManager: FileManager; - projectKey: string; - keep: KeepFile; - branch: string; - orgId: string; - projectId: string; +/** The server's latest for the branch: one read, the one the commit path uses. */ +async function loadServerBaseline(args: { + readonly serviceClient: Pick; + readonly projectId: string; + readonly branch: string; + readonly projectKey: string; }): Promise { - const { localMode, serviceClient, fileManager, projectKey, keep, branch, orgId, projectId } = args; - const keepHash = SyncEngine.computeKeepHash(keep, branch); - if (cacheProbeThrew(localMode, orgId, projectId, keepHash)) return BASELINE_UNAVAILABLE; try { - const blob = localMode - ? readSecretsLocal(orgId, projectId, keepHash) - : await fetchSecretsWithCache(serviceClient!, orgId, projectId, keepHash); + const snapshot = await readSnapshot(args.serviceClient, args.projectId, args.branch, args.projectKey); + return { + entries: Object.fromEntries(Object.entries(snapshot.entries).map(([name, e]) => [name, { plaintext: e.plaintext, hash: e.hash }])), + keep: snapshot.keep, + available: true, + seen: snapshot, + }; + } catch { + return BASELINE_UNAVAILABLE; + } +} + +/** Local-only mode: the committed copy in the local cache, for the committed-vs-working comparison. */ +function loadLocalBaseline(args: { + readonly fileManager: Pick; + readonly projectKey: string; + readonly keep: KeepFile; + readonly branch: string; + readonly orgId: string; + readonly projectId: string; +}): Baseline { + const { fileManager, projectKey, keep, branch, orgId, projectId } = args; + try { + const blob = readSecretsLocal(orgId, projectId, SyncEngine.computeKeepHash(keep, branch)); if (!blob?.env_file) return BASELINE_UNAVAILABLE; const encrypted = fileManager.parseEnvContent(blob.env_file); - const remotePlaintext = Object.fromEntries( + const entries = Object.fromEntries( Object.entries(encrypted).flatMap(([key, value]) => { try { - return [[key, fileManager.decryptValue(value, projectKey)] as const]; + const plaintext = fileManager.decryptValue(value, projectKey); + return [[key, { plaintext, hash: hashValue(plaintext) }] as const]; } catch { return []; // Skip values we can't decrypt } }), ); - // Remote column only applies to server mode; local mode uses the - // committed baseline with local-mode wording instead. - return { remotePlaintext, remoteAvailable: !localMode }; + // The remote column only applies to server mode; local mode uses the committed copy with local-mode wording instead. + return { entries, keep: undefined, available: false, seen: undefined }; } catch { return BASELINE_UNAVAILABLE; } } /** - * CAP-702: the keep file whose deploy-target records `not deployed` is read - * from. The server's copy is the record (CI-mode deploys record targets - * there, not in the local keep.lock until their PR merges); the local - * keep.lock is the fallback when the server can't be read, and the only - * source in local mode. + * The rows: every variable known to the folder's pin, its `.env` or the other side, compared three ways (pinned, + * local, remote) in server mode, committed-vs-working in local mode. Pure. */ -async function loadDeployKeep(args: { - localMode: boolean; - serviceClient: ServiceClient | undefined; - projectId: string; - branch: string; - localKeep: KeepFile; -}): Promise { - const { localMode, serviceClient, projectId, branch, localKeep } = args; - if (localMode || !serviceClient) return localKeep; - try { - const latest = await serviceClient.getLatestSecrets(projectId, branch); - const parsed: unknown = latest?.keep_file ? JSON.parse(latest.keep_file) : undefined; - return isKeepWithVariables(parsed) ? parsed : localKeep; - } catch { - return localKeep; - } +export function buildEditRows(input: { + readonly localMode: boolean; + readonly branch: string; + /** The folder's keep.lock now. */ + readonly keep: KeepFile; + readonly localPlaintext: Readonly>; + readonly baseline: Baseline; + readonly configuredTargets: ReturnType; +}): readonly EditRow[] { + const { localMode, branch, keep, localPlaintext, baseline, configuredTargets } = input; + const pinned = pinnedHashes(keep, branch); + const stampKeep = baseline.keep ?? keep; + const names = [...new Set([...Object.keys(pinned), ...Object.keys(localPlaintext), ...Object.keys(baseline.entries)])]; + const mode = { localMode, remoteAvailable: baseline.available }; + // A name only the pin remembers, that neither the folder nor the server holds any more, is not a variable. + const present = names.filter((key) => localMode || !baseline.available || has(localPlaintext, key) || has(baseline.entries, key)); + // Sorting a copy that was built by the filter above is construction, not mutation. + return present.sort().map((key) => { + const entry = has(baseline.entries, key) ? baseline.entries[key] : undefined; + const base: EditRow = { + key, + localValue: has(localPlaintext, key) ? localPlaintext[key] : undefined, + remoteValue: entry?.plaintext, + ...(entry !== undefined && entry.plaintext === undefined && entry.hash !== undefined ? { remoteHash: entry.hash } : {}), + // Server mode: the pin, so the row is compared three ways. Local mode: committed-vs-working needs none. + ...(localMode ? {} : { pinnedHash: pinned[key] ?? null }), + status: 'unknown', + updatedLabel: '—', + // Server-assigned changed_at for this branch — drives the UPDATED column's recency label ("5 hours ago"). Absent in + // local mode and for entries that predate rotation tracking. + changedAt: stampKeep.variables[key]?.find((e) => e.branch === branch)?.changed_at, + deployedHashes: deployedHashesFor(stampKeep, key, branch, configuredTargets), + }; + return classifiedRow(base, mode); + }); } -function isKeepWithVariables(value: unknown): value is KeepFile { - return typeof value === 'object' && value !== null && typeof (value as { variables?: unknown }).variables === 'object'; +/** + * The rows, from the folder as it is now and the other side as it is now: the server's LATEST (the read a commit + * plans against), or in local-only mode the committed copy. Called for the first screen, and again after every save + * and whenever the server moved under one. + */ +export function editLoader(args: { + readonly pm: Pick; + readonly fileManager: Pick; + /** Absent in local-only mode. */ + readonly serviceClient: Pick | undefined; + readonly localMode: boolean; + /** The keep.lock the command started with: used if the folder's cannot be read again. */ + readonly keep: KeepFile; + readonly branch: string; + readonly orgId: string; + readonly projectId: string; + readonly projectKey: string; + readonly configuredTargets: ReturnType; +}): () => Promise { + const { pm, fileManager, serviceClient, localMode, branch, orgId, projectId, projectKey, configuredTargets } = args; + return async () => { + const folderKeep = pm.readKeepFile() ?? args.keep; + const baseline = serviceClient + ? await loadServerBaseline({ serviceClient, projectId, branch, projectKey }) + : loadLocalBaseline({ fileManager, projectKey, keep: folderKeep, branch, orgId, projectId }); + return { + // Decrypt local .env values. Local ciphertext this profile does not hold the key for is skipped. + rows: buildEditRows({ localMode, branch, keep: folderKeep, localPlaintext: readLocalPlaintext(fileManager, projectKey), baseline, configuredTargets }), + remoteAvailable: baseline.available, + seen: baseline.seen, + }; + }; } -/** Whether probing the keep cache for this keep hash threw (the baseline is then unavailable). */ -function cacheProbeThrew(localMode: boolean, orgId: string, projectId: string, keepHash: string): boolean { - try { - if (!localMode) readKeepCache(orgId, projectId, keepHash); - return false; - } catch { - return true; - } +/** + * One save from the edit screen (`c`, or commit on quit). The shared upload (sync/commit.ts): only the + * edited rows change, on top of the server's latest, and the upload names the keep the screen read (`seen`, + * `base_keep_hash`). If Capy moved since, the server refuses it as stale (`SECRETS_STALE_BASE`, nothing + * uploaded): the screen closes, the command runs bare `capy` and reopens it. The folder is read afresh for + * every save, because an earlier save in the same session moved keep.lock and `.env`. Resolves to the + * server-assigned `changed_at` of every variable on the branch, for the screen's UPDATED column. + */ +export async function saveEdits( + folder: Omit, + startKeep: KeepFile, + edits: Readonly>, + saveLog: Pick, 'record'>, + /** The server read the screen's rows were drawn from. */ + seen?: Snapshot, +): Promise> { + const { branch } = folder; + const committed = await commitFolder( + { + ...folder, + keep: folder.pm.readKeepFile() ?? startKeep, + localPlaintext: readLocalPlaintext(folder.fileManager, folder.projectKey), + }, + { set: edits, remove: [] }, + { seen }, + ); + const adoptedKeep = committed.keep; + + // Record this save for the exit-time PR: the resulting (or deleted) + // entry, for THIS branch only, of every variable this save touched. + saveLog.record({ + branch, + entries: Object.keys(edits).map((variable) => ({ + variable, + entry: adoptedKeep.variables[variable]?.find((e) => e.branch === branch) ?? null, + })), + }); + + // Hand the server-assigned changed_at back to the TUI so the UPDATED + // column reflects the authoritative stamp for this commit, not a + // client-side guess. + return Object.fromEntries( + Object.entries(adoptedKeep.variables) + .map(([varName, entries]) => [varName, entries.find((e) => e.branch === branch)?.changed_at] as const) + .filter((pair): pair is [string, string] => pair[1] !== undefined), + ); } export interface EditOpts { @@ -165,6 +233,8 @@ export interface EditOpts { pr?: PrFlags; /** The global `--dry-run`: piped mode previews; the TUI refuses (`DRY_RUN_UNSUPPORTED`). */ dryRun?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ + conflictFlags?: RawConflictFlags; } export class EditCommand { @@ -240,6 +310,9 @@ export class EditCommand { return refuseDryRunUnsupported(opts.json === true, DRY_RUN_UNSUPPORTED_MESSAGES.edit); } if (opts.name !== undefined && !isValidVarName(opts.name)) return refuseInvalidName(opts.json === true); + // A bad flag is refused here, before stdin is read or anything is authenticated. + conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true); + const gate: GateOptions = { devMode: this.devMode, json: opts.json === true, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun === true }; if (mode === 'piped' && opts.name !== undefined) { return editPipedCommand(opts.name, { json: opts.json === true, @@ -247,9 +320,13 @@ export class EditCommand { devMode: this.devMode, pr: opts.pr, dryRun: opts.dryRun === true, + gate, }); } + // Bare `capy` runs before the screen opens: the folder and Capy must agree before anything here can write. + await gateFirst(gate); + const pm = new ProjectManager(); const projectState = await pm.detectProjectState(); @@ -273,14 +350,6 @@ export class EditCommand { } const fileManager = new FileManager(); - // Pinned hashes for the active branch - const pinned: Record = Object.fromEntries( - Object.entries(keep.variables).flatMap(([varName, entries]) => { - const entry = entries.find((e) => e.branch === branch); - return entry ? [[varName, entry.value_hash] as const] : []; - }), - ); - // Local-only mode: no auth, no server. Identity is synthetic; the key is // unwrapped from the passphrase session. No AuthService/ServiceClient is // constructed (avoids the dev-mode "[dev] AuthService → …" log and any @@ -294,69 +363,18 @@ export class EditCommand { const projectKey = await this.resolveKey({ localMode, orgId, projectId, userId, serviceClient, keep, branch }); if (projectKey === undefined) return; - // Decrypt local .env values. Local ciphertext this profile does not hold - // the key for is skipped. - const { localPlaintext } = decryptLocalEnv(fileManager, projectKey); - - // Baseline the working copy is compared against: - // - remote mode: the latest committed blob fetched from the server. - // - local mode: the committed blob from the local keep cache (no server). - // In both cases it lands in `remotePlaintext` so the TUI's reclassify can - // compare working-vs-baseline. - const { remotePlaintext, remoteAvailable } = await loadBaseline({ - localMode, - serviceClient, - fileManager, - projectKey, - keep, - branch, - orgId, - projectId, - }); - - const deployKeep = await loadDeployKeep({ localMode, serviceClient, projectId, branch, localKeep: keep }); const configuredTargets = readDefaultBranchTargets(process.cwd()); - // Build rows for every variable known to any source - const allKeys = new Set([ - ...Object.keys(pinned), - ...Object.keys(localPlaintext), - ...Object.keys(remotePlaintext), - ]); - - // Sorting a copy that was built one line above is construction, not mutation. - const rows: EditRow[] = [...allKeys].sort().map((key) => { - const localVal = localPlaintext[key]; - const remoteVal = remotePlaintext[key]; - const pinnedHash = pinned[key]; - const localHash = localVal !== undefined ? hashValue(localVal) : undefined; - const remoteHash = remoteVal !== undefined ? hashValue(remoteVal) : undefined; - - // Server-assigned changed_at for this branch — drives the UPDATED - // column's recency label ("5 hours ago"). Absent in local mode and for - // entries that predate rotation tracking. - const changedAt = keep.variables[key]?.find((e) => e.branch === branch)?.changed_at; - // Local mode: committed-vs-working, via the shared classifier so the - // initial build and the in-TUI reclassify can't drift. `remoteVal` holds - // the committed value from the local keep cache. - const { status, updatedLabel } = localMode - ? classifyLocalRow(localVal, remoteVal) - : { - status: classifyStatus(pinnedHash, localHash, remoteHash, remoteAvailable), - updatedLabel: changedAt ? formatRelativeTime(changedAt) : '—', - }; - const deployedHashes = deployedHashesFor(deployKeep, key, branch, configuredTargets); - const base: EditRow = { key, localValue: localVal, remoteValue: remoteVal, status, updatedLabel, changedAt, deployedHashes }; - - return { ...base, status: withDeployStatus(base, status) }; - }); + const load = editLoader({ pm, fileManager, serviceClient, localMode, keep, branch, orgId, projectId, projectKey, configuredTargets }); + const loaded = await load(); const baseState: EditState = { projectName: keep.project_name, branch, - rows, - remoteAvailable, + rows: loaded.rows, + remoteAvailable: loaded.remoteAvailable, localMode, + seen: loaded.seen, }; // `capy edit NAME` on a terminal: the cursor starts on NAME (a new name opens // its value entry). @@ -373,98 +391,9 @@ export class EditCommand { const saveLog = startSaveLog(); const editContext = { - saveLocalEdits: async (edits: Record) => { - // Same flow as the conflict-resolution "commit local" action and - // PushCommand: encrypt the merged local state, mergeWithKeep, push - // to the server, then cache + write keep.lock + .env + sync state. - const finalEnv: Record = { ...localPlaintext, ...edits }; - - const encrypted = Object.fromEntries( - Object.entries(finalEnv).map(([key, value]) => { - const resourceId = deriveResourceId(branch, key); - const enc = Encryptor.encrypt(value, projectKey); - return [key, `capy:${resourceId}:${enc}`]; - }), - ); - const envBlob = Object.entries(encrypted) - .map(([k, v]) => `${k}=${v}`) - .join('\n'); - - const pushedVars = Object.fromEntries( - Object.entries(finalEnv).map(([key, value]) => [ - key, - { - resource_id: deriveResourceId(branch, key), - value_hash: createHash('sha256').update(value).digest('hex').slice(0, 16), - }, - ]), - ); - - const syncEngine = new SyncEngine(); - const mergedKeep = syncEngine.mergeWithKeep(keep, pushedVars, branch); - - // Drop branch entries for variables no longer in finalEnv — built as - // a new object rather than mutated in place (was a `for` loop doing - // `finalKeep.variables[varName] = entries` / `delete - // finalKeep.variables[varName]` on the value mergeWithKeep returned). - const finalKeep: KeepFile = { - ...mergedKeep, - variables: Object.fromEntries( - Object.entries(mergedKeep.variables).flatMap(([varName, entries]) => { - if (varName in finalEnv) return [[varName, entries]]; - const kept = entries.filter((e) => e.branch !== branch); - return kept.length > 0 ? [[varName, kept]] : []; - }), - ), - }; - - // keep_hash is computed locally; the server returns the same value on - // push. In local-only mode there is no push — the local writes below - // ARE the commit. - const localKeepHash = SyncEngine.computeKeepHash(finalKeep, branch); - const pushResult = localMode - ? null - : await serviceClient!.pushSecrets( - projectId, - JSON.stringify(finalKeep), - envBlob, - branch, - ); - const keepHashForCache = pushResult ? pushResult.keep_hash : localKeepHash; - - writeKeepCache(orgId, projectId, keepHashForCache, envBlob); - // Prefer the server's copy — it carries server-assigned changed_at - const adoptedKeep = SyncEngine.adoptServerKeep(pushResult?.keep_file, finalKeep, branch); - fileManager.writeKeepFile(adoptedKeep); - fileManager.writeEncryptedEnvFile(finalEnv, projectKey, undefined, finalKeep, branch); - - const existingSyncState = pm.readSyncState(); - fileManager.writeSyncState({ - ...existingSyncState, - last_sync: new Date().toISOString(), - synced_variables: Object.keys(finalEnv), - user_id: userId, - keep_hash: setSyncKeepHash(existingSyncState, branch, localKeepHash), - }); - - // Record this save for the exit-time PR: the resulting (or deleted) - // entry, for THIS branch only, of every variable this save touched. - const touchedEntries = Object.keys(edits).map((variable) => ({ - variable, - entry: adoptedKeep.variables[variable]?.find((e) => e.branch === branch) ?? null, - })); - saveLog.record({ branch, entries: touchedEntries }); - - // Hand the server-assigned changed_at back to the TUI so the UPDATED - // column reflects the authoritative stamp for this commit, not a - // client-side guess. - const changedAtByKey = Object.fromEntries( - Object.entries(adoptedKeep.variables) - .map(([varName, entries]) => [varName, entries.find((e) => e.branch === branch)?.changed_at] as const) - .filter((pair): pair is [string, string] => pair[1] !== undefined), - ); - return changedAtByKey; - }, + saveLocalEdits: (edits: Record, seen?: Snapshot) => + saveEdits({ pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey }, keep, edits, saveLog, seen), + reload: load, }; // CAP-697: started now, collected after the TUI closes (it has long since finished by then). @@ -472,7 +401,16 @@ export class EditCommand { ? reportRepoLink({ cwd: process.cwd(), orgId, projectId, projectName: keep.project_name, client: serviceClient }) : undefined; - await screen.run(state, editContext); + // A save refused as stale (Capy moved since the screen read it) closes the screen with nothing uploaded: bare `capy` + // runs again (it shows what changed) and the screen reopens with the edits still typed. + const session = async (shown: EditState): Promise => { + const exit = await screen.run(shown, editContext); + if (exit.kind === 'done') return; + await gateFirst(gate); + const fresh = await load(); + return session(withPendingEdits({ ...baseState, rows: fresh.rows, remoteAvailable: fresh.remoteAvailable, seen: fresh.seen }, exit.pending)); + }; + await session(state); if (repoLinkPending) printRepoLinkWarnings(await repoLinkPending); // The TUI has already released stdin (raw mode off, its key listener // removed) by the time `run` resolves, so the prompts in keepLockPr.ts start diff --git a/src/commands/editPiped.ts b/src/commands/editPiped.ts index 5fd9fe2a..14a542d5 100644 --- a/src/commands/editPiped.ts +++ b/src/commands/editPiped.ts @@ -22,6 +22,7 @@ import { isLocalOnly } from '../config/profileConfig'; import { MAX_PIPED_BYTES, readPipedValue, refuseInvalidName, refusePiped } from './pipedValue'; import { isValidVarName, runPipedWrite } from './pipedWrite'; import type { PrFlags } from './keepLockPr'; +import type { GateOptions } from '../sync/gate'; export type EditMode = 'tui' | 'piped' | 'refuse'; @@ -56,6 +57,8 @@ export interface EditPipedOpts { readonly pr?: PrFlags; /** `--dry-run`: say what would happen; change nothing. */ readonly dryRun?: boolean; + /** What bare `capy` runs with as this write's first step: `--resolve` / `--conflict` (already validated) and the flags. */ + readonly gate: GateOptions; } /** ` | capy edit NAME`: read stdin, set the variable, report. Never prompts. */ diff --git a/src/commands/orgCommand.ts b/src/commands/orgCommand.ts index 4d1e738a..1e7d873c 100644 --- a/src/commands/orgCommand.ts +++ b/src/commands/orgCommand.ts @@ -11,6 +11,8 @@ import { excludeSystemProject, assertProjectNameAllowed, isReservedProjectName, import { execSync } from 'child_process'; import { ACCENT } from '../ui/colors'; import { orgProjectQuestion } from '../ui/projectQuestions'; +import { gateFirst, type GateOptions } from '../sync/gate'; +import { conflictFlagsOrExit } from '../sync/conflicts'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -38,8 +40,10 @@ export class OrgCommand { private fileManager: FileManager; private authService: AuthService; private serviceClient: ServiceClient; + private readonly devMode: boolean; constructor(apiUrl?: string, devMode: boolean = false) { + this.devMode = devMode; this.projectManager = new ProjectManager(); this.fileManager = new FileManager(); this.authService = new AuthService(apiUrl, devMode); @@ -48,7 +52,12 @@ export class OrgCommand { this.serviceClient.setTokenProvider(() => this.authService.getValidToken()); } - async execute(): Promise { + /** `gate`: how bare `capy` runs first (`--non-tty`, `--resolve`, `--conflict` as typed). */ + async execute(gate: GateOptions = {}): Promise { + // Re-linking the folder to another organization rewrites its keep.lock: bare `capy` runs first, like every + // folder write (docs/conflict-resolution.md), so nothing the folder and Capy disagree on is left behind. + conflictFlagsOrExit(gate.conflictFlags ?? {}, gate.json === true); + await gateFirst({ ...gate, devMode: this.devMode }); try { await this._execute(); } catch (error: any) { diff --git a/src/commands/pipedWrite.ts b/src/commands/pipedWrite.ts index 5da51532..f0c274ea 100644 --- a/src/commands/pipedWrite.ts +++ b/src/commands/pipedWrite.ts @@ -16,6 +16,7 @@ import { CapyError, ERROR_CODES } from '../types/index'; import { hashValue } from './statusCommand'; import { resolveContext, writeAndSync, type ResolvedContext } from './connectors/shared'; +import { gateFirst, type GateOptions } from '../sync/gate'; import { refusePiped, reportPipedDryRun, reportPipedSuccess, type PipedAction } from './pipedValue'; import { repoLinkWarningsField } from '../core/repoLinkReporter'; import { @@ -73,7 +74,13 @@ export interface PipedWriteOpts { * a refusal `{ code, error }` to stop (e.g. `capy add` without `--force` on an * existing name); `undefined` to go on. */ - readonly gate?: (ctx: ResolvedContext) => { readonly code: string; readonly error: string } | undefined; + readonly refuseWhen?: (ctx: ResolvedContext) => { readonly code: string; readonly error: string } | undefined; + /** + * What bare `capy` runs with as this write's first step (docs/conflict-resolution.md). A piped write is never at a + * terminal, so a difference between the folder and Capy stops it (exit 3, the rows and a `conflict_id`) unless + * `--resolve` + `--conflict` answer it. + */ + readonly gate: GateOptions; } /** Turns anything thrown into a code and a sentence that cannot contain the value. */ @@ -103,16 +110,27 @@ function warningsExtra(ctx: ResolvedContext): object { /** Sets `name` to `value` and reports the outcome (JSON or one stderr line). Exits on any refusal. */ export async function runPipedWrite(name: string, value: string, opts: PipedWriteOpts): Promise { + // Bare `capy` runs before anything else: the folder and Capy must agree before this writes. + // A coded failure of the sync itself (no session: `AUTH_FAILED`) is this command's coded refusal, not a stack. + await gateFirst({ + ...opts.gate, + refuse: (err) => { + const failure = describeFailure(err); + return refusePiped(opts.json, failure.code, failure.error); + }, + }); + const ctx = await resolveContext({ devMode: opts.devMode, // Never the browser sign-in: a piped run has no one to sign in. interactive: false, json: opts.json, dryRun: opts.dryRun === true, + gate: opts.gate, refuse: (code, message) => refusePiped(opts.json, code, message), }); - const refused = opts.gate?.(ctx); + const refused = opts.refuseWhen?.(ctx); if (refused) refusePiped(opts.json, refused.code, refused.error); const action = classifyPipedWrite(ctx, name, value); diff --git a/src/commands/pushCommand.ts b/src/commands/pushCommand.ts index 1071e7bd..483f8896 100644 --- a/src/commands/pushCommand.ts +++ b/src/commands/pushCommand.ts @@ -1,270 +1,47 @@ -import ora from '../ui/spinner'; -import { ProjectManager } from '../core/projectManager'; -import { FileManager } from '../files/fileManager'; -import { AuthService } from '../auth/authService'; -import { ServiceClient } from '../service/serviceClient'; -import { SyncEngine } from '../sync/syncEngine'; -import { debugLine } from '../ui/debug'; -import { createHash } from 'crypto'; -import { - CapyError, - ERROR_CODES, - setSyncKeepHash, -} from '../types/index'; -import { resolveProjectKey, KeyServiceOps } from '../crypto/keyResolver'; -import { deriveResourceId } from '../crypto/resourceId'; -import { writeKeepCache, LOCAL_USER_ID } from '../config/globalConfig'; +/** + * `capy push` — save this folder's changes to Keep. + * + * Bare `capy` is the one place a folder's changes meet Capy's (docs/conflict-resolution.md): it compares the + * folder's `keep.lock` (pinned), its `.env` (local) and Capy's latest (remote), shows every difference, and + * "Commit and push all local values" is the push. So `capy push` IS bare `capy`'s sync, run as the first step it + * always is, and then there is nothing of its own left to apply: the folder and Capy agree. + * + * Off a terminal (`--non-tty`, an agent, CI) the differences are reported with a `conflict_id` and the rerun's + * `--resolve NAME=local` pushes them; there is no push-everything flag. + */ import { isLocalOnly } from '../config/profileConfig'; -import { resolveLocalProjectKey } from '../core/localUnlock'; -import { reportRepoLinkForCommand } from '../core/repoLinkReporter'; - -const B = (s: string) => `\x1b[1m${s}\x1b[0m`; +import { gateFirst, type GateOptions } from '../sync/gate'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; +import { printExpiryWarnings, resolveContext } from './connectors/shared'; + +export interface PushOpts { + /** Treat stdin as not a terminal even when it is one: the differences are then reported, not asked. */ + nonTty?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717). */ + conflictFlags?: RawConflictFlags; +} export class PushCommand { - private projectManager: ProjectManager; - private fileManager: FileManager; - private authService: AuthService; - private serviceClient: ServiceClient; private devMode: boolean; constructor(devMode: boolean = false) { this.devMode = devMode; - this.projectManager = new ProjectManager(); - this.fileManager = new FileManager(); - this.authService = new AuthService(undefined, devMode); - this.serviceClient = new ServiceClient(undefined, devMode); - - this.serviceClient.setTokenProvider(() => this.authService.getValidToken()); - } - - private debug(msg: string, data?: unknown): void { - debugLine(`push: ${msg}`, data); } - private debugError(label: string, err: unknown): void { - if (err instanceof CapyError) { - this.debug(`${label}: CapyError`, { - message: err.message, - code: err.code, - details: err.details, - stack: err.stack, - }); - } else if (err instanceof Error) { - this.debug(`${label}: ${err.name}`, { message: err.message, stack: err.stack }); - } else { - this.debug(`${label}: unknown`, String(err)); - } - } - - async execute(): Promise { + async execute(opts: PushOpts = {}): Promise { + // A bad --resolve / --conflict is refused before anything is read or authenticated. + conflictFlagsOrExit(opts.conflictFlags ?? {}, false); + const gate: GateOptions = { devMode: this.devMode, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags }; + // Bare `capy` is the push. (A failure of the sync itself ends the command inside the gate, with its code.) + await gateFirst(gate); try { - await this._execute(); - } catch (error: any) { - this.debugError('push execute caught', error); + // CAP-697: report which repo/folder holds this keep.lock (never blocks past its budget). Local-only mode has no server. + if (!isLocalOnly()) await resolveContext({ devMode: this.devMode, gate }); + + printExpiryWarnings(); + } catch (error) { const { displayErrorAndExit } = await import('../ui/errorScreen'); await displayErrorAndExit(error); } } - - private async _execute(): Promise { - this.debug('starting push command'); - this.debug('cwd', process.cwd()); - - const projectState = await this.projectManager.detectProjectState(); - this.debug('projectState', { - initialized: projectState.initialized, - organizationId: projectState.organizationId, - projectId: projectState.projectId, - activeBranch: projectState.activeBranch, - userId: projectState.userId, - }); - if (!projectState.initialized) { - console.error(`No keep.lock file found. Run ${B('capy')} first to initialize.`); - process.exit(1); - } - - // Local-only mode: no auth, no server push. `capy push` becomes a local - // commit — the local writes below ARE the commit. serviceClient is unused. - const localMode = isLocalOnly(); - - const { userId, encryptionKey } = await (async (): Promise<{ userId: string; encryptionKey: string }> => { - if (localMode) { - return { - userId: LOCAL_USER_ID, - encryptionKey: await resolveLocalProjectKey(projectState.projectId!), - }; - } - - if (projectState.userId) { - this.authService.setSessionUserId(projectState.userId); - } - - // Authenticate — try silent first (scoped, then any session), then interactive. - const spinner = ora('Authenticating...').start(); - const authResult = await (async () => { - const scoped = await this.authService.authenticateSilent(projectState.organizationId); - if (scoped.success) return scoped; - const anySession = await this.authService.authenticateSilent(); - if (anySession.success) return anySession; - return this.authService.authenticate(projectState.organizationId); - })(); - this.debug('authResult', { - success: authResult.success, - user_id: authResult.user_id, - _auth_method: authResult._auth_method, - }); - if (!authResult.success) { - spinner.fail('Authentication failed'); - throw new CapyError(authResult.error || 'Authentication failed', ERROR_CODES.AUTH_FAILED); - } - - spinner.stop(); - - const keyOps: KeyServiceOps = { - coDecrypt: (oid, ct) => this.serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), - wrapOuterLayer: (oid, pt) => this.serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), - }; - const resolvedKey = await resolveProjectKey( - projectState.organizationId!, - projectState.projectId!, - authResult.user_id!, - keyOps, - ); - return { userId: authResult.user_id!, encryptionKey: resolvedKey }; - })(); - this.debug('encryptionKey resolved', { length: encryptionKey.length }); - - // Read keep.lock - const keep = this.projectManager.readKeepFile(); - this.debug('keep.lock', keep ? { - version: keep.version, - org_id: keep.org_id, - project_id: keep.project_id, - variableCount: Object.keys(keep.variables).length, - variables: Object.keys(keep.variables), - } : 'NOT FOUND'); - if (!keep) { - console.error('No keep.lock file found.'); - process.exit(1); - } - - const branch = projectState.activeBranch; - this.debug('active branch', branch); - if (!branch) { - console.error('No active branch. Run capy to select a branch before pushing.'); - process.exit(1); - } - - // CAP-697: report which repo/folder holds this keep.lock (never blocks past its budget). - if (!localMode) { - await reportRepoLinkForCommand({ - cwd: process.cwd(), - orgId: projectState.organizationId!, - projectId: projectState.projectId!, - projectName: keep.project_name, - client: this.serviceClient, - }); - } - - // Read and encrypt .env file - const pushSpinner = ora(localMode ? 'Storing secrets locally...' : 'Pushing secrets to Keep...').start(); - - const rawLocal = this.fileManager.readEnvFile(); - this.debug('.env raw keys', Object.keys(rawLocal)); - if (Object.keys(rawLocal).length === 0) { - pushSpinner.fail('No .env file to push'); - return; - } - - // Encrypt all values - const { Encryptor } = await import('../crypto/encryptor'); - const encrypted = Object.fromEntries( - Object.entries(rawLocal).map(([key, value]) => { - if (value.startsWith('capy:')) { - this.debug(`${key}: already encrypted, passing through`); - return [key, value]; // Already encrypted - } - const enc = Encryptor.encrypt(value, encryptionKey); - const resourceId = deriveResourceId(branch, key); - this.debug(`${key}: encrypted`, { resourceId, encLength: enc.length }); - return [key, `capy:${resourceId}:${enc}`]; - }), - ); - - const envBlob = Object.entries(encrypted) - .map(([key, value]) => `${key}=${value}`) - .join('\n'); - this.debug('envBlob length', envBlob.length); - - // Update keep.lock hashes for the active branch - const pushedVars = Object.fromEntries( - Object.entries(rawLocal).map(([key, value]) => { - const plaintext = value.startsWith('capy:') - ? this.fileManager.decryptValue(value, encryptionKey) - : value; - const valueHash = createHash('sha256').update(plaintext).digest('hex').slice(0, 16); - const resourceId = deriveResourceId(branch, key); - return [key, { resource_id: resourceId, value_hash: valueHash }]; - }), - ); - this.debug('pushedVars', pushedVars); - - const syncEngine = new SyncEngine(); - const updatedKeep = syncEngine.mergeWithKeep(keep, pushedVars, branch); - - // Push to Keep - const keepFileContent = JSON.stringify(updatedKeep); - this.debug('pushSecrets request', { - projectId: projectState.projectId, - branch, - keepFileLength: keepFileContent.length, - envBlobLength: envBlob.length, - }); - // keep_hash is computed locally; the server returns the same value on a - // push. In local-only mode there is no push. - const localKeepHash = SyncEngine.computeKeepHash(updatedKeep, branch); - const pushResult = localMode - ? null - : await this.serviceClient.pushSecrets( - projectState.projectId!, - keepFileContent, - envBlob, - branch, - ); - const cacheKeepHash = pushResult ? pushResult.keep_hash : localKeepHash; - this.debug('push complete', { localMode, cacheKeepHash }); - - // Cache encrypted blob locally - writeKeepCache( - projectState.organizationId!, - projectState.projectId!, - cacheKeepHash, - envBlob, - ); - this.debug('keep cache written'); - - // Update keep.lock with new state, preferring the server's copy (it - // carries server-assigned changed_at timestamps) - this.fileManager.writeKeepFile(SyncEngine.adoptServerKeep(pushResult?.keep_file, updatedKeep, branch)); - this.debug('keep.lock written to disk'); - - // Update sync state with keep_hash so direction detection works - const existingSyncState = this.projectManager.readSyncState(); - this.fileManager.writeSyncState({ - ...existingSyncState, - last_sync: new Date().toISOString(), - synced_variables: Object.keys(rawLocal), - user_id: userId, - keep_hash: setSyncKeepHash(existingSyncState, branch, localKeepHash), - }); - - pushSpinner.succeed( - localMode - ? `Stored ${Object.keys(rawLocal).length} secret(s) locally (local-only mode)` - : `Pushed ${Object.keys(rawLocal).length} secret(s) to Keep` - ); - - const { printExpiryWarnings } = await import('./connectors/shared'); - printExpiryWarnings(); - } } diff --git a/src/commands/removeCommand.ts b/src/commands/removeCommand.ts index 2594776c..ff6b5b92 100644 --- a/src/commands/removeCommand.ts +++ b/src/commands/removeCommand.ts @@ -15,11 +15,12 @@ import inquirer from 'inquirer'; import { ProjectManager } from '../core/projectManager'; import { resolveContext, removeAndSync, listAllVarsOnBranch, ResolvedContext } from './connectors/shared'; -import { hashValue } from './statusCommand'; import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { DRY_RUN_UNSUPPORTED_MESSAGES } from './pipedValue'; import { listTargets } from '../deploy/config'; -import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; +import { CapyError, ERROR_CODES } from '../types/index'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import { previewKeepLockPrStep, recordsForRemoval, @@ -38,6 +39,8 @@ export interface RemoveOpts { pr?: PrFlags; /** The global `--dry-run`: say what would be removed; change nothing. The confirmation prompt refuses (`DRY_RUN_UNSUPPORTED`). */ dryRun?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ + conflictFlags?: RawConflictFlags; } /** Pure JSON refusal on stdout — never on stderr, so `--json` output stays parseable. */ @@ -57,36 +60,6 @@ function refuse(json: boolean, err: CapyError, exitCode: number = 1): never { refuseHuman(err.message, exitCode); } -/** Pinned value hashes for `branch`, keyed by variable name — same shape editCommand/statusCommand build. */ -export function pinnedHashesFor(keep: KeepFile, branch: string): Record { - return Object.fromEntries( - Object.entries(keep.variables).flatMap(([varName, entries]) => { - const entry = entries.find((e) => e.branch === branch); - return entry ? ([[varName, entry.value_hash]] as const) : []; - }), - ); -} - -/** - * Names, OTHER than the ones being removed, whose local `.env` value - * disagrees with the pinned baseline — new locally, changed locally, or - * pinned but missing from `.env` entirely. Any of these would ride along, - * unrelated, on the push `capy remove` is about to make. - */ -export function computeDrift( - pinned: Record, - localPlaintext: Record, - removedNames: readonly string[], -): string[] { - const removed = new Set(removedNames); - const otherKeys = new Set( - [...Object.keys(pinned), ...Object.keys(localPlaintext)].filter((k) => !removed.has(k)), - ); - return Array.from(otherKeys) - .filter((k) => pinned[k] !== (localPlaintext[k] !== undefined ? hashValue(localPlaintext[k]) : undefined)) - .sort(); -} - export interface DeployTargetWarning { target: string; variable: string; @@ -119,7 +92,7 @@ async function promptRemovalConfirmation(message: string): Promise { /** * Everything that needs `ctx` (an authenticated, decrypted `ResolvedContext`) - * — the drift check, the deploy-target warning, the push, and the output. + * — the deploy-target warning, the push, and the output. * * Split out from `RemoveCommand.execute` so it is directly testable with a * hand-built `ResolvedContext` (the same technique @@ -131,19 +104,6 @@ export async function proceedWithRemoval( names: readonly string[], opts: { json: boolean; cwd: string; pr?: PrFlags; nonTty?: boolean; dryRun?: boolean }, ): Promise { - const pinned = pinnedHashesFor(ctx.keep, ctx.branch); - const drifted = computeDrift(pinned, ctx.localPlaintext, names); - if (drifted.length > 0) { - refuse( - opts.json, - new CapyError( - `${drifted.join(', ')} ${drifted.length === 1 ? 'has' : 'have'} unpushed local changes. Push or revert them before removing.`, // COPY-FLAG - ERROR_CODES.REMOVE_LOCAL_DRIFT, - { drifted }, - ), - ); - } - const warnings = deployTargetWarnings(opts.cwd, names); if (opts.dryRun === true) { @@ -172,6 +132,7 @@ export async function proceedWithRemoval( return; } + // Only these names move, on top of Capy's latest (bare `capy` already made the folder and Capy agree). await removeAndSync(ctx, names); // The removal is done whatever the PR step reports (it never throws). @@ -220,6 +181,7 @@ export class RemoveCommand { const json = opts.json === true; // Contradictory PR flags are refused before anything is changed. refuseBadPrFlags(opts.pr ?? {}, json); + conflictFlagsOrExit(opts.conflictFlags ?? {}, json); // The confirmation prompt would really remove: a dry run never runs for real. Before anything else. if (opts.dryRun === true && opts.yes !== true && !json && isInteractive(opts.nonTty)) { refuse(json, new CapyError(DRY_RUN_UNSUPPORTED_MESSAGES.remove, ERROR_CODES.DRY_RUN_UNSUPPORTED)); @@ -229,6 +191,11 @@ export class RemoveCommand { refuse(json, new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT)); } + // Bare `capy` runs before anything else, the confirmation included: the folder and Capy must agree before this + // can write (docs/conflict-resolution.md). What is on the branch is read after it, because it may have moved keep.lock. + const gate: GateOptions = { devMode: this.devMode, json, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun === true }; + await gateFirst(gate); + const pm = new ProjectManager(); const keep = pm.readKeepFile(); const branch = pm.deriveActiveBranch(); @@ -274,10 +241,8 @@ export class RemoveCommand { } } - // Everything above is local-only — no auth, no network. Only now do we - // authenticate and decrypt `.env`, which the drift check and the push - // both need. - const ctx = await resolveContext({ devMode: this.devMode, json, dryRun: opts.dryRun === true }); + // Only now do we authenticate and decrypt `.env`, which the push needs. + const ctx = await resolveContext({ devMode: this.devMode, json, dryRun: opts.dryRun === true, gate }); await proceedWithRemoval(ctx, names, { json, cwd: process.cwd(), pr: opts.pr, nonTty: opts.nonTty, dryRun: opts.dryRun === true }); } } diff --git a/src/commands/rotateCommand.ts b/src/commands/rotateCommand.ts index 56fb4d40..183cb164 100644 --- a/src/commands/rotateCommand.ts +++ b/src/commands/rotateCommand.ts @@ -14,6 +14,8 @@ import { CapyError, ConnectorMetadata, ERROR_CODES, KeepFile } from '../types/in import { TargetConfig } from '../deploy/adapter'; import { staleTargets } from '../deploy/targetsGate'; import { isInteractive, refuseNonInteractive } from '../ui/interactive'; +import { conflictFlagsOrExit } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import type { RotatePlanStop } from '../ui/screens/contract'; import { writeSync } from 'fs'; @@ -150,10 +152,20 @@ export class RotateCommand { this.devMode = devMode; } + /** What bare `capy` runs with: as the first step of `rotate`, and again if an upload is refused as stale. */ + private gateFor(opts: RotateOpts): GateOptions { + return { devMode: this.devMode, nonTty: opts.nonTty, conflictFlags: opts.conflictFlags }; + } + async execute( varName: string | undefined, opts: RotateOpts & { all?: boolean; skipPrompts?: boolean; provider?: string }, ): Promise { + // A bad --resolve / --conflict is refused before anything is read or asked. + conflictFlagsOrExit(opts.conflictFlags ?? {}, false); + // Bare `capy` runs before anything else, a new key being minted included: the folder and Capy must agree + // first (docs/conflict-resolution.md). What is on the branch is read after it, because it may have moved keep.lock. + await gateFirst(this.gateFor(opts)); const pm = new ProjectManager(); const keep = pm.readKeepFile(); const branch = pm.deriveActiveBranch(); @@ -464,7 +476,7 @@ export class RotateCommand { return []; } - const ctx = await resolveContext({ devMode: this.devMode }); + const ctx = await resolveContext({ devMode: this.devMode, gate: this.gateFor(opts) }); const result = await this.rotateSequentially(toRotate, ctx, opts, { succeeded: [], failed: [] }); @@ -569,7 +581,7 @@ export class RotateCommand { noPush: opts.noPush, }); - const freshCtx = await resolveContext({ devMode: this.devMode }); + const freshCtx = await resolveContext({ devMode: this.devMode, gate: this.gateFor(opts) }); await writeAndSync(freshCtx, name, value, { push: !opts.noPush, connector: updated }); const succeededState: RotateLoopState = { diff --git a/src/commands/secretsDeployActions.ts b/src/commands/secretsDeployActions.ts index f9319a91..078a44d6 100644 --- a/src/commands/secretsDeployActions.ts +++ b/src/commands/secretsDeployActions.ts @@ -3,6 +3,13 @@ // (deploy/batchDeploy.ts), so the two cannot drift. Neither ever rejects: a failure comes // back as a code. // +// PLATFORM VALUES (docs/conflict-resolution.md): on a platform whose live values can be read (Dokploy), `run` +// first reads every target and compares it with what Capy last delivered. A variable someone changed on the +// service outside Capy comes back as a row, with nothing written; the screen asks for each (`local` delivers +// Capy's value, `remote` leaves the service's value and records nothing for it) and runs again with the +// answers. An answer is used only where the live value is still the one it was made against, otherwise the row +// comes back. Values read from Capy are never written back, so there is no folder-side comparison here. +// // 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. @@ -10,12 +17,43 @@ import type { OrgRepoLink, SecretIndexRow, ServiceClient } from '../service/serviceClient'; import type { SecretsDeployActions } from '../ui/secretsScreenDriver'; import { BatchEnv, createBatchEnv, deployLocationsOf, planBatchDeploy, runBatchDeploy } from '../deploy/batchDeploy'; +import { + conflictRefusalFor, + isConflictRefusal, + resolveKeyOf, + isTargetConflict, + type ConflictScope, + type Resolutions, + type SeenResolutions, + type TargetConflict, +} from '../sync/conflicts'; 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 decider of a run (the batch engine's `decide`): given the rows its comparison pass found, the answers that + * were made for exactly these (each against the live value and the delivered value it holds now), or a + * `ConflictRefusal` (nothing has been written) listing them. Pure. + */ +export function decideFrom(decisions: SeenResolutions | undefined): (scopes: readonly ConflictScope[]) => Resolutions { + return (scopes) => { + const found = scopes.flatMap((s) => s.conflicts); + const holds = (c: (typeof found)[number]): boolean => { + const d = decisions?.[resolveKeyOf(c)]; + return d !== undefined && d.remote === c.remote && d.local === c.local; + }; + if (found.some((c) => !holds(c))) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes); + return Object.fromEntries(found.map((c) => [resolveKeyOf(c), (decisions as SeenResolutions)[resolveKeyOf(c)].choice])); + }; +} + +/** The platform rows of a refusal, in the order it carries them. */ +const targetConflictsOf = (conflicts: readonly (TargetConflict | { readonly name: string })[]): readonly TargetConflict[] => + conflicts.filter((c): c is TargetConflict => 'target' in c); + /** * 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 @@ -56,16 +94,22 @@ export function createDeployActionsWith( return { ok: false, code: codeOf(err) }; } }, - run: async (plan, onProgress) => { + run: async (plan, onProgress, decisions) => { 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') }) }; + const stopPushes = begin('pushing'); + // Only a platform whose live values can be read has anything to compare. A stop asked for during the comparison decides nothing. + const decide = env.adapter.readsLiveValues === true ? (scopes: readonly ConflictScope[]) => (stopPushes.aborted ? {} : decideFrom(decisions)(scopes)) : undefined; + return { ok: true, result: await runBatchDeploy(plan, env, { onProgress, stopPushes, stopPrs: begin('prs'), decide }) }; } catch (err) { + // Nothing has been written: the comparison found rows that are unanswered, or answered against a value that has moved. + if (isConflictRefusal(err)) return { ok: false, code: err.code, conflicts: targetConflictsOf(err.payload.conflicts) }; return { ok: false, code: codeOf(err) }; } }, cancel: (phase) => { - control.dispatchEvent(new Event(phase)); + // The comparison pass stops with the pushes. + control.dispatchEvent(new Event(phase === 'checking' ? 'pushing' : phase)); }, }; } diff --git a/src/commands/secretsDeployCommand.ts b/src/commands/secretsDeployCommand.ts index 3d1d359d..7830aedf 100644 --- a/src/commands/secretsDeployCommand.ts +++ b/src/commands/secretsDeployCommand.ts @@ -43,14 +43,17 @@ import { createBatchEnv, deployLocationsOf, planBatchDeploy, + previewBatchConflicts, runBatchDeploy, } from '../deploy/batchDeploy'; +import { ConflictRefusal, conflictFlagsOrExit, conflictRefusalFor, gateConflicts, type RawConflictFlags } from '../sync/conflicts'; import { targetRefFor } from '../deploy/deliveryRecord'; import type { SecretsSetIo } from './secretsSetCommand'; import { byText, candidatesOf, excludeLocations, + exitWithConflicts, loadLinks, readSecretIndex, refuse, @@ -72,6 +75,8 @@ export interface SecretsDeployOpts { readonly allRows?: boolean; /** `--exclude :` (repeatable) */ readonly exclude?: readonly string[]; + /** `--resolve` / `--conflict` as typed (CAP-717): they decide a Dokploy value changed outside Capy since Capy last delivered it. */ + readonly conflictFlags?: RawConflictFlags; } /** Injectable seam: tests pass fakes for the service, GitHub and Dokploy. */ @@ -190,6 +195,7 @@ async function readPlan(sel: Selection, io: SecretsDeployIo, links: readonly Org 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 flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, json); const rows = selectRows(await readSecretIndex(io, json), names, opts, json); const located = excludeLocations(rows.flatMap(locatedOf), opts.exclude ?? [], json); @@ -203,6 +209,12 @@ export async function runSecretsDeploy(names: readonly string[], opts: SecretsDe const planBody = { names, ...planJsonOf(plan), ...(loaded.unavailable === undefined ? {} : { repos_unavailable: loaded.unavailable }) }; if (opts.dryRun === true) { + // Reading only: a Dokploy value changed outside Capy since Capy last delivered it is reported with the conflict_id (exit 3). + // A preview that could not look says nothing about conflicts. + const scopes = await previewBatchConflicts(plan, io.env).catch(() => []); + if (scopes.length > 0) { + return exitWithConflicts(conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }), { plan_id: planId, plan: planBody }); + } 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; @@ -229,11 +241,13 @@ export async function runSecretsDeploy(names: readonly string[], opts: SecretsDe } const result: BatchResult = await runBatchDeploy(plan, io.env, { + // Every Dokploy value changed outside Capy since Capy last delivered it needs an answer before anything is written. + decide: (scopes) => gateConflicts(flags, scopes), 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))), - }); + }).catch((err: unknown) => (err instanceof ConflictRefusal ? exitWithConflicts(err, { plan_id: planId }) : Promise.reject(err))); const ok = batchSucceeded(result); if (json) { diff --git a/src/commands/secretsEditActions.ts b/src/commands/secretsEditActions.ts index 6c780258..4df0364a 100644 --- a/src/commands/secretsEditActions.ts +++ b/src/commands/secretsEditActions.ts @@ -7,10 +7,31 @@ // DRY RUN (`capy --dry-run secrets`): `run` never calls `runSecretSet`. It READS // (the branches, GitHub's default branch and keep.lock) to say what would // happen, and writes nothing: no push, no cache, no GitHub write. +// +// MOVED LOCATIONS (docs/conflict-resolution.md, "Commands with no folder"): before any push, `run` reads every +// location and compares the three fingerprints with `compareSecrets` (the row's, the new value's, the location's). +// One that moved since the person looked, to something other than the new value, comes back as a row and nothing is +// written. The screen asks for each (`local` writes the new value, `remote` leaves the location) and runs again with +// the answers; an answer holds only for the fingerprints it was made against, so a location that moved again comes +// back as a row. import type { ServiceClient } from '../service/serviceClient'; import type { SecretsEditActions } from '../ui/secretsScreenDriver'; -import { RunProgress, SetEnv, createSetEnv, repoLabel, resolveBases, runSecretSet } from './secretsSet'; +import { + LocationConflict, + LocationPreview, + RunProgress, + SetEnv, + SetLocation, + createSetEnv, + locationKeyOf, + movedLocations, + previewLocations, + repoLabel, + resolveBases, + runSecretSet, +} from './secretsSet'; +import { holdsFor, type BoundAnswer } from '../sync/noFolder'; import { describePlanBody, type SetPlanBody } from './secretsSetCommand'; import type { DryRunView } from './secretsSetText'; import { CapyError, ERROR_CODES } from '../types/index'; @@ -44,6 +65,39 @@ async function planOnly(env: SetEnv, request: RunRequest): Promise { return dryRunView(body, request); } +/** What the locations that were read allow: the run goes on with these, or it stops for answers. */ +export type SettledLocations = + | { + readonly kind: 'ready'; + readonly locations: readonly SetLocation[]; + /** The fingerprint each location's row listed, by `locationKeyOf`, and the answers for the ones that moved. Absent: not compared. */ + readonly shown?: Readonly>; + readonly answers?: Readonly>; + } + | { readonly kind: 'conflicts'; readonly conflicts: readonly LocationConflict[] }; + +/** + * Pure. `reads` are the previews of `request.locations`, in order. A location that moved since the row was listed (to + * something other than the new value) is a row (`compareSecrets`). All answered, each against the fingerprints the + * location holds now: the locations to push (`remote` leaves a location alone) and the binding for the push. Any + * unanswered, or answered against a value that has moved since: the rows, and nothing is pushed. + */ +export function settleLocations(request: RunRequest, reads: readonly LocationPreview[]): SettledLocations { + const seen = request.seen; + if (seen === undefined) return { kind: 'ready', locations: request.locations }; + const shown = Object.fromEntries(request.locations.map((l) => [locationKeyOf(l), seen])); + const moved = movedLocations(request.name, request.value, request.locations, shown, reads); + const answerFor = (c: LocationConflict): BoundAnswer | undefined => request.decisions?.[locationKeyOf(c.location)]; + if (moved.some((c) => !holdsFor(answerFor(c), c.conflict))) return { kind: 'conflicts', conflicts: moved }; + const left = new Set(moved.filter((c) => answerFor(c)?.choice === 'remote').map((c) => locationKeyOf(c.location))); + return { + kind: 'ready', + locations: request.locations.filter((l) => !left.has(locationKeyOf(l))), + shown, + answers: request.decisions, + }; +} + type CancelPhase = Parameters[0]; /** @@ -81,9 +135,17 @@ export function createEditActionsWith( }, run: async (request, onProgress?: (progress: RunProgress) => void) => { try { - return dryRun - ? { ok: true, plan: await planOnly(env, request) } - : { ok: true, result: await runSecretSet(request, env, { onProgress, stopPushes: begin('pushing'), stopPrs: begin('prs') }) }; + if (dryRun) return { ok: true, plan: await planOnly(env, request) }; + // Made first: a stop asked for while the locations are being read must still reach the pushes. + const stopPushes = begin('pushing'); + const stopPrs = begin('prs'); + // One unlock for the reading and the pushing. + const keys = request.seen === undefined || request.locations.length === 0 ? undefined : env.openKeys(); + const reads = keys === undefined ? [] : await previewLocations(env, request.locations, request.name, request.value, keys); + const settled: SettledLocations = stopPushes.aborted ? { kind: 'ready', locations: request.locations } : settleLocations(request, reads); + if (settled.kind === 'conflicts') return { ok: false, code: ERROR_CODES.SECRETS_CONFLICT, conflicts: settled.conflicts }; + const result = await runSecretSet({ ...request, locations: settled.locations, shown: settled.shown, answers: settled.answers, keys }, env, { onProgress, stopPushes, stopPrs }); + return { ok: true, result }; } catch (err) { return { ok: false, code: codeOf(err) }; } diff --git a/src/commands/secretsSet.ts b/src/commands/secretsSet.ts index 325de722..72df0434 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -8,13 +8,19 @@ * (the keep.lock hash), so clones on the old keep.lock keep getting the old * value until they pull the merged PR. * - * HOW A LOCATION IS PUSHED, with no project folder: the same sequence the org - * system store uses (system/systemStore.ts) and `capy edit` uses — resolve the - * project key, fetch the branch's blob + keep from the service, change only - * this variable, push with the keep update through the same `pushSecrets` - * route, `writeKeepCache`. Every OTHER variable's line in the blob is carried - * over byte for byte (it is never decrypted and re-encrypted), so a push - * touches this one variable and nothing else. Each branch is its own push. + * HOW A LOCATION IS PUSHED, with no project folder: the shared upload every + * write uses (sync/commit.ts `uploadChanges`) — resolve the project key, read the + * branch's blob + keep from the service, change only this variable, push with the + * keep update and `base_keep_hash` through the same `pushSecrets` route, + * `writeKeepCache`. Every OTHER variable's line in the blob is carried over byte + * for byte (it is never decrypted and re-encrypted), so a push touches this one + * variable and nothing else. Each branch is its own push. + * + * WHEN A LOCATION MOVED (docs/conflict-resolution.md, "Commands with no folder"): + * `compareSecrets` over the fingerprint the row listed (pinned), the new value + * (local) and what the location holds now (remote). Unchanged since the row was + * listed: written. Moved: written only as the person answered (`local` writes, + * `remote` leaves it), and an answer holds only for the state it was made against. * * HOW A REPO IS UPDATED: one PR on the repo's default branch through * keepLockPr's `openKeepLockPullRequest` (blob per keep.lock, one tree, one @@ -27,10 +33,10 @@ * Locations and repos fail independently. A failure is a code, never a message, * and nothing here ever prints, returns or throws the value or any hash of it. */ -import { FileManager } from '../files/fileManager'; -import { Encryptor } from '../crypto/encryptor'; -import { deriveResourceId } from '../crypto/resourceId'; import { SyncEngine } from '../sync/syncEngine'; +import { UploadIo, envBlobOf, isStaleBase, snapshotFrom, uploadChanges, type Changes } from '../sync/commit'; +import type { SourceConflict } from '../sync/conflicts'; +import { BoundAnswer, actionFor, holdsFor, movedRow, shownValueUnavailable } from '../sync/noFolder'; import { hashValue } from '../deploy/keepGate'; import type { EditSaveRecord } from '../deploy/keepGate'; import { writeKeepCache } from '../config/globalConfig'; @@ -91,6 +97,18 @@ export interface SetRequest { readonly repos: readonly RepoTarget[]; /** Each repo's PR base, by `repoKey`. Absent: the repo's default branch. */ readonly bases?: Readonly>; + /** + * The fingerprint each location's row listed, by `locationKeyOf` (CAP-717): what the person was shown. A location + * that holds something else now (and not the new value) moved since, and is written only as `answers` say. + * Absent: the location is not compared. + */ + readonly shown?: Readonly>; + /** The answers for locations that moved, by `locationKeyOf`, each bound to the state it was made against. */ + readonly answers?: Readonly>; + /** The value each location answered `pinned` gets written back: the plaintext of what the person was shown, by `locationKeyOf`. */ + readonly restore?: Readonly>; + /** The run's key resolver, when the caller already unlocked (the unlock is once per run). Absent: opened here. */ + readonly keys?: KeyResolver; } export interface PrResult { @@ -110,6 +128,8 @@ export interface SecretSetResult { readonly name: string; readonly updated: readonly LocationRef[]; readonly unchanged: readonly LocationRef[]; + /** Locations whose variable was removed (the answer was `delete`). Left out of JSON when there are none. */ + readonly removed?: readonly LocationRef[]; readonly prs: readonly PrResult[]; /** Repos whose keep.lock on the default branch already carries these entries: no PR was needed. */ readonly no_pr: ReadonlyArray<{ readonly repo: string; readonly reason: 'NO_DIFF_VS_BASE' }>; @@ -272,16 +292,9 @@ export function keepDiverged( type LocationOutcome = | { readonly kind: 'updated'; readonly location: SetLocation; readonly entry: KeepVariableEntry; readonly serverKeep: KeepFile } | { readonly kind: 'unchanged'; readonly location: SetLocation } + | { readonly kind: 'removed'; readonly location: SetLocation } | { readonly kind: 'failed'; readonly location: SetLocation; readonly code: string }; -/** What a branch holds right now: its blob lines (kept verbatim), the server's keep, and the current plaintext of `name`. */ -interface Current { - readonly lines: readonly string[]; - readonly serverKeep: KeepFile; - readonly index: number; - readonly plaintext: string; -} - function parseKeepJson(raw: string | undefined): KeepFile | undefined { if (raw === undefined || raw === '') return undefined; try { @@ -294,82 +307,125 @@ function parseKeepJson(raw: string | undefined): KeepFile | undefined { } } -const lineIndexOf = (lines: readonly string[], name: string): number => - lines.findIndex((l) => l.startsWith(`${name}=`)); - type Tried = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: unknown }; -/** `f()` as a result instead of a throw. */ -function attempt(f: () => T): Tried { - try { - return { ok: true, value: f() }; - } catch (error) { - return { ok: false, error }; - } -} - const settled = (promise: Promise): Promise> => promise.then( (value): Tried => ({ ok: true, value }), (error: unknown): Tried => ({ ok: false, error }), ); -type Fetched = Awaited>; - -/** What a branch holds, from its fetched data: blob lines (kept verbatim), the server's keep, and the plaintext of `name`. Throws a coded `CapyError`; the value is never in what it throws. */ -function currentFrom(data: Fetched, name: string, projectKey: string): Current { - const lines = (data.env_content ?? '').split('\n').filter((l) => l.trim().length > 0); - const serverKeep = parseKeepJson(data.keep_file); - if (serverKeep === undefined) throw new CapyError('keep.lock unavailable', ERROR_CODES.NO_KEEP_FILE); - const index = lineIndexOf(lines, name); - if (index === -1) throw new CapyError('variable not found', ERROR_CODES.VARIABLE_NOT_FOUND); - const raw = lines[index].slice(name.length + 1); - const plaintext = new FileManager().decryptValue(raw, projectKey); - return { lines, serverKeep, index, plaintext }; -} - const withRate = (result: R, rate: RateInfo | undefined): PoolItem => ({ result, ...(rate === undefined ? {} : { rate }) }); const failedItem = (location: SetLocation, err: unknown, rate?: RateInfo): PoolItem => withRate({ kind: 'failed', location, code: codeOf(err) }, rate); -/** One location: read the current branch, replace `name`, push, write the cache. Never throws; reports the rate-limit reading it saw. */ -async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, name: string, value: string): Promise> { +/** A call that failed, with the rate-limit reading it saw: how the bulk transport reports it through the shared upload. */ +class RatedFailure extends Error { + constructor( + readonly error: unknown, + readonly rate: RateInfo | undefined, + ) { + super('rated failure'); + } +} + +/** The error a failure carries (a transport failure wraps the real one), and its reading. */ +const unwrapped = (err: unknown): { readonly error: unknown; readonly rate: RateInfo | undefined } => + err instanceof RatedFailure ? { error: err.error, rate: err.rate } : { error: err, rate: undefined }; + +/** The shared upload's transport for the bulk writes: every call reads the rate-limit headers and waits out a 429. */ +function bulkIo(env: SetEnv, loc: SetLocation, projectKey: string): UploadIo { + return { + read: async () => { + const read = await callWithBackoff((onRate) => env.client.getDecryptData(loc.project_id, loc.branch, undefined, true, onRate), env.sleep); + if (!read.ok) throw new RatedFailure(read.error, read.rate); + return { snapshot: snapshotFrom(read.value, loc.branch, projectKey), rate: read.rate }; + }, + push: async (keepFile, envBlob, baseKeepHash) => { + const pushed = await callWithBackoff((onRate) => env.client.pushSecrets(loc.project_id, keepFile, envBlob, loc.branch, onRate, baseKeepHash), env.sleep); + if (!pushed.ok) throw new RatedFailure(pushed.error, pushed.rate); + return { result: pushed.value, rate: pushed.rate }; + }, + }; +} + +/** + * One location: read the current branch, replace `name`, push (the shared upload, with `base_keep_hash`), write the + * cache. Never throws; reports the rate-limit reading it saw. If the server says someone saved since the read + * (`SECRETS_STALE_BASE`), the location is read and compared once more from the new latest. + */ +async function pushLocation( + env: SetEnv, + keys: KeyResolver, + loc: SetLocation, + name: string, + value: string, + check: { readonly shown?: string; readonly answer?: BoundAnswer; readonly restore?: string }, + retried: boolean = false, +): Promise> { const key = await settled(keys(loc.project_id)); if (!key.ok) return failedItem(loc, key.error); - const read = await callWithBackoff((onRate) => env.client.getDecryptData(loc.project_id, loc.branch, undefined, true, onRate), env.sleep); - if (!read.ok) return failedItem(loc, read.error, read.rate); - const current = attempt(() => currentFrom(read.value, name, key.value)); - if (!current.ok) return failedItem(loc, current.error, read.rate); - if (current.value.plaintext === value) return withRate({ kind: 'unchanged', location: loc }, read.rate); - - const write = attempt(() => { - const existing = current.value.serverKeep.variables[name]?.find((e) => e.branch === loc.branch); - const resourceId = existing?.resource_id ?? deriveResourceId(loc.branch, name); - const newLine = `${name}=capy:${resourceId}:${Encryptor.encrypt(value, key.value)}`; - return { - envBlob: current.value.lines.map((l, i) => (i === current.value.index ? newLine : l)).join('\n'), - merged: new SyncEngine().mergeWithKeep( - current.value.serverKeep, - { [name]: { resource_id: resourceId, value_hash: hashValue(value) } }, - loc.branch, - ), - }; - }); - if (!write.ok) return failedItem(loc, write.error, read.rate); - - const pushed = await callWithBackoff( - (onRate) => env.client.pushSecrets(loc.project_id, JSON.stringify(write.value.merged), write.value.envBlob, loc.branch, onRate), - env.sleep, + const io = bulkIo(env, loc, key.value); + const read = await settled(io.read()); + if (!read.ok) { + const failure = unwrapped(read.error); + return failedItem(loc, failure.error, failure.rate); + } + const { snapshot, rate: readRate } = read.value; + const entry = snapshot.entries[name]; + if (entry?.plaintext === value) return withRate({ kind: 'unchanged', location: loc }, readRate); + if (snapshot.keep === undefined) return failedItem(loc, new CapyError('keep.lock unavailable', ERROR_CODES.NO_KEEP_FILE), readRate); + if (entry !== undefined && entry.plaintext === undefined) return failedItem(loc, new CapyError('variable not readable', ERROR_CODES.PERMISSION_DENIED), readRate); + + // Someone else changed it since the person looked: not overwritten unless they answered, against exactly this state. + const moved = + check.shown === undefined + ? undefined + : movedRow({ + name, + project: loc.project_name, + branch: loc.branch, + shown: check.shown, + local: hashValue(value), + remote: entry?.hash, + serverKeepHash: SyncEngine.computeKeepHash(snapshot.keep, loc.branch), + }); + const answered = moved === undefined ? { ok: true as const, value: 'write' as const } : answerAt(check, moved); + if (!answered.ok) return failedItem(loc, answered.error, readRate); + const action = answered.value; + // Nothing to remove where the variable is already gone. + if (action === 'leave' || (action === 'remove' && entry === undefined)) return withRate({ kind: 'unchanged', location: loc }, readRate); + // `pinned` writes back the value that was shown; no plaintext for it is a refusal, never a guess. + if (action === 'restore' && check.restore === undefined) return failedItem(loc, shownValueUnavailable(name), readRate); + + const uploaded = await settled( + uploadChanges({ + client: env.client, + projectId: loc.project_id, + branch: loc.branch, + projectKey: key.value, + startKeep: snapshot.keep, + changes: changesFor(action, name, value, check.restore), + seen: snapshot, + io, + }), ); - const rate = mergeRates(read.rate, pushed.rate, (env.now ?? Date.now)()); - if (!pushed.ok) return failedItem(loc, pushed.error, rate); + if (!uploaded.ok) { + if (!retried && isStaleBase(unwrapped(uploaded.error).error)) return pushLocation(env, keys, loc, name, value, check, true); + const failure = unwrapped(uploaded.error); + return failedItem(loc, failure.error, mergeRates(readRate, failure.rate, (env.now ?? Date.now)())); + } + const rate = mergeRates(readRate, uploaded.value.rates.push, (env.now ?? Date.now)()); + const pushedResult = uploaded.value.result; + if (pushedResult === undefined) return failedItem(loc, new CapyError('nothing was pushed', ERROR_CODES.SERVICE_ERROR), rate); - const done = attempt(() => { - env.writeCache(env.orgId, loc.project_id, pushed.value.keep_hash, write.value.envBlob); - const adopted = SyncEngine.adoptServerKeep(pushed.value.keep_file, write.value.merged, loc.branch); + const done = attemptSync(() => { + env.writeCache(env.orgId, loc.project_id, pushedResult.keep_hash, envBlobOf(uploaded.value.lines)); + const adopted = SyncEngine.adoptServerKeep(pushedResult.keep_file, uploaded.value.keep, loc.branch); return { adopted, entry: adopted.variables[name]?.find((e) => e.branch === loc.branch) }; }); if (!done.ok) return failedItem(loc, done.error, rate); + if (action === 'remove') return withRate({ kind: 'removed', location: loc }, rate); return withRate( done.value.entry === undefined ? { kind: 'failed', location: loc, code: ERROR_CODES.SERVICE_ERROR } @@ -378,9 +434,95 @@ async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, na ); } +/** `f()` as a result instead of a throw. */ +function attemptSync(f: () => T): Tried { + try { + return { ok: true, value: f() }; + } catch (error) { + return { ok: false, error }; + } +} + +/** What the answer for a moved location does there: it must be bound to exactly the state that was read, else the location is not written. */ +function answerAt(check: { readonly answer?: BoundAnswer }, moved: SourceConflict): Tried> { + return holdsFor(check.answer, moved) + ? { ok: true, value: actionFor(check.answer.choice) } + : { ok: false, error: new CapyError('changed since it was read', ERROR_CODES.SECRETS_CONFLICT) }; +} + +/** The change one location gets: the new value (`write`), the value that was shown (`restore`), or the variable removed (`remove`). */ +const changesFor = (action: ReturnType, name: string, value: string, restore: string | undefined): Changes => + action === 'remove' ? { set: {}, remove: [name] } : { set: { [name]: action === 'restore' && restore !== undefined ? restore : value }, remove: [] }; + +/** The plaintext of the value `hash` names, read from the first of `candidates` that still holds it (a location of the same row); `undefined` if none does. */ +export async function recoverShownValue( + env: SetEnv, + keys: KeyResolver, + candidates: readonly SetLocation[], + name: string, + hash: string, +): Promise { + const at = async (i: number): Promise => { + const loc = candidates[i]; + if (loc === undefined) return undefined; + const found = await settled( + (async (): Promise => { + const key = await keys(loc.project_id); + const read = await callWithBackoff((onRate) => env.client.getDecryptData(loc.project_id, loc.branch, undefined, true, onRate), env.sleep); + if (!read.ok) return undefined; + const entry = snapshotFrom(read.value, loc.branch, key).entries[name]; + return entry !== undefined && entry.hash === hash ? entry.plaintext : undefined; + })(), + ); + return found.ok && found.value !== undefined ? found.value : at(i + 1); + }; + return at(0); +} + export interface LocationPreview { readonly action: 'update' | 'unchanged'; readonly serverKeep: KeepFile | undefined; + /** What the location holds now, as fingerprints (never the value); absent when it could not be read. `hash` is absent when the location no longer has the variable. */ + readonly current?: { readonly hash: string | undefined; readonly serverKeepHash: string; readonly changedAt?: string }; +} + +/** A location whose value moved since its row was listed (CAP-717): the row `compareSecrets` gives, and when Capy's value last changed. */ +export interface LocationConflict { + readonly location: SetLocation; + readonly conflict: SourceConflict; + readonly changedAt?: string; +} + +/** The identity an answer for a location is kept under. */ +export const locationKeyOf = (l: Pick): string => `${l.project_id}/${l.branch}`; + +/** + * The locations whose value moved since their row was listed, and what they hold now: `shown` is each row's + * fingerprint by `locationKeyOf`, `previews` are the reads of `locations`, in order. A location that could not be + * read has nothing known to compare. Fingerprints only. + */ +export function movedLocations( + name: string, + value: string, + locations: readonly SetLocation[], + shown: Readonly>, + previews: readonly LocationPreview[], +): readonly LocationConflict[] { + return locations.flatMap((location, i): LocationConflict[] => { + const current = previews[i]?.current; + const was = shown[locationKeyOf(location)]; + if (current === undefined || was === undefined) return []; + const conflict = movedRow({ + name, + project: location.project_name, + branch: location.branch, + shown: was, + local: hashValue(value), + remote: current.hash, + serverKeepHash: current.serverKeepHash, + }); + return conflict === undefined ? [] : [{ location, conflict, ...(current.changedAt === undefined ? {} : { changedAt: current.changedAt }) }]; + }); } const UNKNOWN_PREVIEW: LocationPreview = { action: 'update', serverKeep: undefined }; @@ -398,9 +540,21 @@ async function previewItem( const read = await callWithBackoff((onRate) => env.client.getDecryptData(loc.project_id, loc.branch, undefined, true, onRate), env.sleep); if (!read.ok) return withRate(UNKNOWN_PREVIEW, read.rate); if (key === undefined || !key.ok) return withRate({ action: 'update', serverKeep: parseKeepJson(read.value.keep_file) }, read.rate); - const current = attempt(() => currentFrom(read.value, name, key.value)); + const snapshot = attemptSync(() => snapshotFrom(read.value, loc.branch, key.value)); + const serverKeep = snapshot.ok ? snapshot.value.keep : undefined; + const entry = snapshot.ok ? snapshot.value.entries[name] : undefined; return withRate( - current.ok ? { action: current.value.plaintext === value ? 'unchanged' : 'update', serverKeep: current.value.serverKeep } : UNKNOWN_PREVIEW, + serverKeep === undefined || (entry !== undefined && entry.plaintext === undefined) + ? UNKNOWN_PREVIEW + : { + action: entry?.plaintext === value ? 'unchanged' : 'update', + serverKeep, + current: { + hash: entry?.hash, + serverKeepHash: SyncEngine.computeKeepHash(serverKeep, loc.branch), + changedAt: serverKeep.variables[name]?.find((e) => e.branch === loc.branch)?.changed_at, + }, + }, read.rate, ); } @@ -421,8 +575,10 @@ export async function previewLocations( locations: readonly SetLocation[], name: string, value: string | undefined, + /** The run's key resolver, when the caller already unlocked. */ + opened?: KeyResolver, ): Promise { - const keys = value === undefined || locations.length === 0 ? undefined : env.openKeys(); + const keys = value === undefined || locations.length === 0 ? undefined : (opened ?? env.openKeys()); const pooled = await runPool({ items: locations, limit: LOCATION_CONCURRENCY, @@ -559,11 +715,16 @@ const NO_KEYS: KeyResolver = async () => { * what it left unstarted is in `cancelled`. Everything in the result is in input order. */ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks = {}): Promise { - const keys = req.locations.length === 0 || hooks.stopPushes?.aborted === true ? NO_KEYS : env.openKeys(); + const keys = req.locations.length === 0 || hooks.stopPushes?.aborted === true ? NO_KEYS : (req.keys ?? env.openKeys()); const pushed = await runPool({ items: req.locations, limit: LOCATION_CONCURRENCY, - run: (loc) => pushLocation(env, keys, loc, req.name, req.value), + run: (loc) => + pushLocation(env, keys, loc, req.name, req.value, { + shown: req.shown?.[locationKeyOf(loc)], + answer: req.answers?.[locationKeyOf(loc)], + restore: req.restore?.[locationKeyOf(loc)], + }), stop: hooks.stopPushes, reserve: PACING_RESERVE, now: env.now, @@ -618,6 +779,7 @@ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks name: req.name, updated: refsFor(updated), unchanged: outcomes.flatMap((o) => (o.kind === 'unchanged' ? [locationRef(o.location)] : [])), + ...(outcomes.some((o) => o.kind === 'removed') ? { removed: outcomes.flatMap((o) => (o.kind === 'removed' ? [locationRef(o.location)] : [])) } : {}), prs: present.flatMap((o) => (o.kind === 'pr' ? [o.pr] : [])), no_pr: present.flatMap((o) => (o.kind === 'no_pr' ? [{ repo: o.repo, reason: 'NO_DIFF_VS_BASE' as const }] : [])), failed: [ diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index 8ca6006f..ee4feb34 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -33,12 +33,27 @@ import { ProjectManager } from '../core/projectManager'; import { ServiceClient } from '../service/serviceClient'; import type { OrgRepoLink, SecretIndexRow } from '../service/serviceClient'; import { CapyError, ERROR_CODES } from '../types/index'; +import { hashValue } from '../deploy/keepGate'; +import { + ConflictRefusal, + conflictFlagsOrExit, + conflictRefusalFor, + gateConflicts, + printConflictRefusal, + type ConflictFlags, + type ConflictScope, + type RawConflictFlags, + type Resolutions, +} from '../sync/conflicts'; import { EXIT_NEEDS_INPUT } from '../ui/interactive'; import { mostRecentChangedAt } from '../ui/secretsScreen'; import { MAX_PIPED_BYTES, PipedValueResult, readPipedValue, refuseInvalidName, exitCodeForRefusal } from './pipedValue'; import { isValidVarName } from './pipedWrite'; import { rowIdOf } from './secretsRowId'; import { + KeyResolver, + LocationConflict, + LocationPreview, LocationRef, RepoTarget, SetEnv, @@ -54,7 +69,11 @@ import { repoLabel, resolveBases, runSecretSet, + locationKeyOf, + movedLocations, + recoverShownValue, } from './secretsSet'; +import { bindAnswer, shownValueUnavailable, type BoundAnswer } from '../sync/noFolder'; import { renderSecretSetConfirmation, stoppingAfter, TERMINAL_STYLE } from './secretsSetText'; import { PoolItem, runPool } from '../utils/pool'; @@ -74,6 +93,8 @@ export interface SecretsSetOpts { readonly noPrFor?: readonly string[]; /** `--no-pr` */ readonly noPr?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717): they decide a location someone else changed since its row was listed. */ + readonly conflictFlags?: RawConflictFlags; } /** Injectable seam: tests pass fakes for the service, GitHub and stdin. */ @@ -133,6 +154,22 @@ export function refuse(json: boolean, code: string, error: string, extra: object return process.exit(CODE_EXIT[code] ?? exitCodeForRefusal(code)); } +/** A conflict ends the command: the payload (with `extra`, e.g. the plan) on stdout, the sentence on stderr, exit 3. */ +export function exitWithConflicts(refusal: ConflictRefusal, extra: object = {}): never { + printConflictRefusal(refusal, extra); + return process.exit(EXIT_NEEDS_INPUT); +} + +/** The answers for `scopes` (the flags', when they cover them), or the command ends with the payload. */ +export function decideOrExit(scopes: readonly ConflictScope[], flags: ConflictFlags): Resolutions { + try { + return gateConflicts(flags, scopes); + } catch (err) { + if (err instanceof ConflictRefusal) return exitWithConflicts(err); + throw err; + } +} + export function describe(err: unknown): { readonly code: string; readonly message: string } { return err instanceof CapyError ? { code: err.code, message: err.message } @@ -143,6 +180,8 @@ export function describe(err: unknown): { readonly code: string; readonly messag interface Located extends SetLocation { readonly row_id: string; + /** The fingerprint the row listed for this location: what the caller was shown (`pinned`). */ + readonly seen: string; } const withRowId = (row: SecretIndexRow): readonly Located[] => { @@ -153,9 +192,32 @@ const withRowId = (row: SecretIndexRow): readonly Located[] => { branch: l.branch, protected: l.protected, row_id, + seen: row.value_hash, })); }; +// ── Moved locations (CAP-717) ─────────────────────────────────────────────── + +/** The fingerprint each row listed, by location: the `pinned` of the comparison. */ +const shownOf = (located: readonly Located[]): Readonly> => Object.fromEntries(located.map((l) => [locationKeyOf(l), l.seen])); + +/** + * The locations whose value is no longer the one the row listed AND is not the value being set: someone else + * changed it since the caller was shown it. `compareSecrets` over (listed, new, now), one scope per location (each is + * its own project and branch, with the server's keep hash at that read). + */ +export function secretConflictScopes( + name: string, + value: string, + located: readonly Located[], + previews: readonly LocationPreview[], +): readonly ConflictScope[] { + return movedLocations(name, value, located, shownOf(located), previews).flatMap((c): ConflictScope[] => { + const current = previews[located.findIndex((l) => locationKeyOf(l) === locationKeyOf(c.location))]?.current; + return current === undefined ? [] : [{ project: c.location.project_name, branch: c.location.branch, serverKeepHash: current.serverKeepHash, conflicts: [c.conflict] }]; + }); +} + export function candidatesOf(rows: readonly SecretIndexRow[]) { return rows.map((r) => ({ row_id: rowIdOf(r.name, r.value_hash), @@ -284,9 +346,15 @@ const planIdFor = (sel: Selection): string => * (needs a value to know `unchanged`) and GitHub's keep.lock for divergence. * The one planner: `capy secrets set --dry-run` and the `capy --dry-run secrets` screen both use it. */ -export async function describePlanBody(sel: PlanInput, env: SetEnv, value: string | undefined): Promise { +export async function describePlanBody( + sel: PlanInput, + env: SetEnv, + value: string | undefined, + /** Reads the caller already made (`previewLocations` for these locations and this value): not read again. */ + read?: readonly LocationPreview[], +): Promise { // ONE unlock, then the reads `LOCATION_CONCURRENCY` at a time (and the repos `PR_CONCURRENCY` at a time). - const previews = await previewLocations(env, sel.located, sel.name, value); + const previews = read ?? (await previewLocations(env, sel.located, sel.name, value)); const github = env.github(); const prs = ( await runPool({ @@ -318,8 +386,8 @@ export async function describePlanBody(sel: PlanInput, env: SetEnv, value: strin } /** The plan with its `plan_id`. */ -async function describePlan(sel: Selection, io: SecretsSetIo, value: string | undefined): Promise { - return { plan_id: planIdFor(sel), ...(await describePlanBody(sel, io.env, value)) }; +async function describePlan(sel: Selection, io: SecretsSetIo, value: string | undefined, read?: readonly LocationPreview[]): Promise { + return { plan_id: planIdFor(sel), ...(await describePlanBody(sel, io.env, value, read)) }; } /** Human text for a plan. */ @@ -366,6 +434,31 @@ export async function readSecretIndex(io: Pick } } +/** + * The plaintext each moved location gets written back (`pinned`), by `locationKeyOf`: the value its row listed, read + * from a location of that row that still holds it (never one of the moved ones). `undefined` when any moved location's + * value cannot be recovered. + */ +async function restoreValues( + env: SetEnv, + keys: KeyResolver, + name: string, + moved: readonly LocationConflict[], + shown: Readonly>, + everyLocation: readonly Located[], +): Promise> | undefined> { + const movedKeys = new Set(moved.map((c) => locationKeyOf(c.location))); + const hashes = [...new Set(moved.map((c) => shown[locationKeyOf(c.location)]))].flatMap((h) => (h === undefined ? [] : [h])); + const recovered = await Promise.all( + hashes.map(async (hash) => [hash, await recoverShownValue(env, keys, everyLocation.filter((l) => l.seen === hash && !movedKeys.has(locationKeyOf(l))), name, hash)] as const), + ); + const byHash = Object.fromEntries(recovered); + const entries = moved.map((c) => [locationKeyOf(c.location), byHash[shown[locationKeyOf(c.location)] ?? '']] as const); + return entries.some(([, value]) => value === undefined) + ? undefined + : Object.fromEntries(entries.flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const]))); +} + /** The value for a real run, from stdin only. A terminal on stdin is "nothing was piped". */ async function requireValue(io: SecretsSetIo, json: boolean): Promise { const piped: PipedValueResult = io.stdinIsTTY @@ -389,9 +482,11 @@ async function optionalValue(io: SecretsSetIo): Promise { export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: SecretsSetIo): Promise { const json = opts.json === true; if (!isValidVarName(name)) return refuseInvalidName(json); + const flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, json); const rows = selectRows(await readSecretIndex(io, json), name, opts, json); - const located = excludeLocations(rows.flatMap(withRowId), opts.exclude ?? [], json); + const everyLocation = rows.flatMap(withRowId); + const located = excludeLocations(everyLocation, opts.exclude ?? [], json); if (located.length === 0) { refuse(json, ERROR_CODES.SECRETS_NOTHING_SELECTED, 'No location is left to change.'); // COPY-FLAG } @@ -404,7 +499,15 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr const sel: Selection = { name, located, targets, notLinked: planning.notLinked, bases, reposUnavailable: loaded.unavailable }; if (opts.dryRun === true) { - const plan = await describePlan(sel, io, await optionalValue(io)); + const previewValue = await optionalValue(io); + // With a value to compare, a location someone else changed since its row was listed is reported with the + // conflict_id (exit 3), the plan alongside; without one there is nothing to compare yet. + const read = previewValue === undefined || located.length === 0 ? undefined : await previewLocations(io.env, located, name, previewValue); + const plan = await describePlan(sel, io, previewValue, read); + const scopes = previewValue === undefined || read === undefined ? [] : secretConflictScopes(name, previewValue, located, read); + if (scopes.length > 0) { + return exitWithConflicts(conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }), { plan_id: plan.plan_id, plan }); + } if (json) console.log(JSON.stringify({ ok: true, dry_run: true, ...plan }, null, 2)); else console.log(renderPlanText(plan)); return 0; @@ -431,7 +534,28 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr } const value = await requireValue(io, json); - const result = await runSecretSet({ name, value, locations: located, repos: targets, bases }, io.env, { + // Before anything is pushed: a location someone else changed since its row was listed needs an answer + // (`--resolve NAME=local|remote`, exit 3 with the rows and a conflict_id otherwise). An answer is for a NAME and + // covers every moved location of it; nothing is written until every row has one. + const keys = io.env.openKeys(); + const reads = await previewLocations(io.env, located, name, value, keys); + const scopes = secretConflictScopes(name, value, located, reads); + const decided = await decideOrExit(scopes, flags); + const moved = movedLocations(name, value, located, shownOf(located), reads); + const choice = decided[name]; + // `pinned` writes back the value the person was shown: recovered before anything is pushed, from a location of the same + // row that still holds it (the listing carries a fingerprint only). If it cannot be, the run ends with a code, writing nothing. + const restore = choice === 'pinned' ? await restoreValues(io.env, keys, name, moved, shownOf(located), everyLocation) : {}; + if (restore === undefined) { + const refusal = shownValueUnavailable(name); + return refuse(json, refusal.code, refusal.message); + } + const answers: Readonly> = Object.fromEntries( + choice === undefined ? [] : moved.map((c) => [locationKeyOf(c.location), bindAnswer(choice, c.conflict)] as const), + ); + const leftAlone = new Set(moved.filter(() => choice === 'remote').map((c) => locationKeyOf(c.location))); + const pushTo = located.filter((l) => !leftAlone.has(locationKeyOf(l))); + const result = await runSecretSet({ name, value, locations: pushTo, repos: targets, bases, shown: shownOf(located), answers, restore, keys }, 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. @@ -451,6 +575,7 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr plan_id: planId, updated: result.updated, unchanged: result.unchanged, + ...(result.removed === undefined ? {} : { removed: result.removed }), prs: result.prs, no_pr: result.no_pr, ...(result.skipped !== undefined ? { skipped: result.skipped } : {}), diff --git a/src/commands/secretsSetText.ts b/src/commands/secretsSetText.ts index 4a5a5b76..2dac71b9 100644 --- a/src/commands/secretsSetText.ts +++ b/src/commands/secretsSetText.ts @@ -27,6 +27,7 @@ const HEADER_NOTHING_TO_DO = (name: string) => `${name} already has this value in every selected location. Nothing changed.`; // COPY-FLAG const NOT_UPDATED = 'Not updated:'; // COPY-FLAG const SAVED_NO_PR = 'The new value is saved in Capy now.'; // COPY-FLAG +const removedHeader = (name: string, n: number): string => `✓ ${name} removed from ${n} location${n === 1 ? '' : 's'}.`; // COPY-FLAG export const CANCELLED_NOTHING = 'Cancelled. Nothing was changed.'; // COPY-FLAG /** The line after a stop: every item already running is waited for, none is started. */ export const stoppingAfter = (inFlight: number): string => `Stopping after the ${inFlight} in progress…`; // COPY-FLAG @@ -60,7 +61,10 @@ function header(result: SecretSetResult): string { ? `✓ ${result.name} updated in ${n} of ${plural(total)} (cancelled).` // COPY-FLAG : CANCELLED_NOTHING; } + const removed = result.removed?.length ?? 0; + if (n > 0 && removed > 0) return `✓ ${result.name} updated in ${plural(n)}.\n${removedHeader(result.name, removed)}`; if (n > 0) return `✓ ${result.name} updated in ${plural(n)}.`; + if (removed > 0) return removedHeader(result.name, removed); const locationFailures = result.failed.filter((f) => f.kind === 'location'); return locationFailures.length > 0 ? HEADER_FAILED(result.name) : HEADER_NOTHING_TO_DO(result.name); } diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index ab3f4843..7931309c 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -1,9 +1,9 @@ -import { createHash } from 'crypto'; import { ProjectManager } from '../core/projectManager'; import { FileManager } from '../files/fileManager'; import { AuthService, silentAuthFailureMessage } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { SyncEngine } from '../sync/syncEngine'; +import { compareSecrets, hashValue, type DiffResult } from '../sync/compareSecrets'; import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; import { fetchSecretsWithCache, readSecretsLocal } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; @@ -40,93 +40,6 @@ function classifyRemoteFailure(err: unknown): RemoteFailure { return 'network_error'; } -export interface DiffResult { - variable: string; - type: 'new' | 'changed' | 'deleted'; - pinned?: string; - local?: string; - remote?: string; -} - -/** - * Compare three sources: pinned (keep.lock hashes), local (.env values), remote (Keep values). - * Returns diff results and column visibility flags. - */ -export function compareSecrets( - pinned: Record, // variable -> value_hash - local: Record, // variable -> plaintext value (hashed for comparison) - remote: Record, // variable -> plaintext value (hashed for comparison) -): { diffs: DiffResult[]; showLocal: boolean; showRemote: boolean } { - const hasRemote = Object.keys(remote).length > 0; - - const allVars = new Set([ - ...Object.keys(pinned), - ...Object.keys(local), - ...(hasRemote ? Object.keys(remote) : []), - ]); - - const diffs: DiffResult[] = []; - let localDiffersFromPinned = false; - let remoteDiffersFromPinned = false; - - for (const variable of allVars) { - const pinnedHash = pinned[variable]; - const localHash = local[variable]; - // If remote has no data at all, treat each variable as matching pinned. - // If remote has data but this variable is missing, it's a real absence. - const remoteHash = hasRemote ? remote[variable] : pinnedHash; - - // Silent reconcile (CAP-307): no pinned baseline yet, but local and remote - // already agree. There is nothing to push (remote already holds this exact - // value) and nothing to pull — so don't report a diff and don't let this - // variable flip the showLocal/showRemote direction hints. The pin is adopted - // naturally via the "Everything is up to date!" path when no other variable - // differs. Scoped tightly: only when pinned is absent AND local === remote - // (both present); genuine divergence (local !== remote) still surfaces below. - if (!pinnedHash && localHash !== undefined && localHash === remoteHash) { - continue; - } - - // Track if local or remote differs from pinned at all - if (localHash !== pinnedHash) localDiffersFromPinned = true; - if (remoteHash !== pinnedHash) remoteDiffersFromPinned = true; - - // Only report rows with mismatches - if (pinnedHash === localHash && pinnedHash === remoteHash) continue; - if (!pinnedHash && !localHash && !remoteHash) continue; - - // Determine type - let type: 'new' | 'changed' | 'deleted'; - if (!pinnedHash && !localHash && remoteHash) { - type = 'new'; - } else if (!pinnedHash && localHash && !remoteHash) { - type = 'new'; - } else if (pinnedHash && !remoteHash && !localHash) { - type = 'deleted'; - } else if ((pinnedHash && !remoteHash) || (!pinnedHash && remoteHash)) { - type = remoteHash ? 'new' : 'deleted'; - } else { - type = 'changed'; - } - - diffs.push({ - variable, - type, - pinned: pinnedHash || undefined, - local: localHash || undefined, - remote: remoteHash || undefined, - }); - } - - // Column visibility: - // If all local values match pinned → hide Local column - // If all remote values match pinned → hide Remote column - const showLocal = localDiffersFromPinned; - const showRemote = remoteDiffersFromPinned; - - return { diffs, showLocal, showRemote }; -} - /** * Create a value snippet in abc...xyz format. */ @@ -137,12 +50,9 @@ export function formatSnippet(value: string): string { return `${value.slice(0, 3)}...${value.slice(-3)}`; } -/** - * Hash a plaintext value the same way keep.lock stores it. - */ -export function hashValue(value: string): string { - return createHash('sha256').update(value).digest('hex').slice(0, 16); -} +// Where they have always been imported from (the comparison itself lives in sync/compareSecrets.ts). +export { compareSecrets, hashValue }; +export type { DiffResult }; export class StatusCommand { private projectManager: ProjectManager; diff --git a/src/core/agentsBlockPlan.ts b/src/core/agentsBlockPlan.ts index a3831806..5939470f 100644 --- a/src/core/agentsBlockPlan.ts +++ b/src/core/agentsBlockPlan.ts @@ -25,6 +25,9 @@ export const AGENTS_BLOCK = [ '- Run `capy help --json` for every command, its options, and its error codes.', '- Always pass `--json` and branch on the `code` field, never on message text.', '- Run `capy pair --json` in an interactive terminal/PTY. After browser approval, it asks "Enable this location as [email]?" before installing the session or keys. Show the returned email to the human and obtain explicit Yes/No approval before answering. Never automatically confirm or pipe `yes`; Enter defaults to No. No upfront email is required.', + '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', // COPY-FLAG + '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', // COPY-FLAG + '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', // COPY-FLAG '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', AGENTS_BLOCK_END, diff --git a/src/core/availableCommands.ts b/src/core/availableCommands.ts new file mode 100644 index 00000000..ab0c4494 --- /dev/null +++ b/src/core/availableCommands.ts @@ -0,0 +1,39 @@ +/** + * The "Available commands" list an unknown command prints. Generated from the + * registered Commander program (through `buildCliHelpDoc`, the same walk + * `capy help --json` uses), so it cannot drift from the real command set: + * a command added to an entrypoint is in the list, a hidden one never is. + */ +import type { Command } from 'commander'; +import { buildCliHelpDoc, type CliArgumentDoc, type CliCommandDoc } from './cliHelpDoc'; + +const BOLD = (s: string): string => `\x1b[1m${s}\x1b[0m`; +const DIM = (s: string): string => `\x1b[90m${s}\x1b[0m`; + +const argumentText = (arg: CliArgumentDoc): string => { + const name = arg.variadic ? `${arg.name}...` : arg.name; + return arg.required ? `<${name}>` : `[${name}]`; +}; + +/** `status`, `invite `, `connect `: the command as it is typed, after the bin name. */ +const usageOf = (command: CliCommandDoc): string => [command.name, ...command.arguments.map(argumentText)].join(' '); + +/** + * One line per visible top-level command: the bin name and usage in bold, the + * description dimmed, descriptions aligned. The first line is the bare bin, + * which syncs. + */ +export function availableCommandLines(program: Command): readonly string[] { + const bin = program.name(); + const entries = [ + { typed: bin, description: 'Sync secrets' }, // COPY-FLAG + ...buildCliHelpDoc(program).commands.map((command) => ({ typed: `${bin} ${usageOf(command)}`, description: command.description })), + ]; + const width = Math.max(...entries.map((e) => e.typed.length)) + 2; + return entries.map((e) => ` ${BOLD(e.typed)}${' '.repeat(width - e.typed.length)}${DIM(e.description)}`); +} + +/** The whole unknown-command message, ready to print. */ +export function unknownCommandText(program: Command, unknown: string): string { + return ['', ` Unknown command: ${unknown}`, '', ' Available commands:', '', ...availableCommandLines(program), ''].join('\n'); +} diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 406efc7c..acaa0606 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -72,6 +72,8 @@ export interface CliHelpDoc { conventions: { json: string; codes: string; + /** What a command that writes from a folder does first, and how a difference is answered off a terminal (CAP-717). */ + conflicts?: string; }; } @@ -173,6 +175,9 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { conventions: { json: 'Pass --json on any command whose supportsJson is true and parse stdout as JSON; prose (progress, prompts, errors) goes to stderr so stdout stays pure JSON.', codes: 'Branch only on the machine-readable `code` field (from --json output or a CapyError), never on human-readable message text — messages may be reworded without notice.', + // COPY-FLAG: minimal and neutral; the contract is docs/conflict-resolution.md. + conflicts: + 'Every command that writes from a folder (edit, add, remove, connect, rotate, push, checkout, org, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first; `checkout -b` is the one exception (a new branch carries the uncommitted values of the folder). Off a terminal, if the folder and Capy differ on any variable, nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[{name, project, branch, type, pinned, local, remote}]} (fingerprints only, never values; a Dokploy value has {name, target, pinned, local, remote}). Ask the person, then rerun the same command with --resolve NAME=pinned|local|remote|delete for every row (NAME@TARGET=local|remote for a Dokploy value) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh rows. There is no resolve-all.', }, }; } diff --git a/src/core/cliReferenceMarkdown.ts b/src/core/cliReferenceMarkdown.ts index 8f3c1895..93d1c374 100644 --- a/src/core/cliReferenceMarkdown.ts +++ b/src/core/cliReferenceMarkdown.ts @@ -101,6 +101,7 @@ export function renderCliReferenceMarkdown(doc: CliHelpDoc): string { '', `- **JSON**: ${doc.conventions.json}`, `- **Codes**: ${doc.conventions.codes}`, + ...(doc.conventions.conflicts === undefined ? [] : [`- **Conflicts**: ${doc.conventions.conflicts}`]), '', ]; return lines.join('\n'); diff --git a/src/deploy/adapter.ts b/src/deploy/adapter.ts index 440063a2..cc1d4bac 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -11,7 +11,8 @@ */ import type { Classification } from './classify'; -import type { ResolveDokployApiKeyResult } from './dokployApi'; +import type { ResolveDokployApiKeyResult, TargetCheck } from './dokployApi'; +import type { TargetConflict } from '../sync/conflicts'; import type { ErrorCode } from '../types/index'; export type DeployMode = 'direct' | 'ci'; @@ -132,6 +133,14 @@ export interface DeployResult { epilogue?: string; /** Non-blocking heads-up(s) about the deploy that just ran. */ warnings?: readonly DeployWarning[]; + /** + * Target conflicts (CAP-717; only an adapter with `readsLiveValues`): delivered names whose live value on + * the platform was changed outside Capy. On a failed result they are unanswered and NOTHING was written; on a + * dry run with a `targetCheck` they are the preview. Fingerprints only. + */ + conflicts?: readonly TargetConflict[]; + /** Names the delivery left alone because the platform's value was kept (`remote`): not written, not to be recorded as delivered. */ + skipped?: readonly string[]; } export interface DeployContext extends AdapterCallContext { @@ -164,6 +173,17 @@ export interface DeployContext extends AdapterCallContext { noDeploy?: boolean; /** cwd of the user's invocation. */ cwd: string; + /** + * CAP-717, an adapter with `readsLiveValues` only: compare each delivered name with the platform's live value + * before writing. Absent: no comparison (the delivery is as it always was). With `dryRun` it reads and reports + * `conflicts` and writes nothing. + */ + targetCheck?: TargetCheck; + /** + * Deliver only the names in `config.vars`, leaving every other line Capy already manages on the platform exactly + * as it is (the batch deploy of picked variables, CAP-715). Absent: the managed block holds the target's vars. + */ + keepOtherManaged?: boolean; } export type AdapterVarKind = 'runtime' | 'build-time'; @@ -252,6 +272,11 @@ export interface DeployAdapter { * than setting each secret as a plaintext vendor env var. */ needsDeployToken?: boolean; + /** + * When true the adapter can read the platform's live values before it writes, and honours + * `DeployContext.targetCheck` (CAP-717: Dokploy). Other platforms cannot be read back. + */ + readsLiveValues?: boolean; /** * Hard requirements: binaries that must be on PATH, env vars that must be * set (typically only in CI), and an adapter-specific auth check. Any diff --git a/src/deploy/adapters/dokploy.ts b/src/deploy/adapters/dokploy.ts index 685ff4fe..ee147bfe 100644 --- a/src/deploy/adapters/dokploy.ts +++ b/src/deploy/adapters/dokploy.ts @@ -83,14 +83,17 @@ import { mergeManagedValuesBlock, mismatchedDeliveredValues, outsideLines, + planTargetDelivery, removeManagedValuesBlock, resolveDokployApiKey, resolveDokployToken, sortedCopy, splitManagedBlock, stripManagedBlock, + targetConflictsOf, trackedGitBranch, watchPathsExcludeKeep, + type TargetPlan, } from '../dokployApi'; import { repoRelPath } from '../git'; @@ -125,11 +128,13 @@ export { mergeManagedValuesBlock, mismatchedDeliveredValues, outsideLines, + planTargetDelivery, removeManagedValuesBlock, resolveDokployApiKey, resolveDokployToken, splitManagedBlock, stripManagedBlock, + targetConflictsOf, trackedGitBranch, watchPathsExcludeKeep, }; @@ -151,6 +156,8 @@ export type { ImportableEnvEntry, ImportSkipReason, ResolveDokployApiKeyResult, + TargetCheck, + TargetPlan, } from '../dokployApi'; export interface DokployOptions { @@ -738,6 +745,74 @@ function deliveredValuesFor( return { ok: true, values: config.vars.map((name) => ({ name, value: env[name] })) }; } +/** + * What a Dokploy delivery does with its target conflicts (CAP-717), once the service's env was freshly read: + * `stop` a result that ends the delivery with nothing written (an unanswered conflict, a dry run, or nothing + * left to write), or `write` the plan with the `ManagedMergeOptions` to merge it with. + */ +type Prepared = + | { readonly kind: 'stop'; readonly result: (steps: readonly DeployStep[], label: string) => DeployResult } + | { + readonly kind: 'write'; + readonly deliver: ReadonlyArray<{ name: string; value: string }>; + readonly skipped: readonly string[]; + readonly merge: { readonly keep?: (name: string) => boolean }; + }; + +function prepareDelivery( + config: TargetConfig, + ctx: DeployContext, + currentEnv: string | null, + values: ReadonlyArray<{ name: string; value: string }>, + withWarnings: (r: DeployResult) => DeployResult, +): Prepared { + const plan: TargetPlan = planTargetDelivery(currentEnv, values, ctx.targetCheck); + // A dry run (with a `targetCheck`) is the preview: everything found, nothing written, nothing refused. + if (ctx.dryRun) { + return { + kind: 'stop', + result: (steps, label) => + withWarnings({ + ok: true, + steps: [...steps, { label, status: 'skip', detail: 'dry-run' }], + conflicts: targetConflictsOf(config.name, plan.found), + skipped: plan.kind === 'clear' ? plan.skipped : [], + }), + }; + } + if (plan.kind === 'conflict') { + return { + kind: 'stop', + result: (steps, label) => + withWarnings({ + ok: false, + // COPY-FLAG: minimal wording; the names are in `conflicts`. + steps: [...steps, { label, status: 'fail', detail: 'the service changed since Capy last delivered', code: ERROR_CODES.SECRETS_CONFLICT }], + conflicts: targetConflictsOf(config.name, plan.found), + }), + }; + } + const keepOthers = ctx.keepOtherManaged === true; + if (plan.deliver.length === 0 && (keepOthers || plan.skipped.length > 0)) { + return { + kind: 'stop', + result: (steps, label) => + withWarnings({ + ok: true, + // COPY-FLAG: minimal wording. + steps: [...steps, { label, status: 'skip', detail: 'nothing to write: the values on the service were kept' }], + skipped: plan.skipped, + }), + }; + } + return { + kind: 'write', + deliver: plan.deliver, + skipped: plan.skipped, + merge: keepOthers || plan.skipped.length > 0 ? { keep: (name: string) => keepOthers || plan.skipped.includes(name) } : {}, + }; +} + /** * The Compose sequence (CAP-679, plain-value delivery per CAP-682): read → * refuse on `createEnvFile: false` → merge delivered vars as plain values @@ -799,10 +874,17 @@ async function deployComposeFlow( const warnings: readonly DeployWarning[] | undefined = stackWarning ? [stackWarning] : undefined; const withWarnings = (r: DeployResult): DeployResult => (warnings ? { ...r, warnings } : r); - const merged = mergeManagedValuesBlock(current.env, values); + // Before anything is written: the live value of every delivered name against what Capy last delivered (CAP-717). + const prepared = prepareDelivery(config, ctx, current.env, values, withWarnings); + if (prepared.kind === 'stop') return prepared.result(s1, 'compose.saveEnvironment'); + const deliver = prepared.deliver; + // A delivery that left names alone says so: they must not be recorded as delivered. + const withOutcome = (r: DeployResult): DeployResult => withWarnings(prepared.skipped.length > 0 ? { ...r, skipped: prepared.skipped } : r); + + const merged = mergeManagedValuesBlock(current.env, deliver, prepared.merge); if (!merged.ok) { const described = describeDokployPlainMergeProblem(merged.problem); - return withWarnings( + return withOutcome( fail(s1, { label: 'env merge', status: 'fail', @@ -818,7 +900,7 @@ async function deployComposeFlow( client.saveComposeEnvironment({ composeId: current.composeId, env: nextEnv, createEnvFile: current.createEnvFile }), ); if (!saved.ok) { - return withWarnings( + return withOutcome( fail(s1, { label: 'compose.saveEnvironment', status: 'fail', detail: explainApiError(saved.error, 'write', opts).reason }), ); } @@ -827,7 +909,7 @@ async function deployComposeFlow( const reread = await settle(client.getCompose(composeId)); const byteIntact = reread.ok && reread.value.env === nextEnv && reread.value.createEnvFile === current.createEnvFile; if (!byteIntact) { - return withWarnings( + return withOutcome( fail(s1, { label: 'compose.saveEnvironment', status: 'fail', @@ -837,9 +919,9 @@ async function deployComposeFlow( }), ); } - const mismatched = mismatchedDeliveredValues(nextEnv, values); + const mismatched = mismatchedDeliveredValues(nextEnv, deliver); if (mismatched.length > 0) { - return withWarnings( + return withOutcome( fail(s1, { label: 'compose.saveEnvironment', status: 'fail', @@ -851,18 +933,18 @@ async function deployComposeFlow( const otherVarsCount = 'code' in nextSplit ? 0 : envKeys(outsideLines(nextSplit)).length; const s2: readonly DeployStep[] = [ ...s1, - { label: 'compose.saveEnvironment', status: 'ok', detail: `${values.length} var(s) written plaintext; ${otherVarsCount} other var(s) kept` }, + { label: 'compose.saveEnvironment', status: 'ok', detail: `${deliver.length} var(s) written plaintext; ${otherVarsCount} other var(s) kept` }, ]; if (ctx.secretsOnly) { - return withWarnings({ + return withOutcome({ ok: true, steps: [...s2, { label: 'compose.redeploy', status: 'skip', detail: 'CI mode — merging the deploy PR triggers Dokploy’s own auto-deploy' }], epilogue: plainDeliveryEpilogue(config.name), }); } if (ctx.noDeploy) { - return withWarnings({ + return withOutcome({ ok: true, steps: [...s2, { label: 'compose.redeploy', status: 'skip', detail: '--no-deploy' }], epilogue: plainDeliveryEpilogue(config.name), @@ -873,7 +955,7 @@ async function deployComposeFlow( // redeploy call returns no id. const baseline = await settle(client.listComposeDeployments(composeId)); if (!baseline.ok) { - return withWarnings( + return withOutcome( fail(s2, { label: 'deployment.allByCompose', status: 'fail', detail: explainApiError(baseline.error, 'list', opts).reason }), ); } @@ -881,7 +963,7 @@ async function deployComposeFlow( // 5. Redeploy — never `compose.deploy` (re-clones the branch head), never `freshVolumes`. const triggered = await settle(client.redeployCompose(composeId, `capy deploy ${config.name}`)); if (!triggered.ok) { - return withWarnings( + return withOutcome( fail(s2, { label: 'compose.redeploy', status: 'fail', detail: explainApiError(triggered.error, 'trigger', opts).reason }), ); } @@ -900,12 +982,12 @@ async function deployComposeFlow( ), ); if (!polled.ok) { - return withWarnings(fail(s3, { label: 'deployment', status: 'fail', detail: explainApiError(polled.error, 'status', opts).reason })); + return withOutcome(fail(s3, { label: 'deployment', status: 'fail', detail: explainApiError(polled.error, 'status', opts).reason })); } const outcome = polled.value; switch (outcome.kind) { case 'succeeded': - return withWarnings({ + return withOutcome({ ok: true, steps: [...s3, { label: 'deployment', status: 'ok', detail: `succeeded (${outcome.deployment.deploymentId})` }], epilogue: plainDeliveryEpilogue(config.name), @@ -913,7 +995,7 @@ async function deployComposeFlow( case 'failed': { const logs = await settle(client.readLogs(outcome.deployment.deploymentId)); const logText = logs.ok ? logs.value.trim() : ''; - return withWarnings( + return withOutcome( fail( s3, { @@ -930,7 +1012,7 @@ async function deployComposeFlow( ); } case 'timed_out': - return withWarnings( + return withOutcome( fail(s3, { label: 'deployment', status: 'fail', @@ -1107,6 +1189,8 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt // token is minted for it. The blob/token machinery itself is untouched // for adapters that still use it, and for `capy run`. needsDeployToken: false, + // CAP-717: the service's env is read before it is written, so a value changed outside Capy can be compared. + readsLiveValues: true, requires: { binaries: [] }, async detect(): Promise { @@ -1153,7 +1237,8 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt async deploy(config: TargetConfig, ctx: DeployContext): Promise { const opts = config.options as unknown as DokployOptions; - if (ctx.dryRun) { + // A plain dry run reads nothing; with a `targetCheck` it reads the service and reports the conflicts (CAP-717). + if (ctx.dryRun && ctx.targetCheck === undefined) { return { ok: true, steps: [ @@ -1220,7 +1305,13 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt } // CAP-682: DOKPLOY_SHADOWED_VAR no longer applies — see the identical // note in `deployComposeFlow`. - const merged = mergeManagedValuesBlock(current.env, delivered.values); + // Before anything is written: the live value of every delivered name against what Capy last delivered (CAP-717). + const prepared = prepareDelivery(config, ctx, current.env, delivered.values, (r) => r); + if (prepared.kind === 'stop') return prepared.result(s1, 'application.saveEnvironment'); + const deliver = prepared.deliver; + // A delivery that left names alone says so: they must not be recorded as delivered. + const withSkipped = (r: DeployResult): DeployResult => (prepared.skipped.length > 0 ? { ...r, skipped: prepared.skipped } : r); + const merged = mergeManagedValuesBlock(current.env, deliver, prepared.merge); if (!merged.ok) { const described = describeDokployPlainMergeProblem(merged.problem); return fail(s1, { @@ -1270,7 +1361,7 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt 'same moment. Check the Environment tab in Dokploy, then re-run.', }); } - const mismatched = mismatchedDeliveredValues(nextEnv, delivered.values); + const mismatched = mismatchedDeliveredValues(nextEnv, deliver); if (mismatched.length > 0) { return fail(s1, { label: 'application.saveEnvironment', @@ -1285,26 +1376,26 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt { label: 'application.saveEnvironment', status: 'ok', - detail: `${delivered.values.length} var(s) written plaintext; ${otherVarsCount} other var(s), build args and build secrets kept`, + detail: `${deliver.length} var(s) written plaintext; ${otherVarsCount} other var(s), build args and build secrets kept`, }, ]; if (ctx.secretsOnly) { - return { + return withSkipped({ ok: true, steps: [ ...s2, { label: 'application.deploy', status: 'skip', detail: "CI mode — merging the deploy PR triggers Dokploy's own auto-deploy" }, ], epilogue: plainDeliveryEpilogue(config.name), - }; + }); } if (ctx.noDeploy) { - return { + return withSkipped({ ok: true, steps: [...s2, { label: 'application.deploy', status: 'skip', detail: '--no-deploy' }], epilogue: plainDeliveryEpilogue(config.name), - }; + }); } // 4. Trigger, remembering which deployments already existed — Dokploy's @@ -1352,14 +1443,14 @@ export function createDokployAdapter(deps: DokployAdapterDeps = {}): DeployAdapt const outcome = polled.value; switch (outcome.kind) { case 'succeeded': - return { + return withSkipped({ ok: true, steps: [ ...s3, { label: 'deployment', status: 'ok', detail: `succeeded (${outcome.deployment.deploymentId})` }, ], epilogue: plainDeliveryEpilogue(config.name), - }; + }); case 'failed': { const logs = await settle(client.readLogs(outcome.deployment.deploymentId)); const logText = logs.ok ? logs.value.trim() : ''; diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts index a0e63ad3..34e1a49b 100644 --- a/src/deploy/batchDeploy.ts +++ b/src/deploy/batchDeploy.ts @@ -42,7 +42,9 @@ import { RecordClient, RecordResult, deliveryFor, recordDeliveriesOnServer } fro import { buildDeployPrBody } from './deployPrBody'; import { foldDeployKeep, hashValue } from './keepGate'; import { parseTargets } from './configuredTargets'; -import { TargetDeliveryDescriptor, VarDelivery, deliveryWorthGating } from './targetsGate'; +import { TargetDeliveryDescriptor, VarDelivery, deployedHashesFor, deliveryWorthGating } from './targetsGate'; +import { SyncEngine } from '../sync/syncEngine'; +import { seenResolutions, targetResolutions, type ConflictScope, type Resolutions, type SeenResolutions } from '../sync/conflicts'; import { callWithBackoff, retryOnRateLimit } from '../service/capyCalls'; import { PACING_RESERVE, PoolItem, RateInfo, runPool } from '../utils/pool'; import type { Sleep } from '../utils/backoff'; @@ -72,6 +74,8 @@ export const SKIP_CODES = { 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', + /** Every picked value on the service was changed outside Capy and the caller chose to keep the service's (CAP-717). */ + KEPT_ON_PLATFORM: 'KEPT_ON_PLATFORM', } as const; export type SkipCode = (typeof SKIP_CODES)[keyof typeof SKIP_CODES]; @@ -118,6 +122,12 @@ export interface BatchTarget { /** The PR base: the target's `gitBaseBranch`, else the repo's default branch. */ readonly base: string; readonly config: TargetConfig; + /** + * The selected names this target ships: the only variables the deploy delivers and records (CAP-715). + * Every other variable of the target, and every other line Capy manages in its Dokploy block, is left as it is. + * Absent: all of the target's variables. + */ + readonly picked?: readonly string[]; } export interface PlanSkipped { @@ -205,6 +215,7 @@ function classifyLocation( path: link.path, base: config.gitBaseBranch ?? base, config, + picked: config.vars.filter((v) => names.includes(v)), })); const skipped = [...notOnDefault, ...foundSkips]; return { @@ -353,7 +364,7 @@ export interface BatchEnv { readonly now?: () => number; } -export type BatchPhase = 'pushing' | 'recording' | 'prs'; +export type BatchPhase = 'checking' | 'pushing' | 'recording' | 'prs'; export interface BatchProgress { readonly phase: BatchPhase; @@ -372,6 +383,12 @@ export interface BatchHooks { readonly onStopping?: (stopping: { readonly phase: BatchPhase; readonly inFlight: number }) => void; readonly stopPushes?: AbortSignal; readonly stopPrs?: AbortSignal; + /** + * CAP-717: given, every Dokploy target is first compared with the service's live env (reading only), and the + * conflicts found are handed over BEFORE anything is written. It returns the decisions (by `NAME@TARGET`) or + * throws a `ConflictRefusal` (nothing has been written). Absent: no comparison, as before. + */ + readonly decide?: (scopes: readonly ConflictScope[]) => Resolutions; } // --------------------------------------------------------------------------- @@ -476,15 +493,24 @@ export interface Pushed { 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)) } }; +/** The target restricted to `names`: only these are delivered and recorded. */ +const withVars = (item: BatchTarget, names: readonly string[]): BatchTarget => ({ ...item, config: { ...item.config, vars: [...names] } }); + +/** The names the deploy is about: the picked ones the branch has a value for (all of the target's when none were picked). */ +const wantedOf = (item: BatchTarget): readonly string[] => item.picked ?? item.config.vars; 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 }; +/** The comparison pass (reading only): where this target's conflicts, if any, were found. */ +export interface Checked { + readonly kind: 'checked'; + readonly item: BatchTarget; + readonly scope: ConflictScope | undefined; +} + const failedPush = (item: BatchTarget, code: string, stage: FailStage, rate?: RateInfo): PoolItem => withRate({ kind: 'failed', item, code, stage }, rate); @@ -498,12 +524,43 @@ function specFor(p: Pushed): KeepLockFileSpec { }; } +/** What one pass over a target does: look for conflicts (reading only), or deliver with the decisions made. */ +type TargetPass = { readonly kind: 'check' } | { readonly kind: 'deliver'; readonly resolutions: SeenResolutions }; + /** - * One target, up to and including the delivery to Dokploy: preflight, read and decrypt its + * One target, up to and including the delivery to Dokploy: preflight, read and decrypt its picked * variables, check against the PR base's keep.lock that something would change, push. * Never throws; reports the rate-limit reading it saw. + * + * Only the picked variables are delivered and recorded; every other managed line in the service's Capy block + * stays exactly as it is (CAP-715). On Dokploy the live value of each is first compared with what Capy last + * delivered (CAP-717): the `check` pass reads and reports, the `deliver` pass writes with the decisions made + * (`NAME@TARGET`): `remote` leaves that variable on the service as it is and records nothing for it. */ -export async function pushTarget(env: BatchEnv, keys: KeyResolver, apiKeys: ApiKeys, item: BatchTarget): Promise> { +export async function pushTarget( + env: BatchEnv, + keys: KeyResolver, + apiKeys: ApiKeys, + item: BatchTarget, + resolutions: SeenResolutions = {}, +): Promise> { + const done = await passOverTarget(env, keys, apiKeys, item, { kind: 'deliver', resolutions }); + // A delivery pass never answers `checked`; if it ever did, nothing was written and the target says so. + return done.result.kind === 'checked' ? failedPush(item, ERROR_CODES.SERVICE_ERROR, 'push', done.rate) : (done as PoolItem); +} + +/** The comparison pass over one target: reads, decrypts and compares; writes nothing. */ +export async function checkTarget(env: BatchEnv, keys: KeyResolver, apiKeys: ApiKeys, item: BatchTarget): Promise> { + return passOverTarget(env, keys, apiKeys, item, { kind: 'check' }); +} + +async function passOverTarget( + env: BatchEnv, + keys: KeyResolver, + apiKeys: ApiKeys, + item: BatchTarget, + pass: TargetPass, +): Promise> { const config = item.config; const github = env.github(); if (github === undefined) return failedPush(item, ERROR_CODES.KEEP_PR_GH_UNAVAILABLE, 'github'); @@ -522,11 +579,12 @@ export async function pushTarget(env: BatchEnv, keys: KeyResolver, apiKeys: ApiK 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); + const decrypted = decryptVars(read.value.env_content ?? '', wantedOf(item), 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])])); + // From here on the target carries only the picked names that have a value (`capy deploy` drops a missing one the same way). + const present = wantedOf(item).filter((v) => !decrypted.missing.includes(v)); + const effective = withVars(item, present); + const hashes = Object.fromEntries(present.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. @@ -547,17 +605,61 @@ export async function pushTarget(env: BatchEnv, keys: KeyResolver, apiKeys: ApiK return withRate({ kind: 'skipped', item, code: SKIP_CODES.NOTHING_TO_DEPLOY }, read.rate); } + // Dokploy: the service's live value of each picked name against what Capy last delivered (CAP-717). + const targetCheck = env.adapter.readsLiveValues + ? { + pinned: deployedHashesFor(serverKeep, item.branch, delivery.provider, config.name), + resolutions: pass.kind === 'deliver' ? targetResolutions(pass.resolutions, config.name) : {}, + } + : undefined; + const ctx = { + env: decrypted.values, + secretsOnly: true, + noDeploy: false, + keepOtherManaged: true, + cwd: process.cwd(), + ...callCtx, + ...(targetCheck === undefined ? {} : { targetCheck }), + }; + + if (pass.kind === 'check') { + if (targetCheck === undefined) return withRate({ kind: 'checked', item, scope: undefined }, read.rate); + const looked = await settled(env.adapter.deploy(effective.config, { ...ctx, dryRun: true })); + if (!looked.ok) return failedPush(item, codeOf(looked.error), 'push', read.rate); + if (!looked.value.ok) return failedPush(item, looked.value.steps.find((st) => st.status === 'fail' && st.code !== undefined)?.code ?? ERROR_CODES.DEPLOY_PUSH_FAILED, 'push', read.rate); + const conflicts = looked.value.conflicts ?? []; + const scope: ConflictScope | undefined = + conflicts.length === 0 + ? undefined + : { project: serverKeep.project_name, branch: item.branch, serverKeepHash: SyncEngine.computeKeepHash(serverKeep, item.branch), conflicts }; + return withRate({ kind: 'checked', item, scope }, 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 }), - ); + const delivered = await settled(env.adapter.deploy(effective.config, { ...ctx, dryRun: false })); if (!delivered.ok) return failedPush(item, codeOf(delivered.error), 'push', read.rate); if (!delivered.value.ok) { + // The service changed between the comparison and the write: refused, nothing written. + if ((delivered.value.conflicts ?? []).length > 0) return failedPush(item, ERROR_CODES.SECRETS_CONFLICT, 'push', read.rate); 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); } + // A name whose service value was kept (`remote`) was not written: it is not recorded as delivered either. + const kept = delivered.value.skipped ?? []; + const landed = present.filter((v) => !kept.includes(v)); + if (landed.length === 0) return withRate({ kind: 'skipped', item, code: SKIP_CODES.KEPT_ON_PLATFORM }, read.rate); + const recorded = deliveryFor(withVars(item, landed).config, env.adapter, undefined, false, hashes); return withRate( - { kind: 'pushed', item: effective, delivery, values, hashes, deliveredAt: new Date((env.now ?? Date.now)()).toISOString(), scaffold, missing: decrypted.missing }, + { + kind: 'pushed', + item: withVars(item, landed), + delivery: recorded.delivery, + values: recorded.values, + hashes: Object.fromEntries(landed.map((v) => [v, hashes[v]])), + deliveredAt: new Date((env.now ?? Date.now)()).toISOString(), + scaffold, + missing: decrypted.missing, + }, read.rate, ); } @@ -571,7 +673,8 @@ const groupKey = (p: Pushed): string => `${p.item.project_id}\u0000${p.item.bran /** 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), + pushSecrets: (projectId, keepFile, envBlob, branch, _onRateLimit, baseKeepHash) => + retryOnRateLimit(() => env.client.pushSecrets(projectId, keepFile, envBlob, branch, undefined, baseKeepHash), env.sleep), }); /** Records every pushed target of ONE (project, branch) with ONE server write. */ @@ -674,6 +777,37 @@ export async function deployTarget(env: BatchEnv, keys: KeyResolver, apiKeys: Ap // The run // --------------------------------------------------------------------------- +/** + * The comparison pass of a run (CAP-717): each target read, decrypted and compared with its service, nothing + * written. Returns where conflicts were found (none for a target on a platform that cannot be read back). + */ +async function findBatchConflicts( + plan: BatchPlan, + env: BatchEnv, + keys: KeyResolver, + apiKeys: ApiKeys, + hooks: BatchHooks, +): Promise { + const looked = await runPool({ + items: plan.targets, + limit: BATCH_DEPLOY_CONCURRENCY, + run: (item) => checkTarget(env, keys, apiKeys, item), + stop: hooks.stopPushes, + reserve: PACING_RESERVE, + now: env.now, + sleep: env.sleep, + onChange: (p) => hooks.onProgress?.({ phase: 'checking', ...p }), + onStopping: (inFlight) => hooks.onStopping?.({ phase: 'checking', inFlight }), + }); + return looked.results.flatMap((o) => (o?.kind === 'checked' && o.scope !== undefined ? [o.scope] : [])); +} + +/** The conflicts a run of `plan` would meet, from one reading pass: what `--dry-run` reports. Writes nothing. */ +export async function previewBatchConflicts(plan: BatchPlan, env: BatchEnv): Promise { + if (plan.targets.length === 0 || env.adapter.readsLiveValues !== true) return []; + return findBatchConflicts(plan, env, env.openKeys(), await resolveApiKeys(env, plan.targets), {}); +} + /** * 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 @@ -687,10 +821,18 @@ export async function runBatchDeploy(plan: BatchPlan, env: BatchEnv, hooks: Batc 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); + // Before anything is written: every target's live values are compared (reading only) and the conflicts decided. + const found = + hooks.decide === undefined || plan.targets.length === 0 || stoppedAtStart ? [] : await findBatchConflicts(plan, env, keys, apiKeys, hooks); + const decided: Resolutions = hooks.decide === undefined || found.length === 0 ? {} : hooks.decide(found); + // Each decision is bound to the fingerprints it was made against: the delivery pass re-reads every service, and a + // decision does not apply to a value that moved in between. + const decisions: SeenResolutions = seenResolutions(decided, found.flatMap((s) => s.conflicts)); + const pushedPhase = await runPool({ items: plan.targets, limit: BATCH_DEPLOY_CONCURRENCY, - run: (item) => pushTarget(env, keys, apiKeys, item), + run: (item) => pushTarget(env, keys, apiKeys, item, decisions), stop: hooks.stopPushes, reserve: PACING_RESERVE, now: env.now, diff --git a/src/deploy/configuredTargets.ts b/src/deploy/configuredTargets.ts index 8dea201a..56f22f99 100644 --- a/src/deploy/configuredTargets.ts +++ b/src/deploy/configuredTargets.ts @@ -17,6 +17,7 @@ import type { TargetConfig } from './adapter'; import type { KeepFile, KeepVariableEntry, TargetDelivery } from '../types/index'; import { readFileAtRef, repoRelPath, resolveDefaultBranch } from './git'; +import { uploadKeepOnly } from '../sync/commit'; /** Where `.capy/deploy.json` sits relative to the project folder (same place `deployConfigPath` points). */ const DEPLOY_CONFIG_REL = '.capy/deploy.json'; @@ -119,7 +120,7 @@ export function withPlaceholderRecords(keep: KeepFile, branch: string, missing: /** What `recordConfiguredTargets` needs from the service client. */ export interface ConfiguredTargetsClient { 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; + pushSecrets(projectId: string, keepFile: string, envBlob: string, branch?: string, onRateLimit?: undefined, baseKeepHash?: string): Promise; } /** @@ -151,24 +152,10 @@ async function recordBranch( targets: readonly TargetConfig[], branch: string, ): Promise { - try { - const latest = await input.client.getLatestSecrets(input.projectId, branch); - const keep = latest?.keep_file ? parseKeep(latest.keep_file) : undefined; - if (!latest || !keep) return 0; - const missing = missingTargetRecords(targets, keep, branch); - if (missing.length === 0) return 0; - await input.client.pushSecrets(input.projectId, JSON.stringify(withPlaceholderRecords(keep, branch, missing)), latest.env_file, branch); - return missing.length; - } catch { - return 0; - } + // The shared keep-only upload (sync/commit.ts): the server's own keep and blob, `base_keep_hash`, one retry if stale. + const done = await uploadKeepOnly(input.client, input.projectId, branch, (keep) => + withPlaceholderRecords(keep, branch, missingTargetRecords(targets, keep, branch)), + ); + return done.ok && done.pushed ? missingTargetRecords(targets, done.previous, branch).length : 0; } -function parseKeep(raw: string): KeepFile | undefined { - try { - const parsed: unknown = JSON.parse(raw); - return isRecord(parsed) && isRecord(parsed.variables) ? (parsed as unknown as KeepFile) : undefined; - } catch { - return undefined; - } -} diff --git a/src/deploy/deliveryRecord.ts b/src/deploy/deliveryRecord.ts index e46367f9..3cfa8155 100644 --- a/src/deploy/deliveryRecord.ts +++ b/src/deploy/deliveryRecord.ts @@ -13,7 +13,8 @@ */ import type { DeployAdapter, TargetConfig } from './adapter'; import { TargetDeliveryDescriptor, VarDelivery, recordTargetDeliveries } from './targetsGate'; -import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; +import { uploadKeepOnly } from '../sync/commit'; +import type { RateInfo } from '../utils/pool'; /** * Adapter-specific handle for what a target actually points at, when one is @@ -59,76 +60,47 @@ export function deliveryFor( /** 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; + pushSecrets( + projectId: string, + keepFile: string, + envBlob: string, + branch: string, + onRateLimit?: (rate: RateInfo) => void, + /** The branch hash of the keep this push was built from; the server refuses (SECRETS_STALE_BASE) when it is no longer the latest. */ + baseKeepHash?: 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) }; - } -} +type Deliveries = ReadonlyArray<{ + readonly delivery: TargetDeliveryDescriptor; + readonly values: readonly VarDelivery[]; + /** ISO time the values reached the platform. */ + readonly deliveredAt: string; +}>; /** * 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. + * unchanged. Nothing is pushed when every delivery is already recorded. This is + * the shared keep-only upload (`uploadKeepOnly`, sync/commit.ts): the push names + * the keep it was built from (`base_keep_hash`), and if the server says it is no + * longer the latest (`SECRETS_STALE_BASE`) the keep is read and built once more. * 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, +export async function recordDeliveriesOnServer(client: RecordClient, projectId: string, branch: string, deliveries: Deliveries): Promise { + const done = await uploadKeepOnly(client, projectId, branch, (keep) => + deliveries.reduce( + (acc, d) => (d.values.length === 0 ? acc : recordTargetDeliveries(acc, branch, d.delivery, d.deliveredAt, d.values)), + 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) }; - } + return done.ok ? { ok: true, pushed: done.pushed } : done; } diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index 2f514e7e..b24a5868 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -10,6 +10,8 @@ * literal. */ import { parse as parseDotenv } from 'dotenv'; +import { hashValue } from '../sync/compareSecrets'; +import type { SeenResolutions, TargetConflict, TargetResolution } from '../sync/conflicts'; import { ERROR_CODES } from '../types/index'; /** @@ -1359,6 +1361,64 @@ export type DokployPlainMergeProblem = // `describeDokployPlainMergeProblem`, which groups them back out). | { code: 'value_problem'; problems: readonly DotenvValueNameProblem[] }; +/** One entry of the existing Capy block, with the physical lines it occupies. */ +interface BlockEntry { + readonly name: string; + /** The entry's raw lines, byte for byte, each with its own line ending. */ + readonly raw: string; +} + +/** + * The entries of the Capy block `split` found in `env` (a multi-line quoted value is one entry). Comments and + * blank lines inside the block are not entries. No block: none. + */ +function blockEntriesOf(env: string | null, split: EnvSplit): readonly BlockEntry[] { + if (!split.hadBlock) return []; + const text = env ?? ''; + const block = text.slice(split.before.length, text.length - split.after.length); + const inner = splitPreservingEol(block).slice(1, -1); + return findEntrySpans(inner).map((span) => ({ + name: span.name, + raw: inner + .slice(span.lineStart, span.lineEnd + 1) + .map((l) => l.content + l.eol) + .join(''), + })); +} + +/** What `mergeManagedValuesBlock` may be asked beyond delivering `values`. */ +export interface ManagedMergeOptions { + /** + * Names, other than the delivered ones, whose lines in the Capy block stay exactly as they are (byte for byte, + * in place) instead of being dropped, and whose commented-out outside lines stay commented. Absent: the block + * holds exactly the delivered values (the delivery of a whole target). CAP-717 uses it for a variable skipped + * because the platform's value was kept (`remote`), and for a batch deploy that sends only the picked names. + */ + readonly keep?: (name: string) => boolean; +} + +/** + * The Capy block when some of its existing lines are kept: the existing entries in their order (a delivered one + * rewritten where it stands, a kept one byte for byte, any other dropped), then the delivered names that were + * not in the block. + */ +function retainingBlock( + existing: readonly BlockEntry[], + values: ReadonlyArray<{ name: string; value: string }>, + renderedLines: readonly string[], + keep: (name: string) => boolean, + eol: '\n' | '\r\n', +): string { + const lineOf = Object.fromEntries(values.map((v, i) => [v.name, renderedLines[i]] as const)); + const delivered = (name: string): boolean => Object.hasOwn(lineOf, name); + const inPlace = existing.flatMap((e, i) => { + if (delivered(e.name)) return existing.findIndex((x) => x.name === e.name) === i ? [lineOf[e.name] + eol] : []; + return keep(e.name) ? [e.raw] : []; + }); + const appended = values.filter((v) => !existing.some((e) => e.name === v.name)).map((v) => lineOf[v.name] + eol); + return [MANAGED_BEGIN + eol, ...inPlace, ...appended, MANAGED_END].join(''); +} + /** * Whole-env write for CAP-682's plaintext delivery: comments out every * pre-existing active line outside the block for a delivered name (see @@ -1368,6 +1428,10 @@ export type DokployPlainMergeProblem = * placement rule `mergeManagedBlock` uses (reuses its own `blockEol`/ * `dominantEol` choice, so a redeploy's block framing never drifts). * + * `options.keep` names entries of the existing block that are carried over untouched (see + * `ManagedMergeOptions`): a delivered name that already has a line is rewritten in place, a new one is + * appended, a kept one is left exactly as it is, any other is dropped. + * * All-or-nothing on an unrepresentable value: nothing is written (not even * the comment/uncomment half) — a partial write would leave the platform's * env in a state no single `capy deploy` run produced. @@ -1375,6 +1439,7 @@ export type DokployPlainMergeProblem = export function mergeManagedValuesBlock( env: string | null, values: ReadonlyArray<{ name: string; value: string }>, + options: ManagedMergeOptions = {}, ): { ok: true; env: string } | { ok: false; problem: DokployPlainMergeProblem } { const split = splitManagedBlock(env); if ('code' in split) return { ok: false, problem: { code: 'malformed_block' } }; @@ -1382,11 +1447,18 @@ export function mergeManagedValuesBlock( if (!rendered.ok) { return { ok: false, problem: { code: 'value_problem', problems: rendered.problems } }; } - const deliveredNames = new Set(values.map((v) => v.name)); + const keep = options.keep; + const existing = keep === undefined ? [] : blockEntriesOf(env, split); + const valueNames = new Set(values.map((v) => v.name)); + const keptNames = keep === undefined ? [] : existing.filter((e) => !valueNames.has(e.name) && keep(e.name)).map((e) => e.name); + const deliveredNames = new Set([...valueNames, ...keptNames]); const nextBefore = syncCommentedLines(split.before, deliveredNames); const nextAfter = syncCommentedLines(split.after, deliveredNames); const eol = split.hadBlock ? (split.blockEol ?? '\n') : dominantEol(nextBefore); - const block = [MANAGED_BEGIN, ...rendered.lines, MANAGED_END].join(eol); + const block = + keep === undefined + ? [MANAGED_BEGIN, ...rendered.lines, MANAGED_END].join(eol) + : retainingBlock(existing, values, rendered.lines, keep, eol); const withBlock = split.hadBlock ? nextBefore + block : nextBefore.length > 0 @@ -1430,6 +1502,89 @@ export function mismatchedDeliveredValues( return values.filter((v) => parsed[v.name] !== v.value).map((v) => v.name); } +// ── Target conflicts (CAP-717) ────────────────────────────────────────────── + +/** A delivered name whose live value on Dokploy was changed outside Capy. Fingerprints only. */ +export interface LiveConflict { + readonly name: string; + /** What Capy last delivered (`undefined` on a first delivery). */ + readonly pinned: string | undefined; + /** What Capy is about to deliver (`local`). */ + readonly local: string; + /** What the service would see now (`remote`). */ + readonly remote: string; +} + +/** + * The delivered names whose live value on the platform was changed outside Capy. For each name: + * - remote = the value the service would read: `dotenv.parse` of the WHOLE current env (inside Capy's block, + * or an active line outside it), the same parse `mismatchedDeliveredValues` verifies a write with; + * - pinned = the `deployed_value_hash` Capy recorded when it last delivered the name here (absent on a first delivery); + * - local = the value about to be delivered. + * A conflict is a live value that exists and differs from BOTH pinned and local. A name the service does not + * have, one already at Capy's value, and one still at what Capy last put there are not conflicts. Pure; never + * returns or logs a value. + */ +export function dokployTargetConflicts( + env: string | null, + values: ReadonlyArray<{ name: string; value: string }>, + pinned: Readonly>, +): readonly LiveConflict[] { + const live = parseDotenv(env ?? ''); + return values.flatMap(({ name, value }) => { + if (!Object.hasOwn(live, name)) return []; + const remote = hashValue(live[name]); + const local = hashValue(value); + return remote === local || remote === pinned[name] ? [] : [{ name, pinned: pinned[name], local, remote }]; + }); +} + +/** How a Dokploy delivery treats its target conflicts: what was already decided, and what Capy last delivered. */ +export interface TargetCheck { + /** `deployed_value_hash` by name for this target. */ + readonly pinned: Readonly>; + /** + * Answers already given, by bare NAME, each bound to the `local` / `remote` fingerprints it was made against: + * an answer applies only to that exact state. If the service's value (or the value being delivered) is no longer + * the one the caller saw, the answer does not count and the row is open again. + */ + readonly resolutions: SeenResolutions; +} + +export type TargetPlan = + | { + readonly kind: 'clear'; + /** The values to write: all of them minus the names kept as `remote`. */ + readonly deliver: ReadonlyArray<{ name: string; value: string }>; + /** Names whose platform value is kept (`remote`): not written, not recorded. */ + readonly skipped: readonly string[]; + /** Every conflict found, decided or not (for the command layer's id). */ + readonly found: readonly LiveConflict[]; + } + | { readonly kind: 'conflict'; readonly found: readonly LiveConflict[]; readonly open: readonly LiveConflict[] }; + +/** `dokployTargetConflicts` and the decisions applied: either a clear write plan, or the conflicts that still need an answer. */ +export function planTargetDelivery( + env: string | null, + values: ReadonlyArray<{ name: string; value: string }>, + check: TargetCheck | undefined, +): TargetPlan { + if (check === undefined) return { kind: 'clear', deliver: values, skipped: [], found: [] }; + const found = dokployTargetConflicts(env, values, check.pinned); + const choiceFor = (c: LiveConflict): TargetResolution | undefined => { + const seen = check.resolutions[c.name]; + return seen !== undefined && seen.remote === c.remote && seen.local === c.local ? seen.choice : undefined; + }; + const open = found.filter((c) => choiceFor(c) === undefined); + if (open.length > 0) return { kind: 'conflict', found, open }; + const skipped = found.filter((c) => choiceFor(c) === 'remote').map((c) => c.name); + return { kind: 'clear', deliver: values.filter((v) => !skipped.includes(v.name)), skipped, found }; +} + +/** The protocol's rows for `found` on `target`. */ +export const targetConflictsOf = (target: string, found: readonly LiveConflict[]): readonly TargetConflict[] => + found.map((c) => ({ name: c.name, target, pinned: c.pinned ?? null, local: c.local, remote: c.remote })); + /** * CAP-679: a Compose service with `createEnvFile: false` never gets an * `.env` file on disk, so a service that reads its config via `env_file: diff --git a/src/deploy/targetsGate.ts b/src/deploy/targetsGate.ts index 90f32f34..ae1f7432 100644 --- a/src/deploy/targetsGate.ts +++ b/src/deploy/targetsGate.ts @@ -143,6 +143,26 @@ export function upsertTargetElement( return [...filtered, element]; } +/** + * `deployed_value_hash` by variable name: what Capy last delivered to (provider, target) on `branch` + * (CAP-717's target conflict check compares the platform's live value against it). A variable the target + * never received, or a placeholder that never had a push, is absent. + */ +export function deployedHashesFor( + keep: KeepFile | null | undefined, + branch: string, + provider: string, + target: string, +): Readonly> { + return Object.fromEntries( + Object.entries(keep?.variables ?? {}).flatMap(([name, entries]) => { + const entry = entries.find((e) => (e.branch ?? '') === branch) as EntryWithTargets | undefined; + const hash = entry?.targets?.find((t) => t.provider === provider && t.target === target)?.deployed_value_hash; + return hash === undefined ? [] : ([[name, hash]] as const); + }), + ); +} + export interface VarDelivery { name: string; valueHash: string; diff --git a/src/index-dev.ts b/src/index-dev.ts index a2027246..339a1b25 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -10,6 +10,8 @@ import { Command, Option } from 'commander'; import { CapyCommand } from './commands/capyCommand'; import { CliOptions } from './types/index'; import { version as CLI_VERSION } from '../package.json'; +import { conflictOption, rawConflictFlagsOf, resolveOption } from './sync/conflicts'; +import { answersByProtocol } from './sync/gate'; import { refuseWebMode } from './core/webModeRemoved'; import { ACCENT } from './ui/colors'; @@ -86,6 +88,8 @@ program .option('-v, --verbose', 'enable detailed logging') .option('-f, --force', 're-encrypt existing variables') .option('-d, --dry-run', 'preview changes without applying') + .addOption(resolveOption()) + .addOption(conflictOption()) // Hidden: `--web` was removed. It still parses so the hook below can refuse it (WEB_MODE_REMOVED). .addOption(new Option('--web').hideHelp()) // Root help only (Commander scopes addHelpText to the command it's called @@ -100,26 +104,8 @@ program }) .action(async (options, cmd) => { if (cmd.args.length > 0) { - console.log(`\n Unknown command: ${cmd.args[0]}\n`); - console.log(' Available commands:\n'); - console.log(` ${B('capy-dev')} \x1b[90mSync secrets\x1b[0m`); - console.log(` ${B('capy-dev')} edit \x1b[90mInspect and edit secrets in a TUI\x1b[0m`); - console.log(` ${B('capy-dev')} branch \x1b[90mList secret branches\x1b[0m`); - console.log(` ${B('capy-dev')} checkout -b \x1b[90mSwitch to a secret branch\x1b[0m`); - console.log(` ${B('capy-dev')} invite \x1b[90mInvite a teammate\x1b[0m`); - console.log(` ${B('capy-dev')} redeem \x1b[90mRedeem an invite code\x1b[0m`); - console.log(` ${B('capy-dev')} transport \x1b[90mMove your account to another machine\x1b[0m`); - console.log(` ${B('capy-dev')} kick \x1b[90mRemove a teammate\x1b[0m`); - console.log(` ${B('capy-dev')} users \x1b[90mList organization members\x1b[0m`); - console.log(` ${B('capy-dev')} deploy \x1b[90mGenerate a deployment\x1b[0m`); - console.log(` ${B('capy-dev')} decrypt \x1b[90mDecrypt secrets offline (owner only)\x1b[0m`); - console.log(` ${B('capy-dev')} end-recover \x1b[90mEnd recovery session\x1b[0m`); - console.log(` ${B('capy-dev')} recover \x1b[90mReconstruct master key from recovery phrase\x1b[0m`); - console.log(` ${B('capy-dev')} auth-decrypt \x1b[90mDecrypt using auth (dev only)\x1b[0m`); - console.log(` ${B('capy-dev')} byoc [url] \x1b[90mConnect to a self-hosted Capy (BYOC) instance\x1b[0m`); - console.log(` ${B('capy-dev')} use \x1b[90mSwitch to a different profile\x1b[0m`); - console.log(` ${B('capy-dev')} profile list \x1b[90mList configured profiles\x1b[0m`); - console.log(''); + const { unknownCommandText } = await import('./core/availableCommands'); + console.log(unknownCommandText(program, cmd.args[0])); process.exit(1); } @@ -127,7 +113,10 @@ program envPath: options.envPath, verbose: options.verbose, force: options.force, - dryRun: options.dryRun + dryRun: options.dryRun, + conflictFlags: rawConflictFlagsOf(cmd), + // Off a terminal, under --dry-run, or given --resolve nobody can answer the menu: the flags do (CAP-717). + protocol: answersByProtocol({ conflictFlags: rawConflictFlagsOf(cmd), dryRun: options.dryRun === true }), }; const command = new CapyCommand(cliOptions, true); @@ -273,22 +262,30 @@ program .description('Switch to a secret branch') .option('-b, --create', 'Create the branch if it does not exist') .option('--protected', 'Mark as a protected branch (invite-only)') + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the checkout with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (branch, options, command) => { const { CheckoutCommand } = await import('./commands/checkoutCommand'); const cmd = new CheckoutCommand(true); await cmd.execute(branch, { create: options.create, protected: options.protected, + nonTty: options.nonTty === true, + conflictFlags: rawConflictFlagsOf(command), }); }); program .command('push') .description('Push encrypted values to Keep') - .action(async () => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the push with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (options, command) => { const { PushCommand } = await import('./commands/pushCommand'); const cmd = new PushCommand(true); - await cmd.execute(); + await cmd.execute({ nonTty: options.nonTty, conflictFlags: rawConflictFlagsOf(command) }); }); program @@ -318,6 +315,8 @@ program 'With a name and a piped value, sets that one variable. The value is read from stdin only:\n' + ' | capy-dev edit NAME --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (name, options, command) => { const { EditCommand } = await import('./commands/editCommand'); const { prFlagsFromCommand } = await import('./commands/keepLockPr'); @@ -337,7 +336,7 @@ const deploy = program .command('deploy [target]') .description('Set up secret delivery — token + docs (existing) or connector deploy') .option('--target ', 'adapter id; requires --yes (CI mode)') - .option('--yes', 'skip all prompts (CI)') + .option('--yes', 'skip all prompts (CI); any difference between the folder and Capy stops the deploy with exit 3') // COPY-FLAG .option('--dry-run', 'preflight + show plan, push nothing (connector mode)') .option('--edit', 're-enter the picker for an existing connector target') .option('--connect', 'force connector mode (skip the token+docs path)') @@ -350,12 +349,14 @@ const deploy = program .option('--plan ', 'dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`)') .option('--confirm ', 'dokploy --discover: write the plan that --dry-run printed (one PR per repo)') .option('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (target: string | undefined, options: any, cmd: any) => { const merged = cmd.optsWithGlobals ? cmd.optsWithGlobals() : options; // `capy-dev deploy dokploy --discover` (CAP-703): always JSON, never prompts. const { routeDeployDiscover } = await import('./commands/deployDiscover/route'); - const discoverCode = await routeDeployDiscover(target, options, (options.dryRun ?? merged.dryRun) === true, true); + const discoverCode = await routeDeployDiscover(target, options, (options.dryRun ?? merged.dryRun) === true, true, rawConflictFlagsOf(cmd)); if (discoverCode !== undefined) process.exit(discoverCode); // CI/explicit connector path — go straight to the adapter flow (devMode). @@ -367,6 +368,7 @@ const deploy = program dryRun: options.dryRun ?? merged.dryRun, edit: options.edit, devMode: true, + conflictFlags: rawConflictFlagsOf(cmd), }); process.exit(code); } @@ -380,6 +382,9 @@ const deploy = program scope: options.scope, envName: options.envName, yes: !!options.yes, + json: !!options.json, + dryRun: (options.dryRun ?? merged.dryRun) === true, + conflictFlags: rawConflictFlagsOf(cmd), }); await c.execute(); }); @@ -387,10 +392,13 @@ const deploy = program deploy .command('revoke ') .description('Revoke a deploy token') - .action(async (deployId: string, _options: unknown, command: any) => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the revoke with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (deployId: string, options: any, command: any) => { const { DeployRevokeCommand } = await import('./commands/deployTokenCommand'); const cmd = new DeployRevokeCommand(process.env.CAPY_API_URL, true); - await cmd.execute(deployId); + await cmd.execute(deployId, { nonTty: options.nonTty, conflictFlags: rawConflictFlagsOf(command) }); }); deploy @@ -419,10 +427,13 @@ deploy deploy .command('targets-remove ') .description('Remove a configured connector target') - .action(async (name: string, _options, command) => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the removal with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (name: string, options: any, command) => { const { deployRemove } = await import('./commands/deployCommand'); process.exit( - await deployRemove(name, process.cwd(), { devMode: true }), + await deployRemove(name, process.cwd(), { devMode: true, nonTty: options.nonTty, conflictFlags: rawConflictFlagsOf(command) }), ); }); @@ -725,10 +736,13 @@ program program .command('org') .description('Switch organization') - .action(async (_options, command) => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (options, command) => { const { OrgCommand } = await import('./commands/orgCommand'); const cmd = new OrgCommand(process.env.CAPY_API_URL, true); - await cmd.execute(); + await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); program @@ -805,6 +819,8 @@ secretsCmd ' | capy secrets set NAME --row --dry-run --json\n' + ' | capy secrets set NAME --row --confirm --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (name: 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. @@ -821,6 +837,7 @@ secretsCmd exclude: options.exclude, noPrFor: options.prFor, noPr: options.pr === false, + conflictFlags: rawConflictFlagsOf(command), }, true, ); @@ -846,6 +863,8 @@ secretsCmd ' capy secrets deploy NAME --row --dry-run --json\n' + ' capy secrets deploy NAME --row --confirm --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .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. @@ -860,6 +879,7 @@ secretsCmd row: options.row, allRows: options.allRows === true, exclude: options.exclude, + conflictFlags: rawConflictFlagsOf(command), }, true, ); @@ -1035,6 +1055,8 @@ program 'With a value piped in and one name, adds that variable (an existing one needs --force):\n' + ' | capy-dev add NAME --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (varNames, options, command) => { const { AddCommand } = await import('./commands/addCommand'); const { prFlagsFromCommand } = await import('./commands/keepLockPr'); @@ -1047,6 +1069,7 @@ program json: options.json, pr: prFlagsFromCommand(command, options.prBase), dryRun: merged.dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1059,6 +1082,8 @@ program .option('--pr', 'create a PR with the keep.lock change (answers the prompt)') // COPY-FLAG .option('--no-pr', 'do not create a PR with the keep.lock change') // COPY-FLAG .option('--pr-base ', 'base branch for the PR (answers the prompt)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (varNames, options, command) => { const { RemoveCommand } = await import('./commands/removeCommand'); const { prFlagsFromCommand } = await import('./commands/keepLockPr'); @@ -1069,6 +1094,7 @@ program nonTty: options.nonTty, pr: prFlagsFromCommand(command, options.prBase), dryRun: command.optsWithGlobals().dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1107,6 +1133,8 @@ program '--environment ', 'dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview)', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (provider, options, command) => { const { ConnectCommand } = await import('./commands/connectCommand'); const cmd = new ConnectCommand(true); // devMode: hard-blocks live @@ -1132,6 +1160,7 @@ program yes: options.yes === true, overwrite: options.overwrite === true, environment: options.environment, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1144,6 +1173,8 @@ program .option('--skip-prompts', 'alias for --yes') .option('--non-tty', 'never prompt; resolve choices from flags or fail fast (agents/CI)') .option('--provider ', 'integration to promote an unmanaged var through (non-interactive)') + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (varName, options, command) => { const { RotateCommand } = await import('./commands/rotateCommand'); const cmd = new RotateCommand(true); // devMode: skips live entries @@ -1153,6 +1184,7 @@ program skipPrompts: !!(options.yes || options.skipPrompts), nonTty: options.nonTty, provider: options.provider, + conflictFlags: rawConflictFlagsOf(command), }); }); diff --git a/src/index.ts b/src/index.ts index 89671a12..981d2a82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ import { assertNotLocalOnly } from './core/localGate'; import { version as CLI_VERSION } from '../package.json'; import { refuseWebMode } from './core/webModeRemoved'; import { ACCENT } from './ui/colors'; +import { conflictOption, rawConflictFlagsOf, resolveOption } from './sync/conflicts'; +import { answersByProtocol } from './sync/gate'; // Prod talks to api.capy.sc and ~/.capy, full stop. Strip the environment's // attempts to move it before anything can read them — see config/prodPins.ts @@ -71,6 +73,8 @@ program .option('-v, --verbose', 'enable detailed logging') .option('-f, --force', 're-encrypt existing variables') .option('-d, --dry-run', 'preview changes without applying') + .addOption(resolveOption()) + .addOption(conflictOption()) // Hidden: `--web` was removed. It still parses so the hook below can refuse it (WEB_MODE_REMOVED). .addOption(new Option('--web').hideHelp()) // Root help only (Commander scopes addHelpText to the command it's called @@ -85,31 +89,8 @@ program }) .action(async (options, cmd) => { if (cmd.args.length > 0) { - console.log(`\n Unknown command: ${cmd.args[0]}\n`); - console.log(' Available commands:\n'); - console.log(` ${B('capy')} \x1b[90mSync secrets\x1b[0m`); - console.log(` ${B('capy')} run -- \x1b[90mRun a command with decrypted secrets\x1b[0m`); - console.log(` ${B('capy')} status \x1b[90mShow secret drift\x1b[0m`); - console.log(` ${B('capy')} edit \x1b[90mInspect and edit secrets in a TUI\x1b[0m`); - console.log(` ${B('capy')} push \x1b[90mPush encrypted values to Keep\x1b[0m`); - console.log(` ${B('capy')} deploy \x1b[90mDeploy or set up CI deploy credentials\x1b[0m`); - console.log(` ${B('capy')} deploy revoke \x1b[90mRevoke a deploy token\x1b[0m`); - console.log(` ${B('capy')} deploy list \x1b[90mList deploy tokens\x1b[0m`); - console.log(` ${B('capy')} invite \x1b[90mInvite a teammate\x1b[0m`); - console.log(` ${B('capy')} redeem \x1b[90mRedeem an invite code\x1b[0m`); - console.log(` ${B('capy')} kick \x1b[90mRemove a teammate\x1b[0m`); - console.log(` ${B('capy')} users \x1b[90mList organization members\x1b[0m`); - console.log(` ${B('capy')} org \x1b[90mSwitch organization\x1b[0m`); - console.log(` ${B('capy')} info \x1b[90mShow current session info\x1b[0m`); - console.log(` ${B('capy')} byoc [url] \x1b[90mConnect to a self-hosted Capy (BYOC) instance\x1b[0m`); - console.log(` ${B('capy')} use \x1b[90mSwitch to a different profile\x1b[0m`); - console.log(` ${B('capy')} profile list \x1b[90mList configured profiles\x1b[0m`); - console.log(` ${B('capy')} connect \x1b[90mPull a credential from a provider into .env\x1b[0m`); - console.log(` ${B('capy')} rotate [var] \x1b[90mRotate a credential previously set up via connect\x1b[0m`); - console.log(` ${B('capy')} decrypt \x1b[90mDecrypt secrets offline (owner only)\x1b[0m`); - console.log(` ${B('capy')} end-recover \x1b[90mEnd recovery session\x1b[0m`); - console.log(` ${B('capy')} recover \x1b[90mReconstruct master key from recovery phrase\x1b[0m`); - console.log(''); + const { unknownCommandText } = await import('./core/availableCommands'); + console.log(unknownCommandText(program, cmd.args[0])); process.exit(1); } @@ -117,7 +98,10 @@ program envPath: options.envPath, verbose: options.verbose, force: options.force, - dryRun: options.dryRun + dryRun: options.dryRun, + conflictFlags: rawConflictFlagsOf(cmd), + // Off a terminal, under --dry-run, or given --resolve nobody can answer the menu: the flags do (CAP-717). + protocol: answersByProtocol({ conflictFlags: rawConflictFlagsOf(cmd), dryRun: options.dryRun === true }), }; const command = new CapyCommand(cliOptions); @@ -164,6 +148,8 @@ program 'With a name and a piped value, sets that one variable. The value is read from stdin only:\n' + ' | capy edit NAME --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (name, options, command) => { const { EditCommand } = await import('./commands/editCommand'); const { prFlagsFromCommand } = await import('./commands/keepLockPr'); @@ -176,6 +162,7 @@ program pr: prFlagsFromCommand(command, options.prBase), // The program-level `--dry-run`, wherever it was typed. dryRun: command.optsWithGlobals().dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -321,6 +308,9 @@ program .option('--protected', 'Mark as a protected branch (invite-only)') .option('--no-protected', 'Create it open to the project') .option('--json', 'emit machine-readable JSON instead of the human UI') + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the checkout with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (branch, options, command) => { assertNotLocalOnly('checkout'); @@ -344,16 +334,22 @@ program await cmd.execute(branch, { create: options.create, protected: options.protected, + json: options.json === true, + nonTty: options.nonTty === true, + conflictFlags: rawConflictFlagsOf(command), }); }); program .command('push') .description('Push encrypted values to Keep') - .action(async () => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the push with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (options, command) => { const { PushCommand } = await import('./commands/pushCommand'); const cmd = new PushCommand(); - await cmd.execute(); + await cmd.execute({ nonTty: options.nonTty, conflictFlags: rawConflictFlagsOf(command) }); }); // `capy deploy` is a single picker that surfaces both: @@ -374,7 +370,7 @@ const deploy = program .command('deploy [target]') .description('Set up secret delivery — token + docs (existing) or target deploy') .option('--target ', 'adapter id; requires --yes (CI mode)') - .option('--yes', 'skip all prompts (CI)') + .option('--yes', 'skip all prompts (CI); any difference between the folder and Capy stops the deploy with exit 3') // COPY-FLAG .option('--dry-run', 'preflight + show plan, push nothing (target mode)') .option('--force', 'redeploy even when keep.lock is unchanged — bumps keep.lock to trigger CI') .option('--edit', 're-enter the picker for an existing target') @@ -390,6 +386,8 @@ const deploy = program .option('--plan ', 'dokploy --discover: the plan file to check (see schemas.deploy_dokploy_plan in `capy help --json`)') .option('--confirm ', 'dokploy --discover: write the plan that --dry-run printed (one PR per repo)') .option('--base-url ', 'dokploy --discover: the Dokploy dashboard URL (else the org system variable _CONNECTOR_DOKPLOY_BASE_URL)') + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (target: string | undefined, options: any, cmd: any) => { assertNotLocalOnly('deploy'); // Top-level program also defines --dry-run; merge globals so either @@ -398,7 +396,7 @@ const deploy = program // `capy deploy dokploy --discover` (CAP-703): always JSON, never prompts. const { routeDeployDiscover } = await import('./commands/deployDiscover/route'); - const discoverCode = await routeDeployDiscover(target, options, (options.dryRun ?? merged.dryRun) === true, false); + const discoverCode = await routeDeployDiscover(target, options, (options.dryRun ?? merged.dryRun) === true, false, rawConflictFlagsOf(cmd)); if (discoverCode !== undefined) process.exit(discoverCode); // CI/explicit target path — go straight to the adapter flow. @@ -415,6 +413,7 @@ const deploy = program // commander negates `--no-deploy` onto the positive `deploy` property. noDeploy: options.deploy === false, json: !!options.json, + conflictFlags: rawConflictFlagsOf(cmd), }); process.exit(code); } @@ -434,6 +433,9 @@ const deploy = program envName: options.envName, yes: !!options.yes, force: !!options.force, + json: !!options.json, + dryRun: (options.dryRun ?? merged.dryRun) === true, + conflictFlags: rawConflictFlagsOf(cmd), }); await c.execute(); }); @@ -441,11 +443,14 @@ const deploy = program deploy .command('revoke ') .description('Revoke a deploy token') - .action(async (deployId: string, _options, command) => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the revoke with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (deployId: string, options: any, command) => { assertNotLocalOnly('deploy revoke'); const { DeployRevokeCommand } = await import('./commands/deployTokenCommand'); const cmd = new DeployRevokeCommand(); - await cmd.execute(deployId); + await cmd.execute(deployId, { nonTty: options.nonTty, conflictFlags: rawConflictFlagsOf(command) }); }); deploy @@ -471,6 +476,9 @@ deploy .command('targets-remove ') .description('Remove a configured target') .option('--no-deploy', 'strip the config but skip the redeploy that would apply the revert') + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the removal with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (name: string, options: any, command) => { assertNotLocalOnly('deploy targets-remove'); const { deployRemove } = await import('./commands/deployCommand'); @@ -478,6 +486,8 @@ deploy await deployRemove(name, process.cwd(), { // commander negates `--no-deploy` onto the positive `deploy` property. noDeploy: options.deploy === false, + nonTty: options.nonTty, + conflictFlags: rawConflictFlagsOf(command), }), ); }); @@ -756,13 +766,16 @@ systemCmd program .command('org') .description('Switch organization') - .action(async (_options, command) => { + .option('--non-tty', 'treat stdin as not a terminal; differences from Capy stop the switch with the conflict_id (agents/CI)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) + .action(async (options, command) => { assertNotLocalOnly('org'); const { OrgCommand } = await import('./commands/orgCommand'); // OrgCommand takes it at construction — see its own note on why a // subcommand must read the inherited global rather than its own options. const cmd = new OrgCommand(); - await cmd.execute(); + await cmd.execute({ nonTty: options.nonTty === true, conflictFlags: rawConflictFlagsOf(command) }); }); program @@ -851,6 +864,8 @@ secretsCmd ' | capy secrets set NAME --row --dry-run --json\n' + ' | capy secrets set NAME --row --confirm --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (name: string, options: any, command: any) => { assertNotLocalOnly('secrets set'); // `--json` is also a `capy secrets` option, which Commander lets the parent claim; @@ -868,6 +883,7 @@ secretsCmd exclude: options.exclude, noPrFor: options.prFor, noPr: options.pr === false, + conflictFlags: rawConflictFlagsOf(command), }, false, ); @@ -893,6 +909,8 @@ secretsCmd ' capy secrets deploy NAME --row --dry-run --json\n' + ' capy secrets deploy NAME --row --confirm --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (names: string[], options: any, command: any) => { assertNotLocalOnly('secrets deploy'); // `--json` is also a `capy secrets` option, which Commander lets the parent claim; @@ -908,6 +926,7 @@ secretsCmd row: options.row, allRows: options.allRows === true, exclude: options.exclude, + conflictFlags: rawConflictFlagsOf(command), }, false, ); @@ -979,6 +998,8 @@ program 'With a value piped in and one name, adds that variable (an existing one needs --force):\n' + ' | capy add NAME --json\n', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (varNames, options, command) => { assertNotLocalOnly('add'); const { AddCommand } = await import('./commands/addCommand'); @@ -992,6 +1013,7 @@ program json: options.json, pr: prFlagsFromCommand(command, options.prBase), dryRun: merged.dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1004,6 +1026,8 @@ program .option('--pr', 'create a PR with the keep.lock change (answers the prompt)') // COPY-FLAG .option('--no-pr', 'do not create a PR with the keep.lock change') // COPY-FLAG .option('--pr-base ', 'base branch for the PR (answers the prompt)') // COPY-FLAG + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (varNames, options, command) => { assertNotLocalOnly('remove'); const { RemoveCommand } = await import('./commands/removeCommand'); @@ -1015,6 +1039,7 @@ program nonTty: options.nonTty, pr: prFlagsFromCommand(command, options.prBase), dryRun: command.optsWithGlobals().dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1053,6 +1078,8 @@ program '--environment ', 'dokploy discover: restrict the plan to these Dokploy environment names, comma-separated (e.g. staging,preview)', ) + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (provider, options, command) => { assertNotLocalOnly('connect'); const { ConnectCommand } = await import('./commands/connectCommand'); @@ -1082,6 +1109,7 @@ program yes: options.yes === true, overwrite: options.overwrite === true, environment: options.environment, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1094,6 +1122,8 @@ program .option('--skip-prompts', 'alias for --yes') .option('--non-tty', 'never prompt; resolve choices from flags or fail fast (agents/CI)') .option('--provider ', 'integration to promote an unmanaged var through (non-interactive)') + .addOption(resolveOption()) + .addOption(conflictOption()) .action(async (varName, options, command) => { assertNotLocalOnly('rotate'); const { RotateCommand } = await import('./commands/rotateCommand'); @@ -1104,6 +1134,7 @@ program skipPrompts: !!(options.yes || options.skipPrompts), nonTty: options.nonTty, provider: options.provider, + conflictFlags: rawConflictFlagsOf(command), }); }); diff --git a/src/service/serviceClient.ts b/src/service/serviceClient.ts index 8c3501cf..3fe57b11 100644 --- a/src/service/serviceClient.ts +++ b/src/service/serviceClient.ts @@ -46,6 +46,10 @@ const SERVER_CODES = new Set([ // POST already has) and 410 once expired. ERROR_CODES.TRANSPORT_NOT_FOUND, ERROR_CODES.TRANSPORT_EXPIRED, + // CAP-712 — `POST /secrets/:projectId` with a `base_keep_hash` that is no + // longer the server's latest: HTTP 409. The commit path (sync/commit.ts) + // re-reads and re-plans on exactly this code. + ERROR_CODES.SECRETS_STALE_BASE, ]); /** @@ -706,6 +710,9 @@ export class ServiceClient { * The response's keep_file (when the server sends one) is the pushed * keep.lock with server-assigned changed_at timestamps — callers should * adopt it as the local keep.lock (SyncEngine.adoptServerKeep). + * + * `baseKeepHash` (CAP-712): `SyncEngine.computeKeepHash` of the latest keep the push + * was built from. Pass it for every push that was built from a read. */ async pushSecrets( projectId: string, @@ -713,11 +720,16 @@ export class ServiceClient { envBlob: string, branch: string, onRateLimit?: (rate: RateInfo) => void, + baseKeepHash?: string, ): Promise<{ keep_hash: string; keep_file?: string }> { return this.request('POST', `/secrets/${projectId}`, { keep_file: keepFile, env_blob: envBlob, branch, + // The branch hash of the latest keep the caller built this push from. A server + // that knows the field refuses (409 SECRETS_STALE_BASE) when it is no longer the + // latest; one that does not ignores it. + ...(baseKeepHash === undefined ? {} : { base_keep_hash: baseKeepHash }), }, { onRateLimit }); } diff --git a/src/sync/commit.ts b/src/sync/commit.ts new file mode 100644 index 00000000..29c09bc4 --- /dev/null +++ b/src/sync/commit.ts @@ -0,0 +1,552 @@ +/** + * The one way anything uploads secrets to Keep (CAP-712). + * + * WHAT IT IS NOT. It decides nothing about who is right. Bare `capy` is the conflict path + * (docs/conflict-resolution.md): every command that writes from a folder runs it first, so by the + * time something here is called the folder and Capy agree, and a command that writes without a folder + * has had its own answers (sync/noFolder.ts). This module only uploads. + * + * WHAT IT DOES. + * 1. Reads the server's latest for (project, branch). + * 2. Applies the caller's change (`set` / `remove`) onto that latest. A name the caller is not + * changing keeps the server's ciphertext line byte for byte. + * 3. Builds the keep to upload from the SERVER's keep (only this branch's entries for the changed + * names move) and pushes it with `base_keep_hash`: the keep it started from. + * 4. If the server refuses the upload as stale (`SECRETS_STALE_BASE`, someone saved in between), the + * caller runs its `between` step (a folder command runs bare `capy` again) and tries once more. + * 5. Folder side only: caches the blob, adopts this branch's slice of the server's keep into keep.lock + * (CAP-303), and writes `.env` and the sync state. + * + * Never logs or throws a value: errors carry codes and variable NAMES. + */ +import { Encryptor } from '../crypto/encryptor'; +import { deriveResourceId } from '../crypto/resourceId'; +import { FileManager } from '../files/fileManager'; +import { writeKeepCache } from '../config/globalConfig'; +import type { ProjectManager } from '../core/projectManager'; +import type { ServiceClient } from '../service/serviceClient'; +import { CapyError, ERROR_CODES, KeepFile, setSyncKeepHash } from '../types/index'; +import { hashValue } from './compareSecrets'; +import { SyncEngine } from './syncEngine'; +import type { RateInfo } from '../utils/pool'; + +// ── Vocabulary ────────────────────────────────────────────────────────────── + +/** What the caller wants different. Names not mentioned are never touched. */ +export interface Changes { + readonly set: Readonly>; + readonly remove: readonly string[]; +} + +/** One variable as the server holds it for the branch. */ +export interface LatestEntry { + /** `capy::`, exactly as stored. */ + readonly line: string; + /** `undefined` when this profile cannot decrypt the line (it is still kept verbatim). */ + readonly plaintext: string | undefined; + /** Hash of the plaintext; the keep's `value_hash` when the line cannot be decrypted. */ + readonly hash: string; +} + +/** The server's latest for one branch: its (project-wide) keep file and the branch's blob. */ +export interface Snapshot { + /** `undefined` when the server holds no keep for the project yet. */ + readonly keep: KeepFile | undefined; + /** In blob order. */ + readonly entries: Readonly>; +} + +/** value_hash by name for `branch`: what a keep.lock pins. */ +export function pinnedHashes(keep: KeepFile | null | undefined, branch: string): Record { + return Object.fromEntries( + Object.entries(keep?.variables ?? {}).flatMap(([name, entries]) => { + const entry = entries.find((e) => e.branch === branch); + return entry ? ([[name, entry.value_hash]] as const) : []; + }), + ); +} + +const has = (record: object, name: string): boolean => Object.hasOwn(record, name); + +/** The keep hash for `branch` of a snapshot: the `base_keep_hash` of an upload built from it. `undefined` when the server holds no keep. */ +export const baseHashOf = (snapshot: Snapshot, branch: string): string | undefined => + snapshot.keep === undefined ? undefined : SyncEngine.computeKeepHash(snapshot.keep, branch); + +// ── Applying a change (pure) ──────────────────────────────────────────────── + +export interface Applied { + /** The whole blob after the change, in order: name → line. */ + readonly lines: Readonly>; + /** Plaintext of every decryptable variable after the change. */ + readonly env: Readonly>; + /** The names whose value is actually (re-)encrypted: name → plaintext. */ + readonly written: Readonly>; + /** The names actually dropped. */ + readonly removed: readonly string[]; +} + +/** + * `changes` applied to `latest`. A name already holding the intended value keeps the server's line; a name + * the caller does not mention is never touched. + */ +export function applyChanges(input: { + readonly latest: Readonly>; + readonly changes: Changes; + /** Encrypts one value into its blob line. */ + readonly encryptLine: (name: string, value: string) => string; +}): Applied { + const { latest, changes, encryptLine } = input; + const toWrite = Object.entries(changes.set).filter(([name, value]) => latest[name]?.plaintext !== value); + const toRemove = changes.remove.filter((name) => !has(changes.set, name) && has(latest, name)); + + const written = Object.fromEntries(toWrite); + const encrypted = Object.fromEntries(toWrite.map(([name, value]) => [name, encryptLine(name, value)])); + const gone = new Set(toRemove); + const kept = Object.entries(latest).filter(([name]) => !gone.has(name)); + + return { + lines: Object.fromEntries([ + ...kept.map(([name, entry]) => [name, encrypted[name] ?? entry.line] as const), + ...Object.entries(encrypted).filter(([name]) => !has(latest, name)), + ]), + env: { + ...Object.fromEntries(kept.flatMap(([name, entry]) => (entry.plaintext === undefined ? [] : [[name, entry.plaintext] as const]))), + ...written, + }, + written, + removed: toRemove, + }; +} + +/** + * The change that makes Capy's branch hold exactly `env`: every value that differs, and every variable Capy + * has (that this profile can read) that `env` does not. Used when the folder's values ARE the answer + * ("Commit and push all local values", or the answers of the non-TTY protocol). + */ +export function changesToMatch(latest: Readonly>, env: Readonly>): Changes { + return { + set: Object.fromEntries(Object.entries(env).filter(([name, value]) => latest[name]?.plaintext !== value)), + remove: Object.entries(latest) + .filter(([name, entry]) => entry.plaintext !== undefined && !has(env, name)) + .map(([name]) => name), + }; +} + +// ── Errors ────────────────────────────────────────────────────────────────── + +export const isStaleBase = (err: unknown): boolean => err instanceof CapyError && err.code === ERROR_CODES.SECRETS_STALE_BASE; + +// ── Reading the folder ────────────────────────────────────────────────────── + +/** + * The folder's `.env`, decrypted. A `capy:` value this profile holds no key for is skipped + * (it cannot be edited here and is never uploaded); a plaintext value passes through. + */ +export function readLocalPlaintext(fileManager: Pick, projectKey: string): Record { + return Object.fromEntries( + Object.entries(fileManager.readEnvFile()).flatMap(([name, value]) => { + if (!value.startsWith('capy:')) return [[name, value] as const]; + try { + return [[name, fileManager.decryptValue(value, projectKey)] as const]; + } catch { + return []; + } + }), + ); +} + +// ── Reading the server ────────────────────────────────────────────────────── + +function parseKeep(raw: string | undefined): KeepFile | undefined { + if (raw === undefined || raw === '') return undefined; + try { + const parsed: unknown = JSON.parse(raw); + const variables = typeof parsed === 'object' && parsed !== null ? (parsed as { variables?: unknown }).variables : undefined; + return typeof variables === 'object' && variables !== null ? (parsed as KeepFile) : undefined; + } catch { + return undefined; + } +} + +function tryDecrypt(fm: Pick, line: string, projectKey: string): string | undefined { + try { + return fm.decryptValue(line, projectKey); + } catch { + return undefined; + } +} + +/** What a `getDecryptData` answer says the branch holds. Lines this key cannot decrypt are kept, undecrypted. */ +export function snapshotFrom( + data: { readonly env_content?: string | null; readonly keep_file?: string }, + branch: string, + projectKey: string, + fm: Pick = new FileManager(), +): Snapshot { + const keep = parseKeep(data.keep_file); + return { + keep, + entries: Object.fromEntries( + Object.entries(data.env_content ? fm.parseEnvContent(data.env_content) : {}).map(([name, line]) => { + const plaintext = tryDecrypt(fm, line, projectKey); + const pinnedHash = keep?.variables[name]?.find((e) => e.branch === branch)?.value_hash ?? ''; + return [name, { line, plaintext, hash: plaintext === undefined ? pinnedHash : hashValue(plaintext) }]; + }), + ), + }; +} + +export type CommitClient = Pick; + +export async function readSnapshot(client: CommitClient, projectId: string, branch: string, projectKey: string): Promise { + return snapshotFrom(await client.getDecryptData(projectId, branch, undefined, true), branch, projectKey); +} + +// ── Building the keep ─────────────────────────────────────────────────────── + +/** Drops `names`' entries for `branch`; other branches stay as they are. */ +function dropBranchEntries(keep: KeepFile, branch: string, names: readonly string[]): KeepFile { + const dropped = new Set(names); + return { + ...keep, + variables: Object.fromEntries( + Object.entries(keep.variables).flatMap(([name, entries]) => { + if (!dropped.has(name)) return [[name, entries]] as const; + const kept = entries.filter((e) => e.branch !== branch); + return kept.length > 0 ? ([[name, kept]] as const) : []; + }), + ), + }; +} + +/** + * `base` with this branch's entries for `written` (name → plaintext) set and for `removed` + * dropped. Every other variable and branch is exactly as in `base`, and extra fields on + * an entry (a connector, deploy targets) survive. + */ +export function keepWithChanges( + base: KeepFile, + branch: string, + written: Readonly>, + removed: readonly string[], + resourceIdOf: (name: string) => string, +): KeepFile { + const entries = Object.fromEntries( + Object.entries(written).map(([name, value]) => [name, { resource_id: resourceIdOf(name), value_hash: hashValue(value) }]), + ); + const merged = Object.keys(entries).length === 0 ? base : new SyncEngine().mergeWithKeep(base, entries, branch); + return removed.length === 0 ? merged : dropBranchEntries(merged, branch, removed); +} + +export const envBlobOf = (lines: Readonly>): string => + Object.entries(lines) + .map(([name, line]) => `${name}=${line}`) + .join('\n'); + +/** `keep` with this branch's slice rebuilt from `env`: every name set, every other pinned name dropped. */ +export function keepForEnv(keep: KeepFile, branch: string, env: Readonly>): KeepFile { + const stale = Object.keys(pinnedHashes(keep, branch)).filter((name) => !has(env, name)); + return keepWithChanges(keep, branch, env, stale, (name) => deriveResourceId(branch, name)); +} + +// ── Uploading ─────────────────────────────────────────────────────────────── + +/** The answer of a push. */ +export interface PushResult { + readonly keep_hash: string; + readonly keep_file?: string; +} + +/** + * How an upload talks to the server, for a caller that needs more than the client's plain calls (the bulk + * `capy secrets` writes read the rate-limit headers and wait out a 429). Both calls reject with the error as it is. + */ +export interface UploadIo { + readonly read: () => Promise<{ readonly snapshot: Snapshot; readonly rate?: RateInfo }>; + readonly push: (keepFile: string, envBlob: string, baseKeepHash: string | undefined) => Promise<{ readonly result: PushResult; readonly rate?: RateInfo }>; +} + +export interface UploadInput { + readonly client: CommitClient; + readonly projectId: string; + readonly branch: string; + readonly projectKey: string; + /** Talk to the server this way instead of through `client` directly. */ + readonly io?: UploadIo; + /** The keep the caller started from; only used when the server holds none yet. */ + readonly startKeep: KeepFile; + readonly changes: Changes; + /** Runs on the keep to upload (e.g. attach connector metadata). Return its input to change nothing. */ + readonly transformKeep?: (keep: KeepFile) => KeepFile; + /** A read the caller already made (bare `capy`'s): used for this attempt instead of reading again. */ + readonly seen?: Snapshot; +} + +export interface Uploaded { + /** The server state this upload was planned against. */ + readonly snapshot: Snapshot; + /** The server's keep after the upload. */ + readonly keep: KeepFile; + readonly lines: Readonly>; + readonly env: Readonly>; + readonly written: readonly string[]; + readonly removed: readonly string[]; + /** The push response; `undefined` when nothing needed uploading. */ + readonly result: PushResult | undefined; + /** The rate-limit readings of the read and of the push, when the `io` reports them. */ + readonly rates: { readonly read?: RateInfo; readonly push?: RateInfo }; +} + +/** The plain client, as an `UploadIo`. */ +function clientIo(input: Pick): UploadIo { + const { client, projectId, branch, projectKey } = input; + return { + read: async () => ({ snapshot: await readSnapshot(client, projectId, branch, projectKey) }), + push: async (keepFile, envBlob, baseKeepHash) => ({ result: await client.pushSecrets(projectId, keepFile, envBlob, branch, undefined, baseKeepHash) }), + }; +} + +/** + * THE shared upload: `input.changes` applied to the server's latest and uploaded with `base_keep_hash`. + * One attempt. Throws `SECRETS_STALE_BASE` when the server's latest moved since the read (nothing was written); + * `retryOnceOnStale` is how a caller goes again. Does not touch the local disk. + */ +export async function uploadChanges(input: UploadInput): Promise { + const { branch, projectKey, startKeep, changes, transformKeep } = input; + const io = input.io ?? clientIo(input); + const read = input.seen === undefined ? await io.read() : { snapshot: input.seen }; + const snapshot = read.snapshot; + const latestKeep: KeepFile = snapshot.keep ?? { ...startKeep, variables: {} }; + const resourceIdOf = (name: string): string => + latestKeep.variables[name]?.find((e) => e.branch === branch)?.resource_id || deriveResourceId(branch, name); + + const applied = applyChanges({ + latest: snapshot.entries, + changes, + encryptLine: (name, value) => `capy:${resourceIdOf(name)}:${Encryptor.encrypt(value, projectKey)}`, + }); + const changed = keepWithChanges(latestKeep, branch, applied.written, applied.removed, resourceIdOf); + const keep = transformKeep ? transformKeep(changed) : changed; + const base = { snapshot, keep, lines: applied.lines, env: applied.env, written: Object.keys(applied.written), removed: applied.removed }; + const readRate = 'rate' in read && read.rate !== undefined ? { read: read.rate } : {}; + if (keep === latestKeep) return { ...base, result: undefined, rates: readRate }; + + const pushed = await io.push(JSON.stringify(keep), envBlobOf(applied.lines), baseHashOf(snapshot, branch)); + return { ...base, result: pushed.result, rates: { ...readRate, ...(pushed.rate === undefined ? {} : { push: pushed.rate }) } }; +} + +/** + * `uploadChanges`, and once more from a fresh read if the server refuses it as stale (`SECRETS_STALE_BASE`): for a + * writer with no folder and no bare `capy` to run (the org system store). A second stale refusal is thrown. + */ +export async function uploadChangesRereading(input: UploadInput): Promise { + try { + return await uploadChanges(input); + } catch (err) { + if (!isStaleBase(err)) throw err; + return uploadChanges({ ...input, seen: undefined }); + } +} + +/** + * `attempt()`, and once more if the server refused it as stale (`SECRETS_STALE_BASE`). `between` runs first and + * says whether to go on (a folder command runs bare `capy` again; a record writer just reads again). `undefined` + * when `between` said to stop. A second stale refusal is thrown as it is. + */ +export async function retryOnceOnStale(attempt: () => Promise, between: () => Promise): Promise { + try { + return await attempt(); + } catch (err) { + if (!isStaleBase(err)) throw err; + return (await between()) ? attempt() : undefined; + } +} + +/** + * The keep.lock a folder holding `local` should have after `uploaded`: `local` with this + * branch's slice replaced by the server's (CAP-303: keep.lock is git-owned, so no other + * branch's pins move). + */ +export function adoptedKeepFor(local: KeepFile, uploaded: Uploaded, branch: string): KeepFile { + // The server holds no keep yet and nothing was uploaded: there is nothing to adopt. + if (uploaded.result === undefined && uploaded.snapshot.keep === undefined) return local; + const serverKeep = SyncEngine.adoptServerKeep(uploaded.result?.keep_file, uploaded.keep, branch); + return SyncEngine.spliceKeepBranch(local, serverKeep, branch); +} + +// ── A keep-only change ────────────────────────────────────────────────────── + +/** What a keep-only upload needs from the service client (the read that returns the blob as stored). */ +export interface KeepOnlyClient { + 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, + onRateLimit?: undefined, + /** The branch hash of the keep this push was built from. */ + baseKeepHash?: string, + ): Promise; +} + +export type KeepOnlyResult = + | { + readonly ok: true; + readonly pushed: boolean; + /** The keep as the server held it, and the one now there. */ + readonly previous: KeepFile; + readonly keep: KeepFile; + /** The push response's keep (with server-assigned `changed_at`), when the server sent one. */ + readonly serverKeepFile?: string; + } + | { readonly ok: false; readonly code: string }; + +const codeOf = (err: unknown): string => (err instanceof CapyError ? err.code : ERROR_CODES.SERVICE_ERROR); + +async function keepOnlyOnce(client: KeepOnlyClient, projectId: string, branch: string, transform: (keep: KeepFile) => KeepFile): Promise { + const read = await client.getLatestSecrets(projectId, branch).then( + (latest) => ({ ok: true as const, latest }), + (err: unknown) => ({ ok: false as const, code: codeOf(err) }), + ); + if (!read.ok) return read; + const { latest } = read; + const keep = latest === null ? undefined : parseKeep(latest.keep_file); + if (latest === null || keep === undefined) return { ok: false, code: ERROR_CODES.NO_KEEP_FILE }; + const next = transform(keep); + if (next === keep) return { ok: true, pushed: false, previous: keep, keep }; + // The blob goes back exactly as the server holds it: a keep-only change moves no value. + const response = await client.pushSecrets(projectId, JSON.stringify(next), latest.env_file, branch, undefined, SyncEngine.computeKeepHash(keep, branch)); + const serverKeepFile = (response as { keep_file?: unknown } | null | undefined)?.keep_file; + return { ok: true, pushed: true, previous: keep, keep: next, ...(typeof serverKeepFile === 'string' ? { serverKeepFile } : {}) }; +} + +/** + * A change to Capy's keep that moves no value (a deploy target's record, a connector's metadata, a revoked + * token): reads the server's latest, applies `transform` to THAT keep and uploads it with the blob exactly as + * stored and `base_keep_hash`. If the server says it is stale it reads again and rebuilds once. Never throws; a + * failure is a code. + */ +export async function uploadKeepOnly( + client: KeepOnlyClient, + projectId: string, + branch: string, + transform: (keep: KeepFile) => KeepFile, +): Promise { + try { + const done = await retryOnceOnStale(() => keepOnlyOnce(client, projectId, branch, transform), async () => true); + return done ?? { ok: false, code: ERROR_CODES.SECRETS_STALE_BASE }; + } catch (err) { + return { ok: false, code: codeOf(err) }; + } +} + +// ── Committing from a folder ──────────────────────────────────────────────── + +/** Everything a folder commit needs; `ResolvedContext` has all of it. */ +export interface FolderContext { + readonly pm: Pick; + readonly fileManager: Pick; + /** Absent in local-only mode: there is no server, the working copy is committed as it stands. */ + readonly serviceClient?: CommitClient; + readonly orgId: string; + readonly projectId: string; + readonly branch: string; + readonly userId: string; + readonly projectKey: string; + /** The folder's keep.lock when the command started. */ + readonly keep: KeepFile; + /** The folder's decrypted `.env` when the command started. */ + readonly localPlaintext: Readonly>; +} + +export interface FolderCommitOptions { + readonly transformKeep?: (keep: KeepFile) => KeepFile; + readonly seen?: Snapshot; +} + +export interface FolderCommit { + /** Whether anything was uploaded. */ + readonly uploaded: boolean; + readonly written: readonly string[]; + readonly removed: readonly string[]; + /** The keep.lock now on disk. */ + readonly keep: KeepFile; + /** The plaintext env now in `.env`. */ + readonly env: Readonly>; +} + +/** `changes` applied to a plain env. */ +function applyToEnv(env: Readonly>, changes: Changes): Record { + const gone = new Set(changes.remove); + return { + ...Object.fromEntries(Object.entries(env).filter(([name]) => !gone.has(name))), + ...changes.set, + }; +} + +/** The local-only "server": the working copy is the truth, there is no one to disagree with. */ +function commitLocalCopy(ctx: FolderContext, changes: Changes, transformKeep: FolderCommitOptions['transformKeep']) { + const env = applyToEnv(ctx.localPlaintext, changes); + const lines = Object.fromEntries( + Object.entries(env).map(([name, value]) => [name, `capy:${deriveResourceId(ctx.branch, name)}:${Encryptor.encrypt(value, ctx.projectKey)}`]), + ); + const rebuilt = keepForEnv(ctx.keep, ctx.branch, env); + const keep = transformKeep ? transformKeep(rebuilt) : rebuilt; + return { + env, + lines, + adopted: keep, + cacheHash: SyncEngine.computeKeepHash(keep, ctx.branch), + uploaded: false, + written: Object.keys(changes.set), + removed: changes.remove, + }; +} + +async function uploadForFolder(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions) { + const uploaded = await uploadChanges({ + client: ctx.serviceClient!, + projectId: ctx.projectId, + branch: ctx.branch, + projectKey: ctx.projectKey, + startKeep: ctx.keep, + changes, + transformKeep: opts.transformKeep, + seen: opts.seen, + }); + return { + env: uploaded.env, + lines: uploaded.lines, + adopted: adoptedKeepFor(ctx.keep, uploaded, ctx.branch), + cacheHash: uploaded.result?.keep_hash ?? SyncEngine.computeKeepHash(uploaded.keep, ctx.branch), + uploaded: uploaded.result !== undefined, + written: uploaded.written, + removed: uploaded.removed, + }; +} + +/** + * Commits `changes` for a folder: to the server (or, in local-only mode, to the working copy), then caches + * the blob and writes keep.lock, `.env` and the sync state. The folder and Capy agreed before this ran (bare + * `capy` ran first), so `.env` afterwards is the server's latest plus `changes`. Throws `SECRETS_STALE_BASE` + * (nothing written) when someone saved since the read. + */ +export async function commitFolder(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions = {}): Promise { + const done = ctx.serviceClient === undefined ? commitLocalCopy(ctx, changes, opts.transformKeep) : await uploadForFolder(ctx, changes, opts); + + writeKeepCache(ctx.orgId, ctx.projectId, done.cacheHash, envBlobOf(done.lines)); + ctx.fileManager.writeKeepFile(done.adopted); + ctx.fileManager.writeEncryptedEnvFile({ ...done.env }, ctx.projectKey, undefined, done.adopted, ctx.branch); + + const existingSyncState = ctx.pm.readSyncState(); + ctx.fileManager.writeSyncState({ + ...existingSyncState, + last_sync: new Date().toISOString(), + synced_variables: Object.keys(done.env), + user_id: ctx.userId, + keep_hash: setSyncKeepHash(existingSyncState, ctx.branch, SyncEngine.computeKeepHash(done.adopted, ctx.branch)), + }); + + return { uploaded: done.uploaded, written: done.written, removed: done.removed, keep: done.adopted, env: done.env }; +} diff --git a/src/sync/compareSecrets.ts b/src/sync/compareSecrets.ts new file mode 100644 index 00000000..9a6b41f3 --- /dev/null +++ b/src/sync/compareSecrets.ts @@ -0,0 +1,80 @@ +import { createHash } from 'crypto'; + +/** + * Hash a plaintext value the same way keep.lock stores it. + */ +export function hashValue(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 16); +} + +export interface DiffResult { + variable: string; + type: 'new' | 'changed' | 'deleted'; + pinned?: string; + local?: string; + remote?: string; +} + +type Hash = string | undefined; + +/** `new`, `changed` or `deleted` for a row that is known to differ (any hash may be absent). */ +function diffTypeOf(pinnedHash: Hash, localHash: Hash, remoteHash: Hash): DiffResult['type'] { + if (!pinnedHash && !localHash && remoteHash) return 'new'; + if (!pinnedHash && localHash && !remoteHash) return 'new'; + if (pinnedHash && !remoteHash && !localHash) return 'deleted'; + if ((pinnedHash && !remoteHash) || (!pinnedHash && remoteHash)) return remoteHash ? 'new' : 'deleted'; + return 'changed'; +} + +/** + * Compare three sources: pinned (keep.lock hashes), local (.env values), remote (Keep values). + * Returns diff results and column visibility flags. + */ +export function compareSecrets( + pinned: Record, // variable -> value_hash + local: Record, // variable -> value hash of the .env value + remote: Record, // variable -> value hash of the Keep value +): { diffs: DiffResult[]; showLocal: boolean; showRemote: boolean } { + const hasRemote = Object.keys(remote).length > 0; + + const allVars = [...new Set([...Object.keys(pinned), ...Object.keys(local), ...(hasRemote ? Object.keys(remote) : [])])]; + + // If remote has no data at all, treat each variable as matching pinned. + // If remote has data but this variable is missing, it's a real absence. + const rows = allVars + .map((variable) => ({ + variable, + pinnedHash: pinned[variable] as Hash, + localHash: local[variable] as Hash, + remoteHash: (hasRemote ? remote[variable] : pinned[variable]) as Hash, + })) + // Silent reconcile (CAP-307): no pinned baseline yet, but local and remote + // already agree. There is nothing to push (remote already holds this exact + // value) and nothing to pull — so don't report a diff and don't let this + // variable flip the showLocal/showRemote direction hints. The pin is adopted + // naturally via the "Everything is up to date!" path when no other variable + // differs. Scoped tightly: only when pinned is absent AND local === remote + // (both present); genuine divergence (local !== remote) still surfaces. + .filter((r) => !(!r.pinnedHash && r.localHash !== undefined && r.localHash === r.remoteHash)); + + const diffs: DiffResult[] = rows + // Only report rows with mismatches + .filter((r) => !(r.pinnedHash === r.localHash && r.pinnedHash === r.remoteHash)) + .filter((r) => !(!r.pinnedHash && !r.localHash && !r.remoteHash)) + .map((r) => ({ + variable: r.variable, + type: diffTypeOf(r.pinnedHash, r.localHash, r.remoteHash), + pinned: r.pinnedHash || undefined, + local: r.localHash || undefined, + remote: r.remoteHash || undefined, + })); + + // Column visibility: + // If all local values match pinned → hide Local column + // If all remote values match pinned → hide Remote column + return { + diffs, + showLocal: rows.some((r) => r.localHash !== r.pinnedHash), + showRemote: rows.some((r) => r.remoteHash !== r.pinnedHash), + }; +} diff --git a/src/sync/conflicts.ts b/src/sync/conflicts.ts new file mode 100644 index 00000000..4906647a --- /dev/null +++ b/src/sync/conflicts.ts @@ -0,0 +1,336 @@ +/** + * Conflicts everywhere (CAP-717; the contract is docs/conflict-resolution.md). + * + * Bare `capy` is the one conflict path. It compares three versions of every variable with + * `compareSecrets` (pinned = keep.lock, local = `.env`, remote = Capy's latest) and the person answers per + * variable with one of FOUR choices: `pinned` | `local` | `remote` | `delete`. This module is what lets a + * caller that is NOT at a terminal give the same answers: + * - the rows: `compareSecrets` rows as the protocol reports them (fingerprints only, never a value); + * - `conflictIdOf`: the id that binds the answers to exactly the rows they were made against; + * - `parseConflictFlags`: `--resolve NAME=pinned|local|remote|delete` (a Dokploy value: `NAME@TARGET=local|remote`) + * and `--conflict `, validated with coded errors; + * - `gateConflicts`: "apply the answers or refuse". Off a terminal a difference stops the write (nothing is + * written, exit 3, JSON on stdout, prose on stderr); a rerun with the right id and an answer for every row goes on. + * + * Nothing here ever holds, prints or throws a value. + */ +import { createHash } from 'crypto'; +import { Option, type Command } from 'commander'; +import { CapyError, ERROR_CODES } from '../types/index'; +import { EXIT_NEEDS_INPUT } from '../ui/interactive'; +import type { DiffResult } from './compareSecrets'; + +// ── Vocabulary ────────────────────────────────────────────────────────────── + +/** Bare `capy`'s four choices for a variable. */ +export type Resolution = 'pinned' | 'local' | 'remote' | 'delete'; +/** A Dokploy value has two: deliver Capy's (`local`) or keep the platform's and skip it (`remote`). */ +export type TargetResolution = 'local' | 'remote'; +/** Answers by key: `NAME` for a folder row, `NAME@TARGET` for a platform row. */ +export type Resolutions = Readonly>; + +/** One `compareSecrets` row, as the protocol reports it. Fingerprints are `null` where there is no value. */ +export interface SourceConflict { + readonly name: string; + readonly project: string; + readonly branch: string; + readonly type: DiffResult['type']; + readonly pinned: string | null; + readonly local: string | null; + readonly remote: string | null; +} + +/** A platform row (Dokploy): Capy's value against the one live on the platform. Only `local` / `remote` are allowed answers. */ +export interface TargetConflict { + readonly name: string; + readonly target: string; + readonly pinned: string | null; + readonly local: string | null; + readonly remote: string | null; +} + +export type Conflict = SourceConflict | TargetConflict; + +export const isTargetConflict = (c: Conflict): c is TargetConflict => 'target' in c; + +/** The key an answer for `conflict` is given under. */ +export const resolveKeyOf = (conflict: Conflict): string => (isTargetConflict(conflict) ? `${conflict.name}@${conflict.target}` : conflict.name); + +/** Where the rows were found: what the id binds. `serverKeepHash` is the server's keep hash for the branch at the read. */ +export interface ConflictView { + readonly project: string; + readonly branch: string; + readonly serverKeepHash: string; +} + +export interface ConflictScope extends ConflictView { + readonly conflicts: readonly Conflict[]; +} + +/** The `compareSecrets` rows of `diffs` as protocol rows for `view`. */ +export function sourceRowsOf(diffs: readonly DiffResult[], view: ConflictView): readonly SourceConflict[] { + return diffs.map((d) => ({ + name: d.variable, + project: view.project, + branch: view.branch, + type: d.type, + pinned: d.pinned ?? null, + local: d.local ?? null, + remote: d.remote ?? null, + })); +} + +// ── The id ────────────────────────────────────────────────────────────────── + +const byText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** A sorted copy (`toSorted` is ES2023; this package targets older Node): the input is never changed. */ +const sortedBy = (items: readonly T[], compare: (a: T, b: T) => number): readonly T[] => Array.from(items).sort(compare); + +const orderKey = (c: Conflict): string => + [isTargetConflict(c) ? 't' : 's', c.name, isTargetConflict(c) ? c.target : '', isTargetConflict(c) ? '' : c.project, isTargetConflict(c) ? '' : c.branch].join('\u0000'); + +/** The rows in the one order the id and the payload use. */ +export const sortedConflicts = (conflicts: readonly Conflict[]): readonly Conflict[] => sortedBy(conflicts, (a, b) => byText(orderKey(a), orderKey(b))); + +const canonical = (c: Conflict) => ({ + name: c.name, + target: isTargetConflict(c) ? c.target : null, + type: isTargetConflict(c) ? null : c.type, + pinned: c.pinned, + local: c.local, + remote: c.remote, +}); + +/** Scopes of one (project, branch, server keep hash) become one scope. */ +function mergedScopes(scopes: readonly ConflictScope[]): readonly ConflictScope[] { + const keyOf = (s: ConflictScope): string => [s.project, s.branch, s.serverKeepHash].join('\u0000'); + return [...new Set(scopes.map(keyOf))].map((key) => { + const group = scopes.filter((s) => keyOf(s) === key); + return { ...group[0], conflicts: group.flatMap((s) => s.conflicts) }; + }); +} + +/** + * `c_` + the first 12 hex of sha256 over the canonical JSON of the project, branch, the server's keep hash + * for the branch and the sorted rows. A run that looked at several (project, branch) places binds all of + * them: the same object per place, sorted. Anything that moved gives another id. + */ +export function conflictIdOf(scopes: readonly ConflictScope[]): string { + const bound = sortedBy( + mergedScopes(scopes).map((s) => ({ project: s.project, branch: s.branch, server_keep_hash: s.serverKeepHash, conflicts: sortedConflicts(s.conflicts).map(canonical) })), + (a, b) => byText(`${a.project}\u0000${a.branch}\u0000${a.server_keep_hash}`, `${b.project}\u0000${b.branch}\u0000${b.server_keep_hash}`), + ); + const body = bound.length === 1 ? bound[0] : bound; + return `c_${createHash('sha256').update(JSON.stringify(body)).digest('hex').slice(0, 12)}`; +} + +export const CONFLICT_ID_FORMAT = /^c_[0-9a-f]{12}$/; + +// ── The payload and the refusal ───────────────────────────────────────────── + +/** Exactly what stdout carries: `ok:false`, the code, the id, the rows. A preview adds `dry_run: true`. */ +export interface ConflictPayload { + readonly ok: false; + readonly code: typeof ERROR_CODES.SECRETS_CONFLICT | typeof ERROR_CODES.SECRETS_CONFLICT_STALE; + readonly conflict_id: string; + readonly conflicts: readonly Conflict[]; + readonly dry_run?: true; +} + +/** A write was stopped for a decision. Carries the payload; `code` is `SECRETS_CONFLICT` or `SECRETS_CONFLICT_STALE`. */ +export class ConflictRefusal extends CapyError { + constructor( + readonly payload: ConflictPayload, + message: string, + ) { + super(message, payload.code, { names: [...new Set(payload.conflicts.map((c) => c.name))], conflict_id: payload.conflict_id }); + this.name = 'ConflictRefusal'; + } +} + +export const isConflictRefusal = (err: unknown): err is ConflictRefusal => err instanceof ConflictRefusal; + +const namesOf = (conflicts: readonly Conflict[]): string => [...new Set(conflicts.map((c) => (isTargetConflict(c) ? `${c.name} on ${c.target}` : c.name)))].join(', '); + +/** The refusal for `listed` (of the rows in `scopes`, whose id it carries). */ +export function conflictRefusalFor( + code: ConflictPayload['code'], + scopes: readonly ConflictScope[], + listed: readonly Conflict[] = scopes.flatMap((s) => s.conflicts), + opts: { readonly dryRun?: boolean } = {}, +): ConflictRefusal { + const message = + code === ERROR_CODES.SECRETS_CONFLICT_STALE + ? `${namesOf(listed)}: the state changed since that id was issued. Nothing was written.` // COPY-FLAG + : `${namesOf(listed)} differ between this folder and Capy. Nothing was written.`; // COPY-FLAG + return new ConflictRefusal( + { ok: false, code, conflict_id: conflictIdOf(scopes), conflicts: sortedConflicts(listed), ...(opts.dryRun === true ? { dry_run: true as const } : {}) }, + message, + ); +} + +/** stdout: the payload as JSON (plus the caller's `extra` fields, e.g. a plan), nothing else. stderr: one line saying what to do. */ +export function printConflictRefusal(refusal: ConflictRefusal, extra: object = {}): void { + console.log(JSON.stringify({ ...refusal.payload, ...extra }, null, 2)); + console.error(refusal.message); + console.error('Rerun with --resolve NAME=pinned|local|remote|delete for each name (NAME@TARGET=local|remote for a platform value) and --conflict .'); // COPY-FLAG +} + +/** Prints a `ConflictRefusal` and exits `EXIT_NEEDS_INPUT`; any other value is left to the caller. */ +export function exitOnConflictRefusal(err: unknown): void { + if (!isConflictRefusal(err)) return; + printConflictRefusal(err); + process.exit(EXIT_NEEDS_INPUT); +} + +// ── The flags ─────────────────────────────────────────────────────────────── + +/** `--resolve` (repeatable) and `--conflict`, as commander hands them over. */ +export interface RawConflictFlags { + readonly resolve?: readonly string[]; + readonly conflict?: string; +} + +export interface ConflictFlags { + readonly resolutions: Resolutions; + readonly conflictId: string | undefined; +} + +export const NO_CONFLICT_FLAGS: ConflictFlags = { resolutions: {}, conflictId: undefined }; + +/** Whether the caller passed either flag. */ +export const hasConflictFlags = (flags: ConflictFlags): boolean => flags.conflictId !== undefined || Object.keys(flags.resolutions).length > 0; + +const RESOLVE_FORM = /^([A-Za-z_][A-Za-z0-9_]*)(?:@([^=@\s]+))?=(pinned|local|remote|delete)$/; + +const invalid = (message: string): CapyError => new CapyError(message, ERROR_CODES.SECRETS_RESOLVE_INVALID); + +function parseResolve(raw: string): readonly [string, Resolution] { + const m = RESOLVE_FORM.exec(raw); + if (m === null) throw invalid('--resolve takes NAME=pinned|local|remote|delete (NAME@TARGET=local|remote for a platform value).'); // COPY-FLAG + // A platform value has no pinned snapshot to go back to and nothing to delete here. + if (m[2] !== undefined && m[3] !== 'local' && m[3] !== 'remote') throw invalid('--resolve NAME@TARGET takes local or remote.'); // COPY-FLAG + return [m[2] === undefined ? m[1] : `${m[1]}@${m[2]}`, m[3] as Resolution]; +} + +/** + * Validates `--resolve` / `--conflict`. Throws a coded `CapyError`: `SECRETS_RESOLVE_INVALID` for a + * bad form (or the same key answered twice, differently, or a malformed id), and + * `SECRETS_RESOLVE_NEEDS_CONFLICT` for `--resolve` without `--conflict`. There is no resolve-all. + */ +export function parseConflictFlags(raw: RawConflictFlags): ConflictFlags { + const pairs = (raw.resolve ?? []).map(parseResolve); + const resolutions: Resolutions = Object.fromEntries(pairs); + const contradicts = pairs.some(([key, choice]) => resolutions[key] !== choice); + if (contradicts) throw invalid('--resolve gives one name two different answers.'); // COPY-FLAG + if (raw.conflict !== undefined && !CONFLICT_ID_FORMAT.test(raw.conflict)) throw invalid('--conflict takes the conflict_id a conflict returned.'); // COPY-FLAG + if (pairs.length > 0 && raw.conflict === undefined) { + throw new CapyError('--resolve needs --conflict : an answer is only valid for the rows it was made against.', ERROR_CODES.SECRETS_RESOLVE_NEEDS_CONFLICT); // COPY-FLAG + } + return { resolutions, conflictId: raw.conflict }; +} + +/** + * `parseConflictFlags` for a command: a bad flag is refused (exit 1) with its code, JSON on stdout under + * `--json`, the sentence on stderr otherwise. Nothing has been read or written yet. + */ +export function conflictFlagsOrExit(raw: RawConflictFlags, json: boolean): ConflictFlags { + try { + return parseConflictFlags(raw); + } catch (err) { + const coded = err instanceof CapyError ? err : invalid('Invalid --resolve or --conflict.'); // COPY-FLAG + if (json) console.log(JSON.stringify({ ok: false, code: coded.code, error: coded.message }, null, 2)); + else console.error(coded.message); + return process.exit(1); + } +} + +// ── Apply the answers, or refuse ──────────────────────────────────────────── + +/** + * The decision step off a terminal. `scopes` hold every row found. + * + * - none → nothing to answer; + * - no `--conflict` → `SECRETS_CONFLICT` with all of them; + * - `--conflict` that is not the id of exactly these → `SECRETS_CONFLICT_STALE` with the fresh ones (new id); + * - the id matches but a row has no `--resolve` → `SECRETS_CONFLICT` with the remaining ones + * (the id of the whole set, so the rerun keeps it); + * - otherwise the answers, keyed as given. A platform row's answer must be `local` or `remote`. + * + * `dryRun` reports the rows (with `dry_run: true`) and never applies. Throws `ConflictRefusal`. + */ +export function gateConflicts(flags: ConflictFlags, scopes: readonly ConflictScope[], opts: { readonly dryRun?: boolean } = {}): Resolutions { + const all = scopes.flatMap((s) => s.conflicts); + if (all.length === 0) return {}; + if (opts.dryRun === true) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, all, opts); + if (flags.conflictId === undefined) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, all, opts); + if (flags.conflictId !== conflictIdOf(scopes)) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT_STALE, scopes, all, opts); + const open = all.filter((c) => flags.resolutions[resolveKeyOf(c)] === undefined); + if (open.length > 0) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, open, opts); + return Object.fromEntries(all.map((c) => [resolveKeyOf(c), flags.resolutions[resolveKeyOf(c)]])); +} + +/** The folder answers of `resolutions` (keys without a target). */ +export const sourceResolutions = (resolutions: Resolutions): Resolutions => + Object.fromEntries(Object.entries(resolutions).filter(([key]) => !key.includes('@'))); + +/** A platform answer together with the fingerprints it was made against: it applies to exactly that state and no other. */ +export interface SeenResolution { + readonly choice: TargetResolution; + /** What Capy was about to deliver when the caller answered (`null`: nothing). */ + readonly local: string | null; + /** What the platform held when the caller answered (`null`: nothing). */ + readonly remote: string | null; +} + +/** `SeenResolution`s by NAME, for one target. */ +export type SeenResolutions = Readonly>; + +/** + * Binds the platform answers in `resolutions` to the rows they answered: each carries the `local` / `remote` + * fingerprints of its row. A key that answers no listed row, or answers it with something other than + * `local` / `remote`, is dropped. Pure; fingerprints only. + */ +export function seenResolutions(resolutions: Resolutions, conflicts: readonly Conflict[]): SeenResolutions { + return Object.fromEntries( + conflicts.flatMap((c) => { + const choice = resolutions[resolveKeyOf(c)]; + return choice === 'local' || choice === 'remote' ? [[resolveKeyOf(c), { choice, local: c.local, remote: c.remote }] as const] : []; + }), + ); +} + +/** The answers for `target`, keyed by bare NAME. */ +export const targetResolutions = (resolutions: Readonly>, target: string): Readonly> => + Object.fromEntries( + Object.entries(resolutions) + .filter(([key]) => key.endsWith(`@${target}`)) + .map(([key, choice]) => [key.slice(0, key.length - target.length - 1), choice]), + ); + +// ── The command line ──────────────────────────────────────────────────────── + +const collectResolve = (value: string, previous: string[]): string[] => [...previous, value]; + +/** `--resolve NAME=pinned|local|remote|delete` (repeatable). Every write command takes it; the root program takes it for bare `capy`. */ +export const resolveOption = (): Option => + new Option( + '--resolve ', + 'answer a difference from Capy: pinned, local, remote or delete; NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict)', // COPY-FLAG + ) + .default([]) + .argParser(collectResolve); + +/** `--conflict `: binds `--resolve` to the rows that were seen. */ +export const conflictOption = (): Option => + new Option('--conflict ', 'the conflict_id a conflict returned: binds --resolve to what was seen'); // COPY-FLAG + +/** + * `--resolve` / `--conflict` as typed, wherever they were typed: a flag the root program took (it takes + * them for bare `capy`) is found through `optsWithGlobals`. + */ +export function rawConflictFlagsOf(command: Pick): RawConflictFlags { + const opts = command.optsWithGlobals() as { resolve?: readonly string[]; conflict?: string }; + return { resolve: opts.resolve, conflict: opts.conflict }; +} diff --git a/src/sync/gate.ts b/src/sync/gate.ts new file mode 100644 index 00000000..3ed9a189 --- /dev/null +++ b/src/sync/gate.ts @@ -0,0 +1,243 @@ +/** + * Bare `capy` runs first (CAP-711; the contract is docs/conflict-resolution.md). + * + * Every command that writes from a folder (`capy edit`, `add`, `remove`, `connect` (discovery too), `rotate`, `push`, + * `checkout ` (not `-b`), `org`, `deploy` in every mode, `deploy revoke`, `deploy targets-remove`) calls + * `gateFirst` as its VERY FIRST step, before any prompt, preflight, plan or confirm screen, and nothing lets a command + * skip it. The only things allowed before it: flag validation (a bad `--resolve` is refused before anything is read) and + * `deploy --json`'s route description (it reads no values and writes nothing). It runs bare `capy`'s own sync + * (`CapyCommand.runSync`: the code `capy` runs, not a copy) and then runs it again until the folder and Capy agree on + * every variable of the branch, or until the person cancels or chooses "Continue working", in which case the command + * writes nothing and ends. + * + * Off a terminal (`--non-tty`, `--json`, an agent, CI, or given `--resolve`) nobody can answer bare `capy`'s + * menu: the differences stop the command (exit 3, the rows and a `conflict_id` on stdout), and a rerun with + * `--resolve` + `--conflict` answers them (sync/conflicts.ts). + * + * After the gate the folder equals Capy for the branch, so the command applies ONLY its own change on top of + * Capy's latest and uploads it with `base_keep_hash` (sync/commit.ts). If the server refuses that upload as stale + * (someone saved in between) the command runs this gate again and tries once more (`commitGated`). + */ +import { existsSync } from 'fs'; +import { isInteractive } from '../ui/interactive'; +import { + exitOnConflictRefusal, + hasConflictFlags, + isConflictRefusal, + parseConflictFlags, + type ConflictPayload, + type RawConflictFlags, +} from './conflicts'; +import { + commitFolder, + isStaleBase, + readLocalPlaintext, + type Changes, + type FolderCommit, + type FolderCommitOptions, + type FolderContext, +} from './commit'; +import { CapyError, ERROR_CODES } from '../types/index'; +import type { SyncOutcome } from '../commands/capyCommand'; + +export interface GateOptions { + readonly devMode?: boolean; + /** `--json`: stdout belongs to the command's JSON, so the differences are reported rather than asked. */ + readonly json?: boolean; + /** `--non-tty`: treat stdin as not a terminal even when it is one. */ + readonly nonTty?: boolean; + /** `--resolve` / `--conflict` as typed. */ + readonly conflictFlags?: RawConflictFlags; + /** The command is a `--dry-run`: differences are reported (with `dry_run`), never answered or applied. */ + readonly dryRun?: boolean; + /** How a failure of the sync itself (no session, Capy unreachable) ends the command. Default: coded JSON under `--json`, the error screen otherwise. */ + readonly refuse?: (err: unknown) => never; + /** + * The folder bare `capy` runs in, when it is not the working directory (discovery writes into other checkouts). + * A folder that does not exist yet has nothing to compare, so it counts as in sync. + */ + readonly cwd?: string; + /** A difference nobody can answer is thrown to the caller instead of ending the process (a multi-folder run reports it per folder). */ + readonly throwConflicts?: boolean; +} + +/** `in-sync`: the command may go on. `stopped`: the person cancelled; the command writes nothing and ends (exit 0). */ +export type GateResult = 'in-sync' | 'stopped'; + +/** One run of bare `capy`'s sync. Injected so a test can stand in for the whole of Keep. */ +export type SyncRunner = (opts: GateOptions, protocol: boolean) => Promise; + +/** Whether the typed `--resolve` / `--conflict` are there. A malformed flag counts: the command refuses it on its own, before anything is read. */ +function givesFlags(raw: RawConflictFlags): boolean { + try { + return hasConflictFlags(parseConflictFlags(raw)); + } catch { + return true; + } +} + +/** Whether the differences are answered by `--resolve` instead of asked: off a terminal, under `--json` or `--dry-run`, or given those flags. */ +export function answersByProtocol(opts: GateOptions): boolean { + return opts.dryRun === true || opts.json === true || !isInteractive(opts.nonTty) || givesFlags(opts.conflictFlags ?? {}); +} + +/** + * `work` with `dir` as the working directory, then back. Bare `capy` is written against the working directory (its + * managers, git, the kick cleanup), so another checkout is synced by standing in it, not by a second implementation. + * Callers run one folder at a time: nothing else may run while the directory is changed. + */ +async function inDirectory(dir: string | undefined, work: () => Promise, absent: T): Promise { + if (dir === undefined) return work(); + if (!existsSync(dir)) return absent; + const origin = process.cwd(); + process.chdir(dir); + try { + return await work(); + } finally { + process.chdir(origin); + } +} + +/** The real runner: a fresh `CapyCommand` doing exactly what `capy` does, in `opts.cwd` when given. */ +const runBareCapy: SyncRunner = (opts, protocol) => + inDirectory( + opts.cwd, + async () => { + const { CapyCommand } = await import('../commands/capyCommand'); + // A torn checkout (`.capy/branch` and the `.env` header name different branches) must not end the process here: in the + // working directory the header's branch is compared (so `capy checkout `, the recovery, can run); in another + // checkout it is that folder's coded failure, so a multi-folder run goes on to the next folder. + const onBranchMismatch = opts.cwd === undefined ? 'follow-env-header' : 'throw'; + return new CapyCommand({ protocol, silentAuthOnly: protocol, quiet: true, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun, onBranchMismatch }, opts.devMode === true).runSync(); + }, + 'up-to-date', + ); + +async function repeat(run: () => Promise): Promise { + const outcome = await run(); + if (outcome === 'up-to-date') return 'in-sync'; + if (outcome === 'stopped') return 'stopped'; + // The sync changed something: compare again. Any difference left (e.g. after "Individually resolve") shows the menu again. + return repeat(run); +} + +/** Bare `capy` until the folder and Capy agree. A difference nobody can answer is thrown (a `ConflictRefusal`). */ +const syncUntilInSync = (opts: GateOptions, runner: SyncRunner): Promise => repeat(() => runner(opts, answersByProtocol(opts))); + +/** The outcome of `work` as a value, so what follows it is not inside its own `catch`. */ +const outcomeOf = (work: Promise) => + work.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ); + +/** + * Runs bare `capy` until the folder and Capy agree. A difference nobody can answer ends the process here (exit 3, + * the payload on stdout); an error is thrown as it is. + */ +export async function ensureInSync(opts: GateOptions = {}, runner: SyncRunner = runBareCapy): Promise { + const done = await outcomeOf(syncUntilInSync(opts, runner)); + if (done.ok) return done.value; + if (opts.throwConflicts !== true) exitOnConflictRefusal(done.error); + throw done.error; +} + +/** What bare `capy` found in one folder that is not the working directory. */ +export type FolderSync = + | { readonly ok: true } + | { + readonly ok: false; + /** `SECRETS_CONFLICT` / `SECRETS_CONFLICT_STALE` for a difference to decide, `FOLDER_SYNC_STOPPED` when the person stopped, else the error's own code. */ + readonly code: string; + readonly message: string; + /** The rows and `conflict_id` of a difference nobody could answer (fingerprints only). */ + readonly conflict?: ConflictPayload; + }; + +/** The code of a sync that was stopped (the person cancelled or chose "Continue working"). */ +export const FOLDER_SYNC_STOPPED = 'STOPPED'; + +const folderSyncFailure = (err: unknown): FolderSync => { + if (isConflictRefusal(err)) return { ok: false, code: err.code, message: err.message, conflict: err.payload }; + if (err instanceof CapyError) return { ok: false, code: err.code, message: err.message }; + return { ok: false, code: ERROR_CODES.SERVICE_ERROR, message: 'The sync did not complete.' }; // COPY-FLAG +}; + +/** + * Bare `capy` in `dir` (a checkout other than the working directory) until it and Capy agree: the step before anything is + * written into that folder. Never ends the process: a difference nobody can answer, a stop or a failure comes back as the + * folder's own coded result, and nothing has been written into it. + */ +export async function syncFolder(dir: string, opts: GateOptions = {}, runner: SyncRunner = runBareCapy): Promise { + const done = await outcomeOf(syncUntilInSync({ ...opts, cwd: dir }, runner)); + if (!done.ok) return folderSyncFailure(done.error); + return done.value === 'in-sync' ? { ok: true } : { ok: false, code: FOLDER_SYNC_STOPPED, message: 'Stopped. Nothing was written.' }; // COPY-FLAG +} + +/** The gate stopped (the person cancelled or chose "Continue working"): the command ends here, exit 0, nothing written. */ +export function stopWithNothingWritten(): never { + console.error('Stopped. Nothing was written.'); // COPY-FLAG + return process.exit(0); +} + +/** The sync itself failed (a coded `CapyError`: no session, Capy unreachable): the command ends with that code, not a stack. */ +async function failedSync(err: unknown, opts: GateOptions): Promise { + if (opts.refuse !== undefined) return opts.refuse(err); + const coded = err instanceof CapyError ? err : new CapyError('The sync did not complete.', ERROR_CODES.SERVICE_ERROR); // COPY-FLAG + if (opts.json === true) { + console.log(JSON.stringify({ ok: false, code: coded.code, error: coded.message }, null, 2)); + return process.exit(1); + } + // Off a terminal (no one to read the error screen, stdout reserved for payloads): the code and what to do, on stderr. + if (answersByProtocol(opts)) { + console.error(`${coded.code}: ${coded.message}`); + return process.exit(1); + } + const { displayErrorAndExit } = await import('../ui/errorScreen'); + return displayErrorAndExit(err); +} + +/** The gate as a command's first step: a stop ends the command on the spot, and so does a failure of the sync. */ +export async function gateFirst(opts: GateOptions = {}, runner: SyncRunner = runBareCapy): Promise { + const done = await outcomeOf(syncUntilInSync(opts, runner)); + if (!done.ok) { + exitOnConflictRefusal(done.error); + return failedSync(done.error, opts); + } + if (done.value === 'stopped') stopWithNothingWritten(); +} + +/** A folder context the gate can be re-run for (`ResolvedContext` has all of it). */ +export type GatedFolder = FolderContext & { readonly gate?: GateOptions }; + +/** The same folder, read again from disk: bare `capy` may have moved keep.lock and `.env` since the context was made. */ +function reread(ctx: C): C { + return { ...ctx, keep: ctx.pm.readKeepFile() ?? ctx.keep, localPlaintext: readLocalPlaintext(ctx.fileManager, ctx.projectKey) }; +} + +/** + * `commitFolder` (the command's own change onto Capy's latest, with `base_keep_hash`) and, if the server refuses it + * as stale because someone saved in between, bare `capy` again and one more attempt from the folder as it is then. + * A stop in that second gate ends the command (nothing written); a second stale refusal is thrown + * (`SECRETS_STALE_BASE`, exit 1). A context without `gate` throws the stale refusal as it is. A context for another + * checkout than the working directory (discovery) carries a gate with that folder as `cwd`, so the retry runs bare `capy` + * there. + */ +export async function commitGated( + ctx: C, + changes: Changes, + opts: FolderCommitOptions = {}, + runner: SyncRunner = runBareCapy, +): Promise { + const attempt = async (folder: C, options: FolderCommitOptions, retried: boolean): Promise => { + try { + return await commitFolder(folder, changes, options); + } catch (err) { + if (!isStaleBase(err) || retried || ctx.gate === undefined) throw err; + return (await ensureInSync(ctx.gate, runner)) === 'in-sync' + ? attempt(reread(folder), { ...options, seen: undefined }, true) + : stopWithNothingWritten(); + } + }; + return attempt(ctx, opts, false); +} diff --git a/src/sync/noFolder.ts b/src/sync/noFolder.ts new file mode 100644 index 00000000..96aa7001 --- /dev/null +++ b/src/sync/noFolder.ts @@ -0,0 +1,91 @@ +/** + * Writes with no folder (CAP-717; docs/conflict-resolution.md, "Commands with no folder"): `capy secrets set`, the + * `capy secrets` edit screens and the batch deploy write straight to Capy, so there is no `keep.lock`, no `.env` + * and no bare `capy` to run first. For each value written the SAME three versions are compared with + * `compareSecrets`: + * + * pinned = the fingerprint the row listed (the value the person was shown), + * local = the value being written, + * remote = what Capy holds at that location now. + * + * Capy unchanged since it was shown (remote = pinned), or already holding the new value, needs no decision: the + * value is written (or there is nothing to write). Capy moved since it was shown to something else is a row with + * the same four choices as bare `capy`, answered before anything is written: + * + * local → write the new value over it, + * remote → leave that location as it is (keep Capy's latest), + * pinned → write back the value the person was shown. The listing holds its fingerprint only, so the caller supplies the + * plaintext: the edit screen has it decrypted; the CLI reads it from another location of the same row that still + * holds that fingerprint. Not recoverable: refused with `SECRETS_RESOLVE_INVALID`, never guessed, + * delete → remove the variable at that location (through the shared upload). + * + * Fingerprints only, never a value. + */ +import { CapyError, ERROR_CODES } from '../types/index'; +import { compareSecrets } from './compareSecrets'; +import { sourceRowsOf, type ConflictView, type Resolution, type SourceConflict } from './conflicts'; + +/** One value about to be written at one location. */ +export interface WriteComparison { + readonly name: string; + readonly project: string; + readonly branch: string; + /** The fingerprint the row listed: the value the person was shown (`undefined`: none). */ + readonly shown: string | undefined; + /** The fingerprint of the new value. */ + readonly local: string; + /** The fingerprint of what the location holds now (`undefined`: the location no longer has the variable). */ + readonly remote: string | undefined; + /** The server's keep hash for the branch at that read: what the `conflict_id` binds. */ + readonly serverKeepHash: string; +} + +/** A name `compareSecrets` is given so that "Capy has data" is true even when the location lost the variable. */ +const PRESENT = '\u0000present'; + +/** + * The row for this write, or `undefined` when no decision is needed (the location is as it was shown, or already + * holds the new value). Decided by `compareSecrets` over the three fingerprints. + */ +export function movedRow(w: WriteComparison): SourceConflict | undefined { + const { diffs } = compareSecrets( + w.shown === undefined ? {} : { [w.name]: w.shown }, + { [w.name]: w.local }, + w.remote === undefined ? { [PRESENT]: '' } : { [w.name]: w.remote }, + ); + const row = diffs.find((d) => d.variable === w.name); + if (row === undefined || row.remote === row.pinned || row.remote === row.local) return undefined; + const view: ConflictView = { project: w.project, branch: w.branch, serverKeepHash: w.serverKeepHash }; + return sourceRowsOf([row], view)[0]; +} + +/** What an answer does at a location. */ +export type LocationAction = 'write' | 'leave' | 'restore' | 'remove'; + +/** + * `local` writes the new value, `remote` leaves the location as it is, `pinned` writes back the value that was shown + * (`restore`), `delete` removes the variable there (`remove`). Total: all four of bare `capy`'s choices mean something. + */ +export function actionFor(choice: Resolution): LocationAction { + if (choice === 'local') return 'write'; + if (choice === 'remote') return 'leave'; + return choice === 'pinned' ? 'restore' : 'remove'; +} + +/** The refusal for a `pinned` answer whose shown value cannot be recovered here (it would write nothing or guess). Names only. */ +export const shownValueUnavailable = (name: string): CapyError => + new CapyError(`${name}: the value you were shown cannot be recovered here, so pinned cannot be written back. Answer local, remote or delete.`, ERROR_CODES.SECRETS_RESOLVE_INVALID, { names: [name] }); // COPY-FLAG + +/** An answer that holds only for the state it was made against: the location still holds what the person saw. */ +export interface BoundAnswer { + readonly choice: Resolution; + /** What the location held (`null`: nothing) and what was being written, when the person answered. */ + readonly remote: string | null; + readonly local: string | null; +} + +export const bindAnswer = (choice: Resolution, row: SourceConflict): BoundAnswer => ({ choice, remote: row.remote, local: row.local }); + +/** Whether `answer` was made against exactly the state `row` describes. */ +export const holdsFor = (answer: BoundAnswer | undefined, row: SourceConflict): answer is BoundAnswer => + answer !== undefined && answer.remote === row.remote && answer.local === row.local; diff --git a/src/sync/syncEngine.ts b/src/sync/syncEngine.ts index 6d313653..70979d8b 100644 --- a/src/sync/syncEngine.ts +++ b/src/sync/syncEngine.ts @@ -31,99 +31,39 @@ export class SyncEngine { remoteEncrypted?: Record, syncState?: SyncState | null ): ChangeSet { - const localKeys = new Set(Object.keys(local)); - const remoteKeys = new Set(Object.keys(remote)); - const syncedKeys = syncState ? new Set(syncState.synced_variables) : new Set(); + const syncedKeys = new Set(syncState ? syncState.synced_variables : []); + const localKeys = Object.keys(local); + const plain = (name: string, value: string, source: EnvVariable['source'], encrypted: boolean): EnvVariable => ({ name, value, source, encrypted }); - const newLocal: EnvVariable[] = []; - const newRemote: EnvVariable[] = []; - const conflicts: ConflictVariable[] = []; - const unchanged: EnvVariable[] = []; - const deleted: EnvVariable[] = []; - const deletedLocal: EnvVariable[] = []; + // A name both sides have is: marked deleted on remote, a conflict, or unchanged. + const both = localKeys.filter((key) => key in remote); + const isDeletedMarker = (key: string): boolean => remote[key] === 'capy:deleted'; - // Check local variables - for (const key of localKeys) { - if (!remoteKeys.has(key)) { - // New local variable - newLocal.push({ - name: key, - value: local[key], - source: 'local', - encrypted: false - }); - } else if (remote[key] === 'capy:deleted') { - // Remote variable marked as deleted - deleted.push({ - name: key, - value: local[key], - source: 'local', - encrypted: false - }); - } else if (local[key] !== remote[key]) { + const conflicts: ConflictVariable[] = both + .filter((key) => !isDeletedMarker(key) && local[key] !== remote[key]) + .map((key) => { // Conflict - check if resource_ids differ using encrypted versions - const localEncryptedValue = localEncrypted ? localEncrypted[key] : local[key]; - const remoteEncryptedValue = remoteEncrypted ? remoteEncrypted[key] : remote[key]; + const localResourceId = this.extractResourceId(localEncrypted ? localEncrypted[key] : local[key]); + const remoteResourceId = this.extractResourceId(remoteEncrypted ? remoteEncrypted[key] : remote[key]); + const isNew = + (localResourceId !== null && remoteResourceId !== null && localResourceId !== remoteResourceId) || + (localResourceId === null && remoteResourceId !== null); + return { name: key, localValue: local[key], remoteValue: remote[key], isNew }; + }); - const localResourceId = this.extractResourceId(localEncryptedValue); - const remoteResourceId = this.extractResourceId(remoteEncryptedValue); - - const isNew = (localResourceId !== null && - remoteResourceId !== null && - localResourceId !== remoteResourceId) || - (localResourceId === null && remoteResourceId !== null); - - conflicts.push({ - name: key, - localValue: local[key], - remoteValue: remote[key], - isNew - }); - } else { - // Unchanged - unchanged.push({ - name: key, - value: local[key], - source: 'both', - encrypted: false - }); - } - } - - // Check remote variables not in local - for (const key of remoteKeys) { - // Skip deleted markers if they don't exist locally (already handled above) - if (!localKeys.has(key) && remote[key] !== 'capy:deleted') { - // Check if this variable was previously synced to this machine - if (syncedKeys.has(key)) { - // Variable was synced before but now missing locally - // → User deleted it locally - deletedLocal.push({ - name: key, - value: remote[key], - source: 'remote', - encrypted: true - }); - } else { - // Variable never synced to this machine - // → New remote variable (or first sync) - newRemote.push({ - name: key, - value: remote[key], - source: 'remote', - encrypted: true - }); - } - } - } + // Remote variables not in local (a deleted marker for a name local lacks is not a new remote variable). + const remoteOnly = Object.keys(remote).filter((key) => !(key in local) && !isDeletedMarker(key)); return { - newLocal, - newRemote, + newLocal: localKeys.filter((key) => !(key in remote)).map((key) => plain(key, local[key], 'local', false)), + // Variable never synced to this machine → new remote variable (or first sync) + newRemote: remoteOnly.filter((key) => !syncedKeys.has(key)).map((key) => plain(key, remote[key], 'remote', true)), conflicts, - unchanged, - deleted, - deletedLocal + unchanged: both.filter((key) => !isDeletedMarker(key) && local[key] === remote[key]).map((key) => plain(key, local[key], 'both', false)), + // Remote variable marked as deleted + deleted: both.filter(isDeletedMarker).map((key) => plain(key, local[key], 'local', false)), + // Variable was synced before but now missing locally → user deleted it locally + deletedLocal: remoteOnly.filter((key) => syncedKeys.has(key)).map((key) => plain(key, remote[key], 'remote', true)), }; } @@ -132,56 +72,27 @@ export class SyncEngine { remote: Record, decisions: UserDecisions ): Record { - // Start from remote as the base — unchanged variables keep their remote value - const result: Record = { ...remote }; - - // Remove deletion markers - for (const key of Object.keys(result)) { - if (result[key] === 'capy:deleted') { - delete result[key]; - } - } - - // Add unchanged local-only variables (not in remote, not conflicts) - for (const key of Object.keys(local)) { - if (!(key in remote)) { - // New local variable — only include if user chose to push - if (decisions.pushVariables.includes(key)) { - result[key] = local[key]; - } - } - } - - // Apply user's explicit push decisions (local value wins) - for (const varName of decisions.pushVariables) { - if (local[varName] !== undefined) { - result[varName] = local[varName]; - } - } - - // Keep local values for conflicts resolved as "keep local" (not pushed) - for (const varName of decisions.keepLocal) { - if (local[varName] !== undefined) { - result[varName] = local[varName]; - } - } - - // Apply user's explicit pull decisions (remote value wins, already in result) - for (const varName of decisions.pullVariables) { - if (remote[varName] !== undefined && remote[varName] !== 'capy:deleted') { - result[varName] = remote[varName]; - } - } + // Start from remote as the base — unchanged variables keep their remote value (deletion markers removed) + const base = Object.fromEntries(Object.entries(remote).filter(([, value]) => value !== 'capy:deleted')); + + // New local-only variables are included only if the user chose to push them; an explicit push decision + // (local value wins) and "keep local" for conflicts both take the local value. + const fromLocal = Object.fromEntries( + [...new Set([...decisions.pushVariables, ...decisions.keepLocal])] + .filter((name) => local[name] !== undefined) + .map((name) => [name, local[name]] as const), + ); + + // Apply user's explicit pull decisions (remote value wins) + const pulled = Object.fromEntries( + decisions.pullVariables + .filter((name) => remote[name] !== undefined && remote[name] !== 'capy:deleted') + .map((name) => [name, remote[name]] as const), + ); // Delete variables the user confirmed for deletion - for (const varName of decisions.deleteLocal) { - delete result[varName]; - } - for (const varName of decisions.deleteRemote) { - delete result[varName]; - } - - return result; + const gone = new Set([...decisions.deleteLocal, ...decisions.deleteRemote]); + return Object.fromEntries(Object.entries({ ...base, ...fromLocal, ...pulled }).filter(([name]) => !gone.has(name))); } generateSyncResult( @@ -221,37 +132,30 @@ export class SyncEngine { pushedVariables: Record, branch?: string, ): KeepFile { - const updatedKeep = { ...keep, variables: { ...keep.variables } }; - - for (const [varName, data] of Object.entries(pushedVariables)) { - const existing = updatedKeep.variables[varName] || []; - // Clone existing entries - const entries = existing.map(e => ({ ...e })); - - // Find existing entry for this branch - const targetBranch = branch || SyncEngine.DEFAULT_BRANCH; - const idx = entries.findIndex(e => e.branch === targetBranch); - - // Preserve any extra fields on the existing entry (e.g. `connector` - // metadata set by `capy connect`). Without the spread, every sync - // would clobber them. - const entry: KeepVariableEntry = { - ...(idx >= 0 ? entries[idx] : {}), - resource_id: data.resource_id, - value_hash: data.value_hash || '', - branch: targetBranch, - }; - - if (idx >= 0) { - entries[idx] = entry; - } else { - entries.push(entry); - } - - updatedKeep.variables[varName] = entries; - } - - return updatedKeep; + const targetBranch = branch || SyncEngine.DEFAULT_BRANCH; + const merged = Object.fromEntries( + Object.entries(pushedVariables).map(([varName, data]) => { + // Clone existing entries + const entries = (keep.variables[varName] || []).map(e => ({ ...e })); + + // Find existing entry for this branch + const idx = entries.findIndex(e => e.branch === targetBranch); + + // Preserve any extra fields on the existing entry (e.g. `connector` + // metadata set by `capy connect`). Without the spread, every sync + // would clobber them. + const entry: KeepVariableEntry = { + ...(idx >= 0 ? entries[idx] : {}), + resource_id: data.resource_id, + value_hash: data.value_hash || '', + branch: targetBranch, + }; + + return [varName, idx >= 0 ? entries.map((e, i) => (i === idx ? entry : e)) : [...entries, entry]] as const; + }), + ); + + return { ...keep, variables: { ...keep.variables, ...merged } }; } /** @@ -270,20 +174,16 @@ export class SyncEngine { static spliceKeepBranch(local: KeepFile | null, server: KeepFile, branch: string): KeepFile { if (!local) return server; - const variables: KeepFile['variables'] = {}; const names = new Set([...Object.keys(local.variables), ...Object.keys(server.variables)]); - - for (const name of names) { - const localEntries = local.variables[name] || []; - const serverEntries = server.variables[name] || []; - const merged = [ - ...localEntries.filter(e => e.branch !== branch), - ...serverEntries.filter(e => e.branch === branch), - ]; - if (merged.length > 0) { - variables[name] = merged; - } - } + const variables: KeepFile['variables'] = Object.fromEntries( + [...names].flatMap((name) => { + const merged = [ + ...(local.variables[name] || []).filter(e => e.branch !== branch), + ...(server.variables[name] || []).filter(e => e.branch === branch), + ]; + return merged.length > 0 ? [[name, merged] as const] : []; + }), + ); return { ...local, variables }; } @@ -310,64 +210,37 @@ export class SyncEngine { } formatSyncSummary(changeSet: ChangeSet): string { - const lines: string[] = []; - - if (changeSet.newLocal.length > 0) { - lines.push(`${changeSet.newLocal.length} new local variable(s)`); - } - - if (changeSet.newRemote.length > 0) { - lines.push(`${changeSet.newRemote.length} new remote variable(s)`); - } - - if (changeSet.deleted.length > 0) { - lines.push(`${changeSet.deleted.length} deleted variable(s)`); - } - - if (changeSet.conflicts.length > 0) { - lines.push(`${changeSet.conflicts.length} conflict(s)`); - } - - if (changeSet.unchanged.length > 0) { - lines.push(`${changeSet.unchanged.length} unchanged`); - } - - return lines.join('\n'); + return [ + changeSet.newLocal.length > 0 ? `${changeSet.newLocal.length} new local variable(s)` : '', + changeSet.newRemote.length > 0 ? `${changeSet.newRemote.length} new remote variable(s)` : '', + changeSet.deleted.length > 0 ? `${changeSet.deleted.length} deleted variable(s)` : '', + changeSet.conflicts.length > 0 ? `${changeSet.conflicts.length} conflict(s)` : '', + changeSet.unchanged.length > 0 ? `${changeSet.unchanged.length} unchanged` : '', + ] + .filter((line) => line !== '') + .join('\n'); } validateDecisions(decisions: UserDecisions, changeSet: ChangeSet): string[] { - const errors: string[] = []; - const localVarNames = changeSet.newLocal.map(v => v.name); const conflictNames = changeSet.conflicts.map(c => c.name); const deletedVarNames = changeSet.deleted.map(v => v.name); - for (const varName of decisions.pushVariables) { - if (!localVarNames.includes(varName) && !conflictNames.includes(varName) && !deletedVarNames.includes(varName)) { - errors.push(`Cannot push ${varName}: not a new local variable, conflict, or deleted variable`); - } - } - const remoteVarNames = changeSet.newRemote.map(v => v.name); const deletedLocalVarNames = changeSet.deletedLocal.map(v => v.name); - for (const varName of decisions.pullVariables) { - if (!remoteVarNames.includes(varName) && !deletedLocalVarNames.includes(varName)) { - errors.push(`Cannot pull ${varName}: not a new remote variable or locally deleted variable`); - } - } - const allResolutions = [...decisions.keepLocal, ...decisions.keepRemote]; - for (const varName of allResolutions) { - if (!conflictNames.includes(varName)) { - errors.push(`Cannot resolve ${varName}: not a conflict`); - } - } - const resolutionSet = new Set(allResolutions); - if (resolutionSet.size !== allResolutions.length) { - errors.push('Duplicate conflict resolutions detected'); - } - - return errors; + return [ + ...decisions.pushVariables + .filter((varName) => !localVarNames.includes(varName) && !conflictNames.includes(varName) && !deletedVarNames.includes(varName)) + .map((varName) => `Cannot push ${varName}: not a new local variable, conflict, or deleted variable`), + ...decisions.pullVariables + .filter((varName) => !remoteVarNames.includes(varName) && !deletedLocalVarNames.includes(varName)) + .map((varName) => `Cannot pull ${varName}: not a new remote variable or locally deleted variable`), + ...allResolutions + .filter((varName) => !conflictNames.includes(varName)) + .map((varName) => `Cannot resolve ${varName}: not a conflict`), + ...(new Set(allResolutions).size !== allResolutions.length ? ['Duplicate conflict resolutions detected'] : []), + ]; } static readonly DEFAULT_BRANCH = 'development'; @@ -378,14 +251,12 @@ export class SyncEngine { */ static computeKeepHash(keep: KeepFile, branch?: string): string { const targetBranch = branch || SyncEngine.DEFAULT_BRANCH; - const entries: string[] = []; - for (const key of Object.keys(keep.variables).sort()) { - const varEntries = keep.variables[key]; - const entry = varEntries.find(e => e.branch === targetBranch); - if (entry) { - entries.push(`${key}:${entry.resource_id}:${entry.value_hash}`); - } - } + const entries = Object.keys(keep.variables) + .sort() + .flatMap((key) => { + const entry = keep.variables[key].find(e => e.branch === targetBranch); + return entry ? [`${key}:${entry.resource_id}:${entry.value_hash}`] : []; + }); return createHash('sha256').update(entries.join('\n')).digest('hex'); } } diff --git a/src/system/systemStore.ts b/src/system/systemStore.ts index 7fc73d45..1a4b1440 100644 --- a/src/system/systemStore.ts +++ b/src/system/systemStore.ts @@ -13,8 +13,8 @@ * local state lives entirely under `~/.capy/orgs//system/` (see * `src/config/globalConfig.ts`'s "Org system store" section). * - * WHAT THIS REUSES, NOT COPIES: `Encryptor`, `deriveResourceId`, `SyncEngine` - * (mergeWithKeep / computeKeepHash / adoptServerKeep), `resolveProjectKey`, + * WHAT THIS REUSES, NOT COPIES: `Encryptor`, `SyncEngine`, the one commit path + * (`sync/commit.ts`), `resolveProjectKey`, * and the existing `ServiceClient` secrets routes (`pushSecrets`, * `getDecryptData`) plus `writeKeepCache`. The only new service call is * `getOrCreateSystemStore` — the create-or-get endpoint. Everything else goes @@ -22,7 +22,6 @@ * admin-only restriction is enforced by the server on those same routes, not * by anything special about how this module calls them. */ -import { createHash } from 'crypto'; import inquirer from 'inquirer'; import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; @@ -30,8 +29,8 @@ import { ProjectManager } from '../core/projectManager'; import { resolveOrgContext } from '../core/orgContext'; import { resolveProjectKey } from '../crypto/keyResolver'; import { Encryptor } from '../crypto/encryptor'; -import { deriveResourceId } from '../crypto/resourceId'; import { SyncEngine } from '../sync/syncEngine'; +import { adoptedKeepFor, envBlobOf, uploadChangesRereading, type Changes } from '../sync/commit'; import { writeKeepCache, readSystemKeepFile, @@ -206,65 +205,42 @@ function decryptStoredValue(raw: string, projectKey: string): string | null { return Encryptor.decrypt(cipher, projectKey); } -/** Encrypts `env` into the `KEY=capy:{resourceId}:{cipher}` blob this module writes. */ -function buildEnvBlob(env: Readonly>, branch: string, projectKey: string): string { - return Object.entries(env) - .map(([k, v]) => `${k}=capy:${deriveResourceId(branch, k)}:${Encryptor.encrypt(v, projectKey)}`) - .join('\n'); -} - /** - * Drops any keep.lock entry (on `branch`) for a name no longer in `keptNames` - * — the immutable equivalent of the delete-stale-entries loop in - * `connectors/shared.ts`'s `writeAndSync`. Names in `keptNames` are left - * exactly as `mergeWithKeep` produced them. + * Commit `changes` to the system store's `ctx.branch` through the shared upload (sync/commit.ts) and + * self-heal the local cache. Only the changed names move: another admin's entries are never re-sent from + * this (possibly older) snapshot, and the upload names the keep it was built from (`base_keep_hash`), read + * again once if another admin saved in between. */ -function pruneRemovedVariables(keep: KeepFile, branch: string, keptNames: ReadonlySet): KeepFile { - const variables = Object.fromEntries( - Object.entries(keep.variables) - .map(([name, entries]): [string, KeepVariableEntry[]] => [ - name, - keptNames.has(name) ? entries : entries.filter((e) => e.branch !== branch), - ]) - .filter(([, entries]) => entries.length > 0), - ); - return { ...keep, variables }; -} - -/** Push `newEnv` as the system store's complete env for `ctx.branch`, and self-heal the local cache. */ -async function pushSystemEnv(ctx: SystemStoreContext, newEnv: Readonly>): Promise { - const envBlob = buildEnvBlob(newEnv, ctx.branch, ctx.projectKey); - - const pushedVars = Object.fromEntries( - Object.entries(newEnv).map(([k, v]) => [ - k, - { resource_id: deriveResourceId(ctx.branch, k), value_hash: createHash('sha256').update(v).digest('hex').slice(0, 16) }, - ]), +async function commitSystemStore(ctx: SystemStoreContext, changes: Changes): Promise { + const startKeep = readSystemKeepFile(ctx.orgId) ?? ctx.keep; + const committed = await withAdminOnlyTranslation(() => + uploadChangesRereading({ + client: ctx.serviceClient, + projectId: ctx.projectId, + branch: ctx.branch, + projectKey: ctx.projectKey, + startKeep, + changes, + }), ); - const syncEngine = new SyncEngine(); - const merged = syncEngine.mergeWithKeep(ctx.keep, pushedVars, ctx.branch); - const pruned = pruneRemovedVariables(merged, ctx.branch, new Set(Object.keys(newEnv))); - - const result = await withAdminOnlyTranslation(() => - ctx.serviceClient.pushSecrets(ctx.projectId, JSON.stringify(pruned), envBlob, ctx.branch), - ); - - writeKeepCache(ctx.orgId, ctx.projectId, result.keep_hash, envBlob); - const adopted = SyncEngine.adoptServerKeep(result.keep_file, pruned, ctx.branch); + const adopted = adoptedKeepFor(startKeep, committed, ctx.branch); + writeKeepCache(ctx.orgId, ctx.projectId, committed.result?.keep_hash ?? SyncEngine.computeKeepHash(committed.keep, ctx.branch), envBlobOf(committed.lines)); saveSystemKeepFile(ctx.orgId, adopted); const existingSyncState = readSystemSyncState(ctx.orgId); const nextSyncState: SyncState = { ...(existingSyncState ?? { last_sync: '', synced_variables: [] }), last_sync: new Date().toISOString(), - synced_variables: Object.keys(newEnv), + synced_variables: Object.keys(committed.env), user_id: ctx.userId, - keep_hash: setSyncKeepHash(existingSyncState, ctx.branch, SyncEngine.computeKeepHash(pruned, ctx.branch)), + keep_hash: setSyncKeepHash(existingSyncState, ctx.branch, SyncEngine.computeKeepHash(adopted, ctx.branch)), }; saveSystemSyncState(ctx.orgId, nextSyncState); } +const setting = (name: string, value: string): Changes => ({ set: { [name]: value }, remove: [] }); + /** Silent-then-interactive auth, pinned to `orgId`. Throws `AUTH_FAILED` rather than exiting — this is a library, not a command. */ async function authenticateOrThrow(authService: AuthService, orgId: string): Promise { const silent = await authService.authenticateSilent(orgId); @@ -412,12 +388,11 @@ export async function openSystemStore(opts: OpenSystemStoreOptions = {}): Promis get: (name: string) => getFrom(ctx, name), set: async (name: string, value: string) => { assertValidConnectorName(name); - await pushSystemEnv(ctx, { ...ctx.plaintextEnv, [name]: value }); + await commitSystemStore(ctx, setting(name, value)); }, remove: async (name: string) => { assertValidConnectorName(name); - const { [name]: _removed, ...rest } = ctx.plaintextEnv; - await pushSystemEnv(ctx, rest); + await commitSystemStore(ctx, { set: {}, remove: [name] }); }, }; } @@ -458,7 +433,7 @@ export async function getConnectorSecret(name: string, opts: GetConnectorSecretO const trimmed = typeof value === 'string' ? value.trim() : ''; if (!trimmed) return null; - await pushSystemEnv(ctx, { ...ctx.plaintextEnv, [name]: trimmed }); + await commitSystemStore(ctx, setting(name, trimmed)); return trimmed; } @@ -557,7 +532,7 @@ export async function getDirectionalConnectorSecret( { primaryName, fallbackName }, ); } - await pushSystemEnv(ctx, { ...ctx.plaintextEnv, [primaryName]: makeReferenceValue(fallbackName) }); + await commitSystemStore(ctx, setting(primaryName, makeReferenceValue(fallbackName))); return fallbackValue; } // else "add new" — fall through to the same prompt case 2 uses. @@ -574,6 +549,6 @@ export async function getDirectionalConnectorSecret( const trimmed = typeof value === 'string' ? value.trim() : ''; if (!trimmed) return null; - await pushSystemEnv(ctx, { ...ctx.plaintextEnv, [primaryName]: trimmed }); + await commitSystemStore(ctx, setting(primaryName, trimmed)); return trimmed; } diff --git a/src/types/index.ts b/src/types/index.ts index 76420b00..56d195dc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -385,6 +385,26 @@ export interface CliOptions { verbose?: boolean; force?: boolean; dryRun?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717): off a terminal they answer the differences between the folder and Capy. */ + conflictFlags?: { readonly resolve?: readonly string[]; readonly conflict?: string }; + /** + * Nobody can answer the menu (off a terminal, `--json`, or given `--resolve`): the differences are reported with a + * `conflict_id` and `conflictFlags` answer them. Prose goes to stderr. Absent: the menu, as at a terminal. + */ + protocol?: boolean; + /** Say nothing while the folder and Capy agree (spinners, "Everything is up to date!"): stdout and stderr belong to the command that ran this first. */ + quiet?: boolean; + /** Never the browser sign-in: a command that runs the sync as its first step and has no one to sign in is told why instead. */ + silentAuthOnly?: boolean; + /** + * Bare `capy` as a command's first step (sync/gate.ts), not typed by a person: what to do when `.capy/branch` and the + * `.env` header name different, real branches (an interrupted checkout). Absent (a person ran `capy`): stop with the + * recovery message, exit 1. `follow-env-header`: the header outranks `.capy/branch`, so compare against the header's + * branch and leave `.capy/branch` for the command to settle (this is what lets `capy checkout ` recover). + * `throw`: a coded `BRANCH_STATE_INCONSISTENT` error, for a folder other than the working directory, so one folder's + * state ends only that folder's sync. + */ + onBranchMismatch?: 'follow-env-header' | 'throw'; } export interface ProjectInitResult { @@ -443,6 +463,8 @@ export const ERROR_CODES = { BRANCH_NOT_FOUND: 'BRANCH_NOT_FOUND', // No .env header, no .capy/branch, and no unambiguous local fallback. NO_ACTIVE_BRANCH: 'NO_ACTIVE_BRANCH', + // .capy/branch and the .env header name different, real branches (an interrupted checkout) and the run cannot follow either. + BRANCH_STATE_INCONSISTENT: 'BRANCH_STATE_INCONSISTENT', SNAPSHOT_NOT_FOUND: 'SNAPSHOT_NOT_FOUND', NO_SECRETS: 'NO_SECRETS', DEPLOY_TOKEN_NOT_FOUND: 'DEPLOY_TOKEN_NOT_FOUND', @@ -624,16 +646,38 @@ export const ERROR_CODES = { // --- capy remove (CAP-686) --- /** `capy remove NAME...`: one or more names given are not on the active branch. */ VAR_NOT_FOUND: 'VAR_NOT_FOUND', + /** `capy remove` needs a human (confirmation) and there is no TTY, and `--yes` wasn't passed. */ + REMOVE_NEEDS_TTY: 'REMOVE_NEEDS_TTY', + // --- One commit path (CAP-712) --- + /** + * The server refused a push because the keep it was built from is no longer + * the server's latest (HTTP 409). Mirrors the server's `code`. The commit + * path re-reads and re-plans once; it is only seen when that fails too. + */ + SECRETS_STALE_BASE: 'SECRETS_STALE_BASE', + /** + * The folder and Capy differ on a variable (or a Dokploy value was changed outside Capy) and nobody can answer: + * off a terminal, bare `capy` reports the rows with a `conflict_id` and exits 3. Nothing was written. + * `details.names` lists the variables (names only, never values); never auto-resolved. + */ + SECRETS_CONFLICT: 'SECRETS_CONFLICT', + // --- Conflicts everywhere: the non-TTY protocol (CAP-717, docs/conflict-resolution.md) --- + /** + * A rerun with `--conflict ` whose state moved since the id was issued: the old answers + * no longer apply. Nothing was written; the fresh rows (with a new id) come back + * with it. Exit 3. + */ + SECRETS_CONFLICT_STALE: 'SECRETS_CONFLICT_STALE', + /** `--resolve` is not `NAME=pinned|local|remote|delete` (or `NAME@TARGET=local|remote`), or `--conflict` is not a conflict id. Exit 1. */ + SECRETS_RESOLVE_INVALID: 'SECRETS_RESOLVE_INVALID', + /** `--resolve` was given without `--conflict `: an answer is only valid for the rows it was made against. Exit 1. */ + SECRETS_RESOLVE_NEEDS_CONFLICT: 'SECRETS_RESOLVE_NEEDS_CONFLICT', /** - * `capy remove` refused: the working `.env` has unpushed differences (new, - * changed, or locally-missing values) for variables OTHER than the ones - * being removed. Removing would push that full remaining set — including - * those unrelated edits — as a side effect, so it's refused instead of - * carrying someone else's in-progress change along for the ride. + * `capy remove` used to refuse when other variables had unpushed local edits. + * @deprecated Retired by CAP-712: `capy remove` only checks the names it removes. Kept defined + * so existing callers keep compiling; nothing raises it. */ REMOVE_LOCAL_DRIFT: 'REMOVE_LOCAL_DRIFT', - /** `capy remove` needs a human (confirmation) and there is no TTY, and `--yes` wasn't passed. */ - REMOVE_NEEDS_TTY: 'REMOVE_NEEDS_TTY', // --- keep.lock PR step (capy add / edit / remove, CAP-699) --- // Reported as `keep_lock.error.code`: the secret change itself still succeeded. /** keep.lock PR step: the working directory is not inside a git repository. */ diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index e5408c9c..9071e088 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -1,9 +1,23 @@ // Single-column TUI for `capy edit`: variables list with an inline popup // detail view. Built on raw stdin + ANSI codes so we don't add a TUI dependency. - +// +// Pure core, thin shell. `reduce(model, action)` takes the whole screen state and one +// action (a key chunk, a finished save) and returns the next state plus (at most) one +// effect for the shell to perform; `render` draws a state. Nothing in the core touches +// stdin, stdout or the network. `EditScreen.run` is the shell: it owns the raw terminal, +// feeds actions in, draws what `render` returns and performs the one effect (a save). +// +// Rows are compared three ways (pinned, local, remote); see `reclassifyRow`. A row both +// sides changed reads `conflict`. The screen opens after bare `capy` has settled every +// difference (sync/gate.ts), so it starts in sync; a save that finds Capy moved since the +// screen read it is refused as stale, the screen closes, and the command runs bare `capy` +// again and reopens with the edits still typed. + +import { EventEmitter } from 'node:events'; import { formatSnippet, hashValue } from '../commands/statusCommand'; import { anyTargetBehind } from '../core/deployStatus'; -import { statusBadge } from './statusBadge'; +import { isStaleBase, type Snapshot } from '../sync/commit'; +import { statusBadge, statusLabel } from './statusBadge'; import { formatRelativeTime } from './relativeTime'; import { isRevealKey, stepEditBuffer } from './editBuffer'; import { OldValueView, canReveal, renderInlineValue as inlineValue, valueDialogRows } from './valueDialog'; @@ -36,47 +50,59 @@ const MARGIN = 2; const NO_VALUE = '—'; export interface EditRow { - key: string; - localValue: string | undefined; - remoteValue: string | undefined; - status: 'in sync' | 'local' | 'remote' | 'conflict' | 'behind' | 'unknown'; - updatedLabel: string; + readonly key: string; + readonly localValue: string | undefined; + readonly remoteValue: string | undefined; + readonly status: 'in sync' | 'local' | 'remote' | 'conflict' | 'behind' | 'unknown'; + readonly updatedLabel: string; /** * CAP-702: `deployed_value_hash` of every deploy target this variable was * pushed to on the active branch (`null`: never pushed). A row that is * otherwise in sync reads `behind` while any of these differs from its * value's hash. */ - deployedHashes?: readonly (string | null)[]; + readonly deployedHashes?: readonly (string | null)[]; /** * keep.lock changed_at for this variable on the active branch (ISO8601, * server-assigned) — when the value last changed server-side. Drives the * UPDATED column's recency label; absent = unknown/never pushed. */ - changedAt?: string; + readonly changedAt?: string; + /** + * What this folder last agreed with the server (the fingerprint keep.lock pins for the branch; `null`: + * not pinned). When present the row is compared three ways: pinned, local (`localValue`), remote (the + * server's latest). Absent: only `localValue` and `remoteValue` are compared. + */ + readonly pinnedHash?: string | null; + /** The server's fingerprint when the server's value cannot be read here (`remoteValue` is then absent). */ + readonly remoteHash?: string; } export interface EditState { - projectName: string; - branch: string; - rows: EditRow[]; - remoteAvailable: boolean; + readonly projectName: string; + readonly branch: string; + readonly rows: readonly EditRow[]; + readonly remoteAvailable: boolean; /** * Local-only mode: there is no remote. The comparison is committed-vs-working * (status is 'in sync' or 'local'/uncommitted), and the UPDATED column shows * committed/uncommitted instead of remote-drift wording. */ - localMode?: boolean; + readonly localMode?: boolean; /** * `capy edit NAME`: the cursor starts on this row. A key with no row is * ignored (the cursor stays at the top). */ - focusKey?: string; + readonly focusKey?: string; /** * `capy edit NAME` for a name that does not exist yet: the value entry for * this key opens straight away (its row was added by {@link withNewEntryRow}). */ - entryKey?: string; + readonly entryKey?: string; + /** The server read the rows were drawn from: a commit is built on it and names its keep (`base_keep_hash`). */ + readonly seen?: Snapshot; + /** Values typed before the screen was closed for bare `capy` and reopened: shown as uncommitted edits. */ + readonly pending?: Readonly>; } /** @@ -96,21 +122,59 @@ export function focusedOn(state: EditState, name: string): EditState { status: state.localMode || state.remoteAvailable ? 'local' : 'unknown', updatedLabel: state.localMode ? 'uncommitted' : NO_VALUE, }; - // The spread is a fresh array, so sorting it is construction (no `toSorted` below ES2023). - const rows = [...state.rows, placeholder].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); - return { ...state, rows, focusKey: name, entryKey: name }; + return { ...state, rows: sortedRows([...state.rows, placeholder]), focusKey: name, entryKey: name }; +} + +/** + * `state` with edits typed earlier shown again (the screen was closed for bare `capy` and reopened): each typed + * value is the row's working value, a name no row has gets one, and all of it is still uncommitted. + */ +export function withPendingEdits(state: EditState, pending: Readonly>): EditState { + if (Object.keys(pending).length === 0) return state; + const mode = { localMode: state.localMode, remoteAvailable: state.remoteAvailable }; + const known = new Set(state.rows.map((r) => r.key)); + const typed = state.rows.map((row) => (Object.hasOwn(pending, row.key) ? classifiedRow({ ...row, localValue: pending[row.key] }, mode) : row)); + const added = Object.keys(pending) + .filter((key) => !known.has(key)) + .map((key): EditRow => classifiedRow({ key, localValue: pending[key], remoteValue: undefined, status: 'local', updatedLabel: NO_VALUE, pinnedHash: null }, mode)); + return { ...state, rows: sortedRows([...typed, ...added]), pending }; +} + +/** What a commit carries. */ +export interface EditCommitRequest { + /** The values typed this session and not committed yet: the commit's own change. */ + readonly set: Readonly>; + /** The server read the rows were drawn from. */ + readonly seen: Snapshot | undefined; +} + +/** What the screen shows after re-reading the folder and the server. */ +export interface EditLoaded { + readonly rows: readonly EditRow[]; + readonly remoteAvailable: boolean; + readonly seen: Snapshot | undefined; } export interface EditContext { /** - * Commit (and in server mode, push) the given edits. Resolves to the - * server-assigned changed_at per variable for the active branch, taken - * from the push response's keep_file — empty in local mode or when the - * service didn't return timestamps. + * Commit (and in server mode, push) `set` on top of Capy's latest, naming the keep of `seen`. Resolves to the + * server-assigned changed_at per variable for the active branch, taken from the push response's keep_file — + * empty in local mode or when the service didn't return timestamps. Rejects with `SECRETS_STALE_BASE` + * (nothing uploaded) when Capy moved since the screen read it. */ - saveLocalEdits: (edits: Record) => Promise>; + readonly saveLocalEdits: (set: Record, seen?: Snapshot) => Promise>; + /** Re-reads the folder and the server (after a save). Absent: rows are updated from what the save returned. */ + readonly reload?: () => Promise; } +// ── Rows: fingerprints, status ────────────────────────────────────────────── + +/** The server's fingerprint for the row (`null`: the server has no such variable). */ +const remoteHashOf = (row: EditRow): string | null => row.remoteHash ?? (row.remoteValue === undefined ? null : hashValue(row.remoteValue)); + +/** The fingerprint of the row's working value (`null`: none). */ +const localHashOf = (row: EditRow): string | null => (row.localValue === undefined ? null : hashValue(row.localValue)); + /** * Local-only row classification: committed-vs-working. `working` is the value * in .env; `committed` is the value in the local keep (cache). Shared by the @@ -127,11 +191,9 @@ export function classifyLocalRow( } /** - * How a row reads after it has been edited in this session. - * - * The remote side is not refetched once a session is open, so it is treated as - * unchanged from when the screen loaded — the same assumption the TUI has - * always made. + * How a row reads. Server mode compares three versions when the row knows its pin (`pinnedHash`): + * `local` (you changed it), `remote` (the server changed it since your pin), `conflict` (both did, + * to different values), `in sync`. * * Pulled out of `EditScreen` so the browser path classifies with this code * rather than with a second copy of it. Two implementations of "what changed" @@ -161,6 +223,11 @@ function syncStatus(row: EditRow, mode: { localMode?: boolean; remoteAvailable: // Local mode: committed-vs-working. remoteValue holds the committed value. if (mode.localMode) return classifyLocalRow(row.localValue, row.remoteValue).status; if (!mode.remoteAvailable) return 'unknown'; + return row.pinnedHash === undefined ? twoWayStatus(row) : threeWayStatus(row, row.pinnedHash ?? undefined); +} + +/** Only the working and the server's value are known (a row without a pin). */ +function twoWayStatus(row: EditRow): EditRow['status'] { const localPresent = row.localValue !== undefined; const remotePresent = row.remoteValue !== undefined; if (localPresent && remotePresent && row.localValue === row.remoteValue) return 'in sync'; @@ -170,17 +237,35 @@ function syncStatus(row: EditRow, mode: { localMode?: boolean; remoteAvailable: return 'in sync'; } -/** - * The UPDATED column's label, mode-aware. - * - * Local mode has no server timestamps, so it keeps the committed/uncommitted - * wording; otherwise the label says when the value last changed server-side. - */ +function threeWayStatus(row: EditRow, pinned: string | undefined): EditRow['status'] { + const local = localHashOf(row) ?? undefined; + const remote = remoteHashOf(row) ?? undefined; + if (local === remote) return 'in sync'; + const localChanged = local !== pinned; + const remoteChanged = remote !== pinned; + // Both sides changed it, to different values. (A variable missing from the folder is not a change to keep: the server's value is pulled.) + if (localChanged && remoteChanged && row.localValue !== undefined) return 'conflict'; + return localChanged && !remoteChanged ? 'local' : 'remote'; +} + +/** The UPDATED column's label, mode-aware. Local mode has no server timestamps, so it keeps the committed/uncommitted wording; otherwise the label says when the value last changed server-side. */ export function updatedLabelForRow(row: EditRow, mode: { localMode?: boolean }): string { if (mode.localMode) return row.status === 'in sync' || row.status === 'behind' ? 'committed' : 'uncommitted'; return row.changedAt ? formatRelativeTime(row.changedAt) : NO_VALUE; } +/** `row` with its status and UPDATED label worked out afresh. */ +export function classifiedRow(row: EditRow, mode: { localMode?: boolean; remoteAvailable: boolean }): EditRow { + const status = reclassifyRow(row, mode); + const classified = { ...row, status }; + return { ...classified, updatedLabel: updatedLabelForRow(classified, mode) }; +} + +const byKey = (a: EditRow, b: EditRow): number => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0); + +/** A sorted copy (`toSorted` is ES2023; this package targets older Node): the input is never changed. */ +const sortedRows = (rows: readonly EditRow[]): readonly EditRow[] => Array.from(rows).sort(byKey); + /** * Normalizes bracketed-paste content for storage in an edit buffer. Pasted * line breaks are kept (so multi-line secrets like PEM keys survive), CRLF/CR @@ -199,678 +284,626 @@ export function sanitizePastedText(raw: string): string { /** One line for a (possibly multi-line) value, length preserved: see ./valueDialog. */ export const renderInlineValue = inlineValue; -export class EditScreen { - private state: EditState = { projectName: '', branch: '', rows: [], remoteAvailable: false }; - private ctx: EditContext | null = null; - private cursorIndex = 0; - private revealed = new Set(); +// ── The screen's state ────────────────────────────────────────────────────── + +interface Editing { + readonly key: string; + readonly buffer: string; + readonly oldValue: string | undefined; + readonly isNew: boolean; + readonly revealed: boolean; +} + +interface StatusMessage { + readonly text: string; + readonly isError: boolean; +} + +/** A commit in flight: the names it carries, and whether the screen closes when it lands. */ +interface Saving { + readonly keys: readonly string[]; + readonly quit: boolean; +} + +export interface EditModel { + readonly state: EditState; + readonly cursorIndex: number; + readonly revealed: ReadonlySet; /** * The value dialog (shared layout: ui/valueDialog.ts). `buffer` is what has been * typed (it always starts empty); `oldValue` is the current value, held in memory * only to show on Ctrl+R; `isNew` is a variable that does not exist yet. */ - private editing: { key: string; buffer: string; oldValue: string | undefined; isNew: boolean; revealed: boolean } | null = null; - private statusMessage: { text: string; isError: boolean } | null = null; - private scrollOffset = 0; - private onDataHandler: ((data: Buffer) => void) | null = null; - private onResizeHandler: (() => void) | null = null; - private cleanedUp = false; - private pendingEdits: Map = new Map(); - private quitPrompt: 'commit' | null = null; - private popupOpen = false; - private popupPanOffset = 0; - // Accumulates raw stdin between PASTE_START and PASTE_END markers when a - // bracketed paste spans multiple `data` chunks. null when not mid-paste. - private pasteBuffer: string | null = null; - - run(state: EditState, ctx: EditContext): Promise { - this.state = state; - this.ctx = ctx; - const focusIndex = state.rows.findIndex((r) => r.key === state.focusKey); - this.cursorIndex = Math.max(0, focusIndex); - this.revealed.clear(); - const entering = state.entryKey !== undefined && state.rows.some((r) => r.key === state.entryKey); - this.editing = + readonly editing: Editing | null; + readonly status: StatusMessage | null; + readonly scrollOffset: number; + /** The values typed this session and not committed yet. */ + readonly pending: Readonly>; + readonly quitPrompt: boolean; + readonly popupOpen: boolean; + readonly popupPan: number; + /** Accumulates raw stdin between PASTE_START and PASTE_END when a bracketed paste spans several chunks. `null`: not mid-paste. */ + readonly paste: string | null; + readonly saving: Saving | null; +} + +export function initialModel(state: EditState): EditModel { + const focusIndex = state.rows.findIndex((r) => r.key === state.focusKey); + const entering = state.entryKey !== undefined && state.rows.some((r) => r.key === state.entryKey); + return { + state, + cursorIndex: Math.max(0, focusIndex), + revealed: new Set(), + editing: entering && state.entryKey !== undefined ? { key: state.entryKey, buffer: '', oldValue: undefined, isNew: true, revealed: false } - : null; - this.statusMessage = null; - this.scrollOffset = 0; - this.cleanedUp = false; - this.pendingEdits.clear(); - this.quitPrompt = null; - this.popupOpen = entering; - this.popupPanOffset = 0; - - return new Promise((resolve) => { - process.stdout.write(ENTER_ALT_SCREEN + HIDE_CURSOR + ENABLE_BRACKETED_PASTE); - if (process.stdin.isTTY) process.stdin.setRawMode(true); - process.stdin.resume(); + : null, + status: null, + scrollOffset: 0, + pending: state.pending ?? {}, + quitPrompt: false, + popupOpen: entering, + popupPan: 0, + paste: null, + saving: null, + }; +} - this.draw(); +/** Names with something to commit: the edits typed. */ +const dirtyKeys = (model: EditModel): readonly string[] => Object.keys(model.pending); - this.onDataHandler = (data: Buffer) => this.handleKeypress(data, resolve); - process.stdin.on('data', this.onDataHandler); +const withRows = (model: EditModel, rows: readonly EditRow[]): EditModel => ({ ...model, state: { ...model.state, rows } }); - this.onResizeHandler = () => this.draw(); - process.on('SIGWINCH', this.onResizeHandler); +const replaceRow = (model: EditModel, key: string, change: (row: EditRow) => EditRow): EditModel => + withRows(model, model.state.rows.map((r) => (r.key === key ? change(r) : r))); - const exit = () => { - this.cleanup(); - resolve(); - }; - process.on('SIGINT', exit); - process.on('SIGTERM', exit); - }); - } +const modeOf = (model: EditModel): { localMode?: boolean; remoteAvailable: boolean } => ({ + localMode: model.state.localMode, + remoteAvailable: model.state.remoteAvailable, +}); - private cleanup(): void { - if (this.cleanedUp) return; - this.cleanedUp = true; - process.stdout.write(DISABLE_BRACKETED_PASTE + SHOW_CURSOR + EXIT_ALT_SCREEN); - if (process.stdin.isTTY) process.stdin.setRawMode(false); - process.stdin.pause(); - if (this.onDataHandler) process.stdin.removeListener('data', this.onDataHandler); - if (this.onResizeHandler) process.removeListener('SIGWINCH', this.onResizeHandler); - } +// ── Actions, effects, outcome ─────────────────────────────────────────────── - private handleKeypress(data: Buffer, resolve: () => void): void { - const key = data.toString(); - - // Bracketed paste arrives as ESC[200~ ESC[201~, possibly split - // across data chunks. Intercept it before any key dispatch so pasted text - // (which may contain 'q', newlines, etc.) can never trigger quit/commit or - // navigation — its content is only ever appended to an active edit buffer. - if (this.pasteBuffer !== null || key.includes(PASTE_START)) { - this.consumePaste(key); - return; - } - - // Ctrl-C exits unconditionally - if (key === '\x03') { - this.cleanup(); - resolve(); - return; - } - - if (this.quitPrompt) { - this.handleQuitPromptKey(key, resolve); - return; - } - - if (this.editing) { - this.handleEditKey(key); - return; - } - - if (key === 'q' || key === 'Q') { - if (this.pendingEdits.size > 0) { - this.quitPrompt = 'commit'; - this.draw(); - } else { - this.cleanup(); - resolve(); - } - return; - } - - // Esc closes the popup (when open) - if ((key === ESC || key === `${ESC}\x1b`) && this.popupOpen) { - this.popupOpen = false; - this.popupPanOffset = 0; - this.draw(); - return; - } - - // Arrow up - if (key === `${ESC}[A`) { - if (this.cursorIndex > 0) { - this.cursorIndex--; - this.popupPanOffset = 0; - this.draw(); - } - return; - } - - // Arrow down - if (key === `${ESC}[B`) { - if (this.cursorIndex < this.state.rows.length - 1) { - this.cursorIndex++; - this.popupPanOffset = 0; - this.draw(); - } - return; - } - - // Arrow left — pan value left when popup open + revealed - if (key === `${ESC}[D`) { - if (this.popupOpen && this.popupPanOffset > 0) { - this.popupPanOffset = Math.max(0, this.popupPanOffset - 8); - this.draw(); - } - return; - } - - // Arrow right — pan value right when popup open + revealed - if (key === `${ESC}[C`) { - if (this.popupOpen) { - this.popupPanOffset += 8; - this.draw(); - } - return; - } - - // Home / End — pan to start / end (when popup open + revealed) - if (key === `${ESC}[H` || key === `${ESC}[1~`) { - if (this.popupOpen) { - this.popupPanOffset = 0; - this.draw(); - } - return; - } - if (key === `${ESC}[F` || key === `${ESC}[4~`) { - if (this.popupOpen) { - this.popupPanOffset = Number.MAX_SAFE_INTEGER; - this.draw(); - } - return; - } - - // Enter / Space — toggle popup - if (key === '\r' || key === '\n' || key === ' ') { - this.popupOpen = !this.popupOpen; - this.popupPanOffset = 0; - this.draw(); - return; - } - - if (key === 'r' || key === 'R') { - const row = this.state.rows[this.cursorIndex]; - if (!row) return; - if (this.revealed.has(row.key)) this.revealed.delete(row.key); - else this.revealed.add(row.key); - this.popupOpen = true; - this.popupPanOffset = 0; - this.draw(); - return; - } - - if (key === 'e' || key === 'E') { - const row = this.state.rows[this.cursorIndex]; - if (!row) return; - this.editing = { key: row.key, buffer: '', oldValue: row.localValue ?? row.remoteValue, isNew: false, revealed: false }; - this.statusMessage = null; - this.popupOpen = true; - this.popupPanOffset = 0; - this.draw(); - return; - } - - if (key === 'c' || key === 'C') { - if (this.pendingEdits.size > 0) { - void this.commitInPlace(); - } - return; - } - } +/** What a save came back with. Never carries a value. */ +export type SaveResult = + | { readonly kind: 'saved'; readonly changedAt: Readonly>; readonly loaded: EditLoaded | undefined } + /** Capy moved since the screen read it (`SECRETS_STALE_BASE`): nothing was uploaded. The screen closes so bare `capy` can run. */ + | { readonly kind: 'stale' } + | { readonly kind: 'failed'; readonly message: string }; - private async commitInPlace(): Promise { - if (!this.ctx || this.pendingEdits.size === 0) return; - const editedKeys = Array.from(this.pendingEdits.keys()); - const edits = Object.fromEntries(this.pendingEdits); - const verb = this.state.localMode ? 'Committing' : 'Committing & pushing'; - this.statusMessage = { text: `${verb} ${editedKeys.length} change(s)…`, isError: false }; - this.draw(); - try { - const changedAtByKey = await this.ctx.saveLocalEdits(edits); - this.pendingEdits.clear(); - // After commit, the committed baseline == local for the edited rows, - // and the UPDATED column picks up the server-assigned changed_at from - // the push response (authoritative — not a client-side clock guess). - for (const k of editedKeys) { - const row = this.state.rows.find((r) => r.key === k); - if (row && row.localValue !== undefined) { - if (changedAtByKey[k]) row.changedAt = changedAtByKey[k]; - row.remoteValue = row.localValue; - row.status = this.reclassify(row); - row.updatedLabel = this.updatedLabelFor(row); - } - } - this.statusMessage = { text: `Committed ${editedKeys.length} change(s)`, isError: false }; - } catch (err: any) { - this.statusMessage = { text: `Error committing: ${err?.message || err}`, isError: true }; - } - this.draw(); - } +export type EditAction = + | { readonly kind: 'data'; readonly data: string } + | { readonly kind: 'saved'; readonly result: SaveResult }; - // Accumulates a bracketed paste across data chunks and, once the closing - // marker arrives, appends the pasted text to the active edit buffer verbatim - // (newlines preserved). Pasted outside of edit mode, the content is dropped. - private consumePaste(chunk: string): void { - this.pasteBuffer = (this.pasteBuffer ?? '') + chunk; - const start = this.pasteBuffer.indexOf(PASTE_START); - if (start === -1) { - this.pasteBuffer = null; - return; - } - const end = this.pasteBuffer.indexOf(PASTE_END, start + PASTE_START.length); - if (end === -1) return; // paste spans more chunks — wait for the rest - - const pasted = this.pasteBuffer.substring(start + PASTE_START.length, end); - this.pasteBuffer = null; - - if (!this.editing) return; - const appended = sanitizePastedText(pasted); - if (appended) { - this.editing = { ...this.editing, buffer: this.editing.buffer + appended }; - this.draw(); - } - } +export type EditEffect = { readonly type: 'save'; readonly request: EditCommitRequest }; - private handleEditKey(key: string): void { - if (!this.editing) return; - - // Esc — both bare ESC and ESC ESC (some terminals double-emit) - if (key === ESC || key === `${ESC}\x1b`) { - this.editing = null; - this.draw(); - return; - } - - // Enter: saves what was typed. An empty box has nothing to save (Esc cancels). - if (key === '\r' || key === '\n') { - if (this.editing.buffer !== '') void this.commitEdit(); - return; - } - - // Ctrl+R shows or hides both rows (on screen only); nothing to show for a new variable until something is typed. - if (isRevealKey(key)) { - if (canReveal(this.oldValueView(this.editing), this.editing.buffer)) { - this.editing = { ...this.editing, revealed: !this.editing.revealed }; - this.draw(); - } - return; - } - - // Backspace, arrows (ignored) and printable text, every letter included: - // one shared rule with the `capy secrets` edit dialog (see editBuffer.ts). - const next = stepEditBuffer(this.editing.buffer, key); - if (next !== this.editing.buffer) { - this.editing = { ...this.editing, buffer: next }; - this.draw(); - } - } +export interface EditOutcome { + readonly model: EditModel; + readonly effect?: EditEffect; + /** The screen is over. */ + readonly done?: true; + /** It is over because a save was refused as stale: the edits typed so far are in `model.pending`. */ + readonly stale?: true; +} - private async commitEdit(): Promise { - if (!this.editing || !this.ctx) return; - const { key, buffer } = this.editing; - this.editing = null; - - // Buffer the edit in memory; it gets written on quit-confirm. - this.pendingEdits.set(key, buffer); - const row = this.state.rows.find((r) => r.key === key); - if (row) { - row.localValue = buffer; - row.status = this.reclassify(row); - row.updatedLabel = this.updatedLabelFor(row); - } - this.statusMessage = { text: `Edited ${key} (uncommitted)`, isError: false }; - this.draw(); - } +/** How the screen ended: for good, or because Capy moved under a save (the command runs bare `capy` and reopens it with `pending`). */ +export type EditExit = { readonly kind: 'done' } | { readonly kind: 'stale'; readonly pending: Readonly> }; + +const stay = (model: EditModel): EditOutcome => ({ model }); +const finished = (model: EditModel): EditOutcome => ({ model, done: true }); - private handleQuitPromptKey(key: string, resolve: () => void): void { - // 'c' commit & push, 'd' discard & quit, 'k'/Esc keep working - if (key === 'c' || key === 'C') { - void this.commitAndQuit(resolve); - return; - } - if (key === 'd' || key === 'D') { - this.pendingEdits.clear(); - this.cleanup(); - resolve(); - return; - } - if (key === 'k' || key === 'K' || key === ESC || key === '\x1b\x1b') { - this.quitPrompt = null; - this.draw(); - return; - } +// ── Keys ──────────────────────────────────────────────────────────────────── + +/** Accumulates a bracketed paste across data chunks and, once the closing marker arrives, appends the pasted text to the active edit buffer verbatim (newlines preserved). Pasted outside of edit mode, the content is dropped. */ +function consumePaste(model: EditModel, chunk: string): EditModel { + const buffered = (model.paste ?? '') + chunk; + const start = buffered.indexOf(PASTE_START); + if (start === -1) return { ...model, paste: null }; + const end = buffered.indexOf(PASTE_END, start + PASTE_START.length); + if (end === -1) return { ...model, paste: buffered }; // paste spans more chunks — wait for the rest + const appended = sanitizePastedText(buffered.substring(start + PASTE_START.length, end)); + if (!model.editing || !appended) return { ...model, paste: null }; + return { ...model, paste: null, editing: { ...model.editing, buffer: model.editing.buffer + appended } }; +} + +const oldValueView = (editing: { readonly oldValue: string | undefined; readonly isNew: boolean }): OldValueView => { + if (editing.isNew) return { kind: 'none' }; + return editing.oldValue === undefined + ? { kind: 'unavailable', code: ERROR_CODES.VARIABLE_NOT_FOUND } + : { kind: 'value', value: editing.oldValue }; +}; + +/** Buffer the edit in memory; it is written on commit. */ +function commitEdit(model: EditModel): EditModel { + if (!model.editing) return model; + const { key, buffer } = model.editing; + const edited = replaceRow( + { ...model, editing: null, pending: { ...model.pending, [key]: buffer } }, + key, + (row) => classifiedRow({ ...row, localValue: buffer }, modeOf(model)), + ); + return { ...edited, status: { text: `Edited ${key} (uncommitted)`, isError: false } }; +} + +function onEditKey(model: EditModel, key: string): EditModel { + const editing = model.editing; + if (!editing) return model; + + // Esc — both bare ESC and ESC ESC (some terminals double-emit) + if (key === ESC || key === `${ESC}${ESC}`) return { ...model, editing: null }; + + // Enter: saves what was typed. An empty box has nothing to save (Esc cancels). + if (key === '\r' || key === '\n') return editing.buffer !== '' ? commitEdit(model) : model; + + // Ctrl+R shows or hides both rows (on screen only); nothing to show for a new variable until something is typed. + if (isRevealKey(key)) { + return canReveal(oldValueView(editing), editing.buffer) ? { ...model, editing: { ...editing, revealed: !editing.revealed } } : model; } - private async commitAndQuit(resolve: () => void): Promise { - if (!this.ctx) return; - const edits = Object.fromEntries(this.pendingEdits); - const verb = this.state.localMode ? 'Committing' : 'Committing & pushing'; - this.statusMessage = { text: `${verb} ${this.pendingEdits.size} change(s)…`, isError: false }; - this.draw(); - try { - await this.ctx.saveLocalEdits(edits); - this.pendingEdits.clear(); - this.cleanup(); - resolve(); - } catch (err: any) { - this.quitPrompt = null; - this.statusMessage = { text: `Error committing: ${err?.message || err}`, isError: true }; - this.draw(); - } + // Backspace, arrows (ignored) and printable text, every letter included: + // one shared rule with the `capy secrets` edit dialog (see editBuffer.ts). + const next = stepEditBuffer(editing.buffer, key); + return next !== editing.buffer ? { ...model, editing: { ...editing, buffer: next } } : model; +} + +/** `c`: the commit this screen would make: the edits typed, on top of Capy's latest. */ +function requestCommit(model: EditModel, quit: boolean): EditOutcome { + const request: EditCommitRequest = { set: model.pending, seen: model.state.seen }; + const keys = Object.keys(model.pending); + const verb = model.state.localMode ? 'Committing' : 'Committing & pushing'; + return { + model: { + ...model, + quitPrompt: false, + saving: { keys, quit }, + status: { text: `${verb} ${keys.length} change(s)…`, isError: false }, + }, + effect: { type: 'save', request }, + }; +} + +function onQuitPromptKey(model: EditModel, key: string): EditOutcome { + // 'c' commit & push, 'd' discard & quit, 'k'/Esc keep working + if (key === 'c' || key === 'C') return requestCommit(model, true); + if (key === 'd' || key === 'D') return finished({ ...model, pending: {} }); + if (key === 'k' || key === 'K' || key === ESC || key === `${ESC}${ESC}`) return stay({ ...model, quitPrompt: false }); + return stay(model); +} + +const movedTo = (model: EditModel, cursorIndex: number): EditModel => ({ ...model, cursorIndex, popupPan: 0 }); + +function onListKey(model: EditModel, key: string): EditOutcome { + if (key === 'q' || key === 'Q') { + return dirtyKeys(model).length > 0 ? stay({ ...model, quitPrompt: true }) : finished(model); } - // Local-only reclassification. We don't refetch remote, so the remote side - // is treated as unchanged from when the TUI loaded. - /** - * UPDATED-column label, mode-aware. Local mode has no server timestamps, - * so it keeps the committed/uncommitted wording; otherwise show when the - * value last changed server-side. - */ - private updatedLabelFor(row: EditRow): string { - return updatedLabelForRow(row, { localMode: this.state.localMode }); + // Esc closes the popup (when open) + if ((key === ESC || key === `${ESC}${ESC}`) && model.popupOpen) return stay({ ...model, popupOpen: false, popupPan: 0 }); + + if (key === `${ESC}[A`) return stay(model.cursorIndex > 0 ? movedTo(model, model.cursorIndex - 1) : model); + if (key === `${ESC}[B`) return stay(model.cursorIndex < model.state.rows.length - 1 ? movedTo(model, model.cursorIndex + 1) : model); + + // Arrow left / right — pan the value when the popup is open + revealed + if (key === `${ESC}[D`) return stay(model.popupOpen && model.popupPan > 0 ? { ...model, popupPan: Math.max(0, model.popupPan - 8) } : model); + if (key === `${ESC}[C`) return stay(model.popupOpen ? { ...model, popupPan: model.popupPan + 8 } : model); + + // Home / End — pan to start / end (when popup open + revealed) + if (key === `${ESC}[H` || key === `${ESC}[1~`) return stay(model.popupOpen ? { ...model, popupPan: 0 } : model); + if (key === `${ESC}[F` || key === `${ESC}[4~`) return stay(model.popupOpen ? { ...model, popupPan: Number.MAX_SAFE_INTEGER } : model); + + // Enter / Space — toggle popup + if (key === '\r' || key === '\n' || key === ' ') return stay({ ...model, popupOpen: !model.popupOpen, popupPan: 0 }); + + if (key === 'r' || key === 'R') { + const row = model.state.rows[model.cursorIndex]; + if (!row) return stay(model); + const revealed = model.revealed.has(row.key) + ? new Set([...model.revealed].filter((k) => k !== row.key)) + : new Set([...model.revealed, row.key]); + return stay({ ...model, revealed, popupOpen: true, popupPan: 0 }); } - private reclassify(row: EditRow): EditRow['status'] { - return reclassifyRow(row, { - localMode: this.state.localMode, - remoteAvailable: this.state.remoteAvailable, + if (key === 'e' || key === 'E') { + const row = model.state.rows[model.cursorIndex]; + if (!row) return stay(model); + return stay({ + ...model, + editing: { key: row.key, buffer: '', oldValue: row.localValue ?? row.remoteValue, isNew: false, revealed: false }, + status: null, + popupOpen: true, + popupPan: 0, }); } - private draw(): void { - const termWidth = process.stdout.columns || 80; - const termHeight = process.stdout.rows || 24; - const output = this.render(termWidth, termHeight); - process.stdout.write(CLEAR_SCREEN + MOVE_HOME + output); - } + if (key === 'c' || key === 'C') return dirtyKeys(model).length > 0 ? requestCommit(model, false) : stay(model); + return stay(model); +} - private render(termWidth: number, termHeight: number): string { - const m = ' '.repeat(MARGIN); - const available = Math.max(40, termWidth - MARGIN * 2); - - const lines: string[] = []; - - // Header - const header = `${BOLD}capy edit${RESET}: ${this.state.projectName} ${DIM}(${this.state.branch})${RESET}`; - lines.push(m + header); - lines.push(''); - - // Top status cells — 4 cells, each exactly a quarter of the available width. - // The last cell absorbs the remainder so the row fills exactly. - const topCells = this.buildTopCells(); - const baseCellWidth = Math.floor(available / topCells.length); - const cellLine1 = topCells - .map((c, i) => { - const w = i === topCells.length - 1 ? available - baseCellWidth * (topCells.length - 1) : baseCellWidth; - return this.pad(c.value, w); - }) - .join(''); - const cellLine2 = topCells - .map((c, i) => { - const w = i === topCells.length - 1 ? available - baseCellWidth * (topCells.length - 1) : baseCellWidth; - return DIM + this.pad(c.action, w) + RESET; - }) - .join(''); - lines.push(m + cellLine1); - lines.push(m + cellLine2); - lines.push(''); - - // Build the single-column table. - const tableLines = this.buildTable(available); - - // If the popup is open, splice it in after the cursor row. - // Table layout: [0] "Variables", [1] column header, [2+] data rows. - if (this.popupOpen && this.state.rows[this.cursorIndex]) { - const cursorLineIdx = 2 + this.cursorIndex; - const popupLines = this.buildPopup(available); - tableLines.splice(cursorLineIdx + 1, 0, ...popupLines); - } - - // Reserve space for header lines already pushed + footer (2) + status (1) - const reserved = lines.length + 3; - const bodyHeight = Math.max(6, termHeight - reserved); - - // Scroll: keep cursor row visible. When popup is open, also try to keep - // some of the popup visible by scrolling so the cursor sits near the top. - const cursorLineIdx = 2 + this.cursorIndex; - if (cursorLineIdx < this.scrollOffset) this.scrollOffset = cursorLineIdx; - if (cursorLineIdx >= this.scrollOffset + bodyHeight) { - this.scrollOffset = cursorLineIdx - bodyHeight + 1; - } - if (this.popupOpen) { - // Pin cursor to near the top so the popup (which renders just below) - // has room. If cursor is already higher up, leave it alone. - const targetOffset = Math.max(0, cursorLineIdx - 1); - if (this.scrollOffset < targetOffset && cursorLineIdx >= this.scrollOffset + 3) { - this.scrollOffset = Math.min(targetOffset, Math.max(0, tableLines.length - bodyHeight)); - } - } - if (this.scrollOffset < 0) this.scrollOffset = 0; - - const slice = tableLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight); - for (const line of slice) { - lines.push(m + line); - } - - // Footer - lines.push(''); - if (this.quitPrompt) { - const n = this.pendingEdits.size; - lines.push(`${m}${YELLOW}${n} uncommitted change${n === 1 ? '' : 's'}.${RESET} ${BOLD}c${RESET}${DIM} ${this.state.localMode ? 'commit' : 'commit & push'} · ${RESET}${BOLD}d${RESET}${DIM} discard · ${RESET}${BOLD}k${RESET}${DIM} keep working${RESET}`); - } else if (this.editing) { - const revealHintText = canReveal(this.oldValueView(this.editing), this.editing.buffer) - ? `${BOLD}ctrl+r${RESET}${DIM} ${this.editing.revealed ? 'hide' : 'reveal'} · ${RESET}` - : ''; // COPY-FLAG - lines.push(`${m}${DIM}Type new value · ${RESET}${revealHintText}${BOLD}Enter${RESET}${DIM} save · ${RESET}${BOLD}Esc${RESET}${DIM} cancel${RESET}`); - } else if (this.popupOpen) { - const row = this.state.rows[this.cursorIndex]; - const isRevealed = row ? this.revealed.has(row.key) : false; - const revealLabel = isRevealed ? 'hide' : 'reveal'; - const panHint = isRevealed ? `${DIM} · ${RESET}${BOLD}←/→${RESET}${DIM} pan${RESET}` : ''; - const dirty = this.pendingEdits.size; - const dirtyHint = dirty > 0 - ? `${DIM} · ${RESET}${BOLD}c${RESET}${DIM} ${this.state.localMode ? "commit" : "commit & push"} (${dirty})${RESET}` - : ''; - lines.push(`${m}${BOLD}r${RESET}${DIM} ${revealLabel} · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${panHint}${dirtyHint}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM} close${RESET}`); - } else { - const dirty = this.pendingEdits.size; - const dirtyHint = dirty > 0 - ? `${DIM} · ${RESET}${BOLD}c${RESET}${DIM} ${this.state.localMode ? "commit" : "commit & push"} (${dirty})${RESET}` - : ''; - lines.push(`${m}${DIM}↑↓ navigate · ${RESET}${BOLD}enter${RESET}${DIM} inspect · ${RESET}${BOLD}r${RESET}${DIM} reveal · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${dirtyHint}${DIM} · ${RESET}${BOLD}q${RESET}${DIM} quit${RESET}`); - } - - if (this.statusMessage) { - const color = this.statusMessage.isError ? RED : GREEN; - lines.push(`${m}${color}${this.statusMessage.text}${RESET}`); - } - - return lines.map((l) => l + CLEAR_EOL).join('\n'); - } +function onData(model: EditModel, key: string): EditOutcome { + // Bracketed paste arrives as ESC[200~ ESC[201~, possibly split + // across data chunks. Intercept it before any key dispatch so pasted text + // (which may contain 'q', newlines, etc.) can never trigger quit/commit or + // navigation — its content is only ever appended to an active edit buffer. + if (model.paste !== null || key.includes(PASTE_START)) return stay(consumePaste(model, key)); + // Ctrl-C exits unconditionally + if (key === '\x03') return finished(model); + // A commit in flight is not interrupted by keys; it finishes first. + if (model.saving !== null) return stay(model); + if (model.quitPrompt) return onQuitPromptKey(model, key); + if (model.editing) return stay(onEditKey(model, key)); + return onListKey(model, key); +} - private buildTopCells(): { value: string; action: string }[] { - const total = this.state.rows.length; - const countOf = (statuses: readonly EditRow['status'][]): number => - this.state.rows.filter((r) => statuses.includes(r.status)).length; - const drift = countOf(['local', 'remote']); - const conflicts = countOf(['conflict']); - const unknown = countOf(['unknown']); - - return [ - { value: this.state.branch, action: 'active branch' }, - { value: `${total} tracked`, action: 'shown as abc…xyz snippets' }, - { - value: this.state.localMode - ? `${drift} uncommitted` - : this.state.remoteAvailable ? `${drift} drift` : `${unknown} ?`, - action: this.state.localMode - ? 'changed since commit' - : this.state.remoteAvailable ? 'changed local/remote' : 'remote unavailable', - }, - { - value: `${conflicts} conflicts`, - action: conflicts > 0 ? 'need resolution' : 'all clear', - }, - ]; - } +// ── A save coming back ────────────────────────────────────────────────────── - private buildTable(width: number): string[] { - const lines: string[] = []; - lines.push(`${BOLD}Variables${RESET}`); - // 4 columns: KEY, VALUE, STATUS, UPDATED. UPDATED absorbs slack so the - // row fills the full width. - const keyW = Math.max(12, Math.floor(width * 0.32)); - const valW = 14; - const statusW = 14; - const gap = ' '; - const fixed = keyW + valW + statusW + gap.length * 3; - const updatedW = Math.max(8, width - fixed); - - const headerRow = - this.pad('KEY', keyW) + gap + this.pad('VALUE', valW) + gap + this.pad('STATUS', statusW) + gap + this.pad('UPDATED', updatedW); - lines.push(DIM + headerRow + RESET); - - for (let i = 0; i < this.state.rows.length; i++) { - const row = this.state.rows[i]; - const isSelected = i === this.cursorIndex; - const pointer = isSelected ? '▶' : ' '; - const keyCell = this.pad(`${pointer} ${row.key}`, keyW); - const valCell = this.pad(this.maskedSnippet(row), valW); - const statusCell = this.padVis(this.statusBadge(row.status), statusW); - const updatedCell = this.pad(row.updatedLabel, updatedW); - let line = keyCell + gap + valCell + gap + statusCell + gap + updatedCell; - if (isSelected && !this.editing) { - line = INVERSE + this.padVis(line, width) + RESET; - } - lines.push(line); - } - - return lines; - } +/** `loaded`'s rows, with what this screen holds that the disk and the server cannot know: the typed values. */ +function mergedRows(model: EditModel, loaded: EditLoaded, pending: Readonly>): readonly EditRow[] { + const mode = { localMode: model.state.localMode, remoteAvailable: loaded.remoteAvailable }; + const known = new Set(loaded.rows.map((r) => r.key)); + const typed = (row: EditRow): EditRow => { + const value = Object.hasOwn(pending, row.key) ? pending[row.key] : row.localValue; + return classifiedRow({ ...row, localValue: value }, mode); + }; + // A variable typed here that neither the folder nor the server has yet. + const added = Object.keys(pending) + .filter((key) => !known.has(key)) + .map((key): EditRow => classifiedRow({ key, localValue: pending[key], remoteValue: undefined, status: 'local', updatedLabel: NO_VALUE, pinnedHash: null }, mode)); + return sortedRows([...loaded.rows.map(typed), ...added]); +} - // Inline popup that splices into the table beneath the cursor row. - private buildPopup(width: number): string[] { - const row = this.state.rows[this.cursorIndex]; - if (!row) return []; - - const indent = ' '; // popup is inset 2 cols from the table margin - const inner = ' '; // content inside the popup is indented 3 more cols - const ruleWidth = Math.max(10, width - indent.length); - const rule = `${indent}${DIM}╶${'─'.repeat(ruleWidth - 2)}╴${RESET}`; - - // Width available for value content inside the popup - const labelW = 9; - const contentWidth = Math.max(20, ruleWidth - inner.length - 1); - const valueContentWidth = Math.max(10, contentWidth - labelW); - - const lines: string[] = []; - lines.push(rule); - lines.push(''); - lines.push(`${indent}${inner}${BOLD}${this.truncate(row.key, contentWidth)}${RESET}`); - lines.push(''); - - // Editing this row: the shared Old value / New value rows replace the details. - if (this.editing && this.editing.key === row.key) { - const rows = valueDialogRows({ - old: this.oldValueView(this.editing), - revealed: this.editing.revealed, - buffer: this.editing.buffer, - width: ruleWidth - inner.length, - }); - return [...lines, ...rows.map((r) => `${indent}${inner}${r}`), '', rule]; - } - - const fieldVal = (label: string, value: string) => - `${indent}${inner}${DIM}${this.pad(label, labelW)}${RESET}${value}`; - - lines.push(fieldVal('status', this.statusBadge(row.status))); - lines.push(fieldVal('updated', row.updatedLabel)); - lines.push(''); - - // Value field — always rendered on a single line so mouse-select never - // captures a wrap-induced newline. - const valueLine = this.renderValueField(row, valueContentWidth); - lines.push(fieldVal('value', valueLine)); - - lines.push(''); - lines.push(rule); - - return lines; - } +/** The model showing `loaded`, the cursor kept on the same variable. */ +function withLoaded(model: EditModel, loaded: EditLoaded, pending: Readonly>): EditModel { + const rows = mergedRows(model, loaded, pending); + const cursorKey = model.state.rows[model.cursorIndex]?.key; + const at = rows.findIndex((r) => r.key === cursorKey); + return { + ...model, + state: { ...model.state, rows, remoteAvailable: loaded.remoteAvailable, seen: loaded.seen }, + cursorIndex: at === -1 ? Math.min(model.cursorIndex, Math.max(0, rows.length - 1)) : at, + }; +} - /** What the Old value row says: nothing for a new variable, the current value, or why there is none. */ - private oldValueView(editing: { oldValue: string | undefined; isNew: boolean }): OldValueView { - if (editing.isNew) return { kind: 'none' }; - return editing.oldValue === undefined - ? { kind: 'unavailable', code: ERROR_CODES.VARIABLE_NOT_FOUND } - : { kind: 'value', value: editing.oldValue }; - } +/** No re-read was possible: the rows that were committed now agree with what was written, as `markCommitted` of old. */ +function committedLocally(model: EditModel, keys: readonly string[], changedAt: Readonly>): EditModel { + const committed = new Set(keys); + return withRows( + model, + model.state.rows.map((row) => { + if (!committed.has(row.key) || row.localValue === undefined) return row; + const agreed: EditRow = { + ...row, + changedAt: changedAt[row.key] ?? row.changedAt, + remoteValue: row.localValue, + remoteHash: undefined, + pinnedHash: hashValue(row.localValue), + }; + return classifiedRow(agreed, modeOf(model)); + }), + ); +} - // Renders the value field as a single line of at most `width` visible chars. - // Handles the masked state and the revealed state with horizontal panning - // when the value overflows. (Editing has its own rows: see `buildPopup`.) - private renderValueField(row: EditRow, width: number): string { - const isRevealed = this.revealed.has(row.key); - if (!isRevealed) { - return this.maskedSnippet(row); - } - - const rawValue = row.localValue ?? row.remoteValue; - if (rawValue === undefined) return `${DIM}(no value)${RESET}`; - if (rawValue === '') return `${DIM}(empty)${RESET}`; - // Collapse newlines/tabs so a revealed multi-line value (e.g. a PEM key) - // stays on one row. renderInlineValue preserves length 1:1, so the panning - // math below remains correct. - const value = renderInlineValue(rawValue); - - if (value.length <= width) { - this.popupPanOffset = 0; - return value; - } - - // Need to pan. Reserve 2 cols on each side for indicators (or padding). - const visibleWidth = Math.max(1, width - 4); - const maxOffset = Math.max(0, value.length - visibleWidth); - if (this.popupPanOffset > maxOffset) this.popupPanOffset = maxOffset; - if (this.popupPanOffset < 0) this.popupPanOffset = 0; - - const leftIndicator = this.popupPanOffset > 0 ? `${DIM}◂${RESET} ` : ' '; - const rightIndicator = this.popupPanOffset < maxOffset ? ` ${DIM}▸${RESET}` : ' '; - const slice = value.slice(this.popupPanOffset, this.popupPanOffset + visibleWidth); - - return leftIndicator + slice + rightIndicator; +function onSaved(model: EditModel, result: SaveResult): EditOutcome { + const saving = model.saving; + const settled: EditModel = { ...model, saving: null }; + if (result.kind === 'saved') { + if (saving?.quit === true) return finished({ ...settled, pending: {} }); + const keys = saving?.keys ?? []; + const refreshed = + result.loaded === undefined + ? committedLocally({ ...settled, pending: {} }, keys, result.changedAt) + : withLoaded({ ...settled, pending: {} }, result.loaded, {}); + return stay({ ...refreshed, status: { text: `Committed ${keys.length} change(s)`, isError: false } }); } + // Capy moved since the screen read it. Nothing was uploaded; the screen closes (its edits kept) so bare `capy` can show what changed. + if (result.kind === 'stale') return { model: settled, done: true, stale: true }; + return stay({ ...settled, status: { text: `Error committing: ${result.message}`, isError: true } }); +} - private statusBadge(status: EditRow['status']): string { - return statusBadge(status); - } +/** One action applied to the screen. */ +export function reduce(model: EditModel, action: EditAction): EditOutcome { + return action.kind === 'data' ? onData(model, action.data) : onSaved(model, action.result); +} + +// ── Rendering ─────────────────────────────────────────────────────────────── - // --- helpers --- +const visLen = (str: string): number => str.replace(/\x1b\[[0-9;]*m/g, '').length; - private pad(str: string, width: number): string { - const v = this.visLen(str); - if (v >= width) return this.truncate(str, width); - return str + ' '.repeat(width - v); +/** Naive truncate that ignores ANSI for length calculation. If the visible length already fits, return as-is; otherwise strip codes and clip — we accept that color may be lost at the truncation point. */ +function truncate(str: string, maxLen: number): string { + if (visLen(str) <= maxLen) return str; + const stripped = str.replace(/\x1b\[[0-9;]*m/g, ''); + return stripped.slice(0, Math.max(0, maxLen - 1)) + '…'; +} + +function pad(str: string, width: number): string { + const v = visLen(str); + return v >= width ? truncate(str, width) : str + ' '.repeat(width - v); +} + +const padVis = (str: string, width: number): string => { + const v = visLen(str); + return v >= width ? str : str + ' '.repeat(width - v); +}; + +function maskedSnippet(row: EditRow): string { + const value = row.localValue ?? row.remoteValue; + if (value === undefined || value === '') return NO_VALUE; + return renderInlineValue(formatSnippet(value)); +} + +/** The status cell: the coloured badge. */ +const rowBadge = (row: EditRow): string => statusBadge(row.status, statusLabel(row.status)); + +function buildTopCells(model: EditModel): { value: string; action: string }[] { + const { rows, localMode, remoteAvailable, branch } = model.state; + const countOf = (statuses: readonly EditRow['status'][]): number => rows.filter((r) => statuses.includes(r.status)).length; + const drift = countOf(['local', 'remote']); + const conflicts = countOf(['conflict']); + const unknown = countOf(['unknown']); + + return [ + { value: branch, action: 'active branch' }, + { value: `${rows.length} tracked`, action: 'shown as abc…xyz snippets' }, + { + value: localMode ? `${drift} uncommitted` : remoteAvailable ? `${drift} drift` : `${unknown} ?`, + action: localMode ? 'changed since commit' : remoteAvailable ? 'changed local/remote' : 'remote unavailable', + }, + { + value: `${conflicts} conflicts`, + action: conflicts > 0 ? 'need resolution' : 'all clear', + }, + ]; +} + +function buildTable(model: EditModel, width: number): string[] { + // 4 columns: KEY, VALUE, STATUS, UPDATED. UPDATED absorbs slack so the + // row fills the full width. + const keyW = Math.max(12, Math.floor(width * 0.32)); + const valW = 14; + const statusW = 18; + const gap = ' '; + const fixed = keyW + valW + statusW + gap.length * 3; + const updatedW = Math.max(8, width - fixed); + + const headerRow = pad('KEY', keyW) + gap + pad('VALUE', valW) + gap + pad('STATUS', statusW) + gap + pad('UPDATED', updatedW); + const rowLines = model.state.rows.map((row, i) => { + const isSelected = i === model.cursorIndex; + const pointer = isSelected ? '▶' : ' '; + const line = pad(`${pointer} ${row.key}`, keyW) + gap + pad(maskedSnippet(row), valW) + gap + padVis(rowBadge(row), statusW) + gap + pad(row.updatedLabel, updatedW); + return isSelected && !model.editing ? INVERSE + padVis(line, width) + RESET : line; + }); + return [`${BOLD}Variables${RESET}`, DIM + headerRow + RESET, ...rowLines]; +} + +/** The value as one line of at most `width` visible chars, masked or (revealed) panned. Returns the pan actually used. */ +function valueField(raw: string | undefined, revealed: boolean, width: number, pan: number, fallback: string): { text: string; pan: number } { + if (!revealed) return { text: fallback, pan }; + if (raw === undefined) return { text: `${DIM}(no value)${RESET}`, pan }; + if (raw === '') return { text: `${DIM}(empty)${RESET}`, pan }; + // Collapse newlines/tabs so a revealed multi-line value (e.g. a PEM key) + // stays on one row. renderInlineValue preserves length 1:1, so the panning + // math below remains correct. + const value = renderInlineValue(raw); + if (value.length <= width) return { text: value, pan: 0 }; + + // Need to pan. Reserve 2 cols on each side for indicators (or padding). + const visibleWidth = Math.max(1, width - 4); + const maxOffset = Math.max(0, value.length - visibleWidth); + const offset = Math.min(Math.max(pan, 0), maxOffset); + const leftIndicator = offset > 0 ? `${DIM}◂${RESET} ` : ' '; + const rightIndicator = offset < maxOffset ? ` ${DIM}▸${RESET}` : ' '; + return { text: leftIndicator + value.slice(offset, offset + visibleWidth) + rightIndicator, pan: offset }; +} + +/** Inline popup that splices into the table beneath the cursor row. Returns the pan offset it ended up using. */ +function buildPopup(model: EditModel, width: number): { lines: string[]; pan: number } { + const row = model.state.rows[model.cursorIndex]; + if (!row) return { lines: [], pan: model.popupPan }; + + const indent = ' '; // popup is inset 2 cols from the table margin + const inner = ' '; // content inside the popup is indented 3 more cols + const ruleWidth = Math.max(10, width - indent.length); + const rule = `${indent}${DIM}╶${'─'.repeat(ruleWidth - 2)}╴${RESET}`; + + // Width available for value content inside the popup + const labelW = 9; + const contentWidth = Math.max(20, ruleWidth - inner.length - 1); + const valueContentWidth = Math.max(10, contentWidth - labelW); + + const head = [rule, '', `${indent}${inner}${BOLD}${truncate(row.key, contentWidth)}${RESET}`, '']; + + // Editing this row: the shared Old value / New value rows replace the details. + if (model.editing && model.editing.key === row.key) { + const rows = valueDialogRows({ + old: oldValueView(model.editing), + revealed: model.editing.revealed, + buffer: model.editing.buffer, + width: ruleWidth - inner.length, + }); + return { lines: [...head, ...rows.map((r) => `${indent}${inner}${r}`), '', rule], pan: model.popupPan }; } - private padVis(str: string, width: number): string { - const v = this.visLen(str); - if (v >= width) return str; - return str + ' '.repeat(width - v); + const fieldVal = (label: string, value: string): string => `${indent}${inner}${DIM}${pad(label, labelW)}${RESET}${value}`; + const revealed = model.revealed.has(row.key); + // Value field — always rendered on a single line so mouse-select never captures a wrap-induced newline. + const value = valueField(row.localValue ?? row.remoteValue, revealed, valueContentWidth, model.popupPan, maskedSnippet(row)); + + return { + lines: [...head, fieldVal('status', rowBadge(row)), fieldVal('updated', row.updatedLabel), '', fieldVal('value', value.text), '', rule], + pan: value.pan, + }; +} + +function scrollFor(previous: number, cursorLine: number, bodyHeight: number, popupOpen: boolean, tableLength: number): number { + const followed = cursorLine < previous ? cursorLine : cursorLine >= previous + bodyHeight ? cursorLine - bodyHeight + 1 : previous; + if (!popupOpen) return Math.max(0, followed); + // Pin cursor to near the top so the popup (which renders just below) + // has room. If cursor is already higher up, leave it alone. + const target = Math.max(0, cursorLine - 1); + const popped = followed < target && cursorLine >= followed + 3 ? Math.min(target, Math.max(0, tableLength - bodyHeight)) : followed; + return Math.max(0, popped); +} + +const hint = (...pairs: ReadonlyArray): string => + pairs.map(([key, what]) => `${BOLD}${key}${RESET}${DIM} ${what}${RESET}`).join(`${DIM} · ${RESET}`); + +function footerOf(model: EditModel): string { + const m = ' '.repeat(MARGIN); + const dirty = dirtyKeys(model).length; + const commitLabel = model.state.localMode ? 'commit' : 'commit & push'; + const dirtyHint = dirty > 0 ? `${DIM} · ${RESET}${BOLD}c${RESET}${DIM} ${commitLabel} (${dirty})${RESET}` : ''; + + if (model.quitPrompt) { + const n = dirty; + return `${m}${YELLOW}${n} uncommitted change${n === 1 ? '' : 's'}.${RESET} ${BOLD}c${RESET}${DIM} ${commitLabel} · ${RESET}${BOLD}d${RESET}${DIM} discard · ${RESET}${BOLD}k${RESET}${DIM} keep working${RESET}`; } + if (model.editing) { + const revealHintText = canReveal(oldValueView(model.editing), model.editing.buffer) + ? `${BOLD}ctrl+r${RESET}${DIM} ${model.editing.revealed ? 'hide' : 'reveal'} · ${RESET}` + : ''; // COPY-FLAG + return `${m}${DIM}Type new value · ${RESET}${revealHintText}${BOLD}Enter${RESET}${DIM} save · ${RESET}${BOLD}Esc${RESET}${DIM} cancel${RESET}`; + } + if (model.popupOpen) { + const row = model.state.rows[model.cursorIndex]; + const isRevealed = row ? model.revealed.has(row.key) : false; + const panHint = isRevealed ? `${DIM} · ${RESET}${BOLD}←/→${RESET}${DIM} pan${RESET}` : ''; + return `${m}${BOLD}r${RESET}${DIM} ${isRevealed ? 'hide' : 'reveal'} · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${panHint}${dirtyHint}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM} close${RESET}`; + } + return `${m}${DIM}↑↓ navigate · ${RESET}${BOLD}enter${RESET}${DIM} inspect · ${RESET}${BOLD}r${RESET}${DIM} reveal · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${dirtyHint}${DIM} · ${RESET}${BOLD}q${RESET}${DIM} quit${RESET}`; +} + +/** The frame for `termWidth` x `termHeight`, and the model with the scroll and pan this frame settled on. */ +export function render(model: EditModel, termWidth: number, termHeight: number): { readonly text: string; readonly model: EditModel } { + const m = ' '.repeat(MARGIN); + const available = Math.max(40, termWidth - MARGIN * 2); + + const header = `${BOLD}capy edit${RESET}: ${model.state.projectName} ${DIM}(${model.state.branch})${RESET}`; + + // Top status cells — 4 cells, each exactly a quarter of the available width. + // The last cell absorbs the remainder so the row fills exactly. + const topCells = buildTopCells(model); + const baseCellWidth = Math.floor(available / topCells.length); + const cellWidth = (i: number): number => (i === topCells.length - 1 ? available - baseCellWidth * (topCells.length - 1) : baseCellWidth); + const cellLine1 = topCells.map((c, i) => pad(c.value, cellWidth(i))).join(''); + const cellLine2 = topCells.map((c, i) => DIM + pad(c.action, cellWidth(i)) + RESET).join(''); + const headLines = [m + header, '', m + cellLine1, m + cellLine2, '']; + + // Build the single-column table. If the popup is open, splice it in after the cursor row. + // Table layout: [0] "Variables", [1] column header, [2+] data rows. + const table = buildTable(model, available); + const cursorLine = 2 + model.cursorIndex; + const popup = model.popupOpen && model.state.rows[model.cursorIndex] ? buildPopup(model, available) : undefined; + const tableLines = popup === undefined ? table : [...table.slice(0, cursorLine + 1), ...popup.lines, ...table.slice(cursorLine + 1)]; + + // Reserve space for header lines already pushed + footer (2) + status (1) + const bodyHeight = Math.max(6, termHeight - (headLines.length + 3)); + const scrollOffset = scrollFor(model.scrollOffset, cursorLine, bodyHeight, model.popupOpen, tableLines.length); + + const body = tableLines.slice(scrollOffset, scrollOffset + bodyHeight).map((l) => m + l); + const status = model.status ? [`${m}${model.status.isError ? RED : GREEN}${model.status.text}${RESET}`] : []; + const lines = [...headLines, ...body, '', footerOf(model), ...status]; + + return { + text: lines.map((l) => l + CLEAR_EOL).join('\n'), + model: { ...model, scrollOffset, popupPan: popup === undefined ? model.popupPan : popup.pan }, + }; +} + +// ── The shell ─────────────────────────────────────────────────────────────── - private truncate(str: string, maxLen: number): string { - // Naive truncate that ignores ANSI for length calculation. If the visible - // length already fits, return as-is; otherwise strip codes and clip — we - // accept that color may be lost at the truncation point. - if (this.visLen(str) <= maxLen) return str; - const stripped = str.replace(/\x1b\[[0-9;]*m/g, ''); - return stripped.slice(0, Math.max(0, maxLen - 1)) + '…'; +const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +/** `reload`, or `undefined` when there is none or it fails (the save already happened; the rows are then updated from it). */ +async function reloaded(ctx: EditContext): Promise { + if (ctx.reload === undefined) return undefined; + try { + return await ctx.reload(); + } catch { + return undefined; } +} - private visLen(str: string): number { - return str.replace(/\x1b\[[0-9;]*m/g, '').length; +/** + * Commits `request` through the context. Never rejects: Capy having moved since the screen read it is `stale` + * (decided by the error's code); anything else is `failed`. + */ +export async function saveOutcome(ctx: EditContext, request: EditCommitRequest): Promise { + try { + const changedAt = await ctx.saveLocalEdits({ ...request.set }, request.seen); + return { kind: 'saved', changedAt, loaded: await reloaded(ctx) }; + } catch (err) { + return isStaleBase(err) ? { kind: 'stale' } : { kind: 'failed', message: messageOf(err) }; } +} + +function drawn(model: EditModel): EditModel { + const frame = render(model, process.stdout.columns || 80, process.stdout.rows || 24); + process.stdout.write(CLEAR_SCREEN + MOVE_HOME + frame.text); + return frame.model; +} + +export class EditScreen { + run(state: EditState, ctx: EditContext): Promise { + return new Promise((resolve) => { + // Every input (a key chunk, a resize, a save landing) is one action on one bus. The listener that is + // attached holds the CURRENT model; handling an action detaches it and attaches one for the next model, + // so no model is ever reassigned or modified. + const bus = new EventEmitter(); + + const onData = (data: Buffer): void => { + bus.emit('action', { kind: 'data', data: data.toString() } satisfies EditAction); + }; + const onResize = (): void => { + bus.emit('redraw'); + }; + const onSignal = (): void => { + bus.emit('action', { kind: 'data', data: '\x03' } satisfies EditAction); + }; + + const leave = (exit: EditExit): void => { + process.stdout.write(DISABLE_BRACKETED_PASTE + SHOW_CURSOR + EXIT_ALT_SCREEN); + if (process.stdin.isTTY) process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeListener('data', onData); + process.removeListener('SIGWINCH', onResize); + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + bus.removeAllListeners(); + resolve(exit); + }; + + const perform = (effect: EditEffect): void => { + void saveOutcome(ctx, effect.request).then((result) => bus.emit('action', { kind: 'saved', result } satisfies EditAction)); + }; + + const attach = (model: EditModel): void => { + const onAction = (action: EditAction): void => { + bus.removeListener('action', onAction); + bus.removeListener('redraw', onRedraw); + const outcome = reduce(model, action); + if (outcome.done === true) return leave(outcome.stale === true ? { kind: 'stale', pending: outcome.model.pending } : { kind: 'done' }); + attach(drawn(outcome.model)); + if (outcome.effect !== undefined) perform(outcome.effect); + }; + const onRedraw = (): void => { + bus.removeListener('action', onAction); + bus.removeListener('redraw', onRedraw); + attach(drawn(model)); + }; + bus.on('action', onAction); + bus.on('redraw', onRedraw); + }; - private maskedSnippet(row: EditRow): string { - const value = row.localValue ?? row.remoteValue; - if (value === undefined || value === '') return NO_VALUE; - return renderInlineValue(formatSnippet(value)); + process.stdout.write(ENTER_ALT_SCREEN + HIDE_CURSOR + ENABLE_BRACKETED_PASTE); + if (process.stdin.isTTY) process.stdin.setRawMode(true); + process.stdin.resume(); + + attach(drawn(initialModel(state))); + + process.stdin.on('data', onData); + process.on('SIGWINCH', onResize); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + }); } } diff --git a/src/ui/secretsDeployFlow.ts b/src/ui/secretsDeployFlow.ts index b04d80b0..14853b8d 100644 --- a/src/ui/secretsDeployFlow.ts +++ b/src/ui/secretsDeployFlow.ts @@ -8,7 +8,7 @@ // 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 +// Steps: planning -> plan -> running -> (conflicts -> 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, @@ -18,12 +18,20 @@ // 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. +// running Ctrl+C / Esc stop: no further target is started, every one in flight finishes. Before +// anything is delivered every Dokploy target is read and compared with what Capy last +// delivered; a variable someone changed on the service outside Capy comes back as a +// row and the flow goes to `conflicts` (nothing has been written). +// conflicts one line per variable and target. l delivers Capy's value (local), r leaves the +// service's (remote: that variable is not delivered or recorded); Enter runs once every +// one is answered, Esc cancels with nothing written. An answer holds only for the live +// value it was made against: if the service changed again, the run comes back here. // 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'; +import { resolveKeyOf, seenResolutions, type SeenResolutions, type TargetConflict, type TargetResolution } from '../sync/conflicts'; import { DRY_RUN_DEPLOY_NOTE, PLAN_HEADINGS, @@ -74,6 +82,17 @@ export type DeployFlow = 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 }; + /** The decisions this run was started with, if it is the one that follows `conflicts`. */ + readonly decisions?: SeenResolutions; + } + | { + readonly step: 'conflicts'; + readonly row: SecretIndexRow; + readonly plan: BatchPlan; + readonly conflicts: readonly TargetConflict[]; + /** The answer for each row, by `NAME@TARGET`. */ + readonly choices: Readonly>; + readonly cursor: number; } | { readonly step: 'done'; @@ -90,7 +109,7 @@ 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 }; + | { readonly type: 'runDeploy'; readonly plan: BatchPlan; readonly decisions?: SeenResolutions }; export interface DeployStep { /** `null`: the flow is over and the list is back. */ @@ -161,6 +180,35 @@ function stepRunning(flow: Extract, key: string return { flow: { ...flow, stop: { phase, count } }, effect: { type: 'cancelDeploy', phase } }; } +// ── Conflicts: variables someone changed on the service outside Capy ─────── + +const choiceAt = (flow: Extract, c: TargetConflict): TargetResolution | undefined => flow.choices[resolveKeyOf(c)]; + +/** Whether every row has been answered. */ +export const allDecided = (flow: Extract): boolean => flow.conflicts.every((c) => choiceAt(flow, c) !== undefined); + +/** The answers given, each bound to the fingerprints it was made against (`seenResolutions`). */ +const decisionsOf = (flow: Extract): SeenResolutions => + seenResolutions( + Object.fromEntries(flow.conflicts.flatMap((c) => (choiceAt(flow, c) === undefined ? [] : [[resolveKeyOf(c), choiceAt(flow, c) as TargetResolution] as const]))), + flow.conflicts, + ); + +function stepConflicts(flow: Extract, key: string): DeployStep { + if (isEsc(key)) return { flow: null, effect: null, note: CANCELLED_NOTHING }; + if (key === `${ESC}[A`) return stay({ ...flow, cursor: Math.max(0, flow.cursor - 1) }); + if (key === `${ESC}[B`) return stay({ ...flow, cursor: Math.min(flow.conflicts.length - 1, flow.cursor + 1) }); + const decide = (choice: TargetResolution): DeployStep => { + const at = flow.conflicts[flow.cursor]; + return at === undefined ? stay(flow) : stay({ ...flow, choices: { ...flow.choices, [resolveKeyOf(at)]: choice } }); + }; + if (key === 'l' || key === 'L') return decide('local'); + if (key === 'r' || key === 'R') return decide('remote'); + if (key !== '\r' || !allDecided(flow)) return stay(flow); + const decisions = decisionsOf(flow); + return { flow: { step: 'running', row: flow.row, plan: flow.plan, decisions }, effect: { type: 'runDeploy', plan: flow.plan, decisions } }; +} + /** One key token applied to the flow. */ export function stepDeploy(flow: DeployFlow, key: string, dryRun: boolean = false): DeployStep { if (flow.step === 'planning') { @@ -168,6 +216,7 @@ 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); + if (flow.step === 'conflicts') return stepConflicts(flow, key); return stepDone(flow, key); } @@ -231,11 +280,22 @@ export const prUrlsOf = (result: BatchResult): readonly string[] => export type DeployFinished = | { readonly ok: true; readonly result: BatchResult } - | { readonly ok: false; readonly code: string }; + /** `conflicts`: nothing was delivered, because these variables were changed on the service outside Capy. */ + | { readonly ok: false; readonly code: string; readonly conflicts?: readonly TargetConflict[] }; /** 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; + if (!finished.ok && finished.conflicts !== undefined && finished.conflicts.length > 0) { + // An answer given earlier stays only where the live value it was made against is still the one on the service. + const choices = Object.fromEntries( + finished.conflicts.flatMap((c) => { + const earlier = flow.decisions?.[resolveKeyOf(c)]; + return earlier !== undefined && earlier.remote === c.remote && earlier.local === c.local ? [[resolveKeyOf(c), earlier.choice] as const] : []; + }), + ); + return { step: 'conflicts', row: flow.row, plan: flow.plan, conflicts: finished.conflicts, choices, cursor: 0 }; + } return { step: 'done', text: finished.ok ? renderBatchResult(finished.result, TERMINAL_STYLE) : `✗ ${flow.row.name} was not deployed. (${finished.code})`, // COPY-FLAG @@ -259,6 +319,7 @@ export function deployRunningLine(flow: Extract 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 === 'checking') return 'Checking targets…'; // COPY-FLAG if (p.phase === 'recording') return 'Recording deliveries…'; // COPY-FLAG return `Opening pull requests… ${p.done} of ${p.total}`; // COPY-FLAG } @@ -315,6 +376,21 @@ function renderPlan( }; } +/** The conflicts step: which variable on which target, and the decision so far. Names only, never a value. */ +function renderConflicts(flow: Extract, width: number): { readonly lines: readonly string[]; readonly footer: string } { + const maxWidth = Math.max(20, width - 3); + const n = flow.conflicts.length; + const row = (c: TargetConflict, i: number): string => { + const choice = choiceAt(flow, c); + const line = `${i === flow.cursor ? '▶' : ' '} ${c.target} · ${c.name} · changed outside Capy ${choice === undefined ? '—' : choice}`; // COPY-FLAG + return i === flow.cursor ? `${BOLD}${clipLine(line, maxWidth)}${RESET}` : clipLine(line, maxWidth); + }; + return { + lines: [`${BOLD}${flow.row.name}${RESET} ${DIM}· changed outside Capy in ${n} place${n === 1 ? '' : 's'}${RESET}`, '', ...flow.conflicts.map(row)], // COPY-FLAG + footer: hint(['l', 'local'], ['r', 'remote'], ...(allDecided(flow) ? ([['enter', 'continue']] 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, @@ -325,6 +401,7 @@ export function renderDeploy( 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 === 'conflicts') return renderConflicts(flow, width); if (flow.step === 'running') { return { lines: [`${DIM}${deployRunningLine(flow)}${RESET}`], footer: flow.stop === undefined ? hint(['ctrl+c', 'stop']) : '' }; // COPY-FLAG } diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index 23933249..a68a7ccf 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -9,14 +9,23 @@ // ever contains it (the dialog shows a mask), and no effect other than `runSet` // carries it. // -// Steps: value -> locations -> (loading) -> repos -> running -> done +// Steps: value -> locations -> (loading) -> repos -> running -> (conflicts -> running) -> done // value Old value (Ctrl+R reveals it, on screen only) and a hidden New value // input. Enter next, Esc cancels. Layout shared with capy edit (valueDialog.ts). // locations the row's project/branch locations, ALL selected (protected too, // each labelled `protected`). Same keys as `capy deploy`'s picker: // 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. +// running keys are ignored: a push in flight is never abandoned halfway. Before any push, every +// location is read; one whose value someone else changed since the row was listed comes +// back as a row (`compareSecrets`: pinned = the listed value, local = the new one, +// remote = what is there now) and the flow goes to `conflicts` (nothing has been written). +// conflicts one line per such location, answered with bare `capy`'s four choices: l writes the new value +// there (local), r leaves the location (remote), p writes back the value you were shown +// (pinned; only when that old value could be read, it is on screen as the Old value), d removes +// the variable there (delete). Enter runs once every one is answered, Esc cancels with nothing +// written. An answer holds only for the value it was made against: if the location moved again, +// the run comes back here with it. // 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. @@ -27,7 +36,10 @@ import type { ValueState } from './secretsScreen'; import { sanitizePastedText } from './editScreen'; 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 { LocationConflict, RepoTarget, RunProgress, SecretSetResult, SetLocation, locationKeyOf, planRepos, repoKey, repoLabel } from '../commands/secretsSet'; +import { bindAnswer, holdsFor, type BoundAnswer } from '../sync/noFolder'; +import type { Resolution } from '../sync/conflicts'; +import { formatRelativeTime } from './relativeTime'; 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'; @@ -106,6 +118,16 @@ export type EditFlow = readonly progress?: RunProgress; /** A stop was asked for in this phase, this many times (Ctrl+C / Esc). */ readonly stop?: { readonly phase: RunProgress['phase']; readonly count: number }; + /** What the run was asked to do: asked again, with decisions, if it comes back with conflicts. */ + readonly request: RunRequest; + } & Carried) + | ({ + readonly step: 'conflicts'; + readonly request: RunRequest; + readonly conflicts: readonly LocationConflict[]; + /** The answer at each moved location, by `locationKeyOf`: `local` or `remote`. */ + readonly choices: Readonly>; + readonly cursor: number; } & Carried) | { readonly step: 'done'; @@ -125,6 +147,15 @@ export interface RunRequest { readonly reposUnavailable?: string; /** The PR base of each repo, already read for the repo step; absent: read again. */ readonly bases?: Readonly>; + /** + * The fingerprint the row listed for its locations: what the person saw. A location holding anything else (and not + * the new value) was changed by someone else since (CAP-714). Absent: locations are not compared. + */ + readonly seen?: string; + /** The answers for moved locations, by `locationKeyOf`, with the fingerprints they were made against. */ + readonly decisions?: Readonly>; + /** The old value (what the person was shown) for each location answered `pinned`, by `locationKeyOf`: written back there. */ + readonly restore?: Readonly>; } export type EditEffect = @@ -312,13 +343,14 @@ function stepRepos(flow: Extract, key: string): Edi value: flow.value, locations: flow.chosen, repos: flow.box.checked.map((i) => flow.targets[i]), + seen: flow.rowHash, ...(flow.unavailable === undefined ? {} : { reposUnavailable: flow.unavailable }), }; const running = { step: 'running', name: flow.name, value: flow.value, locations: flow.locations, count: flow.chosen.length, ...oldOf(flow) } as const; // Enter while the bases are still loading is fine: the run waits for that same read, it does not start another. return flow.bases === undefined - ? { flow: { ...running, pending: request }, effect: null } - : { flow: running, effect: { type: 'runSet', request: { ...request, bases: flow.bases } } }; + ? { flow: { ...running, request, pending: request }, effect: null } + : { flow: { ...running, request: { ...request, bases: flow.bases } }, effect: { type: 'runSet', request: { ...request, bases: flow.bases } } }; } /** The effect that reads the BASE column, once the repo table is up and its bases are not known yet. */ @@ -338,7 +370,7 @@ export function applyBasesLoaded(flow: EditFlow | null, bases: Readonly, c: LocationConflict): Resolution | undefined => flow.choices[locationKeyOf(c.location)]; + +/** Whether every conflicting location has been decided. */ +export const allDecided = (flow: Extract): boolean => flow.conflicts.every((c) => choiceAt(flow, c) !== undefined); + +/** The run again, with each answer bound to the fingerprints it was made against. */ +function withDecisions(flow: Extract): RunRequest { + const oldValue = flow.old.status === 'ok' ? flow.old.value : undefined; + return { + ...flow.request, + decisions: Object.fromEntries( + flow.conflicts.flatMap((c) => { + const choice = choiceAt(flow, c); + return choice === undefined ? [] : [[locationKeyOf(c.location), bindAnswer(choice, c.conflict)] as const]; + }), + ), + // `pinned` writes back the old value (the one that was shown); `p` is only offered when it could be read. + restore: + oldValue === undefined + ? undefined + : Object.fromEntries(flow.conflicts.filter((c) => choiceAt(flow, c) === 'pinned').map((c) => [locationKeyOf(c.location), oldValue] as const)), + }; +} + +function stepConflicts(flow: Extract, key: string): EditStep { + if (isEsc(key)) return { flow: null, effect: null, note: CANCELLED_NOTHING }; + if (key === KEY_UP) return stay({ ...flow, cursor: Math.max(0, flow.cursor - 1) }); + if (key === KEY_DOWN) return stay({ ...flow, cursor: Math.min(flow.conflicts.length - 1, flow.cursor + 1) }); + const decide = (choice: Resolution): EditStep => { + const at = flow.conflicts[flow.cursor]; + return at === undefined ? stay(flow) : stay({ ...flow, choices: { ...flow.choices, [locationKeyOf(at.location)]: choice } }); + }; + if (key === 'l' || key === 'L') return decide('local'); + if (key === 'r' || key === 'R') return decide('remote'); + if (key === 'd' || key === 'D') return decide('delete'); + // Pinned writes back the value the person was shown: unavailable (a key that does nothing) when it could not be read. + if ((key === 'p' || key === 'P') && flow.old.status === 'ok') return decide('pinned'); + if (key !== '\r' || !allDecided(flow)) return stay(flow); + const request = withDecisions(flow); + const kept = flow.conflicts.filter((c) => choiceAt(flow, c) === 'remote').length; + return { + flow: { + step: 'running', + name: flow.name, + value: flow.value, + locations: flow.locations, + count: flow.request.locations.length - kept, + request, + ...oldOf(flow), + }, + effect: { type: 'runSet', request }, + }; +} + /** One key token applied to the flow. */ export function stepEdit(flow: EditFlow, key: string): EditStep { if (flow.step === 'value') return stepValue(flow, key); @@ -374,6 +462,7 @@ 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); + if (flow.step === 'conflicts') return stepConflicts(flow, key); return stepDone(flow, key); } @@ -416,7 +505,8 @@ export type RunFinished = | { readonly ok: true; readonly result: SecretSetResult } /** A dry run: what would happen. Nothing was changed. */ | { readonly ok: true; readonly plan: DryRunView } - | { readonly ok: false; readonly code: string }; + /** `conflicts`: nothing was written, because these locations were changed by someone else since the row was listed (CAP-714). */ + | { readonly ok: false; readonly code: string; readonly conflicts?: readonly LocationConflict[] }; /** 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[] => @@ -427,10 +517,37 @@ function finishedText(name: string, finished: RunFinished): string { return 'plan' in finished ? renderDryRunPlan(finished.plan) : renderSecretSetConfirmation(finished.result, TERMINAL_STYLE); } +/** Locations where the person left the other value (remote): said under the result, since nothing else mentions them. */ +const keptNote = (kept: number): string => (kept === 0 ? '' : `\nKept the other value in ${kept} location${kept === 1 ? '' : 's'}.`); // COPY-FLAG + +/** The conflicts screen for what a run found. An answer made earlier stays only where the value it was made against is still the one there. */ +function conflictsStep(flow: Extract, conflicts: readonly LocationConflict[]): EditFlow { + const choices = Object.fromEntries( + conflicts.flatMap((c) => { + const earlier = flow.request.decisions?.[locationKeyOf(c.location)]; + return holdsFor(earlier, c.conflict) ? [[locationKeyOf(c.location), earlier.choice] as const] : []; + }), + ); + return { + step: 'conflicts', + name: flow.name, + value: flow.value, + locations: flow.locations, + request: flow.request, + conflicts, + choices, + cursor: 0, + ...oldOf(flow), + }; +} + /** 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), prUrls: prUrlsOf(finished) }; + if (!finished.ok && finished.conflicts !== undefined && finished.conflicts.length > 0) return conflictsStep(flow, finished.conflicts); + const kept = Object.values(flow.request.decisions ?? {}).filter((d) => d.choice === 'remote').length; + const text = finishedText(flow.name, finished); + return { step: 'done', text: finished.ok && 'result' in finished ? text + keptNote(kept) : text, prUrls: prUrlsOf(finished) }; } /** The copy the `copyToClipboard` effect started finished. Dropped unless the flow is still on the result. */ @@ -477,6 +594,25 @@ function pickerHint(searching: boolean, next: string): string { : hint(['space', 'select'], ['a', 'toggle all'], ['i', 'invert'], ['/', 'filter'], ['enter', next], ['esc', 'back']); } +/** The conflicts step: what changed where, and the decision so far at each location. Names and places only, never a value. */ +function renderConflicts(flow: Extract, maxWidth: number): { readonly lines: readonly string[]; readonly footer: string } { + const n = flow.conflicts.length; + const row = (c: LocationConflict, i: number): string => { + const choice = choiceAt(flow, c); + const when = c.changedAt === undefined ? '' : ` · changed ${formatRelativeTime(c.changedAt)}`; // COPY-FLAG + const line = `${i === flow.cursor ? '▶' : ' '} ${c.location.project_name} ${c.location.branch}${when} ${choice === undefined ? '—' : choice}`; // COPY-FLAG + return i === flow.cursor ? `${BOLD}${clipLine(line, maxWidth)}${RESET}` : clipLine(line, maxWidth); + }; + return { + lines: [ + `${BOLD}${flow.name}${RESET} ${DIM}· changed since you looked in ${n} location${n === 1 ? '' : 's'}${RESET}`, // COPY-FLAG + '', + ...flow.conflicts.map(row), + ], + footer: hint(['l', 'local'], ['r', 'remote'], ...(flow.old.status === 'ok' ? ([['p', 'pinned']] as const) : []), ['d', 'delete'], ...(allDecided(flow) ? ([['enter', 'continue']] as const) : []), ['esc', 'cancel']), // COPY-FLAG + }; +} + /** Body lines (no margin) and the footer for the flow, for `width` x `height`. */ export function renderEdit( flow: EditFlow, @@ -542,6 +678,7 @@ export function renderEdit( footer: pickerHint(flow.box.searching, 'run'), }; } + if (flow.step === 'conflicts') return renderConflicts(flow, maxWidth); if (flow.step === 'running') { return { lines: [`${DIM}${runningLine(flow, dryRun)}${RESET}`], footer: flow.stop === undefined ? hint(['ctrl+c', 'stop']) : '' }; // COPY-FLAG } diff --git a/src/ui/secretsScreenDriver.ts b/src/ui/secretsScreenDriver.ts index 46456001..14d60f3c 100644 --- a/src/ui/secretsScreenDriver.ts +++ b/src/ui/secretsScreenDriver.ts @@ -84,7 +84,12 @@ 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; + readonly run: ( + plan: BatchPlan, + onProgress?: (progress: BatchProgress) => void, + /** The decisions made for conflicts found by an earlier run, with the fingerprints they were made against (CAP-714). */ + decisions?: Extract['decisions'], + ) => 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; } @@ -206,7 +211,7 @@ function perform( } if (effect.type === 'runDeploy') { const ran = deploy - ? deploy.run(effect.plan, (progress) => bus.emit('action', { kind: 'deployProgress', progress })) + ? deploy.run(effect.plan, (progress) => bus.emit('action', { kind: 'deployProgress', progress }), effect.decisions) : Promise.resolve({ ok: false, code: 'UNAVAILABLE' }); void ran.then((finished) => bus.emit('action', { kind: 'deployRan', finished })); return; diff --git a/src/ui/spinner.ts b/src/ui/spinner.ts index 7f8013af..69a06bae 100644 --- a/src/ui/spinner.ts +++ b/src/ui/spinner.ts @@ -19,7 +19,14 @@ export class Spinner { return this.tty ? process.stdout : process.stderr; } - constructor(text: string) { + /** + * `quiet`: says nothing while things go well, and puts a failure or a warning on stderr whatever stdout is. For a + * step a command runs first on behalf of its own output (sync/gate.ts), where stdout belongs to that command. + */ + constructor( + text: string, + private readonly quiet: boolean = false, + ) { this.text = text; } @@ -27,6 +34,7 @@ export class Spinner { if (this.isSpinning) return this; this.isSpinning = true; + if (this.quiet) return this; if (!this.tty) { // No animation off-TTY (carriage-return redraw is meaningless when piped); // emit a single progress line to stderr instead. @@ -63,23 +71,24 @@ export class Spinner { succeed(text?: string): void { this.stop(); + if (this.quiet) return; this.stream.write(`\x1b[32m✓\x1b[0m ${text || this.text}\n`); // Green checkmark } fail(text?: string): void { this.stop(); - this.stream.write(`\x1b[31m✗\x1b[0m ${text || this.text}\n`); // Red X + (this.quiet ? process.stderr : this.stream).write(`\x1b[31m✗\x1b[0m ${text || this.text}\n`); // Red X } warn(text?: string): void { this.stop(); - this.stream.write(`\x1b[33m⚠\x1b[0m ${text || this.text}\n`); // Yellow warning + (this.quiet ? process.stderr : this.stream).write(`\x1b[33m⚠\x1b[0m ${text || this.text}\n`); // Yellow warning } } /** * Factory function to match ora's API */ -export default function ora(text: string): Spinner { - return new Spinner(text); +export default function ora(text: string, quiet: boolean = false): Spinner { + return new Spinner(text, quiet); } diff --git a/tests/commands/addPiped.test.ts b/tests/commands/addPiped.test.ts index aea48162..2d28837c 100644 --- a/tests/commands/addPiped.test.ts +++ b/tests/commands/addPiped.test.ts @@ -7,7 +7,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { createHarness, BRANCH, type Harness } from '../helpers/pipedHarness'; +import { createHarness, withHarness, BRANCH, type Harness } from '../helpers/pipedHarness'; const PEM = readFileSync(join(__dirname, '../fixtures/rsa_test_key.pem'), 'utf8').trimEnd(); @@ -98,8 +98,11 @@ describe('capy add NAME < value (piped)', () => { expect(parseJson(over.stdout).code).toBe('STDIN_TOO_LARGE'); expect(h().envValue('ADD_BIG')).toBeUndefined(); - const at = await h().run(['add', 'ADD_AT_CAP', '--json', '--no-push'], 'a'.repeat(1024 * 1024)); - expect(at.code).toBe(0); + // --no-push leaves .env different from Capy, which the next write would stop on: its own world. + await withHarness(async (own) => { + const at = await own.run(['add', 'ADD_AT_CAP', '--json', '--no-push'], 'a'.repeat(1024 * 1024)); + expect(at.code).toBe(0); + }); }); test('a PEM key with inner newlines round-trips byte for byte; one trailing newline is stripped, nothing else', async () => { @@ -117,11 +120,12 @@ describe('capy add NAME < value (piped)', () => { }); test('--no-push writes .env only: no push call, result says so', async () => { - const pushes = h().pushCount(); - const r = await h().run(['add', 'ADD_LOCAL', '--no-push', '--json'], 'local\n'); - expect(parseJson(r.stdout)).toEqual({ ok: true, name: 'ADD_LOCAL', branch: BRANCH, action: 'created', pushed: false, keep_lock: { changed: false } }); - expect(h().envValue('ADD_LOCAL')).toBe('local'); - expect(h().pushCount()).toBe(pushes); + await withHarness(async (own) => { + const r = await own.run(['add', 'ADD_LOCAL', '--no-push', '--json'], 'local\n'); + expect(parseJson(r.stdout)).toEqual({ ok: true, name: 'ADD_LOCAL', branch: BRANCH, action: 'created', pushed: false, keep_lock: { changed: false } }); + expect(own.envValue('ADD_LOCAL')).toBe('local'); + expect(own.pushCount()).toBe(0); + }); }); test('a bad name is INVALID_FORMAT and the argument is not echoed', async () => { diff --git a/tests/commands/bareCapyCommit.test.ts b/tests/commands/bareCapyCommit.test.ts new file mode 100644 index 00000000..fcebef6c --- /dev/null +++ b/tests/commands/bareCapyCommit.test.ts @@ -0,0 +1,351 @@ +/** + * Bare `capy`'s sync (the code every folder write command runs first: `CapyCommand.runSync`), against the REAL command + * over a real folder on disk and an in-memory Capy server (tests/helpers/fakeCapyServer.ts). Only the network edge is + * replaced (auth, the service client, the key). The menu is bare `capy`'s own; "what would you like to do" is answered + * by a recorder. + * + * The properties (asserted on what the server's latest DECRYPTS to and on the folder after): + * - "Commit and push all local values" uploads the folder's values through the shared upload, naming the keep it read + * (`base_keep_hash`), and leaves the folder and Capy equal; + * - if the server refuses that upload as stale (409), the sync runs ONCE more from a fresh read; a second refusal is + * thrown (SECRETS_STALE_BASE); + * - retrieving (pinned or remote values) and resolving row by row only move the FOLDER: nothing is pushed; + * - a run that is a command's first step (`quiet`) says nothing while the folder and Capy agree; + * - answering by protocol (`--resolve`) applies the answers as the individual resolution, then commits so the folder + * and Capy agree. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'; +import { rmSync } from 'fs'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { FIXED_PROJECT_KEY } from '../helpers/deployServer'; +import { fakeCapyServer, pushOf, seedServer, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { ensureInSync } from '../../src/sync/gate'; +import { saveEdits } from '../../src/commands/editCommand'; +import { removeAndSync, writeAndSync, writeImportedAndSync, type ResolvedContext } from '../../src/commands/connectors/shared'; +import { ERROR_CODES, type ConnectorMetadata, type KeepFile } from '../../src/types/index'; + +const KEY = FIXED_PROJECT_KEY; +const DEV = 'development'; + +/** The server this test talks to: one `mock` returning it, so the module mocks below can reach the per-test value. */ +const serverOf = mock((): ReturnType | undefined => undefined); +const asked = mock((_questions: unknown) => undefined); +const answer = mock((): { action: string } => ({ action: 'skip' })); + +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + setSessionUserId() {} + async authenticateSilent() { + return { success: true, user_id: 'user-b', organization_id: 'org', user_email: 'b@example.test', _auth_method: 'cached' }; + } + getLastRefreshFailure() { + return null; + } + getToken() { + return { access_token: 't', refresh_token: 'r', expires_at: Date.now() + 3_600_000 }; + } + async getValidToken() { + return 't'; + } + }, + silentAuthFailureMessage: () => 'no session', +})); +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + getDecryptData(...args: unknown[]) { + return serverOf()!.getDecryptData(...(args as [string, string])); + } + pushSecrets(...args: unknown[]) { + return serverOf()!.pushSecrets(...(args as Parameters['pushSecrets']>)); + } + async listBranches() { + return []; + } + }, +})); +mock.module('../../src/crypto/keyResolver', () => ({ + resolveProjectKey: async () => KEY, + hasOrgKey: () => true, +})); +mock.module('inquirer', () => ({ + default: { + prompt: async (questions: unknown) => { + asked(questions); + return answer(); + }, + }, +})); + +import { CapyCommand } from '../../src/commands/capyCommand'; + +const globalDirName = `.capy-test-bare-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +const ORIGINAL_CWD = process.cwd(); +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +const made = mock((_: Folder) => undefined); +afterEach(() => { + process.chdir(ORIGINAL_CWD); + made.mock.calls.forEach(([folder]) => removeFolder(folder)); + made.mockClear(); + serverOf.mockReset(); + asked.mockClear(); + answer.mockReset(); + answer.mockImplementation(() => ({ action: 'skip' })); +}); + +const OLD = { X: 'x-old', Y: 'y-old' } as const; + +/** A folder pinned to OLD holding `local`, and a server holding `remote`. */ +function world(local: Record, remote: Record, opts: Parameters[0] = {}) { + const seed = seedServer({ projectKey: KEY, values: { [DEV]: OLD } }); + const server = fakeCapyServer({ initial: seedServer({ projectKey: KEY, values: { [DEV]: remote } }), ...opts }); + serverOf.mockReturnValue(server); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: seed.keep as KeepFile, env: local, client: server.client }); + made(folder); + process.chdir(folder.dir); + return { server, folder }; +} + +async function sync(options: ConstructorParameters[0] = {}) { + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + const outcome = await new CapyCommand(options).runSync(); + return { outcome, stdout: log.mock.calls.map((c) => c.map(String).join(' ')).join('\n'), stderr: err.mock.calls.map((c) => c.map(String).join(' ')).join('\n') }; + } finally { + log.mockRestore(); + err.mockRestore(); + } +} + +const choicesOffered = (): readonly string[] => + asked.mock.calls.flatMap(([q]) => { + const question = (Array.isArray(q) ? q[0] : q) as { name?: string; choices?: Array<{ value: string }> }; + return question.name === 'action' ? (question.choices ?? []).map((c) => c.value) : []; + }); + +describe('Commit and push all local values', () => { + test('uploads the folder\'s values through the shared upload: Capy decrypts to them, the folder equals Capy, and the push names the keep it read', async () => { + const w = world({ X: 'x-mine', Y: OLD.Y, Z: 'z-new' }, OLD); + answer.mockImplementation(() => ({ action: 'commit_local' })); + const { outcome } = await sync(); + expect(outcome).toBe('changed'); + expect(choicesOffered()).toContain('commit_local'); + const after = w.server.state(); + expect(serverValues(after, DEV, KEY)).toEqual({ X: 'x-mine', Y: OLD.Y, Z: 'z-new' }); + expect(w.folder.env()).toEqual(serverValues(after, DEV, KEY)); + expect(w.folder.pin('X')).toBe(hashValue('x-mine')); + expect(w.server.pushSecrets.mock.calls).toHaveLength(1); + expect(w.server.pushSecrets.mock.calls[0][5]).toBe(SyncEngine.computeKeepHash(seedServer({ projectKey: KEY, values: { [DEV]: OLD } }).keep as KeepFile, DEV)); + }); + + test('what the folder lacks goes from Capy too (the folder\'s values ARE what is committed), as bare capy always did', async () => { + const w = world({ X: 'x-mine' }, OLD); + answer.mockImplementation(() => ({ action: 'commit_local' })); + await sync(); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-mine' }); + }); + + test('the server refuses it as stale (someone saved after the read): the sync runs once more from a fresh read, and the second pass commits', async () => { + const teammate = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-TEAMMATE', Y: OLD.Y } }); + const w = world({ X: 'x-mine', Y: OLD.Y }, OLD, { racing: (n) => (n === 1 ? [teammate] : []) }); + answer.mockImplementation(() => ({ action: 'commit_local' })); + const { outcome } = await sync(); + expect(outcome).toBe('changed'); + // Two reads, two pushes (the first refused), the menu shown twice: the second time with THEIR X in the table. + expect(w.server.pushSecrets.mock.calls).toHaveLength(2); + expect(asked.mock.calls.filter(([q]) => (Array.isArray(q) ? q[0] : q).name === 'action')).toHaveLength(2); + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-mine'); + }); + + test('a second stale refusal is thrown: SECRETS_STALE_BASE, nothing of the folder\'s reached Capy', async () => { + const a = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-A', Y: OLD.Y } }); + const b = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-B', Y: OLD.Y } }); + const w = world({ X: 'x-mine', Y: OLD.Y }, OLD, { racing: (n) => (n === 1 ? [a] : n === 2 ? [b] : []) }); + answer.mockImplementation(() => ({ action: 'commit_local' })); + await expect(sync()).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_STALE_BASE }); + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-B'); + }); +}); + +describe('retrieving and resolving only move the folder', () => { + test('Retrieve all remote values: .env and keep.lock take Capy\'s; nothing is pushed', async () => { + const w = world(OLD, { X: 'x-new', Y: OLD.Y, Z: 'z-new' }); + answer.mockImplementation(() => ({ action: 'retrieve_remote' })); + const { outcome } = await sync(); + expect(outcome).toBe('changed'); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.folder.env()).toEqual({ X: 'x-new', Y: OLD.Y, Z: 'z-new' }); + expect(w.folder.pin('X')).toBe(hashValue('x-new')); + }); + + test('Continue working: stopped, and nothing is written anywhere', async () => { + const w = world(OLD, { X: 'x-new', Y: OLD.Y }); + const { outcome } = await sync(); + expect(outcome).toBe('stopped'); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.folder.env()).toEqual(OLD); + }); +}); + +describe('a command\'s first step (quiet)', () => { + test('folder and Capy agree: up-to-date, and not a word on stdout or stderr', async () => { + world(OLD, OLD); + const { outcome, stdout, stderr } = await sync({ quiet: true, protocol: true }); + expect(outcome).toBe('up-to-date'); + expect(stdout).toBe(''); + expect(stderr).toBe(''); + expect(asked).not.toHaveBeenCalled(); + }); + + test('bare capy itself, not quiet, says so (unchanged)', async () => { + world(OLD, OLD); + const { stdout } = await sync(); + expect(stdout).toContain('Everything is up to date!'); + }); + + test('off a terminal the menu is not shown: the rows are the payload (no prompt, nothing on stdout but the refusal)', async () => { + const w = world(OLD, { X: 'x-new', Y: OLD.Y }); + await expect(sync({ protocol: true, quiet: true })).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_CONFLICT }); + expect(asked).not.toHaveBeenCalled(); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + }); +}); + +describe('answers by protocol are bare capy\'s individual resolution, committed so the folder and Capy agree', () => { + const idOf = async (): Promise => { + const refused = await sync({ protocol: true, quiet: true }).catch((e: { payload?: { conflict_id: string } }) => e); + return (refused as { payload: { conflict_id: string } }).payload.conflict_id; + }; + + test('remote: the folder takes Capy\'s value and nothing is pushed (Capy already holds it)', async () => { + const w = world(OLD, { X: 'x-new', Y: OLD.Y }); + const id = await idOf(); + const { outcome } = await sync({ protocol: true, quiet: true, conflictFlags: { resolve: ['X=remote'], conflict: id } }); + expect(outcome).toBe('changed'); + expect(w.folder.env()).toEqual({ X: 'x-new', Y: OLD.Y }); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-new', Y: OLD.Y }); + }); + + test('local: the folder\'s value is committed to Capy, over theirs', async () => { + const w = world({ X: 'x-mine', Y: OLD.Y }, { X: 'x-new', Y: OLD.Y }); + const id = await idOf(); + await sync({ protocol: true, quiet: true, conflictFlags: { resolve: ['X=local'], conflict: id } }); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-mine', Y: OLD.Y }); + expect(w.folder.env()).toEqual({ X: 'x-mine', Y: OLD.Y }); + }); + + test('delete: the variable goes from Capy and from the folder', async () => { + const w = world({ X: 'x-mine', Y: OLD.Y }, { X: 'x-new', Y: OLD.Y }); + const id = await idOf(); + await sync({ protocol: true, quiet: true, conflictFlags: { resolve: ['X=delete'], conflict: id } }); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ Y: OLD.Y }); + expect(w.folder.env()).toEqual({ Y: OLD.Y }); + }); + + test('--dry-run: the rows come back with dry_run even with the right answers, and nothing in the folder changes', async () => { + const w = world(OLD, { X: 'x-new', Y: OLD.Y }); + const id = await idOf(); + const refusal = await sync({ protocol: true, quiet: true, dryRun: true, conflictFlags: { resolve: ['X=remote'], conflict: id } }).catch((e: { payload: Record }) => e); + expect((refusal as { payload: Record }).payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflict_id: id }); + expect(w.folder.env()).toEqual(OLD); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + }); +}); + +describe('Oct 2: a stale folder (pinned to an old snapshot, a teammate saved a newer X) runs each writer', () => { + // The folder is pinned to OLD and holds OLD; Capy holds a newer X and a variable only it has. + const NEWER = { X: 'x-TEAMMATE', Y: OLD.Y, SERVER_ONLY: 'only-on-the-server' } as const; + const noLog = { record: mock((_: unknown) => undefined) } as never; + const connector = { provider: 'stripe', source: 'cli', created_at: 1, fingerprint: 'fp' } as unknown as ConnectorMetadata; + + /** What the person does at the table: bare capy's own menu, answered with "Retrieve all remote values". */ + async function gateRetrievingRemote() { + answer.mockImplementation(() => ({ action: 'retrieve_remote' })); + const tty = spyOn(process.stdin, 'isTTY', 'get' as never).mockReturnValue(true as never); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + return await ensureInSync({ nonTty: false }); + } finally { + tty.mockRestore(); + log.mockRestore(); + err.mockRestore(); + } + } + + /** The command's context as it is AFTER the gate: the folder as bare capy left it. */ + const after = (w: ReturnType): ResolvedContext => + ({ ...w.folder.ctx, keep: w.folder.keep(), localPlaintext: w.folder.env() }) as unknown as ResolvedContext; + + /** Everything the incident put at risk, asserted after a write that touched only `touched`. */ + function expectTeammateSurvives(w: ReturnType, touched: readonly string[]) { + const values = serverValues(w.server.state(), DEV, KEY); + Object.entries(NEWER) + .filter(([name]) => !touched.includes(name)) + .forEach(([name, value]) => expect(values[name]).toBe(value)); + expect(w.folder.env()).toEqual(values); + } + + test('the gate shows the row, and choosing remote brings the folder to Capy before anything else is written', async () => { + const w = world(OLD, NEWER); + expect(await gateRetrievingRemote()).toBe('in-sync'); + expect(w.folder.env()).toEqual(NEWER); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('capy edit: the edited row lands on top; X stays the teammate\'s', async () => { + const w = world(OLD, NEWER); + await gateRetrievingRemote(); + const { keep: _keep, localPlaintext: _local, ...folder } = after(w); + await saveEdits(folder as never, w.folder.keep(), { Y: 'y-edited' }, noLog); + expect(serverValues(w.server.state(), DEV, KEY).Y).toBe('y-edited'); + expectTeammateSurvives(w, ['Y']); + }); + + test('capy add / capy rotate (a new value for one name): lands on top', async () => { + const w = world(OLD, NEWER); + await gateRetrievingRemote(); + await writeAndSync(after(w), 'Z', 'z-new', { push: true }); + expect(serverValues(w.server.state(), DEV, KEY).Z).toBe('z-new'); + expectTeammateSurvives(w, []); + }); + + test('capy connect (a link, no value): only the connector entry moves; no value changes', async () => { + const w = world(OLD, NEWER); + await gateRetrievingRemote(); + await writeAndSync(after(w), 'Y', undefined, { push: true, connector }); + expect(w.server.state().keep?.variables.Y.find((e) => e.branch === DEV)).toMatchObject({ connector }); + expectTeammateSurvives(w, []); + }); + + test('capy connect : the imported names land on top', async () => { + const w = world(OLD, NEWER); + await gateRetrievingRemote(); + await writeImportedAndSync(after(w), [{ varName: 'IMPORTED', value: 'i-1', entry: connector }], { push: true }); + expect(serverValues(w.server.state(), DEV, KEY).IMPORTED).toBe('i-1'); + expectTeammateSurvives(w, []); + }); + + test('capy remove Y: only Y goes', async () => { + const w = world(OLD, NEWER); + await gateRetrievingRemote(); + await removeAndSync(after(w), ['Y']); + expect(serverValues(w.server.state(), DEV, KEY).Y).toBeUndefined(); + expectTeammateSurvives(w, ['Y']); + }); +}); diff --git a/tests/commands/bootstrapNoSecrets.test.ts b/tests/commands/bootstrapNoSecrets.test.ts new file mode 100644 index 00000000..61fc18bb --- /dev/null +++ b/tests/commands/bootstrapNoSecrets.test.ts @@ -0,0 +1,72 @@ +/** + * Bootstrapping an existing project into an empty folder: a 404 from the service that is CODED `NO_SECRETS` means the + * project is empty (a stub keep.lock is written); anything else is a failure and is thrown. The decision is the code + * (`serviceClient.classifyResponse` mints it once, from the response), never the wording of the message. + * + * Isolated: `mock.module()` is process-wide (tests/run-tests.sh). + */ +import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import * as realKeyResolver from '../../src/crypto/keyResolver'; + +mock.module('../../src/crypto/keyResolver', () => ({ + ...realKeyResolver, + resolveProjectKey: async () => 'a'.repeat(64), +})); + +import { CapyCommand } from '../../src/commands/capyCommand'; +import { ServiceClient } from '../../src/service/serviceClient'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const PROJECT = { id: 'proj-1', name: 'demo', organization_id: 'org-1' }; + +const ORIGIN = process.cwd(); +const dirs = mock((_: string) => undefined); + +beforeEach(() => { + // A folder that is not a git repository: bootstrapping installs hooks into the working directory's repository. + const dir = mkdtempSync(join(tmpdir(), 'capy-bootstrap-')); + dirs(dir); + process.chdir(dir); +}); +afterEach(() => { + mock.restore(); + process.chdir(ORIGIN); + dirs.mock.calls.forEach(([dir]) => rmSync(dir, { recursive: true, force: true })); + dirs.mockClear(); +}); + +/** A command whose service answers `getDecryptData` with `failure` (the one call the bootstrap makes before it decides). */ +function commandFailingWith(failure: unknown): CapyCommand { + spyOn(ServiceClient.prototype, 'getDecryptData').mockRejectedValue(failure); + return new CapyCommand({}, false); +} + +const bootstrap = (cmd: CapyCommand): Promise => + (cmd as unknown as { bootstrapExistingProject: (p: typeof PROJECT, org: string, user: string) => Promise }).bootstrapExistingProject(PROJECT, 'org-1', 'user-1'); + +describe('bootstrapExistingProject: an empty project is decided by the code', () => { + test('404 coded NO_SECRETS, whatever the message says: a stub keep.lock is written and the run goes on', async () => { + const log = spyOn(console, 'log').mockImplementation(() => {}); + try { + await bootstrap(commandFailingWith(new CapyError('This wording could be anything', ERROR_CODES.NO_SECRETS, { status: 404 }))); + expect(existsSync(join(process.cwd(), 'keep.lock'))).toBe(true); + expect(JSON.parse(readFileSync(join(process.cwd(), 'keep.lock'), 'utf-8'))).toMatchObject({ project_id: 'proj-1', variables: {} }); + } finally { + log.mockRestore(); + } + }); + + test('a 404 that merely SAYS "No secrets" but is coded otherwise is a failure: the wording decides nothing', async () => { + const failure = new CapyError('No secrets stored for this project', ERROR_CODES.SERVICE_ERROR, { status: 404 }); + await expect(bootstrap(commandFailingWith(failure))).rejects.toBe(failure); + expect(existsSync(join(process.cwd(), 'keep.lock'))).toBe(false); + }); + + test('NO_SECRETS on another status is not an empty project either', async () => { + const failure = new CapyError('x', ERROR_CODES.NO_SECRETS, { status: 500 }); + await expect(bootstrap(commandFailingWith(failure))).rejects.toBe(failure); + }); +}); diff --git a/tests/commands/conflictProtocol.test.ts b/tests/commands/conflictProtocol.test.ts new file mode 100644 index 00000000..6b12e75f --- /dev/null +++ b/tests/commands/conflictProtocol.test.ts @@ -0,0 +1,730 @@ +/** + * Bare `capy` runs first, and off a terminal it answers by protocol (CAP-717; docs/conflict-resolution.md). Driven + * through the BUILT cli (`dist/index.js`): the real argument parser, the real stdin, an isolated HOME and a mock + * service that remembers every push (tests/helpers/pipedHarness.ts). Needs `bun run build` first. + * + * Two worlds. In both, the folder is pinned to an old snapshot of the branch and a teammate has saved a newer X since + * (the Oct 2 incident): + * - STALE: the folder's `.env` still holds the old values (nothing changed here); + * - EDITED: the folder's `.env` also holds ITS OWN edit of X. + * + * Every command that writes from a folder must: + * - stop at the difference before doing anything of its own: exit 3, `{ ok:false, code:'SECRETS_CONFLICT', + * conflict_id, conflicts }` on stdout and nothing else there (rows are bare `capy`'s `compareSecrets` rows: + * `name, project, branch, type, pinned, local, remote`), the explanation on stderr, no value anywhere, NOTHING + * uploaded and the folder untouched; + * - on a rerun with `--resolve X=pinned|local|remote|delete --conflict `: apply the answer as bare `capy`'s + * individual resolution, commit it so the folder and Capy agree, THEN write only its own change on top; + * - refuse a rerun whose state moved (`SECRETS_CONFLICT_STALE`, a new id, nothing written), answer a partial set + * of answers with the rows still open, and refuse `--resolve` without `--conflict`. + * The property asserted is always the server's: what its latest DECRYPTS to. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { readFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { deriveResourceId } from '../../src/crypto/resourceId'; +import { FileManager } from '../../src/files/fileManager'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import type { KeepFile } from '../../src/types/index'; +import { createHarness, BRANCH, ORG_ID, PROJECT_ID, type CliResult, type Harness } from '../helpers/pipedHarness'; + +const hash16 = (v: string): string => createHash('sha256').update(v).digest('hex').slice(0, 16); + +// Values no output may ever carry. +const OLD = { X: 'SENTINEL-x-old-7a1c', Y: 'SENTINEL-y-old-3b9d' } as const; +const MINE = 'SENTINEL-x-mine-51e2'; +const THEIRS = 'SENTINEL-x-theirs-c04f'; +const THEIRS_AGAIN = 'SENTINEL-x-theirs-AGAIN-88d1'; +const NEW_Z = 'SENTINEL-z-new-d2e8'; +const ALL_SENTINELS = [OLD.X, OLD.Y, MINE, THEIRS, THEIRS_AGAIN, NEW_Z]; + +const keepOf = (values: Readonly>): KeepFile => ({ + version: '3.0', + org_id: ORG_ID, + project_id: PROJECT_ID, + project_name: 'piped-test', + variables: Object.fromEntries( + Object.entries(values).map(([name, value]) => [ + name, + [{ resource_id: deriveResourceId(BRANCH, name), branch: BRANCH, value_hash: hash16(value), changed_at: '2026-10-01T21:44:18.000Z' }], + ]), + ), +}); + +/** The folder: pinned to `pinned`, its `.env` holding `local`. */ +function seedFolder(h: Harness, pinned: Readonly>, local: Readonly>): void { + const files = new FileManager(h.project); + const keep = keepOf(pinned); + files.writeKeepFile(keep); + files.writeEncryptedEnvFile({ ...local }, h.projectKey, undefined, keep, BRANCH); +} + +/** What the server holds for the branch right now (replaces the last fixture). */ +function seedServer(h: Harness, values: Readonly>): void { + const lines = Object.entries(values).map(([name, value]) => `${name}=capy:${deriveResourceId(BRANCH, name)}:${Encryptor.encrypt(value, h.projectKey)}`); + h.setBranchData(PROJECT_ID, BRANCH, { env_file: lines.join('\n'), permissions: [], keep_hash: SyncEngine.computeKeepHash(keepOf(values), BRANCH), keep_file: JSON.stringify(keepOf(values)) }); +} + +/** What the server holds for `branch` of the same project (replaces that branch's last fixture): another location of a row. */ +function seedBranch(h: Harness, branch: string, values: Readonly>): void { + const keep: KeepFile = { + version: '3.0', + org_id: ORG_ID, + project_id: PROJECT_ID, + project_name: 'piped-test', + variables: Object.fromEntries( + Object.entries(values).map(([name, value]) => [ + name, + [{ resource_id: deriveResourceId(branch, name), branch, value_hash: hash16(value), changed_at: '2026-10-01T21:44:18.000Z' }], + ]), + ), + }; + const lines = Object.entries(values).map(([name, value]) => `${name}=capy:${deriveResourceId(branch, name)}:${Encryptor.encrypt(value, h.projectKey)}`); + h.setBranchData(PROJECT_ID, branch, { env_file: lines.join('\n'), permissions: [], keep_hash: SyncEngine.computeKeepHash(keep, branch), keep_file: JSON.stringify(keep) }); +} + +/** What the server's latest decrypts to for `name`: the last accepted push, else the fixture. */ +function serverValue(h: Harness, name: string): string | undefined { + const file = join(h.root, 'store.json'); + const fixture = join(h.root, `branch-${PROJECT_ID}-${BRANCH}.json`); + const fm = new FileManager(h.project); + const envFile = existsSync(file) + ? (() => { + const store = JSON.parse(readFileSync(file, 'utf8')) as { keep?: KeepFile; blobs: Record }; + return store.keep === undefined ? '' : (store.blobs[SyncEngine.computeKeepHash(store.keep, BRANCH)] ?? ''); + })() + : existsSync(fixture) + ? (JSON.parse(readFileSync(fixture, 'utf8')) as { env_file: string }).env_file + : ''; + const line = envFile.split('\n').find((l) => l.startsWith(`${name}=`)); + return line === undefined ? undefined : fm.decryptValue(line.slice(name.length + 1), h.projectKey); +} + +const json = (r: CliResult): Record => JSON.parse(r.stdout) as Record; +const noValuesIn = (r: CliResult): void => ALL_SENTINELS.forEach((s) => expect(`${r.stdout}\n${r.stderr}`).not.toContain(s)); + +const rowX = (pinned: string, local: string, remote: string) => ({ + name: 'X', + project: 'piped-test', + branch: BRANCH, + type: 'changed', + pinned: hash16(pinned), + local: hash16(local), + remote: hash16(remote), +}); + +describe('bare capy first, off a terminal (built cli)', () => { + const state = { h: undefined as Harness | undefined }; + const h = (): Harness => { + if (!state.h) throw new Error('harness not ready'); + return state.h; + }; + beforeEach(async () => { + Object.assign(state, { h: await createHarness() }); + // Folder: pinned to the old snapshot, nothing edited. Server: a teammate already saved a newer X. + seedFolder(h(), OLD, OLD); + seedServer(h(), { X: THEIRS, Y: OLD.Y }); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + /** What a first run reports, and the facts every refusal must leave untouched. */ + async function firstRefusal(args: readonly string[], stdin?: string): Promise<{ readonly result: CliResult; readonly id: string }> { + const result = await h().run(args, stdin); + expect(result.code).toBe(3); + const out = json(result); + expect(out).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(Object.keys(out).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); + expect(out.conflict_id).toMatch(/^c_[0-9a-f]{12}$/); + expect(out.conflicts).toEqual([rowX(OLD.X, OLD.X, THEIRS)]); + noValuesIn(result); + // Nothing uploaded; the folder is as it was. + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(OLD.X); + return { result, id: out.conflict_id as string }; + } + + describe('Oct 2: a stale folder, every writer', () => { + test('capy edit Z (piped): stops with the row, no value, nothing uploaded; remote takes their X, then Z lands on top', async () => { + const { result, id } = await firstRefusal(['edit', 'Z', '--json'], `${NEW_Z}\n`); + expect(result.stderr).toContain('X'); + const rerun = await h().run(['edit', 'Z', '--json', '--resolve', 'X=remote', '--conflict', id], `${NEW_Z}\n`); + expect(rerun.code).toBe(0); + expect(json(rerun)).toMatchObject({ ok: true, name: 'Z', pushed: true }); + // The property: what a teammate saved is still what Capy holds, and Z is there too. + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(serverValue(h(), 'Y')).toBe(OLD.Y); + expect(serverValue(h(), 'Z')).toBe(NEW_Z); + // The folder took Capy's X. + expect(h().envValue('X')).toBe(THEIRS); + expect(h().envValue('Z')).toBe(NEW_Z); + }); + + test('capy add Z (piped): the same', async () => { + const { id } = await firstRefusal(['add', 'Z', '--json'], `${NEW_Z}\n`); + const rerun = await h().run(['add', 'Z', '--json', '--resolve', 'X=remote', '--conflict', id], `${NEW_Z}\n`); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(serverValue(h(), 'Z')).toBe(NEW_Z); + expect(h().envValue('X')).toBe(THEIRS); + }); + + test('capy remove Y --yes: the same, and only Y goes', async () => { + const args = ['remove', 'Y', '--yes', '--json']; + const { id } = await firstRefusal(args); + const rerun = await h().run([...args, '--resolve', 'X=remote', '--conflict', id]); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'Y')).toBeUndefined(); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(h().envValue('X')).toBe(THEIRS); + }); + + test('capy push: bare capy IS the push; remote leaves Capy as it is and the folder takes it', async () => { + const { id } = await firstRefusal(['push']); + const rerun = await h().run(['push', '--resolve', 'X=remote', '--conflict', id]); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(h().envValue('X')).toBe(THEIRS); + }); + + test('bare capy: stdout is the payload alone (no banner), prose on stderr', async () => { + const result = await h().run([], ''); + expect(result.code).toBe(3); + expect(Object.keys(json(result)).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); + noValuesIn(result); + expect(h().pushCount()).toBe(0); + }); + + test('after the answer, the folder and Capy agree: bare capy has nothing to say', async () => { + const { id } = await firstRefusal(['edit', 'Z', '--json'], `${NEW_Z}\n`); + await h().run(['edit', 'Z', '--json', '--resolve', 'X=remote', '--conflict', id], `${NEW_Z}\n`); + const again = await h().run([], ''); + expect(again.code).toBe(0); + expect(again.stdout).toBe(''); + }); + + test('a folder that agrees with Capy is not asked anything: the command goes straight through', async () => { + seedServer(h(), OLD); // nobody saved since + const result = await h().run(['edit', 'Z', '--json'], `${NEW_Z}\n`); + expect(result.code).toBe(0); + expect(json(result)).toMatchObject({ ok: true, name: 'Z' }); + expect(result.stderr).toBe(''); + expect(serverValue(h(), 'Z')).toBe(NEW_Z); + }); + }); + + describe('the four choices', () => { + beforeEach(() => { + // EDITED: the folder also holds its own X. + seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); + }); + + const rerunWith = async (choice: string) => { + const first = await h().run(['edit', 'Z', '--json'], `${NEW_Z}\n`); + expect(first.code).toBe(3); + expect(json(first).conflicts).toEqual([rowX(OLD.X, MINE, THEIRS)]); + return h().run(['edit', 'Z', '--json', '--resolve', `X=${choice}`, '--conflict', json(first).conflict_id as string], `${NEW_Z}\n`); + }; + + test('local: MY value is committed to Capy (what its latest decrypts to), then Z', async () => { + const rerun = await rerunWith('local'); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(MINE); + expect(serverValue(h(), 'Z')).toBe(NEW_Z); + expect(serverValue(h(), 'Y')).toBe(OLD.Y); + expect(h().envValue('X')).toBe(MINE); + }); + + test('remote: THEIR value stays in Capy and the folder takes it', async () => { + const rerun = await rerunWith('remote'); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(h().envValue('X')).toBe(THEIRS); + }); + + test('pinned, when the pinned value cannot be read here: refused with a code, not guessed (it would delete X); nothing is written', async () => { + const rerun = await rerunWith('pinned'); + expect(rerun.code).toBe(1); + expect(json(rerun)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_INVALID' }); + noValuesIn(rerun); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(MINE); + }); + + test('delete: X goes from Capy and from the folder', async () => { + const rerun = await rerunWith('delete'); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'X')).toBeUndefined(); + expect(h().envValue('X')).toBeUndefined(); + expect(serverValue(h(), 'Z')).toBe(NEW_Z); + }); + }); + + describe('pinned', () => { + test('the value this folder last agreed on (still in .env here) is committed back, over their newer one', async () => { + const first = await h().run(['edit', 'Z', '--json'], `${NEW_Z}\n`); + const rerun = await h().run(['edit', 'Z', '--json', '--resolve', 'X=pinned', '--conflict', json(first).conflict_id as string], `${NEW_Z}\n`); + expect(rerun.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(OLD.X); + expect(h().envValue('X')).toBe(OLD.X); + }); + }); + + describe('the id binds the answers to what was seen', () => { + test('a rerun whose state moved is SECRETS_CONFLICT_STALE: the fresh row, a new id, nothing written; the new id works', async () => { + const { id } = await firstRefusal(['edit', 'Z', '--json'], `${NEW_Z}\n`); + seedServer(h(), { X: THEIRS_AGAIN, Y: OLD.Y }); // another writer moved X again + const rerun = await h().run(['edit', 'Z', '--json', '--resolve', 'X=remote', '--conflict', id], `${NEW_Z}\n`); + expect(rerun.code).toBe(3); + const out = json(rerun); + expect(out).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT_STALE' }); + expect(out.conflict_id).not.toBe(id); + expect(out.conflicts).toEqual([rowX(OLD.X, OLD.X, THEIRS_AGAIN)]); + noValuesIn(rerun); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(OLD.X); + const again = await h().run(['edit', 'Z', '--json', '--resolve', 'X=remote', '--conflict', out.conflict_id], `${NEW_Z}\n`); + expect(again.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS_AGAIN); + }); + + test('every row needs an answer: a partial set gets the rows still open, under the id of the whole set', async () => { + seedFolder(h(), OLD, OLD); + seedServer(h(), { X: THEIRS, Y: 'SENTINEL-y-theirs-91bc' }); + const first = await h().run(['push']); + expect(first.code).toBe(3); + const out = json(first); + expect(out.conflicts.map((c: { name: string }) => c.name)).toEqual(['X', 'Y']); + const partial = await h().run(['push', '--resolve', 'X=remote', '--conflict', out.conflict_id]); + expect(partial.code).toBe(3); + expect(json(partial)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: out.conflict_id }); + expect(json(partial).conflicts.map((c: { name: string }) => c.name)).toEqual(['Y']); + expect(h().pushCount()).toBe(0); + const full = await h().run(['push', '--resolve', 'X=remote', '--resolve', 'Y=pinned', '--conflict', out.conflict_id]); + expect(full.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(serverValue(h(), 'Y')).toBe(OLD.Y); + }); + + test('--resolve without --conflict is refused with a code, and nothing is read or written', async () => { + const result = await h().run(['edit', 'Z', '--json', '--resolve', 'X=remote'], `${NEW_Z}\n`); + expect(result.code).toBe(1); + expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_NEEDS_CONFLICT' }); + expect(h().pushCount()).toBe(0); + }); + + test('a malformed --resolve is SECRETS_RESOLVE_INVALID; so is mine/theirs, which are not answers any more', async () => { + const bad = await h().run(['edit', 'Z', '--json', '--resolve', 'X:remote', '--conflict', 'c_9f2e41a07b3d'], `${NEW_Z}\n`); + expect(bad.code).toBe(1); + expect(json(bad)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_INVALID' }); + const old = await h().run(['edit', 'Z', '--json', '--resolve', 'X=mine', '--conflict', 'c_9f2e41a07b3d'], `${NEW_Z}\n`); + expect(old.code).toBe(1); + expect(json(old)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_INVALID' }); + }); + + test('--dry-run reports the same conflict_id with dry_run, and writes nothing', async () => { + const real = await firstRefusal(['edit', 'Z', '--json'], `${NEW_Z}\n`); + const preview = await h().run(['edit', 'Z', '--json', '--dry-run'], `${NEW_Z}\n`); + expect(preview.code).toBe(3); + expect(json(preview)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflict_id: real.id }); + noValuesIn(preview); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(OLD.X); + }); + }); +}); + +describe('capy secrets set NAME with no folder (built cli)', () => { + const state = { h: undefined as Harness | undefined }; + const h = (): Harness => { + if (!state.h) throw new Error('harness not ready'); + return state.h; + }; + beforeEach(async () => { + Object.assign(state, { h: await createHarness() }); + // The row `capy secrets` listed holds the OLD X; since then a teammate saved a newer X at the one location. + h().setSecretsIndex({ + org_id: ORG_ID, + skipped: [], + rows: [ + { + name: 'X', + value_hash: hash16(OLD.X), + locations: [{ project_id: PROJECT_ID, project_name: 'piped-test', branch: BRANCH, protected: false, service: null }], + users: [], + }, + ], + }); + seedServer(h(), { X: THEIRS }); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + const SET = ['secrets', 'set', 'X', '--all-rows', '--no-pr', '--json']; + const row = { name: 'X', project: 'piped-test', branch: BRANCH, type: 'changed', pinned: hash16(OLD.X), local: hash16(MINE), remote: hash16(THEIRS) }; + + test('--dry-run with the value reports the row and the plan_id; the real run is stopped the same way before anything is pushed', async () => { + const preview = await h().run([...SET, '--dry-run'], `${MINE}\n`); + expect(preview.code).toBe(3); + const out = json(preview); + expect(out).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true }); + expect(out.plan_id).toMatch(/^[0-9a-f]{16}$/); + expect(out.conflicts).toEqual([row]); + noValuesIn(preview); + + const real = await h().run([...SET, '--confirm', out.plan_id], `${MINE}\n`); + expect(real.code).toBe(3); + expect(json(real)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: out.conflict_id }); + noValuesIn(real); + expect(h().pushCount()).toBe(0); + }); + + test('rerun with local writes MY value (and names the keep it started from); with remote it pushes nothing and leaves their value', async () => { + const preview = json(await h().run([...SET, '--dry-run'], `${MINE}\n`)); + const base = [...SET, '--confirm', preview.plan_id as string, '--conflict', preview.conflict_id as string]; + + const remote = await h().run([...base, '--resolve', 'X=remote'], `${MINE}\n`); + expect(remote.code).toBe(0); + expect(h().pushCount()).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); + + const local = await h().run([...base, '--resolve', 'X=local'], `${MINE}\n`); + expect(local.code).toBe(0); + expect(h().pushCount()).toBe(1); + expect(serverValue(h(), 'X')).toBe(MINE); + const push = h().requests().find((r) => r.method === 'POST' && r.path.startsWith('/secrets/')); + expect(JSON.parse(push?.body ?? '{}').base_keep_hash).toMatch(/^[0-9a-f]{64}$/); + }); + + test('pinned, when no other location of the row still holds the value that was shown: refused with a code (it cannot be written back, and is never guessed); nothing pushed', async () => { + const preview = json(await h().run([...SET, '--dry-run'], `${MINE}\n`)); + const base = [...SET, '--confirm', preview.plan_id as string, '--conflict', preview.conflict_id as string]; + const pinned = await h().run([...base, '--resolve', 'X=pinned'], `${MINE}\n`); + expect(pinned.code).toBe(1); + expect(json(pinned)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_INVALID' }); + noValuesIn(pinned); + expect(h().pushCount()).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); + }); + + test('delete removes the variable at the moved location, through the shared upload (base_keep_hash), and says so; no value in the output', async () => { + const preview = json(await h().run([...SET, '--dry-run'], `${MINE}\n`)); + const base = [...SET, '--confirm', preview.plan_id as string, '--conflict', preview.conflict_id as string]; + const deleted = await h().run([...base, '--resolve', 'X=delete'], `${MINE}\n`); + expect(deleted.code).toBe(0); + expect(json(deleted).removed).toEqual([{ project: 'piped-test', branch: BRANCH, protected: false }]); + noValuesIn(deleted); + expect(h().pushCount()).toBe(1); + expect(serverValue(h(), 'X')).toBeUndefined(); + const push = h().requests().find((r) => r.method === 'POST' && r.path.startsWith('/secrets/')); + expect(JSON.parse(push?.body ?? '{}').base_keep_hash).toMatch(/^[0-9a-f]{64}$/); + }); + + test('pinned, when another location of the row still holds the value that was shown: that value is read there and written back over THEIR newer one', async () => { + // The row also lists `staging`, where nobody changed X: it still holds the value the row listed. + h().setSecretsIndex({ + org_id: ORG_ID, + skipped: [], + rows: [ + { + name: 'X', + value_hash: hash16(OLD.X), + locations: [ + { project_id: PROJECT_ID, project_name: 'piped-test', branch: BRANCH, protected: false, service: null }, + { project_id: PROJECT_ID, project_name: 'piped-test', branch: 'staging', protected: false, service: null }, + ], + users: [], + }, + ], + }); + seedBranch(h(), 'staging', { X: OLD.X }); + const preview = json(await h().run([...SET, '--dry-run'], `${MINE}\n`)); + expect(preview.conflicts).toEqual([row]); + const base = [...SET, '--confirm', preview.plan_id as string, '--conflict', preview.conflict_id as string]; + const pinned = await h().run([...base, '--resolve', 'X=pinned'], `${MINE}\n`); + expect(pinned.code).toBe(0); + noValuesIn(pinned); + // The property: what Capy holds for the moved location now DECRYPTS to the value that was shown, not THEIRS and not MINE. + expect(serverValue(h(), 'X')).toBe(OLD.X); + }); + + test('a rerun whose location moved again is stale: a new id, nothing pushed', async () => { + const preview = json(await h().run([...SET, '--dry-run'], `${MINE}\n`)); + seedServer(h(), { X: THEIRS_AGAIN }); + const stale = await h().run([...SET, '--confirm', preview.plan_id as string, '--conflict', preview.conflict_id as string, '--resolve', 'X=local'], `${MINE}\n`); + expect(stale.code).toBe(3); + expect(json(stale)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT_STALE' }); + expect(json(stale).conflict_id).not.toBe(preview.conflict_id); + expect(h().pushCount()).toBe(0); + }); + + test('a location nobody changed since its row was listed is set without a question', async () => { + seedServer(h(), { X: OLD.X }); + const preview = json(await h().run([...SET, '--dry-run'], `${MINE}\n`)); + expect(preview.ok).toBe(true); + const real = await h().run([...SET, '--confirm', preview.plan_id as string], `${MINE}\n`); + expect(real.code).toBe(0); + expect(serverValue(h(), 'X')).toBe(MINE); + }); +}); + +describe('protocol answers with no value behind them are refused, never turned into a deletion (built cli)', () => { + const state = { h: undefined as Harness | undefined }; + const h = (): Harness => { + if (!state.h) throw new Error('harness not ready'); + return state.h; + }; + beforeEach(async () => { + Object.assign(state, { h: await createHarness() }); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + /** Stops at the difference, then reruns `push` with `X=`: what bare capy does with an answer its source cannot back. */ + async function answered(choice: string): Promise { + const first = await h().run(['push']); + expect(first.code).toBe(3); + return h().run(['push', '--resolve', `X=${choice}`, '--conflict', json(first).conflict_id as string]); + } + + /** Refused with a code before anything is written; no value anywhere; the folder is as it was. */ + function refused(result: CliResult, envBefore: string | undefined): void { + expect(result.code).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain('SECRETS_RESOLVE_INVALID'); + noValuesIn(result); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(envBefore); + } + + test('local, on a row the folder no longer has (deleted here): refused; only delete deletes', async () => { + seedFolder(h(), OLD, { Y: OLD.Y }); // X was removed from .env; keep.lock still pins it + seedServer(h(), OLD); + refused(await answered('local'), undefined); + // The same row answered with delete goes through. + const first = await h().run(['push']); + const deleted = await h().run(['push', '--resolve', 'X=delete', '--conflict', json(first).conflict_id as string]); + expect(deleted.code).toBe(0); + expect(serverValue(h(), 'X')).toBeUndefined(); + }); + + test('remote, on a row Capy no longer has (deleted there): refused', async () => { + seedFolder(h(), OLD, OLD); + seedServer(h(), { Y: OLD.Y }); + refused(await answered('remote'), OLD.X); + }); + + test('pinned, on a row keep.lock never pinned (a new local variable): refused', async () => { + seedFolder(h(), { Y: OLD.Y }, { X: MINE, Y: OLD.Y }); + seedServer(h(), { Y: OLD.Y }); + refused(await answered('pinned'), MINE); + }); +}); + +describe('every other write path meets bare capy first (built cli)', () => { + const state = { h: undefined as Harness | undefined }; + const h = (): Harness => { + if (!state.h) throw new Error('harness not ready'); + return state.h; + }; + beforeEach(async () => { + Object.assign(state, { h: await createHarness() }); + // The Oct 2 world: the folder is pinned to the old snapshot, a teammate has saved a newer X since. + seedFolder(h(), OLD, OLD); + seedServer(h(), { X: THEIRS, Y: OLD.Y }); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + const mintedAnything = (): boolean => h().requests().some((r) => r.method === 'POST' && /^\/orgs\/[^/]+\/deploy$/.test(r.path)); + + test('the default capy deploy (token + docs) stops at the difference: nothing minted, no deploy token created, until it is answered', async () => { + const args = ['deploy', '--platform', 'other', '--mode', 'token', '--yes']; + const first = await h().run(args); + expect(first.code).toBe(3); + expect(json(first)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(json(first).conflicts).toEqual([rowX(OLD.X, OLD.X, THEIRS)]); + noValuesIn(first); + expect(mintedAnything()).toBe(false); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(OLD.X); + + // Answered: the folder takes Capy's X, and only then does the deploy go on to mint. + const rerun = await h().run([...args, '--resolve', 'X=remote', '--conflict', json(first).conflict_id as string]); + expect(h().envValue('X')).toBe(THEIRS); + expect(mintedAnything()).toBe(true); + noValuesIn(rerun); + }); + + test('capy deploy revoke: stops at the difference before any revoke is sent', async () => { + const first = await h().run(['deploy', 'revoke', 'dep_0123456789ab']); + expect(first.code).toBe(3); + expect(json(first)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(h().requests().some((r) => r.method === 'DELETE')).toBe(false); + expect(h().pushCount()).toBe(0); + }); + + test('capy deploy dokploy --discover: stops at the difference in the working directory before anything is discovered or written', async () => { + const first = await h().run(['deploy', 'dokploy', '--discover']); + expect(first.code).toBe(3); + expect(json(first)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + noValuesIn(first); + expect(h().pushCount()).toBe(0); + }); + + test('capy connect dokploy --discover: the same, in the working directory (no conditional skip)', async () => { + const first = await h().run(['connect', 'dokploy', '--discover']); + expect(first.code).toBe(3); + expect(json(first)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + noValuesIn(first); + expect(h().pushCount()).toBe(0); + }); + + /** The folder as a command could have changed it: keep.lock, the branch cache, and X in `.env`. */ + const folderNow = () => ({ + keep: h().keepLockText(), + branch: readFileSync(join(h().project, '.capy', 'branch'), 'utf8'), + x: h().envValue('X'), + }); + + /** Everything the mock saw that is not a read (the gate's own co-decrypt / wrap are not writes). */ + const writesSeen = () => h().requests().filter((r) => r.method !== 'GET' && !/\/(co-decrypt|wrap)$/.test(r.path)); + + /** The command meets bare capy first: exit 3 with the stale row and no value, and nothing was written, minted, removed or switched. */ + async function stoppedAtTheGate(args: readonly string[]): Promise { + const before = folderNow(); + const result = await h().run(args); + expect(result.code).toBe(3); + expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(Object.keys(json(result)).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); + expect(json(result).conflicts).toEqual([rowX(OLD.X, OLD.X, THEIRS)]); + noValuesIn(result); + expect(writesSeen()).toEqual([]); + expect(mintedAnything()).toBe(false); + expect(h().pushCount()).toBe(0); + expect(folderNow()).toEqual(before); + return result; + } + + test('capy rotate: stops at the difference before any credential is minted or rotated', async () => { + await stoppedAtTheGate(['rotate', '--non-tty']); + }); + + test('capy connect stripe (a link, not a discovery): stops at the difference before the provider is touched or the link recorded', async () => { + await stoppedAtTheGate(['connect', 'stripe', '--var', 'X', '--non-tty']); + }); + + test('capy deploy targets-remove: stops at the difference before the target is removed from the config or anything is redeployed', async () => { + await stoppedAtTheGate(['deploy', 'targets-remove', 'staging', '--non-tty']); + }); + + test('capy checkout : stops at the difference before the branch is switched (keep.lock, .env and the branch cache untouched; the target branch is never read)', async () => { + seedBranch(h(), 'development', { X: 'SENTINEL-x-dev-0b6e' }); + h().setBranches(PROJECT_ID, [ + { id: 'b1', name: BRANCH, is_protected: false }, + { id: 'b2', name: 'development', is_protected: false }, + ]); + await stoppedAtTheGate(['checkout', 'development', '--non-tty']); + expect(h().requests().some((r) => r.path.includes('branch=development'))).toBe(false); + }); + + test('capy org: stops at the difference before the folder is re-linked to another organization', async () => { + await stoppedAtTheGate(['org', '--non-tty']); + }); + + test('a folder whose keep.lock was deleted (the .env header still names project and branch) is synced by the gate exactly as bare capy syncs it', async () => { + rmSync(join(h().project, 'keep.lock')); + const bare = await h().run([], ''); + expect(bare.code).toBe(3); + expect(json(bare)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + const gated = await h().run(['push']); + expect(gated.code).toBe(3); + expect(json(gated)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + // The same rows (and so the same conflict_id) as bare capy's own run in that folder. + expect(json(gated).conflicts).toEqual(json(bare).conflicts); + expect(json(gated).conflict_id).toBe(json(bare).conflict_id); + noValuesIn(gated); + expect(h().pushCount()).toBe(0); + }); +}); + +describe('capy checkout after an interrupted checkout (built cli)', () => { + const state = { h: undefined as Harness | undefined }; + const h = (): Harness => { + if (!state.h) throw new Error('harness not ready'); + return state.h; + }; + const DEV = 'development'; + const DEV_X = 'SENTINEL-x-dev-0b6e'; + const branchCache = () => readFileSync(join(h().project, '.capy', 'branch'), 'utf8').trim(); + beforeEach(async () => { + Object.assign(state, { h: await createHarness() }); + // The torn state: `.env` holds production (its header says so) but `.capy/branch` already says development. + seedFolder(h(), OLD, OLD); + writeFileSync(join(h().project, '.capy', 'branch'), DEV); + seedServer(h(), OLD); // Capy agrees with the folder's production + seedBranch(h(), DEV, { X: DEV_X }); + h().setBranches(PROJECT_ID, [ + { id: 'b1', name: BRANCH, is_protected: false }, + { id: 'b2', name: DEV, is_protected: false }, + ]); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + test('capy checkout production (the branch .env holds) is the recovery, and it works: bare capy compares against the .env header instead of ending the run', async () => { + const result = await h().run(['checkout', BRANCH, '--non-tty']); + expect(result.code).toBe(0); + expect(branchCache()).toBe(BRANCH); + expect(h().envValue('X')).toBe(OLD.X); + noValuesIn(result); + }); + + test('capy checkout development (the branch .capy/branch claims) works too: production was compared first, then the switch finishes', async () => { + const result = await h().run(['checkout', DEV, '--non-tty']); + expect(result.code).toBe(0); + expect(branchCache()).toBe(DEV); + expect(h().envValue('X')).toBe(DEV_X); + // The gate compared production (what .env holds), not development. + expect(h().requests().some((r) => r.method === 'GET' && r.path.includes(`/secrets/${PROJECT_ID}?branch=${BRANCH}`))).toBe(true); + }); + + test('the comparison is against production: a production value that differs from Capy stops the checkout with that row, and the local production values are not lost', async () => { + seedServer(h(), { X: THEIRS, Y: OLD.Y }); // a teammate saved a newer production X + const result = await h().run(['checkout', DEV, '--non-tty']); + expect(result.code).toBe(3); + expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(json(result).conflicts).toEqual([rowX(OLD.X, OLD.X, THEIRS)]); + noValuesIn(result); + // Nothing switched, nothing lost: production's X is still in .env, the branch cache is as the interrupted checkout left it. + expect(h().envValue('X')).toBe(OLD.X); + expect(branchCache()).toBe(DEV); + expect(h().pushCount()).toBe(0); + }); + + test('bare capy typed by a person keeps its recovery message and exit 1 (it is not a gate)', async () => { + const result = await h().run([], ''); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`capy checkout ${BRANCH}`); + expect(result.stderr).toContain(`capy checkout ${DEV}`); + }); + + test('in seed-phrase recovery mode the gate is skipped (as for capy decrypt): a folder that differs from Capy still switches, offline, and the server is never asked for the folder\'s branch', async () => { + seedServer(h(), { X: THEIRS, Y: OLD.Y }); // outside recovery mode this stops at the difference (exit 3) + rmSync(join(h().root, `branches-${PROJECT_ID}.json`)); // and the online checkout cannot finish (no branch list): the offline fallback is what remains + mkdirSync(join(h().home, '.capy', 'recover'), { recursive: true }); + writeFileSync(join(h().home, '.capy', 'recover', 'session.json'), JSON.stringify({ master_key: '00', org_id: ORG_ID, created_at: '2026-10-16T00:00:00.000Z' })); + const result = await h().run(['checkout', 'staging', '--non-tty']); + expect(result.code).toBe(0); + expect(branchCache()).toBe('staging'); + // The gate reads the folder's branch from the server; recovery mode never asked. + expect(h().requests().some((r) => r.method === 'GET' && r.path.includes('/secrets/'))).toBe(false); + expect(h().pushCount()).toBe(0); + }); +}); diff --git a/tests/commands/connectDokploy.test.ts b/tests/commands/connectDokploy.test.ts index b8b68e5f..b6f41a37 100644 --- a/tests/commands/connectDokploy.test.ts +++ b/tests/commands/connectDokploy.test.ts @@ -13,6 +13,11 @@ * `mock.module()` and does not have to run in `run-tests.sh`'s isolated list. */ import { describe, test, expect, spyOn, mock, beforeEach, afterEach } from 'bun:test'; +import { stubbedGate } from '../helpers/gateMock'; + +// Bare `capy` (the first step of `capy rotate` / `capy connect`) is tested in tests/sync/gate.test.ts and +// tests/commands/gateFirst.test.ts. `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). +mock.module('../../src/sync/gate', () => stubbedGate()); import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -29,6 +34,7 @@ import { import type { ResolvedContext } from '../../src/commands/connectors/shared'; import { writeImportedAndSync, writeImportOutcome } from '../../src/commands/connectors/shared'; import { FileManager } from '../../src/files/fileManager'; +import { Encryptor } from '../../src/crypto/encryptor'; import { ConnectCommand } from '../../src/commands/connectCommand'; import { DokployApiError } from '../../src/deploy/dokployApi'; import type { DokploySystemStoreCallOptions, FetchLike } from '../../src/deploy/dokployApi'; @@ -730,6 +736,16 @@ describe('writeImportOutcome — unchanged-value connector backfill', () => { writeSyncState: () => {}, }, serviceClient: { + // The commit path reads the server's latest first: the server already holds PORT=3000. + getDecryptData: async () => ({ + env_content: `PORT=capy:r1:${Encryptor.encrypt('3000', 'k')}`, + decrypt_key: '', + expires_at: '', + keep_file: JSON.stringify({ + ...keep, + variables: { PORT: [{ resource_id: 'r1', branch: 'development', value_hash: createHash('sha256').update('3000').digest('hex').slice(0, 16) }] }, + }), + }), pushSecrets: async (projectId: string, keepFile: string, envBlob: string, branch: string) => { pushCalls.push({ projectId, keepFile, envBlob, branch }); return { keep_hash: 'h'.repeat(16) }; diff --git a/tests/commands/connectDokployDiscovery.test.ts b/tests/commands/connectDokployDiscovery.test.ts index fdbc6fa4..a6c19f38 100644 --- a/tests/commands/connectDokployDiscovery.test.ts +++ b/tests/commands/connectDokployDiscovery.test.ts @@ -56,9 +56,15 @@ import { type DiscoveryPlanEnv, type DiscoveryPlanFolder, } from '../../src/commands/connectors/dokployDiscovery'; -import { createDokployConnector, computeOverwritePlan, DISCOVERY_NEW_PROJECT } from '../../src/commands/connectors/dokploy'; +import { createDokployConnector as createRealDokployConnector, computeOverwritePlan, DISCOVERY_NEW_PROJECT } from '../../src/commands/connectors/dokploy'; +import type { DokployConnectorDeps } from '../../src/commands/connectors/dokploy'; + +// These tests drive discovery over throwaway folders with a fake service and no session; bare `capy` in each folder +// (sync/gate.ts) is the real thing unless a test supplies its own `syncFolder`. The default here reports every folder in sync; +// that bare capy runs first in each folder is tested at the end of this file. +const createDokployConnector = (deps: DokployConnectorDeps = {}) => createRealDokployConnector({ syncFolder: async () => ({ ok: true }), ...deps }); import { commitDiscoveryChanges, snapshotPathStatus, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName } from '../../src/git/discoveryCommit'; -import { ConnectCommand } from '../../src/commands/connectCommand'; +import { ConnectCommand, discoveryExitCode } from '../../src/commands/connectCommand'; import type { DiscoveryContext } from '../../src/commands/connectors/dokployDiscovery'; import type { ImportOutcome } from '../../src/commands/connectors/registry'; import type { FetchLike } from '../../src/deploy/dokployApi'; @@ -773,6 +779,7 @@ describe('runDiscoverySequence', () => { ], }); const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => { calls.push('ensureProject'); return { ok: true, projectId: 'proj_1', created: false }; @@ -812,6 +819,7 @@ describe('runDiscoverySequence', () => { test('CAP-673: importIntoBranch is called with this folder\'s Dokploy project name', async () => { const seen: Array<{ projectName: string; environmentName: string }> = []; const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: true, projectId: 'proj_1', created: false }), checkFolderDirty: async () => ({ ok: true }), checkoutBranch: async () => ({ ok: true, created: false }), @@ -829,6 +837,7 @@ describe('runDiscoverySequence', () => { test('ensureProject refusing aborts the folder before any checkout/import runs', async () => { const calls: string[] = []; const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: false, code: 'DOKPLOY_FOLDER_NOT_INITIALIZED', message: 'no keep.lock' }), checkFolderDirty: async () => { calls.push('checkFolderDirty'); @@ -859,6 +868,7 @@ describe('runDiscoverySequence', () => { environments: [samplePlanEnv({ environmentName: 'production' }), samplePlanEnv({ environmentName: 'staging' })], }); const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: true, projectId: 'proj_1', created: false }), checkFolderDirty: async () => ({ ok: true }), checkoutBranch: async (_r, _f, _p, branchName) => { @@ -883,6 +893,7 @@ describe('runDiscoverySequence', () => { environments: [samplePlanEnv({ environmentName: 'production' })], }); const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: true, projectId: 'proj_1', created: false }), checkFolderDirty: async () => ({ ok: true }), checkoutBranch: async () => ({ ok: true, created: false }), @@ -898,6 +909,7 @@ describe('runDiscoverySequence', () => { test('a dirty folder (checkFolderDirty refuses) aborts with zero checkout/import calls (defect fix)', async () => { const calls: string[] = []; const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: true, projectId: 'proj_1', created: false }), checkFolderDirty: async () => ({ ok: false, code: 'DOKPLOY_FOLDER_DIRTY', message: 'unpushed changes' }), checkoutBranch: async () => { @@ -920,6 +932,7 @@ describe('runDiscoverySequence', () => { test('checkFolderDirty is never called for a folder whose project was just created this run', async () => { const dirtyCalls: true[] = []; const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: true, projectId: 'proj_new', created: true }), checkFolderDirty: async () => { dirtyCalls.push(true); @@ -939,6 +952,7 @@ describe('runDiscoverySequence', () => { environments: [samplePlanEnv({ environmentName: 'production' }), samplePlanEnv({ environmentName: 'staging' })], }); const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async () => ({ ok: true, projectId: 'proj_1', created: false }), checkFolderDirty: async () => ({ ok: true }), checkoutBranch: async () => ({ ok: true, created: false }), @@ -952,11 +966,95 @@ describe('runDiscoverySequence', () => { }); }); +describe('runDiscoverySequence: bare capy first, in each folder, before anything is written into it', () => { + test('syncFolder runs FIRST (before ensureProject), for this folder\'s repoDir and folder', async () => { + const calls: string[] = []; + const deps: DiscoverySequenceDeps = { + syncFolder: async (repoDir, folder) => { + calls.push(`sync:${repoDir}:${folder}`); + return { ok: true }; + }, + ensureProject: async () => { + calls.push('ensureProject'); + return { ok: true, projectId: 'proj_1', created: false }; + }, + checkFolderDirty: async () => { + calls.push('checkFolderDirty'); + return { ok: true }; + }, + checkoutBranch: async () => { + calls.push('checkout'); + return { ok: true, created: false }; + }, + importIntoBranch: async () => { + calls.push('import'); + return okImport(['A']); + }, + }; + const result = await runDiscoverySequence(samplePlanFolder({ repoDir: '/repos/a', folder: 'svc' }), { overwrite: false }, deps); + expect(result.ok).toBe(true); + expect(calls).toEqual(['sync:/repos/a:svc', 'ensureProject', 'checkFolderDirty', 'checkout', 'import']); + }); + + test('a folder bare capy could not settle is skipped with its coded failure and the rows: nothing is initialised, checked out or imported into it', async () => { + const calls: string[] = []; + const conflict = { ok: false as const, code: 'SECRETS_CONFLICT' as const, conflict_id: 'c_9f2e41a07b3d', conflicts: [] }; + const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: false, code: 'SECRETS_CONFLICT', message: 'X differs', conflict }), + ensureProject: async () => { + calls.push('ensureProject'); + return { ok: true, projectId: 'proj_1', created: false }; + }, + checkFolderDirty: async () => { + calls.push('checkFolderDirty'); + return { ok: true }; + }, + checkoutBranch: async () => { + calls.push('checkout'); + return { ok: true, created: false }; + }, + importIntoBranch: async () => { + calls.push('import'); + return okImport(['A']); + }, + }; + const result = await runDiscoverySequence(samplePlanFolder(), { overwrite: false }, deps); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('SECRETS_CONFLICT'); + expect(result.conflict).toEqual(conflict); + expect(result.environments).toEqual([]); + expect(calls).toEqual([]); + }); + + test('one stopped folder does not stop the next: each folder is synced on its own', async () => { + const synced: string[] = []; + const deps: DiscoverySequenceDeps = { + syncFolder: async (repoDir) => { + synced.push(repoDir); + return repoDir === '/repos/a' ? { ok: false, code: 'STOPPED', message: 'Stopped.' } : { ok: true }; + }, + ensureProject: async () => ({ ok: true, projectId: 'proj_b', created: false }), + checkFolderDirty: async () => ({ ok: true }), + checkoutBranch: async () => ({ ok: true, created: false }), + importIntoBranch: async () => okImport(['A']), + }; + const results = await runDiscoverySequences( + [samplePlanFolder({ repoDir: '/repos/a', serviceName: 'a' }), samplePlanFolder({ repoDir: '/repos/b', serviceName: 'b' })], + { overwrite: false }, + deps, + ); + expect(synced).toEqual(['/repos/a', '/repos/b']); + expect(results.map((r) => r.ok)).toEqual([false, true]); + }); +}); + describe('runDiscoverySequences', () => { test('one folder failing does not stop the NEXT folder from running', async () => { const folderA = samplePlanFolder({ repoDir: '/repos/a', serviceName: 'a' }); const folderB = samplePlanFolder({ repoDir: '/repos/b', serviceName: 'b' }); const deps: DiscoverySequenceDeps = { + syncFolder: async () => ({ ok: true }), ensureProject: async (repoDir) => repoDir === '/repos/a' ? { ok: false, code: 'DOKPLOY_FOLDER_NOT_INITIALIZED', message: 'x' } : { ok: true, projectId: 'proj_b', created: false }, checkFolderDirty: async () => ({ ok: true }), @@ -2469,6 +2567,124 @@ describe('connector.discover — a real apply honors --no-push', () => { }); }); +describe('connector.discover - bare capy runs in each discovered folder before anything is written into it', () => { + /** An initialised folder in a repo, a fake service that records pushes, and a Dokploy with one compose service. */ + async function world(run: (w: { ROOT: string; ctx: DiscoveryContext; pushCalls: true[]; fetchImpl: FetchLike; keepBefore: string }) => Promise): Promise { + const ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'capy-discover-sync-'))); + const globalDirName = `.capy-test-discsync-${process.pid}-${Date.now()}`; + const savedDirName = process.env.CAPY_GLOBAL_DIR_NAME; + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; + try { + initRepo(ROOT, 'git@github.com:acme/widgets.git'); + const orgId = 'org_discsync_test'; + const userId = 'user_discsync_test'; + await wrapAndSaveMasterKey(randomBytes(32), orgId, userId, { + coDecrypt: async (_oid: string, ct: string) => ct, + wrapOuterLayer: async (_oid: string, pt: string) => pt, + }); + const projectId = 'proj_discsync'; + const keepBefore = JSON.stringify({ version: '3.0', org_id: orgId, project_id: projectId, project_name: 'widgets', variables: {} }); + writeFileSync(join(ROOT, 'keep.lock'), keepBefore); + const pushCalls: true[] = []; + const ctx: DiscoveryContext = { + orgId, + userId, + authService: {} as unknown as DiscoveryContext['authService'], + serviceClient: { + coDecrypt: async (_oid: string, ct: string) => ({ plaintext: ct }), + wrapOuterLayer: async (_oid: string, pt: string) => ({ ciphertext: pt }), + listBranches: async () => [], + createBranch: async () => ({ id: 'b1', name: 'production', project_id: projectId, is_protected: false }), + getDecryptData: async () => ({ env_content: '', keep_file: '', decrypt_key: '', expires_at: new Date().toISOString() }), + pushSecrets: async () => { + pushCalls.push(true); + return { keep_hash: 'h', keep_file: keepBefore }; + }, + } as unknown as DiscoveryContext['serviceClient'], + }; + const fetchImpl: FetchLike = (async (url: string) => { + if (url.includes('project.all')) { + return { + status: 200, + ok: true, + text: async () => + JSON.stringify([ + { projectId: 'proj_1', name: 'acme', environments: [{ environmentId: 'env_1', name: 'production', compose: [{ composeId: 'compose_1', name: 'widgets' }] }] }, + ]), + }; + } + return { + status: 200, + ok: true, + text: async () => JSON.stringify({ composeId: 'compose_1', env: 'A=1', createEnvFile: true, owner: 'acme', repository: 'widgets', sourceType: 'github', composePath: 'docker-compose.yml' }), + }; + }) as FetchLike; + return await run({ ROOT, ctx, pushCalls, fetchImpl, keepBefore }); + } finally { + if (savedDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedDirName; + rmSync(join(homedir(), globalDirName), { recursive: true, force: true }); + rmSync(ROOT, { recursive: true, force: true }); + } + } + + const OPTS = { nonTty: true, baseUrl: 'https://d', tokenEnv: 'T', yes: true } as ConnectOpts; + + test('the folder is synced first - asked for its own path, answered by protocol (never a prompt)', async () => { + await world(async ({ ROOT, ctx, fetchImpl }) => { + const asked: Array<{ dir: string; json: boolean | undefined; nonTty: boolean | undefined }> = []; + const connector = createDokployConnector({ + fetch: fetchImpl, + env: { T: 'x' }, + cwd: ROOT, + syncFolder: async (dir, gate) => { + asked.push({ dir, json: gate.json, nonTty: gate.nonTty }); + return { ok: true }; + }, + }); + const outcome = await connector.discover!(ctx, OPTS); + expect(outcome.ok).toBe(true); + expect(asked).toEqual([{ dir: ROOT, json: true, nonTty: true }]); + if (!outcome.ok) return; + expect(outcome.applied?.[0].ok).toBe(true); + }); + }); + + test('a folder that differs from Capy (nobody can answer) is skipped: coded failure and its rows reported, nothing written into it, exit code 3', async () => { + await world(async ({ ROOT, ctx, pushCalls, fetchImpl, keepBefore }) => { + const conflict = { + ok: false as const, + code: 'SECRETS_CONFLICT' as const, + conflict_id: 'c_9f2e41a07b3d', + conflicts: [{ name: 'X', project: 'widgets', branch: 'production', type: 'changed' as const, pinned: 'aa', local: 'aa', remote: 'bb' }], + }; + const connector = createDokployConnector({ + fetch: fetchImpl, + env: { T: 'x' }, + cwd: ROOT, + syncFolder: async () => ({ ok: false, code: 'SECRETS_CONFLICT', message: 'X differs', conflict }), + }); + const outcome = await connector.discover!(ctx, OPTS); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + const folder = outcome.applied?.[0]; + expect(folder).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict }); + expect(discoveryExitCode(outcome.applied)).toBe(3); + // Nothing was written into the folder or sent to Capy. + expect(existsSync(join(ROOT, '.env'))).toBe(false); + expect(readFileSync(join(ROOT, 'keep.lock'), 'utf-8')).toBe(keepBefore); + expect(pushCalls.length).toBe(0); + }); + }); + + test('discoveryExitCode: none failed is undefined, a plain failure is 1, a folder that needs a decision is 3 (even beside other failures)', () => { + expect(discoveryExitCode(undefined)).toBeUndefined(); + expect(discoveryExitCode([{ ok: true }])).toBeUndefined(); + expect(discoveryExitCode([{ ok: true }, { ok: false }])).toBe(1); + expect(discoveryExitCode([{ ok: false }, { ok: false, conflict: {} }])).toBe(3); + }); +}); + // ── Auto-overwrite: a step whose checkout CREATED the branch overwrites ──── describe('connector.discover — auto-overwrite for branches created this run', () => { diff --git a/tests/commands/connectDokploySystemStoreSentinel.test.ts b/tests/commands/connectDokploySystemStoreSentinel.test.ts index 3dff562e..01a9e73f 100644 --- a/tests/commands/connectDokploySystemStoreSentinel.test.ts +++ b/tests/commands/connectDokploySystemStoreSentinel.test.ts @@ -54,8 +54,10 @@ function fakeCtx(dir: string): ResolvedContext { writeSyncState: () => {}, }, serviceClient: { - // No real server round trip: omitting `keep_file` makes `adoptServerKeep` - // fall back to the locally-merged keep (see its own doc). + // No real server round trip. The commit path reads the server's latest first (an empty + // project here); omitting `keep_file` from the push answer makes `adoptServerKeep` + // fall back to the keep that was uploaded (see its own doc). + getDecryptData: async () => ({ env_content: '', decrypt_key: '', expires_at: '' }), pushSecrets: async () => ({ keep_hash: 'h'.repeat(16) }), }, orgId: 'o', diff --git a/tests/commands/connectImportJsonPurity.test.ts b/tests/commands/connectImportJsonPurity.test.ts index f37fbe39..7691047b 100644 --- a/tests/commands/connectImportJsonPurity.test.ts +++ b/tests/commands/connectImportJsonPurity.test.ts @@ -97,8 +97,10 @@ function fakeCtx(dir: string): ResolvedContext { writeSyncState: () => {}, }, serviceClient: { - // No real server round trip: `keep_file` omitted so `adoptServerKeep` - // falls back to the locally-merged keep (see its own doc). + // No real server round trip. The commit path reads the server's latest first (an empty + // project here); `keep_file` omitted from the push answer so `adoptServerKeep` falls back + // to the keep that was uploaded (see its own doc). + getDecryptData: async () => ({ env_content: '', decrypt_key: '', expires_at: '' }), pushSecrets: async () => ({ keep_hash: 'h'.repeat(16) }), }, orgId: 'o', diff --git a/tests/commands/deployBranchCheck.test.ts b/tests/commands/deployBranchCheck.test.ts index e1cf9b69..aee35605 100644 --- a/tests/commands/deployBranchCheck.test.ts +++ b/tests/commands/deployBranchCheck.test.ts @@ -19,6 +19,8 @@ import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; +import { withHarness, ORG_ID, PROJECT_ID, type Harness } from '../helpers/pipedHarness'; +import { hashValue } from '../../src/sync/compareSecrets'; const CLI = join(__dirname, '../../dist/index.js'); const ROOT = join(tmpdir(), `capy-deploy-branch-${process.pid}-${Date.now()}`); @@ -112,152 +114,190 @@ const dokployTarget = (branch: string) => ({ options: { baseUrl: 'https://dokploy.example.com', applicationId: 'app_1', tokenEnv: 'DOKPLOY_API_KEY' }, }); -describe('capy deploy — branch check (CAP-679)', () => { - test('active branch (.env header) differs from target.branch → refused with the coded reason', () => { - writeKeep(ROOT); - writeDeployConfig(ROOT, [dokployTarget('production')]); - writeFileSync(join(ROOT, '.env'), '# capy:branch=development\nDATABASE_URL=x\n'); +/** + * A project in `h` that bare `capy` (the first step of every `capy deploy`) finds in sync: `keep.lock` pins the hash of + * every value, and `.env` holds those values. The branch checks below are `deploy`'s own, behind that first step. + */ +function syncedProject(h: Harness, opts: { header?: string; branches?: string[]; branchFile?: string | null }): void { + const branches = opts.branches ?? ['development', 'production']; + const variables = { + DATABASE_URL: branches.map((b) => ({ resource_id: 'rid' + b[0], branch: b, value_hash: hashValue('x') })), + }; + writeFileSync(join(h.project, 'keep.lock'), JSON.stringify({ version: '3.0', org_id: ORG_ID, project_id: PROJECT_ID, project_name: 'test', variables }, null, 2)); + writeDeployConfig(h.project, [dokployTarget('production')]); + writeFileSync(join(h.project, '.env'), `${opts.header === undefined ? '' : `${opts.header}\n`}DATABASE_URL=x\n`); + if (opts.branchFile === null) rmSync(join(h.project, '.capy', 'branch'), { force: true }); + else if (opts.branchFile !== undefined) writeFileSync(join(h.project, '.capy', 'branch'), opts.branchFile); +} - const r = capy(['deploy', 'dokploy-target', '--yes']); - expect(r.code).toBe(1); - expect(r.stderr).toContain('DEPLOY_BRANCH_MISMATCH'); - expect(r.stderr).toContain('development'); - expect(r.stderr).toContain('production'); +describe('capy deploy — branch check (CAP-679)', () => { + test('active branch (.env header) differs from target.branch → refused with the coded reason', async () => { + await withHarness(async (h) => { + syncedProject(h, { header: '# capy:branch=development', branchFile: 'development' }); + const r = await h.run(['deploy', 'dokploy-target', '--yes']); + expect(r.code).toBe(1); + expect(r.stderr).toContain('DEPLOY_BRANCH_MISMATCH'); + expect(r.stderr).toContain('development'); + expect(r.stderr).toContain('production'); + }); }); - test('active branch matches target.branch → passes the check (fails later, at Dokploy auth)', () => { - writeKeep(ROOT); - writeDeployConfig(ROOT, [dokployTarget('production')]); - writeFileSync(join(ROOT, '.env'), '# capy:branch=production\nDATABASE_URL=x\n'); - - const r = capy(['deploy', 'dokploy-target', '--yes']); - expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); - // Reaches preflight instead — no Dokploy token configured in this env. - expect(r.stderr).toContain('preflight'); + test('active branch matches target.branch → passes the check (fails later, at Dokploy auth)', async () => { + await withHarness(async (h) => { + syncedProject(h, { header: '# capy:branch=production', branchFile: 'production' }); + const r = await h.run(['deploy', 'dokploy-target', '--yes'], undefined, undefined, { DOKPLOY_API_KEY: '' }); + expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); + // Reaches preflight instead — no Dokploy token configured in this env. + expect(r.stderr).toContain('preflight'); + }); }); - test('no active-branch signal (plaintext .env, several keep.lock branches) never refuses on this check', () => { - writeKeep(ROOT); // two branches — deriveActiveBranch cannot pick one - writeDeployConfig(ROOT, [dokployTarget('production')]); - writeFileSync(ROOT + '/.env', 'DATABASE_URL=x\n'); // no header, no .capy/branch + test('no active-branch signal at all: bare capy, which runs first, stops with the coded NO_ACTIVE_BRANCH and the `capy checkout` hint, so the deploy never reaches its own check', async () => { + await withHarness(async (h) => { + // two branches on the server — nothing to pick one by: no header, no .capy/branch + h.setBranches(PROJECT_ID, TWO_BRANCHES); + syncedProject(h, { branchFile: null }); + const r = await h.run(['deploy', 'dokploy-target', '--yes'], undefined, undefined, { DOKPLOY_API_KEY: '' }); + expect(r.code).toBe(1); + // The code and the way out are on stderr; stdout (a payload's place) stays empty. + expect(r.stderr).toContain('NO_ACTIVE_BRANCH'); + expect(r.stderr).toContain('capy checkout'); + expect(r.stdout).toBe(''); + expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); + expect(r.stderr).not.toContain('preflight'); + }); + }); - const r = capy(['deploy', 'dokploy-target', '--yes']); - expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); - expect(r.stderr).toContain('preflight'); + test('...and under --json the same stop is coded JSON on stdout (capy edit NAME --json), with the hint in it', async () => { + await withHarness(async (h) => { + h.setBranches(PROJECT_ID, TWO_BRANCHES); + syncedProject(h, { branchFile: null }); + const r = await h.run(['edit', 'Z', '--json'], 'a-value\n'); + expect(r.code).toBe(1); + const out = JSON.parse(r.stdout) as { ok: boolean; code: string; error: string }; + expect(out).toMatchObject({ ok: false, code: 'NO_ACTIVE_BRANCH' }); + expect(out.error).toContain('capy checkout'); + }); }); }); -function writeKeepWithTargets( - dir: string, - branch: string, - targetsEl: Record, -): void { - writeFileSync( - join(dir, 'keep.lock'), - JSON.stringify( - { - version: '3.0', - org_id: 'org-test', - project_id: 'proj-test', - project_name: 'test', - variables: { - DATABASE_URL: [ - { resource_id: 'rid1', branch, value_hash: 'hhh', targets: [targetsEl] }, - ], - }, - }, - null, - 2, - ), - ); +const TWO_BRANCHES = [ + { id: 'b-dev', name: 'development', is_protected: false }, + { id: 'b-prod', name: 'production', is_protected: false }, +] as const; + +/** + * A project in `h` for `capy deploy targets-remove`: every branch pins the hash of `x` (what `.env` holds, so bare capy, + * which runs first, finds the folder in sync) and `production`'s entry carries the deploy record of `dokploy-target`. + */ +function targetsProject(h: Harness, opts: { header?: string; branchFile?: string | null; branches?: readonly string[]; targetsEl: Record }): void { + const branches = opts.branches ?? ['development', 'production']; + const variables = { + DATABASE_URL: branches.map((b) => ({ + resource_id: 'rid' + b[0], + branch: b, + value_hash: hashValue('x'), + ...(b === 'production' ? { targets: [opts.targetsEl] } : {}), + })), + }; + writeFileSync(join(h.project, 'keep.lock'), JSON.stringify({ version: '3.0', org_id: ORG_ID, project_id: PROJECT_ID, project_name: 'test', variables }, null, 2)); + writeDeployConfig(h.project, [dokployTarget('production')]); + writeFileSync(join(h.project, '.env'), `${opts.header === undefined ? '' : `${opts.header}\n`}DATABASE_URL=x\n`); + if (opts.branchFile === null) rmSync(join(h.project, '.capy', 'branch'), { force: true }); + else if (opts.branchFile !== undefined) writeFileSync(join(h.project, '.capy', 'branch'), opts.branchFile); } -describe('capy deploy targets-remove — never pushes a wrong-branch env blob (CAP-679)', () => { - test('active branch differs from target.branch → refuses the keep.lock push, leaves targets untouched', () => { - writeKeepWithTargets(ROOT, 'production', { - provider: 'dokploy', - target: 'dokploy-target', - deployed_value_hash: 'hhh', - deployed_at: '2026-09-01T00:00:00.000Z', +const DOKPLOY_RECORD = { + provider: 'dokploy', + target: 'dokploy-target', + deployed_value_hash: 'hhh', + deployed_at: '2026-09-01T00:00:00.000Z', +}; + +describe('capy deploy targets-remove — bare capy runs first, then never pushes a wrong-branch env blob (CAP-679)', () => { + test('active branch differs from target.branch → refuses the keep.lock push, leaves targets untouched', async () => { + await withHarness(async (h) => { + targetsProject(h, { header: '# capy:branch=development', branchFile: 'development', targetsEl: DOKPLOY_RECORD }); + const before = h.keepLockText(); + + const r = await h.run(['deploy', 'targets-remove', 'dokploy-target']); + expect(r.code).toBe(0); // local removal still succeeds + expect(r.stderr).toContain('DEPLOY_BRANCH_MISMATCH'); + + const after = h.keepLockText(); + expect(after).toBe(before); // the push never happened — targets record untouched + expect(JSON.parse(after).variables.DATABASE_URL.find((e: { branch: string }) => e.branch === 'production').targets).toHaveLength(1); + expect(h.pushCount()).toBe(0); }); - writeDeployConfig(ROOT, [dokployTarget('production')]); - writeFileSync(join(ROOT, '.env'), '# capy:branch=development\nDATABASE_URL=x\n'); - const before = readFileSync(join(ROOT, 'keep.lock'), 'utf-8'); - - const r = capy(['deploy', 'targets-remove', 'dokploy-target']); - expect(r.code).toBe(0); // local removal still succeeds - expect(r.stderr).toContain('DEPLOY_BRANCH_MISMATCH'); - - const after = readFileSync(join(ROOT, 'keep.lock'), 'utf-8'); - expect(after).toBe(before); // the push never happened — targets record untouched - expect(JSON.parse(after).variables.DATABASE_URL[0].targets).toHaveLength(1); }); - test('no active-branch signal at all → refuses with DEPLOY_BRANCH_UNKNOWN, leaves targets untouched', () => { - writeKeepWithTargets(ROOT, 'production', { - provider: 'dokploy', - target: 'dokploy-target', - deployed_value_hash: 'hhh', - deployed_at: '2026-09-01T00:00:00.000Z', + test('no active-branch signal at all → bare capy stops it first with NO_ACTIVE_BRANCH (exit 1): nothing removed, nothing pushed, keep.lock untouched', async () => { + await withHarness(async (h) => { + // Two branches on the server and in keep.lock, no header, no .capy/branch: nothing to pick one by. + h.setBranches(PROJECT_ID, TWO_BRANCHES); + targetsProject(h, { branchFile: null, targetsEl: DOKPLOY_RECORD }); + const before = h.keepLockText(); + + const r = await h.run(['deploy', 'targets-remove', 'dokploy-target']); + expect(r.code).toBe(1); + expect(r.stderr).toContain('NO_ACTIVE_BRANCH'); + expect(r.stderr).toContain('capy checkout'); + expect(h.keepLockText()).toBe(before); + expect(h.pushCount()).toBe(0); }); - writeDeployConfig(ROOT, [dokployTarget('production')]); - writeFileSync(join(ROOT, '.env'), 'DATABASE_URL=x\n'); // no header, ambiguous - // Give keep.lock a SECOND branch too so deriveActiveBranch's keep-branches - // fallback also can't pick one — same "unknown" shape as the main-deploy test above. - const keep = JSON.parse(readFileSync(join(ROOT, 'keep.lock'), 'utf-8')); - const withSecondBranch = { - ...keep, - variables: { - ...keep.variables, - DATABASE_URL: [...keep.variables.DATABASE_URL, { resource_id: 'rid2', branch: 'staging', value_hash: 'zzz' }], - }, - }; - writeFileSync(join(ROOT, 'keep.lock'), JSON.stringify(withSecondBranch, null, 2)); - const before = readFileSync(join(ROOT, 'keep.lock'), 'utf-8'); - - const r = capy(['deploy', 'targets-remove', 'dokploy-target']); - expect(r.code).toBe(0); - expect(r.stderr).toContain('DEPLOY_BRANCH_UNKNOWN'); - - const after = readFileSync(join(ROOT, 'keep.lock'), 'utf-8'); - expect(after).toBe(before); }); - test('matching active branch → the push proceeds (fails downstream at auth, never at the branch check)', () => { - writeKeepWithTargets(ROOT, 'production', { - provider: 'dokploy', - target: 'dokploy-target', - deployed_value_hash: 'hhh', - deployed_at: '2026-09-01T00:00:00.000Z', + test('matching active branch → the push proceeds (never refused at the branch check)', async () => { + await withHarness(async (h) => { + targetsProject(h, { header: '# capy:branch=production', branchFile: 'production', targetsEl: DOKPLOY_RECORD }); + + const r = await h.run(['deploy', 'targets-remove', 'dokploy-target']); + expect(r.code).toBe(0); + expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); + expect(r.stderr).not.toContain('DEPLOY_BRANCH_UNKNOWN'); }); - writeDeployConfig(ROOT, [dokployTarget('production')]); - writeFileSync(join(ROOT, '.env'), '# capy:branch=production\nDATABASE_URL=x\n'); + }); - const r = capy(['deploy', 'targets-remove', 'dokploy-target']); - expect(r.code).toBe(0); - expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); - expect(r.stderr).not.toContain('DEPLOY_BRANCH_UNKNOWN'); + test('a folder that differs from Capy is stopped before anything is removed or pushed: exit 3, the rows, and --resolve + --conflict answer it', async () => { + await withHarness(async (h) => { + targetsProject(h, { header: '# capy:branch=production', branchFile: 'production', targetsEl: DOKPLOY_RECORD }); + // The folder's .env holds a different value than keep.lock pins: bare capy must ask. + writeFileSync(join(h.project, '.env'), '# capy:branch=production\nDATABASE_URL=SENTINEL-local-edit-7c1d\n'); + const before = h.keepLockText(); + const deployJson = readFileSync(join(h.project, '.capy', 'deploy.json'), 'utf-8'); + + const first = await h.run(['deploy', 'targets-remove', 'dokploy-target']); + expect(first.code).toBe(3); + const out = JSON.parse(first.stdout) as { code: string; conflict_id: string; conflicts: { name: string }[] }; + expect(out.code).toBe('SECRETS_CONFLICT'); + expect(out.conflicts.map((c) => c.name)).toEqual(['DATABASE_URL']); + expect(first.stdout + first.stderr).not.toContain('SENTINEL-local-edit-7c1d'); + // Nothing removed from .capy/deploy.json, keep.lock untouched, nothing pushed. + expect(readFileSync(join(h.project, '.capy', 'deploy.json'), 'utf-8')).toBe(deployJson); + expect(h.keepLockText()).toBe(before); + expect(h.pushCount()).toBe(0); + + // The rerun with the answer (the flags reach bare capy through the subcommand): the folder's value is committed, then the removal goes on. + const rerun = await h.run(['deploy', 'targets-remove', 'dokploy-target', '--resolve', 'DATABASE_URL=local', '--conflict', out.conflict_id]); + expect(rerun.code).toBe(0); + expect(h.pushCount()).toBeGreaterThanOrEqual(1); + expect(JSON.parse(readFileSync(join(h.project, '.capy', 'deploy.json'), 'utf-8')).targets['dokploy-target']).toBeUndefined(); + }); }); }); describe('capy deploy targets-remove — token revocation gated on strip outcome (CAP-679)', () => { - test('onRemove could not clean up the platform side (no token) → the deploy token is KEPT, not revoked', () => { - writeKeepWithTargets(ROOT, 'production', { - provider: 'dokploy', - target: 'dokploy-target', - deployed_value_hash: 'hhh', - deployed_at: '2026-09-01T00:00:00.000Z', - deploy_id: 'dep_test123', + test('onRemove could not clean up the platform side (no token) → the deploy token is KEPT, not revoked', async () => { + await withHarness(async (h) => { + // No DOKPLOY_API_KEY anywhere → onRemove refuses with code "no_token". + targetsProject(h, { header: '# capy:branch=production', branchFile: 'production', targetsEl: { ...DOKPLOY_RECORD, deploy_id: 'dep_test123' } }); + + const r = await h.run(['deploy', 'targets-remove', 'dokploy-target'], undefined, undefined, { DOKPLOY_API_KEY: '' }); + expect(r.code).toBe(0); + expect(r.stdout).toContain('left untouched'); // the onRemove offer's own refusal (offer.ok === false) + expect(r.stdout).toContain('keeping 1 deploy token(s)'); + expect(r.stdout).not.toContain('revoked 1 deploy token(s)'); }); - writeDeployConfig(ROOT, [dokployTarget('production')]); - // No DOKPLOY_API_KEY anywhere → onRemove refuses with code "no_token". - writeFileSync(join(ROOT, '.env'), '# capy:branch=production\nDATABASE_URL=x\n'); - - const r = capy(['deploy', 'targets-remove', 'dokploy-target']); - expect(r.code).toBe(0); - expect(r.stdout).toContain('left untouched'); // the onRemove offer's own refusal (offer.ok === false) - expect(r.stdout).toContain('keeping 1 deploy token(s)'); - expect(r.stdout).not.toContain('revoked 1 deploy token(s)'); }); }); diff --git a/tests/commands/deployCiChangeGate.test.ts b/tests/commands/deployCiChangeGate.test.ts index 8c47e5f7..15d6b633 100644 --- a/tests/commands/deployCiChangeGate.test.ts +++ b/tests/commands/deployCiChangeGate.test.ts @@ -33,6 +33,10 @@ * repo behind it) for reasons unrelated to the property being tested. */ import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from 'bun:test'; +import { stubbedGate } from '../helpers/gateMock'; + +// Bare `capy` (a deploy's first step) is tested in tests/sync/gate.test.ts and tests/commands/gateFirst.test.ts. +mock.module('../../src/sync/gate', () => stubbedGate()); mock.module('../../src/auth/authService', () => ({ AuthService: class { @@ -45,11 +49,16 @@ mock.module('../../src/auth/authService', () => ({ }, silentAuthFailureMessage: () => 'auth failed', })); +// The server answers in agreement with the folder (the keep-only record writes read it). mock.module('../../src/service/serviceClient', () => ({ ServiceClient: class { setTokenProvider() {} + async getDecryptData() { + return agreeingDecryptData(JSON.parse(readFileSync(join(REPO, 'keep.lock'), 'utf-8')), 'main'); + } }, })); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); /** CAP-682: mocked purely to prove it is NEVER called — Dokploy mints no deploy token at all. */ const mintDeployTokenMock = mock(async () => ({ @@ -70,6 +79,7 @@ import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; import { deployCommand } from '../../src/commands/deployCommand'; import { hashValue } from '../../src/deploy/keepGate'; import { serializeKeep } from '../../src/files/fileManager'; @@ -140,7 +150,8 @@ function dokployFetchMock(opts: { allowWrite?: boolean; stripeKeyValue?: string const value = opts.stripeKeyValue ?? 'same-value'; const merged = mergeManagedValuesBlock(RAW_APP_ENV, [{ name: 'STRIPE_KEY', value }]); if (!merged.ok) throw new Error('unexpected merge problem in test fixture'); - const reads = [RAW_APP_ENV, RAW_APP_ENV, merged.env][Symbol.iterator](); + // preflight, the conflict check before anything is written (CAP-717), deploy's fresh read, the post-write verify. + const reads = [RAW_APP_ENV, RAW_APP_ENV, RAW_APP_ENV, merged.env][Symbol.iterator](); return mock(async (url: string, init: { method: string; body?: string }) => { const u = new URL(url); if (u.pathname.endsWith('application.one')) { @@ -256,11 +267,10 @@ describe('capy deploy — CI mode change gate (validator fix-first: no CI churn) // No token minted at all. expect(mintDeployTokenMock).not.toHaveBeenCalled(); - // Dokploy saw ONLY the preflight read — never a write (the scripted fetch - // throws on anything else, so a nonzero call count with no throw means - // every call was the one scripted `application.one` read). - expect(fetchMock).toHaveBeenCalledTimes(1); - expect((fetchMock.mock.calls[0][0] as string)).toContain('application.one'); + // Dokploy saw ONLY reads — the preflight and the conflict check before anything is written (CAP-717) — + // never a write (the scripted fetch throws on anything else). + expect(fetchMock.mock.calls.length).toBeGreaterThan(0); + expect(fetchMock.mock.calls.every((c) => (c[0] as string).includes('application.one'))).toBe(true); // No PR branch pushed to origin. const branches = git(['ls-remote', '--heads', ORIGIN], REPO).stdout; diff --git a/tests/commands/deployCiTargetsRecord.test.ts b/tests/commands/deployCiTargetsRecord.test.ts index c38b6cfb..b5bd469d 100644 --- a/tests/commands/deployCiTargetsRecord.test.ts +++ b/tests/commands/deployCiTargetsRecord.test.ts @@ -33,9 +33,11 @@ * - a successful CI delivery calls `pushSecrets` exactly once, with a * keep file whose entries carry the target's delivery record, and the * env blob is the server's OWN blob passed through unchanged; - * - an unpushed local `.env` edit, and a variable that was never synced at - * all, NEVER appear in what gets pushed — in CI mode AND direct mode - * (the drift bug lived in the SHARED `pushKeepTransform`); + * - a variable that was never synced at all NEVER appears in what gets pushed, in + * CI mode AND direct mode (the drift bug lived in the SHARED `pushKeepTransform`). + * (An unpushed local `.env` edit is a difference bare `capy` settles BEFORE a deploy + * starts: tests/sync/gate.test.ts and tests/commands/gateFirst.test.ts. The keep-only + * record itself never rebuilds the blob from `.env`.); * - the user's local keep.lock / `.env` / git tree are BYTE-IDENTICAL * before and after a CI-mode run (asserted directly, not inferred from * an absence of git-status noise); @@ -45,12 +47,13 @@ * reports the deploy as succeeded (the delivery already happened) — and * the PR still opens (CI mode). * - * No crypto mocking needed: `pushKeepTransform` no longer touches - * `resolveProjectKey`/`Encryptor`/`deriveResourceId` at all (it never - * decrypts or re-encrypts anything — see its own doc), and this fixture's - * `.env` is plain (unencrypted), so `decryptCurrentBranch` (the ADAPTER's - * own read, delivering to Dokploy — unrelated to the bug fixed here) never - * needs them either. + * Crypto: the record now goes through the one commit path (CAP-717/712), which + * needs the project key to read the server's lines, so `resolveProjectKey` is + * mocked to a fixed key. It still never encrypts or decrypts a value of its own: + * the server's blob in this fixture is an opaque line nobody holds the key for, + * kept verbatim, and this fixture's `.env` is plain (unencrypted), so + * `decryptCurrentBranch` (the ADAPTER's own read, delivering to Dokploy) never + * needs the key either. * * The mocked `ServiceClient#getLatestSecrets` fakes "the server's current * snapshot" by reading a small JSON file this test writes per branch @@ -68,6 +71,11 @@ import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { SyncEngine } from '../../src/sync/syncEngine'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; +import { stubbedGate } from '../helpers/gateMock'; + +// Bare `capy` (a deploy's first step) is tested in tests/sync/gate.test.ts and tests/commands/gateFirst.test.ts. +mock.module('../../src/sync/gate', () => stubbedGate()); // Declared before the mocks below (which close over them) — every binding // here is fully initialized before any test() callback runs, so the mocked @@ -97,12 +105,26 @@ mock.module('../../src/service/serviceClient', () => ({ ServiceClient: class { setTokenProvider() {} async pushSecrets(projectId: string, keepFileJson: string, envBlob: string, branch: string) { - return pushSecretsMock(projectId, keepFileJson, envBlob, branch); + const pushed = await pushSecretsMock(projectId, keepFileJson, envBlob, branch); + // The server keeps what it accepted: the next read (a later push's base) is the pushed state. + const existing = existsSync(SERVER_SNAPSHOTS_PATH) + ? (JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record) + : {}; + writeFileSync(SERVER_SNAPSHOTS_PATH, JSON.stringify({ ...existing, [branch]: { keepFile: keepFileJson, envFile: envBlob } })); + return pushed; } // Fakes "the server's current snapshot for this branch" — reads the // per-branch fixture `writeServerSnapshot` (below) wrote, NEVER the // repo's own `.env`. Returns null (→ pushKeepTransform's coded "no // snapshot yet" warning) when nothing was written for this branch. + // The same snapshot as `getDecryptData`, for the commit path (which reads the server's latest before it writes). + async getDecryptData(_projectId: string, branch: string) { + const snap = existsSync(SERVER_SNAPSHOTS_PATH) + ? (JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record)[branch] + : undefined; + if (!snap) throw new Error('no server snapshot written for this branch'); + return agreeingDecryptData(JSON.parse(snap.keepFile), branch, snap.envFile); + } async getLatestSecrets(_projectId: string, branch: string) { if (!existsSync(SERVER_SNAPSHOTS_PATH)) return null; const snapshots = JSON.parse(readFileSync(SERVER_SNAPSHOTS_PATH, 'utf-8')) as Record< @@ -119,6 +141,7 @@ mock.module('../../src/service/serviceClient', () => ({ } }, })); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); // `gh` unavailable → `createPr` takes its manual-instructions path (ok: // true, no real GitHub call needed) — deterministic on any machine. mock.module('../../src/utils/gh', () => ({ @@ -282,7 +305,8 @@ function dokployFetchMock(opts: { failWrite?: boolean; stripeKeyValue?: string } const value = opts.stripeKeyValue ?? STRIPE_KEY_VALUE; const merged = mergeManagedValuesBlock(RAW_APP_ENV, [{ name: 'STRIPE_KEY', value }]); if (!merged.ok) throw new Error('unexpected merge problem in test fixture'); - const reads = [RAW_APP_ENV, RAW_APP_ENV, merged.env][Symbol.iterator](); + // preflight, the conflict check before anything is written (CAP-717), deploy's fresh read, the post-write verify. + const reads = [RAW_APP_ENV, RAW_APP_ENV, RAW_APP_ENV, merged.env][Symbol.iterator](); return mock(async (url: string, init: { method: string; body?: string }) => { const u = new URL(url); if (u.pathname.endsWith('application.one')) { @@ -359,9 +383,10 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { fetchMock: ReturnType, options: Parameters[1], errorMock: ReturnType = mock((..._a: unknown[]) => {}), + logMock: ReturnType = mock((..._a: unknown[]) => {}), ): Promise { const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(fetchMock as never); - const logSpy = spyOn(console, 'log').mockImplementation((() => {}) as never); + const logSpy = spyOn(console, 'log').mockImplementation(logMock as never); const errSpy = spyOn(console, 'error').mockImplementation(errorMock as never); try { return await withTestEnv(() => deployCommand(targetName, options, REPO)); @@ -419,78 +444,29 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { expect(after.stash).toBe(''); }, 30_000); - test('CI mode: an unpushed local `.env` edit and a never-synced variable never leak into the server push', async () => { - setUpRepo(); - process.chdir(REPO); - // Drift: the server's stored blob/keep still agree with local keep.lock - // (same hash — nobody has pushed since), but local `.env` has moved on: - // an EDITED value for the tracked var, plus a variable that was never - // synced (and never will be — it's not in `target.vars`/keep.lock). - writeServerSnapshot('main'); // server truth == local keep.lock, untouched blob - writeFileSync(join(REPO, '.env'), 'STRIPE_KEY=new-unpushed-value\nUNPUSHED_OTHER=stray\n'); - - // Dokploy itself legitimately receives the CURRENT (unpushed) value — - // that delivery is correct and not what this test is about. - const code = await runScriptedDeploy( - 'dokploy-ci', - dokployFetchMock({ stripeKeyValue: 'new-unpushed-value' }), - { yes: true }, - ); - - expect(code).toBe(0); - expect(pushSecretsMock).toHaveBeenCalledTimes(1); - const [, keepFileJson, envBlob] = pushSecretsMock.mock.calls[0]; - - // The property: the push to CAPY's OWN server carries the SERVER's - // already-stored blob, byte-identical — never the edited value, and - // never the never-synced variable. - expect(envBlob).toBe(SERVER_ENV_FILE); - expect(envBlob).not.toContain('new-unpushed-value'); - expect(envBlob).not.toContain('UNPUSHED_OTHER'); - expect(envBlob).not.toContain('stray'); - - // The keep entry's own pin is untouched too — still the OLD synced - // value's hash, not the unpushed edit's. - const pushedKeep = JSON.parse(keepFileJson); - expect(pushedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue(STRIPE_KEY_VALUE)); - // The delivery record itself is honest about what Dokploy actually - // received (the unpushed value) — that's target metadata, not a value - // push, and recording it accurately is correct, not a drift. - expect(pushedKeep.variables.STRIPE_KEY[0].targets[0].deployed_value_hash).toBe(hashValue('new-unpushed-value')); - }, 30_000); - - test('direct mode: an unpushed local `.env` edit and a never-synced variable never leak into the server push', async () => { + // The properties the old drift tests also held, now with a folder that agrees with Capy (the lazy fixture fixed: + // the pin is the hash of the `.env` value): a variable that was never synced is never pushed, in CI mode AND + // direct mode, and the record is the only push (the server's own blob re-sent unchanged). + test.each([ + ['dokploy-ci', { yes: true }], + ['dokploy-direct', { yes: true, noDeploy: true }], + ] as const)('%s: a variable that was never synced is never pushed or delivered; the delivery is recorded against the pin with the server\'s own blob', async (targetName, options) => { setUpRepo(); process.chdir(REPO); writeServerSnapshot('main'); - writeFileSync(join(REPO, '.env'), 'STRIPE_KEY=new-unpushed-value\nUNPUSHED_OTHER=stray\n'); + writeFileSync(join(REPO, '.env'), `STRIPE_KEY=${STRIPE_KEY_VALUE}\nUNPUSHED_OTHER=stray\n`); - // `--no-deploy`: writes the config (recordDeployTargets still runs) but - // skips the trigger+poll steps, same shape as - // `deployRevokeWiring.test.ts`'s own `--no-deploy` test. - const code = await runScriptedDeploy( - 'dokploy-direct', - dokployFetchMock({ stripeKeyValue: 'new-unpushed-value' }), - { yes: true, noDeploy: true }, - ); + const code = await runScriptedDeploy(targetName, dokployFetchMock(), options); expect(code).toBe(0); expect(pushSecretsMock).toHaveBeenCalledTimes(1); const [, keepFileJson, envBlob] = pushSecretsMock.mock.calls[0]; - + const recorded = JSON.parse(keepFileJson); + expect(recorded.variables.STRIPE_KEY[0].value_hash).toBe(hashValue(STRIPE_KEY_VALUE)); + expect(recorded.variables.STRIPE_KEY[0].targets[0]).toMatchObject({ target: targetName, deployed_value_hash: hashValue(STRIPE_KEY_VALUE) }); expect(envBlob).toBe(SERVER_ENV_FILE); - expect(envBlob).not.toContain('new-unpushed-value'); - expect(envBlob).not.toContain('UNPUSHED_OTHER'); + expect(keepFileJson).not.toContain('UNPUSHED_OTHER'); expect(envBlob).not.toContain('stray'); - - const pushedKeep = JSON.parse(keepFileJson); - expect(pushedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue(STRIPE_KEY_VALUE)); - expect(pushedKeep.variables.STRIPE_KEY[0].targets[0]).toMatchObject({ - provider: 'dokploy', - target: 'dokploy-direct', - deployed: false, // --no-deploy - deployed_value_hash: hashValue('new-unpushed-value'), - }); }, 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 () => { diff --git a/tests/commands/deployCommand.test.ts b/tests/commands/deployCommand.test.ts index 984c7235..c7386445 100644 --- a/tests/commands/deployCommand.test.ts +++ b/tests/commands/deployCommand.test.ts @@ -11,6 +11,8 @@ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; +import { withHarness, ORG_ID, PROJECT_ID, type Harness } from '../helpers/pipedHarness'; +import { hashValue } from '../../src/sync/compareSecrets'; // Tests that run a full deploy through preflight need wrangler on PATH — // adapter preflight terminates with "wrangler not found" otherwise. Gate @@ -70,6 +72,18 @@ function writeWranglerToml(dir: string, name: string): void { ); } +/** + * A project in `h` that bare `capy` (the first step of every `capy deploy`) finds in sync: `keep.lock` pins the hash of + * every value in `values` for `branch`, and `.env` holds them. What `deploy` does with it is what these tests are about. + */ +function syncedProject(h: Harness, values: Record = {}, branch: string = 'production'): void { + const variables = Object.fromEntries( + Object.entries(values).map(([name, value]) => [name, [{ resource_id: `rid-${name}`, branch, value_hash: hashValue(value) }]]), + ); + writeFileSync(join(h.project, 'keep.lock'), JSON.stringify({ version: '3.0', org_id: ORG_ID, project_id: PROJECT_ID, project_name: 'test', variables }, null, 2)); + writeFileSync(join(h.project, '.env'), Object.entries(values).map(([name, value]) => `${name}=${value}\n`).join('')); +} + function writeDeployConfig(cwd: string, targets: any[]): void { mkdirSync(join(cwd, '.capy'), { recursive: true }); const obj: any = { version: '1', targets: {} }; @@ -86,19 +100,23 @@ describe('capy deploy — refusals', () => { expect(r.stderr).toContain('keep.lock'); }); - test('unknown adapter id → lists known ones', () => { - writeKeep(ROOT); - const r = capy(['deploy', '--target=does-not-exist', '--yes']); - expect(r.code).toBe(1); - expect(r.stderr).toContain('Unknown adapter'); - expect(r.stderr).toContain('cf-worker'); + test('unknown adapter id → lists known ones', async () => { + await withHarness(async (h) => { + syncedProject(h); + const r = await h.run(['deploy', '--target=does-not-exist', '--yes']); + expect(r.code).toBe(1); + expect(r.stderr).toContain('Unknown adapter'); + expect(r.stderr).toContain('cf-worker'); + }); }); - test('named target that does not exist → exit 1', () => { - writeKeep(ROOT); - const r = capy(['deploy', 'missing-target']); - expect(r.code).toBe(1); - expect(r.stderr).toContain('No target named'); + test('named target that does not exist → exit 1', async () => { + await withHarness(async (h) => { + syncedProject(h); + const r = await h.run(['deploy', 'missing-target', '--yes']); + expect(r.code).toBe(1); + expect(r.stderr).toContain('No target named'); + }); }); }); @@ -155,55 +173,54 @@ describe('capy deploy list / remove', () => { // ── --dry-run, plaintext .env (no decrypt path) ──────────────────────────── describe('capy deploy --dry-run', () => { - test.if(HAS_WRANGLER)('plaintext .env + dry-run: preflight + plan, no side effects', () => { - writeKeep(ROOT); - writeWranglerToml(join(ROOT, 'worker'), 'my-worker'); - writeFileSync( - join(ROOT, '.env'), - 'SUPABASE_URL=https://x.supabase.co\nSTRIPE_SECRET_KEY=sk_test\n', - ); - writeDeployConfig(ROOT, [ - { - name: 'worker-prod', - kind: 'cf-worker', - branch: 'production', - vars: ['SUPABASE_URL', 'STRIPE_SECRET_KEY'], - options: { workerName: 'my-worker', workerDir: 'worker' }, - }, - ]); + test.if(HAS_WRANGLER)('plaintext .env + dry-run: preflight + plan, no side effects', async () => { + await withHarness(async (h) => { + syncedProject(h, { SUPABASE_URL: 'https://x.supabase.co', STRIPE_SECRET_KEY: 'sk_test' }); + writeWranglerToml(join(h.project, 'worker'), 'my-worker'); + writeDeployConfig(h.project, [ + { + name: 'worker-prod', + kind: 'cf-worker', + branch: 'production', + vars: ['SUPABASE_URL', 'STRIPE_SECRET_KEY'], + options: { workerName: 'my-worker', workerDir: 'worker' }, + }, + ]); - const r = capy(['deploy', 'worker-prod', '--dry-run', '--yes']); - if (r.code !== 0) { - console.error('STDOUT:', r.stdout); - console.error('STDERR:', r.stderr); - } - expect(r.code).toBe(0); - expect(r.stdout).toContain('worker-prod'); - expect(r.stdout).toContain('cf-worker'.length > 0 ? 'Cloudflare Workers' : 'cf-worker'); - expect(r.stdout).toContain('dry-run'); - expect(r.stdout).toContain('filter vars'); + const r = await h.run(['deploy', 'worker-prod', '--dry-run', '--yes']); + if (r.code !== 0) { + console.error('STDOUT:', r.stdout); + console.error('STDERR:', r.stderr); + } + expect(r.code).toBe(0); + expect(r.stdout).toContain('worker-prod'); + expect(r.stdout).toContain('cf-worker'.length > 0 ? 'Cloudflare Workers' : 'cf-worker'); + expect(r.stdout).toContain('dry-run'); + expect(r.stdout).toContain('filter vars'); + }); }); - test.if(HAS_WRANGLER)('dry-run accepts mixed vars (the picker is the gate, not preflight)', () => { + test.if(HAS_WRANGLER)('dry-run accepts mixed vars (the picker is the gate, not preflight)', async () => { // Once a target is saved, capy trusts the user's earlier picker choice. // No silent var filtering, no preflight refusal — the picker pre-selects // what's likely relevant and surfaces what's not, but lets the user // toggle anything in or out. - writeKeep(ROOT); - writeWranglerToml(join(ROOT, 'worker'), 'my-worker'); - writeFileSync(join(ROOT, '.env'), 'SUPABASE_URL=x\nVITE_API_URL=y\n'); - writeDeployConfig(ROOT, [ - { - name: 'mixed', - kind: 'cf-worker', - branch: 'production', - vars: ['SUPABASE_URL', 'VITE_API_URL'], - options: { workerName: 'my-worker', workerDir: 'worker' }, - }, - ]); - const r = capy(['deploy', 'mixed', '--dry-run', '--yes']); - expect(r.code).toBe(0); - expect(r.stdout).toContain('VITE_API_URL'); + await withHarness(async (h) => { + syncedProject(h, { SUPABASE_URL: 'x', VITE_API_URL: 'y' }); + writeWranglerToml(join(h.project, 'worker'), 'my-worker'); + writeDeployConfig(h.project, [ + { + name: 'mixed', + kind: 'cf-worker', + branch: 'production', + vars: ['SUPABASE_URL', 'VITE_API_URL'], + options: { workerName: 'my-worker', workerDir: 'worker' }, + }, + ]); + const r = await h.run(['deploy', 'mixed', '--dry-run', '--yes']); + expect(r.code).toBe(0); + expect(r.stdout).toContain('VITE_API_URL'); + }); }); }); diff --git a/tests/commands/deployDirectModeTiming.test.ts b/tests/commands/deployDirectModeTiming.test.ts index a9595332..e90785a4 100644 --- a/tests/commands/deployDirectModeTiming.test.ts +++ b/tests/commands/deployDirectModeTiming.test.ts @@ -34,6 +34,8 @@ import { SyncEngine } from '../../src/sync/syncEngine'; const REPO_FOR_MOCK = join(tmpdir(), `capy-deploy-timing-${process.pid}`, 'repo'); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); + mock.module('../../src/auth/authService', () => ({ AuthService: class { async authenticateSilent() { @@ -51,6 +53,12 @@ mock.module('../../src/service/serviceClient', () => ({ // Always "in sync" — reads local keep.lock the same working-copy-first // way the real code under test does, so this pre-check never refuses. // Staleness itself is covered separately (deployRevokeWiring.test.ts). + // The same server, for the compare-before-delivering step (CAP-717) and the commit path. + async getDecryptData(_projectId: string, branch: string) { + const keep = new ProjectManager(REPO_FOR_MOCK).readKeepFile(); + if (!keep) throw new Error('test fixture has no keep.lock to read'); + return agreeingDecryptData(keep, branch); + } async getLatestSecrets(_projectId: string, branch: string) { const keep = new ProjectManager(REPO_FOR_MOCK).readKeepFile(); if (!keep) return null; @@ -59,7 +67,12 @@ mock.module('../../src/service/serviceClient', () => ({ }, })); +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; import { deployCommand } from '../../src/commands/deployCommand'; +import { hashValue } from '../../src/sync/compareSecrets'; + +/** `.env`'s value; keep.lock pins its real fingerprint (the folder agrees with Capy, so the deploy has nothing of its own to commit). */ +const SUPABASE_VALUE = 'https://x.supabase.co'; const HAS_WRANGLER = spawnSync('which', ['wrangler']).status === 0; @@ -88,7 +101,7 @@ function workingKeepWithPins(): string { project_id: 'proj-1', project_name: 'test-project', variables: { - SUPABASE_URL: [{ resource_id: 'r1', branch: 'production', value_hash: 'h1' }], + SUPABASE_URL: [{ resource_id: 'r1', branch: 'production', value_hash: hashValue(SUPABASE_VALUE) }], }, }); } @@ -143,7 +156,7 @@ function setUpRepo(): void { // Plaintext .env (gitignored) so decryptCurrentBranch never needs a real // project key — it only decrypts values that start with `capy:`. - writeFileSync(join(REPO, '.env'), 'SUPABASE_URL=https://x.supabase.co\n'); + writeFileSync(join(REPO, '.env'), `SUPABASE_URL=${SUPABASE_VALUE}\n`); } describe.if(HAS_WRANGLER)('deploy direct mode: keep.lock timing (real git, no mocks)', () => { @@ -198,7 +211,7 @@ describe.if(HAS_WRANGLER)('deploy direct mode: keep.lock timing (real git, no mo expect(git(['rev-parse', 'HEAD'], REPO).stdout.trim()).not.toBe(headBefore); const committed = JSON.parse(git(['show', 'HEAD:keep.lock'], REPO).stdout); expect(Object.keys(committed.variables)).toEqual(['SUPABASE_URL']); - expect(committed.variables.SUPABASE_URL[0].value_hash).toBe('h1'); + expect(committed.variables.SUPABASE_URL[0].value_hash).toBe(hashValue(SUPABASE_VALUE)); // The working copy itself was never written to by any of this. expect(readFileSync(join(REPO, '.capy', 'keep.lock'), 'utf-8')).toBe(workingKeepWithPins()); diff --git a/tests/commands/deployDokploySystemStoreToken.test.ts b/tests/commands/deployDokploySystemStoreToken.test.ts index b65ee961..ae45bab6 100644 --- a/tests/commands/deployDokploySystemStoreToken.test.ts +++ b/tests/commands/deployDokploySystemStoreToken.test.ts @@ -34,11 +34,17 @@ mock.module('../../src/auth/authService', () => ({ }, silentAuthFailureMessage: () => 'auth failed', })); +// CAP-717: a deploy compares the values it delivers with Capy's latest before it writes, so the server answers +// (in agreement with the folder: nothing here is about conflicts). mock.module('../../src/service/serviceClient', () => ({ ServiceClient: class { setTokenProvider() {} + async getDecryptData() { + return agreeingDecryptData(JSON.parse(readFileSync(join(ROOT, 'keep.lock'), 'utf-8')), 'production'); + } }, })); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); const mintDeployTokenMock = mock(async () => ({ secretsBlob: 'BLOB_VALUE', projectKey: 'KEY_VALUE', @@ -53,17 +59,23 @@ afterEach(() => { mintDeployTokenMock.mockClear(); }); -import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; import { deployCommand, deployRemove } from '../../src/commands/deployCommand'; import { mergeManagedValuesBlock } from '../../src/deploy/dokployApi'; +import { hashValue } from '../../src/sync/compareSecrets'; const ROOT = join(tmpdir(), `capy-deploy-dokploy-store-token-${process.pid}-${Date.now()}`); const ROOT_NO_KEEP = join(tmpdir(), `capy-deploy-dokploy-store-token-no-keep-${process.pid}-${Date.now()}`); const APP_ID = 'app_test'; -/** An active line for STRIPE_KEY already outside the block — gets COMMENTED, not deleted, once Capy delivers it. */ -const RAW_ENV = 'STRIPE_KEY=stale\n'; +/** + * The service's env before the deploy. (It used to hold a different active `STRIPE_KEY` outside the block, which the + * deploy silently commented out; CAP-717 makes that a conflict that needs an answer, which is not what this file is + * about. The comment-out itself is covered in tests/deploy/dokploy.test.ts and tests/deploy/dokployTargetConflict.test.ts.) + */ +const RAW_ENV = 'NODE_ENV=production\n'; /** `.env`'s own plaintext value for STRIPE_KEY (see `setUp`) — a fake, non-secret test value. */ const DECRYPTED_STRIPE_KEY = 'whatever'; @@ -85,7 +97,8 @@ function setUp(targetOptions: Record): void { org_id: 'org_1', project_id: 'proj_1', project_name: 'demo', - variables: { STRIPE_KEY: [{ resource_id: 'r-1', branch: 'production', value_hash: 'h' }] }, + // The pin is the real fingerprint of `.env`'s value: the folder agrees with Capy, so the deploy commits nothing (an edit would be committed first). + variables: { STRIPE_KEY: [{ resource_id: 'r-1', branch: 'production', value_hash: hashValue('whatever') }] }, }), ); writeFileSync(join(ROOT, '.env'), 'STRIPE_KEY=whatever\n'); @@ -214,9 +227,10 @@ function fakeSystemStore(entry: string | null) { } /** - * `application.one` is read exactly 3 times in a SUCCESSFUL CI-mode deploy - * (preflight, deploy's fresh read, deploy's post-write verify) — RAW_ENV for - * the first two, the merged env once the write has landed. A fixed-sequence + * `application.one` is read exactly 4 times in a SUCCESSFUL CI-mode deploy + * (preflight, the conflict check before anything is written (CAP-717), deploy's + * fresh read, deploy's post-write verify) — RAW_ENV for the first three, the + * merged env once the write has landed. A fixed-sequence * iterator (same idea `tests/deploy/dokploy.test.ts`'s own `scripted()` * helper and `deployDokployWarningOnce.test.ts` use) hands back each read in * order — no mutable variable of our own, only the iterator's own built-in @@ -225,7 +239,7 @@ function fakeSystemStore(entry: string | null) { * own call count instead. */ function dokployFetchMock() { - const reads = [RAW_ENV, RAW_ENV, EXPECTED_MERGED_ENV][Symbol.iterator](); + const reads = [RAW_ENV, RAW_ENV, RAW_ENV, EXPECTED_MERGED_ENV][Symbol.iterator](); return mock(async (url: string, init: { method: string; headers: Record; body?: string }) => { const u = new URL(url); if (u.pathname.endsWith('application.saveEnvironment')) { diff --git a/tests/commands/deployDokployWarningOnce.test.ts b/tests/commands/deployDokployWarningOnce.test.ts index c76facb8..7f4341f9 100644 --- a/tests/commands/deployDokployWarningOnce.test.ts +++ b/tests/commands/deployDokployWarningOnce.test.ts @@ -37,11 +37,17 @@ mock.module('../../src/auth/authService', () => ({ }, silentAuthFailureMessage: () => 'auth failed', })); +// CAP-717: a deploy compares the values it delivers with Capy's latest before it writes, so the server answers +// (in agreement with the folder: nothing here is about conflicts). mock.module('../../src/service/serviceClient', () => ({ ServiceClient: class { setTokenProvider() {} + async getDecryptData() { + return agreeingDecryptData(JSON.parse(readFileSync(join(ROOT, 'keep.lock'), 'utf-8')), 'production'); + } }, })); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); const mintDeployTokenMock = mock(async () => ({ secretsBlob: 'BLOB_VALUE', projectKey: 'KEY_VALUE', @@ -53,11 +59,13 @@ mock.module('../../src/commands/deployTokenCommand', () => ({ })); afterAll(() => mock.restore()); -import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; import { deployCommand } from '../../src/commands/deployCommand'; import { mergeManagedValuesBlock } from '../../src/deploy/dokployApi'; +import { hashValue } from '../../src/sync/compareSecrets'; const ROOT = join(tmpdir(), `capy-deploy-dokploy-warn-once-${process.pid}-${Date.now()}`); const COMPOSE_ID = 'compose_test'; @@ -84,7 +92,8 @@ function setUp(): void { org_id: 'org_1', project_id: 'proj_1', project_name: 'demo', - variables: { STRIPE_KEY: [{ resource_id: 'r-1', branch: 'production', value_hash: 'h' }] }, + // The pin is the real fingerprint of `.env`'s value: the folder agrees with Capy, so the deploy commits nothing (an edit would be committed first). + variables: { STRIPE_KEY: [{ resource_id: 'r-1', branch: 'production', value_hash: hashValue(DECRYPTED_STRIPE_KEY) }] }, }), ); writeFileSync(join(ROOT, '.env'), `STRIPE_KEY=${DECRYPTED_STRIPE_KEY}\n`); @@ -114,9 +123,10 @@ function tearDown(): void { } /** - * `compose.one` is read exactly 3 times in CI mode (preflight, deploy's - * fresh read, deploy's post-write verify) — RAW_ENV for the first two, - * EXPECTED_MERGED_ENV once the write has landed. `settings.getDokployVersion` + * `compose.one` is read exactly 4 times in CI mode (preflight, the conflict + * check before anything is written (CAP-717), deploy's fresh read, deploy's + * post-write verify) — RAW_ENV for the first three, EXPECTED_MERGED_ENV once + * the write has landed. `settings.getDokployVersion` * is read once, in preflight, to decide the `DOKPLOY_STACK_QUOTES` warning. * A fixed-sequence iterator (the same idea `tests/deploy/dokploy.test.ts`'s * own `scripted()` helper uses) hands back each read in order with no @@ -124,7 +134,7 @@ function tearDown(): void { * advances. */ function scriptedDokployFetch(): typeof fetch { - const reads = [RAW_ENV, RAW_ENV, EXPECTED_MERGED_ENV][Symbol.iterator](); + const reads = [RAW_ENV, RAW_ENV, RAW_ENV, EXPECTED_MERGED_ENV][Symbol.iterator](); const impl = (async (url: string, init: { method: string; body?: string }) => { const u = new URL(url); if (u.pathname.endsWith('compose.saveEnvironment')) { diff --git a/tests/commands/deployGate.test.ts b/tests/commands/deployGate.test.ts new file mode 100644 index 00000000..b7952191 --- /dev/null +++ b/tests/commands/deployGate.test.ts @@ -0,0 +1,276 @@ +/** + * `capy deploy` after bare `capy` (docs/conflict-resolution.md): the real `deployCommand()` over a real folder, with the + * REAL gate (`CapyCommand.runSync`) on an in-memory Capy server (tests/helpers/fakeCapyServer.ts) and a scripted Dokploy + * over `fetch`. Only the network edges are replaced. + * + * Vince's case: the folder is pinned to GEMINI_TIMEOUT_SECONDS=120 and its `.env` holds 120, while Capy holds 180 (a + * teammate saved). Deploying a target that delivers GEMINI: + * - the difference is shown by bare `capy` BEFORE anything else: the Dokploy preflight and the confirm screen come after; + * - off a terminal (`--yes`): exit 3 with that row (fingerprints only), Dokploy not contacted, nothing delivered or saved; + * - `--resolve GEMINI_TIMEOUT_SECONDS=local --conflict `: 120 is committed to Capy, THEN delivered; + * - `=remote`: 180 is delivered and the folder takes it. + * Asserted on what Capy's latest decrypts to and on the body Dokploy was sent. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, spyOn, afterEach, beforeAll, afterAll } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { FIXED_PROJECT_KEY } from '../helpers/deployServer'; +import { fakeCapyServer, seedServer, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import type { KeepFile } from '../../src/types/index'; + +const KEY = FIXED_PROJECT_KEY; +const BRANCH = 'production'; +const NAME = 'GEMINI_TIMEOUT_SECONDS'; +const APP_ID = 'app_gate'; +const TOKEN_ENV = 'DEPLOY_GATE_TEST_TOKEN'; + +/** One ordered log of what happened, so "before" is an assertion about order. */ +const event = mock((_name: string) => undefined); +const events = (): readonly string[] => event.mock.calls.map((c) => String(c[0])); +const serverOf = mock((): ReturnType | undefined => undefined); +const menu = mock((): { action: string } => ({ action: 'skip' })); + +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + setSessionUserId() {} + async authenticateSilent() { + return { success: true, user_id: 'user-b', organization_id: 'org', user_email: 'b@example.test', _auth_method: 'cached' }; + } + getLastRefreshFailure() { + return null; + } + getToken() { + return { access_token: 't', refresh_token: 'r', expires_at: Date.now() + 3_600_000 }; + } + async getValidToken() { + return 't'; + } + }, + silentAuthFailureMessage: () => 'no session', +})); +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + getDecryptData(...args: unknown[]) { + return serverOf()!.getDecryptData(...(args as [string, string])); + } + pushSecrets(...args: unknown[]) { + event('capy.push'); + return serverOf()!.pushSecrets(...(args as Parameters['pushSecrets']>)); + } + async getLatestSecrets(_projectId: string, branch: string) { + const state = serverOf()!.state(); + const hash = SyncEngine.computeKeepHash(state.keep as KeepFile, branch); + return { env_file: state.blobs[hash] ?? '', keep_hash: hash, keep_file: JSON.stringify(state.keep) }; + } + async listBranches() { + return []; + } + }, +})); +mock.module('../../src/crypto/keyResolver', () => ({ resolveProjectKey: async () => KEY, hasOrgKey: () => true })); +mock.module('../../src/commands/deployTokenCommand', () => ({ mintDeployToken: async () => ({ secretsBlob: 'B', projectKey: 'K', deployId: 'd', secretCount: 1 }) })); +mock.module('inquirer', () => ({ + default: { + prompt: async () => { + event('menu'); + return menu(); + }, + }, +})); +mock.module('../../src/ui/keypressConfirm', () => ({ + keypressConfirm: async () => { + event('confirm'); + return 'cancel'; + }, +})); + +import { deployCommand } from '../../src/commands/deployCommand'; +import { mergeManagedValuesBlock } from '../../src/deploy/dokployApi'; +import { hashValue } from '../../src/sync/compareSecrets'; + +const globalDirName = `.capy-test-deploy-gate-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +const ORIGINAL_CWD = process.cwd(); +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +const made = mock((_: Folder) => undefined); +afterEach(() => { + process.chdir(ORIGINAL_CWD); + made.mock.calls.forEach(([folder]) => removeFolder(folder)); + made.mockClear(); + serverOf.mockReset(); + event.mockClear(); + menu.mockReset(); + menu.mockImplementation(() => ({ action: 'skip' })); +}); + +/** The folder (pinned to 120, `.env` 120, a deploy target delivering GEMINI) and Capy (holding 180). */ +function world() { + const server = fakeCapyServer({ initial: seedServer({ projectKey: KEY, values: { [BRANCH]: { [NAME]: '180' } } }) }); + serverOf.mockReturnValue(server); + const folder = makeFolder({ projectKey: KEY, branch: BRANCH, keep: seedServer({ projectKey: KEY, values: { [BRANCH]: { [NAME]: '120' } } }).keep as KeepFile, env: { [NAME]: '120' }, client: server.client }); + made(folder); + mkdirSync(join(folder.dir, '.capy'), { recursive: true }); + writeFileSync(join(folder.dir, '.capy', 'branch'), BRANCH); + writeFileSync( + join(folder.dir, '.capy', 'deploy.json'), + JSON.stringify({ + version: '1', + targets: { + 'dokploy-ci': { + name: 'dokploy-ci', + kind: 'dokploy', + branch: BRANCH, + vars: [NAME], + mode: 'ci', + gitBaseBranch: 'main', + options: { baseUrl: 'https://dokploy.example.com', applicationId: APP_ID, tokenEnv: TOKEN_ENV }, + }, + }, + }), + ); + process.chdir(folder.dir); + return { server, folder }; +} + +const RAW_ENV = 'NODE_ENV=production\n'; +const merged = (value: string): string => { + const m = mergeManagedValuesBlock(RAW_ENV, [{ name: NAME, value }]); + if (!m.ok) throw new Error('fixture'); + return m.env; +}; + +/** `application.one` reads from `reads`, in order; every call is logged as `dokploy`. */ +function dokployFetch(reads: readonly string[]) { + const it = reads[Symbol.iterator](); + return mock(async (url: string) => { + event('dokploy'); + const path = new URL(url).pathname; + if (path.endsWith('application.saveEnvironment')) return { status: 200, ok: true, text: async () => 'true' }; + if (path.endsWith('application.one')) { + const next = it.next(); + if (next.done) throw new Error('unscripted extra application.one read'); + return { + status: 200, + ok: true, + text: async () => + JSON.stringify({ applicationId: APP_ID, name: 'demo-app', env: next.value, buildArgs: null, buildSecrets: null, createEnvFile: true, autoDeploy: true, branch: 'main' }), + }; + } + throw new Error(`unscripted Dokploy request: ${path}`); + }); +} + +async function run(fetchMock: ReturnType, options: Parameters[1], cwd: string, tty: boolean = false) { + const logSpy = spyOn(console, 'log').mockImplementation((() => {}) as never); + const errSpy = spyOn(console, 'error').mockImplementation((() => {}) as never); + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(fetchMock as never); + const ttySpy = spyOn(process.stdin, 'isTTY', 'get' as never).mockReturnValue(tty as never); + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit_${code}__`); + }) as never); + const saved = process.env[TOKEN_ENV]; + process.env[TOKEN_ENV] = 'dk_test_token'; + try { + const code = await deployCommand('dokploy-ci', options, cwd).catch((err: unknown) => { + const m = err instanceof Error ? err.message : String(err); + if (!m.startsWith('__exit_')) throw err; + return Number(m.slice('__exit_'.length, -2)); + }); + return { code, stdout: logSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'), stderr: errSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n') }; + } finally { + if (saved === undefined) delete process.env[TOKEN_ENV]; + else process.env[TOKEN_ENV] = saved; + logSpy.mockRestore(); + errSpy.mockRestore(); + fetchSpy.mockRestore(); + ttySpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +const saves = (f: ReturnType) => f.mock.calls.filter((c) => String(c[0]).endsWith('saveEnvironment')); +const row = { name: NAME, project: 'demo', branch: BRANCH, type: 'changed', pinned: hashValue('120'), local: hashValue('120'), remote: hashValue('180') }; + +describe('Vince\'s case: folder 120, .env 120, Capy 180, deploy a target delivering GEMINI', () => { + test('at a terminal: bare capy\'s table and menu come BEFORE the Dokploy preflight and the confirm screen', async () => { + const w = world(); + menu.mockImplementation(() => ({ action: 'retrieve_remote' })); + const f = dokployFetch([RAW_ENV, RAW_ENV]); + await run(f, {}, w.folder.dir, true); + const log = events(); + expect(log[0]).toBe('menu'); + expect(log.indexOf('menu')).toBeLessThan(log.indexOf('dokploy')); + expect(log.indexOf('menu')).toBeLessThan(log.indexOf('confirm')); + // The person chose Capy's value at the table: the folder has it by the time the confirm screen is up. + expect(w.folder.env()[NAME]).toBe('180'); + }); + + test('off a terminal (--yes): exit 3 with the row alone on stdout; Dokploy is not contacted; nothing is delivered or saved', async () => { + const w = world(); + const f = dokployFetch([]); + const out = await run(f, { yes: true }, w.folder.dir); + expect(out.code).toBe(3); + const payload = JSON.parse(out.stdout) as Record; + expect(Object.keys(payload).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); + expect(payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(payload.conflicts).toEqual([row]); + expect(f).not.toHaveBeenCalled(); + expect(`${out.stdout}${out.stderr}`).not.toContain('120'); + expect(`${out.stdout}${out.stderr}`).not.toContain('180'); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.folder.env()[NAME]).toBe('120'); + }, 30_000); + + test('--resolve =local: 120 is committed to Capy, THEN delivered to Dokploy', async () => { + const w = world(); + const first = JSON.parse((await run(dokployFetch([]), { yes: true }, w.folder.dir)).stdout) as { conflict_id: string }; + const f = dokployFetch([RAW_ENV, RAW_ENV, RAW_ENV, merged('120')]); + const out = await run(f, { yes: true, conflictFlags: { resolve: [`${NAME}=local`], conflict: first.conflict_id } }, w.folder.dir); + expect(out.code).toBe(0); + expect(serverValues(w.server.state(), BRANCH, KEY)[NAME]).toBe('120'); + expect(saves(f)).toHaveLength(1); + expect(JSON.parse(String(saves(f)[0][1].body)).env).toContain(`${NAME}=120`); + // Capy was written to BEFORE Dokploy: the first push to Capy precedes the first Dokploy request in the shared log. + expect(events().indexOf('capy.push')).toBeGreaterThanOrEqual(0); + expect(events().indexOf('capy.push')).toBeLessThan(events().indexOf('dokploy')); + expect(w.folder.env()[NAME]).toBe('120'); + }, 30_000); + + test('--resolve =remote: 180 is delivered, and the folder takes it', async () => { + const w = world(); + const first = JSON.parse((await run(dokployFetch([]), { yes: true }, w.folder.dir)).stdout) as { conflict_id: string }; + const f = dokployFetch([RAW_ENV, RAW_ENV, RAW_ENV, merged('180')]); + const out = await run(f, { yes: true, conflictFlags: { resolve: [`${NAME}=remote`], conflict: first.conflict_id } }, w.folder.dir); + expect(out.code).toBe(0); + expect(saves(f)).toHaveLength(1); + expect(JSON.parse(String(saves(f)[0][1].body)).env).toContain(`${NAME}=180`); + expect(serverValues(w.server.state(), BRANCH, KEY)[NAME]).toBe('180'); + expect(w.folder.env()[NAME]).toBe('180'); + expect(w.folder.pin(NAME)).toBe(hashValue('180')); + }, 30_000); + + test('--dry-run reports the same row and id with dry_run, and changes nothing', async () => { + const w = world(); + const real = JSON.parse((await run(dokployFetch([]), { yes: true }, w.folder.dir)).stdout) as { conflict_id: string }; + const f = dokployFetch([]); + const out = await run(f, { yes: true, dryRun: true }, w.folder.dir); + expect(out.code).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflict_id: real.conflict_id, conflicts: [row] }); + expect(f).not.toHaveBeenCalled(); + expect(w.folder.env()[NAME]).toBe('120'); + }, 30_000); +}); diff --git a/tests/commands/deployRevokeWiring.test.ts b/tests/commands/deployRevokeWiring.test.ts index 9efb76d7..af5694eb 100644 --- a/tests/commands/deployRevokeWiring.test.ts +++ b/tests/commands/deployRevokeWiring.test.ts @@ -24,18 +24,18 @@ * the adapter that recorded them still mints tokens at all (the LAST * test: two pre-recorded `superseded_deploy_ids` plus the current one). * - * CAP-687 (validator fix-first): `pushKeepTransform` no longer builds the - * pushed env blob from local `.env` at all — it fetches the server's OWN - * current snapshot for the branch (`getLatestSecrets`) and re-sends that - * blob unchanged, after verifying its `keep_hash` matches the local keep's - * branch entries. The mocked `ServiceClient` below fakes that snapshot by - * reading + hashing `ROOT/keep.lock` itself (via the REAL `SyncEngine`, not - * mocked here) — always "in sync", since this file is about the REVOKE - * WIRING, not the drift guard (see `deployCiTargetsRecord.test.ts` for - * that). No crypto mocking is needed any more: `pushKeepTransform` never - * touches `resolveProjectKey`/`Encryptor`/`deriveResourceId`, and this - * fixture's `.env` is plain (unencrypted), so `decryptCurrentBranch` never - * needs them either. + * CAP-687 (validator fix-first): `pushKeepTransform` never builds the + * pushed env blob from local `.env` — it fetches the server's OWN current + * snapshot for the branch and re-sends that blob unchanged, after + * verifying its `keep_hash` matches the local keep's branch entries. The + * mocked `ServiceClient` below fakes that snapshot by reading + hashing + * `ROOT/keep.lock` itself (via the REAL `SyncEngine`, not mocked here) — + * always "in sync", since this file is about the REVOKE WIRING, not the + * drift guard (see `deployCiTargetsRecord.test.ts` for that). The record is + * now written through the one commit path (CAP-717/712), which reads the + * server's latest with `getDecryptData` and needs the project key, so + * `resolveProjectKey` is mocked to a fixed key; the server's lines in this + * fixture are ones nobody holds the key for, so no value is ever decrypted. * * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). */ @@ -45,6 +45,7 @@ import { join } from 'path'; import { tmpdir } from 'os'; import { SyncEngine } from '../../src/sync/syncEngine'; import { ProjectManager } from '../../src/core/projectManager'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; /** Sentinel filename (see the mocked `getLatestSecrets` below) — its mere presence next to `ROOT/keep.lock` models a stale local keep.lock. */ const STALE_KEEP_MARKER = '.simulate-stale-keep'; @@ -78,6 +79,8 @@ mock.module('../../src/auth/authService', () => ({ silentAuthFailureMessage: () => 'auth failed', })); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); + const revokeDeployTokenMock = mock(async (_id: string) => ({})); const pushSecretsMock = mock(async (_projectId: string, keepFileJson: string, _envBlob: string, _branch: string) => ({ keep_hash: 'h'.repeat(16), @@ -103,6 +106,15 @@ mock.module('../../src/service/serviceClient', () => ({ // that can never match the local file's real one. `SERVER_SNAPSHOT_OVERRIDE` // (also below) models an independent server snapshot instead of // deriving one from the local read at all. + // The same server, for the commit path (which reads the server's latest before it writes). + async getDecryptData(_projectId: string, branch: string) { + const overridePath = join(ROOT, SERVER_SNAPSHOT_OVERRIDE); + const keep = existsSync(overridePath) + ? JSON.parse(readFileSync(overridePath, 'utf-8')) + : new ProjectManager(ROOT).readKeepFile(); + if (!keep) throw new Error('test fixture has no keep.lock to read'); + return agreeingDecryptData(keep, branch); + } async getLatestSecrets(_projectId: string, branch: string) { const overridePath = join(ROOT, SERVER_SNAPSHOT_OVERRIDE); const keep = existsSync(overridePath) @@ -144,10 +156,15 @@ afterEach(() => { import { deployCommand, deployRemove } from '../../src/commands/deployCommand'; import { mergeManagedValuesBlock } from '../../src/deploy/dokployApi'; +import { hashValue } from '../../src/sync/compareSecrets'; const APP_ID = 'app_revoke_test'; -/** An active line for STRIPE_KEY already outside the block — gets COMMENTED once Capy delivers it. */ -const RAW_ENV = 'STRIPE_KEY=stale\n'; +/** + * The service's env before the deploy. (It used to hold a different active `STRIPE_KEY` outside the block, which the + * deploy silently commented out; since CAP-717 that is a conflict that needs an answer, which is not what this file is + * about. The comment-out itself is covered in tests/deploy/dokploy.test.ts and tests/deploy/dokployTargetConflict.test.ts.) + */ +const RAW_ENV = 'NODE_ENV=production\n'; /** `.env`'s own plaintext value for STRIPE_KEY (see `setUp`) — a fake, non-secret test value. */ const DECRYPTED_STRIPE_KEY = 'whatever'; @@ -179,12 +196,12 @@ function setUp(opts: { mode?: 'ci' | 'direct'; priorSuperseded?: string[]; gitBa { resource_id: 'r-1', branch: 'production', - value_hash: 'h', + value_hash: hashValue('whatever'), targets: [ { provider: 'dokploy', target: 'dokploy-direct', - deployed_value_hash: 'h', + deployed_value_hash: hashValue('whatever'), deployed_at: '2026-01-01T00:00:00.000Z', deploy_id: 'dep_prior', ...(opts.priorSuperseded?.length ? { superseded_deploy_ids: opts.priorSuperseded } : {}), @@ -237,7 +254,8 @@ function tearDown(): void { */ function dokployFetchMock(opts: { failWrite?: boolean; realDeploy?: boolean } = {}) { const expectedMergedEnv = mergedEnv(RAW_ENV); - const reads = [RAW_ENV, RAW_ENV, expectedMergedEnv][Symbol.iterator](); + // preflight, the conflict check before anything is written (CAP-717), deploy's fresh read, the post-write verify. + const reads = [RAW_ENV, RAW_ENV, RAW_ENV, expectedMergedEnv][Symbol.iterator](); const deploymentReads = [ [] as const, [{ deploymentId: 'dep_triggered', status: 'done' as const, createdAt: '2026-01-01T00:00:00.000Z' }], diff --git a/tests/commands/deployTargetConflict.test.ts b/tests/commands/deployTargetConflict.test.ts new file mode 100644 index 00000000..08247bbb --- /dev/null +++ b/tests/commands/deployTargetConflict.test.ts @@ -0,0 +1,215 @@ +/** + * A REAL `deployCommand()` run against a Dokploy CI-mode target whose live env holds a value changed outside Capy + * (CAP-717, docs/conflict-resolution.md "Platform values"). Same vehicle as deployDokploySystemStoreToken.test.ts: + * scripted Dokploy over `fetch`, mocked auth and Capy server; bare `capy` (a deploy's first step) is stubbed in + * agreement, so the only difference here is the PLATFORM one. + * + * The properties: off a terminal the deploy stops BEFORE anything is written (exit 3, the payload alone on + * stdout, Dokploy saw only reads); a rerun with `--resolve NAME@TARGET=local --conflict ` delivers Capy's value + * and comments the outside line out; `remote` keeps the platform's value: nothing written and no delivery + * recorded; a moved service is STALE. + * + * mock.module is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, spyOn, afterEach } from 'bun:test'; +import { stubbedGate } from '../helpers/gateMock'; + +mock.module('../../src/sync/gate', () => stubbedGate()); + +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: 'user_1' }; + } + async getValidToken() { + return 'fake-session-token'; + } + }, + silentAuthFailureMessage: () => 'auth failed', +})); +const pushSecretsMock = mock(async (_p: string, keepFileJson: string) => ({ keep_hash: 'h'.repeat(16), keep_file: keepFileJson })); +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + async getDecryptData() { + return agreeingDecryptData(JSON.parse(readFileSync(join(ROOT, 'keep.lock'), 'utf-8')), 'production'); + } + async getLatestSecrets(_p: string, branch: string) { + const keep = JSON.parse(readFileSync(join(ROOT, 'keep.lock'), 'utf-8')); + return { env_file: 'STUB', keep_hash: SyncEngine.computeKeepHash(keep, branch), keep_file: JSON.stringify(keep) }; + } + async pushSecrets(...args: [string, string]) { + return pushSecretsMock(...args); + } + }, +})); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); +const mintDeployTokenMock = mock(async () => ({ secretsBlob: 'B', projectKey: 'K', deployId: 'd', secretCount: 1 })); +mock.module('../../src/commands/deployTokenCommand', () => ({ mintDeployToken: mintDeployTokenMock })); +afterEach(() => { + mock.restore(); + pushSecretsMock.mockClear(); +}); + +import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { deployCommand } from '../../src/commands/deployCommand'; +import { CAPY_OFF_MARKER, mergeManagedValuesBlock } from '../../src/deploy/dokployApi'; +import { hashValue } from '../../src/sync/compareSecrets'; + +const ROOT = join(tmpdir(), `capy-deploy-target-conflict-${process.pid}-${Date.now()}`); +const APP_ID = 'app_target_conflict'; +const TOKEN_ENV = 'TARGET_CONFLICT_TEST_TOKEN'; +const DELIVERED = 'delivered-by-capy'; +const OUTSIDE = 'edited-on-the-service'; +const RAW_ENV = `NODE_ENV=production\nSTRIPE_KEY=${OUTSIDE}\n`; + +function setUp(): void { + rmSync(ROOT, { recursive: true, force: true }); + mkdirSync(join(ROOT, '.capy'), { recursive: true }); + writeFileSync( + join(ROOT, 'keep.lock'), + JSON.stringify({ + version: '3.0', + org_id: 'org_1', + project_id: 'proj_1', + project_name: 'demo', + variables: { STRIPE_KEY: [{ resource_id: 'r-1', branch: 'production', value_hash: hashValue(DELIVERED) }] }, + }), + ); + writeFileSync(join(ROOT, '.env'), `STRIPE_KEY=${DELIVERED}\n`); + writeFileSync( + join(ROOT, '.capy', 'deploy.json'), + JSON.stringify({ + version: '1', + targets: { + 'dokploy-ci': { + name: 'dokploy-ci', + kind: 'dokploy', + branch: 'production', + vars: ['STRIPE_KEY'], + mode: 'ci', + gitBaseBranch: 'main', + options: { baseUrl: 'https://dokploy.example.com', applicationId: APP_ID, tokenEnv: TOKEN_ENV }, + }, + }, + }), + ); +} + +const MERGED = (() => { + const merged = mergeManagedValuesBlock(RAW_ENV, [{ name: 'STRIPE_KEY', value: DELIVERED }]); + if (!merged.ok) throw new Error('fixture'); + return merged.env; +})(); + +/** `application.one` reads from `reads`, in order; `saveEnvironment` accepted (and recorded by the mock's own calls). */ +function dokployFetch(reads: readonly string[]) { + const it = reads[Symbol.iterator](); + return mock(async (url: string) => { + const path = new URL(url).pathname; + if (path.endsWith('application.saveEnvironment')) return { status: 200, ok: true, text: async () => 'true' }; + if (path.endsWith('application.one')) { + const next = it.next(); + if (next.done) throw new Error('unscripted extra application.one read'); + return { + status: 200, + ok: true, + text: async () => + JSON.stringify({ applicationId: APP_ID, name: 'demo-app', env: next.value, buildArgs: null, buildSecrets: null, createEnvFile: true, autoDeploy: true, branch: 'main' }), + }; + } + throw new Error(`unscripted Dokploy request: ${path}`); + }); +} + +async function run(fetchMock: ReturnType, options: Parameters[1] = { yes: true }) { + const logSpy = spyOn(console, 'log').mockImplementation((() => {}) as never); + const errSpy = spyOn(console, 'error').mockImplementation((() => {}) as never); + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(fetchMock as never); + const saved = process.env[TOKEN_ENV]; + process.env[TOKEN_ENV] = 'dk_test_token'; + try { + const code = await deployCommand('dokploy-ci', options, ROOT); + return { code, stdout: logSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'), stderr: errSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n') }; + } finally { + if (saved === undefined) delete process.env[TOKEN_ENV]; + else process.env[TOKEN_ENV] = saved; + logSpy.mockRestore(); + errSpy.mockRestore(); + fetchSpy.mockRestore(); + } +} + +const writes = (f: ReturnType) => f.mock.calls.filter((c) => String(c[0]).endsWith('saveEnvironment')); + +describe('capy deploy: a Dokploy value changed outside Capy', () => { + test('off a terminal: exit 3, the payload alone on stdout, and Dokploy was only READ', async () => { + setUp(); + const f = dokployFetch([RAW_ENV, RAW_ENV]); // preflight, the comparison + const out = await run(f); + expect(out.code).toBe(3); + const payload = JSON.parse(out.stdout) as { ok: boolean; code: string; conflict_id: string; conflicts: Array> }; + expect(payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(payload.conflict_id).toMatch(/^c_[0-9a-f]{12}$/); + expect(payload.conflicts).toEqual([ + { name: 'STRIPE_KEY', target: 'dokploy-ci', pinned: null, local: hashValue(DELIVERED), remote: hashValue(OUTSIDE) }, + ]); + expect(`${out.stdout}${out.stderr}`).not.toContain(OUTSIDE); + expect(`${out.stdout}${out.stderr}`).not.toContain(DELIVERED); + expect(writes(f)).toHaveLength(0); + expect(pushSecretsMock).not.toHaveBeenCalled(); + rmSync(ROOT, { recursive: true, force: true }); + }, 30_000); + + test('local: delivered, the outside line commented out as `# capy:off`', async () => { + setUp(); + const first = JSON.parse((await run(dokployFetch([RAW_ENV, RAW_ENV]))).stdout) as { conflict_id: string }; + // preflight, the comparison, deploy's fresh read, the read-back. + const f = dokployFetch([RAW_ENV, RAW_ENV, RAW_ENV, MERGED]); + const out = await run(f, { yes: true, conflictFlags: { resolve: ['STRIPE_KEY@dokploy-ci=local'], conflict: first.conflict_id } }); + expect(out.code).toBe(0); + expect(writes(f)).toHaveLength(1); + expect(JSON.parse(String(writes(f)[0][1].body)).env).toContain(`${CAPY_OFF_MARKER}STRIPE_KEY=${OUTSIDE}`); + rmSync(ROOT, { recursive: true, force: true }); + }, 30_000); + + test('remote: nothing is written to the service and no delivery is recorded', async () => { + setUp(); + const first = JSON.parse((await run(dokployFetch([RAW_ENV, RAW_ENV]))).stdout) as { conflict_id: string }; + const f = dokployFetch([RAW_ENV, RAW_ENV]); // the variable was the only one: the run ends after the comparison + const out = await run(f, { yes: true, conflictFlags: { resolve: ['STRIPE_KEY@dokploy-ci=remote'], conflict: first.conflict_id } }); + expect(out.code).toBe(0); + expect(writes(f)).toHaveLength(0); + expect(pushSecretsMock).not.toHaveBeenCalled(); + rmSync(ROOT, { recursive: true, force: true }); + }, 30_000); + + test('the service moved after the conflict was seen: STALE, a new id, nothing written', async () => { + setUp(); + const first = JSON.parse((await run(dokployFetch([RAW_ENV, RAW_ENV]))).stdout) as { conflict_id: string }; + const moved = `NODE_ENV=production\nSTRIPE_KEY=edited-again\n`; + const f = dokployFetch([moved, moved]); + const out = await run(f, { yes: true, conflictFlags: { resolve: ['STRIPE_KEY@dokploy-ci=local'], conflict: first.conflict_id } }); + expect(out.code).toBe(3); + const payload = JSON.parse(out.stdout) as { code: string; conflict_id: string }; + expect(payload.code).toBe('SECRETS_CONFLICT_STALE'); + expect(payload.conflict_id).not.toBe(first.conflict_id); + expect(writes(f)).toHaveLength(0); + rmSync(ROOT, { recursive: true, force: true }); + }, 30_000); + + test('--dry-run reports the same conflict_id and writes nothing', async () => { + setUp(); + const real = JSON.parse((await run(dokployFetch([RAW_ENV, RAW_ENV]))).stdout) as { conflict_id: string }; + const f = dokployFetch([RAW_ENV, RAW_ENV]); + const out = await run(f, { yes: true, dryRun: true }); + expect(out.code).toBe(3); + expect(JSON.parse(out.stdout)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflict_id: real.conflict_id }); + expect(writes(f)).toHaveLength(0); + rmSync(ROOT, { recursive: true, force: true }); + }, 30_000); +}); diff --git a/tests/commands/dryRunE2E.test.ts b/tests/commands/dryRunE2E.test.ts index 9c66189a..1dc1898a 100644 --- a/tests/commands/dryRunE2E.test.ts +++ b/tests/commands/dryRunE2E.test.ts @@ -310,13 +310,17 @@ describe('capy remove NAME --dry-run', () => { }); }); - test('a local drift on OTHER variables still refuses, as in a real run', async () => { + test('a local-only variable is a difference bare capy settles first (docs/conflict-resolution.md): the preview reports it with dry_run, exit 3, and changes nothing', async () => { await withWorld(async (h, env) => { await seedReal(h, env); - // A second variable that exists only locally (unpushed): removing NAME would carry it along. + // A second variable that exists only locally (unpushed): the folder and Capy differ, so nothing is written from this folder. await h.run(['edit', 'DRY_OTHER', '--json', '--no-push'], 'x\n', undefined, { ...env, CAPY_NO_REPO_LINK: '1' }); + const before = state(h); + const pushes = h.pushCount(); const out = await h.run(['remove', NAME, '--yes', '--json', '--dry-run'], undefined, undefined, env); - expect(json(out)).toMatchObject({ ok: false, code: 'REMOVE_LOCAL_DRIFT' }); + expect(out.code).toBe(3); + expect(json(out)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflicts: [{ name: 'DRY_OTHER', type: 'new' }] }); + expectNothingWritten(h, before, pushes); }); }); }); diff --git a/tests/commands/editPiped.test.ts b/tests/commands/editPiped.test.ts index 2a4f0020..edfcaae5 100644 --- a/tests/commands/editPiped.test.ts +++ b/tests/commands/editPiped.test.ts @@ -10,7 +10,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { createHarness, digest, BRANCH, DEV_CLI, type Harness } from '../helpers/pipedHarness'; +import { createHarness, withHarness, digest, BRANCH, DEV_CLI, type Harness } from '../helpers/pipedHarness'; const PEM = [ '-----BEGIN PRIVATE KEY-----', @@ -123,9 +123,12 @@ describe('capy edit NAME < value (piped)', () => { }); test('6. exactly 1 MiB is accepted; 1 MiB + 1 byte is STDIN_TOO_LARGE and nothing is written', async () => { - const atCap = await h().run(['edit', 'SPEC6_AT_CAP', '--json', '--no-push'], 'a'.repeat(1024 * 1024)); - expect(atCap.code).toBe(0); - expect(h().envValue('SPEC6_AT_CAP')?.length).toBe(1024 * 1024); + // --no-push leaves .env different from Capy, which the next write would stop on: its own world. + await withHarness(async (own) => { + const atCap = await own.run(['edit', 'SPEC6_AT_CAP', '--json', '--no-push'], 'a'.repeat(1024 * 1024)); + expect(atCap.code).toBe(0); + expect(own.envValue('SPEC6_AT_CAP')?.length).toBe(1024 * 1024); + }); const over = await h().run(['edit', 'SPEC6_OVER', '--json'], 'a'.repeat(1024 * 1024 + 1)); expect(over.code).toBe(1); @@ -163,15 +166,17 @@ describe('capy edit NAME < value (piped)', () => { }, 15000); test('8. --no-push updates .env only: no push call, and the result says so', async () => { - const pushesBefore = h().pushCount(); - const r = await h().run(['edit', 'SPEC8_LOCAL', '--json', '--no-push'], 'local-only\n'); - expect(r.code).toBe(0); - expect(parseJson(r.stdout)).toEqual({ ok: true, name: 'SPEC8_LOCAL', branch: BRANCH, action: 'created', pushed: false, keep_lock: { changed: false } }); - expect(h().envValue('SPEC8_LOCAL')).toBe('local-only'); - expect(h().pushCount()).toBe(pushesBefore); - - const human = await h().run(['edit', 'SPEC8_LOCAL_B', '--no-push'], 'x\n'); - expect(human.stderr.trim()).toBe(`✓ Set SPEC8_LOCAL_B on ${BRANCH} (.env only — not pushed)`); + await withHarness(async (own) => { + const r = await own.run(['edit', 'SPEC8_LOCAL', '--json', '--no-push'], 'local-only\n'); + expect(r.code).toBe(0); + expect(parseJson(r.stdout)).toEqual({ ok: true, name: 'SPEC8_LOCAL', branch: BRANCH, action: 'created', pushed: false, keep_lock: { changed: false } }); + expect(own.envValue('SPEC8_LOCAL')).toBe('local-only'); + expect(own.pushCount()).toBe(0); + }); + await withHarness(async (own) => { + const human = await own.run(['edit', 'SPEC8_LOCAL_B', '--no-push'], 'x\n'); + expect(human.stderr.trim()).toBe(`✓ Set SPEC8_LOCAL_B on ${BRANCH} (.env only — not pushed)`); + }); }); test('a name that is not a valid variable name is INVALID_FORMAT and is not echoed', async () => { diff --git a/tests/commands/gateFirst.test.ts b/tests/commands/gateFirst.test.ts new file mode 100644 index 00000000..66e89ee8 --- /dev/null +++ b/tests/commands/gateFirst.test.ts @@ -0,0 +1,288 @@ +/** + * Every command that writes from a folder runs bare `capy` as its VERY FIRST step (src/sync/gate.ts; + * docs/conflict-resolution.md): before any prompt, preflight, plan or confirm screen, with nothing letting it be + * skipped; and when the person cancels (or chooses "Continue working") the command writes nothing and ends, exit 0. + * + * The vehicle: each command's REAL `execute` over a throwaway project directory, with the gate replaced by a recorder and + * everything the command could do next (an inquirer prompt, `resolveContext`, a deploy adapter's preflight, the deploy + * confirm screen, the edit screen) replaced by a recorder too. One shared, ordered log of those events is the assertion: + * - run to the first thing after the gate: the log STARTS with `gate`; + * - cancelled: the log IS `['gate']` and the exit code is 0, so no prompt, no context, no preflight, no upload. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import * as realGate from '../../src/sync/gate'; +import * as realShared from '../../src/commands/connectors/shared'; +import * as realRegistry from '../../src/deploy/registry'; +import * as realPipedValue from '../../src/commands/pipedValue'; + +/** The ordered log of what happened. A `mock`, so the log itself is never mutated by the test. */ +const event = mock((_name: string) => undefined); +const events = (): readonly string[] => event.mock.calls.map((c) => String(c[0])); + +/** What the gate answers this time: `in-sync` (go on) or `stopped` (the person cancelled). */ +const verdict = mock((): 'in-sync' | 'stopped' => 'in-sync'); + +mock.module('../../src/sync/gate', () => ({ + ...realGate, + gateFirst: async () => { + event('gate'); + if (verdict() === 'stopped') realGate.stopWithNothingWritten(); + }, + ensureInSync: async () => { + event('gate'); + return verdict(); + }, +})); + +/** Ends the command at the first thing after the gate, so nothing real runs. */ +class Halt extends Error {} + +mock.module('../../src/commands/connectors/shared', () => ({ + ...realShared, + resolveContext: async () => { + event('resolveContext'); + throw new Halt('resolveContext'); + }, +})); +// A command that gets past the gate and wants to sign in on its own is as far as these runs go. Constructing the +// service is not signing in (checkout and org build theirs in their constructors): the first USE of it is. +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + constructor() { + return new Proxy(this, { + get: (_target, prop) => + typeof prop === 'string' && prop !== 'then' + ? () => { + event('auth'); + throw new Halt('auth'); + } + : undefined, + }); + } + }, + silentAuthFailureMessage: () => 'no session', +})); +mock.module('inquirer', () => ({ + default: { + prompt: async () => { + event('prompt'); + throw new Halt('prompt'); + }, + Separator: class {}, + }, +})); +mock.module('../../src/commands/pipedValue', () => ({ + ...realPipedValue, + readPipedValue: async () => ({ ok: true, value: 'a-value' }), +})); +mock.module('../../src/ui/keypressConfirm', () => ({ + keypressConfirm: async () => { + event('confirm'); + return 'cancel'; + }, +})); +const fakeAdapter = { + id: 'fake', + kind: 'fake', + label: 'Fake', + requires: { binaries: [] }, + async preflight() { + event('preflight'); + return { ok: true as const }; + }, + async deploy() { + event('adapter.deploy'); + return { ok: true as const, steps: [] }; + }, + async detect() { + return {}; + }, + async prompt() { + event('adapter.prompt'); + return {}; + }, +}; +mock.module('../../src/deploy/registry', () => ({ + ...realRegistry, + getAdapter: (kind: string) => (kind === 'fake' ? fakeAdapter : realRegistry.getAdapter(kind)), +})); +// The edit screen is the TUI: it must not open before the gate (or at all in these runs). +mock.module('../../src/ui/editScreen', () => ({ + ...require('../../src/ui/editScreen'), + EditScreen: class { + async run() { + event('screen'); + throw new Halt('screen'); + } + }, +})); + +import { AddCommand } from '../../src/commands/addCommand'; +import { ConnectCommand } from '../../src/commands/connectCommand'; +import { EditCommand } from '../../src/commands/editCommand'; +import { PushCommand } from '../../src/commands/pushCommand'; +import { RemoveCommand } from '../../src/commands/removeCommand'; +import { RotateCommand } from '../../src/commands/rotateCommand'; +import { deployCommand, deployRemove } from '../../src/commands/deployCommand'; +import { DeployCommand, DeployRevokeCommand } from '../../src/commands/deployTokenCommand'; +import { routeDeployDiscover } from '../../src/commands/deployDiscover/route'; +import { CheckoutCommand } from '../../src/commands/checkoutCommand'; +import { OrgCommand } from '../../src/commands/orgCommand'; + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +const ORIGINAL_CWD = process.cwd(); +const dirs = mock((_: string) => undefined); + +/** A project folder: keep.lock with X on `development`, `.capy/branch`, a deploy target `t` of the recorded adapter. */ +function project(): string { + const dir = mkdtempSync(join(tmpdir(), 'capy-gate-first-')); + dirs(dir); + mkdirSync(join(dir, '.capy'), { recursive: true }); + writeFileSync(join(dir, '.capy', 'branch'), 'development'); + writeFileSync( + join(dir, 'keep.lock'), + JSON.stringify({ + version: '3.0', + org_id: 'org', + project_id: 'proj', + project_name: 'demo', + variables: { X: [{ resource_id: 'r-x', branch: 'development', value_hash: 'aaaaaaaaaaaaaaaa' }] }, + }), + ); + writeFileSync( + join(dir, '.capy', 'deploy.json'), + JSON.stringify({ version: '1', targets: { t: { name: 't', kind: 'fake', branch: 'development', vars: ['X'], options: {} } } }), + ); + process.chdir(dir); + return dir; +} + +beforeEach(() => { + event.mockClear(); + verdict.mockReset(); + verdict.mockImplementation(() => 'in-sync'); +}); +afterEach(() => { + process.chdir(ORIGINAL_CWD); + dirs.mock.calls.forEach(([dir]) => rmSync(dir, { recursive: true, force: true })); + dirs.mockClear(); +}); + +/** Runs `fn` with `process.exit` trapped and the terminal quiet; resolves to the exit code if it exited (else undefined). */ +async function ran(fn: () => Promise, tty: boolean = false): Promise { + const exit = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const isTTY = spyOn(process.stdin, 'isTTY', 'get' as never).mockReturnValue(tty as never); + try { + await fn(); + return undefined; + } catch (thrown) { + if (thrown instanceof ExitSignal) return thrown.code; + if (thrown instanceof Halt) return undefined; + throw thrown; + } finally { + exit.mockRestore(); + log.mockRestore(); + err.mockRestore(); + isTTY.mockRestore(); + } +} + +/** Each folder write command, driven to the first thing it would do after the gate. `tty`: the terminal path of that command. */ +const COMMANDS: ReadonlyArray Promise, boolean]> = [ + ['capy edit (the TUI)', () => new EditCommand().execute({}), true], + ['capy edit NAME (piped)', () => new EditCommand().execute({ name: 'X', json: true }), false], + ['capy add NAME (piped)', () => new AddCommand().execute(['X'], { json: true, force: true }), false], + ['capy add NAME (the terminal prompts)', () => new AddCommand().execute(['X'], {}), true], + ['capy remove NAME (its confirmation is a prompt)', () => new RemoveCommand().execute(['X'], {}), true], + ['capy remove NAME --yes', () => new RemoveCommand().execute(['X'], { yes: true, json: true }), false], + ['capy connect stripe', () => new ConnectCommand().execute('stripe', { nonTty: true, json: true }), false], + ['capy rotate NAME', () => new RotateCommand().execute('X', { nonTty: true }), false], + ['capy push', () => new PushCommand().execute({}), false], + ['capy deploy t (its preflight and confirm screen)', () => deployCommand('t', {}, process.cwd()), true], + ['capy deploy t --yes', () => deployCommand('t', { yes: true }, process.cwd()), false], + // The default `capy deploy` (token + docs): it mints a deploy token from the folder's .env, so it is gated first too. + ['capy deploy (token + docs, its platform picker is a prompt)', () => new DeployCommand(undefined, false, {}).execute(), true], + ['capy deploy --platform other --mode token --yes', () => new DeployCommand(undefined, false, { platform: 'other', mode: 'token', yes: true }).execute(), false], + ['capy deploy revoke ID', () => new DeployRevokeCommand().execute('dep_0123456789ab', { nonTty: true }), false], + ['capy deploy targets-remove t', () => deployRemove('t', process.cwd(), { nonTty: true }), false], + ['capy deploy dokploy --discover', () => routeDeployDiscover('dokploy', { discover: true }, false, false), false], + // Discovery has no conditional skip: the working directory is gated, then each discovered folder is synced before it is written. + ['capy connect dokploy --discover', () => new ConnectCommand().execute('dokploy', { discover: true, nonTty: true, json: true }), false], + // Switching to an existing branch rewrites keep.lock and .env from Capy: gated like every folder write. + ['capy checkout ', () => new CheckoutCommand().execute('development', { nonTty: true }), false], + // Re-linking the folder to another organization rewrites its keep.lock. + ['capy org', () => new OrgCommand().execute({ nonTty: true }), false], +]; + +describe('the one exception: capy checkout -b', () => { + test('creating a branch does not run bare capy: the uncommitted values travel to the new branch', async () => { + project(); + await ran(() => new CheckoutCommand().execute('feature-x', { create: true, nonTty: true }), false); + expect(events()).not.toContain('gate'); + }); +}); + +describe.each(COMMANDS)('%s', (_name, run, tty) => { + test('bare capy runs first: before any prompt, context, preflight, adapter, confirm or screen', async () => { + project(); + await ran(run, tty); + const log = events(); + expect(log[0]).toBe('gate'); + // The gate is asked once at the start; whatever follows it came after. + expect(log.filter((e) => e === 'gate').length).toBeGreaterThanOrEqual(1); + }); + + test('cancelled ("Continue working", or q): the command writes nothing and ends, exit 0 - the log is the gate alone', async () => { + project(); + verdict.mockImplementation(() => 'stopped'); + const code = await ran(run, tty); + expect(code).toBe(0); + expect(events()).toEqual(['gate']); + }); +}); + +describe('what comes after the gate comes after it', () => { + test('capy edit (the TUI): signing in follows the gate, and the screen opens after both', async () => { + project(); + await ran(() => new EditCommand().execute({}), true); + expect(events()).toEqual(['gate', 'auth']); + }); + + test('capy deploy: the preflight and then the confirm screen follow the gate', async () => { + project(); + await ran(() => deployCommand('t', {}, process.cwd()), true); + expect(events()).toEqual(['gate', 'preflight', 'confirm']); + }); + + test('capy remove (a terminal): the confirmation prompt follows the gate', async () => { + project(); + await ran(() => new RemoveCommand().execute(['X'], {}), true); + expect(events()).toEqual(['gate', 'prompt']); + }); + + test('capy add (a terminal): the context, and with it every prompt for a value, follows the gate', async () => { + project(); + await ran(() => new AddCommand().execute(['X'], {}), true); + expect(events()).toEqual(['gate', 'resolveContext']); + }); + + test('capy push: the repo-link context follows the gate', async () => { + project(); + await ran(() => new PushCommand().execute({}), false); + expect(events()[0]).toBe('gate'); + }); +}); diff --git a/tests/commands/localOnlyFlow.test.ts b/tests/commands/localOnlyFlow.test.ts index fbb74be7..3ab68497 100644 --- a/tests/commands/localOnlyFlow.test.ts +++ b/tests/commands/localOnlyFlow.test.ts @@ -1,4 +1,4 @@ -import { mock, describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { mock, describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'fs'; import { join } from 'path'; @@ -6,7 +6,8 @@ import { join } from 'path'; const tempHome = mkdtempSync(join(require('os').tmpdir(), 'capy-localflow-home-')); mock.module('os', () => { const actual = require('os'); - return { ...actual, homedir: () => tempHome }; + // `default` too: modules loaded later (bare capy, which every `capy push` runs first, loads inquirer) import `os` by default. + return { ...actual, default: { ...actual, homedir: () => tempHome }, homedir: () => tempHome }; }); // Network tripwire: any fetch in local-only mode is a leak. @@ -59,9 +60,32 @@ afterAll(() => { }); describe('local-only end-to-end (offline, no leakage)', () => { - it('capy push commits locally with zero network calls', async () => { + it('capy push commits locally with zero network calls (off a terminal bare capy reports the new variable first; --resolve local commits it)', async () => { const { PushCommand } = await import('../../src/commands/pushCommand'); - await new PushCommand().execute(); + // stdin is not a terminal here, so nobody can answer bare capy's menu: the difference comes back as a row. + const exit = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit_${code}__`); + }) as never); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const first = await new PushCommand() + .execute() + .then(() => 'returned') + .catch((e: Error) => e.message); + const payload = JSON.parse(String(log.mock.calls[0]?.[0] ?? '{}')) as { code?: string; conflict_id?: string; conflicts?: Array<{ name: string; type: string; remote: string | null }> }; + log.mockRestore(); + err.mockRestore(); + expect(first).toBe('__exit_3__'); + expect(payload.code).toBe('SECRETS_CONFLICT'); + expect(payload.conflicts).toMatchObject([{ name: 'MY_SECRET', type: 'new', remote: null }]); + expect(fetchCalled).toBe(false); + + const quiet = spyOn(console, 'log').mockImplementation(() => {}); + const quietErr = spyOn(console, 'error').mockImplementation(() => {}); + await new PushCommand().execute({ conflictFlags: { resolve: ['MY_SECRET=local'], conflict: payload.conflict_id } }); + quiet.mockRestore(); + quietErr.mockRestore(); + exit.mockRestore(); expect(fetchCalled).toBe(false); diff --git a/tests/commands/pipedLeak.test.ts b/tests/commands/pipedLeak.test.ts index deafa0b9..252dbbb4 100644 --- a/tests/commands/pipedLeak.test.ts +++ b/tests/commands/pipedLeak.test.ts @@ -15,7 +15,7 @@ import { describe, test, expect } from 'bun:test'; import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { createHarness, type CliResult, type Harness } from '../helpers/pipedHarness'; +import { createHarness, withHarness, type CliResult, type Harness } from '../helpers/pipedHarness'; const SENTINEL = 'SENTINEL_5d41402abc4b2a76b9719d911017c592_ZZ'; const SENTINEL_FORMS: readonly (string | Buffer)[] = [ @@ -79,8 +79,8 @@ describe('leak test: the value never appears in output, errors, requests or file ['update --json', ['edit', 'LEAK_A', '--json', VERBOSE], `${SENTINEL}-two\n`], ['create human', ['edit', 'LEAK_B', VERBOSE], `${SENTINEL}\n`], ['update human', ['edit', 'LEAK_B', VERBOSE], `${SENTINEL}-two\n`], + // --no-push leaves .env different from Capy: the next write would stop on it, so it goes last. ['no-push --json', ['edit', 'LEAK_C', '--json', '--no-push', VERBOSE], `${SENTINEL}\n`], - ['no-push human', ['edit', 'LEAK_D', '--no-push', VERBOSE], `${SENTINEL}\n`], ]; // Sequential on purpose: each run builds on the state the one before left. await runs.reduce(async (previous, [label, args, stdin]) => { @@ -92,6 +92,11 @@ describe('leak test: the value never appears in output, errors, requests or file } finally { await harness.dispose(); } + await withHarness(async (own) => { + const result = await own.run(['edit', 'LEAK_D', '--no-push', VERBOSE], `${SENTINEL}\n`); + expect({ label: 'no-push human', code: result.code }).toEqual({ label: 'no-push human', code: 0 }); + expectNoLeak('no-push human', result, own); + }); }, 60000); test('every refusal: bad name carrying the value, NUL, invalid UTF-8, too large, empty, no keep.lock', async () => { @@ -181,8 +186,9 @@ describe('leak test: the value never appears in output, errors, requests or file ['add --force --json', ['add', 'LEAK_ADD', '--force', '--json', VERBOSE], `${SENTINEL}-2\n`, 0], ['add multiple names', ['add', 'LEAK_X', 'LEAK_Y', '--json', VERBOSE], `${SENTINEL}\n`, 3], ['add multiple names human', ['add', 'LEAK_X', 'LEAK_Y', VERBOSE], `${SENTINEL}\n`, 3], - ['add no-push', ['add', 'LEAK_LOCAL', '--no-push', '--json', VERBOSE], `${SENTINEL}\n`, 0], ['add human', ['add', 'LEAK_HUMAN', VERBOSE], `${SENTINEL}\n`, 0], + // --no-push leaves .env different from Capy: the next write would stop on it, so it goes last. + ['add no-push', ['add', 'LEAK_LOCAL', '--no-push', '--json', VERBOSE], `${SENTINEL}\n`, 0], ]; await runs.reduce(async (previous, [label, args, stdin, code]) => { await previous; diff --git a/tests/commands/removeCommand.test.ts b/tests/commands/removeCommand.test.ts index f3e72ae4..1bef860f 100644 --- a/tests/commands/removeCommand.test.ts +++ b/tests/commands/removeCommand.test.ts @@ -2,16 +2,16 @@ * `capy remove NAME [NAME...]` (CAP-686). * * Three layers, tested at the layer where they're cheapest to prove: - * - the pure helpers (`computeDrift`, `deployTargetWarnings`, - * `confirmationMessage`, `pinnedHashesFor`) — plain unit tests, no I/O; + * - the pure helpers (`deployTargetWarnings`, `confirmationMessage`) — + * plain unit tests, no I/O; * - `removeAndSync` (`connectors/shared.ts`) — the actual push mechanics, - * exercised the same way `writeImportedAndSync`'s own tests - * (`connectDokploy.test.ts`) do: a hand-built `ResolvedContext` with a - * mocked `serviceClient.pushSecrets` and a real `FileManager` writing - * into a throwaway directory, so `.env`'s layout survives for real; + * exercised against an in-memory server (tests/helpers/fakeCapyServer.ts) + * holding the variables, with a hand-built `ResolvedContext` and a real + * `FileManager` writing into a throwaway directory, so `.env`'s layout + * survives for real; * - `RemoveCommand.execute`'s local-only refusals (VAR_NOT_FOUND, the * non-interactive/--json confirmation gate) and `proceedWithRemoval`'s - * drift refusal / deploy-target warning / --json purity — driven end to + * deploy-target warning / --json purity — driven end to * end against a real temp project directory, the same pattern * `rotateRefusals.test.ts` uses, with no auth or network involved because * every one of these paths returns before `resolveContext()` is reached. @@ -26,16 +26,23 @@ import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun: import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +import { stubbedGate } from '../helpers/gateMock'; + +// These tests are about `capy remove`'s own checks. Bare `capy` (its first step) is tested in tests/sync/gate.test.ts and +// tests/commands/gateFirst.test.ts. `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). +mock.module('../../src/sync/gate', () => stubbedGate()); + import { - computeDrift, deployTargetWarnings, confirmationMessage, - pinnedHashesFor, proceedWithRemoval, RemoveCommand, } from '../../src/commands/removeCommand'; import { removeAndSync, ResolvedContext } from '../../src/commands/connectors/shared'; import { hashValue } from '../../src/commands/statusCommand'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { fakeCapyServer } from '../helpers/fakeCapyServer'; import { FileManager } from '../../src/files/fileManager'; import { ProjectManager } from '../../src/core/projectManager'; import { upsertTarget } from '../../src/deploy/config'; @@ -109,26 +116,6 @@ afterEach(() => { // ── pure helpers ───────────────────────────────────────────────────────── -describe('computeDrift', () => { - test('flags a value changed locally, a new-locally value, and a pinned value missing from .env', () => { - const pinned = { CHANGED: hashValue('old'), MISSING: hashValue('gone'), INSYNC: hashValue('same') }; - const local = { CHANGED: 'new', NEW_LOCAL: 'brand-new', INSYNC: 'same' }; - expect(computeDrift(pinned, local, [])).toEqual(['CHANGED', 'MISSING', 'NEW_LOCAL']); - }); - - test('excludes the names being removed even when they themselves are drifted', () => { - const pinned = { REMOVED: hashValue('old') }; - const local = { REMOVED: 'changed-but-being-removed-anyway' }; - expect(computeDrift(pinned, local, ['REMOVED'])).toEqual([]); - }); - - test('nothing drifted → empty array', () => { - const pinned = { A: hashValue('a'), B: hashValue('b') }; - const local = { A: 'a', B: 'b' }; - expect(computeDrift(pinned, local, [])).toEqual([]); - }); -}); - describe('deployTargetWarnings', () => { test('names every target whose vars include a removed name', () => { upsertTarget(TEST_DIR, baseTarget({ name: 'prod', vars: ['STRIPE_KEY', 'OTHER'] })); @@ -164,27 +151,16 @@ describe('confirmationMessage', () => { }); }); -describe('pinnedHashesFor', () => { - test('picks each variable\'s entry for the given branch, ignoring other branches', () => { - const keep: KeepFile = { - version: '3.0', - org_id: 'o', - project_id: 'p', - project_name: 'demo', - variables: { - A: [ - { resource_id: 'r1', branch: 'development', value_hash: 'hash-dev' }, - { resource_id: 'r1b', branch: 'staging', value_hash: 'hash-staging' }, - ], - B: [{ resource_id: 'r2', branch: 'staging', value_hash: 'hash-b-staging' }], - }, - }; - expect(pinnedHashesFor(keep, 'development')).toEqual({ A: 'hash-dev' }); - }); -}); - // ── removeAndSync — the actual push mechanics ─────────────────────────── +/** A server that holds `values` on development, pinned as `keep` says: the way the folder last synced them. */ +function serverHolding(keep: KeepFile, values: Record) { + const lines = Object.entries(values).map( + ([name, value]) => `${name}=capy:${keep.variables[name]?.find((e) => e.branch === 'development')?.resource_id ?? 'r'}:${Encryptor.encrypt(value, 'test-key')}`, + ); + return fakeCapyServer({ initial: { keep, blobs: { [SyncEngine.computeKeepHash(keep, 'development')]: lines.join('\n') } } }); +} + describe('removeAndSync', () => { function ctxWith(over: Partial): ResolvedContext { const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; @@ -245,20 +221,20 @@ describe('removeAndSync', () => { }, }; - const pushSecrets = mock(async () => ({ keep_hash: 'h'.repeat(16) })); + const server = serverHolding(keep, { FOO: 'foo-value', BAR: 'bar-value', BAZ: 'baz-value' }); const ctx = ctxWith({ pm: new ProjectManager(TEST_DIR) as unknown as ResolvedContext['pm'], fileManager: new FileManager(TEST_DIR) as unknown as ResolvedContext['fileManager'], - serviceClient: { pushSecrets } as unknown as ResolvedContext['serviceClient'], + serviceClient: server.client as unknown as ResolvedContext['serviceClient'], keep, localPlaintext: { FOO: 'foo-value', BAR: 'bar-value', BAZ: 'baz-value' }, }); await removeAndSync(ctx, ['BAR']); - expect(pushSecrets.mock.calls.length).toBe(1); - const [, pushedKeepFile, pushedEnvBlob] = pushSecrets.mock.calls[0]; + expect(server.pushSecrets.mock.calls.length).toBe(1); + const [, pushedKeepFile, pushedEnvBlob] = server.pushSecrets.mock.calls[0]; // The pushed keep.lock carries FOO and BAZ; BAR's branch entry is gone // entirely (it had no other-branch entries to fall back to). const pushedKeep = JSON.parse(pushedKeepFile as string) as KeepFile; @@ -302,23 +278,21 @@ describe('removeAndSync', () => { }, }; - const pushSecrets = mock(async (_projectId: string, _keepFile: string, envBlob: string) => { - expect(envBlob).toBe(''); - return { keep_hash: 'h'.repeat(16) }; - }); + const server = serverHolding(keep, { ONLY_ONE: 'value' }); const ctx = ctxWith({ pm: new ProjectManager(TEST_DIR) as unknown as ResolvedContext['pm'], fileManager: new FileManager(TEST_DIR) as unknown as ResolvedContext['fileManager'], - serviceClient: { pushSecrets } as unknown as ResolvedContext['serviceClient'], + serviceClient: server.client as unknown as ResolvedContext['serviceClient'], keep, localPlaintext: { ONLY_ONE: 'value' }, }); await removeAndSync(ctx, ['ONLY_ONE']); - expect(pushSecrets.mock.calls.length).toBe(1); - const pushedKeep = JSON.parse(pushSecrets.mock.calls[0][1] as string) as KeepFile; + expect(server.pushSecrets.mock.calls.length).toBe(1); + expect(server.pushSecrets.mock.calls[0][2]).toBe(''); + const pushedKeep = JSON.parse(server.pushSecrets.mock.calls[0][1] as string) as KeepFile; expect(Object.keys(pushedKeep.variables)).toEqual([]); }); }); @@ -328,63 +302,24 @@ describe('removeAndSync', () => { describe('proceedWithRemoval', () => { function ctxWith(over: Partial): ResolvedContext { - const keep: KeepFile = { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + const keep: KeepFile = over.keep ?? { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'demo', variables: {} }; + const localPlaintext = over.localPlaintext ?? {}; return { pm: { readSyncState: () => null }, fileManager: { writeKeepFile: () => {}, writeEncryptedEnvFile: () => {}, writeSyncState: () => {} }, - serviceClient: { pushSecrets: async () => ({ keep_hash: 'h'.repeat(16) }) }, + // The server holds what the folder last synced. + serviceClient: serverHolding(keep, localPlaintext).client, orgId: 'o', projectId: 'p', branch: 'development', userId: 'u', projectKey: 'test-key', keep, - localPlaintext: {}, + localPlaintext, ...over, } as unknown as ResolvedContext; } - test('refuses with REMOVE_LOCAL_DRIFT when another variable has an unpushed local change', async () => { - const keep: KeepFile = { - version: '3.0', - org_id: 'o', - project_id: 'p', - project_name: 'demo', - variables: { - TARGET: [{ resource_id: 'r1', branch: 'development', value_hash: hashValue('target-value') }], - OTHER: [{ resource_id: 'r2', branch: 'development', value_hash: hashValue('old-other-value') }], - }, - }; - const ctx = ctxWith({ - keep, - localPlaintext: { TARGET: 'target-value', OTHER: 'a-changed-value-not-yet-pushed' }, - }); - - const r = await capture(() => proceedWithRemoval(ctx, ['TARGET'], { json: false, cwd: TEST_DIR })); - expect(r.exitCode).toBe(1); - expect(r.stderr).toContain('OTHER'); - expect(r.stderr).toContain('unpushed local changes'); - }); - - test('--json drift refusal is coded and pure JSON on stdout', async () => { - const keep: KeepFile = { - version: '3.0', - org_id: 'o', - project_id: 'p', - project_name: 'demo', - variables: { - TARGET: [{ resource_id: 'r1', branch: 'development', value_hash: hashValue('target-value') }], - OTHER: [{ resource_id: 'r2', branch: 'development', value_hash: hashValue('old') }], - }, - }; - const ctx = ctxWith({ keep, localPlaintext: { TARGET: 'target-value', OTHER: 'new-unpushed' } }); - - const r = await capture(() => proceedWithRemoval(ctx, ['TARGET'], { json: true, cwd: TEST_DIR })); - expect(r.exitCode).toBe(1); - const parsed = JSON.parse(r.stdout.trim()); - expect(parsed).toEqual({ ok: false, code: 'REMOVE_LOCAL_DRIFT', error: expect.any(String), drifted: ['OTHER'] }); - }); - test('warns (stderr) about a deploy target still listing a removed variable, in human mode', async () => { upsertTarget(TEST_DIR, baseTarget({ name: 'prod', vars: ['TARGET', 'OTHER'] })); const keep: KeepFile = { diff --git a/tests/commands/roleAccessGuards.test.ts b/tests/commands/roleAccessGuards.test.ts index c8675bda..270e132c 100644 --- a/tests/commands/roleAccessGuards.test.ts +++ b/tests/commands/roleAccessGuards.test.ts @@ -56,6 +56,12 @@ mock.module('../../src/ui/errorScreen', () => ({ }), })); +// These tests are about checkout's OWN guards (403 safety, torn branch state). `capy checkout ` runs bare +// `capy` first (docs/conflict-resolution.md); that is tested in tests/commands/gateFirst.test.ts, so here the gate +// reports the folder in sync and the command's own behaviour is what runs. +import { stubbedGate, gateRan } from '../helpers/gateMock'; +mock.module('../../src/sync/gate', () => stubbedGate()); + afterAll(() => { mock.restore(); }); // --------------------------------------------------------------------------- @@ -313,5 +319,7 @@ describe('CheckoutCommand — torn branch state (CAP-215)', () => { expect(stderr).not.toContain('uncommitted changes'); expect(exitSpy).not.toHaveBeenCalled(); + // Switching to an existing branch ran bare `capy` first. + expect(gateRan).toHaveBeenCalled(); }); }); diff --git a/tests/commands/rotatePromotesThenRotates.test.ts b/tests/commands/rotatePromotesThenRotates.test.ts index 178026f3..872d9b26 100644 --- a/tests/commands/rotatePromotesThenRotates.test.ts +++ b/tests/commands/rotatePromotesThenRotates.test.ts @@ -30,6 +30,7 @@ import type { ConnectorMetadata, KeepFile } from '../../src/types/index'; // can spread it. Resolving it from inside the factory instead hands back the // mock that is being defined, and every untouched export comes out missing. import * as realShared from '../../src/commands/connectors/shared'; +import { stubbedGate } from '../helpers/gateMock'; const TEST_DIR = join(tmpdir(), `capy-rotate-promote-${process.pid}`); const ORIGINAL_CWD = process.cwd(); @@ -68,6 +69,9 @@ function writeKeep(keep: KeepFile): void { // about whether rotate carries on afterwards. What it DOES do is the one thing // the rest of the flow depends on — record the connector in keep.lock — so the // stub does exactly that and nothing else. +// Bare `capy` (rotate's first step) is not what this file is about: see tests/commands/gateFirst.test.ts. +mock.module(join(import.meta.dir, '../../src/sync/gate.ts'), () => stubbedGate()); + mock.module(join(import.meta.dir, '../../src/commands/connectCommand.ts'), () => ({ ConnectCommand: class { constructor(_devMode?: boolean) {} diff --git a/tests/commands/rotateRefusals.test.ts b/tests/commands/rotateRefusals.test.ts index af72066d..1c685bb4 100644 --- a/tests/commands/rotateRefusals.test.ts +++ b/tests/commands/rotateRefusals.test.ts @@ -6,11 +6,16 @@ * the tests run the real command against a real keep.lock and read what it * wrote. */ -import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import type { ConnectorMetadata, KeepFile } from '../../src/types/index'; +import { stubbedGate } from '../helpers/gateMock'; + +// The refusals under test come from rotate's own checks; bare `capy` (its first step) is tested in tests/sync and +// tests/commands/gateFirst.test.ts. `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). +mock.module('../../src/sync/gate', () => stubbedGate()); const TEST_DIR = join(tmpdir(), `capy-rotate-refusals-${process.pid}`); const ORIGINAL_CWD = process.cwd(); diff --git a/tests/commands/secretsDeployConflicts.test.ts b/tests/commands/secretsDeployConflicts.test.ts new file mode 100644 index 00000000..9f26459b --- /dev/null +++ b/tests/commands/secretsDeployConflicts.test.ts @@ -0,0 +1,215 @@ +/** + * `capy secrets deploy` and target conflicts (CAP-717) + the picked-only delivery (CAP-715). + * + * The engine is the real one (`runSecretsDeploy` → `runBatchDeploy`); the service, GitHub and the Dokploy + * adapter are the injected fakes of tests/helpers/batchDeployWorld.ts, with an adapter that says it reads the + * platform's live values (`readsLiveValues`) and reports one conflict for `api`: the service's API_KEY was changed + * outside Capy. The properties: + * - before anything is written every target is looked at (reading only); a conflict stops the whole run: + * exit 3, the payload on stdout, no push, no record, no PR, no value in the output; + * - a rerun with `--resolve API_KEY@api=local|remote --conflict ` delivers, or skips that variable and + * records nothing for it; a moved state is STALE; a partial answer gets the names still open; + * - only the PICKED variables are delivered, and the adapter is told to leave every other managed line alone. + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import type { DeployContext, DeployResult, TargetConfig } from '../../src/deploy/adapter'; +import { runSecretsDeploy, type SecretsDeployIo, type SecretsDeployOpts } from '../../src/commands/secretsDeployCommand'; +import { SKIP_CODES } from '../../src/deploy/batchDeploy'; +import { + LINKS, + NAME, + VALUES, + everythingSentTo, + fakeAdapter, + fakeGithub, + fakeService, + hashOf, + indexRows, + makeEnv, + standardRepos, +} from '../helpers/batchDeployWorld'; + +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; +} + +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 }; +} + +/** What the service holds for `api`'s API_KEY now (fingerprint only), by the script: someone edited it outside Capy. */ +const LIVE = 'deadbeefdeadbeef'; +const conflictFor = (remote: string = LIVE) => ({ name: NAME, target: 'api', pinned: null, local: hashOf(NAME), remote }); + +/** + * A Dokploy adapter that compares live values: `api`'s API_KEY is in conflict until an answer is given, and an answer + * counts only for the live value (and delivered value) it was made against, as `planTargetDelivery` does. + * `liveAtWrite`: what the service holds when the delivery pass reads it (the comparison pass sees `remote`). + */ +function liveAdapter(remote: string = LIVE, liveAtWrite: string = remote) { + const base = fakeAdapter({ + deploy: (config: TargetConfig, ctx: DeployContext): DeployResult => { + if (config.name !== 'api' || ctx.targetCheck === undefined) return { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; + const now = ctx.dryRun ? remote : liveAtWrite; + const seen = ctx.targetCheck.resolutions[NAME]; + const answer = seen !== undefined && seen.remote === now && seen.local === hashOf(NAME) ? seen.choice : undefined; + if (ctx.dryRun) return { ok: true, steps: [], conflicts: [conflictFor(now)] }; + if (answer === undefined) return { ok: false, steps: [{ label: 'application.saveEnvironment', status: 'fail', code: 'SECRETS_CONFLICT' }], conflicts: [conflictFor(now)] }; + return answer === 'remote' + ? { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }], skipped: [NAME] } + : { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; + }, + }); + return Object.assign(base, { readsLiveValues: true }); +} + +function rig(adapter = liveAdapter(), serviceOpts: Parameters[0] = {}) { + const service = fakeService(serviceOpts); + const github = fakeGithub(standardRepos()); + const { env, openKeys } = makeEnv(service, github, adapter); + const io: SecretsDeployIo = { + orgId: 'org1', + client: { + getSecretIndex: async () => ({ org_id: 'org1', rows: indexRows(), skipped: [] }), + getOrgRepos: async () => ({ org_id: 'org1', repos: [...LINKS] }), + }, + env, + }; + return { io, service, github, adapter, openKeys }; +} + +const json = (c: Captured) => JSON.parse(c.stdout); +const run = (r: ReturnType, opts: SecretsDeployOpts) => capture(() => runSecretsDeploy([NAME], { json: true, allRows: true, ...opts }, r.io)); + +/** The plan_id and conflict_id a preview reports. */ +async function preview(r: ReturnType) { + const out = await run(r, { dryRun: true }); + expect(out.exitCode).toBe(3); + return { out, planId: json(out).plan_id as string, conflictId: json(out).conflict_id as string }; +} + +const nothingWritten = (r: ReturnType) => { + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + expect(r.github.createBlob.mock.calls).toHaveLength(0); + expect(r.github.createPull.mock.calls).toHaveLength(0); + expect(r.adapter.deploy.mock.calls.every(([, ctx]) => ctx.dryRun === true || ctx.targetCheck?.resolutions[NAME] === undefined)).toBe(true); +}; + +describe('capy secrets deploy: target conflicts', () => { + test('--dry-run reports the conflict and the plan, reading only', async () => { + const r = rig(); + const { out } = await preview(r); + const body = json(out); + expect(body).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true }); + expect(body.conflict_id).toMatch(/^c_[0-9a-f]{12}$/); + expect(body.conflicts).toEqual([conflictFor()]); + expect(body.plan).toBeDefined(); + nothingWritten(r); + expect(everythingSentTo({ mock: { calls: [[out.stdout, out.stderr]] } })).not.toContain(VALUES.API_KEY); + }); + + test('a real run stops BEFORE anything is written when a conflict is unanswered: exit 3, the same id, no push, no record, no PR', async () => { + const r = rig(); + const { planId, conflictId } = await preview(r); + const out = await run(r, { confirm: planId }); + expect(out.exitCode).toBe(3); + expect(json(out)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: conflictId }); + expect(json(out).conflicts).toEqual([conflictFor()]); + nothingWritten(r); + // No value anywhere in what was printed. + Object.values(VALUES).forEach((v) => expect(`${out.stdout}${out.stderr}`).not.toContain(v)); + }); + + test('local: every target is delivered, with the answer handed to the adapter by bare name', async () => { + const r = rig(); + const { planId, conflictId } = await preview(r); + const out = await run(r, { confirm: planId, conflictFlags: { resolve: [`${NAME}@api=local`], conflict: conflictId } }); + expect(out.exitCode).toBeUndefined(); + expect(json(out).ok).toBe(true); + const api = r.adapter.deploy.mock.calls.find(([c, ctx]) => c.name === 'api' && !ctx.dryRun); + expect(api?.[1].targetCheck?.resolutions).toEqual({ [NAME]: { choice: 'local', remote: LIVE, local: hashOf(NAME) } }); + expect(r.service.pushSecrets.mock.calls.length).toBeGreaterThan(0); + expect(r.github.createPull.mock.calls).toHaveLength(3); + }); + + test('remote: the variable is skipped, not recorded as delivered, and the target reports KEPT_ON_PLATFORM (no PR for it)', async () => { + const r = rig(); + const { planId, conflictId } = await preview(r); + const out = await run(r, { confirm: planId, conflictFlags: { resolve: [`${NAME}@api=remote`], conflict: conflictId } }); + expect(json(out).ok).toBe(true); + expect(json(out).skipped).toContainEqual(expect.objectContaining({ target: 'api', code: SKIP_CODES.KEPT_ON_PLATFORM })); + expect(json(out).delivered.map((d: { target: string }) => d.target).toSorted()).toEqual(['site', 'worker']); + // The record on the Capy server has no `api` delivery for API_KEY. + const recorded = r.service.pushSecrets.mock.calls.map((c) => JSON.parse(String(c[1])) as { variables: Record }>> }); + recorded.forEach((keep) => expect(keep.variables.API_KEY[0].targets?.map((t) => t.target) ?? []).not.toContain('api')); + }); + + test('the service changed again after the preview: STALE, the fresh conflict, a new id, nothing written', async () => { + const first = rig(); + const { planId, conflictId } = await preview(first); + const second = rig(liveAdapter('cafecafecafecafe')); + const out = await run(second, { confirm: planId, conflictFlags: { resolve: [`${NAME}@api=local`], conflict: conflictId } }); + expect(out.exitCode).toBe(3); + expect(json(out)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT_STALE' }); + expect(json(out).conflict_id).not.toBe(conflictId); + expect(json(out).conflicts).toEqual([conflictFor('cafecafecafecafe')]); + nothingWritten(second); + }); + + test('the service moved between the comparison pass and the delivery pass: the answer does not apply, api fails with SECRETS_CONFLICT and nothing is recorded for it', async () => { + const r = rig(liveAdapter(LIVE, 'cafecafecafecafe')); + const { planId, conflictId } = await preview(r); + const out = await run(r, { confirm: planId, conflictFlags: { resolve: [`${NAME}@api=local`], conflict: conflictId } }); + const body = json(out); + expect(body.failed).toContainEqual(expect.objectContaining({ target: 'api', code: 'SECRETS_CONFLICT' })); + expect(body.delivered.map((d: { target: string }) => d.target)).not.toContain('api'); + const recorded = r.service.pushSecrets.mock.calls.map((c) => JSON.parse(String(c[1])) as { variables: Record }>> }); + recorded.forEach((keep) => expect(keep.variables.API_KEY[0].targets?.map((t) => t.target) ?? []).not.toContain('api')); + }); + + test('a conflict id without an answer for the name is a partial answer: the names still open, under the same id', async () => { + const r = rig(); + const { planId, conflictId } = await preview(r); + const out = await run(r, { confirm: planId, conflictFlags: { resolve: [`OTHER@api=local`], conflict: conflictId } }); + expect(out.exitCode).toBe(3); + expect(json(out)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: conflictId }); + expect(json(out).conflicts).toEqual([conflictFor()]); + nothingWritten(r); + }); + + test('a platform without live values (no readsLiveValues) is untouched by all of this: no extra pass, no questions', async () => { + const plain = fakeAdapter(); + const r = rig(plain as never); + const dry = await run(r, { dryRun: true }); + expect(json(dry).ok).toBe(true); + const planId = json(dry).plan_id as string; + const real = await run(r, { confirm: planId }); + expect(json(real).ok).toBe(true); + expect(plain.deploy.mock.calls.every(([, ctx]) => ctx.dryRun === false && ctx.targetCheck === undefined)).toBe(true); + }); +}); diff --git a/tests/commands/secretsEditConflicts.test.ts b/tests/commands/secretsEditConflicts.test.ts new file mode 100644 index 00000000..ca1fea64 --- /dev/null +++ b/tests/commands/secretsEditConflicts.test.ts @@ -0,0 +1,237 @@ +/** + * `capy secrets` edit with no folder (docs/conflict-resolution.md), against the real edit actions and engine + * (`createEditActionsWith` -> `runSecretSet`) over the fake service of tests/helpers/secretsWorld.ts. For each + * location `compareSecrets` gets pinned (the row's fingerprint), local (the new value) and remote (the location now). + * The properties: + * - a location someone else changed since the row was listed is reported as a row, BEFORE any push: nothing is + * written until every such location is answered; + * - local writes that location, bound to the value that was seen; remote leaves it and writes the rest; + * - an answer made against a value that has since moved is not applied: the row comes back, nothing is written; + * - a location that moves between the check and its push is refused (SECRETS_CONFLICT), not overwritten; + * - every upload names the keep it was built from (`base_keep_hash`); + * - no value ever appears in what is returned. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createEditActionsWith, settleLocations } from '../../src/commands/secretsEditActions'; +import type { SetLocation } from '../../src/commands/secretsSet'; +import { hashValue } from '../../src/deploy/keepGate'; +import type { RunRequest } from '../../src/ui/secretsEditFlow'; +import { ERROR_CODES } from '../../src/types/index'; +import { NAME, OLD_VALUE, SENTINEL, PROJECT_NAMES, everythingSentTo, fakeGithub, fakeService, makeEnv, type Loc } from '../helpers/secretsWorld'; + +const MOVED = 'moved-by-someone-else-2222'; + +const A_STAGING: SetLocation = { project_id: 'pA', project_name: PROJECT_NAMES.pA, branch: 'staging', protected: false }; +const C_DEV: SetLocation = { project_id: 'pC', project_name: PROJECT_NAMES.pC, branch: 'development', protected: false }; + +const request = (over: Partial = {}): RunRequest => ({ + name: NAME, + value: SENTINEL, + locations: [A_STAGING, C_DEV], + repos: [], + bases: {}, + // What the row listed for these locations: the old value. + seen: hashValue(OLD_VALUE), + ...over, +}); + +/** The world as the screen listed it, except that `pA/staging` holds `movedValue` now. */ +function rig(movedValue: string | undefined = MOVED) { + const locs: readonly Loc[] = [{ project: 'pA', branch: 'staging', value: movedValue }, { project: 'pC', branch: 'development' }]; + const service = fakeService({ locs }); + const github = fakeGithub([]); + const env = makeEnv(service, github); + const actions = createEditActionsWith({ getOrgRepos: async () => ({ org_id: 'org1', repos: [] }) }, 'org1', env); + return { service, actions, env }; +} + +const decision = (choice: 'local' | 'remote', remote: string, local: string = hashValue(SENTINEL)) => ({ choice, remote, local }); +const pushedProjects = (service: ReturnType): readonly string[] => service.pushSecrets.mock.calls.map((c) => String(c[0])); + +describe('the run reads every location first', () => { + test('a location someone else changed is reported as a conflict and NOTHING is pushed, to it or to any other location', async () => { + const r = rig(); + const out = await r.actions.run(request()); + expect(out.ok).toBe(false); + if (out.ok) throw new Error('expected a conflict'); + expect(out.code).toBe(ERROR_CODES.SECRETS_CONFLICT); + expect(out.conflicts).toHaveLength(1); + expect(out.conflicts?.[0].location).toEqual(A_STAGING); + expect(out.conflicts?.[0].conflict).toEqual({ + name: NAME, + project: PROJECT_NAMES.pA, + branch: 'staging', + type: 'changed', + pinned: hashValue(OLD_VALUE), + local: hashValue(SENTINEL), + remote: hashValue(MOVED), + }); + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + // Fingerprints and names: no value anywhere. + const printed = JSON.stringify(out); + [SENTINEL, MOVED, OLD_VALUE].forEach((v) => expect(printed).not.toContain(v)); + }); + + test('no location moved: the run goes ahead exactly as before', async () => { + const r = rig(OLD_VALUE); + const out = await r.actions.run(request()); + expect(out.ok).toBe(true); + expect(pushedProjects(r.service).toSorted()).toEqual(['pA', 'pC']); + }); + + test('a location that already holds the new value is not a conflict', async () => { + const r = rig(SENTINEL); + const out = await r.actions.run(request()); + expect(out.ok).toBe(true); + }); +}); + +describe('local and remote', () => { + const key = 'pA/staging'; + + test('local: the moved location is written too, with the new value', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('local', hashValue(MOVED)) } })); + expect(out.ok).toBe(true); + if (!out.ok || !('result' in out)) throw new Error('expected a result'); + expect(out.result.updated.map((l) => `${l.project}/${l.branch}`).toSorted()).toEqual([`${PROJECT_NAMES.pA}/staging`, `${PROJECT_NAMES.pC}/development`].toSorted()); + expect(out.result.failed).toEqual([]); + expect(pushedProjects(r.service).toSorted()).toEqual(['pA', 'pC']); + // The pushed keep for pA/staging pins the new value. + const pushedA = r.service.pushSecrets.mock.calls.find((c) => c[0] === 'pA'); + const keep = JSON.parse(String(pushedA?.[1])) as { variables: Record> }; + expect(keep.variables[NAME].find((e) => e.branch === 'staging')?.value_hash).toBe(hashValue(SENTINEL)); + }); + + test('remote: the moved location is skipped (never pushed), the other is written', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('remote', hashValue(MOVED)) } })); + expect(out.ok).toBe(true); + if (!out.ok || !('result' in out)) throw new Error('expected a result'); + expect(pushedProjects(r.service)).toEqual(['pC']); + expect(out.result.updated.map((l) => l.project)).toEqual([PROJECT_NAMES.pC]); + expect(out.result.failed).toEqual([]); + }); + + test('an answer made against another value of the location is NOT applied: the row comes back and nothing is pushed', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('local', hashValue('the-value-it-had-when-answered')) } })); + expect(out.ok).toBe(false); + if (out.ok) throw new Error('expected a conflict'); + expect(out.conflicts?.[0].conflict.remote).toBe(hashValue(MOVED)); + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('an answer made for another new value is NOT applied either', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('local', hashValue(MOVED), hashValue('a-different-new-value')) } })); + expect(out.ok).toBe(false); + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('a location that moves between the check and its push is refused (SECRETS_CONFLICT), and not overwritten', async () => { + // One location. The check reads it as listed; by the time it is pushed, someone else changed it. + const early = fakeService({ locs: [{ project: 'pA', branch: 'staging' }] }); + const late = fakeService({ locs: [{ project: 'pA', branch: 'staging', value: MOVED }] }); + const reads = mock((_: string) => undefined); + const client = { + getDecryptData: mock(async (projectId: string, branch?: string, ...rest: unknown[]) => { + reads(projectId); + return (reads.mock.calls.length <= 1 ? early : late).getDecryptData(projectId, branch, ...(rest as [undefined])); + }), + pushSecrets: late.pushSecrets, + }; + const env = { ...makeEnv(early, fakeGithub([])), client: client as never }; + const actions = createEditActionsWith({ getOrgRepos: async () => ({ org_id: 'org1', repos: [] }) }, 'org1', env); + const out = await actions.run(request({ locations: [A_STAGING] })); + expect(out.ok).toBe(true); + if (!out.ok || !('result' in out)) throw new Error('expected a result'); + expect(out.result.updated).toEqual([]); + expect(out.result.failed).toEqual([expect.objectContaining({ kind: 'location', project: PROJECT_NAMES.pA, branch: 'staging', code: ERROR_CODES.SECRETS_CONFLICT })]); + expect(late.pushSecrets.mock.calls).toHaveLength(0); + expect(everythingSentTo(late.pushSecrets)).not.toContain(SENTINEL); + }); +}); + +describe('pinned and delete (all four of bare capy\'s choices)', () => { + const key = 'pA/staging'; + const answer = (choice: 'pinned' | 'delete') => ({ choice, remote: hashValue(MOVED), local: hashValue(SENTINEL) }); + const keepPushedTo = (service: ReturnType, project: string) => { + const call = service.pushSecrets.mock.calls.find((c) => c[0] === project); + return JSON.parse(String(call?.[1])) as { variables: Record | undefined> }; + }; + + test('pinned: the value that was shown is written back at the moved location (the new value goes to the others)', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: answer('pinned') }, restore: { [key]: OLD_VALUE } })); + expect(out.ok).toBe(true); + if (!out.ok || !('result' in out)) throw new Error('expected a result'); + expect(out.result.failed).toEqual([]); + expect(pushedProjects(r.service).toSorted()).toEqual(['pA', 'pC']); + // The property: what pA/staging pins now is the OLD value, and pC/development got the new one. + expect(keepPushedTo(r.service, 'pA').variables[NAME]?.find((e) => e.branch === 'staging')?.value_hash).toBe(hashValue(OLD_VALUE)); + expect(keepPushedTo(r.service, 'pC').variables[NAME]?.find((e) => e.branch === 'development')?.value_hash).toBe(hashValue(SENTINEL)); + }); + + test('pinned with no value to write back is refused at that location with SECRETS_RESOLVE_INVALID: never guessed, nothing pushed there', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: answer('pinned') } })); + expect(out.ok).toBe(true); + if (!out.ok || !('result' in out)) throw new Error('expected a result'); + expect(out.result.failed).toEqual([expect.objectContaining({ kind: 'location', project: PROJECT_NAMES.pA, branch: 'staging', code: ERROR_CODES.SECRETS_RESOLVE_INVALID })]); + expect(pushedProjects(r.service)).toEqual(['pC']); + }); + + test('delete: the variable is removed at the moved location (through the shared upload, with base_keep_hash) and reported as removed', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: answer('delete') } })); + expect(out.ok).toBe(true); + if (!out.ok || !('result' in out)) throw new Error('expected a result'); + expect(out.result.failed).toEqual([]); + expect(out.result.removed?.map((l) => `${l.project}/${l.branch}`)).toEqual([`${PROJECT_NAMES.pA}/staging`]); + expect(out.result.updated.map((l) => l.project)).toEqual([PROJECT_NAMES.pC]); + expect(keepPushedTo(r.service, 'pA').variables[NAME]?.some((e) => e.branch === 'staging')).toBeFalsy(); + const pushedA = r.service.pushSecrets.mock.calls.find((c) => c[0] === 'pA'); + expect(String(pushedA?.[5])).toMatch(/^[0-9a-f]{64}$/); + // The moved location's own value never goes anywhere. + expect(JSON.stringify(pushedA)).not.toContain(MOVED); + }); +}); + +describe('the upload names the keep it was built from', () => { + test('every push carries base_keep_hash: the keep hash of the branch it read', async () => { + const r = rig(OLD_VALUE); + const out = await r.actions.run(request()); + expect(out.ok).toBe(true); + r.service.pushSecrets.mock.calls.forEach((c) => expect(String(c[5])).toMatch(/^[0-9a-f]{64}$/)); + }); +}); + +describe('settleLocations (pure)', () => { + const reads = [{ action: 'update' as const, serverKeep: undefined, current: { hash: hashValue(MOVED), serverKeepHash: 'k' } }, { action: 'update' as const, serverKeep: undefined, current: { hash: hashValue(OLD_VALUE), serverKeepHash: 'k' } }]; + + test('without a fingerprint to compare with, nothing is checked (the request goes ahead whole)', () => { + const settled = settleLocations(request({ seen: undefined }), reads); + expect(settled).toMatchObject({ kind: 'ready', locations: [A_STAGING, C_DEV] }); + }); + + test('a moved location without an answer for the state it holds: the rows, and nothing to push', () => { + const settled = settleLocations(request(), reads); + expect(settled.kind).toBe('conflicts'); + expect(settled.kind === 'conflicts' && settled.conflicts.map((c) => c.location)).toEqual([A_STAGING]); + }); + + test('answered for the state it holds: ready, with what the push compares against (the listed fingerprint per location, and the answers)', () => { + const answers = { 'pA/staging': decision('local', hashValue(MOVED)) }; + const settled = settleLocations(request({ decisions: answers }), reads); + if (settled.kind !== 'ready') throw new Error('expected ready'); + expect(settled.locations).toEqual([A_STAGING, C_DEV]); + expect(settled.shown).toEqual({ 'pA/staging': hashValue(OLD_VALUE), 'pC/development': hashValue(OLD_VALUE) }); + expect(settled.answers).toEqual(answers); + }); + + test('remote leaves the moved location out of the push', () => { + const settled = settleLocations(request({ decisions: { 'pA/staging': decision('remote', hashValue(MOVED)) } }), reads); + expect(settled).toMatchObject({ kind: 'ready', locations: [C_DEV] }); + }); +}); diff --git a/tests/commands/secretsSetEngine.test.ts b/tests/commands/secretsSetEngine.test.ts index d3c2799a..c14ce4a2 100644 --- a/tests/commands/secretsSetEngine.test.ts +++ b/tests/commands/secretsSetEngine.test.ts @@ -147,15 +147,15 @@ describe('pushing locations', () => { ]); }); - test('a branch with no line for NAME fails VARIABLE_NOT_FOUND and nothing is pushed there', async () => { + test('a branch that lost NAME since its row was listed (moved) is not written to without an answer, and nothing is pushed there', async () => { const { service, env } = world(); const noName = { ...service, getDecryptData: async () => ({ env_content: 'OTHER=capy:x:y', decrypt_key: '', expires_at: '', keep_file: JSON.stringify({ version: '3.0', org_id: 'o', project_id: 'pA', project_name: 'n', variables: {} }) }) }; const result = await runSecretSet( - { name: NAME, value: SENTINEL, locations: [loc('pA', 'staging')], repos: [] }, + { name: NAME, value: SENTINEL, locations: [loc('pA', 'staging')], repos: [], shown: { 'pA/staging': 'aaaaaaaaaaaaaaaa' } }, { ...env, client: noName as never }, ); expect(result.failed).toEqual([ - { kind: 'location', project: PROJECT_NAMES.pA, branch: 'staging', protected: false, code: ERROR_CODES.VARIABLE_NOT_FOUND }, + { kind: 'location', project: PROJECT_NAMES.pA, branch: 'staging', protected: false, code: ERROR_CODES.SECRETS_CONFLICT }, ]); expect(service.pushSecrets.mock.calls).toHaveLength(0); }); diff --git a/tests/core/agentsBlockPlan.test.ts b/tests/core/agentsBlockPlan.test.ts index da2e8eb0..3f29d27c 100644 --- a/tests/core/agentsBlockPlan.test.ts +++ b/tests/core/agentsBlockPlan.test.ts @@ -33,6 +33,9 @@ describe('AGENTS_BLOCK', () => { '- Run `capy help --json` for every command, its options, and its error codes.', '- Always pass `--json` and branch on the `code` field, never on message text.', '- Run `capy pair --json` in an interactive terminal/PTY. After browser approval, it asks "Enable this location as [email]?" before installing the session or keys. Show the returned email to the human and obtain explicit Yes/No approval before answering. Never automatically confirm or pipe `yes`; Enter defaults to No. No upfront email is required.', + '- Commands that write run bare `capy` first. If your folder and Capy differ they write nothing and exit 3 with `SECRETS_CONFLICT`, a `conflict_id`, and the differing names (fingerprints, never values).', + '- Show the human those names and ask, for each: pinned, local, remote or delete. Never answer for the human. Then rerun the same command with `--resolve NAME=` for every name (`NAME@TARGET=local|remote` for a platform row) and `--conflict `.', + '- `SECRETS_CONFLICT_STALE` means Capy moved since: show the fresh rows and ask again.', '- Never print, log, or commit secret values.', '- To set a value, pipe it from the command that produces it: ` | capy edit NAME --json`. Never put a value in a command argument, an `echo`, or a heredoc — it would land in your context and the shell history.', '', diff --git a/tests/core/availableCommands.test.ts b/tests/core/availableCommands.test.ts new file mode 100644 index 00000000..244e7d95 --- /dev/null +++ b/tests/core/availableCommands.test.ts @@ -0,0 +1,93 @@ +/** + * The "Available commands" an unknown command prints is generated from the registered + * program: every visible command is in it, no hidden one is. The pure builder is pinned + * against a small hand-built tree; the built CLI (both entrypoints, needs `bun run build` + * like tests/commands/helpJsonCli.test.ts) is checked against its own `help --json`. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; +import { Command } from 'commander'; +import { availableCommandLines, unknownCommandText } from '../../src/core/availableCommands'; + +const ANSI = /\x1b\[[0-9;]*m/g; +const plain = (text: string): string => text.replace(ANSI, ''); + +function buildProgram(): Command { + const program = new Command(); + program.name('capy').description('Capy CLI'); + program.command('status').description('Show status'); + program.command('invite ').description('Invite a teammate'); + program.command('run').description('Run a command'); + program.command('secret-thing', { hidden: true }).description('should never appear'); + return program; +} + +describe('availableCommandLines', () => { + test('lists the bare command and every visible command with its arguments and description', () => { + const lines = availableCommandLines(buildProgram()).map(plain); + expect(lines).toHaveLength(4); + expect(lines[0]).toMatch(/^ {4}capy {2,}Sync secrets$/); + expect(lines[1]).toMatch(/^ {4}capy status {2,}Show status$/); + expect(lines[2]).toMatch(/^ {4}capy invite {2,}Invite a teammate$/); + expect(lines[3]).toMatch(/^ {4}capy run {2,}Run a command$/); + }); + + test('a hidden command is not listed', () => { + expect(availableCommandLines(buildProgram()).map(plain).join('\n')).not.toContain('secret-thing'); + }); + + test('descriptions start in one column', () => { + const columns = availableCommandLines(buildProgram()) + .map(plain) + .map((l) => l.search(/(Sync secrets|Show status|Invite a teammate|Run a command)/)); + expect(new Set(columns).size).toBe(1); + }); + + test('same look as before: the usage in bold, the description dim', () => { + const [first] = availableCommandLines(buildProgram()); + expect(first).toContain('\x1b[1mcapy\x1b[0m'); + expect(first).toContain('\x1b[90mSync secrets\x1b[0m'); + }); + + test('the bin name is the program name (capy-dev prints capy-dev)', () => { + const program = new Command(); + program.name('capy-dev'); + program.command('edit').description('Edit'); + expect(availableCommandLines(program).map(plain).join('\n')).toContain('capy-dev edit'); + }); + + test('unknownCommandText names the unknown command and the list', () => { + const text = plain(unknownCommandText(buildProgram(), 'bogus')); + expect(text).toContain('Unknown command: bogus'); + expect(text).toContain('Available commands:'); + expect(text).toContain('capy status'); + }); +}); + +describe('the built CLI: unknown command output matches the registered commands', () => { + const run = (entry: string, args: string[]): { stdout: string; code: number } => { + const r = spawnSync('node', [join(__dirname, '../../dist', entry), ...args], { encoding: 'utf-8' }); + return { stdout: r.stdout ?? '', code: r.status ?? 1 }; + }; + + const checkEntry = (entry: string, bin: string): void => { + const doc = JSON.parse(run(entry, ['help', '--json']).stdout) as { commands: Array<{ name: string }> }; + const unknown = run(entry, ['definitely-not-a-command']); + expect(unknown.code).toBe(1); + const listed = plain(unknown.stdout) + .split('\n') + .filter((l) => l.startsWith(' ')) + .slice(1) // the bare command line + .map((l) => l.trim().split(/\s+/)[1]) + .filter((word): word is string => word !== undefined && !word.startsWith('<')); + // Every visible registered command is in the list ... + doc.commands.forEach((c) => expect(listed, `${bin}: ${c.name} missing`).toContain(c.name)); + expect(doc.commands.length).toBeGreaterThan(20); + // ... and nothing else is: the list is the registered set (hidden commands are not in `help --json` either). + expect(new Set(listed).size).toBe(new Set(doc.commands.map((c) => c.name)).size); + }; + + test('capy', () => checkEntry('index.js', 'capy')); + test('capy-dev', () => checkEntry('index-dev.js', 'capy-dev')); +}); diff --git a/tests/deploy/batchDeploy.test.ts b/tests/deploy/batchDeploy.test.ts index d5ba5a84..9c84f346 100644 --- a/tests/deploy/batchDeploy.test.ts +++ b/tests/deploy/batchDeploy.test.ts @@ -160,18 +160,22 @@ describe('a run: values, preflight, record and PR', () => { return { ...w, plan }; }; - test('one unlock for the whole run, and each target\'s own variables (and only those) reach the adapter, decrypted', async () => { + test('one unlock for the whole run, and only the PICKED variables of each target reach the adapter, decrypted (CAP-715)', 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])); + // The request picks API_KEY. `api` also lists DB_URL, which is not picked: it is not sent (it used to be). expect(sent).toEqual({ - api: { API_KEY: VALUES.API_KEY, DB_URL: VALUES.DB_URL }, + api: { API_KEY: VALUES.API_KEY }, worker: { API_KEY: VALUES.API_KEY }, site: { API_KEY: VALUES.API_KEY }, }); + expect(adapter.deploy.mock.calls.every(([config]) => config.vars.every((v) => v === 'API_KEY'))).toBe(true); + // ... and the adapter is told to leave every other line Capy manages in the service's block as it is. + expect(adapter.deploy.mock.calls.every(([, ctx]) => ctx.keepOtherManaged === true)).toBe(true); // 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']); @@ -238,8 +242,8 @@ describe('a run: values, preflight, record and PR', () => { 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 + // DB_URL was not picked, so no target is recorded for it (`api` lists it, `worker` does not ship it). + expect((keep.variables.DB_URL[0] as unknown as { targets?: unknown }).targets).toBeUndefined(); 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')); @@ -255,7 +259,7 @@ describe('a run: values, preflight, record and PR', () => { 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(api?.body).toBe(buildDeployPrBody(dokployTarget('api', { vars: ['API_KEY'] }))); // only the picked variable 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'] }))); @@ -264,7 +268,7 @@ describe('a run: values, preflight, record and PR', () => { 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')); + expect(apiBlob.variables.DB_URL[0].targets ?? []).toEqual([]); // not picked: nothing recorded for it }); test('every PR is read from the target\'s base, and the delivered result carries its URL', async () => { @@ -312,9 +316,11 @@ describe('a run: values, preflight, record and PR', () => { 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 () => { + test('a PICKED 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); + // Both names are picked this time (a variable that is not picked is not "missing", it is not asked for). + const both = await planBatchDeploy(w.github, { names: [NAME, 'DB_URL'], locations: request.locations }, LINKS); + const result = await runBatchDeploy(both, 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']); diff --git a/tests/deploy/batchDeployPicked.test.ts b/tests/deploy/batchDeployPicked.test.ts new file mode 100644 index 00000000..9d895728 --- /dev/null +++ b/tests/deploy/batchDeployPicked.test.ts @@ -0,0 +1,80 @@ +/** + * Batch deploy sends only the PICKED variables (CAP-715), end to end with the REAL Dokploy adapter against a + * scripted service. The plan selects every target that ships at least one picked name; before this, such a target + * got ALL of its deploy.json variables delivered. Now: only the picked names are written, and every other line Capy + * already manages in the service's block is left exactly as it is, byte for byte. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { createDokployAdapter, mergeManagedValuesBlock, MANAGED_BEGIN, MANAGED_END } from '../../src/deploy/adapters/dokploy'; +import { SKIP_CODES, deployLocationsOf, planBatchDeploy, runBatchDeploy } from '../../src/deploy/batchDeploy'; +import { + LINKS, + NAME, + VALUES, + fakeGithub, + fakeService, + indexRows, + makeEnv, + standardRepos, +} from '../helpers/batchDeployWorld'; + +const request = { names: [NAME], locations: deployLocationsOf(indexRows()[0]) }; + +/** The Dokploy `api` target's service already holds DB_URL (delivered earlier, not picked now) inside Capy's block. */ +const DB_LINE = "DB_URL='postgres://old-delivered/db'"; +const BEFORE = `NODE_ENV=production\n${MANAGED_BEGIN}\n${DB_LINE}\n${MANAGED_END}\n`; +const AFTER = `NODE_ENV=production\n${MANAGED_BEGIN}\n${DB_LINE}\nAPI_KEY=${VALUES.API_KEY}\n${MANAGED_END}\n`; + +const appJson = (env: string) => ({ + applicationId: 'app-api', + name: 'api', + env, + buildArgs: null, + buildSecrets: null, + createEnvFile: true, + autoDeploy: true, + branch: 'main', +}); + +describe('batch deploy of picked variables, real Dokploy adapter', () => { + test('delivering API_KEY only leaves the DB_URL line in the block byte for byte (and the bytes written are exactly that)', async () => { + // The adapter's reads: preflight, deploy's fresh read, the read-back after the write. Only `api` is run. + const reads = [BEFORE, BEFORE, AFTER][Symbol.iterator](); + const saved = mock((_env: string) => undefined); + const fetch = mock(async (url: string, init: { body?: string }) => { + const path = new URL(url).pathname; + if (path.endsWith('application.saveEnvironment')) { + saved(JSON.parse(init.body ?? '{}').env as string); + return { status: 200, ok: true, text: async () => 'true' }; + } + if (path.endsWith('application.one')) { + const next = reads.next(); + if (next.done) throw new Error('unscripted extra application.one read'); + return { status: 200, ok: true, text: async () => JSON.stringify(appJson(next.value)) }; + } + throw new Error(`unscripted Dokploy request: ${path}`); + }); + const adapter = createDokployAdapter({ fetch: fetch as never, log: () => undefined }); + const service = fakeService(); + const github = fakeGithub(standardRepos()); + const { env } = makeEnv(service, github, adapter, { resolveApiKey: async () => ({ ok: true, value: 'dk', source: 'env' }) }); + const plan = await planBatchDeploy(github, request, LINKS); + const only = { ...plan, targets: plan.targets.filter((t) => t.config.name === 'api') }; + expect(only.targets[0].picked).toEqual([NAME]); // `api` also lists DB_URL, which is not picked + + const result = await runBatchDeploy(only, env); + expect(result.targets[0]).toMatchObject({ kind: 'delivered' }); + expect(saved.mock.calls.map((c) => c[0])).toEqual([AFTER]); + // What was recorded and put in the PR is the picked variable alone. + const keepSent = JSON.parse(String(service.pushSecrets.mock.calls[0][1])) as { variables: Record }>> }; + expect(keepSent.variables.API_KEY[0].targets?.map((t) => t.target)).toContain('api'); + expect(keepSent.variables.DB_URL[0].targets?.map((t) => t.target) ?? []).not.toContain('api'); + }); + + test('the same delivery without `keepOtherManaged` would have dropped the other line (the old behavior is what the option changes)', () => { + const merged = mergeManagedValuesBlock(BEFORE, [{ name: NAME, value: VALUES.API_KEY }]); + if (!merged.ok) throw new Error('fixture'); + expect(merged.env).not.toContain('DB_URL'); + expect(SKIP_CODES.KEPT_ON_PLATFORM).toBe('KEPT_ON_PLATFORM'); + }); +}); diff --git a/tests/deploy/deliveryRecord.test.ts b/tests/deploy/deliveryRecord.test.ts index 30579e16..417b5686 100644 --- a/tests/deploy/deliveryRecord.test.ts +++ b/tests/deploy/deliveryRecord.test.ts @@ -7,6 +7,7 @@ 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'; +import { SyncEngine } from '../../src/sync/syncEngine'; 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') }); @@ -47,6 +48,40 @@ describe('recordDeliveriesOnServer', () => { } }); + test('the push names the keep it was built from (CAP-712)', async () => { + const serverKeep = serverKeepFor('pA', 'production'); + const c = client(JSON.stringify(serverKeep)); + await recordDeliveriesOnServer(c, 'pA', 'production', [{ delivery, values, deliveredAt: AT }]); + expect(c.pushSecrets.mock.calls[0][5]).toBe(SyncEngine.computeKeepHash(serverKeep, 'production')); + }); + + test('stale base: read again and record on top of the new keep, once', async () => { + const first = serverKeepFor('pA', 'production'); + const second: KeepFile = { ...first, deploy_revision: 7 }; + const reads = mock(async () => ({ env_file: 'BLOB-AS-STORED', keep_hash: 'h', keep_file: JSON.stringify(reads.mock.calls.length === 1 ? first : second) })); + const pushes = mock(async (..._a: unknown[]) => { + if (pushes.mock.calls.length === 1) throw new CapyError('moved', ERROR_CODES.SECRETS_STALE_BASE, { status: 409 }); + return {}; + }); + const out = await recordDeliveriesOnServer({ getLatestSecrets: reads, pushSecrets: pushes }, 'pA', 'production', [{ delivery, values, deliveredAt: AT }]); + expect(out).toEqual({ ok: true, pushed: true }); + expect(reads.mock.calls).toHaveLength(2); + expect(pushes.mock.calls).toHaveLength(2); + // The second push was built from the SECOND read. + expect((JSON.parse(String(pushes.mock.calls[1][1])) as KeepFile).deploy_revision).toBe(7); + expect(pushes.mock.calls[1][5]).toBe(SyncEngine.computeKeepHash(second, 'production')); + }); + + test('stale twice: the code, not a loop', async () => { + const reads = mock(async () => ({ env_file: 'B', keep_hash: 'h', keep_file: JSON.stringify(serverKeepFor('pA', 'production')) })); + const pushes = mock(async (..._a: unknown[]) => { + throw new CapyError('moved', ERROR_CODES.SECRETS_STALE_BASE, { status: 409 }); + }); + const out = await recordDeliveriesOnServer({ getLatestSecrets: reads, pushSecrets: pushes }, 'pA', 'production', [{ delivery, values, deliveredAt: AT }]); + expect(out).toEqual({ ok: false, code: ERROR_CODES.SECRETS_STALE_BASE }); + expect(pushes.mock.calls).toHaveLength(2); + }); + 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/dokployTargetConflict.test.ts b/tests/deploy/dokployTargetConflict.test.ts new file mode 100644 index 00000000..c5f5eb3b --- /dev/null +++ b/tests/deploy/dokployTargetConflict.test.ts @@ -0,0 +1,339 @@ +/** + * Platform values (CAP-717): before a Dokploy delivery writes the service's env it compares each delivered + * name's LIVE value (remote) with what Capy last delivered (keep.lock's `deployed_value_hash`, pinned) and with + * what it is about to deliver (local). A value changed on the platform outside Capy needs an answer: + * local → Capy's value is delivered (the outside line is commented out as `# capy:off`, as always); + * remote → the variable is skipped: not written, its outside line not commented, no delivery recorded for it. + * + * The pure half (`dokployTargetConflicts`, `mergeManagedValuesBlock`'s `keep`) is tested as functions; the adapter + * half against a scripted Dokploy that fails on any request the test did not expect, so "nothing was written" is + * "the script has no save step". Values here are fake, non-secret strings. + */ +import { describe, test, expect } from 'bun:test'; +import { + createDokployAdapter, + mergeManagedValuesBlock, + CAPY_OFF_MARKER, + MANAGED_BEGIN, + MANAGED_END, + type FetchLike, +} from '../../src/deploy/adapters/dokploy'; +import { dokployTargetConflicts, planTargetDelivery } from '../../src/deploy/dokployApi'; +import type { DeployContext, TargetConfig } from '../../src/deploy/adapter'; +import { hashValue } from '../../src/sync/compareSecrets'; + +const h = hashValue; + +describe('dokployTargetConflicts (pure)', () => { + const values = [{ name: 'STRIPE_KEY', value: 'new-capy' }]; + + test('a live value that is neither what Capy last delivered nor what it is about to deliver is a conflict (fingerprints only)', () => { + const found = dokployTargetConflicts('STRIPE_KEY=edited-outside\n', values, { STRIPE_KEY: h('old-delivered') }); + expect(found).toEqual([{ name: 'STRIPE_KEY', pinned: h('old-delivered'), local: h('new-capy'), remote: h('edited-outside') }]); + }); + + test('first delivery (nothing recorded) onto a different existing value is a conflict, with no pinned', () => { + expect(dokployTargetConflicts('STRIPE_KEY=already-there', values, {})).toEqual([ + { name: 'STRIPE_KEY', pinned: undefined, local: h('new-capy'), remote: h('already-there') }, + ]); + }); + + test('no conflict: not on the service, already Capy\'s value, or still what Capy last delivered', () => { + expect(dokployTargetConflicts('NODE_ENV=production', values, {})).toEqual([]); + expect(dokployTargetConflicts('STRIPE_KEY=new-capy', values, {})).toEqual([]); + expect(dokployTargetConflicts('STRIPE_KEY=old-delivered', values, { STRIPE_KEY: h('old-delivered') })).toEqual([]); + expect(dokployTargetConflicts(null, values, {})).toEqual([]); + }); + + test('remote is what the service would READ: the whole env through dotenv, inside Capy\'s block or an active line outside it', () => { + const inBlock = `NODE_ENV=production\n${MANAGED_BEGIN}\nSTRIPE_KEY='edited in the block'\n${MANAGED_END}`; + expect(dokployTargetConflicts(inBlock, values, { STRIPE_KEY: h('old-delivered') })[0]?.remote).toBe(h('edited in the block')); + // A line Capy commented out is a comment: the service does not see it. + const commented = `${CAPY_OFF_MARKER}STRIPE_KEY=hidden\nNODE_ENV=production`; + expect(dokployTargetConflicts(commented, values, {})).toEqual([]); + }); + + test('planTargetDelivery: unanswered conflicts stop the write; answers decide what is written and skipped', () => { + const env = 'STRIPE_KEY=edited-outside\n'; + const both = [...values, { name: 'PORT', value: '3000' }]; + const check = { pinned: { STRIPE_KEY: h('old-delivered') }, resolutions: {} }; + const open = planTargetDelivery(env, both, check); + expect(open.kind).toBe('conflict'); + const seen = (choice: 'local' | 'remote') => ({ STRIPE_KEY: { choice, remote: h('edited-outside'), local: h('new-capy') } }); + const local = planTargetDelivery(env, both, { ...check, resolutions: seen('local') }); + expect(local).toMatchObject({ kind: 'clear', skipped: [] }); + expect(local.kind === 'clear' && local.deliver.map((v) => v.name)).toEqual(['STRIPE_KEY', 'PORT']); + const remote = planTargetDelivery(env, both, { ...check, resolutions: seen('remote') }); + expect(remote).toMatchObject({ kind: 'clear', skipped: ['STRIPE_KEY'] }); + expect(remote.kind === 'clear' && remote.deliver.map((v) => v.name)).toEqual(['PORT']); + // A decision applies only to the state it was made against: the live value moved, or the value being delivered did. + const moved = planTargetDelivery('STRIPE_KEY=moved-after-the-answer\n', both, { ...check, resolutions: seen('local') }); + expect(moved.kind).toBe('conflict'); + expect(moved.kind === 'conflict' && moved.open.map((c) => c.remote)).toEqual([h('moved-after-the-answer')]); + const otherLocal = planTargetDelivery(env, [{ name: 'STRIPE_KEY', value: 'another-capy-value' }], { ...check, resolutions: seen('remote') }); + expect(otherLocal.kind).toBe('conflict'); + // No check at all: the delivery is as it always was. + expect(planTargetDelivery(env, both, undefined)).toMatchObject({ kind: 'clear', skipped: [], found: [] }); + }); +}); + +describe('mergeManagedValuesBlock with `keep` (a delivery that leaves other managed lines alone)', () => { + const AWS = "AWS_ACCESS_KEY_ID='AKIA-fake'"; + const existing = `NODE_ENV=production\n${MANAGED_BEGIN}\n${AWS}\nGEMINI_KEY=gemini-old\n${MANAGED_END}\n`; + const merge = (env: string, values: ReadonlyArray<{ name: string; value: string }>, keep?: (n: string) => boolean) => { + const merged = mergeManagedValuesBlock(env, values, keep === undefined ? {} : { keep }); + if (!merged.ok) throw new Error('unexpected merge problem'); + return merged.env; + }; + + test('delivering GEMINI only leaves the existing AWS line in the block byte for byte', () => { + const next = merge(existing, [{ name: 'GEMINI_KEY', value: 'gemini-new' }], () => true); + expect(next).toBe(`NODE_ENV=production\n${MANAGED_BEGIN}\n${AWS}\nGEMINI_KEY=gemini-new\n${MANAGED_END}\n`); + // Without `keep` the whole block is the delivery, as it always was. + expect(merge(existing, [{ name: 'GEMINI_KEY', value: 'gemini-new' }])).toBe(`NODE_ENV=production\n${MANAGED_BEGIN}\nGEMINI_KEY=gemini-new\n${MANAGED_END}\n`); + }); + + test('a new picked name is appended; the others, in their order, are untouched; delivering again changes nothing', () => { + const next = merge(existing, [{ name: 'OPENAI_KEY', value: 'oa' }], () => true); + expect(next).toBe(`NODE_ENV=production\n${MANAGED_BEGIN}\n${AWS}\nGEMINI_KEY=gemini-old\nOPENAI_KEY=oa\n${MANAGED_END}\n`); + expect(merge(next, [{ name: 'OPENAI_KEY', value: 'oa' }], () => true)).toBe(next); + }); + + test('keeping a name the service edited by hand does not touch it, nor un-comment what Capy commented for it', () => { + const env = `${CAPY_OFF_MARKER}AWS_ACCESS_KEY_ID=outside-old\n${MANAGED_BEGIN}\nAWS_ACCESS_KEY_ID='edited by hand'\n${MANAGED_END}`; + const next = merge(env, [{ name: 'PORT', value: '3000' }], (name) => name === 'AWS_ACCESS_KEY_ID'); + expect(next).toBe(`${CAPY_OFF_MARKER}AWS_ACCESS_KEY_ID=outside-old\n${MANAGED_BEGIN}\nAWS_ACCESS_KEY_ID='edited by hand'\nPORT=3000\n${MANAGED_END}`); + }); + + test('CRLF framing and a multi-line quoted value survive a kept line', () => { + const multi = `PEM='line one\nline two'`; + const env = `A=1\r\n${MANAGED_BEGIN}\r\n${multi}\r\nB=2\r\n${MANAGED_END}\r\n`; + const next = merge(env, [{ name: 'B', value: '3' }], () => true); + expect(next).toBe(`A=1\r\n${MANAGED_BEGIN}\r\n${multi}\r\nB=3\r\n${MANAGED_END}\r\n`); + }); +}); + +// ── The adapter against a scripted Dokploy ────────────────────────────────── + +const BASE = 'https://dokploy.example.com'; +const TOKEN = 'dk_test_token'; +const APP_ID = 'app_conflict'; +const KEY = { ok: true as const, value: TOKEN, source: 'env' as const }; + +interface Step { + readonly expect: (path: string, body: Record | null) => void; + readonly json?: unknown; +} + +/** Fails on any request the script does not list, in order. `done()` says whether all were made. */ +function scripted(steps: readonly Step[]): { fetch: FetchLike; done: () => boolean } { + const it = steps[Symbol.iterator](); + const fetch: FetchLike = async (url, init) => { + const next = it.next(); + if (next.done) throw new Error(`unscripted request: ${init.method} ${url}`); + next.value.expect(new URL(url).pathname.replace('/api/', ''), init.body ? (JSON.parse(init.body) as Record) : null); + return { status: 200, ok: true, text: async () => JSON.stringify(next.value.json ?? null) }; + }; + return { fetch, done: () => it.next().done === true }; +} + +const app = (env: string) => ({ + applicationId: APP_ID, + name: 'web', + appName: 'web-abc', + env, + buildArgs: null, + buildSecrets: null, + createEnvFile: true, + autoDeploy: true, + branch: 'main', +}); +const read = (env: string): Step => ({ expect: (path) => expect(path).toBe('application.one'), json: app(env) }); +const save = (expectedEnv: string): Step => ({ + expect: (path, body) => { + expect(path).toBe('application.saveEnvironment'); + expect(body?.env).toBe(expectedEnv); + }, + json: true, +}); + +const target = (vars: readonly string[] = ['STRIPE_KEY']): TargetConfig => ({ + name: 'dokploy-production', + kind: 'dokploy', + branch: 'production', + vars: [...vars], + mode: 'ci', + options: { baseUrl: BASE, applicationId: APP_ID, tokenEnv: 'DOKPLOY_API_KEY' }, +}); +const ctx = (over: Partial): DeployContext => ({ + env: { STRIPE_KEY: 'new-capy', PORT: '3000' }, + dryRun: false, + secretsOnly: true, + cwd: '/tmp', + resolvedApiKey: KEY, + ...over, +}); +const adapterWith = (fetch: FetchLike) => createDokployAdapter({ fetch, log: () => undefined }); + +const OUTSIDE = 'NODE_ENV=production\nSTRIPE_KEY=already-there\n'; +/** A target check whose decisions were made against the conflict OUTSIDE shows (live 'already-there', delivering 'new-capy'). */ +const check = (resolutions: Record = {}, pinned: Record = {}) => ({ + pinned, + resolutions: Object.fromEntries(Object.entries(resolutions).map(([name, choice]) => [name, { choice, remote: h('already-there'), local: h('new-capy') }])), +}); + +describe('the Dokploy adapter and target conflicts', () => { + test('a first delivery onto a different value already on the service is refused with the conflict: nothing is written', async () => { + const script = scripted([read(OUTSIDE)]); // no save step: a write would be an unscripted request + const result = await adapterWith(script.fetch).deploy(target(), ctx({ targetCheck: check() })); + expect(result.ok).toBe(false); + expect(result.steps.at(-1)?.code).toBe('SECRETS_CONFLICT'); + expect(result.conflicts).toEqual([ + { name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, local: h('new-capy'), remote: h('already-there') }, + ]); + expect(script.done()).toBe(true); + }); + + test('a value edited on the service since Capy last delivered it is a conflict (pinned = what Capy delivered)', async () => { + const env = `NODE_ENV=production\n${MANAGED_BEGIN}\nSTRIPE_KEY='hand-edited'\n${MANAGED_END}`; + const script = scripted([read(env)]); + const result = await adapterWith(script.fetch).deploy(target(), ctx({ targetCheck: check({}, { STRIPE_KEY: h('old-delivered') }) })); + expect(result.ok).toBe(false); + expect(result.conflicts?.[0]).toMatchObject({ pinned: h('old-delivered'), local: h('new-capy'), remote: h('hand-edited') }); + expect(script.done()).toBe(true); + }); + + test('local delivers Capy\'s value and comments the outside line out as `# capy:off`, as it always did', async () => { + const merged = mergeManagedValuesBlock(OUTSIDE, [{ name: 'STRIPE_KEY', value: 'new-capy' }]); + if (!merged.ok) throw new Error('fixture'); + expect(merged.env).toContain(`${CAPY_OFF_MARKER}STRIPE_KEY=already-there`); + const script = scripted([read(OUTSIDE), save(merged.env), read(merged.env)]); + const result = await adapterWith(script.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'local' }) })); + expect(result.ok).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(script.done()).toBe(true); + }); + + test('a decision made against one live value does not apply once the service holds another: conflict again, nothing written (local and remote alike)', async () => { + const MOVED = 'NODE_ENV=production\nSTRIPE_KEY=moved-after-the-answer\n'; + // `check` binds its decisions to the live value OUTSIDE shows; the service now holds something else. No save step is scripted. + const answeredLocal = scripted([read(MOVED)]); + const local = await adapterWith(answeredLocal.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'local' }) })); + expect(local.ok).toBe(false); + expect(local.steps.at(-1)?.code).toBe('SECRETS_CONFLICT'); + expect(local.conflicts).toEqual([ + { name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, local: h('new-capy'), remote: h('moved-after-the-answer') }, + ]); + expect(answeredLocal.done()).toBe(true); + + const answeredRemote = scripted([read(MOVED)]); + const remote = await adapterWith(answeredRemote.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'remote' }) })); + expect(remote.ok).toBe(false); + expect(remote.skipped).toBeUndefined(); + expect(answeredRemote.done()).toBe(true); + + // The value being delivered moved after the answer (the decision saw 'new-capy'): the same. + const changedLocal = scripted([read(OUTSIDE)]); + const other = await adapterWith(changedLocal.fetch).deploy(target(), ctx({ env: { STRIPE_KEY: 'newer-capy', PORT: '3000' }, targetCheck: check({ STRIPE_KEY: 'local' }) })); + expect(other.ok).toBe(false); + expect(other.conflicts?.[0]).toMatchObject({ local: h('newer-capy'), remote: h('already-there') }); + expect(changedLocal.done()).toBe(true); + }); + + test('remote skips the variable: the service\'s env is not written at all when it was the only one, and nothing is recorded for it', async () => { + const script = scripted([read(OUTSIDE)]); + const result = await adapterWith(script.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'remote' }) })); + expect(result.ok).toBe(true); + expect(result.skipped).toEqual(['STRIPE_KEY']); + expect(script.done()).toBe(true); // read only: byte-identical by construction + }); + + test('remote for one variable still delivers the others, and the kept one stays exactly as it was (outside line active, not commented)', async () => { + const env = OUTSIDE; // PORT is not on the service yet + const merged = mergeManagedValuesBlock(env, [{ name: 'PORT', value: '3000' }], { keep: (n) => n === 'STRIPE_KEY' }); + if (!merged.ok) throw new Error('fixture'); + expect(merged.env).toContain('STRIPE_KEY=already-there'); + expect(merged.env).not.toContain(`${CAPY_OFF_MARKER}STRIPE_KEY`); + const script = scripted([read(env), save(merged.env), read(merged.env)]); + const result = await adapterWith(script.fetch).deploy(target(['STRIPE_KEY', 'PORT']), ctx({ targetCheck: check({ STRIPE_KEY: 'remote' }) })); + expect(result.ok).toBe(true); + expect(result.skipped).toEqual(['STRIPE_KEY']); + expect(script.done()).toBe(true); + }); + + test('a live value equal to Capy\'s own is not a conflict, and the delivery proceeds as before', async () => { + const env = 'STRIPE_KEY=new-capy\n'; + const merged = mergeManagedValuesBlock(env, [{ name: 'STRIPE_KEY', value: 'new-capy' }]); + if (!merged.ok) throw new Error('fixture'); + const script = scripted([read(env), save(merged.env), read(merged.env)]); + const result = await adapterWith(script.fetch).deploy(target(), ctx({ targetCheck: check() })); + expect(result.ok).toBe(true); + expect(result.conflicts).toBeUndefined(); + expect(script.done()).toBe(true); + }); + + test('without a targetCheck nothing is compared: the delivery is exactly what it was before CAP-717', async () => { + const merged = mergeManagedValuesBlock(OUTSIDE, [{ name: 'STRIPE_KEY', value: 'new-capy' }]); + if (!merged.ok) throw new Error('fixture'); + const script = scripted([read(OUTSIDE), save(merged.env), read(merged.env)]); + const result = await adapterWith(script.fetch).deploy(target(), ctx({})); + expect(result.ok).toBe(true); + expect(script.done()).toBe(true); + }); + + test('a dry run WITH a targetCheck is the preview: it reads, reports every conflict, writes nothing and refuses nothing', async () => { + const script = scripted([read(OUTSIDE)]); + const result = await adapterWith(script.fetch).deploy(target(), ctx({ dryRun: true, targetCheck: check() })); + expect(result.ok).toBe(true); + expect(result.conflicts).toHaveLength(1); + expect(script.done()).toBe(true); + // ... and a plain dry run still reads nothing at all. + const plain = scripted([]); + const planOnly = await adapterWith(plain.fetch).deploy(target(), ctx({ dryRun: true })); + expect(planOnly.ok).toBe(true); + expect(planOnly.conflicts).toBeUndefined(); + }); + + test('keepOtherManaged: delivering one picked variable leaves the other managed line in the block byte for byte', async () => { + const AWS = "AWS_ACCESS_KEY_ID='AKIA-fake'"; + const env = `NODE_ENV=production\n${MANAGED_BEGIN}\n${AWS}\nSTRIPE_KEY=old\n${MANAGED_END}`; + const expected = `NODE_ENV=production\n${MANAGED_BEGIN}\n${AWS}\nSTRIPE_KEY=new-capy\n${MANAGED_END}`; + const script = scripted([read(env), save(expected), read(expected)]); + const result = await adapterWith(script.fetch).deploy(target(['STRIPE_KEY']), ctx({ keepOtherManaged: true })); + expect(result.ok).toBe(true); + expect(script.done()).toBe(true); + }); +}); + +describe('the Dokploy adapter, Compose services', () => { + const COMPOSE_ID = 'compose_conflict'; + const composeTarget = (): TargetConfig => ({ ...target(), options: { baseUrl: BASE, composeId: COMPOSE_ID, tokenEnv: 'DOKPLOY_API_KEY' } }); + const compose = (env: string) => ({ composeId: COMPOSE_ID, name: 'stack', env, createEnvFile: true, composeType: 'docker-compose', autoDeploy: true, branch: 'main' }); + const readCompose = (env: string): Step => ({ expect: (path) => expect(path).toBe('compose.one'), json: compose(env) }); + const saveCompose = (expectedEnv: string): Step => ({ + expect: (path, body) => { + expect(path).toBe('compose.saveEnvironment'); + expect(body?.env).toBe(expectedEnv); + }, + json: true, + }); + + test('an unanswered conflict writes nothing; local writes; remote keeps the service\'s value', async () => { + const refused = scripted([readCompose(OUTSIDE)]); + const open = await adapterWith(refused.fetch).deploy(composeTarget(), ctx({ targetCheck: check() })); + expect(open.ok).toBe(false); + expect(open.conflicts).toHaveLength(1); + expect(refused.done()).toBe(true); + + const merged = mergeManagedValuesBlock(OUTSIDE, [{ name: 'STRIPE_KEY', value: 'new-capy' }]); + if (!merged.ok) throw new Error('fixture'); + const delivered = scripted([readCompose(OUTSIDE), saveCompose(merged.env), readCompose(merged.env)]); + expect((await adapterWith(delivered.fetch).deploy(composeTarget(), ctx({ targetCheck: check({ STRIPE_KEY: 'local' }) }))).ok).toBe(true); + expect(delivered.done()).toBe(true); + + const remote = scripted([readCompose(OUTSIDE)]); + const kept = await adapterWith(remote.fetch).deploy(composeTarget(), ctx({ targetCheck: check({ STRIPE_KEY: 'remote' }) })); + expect(kept).toMatchObject({ ok: true, skipped: ['STRIPE_KEY'] }); + expect(remote.done()).toBe(true); + }); +}); diff --git a/tests/helpers/deployServer.ts b/tests/helpers/deployServer.ts new file mode 100644 index 00000000..6b0ba707 --- /dev/null +++ b/tests/helpers/deployServer.ts @@ -0,0 +1,34 @@ +/** + * What a `capy deploy` test needs from "the Capy server" now that a deploy compares the values it is about + * to deliver with Capy's latest (CAP-717), and records through the one commit path: a `getDecryptData` answer, + * and a project key. Both are fixtures that make the server and the folder AGREE, so a test about something else + * (a warning printed once, a record pushed once) is not about conflicts. + */ +import * as actualKeyResolver from '../../src/crypto/keyResolver'; +import type { KeepFile } from '../../src/types/index'; + +/** + * The server's answer for `branch` in which every variable the keep pins there has a line this key cannot + * decrypt: its fingerprint is then the keep's own, so nothing the keep pins has moved. Pass `envLines` to give + * the blob exactly the lines a test wants back out of `pushSecrets` (they are kept verbatim). + */ +export function agreeingDecryptData(keep: KeepFile, branch: string, envLines?: string) { + const lines = Object.entries(keep.variables).flatMap(([name, entries]) => { + const entry = entries.find((e) => e.branch === branch); + return entry === undefined ? [] : [`${name}=capy:${entry.resource_id || 'r'}:AAAA`]; + }); + return { + env_content: envLines ?? lines.join('\n'), + decrypt_key: '', + expires_at: '', + keep_file: JSON.stringify(keep), + }; +} + +/** The project key `fixedKeyResolver` hands out: a test that needs a real encrypted server seeds it with this. */ +export const FIXED_PROJECT_KEY = 'ab'.repeat(32); + +/** A `keyResolver` module for `mock.module`: everything the real one exports, with a fixed project key. */ +export function fixedKeyResolver(): Record { + return { ...actualKeyResolver, resolveProjectKey: async () => FIXED_PROJECT_KEY }; +} diff --git a/tests/helpers/fakeCapyServer.ts b/tests/helpers/fakeCapyServer.ts new file mode 100644 index 00000000..4e9c7edb --- /dev/null +++ b/tests/helpers/fakeCapyServer.ts @@ -0,0 +1,210 @@ +/** + * An in-memory Capy secrets server for the commit-path tests (CAP-712): one project, any + * number of branches, the project-wide latest keep, env blobs stored by keep hash, and the + * stale-base check (`base_keep_hash` that is no longer the latest -> 409 `SECRETS_STALE_BASE`). + * + * No mutation, house style: the server's state is a pure fold over a tape of every push + * attempt. `tape` is a `mock`, so what each writer sent is also readable from its calls. + * A push the server refuses is on the tape too, and the fold skips it the same way the + * server did, so the state at any point is a function of the tape alone. + */ +import { mock } from 'bun:test'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { deriveResourceId } from '../../src/crypto/resourceId'; +import { FileManager } from '../../src/files/fileManager'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import type { CommitClient } from '../../src/sync/commit'; +import { CapyError, ERROR_CODES, KeepFile } from '../../src/types/index'; + +export interface PushCall { + readonly projectId: string; + readonly keepFile: string; + readonly envBlob: string; + readonly branch: string; + readonly baseKeepHash: string | undefined; +} + +export interface ServerState { + readonly keep: KeepFile | undefined; + /** env blobs by keep hash */ + readonly blobs: Readonly>; +} + +export const EMPTY_SERVER: ServerState = { keep: undefined, blobs: {} }; + +const staleAgainst = (state: ServerState, call: PushCall): boolean => + call.baseKeepHash !== undefined && state.keep !== undefined && SyncEngine.computeKeepHash(state.keep, call.branch) !== call.baseKeepHash; + +/** The state after one accepted push: the pushed branch's entries spliced into the stored keep, `changed_at` stamped. */ +function applyPush(state: ServerState, call: PushCall, index: number): ServerState { + const incoming = JSON.parse(call.keepFile) as KeepFile; + const spliced = SyncEngine.spliceKeepBranch(state.keep ?? null, incoming, call.branch); + const before = state.keep?.variables ?? {}; + const stampedAt = new Date(Date.UTC(2026, 9, 3, 0, 0, index)).toISOString(); + const stamped: KeepFile = { + ...spliced, + variables: Object.fromEntries( + Object.entries(spliced.variables).map(([name, entries]) => [ + name, + entries.map((e) => { + if (e.branch !== call.branch) return e; + const was = before[name]?.find((b) => b.branch === call.branch); + return was?.value_hash === e.value_hash && was.changed_at !== undefined ? { ...e, changed_at: was.changed_at } : { ...e, changed_at: stampedAt }; + }), + ]), + ), + }; + return { keep: stamped, blobs: { ...state.blobs, [SyncEngine.computeKeepHash(stamped, call.branch)]: call.envBlob } }; +} + +const foldTape = (initial: ServerState, calls: readonly (readonly [PushCall])[]): ServerState => + calls.reduce((state, [call], index) => (staleAgainst(state, call) ? state : applyPush(state, call, index)), initial); + +/** The server state with `values` (by branch) already saved, as one writer would have saved them. */ +export function seedServer(opts: { + readonly projectId?: string; + readonly projectKey: string; + readonly values: Readonly>>>; +}): ServerState { + const projectId = opts.projectId ?? 'proj'; + const branches = Object.keys(opts.values); + const keep: KeepFile = { + version: '3.0', + org_id: 'org', + project_id: projectId, + project_name: 'demo', + variables: Object.fromEntries( + [...new Set(branches.flatMap((b) => Object.keys(opts.values[b])))].map((name) => [ + name, + branches.filter((b) => name in opts.values[b]).map((b) => ({ resource_id: deriveResourceId(b, name), branch: b, value_hash: hashValue(opts.values[b][name]) })), + ]), + ), + }; + const blobFor = (b: string): string => + Object.entries(opts.values[b]) + .map(([name, value]) => `${name}=capy:${deriveResourceId(b, name)}:${Encryptor.encrypt(value, opts.projectKey)}`) + .join('\n'); + return { keep, blobs: Object.fromEntries(branches.map((b) => [SyncEngine.computeKeepHash(keep, b), blobFor(b)])) }; +} + +export interface FakeServerOpts { + readonly initial?: ServerState; + /** + * Pushes that land from someone else right after the CLI's Nth read (N counts from 1): the + * CLI's next push is built from a keep that is no longer the latest, and the server refuses + * it with 409. + */ + readonly racing?: (readNumber: number) => readonly PushCall[]; +} + +/** A push as another writer would send it: `branch` holding exactly `values` (no `base_keep_hash`, so always accepted). */ +export function pushOf(opts: { + readonly projectKey: string; + readonly branch: string; + readonly values: Readonly>; + readonly projectId?: string; +}): PushCall { + const keep: KeepFile = { + version: '3.0', + org_id: 'org', + project_id: opts.projectId ?? 'proj', + project_name: 'demo', + variables: Object.fromEntries( + Object.entries(opts.values).map(([name, value]) => [ + name, + [{ resource_id: deriveResourceId(opts.branch, name), branch: opts.branch, value_hash: hashValue(value) }], + ]), + ), + }; + return { + projectId: opts.projectId ?? 'proj', + keepFile: JSON.stringify(keep), + envBlob: Object.entries(opts.values) + .map(([name, value]) => `${name}=capy:${deriveResourceId(opts.branch, name)}:${Encryptor.encrypt(value, opts.projectKey)}`) + .join('\n'), + branch: opts.branch, + baseKeepHash: undefined, + }; +} + +export function fakeCapyServer(opts: FakeServerOpts = {}) { + const initial = opts.initial ?? EMPTY_SERVER; + const tape = mock((_call: PushCall) => undefined); + const stateNow = (): ServerState => foldTape(initial, tape.mock.calls as unknown as readonly (readonly [PushCall])[]); + + const getDecryptData = mock(async (_projectId: string, branch?: string) => { + const state = stateNow(); + const hash = state.keep === undefined ? undefined : SyncEngine.computeKeepHash(state.keep, branch); + const response = { + env_content: hash === undefined ? '' : (state.blobs[hash] ?? ''), + decrypt_key: '', + expires_at: '', + ...(state.keep === undefined ? {} : { keep_hash: hash, keep_file: JSON.stringify(state.keep) }), + }; + // Someone else saves right after this (first) read. + (opts.racing?.(getDecryptData.mock.calls.length) ?? []).forEach((call) => tape(call)); + return response; + }); + + const pushSecrets = mock(async (projectId: string, keepFile: string, envBlob: string, branch: string, _onRate?: unknown, baseKeepHash?: string) => { + const call: PushCall = { projectId, keepFile, envBlob, branch, baseKeepHash }; + const before = stateNow(); + tape(call); + if (staleAgainst(before, call)) { + throw new CapyError('The keep this push was built from is no longer the latest.', ERROR_CODES.SECRETS_STALE_BASE, { status: 409 }); + } + const after = stateNow(); + return { keep_hash: SyncEngine.computeKeepHash(after.keep as KeepFile, branch), keep_file: JSON.stringify(after.keep) }; + }); + + return { + client: { getDecryptData, pushSecrets } as unknown as CommitClient, + getDecryptData, + pushSecrets, + tape, + state: stateNow, + /** Pushes the server accepted, in order. */ + accepted: (): readonly PushCall[] => { + const calls = tape.mock.calls.map(([c]) => c as PushCall); + return calls.filter((c, i) => !staleAgainst(foldTape(initial, calls.slice(0, i).map((x) => [x] as const)), c)); + }, + }; +} + +/** What a branch holds on the server right now, decrypted (lines this key cannot decrypt are left out). */ +export function serverValues(server: ServerState, branch: string, projectKey: string): Record { + if (server.keep === undefined) return {}; + const blob = server.blobs[SyncEngine.computeKeepHash(server.keep, branch)] ?? ''; + const fm = new FileManager(); + return Object.fromEntries( + blob + .split('\n') + .filter((l) => l.length > 0) + .flatMap((line) => { + const eq = line.indexOf('='); + // A line this key cannot decrypt (someone else's) is not a value here. + return tryDecrypt(fm, line.slice(eq + 1), projectKey).map((value) => [line.slice(0, eq), value] as const); + }), + ); +} + +const tryDecrypt = (fm: FileManager, line: string, key: string): readonly string[] => { + try { + return [fm.decryptValue(line, key)]; + } catch { + return []; + } +}; + +/** The server's ciphertext line for `name` on `branch`, exactly as stored. */ +export function serverLine(server: ServerState, branch: string, name: string): string | undefined { + if (server.keep === undefined) return undefined; + const blob = server.blobs[SyncEngine.computeKeepHash(server.keep, branch)] ?? ''; + return blob.split('\n').find((l) => l.startsWith(`${name}=`)); +} + +/** The server's pinned hash for `name` on `branch`. */ +export function serverHash(server: ServerState, branch: string, name: string): string | undefined { + return server.keep?.variables[name]?.find((e) => e.branch === branch)?.value_hash; +} diff --git a/tests/helpers/folderWorld.ts b/tests/helpers/folderWorld.ts new file mode 100644 index 00000000..42e09ee6 --- /dev/null +++ b/tests/helpers/folderWorld.ts @@ -0,0 +1,66 @@ +/** + * A real folder on disk (keep.lock, `.env`) for the commit-path tests (CAP-712), pinned to + * whatever keep the test hands it. Reads go through the same `ProjectManager` / + * `FileManager` the commands use, so what a test asserts is what the next command would see. + */ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { ProjectManager } from '../../src/core/projectManager'; +import { FileManager } from '../../src/files/fileManager'; +import { readLocalPlaintext, type CommitClient, type FolderContext } from '../../src/sync/commit'; +import type { KeepFile } from '../../src/types/index'; + +export interface Folder { + readonly dir: string; + readonly pm: ProjectManager; + readonly fileManager: FileManager; + /** The folder as the command starts: keep.lock and `.env` read from disk. */ + readonly ctx: FolderContext; + /** `.env`, decrypted, as it is on disk now. */ + readonly env: () => Record; + /** The keep.lock on disk now. */ + readonly keep: () => KeepFile; + /** keep.lock's pin for `name` on the folder's branch now. */ + readonly pin: (name: string) => string | undefined; +} + +export function makeFolder(opts: { + readonly projectKey: string; + readonly branch: string; + /** The keep.lock the folder is pinned to. */ + readonly keep: KeepFile; + /** What its `.env` holds. */ + readonly env: Readonly>; + readonly client: CommitClient | undefined; + readonly projectId?: string; +}): Folder { + const dir = mkdtempSync(join(tmpdir(), 'capy-folder-')); + const pm = new ProjectManager(dir); + const fileManager = new FileManager(dir); + fileManager.writeKeepFile(opts.keep); + fileManager.writeEncryptedEnvFile({ ...opts.env }, opts.projectKey, undefined, opts.keep, opts.branch); + const keep = (): KeepFile => pm.readKeepFile() as KeepFile; + return { + dir, + pm, + fileManager, + ctx: { + pm, + fileManager, + serviceClient: opts.client, + orgId: opts.keep.org_id, + projectId: opts.projectId ?? opts.keep.project_id, + branch: opts.branch, + userId: 'user-b', + projectKey: opts.projectKey, + keep: keep(), + localPlaintext: readLocalPlaintext(fileManager, opts.projectKey), + }, + env: () => readLocalPlaintext(fileManager, opts.projectKey), + keep, + pin: (name) => keep().variables[name]?.find((e) => e.branch === opts.branch)?.value_hash, + }; +} + +export const removeFolder = (folder: Folder): void => rmSync(folder.dir, { recursive: true, force: true }); diff --git a/tests/helpers/gateMock.ts b/tests/helpers/gateMock.ts new file mode 100644 index 00000000..d64bf001 --- /dev/null +++ b/tests/helpers/gateMock.ts @@ -0,0 +1,32 @@ +/** + * A stand-in for bare `capy` as the first step of a command (src/sync/gate.ts), for the tests of a command's OWN + * behaviour (its refusals, its prompts, its output), which must not reach for Keep. + * + * Use it in a file that runs isolated (`mock.module()` is process-wide; see tests/run-tests.sh), BEFORE the command is + * imported: + * + * import { stubbedGate } from '../helpers/gateMock'; + * mock.module('../../src/sync/gate', () => stubbedGate()); + * + * `gateFirst` and `ensureInSync` report the folder as in sync and record that they ran (`gateRan`), with what they were + * given; everything else the module exports (`commitGated`, `answersByProtocol`, ...) is the real one. The gate itself is + * tested in tests/sync/gate.test.ts; that every command calls it first is tested in tests/commands/gateFirst.test.ts. + */ +import { mock } from 'bun:test'; +import * as realGate from '../../src/sync/gate'; + +export const gateRan = mock((_opts: unknown) => undefined); + +export function stubbedGate(result: realGate.GateResult = 'in-sync') { + return { + ...realGate, + gateFirst: async (opts: realGate.GateOptions = {}) => { + gateRan(opts); + if (result === 'stopped') realGate.stopWithNothingWritten(); + }, + ensureInSync: async (opts: realGate.GateOptions = {}) => { + gateRan(opts); + return result; + }, + }; +} diff --git a/tests/helpers/pipedHarness.ts b/tests/helpers/pipedHarness.ts index 6d2d77bf..eda340dc 100644 --- a/tests/helpers/pipedHarness.ts +++ b/tests/helpers/pipedHarness.ts @@ -9,6 +9,7 @@ * POST /orgs/:org/co-decrypt identity (the on-disk blob is already the inner layer) * POST /orgs/:org/wrap identity * POST /secrets/:project answers like the real one, or 500 once `failPushes()` was called + * GET /secrets/:project?branch= the branch fixture if set, else what was pushed (404 before any push) * * Every request it receives is appended to a log file (and the push-failure * switch is a flag file) rather than held in a mutable variable, so tests can @@ -78,6 +79,8 @@ export interface Harness { setOrgRepos(body: unknown): void; /** Makes the mock answer `GET /secrets/:project?branch=` with this body. */ setBranchData(project: string, branch: string, body: unknown): void; + /** Makes the mock answer `GET /projects/:project/branches` with these branches (without it: 404, like an unknown project). */ + setBranches(project: string, branches: readonly { readonly id: string; readonly name: string; readonly is_protected: boolean }[]): void; /** How `PUT /orgs/:org/projects/:project/repos` answers: `ok`, `mismatch` (other repos known), `500`, or `hang` (never). Default `ok`. */ setRepoPutMode(mode: 'ok' | 'mismatch' | '500' | 'hang'): void; /** The `PUT .../repos` requests the mock has received (parsed bodies). */ @@ -161,6 +164,12 @@ function walkFiles(dir: string): readonly (readonly [string, Buffer])[] { }); } +/** What the mock service has been pushed so far (CAP-712: a save reads the server's latest first, so the mock must remember). */ +function readStore(root: string): { readonly keep: KeepFile | undefined; readonly blobs: Readonly> } { + const file = join(root, 'store.json'); + return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as ReturnType) : { keep: undefined, blobs: {} }; +} + interface MockService { readonly server: Server; readonly url: string; @@ -196,18 +205,35 @@ async function startMockService(requestLog: string, failFlag: string, root: stri known_repos: mode === 'mismatch' ? [{ host: 'github.com', owner: 'someone-else', name: 'copied-from', path: '.' }] : [], }); } + if (req.method === 'GET' && /^\/projects\/[^/?]+\/branches$/.test(path)) { + const data = fixture(`branches-${path.split('/')[2]}.json`); + return data === undefined ? send(404, { error: 'no branches fixture' }) : send(200, data); + } if (req.method === 'GET' && /^\/secrets\/[^/?]+\?/.test(path)) { const [project, query] = path.slice('/secrets/'.length).split('?'); const branch = new URLSearchParams(query).get('branch') ?? ''; const data = fixture(`branch-${project}-${branch}.json`); - return data === undefined ? send(404, { error: 'no branch fixture' }) : send(200, data); + if (data !== undefined) return send(200, data); + // No fixture: answer like the real server does, from what was pushed (nothing pushed yet: 404). + const stored = readStore(root); + if (stored.keep === undefined) return send(404, { error: 'no branch fixture' }); + const hash = SyncEngine.computeKeepHash(stored.keep, branch); + return send(200, { env_file: stored.blobs[hash] ?? '', permissions: ['*'], keep_hash: hash, keep_file: JSON.stringify(stored.keep) }); } if (path.endsWith('/co-decrypt')) return send(200, { plaintext: (JSON.parse(body) as { ciphertext: string }).ciphertext }); if (path.endsWith('/wrap')) return send(200, { ciphertext: (JSON.parse(body) as { plaintext: string }).plaintext }); if (path.startsWith('/secrets/') && req.method === 'POST') { if (existsSync(failFlag)) return send(500, { error: 'mock push failure' }); - const parsed = JSON.parse(body) as { keep_file: string; branch: string }; + const parsed = JSON.parse(body) as { keep_file: string; env_blob?: string; branch: string }; const keep = JSON.parse(parsed.keep_file) as KeepFile; + // Remember the push, like the real server: the next read answers with it. + const before = readStore(root); + const spliced = SyncEngine.spliceKeepBranch(before.keep ?? null, keep, parsed.branch); + const hash = SyncEngine.computeKeepHash(spliced, parsed.branch); + writeFileSync(join(root, 'store.json'), JSON.stringify({ keep: spliced, blobs: { ...before.blobs, [hash]: parsed.env_blob ?? '' } })); + // The push is now the server's latest for the branch: a fixture a test set earlier no longer describes it. + const project = path.slice('/secrets/'.length).split('?')[0]; + rmSync(join(root, `branch-${project}-${parsed.branch}.json`), { force: true }); return send(200, { keep_hash: SyncEngine.computeKeepHash(keep, parsed.branch) }); } return send(404, { error: 'unexpected request' }); @@ -271,6 +297,7 @@ export async function createHarness(): Promise { setSecretsIndex: (body) => writeFileSync(join(root, 'secrets-index.json'), JSON.stringify(body)), setOrgRepos: (body) => writeFileSync(join(root, 'org-repos.json'), JSON.stringify(body)), setBranchData: (projectId, branch, body) => writeFileSync(join(root, `branch-${projectId}-${branch}.json`), JSON.stringify(body)), + setBranches: (projectId, branches) => writeFileSync(join(root, `branches-${projectId}.json`), JSON.stringify({ branches })), setRepoPutMode: (mode) => writeFileSync(join(root, 'repo-put-mode'), mode), repoPuts: () => requests() @@ -297,3 +324,17 @@ export async function createHarness(): Promise { export function digest(value: string): string { return createHash('sha256').update(value).digest('hex'); } + +/** + * A world of its own for a test that leaves the folder different from Capy (`--no-push` writes `.env` only): the next + * command that writes meets bare `capy` first (docs/conflict-resolution.md) and would stop on that difference, so such a + * test must not share its world with the ones after it. + */ +export async function withHarness(fn: (harness: Harness) => Promise): Promise { + const harness = await createHarness(); + try { + return await fn(harness); + } finally { + await harness.dispose(); + } +} diff --git a/tests/helpers/syncFoldersChild.ts b/tests/helpers/syncFoldersChild.ts new file mode 100644 index 00000000..71524878 --- /dev/null +++ b/tests/helpers/syncFoldersChild.ts @@ -0,0 +1,20 @@ +/** + * Run as a child process (HOME points at a throwaway world): bare `capy`'s real runner over the folders given as + * arguments, one after the other, the way a discovery run does. Prints one JSON line: each folder's result, the working + * directory before and after, and whether anything tried to end the process. + */ +import { syncFolder } from '../../src/sync/gate'; + +const folders = process.argv.slice(2); +const ended = { code: undefined as number | undefined }; +process.exit = ((code?: number) => { + Object.assign(ended, { code: code ?? 0 }); + throw new Error('the process must not end'); +}) as never; + +const before = process.cwd(); +const results = await folders.reduce>( + async (acc, dir) => [...(await acc), await syncFolder(dir, { json: true, nonTty: true })], + Promise.resolve([]), +); +console.log(JSON.stringify({ results, before, after: process.cwd(), ended: ended.code })); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index b41c4823..ef59e404 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -43,6 +43,7 @@ ISOLATED_FILES=( tests/commands/deployDokploySystemStoreToken.test.ts tests/commands/deployCiChangeGate.test.ts tests/commands/deployCiTargetsRecord.test.ts + tests/commands/deployTargetConflict.test.ts tests/commands/deployRevokeWiring.test.ts tests/commands/deployDirectModeTiming.test.ts tests/commands/deployDokployPickerTokenEnv.test.ts @@ -62,6 +63,13 @@ ISOLATED_FILES=( tests/commands/localOnlyFlow.test.ts tests/ui/secretsScreenSearchHashing.test.ts tests/commands/rotatePromotesThenRotates.test.ts + tests/commands/rotateRefusals.test.ts + tests/commands/removeCommand.test.ts + tests/commands/connectDokploy.test.ts + tests/commands/gateFirst.test.ts + tests/commands/bootstrapNoSecrets.test.ts + tests/commands/bareCapyCommit.test.ts + tests/commands/deployGate.test.ts tests/system/systemStore.test.ts tests/system/systemStoreFreshStore.test.ts tests/commands/systemCommand.test.ts diff --git a/tests/service/serviceClient.test.ts b/tests/service/serviceClient.test.ts index 5b217f25..953ff9e3 100644 --- a/tests/service/serviceClient.test.ts +++ b/tests/service/serviceClient.test.ts @@ -475,3 +475,36 @@ describe('rate-limit headers as structured fields', () => { expect(b.details).toMatchObject({ retry_after_ms: 9000 }); }); }); + +// ── pushSecrets: the base it was built from, and the server's 409 (CAP-712) ─ + +describe('pushSecrets base_keep_hash and SECRETS_STALE_BASE', () => { + const client = () => new ServiceClient('http://localhost:3002'); + const sentBody = (): Record => JSON.parse(String(mockFetch.mock.calls[0][1].body)); + beforeEach(() => jest.clearAllMocks()); + + test('the base hash rides in the body when given, and is absent when not (an older server just ignores it)', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse({ keep_hash: 'h' })); + await client().pushSecrets('p1', '{}', 'blob', 'main', undefined, 'a'.repeat(64)); + expect(sentBody()).toEqual({ keep_file: '{}', env_blob: 'blob', branch: 'main', base_keep_hash: 'a'.repeat(64) }); + + jest.clearAllMocks(); + mockFetch.mockResolvedValueOnce(mockFetchResponse({ keep_hash: 'h' })); + await client().pushSecrets('p1', '{}', 'blob', 'main'); + expect(sentBody()).toEqual({ keep_file: '{}', env_blob: 'blob', branch: 'main' }); + }); + + test('a 409 with code SECRETS_STALE_BASE is thrown with that code, decided by the code and not the sentence', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse({ code: 'SECRETS_STALE_BASE', latest_keep_hash: 'b'.repeat(64), error: 'any wording at all' }, false, 409)); + const err = (await client().pushSecrets('p1', '{}', 'blob', 'main', undefined, 'a'.repeat(64)).catch((e: unknown) => e)) as CapyError; + expect(err).toBeInstanceOf(CapyError); + expect(err.code).toBe(ERROR_CODES.SECRETS_STALE_BASE); + expect(err.details).toMatchObject({ status: 409, data: { latest_keep_hash: 'b'.repeat(64) } }); + }); + + test('a 409 carrying some other code is not mistaken for it', async () => { + mockFetch.mockResolvedValueOnce(mockFetchResponse({ code: 'SOMETHING_ELSE', error: 'SECRETS_STALE_BASE' }, false, 409)); + const err = (await client().pushSecrets('p1', '{}', 'blob', 'main').catch((e: unknown) => e)) as CapyError; + expect(err.code).toBe(ERROR_CODES.SERVICE_ERROR); + }); +}); diff --git a/tests/sync/commit.test.ts b/tests/sync/commit.test.ts new file mode 100644 index 00000000..892d69c3 --- /dev/null +++ b/tests/sync/commit.test.ts @@ -0,0 +1,250 @@ +/** + * The shared upload (src/sync/commit.ts): what every write uses once bare `capy` has made the folder and Capy agree. + * It decides nothing about who is right. The properties, asserted on an in-memory server (tests/helpers/fakeCapyServer.ts) + * by decrypting its latest: + * - only the change moves: every other variable keeps the server's ciphertext line byte for byte, and another + * branch's blob and keep entries are exactly as they were; + * - the upload names the keep it was built from (`base_keep_hash`); a server that moved refuses it (409 + * `SECRETS_STALE_BASE`) and nothing is written; + * - a keep-only change (a deploy record) re-sends the blob as stored and goes again once if the server moved; + * - a folder commit leaves keep.lock's branch slice and `.env` equal to the server's. + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock } from 'bun:test'; +import { rmSync } from 'fs'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { + applyChanges, + changesToMatch, + commitFolder, + retryOnceOnStale, + snapshotFrom, + uploadChanges, + uploadChangesRereading, + uploadKeepOnly, + readSnapshot, +} from '../../src/sync/commit'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { CapyError, ERROR_CODES, type KeepFile } from '../../src/types/index'; +import { fakeCapyServer, pushOf, seedServer, serverHash, serverLine, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const DEV = 'development'; +const STG = 'staging'; + +const SEED = seedServer({ projectKey: KEY, values: { [DEV]: { X: 'x-1', Y: 'y-1', KEEP: 'k-1' }, [STG]: { X: 'x-stg', Y: 'y-stg' } } }); + +const globalDirName = `.capy-test-commit-${process.pid}`; +const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; +beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; +}); +afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; +}); + +// The folders a test made, removed afterwards (a list of ones already created, grown only by `world`). +const made = mock((_: Folder) => undefined); +afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + +function world(opts: Parameters[0] = {}) { + const server = fakeCapyServer({ initial: SEED, ...opts }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: SEED.keep as KeepFile, env: { X: 'x-1', Y: 'y-1', KEEP: 'k-1' }, client: server.client }); + made(folder); + return { server, folder, before: server.state() }; +} + +const upload = (w: ReturnType, changes: Parameters[0]['changes']) => + uploadChanges({ client: w.server.client, projectId: 'proj', branch: DEV, projectKey: KEY, startKeep: w.folder.ctx.keep, changes }); + +describe('applyChanges (pure)', () => { + const snapshot = snapshotFrom( + { env_content: ['A=capy:r1:AAAA', 'B=capy:r2:BBBB'].join('\n'), keep_file: undefined }, + DEV, + KEY, + { parseEnvContent: (c: string) => Object.fromEntries(c.split('\n').map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])), decryptValue: () => { throw new Error('not ours'); } }, + ); + + test('a line this key cannot decrypt is kept verbatim, whatever else changes', () => { + const applied = applyChanges({ latest: snapshot.entries, changes: { set: { C: 'new' }, remove: [] }, encryptLine: (n, v) => `capy:rC:${n}${v.length}` }); + expect(applied.lines).toEqual({ A: 'capy:r1:AAAA', B: 'capy:r2:BBBB', C: 'capy:rC:C3' }); + expect(applied.written).toEqual({ C: 'new' }); + expect(applied.env).toEqual({ C: 'new' }); + }); + + test('removing a name drops its line and nothing else; removing one the server lacks is a no-op', () => { + const applied = applyChanges({ latest: snapshot.entries, changes: { set: {}, remove: ['A', 'NOPE'] }, encryptLine: () => 'x' }); + expect(Object.keys(applied.lines)).toEqual(['B']); + expect(applied.removed).toEqual(['A']); + }); +}); + +describe('changesToMatch', () => { + test('is the change that makes Capy hold exactly the folder: what differs is set, what the folder lacks is removed', () => { + const w = world(); + return readSnapshot(w.server.client, 'proj', DEV, KEY).then((latest) => { + expect(changesToMatch(latest.entries, { X: 'x-1', Y: 'y-NEW', Z: 'z-NEW' })).toEqual({ set: { Y: 'y-NEW', Z: 'z-NEW' }, remove: ['KEEP'] }); + expect(changesToMatch(latest.entries, { X: 'x-1', Y: 'y-1', KEEP: 'k-1' })).toEqual({ set: {}, remove: [] }); + }); + }); +}); + +describe('uploadChanges', () => { + test('changes only its own name; every other line is the server\'s ciphertext byte for byte; the other branch is untouched', async () => { + const w = world(); + const uploaded = await upload(w, { set: { Y: 'y-2' }, remove: [] }); + expect(uploaded.written).toEqual(['Y']); + const after = w.server.state(); + expect(serverValues(after, DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-2', KEEP: 'k-1' }); + expect(serverLine(after, DEV, 'X')).toBe(serverLine(w.before, DEV, 'X')); + expect(serverLine(after, DEV, 'KEEP')).toBe(serverLine(w.before, DEV, 'KEEP')); + expect(serverValues(after, STG, KEY)).toEqual({ X: 'x-stg', Y: 'y-stg' }); + expect(serverHash(after, STG, 'Y')).toBe(hashValue('y-stg')); + }); + + test('names the keep it was built from: base_keep_hash is the branch hash of the keep it read', async () => { + const w = world(); + await upload(w, { set: { Y: 'y-2' }, remove: [] }); + expect(w.server.pushSecrets.mock.calls).toHaveLength(1); + expect(w.server.pushSecrets.mock.calls[0][5]).toBe(SyncEngine.computeKeepHash(w.before.keep as KeepFile, DEV)); + }); + + test('a server that moved since the read refuses it: SECRETS_STALE_BASE, and what a teammate saved is still there', async () => { + const teammate = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-TEAMMATE', Y: 'y-1', KEEP: 'k-1' } }); + const w = world({ racing: (n) => (n === 1 ? [teammate] : []) }); + const err = (await upload(w, { set: { Y: 'y-2' }, remove: [] }).catch((e: unknown) => e)) as CapyError; + expect(err).toBeInstanceOf(CapyError); + expect(err.code).toBe(ERROR_CODES.SECRETS_STALE_BASE); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-TEAMMATE', Y: 'y-1', KEEP: 'k-1' }); + }); + + test('uploadChangesRereading goes again from a fresh read, once: the teammate\'s X survives and Y lands', async () => { + const teammate = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-TEAMMATE', Y: 'y-1', KEEP: 'k-1' } }); + const w = world({ racing: (n) => (n === 1 ? [teammate] : []) }); + await uploadChangesRereading({ client: w.server.client, projectId: 'proj', branch: DEV, projectKey: KEY, startKeep: w.folder.ctx.keep, changes: { set: { Y: 'y-2' }, remove: [] } }); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-TEAMMATE', Y: 'y-2', KEEP: 'k-1' }); + expect(w.server.pushSecrets.mock.calls).toHaveLength(2); + }); + + test('a second stale refusal is thrown as it is', async () => { + const a = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-A', Y: 'y-1', KEEP: 'k-1' } }); + const b = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-B', Y: 'y-1', KEEP: 'k-1' } }); + const w = world({ racing: (n) => (n === 1 ? [a] : n === 2 ? [b] : []) }); + await expect(uploadChangesRereading({ client: w.server.client, projectId: 'proj', branch: DEV, projectKey: KEY, startKeep: w.folder.ctx.keep, changes: { set: { Y: 'y-2' }, remove: [] } })).rejects.toMatchObject({ + code: ERROR_CODES.SECRETS_STALE_BASE, + }); + }); +}); + +describe('retryOnceOnStale', () => { + const stale = () => new CapyError('stale', ERROR_CODES.SECRETS_STALE_BASE); + + test('runs the attempt once when it succeeds; goes again after `between` says to; stops (undefined) when it says not to', async () => { + const ok = mock(async () => 'done'); + expect(await retryOnceOnStale(ok, async () => true)).toBe('done'); + expect(ok).toHaveBeenCalledTimes(1); + + const flaky = mock(async () => { + if (flaky.mock.calls.length === 1) throw stale(); + return 'second'; + }); + expect(await retryOnceOnStale(flaky, async () => true)).toBe('second'); + + const never = mock(async (): Promise => { + throw stale(); + }); + expect(await retryOnceOnStale(never, async () => false)).toBeUndefined(); + expect(never).toHaveBeenCalledTimes(1); + }); + + test('a second stale refusal, and any other error, are thrown', async () => { + const always = mock(async (): Promise => { + throw stale(); + }); + await expect(retryOnceOnStale(always, async () => true)).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_STALE_BASE }); + expect(always).toHaveBeenCalledTimes(2); + await expect(retryOnceOnStale(async () => Promise.reject(new Error('boom')), async () => true)).rejects.toThrow('boom'); + }); +}); + +describe('uploadKeepOnly (a deploy record, a connector entry: no value moves)', () => { + const addNote = (keep: KeepFile): KeepFile => ({ + ...keep, + variables: { ...keep.variables, X: keep.variables.X.map((e) => (e.branch === DEV ? { ...e, targets: [{ provider: 'dokploy', target: 't1' }] } : e)) }, + }); + const clientOf = (server: ReturnType) => ({ + getLatestSecrets: async (_p: string, branch: string) => { + const state = server.state(); + const hash = SyncEngine.computeKeepHash(state.keep as KeepFile, branch); + return { env_file: state.blobs[hash] ?? '', keep_hash: hash, keep_file: JSON.stringify(state.keep) }; + }, + pushSecrets: server.pushSecrets, + }); + + test('re-sends the blob exactly as the server holds it and names the keep it read', async () => { + const w = world(); + const done = await uploadKeepOnly(clientOf(w.server), 'proj', DEV, addNote); + expect(done).toMatchObject({ ok: true, pushed: true }); + const after = w.server.state(); + expect(serverLine(after, DEV, 'X')).toBe(serverLine(w.before, DEV, 'X')); + expect(serverValues(after, DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-1', KEEP: 'k-1' }); + expect(w.server.pushSecrets.mock.calls[0][5]).toBe(SyncEngine.computeKeepHash(w.before.keep as KeepFile, DEV)); + }); + + test('nothing to change: nothing is pushed', async () => { + const w = world(); + expect(await uploadKeepOnly(clientOf(w.server), 'proj', DEV, (k) => k)).toMatchObject({ ok: true, pushed: false }); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('a server that moved in between: read again and go once more, on top of what a teammate saved', async () => { + const teammate = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-TEAMMATE', Y: 'y-1', KEEP: 'k-1' } }); + const w = world({ racing: () => [] }); + // The race: the teammate saves between this writer's read and its push (the read count is the writer's, so arm it on the first push). + const raced = { + getLatestSecrets: clientOf(w.server).getLatestSecrets, + pushSecrets: mock(async (...args: Parameters) => { + if (raced.pushSecrets.mock.calls.length === 1) w.server.tape(teammate); + return w.server.pushSecrets(...args); + }), + }; + const done = await uploadKeepOnly(raced, 'proj', DEV, addNote); + expect(done).toMatchObject({ ok: true, pushed: true }); + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-TEAMMATE'); + expect(w.server.state().keep?.variables.X.find((e) => e.branch === DEV)).toMatchObject({ targets: [{ provider: 'dokploy', target: 't1' }] }); + }); + + test('a failure is a code, never a throw', async () => { + const down = { getLatestSecrets: async () => Promise.reject(new CapyError('down', ERROR_CODES.NETWORK_ERROR)), pushSecrets: async () => ({}) }; + expect(await uploadKeepOnly(down, 'proj', DEV, addNote)).toEqual({ ok: false, code: ERROR_CODES.NETWORK_ERROR }); + }); +}); + +describe('commitFolder: the folder after a commit is the server\'s latest plus the change', () => { + test('.env and keep.lock\'s branch slice equal the server; the other branch\'s pins did not move', async () => { + const teammate = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-TEAMMATE', Y: 'y-1', KEEP: 'k-1', SERVER_ONLY: 's' } }); + // The folder agreed with Capy (bare `capy` ran); here Capy is the SEED plus a later teammate save the folder has now pulled. + const synced = fakeCapyServer({ initial: SEED }); + synced.tape(teammate); + const folder = makeFolder({ + projectKey: KEY, + branch: DEV, + keep: synced.state().keep as KeepFile, + env: { X: 'x-TEAMMATE', Y: 'y-1', KEEP: 'k-1', SERVER_ONLY: 's' }, + client: synced.client, + }); + made(folder); + const done = await commitFolder(folder.ctx, { set: { Z: 'z-new' }, remove: ['KEEP'] }); + expect(done.uploaded).toBe(true); + const after = synced.state(); + expect(serverValues(after, DEV, KEY)).toEqual({ X: 'x-TEAMMATE', Y: 'y-1', SERVER_ONLY: 's', Z: 'z-new' }); + expect(folder.env()).toEqual(serverValues(after, DEV, KEY)); + expect(folder.pin('Z')).toBe(hashValue('z-new')); + expect(folder.pin('KEEP')).toBeUndefined(); + expect(serverValues(after, STG, KEY)).toEqual({ X: 'x-stg', Y: 'y-stg' }); + }); +}); diff --git a/tests/sync/conflicts.test.ts b/tests/sync/conflicts.test.ts new file mode 100644 index 00000000..a9f05104 --- /dev/null +++ b/tests/sync/conflicts.test.ts @@ -0,0 +1,203 @@ +/** + * The non-TTY protocol's pure half (src/sync/conflicts.ts; docs/conflict-resolution.md): the rows are bare `capy`'s + * `compareSecrets` rows, the id binds the answers to exactly those rows, `--resolve` / `--conflict` are validated with + * codes, and `gateConflicts` either returns an answer for every row or refuses with the payload. Fingerprints only. + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { compareSecrets } from '../../src/sync/compareSecrets'; +import { + CONFLICT_ID_FORMAT, + ConflictRefusal, + conflictIdOf, + conflictRefusalFor, + exitOnConflictRefusal, + gateConflicts, + parseConflictFlags, + printConflictRefusal, + seenResolutions, + sourceRowsOf, + targetResolutions, + type ConflictScope, + type SourceConflict, + type TargetConflict, +} from '../../src/sync/conflicts'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const view = { project: 'swot-analysis-generator-server', branch: 'production', serverKeepHash: 'a'.repeat(64) }; + +/** Rows exactly as bare `capy` computes them: `compareSecrets` over (pinned, local, remote) fingerprints. */ +const rows = ( + pinned: Record, + local: Record, + remote: Record, + at = view, +): readonly SourceConflict[] => sourceRowsOf(compareSecrets(pinned, local, remote).diffs, at); + +const gemini = rows({ GEMINI_TIMEOUT_SECONDS: '2abaca4911e68fa9' }, { GEMINI_TIMEOUT_SECONDS: '2abaca4911e68fa9' }, { GEMINI_TIMEOUT_SECONDS: '7b69759630f869f2' }); +const scope = (conflicts: readonly (SourceConflict | TargetConflict)[], at = view): ConflictScope => ({ ...at, conflicts }); + +describe('the rows', () => { + test('are compareSecrets rows: name, project, branch, type, pinned, local, remote (fingerprints, null where there is no value)', () => { + expect(gemini).toEqual([ + { + name: 'GEMINI_TIMEOUT_SECONDS', + project: 'swot-analysis-generator-server', + branch: 'production', + type: 'changed', + pinned: '2abaca4911e68fa9', + local: '2abaca4911e68fa9', + remote: '7b69759630f869f2', + }, + ]); + expect(rows({}, {}, { NEW: 'aaaa' })).toMatchObject([{ name: 'NEW', type: 'new', pinned: null, local: null, remote: 'aaaa' }]); + expect(rows({ GONE: 'bbbb' }, {}, { OTHER: 'cccc' }).find((r) => r.name === 'GONE')).toMatchObject({ pinned: 'bbbb', local: null, remote: null }); + }); +}); + +describe('conflictIdOf', () => { + test('is c_ + 12 hex, the same for the same project, branch, server keep hash and rows (in any order)', () => { + const two = rows({ A: 'a1', B: 'b1' }, { A: 'a2', B: 'b1' }, { A: 'a1', B: 'b2' }); + const id = conflictIdOf([scope(two)]); + expect(id).toMatch(CONFLICT_ID_FORMAT); + expect(conflictIdOf([scope([...two].reverse())])).toBe(id); + }); + + test('changes when anything it binds moves: a fingerprint, the server keep hash, the branch, the project, a row', () => { + const base = conflictIdOf([scope(gemini)]); + const moved = rows({ GEMINI_TIMEOUT_SECONDS: '2abaca4911e68fa9' }, { GEMINI_TIMEOUT_SECONDS: '2abaca4911e68fa9' }, { GEMINI_TIMEOUT_SECONDS: 'ffffffffffffffff' }); + expect(conflictIdOf([scope(moved)])).not.toBe(base); + expect(conflictIdOf([scope(gemini, { ...view, serverKeepHash: 'b'.repeat(64) })])).not.toBe(base); + expect(conflictIdOf([scope(rows({ GEMINI_TIMEOUT_SECONDS: '2abaca4911e68fa9' }, { GEMINI_TIMEOUT_SECONDS: '2abaca4911e68fa9' }, { GEMINI_TIMEOUT_SECONDS: '7b69759630f869f2' }, { ...view, branch: 'staging' }), { ...view, branch: 'staging' })])).not.toBe(base); + expect(conflictIdOf([scope([...gemini, ...rows({}, { EXTRA: 'e1' }, { OTHER: 'o1' })])])).not.toBe(base); + }); + + test('a platform row is bound too, with its target', () => { + const platform: TargetConflict = { name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, local: 'l1', remote: 'r1' }; + const other: TargetConflict = { ...platform, target: 'dokploy-staging' }; + expect(conflictIdOf([scope([platform])])).not.toBe(conflictIdOf([scope([other])])); + }); +}); + +describe('parseConflictFlags', () => { + const id = 'c_9f2e41a07b3d'; + + test('the four choices for a variable, local|remote for a platform value', () => { + expect( + parseConflictFlags({ resolve: ['A=pinned', 'B=local', 'C=remote', 'D=delete', 'E@prod=local', 'F@prod=remote'], conflict: id }), + ).toEqual({ resolutions: { A: 'pinned', B: 'local', C: 'remote', D: 'delete', 'E@prod': 'local', 'F@prod': 'remote' }, conflictId: id }); + }); + + test('anything else is SECRETS_RESOLVE_INVALID: mine/theirs (retired), a platform value pinned or deleted, a malformed form or id, two answers for one key', () => { + const codeOf = (raw: Parameters[0]): string | undefined => { + try { + parseConflictFlags(raw); + return undefined; + } catch (err) { + return err instanceof CapyError ? err.code : 'not-coded'; + } + }; + expect(codeOf({ resolve: ['A=mine'], conflict: id })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(codeOf({ resolve: ['A=theirs'], conflict: id })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(codeOf({ resolve: ['A@prod=pinned'], conflict: id })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(codeOf({ resolve: ['A@prod=delete'], conflict: id })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(codeOf({ resolve: ['A:local'], conflict: id })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(codeOf({ resolve: ['A=local'], conflict: 'c_nothex' })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(codeOf({ resolve: ['A=local', 'A=remote'], conflict: id })).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + }); + + test('--resolve without --conflict is SECRETS_RESOLVE_NEEDS_CONFLICT; --conflict alone is fine', () => { + expect(() => parseConflictFlags({ resolve: ['A=local'] })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_NEEDS_CONFLICT })); + expect(parseConflictFlags({ conflict: id })).toEqual({ resolutions: {}, conflictId: id }); + }); +}); + +describe('gateConflicts', () => { + const scopes = [scope(gemini)]; + const id = conflictIdOf(scopes); + const refusalOf = (run: () => unknown): ConflictRefusal => { + try { + run(); + } catch (err) { + if (err instanceof ConflictRefusal) return err; + throw err; + } + throw new Error('expected a refusal'); + }; + + test('no rows: nothing to answer', () => { + expect(gateConflicts({ resolutions: {}, conflictId: undefined }, [scope([])])).toEqual({}); + }); + + test('without --conflict: SECRETS_CONFLICT with every row and the id (exit 3 is the caller\'s)', () => { + const refusal = refusalOf(() => gateConflicts({ resolutions: {}, conflictId: undefined }, scopes)); + expect(refusal.payload).toEqual({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: id, conflicts: gemini }); + }); + + test('every row answered against the right id: the answers', () => { + expect(gateConflicts({ resolutions: { GEMINI_TIMEOUT_SECONDS: 'local' }, conflictId: id }, scopes)).toEqual({ GEMINI_TIMEOUT_SECONDS: 'local' }); + }); + + test('a partial set of answers: the rows still open, under the id of the WHOLE set', () => { + const two = [scope(rows({ A: 'a1', B: 'b1' }, { A: 'a2', B: 'b2' }, { A: 'a1', B: 'b1' }))]; + const refusal = refusalOf(() => gateConflicts({ resolutions: { A: 'local' }, conflictId: conflictIdOf(two) }, two)); + expect(refusal.payload.code).toBe('SECRETS_CONFLICT'); + expect(refusal.payload.conflict_id).toBe(conflictIdOf(two)); + expect(refusal.payload.conflicts.map((c) => c.name)).toEqual(['B']); + }); + + test('an id that is not the id of exactly these rows: SECRETS_CONFLICT_STALE with the fresh rows and the fresh id', () => { + const refusal = refusalOf(() => gateConflicts({ resolutions: { GEMINI_TIMEOUT_SECONDS: 'local' }, conflictId: 'c_000000000000' }, scopes)); + expect(refusal.payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT_STALE', conflict_id: id }); + expect(refusal.payload.conflicts).toEqual(gemini); + }); + + test('--dry-run reports the rows with dry_run and never applies, even with the right answers', () => { + const refusal = refusalOf(() => gateConflicts({ resolutions: { GEMINI_TIMEOUT_SECONDS: 'local' }, conflictId: id }, scopes, { dryRun: true })); + expect(refusal.payload).toEqual({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: id, conflicts: gemini, dry_run: true }); + }); + + test('a platform row is answered as NAME@TARGET, and bound to the fingerprints it was answered against', () => { + const platform: TargetConflict = { name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, local: 'l1', remote: 'r1' }; + const platformScopes = [scope([platform])]; + const answers = gateConflicts({ resolutions: { 'STRIPE_KEY@dokploy-production': 'remote' }, conflictId: conflictIdOf(platformScopes) }, platformScopes); + expect(answers).toEqual({ 'STRIPE_KEY@dokploy-production': 'remote' }); + const bound = seenResolutions(answers, [platform]); + expect(bound).toEqual({ 'STRIPE_KEY@dokploy-production': { choice: 'remote', local: 'l1', remote: 'r1' } }); + expect(targetResolutions(bound, 'dokploy-production')).toEqual({ STRIPE_KEY: { choice: 'remote', local: 'l1', remote: 'r1' } }); + }); +}); + +describe('what is printed', () => { + test('stdout: the payload as JSON, nothing else. stderr: prose with the names and no value or fingerprint', () => { + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + printConflictRefusal(conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [scope(gemini)])); + expect(log.mock.calls).toHaveLength(1); + expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflicts: gemini }); + const prose = err.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + expect(prose).toContain('GEMINI_TIMEOUT_SECONDS'); + expect(prose).toContain('--resolve'); + expect(prose).not.toContain('7b69759630f869f2'); + } finally { + log.mockRestore(); + err.mockRestore(); + } + }); + + test('exitOnConflictRefusal exits 3 for a refusal and leaves any other error to the caller', () => { + const exit = spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + exitOnConflictRefusal(new Error('something else')); + expect(exit).not.toHaveBeenCalled(); + exitOnConflictRefusal(conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [scope(gemini)])); + expect(exit).toHaveBeenCalledWith(3); + } finally { + exit.mockRestore(); + log.mockRestore(); + err.mockRestore(); + } + }); +}); diff --git a/tests/sync/gate.test.ts b/tests/sync/gate.test.ts new file mode 100644 index 00000000..d962aa3f --- /dev/null +++ b/tests/sync/gate.test.ts @@ -0,0 +1,335 @@ +/** + * Bare `capy` as the first step of a command (src/sync/gate.ts). The runner is injected, so these are about the loop + * and what the gate does with its answers, not about Keep (that is tests/commands/conflictProtocol.test.ts through the + * built cli, and tests/commands/bareCapyCommit.test.ts through `CapyCommand`). + */ +import { describe, test, expect, spyOn, mock, beforeAll, afterAll, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import type { SyncOutcome } from '../../src/commands/capyCommand'; +import { conflictRefusalFor, sourceRowsOf } from '../../src/sync/conflicts'; +import { FOLDER_SYNC_STOPPED, answersByProtocol, commitGated, ensureInSync, gateFirst, stopWithNothingWritten, syncFolder, type SyncRunner } from '../../src/sync/gate'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { CapyError, ERROR_CODES, type KeepFile } from '../../src/types/index'; +import { fakeCapyServer, pushOf, seedServer, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +/** A runner that answers with `outcomes` in order, and counts how often it was asked. */ +function runnerOf(...outcomes: readonly SyncOutcome[]) { + const asked = mock((_protocol: boolean) => undefined); + const run: SyncRunner = async (_opts, protocol) => { + asked(protocol); + const outcome = outcomes[asked.mock.calls.length - 1]; + if (outcome === undefined) throw new Error('the runner was asked more often than the test scripted'); + return outcome; + }; + return { run, asked }; +} + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} +const exiting = () => + spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + +describe('ensureInSync', () => { + test('in sync at once: one pass, in-sync', async () => { + const r = runnerOf('up-to-date'); + expect(await ensureInSync({}, r.run)).toBe('in-sync'); + expect(r.asked).toHaveBeenCalledTimes(1); + }); + + test('the person cancels or chooses "Continue working": stopped, and no second pass', async () => { + const r = runnerOf('stopped'); + expect(await ensureInSync({}, r.run)).toBe('stopped'); + expect(r.asked).toHaveBeenCalledTimes(1); + }); + + test('something changed: compare again, until nothing differs (after "Individually resolve" the menu comes back)', async () => { + const r = runnerOf('changed', 'changed', 'up-to-date'); + expect(await ensureInSync({}, r.run)).toBe('in-sync'); + expect(r.asked).toHaveBeenCalledTimes(3); + }); + + test('...or until the person stops', async () => { + const r = runnerOf('changed', 'stopped'); + expect(await ensureInSync({}, r.run)).toBe('stopped'); + }); + + test('a difference nobody can answer ends the process with exit 3 and the payload alone on stdout', async () => { + const refusal = conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ project: 'p', branch: 'b', serverKeepHash: 'h', conflicts: sourceRowsOf([{ variable: 'X', type: 'changed', pinned: 'aa', local: 'aa', remote: 'bb' }], { project: 'p', branch: 'b', serverKeepHash: 'h' }) }]); + const exit = exiting(); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(ensureInSync({}, async () => Promise.reject(refusal))).rejects.toBeInstanceOf(ExitSignal); + expect(exit).toHaveBeenCalledWith(3); + expect(log.mock.calls).toHaveLength(1); + expect(JSON.parse(String(log.mock.calls[0][0]))).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflicts: [{ name: 'X', pinned: 'aa', local: 'aa', remote: 'bb' }] }); + } finally { + exit.mockRestore(); + log.mockRestore(); + err.mockRestore(); + } + }); + + test('any other error is thrown as it is', async () => { + await expect(ensureInSync({}, async () => Promise.reject(new CapyError('no session', ERROR_CODES.AUTH_FAILED)))).rejects.toMatchObject({ code: ERROR_CODES.AUTH_FAILED }); + }); +}); + +describe('gateFirst', () => { + test('a stop ends the command on the spot: exit 0, a word on stderr, nothing on stdout', async () => { + const exit = exiting(); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(gateFirst({}, runnerOf('stopped').run)).rejects.toBeInstanceOf(ExitSignal); + expect(exit).toHaveBeenCalledWith(0); + expect(log).not.toHaveBeenCalled(); + expect(err).toHaveBeenCalled(); + } finally { + exit.mockRestore(); + log.mockRestore(); + err.mockRestore(); + } + }); + + test('in sync: it returns and the command goes on', async () => { + await expect(gateFirst({}, runnerOf('up-to-date').run)).resolves.toBeUndefined(); + }); + + test('a failure of the sync itself (no session) ends the command with its code: JSON under --json, the error screen otherwise; a command may supply its own refusal', async () => { + const failing: SyncRunner = async () => Promise.reject(new CapyError('no session', ERROR_CODES.AUTH_FAILED)); + const exit = exiting(); + const log = spyOn(console, 'log').mockImplementation(() => {}); + try { + await expect(gateFirst({ json: true }, failing)).rejects.toBeInstanceOf(ExitSignal); + expect(exit).toHaveBeenCalledWith(1); + expect(JSON.parse(String(log.mock.calls[0][0]))).toEqual({ ok: false, code: 'AUTH_FAILED', error: 'no session' }); + const own = mock((_err: unknown) => undefined); + await expect( + gateFirst( + { + refuse: (e) => { + own(e); + throw new ExitSignal(7); + }, + }, + failing, + ), + ).rejects.toBeInstanceOf(ExitSignal); + expect(own).toHaveBeenCalledTimes(1); + } finally { + exit.mockRestore(); + log.mockRestore(); + } + }); +}); + +describe('answersByProtocol', () => { + test('--dry-run, --json, --non-tty (or no terminal) and given --resolve / --conflict answer by protocol; a terminal asks', () => { + // stdin is not a terminal under the test runner, so "a terminal" is --non-tty's opposite only via the flags below. + expect(answersByProtocol({ dryRun: true })).toBe(true); + expect(answersByProtocol({ json: true })).toBe(true); + expect(answersByProtocol({ nonTty: true })).toBe(true); + expect(answersByProtocol({ conflictFlags: { resolve: ['A=local'], conflict: 'c_9f2e41a07b3d' } })).toBe(true); + // A malformed flag counts as typed: the command refuses it on its own before anything is read. + expect(answersByProtocol({ conflictFlags: { resolve: ['nonsense'] } })).toBe(true); + }); + + test('on a real terminal with nothing typed: the menu, not the protocol', () => { + const tty = spyOn(process.stdin, 'isTTY', 'get' as never).mockReturnValue(true as never); + try { + expect(answersByProtocol({})).toBe(false); + expect(answersByProtocol({ nonTty: true })).toBe(true); + } finally { + tty.mockRestore(); + } + }); +}); + +describe('commitGated: the command\'s own change, and on a stale refusal bare capy again and one more try', () => { + const KEY = Encryptor.generateKey(); + const DEV = 'development'; + const SEED = seedServer({ projectKey: KEY, values: { [DEV]: { X: 'x-1', Y: 'y-1' } } }); + const globalDirName = `.capy-test-gate-${process.pid}`; + const savedGlobalDirName = process.env.CAPY_GLOBAL_DIR_NAME; + beforeAll(() => { + process.env.CAPY_GLOBAL_DIR_NAME = globalDirName; + }); + afterAll(() => { + rmSync(getGlobalCapyDir(), { recursive: true, force: true }); + if (savedGlobalDirName === undefined) delete process.env.CAPY_GLOBAL_DIR_NAME; + else process.env.CAPY_GLOBAL_DIR_NAME = savedGlobalDirName; + }); + const made = mock((_: Folder) => undefined); + afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + + const teammate = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-TEAMMATE', Y: 'y-1' } }); + + function world(racing: Parameters[0]) { + const server = fakeCapyServer({ initial: SEED, ...racing }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: SEED.keep as KeepFile, env: { X: 'x-1', Y: 'y-1' }, client: server.client }); + made(folder); + return { server, folder, ctx: { ...folder.ctx, gate: {} } }; + } + + test('nobody saved in between: one upload, the gate is not run again', async () => { + const w = world({}); + const r = runnerOf(); + const done = await commitGated(w.ctx, { set: { Z: 'z' }, remove: [] }, {}, r.run); + expect(done.uploaded).toBe(true); + expect(r.asked).not.toHaveBeenCalled(); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-1', Y: 'y-1', Z: 'z' }); + }); + + test('someone saved in between (409): bare capy runs again, the folder is read again, the change lands on top of THEIR save', async () => { + const w = world({ racing: (n) => (n === 1 ? [teammate] : []) }); + // Bare capy again brings the folder to Capy: here that is what the runner stands for. + const r: SyncRunner = async () => { + w.folder.fileManager.writeKeepFile(w.server.state().keep as KeepFile); + w.folder.fileManager.writeEncryptedEnvFile({ X: 'x-TEAMMATE', Y: 'y-1' }, KEY, undefined, w.server.state().keep as KeepFile, DEV); + return 'up-to-date'; + }; + const done = await commitGated(w.ctx, { set: { Z: 'z' }, remove: [] }, {}, r); + expect(done.uploaded).toBe(true); + const values = serverValues(w.server.state(), DEV, KEY); + expect(values).toEqual({ X: 'x-TEAMMATE', Y: 'y-1', Z: 'z' }); + expect(w.folder.env()).toEqual(values); + expect(w.folder.pin('X')).toBe(hashValue('x-TEAMMATE')); + }); + + test('the person stops the second gate: the command ends (exit 0) and nothing of its own was written', async () => { + const w = world({ racing: (n) => (n === 1 ? [teammate] : []) }); + const exit = exiting(); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(commitGated(w.ctx, { set: { Z: 'z' }, remove: [] }, {}, runnerOf('stopped').run)).rejects.toBeInstanceOf(ExitSignal); + expect(exit).toHaveBeenCalledWith(0); + expect(serverValues(w.server.state(), DEV, KEY).Z).toBeUndefined(); + } finally { + exit.mockRestore(); + err.mockRestore(); + } + }); + + test('a second stale refusal is thrown (SECRETS_STALE_BASE): nothing is written', async () => { + const other = pushOf({ projectKey: KEY, branch: DEV, values: { X: 'x-OTHER', Y: 'y-1' } }); + const w = world({ racing: (n) => (n === 1 ? [teammate] : n === 2 ? [other] : []) }); + await expect(commitGated(w.ctx, { set: { Z: 'z' }, remove: [] }, {}, runnerOf('up-to-date').run)).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_STALE_BASE }); + expect(serverValues(w.server.state(), DEV, KEY).Z).toBeUndefined(); + }); + + test('a discovered folder\'s context carries a gate with ITS directory: the stale retry runs bare capy there, not in the working directory', async () => { + const w = world({ racing: (n) => (n === 1 ? [teammate] : []) }); + const dirs = mock((_cwd: string | undefined) => undefined); + const r: SyncRunner = async (opts) => { + dirs(opts.cwd); + w.folder.fileManager.writeKeepFile(w.server.state().keep as KeepFile); + w.folder.fileManager.writeEncryptedEnvFile({ X: 'x-TEAMMATE', Y: 'y-1' }, KEY, undefined, w.server.state().keep as KeepFile, DEV); + return 'up-to-date'; + }; + const ctx = { ...w.folder.ctx, gate: { cwd: '/work/repo/svc', json: true, nonTty: true, throwConflicts: true } }; + const done = await commitGated(ctx, { set: { Z: 'z' }, remove: [] }, {}, r); + expect(done.uploaded).toBe(true); + expect(dirs).toHaveBeenCalledWith('/work/repo/svc'); + expect(serverValues(w.server.state(), DEV, KEY)).toEqual({ X: 'x-TEAMMATE', Y: 'y-1', Z: 'z' }); + }); + + test('a context with no gate is not retried: the stale refusal is thrown', async () => { + const w = world({ racing: (n) => (n === 1 ? [teammate] : []) }); + const r = runnerOf(); + await expect(commitGated(w.folder.ctx, { set: { Z: 'z' }, remove: [] }, {}, r.run)).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_STALE_BASE }); + expect(r.asked).not.toHaveBeenCalled(); + }); +}); + + +describe('another checkout than the working directory (discovery writes into several)', () => { + const refusal = () => + conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ project: 'p', branch: 'b', serverKeepHash: 'h', conflicts: sourceRowsOf([{ variable: 'X', type: 'changed', pinned: 'aa', local: 'aa', remote: 'bb' }], { project: 'p', branch: 'b', serverKeepHash: 'h' }) }]); + + test('syncFolder asks the runner for THAT folder and says in sync when bare capy has nothing to settle', async () => { + const seen = mock((_cwd: string | undefined) => undefined); + const run: SyncRunner = async (opts) => { + seen(opts.cwd); + return 'up-to-date'; + }; + expect(await syncFolder('/work/repo/svc', { json: true, nonTty: true }, run)).toEqual({ ok: true }); + expect(seen).toHaveBeenCalledWith('/work/repo/svc'); + }); + + test('syncFolder never ends the process: a difference nobody can answer comes back as the folder\'s coded result with its rows and id', async () => { + const exit = exiting(); + const log = spyOn(console, 'log').mockImplementation(() => {}); + try { + const refused = refusal(); + const result = await syncFolder('/work/repo/svc', { json: true, nonTty: true }, async () => Promise.reject(refused)); + expect(result).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict: refused.payload }); + expect(exit).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + } finally { + exit.mockRestore(); + log.mockRestore(); + } + }); + + test('syncFolder: a stop and a failure of the sync are coded results too (the stop never exits 0 for the whole run)', async () => { + const exit = exiting(); + try { + expect(await syncFolder('/work/a', {}, runnerOf('stopped').run)).toMatchObject({ ok: false, code: FOLDER_SYNC_STOPPED }); + expect(await syncFolder('/work/a', {}, async () => Promise.reject(new CapyError('no session', ERROR_CODES.AUTH_FAILED)))).toMatchObject({ ok: false, code: 'AUTH_FAILED' }); + expect(await syncFolder('/work/a', {}, async () => Promise.reject(new Error('boom')))).toMatchObject({ ok: false, code: ERROR_CODES.SERVICE_ERROR }); + expect(exit).not.toHaveBeenCalled(); + } finally { + exit.mockRestore(); + } + }); + + test('ensureInSync with throwConflicts throws the refusal instead of ending the process', async () => { + const exit = exiting(); + try { + const refused = refusal(); + await expect(ensureInSync({ throwConflicts: true }, async () => Promise.reject(refused))).rejects.toBe(refused); + expect(exit).not.toHaveBeenCalled(); + } finally { + exit.mockRestore(); + } + }); + + test('the real runner syncs a folder by standing in it and comes back: a folder with no keep.lock has nothing to compare, a folder that does not exist yet is in sync', async () => { + const origin = process.cwd(); + const dir = mkdtempSync(join(tmpdir(), 'capy-gate-folder-')); + try { + expect(await syncFolder(dir, { json: true, nonTty: true })).toEqual({ ok: true }); + expect(process.cwd()).toBe(origin); + expect(await syncFolder(join(dir, 'not', 'yet'), { json: true, nonTty: true })).toEqual({ ok: true }); + expect(process.cwd()).toBe(origin); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('stopWithNothingWritten', () => { + test('exits 0', () => { + const exit = exiting(); + const err = spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => stopWithNothingWritten()).toThrow(ExitSignal); + expect(exit).toHaveBeenCalledWith(0); + } finally { + exit.mockRestore(); + err.mockRestore(); + } + }); +}); + diff --git a/tests/sync/gateTornFolders.test.ts b/tests/sync/gateTornFolders.test.ts new file mode 100644 index 00000000..398aa421 --- /dev/null +++ b/tests/sync/gateTornFolders.test.ts @@ -0,0 +1,112 @@ +/** + * A torn checkout (`.capy/branch` and the `.env` header name different, real branches) met by bare `capy` as a command's + * first step (src/sync/gate.ts), through the REAL runner and a mock service (tests/helpers/pipedHarness.ts): + * - in another checkout than the working directory (discovery) it is that folder's coded failure + * (`BRANCH_STATE_INCONSISTENT`), never the end of the process, so the next folder is still synced; + * - the working directory is back where it was afterwards. + * (The same state in the working directory is followed, not refused: tests/commands/conflictProtocol.test.ts.) + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { text } from 'node:stream/consumers'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { deriveResourceId } from '../../src/crypto/resourceId'; +import { FileManager } from '../../src/files/fileManager'; +import { ProjectManager } from '../../src/core/projectManager'; +import type { KeepFile } from '../../src/types/index'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { createHarness, BRANCH, ORG_ID, PROJECT_ID, type Harness } from '../helpers/pipedHarness'; + +const hash16 = (v: string): string => createHash('sha256').update(v).digest('hex').slice(0, 16); +const VALUES = { X: 'SENTINEL-x-torn-1d4a' } as const; + +const keepOf = (): KeepFile => ({ + version: '3.0', + org_id: ORG_ID, + project_id: PROJECT_ID, + project_name: 'piped-test', + variables: Object.fromEntries( + Object.entries(VALUES).map(([name, value]) => [ + name, + [{ resource_id: deriveResourceId(BRANCH, name), branch: BRANCH, value_hash: hash16(value), changed_at: '2026-10-01T21:44:18.000Z' }], + ]), + ), +}); + +/** A checkout under `parent` that agrees with the server; `fileBranch` is what `.capy/branch` says. */ +function checkout(h: Harness, parent: string, name: string, fileBranch: string): string { + const dir = join(parent, name); + mkdirSync(join(dir, '.capy'), { recursive: true }); + const files = new FileManager(dir); + const keep = keepOf(); + files.writeKeepFile(keep); + files.writeEncryptedEnvFile({ ...VALUES }, h.projectKey, undefined, keep, BRANCH); + writeFileSync(join(dir, '.capy', 'branch'), fileBranch); + return dir; +} + +/** `syncFolder` over `dirs` in a child process whose HOME is the harness's (the real runner reads its session and profile from there). */ +async function syncInChild(h: Harness, dirs: readonly string[]): Promise<{ readonly results: readonly Record[]; readonly before: string; readonly after: string; readonly ended?: number }> { + const child = spawn(process.execPath, [join(__dirname, '../helpers/syncFoldersChild.ts'), ...dirs], { + cwd: h.project, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, HOME: h.home, USERPROFILE: h.home, CAPY_WEB_NO_OPEN: '1', CAPY_NO_AUTOCOMMIT: '1' }, + }); + const [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr), once(child, 'close')]); + const line = stdout.trim().split('\n').at(-1) ?? ''; + try { + return JSON.parse(line); + } catch { + throw new Error(`child did not report: ${stderr}\n${stdout}`); + } +} + +describe('syncFolder over two discovered folders, the first in a torn state (real runner)', () => { + const state = { h: undefined as Harness | undefined }; + const h = (): Harness => { + if (!state.h) throw new Error('harness not ready'); + return state.h; + }; + beforeEach(async () => { + const harness = await createHarness(); + Object.assign(state, { h: harness }); + // The server: `production` is what both folders hold; `development` is a real branch of the project. + const keep = keepOf(); + const lines = Object.entries(VALUES).map(([name, value]) => `${name}=capy:${deriveResourceId(BRANCH, name)}:${Encryptor.encrypt(value, harness.projectKey)}`); + harness.setBranchData(PROJECT_ID, BRANCH, { env_file: lines.join('\n'), permissions: [], keep_hash: SyncEngine.computeKeepHash(keep, BRANCH), keep_file: JSON.stringify(keep) }); + harness.setBranches(PROJECT_ID, [ + { id: 'b1', name: 'production', is_protected: false }, + { id: 'b2', name: 'development', is_protected: false }, + ]); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + test('the torn folder is reported with BRANCH_STATE_INCONSISTENT (no exit, nothing written), the next folder is synced, the working directory is restored', async () => { + const parent = join(h().root, 'discovered'); + const torn = checkout(h(), parent, 'torn', 'development'); // .capy/branch says development, .env was encrypted for production + const fine = checkout(h(), parent, 'fine', BRANCH); + const run = await syncInChild(h(), [torn, fine]); + expect(run.results[0]).toMatchObject({ ok: false, code: 'BRANCH_STATE_INCONSISTENT' }); + expect(run.results[1]).toEqual({ ok: true }); + expect(run.ended).toBeUndefined(); + expect(run.after).toBe(run.before); + // Nothing was written into the torn folder: the .env header and the branch cache are as they were. + expect(new FileManager(torn).readEnvMeta().branch).toBe(BRANCH); + expect(new ProjectManager(torn).readActiveBranch()).toBe('development'); + expect(h().pushCount()).toBe(0); + }); + + test('the failure names the two branches and carries no value', async () => { + const torn = checkout(h(), join(h().root, 'discovered'), 'torn', 'development'); + const run = await syncInChild(h(), [torn]); + const failure = JSON.stringify(run.results[0]); + expect(failure).toContain('BRANCH_STATE_INCONSISTENT'); + expect(failure).not.toContain(VALUES.X); + }); +}); diff --git a/tests/sync/noFolder.test.ts b/tests/sync/noFolder.test.ts new file mode 100644 index 00000000..09369828 --- /dev/null +++ b/tests/sync/noFolder.test.ts @@ -0,0 +1,72 @@ +/** + * Writes with no folder (src/sync/noFolder.ts; docs/conflict-resolution.md, "Commands with no folder"): for each value + * written, `compareSecrets` over pinned (the value the person was shown), local (the new value) and remote (what Capy + * holds there now). Unchanged since it was shown, or already holding the new value: no decision. Moved: a row. + */ +import { describe, test, expect } from 'bun:test'; +import { bindAnswer, actionFor, holdsFor, movedRow, shownValueUnavailable, type WriteComparison } from '../../src/sync/noFolder'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const base: WriteComparison = { + name: 'API_KEY', + project: 'web', + branch: 'production', + shown: 'aaaaaaaaaaaaaaaa', + local: 'bbbbbbbbbbbbbbbb', + remote: 'aaaaaaaaaaaaaaaa', + serverKeepHash: 'k'.repeat(64), +}; + +describe('movedRow', () => { + test('Capy unchanged since it was shown: write, no decision', () => { + expect(movedRow(base)).toBeUndefined(); + }); + + test('Capy already holds the new value: nothing to write, no decision', () => { + expect(movedRow({ ...base, remote: base.local })).toBeUndefined(); + }); + + test('Capy moved to something else: the compareSecrets row, with the project and branch', () => { + expect(movedRow({ ...base, remote: 'cccccccccccccccc' })).toEqual({ + name: 'API_KEY', + project: 'web', + branch: 'production', + type: 'changed', + pinned: 'aaaaaaaaaaaaaaaa', + local: 'bbbbbbbbbbbbbbbb', + remote: 'cccccccccccccccc', + }); + }); + + test('the location lost the variable since it was shown: a row with no remote', () => { + expect(movedRow({ ...base, remote: undefined })).toMatchObject({ name: 'API_KEY', pinned: 'aaaaaaaaaaaaaaaa', local: 'bbbbbbbbbbbbbbbb', remote: null }); + }); +}); + +describe('answers', () => { + const row = movedRow({ ...base, remote: 'cccccccccccccccc' }); + + test('all four of bare capy\'s choices mean something: local writes, remote leaves the location, pinned writes the shown value back, delete removes the variable', () => { + expect(actionFor('local')).toBe('write'); + expect(actionFor('remote')).toBe('leave'); + expect(actionFor('pinned')).toBe('restore'); + expect(actionFor('delete')).toBe('remove'); + }); + + test('a pinned answer whose shown value cannot be recovered is refused with a code and names only', () => { + const refusal = shownValueUnavailable('API_KEY'); + expect(refusal).toBeInstanceOf(CapyError); + expect(refusal.code).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + expect(refusal.details).toEqual({ names: ['API_KEY'] }); + }); + + test('an answer holds only for the state it was made against', () => { + if (row === undefined) throw new Error('fixture'); + const answer = bindAnswer('local', row); + expect(answer).toEqual({ choice: 'local', remote: 'cccccccccccccccc', local: 'bbbbbbbbbbbbbbbb' }); + expect(holdsFor(answer, row)).toBe(true); + expect(holdsFor(answer, { ...row, remote: 'dddddddddddddddd' })).toBe(false); + expect(holdsFor(answer, { ...row, local: 'eeeeeeeeeeeeeeee' })).toBe(false); + expect(holdsFor(undefined, row)).toBe(false); + }); +}); diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index 4b38af58..b0e26edb 100644 --- a/tests/system/systemStore.test.ts +++ b/tests/system/systemStore.test.ts @@ -228,6 +228,8 @@ function allWrittenBytes(): string { describe('systemStore', () => { beforeEach(async () => { servers.clear(); + // A new fake server is a new org: this machine's copy of its store (keep.lock pins) starts over too. + rmSync(getSystemStoreDir(ORG_A), { recursive: true, force: true }); calls.length = 0; promptedWith = []; promptQueue = []; @@ -287,6 +289,38 @@ describe('systemStore', () => { expect(store2.listNames()).toEqual([]); }); + it('CAP-712: another admin\'s entry saved after this handle opened survives this handle\'s set, and two sets through one handle both land', async () => { + const mine = await openSystemStore({ orgId: ORG_A, devMode: true }); + const theirs = await openSystemStore({ orgId: ORG_A, devMode: true }); + await theirs.set('_CONNECTOR_THEIRS_KEY', 'theirs'); + + await mine.set('_CONNECTOR_MINE_KEY', 'mine-1'); + await mine.set('_CONNECTOR_MINE_OTHER', 'mine-2'); + + const reopened = await openSystemStore({ orgId: ORG_A, devMode: true }); + expect(reopened.listNames().map((e) => e.name).sort()).toEqual(['_CONNECTOR_MINE_KEY', '_CONNECTOR_MINE_OTHER', '_CONNECTOR_THEIRS_KEY']); + expect(reopened.get('_CONNECTOR_THEIRS_KEY')).toBe('theirs'); + }); + + it('the store has no folder to compare with: a set lands on top of the latest, changing only its own name (the shared upload, with base_keep_hash)', async () => { + const seed = await openSystemStore({ orgId: ORG_A, devMode: true }); + await seed.set('_CONNECTOR_SHARED_KEY', 'v0'); + await seed.set('_CONNECTOR_OTHER_KEY', 'o0'); + const mine = await openSystemStore({ orgId: ORG_A, devMode: true }); + const theirs = await openSystemStore({ orgId: ORG_A, devMode: true }); + // The other admin is on another machine: this machine's copy of the store never sees their save. + const gc = await import('../../src/config/globalConfig'); + const mineOnDisk = gc.readSystemKeepFile(ORG_A)!; + await theirs.set('_CONNECTOR_OTHER_KEY', 'o-theirs'); + gc.saveSystemKeepFile(ORG_A, mineOnDisk); + + await mine.set('_CONNECTOR_SHARED_KEY', 'v-mine'); + const after = await openSystemStore({ orgId: ORG_A, devMode: true }); + expect(after.get('_CONNECTOR_SHARED_KEY')).toBe('v-mine'); + // The other admin's save, which this handle never saw, is not reverted by it. + expect(after.get('_CONNECTOR_OTHER_KEY')).toBe('o-theirs'); + }); + it('rejects a bad name before any network call (Proof: SYSTEM_STORE_BAD_NAME)', () => { expect(() => assertValidConnectorName('NOT_A_CONNECTOR_NAME')).toThrow(); try { @@ -338,6 +372,7 @@ describe('systemStore', () => { it('owner and admin roles both succeed on read and write', async () => { for (const role of ['owner', 'admin'] as const) { servers.clear(); + rmSync(getSystemStoreDir(ORG_A), { recursive: true, force: true }); await seedMasterKey(ORG_A, USER_A); serverFor(ORG_A).role = role; const store = await openSystemStore({ orgId: ORG_A, devMode: true }); diff --git a/tests/ui/editScreenReducer.test.ts b/tests/ui/editScreenReducer.test.ts new file mode 100644 index 00000000..008dd611 --- /dev/null +++ b/tests/ui/editScreenReducer.test.ts @@ -0,0 +1,130 @@ +/** + * The `capy edit` screen's pure core (src/ui/editScreen.ts), after bare `capy` has already settled every difference + * (src/sync/gate.ts): the screen starts in sync and has no way to resolve anything itself. + * - a commit carries the edits typed and the server read the rows came from (its `base_keep_hash`), nothing else; + * - Capy having moved since that read (`SECRETS_STALE_BASE`) closes the screen with nothing uploaded and the edits kept, + * so the command can run bare `capy` and reopen it with the edits still typed (`withPendingEdits`); + * - a row both sides changed still READS `conflict`, but no key on this screen resolves it. + */ +import { describe, test, expect } from 'bun:test'; +import { + classifiedRow, + initialModel, + reduce, + render, + saveOutcome, + withPendingEdits, + type EditContext, + type EditModel, + type EditRow, + type EditState, +} from '../../src/ui/editScreen'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const mode = { localMode: false, remoteAvailable: true }; + +const row = (key: string, local: string | undefined, remote: string | undefined, pinned: string | undefined): EditRow => + classifiedRow({ key, localValue: local, remoteValue: remote, status: 'unknown', updatedLabel: '—', pinnedHash: pinned === undefined ? null : hashValue(pinned) }, mode); + +const state = (rows: readonly EditRow[]): EditState => ({ projectName: 'demo', branch: 'development', rows, remoteAvailable: true }); + +const press = (model: EditModel, ...keys: readonly string[]): EditModel => keys.reduce((m, k) => reduce(m, { kind: 'data', data: k }).model, model); +const ENTER = '\r'; + +/** The model after editing row `index`'s value to `value` (e, type, Enter). */ +const edited = (model: EditModel, value: string): EditModel => press(model, 'e', ...value.split(''), ENTER); + +describe('statuses (display only)', () => { + test('in sync, local, remote and conflict read as they always did', () => { + expect(row('A', 'a', 'a', 'a').status).toBe('in sync'); + expect(row('B', 'b2', 'b', 'b').status).toBe('local'); + expect(row('C', 'c', 'c2', 'c').status).toBe('remote'); + expect(row('D', 'd-aaa', 'd-bbb', 'd').status).toBe('conflict'); + }); + + test('no key resolves a conflict row: m and t do nothing, the row stays a conflict, and nothing is dirty', () => { + const model = initialModel(state([row('D', 'd-aaa', 'd-bbb', 'd')])); + const after = press(model, 'm', 't', 'M', 'T'); + expect(after.state.rows[0].status).toBe('conflict'); + expect(after.pending).toEqual({}); + // Nothing to commit: `c` asks for no save. + expect(reduce(after, { kind: 'data', data: 'c' }).effect).toBeUndefined(); + }); + + test('the footer offers no mine/theirs on a conflict row', () => { + const frame = render(initialModel(state([row('D', 'd-aaa', 'd-bbb', 'd')])), 100, 30).text; + expect(frame).not.toMatch(/mine|theirs/); + }); +}); + +describe('committing', () => { + const seen = { keep: undefined, entries: {} } as const; + + test('c: the commit is the edits typed and the server read the rows came from; nothing else', () => { + const model = edited(initialModel({ ...state([row('A', 'a', 'a', 'a'), row('B', 'b', 'b', 'b')]), seen }), 'a2'); + const outcome = reduce(model, { kind: 'data', data: 'c' }); + expect(outcome.effect).toEqual({ type: 'save', request: { set: { A: 'a2' }, seen } }); + }); + + test('a save that landed refreshes the rows and clears the edits', () => { + const model = edited(initialModel(state([row('A', 'a', 'a', 'a')])), 'a2'); + const requested = reduce(model, { kind: 'data', data: 'c' }); + const landed = reduce(requested.model, { kind: 'saved', result: { kind: 'saved', changedAt: { A: '2026-10-15T00:00:00.000Z' }, loaded: undefined } }); + expect(landed.done).toBeUndefined(); + expect(landed.model.pending).toEqual({}); + expect(landed.model.state.rows[0].status).toBe('in sync'); + }); + + test('Capy moved since the screen read it: the screen is over, stale, with the edits kept and nothing claimed saved', () => { + const model = edited(initialModel(state([row('A', 'a', 'a', 'a')])), 'a2'); + const requested = reduce(model, { kind: 'data', data: 'c' }); + const stale = reduce(requested.model, { kind: 'saved', result: { kind: 'stale' } }); + expect(stale.done).toBe(true); + expect(stale.stale).toBe(true); + expect(stale.model.pending).toEqual({ A: 'a2' }); + }); + + test('any other failure keeps the screen open with the edits and says so', () => { + const model = edited(initialModel(state([row('A', 'a', 'a', 'a')])), 'a2'); + const requested = reduce(model, { kind: 'data', data: 'c' }); + const failed = reduce(requested.model, { kind: 'saved', result: { kind: 'failed', message: 'down' } }); + expect(failed.done).toBeUndefined(); + expect(failed.model.pending).toEqual({ A: 'a2' }); + expect(failed.model.status?.isError).toBe(true); + }); +}); + +describe('saveOutcome', () => { + const request = { set: { A: 'a2' }, seen: undefined }; + + test('SECRETS_STALE_BASE is stale (decided by the code); anything else is failed; a landed save carries changed_at', async () => { + const ctx = (save: () => Promise>): EditContext => ({ saveLocalEdits: save }); + expect(await saveOutcome(ctx(async () => Promise.reject(new CapyError('stale', ERROR_CODES.SECRETS_STALE_BASE))), request)).toEqual({ kind: 'stale' }); + expect(await saveOutcome(ctx(async () => Promise.reject(new CapyError('no', ERROR_CODES.NETWORK_ERROR))), request)).toMatchObject({ kind: 'failed' }); + expect(await saveOutcome(ctx(async () => ({ A: '2026-10-15T00:00:00.000Z' })), request)).toMatchObject({ kind: 'saved', changedAt: { A: '2026-10-15T00:00:00.000Z' } }); + }); +}); + +describe('reopening after bare capy (withPendingEdits)', () => { + test('the typed values come back as uncommitted edits, a name no row has gets one, and a clean state is returned as it is', () => { + const base = state([row('A', 'a', 'a', 'a'), row('B', 'b', 'b', 'b')]); + const reopened = withPendingEdits(base, { A: 'a2', NEW: 'n1' }); + expect(reopened.rows.map((r) => [r.key, r.localValue, r.status])).toEqual([ + ['A', 'a2', 'local'], + ['B', 'b', 'in sync'], + ['NEW', 'n1', 'local'], + ]); + const model = initialModel(reopened); + expect(model.pending).toEqual({ A: 'a2', NEW: 'n1' }); + // ... and it can be committed straight away. + expect(reduce(model, { kind: 'data', data: 'c' }).effect).toMatchObject({ type: 'save', request: { set: { A: 'a2', NEW: 'n1' } } }); + expect(withPendingEdits(base, {})).toBe(base); + }); + + test('a row Capy changed in the meantime reads remote/conflict against the reopened read, as any row does', () => { + const reopened = withPendingEdits(state([row('A', 'a', 'a-newer', 'a')]), { A: 'a2' }); + expect(reopened.rows[0].status).toBe('conflict'); + }); +}); + diff --git a/tests/ui/secretsDeployConflicts.test.ts b/tests/ui/secretsDeployConflicts.test.ts new file mode 100644 index 00000000..b6d4b2cf --- /dev/null +++ b/tests/ui/secretsDeployConflicts.test.ts @@ -0,0 +1,271 @@ +/** + * The `capy secrets` batch deploy and Dokploy target conflicts (CAP-714), with the real actions and engine over the + * fakes of tests/helpers/batchDeployWorld.ts. The service's `api` target reports one conflict: its API_KEY was changed + * outside Capy (`liveAdapter`, the same script tests/commands/secretsDeployConflicts.test.ts uses for the CLI). + * + * - the conflict is found BEFORE anything is delivered and nothing is written until it is decided; + * - local delivers Capy's value, remote delivers nothing for that variable and records nothing for it; + * - a decision made against a live value that has since changed is not applied: the conflict comes back; + * - cancelling the conflicts step leaves everything as it was. + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { createDeployActionsWith, decideFrom } from '../../src/commands/secretsDeployActions'; +import { runSecretsScreen } from '../../src/ui/secretsScreenDriver'; +import { applyDeployFinished, renderDeploy, stepDeploy, startDeploy, type DeployFlow } from '../../src/ui/secretsDeployFlow'; +import { SKIP_CODES, type BatchPlan } from '../../src/deploy/batchDeploy'; +import type { DeployContext, DeployResult, TargetConfig } from '../../src/deploy/adapter'; +import { conflictRefusalFor, type TargetConflict } from '../../src/sync/conflicts'; +import { CANCELLED_NOTHING } from '../../src/commands/secretsSetText'; +import { ERROR_CODES } from '../../src/types/index'; +import { LINKS, NAME, VALUES, everythingSentTo, fakeAdapter, fakeGithub, fakeService, hashOf, 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 = 20) => new Promise((resolve) => setTimeout(resolve, ms)); + +const [row] = indexRows(); +const LIVE = 'deadbeefdeadbeef'; +const MOVED_AGAIN = 'cafecafecafecafe'; + +const conflictFor = (remote: string = LIVE): TargetConflict => ({ name: NAME, target: 'api', pinned: null, local: hashOf(NAME), remote }); +const decision = (choice: 'local' | 'remote', remote: string = LIVE) => ({ 'API_KEY@api': { choice, remote, local: hashOf(NAME) } }); + +/** What a Dokploy service that compares live values does: `api`'s API_KEY is in conflict until answered, and an answer counts only for the live value it was made against. */ +const liveScript = + (remote: string, liveAtWrite: string = remote) => + (config: TargetConfig, ctx: DeployContext): DeployResult => { + if (config.name !== 'api' || ctx.targetCheck === undefined) return { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; + const now = ctx.dryRun ? remote : liveAtWrite; + const seen = ctx.targetCheck.resolutions[NAME]; + const answer = seen !== undefined && seen.remote === now && seen.local === hashOf(NAME) ? seen.choice : undefined; + if (ctx.dryRun) return { ok: true, steps: [], conflicts: [conflictFor(now)] }; + if (answer === undefined) return { ok: false, steps: [{ label: 'application.saveEnvironment', status: 'fail', code: 'SECRETS_CONFLICT' }], conflicts: [conflictFor(now)] }; + return answer === 'remote' + ? { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }], skipped: [NAME] } + : { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; + }; + +const liveAdapter = (remote: string = LIVE, liveAtWrite: string = remote) => Object.assign(fakeAdapter({ deploy: liveScript(remote, liveAtWrite) }), { readsLiveValues: true }); + +/** A service whose `api` value is LIVE for the first comparison and MOVED_AGAIN from the second on: someone changed it while the person was deciding. */ +function movingAdapter() { + const adapter: ReturnType = Object.assign( + fakeAdapter({ + deploy: (config: TargetConfig, ctx: DeployContext): DeployResult => { + // The call being answered is already in the log. + const comparisons = adapter.deploy.mock.calls.filter(([c, x]) => c.name === 'api' && x.dryRun === true).length; + return liveScript(comparisons > 1 ? MOVED_AGAIN : LIVE)(config, ctx); + }, + }), + { readsLiveValues: true }, + ); + return adapter; +} + +function rig(adapter = liveAdapter()) { + const service = fakeService(); + const github = fakeGithub(standardRepos()); + const { env } = makeEnv(service, github, adapter); + const actions = createDeployActionsWith({ getOrgRepos: async () => ({ org_id: 'org1', repos: [...LINKS] }) }, 'org1', env, false); + return { service, github, adapter, actions }; +} + +async function planned(r: ReturnType): Promise { + const loaded = await r.actions.loadPlan(row); + if (!loaded.ok) throw new Error('no plan'); + return loaded.plan; +} + +/** Nothing was delivered: no delivery call reached the adapter, no record, no PR. */ +const nothingWritten = (r: ReturnType): void => { + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + expect(r.github.createBlob.mock.calls).toHaveLength(0); + expect(r.github.createPull.mock.calls).toHaveLength(0); + expect(r.adapter.deploy.mock.calls.every(([, ctx]) => ctx.dryRun === true)).toBe(true); +}; + +describe('the real actions: found before anything is delivered', () => { + test('an undecided conflict comes back as a conflict; nothing is delivered, recorded or opened', async () => { + const r = rig(); + const finished = await r.actions.run(await planned(r)); + expect(finished).toEqual({ ok: false, code: ERROR_CODES.SECRETS_CONFLICT, conflicts: [conflictFor()] }); + nothingWritten(r); + // Fingerprints and names only. + Object.values(VALUES).forEach((v) => expect(JSON.stringify(finished)).not.toContain(v)); + }); + + test('local: every target is delivered, the answer handed to the adapter bound to the live value it was made against', async () => { + const r = rig(); + const finished = await r.actions.run(await planned(r), undefined, decision('local')); + expect(finished.ok).toBe(true); + const api = r.adapter.deploy.mock.calls.find(([c, ctx]) => c.name === 'api' && !ctx.dryRun); + expect(api?.[1].targetCheck?.resolutions).toEqual({ [NAME]: { choice: 'local', remote: LIVE, local: hashOf(NAME) } }); + expect(r.github.createPull.mock.calls).toHaveLength(3); + }); + + test('remote: that variable is not delivered and not recorded for api (the target reports it kept on the platform)', async () => { + const r = rig(); + const finished = await r.actions.run(await planned(r), undefined, decision('remote')); + if (!finished.ok) throw new Error('expected a result'); + expect(finished.result.targets.find((t) => t.kind === 'skipped')).toMatchObject({ kind: 'skipped', code: SKIP_CODES.KEPT_ON_PLATFORM, target: { target: 'api' } }); + expect(finished.result.targets.filter((t) => t.kind === 'delivered').map((t) => t.target.target).toSorted()).toEqual(['site', 'worker']); + const recorded = r.service.pushSecrets.mock.calls.map((c) => JSON.parse(String(c[1])) as { variables: Record }>> }); + recorded.forEach((keep) => expect(keep.variables.API_KEY[0].targets?.map((t) => t.target) ?? []).not.toContain('api')); + }); + + test('the live value changed after the decision: the decision is NOT applied, the new conflict comes back, nothing is delivered', async () => { + const r = rig(liveAdapter(MOVED_AGAIN)); + const finished = await r.actions.run(await planned(r), undefined, decision('local', LIVE)); + expect(finished).toEqual({ ok: false, code: ERROR_CODES.SECRETS_CONFLICT, conflicts: [conflictFor(MOVED_AGAIN)] }); + nothingWritten(r); + }); + + test('a decision for another delivered value is not applied either', async () => { + const r = rig(); + const finished = await r.actions.run(await planned(r), undefined, { 'API_KEY@api': { choice: 'local', remote: LIVE, local: 'someotherhash0000' } }); + expect(finished.ok).toBe(false); + nothingWritten(r); + }); + + test('a platform that cannot be read back has nothing to compare: the run is as before', async () => { + const r = rig(fakeAdapter() as never); + const finished = await r.actions.run(await planned(r)); + expect(finished.ok).toBe(true); + expect(r.adapter.deploy.mock.calls.every(([, ctx]) => ctx.dryRun !== true)).toBe(true); + }); +}); + +describe('decideFrom (pure)', () => { + const scope = { project: 'mono-backend', branch: 'production', serverKeepHash: 'k', conflicts: [conflictFor()] }; + + test('every conflict decided against what it holds now: the decisions, by NAME@TARGET', () => { + expect(decideFrom(decision('remote'))([scope])).toEqual({ 'API_KEY@api': 'remote' }); + }); + test('an undecided or moved conflict is a refusal (nothing written) listing them', () => { + expect(() => decideFrom(undefined)([scope])).toThrow(); + const thrown = (() => { + try { + decideFrom(decision('local', 'old'))([scope]); + return undefined; + } catch (e) { + return e; + } + })(); + expect(thrown).toMatchObject({ code: ERROR_CODES.SECRETS_CONFLICT }); + expect((thrown as { payload: { conflicts: unknown[] } }).payload.conflicts).toEqual([conflictFor()]); + expect(conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [scope]).payload.conflicts).toEqual([conflictFor()]); + }); +}); + +describe('the flow step', () => { + const running = (): DeployFlow => ({ step: 'running', row, plan: { targets: [], skipped: [], read_failed: [] } }); + const atConflicts = (): DeployFlow => + applyDeployFinished(running(), { ok: false, code: ERROR_CODES.SECRETS_CONFLICT, conflicts: [conflictFor()] }) as DeployFlow; + const view = (flow: DeployFlow): string => strip(renderDeploy(flow, 120).lines.join('\n')); + + test('a run that comes back with conflicts shows each by target and name, never a value', () => { + const flow = atConflicts(); + expect(flow.step).toBe('conflicts'); + expect(view(flow)).toContain('API_KEY'); + expect(view(flow)).toContain('api · API_KEY · changed outside Capy'); + expect(view(flow)).not.toContain(LIVE); + expect(strip(renderDeploy(flow, 120).footer)).toContain('l local'); + expect(strip(renderDeploy(flow, 120).footer)).toContain('r remote'); + }); + + test('Enter does nothing until decided; then it runs again with the decision bound to the fingerprints', () => { + const flow = atConflicts(); + expect(stepDeploy(flow, '\r').effect).toBeNull(); + expect(strip(renderDeploy(flow, 120).footer)).not.toContain('enter continue'); + const decided = stepDeploy(flow, 'l').flow as DeployFlow; + expect(strip(renderDeploy(decided, 120).footer)).toContain('enter continue'); + const again = stepDeploy(decided, '\r'); + expect(again.flow?.step).toBe('running'); + expect(again.effect).toMatchObject({ type: 'runDeploy', decisions: decision('local') }); + }); + + test('Esc leaves with nothing written: no effect, the cancelled note', () => { + const left = stepDeploy(stepDeploy(atConflicts(), 'l').flow as DeployFlow, ESC); + expect(left.flow).toBeNull(); + expect(left.effect).toBeNull(); + expect(left.note).toBe(CANCELLED_NOTHING); + }); + + test('coming back with the live value moved again drops that decision', () => { + const decided = stepDeploy(stepDeploy(atConflicts(), 'l').flow as DeployFlow, '\r').flow as DeployFlow; + const back = applyDeployFinished(decided, { ok: false, code: ERROR_CODES.SECRETS_CONFLICT, conflicts: [conflictFor(MOVED_AGAIN)] }) as Extract; + expect(back.step).toBe('conflicts'); + expect(back.choices).toEqual({}); + expect(stepDeploy(back, '\r').effect).toBeNull(); + // Unmoved: the decision stands. + const same = applyDeployFinished(decided, { ok: false, code: ERROR_CODES.SECRETS_CONFLICT, conflicts: [conflictFor()] }) as Extract; + expect(same.choices).toEqual({ 'API_KEY@api': 'local' }); + }); + + test('a plain failure is still the failure line', () => { + const done = applyDeployFinished(running(), { ok: false, code: ERROR_CODES.SERVICE_ERROR }) as DeployFlow; + expect(done.step).toBe('done'); + expect(startDeploy(row).flow.step).toBe('planning'); + }); +}); + +describe('the whole TUI: Ctrl+D, Enter, then the conflict', () => { + async function session(keys: readonly string[], adapter = liveAdapter()) { + const r = rig(adapter); + const out = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const lastFrame = (): string => strip(String(out.mock.calls[out.mock.calls.length - 1]?.[0] ?? '')); + const done = runSecretsScreen([row], async () => ({ ok: false, code: 'NO' }), undefined, false, r.actions); + await wait(); + const send = async (key: string) => { + process.stdin.emit('data', Buffer.from(key)); + await wait(40); + }; + await send(CTRL_D); + await wait(100); + await send('\r'); + await wait(200); + const atConflicts = lastFrame(); + const deliveredBeforeDeciding = r.adapter.deploy.mock.calls.filter(([, ctx]) => ctx.dryRun !== true).length; + for (const key of keys) await send(key); + await wait(200); + const afterwards = lastFrame(); + await send(ESC); + await send('\x03'); + await done; + const logged = log.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + out.mockRestore(); + log.mockRestore(); + return { r, atConflicts, deliveredBeforeDeciding, afterwards, logged }; + } + + test('the conflict is shown first, with nothing delivered; Esc cancels and still nothing is', async () => { + const s = await session([ESC]); + expect(s.atConflicts).toContain('changed outside Capy in 1 place'); + expect(s.atConflicts).toContain('api · API_KEY'); + expect(s.deliveredBeforeDeciding).toBe(0); + nothingWritten(s.r); + }); + + test('local, Enter: delivered', async () => { + const s = await session(['l', '\r']); + expect(s.r.github.createPull.mock.calls).toHaveLength(3); + expect(s.r.adapter.deploy.mock.calls.some(([c, ctx]) => c.name === 'api' && !ctx.dryRun)).toBe(true); + expect(everythingSentTo(s.r.github.createPull)).not.toContain(VALUES.API_KEY); + }); + + test('remote, Enter: api is skipped (kept on the platform), the others are delivered', async () => { + const s = await session(['r', '\r']); + expect(s.r.github.createPull.mock.calls).toHaveLength(2); + expect(`${s.logged}\n${s.afterwards}`).toContain(SKIP_CODES.KEPT_ON_PLATFORM); + }); + + test('the live value moves after the decision: the conflict is shown again and nothing is delivered', async () => { + const s = await session(['l', '\r'], movingAdapter()); + expect(s.afterwards).toContain('changed outside Capy in 1 place'); + // It was compared twice (before the decision and after it), and the second time it had moved. + expect(s.r.adapter.deploy.mock.calls.filter(([c, x]) => c.name === 'api' && x.dryRun === true)).toHaveLength(2); + nothingWritten(s.r); + }); +}); diff --git a/tests/ui/secretsEditConflicts.test.ts b/tests/ui/secretsEditConflicts.test.ts new file mode 100644 index 00000000..9340e0e2 --- /dev/null +++ b/tests/ui/secretsEditConflicts.test.ts @@ -0,0 +1,219 @@ +/** + * The `capy secrets` edit flow and conflicts (CAP-714), through the screen's own pure reducer: a run that finds + * locations someone else changed since the row was listed comes back to a `conflicts` step with one line per location; + * m / t decide each; Enter runs again only once every one is decided, with each decision bound to the fingerprints it + * was made against; Esc leaves with nothing written. Names and places only on screen, never a value. + */ +import { describe, test, expect } from 'bun:test'; +import { + SecretsScreenState, + applyRepos, + applyRunDone, + applyValueResult, + handleKey, + initialSecretsScreenState, + render, + tokenizeKeys, +} from '../../src/ui/secretsScreen'; +import type { EditFlow, RunRequest } from '../../src/ui/secretsEditFlow'; +import type { LocationConflict, SetLocation } from '../../src/commands/secretsSet'; +import type { OrgRepoLink, SecretIndexRow } from '../../src/service/serviceClient'; +import { ERROR_CODES } from '../../src/types/index'; + +const ESC = '\x1b'; +const DOWN = `${ESC}[B`; +const ENTER = '\r'; +const CTRL_E = '\x05'; +const VALUE = 'SENTINEL-typed-value-77'; +const ROW_HASH = 'rowhash1'; + +const strip = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); + +const row: SecretIndexRow = { + name: 'API_KEY', + value_hash: ROW_HASH, + locations: [ + { project_id: 'p1', project_name: 'web', branch: 'production', protected: true, service: null }, + { project_id: 'p2', project_name: 'api', branch: 'development', protected: false, service: null }, + ], + users: [], +}; + +const LINKS: readonly OrgRepoLink[] = []; + +const press = (state: SecretsScreenState, ...keys: readonly string[]): SecretsScreenState => keys.reduce((s, k) => handleKey(s, k).state, state); +const type = (state: SecretsScreenState, text: string): SecretsScreenState => press(state, ...tokenizeKeys(text)); +const frame = (state: SecretsScreenState): string => strip(render(state, 100, 30)); +const flowOf = (state: SecretsScreenState): EditFlow => state.edit as EditFlow; + +/** Value typed, locations accepted (all), no repos: Enter on the (empty) repo list starts the run. */ +const running = (): { state: SecretsScreenState; request: RunRequest } => { + const atLocations = press(type(press(initialSecretsScreenState([row]), CTRL_E), VALUE), ENTER); + const loading = handleKey(atLocations, ENTER).state; + const atRepos = applyRepos(loading, { ok: true, links: LINKS, bases: {} }); + const started = handleKey(atRepos, ENTER); + const effect = started.effect as { type: 'runSet'; request: RunRequest }; + return { state: started.state, request: effect.request }; +}; + +const loc = (p: 'p1' | 'p2'): SetLocation => + p === 'p1' + ? { project_id: 'p1', project_name: 'web', branch: 'production', protected: true } + : { project_id: 'p2', project_name: 'api', branch: 'development', protected: false }; + +const conflict = (p: 'p1' | 'p2', remote: string, changedAt?: string): LocationConflict => ({ + location: loc(p), + conflict: { + name: 'API_KEY', + project: loc(p).project_name, + branch: loc(p).branch, + type: 'changed', + pinned: ROW_HASH, + local: 'local-fp', + remote, + }, + ...(changedAt === undefined ? {} : { changedAt }), +}); + +const found = (...conflicts: LocationConflict[]) => ({ ok: false as const, code: ERROR_CODES.SECRETS_CONFLICT, conflicts }); + +describe('a run that finds conflicts', () => { + test('the run asks for the row\'s fingerprint, so the engine can tell what moved', () => { + expect(running().request.seen).toBe(ROW_HASH); + }); + + test('comes back to the conflicts step, listing each location by project and branch, before anything is written', () => { + const { state } = running(); + const shown = applyRunDone(state, found(conflict('p1', 'aaaa'), conflict('p2', 'bbbb'))); + expect(flowOf(shown).step).toBe('conflicts'); + const text = frame(shown); + expect(text).toContain('API_KEY'); + expect(text).toContain('web production'); + expect(text).toContain('api development'); + expect(text).toContain('changed since you looked in 2 locations'); + expect(text).toContain('l local'); + expect(text).toContain('r remote'); + // Never a value, never a fingerprint. + expect(text).not.toContain(VALUE); + expect(text).not.toContain('aaaa'); + expect(text).not.toContain('local-fp'); + }); + + test('says when the other value was set, if the service says', () => { + const shown = applyRunDone(running().state, found(conflict('p1', 'aaaa', new Date(Date.now() - 3 * 3600_000).toISOString()))); + expect(frame(shown)).toContain('changed 3 hours ago'); + }); + + test('a failure that is not a conflict is still the plain failure line', () => { + const shown = applyRunDone(running().state, { ok: false, code: ERROR_CODES.SERVICE_ERROR }); + expect(flowOf(shown).step).toBe('done'); + }); +}); + +describe('deciding', () => { + const atConflicts = () => applyRunDone(running().state, found(conflict('p1', 'aaaa'), conflict('p2', 'bbbb'))); + + test('m and t decide the location under the cursor; ↓ moves; each choice is shown on its line', () => { + const decided = press(atConflicts(), 'l', DOWN, 'r'); + const lines = frame(decided).split('\n'); + expect(lines.find((l) => l.includes('web production'))).toContain('local'); + expect(lines.find((l) => l.includes('api development'))).toContain('remote'); + }); + + test('Enter does nothing until every location is decided, and the footer offers it only then', () => { + const half = press(atConflicts(), 'l'); + const blocked = handleKey(half, ENTER); + expect(blocked.effect).toBeNull(); + expect(flowOf(blocked.state).step).toBe('conflicts'); + expect(frame(half)).not.toContain('enter continue'); + expect(frame(press(half, DOWN, 'r'))).toContain('enter continue'); + }); + + test('Enter runs again with each decision bound to the fingerprints it was made against', () => { + const decided = press(atConflicts(), 'l', DOWN, 'r'); + const again = handleKey(decided, ENTER); + expect(flowOf(again.state).step).toBe('running'); + const effect = again.effect as { type: 'runSet'; request: RunRequest }; + expect(effect.type).toBe('runSet'); + expect(effect.request.decisions).toEqual({ + 'p1/production': { choice: 'local', remote: 'aaaa', local: 'local-fp' }, + 'p2/development': { choice: 'remote', remote: 'bbbb', local: 'local-fp' }, + }); + // The rest of the request is what was asked the first time. + expect(effect.request.locations.map((l) => `${l.project_id}/${l.branch}`)).toEqual(['p1/production', 'p2/development']); + expect(effect.request.value).toBe(VALUE); + }); + + test('Esc leaves with nothing written: no effect, the list is back with the cancelled note', () => { + const left = handleKey(press(atConflicts(), 'l'), ESC); + expect(left.effect).toBeNull(); + expect(left.state.edit).toBeNull(); + expect(frame(left.state)).toContain('Cancelled'); + }); + + test('Ctrl+C quits the screen from the conflicts step: nothing was running, nothing was written', () => { + const left = handleKey(atConflicts(), '\x03'); + expect(left.effect).toBeNull(); + expect(left.state.quit).toBe(true); + }); +}); + +describe('pinned and delete: all four of bare capy\'s choices', () => { + const OLD = 'SENTINEL-old-value-31'; + const atConflicts = (oldReadable: boolean) => { + const base = applyRunDone(running().state, found(conflict('p1', 'aaaa'), conflict('p2', 'bbbb'))); + return oldReadable ? applyValueResult(base, { name: 'API_KEY', value_hash: ROW_HASH }, { status: 'ok', value: OLD }) : base; + }; + + test('d decides delete; p decides pinned only when the old value was read (else the key does nothing and the footer does not offer it)', () => { + const unreadable = atConflicts(false); + expect(frame(unreadable)).not.toContain('p pinned'); + expect(frame(press(unreadable, 'p'))).not.toMatch(/web production.*pinned/); + expect(frame(press(unreadable, 'd'))).toMatch(/web production.*delete/); + + const readable = atConflicts(true); + expect(frame(readable)).toContain('p pinned'); + expect(frame(readable)).toContain('d delete'); + expect(frame(press(readable, 'p'))).toMatch(/web production.*pinned/); + }); + + test('Enter runs again with the old value attached for each location answered pinned; delete carries none', () => { + const decided = press(atConflicts(true), 'p', DOWN, 'd'); + const again = handleKey(decided, ENTER); + const request = (again.effect as { type: 'runSet'; request: RunRequest }).request; + expect(request.decisions).toEqual({ + 'p1/production': { choice: 'pinned', remote: 'aaaa', local: 'local-fp' }, + 'p2/development': { choice: 'delete', remote: 'bbbb', local: 'local-fp' }, + }); + expect(request.restore).toEqual({ 'p1/production': OLD }); + }); + + test('no value appears on screen', () => { + expect(frame(press(atConflicts(true), 'p'))).not.toContain(OLD); + }); +}); + +describe('a decision never carries over to a value it was not made against', () => { + const decidedBoth = () => handleKey(press(applyRunDone(running().state, found(conflict('p1', 'aaaa'), conflict('p2', 'bbbb'))), 'l', DOWN, 'r'), ENTER); + + test('the run comes back with the location moved again: that location is undecided, the unmoved one keeps its answer', () => { + const { state } = decidedBoth(); + // p1 moved again (cccc), p2 did not. + const back = applyRunDone(state, found(conflict('p1', 'cccc'), conflict('p2', 'bbbb'))); + expect(flowOf(back).step).toBe('conflicts'); + const lines = frame(back).split('\n'); + expect(lines.find((l) => l.includes('web production'))).not.toMatch(/local|remote/); + expect(lines.find((l) => l.includes('api development'))).toContain('remote'); + // Not continuable until the moved one is decided again. + expect(handleKey(back, ENTER).effect).toBeNull(); + }); + + test('keeping the other value says so under the result', () => { + const { state } = decidedBoth(); + const done = applyRunDone(state, { + ok: true, + result: { name: 'API_KEY', updated: [{ project: 'web', branch: 'production', protected: true }], unchanged: [], prs: [], no_pr: [], failed: [] }, + }); + expect(frame(done)).toContain('Kept the other value in 1 location.'); + }); +}); diff --git a/tests/ui/secretsEditConflictsDriver.test.ts b/tests/ui/secretsEditConflictsDriver.test.ts new file mode 100644 index 00000000..182145d2 --- /dev/null +++ b/tests/ui/secretsEditConflictsDriver.test.ts @@ -0,0 +1,78 @@ +/** + * The whole `capy secrets` edit with a conflict (CAP-714): real keystrokes through the driver and the reducer, the real + * edit actions and engine over the fake service. One of the row's two locations was changed by someone else after the + * row was listed. Nothing is written until it is decided: local writes it, remote leaves it alone. + */ +import { describe, test, expect, spyOn } from 'bun:test'; +import { runSecretsScreen } from '../../src/ui/secretsScreenDriver'; +import { createEditActionsWith } from '../../src/commands/secretsEditActions'; +import { NAME, SENTINEL, fakeGithub, fakeService, indexRows, makeEnv, type Loc } from '../helpers/secretsWorld'; + +const ESC = '\x1b'; +const CTRL_E = '\x05'; +const wait = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms)); +const strip = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); + +const LISTED: readonly Loc[] = [{ project: 'pA', branch: 'staging' }, { project: 'pC', branch: 'development' }]; +const NOW: readonly Loc[] = [{ project: 'pA', branch: 'staging', value: 'moved-by-someone-else-2222' }, { project: 'pC', branch: 'development' }]; + +async function session(choose: readonly string[]) { + const service = fakeService({ locs: NOW }); + const env = makeEnv(service, fakeGithub([])); + const actions = createEditActionsWith({ getOrgRepos: async () => ({ org_id: 'org1', repos: [] }) }, 'org1', env); + const out = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const log = spyOn(console, 'log').mockImplementation(() => {}); + const frames = (): string => strip(out.mock.calls.map((c) => String(c[0])).join('\u0000')); + const lastFrame = (): string => strip(String(out.mock.calls[out.mock.calls.length - 1]?.[0] ?? '')); + const done = runSecretsScreen(indexRows(LISTED), async () => ({ ok: false, code: 'NO' }), actions); + await wait(); + const send = async (key: string) => { + process.stdin.emit('data', Buffer.from(key)); + await wait(); + }; + // Open the edit dialog on the row, type the value, accept all locations, accept the (empty) repo list: the run starts. + await send(CTRL_E); + await send(SENTINEL); + await send('\r'); + await send('\r'); + await wait(60); + await send('\r'); + await wait(80); + const atConflicts = lastFrame(); + const pushedBeforeDeciding = service.pushSecrets.mock.calls.length; + for (const key of choose) await send(key); + await wait(80); + const afterRun = lastFrame(); + // Leave: Esc on the result (or the conflicts step) and Ctrl-C. + await send(ESC); + await send('\x03'); + await done; + const logged = log.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + out.mockRestore(); + log.mockRestore(); + return { service, atConflicts, pushedBeforeDeciding, afterRun, logged, frames: frames() }; +} + +describe('capy secrets edit, with a location someone else changed', () => { + test('the conflict is shown and nothing has been written until it is decided', async () => { + const s = await session([]); + expect(s.atConflicts).toContain('changed since you looked in 1 location'); + expect(s.atConflicts).toContain('staging'); + expect(s.pushedBeforeDeciding).toBe(0); + // Cancelled out of the conflicts step: still nothing written. + expect(s.service.pushSecrets.mock.calls).toHaveLength(0); + expect(s.frames).not.toContain('moved-by-someone-else-2222'); + }); + + test('local: both locations are written, and the confirmation says so', async () => { + const s = await session(['l', '\r']); + expect(s.service.pushSecrets.mock.calls.map((c) => c[0]).toSorted()).toEqual(['pA', 'pC']); + expect(`${s.logged}\n${s.afterRun}`).toContain(NAME); + }); + + test('remote: only the other location is written; the confirmation notes the one that kept its value', async () => { + const s = await session(['r', '\r']); + expect(s.service.pushSecrets.mock.calls.map((c) => c[0])).toEqual(['pC']); + expect(`${s.logged}\n${s.afterRun}`).toContain('Kept the other value in 1 location.'); + }); +});