From 519c7395b0c51515d7c679dfd19d01845078a984 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 00:54:07 -0700 Subject: [PATCH 01/12] fix(sync): one commit path for every write; apply only intended changes on top of the server's latest Every folder-based save (capy edit, edit NAME, add, remove, connect, rotations, push, bare capy commit-local, token revoke, the system store) used to upload the folder's whole env merged into its own keep.lock, so a folder pinned behind the server silently reverted newer values and dropped server-only variables. src/sync/commit.ts now owns every write: read the server's latest for the branch, apply only the intended set/remove on top of it (unchanged names keep the server's ciphertext verbatim), stop with SECRETS_CONFLICT when a changed name was also changed on the server since the folder's pin, upload with base_keep_hash, and re-read and re-plan once on SECRETS_STALE_BASE. capy secrets set and the deploy records send the base too. compareSecrets moves to src/sync/. The unused SyncEngine.compareEnvironments, applyDecisions, generateSyncResult, formatSyncSummary and validateDecisions are removed. --- docs/cli-reference.md | 3 +- src/commands/capyCommand.ts | 143 ++--- src/commands/connectCommand.ts | 17 +- src/commands/connectors/dokploy.ts | 4 +- src/commands/connectors/shared.ts | 243 ++------ src/commands/deployCommand.ts | 2 +- src/commands/deployTokenCommand.ts | 26 +- src/commands/editCommand.ts | 179 ++---- src/commands/pushCommand.ts | 120 +--- src/commands/removeCommand.ts | 55 +- src/commands/secretsSet.ts | 20 +- src/commands/statusCommand.ts | 96 +--- src/deploy/batchDeploy.ts | 3 +- src/deploy/configuredTargets.ts | 4 +- src/deploy/deliveryRecord.ts | 63 +- src/service/serviceClient.ts | 12 + src/sync/commit.ts | 539 ++++++++++++++++++ src/sync/compareSecrets.ts | 80 +++ src/sync/syncEngine.ts | 273 +-------- src/system/systemStore.ts | 84 +-- src/types/index.ts | 21 +- tests/commands/branchKeepFile.test.ts | 5 - tests/commands/capyCommand.test.ts | 5 - tests/commands/connectDokploy.test.ts | 11 + .../connectDokploySystemStoreSentinel.test.ts | 6 +- .../commands/connectImportJsonPurity.test.ts | 6 +- tests/commands/dryRunE2E.test.ts | 9 +- tests/commands/removeCommand.test.ts | 135 ++--- tests/deploy/deliveryRecord.test.ts | 35 ++ tests/helpers/fakeCapyServer.ts | 210 +++++++ tests/helpers/folderWorld.ts | 66 +++ tests/helpers/pipedHarness.ts | 21 +- tests/integration/cliIntegration.test.ts | 114 ---- tests/service/serviceClient.test.ts | 33 ++ tests/sync/commitOct2.test.ts | 424 ++++++++++++++ tests/sync/commitPlan.test.ts | 200 +++++++ .../{commands => sync}/compareSecrets.test.ts | 2 +- tests/sync/syncEngine.test.ts | 499 +--------------- tests/system/systemStore.test.ts | 33 ++ 39 files changed, 2035 insertions(+), 1766 deletions(-) create mode 100644 src/sync/commit.ts create mode 100644 src/sync/compareSecrets.ts create mode 100644 tests/helpers/fakeCapyServer.ts create mode 100644 tests/helpers/folderWorld.ts create mode 100644 tests/sync/commitOct2.test.ts create mode 100644 tests/sync/commitPlan.test.ts rename tests/{commands => sync}/compareSecrets.test.ts (96%) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b5ce7758..812ea3ea 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -878,8 +878,9 @@ 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` - `KEEP_PR_NOT_GIT_REPO` - `KEEP_PR_NO_GITHUB_REMOTE` - `KEEP_PR_GH_UNAVAILABLE` diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index c46d87ad..dec68a4f 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -43,7 +43,9 @@ 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 } from '../sync/compareSecrets'; +import { commitLocalValues, envBlobOf, keepForEnv, pinnedHashes, snapshotFrom, type Snapshot } from '../sync/commit'; import { ACCENT } from '../ui/colors'; import { installSyncHooks } from '../git/syncHooks'; import { initProjectQuestion } from '../ui/projectQuestions'; @@ -1280,13 +1282,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,16 +1322,17 @@ 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(); @@ -1362,18 +1359,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 +1380,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 +1426,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 }; } })(); @@ -1692,10 +1683,6 @@ export class CapyCommand { if (finalEnvDecision.kind === 'abort') return; 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!, @@ -1704,62 +1691,49 @@ export class CapyCommand { variables: {}, }; - const pushedVars = Object.fromEntries( - Object.entries(finalEnv).map(([key, value]) => [ - key, + // Commit + push are coupled: choosing "commit local" pushes too — except + // in local-only mode, where there is no remote and the local writes ARE the commit. + if (action === 'commit_local') { + // What the user committed is what they CHANGED, on top of the server's latest (sync/commit.ts). + const { changed: changeCount } = await commitLocalValues( { - resource_id: deriveResourceId(branch, key), - value_hash: createHash('sha256').update(value).digest('hex').slice(0, 16), + 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, }, - ]), - ); - - 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]] : []; - }), - ), - }; + { pinned, remoteHashes, seen: seenSnapshot }, + ); + console.log(`\n> keep.lock updated (${diffs.length} changes)`); + console.log( + localMode + ? `\nStored ${changeCount} change(s) locally (local-only mode).` + : `\nPushed ${changeCount} change(s) to Keep.`, + ); + this.installGitHooks(); + return; + } + // 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 (used for both push and local cache). + // Build the encrypted env blob (the local cache). + const { deriveResourceId } = await import('../crypto/resourceId'); 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, - ); - // 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)); - } - - writeKeepCache(projectState.organizationId!, projectState.projectId!, cacheKeepHash, envBlob); + 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,17 +1748,8 @@ 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.`, - ); - } - // Install hooks on every run (idempotent) this.installGitHooks(); } diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 7bd16bf1..b92e5d43 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -2,6 +2,7 @@ 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 { isSecretsConflict } from '../sync/commit'; import type { DiscoveryContext } from './connectors/dokployDiscovery'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -215,7 +216,21 @@ export class ConnectCommand { // `writeImportOutcome` never auto-commits keep.lock and never prints // anything itself, so there is nothing left for a `quiet` flag to // suppress here. - const { wrote } = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }); + // A name someone else changed on the server since this folder's pin is a refusal naming it (nothing was uploaded). + const written = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }).then( + (result) => ({ conflict: undefined, wrote: result.wrote }), + (err: unknown) => (isSecretsConflict(err) ? { conflict: err, wrote: false } : Promise.reject(err)), + ); + if (written.conflict) { + if (opts.json) { + console.log(JSON.stringify({ ok: false, code: written.conflict.code, message: written.conflict.message, names: written.conflict.details?.names })); + } else { + console.error(`\n ${written.conflict.message}\n`); + } + process.exitCode = 1; + return { linked: false }; + } + const { wrote } = written; const pushed = !opts.noPush && wrote; const cleared = outcome.cleared ?? []; const hasChange = outcome.imported.length > 0 || cleared.length > 0; diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index dd3dba97..01d027f2 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -64,6 +64,7 @@ import { listOrgProjectsOrUnavailable } from '../capyCommand'; import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { BaseUrlStore, Unanswered, offerSaveAskedBaseUrl, realBaseUrlStoreOpener, resolveDokployBaseUrl, settingsUnanswered } from '../../deploy/dokployBaseUrl'; import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; +import { isSecretsConflict } from '../../sync/commit'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; import { discoveryProjectQuestion } from '../../ui/projectQuestions'; import { @@ -1898,7 +1899,8 @@ 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 conflict keeps its own code (and names the variables); anything else is the push failing. + return { ok: false, code: isSecretsConflict(err) ? err.code : 'DOKPLOY_PUSH_FAILED', message: err instanceof Error ? err.message : String(err) }; } return outcome; } diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index abb4d053..c7ba27bc 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -1,13 +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 { 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 { commitFolder, readLocalPlaintext } from '../../sync/commit'; +import { KeepFile, ConnectorMetadata, CapyError, ERROR_CODES, AuthResult } from '../../types/index'; import type { ImportOutcome } from './registry'; import { reportRepoLinkForCommand, type RepoLinkWarning } from '../../core/repoLinkReporter'; @@ -54,20 +50,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 @@ -152,7 +134,7 @@ export async function resolveContext( userId, projectKey, keep, - localPlaintext: decryptLocalEnv(fileManager, projectKey), + localPlaintext: readLocalPlaintext(fileManager, projectKey), repoLinkWarnings: repoLink.warnings, }; } @@ -195,100 +177,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 `commitFolder` (sync/commit.ts): it changes ONLY `varName` (plus the + * connector entries) on top of the server's latest, so nothing else in this folder's + * `.env` or keep.lock can revert anyone else's value. If the server changed `varName` + * since this folder's pin, it throws `SECRETS_CONFLICT` and uploads nothing. * - * 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 +208,38 @@ 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 commitFolder(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, and every local edit in `.env`, is left + * alone. A name the server changed since this folder's pin is a `SECRETS_CONFLICT`. */ 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 commitFolder(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 +248,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 +268,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 commitFolder(ctx, { set, remove: clear }, { transformKeep: attachAll }); } /** @@ -445,9 +292,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 +314,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..839c105e 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -483,7 +483,7 @@ async function pushKeepTransform( 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); + const pushed = await fresh.serviceClient.pushSecrets(fresh.projectId, JSON.stringify(nextKeep), fresh.latest.env_file, branch, undefined, fresh.latest.keep_hash); if (writeLocal) { const { SyncEngine } = await import('../sync/syncEngine'); diff --git a/src/commands/deployTokenCommand.ts b/src/commands/deployTokenCommand.ts index 65b0953a..ca80756f 100644 --- a/src/commands/deployTokenCommand.ts +++ b/src/commands/deployTokenCommand.ts @@ -18,6 +18,7 @@ import inquirer from 'inquirer'; import { generateDeployHtml } from '../ui/deployPage/html'; import { hashValue } from '../deploy/keepGate'; import { stripTargetsForDeployId } from '../deploy/targetsGate'; +import { adoptedKeepFor, commitToServer } from '../sync/commit'; import { ERROR_CODES } from '../types/index'; import type { ProjectState } from '../types/index'; @@ -65,23 +66,22 @@ async function stripRevokedTargets( } 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 (sync/commit.ts): no value moves, so nothing + // in this folder's `.env` can leak into the blob or revert anyone's value. + const committed = await commitToServer({ + client: serviceClient, + projectId: projectState.projectId, + branch, + projectKey, + startKeep: keep, + changes: { set: {}, remove: [] }, + transformKeep: (latest) => stripTargetsForDeployId(latest, deployId), + }); + if (committed.result) new FileManager().writeKeepFile(adoptedKeepFor(keep, committed, branch)); } catch (err: any) { console.error(` \x1b[33m!\x1b[0m could not strip revoked deploy targets in keep.lock: ${err?.message ?? err}`); } diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index aa318077..9fa52135 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -1,10 +1,9 @@ -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 { fetchSecretsWithCache, readKeepCache, readSecretsLocal, LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { hashValue } from './statusCommand'; @@ -12,9 +11,8 @@ import { EditScreen, EditRow, EditState, classifyLocalRow, focusedOn, withDeploy 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, type FolderContext } from '../sync/commit'; import { reportKeepLockHuman, refuseBadPrFlags, runKeepLockPrStep, type PrFlags } from './keepLockPr'; import { startSaveLog } from './sessionSaveLog'; import { decideEditMode, editPipedCommand, refuseEditNeedsTty } from './editPiped'; @@ -42,29 +40,6 @@ function classifyStatus( return 'in sync'; } -/** - * Decrypts `.env`. A value this profile holds no key for is skipped; - * plaintext values pass through. - */ -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])), - ), - }; -} - interface Baseline { remotePlaintext: Record; remoteAvailable: boolean; @@ -148,6 +123,51 @@ function cacheProbeThrew(localMode: boolean, orgId: string, projectId: string, k } } +/** + * One save from the edit screen (`c`, or commit on quit). The one commit path (sync/commit.ts): + * only the edited rows change, on top of the server's latest. A row someone else changed since + * this folder's pin is a `SECRETS_CONFLICT` (nothing uploaded), which the screen shows as the + * error. 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'>, +): Promise> { + const { branch } = folder; + const committed = await commitFolder( + { + ...folder, + keep: folder.pm.readKeepFile() ?? startKeep, + localPlaintext: readLocalPlaintext(folder.fileManager, folder.projectKey), + }, + { set: edits, remove: [] }, + ); + 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 { /** * The variable to work on (`capy edit NAME`). With a terminal: the TUI opens @@ -274,12 +294,7 @@ 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] : []; - }), - ); + const pinned = pinnedHashes(keep, branch); // Local-only mode: no auth, no server. Identity is synthetic; the key is // unwrapped from the passphrase session. No AuthService/ServiceClient is @@ -296,7 +311,7 @@ export class EditCommand { // Decrypt local .env values. Local ciphertext this profile does not hold // the key for is skipped. - const { localPlaintext } = decryptLocalEnv(fileManager, projectKey); + const localPlaintext = readLocalPlaintext(fileManager, projectKey); // Baseline the working copy is compared against: // - remote mode: the latest committed blob fetched from the server. @@ -373,98 +388,8 @@ 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) => + saveEdits({ pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey }, keep, edits, saveLog), }; // CAP-697: started now, collected after the TUI closes (it has long since finished by then). diff --git a/src/commands/pushCommand.ts b/src/commands/pushCommand.ts index 1071e7bd..f050719c 100644 --- a/src/commands/pushCommand.ts +++ b/src/commands/pushCommand.ts @@ -3,17 +3,11 @@ 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 { commitFolder, localChanges, pinnedHashes } from '../sync/commit'; import { debugLine } from '../ui/debug'; -import { createHash } from 'crypto'; -import { - CapyError, - ERROR_CODES, - setSyncKeepHash, -} from '../types/index'; +import { CapyError, ERROR_CODES } from '../types/index'; import { resolveProjectKey, KeyServiceOps } from '../crypto/keyResolver'; -import { deriveResourceId } from '../crypto/resourceId'; -import { writeKeepCache, LOCAL_USER_ID } from '../config/globalConfig'; +import { LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { reportRepoLinkForCommand } from '../core/repoLinkReporter'; @@ -166,7 +160,6 @@ export class PushCommand { }); } - // Read and encrypt .env file const pushSpinner = ora(localMode ? 'Storing secrets locally...' : 'Pushing secrets to Keep...').start(); const rawLocal = this.fileManager.readEnvFile(); @@ -176,92 +169,37 @@ export class PushCommand { 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}`]; - }), + // What this folder has to commit is what it CHANGED: a value that differs from the one + // keep.lock pins (an edit, or a new name). Values it merely still holds from an older + // sync are not pushed, so a stale folder cannot revert what someone else saved since. + const localPlaintext = Object.fromEntries( + Object.entries(rawLocal).map(([key, value]) => [key, value.startsWith('capy:') ? this.fileManager.decryptValue(value, encryptionKey) : value]), ); - - 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, + const changes = localChanges(localPlaintext, pinnedHashes(keep, branch), { removals: false }); + this.debug('local edits', Object.keys(changes.set)); + + const committed = await commitFolder( + { + pm: this.projectManager, + fileManager: this.fileManager, + serviceClient: localMode ? undefined : this.serviceClient, + orgId: projectState.organizationId!, + projectId: projectState.projectId!, + branch, + userId, + projectKey: encryptionKey, + keep, + localPlaintext, + }, + changes, ); - 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), - }); + this.debug('push complete', { localMode, uploaded: committed.uploaded, written: committed.written }); + const count = Object.keys(changes.set).length; pushSpinner.succeed( localMode - ? `Stored ${Object.keys(rawLocal).length} secret(s) locally (local-only mode)` - : `Pushed ${Object.keys(rawLocal).length} secret(s) to Keep` + ? `Stored ${count} secret(s) locally (local-only mode)` + : `Pushed ${count} secret(s) to Keep` ); const { printExpiryWarnings } = await import('./connectors/shared'); diff --git a/src/commands/removeCommand.ts b/src/commands/removeCommand.ts index 2594776c..f0b4718c 100644 --- a/src/commands/removeCommand.ts +++ b/src/commands/removeCommand.ts @@ -15,11 +15,11 @@ 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 { isSecretsConflict } from '../sync/commit'; import { previewKeepLockPrStep, recordsForRemoval, @@ -57,36 +57,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 +89,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 +101,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,7 +129,8 @@ export async function proceedWithRemoval( return; } - await removeAndSync(ctx, names); + // A name someone else changed on the server since this folder's pin is a refusal, not a silent overwrite. + await removeAndSync(ctx, names).catch((err: unknown) => (isSecretsConflict(err) ? refuse(opts.json, err) : Promise.reject(err))); // The removal is done whatever the PR step reports (it never throws). const outcome = await runKeepLockPrStep({ @@ -275,8 +233,7 @@ 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. + // authenticate and decrypt `.env`, which the push needs. const ctx = await resolveContext({ devMode: this.devMode, json, dryRun: opts.dryRun === true }); await proceedWithRemoval(ctx, names, { json, cwd: process.cwd(), pr: opts.pr, nonTty: opts.nonTty, dryRun: opts.dryRun === true }); } diff --git a/src/commands/secretsSet.ts b/src/commands/secretsSet.ts index 325de722..8ef311b5 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -31,6 +31,7 @@ import { FileManager } from '../files/fileManager'; import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; import { SyncEngine } from '../sync/syncEngine'; +import { isStaleBase } from '../sync/commit'; import { hashValue } from '../deploy/keepGate'; import type { EditSaveRecord } from '../deploy/keepGate'; import { writeKeepCache } from '../config/globalConfig'; @@ -332,8 +333,12 @@ const withRate = (result: R, rate: RateInfo | undefined): PoolItem => ({ r 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> { +/** + * One location: read the current branch, replace `name`, push, write the cache. Never throws; reports the rate-limit + * reading it saw. The push names the keep it was built from (`base_keep_hash`); if the server says someone saved + * since the read (`SECRETS_STALE_BASE`), the location is read and replaced once more from the new latest. + */ +async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, name: string, value: 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); @@ -358,9 +363,18 @@ async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, na 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), + (onRate) => + env.client.pushSecrets( + loc.project_id, + JSON.stringify(write.value.merged), + write.value.envBlob, + loc.branch, + onRate, + SyncEngine.computeKeepHash(current.value.serverKeep, loc.branch), + ), env.sleep, ); + if (!pushed.ok && !retried && isStaleBase(pushed.error)) return pushLocation(env, keys, loc, name, value, true); const rate = mergeRates(read.rate, pushed.rate, (env.now ?? Date.now)()); if (!pushed.ok) return failedItem(loc, pushed.error, rate); diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index ab3f4843..9890648c 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,7 @@ 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); -} +export { hashValue }; export class StatusCommand { private projectManager: ProjectManager; diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts index a0e63ad3..3a349c1c 100644 --- a/src/deploy/batchDeploy.ts +++ b/src/deploy/batchDeploy.ts @@ -571,7 +571,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. */ diff --git a/src/deploy/configuredTargets.ts b/src/deploy/configuredTargets.ts index 8dea201a..2c97d5fc 100644 --- a/src/deploy/configuredTargets.ts +++ b/src/deploy/configuredTargets.ts @@ -119,7 +119,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; } /** @@ -157,7 +157,7 @@ async function recordBranch( 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); + await input.client.pushSecrets(input.projectId, JSON.stringify(withPlaceholderRecords(keep, branch, missing)), latest.env_file, branch, undefined, latest.keep_hash); return missing.length; } catch { return 0; diff --git a/src/deploy/deliveryRecord.ts b/src/deploy/deliveryRecord.ts index e46367f9..dcc6b5f0 100644 --- a/src/deploy/deliveryRecord.ts +++ b/src/deploy/deliveryRecord.ts @@ -14,6 +14,9 @@ import type { DeployAdapter, TargetConfig } from './adapter'; import { TargetDeliveryDescriptor, VarDelivery, recordTargetDeliveries } from './targetsGate'; import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; +import { SyncEngine } from '../sync/syncEngine'; +import { isStaleBase } from '../sync/commit'; +import type { RateInfo } from '../utils/pool'; /** * Adapter-specific handle for what a target actually points at, when one is @@ -59,7 +62,15 @@ 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 = @@ -97,27 +108,14 @@ async function readServerSnapshot( } } -/** - * Fetches the server's keep for (project, branch), applies every delivery in - * `deliveries` to it, and pushes the result back server-only with the blob - * unchanged. Nothing is pushed when every delivery is already recorded. - * Never throws; a failure is a code. - * - * Two callers must not run this for the SAME (project, branch) at once: each - * would read the same keep and the later push would drop the earlier record. - * Put every delivery of one (project, branch) in one call instead. - */ -export async function recordDeliveriesOnServer( - client: RecordClient, - projectId: string, - branch: string, - deliveries: ReadonlyArray<{ - readonly delivery: TargetDeliveryDescriptor; - readonly values: readonly VarDelivery[]; - /** ISO time the values reached the platform. */ - readonly deliveredAt: string; - }>, -): Promise { +type Deliveries = ReadonlyArray<{ + readonly delivery: TargetDeliveryDescriptor; + readonly values: readonly VarDelivery[]; + /** ISO time the values reached the platform. */ + readonly deliveredAt: string; +}>; + +async function recordOnce(client: RecordClient, projectId: string, branch: string, deliveries: Deliveries, mayRetry: boolean): Promise { const snapshot = await readServerSnapshot(client, projectId, branch); if (!snapshot.ok) return snapshot; const next = deliveries.reduce( @@ -126,9 +124,26 @@ export async function recordDeliveriesOnServer( ); if (next === snapshot.keep) return { ok: true, pushed: false }; try { - await client.pushSecrets(projectId, JSON.stringify(next), snapshot.envFile, branch); + await client.pushSecrets(projectId, JSON.stringify(next), snapshot.envFile, branch, undefined, SyncEngine.computeKeepHash(snapshot.keep, branch)); return { ok: true, pushed: true }; } catch (err) { - return { ok: false, code: codeOf(err) }; + // Someone saved between the read and the push: read again and record on top of that, once. + return mayRetry && isStaleBase(err) ? recordOnce(client, projectId, branch, deliveries, false) : { ok: false, code: codeOf(err) }; } } + +/** + * Fetches the server's keep for (project, branch), applies every delivery in + * `deliveries` to it, and pushes the result back server-only with the blob + * unchanged. Nothing is pushed when every delivery is already recorded. The push + * names the keep it was built from; 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 function recordDeliveriesOnServer(client: RecordClient, projectId: string, branch: string, deliveries: Deliveries): Promise { + return recordOnce(client, projectId, branch, deliveries, true); +} 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..1a732a93 --- /dev/null +++ b/src/sync/commit.ts @@ -0,0 +1,539 @@ +/** + * The one way a folder (or the system store) saves secrets to Keep (CAP-712). + * + * WHAT IT REPLACES. Every save used to upload the folder's WHOLE local env, + * merged into the folder's OWN keep.lock, and let the server take it. A folder + * pinned to an old snapshot therefore silently reverted every value someone + * else had saved since, deleted server-only variables, and (the server's keep + * being project-wide) rolled other branches back with it. + * + * WHAT IT DOES INSTEAD. + * 1. Reads the server's latest for (project, branch). + * 2. `planCommit` (pure): the caller's intended changes applied to that latest. + * A name the caller is not changing is never touched, and keeps the server's + * ciphertext line byte for byte. A name the caller IS changing, that someone + * else changed on the server since the caller's pin, is a conflict: nothing is + * uploaded unless the caller already resolved it. + * 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`. If the server + * says the base is stale (`SECRETS_STALE_BASE`), it re-reads and re-plans ONCE. + * 4. 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'; + +// ── Vocabulary ────────────────────────────────────────────────────────────── + +/** What the caller wants different. Names not mentioned are never touched. */ +export interface Changes { + readonly set: Readonly>; + readonly remove: readonly string[]; +} + +/** The caller's answer for a name the server changed under it: keep theirs, or overwrite with mine. */ +export type Resolution = 'mine' | 'theirs'; +export type Resolutions = Readonly>; + +/** 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>; +} + +export type Plan = + | { readonly kind: 'conflict'; readonly names: readonly string[] } + | { + readonly kind: 'ok'; + /** 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[]; + }; + +/** 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) : []; + }), + ); +} + +// ── The planner (pure) ────────────────────────────────────────────────────── + +const has = (record: object, name: string): boolean => Object.hasOwn(record, name); + +/** + * `changes` applied to `latest`. + * + * A name in `set` is a conflict when the server's value moved since `pinned` + * (different hash, including added or removed on the server since) AND differs from + * the intended value AND the caller gave no resolution. A name in `remove` is a + * conflict when the server's value moved since `pinned`; removing what the server no + * longer has is a no-op. `resolutions`: `theirs` leaves the server's value alone, + * `mine` overwrites it. + */ +export function planCommit(input: { + readonly pinned: Readonly>; + readonly latest: Readonly>; + readonly changes: Changes; + readonly resolutions?: Resolutions; + /** Encrypts one value into its blob line. */ + readonly encryptLine: (name: string, value: string) => string; +}): Plan { + const { pinned, latest, changes, encryptLine } = input; + const resolutions = input.resolutions ?? {}; + const moved = (name: string): boolean => latest[name]?.hash !== pinned[name]; + const contested = (name: string): boolean => moved(name) && resolutions[name] !== 'mine'; + + const setEntries = Object.entries(changes.set); + const toWrite = setEntries.filter(([name, value]) => resolutions[name] !== 'theirs' && latest[name]?.plaintext !== value); + const toRemove = changes.remove.filter((name) => !has(changes.set, name) && has(latest, name) && resolutions[name] !== 'theirs'); + + const conflicts = [...toWrite.map(([name]) => name), ...toRemove].filter(contested); + if (conflicts.length > 0) return { kind: 'conflict', names: [...new Set(conflicts)].sort() }; + + 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 { + kind: 'ok', + 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, + }; +} + +// ── Errors ────────────────────────────────────────────────────────────────── + +/** Nothing was uploaded: `names` (never values) changed on the server since this folder last synced. */ +export function conflictError(names: readonly string[]): CapyError { + return new CapyError( + `${names.join(', ')} changed on the server since this was last synced. Nothing was uploaded.`, // COPY-FLAG + ERROR_CODES.SECRETS_CONFLICT, + { names }, + ); +} + +export function isSecretsConflict(err: unknown): err is CapyError { + return err instanceof CapyError && err.code === ERROR_CODES.SECRETS_CONFLICT; +} + +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(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'); + +// ── Committing to the server ──────────────────────────────────────────────── + +export interface ServerCommitInput { + readonly client: CommitClient; + readonly projectId: string; + readonly branch: string; + readonly projectKey: string; + /** The keep the caller started from: its branch slice is the pin. */ + readonly startKeep: KeepFile; + readonly changes: Changes; + readonly resolutions?: Resolutions; + /** 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 and showed its user: used for the first attempt instead of reading again. */ + readonly seen?: Snapshot; +} + +export interface ServerCommit { + /** The server state this commit was planned against (after a stale retry, the re-read). */ + readonly snapshot: Snapshot; + /** The server's keep after the commit. */ + 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: { readonly keep_hash: string; readonly keep_file?: string } | undefined; +} + +/** A resolution holds only for a name whose server value is the one the caller resolved against. */ +function stillValid(resolutions: Resolutions | undefined, before: Snapshot, after: Snapshot): Resolutions { + return Object.fromEntries( + Object.entries(resolutions ?? {}).filter(([name]) => before.entries[name]?.hash === after.entries[name]?.hash), + ); +} + +async function attempt(input: ServerCommitInput, snapshot: Snapshot, resolutions: Resolutions | undefined, mayRetry: boolean): Promise { + const { client, projectId, branch, projectKey, startKeep, changes, transformKeep } = input; + 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 plan = planCommit({ + pinned: pinnedHashes(startKeep, branch), + latest: snapshot.entries, + changes, + resolutions, + encryptLine: (name, value) => `capy:${resourceIdOf(name)}:${Encryptor.encrypt(value, projectKey)}`, + }); + if (plan.kind === 'conflict') throw conflictError(plan.names); + + const changed = keepWithChanges(latestKeep, branch, plan.written, plan.removed, resourceIdOf); + const keep = transformKeep ? transformKeep(changed) : changed; + const base = { snapshot, keep, lines: plan.lines, env: plan.env, written: Object.keys(plan.written), removed: plan.removed }; + if (keep === latestKeep) return { ...base, result: undefined }; + + try { + const result = await client.pushSecrets( + projectId, + JSON.stringify(keep), + envBlobOf(plan.lines), + branch, + undefined, + snapshot.keep === undefined ? undefined : SyncEngine.computeKeepHash(snapshot.keep, branch), + ); + return { ...base, result }; + } catch (err) { + if (!mayRetry || !isStaleBase(err)) throw err; + const fresh = await readSnapshot(client, projectId, branch, projectKey); + return attempt(input, fresh, stillValid(resolutions, snapshot, fresh), false); + } +} + +/** + * Applies `input.changes` to the server's latest and uploads the result. Throws + * `SECRETS_CONFLICT` (nothing uploaded) when a change would overwrite a value the + * server changed since the caller's pin, and `SECRETS_STALE_BASE` if the server moved + * again after the one re-read. Does not touch the local disk. + */ +export async function commitToServer(input: ServerCommitInput): Promise { + const snapshot = input.seen ?? (await readSnapshot(input.client, input.projectId, input.branch, input.projectKey)); + return attempt(input, snapshot, input.resolutions, true); +} + +/** + * The keep.lock a folder holding `local` should have after `committed`: `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, committed: ServerCommit, branch: string): KeepFile { + // The server holds no keep yet and nothing was uploaded: there is nothing to adopt. + if (committed.result === undefined && committed.snapshot.keep === undefined) return local; + const serverKeep = SyncEngine.adoptServerKeep(committed.result?.keep_file, committed.keep, branch); + return SyncEngine.spliceKeepBranch(local, serverKeep, branch); +} + +// ── 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 resolutions?: Resolutions; + 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; +} + +/** + * What a folder changed since its pin: the values that differ from it (an edit, or a new + * name) and, with `removals`, the pinned names gone from `.env`. Values the folder merely + * still holds from an older sync are not its changes. + */ +export function localChanges(local: Readonly>, pinned: Readonly>, opts: { readonly removals: boolean }): Changes { + return { + set: Object.fromEntries(Object.entries(local).filter(([name, value]) => hashValue(value) !== pinned[name])), + remove: opts.removals ? Object.keys(pinned).filter((name) => !has(local, name)) : [], + }; +} + +/** + * Bare `capy`'s "commit local values": the folder's changes, committed with the answer the + * user gave by choosing it. The table showed every changed name with the server's value next + * to it, so for each one the server also moved, the answer is `mine`. `seen` is the read the + * table was drawn from; the commit is planned against it, so a save in between is caught by + * the server (`SECRETS_STALE_BASE`) rather than overwritten. + */ +export async function commitLocalValues( + ctx: FolderContext, + shown: { readonly pinned: Readonly>; readonly remoteHashes: Readonly>; readonly seen: Snapshot | undefined }, +): Promise<{ readonly changed: number }> { + const changes = localChanges(ctx.localPlaintext, shown.pinned, { removals: true }); + const names = [...Object.keys(changes.set), ...changes.remove]; + const resolutions = Object.fromEntries(names.filter((name) => shown.remoteHashes[name] !== shown.pinned[name]).map((name) => [name, 'mine' as const])); + await commitFolder(ctx, changes, { resolutions, seen: shown.seen }); + return { changed: names.length }; +} + +/** `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, + }; +} + +/** `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)); +} + +/** `keep` with `names`' entries for `branch` put back as `original` has them (or dropped when it has none). */ +function restorePins(keep: KeepFile, original: KeepFile, branch: string, names: readonly string[]): KeepFile { + if (names.length === 0) return keep; + const restored = new Set(names); + return { + ...keep, + variables: Object.fromEntries( + [...new Set([...Object.keys(keep.variables), ...names])].flatMap((name) => { + if (!restored.has(name)) return [[name, keep.variables[name]]] as const; + const others = (keep.variables[name] ?? []).filter((e) => e.branch !== branch); + const own = (original.variables[name] ?? []).filter((e) => e.branch === branch); + const entries = [...others, ...own]; + return entries.length > 0 ? ([[name, entries]] as const) : []; + }), + ), + }; +} + +/** The local-only "server": the working copy is the truth, there is no one to conflict 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, + keep, + adopted: keep, + cacheHash: SyncEngine.computeKeepHash(keep, ctx.branch), + uploaded: false, + written: Object.keys(changes.set), + removed: changes.remove, + }; +} + +/** The server commit, and what the folder should adopt from it. */ +async function commitToServerFor(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions) { + const committed = await commitToServer({ + client: ctx.serviceClient!, + projectId: ctx.projectId, + branch: ctx.branch, + projectKey: ctx.projectKey, + startKeep: ctx.keep, + changes, + resolutions: opts.resolutions, + transformKeep: opts.transformKeep, + seen: opts.seen, + }); + const pinned = pinnedHashes(ctx.keep, ctx.branch); + // A local edit nobody committed stays an edit: its value stays in `.env` and its pin stays + // where it was, so the next bare `capy` still sees what the server changed under it. + const preserved = Object.keys(ctx.localPlaintext).filter( + (name) => + !has(changes.set, name) && + !changes.remove.includes(name) && + hashValue(ctx.localPlaintext[name]) !== pinned[name] && + ctx.localPlaintext[name] !== committed.env[name], + ); + const adopted = restorePins(adoptedKeepFor(ctx.keep, committed, ctx.branch), ctx.keep, ctx.branch, preserved); + return { + env: { ...committed.env, ...Object.fromEntries(preserved.map((name) => [name, ctx.localPlaintext[name]])) }, + lines: committed.lines, + keep: committed.keep, + adopted, + cacheHash: committed.result?.keep_hash ?? SyncEngine.computeKeepHash(committed.keep, ctx.branch), + uploaded: committed.result !== undefined, + written: committed.written, + removed: committed.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. `.env` afterwards is the + * server's latest plus `changes`, except that local edits nobody committed are kept as edits. + */ +export async function commitFolder(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions = {}): Promise { + const done = ctx.serviceClient === undefined ? commitLocalCopy(ctx, changes, opts.transformKeep) : await commitToServerFor(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 }; +} 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/syncEngine.ts b/src/sync/syncEngine.ts index 6d313653..a12eab12 100644 --- a/src/sync/syncEngine.ts +++ b/src/sync/syncEngine.ts @@ -1,217 +1,7 @@ import { createHash } from 'crypto'; -import { - EnvVariable, - ChangeSet, - ConflictVariable, - UserDecisions, - SyncResult, - KeepFile, - KeepVariableEntry, - SyncState, -} from '../types/index'; +import { KeepFile, KeepVariableEntry } from '../types/index'; export class SyncEngine { - /** - * Extract resource_id from capy:{resource_id}:{encrypted_value} format - */ - private extractResourceId(value: string): string | null { - if (value.startsWith('capy:')) { - const parts = value.split(':'); - if (parts.length >= 3) { - return parts[1]; // Return the resource_id part - } - } - return null; - } - - compareEnvironments( - local: Record, - remote: Record, - localEncrypted?: Record, - 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 newLocal: EnvVariable[] = []; - const newRemote: EnvVariable[] = []; - const conflicts: ConflictVariable[] = []; - const unchanged: EnvVariable[] = []; - const deleted: EnvVariable[] = []; - const deletedLocal: EnvVariable[] = []; - - // 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]) { - // 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(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 - }); - } - } - } - - return { - newLocal, - newRemote, - conflicts, - unchanged, - deleted, - deletedLocal - }; - } - - applyDecisions( - local: Record, - 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]; - } - } - - // 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; - } - - generateSyncResult( - changeSet: ChangeSet, - decisions: UserDecisions, - errors: string[] = [] - ): SyncResult { - const pushed = decisions.pushVariables; - const pulled = decisions.pullVariables; - const resolved = [...decisions.keepLocal, ...decisions.keepRemote]; - - // Deduplicate: pushed conflicts are in both pushVariables and keepLocal - const allNames = new Set([ - ...changeSet.unchanged.map(v => v.name), - ...pushed, - ...pulled, - ...resolved, - ]); - const totalVariables = allNames.size; - - return { - success: errors.length === 0, - pushed, - pulled, - conflicts: resolved, - errors, - totalVariables - }; - } - /** * Merge pushed variables into a v3 keep.lock file. * Updates the value hash for the specified branch. @@ -309,67 +99,6 @@ 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'); - } - - 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; - } - static readonly DEFAULT_BRANCH = 'development'; /** diff --git a/src/system/systemStore.ts b/src/system/systemStore.ts index 7fc73d45..93e58b10 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, commitToServer, envBlobOf, type Changes } from '../sync/commit'; import { writeKeepCache, readSystemKeepFile, @@ -206,65 +205,43 @@ 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` (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 a name another admin changed since this store was opened is a + * `SECRETS_CONFLICT`. The pin is the store's keep as last written locally, so several writes + * through one handle each build on the one before. */ -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(() => + commitToServer({ + 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 +389,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 +434,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 +533,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 +550,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..ff38eced 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -624,16 +624,21 @@ 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` 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. - */ - 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', + // --- 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', + /** + * A save would overwrite a value someone else changed on the server since this + * folder last synced. Nothing was uploaded. `details.names` lists the + * variables (names only, never values); never auto-resolved. + */ + SECRETS_CONFLICT: 'SECRETS_CONFLICT', // --- 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/tests/commands/branchKeepFile.test.ts b/tests/commands/branchKeepFile.test.ts index f1fafd05..23176561 100644 --- a/tests/commands/branchKeepFile.test.ts +++ b/tests/commands/branchKeepFile.test.ts @@ -517,12 +517,7 @@ describe('Direction detection — branch-aware keep_hash', () => { listProjects: mock(() => Promise.resolve([])), }; mockSyncEngine = { - compareEnvironments: mock(() => undefined), - formatSyncSummary: mock(() => undefined), - validateDecisions: mock(() => []), - applyDecisions: mock(() => undefined), mergeWithKeep: mock(() => undefined), - generateSyncResult: mock(() => undefined), }; mockPromptEngine = { promptForProjectName: mock(() => undefined), diff --git a/tests/commands/capyCommand.test.ts b/tests/commands/capyCommand.test.ts index cc80625f..f9a68e0a 100644 --- a/tests/commands/capyCommand.test.ts +++ b/tests/commands/capyCommand.test.ts @@ -199,12 +199,7 @@ describe('CapyCommand', () => { } as any; mockSyncEngine = { - compareEnvironments: mock(() => undefined), - formatSyncSummary: mock(() => undefined), - validateDecisions: mock(() => []), - applyDecisions: mock(() => undefined), mergeWithKeep: mock(() => undefined), - generateSyncResult: mock(() => undefined) } as any; mockPromptEngine = { diff --git a/tests/commands/connectDokploy.test.ts b/tests/commands/connectDokploy.test.ts index b8b68e5f..6272cb48 100644 --- a/tests/commands/connectDokploy.test.ts +++ b/tests/commands/connectDokploy.test.ts @@ -29,6 +29,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 +731,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/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/dryRunE2E.test.ts b/tests/commands/dryRunE2E.test.ts index 9c66189a..7233cb54 100644 --- a/tests/commands/dryRunE2E.test.ts +++ b/tests/commands/dryRunE2E.test.ts @@ -310,13 +310,16 @@ 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 no longer blocks a removal (CAP-712): only the named variable is ever removed, and nothing is changed', 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): a removal does not carry it along any more. 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(json(out)).toMatchObject({ removed: [NAME], dry_run: true }); + expectNothingWritten(h, before, pushes); }); }); }); diff --git a/tests/commands/removeCommand.test.ts b/tests/commands/removeCommand.test.ts index f3e72ae4..621e3a0d 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. @@ -27,15 +27,16 @@ import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; 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 +110,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 +145,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 +215,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 +272,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 +296,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/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/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/pipedHarness.ts b/tests/helpers/pipedHarness.ts index 6d2d77bf..796eb0b8 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 @@ -161,6 +162,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; @@ -200,14 +207,24 @@ async function startMockService(requestLog: string, failFlag: string, root: stri 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 ?? '' } })); return send(200, { keep_hash: SyncEngine.computeKeepHash(keep, parsed.branch) }); } return send(404, { error: 'unexpected request' }); diff --git a/tests/integration/cliIntegration.test.ts b/tests/integration/cliIntegration.test.ts index a3adce67..27e981e7 100644 --- a/tests/integration/cliIntegration.test.ts +++ b/tests/integration/cliIntegration.test.ts @@ -134,56 +134,6 @@ DEBUG=true`; }); describe('Sync Engine Integration', () => { - test('should correctly compare local and remote environments', () => { - const localEnv = { - LOCAL_ONLY: 'local_value', - CONFLICT_VAR: 'local_conflict', - SAME_VAR: 'same_value' - }; - - const remoteEnv = { - REMOTE_ONLY: 'remote_value', - CONFLICT_VAR: 'remote_conflict', - SAME_VAR: 'same_value' - }; - - const changeSet = syncEngine.compareEnvironments(localEnv, remoteEnv); - - expect(changeSet.newLocal).toHaveLength(1); - expect(changeSet.newLocal[0].name).toBe('LOCAL_ONLY'); - - expect(changeSet.newRemote).toHaveLength(1); - expect(changeSet.newRemote[0].name).toBe('REMOTE_ONLY'); - - expect(changeSet.conflicts).toHaveLength(1); - expect(changeSet.conflicts[0].name).toBe('CONFLICT_VAR'); - - expect(changeSet.unchanged).toHaveLength(1); - expect(changeSet.unchanged[0].name).toBe('SAME_VAR'); - }); - - test('should apply user decisions correctly', () => { - const localEnv = { LOCAL_VAR: 'local' }; - const remoteEnv = { REMOTE_VAR: 'remote', CONFLICT: 'remote_val' }; - - const decisions = { - pushVariables: ['LOCAL_VAR'], - pullVariables: ['REMOTE_VAR'], - keepLocal: [], - keepRemote: ['CONFLICT'], - deleteLocal: [], - deleteRemote: [] - }; - - const result = syncEngine.applyDecisions(localEnv, remoteEnv, decisions); - - expect(result).toEqual({ - LOCAL_VAR: 'local', - REMOTE_VAR: 'remote', - CONFLICT: 'remote_val' - }); - }); - test('should merge keep file with pushed variables', () => { const originalKeep: KeepFile = { version: '3.0', @@ -209,44 +159,6 @@ DEBUG=true`; expect(updatedKeep.variables.EXISTING_VAR[0].resource_id).toBe('res_updated'); }); - test('should validate user decisions correctly', () => { - const changeSet = { - newLocal: [{ name: 'LOCAL_VAR', value: 'val', source: 'local' as const, encrypted: false }], - newRemote: [{ name: 'REMOTE_VAR', value: 'val', source: 'remote' as const, encrypted: true }], - conflicts: [{ name: 'CONFLICT_VAR', localValue: 'local', remoteValue: 'remote' }], - unchanged: [], - deleted: [], - deletedLocal: [] - }; - - // Valid decisions - const validDecisions = { - pushVariables: ['LOCAL_VAR'], - pullVariables: ['REMOTE_VAR'], - keepLocal: ['CONFLICT_VAR'], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const validErrors = syncEngine.validateDecisions(validDecisions, changeSet); - expect(validErrors).toHaveLength(0); - - // Invalid decisions - const invalidDecisions = { - pushVariables: ['INVALID_VAR'], - pullVariables: ['REMOTE_VAR'], - keepLocal: ['CONFLICT_VAR'], - keepRemote: ['CONFLICT_VAR'], // Duplicate conflict resolution - deleteLocal: [], - deleteRemote: [] - }; - - const invalidErrors = syncEngine.validateDecisions(invalidDecisions, changeSet); - expect(invalidErrors.length).toBeGreaterThan(0); - expect(invalidErrors).toContain('Cannot push INVALID_VAR: not a new local variable, conflict, or deleted variable'); - expect(invalidErrors).toContain('Duplicate conflict resolutions detected'); - }); }); describe('Error Handling Integration', () => { @@ -271,32 +183,6 @@ DEBUG=true`; expect(() => projectManager.readKeepFile()).toThrow(CapyError); }); - test('should generate proper error messages for sync failures', () => { - const changeSet = { - newLocal: [], - newRemote: [], - conflicts: [], - unchanged: [], - deleted: [], - deletedLocal: [] - }; - - const decisions = { - pushVariables: [], - pullVariables: [], - keepLocal: [], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = ['Network timeout', 'Permission denied']; - - const syncResult = syncEngine.generateSyncResult(changeSet, decisions, errors); - - expect(syncResult.success).toBe(false); - expect(syncResult.errors).toEqual(errors); - }); }); describe('Project Name Generation Integration', () => { 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/commitOct2.test.ts b/tests/sync/commitOct2.test.ts new file mode 100644 index 00000000..5c5761b8 --- /dev/null +++ b/tests/sync/commitOct2.test.ts @@ -0,0 +1,424 @@ +/** + * The Oct 2 incident (CAP-712), reproduced on an in-memory server and asserted as a + * PROPERTY: a folder pinned to an old snapshot saves something, and what someone else + * saved since is still what the server holds afterwards. Each save path gets its own test. + * + * World: the server holds A's newer state for `development` (X changed, a server-only + * variable added) and a `staging` branch. Folder B is pinned to the OLDER snapshot and its + * `.env` still holds the old values. + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'; +import { rmSync } from 'fs'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { saveEdits } from '../../src/commands/editCommand'; +import { removeAndSync, writeAndSync, writeImportedAndSync } from '../../src/commands/connectors/shared'; +import { runSecretSet, type SetEnv, type SetRequest } from '../../src/commands/secretsSet'; +import { proceedWithRemoval } from '../../src/commands/removeCommand'; +import { hashValue, compareSecrets } from '../../src/sync/compareSecrets'; +import { commitFolder, commitLocalValues, commitToServer, localChanges, pinnedHashes, readSnapshot } from '../../src/sync/commit'; +import { SyncEngine } from '../../src/sync/syncEngine'; +import { CapyError, ERROR_CODES, type KeepFile } from '../../src/types/index'; +import { fakeCapyServer, pushOf, seedServer, serverHash, serverLine, serverValues, type FakeServerOpts } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const DEV = 'development'; +const STG = 'staging'; + +const OLD_DEV = { X: 'x-old', Y: 'y-old', KEEP: 'k-keep' } as const; +const NEW_X = 'x-NEW-from-A'; +const NEW_DEV = { X: NEW_X, Y: 'y-old', KEEP: 'k-keep', SERVER_ONLY: 'only-on-the-server' } as const; +const STAGING = { X: 'x-stg', Y: 'y-stg' } as const; + +const OLD_SEED = seedServer({ projectKey: KEY, values: { [DEV]: OLD_DEV, [STG]: STAGING } }); +const NEW_SEED = seedServer({ projectKey: KEY, values: { [DEV]: NEW_DEV, [STG]: STAGING } }); + +const stagingEntries = (keep: KeepFile | undefined) => + Object.fromEntries( + Object.entries(keep?.variables ?? {}) + .map(([name, entries]) => [name, entries.filter((e) => e.branch === STG)] as const) + .filter(([, entries]) => entries.length > 0), + ); + +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))); + +/** The server holds A's newer state; B is pinned to the older one. */ +function world(opts: { env?: Record } & Pick = {}) { + const server = fakeCapyServer({ initial: NEW_SEED, racing: opts.racing }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: opts.env ?? OLD_DEV, client: server.client }); + made(folder); + return { server, folder, before: server.state() }; +} + +const valuesOf = (server: ReturnType, branch = DEV) => serverValues(server.state(), branch, KEY); + +/** Everything the incident put at risk, asserted after a save that did not touch it. */ +function expectOthersIntact(w: ReturnType, touched: readonly string[]): void { + const after = w.server.state(); + const values = serverValues(after, DEV, KEY); + // A's newer value of X and the server-only variable survive, and so does everything else B did not touch. + Object.entries(NEW_DEV) + .filter(([name]) => !touched.includes(name)) + .forEach(([name, value]) => expect(values[name]).toBe(value)); + // ... down to the very ciphertext line the server holds. + Object.keys(NEW_DEV) + .filter((name) => !touched.includes(name)) + .forEach((name) => expect(serverLine(after, DEV, name)).toBe(serverLine(w.before, DEV, name))); + // Another branch's keep entries and blob are exactly as they were. + expect(stagingEntries(after.keep)).toEqual(stagingEntries(w.before.keep)); + expect(serverValues(after, STG, KEY)).toEqual(STAGING); +} + +const folderParts = (f: Folder) => ({ ...f.ctx }); +const noLog = { record: mock((_: unknown) => undefined) } as never; + +describe('Oct 2: a folder pinned to an old snapshot saves — what others saved since survives', () => { + test('capy edit (TUI save) of a DIFFERENT variable', async () => { + const w = world(); + await saveEdits(folderParts(w.folder), w.folder.ctx.keep, { Y: 'y-B' }, noLog); + expect(valuesOf(w.server).Y).toBe('y-B'); + expectOthersIntact(w, ['Y']); + // The folder now holds the server's state: A's X, the server-only variable, and B's own save. + expect(w.folder.env()).toMatchObject({ X: NEW_X, Y: 'y-B', SERVER_ONLY: NEW_DEV.SERVER_ONLY }); + expect(w.folder.pin('X')).toBe(hashValue(NEW_X)); + }); + + test('capy add Z', async () => { + const w = world(); + await writeAndSync(w.folder.ctx as never, 'Z', 'z-value', { push: true }); + expect(valuesOf(w.server).Z).toBe('z-value'); + expectOthersIntact(w, []); + expect(w.folder.env().Z).toBe('z-value'); + }); + + test('capy remove Y', async () => { + const w = world(); + await removeAndSync(w.folder.ctx as never, ['Y']); + expect(valuesOf(w.server).Y).toBeUndefined(); + expectOthersIntact(w, ['Y']); + expect(serverHash(w.server.state(), DEV, 'Y')).toBeUndefined(); + // Y's entry on the other branch stays. + expect(serverHash(w.server.state(), STG, 'Y')).toBe(hashValue(STAGING.Y)); + expect(w.folder.env().Y).toBeUndefined(); + }); + + test('capy remove Y, human mode: the removal command itself', async () => { + const w = world(); + const log = spyOn(console, 'log').mockImplementation(() => {}); + try { + await proceedWithRemoval(w.folder.ctx as never, ['Y'], { json: false, cwd: w.folder.dir, pr: { noPr: true } }); + } finally { + log.mockRestore(); + } + expect(valuesOf(w.server).Y).toBeUndefined(); + expectOthersIntact(w, ['Y']); + }); + + test('capy edit X (piped): a conflict, and NOTHING is uploaded', async () => { + const w = world(); + const err = (await writeAndSync(w.folder.ctx as never, 'X', 'x-from-B', { push: true }).catch((e: unknown) => e)) as CapyError; + expect(err).toBeInstanceOf(CapyError); + expect(err.code).toBe(ERROR_CODES.SECRETS_CONFLICT); + expect(err.details).toEqual({ names: ['X'] }); + // Names, never values. + expect(`${err.message} ${JSON.stringify(err.details)}`).not.toContain('x-from-B'); + expect(`${err.message} ${JSON.stringify(err.details)}`).not.toContain(NEW_X); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.server.state()).toEqual(w.before); + // ... and the folder is untouched. + expect(w.folder.env().X).toBe(OLD_DEV.X); + expect(w.folder.pin('X')).toBe(hashValue(OLD_DEV.X)); + }); + + test('capy edit X (piped) from a folder that is NOT behind: saves', async () => { + const server = fakeCapyServer({ initial: NEW_SEED }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: NEW_SEED.keep as KeepFile, env: NEW_DEV, client: server.client }); + made(folder); + await writeAndSync(folder.ctx as never, 'X', 'x-from-B', { push: true }); + expect(serverValues(server.state(), DEV, KEY)).toEqual({ ...NEW_DEV, X: 'x-from-B' }); + }); + + test('bare capy: commit local values after changing Y', async () => { + const w = world({ env: { ...OLD_DEV, Y: 'y-B' } }); + const seen = await readSnapshot(w.server.client, 'proj', DEV, KEY); + const remoteHashes = Object.fromEntries(Object.entries(seen.entries).map(([name, e]) => [name, e.hash])); + const { changed } = await commitLocalValues(w.folder.ctx, { pinned: pinnedHashes(w.folder.ctx.keep, DEV), remoteHashes, seen }); + expect(changed).toBe(1); + expect(valuesOf(w.server).Y).toBe('y-B'); + expectOthersIntact(w, ['Y']); + // The folder's untouched X is now A's. + expect(w.folder.env().X).toBe(NEW_X); + }); + + test('bare capy: B also changed X, the table showed A\'s X next to it, and B committed local: B\'s X is the answer', async () => { + const w = world({ env: { ...OLD_DEV, X: 'x-from-B' } }); + const seen = await readSnapshot(w.server.client, 'proj', DEV, KEY); + const remoteHashes = Object.fromEntries(Object.entries(seen.entries).map(([name, e]) => [name, e.hash])); + await commitLocalValues(w.folder.ctx, { pinned: pinnedHashes(w.folder.ctx.keep, DEV), remoteHashes, seen }); + expect(valuesOf(w.server).X).toBe('x-from-B'); + expectOthersIntact(w, ['X']); + }); + + test('bare capy: someone saves while the table is up — the push is refused, re-planned from the new latest, and succeeds', async () => { + const w = world({ env: { ...OLD_DEV, Y: 'y-B' } }); + const seen = await readSnapshot(w.server.client, 'proj', DEV, KEY); + const remoteHashes = Object.fromEntries(Object.entries(seen.entries).map(([name, e]) => [name, e.hash])); + // C saves a new variable while B reads the table. + w.server.tape(pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, LATE: 'late' } })); + await commitLocalValues(w.folder.ctx, { pinned: pinnedHashes(w.folder.ctx.keep, DEV), remoteHashes, seen }); + expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, LATE: 'late', Y: 'y-B' }); + expect(w.server.pushSecrets.mock.calls).toHaveLength(2); + }); + + test('capy push: pushes the folder\'s edits, not the whole env it still holds from the old snapshot', async () => { + const w = world({ env: { ...OLD_DEV, Y: 'y-B', LOCAL_NEW: 'ln' } }); + const changes = localChanges(w.folder.ctx.localPlaintext, pinnedHashes(w.folder.ctx.keep, DEV), { removals: false }); + expect(changes).toEqual({ set: { Y: 'y-B', LOCAL_NEW: 'ln' }, remove: [] }); + await commitFolder(w.folder.ctx, changes); + expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, Y: 'y-B', LOCAL_NEW: 'ln' }); + expectOthersIntact(w, ['Y']); + }); + + test('capy secrets set Y', async () => { + const w = world(); + const env: SetEnv = { + client: w.server.client as SetEnv['client'], + orgId: 'org', + openKeys: () => async () => KEY, + writeCache: () => undefined, + github: () => undefined, + branchName: () => 'capy/test', + }; + const req: SetRequest = { + name: 'Y', + value: 'y-set', + locations: [{ project_id: 'proj', project_name: 'demo', branch: DEV, protected: false }], + repos: [], + }; + const result = await runSecretSet(req, env); + expect(result.failed).toEqual([]); + expect(valuesOf(w.server).Y).toBe('y-set'); + expectOthersIntact(w, ['Y']); + }); + + test('capy connect import: many variables in one write, the rest of the server untouched', async () => { + const w = world(); + await writeImportedAndSync( + w.folder.ctx as never, + [ + { varName: 'IMPORTED_1', value: 'i1', entry: { provider: 'dokploy' } as never }, + { varName: 'IMPORTED_2', value: 'i2', entry: { provider: 'dokploy' } as never }, + ], + { push: true }, + ); + expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, IMPORTED_1: 'i1', IMPORTED_2: 'i2' }); + expectOthersIntact(w, []); + expect(w.server.pushSecrets.mock.calls).toHaveLength(1); + // The connector marker rides on the new entries only. + const keep = w.server.state().keep as KeepFile; + expect(keep.variables.IMPORTED_1.find((e) => e.branch === DEV)?.connector).toEqual({ provider: 'dokploy' }); + expect(keep.variables.X.find((e) => e.branch === DEV)?.connector).toBeUndefined(); + }); + + test('a save of the value the server already holds uploads nothing, and still syncs the folder', async () => { + const w = world(); + await writeAndSync(w.folder.ctx as never, 'Y', OLD_DEV.Y, { push: true }); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.folder.env().X).toBe(NEW_X); + }); +}); + +describe('uncommitted local edits stay edits', () => { + test('a new local variable and an edited one survive another variable\'s save, un-pinned', async () => { + const w = world({ env: { ...OLD_DEV, LOCAL_NEW: 'ln', KEEP: 'k-edited' } }); + await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); + expect(w.folder.env()).toMatchObject({ LOCAL_NEW: 'ln', KEEP: 'k-edited', Y: 'y-B', X: NEW_X }); + // Never uploaded. + expect(valuesOf(w.server).LOCAL_NEW).toBeUndefined(); + expect(valuesOf(w.server).KEEP).toBe(OLD_DEV.KEEP); + expect(w.folder.pin('LOCAL_NEW')).toBeUndefined(); + }); + + test('a local edit of a variable the server ALSO changed is still a conflict for the next bare `capy` (its pin does not move)', async () => { + const w = world({ env: { ...OLD_DEV, X: 'x-edited-by-B' } }); + await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); + expect(w.folder.env().X).toBe('x-edited-by-B'); + expect(w.folder.pin('X')).toBe(hashValue(OLD_DEV.X)); + const local = { ...w.folder.env() }; + const remote = valuesOf(w.server); + const hashes = (m: Record) => Object.fromEntries(Object.entries(m).map(([k, v]) => [k, hashValue(v)])); + const { diffs } = compareSecrets(pinnedHashes(w.folder.keep(), DEV), hashes(local), hashes(remote)); + expect(diffs.find((d) => d.variable === 'X')).toMatchObject({ pinned: hashValue(OLD_DEV.X), local: hashValue('x-edited-by-B'), remote: hashValue(NEW_X) }); + }); + + test('capy remove carries no one\'s unrelated edit along: it removes Y, uploads nothing else, and the edit stays in .env', async () => { + const w = world({ env: { ...OLD_DEV, LOCAL_NEW: 'ln', KEEP: 'k-edited' } }); + await removeAndSync(w.folder.ctx as never, ['Y']); + expect(valuesOf(w.server)).toEqual({ X: NEW_X, KEEP: OLD_DEV.KEEP, SERVER_ONLY: NEW_DEV.SERVER_ONLY }); + expect(w.folder.env()).toMatchObject({ LOCAL_NEW: 'ln', KEEP: 'k-edited' }); + }); + + test('capy remove --json of a variable the server changed: a coded refusal naming it, exit 1, nothing uploaded', async () => { + const w = world(); + const exit = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + const out = spyOn(console, 'log').mockImplementation(() => {}); + const result = await proceedWithRemoval(w.folder.ctx as never, ['X'], { json: true, cwd: w.folder.dir }).catch((e: unknown) => e); + const printed = out.mock.calls.map((c) => String(c[0])).join('\n'); + exit.mockRestore(); + out.mockRestore(); + expect((result as Error).message).toBe('exit 1'); + expect(JSON.parse(printed)).toEqual({ ok: false, code: ERROR_CODES.SECRETS_CONFLICT, error: expect.any(String), names: ['X'] }); + expect(printed).not.toContain(NEW_X); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('a local value that already equals the server\'s new value is not an edit: the pin moves', async () => { + const w = world({ env: { ...OLD_DEV, X: NEW_X } }); + await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); + expect(w.folder.pin('X')).toBe(hashValue(NEW_X)); + }); +}); + +describe('stale base: the server moved between the read and the push', () => { + const w1 = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, W1: 'w1' } }); + const w2 = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, W1: 'w1', W2: 'w2' } }); + + test('409 -> one re-read -> success, on top of what landed in between', async () => { + const w = world({ racing: (n) => (n === 1 ? [w1] : []) }); + await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); + expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, W1: 'w1', Y: 'y-B' }); + expect(w.server.getDecryptData.mock.calls).toHaveLength(2); + expect(w.server.pushSecrets.mock.calls).toHaveLength(2); + // The second push was built from the keep that was latest at the second read. + const [, , , , , firstBase] = w.server.pushSecrets.mock.calls[0]; + const [, , , , , secondBase] = w.server.pushSecrets.mock.calls[1]; + expect(firstBase).not.toBe(secondBase); + expect(w.folder.env().W1).toBe('w1'); + }); + + test('capy secrets set: the push names its base, and a stale base is read again once', async () => { + const w = world({ racing: (n) => (n === 1 ? [w1] : []) }); + const env: SetEnv = { + client: w.server.client as SetEnv['client'], + orgId: 'org', + openKeys: () => async () => KEY, + writeCache: () => undefined, + github: () => undefined, + branchName: () => 'capy/test', + }; + const req: SetRequest = { + name: 'Y', + value: 'y-set', + locations: [{ project_id: 'proj', project_name: 'demo', branch: DEV, protected: false }], + repos: [], + }; + const result = await runSecretSet(req, env); + expect(result.failed).toEqual([]); + expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, W1: 'w1', Y: 'y-set' }); + expect(w.server.getDecryptData.mock.calls).toHaveLength(2); + expect(w.server.pushSecrets.mock.calls.map((c) => c[5])).toEqual([expect.stringMatching(/^[0-9a-f]{64}$/), expect.stringMatching(/^[0-9a-f]{64}$/)]); + }); + + test('refused again: stops with the coded error, uploads nothing, folder untouched', async () => { + const w = world({ racing: (n) => (n === 1 ? [w1] : n === 2 ? [w2] : []) }); + const err = (await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }).catch((e: unknown) => e)) as CapyError; + expect(err.code).toBe(ERROR_CODES.SECRETS_STALE_BASE); + expect(w.server.getDecryptData.mock.calls).toHaveLength(2); + expect(w.server.pushSecrets.mock.calls).toHaveLength(2); + expect(valuesOf(w.server).Y).toBe(OLD_DEV.Y); + expect(w.folder.env()).toEqual(OLD_DEV); + }); + + test('the re-plan finds a conflict: SECRETS_CONFLICT, nothing uploaded', async () => { + const racing = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, Y: 'y-from-C' } }); + const w = world({ racing: (n) => (n === 1 ? [racing] : []) }); + const err = (await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }).catch((e: unknown) => e)) as CapyError; + expect(err.code).toBe(ERROR_CODES.SECRETS_CONFLICT); + expect(err.details).toEqual({ names: ['Y'] }); + expect(valuesOf(w.server).Y).toBe('y-from-C'); + }); + + test('a resolution answers the value the user saw, not a newer one', async () => { + const mine = { set: { X: 'x-mine' }, remove: [] }; + const answered = world(); + await commitFolder(answered.folder.ctx, mine, { resolutions: { X: 'mine' } }); + expect(valuesOf(answered.server).X).toBe('x-mine'); + + const third = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, X: 'x-from-C' } }); + const raced = world({ racing: (n) => (n === 1 ? [third] : []) }); + const err = (await commitFolder(raced.folder.ctx, mine, { resolutions: { X: 'mine' } }).catch((e: unknown) => e)) as CapyError; + expect(err.code).toBe(ERROR_CODES.SECRETS_CONFLICT); + expect(valuesOf(raced.server).X).toBe('x-from-C'); + }); +}); + +describe('the commit path', () => { + test('a server with no keep yet: the first save creates it and sends no base', async () => { + const server = fakeCapyServer(); + const keep: KeepFile = { version: '3.0', org_id: 'org', project_id: 'proj', project_name: 'demo', variables: {} }; + const committed = await commitToServer({ client: server.client, projectId: 'proj', branch: DEV, projectKey: KEY, startKeep: keep, changes: { set: { A: 'a' }, remove: [] } }); + expect(committed.result?.keep_hash).toBeDefined(); + expect(serverValues(server.state(), DEV, KEY)).toEqual({ A: 'a' }); + expect(server.pushSecrets.mock.calls[0][5]).toBeUndefined(); + }); + + test('every push after a read names the keep it was built from', async () => { + const w = world(); + await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); + expect(w.server.pushSecrets.mock.calls[0][5]).toMatch(/^[0-9a-f]{64}$/); + }); + + test('the keep that is uploaded is the server\'s, plus this branch\'s changed names only', async () => { + const w = world(); + await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); + const uploaded = JSON.parse(w.server.pushSecrets.mock.calls[0][1] as string) as KeepFile; + expect(uploaded.variables.SERVER_ONLY).toBeDefined(); + expect(uploaded.variables.X.find((e) => e.branch === DEV)?.value_hash).toBe(hashValue(NEW_X)); + expect(stagingEntries(uploaded)).toEqual(stagingEntries(w.before.keep)); + }); + + test('local-only mode commits the working copy without a server', async () => { + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: { ...OLD_DEV, Y: 'y-local' }, client: undefined }); + made(folder); + const committed = await commitFolder(folder.ctx, { set: { N: 'n' }, remove: [] }); + expect(committed.uploaded).toBe(false); + expect(folder.env()).toEqual({ ...OLD_DEV, Y: 'y-local', N: 'n' }); + // The whole working copy is what local-only mode commits: every name is pinned to its value. + expect(folder.pin('Y')).toBe(hashValue('y-local')); + expect(folder.pin('N')).toBe(hashValue('n')); + }); + + test('a line this profile cannot decrypt is left on the server exactly as it is', async () => { + const sealedLine = `capy:rid-sealed:${Encryptor.encrypt('for-someone-else', Encryptor.generateKey())}`; + const base = seedServer({ projectKey: KEY, values: { [DEV]: OLD_DEV } }); + const baseKeep = base.keep as KeepFile; + const keep: KeepFile = { + ...baseKeep, + variables: { ...baseKeep.variables, SEALED: [{ resource_id: 'rid-sealed', branch: DEV, value_hash: 'h-sealed' }] }, + }; + const blob = `${base.blobs[SyncEngine.computeKeepHash(baseKeep, DEV)]}\nSEALED=${sealedLine}`; + const server = fakeCapyServer({ initial: { keep, blobs: { [SyncEngine.computeKeepHash(keep, DEV)]: blob } } }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep, env: OLD_DEV, client: server.client }); + made(folder); + await writeAndSync(folder.ctx as never, 'Y', 'y-B', { push: true }); + expect(serverLine(server.state(), DEV, 'SEALED')).toBe(`SEALED=${sealedLine}`); + expect(serverValues(server.state(), DEV, KEY)).toEqual({ ...OLD_DEV, Y: 'y-B' }); + expect(serverHash(server.state(), DEV, 'SEALED')).toBe('h-sealed'); + }); +}); diff --git a/tests/sync/commitPlan.test.ts b/tests/sync/commitPlan.test.ts new file mode 100644 index 00000000..a7e88903 --- /dev/null +++ b/tests/sync/commitPlan.test.ts @@ -0,0 +1,200 @@ +/** + * The pure planner of the one commit path (CAP-712): what happens to each name when + * the caller's changes meet the server's latest, given the caller's pin. + */ +import { describe, test, expect, mock } from 'bun:test'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { localChanges, planCommit, pinnedHashes, type LatestEntry, type Plan } from '../../src/sync/commit'; +import type { KeepFile } from '../../src/types/index'; + +const entry = (value: string): LatestEntry => ({ line: `capy:r:${value}`, plaintext: value, hash: hashValue(value) }); +const latestOf = (values: Record): Record => + Object.fromEntries(Object.entries(values).map(([name, value]) => [name, entry(value)])); +const pinOf = (values: Record): Record => + Object.fromEntries(Object.entries(values).map(([name, value]) => [name, hashValue(value)])); +const encryptLine = (name: string, value: string): string => `capy:new:${name}:${value}`; + +function plan(args: { + pinned: Record; + latest: Record; + set?: Record; + remove?: string[]; + resolutions?: Record; +}): Plan { + return planCommit({ + pinned: pinOf(args.pinned), + latest: latestOf(args.latest), + changes: { set: args.set ?? {}, remove: args.remove ?? [] }, + resolutions: args.resolutions, + encryptLine, + }); +} + +const okPlan = (p: Plan) => { + if (p.kind !== 'ok') throw new Error('expected an ok plan'); + return p; +}; + +describe('planCommit — set', () => { + test('a name the server has not moved since the pin is written; every other name keeps the server line verbatim', () => { + const p = okPlan(plan({ pinned: { X: 'x0', Y: 'y0' }, latest: { X: 'x0', Y: 'y0' }, set: { Y: 'y1' } })); + expect(p.written).toEqual({ Y: 'y1' }); + expect(p.lines.X).toBe('capy:r:x0'); + expect(p.lines.Y).toBe('capy:new:Y:y1'); + expect(p.env).toEqual({ X: 'x0', Y: 'y1' }); + }); + + test('only the changed names are encrypted', () => { + const encrypt = mock(encryptLine); + planCommit({ + pinned: pinOf({ A: 'a', B: 'b', C: 'c' }), + latest: latestOf({ A: 'a', B: 'b', C: 'c' }), + changes: { set: { B: 'b2' }, remove: [] }, + encryptLine: encrypt, + }); + expect(encrypt.mock.calls.map((c) => c[0])).toEqual(['B']); + }); + + test('the server moved a name this caller does NOT change: not a conflict, and the server value stays', () => { + const p = okPlan(plan({ pinned: { X: 'x0', Y: 'y0' }, latest: { X: 'x-new', Y: 'y0' }, set: { Y: 'y1' } })); + expect(p.lines.X).toBe('capy:r:x-new'); + expect(p.env.X).toBe('x-new'); + }); + + test('the server moved the name since the pin and holds a different value: conflict', () => { + expect(plan({ pinned: { X: 'x0' }, latest: { X: 'x-new' }, set: { X: 'x-mine' } })).toEqual({ kind: 'conflict', names: ['X'] }); + }); + + test('the server moved the name but already holds the intended value: nothing to write, no conflict', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, set: { X: 'x1' } })); + expect(p.written).toEqual({}); + expect(p.lines.X).toBe('capy:r:x1'); + }); + + test('added on the server since the pin (the pin does not have it): conflict when the values differ', () => { + expect(plan({ pinned: {}, latest: { Z: 'z-theirs' }, set: { Z: 'z-mine' } })).toEqual({ kind: 'conflict', names: ['Z'] }); + }); + + test('removed on the server since the pin: setting it is a conflict', () => { + expect(plan({ pinned: { Z: 'z0' }, latest: {}, set: { Z: 'z1' } })).toEqual({ kind: 'conflict', names: ['Z'] }); + }); + + test('a new name that is neither pinned nor on the server is appended', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x0' }, set: { N: 'n' } })); + expect(Object.keys(p.lines)).toEqual(['X', 'N']); + expect(p.written).toEqual({ N: 'n' }); + }); + + test('conflicts name every contested variable, sorted, once', () => { + const p = plan({ pinned: { B: 'b0', A: 'a0', C: 'c0' }, latest: { B: 'b1', A: 'a1', C: 'c0' }, set: { B: 'b2', A: 'a2', C: 'c2' } }); + expect(p).toEqual({ kind: 'conflict', names: ['A', 'B'] }); + }); +}); + +describe('planCommit — remove', () => { + test('removes a name the server has not moved', () => { + const p = okPlan(plan({ pinned: { X: 'x0', Y: 'y0' }, latest: { X: 'x0', Y: 'y0' }, remove: ['Y'] })); + expect(p.removed).toEqual(['Y']); + expect(Object.keys(p.lines)).toEqual(['X']); + expect(p.env).toEqual({ X: 'x0' }); + }); + + test('the server moved it since the pin: conflict', () => { + expect(plan({ pinned: { Y: 'y0' }, latest: { Y: 'y1' }, remove: ['Y'] })).toEqual({ kind: 'conflict', names: ['Y'] }); + }); + + test('the server already removed it: a no-op, not a conflict', () => { + const p = okPlan(plan({ pinned: { Y: 'y0', X: 'x0' }, latest: { X: 'x0' }, remove: ['Y'] })); + expect(p.removed).toEqual([]); + expect(Object.keys(p.lines)).toEqual(['X']); + }); + + test('added on the server since the pin: removing it is a conflict', () => { + expect(plan({ pinned: {}, latest: { Z: 'z' }, remove: ['Z'] })).toEqual({ kind: 'conflict', names: ['Z'] }); + }); +}); + +describe('planCommit — resolutions', () => { + test('mine overwrites the value the server moved', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, set: { X: 'x-mine' }, resolutions: { X: 'mine' } })); + expect(p.written).toEqual({ X: 'x-mine' }); + expect(p.env.X).toBe('x-mine'); + }); + + test('theirs leaves the server value alone', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, set: { X: 'x-mine' }, resolutions: { X: 'theirs' } })); + expect(p.written).toEqual({}); + expect(p.lines.X).toBe('capy:r:x1'); + expect(p.env.X).toBe('x1'); + }); + + test('mine removes a name the server moved; theirs keeps it', () => { + const mine = okPlan(plan({ pinned: { Y: 'y0' }, latest: { Y: 'y1' }, remove: ['Y'], resolutions: { Y: 'mine' } })); + expect(mine.removed).toEqual(['Y']); + const theirs = okPlan(plan({ pinned: { Y: 'y0' }, latest: { Y: 'y1' }, remove: ['Y'], resolutions: { Y: 'theirs' } })); + expect(theirs.removed).toEqual([]); + expect(theirs.env.Y).toBe('y1'); + }); + + test('a resolution for a name nobody changes does nothing', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, resolutions: { X: 'mine' } })); + expect(p.lines.X).toBe('capy:r:x1'); + }); +}); + +describe('planCommit — edge cases', () => { + test('a line this profile cannot decrypt is kept verbatim and left out of env', () => { + const latest = { ...latestOf({ X: 'x0' }), SEALED: { line: 'capy:r:sealed-for-someone-else', plaintext: undefined, hash: 'h-sealed' } }; + const p = okPlan(planCommit({ pinned: { X: hashValue('x0'), SEALED: 'h-sealed' }, latest, changes: { set: { X: 'x1' }, remove: [] }, encryptLine })); + expect(p.lines.SEALED).toBe('capy:r:sealed-for-someone-else'); + expect(p.env).toEqual({ X: 'x1' }); + }); + + test('a name in both set and remove is set', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x0' }, set: { X: 'x1' }, remove: ['X'] })); + expect(p.env.X).toBe('x1'); + expect(p.removed).toEqual([]); + }); + + test('an empty value is a value', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x0' }, set: { X: '' } })); + expect(p.written).toEqual({ X: '' }); + }); + + test('no changes: the server is left exactly as it is', () => { + const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x9', Y: 'y' } })); + expect(p.written).toEqual({}); + expect(p.removed).toEqual([]); + expect(p.lines).toEqual({ X: 'capy:r:x9', Y: 'capy:r:y' }); + }); +}); + +describe('localChanges / pinnedHashes', () => { + const pinned = pinOf({ A: 'a', B: 'b', GONE: 'g' }); + + test('a folder changed only what differs from its pin', () => { + expect(localChanges({ A: 'a', B: 'b2', NEW: 'n' }, pinned, { removals: false })).toEqual({ set: { B: 'b2', NEW: 'n' }, remove: [] }); + }); + + test('with removals, a pinned name gone from .env is a removal', () => { + expect(localChanges({ A: 'a', B: 'b' }, pinned, { removals: true })).toEqual({ set: {}, remove: ['GONE'] }); + }); + + test('pinnedHashes reads one branch', () => { + const keep: KeepFile = { + version: '3.0', + org_id: 'o', + project_id: 'p', + project_name: 'demo', + variables: { + A: [ + { resource_id: 'r1', branch: 'development', value_hash: 'h-dev' }, + { resource_id: 'r1b', branch: 'staging', value_hash: 'h-stg' }, + ], + B: [{ resource_id: 'r2', branch: 'staging', value_hash: 'h-b' }], + }, + }; + expect(pinnedHashes(keep, 'development')).toEqual({ A: 'h-dev' }); + expect(pinnedHashes(null, 'development')).toEqual({}); + }); +}); diff --git a/tests/commands/compareSecrets.test.ts b/tests/sync/compareSecrets.test.ts similarity index 96% rename from tests/commands/compareSecrets.test.ts rename to tests/sync/compareSecrets.test.ts index 3d656bf5..165b4e46 100644 --- a/tests/commands/compareSecrets.test.ts +++ b/tests/sync/compareSecrets.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { compareSecrets, hashValue } from '../../src/commands/statusCommand'; +import { compareSecrets, hashValue } from '../../src/sync/compareSecrets'; // compareSecrets takes maps of variable -> value_hash for pinned, and // variable -> plaintext for local/remote (it hashes local/remote internally... diff --git a/tests/sync/syncEngine.test.ts b/tests/sync/syncEngine.test.ts index abc87382..9291f3c8 100644 --- a/tests/sync/syncEngine.test.ts +++ b/tests/sync/syncEngine.test.ts @@ -1,5 +1,5 @@ import { SyncEngine } from '../../src/sync/syncEngine'; -import { ChangeSet, UserDecisions, KeepFile, ConflictVariable } from '../../src/types/index'; +import { KeepFile } from '../../src/types/index'; describe('SyncEngine', () => { let syncEngine: SyncEngine; @@ -8,349 +8,6 @@ describe('SyncEngine', () => { syncEngine = new SyncEngine(); }); - describe('compareEnvironments', () => { - test('should identify new local variables', () => { - const local = { LOCAL_VAR: 'local_value', SHARED_VAR: 'shared' }; - const remote = { SHARED_VAR: 'shared' }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.newLocal).toHaveLength(1); - expect(result.newLocal[0]).toEqual({ - name: 'LOCAL_VAR', - value: 'local_value', - source: 'local', - encrypted: false - }); - }); - - test('should identify new remote variables', () => { - const local = { SHARED_VAR: 'shared' }; - const remote = { REMOTE_VAR: 'remote_value', SHARED_VAR: 'shared' }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.newRemote).toHaveLength(1); - expect(result.newRemote[0]).toEqual({ - name: 'REMOTE_VAR', - value: 'remote_value', - source: 'remote', - encrypted: true - }); - }); - - test('should identify conflicts', () => { - const local = { CONFLICT_VAR: 'local_value', SHARED_VAR: 'shared' }; - const remote = { CONFLICT_VAR: 'remote_value', SHARED_VAR: 'shared' }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]).toEqual({ - name: 'CONFLICT_VAR', - localValue: 'local_value', - remoteValue: 'remote_value', - isNew: false - }); - }); - - test('should mark conflict as isNew when resource_ids differ', () => { - const local = { - VAR1: 'capy:res_var1_abc123:encrypted_local_data' - }; - const remote = { - VAR1: 'capy:res_var1_xyz789:encrypted_remote_data' - }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]).toEqual({ - name: 'VAR1', - localValue: 'capy:res_var1_abc123:encrypted_local_data', - remoteValue: 'capy:res_var1_xyz789:encrypted_remote_data', - isNew: true - }); - }); - - test('should not mark conflict as isNew when resource_ids match', () => { - const local = { - VAR1: 'capy:res_var1_abc123:encrypted_local_data' - }; - const remote = { - VAR1: 'capy:res_var1_abc123:encrypted_remote_data' - }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]).toEqual({ - name: 'VAR1', - localValue: 'capy:res_var1_abc123:encrypted_local_data', - remoteValue: 'capy:res_var1_abc123:encrypted_remote_data', - isNew: false - }); - }); - - test('should not mark conflict as isNew when values are not encrypted', () => { - const local = { VAR1: 'plain_local_value' }; - const remote = { VAR1: 'plain_remote_value' }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]).toEqual({ - name: 'VAR1', - localValue: 'plain_local_value', - remoteValue: 'plain_remote_value', - isNew: false - }); - }); - - test('should mark conflict as isNew when local is plaintext but remote has resource_id', () => { - const local = { - VAR1: 'plaintext_value' - }; - const remote = { - VAR1: 'capy:res_var1_abc123:encrypted_remote_data' - }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.conflicts).toHaveLength(1); - expect(result.conflicts[0]).toEqual({ - name: 'VAR1', - localValue: 'plaintext_value', - remoteValue: 'capy:res_var1_abc123:encrypted_remote_data', - isNew: true - }); - }); - - test('should identify unchanged variables', () => { - const local = { SAME_VAR: 'same_value', DIFF_VAR: 'local' }; - const remote = { SAME_VAR: 'same_value', DIFF_VAR: 'remote' }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.unchanged).toHaveLength(1); - expect(result.unchanged[0]).toEqual({ - name: 'SAME_VAR', - value: 'same_value', - source: 'both', - encrypted: false - }); - }); - - test('should handle empty environments', () => { - const result = syncEngine.compareEnvironments({}, {}); - - expect(result.newLocal).toHaveLength(0); - expect(result.newRemote).toHaveLength(0); - expect(result.conflicts).toHaveLength(0); - expect(result.unchanged).toHaveLength(0); - }); - - test('should handle complex scenario with all types', () => { - const local = { - ONLY_LOCAL: 'local', - CONFLICT_VAR: 'local_conflict', - UNCHANGED: 'same' - }; - const remote = { - ONLY_REMOTE: 'remote', - CONFLICT_VAR: 'remote_conflict', - UNCHANGED: 'same' - }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.newLocal).toHaveLength(1); - expect(result.newRemote).toHaveLength(1); - expect(result.conflicts).toHaveLength(1); - expect(result.unchanged).toHaveLength(1); - }); - - test('should identify deleted variables marked as capy:deleted', () => { - const local = { - DELETED_VAR: 'local_value', - NORMAL_VAR: 'normal' - }; - const remote = { - DELETED_VAR: 'capy:deleted', - NORMAL_VAR: 'normal' - }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.deleted).toHaveLength(1); - expect(result.deleted[0]).toEqual({ - name: 'DELETED_VAR', - value: 'local_value', - source: 'local', - encrypted: false - }); - expect(result.unchanged).toHaveLength(1); - }); - - test('should not show capy:deleted markers for non-existent local variables', () => { - const local = { - NORMAL_VAR: 'normal' - }; - const remote = { - DELETED_VAR: 'capy:deleted', - NORMAL_VAR: 'normal' - }; - - const result = syncEngine.compareEnvironments(local, remote); - - expect(result.deleted).toHaveLength(0); - expect(result.newRemote).toHaveLength(0); // capy:deleted shouldn't show as new remote - expect(result.unchanged).toHaveLength(1); - }); - }); - - describe('applyDecisions', () => { - test('should apply pull decisions', () => { - const local = { LOCAL_VAR: 'local' }; - const remote = { REMOTE_VAR: 'remote' }; - const decisions: UserDecisions = { - pushVariables: ['LOCAL_VAR'], - pullVariables: ['REMOTE_VAR'], - keepLocal: [], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const result = syncEngine.applyDecisions(local, remote, decisions); - - expect(result).toEqual({ - LOCAL_VAR: 'local', - REMOTE_VAR: 'remote' - }); - }); - - test('should apply conflict resolutions', () => { - const local = { CONFLICT: 'local_value' }; - const remote = { CONFLICT: 'remote_value' }; - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: [], - keepLocal: [], - keepRemote: ['CONFLICT'], - deleteLocal: [], - deleteRemote: [] - }; - - const result = syncEngine.applyDecisions(local, remote, decisions); - - expect(result.CONFLICT).toBe('remote_value'); - }); - - test('should handle empty decisions', () => { - const local = { VAR1: 'value1' }; - const remote = { VAR2: 'value2' }; - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: [], - keepLocal: [], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const result = syncEngine.applyDecisions(local, remote, decisions); - - // With no decisions, result is remote base (local-only vars not included) - expect(result).toEqual(remote); - }); - - test('should delete local variables when user confirms deletion', () => { - const local = { - DELETE_ME: 'local_value', - KEEP_ME: 'keep_value' - }; - const remote = { - DELETE_ME: 'capy:deleted', - KEEP_ME: 'keep_value' - }; - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: [], - keepLocal: [], - keepRemote: [], - deleteLocal: ['DELETE_ME'], - deleteRemote: [] - }; - - const result = syncEngine.applyDecisions(local, remote, decisions); - - expect(result).toEqual({ - KEEP_ME: 'keep_value' - }); - expect(result.DELETE_ME).toBeUndefined(); - }); - }); - - describe('generateSyncResult', () => { - test('should generate successful sync result', () => { - const changeSet: ChangeSet = { - newLocal: [{ name: 'LOCAL', value: 'val', source: 'local', encrypted: false }], - newRemote: [{ name: 'REMOTE', value: 'val', source: 'remote', encrypted: true }], - conflicts: [{ name: 'CONFLICT', localValue: 'local', remoteValue: 'remote' }], - unchanged: [{ name: 'SAME', value: 'val', source: 'both', encrypted: false }], - deleted: [], - deletedLocal: [] - }; - - const decisions: UserDecisions = { - pushVariables: ['LOCAL'], - pullVariables: ['REMOTE'], - keepLocal: [], - keepRemote: ['CONFLICT'], - deleteLocal: [], - deleteRemote: [] - }; - - const result = syncEngine.generateSyncResult(changeSet, decisions); - - expect(result).toEqual({ - success: true, - pushed: ['LOCAL'], - pulled: ['REMOTE'], - conflicts: ['CONFLICT'], - errors: [], - totalVariables: 4 - }); - }); - - test('should generate failed sync result with errors', () => { - const changeSet: ChangeSet = { - newLocal: [], - newRemote: [], - conflicts: [], - unchanged: [], - deleted: [], - deletedLocal: [] - }; - - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: [], - keepLocal: [], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = ['Network error', 'Permission denied']; - const result = syncEngine.generateSyncResult(changeSet, decisions, errors); - - expect(result.success).toBe(false); - expect(result.errors).toEqual(errors); - }); - }); - describe('mergeWithKeep', () => { test('should update existing variables in keep', () => { const keep: KeepFile = { @@ -395,160 +52,6 @@ describe('SyncEngine', () => { }); }); - describe('formatSyncSummary', () => { - test('should format comprehensive sync summary', () => { - const changeSet: ChangeSet = { - newLocal: [ - { name: 'LOCAL1', value: 'val1', source: 'local', encrypted: false }, - { name: 'LOCAL2', value: 'val2', source: 'local', encrypted: false } - ], - newRemote: [ - { name: 'REMOTE1', value: 'val1', source: 'remote', encrypted: true } - ], - conflicts: [ - { name: 'CONFLICT1', localValue: 'local', remoteValue: 'remote' } - ], - unchanged: [ - { name: 'SAME1', value: 'val1', source: 'both', encrypted: false }, - { name: 'SAME2', value: 'val2', source: 'both', encrypted: false }, - { name: 'SAME3', value: 'val3', source: 'both', encrypted: false } - ], - deleted: [], - deletedLocal: [] - }; - - const result = syncEngine.formatSyncSummary(changeSet); - - expect(result).toContain('2 new local variable(s)'); - expect(result).toContain('1 new remote variable(s)'); - expect(result).toContain('1 conflict(s)'); - expect(result).toContain('3 unchanged'); - }); - - test('should handle empty change set', () => { - const changeSet: ChangeSet = { - newLocal: [], - newRemote: [], - conflicts: [], - unchanged: [], - deleted: [], - deletedLocal: [] - }; - - const result = syncEngine.formatSyncSummary(changeSet); - - expect(result).toBe(''); - }); - - test('should handle partial change set', () => { - const changeSet: ChangeSet = { - newLocal: [{ name: 'LOCAL', value: 'val', source: 'local', encrypted: false }], - newRemote: [], - conflicts: [], - unchanged: [], - deleted: [], - deletedLocal: [] - }; - - const result = syncEngine.formatSyncSummary(changeSet); - - expect(result).toBe('1 new local variable(s)'); - }); - }); - - describe('validateDecisions', () => { - const changeSet: ChangeSet = { - newLocal: [ - { name: 'LOCAL1', value: 'val1', source: 'local', encrypted: false }, - { name: 'LOCAL2', value: 'val2', source: 'local', encrypted: false } - ], - newRemote: [ - { name: 'REMOTE1', value: 'val1', source: 'remote', encrypted: true } - ], - conflicts: [ - { name: 'CONFLICT1', localValue: 'local', remoteValue: 'remote' } - ], - unchanged: [], - deleted: [], - deletedLocal: [] - }; - - test('should validate correct decisions', () => { - const decisions: UserDecisions = { - pushVariables: ['LOCAL1'], - pullVariables: ['REMOTE1'], - keepLocal: ['CONFLICT1'], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = syncEngine.validateDecisions(decisions, changeSet); - - expect(errors).toHaveLength(0); - }); - - test('should detect invalid push variables', () => { - const decisions: UserDecisions = { - pushVariables: ['INVALID_VAR'], - pullVariables: [], - keepLocal: [], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = syncEngine.validateDecisions(decisions, changeSet); - - expect(errors).toContain('Cannot push INVALID_VAR: not a new local variable, conflict, or deleted variable'); - }); - - test('should detect invalid pull variables', () => { - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: ['INVALID_VAR'], - keepLocal: [], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = syncEngine.validateDecisions(decisions, changeSet); - - expect(errors).toContain('Cannot pull INVALID_VAR: not a new remote variable or locally deleted variable'); - }); - - test('should detect invalid conflict resolutions', () => { - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: [], - keepLocal: ['INVALID_CONFLICT'], - keepRemote: [], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = syncEngine.validateDecisions(decisions, changeSet); - - expect(errors).toContain('Cannot resolve INVALID_CONFLICT: not a conflict'); - }); - - test('should detect duplicate conflict resolutions', () => { - const decisions: UserDecisions = { - pushVariables: [], - pullVariables: [], - keepLocal: ['CONFLICT1'], - keepRemote: ['CONFLICT1'], - deleteLocal: [], - deleteRemote: [] - }; - - const errors = syncEngine.validateDecisions(decisions, changeSet); - - expect(errors).toContain('Duplicate conflict resolutions detected'); - }); - }); - describe('computeKeepHash', () => { it('produces deterministic hash', () => { const keep: KeepFile = { diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index 4b38af58..69c4e784 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,36 @@ 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('CAP-712: an entry another admin changed since this handle opened is a SECRETS_CONFLICT, and nothing is pushed', async () => { + const seed = await openSystemStore({ orgId: ORG_A, devMode: true }); + await seed.set('_CONNECTOR_SHARED_KEY', 'v0'); + 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_SHARED_KEY', 'v-theirs'); + gc.saveSystemKeepFile(ORG_A, mineOnDisk); + const pushesBefore = serverFor(ORG_A).pushCount; + + await expect(mine.set('_CONNECTOR_SHARED_KEY', 'v-mine')).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_CONFLICT, details: { names: ['_CONNECTOR_SHARED_KEY'] } }); + expect(serverFor(ORG_A).pushCount).toBe(pushesBefore); + expect((await openSystemStore({ orgId: ORG_A, devMode: true })).get('_CONNECTOR_SHARED_KEY')).toBe('v-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 +370,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 }); From ea2a28634b06c7eb33bb6fdd11ad90bbf105eeeb Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 01:55:02 -0700 Subject: [PATCH 02/12] feat(sync): conflict protocol for every write, deploy conflicts, batch deploy sends only picked vars Off a terminal a conflict stops the write, exits 3 and prints {ok:false, code:SECRETS_CONFLICT, conflict_id, conflicts} with fingerprints only; rerun with --resolve NAME=mine|theirs and --conflict . The id binds the decision to the state seen; a moved state returns SECRETS_CONFLICT_STALE with a fresh id. --dry-run previews. Shared module src/sync/conflicts.ts, used by bare capy, edit NAME, add, remove, connect import, rotate, push, secrets set, deploy and batch deploy. capy deploy checks the folder against Capy's latest (a stale folder delivers Capy's value) and, for Dokploy, the live value against what Capy last delivered: mine delivers, theirs skips and records nothing. Batch deploy delivers and records only the picked names, keeping the rest of Capy's block byte-identical. capy push uploads deletions. capy deploy's keep update goes through the commit path. --- docs/cli-reference.md | 31 +- src/commands/addCommand.ts | 6 + src/commands/capyCommand.ts | 123 ++++-- src/commands/conflictPrompt.ts | 35 ++ src/commands/connectCommand.ts | 11 +- src/commands/connectors/registry.ts | 11 + src/commands/connectors/shared.ts | 16 +- src/commands/deployCommand.ts | 222 ++++++---- src/commands/deployConflicts.ts | 201 +++++++++ src/commands/editCommand.ts | 5 + src/commands/editPiped.ts | 3 + src/commands/pipedWrite.ts | 19 + src/commands/pushCommand.ts | 34 +- src/commands/removeCommand.ts | 23 +- src/commands/rotateCommand.ts | 43 +- src/commands/secretsDeployCommand.ts | 16 +- src/commands/secretsSet.ts | 46 +- src/commands/secretsSetCommand.ts | 111 ++++- src/core/cliHelpDoc.ts | 5 + src/core/cliReferenceMarkdown.ts | 1 + src/deploy/adapter.ts | 27 +- src/deploy/adapters/dokploy.ts | 141 ++++-- src/deploy/batchDeploy.ts | 170 ++++++- src/deploy/dokployApi.ts | 151 ++++++- src/deploy/targetsGate.ts | 20 + src/index-dev.ts | 39 +- src/index.ts | 40 +- src/sync/commit.ts | 142 +++++- src/sync/conflicts.ts | 306 +++++++++++++ src/types/index.ts | 19 + tests/commands/conflictProtocol.test.ts | 416 ++++++++++++++++++ tests/commands/deployCiChangeGate.test.ts | 19 +- tests/commands/deployCiTargetsRecord.test.ts | 26 +- tests/commands/deployConflicts.test.ts | 225 ++++++++++ tests/commands/deployDirectModeTiming.test.ts | 9 + .../deployDokploySystemStoreToken.test.ts | 26 +- .../commands/deployDokployWarningOnce.test.ts | 18 +- tests/commands/deployRevokeWiring.test.ts | 47 +- tests/commands/deployTargetConflict.test.ts | 210 +++++++++ tests/commands/rotateConflicts.test.ts | 107 +++++ tests/commands/secretsDeployConflicts.test.ts | 198 +++++++++ tests/deploy/batchDeploy.test.ts | 22 +- tests/deploy/batchDeployPicked.test.ts | 80 ++++ tests/deploy/dokployTargetConflict.test.ts | 302 +++++++++++++ tests/helpers/deployServer.ts | 34 ++ tests/run-tests.sh | 2 + tests/sync/conflicts.test.ts | 203 +++++++++ 47 files changed, 3692 insertions(+), 269 deletions(-) create mode 100644 src/commands/conflictPrompt.ts create mode 100644 src/commands/deployConflicts.ts create mode 100644 src/sync/conflicts.ts create mode 100644 tests/commands/conflictProtocol.test.ts create mode 100644 tests/commands/deployConflicts.test.ts create mode 100644 tests/commands/deployTargetConflict.test.ts create mode 100644 tests/commands/rotateConflicts.test.ts create mode 100644 tests/commands/secretsDeployConflicts.test.ts create mode 100644 tests/deploy/batchDeployPicked.test.ts create mode 100644 tests/deploy/dokployTargetConflict.test.ts create mode 100644 tests/helpers/deployServer.ts create mode 100644 tests/sync/conflicts.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 812ea3ea..be94037e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -87,13 +89,17 @@ JSON support: yes (`--json`) ## `capy push` -Push encrypted values to Keep +Push your changes to Keep: edited, new and deleted variables ``` -capy push +capy push [options] ``` -_No options._ +| Option | Description | Default | +|---|---|---| +| `--non-tty` | treat stdin as not a terminal; a conflict stops the push with the conflict_id (agents/CI) | | +| `--resolve ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -123,6 +129,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -551,6 +559,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -571,6 +581,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -653,6 +665,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -674,6 +688,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -705,6 +721,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: yes (`--json`) @@ -726,6 +744,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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--conflict ` | the conflict_id a conflict returned: binds --resolve to what was seen | | JSON support: no @@ -881,6 +901,10 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `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` @@ -932,3 +956,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**: A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all. diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index fcfd3513..d52253bc 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -2,6 +2,7 @@ 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 { recordsForWrite, refuseBadPrFlags, @@ -26,6 +27,8 @@ 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 decide a conflict of a piped value. */ + conflictFlags?: RawConflictFlags; } const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -137,6 +140,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. + const conflicts = conflictFlagsOrExit(opts.conflictFlags ?? {}, json); const piped = await readPipedValue(process.stdin, MAX_PIPED_BYTES); if (!piped.ok) return refusePiped(json, piped.code, piped.error); @@ -148,6 +153,7 @@ export class AddCommand { command: 'add', pr: opts.pr, dryRun: opts.dryRun === true, + conflicts, gate: (ctx) => variableExists(ctx, name) && opts.force !== true ? { diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index dec68a4f..24f7c518 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -45,7 +45,9 @@ import { isMembershipRevokedError } from '../errors/membershipRevoked'; import { cleanupOrgData } from '../cleanup/orgCleanup'; import { formatSnippet } from './statusCommand'; import { compareSecrets, hashValue } from '../sync/compareSecrets'; -import { commitLocalValues, envBlobOf, keepForEnv, pinnedHashes, snapshotFrom, type Snapshot } from '../sync/commit'; +import { commitLocalValues, envBlobOf, findConflicts, keepForEnv, localChanges, pinnedHashes, snapshotFrom, type Snapshot } from '../sync/commit'; +import { conflictFlagsOrExit, conflictRefusalFor, exitOnConflictRefusal, protocolDecider, type ConflictView } from '../sync/conflicts'; +import { isInteractive } from '../ui/interactive'; import { ACCENT } from '../ui/colors'; import { installSyncHooks } from '../git/syncHooks'; import { initProjectQuestion } from '../ui/projectQuestions'; @@ -146,6 +148,8 @@ export class CapyCommand { } 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(); @@ -185,6 +189,8 @@ 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); } @@ -1151,12 +1157,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: off a terminal stdout stays free for the conflict payload (CAP-717). + if (isInteractive()) { + this.displayHeader( + projectState.projectName || 'local project', + 'local (this machine only)', + 'local', + localBranch, + ); + } return { authResult: { success: true, user_id: LOCAL_USER_ID }, branch: localBranch }; } @@ -1226,12 +1235,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 (isInteractive()) { + this.displayHeader( + projectState.projectName || 'not yet created', + orgName, + result.user_first_name || result.user_email || '', + resolvedBranch, + ); + } const token = this.authService.getToken(); if (!token) { @@ -1520,6 +1531,43 @@ 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: {}, + }; + + // Off a terminal nobody can answer the menu (CAP-717). When both sides changed a name the protocol decides it: + // the local changes are committed on top of the server's latest, and each conflicting name needs --resolve + // (exit 3 with the payload otherwise). Without a conflict nothing changes: the menu below is what it was. + const protocolCommit = !isInteractive() && !localMode && networkAvailable && !isOnboarding && seenSnapshot !== undefined && + findConflicts({ pinned, latest: seenSnapshot.entries, changes: localChanges(localPlaintext, pinned, { removals: true }) }).length > 0; + if (protocolCommit) { + const flags = conflictFlagsOrExit(this.options.conflictFlags ?? {}, false); + const { changed } = await commitLocalValues( + { + pm: this.projectManager, + fileManager: this.fileManager, + serviceClient: this.serviceClient, + orgId: projectState.organizationId!, + projectId: projectState.projectId!, + branch, + userId: authResult.user_id!, + projectKey: encryptionKey, + keep, + localPlaintext, + }, + { pinned, remoteHashes, seen: seenSnapshot }, + protocolDecider(flags), + ); + console.error(`Pushed ${changed} change(s) to Keep.`); // COPY-FLAG + this.installGitHooks(); + return; + } + const DIM = '\x1b[90m'; const RST = '\x1b[0m'; @@ -1675,7 +1723,20 @@ export class CapyCommand { return { kind: 'abort' }; } // 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, + { + project: keep.project_name, + branch, + serverKeepHash: seenSnapshot?.keep === undefined ? '' : SyncEngine.computeKeepHash(seenSnapshot.keep, branch), + }, + ); if (!resolved) return { kind: 'abort' }; // Cancelled return { kind: 'proceed', finalEnv: resolved }; })(); @@ -1683,14 +1744,6 @@ export class CapyCommand { if (finalEnvDecision.kind === 'abort') return; const { finalEnv } = finalEnvDecision; - const keep = currentKeep || { - version: '3.0', - org_id: projectState.organizationId!, - project_id: projectState.projectId!, - project_name: projectState.projectName!, - variables: {}, - }; - // Commit + push are coupled: choosing "commit local" pushes too — except // in local-only mode, where there is no remote and the local writes ARE the commit. if (action === 'commit_local') { @@ -1857,6 +1910,7 @@ export class CapyCommand { 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; @@ -1909,12 +1963,25 @@ 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: diffs.map((d) => ({ + kind: 'source' as const, + name: d.variable, + project: view.project, + branch: view.branch, + pinned: d.pinned ?? null, + yours: d.local ?? null, + theirs: d.remote ?? null, + })), + }, + ], ); } diff --git a/src/commands/conflictPrompt.ts b/src/commands/conflictPrompt.ts new file mode 100644 index 00000000..0e8bd61b --- /dev/null +++ b/src/commands/conflictPrompt.ts @@ -0,0 +1,35 @@ +/** + * The terminal's answer to a conflict (CAP-717): one question per conflicting name, mine or theirs. + * Off a terminal nothing here runs: `--resolve` / `--conflict` decide (sync/conflicts.ts). Never shows a + * value, only the name and, for a deploy target, where. + */ +import { resolveKeyOf, type Conflict, type Resolution, type Resolutions } from '../sync/conflicts'; + +// COPY-FLAG: minimal, neutral wording; pending approval. +function questionFor(conflict: Conflict): string { + return conflict.kind === 'target' + ? `${conflict.name} on ${conflict.target} was changed outside Capy. Which value?` + : `${conflict.name} was changed in Capy since you last synced. Which value?`; +} + +/** Asks about each conflict in turn (one prompt, then the rest) and returns the answers by decision key. */ +export async function askConflicts(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: 'Mine', value: 'mine' }, // COPY-FLAG + { name: 'Theirs', value: 'theirs' }, // COPY-FLAG + ], + }, + ])) as { choice: Resolution }; + return ask(later, { ...answered, [resolveKeyOf(conflict)]: choice }); + }; + return ask(conflicts, {}); +} diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index b92e5d43..861b63a4 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -3,6 +3,7 @@ import { listProviders, loadProvider, ConnectOpts, ConnectorModule } from './con import { EXIT_NEEDS_INPUT, isInteractive } from '../ui/interactive'; import { resolveOrgContext } from '../core/orgContext'; import { isSecretsConflict } from '../sync/commit'; +import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags } from '../sync/conflicts'; import type { DiscoveryContext } from './connectors/dokployDiscovery'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -99,7 +100,10 @@ export class ConnectCommand { return await this.executeDiscovery(mod, provider, discoveryCtx, discoveryOpts); } - const ctx = await resolveContext({ devMode: this.devMode, dryRun: effective.dryRun === true }); + // Off a terminal (or given --resolve), a conflict follows the protocol: nothing is written, exit 3, the payload on stdout. + const flags = conflictFlagsOrExit(effective.conflictFlags ?? {}, effective.json === true); + const protocol = effective.json === true || !isInteractive(effective.nonTty) || hasConflictFlags(flags); + const ctx = await resolveContext({ devMode: this.devMode, dryRun: effective.dryRun === true, ...(protocol ? { conflicts: flags } : {}) }); // Import-kind connectors (CAP-662: `dokploy`) pull MANY variables in one // run rather than linking one existing one, so they skip the var-picking @@ -219,7 +223,10 @@ export class ConnectCommand { // A name someone else changed on the server since this folder's pin is a refusal naming it (nothing was uploaded). const written = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }).then( (result) => ({ conflict: undefined, wrote: result.wrote }), - (err: unknown) => (isSecretsConflict(err) ? { conflict: err, wrote: false } : Promise.reject(err)), + (err: unknown) => { + exitOnConflictRefusal(err); + return isSecretsConflict(err) ? { conflict: err, wrote: false } : Promise.reject(err); + }, ); if (written.conflict) { if (opts.json) { 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 c7ba27bc..a478065c 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -2,7 +2,8 @@ import { ProjectManager } from '../../core/projectManager'; import { FileManager } from '../../files/fileManager'; import { AuthService, silentAuthFailureMessage } from '../../auth/authService'; import { ServiceClient } from '../../service/serviceClient'; -import { commitFolder, readLocalPlaintext } from '../../sync/commit'; +import { commitFolder, readLocalPlaintext, type Resolutions } from '../../sync/commit'; +import { protocolDecider, type ConflictDecider, type ConflictFlags } from '../../sync/conflicts'; import { KeepFile, ConnectorMetadata, CapyError, ERROR_CODES, AuthResult } from '../../types/index'; import type { ImportOutcome } from './registry'; import { reportRepoLinkForCommand, type RepoLinkWarning } from '../../core/repoLinkReporter'; @@ -23,6 +24,12 @@ 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 a write settles a conflict (CAP-717): set by a command that follows the non-TTY protocol, so + * every save through this context refuses with the payload (or applies `--resolve`). Absent: + * a conflict is a plain `SECRETS_CONFLICT`. + */ + decide?: ConflictDecider; } /** @@ -67,6 +74,8 @@ export async function resolveContext( json?: boolean; /** The caller is a dry run: the repo link is not reported. */ dryRun?: boolean; + /** The caller follows the conflict protocol (off a terminal, or given `--resolve`): its flags decide every conflict. */ + conflicts?: ConflictFlags; } = {}, ): Promise { const refuse = opts.refuse ?? exitWithMessage; @@ -136,6 +145,7 @@ export async function resolveContext( keep, localPlaintext: readLocalPlaintext(fileManager, projectKey), repoLinkWarnings: repoLink.warnings, + ...(opts.conflicts === undefined ? {} : { decide: protocolDecider(opts.conflicts) }), }; } @@ -205,6 +215,8 @@ export async function writeAndSync( connector?: ConnectorMetadata; /** Additional (varName, connector) pairs to mark managed in the same write. */ alsoConnect?: ReadonlyArray<{ varName: string; entry: ConnectorMetadata }>; + /** Decisions already made for names the server changed (e.g. settled before a rotation issued its key). */ + resolutions?: Resolutions; }, ): Promise { const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; @@ -221,7 +233,7 @@ export async function writeAndSync( return; } - await commitFolder(ctx, { set: value === undefined ? {} : { [varName]: value }, remove: [] }, { transformKeep: attach }); + await commitFolder(ctx, { set: value === undefined ? {} : { [varName]: value }, remove: [] }, { transformKeep: attach, resolutions: opts.resolutions }); } /** diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 839c105e..2107687f 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -59,6 +59,7 @@ import { VarDelivery, } from '../deploy/targetsGate'; import { KeepFile, ERROR_CODES, AuthResult, ErrorCode } from '../types/index'; +import { adoptedKeepFor, commitToServer } from '../sync/commit'; import type { AuthService } from '../auth/authService'; import type { ServiceClient } from '../service/serviceClient'; import { ProjectManager } from '../core/projectManager'; @@ -87,6 +88,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 +149,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 +413,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 one commit path (sync/commit.ts): `commitToServer` 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 + * (`adoptedKeepFor`): 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,19 +462,27 @@ 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, undefined, fresh.latest.keep_hash); + const { resolveProjectKey } = await import('../crypto/keyResolver'); + const projectKey = await resolveProjectKey(fresh.orgId, fresh.projectId, fresh.userId, { + coDecrypt: (o, c) => fresh.serviceClient.coDecrypt(o, c).then((r) => r.plaintext), + wrapOuterLayer: (o, p) => fresh.serviceClient.wrapOuterLayer(o, p).then((r) => r.ciphertext), + }); + const committed = await commitToServer({ + client: fresh.serviceClient, + projectId: fresh.projectId, + branch, + projectKey, + startKeep: keep, + changes: { set: {}, remove: [] }, + transformKeep: transform, + }); - if (writeLocal) { - const { SyncEngine } = await import('../sync/syncEngine'); - const fm = new FileManager(cwd); - fm.writeKeepFile(SyncEngine.adoptServerKeep(pushed.keep_file, nextKeep, branch)); - } + if (writeLocal) new FileManager(cwd).writeKeepFile(adoptedKeepFor(keep, committed, branch)); return { ok: true }; } catch (err: any) { console.error(` ${YELLOW('!')} could not ${label} in keep.lock: ${codePrefix}${err?.message ?? err}`); @@ -526,7 +519,9 @@ async function resolveFreshSnapshot( ): Promise<{ keep: KeepFile; latest: { env_file: string; keep_hash: string; keep_file?: string }; + orgId: string; projectId: string; + userId: string; serviceClient: ServiceClient; } | null> { const codePrefix = warnCode ? `${warnCode}: ` : ''; @@ -570,7 +565,7 @@ async function resolveFreshSnapshot( ); return null; } - return { keep, latest, projectId: projectState.projectId, serviceClient }; + return { keep, latest, orgId: projectState.organizationId, projectId: projectState.projectId, userId: authResult.user_id, serviceClient }; } catch (err: any) { console.error(` ${YELLOW('!')} could not ${label}: ${codePrefix}${err?.message ?? err}`); return null; @@ -1567,18 +1562,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(''); } /** @@ -2477,6 +2472,9 @@ export async function deployCommand( return 0; } + // A bad --resolve / --conflict is refused before anything is read. + const conflictFlags = conflictFlagsOrExit(options.conflictFlags ?? {}, false); + const keep = readKeep(cwd); if (!keep) { console.error( @@ -2527,7 +2525,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 +2579,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 +2603,47 @@ export async function deployCommand( const msg = `chore(deploy): ${target.name} → ${target.branch} (${target.kind})`; const baseBranch = target.gitBaseBranch ?? 'main'; + // ── Conflicts (CAP-717) — before the gate, a mint, a decrypt or any write. The values about to be delivered + // are compared with Capy's latest (source) and, on Dokploy, with the service's live env (target); every + // conflict is answered (a question per variable in a terminal, --resolve off one) and the folder takes the + // values that will be delivered. 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, + 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 (`theirs`). + 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 +2651,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 +2679,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 +2687,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 +2752,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 +2782,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..2c7d56a8 --- /dev/null +++ b/src/commands/deployConflicts.ts @@ -0,0 +1,201 @@ +/** + * Conflicts for `capy deploy` (CAP-717): before a delivery writes anything, the values it is about to deliver + * are compared with the two other places that hold them. + * + * SOURCE (folder against the Capy server). The folder's `.env` may lag Capy (a teammate saved since this folder's + * pin) or be ahead of it. The same planner every other write uses (`sync/commit.ts`) decides per variable: + * - only the server moved (folder unchanged since its pin) → deliver the server's latest, never the stale value; + * - only the folder changed → delivered as it always was; it stays an edit of the folder (not committed here); + * - both changed, differently → a conflict: mine commits the folder's value (through the commit path) and + * delivers it, theirs delivers Capy's latest and the folder takes it like a pull. + * + * TARGET (Capy against the platform, Dokploy only: `adapter.readsLiveValues`). A value changed on the platform + * outside Capy since Capy last delivered it is a conflict: mine overwrites it, theirs skips that variable. + * + * One decision step for both kinds: in a terminal one question per variable; off a terminal the protocol + * (`sync/conflicts.ts`: nothing is written, exit 3, the payload on stdout, `--resolve` + `--conflict` answer it). + * This module writes nothing until every conflict has an answer. Names and fingerprints only, never a value. + */ +import { ProjectManager } from '../core/projectManager'; +import { FileManager } from '../files/fileManager'; +import type { ServiceClient } from '../service/serviceClient'; +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 { + commitFolder, + findConflicts, + pinnedHashes, + readLocalPlaintext, + readSnapshot, + sourceConflictsOf, + type Changes, + type FolderContext, + type Snapshot, +} from '../sync/commit'; +import { hashValue } from '../sync/compareSecrets'; +import { SyncEngine } from '../sync/syncEngine'; +import { + conflictRefusalFor, + gateConflicts, + protocolDecider, + sourceResolutions, + targetResolutions, + type Conflict, + type ConflictFlags, + type ConflictView, + type Resolutions, +} from '../sync/conflicts'; +import { askConflicts } from './conflictPrompt'; + +export interface ReconcileInput { + readonly cwd: string; + readonly target: TargetConfig; + readonly adapter: DeployAdapter; + readonly devMode?: boolean; + /** `--dry-run`: report the conflicts (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 (`theirs`): not delivered, not recorded. */ + readonly skipped: readonly string[]; + /** Hand to the adapter: what Capy last delivered, and the decisions. Absent: the adapter does not compare. */ + readonly targetCheck?: TargetCheck; + /** Where the conflicts 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: [] }; + +/** The folder, signed in without a prompt, with its key: what a commit needs. `undefined` when this is not a Capy project folder. */ +async function openFolder(cwd: string, branch: string, devMode: boolean | undefined): Promise<(FolderContext & { serviceClient: ServiceClient }) | 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 fileManager = new FileManager(cwd); + return { + pm, + fileManager, + serviceClient, + orgId: state.organizationId, + projectId: state.projectId, + branch, + userId: signedIn.user_id, + projectKey, + keep, + localPlaintext: readLocalPlaintext(fileManager, projectKey), + }; +} + +/** The server's latest for the branch; a branch the server has nothing for yet is an empty snapshot. */ +async function readLatest(folder: FolderContext & { serviceClient: ServiceClient }): Promise { + try { + return await readSnapshot(folder.serviceClient, folder.projectId, folder.branch, folder.projectKey); + } catch (err) { + if (err instanceof CapyError && err.details?.status === 404) return { keep: undefined, entries: {} }; + throw err; + } +} + +/** + * Settles every conflict `capy deploy ` would meet, and brings the folder in line with the values that + * will be delivered. Throws a `ConflictRefusal` off a terminal when the flags do not decide every conflict + * (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. + if (adapter.needsDeployToken || 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 snapshot = await readLatest(folder); + const view: ConflictView = { + project: folder.keep.project_name, + branch: target.branch, + serverKeepHash: snapshot.keep === undefined ? '' : SyncEngine.computeKeepHash(snapshot.keep, target.branch), + }; + + // The folder's own changes to the variables being delivered, and what the server says about them. + const pinned = pinnedHashes(folder.keep, target.branch); + const edited = target.vars.filter((v) => folder.localPlaintext[v] !== undefined && hashValue(folder.localPlaintext[v]) !== pinned[v]); + const changes: Changes = { set: Object.fromEntries(edited.map((v) => [v, folder.localPlaintext[v]])), remove: [] }; + const source = sourceConflictsOf(findConflicts({ pinned, latest: snapshot.entries, changes }), snapshot, view); + + // What is delivered, if every conflict is answered `mine`: the folder's edit, else Capy's latest, else the folder's value. + const valueFor = (name: string): string | undefined => + Object.hasOwn(changes.set, name) ? changes.set[name] : (snapshot.entries[name]?.plaintext ?? folder.localPlaintext[name]); + const env = Object.fromEntries(target.vars.flatMap((v) => (valueFor(v) === undefined ? [] : [[v, valueFor(v) as string] as const]))); + + const targetCheck: TargetCheck | undefined = adapter.readsLiveValues + ? { pinned: deployedHashesFor(folder.keep, target.branch, adapter.id, target.name), resolutions: {} } + : undefined; + const live = targetCheck === undefined ? undefined : await adapter.deploy(target, { ...input.adapterCtx, env, dryRun: true, cwd: input.cwd, targetCheck }); + if (live !== undefined && !live.ok) { + input.showFailure(live); + return { ok: false, code: 1 }; + } + const conflicts: readonly Conflict[] = [...source, ...(live?.conflicts ?? [])]; + const scopes = [{ ...view, conflicts }]; + + if (input.dryRun) { + if (conflicts.length > 0) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }); + return { ok: true, skipped: [], ...(targetCheck === undefined ? {} : { targetCheck, view }) }; + } + + // One decision step for both kinds. + const decisions: Resolutions = conflicts.length === 0 ? {} : input.interactive ? await askConflicts(conflicts) : gateConflicts(input.flags, scopes); + + // The folder takes what will be delivered. A variable the folder did not change is never delivered stale: Capy's + // latest is pulled in. A conflict is committed with its answer (mine writes the folder's value to Capy, theirs + // takes Capy's). A folder edit nobody else touched is not committed here: it is delivered as it always was and + // stays an edit of the folder, for the next `capy`/`capy push`. + const settled = source.map((c) => c.name); + const behind = target.vars.some( + (v) => !edited.includes(v) && snapshot.entries[v]?.plaintext !== undefined && snapshot.entries[v].plaintext !== folder.localPlaintext[v], + ); + if (settled.length > 0 || behind) { + await commitFolder( + { ...folder, ...(input.interactive ? {} : { decide: protocolDecider(input.flags) }) }, + { set: Object.fromEntries(settled.map((name) => [name, folder.localPlaintext[name]])), remove: [] }, + { resolutions: sourceResolutions(decisions), seen: snapshot }, + ); + } + + const platform = targetResolutions(decisions, target.name); + return { + ok: true, + skipped: Object.entries(platform).flatMap(([name, choice]) => (choice === 'theirs' ? [name] : [])), + ...(targetCheck === undefined ? {} : { targetCheck: { ...targetCheck, resolutions: platform }, view }), + }; +} diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 9fa52135..202708f7 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -13,6 +13,7 @@ import { readDefaultBranchTargets } from '../deploy/configuredTargets'; import { formatRelativeTime } from '../ui/relativeTime'; import { KeepFile } from '../types/index'; import { commitFolder, pinnedHashes, readLocalPlaintext, type FolderContext } from '../sync/commit'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; import { reportKeepLockHuman, refuseBadPrFlags, runKeepLockPrStep, type PrFlags } from './keepLockPr'; import { startSaveLog } from './sessionSaveLog'; import { decideEditMode, editPipedCommand, refuseEditNeedsTty } from './editPiped'; @@ -185,6 +186,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 decide a conflict of a piped value. The TUI shows conflicts on its own screen (CAP-714). */ + conflictFlags?: RawConflictFlags; } export class EditCommand { @@ -267,6 +270,8 @@ export class EditCommand { devMode: this.devMode, pr: opts.pr, dryRun: opts.dryRun === true, + // A bad flag is refused here, before stdin is read or anything is authenticated. + conflicts: conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === true), }); } diff --git a/src/commands/editPiped.ts b/src/commands/editPiped.ts index 5fd9fe2a..8d53b9e3 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 { ConflictFlags } from '../sync/conflicts'; 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; + /** `--resolve` / `--conflict`, already validated. */ + readonly conflicts?: ConflictFlags; } /** ` | capy edit NAME`: read stdin, set the variable, report. Never prompts. */ diff --git a/src/commands/pipedWrite.ts b/src/commands/pipedWrite.ts index 5da51532..8ea3df4c 100644 --- a/src/commands/pipedWrite.ts +++ b/src/commands/pipedWrite.ts @@ -16,6 +16,8 @@ import { CapyError, ERROR_CODES } from '../types/index'; import { hashValue } from './statusCommand'; import { resolveContext, writeAndSync, type ResolvedContext } from './connectors/shared'; +import { checkFolder } from '../sync/commit'; +import { NO_CONFLICT_FLAGS, exitOnConflictRefusal, previewDecider, type ConflictFlags } from '../sync/conflicts'; import { refusePiped, reportPipedDryRun, reportPipedSuccess, type PipedAction } from './pipedValue'; import { repoLinkWarningsField } from '../core/repoLinkReporter'; import { @@ -74,6 +76,8 @@ export interface PipedWriteOpts { * existing name); `undefined` to go on. */ readonly gate?: (ctx: ResolvedContext) => { readonly code: string; readonly error: string } | undefined; + /** `--resolve` / `--conflict` (CAP-717): a piped write is never at a terminal, so a conflict stops it unless these decide it. */ + readonly conflicts?: ConflictFlags; } /** Turns anything thrown into a code and a sentence that cannot contain the value. */ @@ -92,10 +96,22 @@ async function writeOrFail( await writeAndSync(ctx, name, value, { push }); return undefined; } catch (err) { + // A conflict is not a failure: it is the protocol's answer (exit 3, the payload on stdout). + exitOnConflictRefusal(err); return describeFailure(err); } } +/** `--dry-run`: would this write meet a value someone else changed? Exits with the payload if so; writes nothing either way. */ +async function previewOrExit(ctx: ResolvedContext, name: string, value: string): Promise { + try { + await checkFolder(ctx, { set: { [name]: value }, remove: [] }, { decide: previewDecider }); + } catch (err) { + exitOnConflictRefusal(err); + throw err; + } +} + /** The `warnings` JSON field for the repo link, or nothing. */ function warningsExtra(ctx: ResolvedContext): object { return repoLinkWarningsField({ status: 'reported', warnings: ctx.repoLinkWarnings ?? [] }); @@ -109,6 +125,8 @@ export async function runPipedWrite(name: string, value: string, opts: PipedWrit interactive: false, json: opts.json, dryRun: opts.dryRun === true, + // A piped write is never at a terminal: a conflict stops it unless --resolve + --conflict decide it. + conflicts: opts.conflicts ?? NO_CONFLICT_FLAGS, refuse: (code, message) => refusePiped(opts.json, code, message), }); @@ -120,6 +138,7 @@ export async function runPipedWrite(name: string, value: string, opts: PipedWrit // Everything above only read. Nothing below this line runs: no write, no push, no PR. const pinned = ctx.keep.variables[name]?.find((e) => e.branch === ctx.branch)?.value_hash; const wouldChange = action !== 'unchanged' && opts.push && pinned !== hashValue(value); + if (wouldChange) await previewOrExit(ctx, name, value); const preview = await previewKeepLockPrStep({ changed: wouldChange, cwd: process.cwd(), flags: opts.pr ?? {} }); return reportPipedDryRun(opts.json, { name, branch: ctx.branch, action }, preview); } diff --git a/src/commands/pushCommand.ts b/src/commands/pushCommand.ts index f050719c..2d3719e5 100644 --- a/src/commands/pushCommand.ts +++ b/src/commands/pushCommand.ts @@ -11,9 +11,18 @@ import { LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { reportRepoLinkForCommand } from '../core/repoLinkReporter'; +import { isInteractive } from '../ui/interactive'; +import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags, protocolDecider, type RawConflictFlags } from '../sync/conflicts'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; +export interface PushOpts { + /** Treat stdin as not a terminal even when it is one: a conflict then follows the protocol. */ + nonTty?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717). */ + conflictFlags?: RawConflictFlags; +} + export class PushCommand { private projectManager: ProjectManager; private fileManager: FileManager; @@ -50,17 +59,23 @@ export class PushCommand { } } - async execute(): Promise { + async execute(opts: PushOpts = {}): Promise { try { - await this._execute(); + await this._execute(opts); } catch (error: any) { this.debugError('push execute caught', error); + // A conflict is not a failure: it is the protocol's answer (exit 3, the payload on stdout). + exitOnConflictRefusal(error); const { displayErrorAndExit } = await import('../ui/errorScreen'); await displayErrorAndExit(error); } } - private async _execute(): Promise { + private async _execute(opts: PushOpts): Promise { + // A bad --resolve / --conflict is refused before anything is read or authenticated. + const flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, false); + // Off a terminal (or given --resolve) a conflict stops the push with the payload; at a terminal it is the plain error. + const protocol = !isInteractive(opts.nonTty) || hasConflictFlags(flags); this.debug('starting push command'); this.debug('cwd', process.cwd()); @@ -170,13 +185,15 @@ export class PushCommand { } // What this folder has to commit is what it CHANGED: a value that differs from the one - // keep.lock pins (an edit, or a new name). Values it merely still holds from an older - // sync are not pushed, so a stale folder cannot revert what someone else saved since. + // keep.lock pins (an edit, or a new name) and a pinned name it no longer has (a deletion; kept + // out, `.env` would get it back from the server). Values it merely still holds from an older + // sync are not pushed, so a stale folder cannot revert what someone else saved since. Every + // change, deletions included, is conflict-checked against what the server holds now. const localPlaintext = Object.fromEntries( Object.entries(rawLocal).map(([key, value]) => [key, value.startsWith('capy:') ? this.fileManager.decryptValue(value, encryptionKey) : value]), ); - const changes = localChanges(localPlaintext, pinnedHashes(keep, branch), { removals: false }); - this.debug('local edits', Object.keys(changes.set)); + const changes = localChanges(localPlaintext, pinnedHashes(keep, branch), { removals: true }); + this.debug('local edits', { set: Object.keys(changes.set), removed: changes.remove }); const committed = await commitFolder( { @@ -190,12 +207,13 @@ export class PushCommand { projectKey: encryptionKey, keep, localPlaintext, + ...(protocol ? { decide: protocolDecider(flags) } : {}), }, changes, ); this.debug('push complete', { localMode, uploaded: committed.uploaded, written: committed.written }); - const count = Object.keys(changes.set).length; + const count = Object.keys(changes.set).length + changes.remove.length; pushSpinner.succeed( localMode ? `Stored ${count} secret(s) locally (local-only mode)` diff --git a/src/commands/removeCommand.ts b/src/commands/removeCommand.ts index f0b4718c..e0058e65 100644 --- a/src/commands/removeCommand.ts +++ b/src/commands/removeCommand.ts @@ -19,7 +19,8 @@ import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { DRY_RUN_UNSUPPORTED_MESSAGES } from './pipedValue'; import { listTargets } from '../deploy/config'; import { CapyError, ERROR_CODES } from '../types/index'; -import { isSecretsConflict } from '../sync/commit'; +import { checkFolder, isSecretsConflict } from '../sync/commit'; +import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags, previewDecider, type RawConflictFlags } from '../sync/conflicts'; 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 decide a conflict on a name being removed. */ + conflictFlags?: RawConflictFlags; } /** Pure JSON refusal on stdout — never on stderr, so `--json` output stays parseable. */ @@ -104,6 +107,11 @@ export async function proceedWithRemoval( const warnings = deployTargetWarnings(opts.cwd, names); if (opts.dryRun === true) { + // A name someone else changed since this folder's pin is reported with its conflict_id; nothing is written either way. + await checkFolder(ctx, { set: {}, remove: names }, { decide: previewDecider }).catch((err: unknown) => { + exitOnConflictRefusal(err); + return Promise.reject(err); + }); // Everything above only read. Nothing below this line runs: no removal, no push, no PR. const preview = await previewKeepLockPrStep({ changed: true, cwd: opts.cwd, flags: opts.pr ?? {} }); if (opts.json) { @@ -129,8 +137,12 @@ export async function proceedWithRemoval( return; } - // A name someone else changed on the server since this folder's pin is a refusal, not a silent overwrite. - await removeAndSync(ctx, names).catch((err: unknown) => (isSecretsConflict(err) ? refuse(opts.json, err) : Promise.reject(err))); + // A name someone else changed on the server since this folder's pin is a refusal, not a silent overwrite: + // the protocol's payload when the command follows it (exit 3), the plain refusal otherwise. + await removeAndSync(ctx, names).catch((err: unknown) => { + exitOnConflictRefusal(err); + return isSecretsConflict(err) ? refuse(opts.json, err) : Promise.reject(err); + }); // The removal is done whatever the PR step reports (it never throws). const outcome = await runKeepLockPrStep({ @@ -178,6 +190,7 @@ export class RemoveCommand { const json = opts.json === true; // Contradictory PR flags are refused before anything is changed. refuseBadPrFlags(opts.pr ?? {}, json); + const flags = 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)); @@ -234,7 +247,9 @@ export class RemoveCommand { // Everything above is local-only — no auth, no network. Only now do we // authenticate and decrypt `.env`, which the push needs. - const ctx = await resolveContext({ devMode: this.devMode, json, dryRun: opts.dryRun === true }); + // Off a terminal (or given --resolve), a conflict follows the protocol: nothing is removed, exit 3, the payload on stdout. + const protocol = json || !isInteractive(opts.nonTty) || hasConflictFlags(flags); + const ctx = await resolveContext({ devMode: this.devMode, json, dryRun: opts.dryRun === true, ...(protocol ? { conflicts: flags } : {}) }); 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..88dba343 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 { checkFolder, type Resolutions } from '../sync/commit'; +import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags } from '../sync/conflicts'; import type { RotatePlanStop } from '../ui/screens/contract'; import { writeSync } from 'fs'; @@ -154,6 +156,8 @@ export class RotateCommand { 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); const pm = new ProjectManager(); const keep = pm.readKeepFile(); const branch = pm.deriveActiveBranch(); @@ -464,9 +468,15 @@ export class RotateCommand { return []; } - const ctx = await resolveContext({ devMode: this.devMode }); + // Off a terminal (or given --resolve), a conflict follows the protocol. It is settled for every variable + // BEFORE the first new key is issued: a key the provider hands out once must not be lost to a refused write. + const flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, false); + const protocol = !isInteractive(opts.nonTty) || hasConflictFlags(flags); + const ctx = await resolveContext({ devMode: this.devMode, ...(protocol ? { conflicts: flags } : {}) }); + const settled = await this.settleConflicts(ctx, toRotate, opts); + if (settled.rotate.length === 0) return []; - const result = await this.rotateSequentially(toRotate, ctx, opts, { succeeded: [], failed: [] }); + const result = await this.rotateSequentially(settled.rotate, ctx, opts, { succeeded: [], failed: [] }, settled.resolutions); if (opts.all && (result.succeeded.length > 0 || result.failed.length > 0)) { console.log(''); @@ -482,6 +492,25 @@ export class RotateCommand { return [...result.succeeded]; } + /** + * With a deciding context (the conflict protocol) and a push: the variables whose server value moved since + * this folder's pin are decided now (exit 3 with the payload when the flags do not decide them). `theirs` drops + * the variable from the run: nothing is rotated that would not be written. Without one, nothing changes. + */ + private async settleConflicts( + ctx: ResolvedContext, + toRotate: readonly { varName: string; connector: ConnectorMetadata }[], + opts: RotateOpts, + ): Promise<{ rotate: readonly { varName: string; connector: ConnectorMetadata }[]; resolutions: Resolutions }> { + if (ctx.decide === undefined || opts.noPush) return { rotate: toRotate, resolutions: {} }; + // A rotation replaces the value whatever it was: for the check it is a change to the name, its new value not yet known. + const checked = await checkFolder(ctx, { set: {}, remove: toRotate.map((t) => t.varName) }).catch((err: unknown) => { + exitOnConflictRefusal(err); + return Promise.reject(err); + }); + return { rotate: toRotate.filter((t) => checked.resolutions[t.varName] !== 'theirs'), resolutions: checked.resolutions }; + } + /** * The dev-mode live-key firewall, applied once before any rotation starts: * `capy-dev` refuses live keys outright — `--all` skips them and carries @@ -536,6 +565,7 @@ export class RotateCommand { ctx: ResolvedContext, opts: RotateOpts & { all?: boolean }, state: RotateLoopState, + resolutions: Resolutions = {}, ): Promise { if (items.length === 0) return state; const [{ varName: name, connector }, ...rest] = items; @@ -559,7 +589,7 @@ export class RotateCommand { ...state, failed: [...state.failed, { name, err: new Error('confirmation declined') }], }; - if (opts.all) return this.rotateSequentially(rest, ctx, opts, declinedState); + if (opts.all) return this.rotateSequentially(rest, ctx, opts, declinedState, resolutions); process.exit(1); } } @@ -570,7 +600,7 @@ export class RotateCommand { }); const freshCtx = await resolveContext({ devMode: this.devMode }); - await writeAndSync(freshCtx, name, value, { push: !opts.noPush, connector: updated }); + await writeAndSync(freshCtx, name, value, { push: !opts.noPush, connector: updated, resolutions }); const succeededState: RotateLoopState = { ...state, @@ -584,8 +614,9 @@ export class RotateCommand { ); } console.log(''); - return this.rotateSequentially(rest, ctx, opts, succeededState); + return this.rotateSequentially(rest, ctx, opts, succeededState, resolutions); } catch (err) { + exitOnConflictRefusal(err); const failState: RotateLoopState = { ...state, failed: [...state.failed, { name, err }], @@ -593,7 +624,7 @@ export class RotateCommand { console.error(''); console.error(` ✗ Failed to rotate ${B(name)}: ${(err as Error).message}`); console.error(''); - if (opts.all) return this.rotateSequentially(rest, ctx, opts, failState); + if (opts.all) return this.rotateSequentially(rest, ctx, opts, failState, resolutions); process.exit(1); } } 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/secretsSet.ts b/src/commands/secretsSet.ts index 8ef311b5..abe06c3d 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -92,6 +92,14 @@ export interface SetRequest { readonly repos: readonly RepoTarget[]; /** Each repo's PR base, by `repoKey`. Absent: the repo's default branch. */ readonly bases?: Readonly>; + /** + * The fingerprints a location's current value may have for this push to go on (CAP-717): what the caller + * saw and decided against. A location holding anything else was changed by someone else since, and is + * `failed` with `SECRETS_CONFLICT` (nothing pushed to it). Absent: the location is not checked. + */ + readonly allowed?: (location: SetLocation) => readonly string[]; + /** 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 { @@ -338,7 +346,15 @@ const failedItem = (location: SetLocation, err: unknown, rate?: RateInfo): PoolI * reading it saw. The push names the keep it was built from (`base_keep_hash`); if the server says someone saved * since the read (`SECRETS_STALE_BASE`), the location is read and replaced once more from the new latest. */ -async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, name: string, value: string, retried: boolean = false): Promise> { +async function pushLocation( + env: SetEnv, + keys: KeyResolver, + loc: SetLocation, + name: string, + value: string, + allowed?: (location: SetLocation) => readonly 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); @@ -346,6 +362,10 @@ async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, na 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); + // Someone else changed it since the caller looked (or again, since it decided): not overwritten. + if (allowed !== undefined && !allowed(loc).includes(hashValue(current.value.plaintext))) { + return failedItem(loc, new CapyError('changed since it was read', ERROR_CODES.SECRETS_CONFLICT), read.rate); + } const write = attempt(() => { const existing = current.value.serverKeep.variables[name]?.find((e) => e.branch === loc.branch); @@ -374,7 +394,7 @@ async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, na ), env.sleep, ); - if (!pushed.ok && !retried && isStaleBase(pushed.error)) return pushLocation(env, keys, loc, name, value, true); + if (!pushed.ok && !retried && isStaleBase(pushed.error)) return pushLocation(env, keys, loc, name, value, allowed, true); const rate = mergeRates(read.rate, pushed.rate, (env.now ?? Date.now)()); if (!pushed.ok) return failedItem(loc, pushed.error, rate); @@ -395,6 +415,8 @@ async function pushLocation(env: SetEnv, keys: KeyResolver, loc: SetLocation, na 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. */ + readonly current?: { readonly hash: string; readonly serverKeepHash: string; readonly changedAt?: string }; } const UNKNOWN_PREVIEW: LocationPreview = { action: 'update', serverKeep: undefined }; @@ -414,7 +436,17 @@ async function previewItem( 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)); return withRate( - current.ok ? { action: current.value.plaintext === value ? 'unchanged' : 'update', serverKeep: current.value.serverKeep } : UNKNOWN_PREVIEW, + current.ok + ? { + action: current.value.plaintext === value ? 'unchanged' : 'update', + serverKeep: current.value.serverKeep, + current: { + hash: hashValue(current.value.plaintext), + serverKeepHash: SyncEngine.computeKeepHash(current.value.serverKeep, loc.branch), + changedAt: current.value.serverKeep.variables[name]?.find((e) => e.branch === loc.branch)?.changed_at, + }, + } + : UNKNOWN_PREVIEW, read.rate, ); } @@ -435,8 +467,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, @@ -573,11 +607,11 @@ 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, req.allowed), stop: hooks.stopPushes, reserve: PACING_RESERVE, now: env.now, diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index 8ca6006f..dd1b8c7f 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -33,12 +33,24 @@ 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, + 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 { + LocationPreview, LocationRef, RepoTarget, SetEnv, @@ -74,6 +86,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 +147,24 @@ 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 { + console.log(JSON.stringify({ ...refusal.payload, ...extra }, null, 2)); + console.error(refusal.message); + console.error('Rerun with --resolve NAME=mine|theirs for each name and --conflict .'); // COPY-FLAG + return process.exit(EXIT_NEEDS_INPUT); +} + +/** The decisions 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 +175,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 saw (the pin of the conflict check). */ + readonly seen: string; } const withRowId = (row: SecretIndexRow): readonly Located[] => { @@ -153,9 +187,48 @@ const withRowId = (row: SecretIndexRow): readonly Located[] => { branch: l.branch, protected: l.protected, row_id, + seen: row.value_hash, })); }; +// ── Conflicts (CAP-717) ───────────────────────────────────────────────────── + +/** + * The locations where the value is no longer the one the row listed AND is not the value being set: someone + * else changed it since the caller looked. One scope per location (each is its own project and branch). + */ +export function secretConflictScopes( + name: string, + value: string, + located: readonly Located[], + previews: readonly LocationPreview[], +): readonly ConflictScope[] { + const yours = hashValue(value); + return located.flatMap((l, i): ConflictScope[] => { + const current = previews[i]?.current; + if (current === undefined || current.hash === yours || current.hash === l.seen) return []; + return [ + { + project: l.project_name, + branch: l.branch, + serverKeepHash: current.serverKeepHash, + conflicts: [ + { + kind: 'source', + name, + project: l.project_name, + branch: l.branch, + pinned: l.seen, + yours, + theirs: current.hash, + ...(current.changedAt === undefined ? {} : { theirs_changed_at: current.changedAt }), + }, + ], + }, + ]; + }); +} + export function candidatesOf(rows: readonly SecretIndexRow[]) { return rows.map((r) => ({ row_id: rowIdOf(r.name, r.value_hash), @@ -284,9 +357,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 +397,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. */ @@ -389,6 +468,7 @@ 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); @@ -404,7 +484,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 conflict with 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 +519,18 @@ 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 a decision. + 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 yours = hashValue(value); + const pushTo = located.filter((l) => decided[name] !== 'theirs' || !scopes.some((sc) => sc.project === l.project_name && sc.branch === l.branch)); + const allowed = (loc: SetLocation): readonly string[] => { + const at = located.findIndex((l) => l.project_id === loc.project_id && l.branch === loc.branch); + return [yours, located[at]?.seen ?? '', reads[at]?.current?.hash ?? '']; + }; + const result = await runSecretSet({ name, value, locations: pushTo, repos: targets, bases, allowed, 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. diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 406efc7c..0d61e4d6 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 write does when someone else changed the value it would replace (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: + 'A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. 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..217518af 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 (`theirs`): 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 3a349c1c..c18ee7b6 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 { targetResolutions, type ConflictScope, type Resolutions } 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: Resolutions }; + /** - * 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`): `theirs` 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: Resolutions = {}, +): 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 (`theirs`) 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, ); } @@ -675,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 @@ -688,10 +821,15 @@ 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 decisions: Resolutions = hooks.decide === undefined || found.length === 0 ? {} : hooks.decide(found); + 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/dokployApi.ts b/src/deploy/dokployApi.ts index 2f514e7e..76bfe65f 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 { Resolutions, TargetConflict } 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 (`theirs`), 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,81 @@ 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. */ + readonly yours: string; + /** What the service would see now. */ + readonly theirs: string; +} + +/** + * The delivered names whose live value on the platform was changed outside Capy. For each name: + * - theirs = 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); + * - yours = the value about to be delivered. + * A conflict is a live value that exists and differs from BOTH pinned and yours. 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 theirs = hashValue(live[name]); + const yours = hashValue(value); + return theirs === yours || theirs === pinned[name] ? [] : [{ name, pinned: pinned[name], yours, theirs }]; + }); +} + +/** 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>; + /** Decisions already made, by bare NAME. */ + readonly resolutions: Resolutions; +} + +export type TargetPlan = + | { + readonly kind: 'clear'; + /** The values to write: all of them minus the names kept as `theirs`. */ + readonly deliver: ReadonlyArray<{ name: string; value: string }>; + /** Names whose platform value is kept (`theirs`): 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 open = found.filter((c) => check.resolutions[c.name] === undefined); + if (open.length > 0) return { kind: 'conflict', found, open }; + const skipped = found.filter((c) => check.resolutions[c.name] === 'theirs').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) => ({ kind: 'target', name: c.name, target, pinned: c.pinned ?? null, yours: c.yours, theirs: c.theirs })); + /** * 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..7d9ef431 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -10,6 +10,7 @@ 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 { refuseWebMode } from './core/webModeRemoved'; import { ACCENT } from './ui/colors'; @@ -86,6 +87,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 @@ -127,7 +130,8 @@ program envPath: options.envPath, verbose: options.verbose, force: options.force, - dryRun: options.dryRun + dryRun: options.dryRun, + conflictFlags: rawConflictFlagsOf(cmd), }; const command = new CapyCommand(cliOptions, true); @@ -284,11 +288,15 @@ program program .command('push') - .description('Push encrypted values to Keep') - .action(async () => { + // COPY-FLAG: names what a push covers, deletions included. + .description('Push your changes to Keep: edited, new and deleted variables') + .option('--non-tty', 'treat stdin as not a terminal; a conflict stops the push with the conflict_id (agents/CI)') + .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 +326,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'); @@ -350,6 +360,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) => { const merged = cmd.optsWithGlobals ? cmd.optsWithGlobals() : options; @@ -367,6 +379,7 @@ const deploy = program dryRun: options.dryRun ?? merged.dryRun, edit: options.edit, devMode: true, + conflictFlags: rawConflictFlagsOf(cmd), }); process.exit(code); } @@ -805,6 +818,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 +836,7 @@ secretsCmd exclude: options.exclude, noPrFor: options.prFor, noPr: options.pr === false, + conflictFlags: rawConflictFlagsOf(command), }, true, ); @@ -846,6 +862,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 +878,7 @@ secretsCmd row: options.row, allRows: options.allRows === true, exclude: options.exclude, + conflictFlags: rawConflictFlagsOf(command), }, true, ); @@ -1035,6 +1054,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 +1068,7 @@ program json: options.json, pr: prFlagsFromCommand(command, options.prBase), dryRun: merged.dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1059,6 +1081,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 +1093,7 @@ program nonTty: options.nonTty, pr: prFlagsFromCommand(command, options.prBase), dryRun: command.optsWithGlobals().dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1107,6 +1132,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 +1159,7 @@ program yes: options.yes === true, overwrite: options.overwrite === true, environment: options.environment, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1144,6 +1172,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 +1183,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..db0f701c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ 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'; // 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 +72,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 @@ -117,7 +120,8 @@ program envPath: options.envPath, verbose: options.verbose, force: options.force, - dryRun: options.dryRun + dryRun: options.dryRun, + conflictFlags: rawConflictFlagsOf(cmd), }; const command = new CapyCommand(cliOptions); @@ -164,6 +168,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 +182,7 @@ program pr: prFlagsFromCommand(command, options.prBase), // The program-level `--dry-run`, wherever it was typed. dryRun: command.optsWithGlobals().dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -349,11 +356,15 @@ program program .command('push') - .description('Push encrypted values to Keep') - .action(async () => { + // COPY-FLAG: names what a push covers, deletions included. + .description('Push your changes to Keep: edited, new and deleted variables') + .option('--non-tty', 'treat stdin as not a terminal; a conflict stops the push with the conflict_id (agents/CI)') + .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: @@ -390,6 +401,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 @@ -415,6 +428,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); } @@ -851,6 +865,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 +884,7 @@ secretsCmd exclude: options.exclude, noPrFor: options.prFor, noPr: options.pr === false, + conflictFlags: rawConflictFlagsOf(command), }, false, ); @@ -893,6 +910,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 +927,7 @@ secretsCmd row: options.row, allRows: options.allRows === true, exclude: options.exclude, + conflictFlags: rawConflictFlagsOf(command), }, false, ); @@ -979,6 +999,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 +1014,7 @@ program json: options.json, pr: prFlagsFromCommand(command, options.prBase), dryRun: merged.dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1004,6 +1027,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 +1040,7 @@ program nonTty: options.nonTty, pr: prFlagsFromCommand(command, options.prBase), dryRun: command.optsWithGlobals().dryRun === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1053,6 +1079,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 +1110,7 @@ program yes: options.yes === true, overwrite: options.overwrite === true, environment: options.environment, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -1094,6 +1123,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 +1135,7 @@ program skipPrompts: !!(options.yes || options.skipPrompts), nonTty: options.nonTty, provider: options.provider, + conflictFlags: rawConflictFlagsOf(command), }); }); diff --git a/src/sync/commit.ts b/src/sync/commit.ts index 1a732a93..45cda652 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -30,6 +30,7 @@ import type { ProjectManager } from '../core/projectManager'; import type { ServiceClient } from '../service/serviceClient'; import { CapyError, ERROR_CODES, KeepFile, setSyncKeepHash } from '../types/index'; import { hashValue } from './compareSecrets'; +import type { ConflictDecider, ConflictView, Resolution, Resolutions, SourceConflict } from './conflicts'; import { SyncEngine } from './syncEngine'; // ── Vocabulary ────────────────────────────────────────────────────────────── @@ -40,9 +41,7 @@ export interface Changes { readonly remove: readonly string[]; } -/** The caller's answer for a name the server changed under it: keep theirs, or overwrite with mine. */ -export type Resolution = 'mine' | 'theirs'; -export type Resolutions = Readonly>; +export type { Resolution, Resolutions }; /** One variable as the server holds it for the branch. */ export interface LatestEntry { @@ -90,6 +89,41 @@ export function pinnedHashes(keep: KeepFile | null | undefined, branch: string): const has = (record: object, name: string): boolean => Object.hasOwn(record, name); +/** A change that meets a value the server moved since the caller's pin. Fingerprints only. */ +export interface Contest { + readonly name: string; + /** What the caller last agreed with the server (`undefined`: the caller never had it). */ + readonly pinned: string | undefined; + /** The intended value's fingerprint (`undefined`: a deletion). */ + readonly yours: string | undefined; + /** The server's fingerprint now (`undefined`: the server no longer has it). */ + readonly theirs: string | undefined; +} + +/** + * Every change in `changes` that meets a value the server moved since `pinned` (different hash, + * including added or removed on the server since) and that would actually change it: a name in + * `set` whose intended value is not already the server's, a name in `remove` the server still has. + * Independent of any resolution, so a decision can be bound to exactly this set. In name order. + */ +export function findConflicts(input: { + readonly pinned: Readonly>; + readonly latest: Readonly>; + readonly changes: Changes; +}): readonly Contest[] { + const { pinned, latest, changes } = input; + const writes = Object.entries(changes.set) + .filter(([name, value]) => latest[name]?.plaintext !== value) + .map(([name, value]) => ({ name, yours: hashValue(value) as string | undefined })); + const removals = changes.remove + .filter((name) => !has(changes.set, name) && has(latest, name)) + .map((name) => ({ name, yours: undefined as string | undefined })); + return [...writes, ...removals] + .filter(({ name }) => latest[name]?.hash !== pinned[name]) + .map(({ name, yours }) => ({ name, pinned: pinned[name], yours, theirs: latest[name]?.hash })) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} + /** * `changes` applied to `latest`. * @@ -110,15 +144,12 @@ export function planCommit(input: { }): Plan { const { pinned, latest, changes, encryptLine } = input; const resolutions = input.resolutions ?? {}; - const moved = (name: string): boolean => latest[name]?.hash !== pinned[name]; - const contested = (name: string): boolean => moved(name) && resolutions[name] !== 'mine'; - const setEntries = Object.entries(changes.set); - const toWrite = setEntries.filter(([name, value]) => resolutions[name] !== 'theirs' && latest[name]?.plaintext !== value); - const toRemove = changes.remove.filter((name) => !has(changes.set, name) && has(latest, name) && resolutions[name] !== 'theirs'); + const open = findConflicts({ pinned, latest, changes }).filter((c) => resolutions[c.name] === undefined); + if (open.length > 0) return { kind: 'conflict', names: open.map((c) => c.name) }; - const conflicts = [...toWrite.map(([name]) => name), ...toRemove].filter(contested); - if (conflicts.length > 0) return { kind: 'conflict', names: [...new Set(conflicts)].sort() }; + const toWrite = Object.entries(changes.set).filter(([name, value]) => resolutions[name] !== 'theirs' && latest[name]?.plaintext !== value); + const toRemove = changes.remove.filter((name) => !has(changes.set, name) && has(latest, name) && resolutions[name] !== 'theirs'); const written = Object.fromEntries(toWrite); const encrypted = Object.fromEntries(toWrite.map(([name, value]) => [name, encryptLine(name, value)])); @@ -279,6 +310,14 @@ export interface ServerCommitInput { readonly transformKeep?: (keep: KeepFile) => KeepFile; /** A read the caller already made and showed its user: used for the first attempt instead of reading again. */ readonly seen?: Snapshot; + /** + * Asked for the decision on every conflict the changes meet that `resolutions` does not already settle + * (CAP-717). It returns the decisions or throws (a `ConflictRefusal`: nothing is uploaded). Absent: an + * unsettled conflict is `SECRETS_CONFLICT`. + */ + readonly decide?: ConflictDecider; + /** Read, plan and decide, but upload nothing: for a caller that wants to know whether the write would go through. */ + readonly check?: boolean; } export interface ServerCommit { @@ -290,6 +329,8 @@ export interface ServerCommit { readonly env: Readonly>; readonly written: readonly string[]; readonly removed: readonly string[]; + /** Every decision this commit was planned with: the caller's, and what `decide` added. */ + readonly resolutions: Resolutions; /** The push response; `undefined` when nothing needed uploading. */ readonly result: { readonly keep_hash: string; readonly keep_file?: string } | undefined; } @@ -301,25 +342,57 @@ function stillValid(resolutions: Resolutions | undefined, before: Snapshot, afte ); } +/** The conflicts `decide` is asked about, as the protocol reports them (fingerprints only). */ +export function sourceConflictsOf(contests: readonly Contest[], snapshot: Snapshot, view: ConflictView): readonly SourceConflict[] { + return contests.map((c) => { + const changedAt = snapshot.keep?.variables[c.name]?.find((e) => e.branch === view.branch)?.changed_at; + return { + kind: 'source', + name: c.name, + project: view.project, + branch: view.branch, + pinned: c.pinned ?? null, + yours: c.yours ?? null, + theirs: c.theirs ?? null, + ...(changedAt === undefined ? {} : { theirs_changed_at: changedAt }), + }; + }); +} + +/** The resolutions `input.decide` gives for the conflicts that `resolutions` leaves open (none when nothing is open or nobody decides). */ +async function decidedFor(input: ServerCommitInput, snapshot: Snapshot, resolutions: Resolutions | undefined, latestKeep: KeepFile): Promise { + const { branch, startKeep, changes } = input; + const open = findConflicts({ pinned: pinnedHashes(startKeep, branch), latest: snapshot.entries, changes }).filter((c) => resolutions?.[c.name] === undefined); + if (open.length === 0 || input.decide === undefined) return {}; + const view: ConflictView = { + project: latestKeep.project_name, + branch, + serverKeepHash: snapshot.keep === undefined ? '' : SyncEngine.computeKeepHash(snapshot.keep, branch), + }; + return input.decide(sourceConflictsOf(open, snapshot, view), view); +} + async function attempt(input: ServerCommitInput, snapshot: Snapshot, resolutions: Resolutions | undefined, mayRetry: boolean): Promise { const { client, projectId, branch, projectKey, startKeep, changes, transformKeep } = input; 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 decided = await decidedFor(input, snapshot, resolutions, latestKeep); + const settled: Resolutions = { ...resolutions, ...decided }; const plan = planCommit({ pinned: pinnedHashes(startKeep, branch), latest: snapshot.entries, changes, - resolutions, + resolutions: settled, encryptLine: (name, value) => `capy:${resourceIdOf(name)}:${Encryptor.encrypt(value, projectKey)}`, }); if (plan.kind === 'conflict') throw conflictError(plan.names); const changed = keepWithChanges(latestKeep, branch, plan.written, plan.removed, resourceIdOf); const keep = transformKeep ? transformKeep(changed) : changed; - const base = { snapshot, keep, lines: plan.lines, env: plan.env, written: Object.keys(plan.written), removed: plan.removed }; - if (keep === latestKeep) return { ...base, result: undefined }; + const base = { snapshot, keep, lines: plan.lines, env: plan.env, written: Object.keys(plan.written), removed: plan.removed, resolutions: settled }; + if (keep === latestKeep || input.check === true) return { ...base, result: undefined }; try { const result = await client.pushSecrets( @@ -378,12 +451,16 @@ export interface FolderContext { readonly keep: KeepFile; /** The folder's decrypted `.env` when the command started. */ readonly localPlaintext: Readonly>; + /** How the command settles a conflict (the non-TTY protocol sets this); absent: a conflict is `SECRETS_CONFLICT`. */ + readonly decide?: ConflictDecider; } export interface FolderCommitOptions { readonly resolutions?: Resolutions; readonly transformKeep?: (keep: KeepFile) => KeepFile; readonly seen?: Snapshot; + /** Overrides the context's `decide` for this one commit. */ + readonly decide?: ConflictDecider; } export interface FolderCommit { @@ -413,15 +490,23 @@ export function localChanges(local: Readonly>, pinned: Re * to it, so for each one the server also moved, the answer is `mine`. `seen` is the read the * table was drawn from; the commit is planned against it, so a save in between is caught by * the server (`SECRETS_STALE_BASE`) rather than overwritten. + * + * Off a terminal there is no table to answer for (CAP-717): the caller passes `decide` and the same + * commit grants nothing up front, so a name the server also changed is a conflict that `decide` settles. */ export async function commitLocalValues( ctx: FolderContext, shown: { readonly pinned: Readonly>; readonly remoteHashes: Readonly>; readonly seen: Snapshot | undefined }, + decide?: ConflictDecider, ): Promise<{ readonly changed: number }> { const changes = localChanges(ctx.localPlaintext, shown.pinned, { removals: true }); const names = [...Object.keys(changes.set), ...changes.remove]; - const resolutions = Object.fromEntries(names.filter((name) => shown.remoteHashes[name] !== shown.pinned[name]).map((name) => [name, 'mine' as const])); - await commitFolder(ctx, changes, { resolutions, seen: shown.seen }); + // Choosing "commit local" in the menu grants `mine` to removals too, exactly as it does to edits: the table showed both. + const resolutions = + decide === undefined + ? Object.fromEntries(names.filter((name) => shown.remoteHashes[name] !== shown.pinned[name]).map((name) => [name, 'mine' as const])) + : {}; + await commitFolder(ctx, changes, { resolutions, seen: shown.seen, decide }); return { changed: names.length }; } @@ -478,9 +563,9 @@ function commitLocalCopy(ctx: FolderContext, changes: Changes, transformKeep: Fo }; } -/** The server commit, and what the folder should adopt from it. */ -async function commitToServerFor(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions) { - const committed = await commitToServer({ +/** What a folder commit asks the server commit to do. */ +function serverInputFor(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions): ServerCommitInput { + return { client: ctx.serviceClient!, projectId: ctx.projectId, branch: ctx.branch, @@ -490,7 +575,13 @@ async function commitToServerFor(ctx: FolderContext, changes: Changes, opts: Fol resolutions: opts.resolutions, transformKeep: opts.transformKeep, seen: opts.seen, - }); + decide: opts.decide ?? ctx.decide, + }; +} + +/** The server commit, and what the folder should adopt from it. */ +async function commitToServerFor(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions) { + const committed = await commitToServer(serverInputFor(ctx, changes, opts)); const pinned = pinnedHashes(ctx.keep, ctx.branch); // A local edit nobody committed stays an edit: its value stays in `.env` and its pin stays // where it was, so the next bare `capy` still sees what the server changed under it. @@ -514,6 +605,19 @@ async function commitToServerFor(ctx: FolderContext, changes: Changes, opts: Fol }; } +/** + * Reads, plans and decides exactly as `commitFolder(ctx, changes, opts)` would, and writes nothing, on the + * server or in the folder: a `--dry-run` that can say "this would conflict", or a command that must settle + * its conflicts BEFORE it does something it cannot take back (`capy rotate` issues a new key first). Throws what + * the commit would throw; returns the decisions it was planned with. A folder with no server has nothing to + * conflict with. + */ +export async function checkFolder(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions = {}): Promise<{ readonly resolutions: Resolutions }> { + if (ctx.serviceClient === undefined) return { resolutions: opts.resolutions ?? {} }; + const checked = await commitToServer({ ...serverInputFor(ctx, changes, opts), check: true }); + return { resolutions: checked.resolutions }; +} + /** * 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. `.env` afterwards is the diff --git a/src/sync/conflicts.ts b/src/sync/conflicts.ts new file mode 100644 index 00000000..bb4a9840 --- /dev/null +++ b/src/sync/conflicts.ts @@ -0,0 +1,306 @@ +/** + * Conflicts everywhere (CAP-717; the contract is docs/conflict-resolution.md). + * + * ONE module for what every write command shares when two sides changed the same name: + * - the vocabulary: a conflict is a NAME where the other side moved since `pinned` + * and differs from `yours`; fingerprints only (the 16-hex `value_hash`), never a value; + * - `conflictIdOf`: the id that binds a decision to the exact conflicts it was made against; + * - `parseConflictFlags`: `--resolve NAME=mine|theirs` (target form `NAME@TARGET=...`) and + * `--conflict `, validated with coded errors; + * - `gateConflicts`: "apply the resolutions or refuse". Off a terminal a conflict stops the + * write (nothing is written, exit 3, JSON on stdout, prose on stderr); a rerun with the + * right id and a decision for every conflicting name goes on. + * + * No command keeps a copy of any of this. 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'; + +// ── Vocabulary ────────────────────────────────────────────────────────────── + +/** The caller's answer for a name the other side changed under it: keep theirs, or overwrite with mine. */ +export type Resolution = 'mine' | 'theirs'; +/** Decisions by key: `NAME` for a source conflict, `NAME@TARGET` for a target conflict. */ +export type Resolutions = Readonly>; + +/** Folder (or earlier read) against the Capy server. Fingerprints are `null` where there is no value (absent, or a deletion). */ +export interface SourceConflict { + readonly kind: 'source'; + readonly name: string; + readonly project: string; + readonly branch: string; + readonly pinned: string | null; + readonly yours: string | null; + readonly theirs: string | null; + /** When the server's value last changed, if the server says. */ + readonly theirs_changed_at?: string; +} + +/** Capy against the platform's live value (Dokploy). */ +export interface TargetConflict { + readonly kind: 'target'; + readonly name: string; + readonly target: string; + readonly pinned: string | null; + readonly yours: string | null; + readonly theirs: string | null; +} + +export type Conflict = SourceConflict | TargetConflict; + +/** The key a decision for `conflict` is given under. */ +export const resolveKeyOf = (conflict: Conflict): string => (conflict.kind === 'source' ? conflict.name : `${conflict.name}@${conflict.target}`); + +/** Where conflicts were found: what the id binds. `serverKeepHash` is the server's keep hash for the branch at the read. */ +export interface ConflictScope { + readonly project: string; + readonly branch: string; + readonly serverKeepHash: string; + readonly conflicts: readonly Conflict[]; +} + +/** What a commit path hands its decider: where it is looking. */ +export interface ConflictView { + readonly project: string; + readonly branch: string; + readonly serverKeepHash: string; +} + +/** Decides the conflicts a write found (or throws a `ConflictRefusal`). Receives only the source conflicts of one scope. */ +export type ConflictDecider = (found: readonly SourceConflict[], view: ConflictView) => Resolutions | Promise; + +// ── 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 => [c.kind, c.name, c.kind === 'target' ? c.target : '', c.kind === 'source' ? c.project : '', c.kind === 'source' ? c.branch : ''].join('\u0000'); + +/** The conflicts 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) => ({ + kind: c.kind, + name: c.name, + target: c.kind === 'target' ? c.target : null, + pinned: c.pinned, + yours: c.yours, + theirs: c.theirs, +}); + +/** 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 conflicts (kind, name, target, pinned, yours, theirs). + * 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 conflicts. 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) => (c.kind === 'target' ? `${c.name} on ${c.target}` : c.name)))].join(', '); + +/** The refusal for `listed` (of the conflicts 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 conflict changed since that id was issued. Nothing was written.` // COPY-FLAG + : `${namesOf(listed)} changed since this was last synced. 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, nothing else. stderr: one line saying what to do. */ +export function printConflictRefusal(refusal: ConflictRefusal): void { + console.log(JSON.stringify(refusal.payload, null, 2)); + console.error(refusal.message); + console.error('Rerun with --resolve NAME=mine|theirs for each name (NAME@TARGET for a target) 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]+))?=(mine|theirs)$/; + +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=mine or NAME=theirs (NAME@TARGET=mine|theirs for a target).'); // 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 decided 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 : a decision is only valid for the conflicts 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 resolutions, or refuse ──────────────────────────────────────── + +/** + * The decision step of every command. `scopes` hold every conflict the write found (resolved or not). + * + * - none → nothing to decide; + * - 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 conflicting name has no `--resolve` → `SECRETS_CONFLICT` with the remaining ones + * (the id of the whole set, so the rerun keeps it); + * - otherwise the decisions, keyed as given. + * + * Throws `ConflictRefusal`; returns only decisions that cover every conflict. + */ +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 (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 decider of a `--dry-run`: any conflict is reported (with `dry_run: true`) and nothing is decided. */ +export const previewDecider: ConflictDecider = (found, view) => { + throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ ...view, conflicts: found }], found, { dryRun: true }); +}; + +/** The decider for a command that follows the protocol: the flags decide, or the write is refused. */ +export const protocolDecider = (flags: ConflictFlags): ConflictDecider => (found, view) => gateConflicts(flags, [{ ...view, conflicts: found }]); + +/** The source decisions of `resolutions` (keys without a target). */ +export const sourceResolutions = (resolutions: Resolutions): Resolutions => + Object.fromEntries(Object.entries(resolutions).filter(([key]) => !key.includes('@'))); + +/** The decisions for `target`, keyed by bare NAME. */ +export const targetResolutions = (resolutions: Resolutions, target: string): Resolutions => + 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=mine|theirs` (repeatable). Every write command takes it; the root program takes it for bare `capy`. */ +export const resolveOption = (): Option => + new Option( + '--resolve ', + 'decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict)', // COPY-FLAG + ) + .default([]) + .argParser(collectResolve); + +/** `--conflict `: binds `--resolve` to the conflicts 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/types/index.ts b/src/types/index.ts index ff38eced..343fd696 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -385,6 +385,8 @@ export interface CliOptions { verbose?: boolean; force?: boolean; dryRun?: boolean; + /** `--resolve` / `--conflict` as typed (CAP-717): off a terminal they decide the names both sides changed. */ + conflictFlags?: { readonly resolve?: readonly string[]; readonly conflict?: string }; } export interface ProjectInitResult { @@ -639,6 +641,23 @@ export const ERROR_CODES = { * 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 decision + * no longer applies. Nothing was written; the fresh conflicts (with a new id) come back + * with it. Exit 3. + */ + SECRETS_CONFLICT_STALE: 'SECRETS_CONFLICT_STALE', + /** `--resolve` is not `NAME=mine|theirs` (or `NAME@TARGET=mine|theirs`), or `--conflict` is not a conflict id. Exit 1. */ + SECRETS_RESOLVE_INVALID: 'SECRETS_RESOLVE_INVALID', + /** `--resolve` was given without `--conflict `: a decision is only valid for the conflicts it was made against. Exit 1. */ + SECRETS_RESOLVE_NEEDS_CONFLICT: 'SECRETS_RESOLVE_NEEDS_CONFLICT', + /** + * `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', // --- 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/tests/commands/conflictProtocol.test.ts b/tests/commands/conflictProtocol.test.ts new file mode 100644 index 00000000..9fe23bc2 --- /dev/null +++ b/tests/commands/conflictProtocol.test.ts @@ -0,0 +1,416 @@ +/** + * The non-TTY conflict 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. + * + * The world is the Oct 2 incident in miniature. The folder is pinned to an old snapshot of the branch and holds + * ITS OWN edit of X (a teammate's newer X is already on the server). Every command that writes must: + * - stop at the conflict: exit 3, `{ ok:false, code:'SECRETS_CONFLICT', conflict_id, conflicts }` on stdout and + * nothing else there, the explanation on stderr, no value anywhere, NOTHING uploaded and the folder untouched; + * - on a rerun with `--resolve X=mine|theirs --conflict `: write what was chosen and nothing else; + * - refuse a rerun whose state moved (`SECRETS_CONFLICT_STALE`, a new id, nothing written), answer a partial + * set of decisions with the names still open, and refuse `--resolve` without `--conflict`. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { readFileSync, existsSync } 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 ALL_SENTINELS = [OLD.X, OLD.Y, MINE, THEIRS, THEIRS_AGAIN]; + +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_file: JSON.stringify(keepOf(values)) }); +} + +/** What the last accepted push left on the server for `name`, decrypted. */ +function pushedValue(h: Harness, name: string): string | undefined { + const file = join(h.root, 'store.json'); + if (!existsSync(file)) return undefined; + const store = JSON.parse(readFileSync(file, 'utf8')) as { keep?: KeepFile; blobs: Record }; + if (store.keep === undefined) return undefined; + const blob = store.blobs[SyncEngine.computeKeepHash(store.keep, BRANCH)] ?? ''; + const line = blob.split('\n').find((l) => l.startsWith(`${name}=`)); + return line === undefined ? undefined : new FileManager(h.project).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)); + +describe('the non-TTY conflict protocol (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, with its own edit of X. Server: a teammate already saved a newer X. + seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); + seedServer(h(), { X: THEIRS, Y: OLD.Y }); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + /** The conflict a first run reports, and the folder/server 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}$/); + noValuesIn(result); + // Nothing uploaded; the folder is as it was. + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(MINE); + return { result, id: out.conflict_id as string }; + } + + describe('capy edit NAME < value (piped)', () => { + test('stops at the conflict: exit 3, the payload on stdout, fingerprints only, prose on stderr, nothing uploaded', async () => { + const { result } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); + expect(json(result).conflicts).toEqual([ + { + kind: 'source', + name: 'X', + project: 'piped-test', + branch: BRANCH, + pinned: hash16(OLD.X), + yours: hash16(MINE), + theirs: hash16(THEIRS), + theirs_changed_at: '2026-10-01T21:44:18.000Z', + }, + ]); + expect(result.stderr).toContain('X'); + expect(result.stderr).toContain('Nothing was written'); + }); + + test('without --json it is the same protocol: still JSON on stdout, exit 3', async () => { + const result = await h().run(['edit', 'X'], `${MINE}\n`); + expect(result.code).toBe(3); + expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(h().pushCount()).toBe(0); + }); + + test('rerun with mine: MY value is what the server decrypts to', async () => { + const { id } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); + const rerun = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine', '--conflict', id], `${MINE}\n`); + expect(rerun.code).toBe(0); + expect(json(rerun)).toMatchObject({ ok: true, name: 'X', pushed: true }); + expect(h().pushCount()).toBe(1); + expect(pushedValue(h(), 'X')).toBe(MINE); + expect(pushedValue(h(), 'Y')).toBe(OLD.Y); + }); + + test('rerun with theirs: nothing is uploaded and the folder takes THEIR value', async () => { + const { id } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); + const rerun = await h().run(['edit', 'X', '--json', '--resolve', 'X=theirs', '--conflict', id], `${MINE}\n`); + expect(rerun.code).toBe(0); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(THEIRS); + }); + + test('a rerun whose state moved is SECRETS_CONFLICT_STALE: a new id, the fresh conflict, nothing written', async () => { + const { id } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); + seedServer(h(), { X: THEIRS_AGAIN, Y: OLD.Y }); // another writer moved X again + const rerun = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine', '--conflict', id], `${MINE}\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[0]).toMatchObject({ name: 'X', theirs: hash16(THEIRS_AGAIN) }); + noValuesIn(rerun); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(MINE); + // ... and the NEW id works. + const again = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine', '--conflict', out.conflict_id], `${MINE}\n`); + expect(again.code).toBe(0); + expect(pushedValue(h(), 'X')).toBe(MINE); + }); + + test('--resolve without --conflict is refused with a code, and nothing is read or written', async () => { + const result = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine'], `${MINE}\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', async () => { + const result = await h().run(['edit', 'X', '--json', '--resolve', 'X:mine', '--conflict', 'c_9f2e41a07b3d'], `${MINE}\n`); + expect(result.code).toBe(1); + expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_INVALID' }); + }); + + test('--dry-run previews the same conflict_id and writes nothing', async () => { + const real = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); + const preview = await h().run(['edit', 'X', '--json', '--dry-run'], `${MINE}\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); + }); + }); + + describe('capy add NAME --force < value (piped)', () => { + test('stops at the conflict, and a rerun with mine writes my value', async () => { + const { id } = await firstRefusal(['add', 'X', '--force', '--json'], `${MINE}\n`); + const rerun = await h().run(['add', 'X', '--force', '--json', '--resolve', 'X=mine', '--conflict', id], `${MINE}\n`); + expect(rerun.code).toBe(0); + expect(pushedValue(h(), 'X')).toBe(MINE); + }); + }); + + describe('capy remove', () => { + test('stops at the conflict, with --dry-run previewing the same id; a rerun with mine removes only what was named', async () => { + const args = ['remove', 'X', '--yes', '--json']; + const { id } = await firstRefusal(args); + const preview = await h().run([...args, '--dry-run']); + expect(preview.code).toBe(3); + expect(json(preview)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflict_id: id }); + expect(json(preview).conflicts[0]).toMatchObject({ name: 'X', yours: null, theirs: hash16(THEIRS) }); + expect(h().pushCount()).toBe(0); + + const rerun = await h().run([...args, '--resolve', 'X=mine', '--conflict', id]); + expect(rerun.code).toBe(0); + expect(h().pushCount()).toBe(1); + expect(pushedValue(h(), 'X')).toBeUndefined(); + expect(pushedValue(h(), 'Y')).toBe(OLD.Y); + }); + + test('a name nobody else touched is removed without a question', async () => { + const result = await h().run(['remove', 'Y', '--yes', '--json']); + expect(result.code).toBe(0); + expect(pushedValue(h(), 'Y')).toBeUndefined(); + expect(pushedValue(h(), 'X')).toBe(THEIRS); // the teammate's X survives + }); + }); + + describe('capy push', () => { + test('stops at the conflict (stdin is not a terminal), and a rerun with mine pushes my edit', async () => { + const { id } = await firstRefusal(['push']); + const rerun = await h().run(['push', '--resolve', 'X=mine', '--conflict', id]); + expect(rerun.code).toBe(0); + expect(pushedValue(h(), 'X')).toBe(MINE); + expect(pushedValue(h(), 'Y')).toBe(OLD.Y); + }); + + test('partial decisions get the names still open, under the id of the whole set', async () => { + seedFolder(h(), OLD, { X: MINE, Y: 'SENTINEL-y-mine-77aa' }); + 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=mine', '--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=mine', '--resolve', 'Y=theirs', '--conflict', out.conflict_id]); + expect(full.code).toBe(0); + expect(pushedValue(h(), 'X')).toBe(MINE); + expect(pushedValue(h(), 'Y')).toBe('SENTINEL-y-theirs-91bc'); + }); + + test('a variable deleted from .env is pushed as a removal and is not resurrected (CAP-712 follow-up)', async () => { + // Nobody else touched anything: the folder is current and only deleted Y. + seedFolder(h(), OLD, { X: OLD.X }); + seedServer(h(), OLD); + const result = await h().run(['push']); + expect(result.code).toBe(0); + expect(pushedValue(h(), 'Y')).toBeUndefined(); + expect(pushedValue(h(), 'X')).toBe(OLD.X); + expect(h().envValue('Y')).toBeUndefined(); + }); + + test('a deletion is conflict-checked like an edit: Y changed on the server since this folder pinned it', async () => { + seedFolder(h(), OLD, { X: OLD.X }); // Y deleted locally + seedServer(h(), { X: OLD.X, Y: 'SENTINEL-y-theirs-91bc' }); + const result = await h().run(['push']); + expect(result.code).toBe(3); + expect(json(result).conflicts[0]).toMatchObject({ name: 'Y', yours: null, theirs: hash16('SENTINEL-y-theirs-91bc') }); + expect(h().pushCount()).toBe(0); + }); + }); +}); + +describe('capy secrets set NAME (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']; + + test('--dry-run with the value reports the conflict 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[0]).toMatchObject({ kind: 'source', name: 'X', pinned: hash16(OLD.X), yours: hash16(MINE), theirs: hash16(THEIRS) }); + 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 mine pushes MY value; with theirs 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 theirs = await h().run([...base, '--resolve', 'X=theirs'], `${MINE}\n`); + expect(theirs.code).toBe(0); + expect(h().pushCount()).toBe(0); + + const mine = await h().run([...base, '--resolve', 'X=mine'], `${MINE}\n`); + expect(mine.code).toBe(0); + expect(h().pushCount()).toBe(1); + expect(pushedValue(h(), 'X')).toBe(MINE); + }); + + 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=mine'], `${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(pushedValue(h(), 'X')).toBe(MINE); + }); +}); + +describe('bare capy 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() }); + seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); + seedServer(h(), { X: THEIRS, Y: OLD.Y }); + }); + afterEach(async () => { + await state.h?.dispose(); + }); + + test('both sides changed X: exit 3 with the payload alone on stdout (no banner), prose on stderr, nothing uploaded', async () => { + const result = await h().run([], ''); + expect(result.code).toBe(3); + const out = json(result); // stdout is JSON and nothing else + expect(Object.keys(out).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); + expect(out).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(out.conflicts).toHaveLength(1); + expect(out.conflicts[0]).toMatchObject({ kind: 'source', name: 'X', pinned: hash16(OLD.X), yours: hash16(MINE), theirs: hash16(THEIRS) }); + noValuesIn(result); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(MINE); + }); + + test('rerun with mine commits MY change on top of the server\'s latest, and pulls what others changed', async () => { + seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); + seedServer(h(), { X: THEIRS, Y: 'SENTINEL-y-theirs-91bc' }); // a teammate also moved Y, which this folder did not touch + const first = await h().run([], ''); + const id = json(first).conflict_id as string; + const rerun = await h().run(['--resolve', 'X=mine', '--conflict', id], ''); + expect(rerun.code).toBe(0); + expect(h().pushCount()).toBe(1); + expect(pushedValue(h(), 'X')).toBe(MINE); + expect(pushedValue(h(), 'Y')).toBe('SENTINEL-y-theirs-91bc'); // untouched by this folder + expect(h().envValue('Y')).toBe('SENTINEL-y-theirs-91bc'); + }); + + test('rerun with theirs uploads nothing and the folder takes their value', async () => { + const id = json(await h().run([], '')).conflict_id as string; + const rerun = await h().run(['--resolve', 'X=theirs', '--conflict', id], ''); + expect(rerun.code).toBe(0); + expect(h().pushCount()).toBe(0); + expect(h().envValue('X')).toBe(THEIRS); + }); + + test('a stale id gets a fresh conflict and a new id; --resolve without --conflict is refused with a code', async () => { + const id = json(await h().run([], '')).conflict_id as string; + seedServer(h(), { X: THEIRS_AGAIN, Y: OLD.Y }); + const stale = await h().run(['--resolve', 'X=mine', '--conflict', id], ''); + expect(stale.code).toBe(3); + expect(json(stale)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT_STALE' }); + expect(json(stale).conflict_id).not.toBe(id); + expect(h().pushCount()).toBe(0); + + const bare = await h().run(['--resolve', 'X=mine'], ''); + expect(bare.code).toBe(1); + expect(bare.stderr).toContain('--conflict'); + expect(h().pushCount()).toBe(0); + }); +}); diff --git a/tests/commands/deployCiChangeGate.test.ts b/tests/commands/deployCiChangeGate.test.ts index 8c47e5f7..2b99fad9 100644 --- a/tests/commands/deployCiChangeGate.test.ts +++ b/tests/commands/deployCiChangeGate.test.ts @@ -45,11 +45,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(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 +76,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 +147,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 +264,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..4ccf7bfc 100644 --- a/tests/commands/deployCiTargetsRecord.test.ts +++ b/tests/commands/deployCiTargetsRecord.test.ts @@ -45,12 +45,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 +69,7 @@ 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'; // Declared before the mocks below (which close over them) — every binding // here is fully initialized before any test() callback runs, so the mocked @@ -103,6 +105,14 @@ mock.module('../../src/service/serviceClient', () => ({ // 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 +129,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 +293,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')) { diff --git a/tests/commands/deployConflicts.test.ts b/tests/commands/deployConflicts.test.ts new file mode 100644 index 00000000..06deaf8e --- /dev/null +++ b/tests/commands/deployConflicts.test.ts @@ -0,0 +1,225 @@ +/** + * `capy deploy` and conflicts (CAP-717): before a delivery writes anything, the values it is about to deliver are + * compared with Capy's latest for the branch (SOURCE, the folder against the server) and, on Dokploy, with the + * service's live env (TARGET). This drives `reconcileDeployConflicts`, the step `deployCommand` takes before the CI + * gate, a mint or a write, against the in-memory Capy server of tests/helpers/fakeCapyServer.ts and a real folder. + * + * The properties: a variable the folder did not change is never delivered stale (Capy's latest is pulled in); a + * variable both sides changed is a conflict that stops the deploy off a terminal (exit-3 payload, nothing uploaded, + * the folder untouched) and, once answered, is either committed first and delivered (mine) or taken from Capy + * (theirs); a folder edit nobody else touched is delivered as always and NOT committed; source and target + * conflicts are answered in one step under one conflict_id. + * + * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). + */ +import { describe, test, expect, mock, afterEach } from 'bun:test'; +import type { DeployAdapter } from '../../src/deploy/adapter'; +import { FIXED_PROJECT_KEY, fixedKeyResolver } from '../helpers/deployServer'; +import { fakeCapyServer, seedServer, serverValues } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = FIXED_PROJECT_KEY; +const currentServer = mock((): ReturnType => { + throw new Error('no server for this test'); +}); + +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + async authenticateSilent() { + return { success: true, user_id: 'user-b' }; + } + async getValidToken() { + return 'fake-session-token'; + } + }, + silentAuthFailureMessage: () => 'auth failed', +})); +mock.module('../../src/service/serviceClient', () => ({ + ServiceClient: class { + setTokenProvider() {} + getDecryptData(...args: unknown[]) { + return (currentServer().getDecryptData as (...a: unknown[]) => unknown)(...args); + } + pushSecrets(...args: unknown[]) { + return (currentServer().pushSecrets as (...a: unknown[]) => unknown)(...args); + } + }, +})); +mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); +const ask = mock(async (_conflicts: readonly unknown[]) => ({}) as Record); +mock.module('../../src/commands/conflictPrompt', () => ({ askConflicts: ask })); + +import { reconcileDeployConflicts, type ReconcileInput } from '../../src/commands/deployConflicts'; +import { ConflictRefusal, parseConflictFlags, type Conflict } from '../../src/sync/conflicts'; +import { hashValue } from '../../src/sync/compareSecrets'; +import type { KeepFile } from '../../src/types/index'; + +const BRANCH = 'production'; +const OLD = { X: 'x-old', Y: 'y-old' } as const; +const THEIRS = 'x-theirs-from-A'; +const MINE = 'x-mine-from-B'; + +const OLD_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: OLD } }); +const NEW_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: { X: THEIRS, Y: OLD.Y } } }); + +const made = mock((_: Folder) => undefined); +afterEach(() => { + made.mock.calls.forEach(([folder]) => removeFolder(folder)); + made.mockClear(); + ask.mockClear(); +}); + +/** A folder pinned to the old snapshot holding `env`, and a server holding `server` (default: a teammate's newer X). */ +function world(env: Record, server: typeof NEW_SEED = NEW_SEED) { + const fake = fakeCapyServer({ initial: server }); + currentServer.mockImplementation(() => fake); + const folder = makeFolder({ projectKey: KEY, branch: BRANCH, keep: OLD_SEED.keep as KeepFile, env, client: fake.client }); + made(folder); + return { fake, folder }; +} + +/** An adapter that delivers plain values and, optionally, reports live-value conflicts the way Dokploy does. */ +function adapterWith(live: readonly Conflict[] = []): DeployAdapter & { readonly deploy: ReturnType } { + const deploy = mock(async (_target: unknown, ctx: { dryRun: boolean; targetCheck?: { resolutions: Record } }) => ({ + ok: true, + steps: [], + conflicts: ctx.dryRun ? live : undefined, + })); + return { id: 'dokploy', label: 'x', description: '', varKind: 'runtime', defaultMode: 'ci', readsLiveValues: live.length > 0, requires: { binaries: [] }, detect: async () => ({}), preflight: async () => ({ ok: true }), deploy } as unknown as DeployAdapter & { + readonly deploy: ReturnType; + }; +} + +const target = (vars: readonly string[] = ['X', 'Y']) => ({ name: 'dokploy-production', kind: 'dokploy', branch: BRANCH, vars: [...vars], options: {}, mode: 'ci' as const }); + +const input = (folder: Folder, adapter: DeployAdapter, over: Partial = {}): ReconcileInput => ({ + cwd: folder.dir, + target: target(), + adapter, + dryRun: false, + interactive: false, + flags: { resolutions: {}, conflictId: undefined }, + adapterCtx: {}, + showFailure: () => undefined, + ...over, +}); + +/** The refusal a non-interactive reconcile throws. */ +async function refusal(run: () => Promise): Promise { + const err = await run().then( + () => undefined, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(ConflictRefusal); + return err as ConflictRefusal; +} + +describe('capy deploy: source conflicts', () => { + test('the folder changed X, a teammate changed X: off a terminal the deploy stops with the payload — nothing uploaded, the folder untouched, no value in it', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + const stopped = await refusal(() => reconcileDeployConflicts(input(folder, adapterWith()))); + expect(stopped.payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); + expect(stopped.payload.conflicts).toEqual([ + expect.objectContaining({ kind: 'source', name: 'X', pinned: hashValue(OLD.X), yours: hashValue(MINE), theirs: hashValue(THEIRS) }), + ]); + expect(JSON.stringify(stopped.payload)).not.toContain(MINE); + expect(JSON.stringify(stopped.payload)).not.toContain(THEIRS); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(folder.env()).toEqual({ X: MINE, Y: OLD.Y }); + }); + + test('mine: the folder\'s value is committed through the commit path FIRST (the server now holds it), and that is what gets delivered', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + const id = (await refusal(() => reconcileDeployConflicts(input(folder, adapterWith())))).payload.conflict_id; + const flags = parseConflictFlags({ resolve: ['X=mine'], conflict: id }); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { flags })); + expect(done.ok).toBe(true); + expect(fake.pushSecrets.mock.calls).toHaveLength(1); + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: MINE, Y: OLD.Y }); + expect(folder.env().X).toBe(MINE); + }); + + test('theirs: nothing is uploaded and the folder takes Capy\'s latest (so Capy\'s latest is what gets delivered)', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + const id = (await refusal(() => reconcileDeployConflicts(input(folder, adapterWith())))).payload.conflict_id; + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { flags: parseConflictFlags({ resolve: ['X=theirs'], conflict: id }) })); + expect(done.ok).toBe(true); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(folder.env().X).toBe(THEIRS); + }); + + test('a variable the folder did NOT change, that Capy moved, is not a conflict: Capy\'s latest is pulled in, never the stale value', async () => { + const { fake, folder } = world({ X: OLD.X, Y: OLD.Y }); // the folder is simply behind + const done = await reconcileDeployConflicts(input(folder, adapterWith())); + expect(done.ok).toBe(true); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(folder.env().X).toBe(THEIRS); + expect(folder.pin('X')).toBe(hashValue(THEIRS)); + }); + + test('a folder edit nobody else touched is delivered as it always was and is NOT committed by the deploy', async () => { + const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); + const done = await reconcileDeployConflicts(input(folder, adapterWith())); + expect(done.ok).toBe(true); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(folder.env().Y).toBe('y-edited'); // still the folder's edit + }); + + test('a folder that agrees with Capy is left alone: no write of any kind', async () => { + const { fake, folder } = world({ X: OLD.X, Y: OLD.Y }, OLD_SEED); + const before = JSON.stringify(folder.keep()); + await reconcileDeployConflicts(input(folder, adapterWith())); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(JSON.stringify(folder.keep())).toBe(before); + }); + + test('--dry-run reports the conflict (dry_run: true, the same id) and writes nothing', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + const real = await refusal(() => reconcileDeployConflicts(input(folder, adapterWith()))); + const preview = await refusal(() => reconcileDeployConflicts(input(folder, adapterWith(), { dryRun: true }))); + expect(preview.payload).toMatchObject({ dry_run: true, conflict_id: real.payload.conflict_id }); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('in a terminal every conflict is a question (mine / theirs), and the answer is applied', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + ask.mockImplementationOnce(async () => ({ X: 'mine' })); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); + expect(done.ok).toBe(true); + expect(ask).toHaveBeenCalledTimes(1); + expect(serverValues(fake.state(), BRANCH, KEY).X).toBe(MINE); + }); +}); + +describe('capy deploy: source and target conflicts in one step', () => { + const live: Conflict = { kind: 'target', name: 'Y', target: 'dokploy-production', pinned: null, yours: hashValue(OLD.Y), theirs: 'deadbeefdeadbeef' }; + + test('one payload carries both kinds under one conflict_id; the answers split between the folder and the adapter', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + const adapter = adapterWith([live]); + const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); + expect(first.payload.conflicts.map((c) => `${c.kind}:${c.name}`)).toEqual(['source:X', 'target:Y']); + // Nothing is written before every conflict is answered: the adapter was only asked to LOOK. + expect(adapter.deploy.mock.calls.every(([, ctx]) => (ctx as { dryRun: boolean }).dryRun)).toBe(true); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + + const flags = parseConflictFlags({ resolve: ['X=mine', 'Y@dokploy-production=theirs'], conflict: first.payload.conflict_id }); + const done = await reconcileDeployConflicts(input(folder, adapter, { flags })); + expect(done).toMatchObject({ ok: true, skipped: ['Y'] }); + expect(done.ok && done.targetCheck?.resolutions).toEqual({ Y: 'theirs' }); + expect(serverValues(fake.state(), BRANCH, KEY).X).toBe(MINE); + }); + + test('answering only the source conflict leaves the target one open: the remaining names come back and nothing is written', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + const adapter = adapterWith([live]); + const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); + const partial = await refusal(() => + reconcileDeployConflicts(input(folder, adapter, { flags: parseConflictFlags({ resolve: ['X=mine'], conflict: first.payload.conflict_id }) })), + ); + expect(partial.payload.code).toBe('SECRETS_CONFLICT'); + expect(partial.payload.conflict_id).toBe(first.payload.conflict_id); + expect(partial.payload.conflicts.map((c) => `${c.kind}:${c.name}`)).toEqual(['target:Y']); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + }); +}); diff --git a/tests/commands/deployDirectModeTiming.test.ts b/tests/commands/deployDirectModeTiming.test.ts index a9595332..7630dce2 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,6 +67,7 @@ mock.module('../../src/service/serviceClient', () => ({ }, })); +import { agreeingDecryptData, fixedKeyResolver } from '../helpers/deployServer'; import { deployCommand } from '../../src/commands/deployCommand'; const HAS_WRANGLER = spawnSync('which', ['wrangler']).status === 0; diff --git a/tests/commands/deployDokploySystemStoreToken.test.ts b/tests/commands/deployDokploySystemStoreToken.test.ts index b65ee961..a05ca09a 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,22 @@ 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'; 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'; @@ -214,9 +225,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 +237,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..37a87ebe 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,9 +59,10 @@ 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'; @@ -114,9 +121,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 +132,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/deployRevokeWiring.test.ts b/tests/commands/deployRevokeWiring.test.ts index 9efb76d7..3585d4ff 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) @@ -146,8 +158,12 @@ import { deployCommand, deployRemove } from '../../src/commands/deployCommand'; import { mergeManagedValuesBlock } from '../../src/deploy/dokployApi'; 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'; @@ -237,7 +253,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..77304e60 --- /dev/null +++ b/tests/commands/deployTargetConflict.test.ts @@ -0,0 +1,210 @@ +/** + * A REAL `deployCommand()` run against a Dokploy CI-mode target whose live env holds a value changed outside Capy + * (CAP-717). Same vehicle as deployDokploySystemStoreToken.test.ts: scripted Dokploy over `fetch`, mocked auth and + * Capy server (in agreement with the folder, so the only conflict here is the TARGET 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=mine --conflict ` delivers and + * comments the outside line out; `theirs` writes nothing and records no delivery; 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'; + +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([ + { kind: 'target', name: 'STRIPE_KEY', target: 'dokploy-ci', pinned: null, yours: hashValue(DELIVERED), theirs: 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('mine: 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=mine'], 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('theirs: 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=theirs'], 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=mine'], 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/rotateConflicts.test.ts b/tests/commands/rotateConflicts.test.ts new file mode 100644 index 00000000..726f1759 --- /dev/null +++ b/tests/commands/rotateConflicts.test.ts @@ -0,0 +1,107 @@ +/** + * `capy rotate` and conflicts (CAP-717). A rotation replaces a value with a key the provider issues ONCE, so a + * conflict on the variable has to be settled BEFORE the new key is issued: after it, a refused write would lose + * the only copy. `settleConflicts` is that step (the first thing `rotateMany` does once it has a deciding context). + * + * The properties: with the protocol on and the server's value moved since the folder's pin, nothing is rotated and the + * payload comes back (exit 3); `theirs` drops the variable from the run (nothing is issued for a value that would not + * be written); `mine` keeps it and hands the answer to the write; a variable nobody changed passes untouched; at a + * terminal (no deciding context) the step is a no-op. + */ +import { describe, test, expect, afterEach, mock, spyOn } from 'bun:test'; +import { RotateCommand } from '../../src/commands/rotateCommand'; +import { parseConflictFlags, protocolDecider } from '../../src/sync/conflicts'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { Encryptor } from '../../src/crypto/encryptor'; +import type { ConnectorMetadata, KeepFile } from '../../src/types/index'; +import { fakeCapyServer, seedServer } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const BRANCH = 'development'; +const OLD = { STRIPE: 'sk_old', OTHER: 'o_old' } as const; +const THEIRS = 'sk_rotated_by_a_teammate'; + +const OLD_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: OLD } }); +const NEW_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: { STRIPE: THEIRS, OTHER: OLD.OTHER } } }); + +const made = mock((_: Folder) => undefined); +afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); + +const connector = (): ConnectorMetadata => ({ provider: 'stripe', source: 'cli', created_at: 1, fingerprint: 'fp' }) as unknown as ConnectorMetadata; +const targets = (...names: string[]) => names.map((varName) => ({ varName, connector: connector() })); + +type Settled = { rotate: Array<{ varName: string }>; resolutions: Record }; + +function world(flags: Parameters[0] | undefined) { + const server = fakeCapyServer({ initial: NEW_SEED }); + const folder = makeFolder({ projectKey: KEY, branch: BRANCH, keep: OLD_SEED.keep as KeepFile, env: OLD, client: server.client }); + made(folder); + const ctx = { ...folder.ctx, ...(flags === undefined ? {} : { decide: protocolDecider(parseConflictFlags(flags)) }) }; + const settle = (list: ReturnType, opts: { noPush?: boolean } = {}): Promise => + (new RotateCommand() as unknown as { settleConflicts: (c: unknown, t: unknown, o: unknown) => Promise }).settleConflicts(ctx, list, opts); + return { server, settle }; +} + +class ExitSignal extends Error { + constructor(public readonly code: number | undefined) { + super('exit'); + } +} + +/** Runs `fn` with `process.exit` and the console captured: what it printed, and the code it exited with (if it did). */ +async function captured(fn: () => Promise) { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSignal(code); + }) as never); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + const outcome = await fn().then( + (value) => ({ value, thrown: undefined as unknown }), + (thrown: unknown) => ({ value: undefined, thrown }), + ); + const stdout = logSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'); + exitSpy.mockRestore(); + logSpy.mockRestore(); + errSpy.mockRestore(); + if (outcome.thrown !== undefined && !(outcome.thrown instanceof ExitSignal)) throw outcome.thrown; + return { value: outcome.value, exitCode: outcome.thrown instanceof ExitSignal ? outcome.thrown.code : undefined, stdout }; +} + +describe('capy rotate: conflicts are settled before the new key is issued', () => { + test('a variable the server changed since this folder\'s pin: nothing is rotated, exit 3 with the payload', async () => { + const { server, settle } = world({}); + const out = await captured(() => settle(targets('STRIPE'))); + expect(out.exitCode).toBe(3); + const payload = JSON.parse(out.stdout) as { code: string; conflicts: unknown[] }; + expect(payload.code).toBe('SECRETS_CONFLICT'); + expect(payload.conflicts).toEqual([ + expect.objectContaining({ kind: 'source', name: 'STRIPE', pinned: hashValue(OLD.STRIPE), yours: null, theirs: hashValue(THEIRS) }), + ]); + expect(out.stdout).not.toContain(THEIRS); + expect(server.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('theirs drops the variable from the run; mine keeps it and carries the answer to the write', async () => { + const conflict = (JSON.parse((await captured(() => world({}).settle(targets('STRIPE')))).stdout) as { conflict_id: string }).conflict_id; + + const theirs = await captured(() => world({ resolve: ['STRIPE=theirs'], conflict }).settle(targets('STRIPE', 'OTHER'))); + expect(theirs.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); + + const mine = await captured(() => world({ resolve: ['STRIPE=mine'], conflict }).settle(targets('STRIPE', 'OTHER'))); + expect(mine.value?.rotate.map((t) => t.varName)).toEqual(['STRIPE', 'OTHER']); + expect(mine.value?.resolutions).toEqual({ STRIPE: 'mine' }); + }); + + test('a variable nobody else changed passes untouched', async () => { + const done = await captured(() => world({}).settle(targets('OTHER'))); + expect(done.exitCode).toBeUndefined(); + expect(done.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); + expect(done.value?.resolutions).toEqual({}); + }); + + test('at a terminal (no deciding context) and with --no-push the step does nothing', async () => { + expect((await captured(() => world(undefined).settle(targets('STRIPE')))).value?.rotate).toHaveLength(1); + expect((await captured(() => world({}).settle(targets('STRIPE'), { noPush: true }))).value?.rotate).toHaveLength(1); + }); +}); diff --git a/tests/commands/secretsDeployConflicts.test.ts b/tests/commands/secretsDeployConflicts.test.ts new file mode 100644 index 00000000..4adcbee9 --- /dev/null +++ b/tests/commands/secretsDeployConflicts.test.ts @@ -0,0 +1,198 @@ +/** + * `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=mine|theirs --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 = (theirs: string = LIVE) => ({ kind: 'target' as const, name: NAME, target: 'api', pinned: null, yours: hashOf(NAME), theirs }); + +/** A Dokploy adapter that compares live values: `api`'s API_KEY is in conflict until an answer is given. */ +function liveAdapter(theirs: string = LIVE) { + 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 answer = ctx.targetCheck.resolutions[NAME]; + if (ctx.dryRun) return { ok: true, steps: [], conflicts: [conflictFor(theirs)] }; + if (answer === undefined) return { ok: false, steps: [{ label: 'application.saveEnvironment', status: 'fail', code: 'SECRETS_CONFLICT' }], conflicts: [conflictFor(theirs)] }; + return answer === 'theirs' + ? { 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('mine: 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=mine`], 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]: 'mine' }); + expect(r.service.pushSecrets.mock.calls.length).toBeGreaterThan(0); + expect(r.github.createPull.mock.calls).toHaveLength(3); + }); + + test('theirs: 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=theirs`], 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=mine`], 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('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=mine`], 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/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/dokployTargetConflict.test.ts b/tests/deploy/dokployTargetConflict.test.ts new file mode 100644 index 00000000..2fe0437a --- /dev/null +++ b/tests/deploy/dokployTargetConflict.test.ts @@ -0,0 +1,302 @@ +/** + * Target conflicts (CAP-717): before a Dokploy delivery writes the service's env it compares each delivered + * name's LIVE value with what Capy last delivered (keep.lock's `deployed_value_hash`) and with what it is about + * to deliver. A value changed on the platform outside Capy is a conflict that needs an answer: + * mine → Capy's value is delivered (the outside line is commented out as `# capy:off`, as always); + * theirs → 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'), yours: h('new-capy'), theirs: 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, yours: h('new-capy'), theirs: 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('theirs 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]?.theirs).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 mine = planTargetDelivery(env, both, { ...check, resolutions: { STRIPE_KEY: 'mine' } }); + expect(mine).toMatchObject({ kind: 'clear', skipped: [] }); + expect(mine.kind === 'clear' && mine.deliver.map((v) => v.name)).toEqual(['STRIPE_KEY', 'PORT']); + const theirs = planTargetDelivery(env, both, { ...check, resolutions: { STRIPE_KEY: 'theirs' } }); + expect(theirs).toMatchObject({ kind: 'clear', skipped: ['STRIPE_KEY'] }); + expect(theirs.kind === 'clear' && theirs.deliver.map((v) => v.name)).toEqual(['PORT']); + // 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'; +const check = (resolutions: Record = {}, pinned: Record = {}) => ({ pinned, resolutions }); + +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([ + { kind: 'target', name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, yours: h('new-capy'), theirs: 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'), yours: h('new-capy'), theirs: h('hand-edited') }); + expect(script.done()).toBe(true); + }); + + test('mine 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: 'mine' }) })); + expect(result.ok).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(script.done()).toBe(true); + }); + + test('theirs 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: 'theirs' }) })); + expect(result.ok).toBe(true); + expect(result.skipped).toEqual(['STRIPE_KEY']); + expect(script.done()).toBe(true); // read only: byte-identical by construction + }); + + test('theirs 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: 'theirs' }) })); + 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; mine writes; theirs 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 mine = scripted([readCompose(OUTSIDE), saveCompose(merged.env), readCompose(merged.env)]); + expect((await adapterWith(mine.fetch).deploy(composeTarget(), ctx({ targetCheck: check({ STRIPE_KEY: 'mine' }) }))).ok).toBe(true); + expect(mine.done()).toBe(true); + + const theirs = scripted([readCompose(OUTSIDE)]); + const kept = await adapterWith(theirs.fetch).deploy(composeTarget(), ctx({ targetCheck: check({ STRIPE_KEY: 'theirs' }) })); + expect(kept).toMatchObject({ ok: true, skipped: ['STRIPE_KEY'] }); + expect(theirs.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/run-tests.sh b/tests/run-tests.sh index b41c4823..121b9fea 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -43,6 +43,8 @@ ISOLATED_FILES=( tests/commands/deployDokploySystemStoreToken.test.ts tests/commands/deployCiChangeGate.test.ts tests/commands/deployCiTargetsRecord.test.ts + tests/commands/deployConflicts.test.ts + tests/commands/deployTargetConflict.test.ts tests/commands/deployRevokeWiring.test.ts tests/commands/deployDirectModeTiming.test.ts tests/commands/deployDokployPickerTokenEnv.test.ts diff --git a/tests/sync/conflicts.test.ts b/tests/sync/conflicts.test.ts new file mode 100644 index 00000000..e39d3dd8 --- /dev/null +++ b/tests/sync/conflicts.test.ts @@ -0,0 +1,203 @@ +/** + * The shared conflict module (CAP-717, src/sync/conflicts.ts): the id that binds a decision to what was seen, + * the flags, and the "apply the resolutions or refuse" step every write command goes through. Pure: no server, + * no folder. Each test asserts the PROPERTY a caller relies on. + */ +import { describe, test, expect } from 'bun:test'; +import { + CONFLICT_ID_FORMAT, + ConflictRefusal, + conflictIdOf, + conflictRefusalFor, + gateConflicts, + parseConflictFlags, + protocolDecider, + resolveKeyOf, + sourceResolutions, + targetResolutions, + type Conflict, + type ConflictScope, +} from '../../src/sync/conflicts'; +import { CapyError, ERROR_CODES } from '../../src/types/index'; + +const source = (name: string, theirs: string, over: Partial> = {}): Conflict => ({ + kind: 'source', + name, + project: 'demo', + branch: 'production', + pinned: '1111111111111111', + yours: '2222222222222222', + theirs, + ...over, +}); + +const target = (name: string, over: Partial> = {}): Conflict => ({ + kind: 'target', + name, + target: 'dokploy-production', + pinned: '1111111111111111', + yours: '2222222222222222', + theirs: '3333333333333333', + ...over, +}); + +const scope = (conflicts: readonly Conflict[], over: Partial = {}): ConflictScope => ({ + project: 'demo', + branch: 'production', + serverKeepHash: 'abcdabcdabcdabcd', + conflicts, + ...over, +}); + +const refusalOf = (run: () => unknown): ConflictRefusal => { + try { + run(); + } catch (err) { + if (err instanceof ConflictRefusal) return err; + throw err; + } + throw new Error('expected a ConflictRefusal'); +}; + +describe('conflictIdOf', () => { + const base = [scope([source('A', 'aaaaaaaaaaaaaaaa'), source('B', 'bbbbbbbbbbbbbbbb')])]; + + test('is c_ + 12 hex, and does not depend on the order the conflicts were found in', () => { + const id = conflictIdOf(base); + expect(id).toMatch(CONFLICT_ID_FORMAT); + expect(conflictIdOf([scope([source('B', 'bbbbbbbbbbbbbbbb'), source('A', 'aaaaaaaaaaaaaaaa')])])).toBe(id); + }); + + test('changes when anything it binds changes: a fingerprint, the server keep hash, the branch, the project, a name, a kind', () => { + const id = conflictIdOf(base); + const others = [ + conflictIdOf([scope([source('A', 'cccccccccccccccc'), source('B', 'bbbbbbbbbbbbbbbb')])]), // theirs moved again + conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa', { yours: '9999999999999999' }), source('B', 'bbbbbbbbbbbbbbbb')])]), // yours + conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa', { pinned: null }), source('B', 'bbbbbbbbbbbbbbbb')])]), // pinned + conflictIdOf([scope(base[0].conflicts, { serverKeepHash: 'ffffffffffffffff' })]), + conflictIdOf([scope(base[0].conflicts, { branch: 'staging' })]), + conflictIdOf([scope(base[0].conflicts, { project: 'other' })]), + conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa')])]), // a name fewer + conflictIdOf([scope([target('A'), source('B', 'bbbbbbbbbbbbbbbb')])]), // a kind + ]; + expect(new Set([id, ...others]).size).toBe(others.length + 1); + }); + + test('theirs_changed_at is not part of it (the spec binds fingerprints only)', () => { + const a = conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa')])]); + const b = conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa', { theirs_changed_at: '2026-10-01T21:44:18Z' })])]); + expect(b).toBe(a); + }); +}); + +describe('parseConflictFlags', () => { + const ID = 'c_9f2e41a07b3d'; + + test('NAME=mine|theirs, and NAME@TARGET=... for a target, repeatable', () => { + const flags = parseConflictFlags({ resolve: ['A=mine', 'B=theirs', 'C@dokploy-production=mine'], conflict: ID }); + expect(flags).toEqual({ resolutions: { A: 'mine', B: 'theirs', 'C@dokploy-production': 'mine' }, conflictId: ID }); + }); + + test('a bad form is SECRETS_RESOLVE_INVALID (code, not prose)', () => { + ['A', 'A=', 'A=ours', '=mine', 'A=mine=theirs', '1A=mine', 'A@=mine'].forEach((bad) => { + const err = (() => { + try { + parseConflictFlags({ resolve: [bad], conflict: ID }); + return undefined; + } catch (e) { + return e as CapyError; + } + })(); + expect(err?.code).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); + }); + }); + + test('--resolve without --conflict is SECRETS_RESOLVE_NEEDS_CONFLICT; a malformed id is SECRETS_RESOLVE_INVALID', () => { + expect(() => parseConflictFlags({ resolve: ['A=mine'] })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_NEEDS_CONFLICT })); + expect(() => parseConflictFlags({ resolve: ['A=mine'], conflict: 'c_nothex' })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); + expect(() => parseConflictFlags({ conflict: 'whatever' })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); + }); + + test('one name decided two ways is refused; the same answer twice is fine; there is no resolve-all', () => { + expect(() => parseConflictFlags({ resolve: ['A=mine', 'A=theirs'], conflict: ID })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); + expect(parseConflictFlags({ resolve: ['A=mine', 'A=mine'], conflict: ID }).resolutions).toEqual({ A: 'mine' }); + // (A variable may be NAMED `all`; the point is that no spelling means "every conflict".) + ['*=mine', '--all', 'ALL', '=mine'].forEach((x) => expect(() => parseConflictFlags({ resolve: [x], conflict: ID })).toThrow()); + }); + + test('no flags is no decisions', () => { + expect(parseConflictFlags({})).toEqual({ resolutions: {}, conflictId: undefined }); + expect(parseConflictFlags({ resolve: [] })).toEqual({ resolutions: {}, conflictId: undefined }); + }); +}); + +describe('gateConflicts', () => { + const scopes = [scope([source('A', 'aaaaaaaaaaaaaaaa'), source('B', 'bbbbbbbbbbbbbbbb'), target('A')])]; + const id = conflictIdOf(scopes); + const flags = (resolve: readonly string[], conflict: string | undefined = id) => parseConflictFlags({ resolve, conflict }); + + test('nothing in conflict decides nothing', () => { + expect(gateConflicts(flags([]), [scope([])])).toEqual({}); + }); + + test('without --conflict every conflict is reported, with the id: SECRETS_CONFLICT', () => { + const refusal = refusalOf(() => gateConflicts(flags([], undefined), scopes)); + expect(refusal.code).toBe(ERROR_CODES.SECRETS_CONFLICT); + expect(refusal.payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: id }); + expect(refusal.payload.conflicts).toHaveLength(3); + }); + + test('a --conflict that is not the id of exactly these is stale: the FRESH conflicts and a NEW id come back', () => { + const refusal = refusalOf(() => gateConflicts(flags(['A=mine'], 'c_000000000000'), scopes)); + expect(refusal.code).toBe(ERROR_CODES.SECRETS_CONFLICT_STALE); + expect(refusal.payload.conflict_id).toBe(id); + expect(refusal.payload.conflicts).toHaveLength(3); + }); + + test('the id matches but a conflicting name has no answer: the REMAINING ones come back (the id of the whole set, so a rerun keeps it)', () => { + const refusal = refusalOf(() => gateConflicts(flags(['A=mine', 'B=theirs']), scopes)); + expect(refusal.code).toBe(ERROR_CODES.SECRETS_CONFLICT); + expect(refusal.payload.conflict_id).toBe(id); + expect(refusal.payload.conflicts.map(resolveKeyOf)).toEqual(['A@dokploy-production']); + }); + + test('every conflict answered: the decisions, source and target apart', () => { + const decided = gateConflicts(flags(['A=mine', 'B=theirs', 'A@dokploy-production=theirs']), scopes); + expect(decided).toEqual({ A: 'mine', B: 'theirs', 'A@dokploy-production': 'theirs' }); + expect(sourceResolutions(decided)).toEqual({ A: 'mine', B: 'theirs' }); + expect(targetResolutions(decided, 'dokploy-production')).toEqual({ A: 'theirs' }); + expect(targetResolutions(decided, 'elsewhere')).toEqual({}); + }); + + test('answers for names that are not in the conflict are ignored, never applied', () => { + expect(gateConflicts(flags(['A=mine', 'B=mine', 'A@dokploy-production=mine', 'ZZZ=theirs']), scopes)).toEqual({ + A: 'mine', + B: 'mine', + 'A@dokploy-production': 'mine', + }); + }); + + test('several places bind one id, and moving any one of them makes the answer stale', () => { + const two = [scope([source('A', 'aaaaaaaaaaaaaaaa')]), scope([source('A', 'aaaaaaaaaaaaaaaa', { branch: 'staging' })], { branch: 'staging' })]; + const both = conflictIdOf(two); + expect(gateConflicts(parseConflictFlags({ resolve: ['A=mine'], conflict: both }), two)).toEqual({ A: 'mine' }); + const moved = [two[0], scope([source('A', 'dddddddddddddddd', { branch: 'staging' })], { branch: 'staging' })]; + expect(refusalOf(() => gateConflicts(parseConflictFlags({ resolve: ['A=mine'], conflict: both }), moved)).code).toBe(ERROR_CODES.SECRETS_CONFLICT_STALE); + }); + + test('a payload names fingerprints and the code, never anything else: exactly ok, code, conflict_id, conflicts (dry_run on a preview)', () => { + const refusal = refusalOf(() => gateConflicts(flags([], undefined), scopes)); + expect(Object.keys(refusal.payload).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); + const preview = conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }); + expect(Object.keys(preview.payload).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'dry_run', 'ok']); + expect(preview.payload.conflict_id).toBe(id); + }); + + test('protocolDecider asks the same gate for one scope', () => { + const found = [source('A', 'aaaaaaaaaaaaaaaa') as Extract]; + const view = { project: 'demo', branch: 'production', serverKeepHash: 'abcdabcdabcdabcd' }; + const here = conflictIdOf([scope(found)]); + expect(protocolDecider(parseConflictFlags({ resolve: ['A=theirs'], conflict: here }))(found, view)).toEqual({ A: 'theirs' }); + expect(() => protocolDecider(parseConflictFlags({}))(found, view)).toThrow(ConflictRefusal); + }); +}); From f962dc75e19634a812a5f5a472522b7dccaa2002 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 02:11:54 -0700 Subject: [PATCH 03/12] fix(sync): bind deploy target decisions to the values seen; rotate asks before minting; deploy commits folder edits - Dokploy target resolutions carry the theirs/yours fingerprints the caller answered; a live value that moved between check and write reopens the conflict. The target comparison runs on the values actually delivered after source decisions. - capy rotate at a terminal asks about conflicts before issuing a key. - capy deploy commits a folder edit nobody else touched before delivering it, so Capy and the platform agree. - capy secrets set reuses the shared conflict printer. --- src/commands/deployConflicts.ts | 52 ++++++++--- src/commands/rotateCommand.ts | 17 ++-- src/commands/secretsSetCommand.ts | 5 +- src/deploy/batchDeploy.ts | 11 ++- src/deploy/dokployApi.ts | 18 +++- src/sync/conflicts.ts | 33 ++++++- tests/commands/deployCiTargetsRecord.test.ts | 93 ++++++++++--------- tests/commands/deployConflicts.test.ts | 86 +++++++++++++++-- tests/commands/deployDirectModeTiming.test.ts | 10 +- .../deployDokploySystemStoreToken.test.ts | 4 +- .../commands/deployDokployWarningOnce.test.ts | 4 +- tests/commands/deployRevokeWiring.test.ts | 5 +- tests/commands/rotateConflicts.test.ts | 59 +++++++++++- tests/commands/secretsDeployConflicts.test.ts | 29 ++++-- tests/deploy/dokployTargetConflict.test.ts | 43 ++++++++- tests/run-tests.sh | 1 + tests/sync/conflicts.test.ts | 10 ++ 17 files changed, 371 insertions(+), 109 deletions(-) diff --git a/src/commands/deployConflicts.ts b/src/commands/deployConflicts.ts index 2c7d56a8..fb5c0dfd 100644 --- a/src/commands/deployConflicts.ts +++ b/src/commands/deployConflicts.ts @@ -5,13 +5,17 @@ * SOURCE (folder against the Capy server). The folder's `.env` may lag Capy (a teammate saved since this folder's * pin) or be ahead of it. The same planner every other write uses (`sync/commit.ts`) decides per variable: * - only the server moved (folder unchanged since its pin) → deliver the server's latest, never the stale value; - * - only the folder changed → delivered as it always was; it stays an edit of the folder (not committed here); + * - only the folder changed → committed to Capy first (the same path as `mine`), then delivered, so Capy holds + * what the platform will hold; * - both changed, differently → a conflict: mine commits the folder's value (through the commit path) and * delivers it, theirs delivers Capy's latest and the folder takes it like a pull. * * TARGET (Capy against the platform, Dokploy only: `adapter.readsLiveValues`). A value changed on the platform * outside Capy since Capy last delivered it is a conflict: mine overwrites it, theirs skips that variable. * + * The target comparison runs with the values that will really be delivered, i.e. after the source answers (a source + * `theirs` delivers Capy's latest), and every target decision is bound to the fingerprints it was made against. + * * One decision step for both kinds: in a terminal one question per variable; off a terminal the protocol * (`sync/conflicts.ts`: nothing is written, exit 3, the payload on stdout, `--resolve` + `--conflict` answer it). * This module writes nothing until every conflict has an answer. Names and fingerprints only, never a value. @@ -40,6 +44,7 @@ import { conflictRefusalFor, gateConflicts, protocolDecider, + seenResolutions, sourceResolutions, targetResolutions, type Conflict, @@ -131,6 +136,10 @@ async function readLatest(folder: FolderContext & { serviceClient: ServiceClient * Settles every conflict `capy deploy ` would meet, and brings the folder in line with the values that * will be delivered. Throws a `ConflictRefusal` off a terminal when the flags do not decide every conflict * (nothing has been written); any other error is a failure to compare, and the caller does not deliver. + * + * The order matters: the target comparison is made with the values that will actually be delivered, and what is + * delivered for a name depends on its SOURCE answer (`theirs` delivers Capy's latest). So the source answers come + * first (asked in a terminal, read from `--resolve` off one), then the platform is compared with those values. */ export async function reconcileDeployConflicts(input: ReconcileInput): Promise { const { target, adapter } = input; @@ -152,9 +161,20 @@ export async function reconcileDeployConflicts(input: ReconcileInput): Promise [v, folder.localPlaintext[v]])), remove: [] }; const source = sourceConflictsOf(findConflicts({ pinned, latest: snapshot.entries, changes }), snapshot, view); - // What is delivered, if every conflict is answered `mine`: the folder's edit, else Capy's latest, else the folder's value. + // The source answers: asked in a terminal (never for a preview), else whatever the flags already say. + const flagged: Resolutions = Object.fromEntries( + Object.entries(sourceResolutions(input.flags.resolutions)).filter(([name]) => source.some((c) => c.name === name)), + ); + const sourceAnswers: Resolutions = input.interactive && !input.dryRun && source.length > 0 ? await askConflicts(source) : flagged; + + // What is delivered once the source is answered: Capy's latest for a name answered `theirs`; else the folder's + // edit, else Capy's latest, else the folder's value (a conflict not yet answered is read as `mine`). const valueFor = (name: string): string | undefined => - Object.hasOwn(changes.set, name) ? changes.set[name] : (snapshot.entries[name]?.plaintext ?? folder.localPlaintext[name]); + sourceAnswers[name] === 'theirs' + ? snapshot.entries[name]?.plaintext + : Object.hasOwn(changes.set, name) + ? changes.set[name] + : (snapshot.entries[name]?.plaintext ?? folder.localPlaintext[name]); const env = Object.fromEntries(target.vars.flatMap((v) => (valueFor(v) === undefined ? [] : [[v, valueFor(v) as string] as const]))); const targetCheck: TargetCheck | undefined = adapter.readsLiveValues @@ -165,7 +185,8 @@ export async function reconcileDeployConflicts(input: ReconcileInput): Promise 0 ? await askConflicts(liveConflicts) : {}) } + : gateConflicts(input.flags, scopes); // The folder takes what will be delivered. A variable the folder did not change is never delivered stale: Capy's - // latest is pulled in. A conflict is committed with its answer (mine writes the folder's value to Capy, theirs - // takes Capy's). A folder edit nobody else touched is not committed here: it is delivered as it always was and - // stays an edit of the folder, for the next `capy`/`capy push`. - const settled = source.map((c) => c.name); + // latest is pulled in. Every variable the folder changed goes through the commit path, so Capy holds what the + // platform will hold: a conflict with its answer (mine writes the folder's value to Capy, theirs takes Capy's), + // an edit nobody else touched as a plain write. const behind = target.vars.some( (v) => !edited.includes(v) && snapshot.entries[v]?.plaintext !== undefined && snapshot.entries[v].plaintext !== folder.localPlaintext[v], ); - if (settled.length > 0 || behind) { + if (edited.length > 0 || behind) { await commitFolder( { ...folder, ...(input.interactive ? {} : { decide: protocolDecider(input.flags) }) }, - { set: Object.fromEntries(settled.map((name) => [name, folder.localPlaintext[name]])), remove: [] }, + { set: Object.fromEntries(edited.map((name) => [name, folder.localPlaintext[name]])), remove: [] }, { resolutions: sourceResolutions(decisions), seen: snapshot }, ); } - const platform = targetResolutions(decisions, target.name); + // The platform decisions, each bound to the fingerprints they were made against (the adapter re-reads the service). + const platform = targetResolutions(seenResolutions(decisions, liveConflicts), target.name); return { ok: true, - skipped: Object.entries(platform).flatMap(([name, choice]) => (choice === 'theirs' ? [name] : [])), + skipped: Object.entries(platform).flatMap(([name, seen]) => (seen.choice === 'theirs' ? [name] : [])), ...(targetCheck === undefined ? {} : { targetCheck: { ...targetCheck, resolutions: platform }, view }), }; } diff --git a/src/commands/rotateCommand.ts b/src/commands/rotateCommand.ts index 88dba343..163f21c7 100644 --- a/src/commands/rotateCommand.ts +++ b/src/commands/rotateCommand.ts @@ -15,7 +15,8 @@ import { TargetConfig } from '../deploy/adapter'; import { staleTargets } from '../deploy/targetsGate'; import { isInteractive, refuseNonInteractive } from '../ui/interactive'; import { checkFolder, type Resolutions } from '../sync/commit'; -import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags } from '../sync/conflicts'; +import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags, type ConflictDecider } from '../sync/conflicts'; +import { askConflicts } from './conflictPrompt'; import type { RotatePlanStop } from '../ui/screens/contract'; import { writeSync } from 'fs'; @@ -493,18 +494,22 @@ export class RotateCommand { } /** - * With a deciding context (the conflict protocol) and a push: the variables whose server value moved since - * this folder's pin are decided now (exit 3 with the payload when the flags do not decide them). `theirs` drops - * the variable from the run: nothing is rotated that would not be written. Without one, nothing changes. + * With a push: the variables whose server value moved since this folder's pin are decided now, before the first + * key is issued. Under the conflict protocol the flags decide (exit 3 with the payload when they do not); at a + * terminal each one is asked. `theirs` drops the variable from the run: nothing is rotated that would not be + * written. With `--no-push` nothing changes. */ private async settleConflicts( ctx: ResolvedContext, toRotate: readonly { varName: string; connector: ConnectorMetadata }[], opts: RotateOpts, ): Promise<{ rotate: readonly { varName: string; connector: ConnectorMetadata }[]; resolutions: Resolutions }> { - if (ctx.decide === undefined || opts.noPush) return { rotate: toRotate, resolutions: {} }; + if (opts.noPush) return { rotate: toRotate, resolutions: {} }; // A rotation replaces the value whatever it was: for the check it is a change to the name, its new value not yet known. - const checked = await checkFolder(ctx, { set: {}, remove: toRotate.map((t) => t.varName) }).catch((err: unknown) => { + // At a terminal (no deciding context) each conflict is a question, asked now: the new key is issued once and a + // write refused afterwards would lose it. Cancelling the question ends the command before anything is issued. + const asker: ConflictDecider = (found) => askConflicts(found); + const checked = await checkFolder(ctx, { set: {}, remove: toRotate.map((t) => t.varName) }, ctx.decide === undefined ? { decide: asker } : {}).catch((err: unknown) => { exitOnConflictRefusal(err); return Promise.reject(err); }); diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index dd1b8c7f..876b382f 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -39,6 +39,7 @@ import { conflictFlagsOrExit, conflictRefusalFor, gateConflicts, + printConflictRefusal, type ConflictFlags, type ConflictScope, type RawConflictFlags, @@ -149,9 +150,7 @@ export function refuse(json: boolean, code: string, error: string, extra: object /** 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 { - console.log(JSON.stringify({ ...refusal.payload, ...extra }, null, 2)); - console.error(refusal.message); - console.error('Rerun with --resolve NAME=mine|theirs for each name and --conflict .'); // COPY-FLAG + printConflictRefusal(refusal, extra); return process.exit(EXIT_NEEDS_INPUT); } diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts index c18ee7b6..d6e370e5 100644 --- a/src/deploy/batchDeploy.ts +++ b/src/deploy/batchDeploy.ts @@ -44,7 +44,7 @@ import { foldDeployKeep, hashValue } from './keepGate'; import { parseTargets } from './configuredTargets'; import { TargetDeliveryDescriptor, VarDelivery, deployedHashesFor, deliveryWorthGating } from './targetsGate'; import { SyncEngine } from '../sync/syncEngine'; -import { targetResolutions, type ConflictScope, type Resolutions } from '../sync/conflicts'; +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'; @@ -525,7 +525,7 @@ 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: Resolutions }; +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 picked @@ -542,7 +542,7 @@ export async function pushTarget( keys: KeyResolver, apiKeys: ApiKeys, item: BatchTarget, - resolutions: Resolutions = {}, + 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. @@ -824,7 +824,10 @@ export async function runBatchDeploy(plan: BatchPlan, env: BatchEnv, hooks: Batc // 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 decisions: Resolutions = hooks.decide === undefined || found.length === 0 ? {} : hooks.decide(found); + 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, diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index 76bfe65f..d49c7ff7 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -11,7 +11,7 @@ */ import { parse as parseDotenv } from 'dotenv'; import { hashValue } from '../sync/compareSecrets'; -import type { Resolutions, TargetConflict } from '../sync/conflicts'; +import type { Resolution, SeenResolutions, TargetConflict } from '../sync/conflicts'; import { ERROR_CODES } from '../types/index'; /** @@ -1543,8 +1543,12 @@ export function dokployTargetConflicts( export interface TargetCheck { /** `deployed_value_hash` by name for this target. */ readonly pinned: Readonly>; - /** Decisions already made, by bare NAME. */ - readonly resolutions: Resolutions; + /** + * Decisions already made, by bare NAME, each bound to the `theirs` / `yours` fingerprints it was made against: + * a decision 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 decision does not count and the conflict is open again. + */ + readonly resolutions: SeenResolutions; } export type TargetPlan = @@ -1567,9 +1571,13 @@ export function planTargetDelivery( ): TargetPlan { if (check === undefined) return { kind: 'clear', deliver: values, skipped: [], found: [] }; const found = dokployTargetConflicts(env, values, check.pinned); - const open = found.filter((c) => check.resolutions[c.name] === undefined); + const choiceFor = (c: LiveConflict): Resolution | undefined => { + const seen = check.resolutions[c.name]; + return seen !== undefined && seen.theirs === c.theirs && seen.yours === c.yours ? 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) => check.resolutions[c.name] === 'theirs').map((c) => c.name); + const skipped = found.filter((c) => choiceFor(c) === 'theirs').map((c) => c.name); return { kind: 'clear', deliver: values.filter((v) => !skipped.includes(v.name)), skipped, found }; } diff --git a/src/sync/conflicts.ts b/src/sync/conflicts.ts index bb4a9840..2f05522c 100644 --- a/src/sync/conflicts.ts +++ b/src/sync/conflicts.ts @@ -161,9 +161,9 @@ export function conflictRefusalFor( ); } -/** stdout: the payload as JSON, nothing else. stderr: one line saying what to do. */ -export function printConflictRefusal(refusal: ConflictRefusal): void { - console.log(JSON.stringify(refusal.payload, null, 2)); +/** 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=mine|theirs for each name (NAME@TARGET for a target) and --conflict .'); // COPY-FLAG } @@ -271,8 +271,33 @@ export const protocolDecider = (flags: ConflictFlags): ConflictDecider => (found export const sourceResolutions = (resolutions: Resolutions): Resolutions => Object.fromEntries(Object.entries(resolutions).filter(([key]) => !key.includes('@'))); +/** A decision together with the fingerprints it was made against: it applies to exactly that state and no other. */ +export interface SeenResolution { + readonly choice: Resolution; + /** What the other side held when the caller decided (`null`: nothing). */ + readonly theirs: string | null; + /** What was about to be written when the caller decided. */ + readonly yours: string | null; +} + +/** `SeenResolution`s by key, in the same key form as `Resolutions`. */ +export type SeenResolutions = Readonly>; + +/** + * Binds `resolutions` to the conflicts they answered: each decision carries the `theirs` / `yours` fingerprints of + * the conflict it was made for. A key that answers no listed conflict 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 === undefined ? [] : [[resolveKeyOf(c), { choice, theirs: c.theirs, yours: c.yours }] as const]; + }), + ); +} + /** The decisions for `target`, keyed by bare NAME. */ -export const targetResolutions = (resolutions: Resolutions, target: string): Resolutions => +export const targetResolutions = (resolutions: Readonly>, target: string): Readonly> => Object.fromEntries( Object.entries(resolutions) .filter(([key]) => key.endsWith(`@${target}`)) diff --git a/tests/commands/deployCiTargetsRecord.test.ts b/tests/commands/deployCiTargetsRecord.test.ts index 4ccf7bfc..7ddf79bb 100644 --- a/tests/commands/deployCiTargetsRecord.test.ts +++ b/tests/commands/deployCiTargetsRecord.test.ts @@ -33,9 +33,12 @@ * - 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 to a variable being delivered is committed to Capy + * FIRST (CAP-717: Capy must hold what the platform will hold, or the record reads + * as "behind"), then the delivery is recorded against that pin (the keep-only + * record itself still 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); @@ -99,7 +102,13 @@ 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 @@ -431,18 +440,15 @@ 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 () => { + test('CI mode: an unpushed local `.env` edit is COMMITTED to Capy before it is delivered (push 1), then the delivery is recorded against it (push 2); a never-synced variable is never pushed', 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 + // The server's stored keep/blob agree with local keep.lock (same hash), but local `.env` has moved on: an EDITED + // value for the tracked var, plus a variable that was never synced (it is not in `target.vars`/keep.lock). + writeServerSnapshot('main'); 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. + // Dokploy receives the CURRENT value, and so does Capy: it must not hold an older one than the platform. const code = await runScriptedDeploy( 'dokploy-ci', dokployFetchMock({ stripeKeyValue: 'new-unpushed-value' }), @@ -450,28 +456,27 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { ); 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')); + expect(pushSecretsMock).toHaveBeenCalledTimes(2); + const [commit, record] = pushSecretsMock.mock.calls; + + // Push 1 is the edit, through the commit path: the pin moves to the edit, and the blob carries a line for it + // (encrypted: never the value) and for nothing the folder never synced. + const committedKeep = JSON.parse(commit[1]); + expect(committedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); + expect(commit[2]).toContain('STRIPE_KEY=capy:'); + expect(commit[2]).not.toContain('new-unpushed-value'); + expect(commit[2]).not.toContain('UNPUSHED_OTHER'); + expect(commit[2]).not.toContain('stray'); + + // Push 2 records the delivery against that pin, so the target reads as current, not behind. It re-sends the + // server's own (now committed) blob unchanged. + const recordedKeep = JSON.parse(record[1]); + expect(recordedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); + expect(recordedKeep.variables.STRIPE_KEY[0].targets[0].deployed_value_hash).toBe(hashValue('new-unpushed-value')); + expect(record[2]).toBe(commit[2]); }, 30_000); - test('direct mode: an unpushed local `.env` edit and a never-synced variable never leak into the server push', async () => { + test('direct mode: the same — the edit is committed first, the delivery recorded against it; a never-synced variable is never pushed', async () => { setUpRepo(); process.chdir(REPO); writeServerSnapshot('main'); @@ -487,22 +492,24 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { ); expect(code).toBe(0); - expect(pushSecretsMock).toHaveBeenCalledTimes(1); - const [, keepFileJson, envBlob] = pushSecretsMock.mock.calls[0]; - - expect(envBlob).toBe(SERVER_ENV_FILE); - expect(envBlob).not.toContain('new-unpushed-value'); - expect(envBlob).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({ + expect(pushSecretsMock).toHaveBeenCalledTimes(2); + const [commit, record] = pushSecretsMock.mock.calls; + + const committedKeep = JSON.parse(commit[1]); + expect(committedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); + expect(commit[2]).not.toContain('new-unpushed-value'); + expect(commit[2]).not.toContain('UNPUSHED_OTHER'); + expect(commit[2]).not.toContain('stray'); + + const recordedKeep = JSON.parse(record[1]); + expect(recordedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); + expect(recordedKeep.variables.STRIPE_KEY[0].targets[0]).toMatchObject({ provider: 'dokploy', target: 'dokploy-direct', deployed: false, // --no-deploy deployed_value_hash: hashValue('new-unpushed-value'), }); + expect(record[2]).toBe(commit[2]); }, 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/deployConflicts.test.ts b/tests/commands/deployConflicts.test.ts index 06deaf8e..927fe112 100644 --- a/tests/commands/deployConflicts.test.ts +++ b/tests/commands/deployConflicts.test.ts @@ -7,7 +7,7 @@ * The properties: a variable the folder did not change is never delivered stale (Capy's latest is pulled in); a * variable both sides changed is a conflict that stops the deploy off a terminal (exit-3 payload, nothing uploaded, * the folder untouched) and, once answered, is either committed first and delivered (mine) or taken from Capy - * (theirs); a folder edit nobody else touched is delivered as always and NOT committed; source and target + * (theirs); a folder edit nobody else touched is committed to Capy, then delivered; source and target * conflicts are answered in one step under one conflict_id. * * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). @@ -53,6 +53,8 @@ import { reconcileDeployConflicts, type ReconcileInput } from '../../src/command import { ConflictRefusal, parseConflictFlags, type Conflict } from '../../src/sync/conflicts'; import { hashValue } from '../../src/sync/compareSecrets'; import type { KeepFile } from '../../src/types/index'; +import { staleTargets } from '../../src/deploy/targetsGate'; +import { planTargetDelivery } from '../../src/deploy/dokployApi'; const BRANCH = 'production'; const OLD = { X: 'x-old', Y: 'y-old' } as const; @@ -79,13 +81,13 @@ function world(env: Record, server: typeof NEW_SEED = NEW_SEED) } /** An adapter that delivers plain values and, optionally, reports live-value conflicts the way Dokploy does. */ -function adapterWith(live: readonly Conflict[] = []): DeployAdapter & { readonly deploy: ReturnType } { - const deploy = mock(async (_target: unknown, ctx: { dryRun: boolean; targetCheck?: { resolutions: Record } }) => ({ +function adapterWith(live: readonly Conflict[] | ((env: Readonly>) => readonly Conflict[]) = []): DeployAdapter & { readonly deploy: ReturnType } { + const deploy = mock(async (_target: unknown, ctx: { dryRun: boolean; env: Readonly>; targetCheck?: { resolutions: Record } }) => ({ ok: true, steps: [], - conflicts: ctx.dryRun ? live : undefined, + conflicts: ctx.dryRun ? (typeof live === 'function' ? live(ctx.env) : live) : undefined, })); - return { id: 'dokploy', label: 'x', description: '', varKind: 'runtime', defaultMode: 'ci', readsLiveValues: live.length > 0, requires: { binaries: [] }, detect: async () => ({}), preflight: async () => ({ ok: true }), deploy } as unknown as DeployAdapter & { + return { id: 'dokploy', label: 'x', description: '', varKind: 'runtime', defaultMode: 'ci', readsLiveValues: typeof live === 'function' || live.length > 0, requires: { binaries: [] }, detect: async () => ({}), preflight: async () => ({ ok: true }), deploy } as unknown as DeployAdapter & { readonly deploy: ReturnType; }; } @@ -157,12 +159,27 @@ describe('capy deploy: source conflicts', () => { expect(folder.pin('X')).toBe(hashValue(THEIRS)); }); - test('a folder edit nobody else touched is delivered as it always was and is NOT committed by the deploy', async () => { + test('a folder edit nobody else touched is COMMITTED to Capy before it is delivered: Capy holds what the platform will hold, and the target is not read as behind', async () => { const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); const done = await reconcileDeployConflicts(input(folder, adapterWith())); expect(done.ok).toBe(true); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(folder.env().Y).toBe('y-edited'); // still the folder's edit + expect(fake.pushSecrets.mock.calls).toHaveLength(1); + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: OLD.X, Y: 'y-edited' }); // Capy's latest decrypts to the folder's value + expect(folder.env().Y).toBe('y-edited'); + expect(folder.pin('Y')).toBe(hashValue('y-edited')); + // What `recordDeployTargets` writes next: deployed_value_hash of the delivered value. Against Capy's pin it is not behind. + const entry = fake.state().keep?.variables.Y.find((e) => e.branch === BRANCH); + expect(entry).toBeDefined(); + const delivered = { provider: 'dokploy', target: 'dokploy-production', deployed_value_hash: hashValue('y-edited') }; + expect(staleTargets({ ...entry!, targets: [delivered] } as never)).toEqual([]); + }); + + test('an edit nobody else touched on a name Capy did not move, next to one a teammate moved: both land, nothing the folder did not change is written', async () => { + const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }); // the server moved X; the folder only changed Y + const done = await reconcileDeployConflicts(input(folder, adapterWith())); + expect(done.ok).toBe(true); + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: THEIRS, Y: 'y-edited' }); + expect(folder.env()).toEqual({ X: THEIRS, Y: 'y-edited' }); }); test('a folder that agrees with Capy is left alone: no write of any kind', async () => { @@ -206,10 +223,61 @@ describe('capy deploy: source and target conflicts in one step', () => { const flags = parseConflictFlags({ resolve: ['X=mine', 'Y@dokploy-production=theirs'], conflict: first.payload.conflict_id }); const done = await reconcileDeployConflicts(input(folder, adapter, { flags })); expect(done).toMatchObject({ ok: true, skipped: ['Y'] }); - expect(done.ok && done.targetCheck?.resolutions).toEqual({ Y: 'theirs' }); + // The decision carries the fingerprints it was made against (what the adapter re-checks at the write). + expect(done.ok && done.targetCheck?.resolutions).toEqual({ Y: { choice: 'theirs', theirs: live.theirs, yours: live.yours } }); expect(serverValues(fake.state(), BRANCH, KEY).X).toBe(MINE); }); + test('the decision handed to the write only counts for the state it was made against: the live value moved afterwards, so the plan is a conflict again', async () => { + const { folder } = world({ X: MINE, Y: OLD.Y }); + // The platform holds 'seen-live' for Y when the answer is given; the adapter reports it against the env it was handed. + const adapter = adapterWith((env) => [{ kind: 'target', name: 'Y', target: 'dokploy-production', pinned: null, yours: hashValue(env.Y), theirs: hashValue('seen-live') }]); + const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); + const flags = parseConflictFlags({ resolve: ['X=mine', 'Y@dokploy-production=mine'], conflict: first.payload.conflict_id }); + const done = await reconcileDeployConflicts(input(folder, adapter, { flags })); + const check = done.ok ? done.targetCheck : undefined; + expect(check).toBeDefined(); + const plan = (liveValue: string) => planTargetDelivery(`Y=${liveValue}\n`, [{ name: 'Y', value: OLD.Y }], check); + expect(plan('seen-live').kind).toBe('clear'); // still what was seen: the answer applies + expect(plan('moved-live').kind).toBe('conflict'); // moved after the answer: asked again, nothing to apply + }); + + test('source answered theirs: the target comparison runs with Capy\'s latest, so the target conflict reports the value that will really be delivered', async () => { + const { fake, folder } = world({ X: MINE, Y: OLD.Y }); + // The platform holds an X that is neither Capy's nor the folder's; the adapter reports it against the env it was given. + const platform = hashValue('x-on-the-platform'); + const adapter = adapterWith((env) => [{ kind: 'target', name: 'X', target: 'dokploy-production', pinned: null, yours: hashValue(env.X), theirs: platform }]); + + const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); + expect(first.payload.conflicts.find((c) => c.kind === 'target')).toMatchObject({ yours: hashValue(MINE) }); // unanswered: read as mine + + // Answering the source side `theirs` changes what is delivered, so the id from the first look no longer binds: STALE, with the real yours. + const answered = parseConflictFlags({ resolve: ['X=theirs', 'X@dokploy-production=theirs'], conflict: first.payload.conflict_id }); + const stale = await refusal(() => reconcileDeployConflicts(input(folder, adapter, { flags: answered }))); + expect(stale.payload.code).toBe('SECRETS_CONFLICT_STALE'); + expect(stale.payload.conflicts.find((c) => c.kind === 'target')).toMatchObject({ yours: hashValue(THEIRS), theirs: platform }); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + + // The rerun with the fresh id goes through; the target decision is bound to the real yours. + const again = parseConflictFlags({ resolve: ['X=theirs', 'X@dokploy-production=mine'], conflict: stale.payload.conflict_id }); + const done = await reconcileDeployConflicts(input(folder, adapter, { flags: again })); + expect(done.ok && done.targetCheck?.resolutions).toEqual({ X: { choice: 'mine', theirs: platform, yours: hashValue(THEIRS) } }); + expect(folder.env().X).toBe(THEIRS); + }); + + test('in a terminal the source is asked first and the platform is compared with its answer: the target question is about Capy\'s latest, not the folder\'s value', async () => { + const { folder } = world({ X: MINE, Y: OLD.Y }); + const platform = hashValue('x-on-the-platform'); + const adapter = adapterWith((env) => [{ kind: 'target', name: 'X', target: 'dokploy-production', pinned: null, yours: hashValue(env.X), theirs: platform }]); + ask.mockImplementationOnce(async () => ({ X: 'theirs' })).mockImplementationOnce(async () => ({ 'X@dokploy-production': 'theirs' })); + const done = await reconcileDeployConflicts(input(folder, adapter, { interactive: true })); + expect(ask).toHaveBeenCalledTimes(2); + expect((ask.mock.calls[0][0] as readonly Conflict[]).map((c) => c.kind)).toEqual(['source']); + expect(ask.mock.calls[1][0]).toEqual([expect.objectContaining({ kind: 'target', name: 'X', yours: hashValue(THEIRS), theirs: platform })]); + expect(done).toMatchObject({ ok: true, skipped: ['X'] }); + expect(done.ok && done.targetCheck?.resolutions).toEqual({ X: { choice: 'theirs', theirs: platform, yours: hashValue(THEIRS) } }); + }); + test('answering only the source conflict leaves the target one open: the remaining names come back and nothing is written', async () => { const { fake, folder } = world({ X: MINE, Y: OLD.Y }); const adapter = adapterWith([live]); diff --git a/tests/commands/deployDirectModeTiming.test.ts b/tests/commands/deployDirectModeTiming.test.ts index 7630dce2..e90785a4 100644 --- a/tests/commands/deployDirectModeTiming.test.ts +++ b/tests/commands/deployDirectModeTiming.test.ts @@ -69,6 +69,10 @@ 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; @@ -97,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) }], }, }); } @@ -152,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)', () => { @@ -207,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 a05ca09a..ae45bab6 100644 --- a/tests/commands/deployDokploySystemStoreToken.test.ts +++ b/tests/commands/deployDokploySystemStoreToken.test.ts @@ -65,6 +65,7 @@ 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()}`); @@ -96,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'); diff --git a/tests/commands/deployDokployWarningOnce.test.ts b/tests/commands/deployDokployWarningOnce.test.ts index 37a87ebe..7f4341f9 100644 --- a/tests/commands/deployDokployWarningOnce.test.ts +++ b/tests/commands/deployDokployWarningOnce.test.ts @@ -65,6 +65,7 @@ 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'; @@ -91,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`); diff --git a/tests/commands/deployRevokeWiring.test.ts b/tests/commands/deployRevokeWiring.test.ts index 3585d4ff..af5694eb 100644 --- a/tests/commands/deployRevokeWiring.test.ts +++ b/tests/commands/deployRevokeWiring.test.ts @@ -156,6 +156,7 @@ 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'; /** @@ -195,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 } : {}), diff --git a/tests/commands/rotateConflicts.test.ts b/tests/commands/rotateConflicts.test.ts index 726f1759..137eda5d 100644 --- a/tests/commands/rotateConflicts.test.ts +++ b/tests/commands/rotateConflicts.test.ts @@ -5,10 +5,16 @@ * * The properties: with the protocol on and the server's value moved since the folder's pin, nothing is rotated and the * payload comes back (exit 3); `theirs` drops the variable from the run (nothing is issued for a value that would not - * be written); `mine` keeps it and hands the answer to the write; a variable nobody changed passes untouched; at a - * terminal (no deciding context) the step is a no-op. + * be written); `mine` keeps it and hands the answer to the write; a variable nobody changed passes untouched. + * At a terminal (no deciding context) the same step ASKS (mine / theirs per variable) before anything is issued; + * cancelling the question ends it with nothing rotated or written. */ import { describe, test, expect, afterEach, mock, spyOn } from 'bun:test'; + +// The terminal's question. `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). +const ask = mock(async (_conflicts: readonly unknown[]) => ({}) as Record); +mock.module('../../src/commands/conflictPrompt', () => ({ askConflicts: ask })); + import { RotateCommand } from '../../src/commands/rotateCommand'; import { parseConflictFlags, protocolDecider } from '../../src/sync/conflicts'; import { hashValue } from '../../src/sync/compareSecrets'; @@ -26,7 +32,11 @@ const OLD_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: OLD } }); const NEW_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: { STRIPE: THEIRS, OTHER: OLD.OTHER } } }); const made = mock((_: Folder) => undefined); -afterEach(() => made.mock.calls.forEach(([folder]) => removeFolder(folder))); +afterEach(() => { + made.mock.calls.forEach(([folder]) => removeFolder(folder)); + ask.mockReset(); + ask.mockImplementation(async () => ({})); +}); const connector = (): ConnectorMetadata => ({ provider: 'stripe', source: 'cli', created_at: 1, fingerprint: 'fp' }) as unknown as ConnectorMetadata; const targets = (...names: string[]) => names.map((varName) => ({ varName, connector: connector() })); @@ -100,8 +110,47 @@ describe('capy rotate: conflicts are settled before the new key is issued', () = expect(done.value?.resolutions).toEqual({}); }); - test('at a terminal (no deciding context) and with --no-push the step does nothing', async () => { - expect((await captured(() => world(undefined).settle(targets('STRIPE')))).value?.rotate).toHaveLength(1); + test('with --no-push the step does nothing', async () => { expect((await captured(() => world({}).settle(targets('STRIPE'), { noPush: true }))).value?.rotate).toHaveLength(1); + expect((await captured(() => world(undefined).settle(targets('STRIPE'), { noPush: true }))).value?.rotate).toHaveLength(1); + expect(ask).not.toHaveBeenCalled(); + }); +}); + +describe('capy rotate at a terminal: the conflict is asked BEFORE the new key is issued', () => { + test('a variable the server changed: the question comes first (names and fingerprints only), and nothing is written before the answer', async () => { + const { server, settle } = world(undefined); + ask.mockImplementationOnce(async () => ({ STRIPE: 'mine' })); + const done = await captured(() => settle(targets('STRIPE'))); + expect(ask).toHaveBeenCalledTimes(1); + expect(ask.mock.calls[0][0]).toEqual([expect.objectContaining({ kind: 'source', name: 'STRIPE', pinned: hashValue(OLD.STRIPE), theirs: hashValue(THEIRS) })]); + expect(done.exitCode).toBeUndefined(); + expect(server.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('theirs drops the variable from the run (no key is issued for it); mine keeps it and carries the answer to the write', async () => { + ask.mockImplementationOnce(async () => ({ STRIPE: 'theirs' })); + const theirs = await captured(() => world(undefined).settle(targets('STRIPE', 'OTHER'))); + expect(theirs.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); + + ask.mockImplementationOnce(async () => ({ STRIPE: 'mine' })); + const mine = await captured(() => world(undefined).settle(targets('STRIPE', 'OTHER'))); + expect(mine.value?.rotate.map((t) => t.varName)).toEqual(['STRIPE', 'OTHER']); + expect(mine.value?.resolutions).toEqual({ STRIPE: 'mine' }); + }); + + test('cancelling the question ends the step with the prompt\'s own error: nothing is rotated and nothing is written', async () => { + const { server, settle } = world(undefined); + ask.mockImplementationOnce(async () => { + throw new Error('prompt cancelled'); + }); + await expect(settle(targets('STRIPE'))).rejects.toThrow('prompt cancelled'); + expect(server.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('a variable nobody else changed is not asked about', async () => { + const done = await captured(() => world(undefined).settle(targets('OTHER'))); + expect(ask).not.toHaveBeenCalled(); + expect(done.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); }); }); diff --git a/tests/commands/secretsDeployConflicts.test.ts b/tests/commands/secretsDeployConflicts.test.ts index 4adcbee9..839eb601 100644 --- a/tests/commands/secretsDeployConflicts.test.ts +++ b/tests/commands/secretsDeployConflicts.test.ts @@ -66,14 +66,20 @@ async function capture(fn: () => Promise): Promise { const LIVE = 'deadbeefdeadbeef'; const conflictFor = (theirs: string = LIVE) => ({ kind: 'target' as const, name: NAME, target: 'api', pinned: null, yours: hashOf(NAME), theirs }); -/** A Dokploy adapter that compares live values: `api`'s API_KEY is in conflict until an answer is given. */ -function liveAdapter(theirs: string = LIVE) { +/** + * 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 `theirs`). + */ +function liveAdapter(theirs: string = LIVE, liveAtWrite: string = theirs) { 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 answer = ctx.targetCheck.resolutions[NAME]; - if (ctx.dryRun) return { ok: true, steps: [], conflicts: [conflictFor(theirs)] }; - if (answer === undefined) return { ok: false, steps: [{ label: 'application.saveEnvironment', status: 'fail', code: 'SECRETS_CONFLICT' }], conflicts: [conflictFor(theirs)] }; + const now = ctx.dryRun ? theirs : liveAtWrite; + const seen = ctx.targetCheck.resolutions[NAME]; + const answer = seen !== undefined && seen.theirs === now && seen.yours === 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 === 'theirs' ? { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }], skipped: [NAME] } : { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; @@ -146,7 +152,7 @@ describe('capy secrets deploy: target conflicts', () => { 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]: 'mine' }); + expect(api?.[1].targetCheck?.resolutions).toEqual({ [NAME]: { choice: 'mine', theirs: LIVE, yours: hashOf(NAME) } }); expect(r.service.pushSecrets.mock.calls.length).toBeGreaterThan(0); expect(r.github.createPull.mock.calls).toHaveLength(3); }); @@ -175,6 +181,17 @@ describe('capy secrets deploy: target conflicts', () => { 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=mine`], 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); diff --git a/tests/deploy/dokployTargetConflict.test.ts b/tests/deploy/dokployTargetConflict.test.ts index 2fe0437a..8e0a46c7 100644 --- a/tests/deploy/dokployTargetConflict.test.ts +++ b/tests/deploy/dokployTargetConflict.test.ts @@ -59,12 +59,19 @@ describe('dokployTargetConflicts (pure)', () => { const check = { pinned: { STRIPE_KEY: h('old-delivered') }, resolutions: {} }; const open = planTargetDelivery(env, both, check); expect(open.kind).toBe('conflict'); - const mine = planTargetDelivery(env, both, { ...check, resolutions: { STRIPE_KEY: 'mine' } }); + const seen = (choice: 'mine' | 'theirs') => ({ STRIPE_KEY: { choice, theirs: h('edited-outside'), yours: h('new-capy') } }); + const mine = planTargetDelivery(env, both, { ...check, resolutions: seen('mine') }); expect(mine).toMatchObject({ kind: 'clear', skipped: [] }); expect(mine.kind === 'clear' && mine.deliver.map((v) => v.name)).toEqual(['STRIPE_KEY', 'PORT']); - const theirs = planTargetDelivery(env, both, { ...check, resolutions: { STRIPE_KEY: 'theirs' } }); + const theirs = planTargetDelivery(env, both, { ...check, resolutions: seen('theirs') }); expect(theirs).toMatchObject({ kind: 'clear', skipped: ['STRIPE_KEY'] }); expect(theirs.kind === 'clear' && theirs.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('mine') }); + expect(moved.kind).toBe('conflict'); + expect(moved.kind === 'conflict' && moved.open.map((c) => c.theirs)).toEqual([h('moved-after-the-answer')]); + const otherYours = planTargetDelivery(env, [{ name: 'STRIPE_KEY', value: 'another-capy-value' }], { ...check, resolutions: seen('theirs') }); + expect(otherYours.kind).toBe('conflict'); // No check at all: the delivery is as it always was. expect(planTargetDelivery(env, both, undefined)).toMatchObject({ kind: 'clear', skipped: [], found: [] }); }); @@ -169,7 +176,11 @@ const ctx = (over: Partial): DeployContext => ({ const adapterWith = (fetch: FetchLike) => createDokployAdapter({ fetch, log: () => undefined }); const OUTSIDE = 'NODE_ENV=production\nSTRIPE_KEY=already-there\n'; -const check = (resolutions: Record = {}, pinned: Record = {}) => ({ pinned, resolutions }); +/** 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, theirs: h('already-there'), yours: 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 () => { @@ -203,6 +214,32 @@ describe('the Dokploy adapter and target conflicts', () => { 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 (mine and theirs 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 answeredMine = scripted([read(MOVED)]); + const mine = await adapterWith(answeredMine.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'mine' }) })); + expect(mine.ok).toBe(false); + expect(mine.steps.at(-1)?.code).toBe('SECRETS_CONFLICT'); + expect(mine.conflicts).toEqual([ + { kind: 'target', name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, yours: h('new-capy'), theirs: h('moved-after-the-answer') }, + ]); + expect(answeredMine.done()).toBe(true); + + const answeredTheirs = scripted([read(MOVED)]); + const theirs = await adapterWith(answeredTheirs.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'theirs' }) })); + expect(theirs.ok).toBe(false); + expect(theirs.skipped).toBeUndefined(); + expect(answeredTheirs.done()).toBe(true); + + // The value being delivered moved after the answer (the decision saw 'new-capy'): the same. + const changedYours = scripted([read(OUTSIDE)]); + const other = await adapterWith(changedYours.fetch).deploy(target(), ctx({ env: { STRIPE_KEY: 'newer-capy', PORT: '3000' }, targetCheck: check({ STRIPE_KEY: 'mine' }) })); + expect(other.ok).toBe(false); + expect(other.conflicts?.[0]).toMatchObject({ yours: h('newer-capy'), theirs: h('already-there') }); + expect(changedYours.done()).toBe(true); + }); + test('theirs 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: 'theirs' }) })); diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 121b9fea..cb4266c3 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -44,6 +44,7 @@ ISOLATED_FILES=( tests/commands/deployCiChangeGate.test.ts tests/commands/deployCiTargetsRecord.test.ts tests/commands/deployConflicts.test.ts + tests/commands/rotateConflicts.test.ts tests/commands/deployTargetConflict.test.ts tests/commands/deployRevokeWiring.test.ts tests/commands/deployDirectModeTiming.test.ts diff --git a/tests/sync/conflicts.test.ts b/tests/sync/conflicts.test.ts index e39d3dd8..f4ad11f0 100644 --- a/tests/sync/conflicts.test.ts +++ b/tests/sync/conflicts.test.ts @@ -13,6 +13,7 @@ import { parseConflictFlags, protocolDecider, resolveKeyOf, + seenResolutions, sourceResolutions, targetResolutions, type Conflict, @@ -169,6 +170,15 @@ describe('gateConflicts', () => { expect(targetResolutions(decided, 'elsewhere')).toEqual({}); }); + test('seenResolutions binds each decision to the fingerprints of the conflict it answered, and drops answers to nothing', () => { + const decided = gateConflicts(flags(['A=mine', 'B=theirs', 'A@dokploy-production=theirs']), scopes); + const seen = seenResolutions({ ...decided, 'ZZZ@dokploy-production': 'mine' }, scopes.flatMap((s) => s.conflicts)); + const target = scopes.flatMap((s) => s.conflicts).find((c) => c.kind === 'target')!; + expect(seen['A@dokploy-production']).toEqual({ choice: 'theirs', theirs: target.theirs, yours: target.yours }); + expect(Object.keys(seen).toSorted()).toEqual(['A', 'A@dokploy-production', 'B']); + expect(targetResolutions(seen, 'dokploy-production')).toEqual({ A: seen['A@dokploy-production'] }); + }); + test('answers for names that are not in the conflict are ignored, never applied', () => { expect(gateConflicts(flags(['A=mine', 'B=mine', 'A@dokploy-production=mine', 'ZZZ=theirs']), scopes)).toEqual({ A: 'mine', From c4732c26f60a0f92618e989d89eb09c14e02b90d Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 03:25:14 -0700 Subject: [PATCH 04/12] feat(ui): resolve conflicts inside capy edit, capy secrets edit and batch deploy; generated command list - capy edit compares against the server's latest (not the folder's pin), shows local / remote / conflict honestly, resolves a conflict row with m (mine) or t (theirs), and blocks commit until every conflict is decided. Decisions are bound to the values shown; a server move between showing and committing reopens the conflict. - capy secrets edit reads every location first and asks mine/theirs for a location that changed since you looked; nothing is written before. - The batch deploy screen shows Dokploy values changed outside Capy and asks before delivering. - The unknown-command list is generated from the registered commands. --- src/commands/editCommand.ts | 299 ++-- src/commands/secretsDeployActions.ts | 49 +- src/commands/secretsEditActions.ts | 78 +- src/commands/secretsSet.ts | 36 + src/commands/secretsSetCommand.ts | 25 +- src/core/availableCommands.ts | 39 + src/index-dev.ts | 22 +- src/index.ts | 27 +- src/sync/commit.ts | 32 +- src/ui/editScreen.ts | 1417 ++++++++++--------- src/ui/secretsDeployFlow.ts | 85 +- src/ui/secretsEditFlow.ts | 141 +- src/ui/secretsScreenDriver.ts | 9 +- tests/commands/editConflicts.test.ts | 450 ++++++ tests/commands/secretsEditConflicts.test.ts | 166 +++ tests/core/availableCommands.test.ts | 93 ++ tests/ui/secretsDeployConflicts.test.ts | 271 ++++ tests/ui/secretsEditConflicts.test.ts | 183 +++ tests/ui/secretsEditConflictsDriver.test.ts | 78 + 19 files changed, 2603 insertions(+), 897 deletions(-) create mode 100644 src/core/availableCommands.ts create mode 100644 tests/commands/editConflicts.test.ts create mode 100644 tests/commands/secretsEditConflicts.test.ts create mode 100644 tests/core/availableCommands.test.ts create mode 100644 tests/ui/secretsDeployConflicts.test.ts create mode 100644 tests/ui/secretsEditConflicts.test.ts create mode 100644 tests/ui/secretsEditConflictsDriver.test.ts diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 202708f7..43c4d230 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -3,17 +3,16 @@ import { FileManager } from '../files/fileManager'; import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import { SyncEngine } from '../sync/syncEngine'; -import { fetchSecretsWithCache, readKeepCache, 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 } from '../ui/editScreen'; import { deployedHashesFor } from '../core/deployStatus'; import { readDefaultBranchTargets } from '../deploy/configuredTargets'; -import { formatRelativeTime } from '../ui/relativeTime'; import { KeepFile } from '../types/index'; -import { commitFolder, pinnedHashes, readLocalPlaintext, type FolderContext } from '../sync/commit'; -import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; +import { commitFolder, pinnedHashes, readLocalPlaintext, readSnapshot, type FolderContext, type Snapshot } from '../sync/commit'; +import { conflictFlagsOrExit, type RawConflictFlags, type Resolutions, type SeenResolutions } from '../sync/conflicts'; import { reportKeepLockHuman, refuseBadPrFlags, runKeepLockPrStep, type PrFlags } from './keepLockPr'; import { startSaveLog } from './sessionSaveLog'; import { decideEditMode, editPipedCommand, refuseEditNeedsTty } from './editPiped'; @@ -23,113 +22,175 @@ 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'; +/** + * 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 the commit path plans against (`seen`), never the folder's pinned + * snapshot. Local-only mode: the committed copy in the local cache, with no server. + */ +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, + * yours, theirs) 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; - } +const has = (record: object, name: string): boolean => Object.hasOwn(record, name); + +/** + * The decisions that still hold: each is kept only if the fingerprints it was made against are the ones in this read of + * the server (`seen`) and in the value about to be written. With no read to bind to, none holds. + */ +export function boundResolutions( + decisions: SeenResolutions, + seen: Snapshot | undefined, + set: Readonly>, +): Resolutions { + if (seen === undefined) return {}; + return Object.fromEntries( + Object.entries(decisions) + .filter(([name, d]) => d.theirs === (seen.entries[name]?.hash ?? null) && d.yours === (has(set, name) ? hashValue(set[name]) : null)) + .map(([name, d]) => [name, d.choice] as const), + ); } /** * One save from the edit screen (`c`, or commit on quit). The one commit path (sync/commit.ts): - * only the edited rows change, on top of the server's latest. A row someone else changed since - * this folder's pin is a `SECRETS_CONFLICT` (nothing uploaded), which the screen shows as the - * error. 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 + * only the edited rows (and the conflict rows the person decided) change, on top of the server's + * latest. A decision applies only to the values it was made against (`boundResolutions`); a name + * someone else changed since the screen read it is a `SECRETS_CONFLICT` (nothing uploaded), which + * the screen answers by reading again and showing it as a conflict. 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( @@ -137,6 +198,8 @@ export async function saveEdits( startKeep: KeepFile, edits: Readonly>, saveLog: Pick, 'record'>, + /** The decisions made on the screen, with the server read they were made against (CAP-714). */ + bound?: { readonly decisions: SeenResolutions; readonly seen: Snapshot | undefined }, ): Promise> { const { branch } = folder; const committed = await commitFolder( @@ -146,6 +209,7 @@ export async function saveEdits( localPlaintext: readLocalPlaintext(folder.fileManager, folder.projectKey), }, { set: edits, remove: [] }, + { resolutions: boundResolutions(bound?.decisions ?? {}, bound?.seen, edits), seen: bound?.seen }, ); const adoptedKeep = committed.keep; @@ -298,9 +362,6 @@ export class EditCommand { } const fileManager = new FileManager(); - // Pinned hashes for the active branch - const pinned = pinnedHashes(keep, branch); - // 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 @@ -314,69 +375,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 = readLocalPlaintext(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). @@ -393,8 +403,9 @@ export class EditCommand { const saveLog = startSaveLog(); const editContext = { - saveLocalEdits: (edits: Record) => - saveEdits({ pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey }, keep, edits, saveLog), + saveLocalEdits: (edits: Record, bound?: Parameters[4]) => + saveEdits({ pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey }, keep, edits, saveLog, bound), + reload: load, }; // CAP-697: started now, collected after the TUI closes (it has long since finished by then). diff --git a/src/commands/secretsDeployActions.ts b/src/commands/secretsDeployActions.ts index f9319a91..42b04f97 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. // +// CONFLICTS (CAP-714): 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 conflict, with nothing written; the screen asks mine or theirs for +// each and runs again with the decisions. A decision is used only where the live value is still the +// one it was made against, otherwise the conflict comes back. Mine delivers Capy's value; theirs +// leaves the service's value and records nothing for it. +// // 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,42 @@ 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, + 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 conflicts its comparison pass found, the decisions 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.theirs === c.theirs && d.yours === c.yours; + }; + 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 target conflicts of a refusal, in the order it carries them. */ +const targetConflictsOf = (conflicts: readonly { readonly kind: string }[]): readonly TargetConflict[] => + conflicts.filter((c): c is TargetConflict => c.kind === 'target'); + /** * 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 +93,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 conflicts that are undecided, or decided 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/secretsEditActions.ts b/src/commands/secretsEditActions.ts index 6c780258..cdfaf9c9 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. +// +// CONFLICTS (CAP-714): before any push, `run` reads every location. One whose value is neither +// the new value nor the one the row listed was changed by someone else since the person looked: +// `run` returns those as conflicts and writes nothing. The screen asks mine or theirs for each and +// runs again with the decisions; a decision holds only for the fingerprints it was made against, +// so a location that moved again comes back as a conflict. Mine writes the location bound to the +// value seen (`allowed`); theirs skips it. 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, + previewLocations, + repoLabel, + resolveBases, + runSecretSet, + secretLocationConflict, +} from './secretsSet'; +import { hashValue } from '../deploy/keepGate'; import { describePlanBody, type SetPlanBody } from './secretsSetCommand'; import type { DryRunView } from './secretsSetText'; import { CapyError, ERROR_CODES } from '../types/index'; @@ -44,6 +65,47 @@ 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 decisions. */ +export type SettledLocations = + | { + readonly kind: 'ready'; + readonly locations: readonly SetLocation[]; + /** The fingerprints a location may hold for its push to go on: what was seen and decided against. Absent: not compared. */ + readonly allowed?: (location: SetLocation) => readonly string[]; + } + | { readonly kind: 'conflicts'; readonly conflicts: readonly LocationConflict[] }; + +/** + * Pure. `reads` are the previews of `request.locations`, in order. A location holding neither the new value nor the one + * the row listed is a conflict. All decided, each against the fingerprints it holds now: the locations to push (theirs + * is skipped) and the binding for the push. Any undecided, or decided against a value that has moved since: the + * conflicts, 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 yours = hashValue(request.value); + const conflicts = request.locations.flatMap((location, i): LocationConflict[] => { + const conflict = secretLocationConflict(request.name, yours, seen, location, reads[i]); + return conflict === undefined ? [] : [{ location, conflict }]; + }); + const decisionFor = (c: LocationConflict) => request.decisions?.[locationKeyOf(c.location)]; + const holds = (c: LocationConflict): boolean => { + const d = decisionFor(c); + return d !== undefined && d.theirs === c.conflict.theirs && d.yours === c.conflict.yours; + }; + if (conflicts.some((c) => !holds(c))) return { kind: 'conflicts', conflicts }; + const kept = new Set(conflicts.filter((c) => decisionFor(c)?.choice === 'theirs').map((c) => locationKeyOf(c.location))); + return { + kind: 'ready', + locations: request.locations.filter((l) => !kept.has(locationKeyOf(l))), + allowed: (loc) => { + const at = request.locations.findIndex((l) => locationKeyOf(l) === locationKeyOf(loc)); + return [yours, seen, reads[at]?.current?.hash ?? '']; + }, + }; +} + type CancelPhase = Parameters[0]; /** @@ -81,9 +143,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, allowed: settled.allowed, 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 abe06c3d..56342c36 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -32,6 +32,7 @@ import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; import { SyncEngine } from '../sync/syncEngine'; import { isStaleBase } from '../sync/commit'; +import type { SourceConflict } from '../sync/conflicts'; import { hashValue } from '../deploy/keepGate'; import type { EditSaveRecord } from '../deploy/keepGate'; import { writeKeepCache } from '../config/globalConfig'; @@ -419,6 +420,41 @@ export interface LocationPreview { readonly current?: { readonly hash: string; readonly serverKeepHash: string; readonly changedAt?: string }; } +/** A location in conflict: its value was changed by someone else since the caller looked (CAP-717, CAP-714). */ +export interface LocationConflict { + readonly location: SetLocation; + readonly conflict: SourceConflict; +} + +/** The identity a decision for a location is kept under. */ +export const locationKeyOf = (l: Pick): string => `${l.project_id}/${l.branch}`; + +/** + * The conflict at `loc`, if any: the location holds neither the value being set (`yours`) nor the one its row listed + * (`seen`), so someone else changed it since the caller looked. Fingerprints only. `undefined` when the location could + * not be read (nothing known to conflict with) or holds one of the two. + */ +export function secretLocationConflict( + name: string, + yours: string, + seen: string, + loc: SetLocation, + preview: LocationPreview | undefined, +): SourceConflict | undefined { + const current = preview?.current; + if (current === undefined || current.hash === yours || current.hash === seen) return undefined; + return { + kind: 'source', + name, + project: loc.project_name, + branch: loc.branch, + pinned: seen, + yours, + theirs: current.hash, + ...(current.changedAt === undefined ? {} : { theirs_changed_at: current.changedAt }), + }; +} + const UNKNOWN_PREVIEW: LocationPreview = { action: 'update', serverKeep: undefined }; /** One location's dry run. `keysFor` is asked only when a value is given (the only case that needs a key). */ diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index 876b382f..30888518 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -67,6 +67,7 @@ import { repoLabel, resolveBases, runSecretSet, + secretLocationConflict, } from './secretsSet'; import { renderSecretSetConfirmation, stoppingAfter, TERMINAL_STYLE } from './secretsSetText'; import { PoolItem, runPool } from '../utils/pool'; @@ -204,27 +205,11 @@ export function secretConflictScopes( ): readonly ConflictScope[] { const yours = hashValue(value); return located.flatMap((l, i): ConflictScope[] => { + const conflict = secretLocationConflict(name, yours, l.seen, l, previews[i]); const current = previews[i]?.current; - if (current === undefined || current.hash === yours || current.hash === l.seen) return []; - return [ - { - project: l.project_name, - branch: l.branch, - serverKeepHash: current.serverKeepHash, - conflicts: [ - { - kind: 'source', - name, - project: l.project_name, - branch: l.branch, - pinned: l.seen, - yours, - theirs: current.hash, - ...(current.changedAt === undefined ? {} : { theirs_changed_at: current.changedAt }), - }, - ], - }, - ]; + return conflict === undefined || current === undefined + ? [] + : [{ project: l.project_name, branch: l.branch, serverKeepHash: current.serverKeepHash, conflicts: [conflict] }]; }); } 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/index-dev.ts b/src/index-dev.ts index 7d9ef431..475b4cad 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -103,26 +103,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); } diff --git a/src/index.ts b/src/index.ts index db0f701c..6d29075a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,31 +88,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); } diff --git a/src/sync/commit.ts b/src/sync/commit.ts index 45cda652..edb31c76 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -100,6 +100,21 @@ export interface Contest { readonly theirs: string | undefined; } +/** + * Whether writing `yours` (a value, or `undefined` for a deletion) over the server's `latest` meets a value the + * server moved since `pinned`: the server's value differs from the pin (including added or removed since) AND + * writing would actually change it (the intended value is not already the server's; a deletion of a value the + * server still has). The one definition `findConflicts` and a screen's row status share. + */ +export function isContested( + pinned: string | undefined, + yours: string | undefined, + latest: Pick | undefined, +): boolean { + const changesIt = yours === undefined ? latest !== undefined : latest?.plaintext !== yours; + return changesIt && latest?.hash !== pinned; +} + /** * Every change in `changes` that meets a value the server moved since `pinned` (different hash, * including added or removed on the server since) and that would actually change it: a name in @@ -112,15 +127,16 @@ export function findConflicts(input: { readonly changes: Changes; }): readonly Contest[] { const { pinned, latest, changes } = input; - const writes = Object.entries(changes.set) - .filter(([name, value]) => latest[name]?.plaintext !== value) - .map(([name, value]) => ({ name, yours: hashValue(value) as string | undefined })); - const removals = changes.remove - .filter((name) => !has(changes.set, name) && has(latest, name)) - .map((name) => ({ name, yours: undefined as string | undefined })); + const writes = Object.entries(changes.set).map(([name, value]) => ({ name, value })); + const removals = changes.remove.filter((name) => !has(changes.set, name)).map((name) => ({ name, value: undefined as string | undefined })); return [...writes, ...removals] - .filter(({ name }) => latest[name]?.hash !== pinned[name]) - .map(({ name, yours }) => ({ name, pinned: pinned[name], yours, theirs: latest[name]?.hash })) + .filter(({ name, value }) => isContested(pinned[name], value, has(latest, name) ? latest[name] : undefined)) + .map(({ name, value }) => ({ + name, + pinned: pinned[name], + yours: value === undefined ? undefined : hashValue(value), + theirs: latest[name]?.hash, + })) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index e5408c9c..bc835d61 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -1,9 +1,22 @@ // 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, yours, theirs); see `reclassifyRow`. A row both +// sides changed is a `conflict`: `m` keeps yours (mine), `t` takes the server's (theirs), +// and nothing is committed while one is undecided. + +import { EventEmitter } from 'node:events'; import { formatSnippet, hashValue } from '../commands/statusCommand'; import { anyTargetBehind } from '../core/deployStatus'; -import { statusBadge } from './statusBadge'; +import { isContested, isSecretsConflict, type Snapshot } from '../sync/commit'; +import type { Resolution, SeenResolution, SeenResolutions } from '../sync/conflicts'; +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 +49,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, yours (`localValue`), theirs (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; + /** The decision for a `conflict` row, with the fingerprints it was made against (CAP-714). Valid only while they still match. */ + readonly resolution?: SeenResolution; } 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 planned against it, so a decision binds to what was shown. */ + readonly seen?: Snapshot; } /** @@ -96,21 +121,64 @@ 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 }; +} + +/** What a commit carries (CAP-714). */ +export interface EditCommitRequest { + /** Every value the commit is about: the edits typed this session, and each conflict row decided (its own value, so the decision has something to bind to). */ + readonly set: Readonly>; + /** The decisions of the conflict rows, with the fingerprints they were made against. */ + readonly decisions: SeenResolutions; + /** 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`; `bound` carries the decisions and the server read they were + * made against. 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_CONFLICT` (nothing uploaded) when the server moved under a change. */ - saveLocalEdits: (edits: Record) => Promise>; + readonly saveLocalEdits: ( + set: Record, + bound?: { readonly decisions: SeenResolutions; readonly seen: Snapshot | undefined }, + ) => Promise>; + /** Re-reads the folder and the server (after a save, or a conflict). Absent: rows are updated from what the save returned. */ + readonly reload?: () => Promise; } +// ── Rows: fingerprints, status, decisions ─────────────────────────────────── + +/** The server's fingerprint for the row (`null`: the server has no such variable). */ +const theirsOf = (row: EditRow): string | null => row.remoteHash ?? (row.remoteValue === undefined ? null : hashValue(row.remoteValue)); + +/** The fingerprint of the row's working value (`null`: none). */ +const yoursOf = (row: EditRow): string | null => (row.localValue === undefined ? null : hashValue(row.localValue)); + +/** The decision for a conflict row, if it was made against the values the row holds now. */ +export function choiceOf(row: EditRow): Resolution | undefined { + const decided = row.resolution; + if (row.status !== 'conflict' || decided === undefined) return undefined; + return decided.theirs === theirsOf(row) && decided.yours === yoursOf(row) ? decided.choice : undefined; +} + +/** `row` decided: `mine` keeps its working value, `theirs` takes the server's. Bound to the values it holds now. */ +export const resolvedAs = (row: EditRow, choice: Resolution): EditRow => ({ + ...row, + resolution: { choice, theirs: theirsOf(row), yours: yoursOf(row) }, +}); + +const isUnresolved = (row: EditRow): boolean => row.status === 'conflict' && choiceOf(row) === undefined; + /** * 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 +195,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; the same test the commit path applies, `isContested`), `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 +227,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 +241,37 @@ 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 yours = yoursOf(row) ?? undefined; + const theirs = theirsOf(row) ?? undefined; + if (yours === theirs) return 'in sync'; + const youChanged = yours !== pinned; + const theyChanged = theirs !== pinned; + // A value you hold that the server moved under: the commit path's own test. (A variable missing from the folder is not a + // change to keep: the server's value is pulled.) + const latest = theirs === undefined ? undefined : { plaintext: row.remoteValue, hash: theirs }; + if (youChanged && theyChanged && row.localValue !== undefined && isContested(pinned, row.localValue, latest)) return 'conflict'; + return youChanged && !theyChanged ? '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 +290,686 @@ 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; + 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; +} - 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 = +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; + : null, + status: null, + scrollOffset: 0, + pending: {}, + quitPrompt: false, + popupOpen: entering, + popupPan: 0, + paste: null, + saving: null, + }; +} - 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(); +/** Names with something to commit: edits typed, and conflict rows decided. */ +const dirtyKeys = (model: EditModel): readonly string[] => [ + ...new Set([...Object.keys(model.pending), ...model.state.rows.filter((r) => choiceOf(r) !== undefined).map((r) => r.key)]), +]; - this.draw(); +const withRows = (model: EditModel, rows: readonly EditRow[]): EditModel => ({ ...model, state: { ...model.state, rows } }); - this.onDataHandler = (data: Buffer) => this.handleKeypress(data, resolve); - process.stdin.on('data', this.onDataHandler); +const replaceRow = (model: EditModel, key: string, change: (row: EditRow) => EditRow): EditModel => + withRows(model, model.state.rows.map((r) => (r.key === key ? change(r) : r))); - this.onResizeHandler = () => this.draw(); - process.on('SIGWINCH', this.onResizeHandler); +const modeOf = (model: EditModel): { localMode?: boolean; remoteAvailable: boolean } => ({ + localMode: model.state.localMode, + remoteAvailable: model.state.remoteAvailable, +}); - const exit = () => { - this.cleanup(); - resolve(); - }; - process.on('SIGINT', exit); - process.on('SIGTERM', exit); - }); - } +// ── Actions, effects, outcome ─────────────────────────────────────────────── - 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); - } +/** What a save came back with. Never carries a value. */ +export type SaveResult = + | { readonly kind: 'saved'; readonly changedAt: Readonly>; readonly loaded: EditLoaded | undefined } + /** The server moved under a change (`SECRETS_CONFLICT`): nothing was uploaded. */ + | { readonly kind: 'conflict'; readonly message: string; readonly loaded: EditLoaded | undefined } + | { readonly kind: 'failed'; readonly message: string }; - 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; - } - } +export type EditAction = + | { readonly kind: 'data'; readonly data: string } + | { readonly kind: 'saved'; readonly result: SaveResult }; - 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 EditEffect = { readonly type: 'save'; readonly request: EditCommitRequest }; - // 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 interface EditOutcome { + readonly model: EditModel; + readonly effect?: EditEffect; + /** The screen is over. */ + readonly done?: true; +} - 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(); - } - } +const stay = (model: EditModel): EditOutcome => ({ model }); +const finished = (model: EditModel): EditOutcome => ({ model, done: true }); + +// ── 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 }; - 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(); + // 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 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; - } + // 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; +} + +/** The unresolved conflicts' names, in row order. */ +const unresolvedNames = (model: EditModel): readonly string[] => model.state.rows.filter(isUnresolved).map((r) => r.key); + +/** + * `c`: the commit this screen would make, or why it cannot be made yet. A conflict nobody decided blocks it + * (nothing is uploaded). Otherwise: the edits, plus each decided conflict row with its value and its decision. + */ +function requestCommit(model: EditModel, quit: boolean): EditOutcome { + const open = unresolvedNames(model); + if (open.length > 0) { + return stay({ + ...model, + quitPrompt: false, + status: { text: `Choose mine or theirs for ${open.join(', ')}. Nothing was uploaded.`, isError: true }, // COPY-FLAG + }); } + const decided = model.state.rows.filter((r) => choiceOf(r) !== undefined); + const set = { + ...model.pending, + ...Object.fromEntries(decided.flatMap((r) => (r.localValue === undefined ? [] : [[r.key, r.localValue] as const]))), + }; + const request: EditCommitRequest = { + set, + decisions: Object.fromEntries(decided.flatMap((r) => (r.resolution === undefined ? [] : [[r.key, r.resolution] as const]))), + seen: model.state.seen, + }; + const keys = Object.keys(set); + 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 }); + +/** `m` / `t` on a conflict row: the decision, shown on the row until it is committed. */ +function decideRow(model: EditModel, choice: Resolution): EditModel { + const row = model.state.rows[model.cursorIndex]; + if (!row || row.status !== 'conflict') return model; + return { ...replaceRow(model, row.key, (r) => resolvedAs(r, choice)), status: null }; +} - 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(); - } +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); - } + // m / t — mine or theirs for the conflict under the cursor (no other key on this screen is bound to either). + if (key === 'm' || key === 'M') return stay(decideRow(model, 'mine')); + if (key === 't' || key === 'T') return stay(decideRow(model, 'theirs')); - 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'); - } + if (key === 'c' || key === 'C') return dirtyKeys(model).length > 0 ? requestCommit(model, false) : stay(model); + return stay(model); +} - 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', +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); +} + +// ── A save coming back ────────────────────────────────────────────────────── + +/** `loaded`'s rows, with what this screen holds that the disk and the server cannot know: typed values, and decisions (valid only while the values they were made against stand). */ +function mergedRows(model: EditModel, loaded: EditLoaded, pending: Readonly>, keepDecisions: boolean): 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 old = model.state.rows.find((r) => r.key === row.key); + const value = Object.hasOwn(pending, row.key) ? pending[row.key] : row.localValue; + return classifiedRow({ ...row, localValue: value, ...(keepDecisions && old?.resolution !== undefined ? { resolution: old.resolution } : {}) }, 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]); +} + +/** The model showing `loaded`, the cursor kept on the same variable. */ +function withLoaded(model: EditModel, loaded: EditLoaded, pending: Readonly>, keepDecisions: boolean): EditModel { + const rows = mergedRows(model, loaded, pending, keepDecisions); + 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, + }; +} + +/** 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), + resolution: undefined, + }; + return classifiedRow(agreed, modeOf(model)); + }), + ); +} + +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, {}, false); + return stay({ ...refreshed, status: { text: `Committed ${keys.length} change(s)`, isError: false } }); + } + if (result.kind === 'conflict' && result.loaded !== undefined) { + // The server moved between showing and committing: the rows are drawn from the new read, and a decision + // stands only where the values it was made against do (never an old decision on a new value). + const refreshed = withLoaded(settled, result.loaded, settled.pending, true); + const open = unresolvedNames(refreshed); + return stay({ + ...refreshed, + status: { + text: open.length > 0 ? `${open.join(', ')} changed on the server. Nothing was uploaded.` : 'The server changed. Nothing was uploaded.', // COPY-FLAG + isError: true, }, - ]; + }); } + return stay({ ...settled, status: { text: `Error committing: ${result.message}`, isError: true } }); +} - 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; - } +/** 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); +} - // 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; - } +// ── Rendering ─────────────────────────────────────────────────────────────── - /** 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 }; - } +const visLen = (str: string): number => str.replace(/\x1b\[[0-9;]*m/g, '').length; - // 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; - } +/** 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)) + '…'; +} - private statusBadge(status: EditRow['status']): string { - return statusBadge(status); - } +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, saying which side was chosen once a conflict is decided. */ +function rowBadge(row: EditRow): string { + const choice = choiceOf(row); + return statusBadge(row.status, choice === undefined ? statusLabel(row.status) : `conflict: ${choice}`); // COPY-FLAG +} + +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 unresolved = rows.filter(isUnresolved).length; + 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: unresolved > 0 ? 'need resolution' : conflicts > 0 ? 'all resolved' : 'all clear', // COPY-FLAG (all resolved) + }, + ]; +} + +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 }; +} + +const maskedOf = (value: string | undefined): string => (value === undefined || value === '' ? NO_VALUE : renderInlineValue(formatSnippet(value))); + +/** 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); - // --- helpers --- + const head = [rule, '', `${indent}${inner}${BOLD}${truncate(row.key, contentWidth)}${RESET}`, '']; - 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); + // 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 mine = valueField(row.localValue ?? row.remoteValue, revealed, valueContentWidth, model.popupPan, maskedSnippet(row)); + // A conflict shows both sides: the working value above, the server's under it (masked until revealed, like the first). + const theirs = + row.status === 'conflict' + ? [fieldVal('theirs', valueField(row.remoteValue, revealed, valueContentWidth, mine.pan, maskedOf(row.remoteValue)).text)] // COPY-FLAG + : []; + + return { + lines: [...head, fieldVal('status', rowBadge(row)), fieldVal('updated', row.updatedLabel), '', fieldVal(row.status === 'conflict' ? 'yours' : 'value', mine.text), ...theirs, '', rule], // COPY-FLAG (yours) + pan: mine.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}` : ''; + // On a conflict row, the two ways to settle it. + const onConflict = model.state.rows[model.cursorIndex]?.status === 'conflict'; + const conflictHint = onConflict ? `${DIM} · ${RESET}${BOLD}m${RESET}${DIM} mine · ${RESET}${BOLD}t${RESET}${DIM} theirs${RESET}` : ''; // COPY-FLAG + + 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}${conflictHint}${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}${conflictHint}${dirtyHint}${DIM} · ${RESET}${BOLD}q${RESET}${DIM} quit${RESET}`; +} - 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)) + '…'; +/** 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 ─────────────────────────────────────────────────────────────── + +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: a server that moved under a change is `conflict` + * (decided by the error's code), with the rows re-read so the screen can show them as conflicts again. + */ +export async function saveOutcome(ctx: EditContext, request: EditCommitRequest): Promise { + try { + const changedAt = await ctx.saveLocalEdits({ ...request.set }, { decisions: request.decisions, seen: request.seen }); + return { kind: 'saved', changedAt, loaded: await reloaded(ctx) }; + } catch (err) { + return isSecretsConflict(err) + ? { kind: 'conflict', message: messageOf(err), loaded: await reloaded(ctx) } + : { kind: 'failed', message: messageOf(err) }; } +} - private maskedSnippet(row: EditRow): string { - const value = row.localValue ?? row.remoteValue; - if (value === undefined || value === '') return NO_VALUE; - return renderInlineValue(formatSnippet(value)); +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 = (): 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(); + }; + + 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(); + 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); + }; + + 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..551481d4 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 +// conflict and the flow goes to `conflicts` (nothing has been written). +// conflicts one line per variable and target. m delivers Capy's value (mine), t leaves the +// service's (theirs: that variable is not delivered or recorded); Enter runs once every +// one is decided, Esc cancels with nothing written. A decision 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 Resolution, type SeenResolutions, type TargetConflict } 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 decision for each conflict, 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): Resolution | undefined => flow.choices[resolveKeyOf(c)]; + +/** Whether every conflict has been decided. */ +export const allDecided = (flow: Extract): boolean => flow.conflicts.every((c) => choiceAt(flow, c) !== undefined); + +/** The decisions made, 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 Resolution] 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: Resolution): DeployStep => { + const at = flow.conflicts[flow.cursor]; + return at === undefined ? stay(flow) : stay({ ...flow, choices: { ...flow.choices, [resolveKeyOf(at)]: choice } }); + }; + if (key === 'm' || key === 'M') return decide('mine'); + if (key === 't' || key === 'T') return decide('theirs'); + 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 (CAP-714). */ + | { 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) { + // A decision made 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.theirs === c.theirs && earlier.yours === c.yours ? [[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(['m', 'mine'], ['t', 'theirs'], ...(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..aacf7a00 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -9,14 +9,20 @@ // 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 conflict and the flow goes to `conflicts` (nothing has been written). +// conflicts one line per such location. m keeps your value there (mine), t leaves theirs (that +// location is skipped); Enter runs once every one is decided, Esc cancels with nothing +// written. A decision 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 +33,9 @@ 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 { seenResolutions, type Resolution, type SeenResolution } 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 +114,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 decision at each conflicting location, by `locationKeyOf`. */ + readonly choices: Readonly>; + readonly cursor: number; } & Carried) | { readonly step: 'done'; @@ -125,6 +143,13 @@ 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 decisions made for conflicting locations, by `locationKeyOf`, with the fingerprints they were made against. */ + readonly decisions?: Readonly>; } export type EditEffect = @@ -312,13 +337,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 +364,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 decision bound to the fingerprints it was made against (`seenResolutions`). */ +function withDecisions(flow: Extract): RunRequest { + return { + ...flow.request, + decisions: Object.fromEntries( + flow.conflicts.flatMap((c) => { + const choice = choiceAt(flow, c); + const bound = choice === undefined ? undefined : seenResolutions({ [c.conflict.name]: choice }, [c.conflict])[c.conflict.name]; + return bound === undefined ? [] : [[locationKeyOf(c.location), bound] 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 === 'm' || key === 'M') return decide('mine'); + if (key === 't' || key === 'T') return decide('theirs'); + if (key !== '\r' || !allDecided(flow)) return stay(flow); + const request = withDecisions(flow); + const kept = flow.conflicts.filter((c) => choiceAt(flow, c) === 'theirs').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 +448,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 +491,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 +503,39 @@ function finishedText(name: string, finished: RunFinished): string { return 'plan' in finished ? renderDryRunPlan(finished.plan) : renderSecretSetConfirmation(finished.result, TERMINAL_STYLE); } +/** Locations where the person kept the other value (theirs): 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. A decision 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 earlier !== undefined && earlier.theirs === c.conflict.theirs && earlier.yours === c.conflict.yours + ? [[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 === 'theirs').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 +582,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.conflict.theirs_changed_at === undefined ? '' : ` · changed ${formatRelativeTime(c.conflict.theirs_changed_at)}`; // COPY-FLAG + const line = `${i === flow.cursor ? '▶' : ' '} ${c.location.project_name} ${c.location.branch}${when} ${choice === undefined ? '—' : choice}`; + 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(['m', 'mine'], ['t', 'theirs'], ...(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 +666,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/tests/commands/editConflicts.test.ts b/tests/commands/editConflicts.test.ts new file mode 100644 index 00000000..53a04a7f --- /dev/null +++ b/tests/commands/editConflicts.test.ts @@ -0,0 +1,450 @@ +/** + * Conflicts inside `capy edit` (CAP-714), asserted as properties on the real screen, the real loader and the real + * commit path, against the in-memory server of tests/helpers/fakeCapyServer.ts: + * - the "remote" column is the server's LATEST, not the folder's pinned snapshot: a stale folder shows what the + * server changed as `remote`, and what both changed as `conflict`; + * - nothing is committed while a conflict is undecided (the server is byte-for-byte what it was); + * - mine writes the folder's value, theirs keeps the server's and `.env` takes it, remote rows are pulled; + * - the server moving between showing and committing reopens the conflict and overwrites nothing. + * + * World: the folder is pinned to an OLD snapshot. Since then the server changed X and Y (someone else saved). + * In the folder: X was edited (so X is a conflict), Y is as pinned (so Y is `remote`), Z was edited and the server + * did not touch it (`local`), W is the same everywhere (`in sync`). + */ +import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'; +import { rmSync } from 'fs'; +import { Encryptor } from '../../src/crypto/encryptor'; +import { getGlobalCapyDir } from '../../src/config/globalConfig'; +import { boundResolutions, buildEditRows, editLoader, saveEdits } from '../../src/commands/editCommand'; +import { findConflicts, readSnapshot, snapshotFrom } from '../../src/sync/commit'; +import { hashValue } from '../../src/sync/compareSecrets'; +import { EditScreen, focusedOn, reclassifyRow, type EditRow, type EditState } from '../../src/ui/editScreen'; +import type { KeepFile } from '../../src/types/index'; +import { fakeCapyServer, pushOf, seedServer, serverValues, type FakeServerOpts } from '../helpers/fakeCapyServer'; +import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; + +const KEY = Encryptor.generateKey(); +const DEV = 'development'; + +const OLD = { W: 'w-same', X: 'x-old', Y: 'y-old', Z: 'z-old' } as const; +const SERVER = { W: 'w-same', X: 'x-A', Y: 'y-A', Z: 'z-old' } as const; +const FOLDER = { W: 'w-same', X: 'x-B', Y: 'y-old', Z: 'z-B' } as const; + +const OLD_SEED = seedServer({ projectKey: KEY, values: { [DEV]: OLD } }); +const NEW_SEED = seedServer({ projectKey: KEY, values: { [DEV]: SERVER } }); + +const globalDirName = `.capy-test-editconf-${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 noLog = { record: mock((_: unknown) => undefined) }; + +function world(opts: Pick = {}) { + const server = fakeCapyServer({ initial: NEW_SEED, racing: opts.racing }); + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: FOLDER, client: server.client }); + made(folder); + const load = editLoader({ + pm: folder.pm, + fileManager: folder.fileManager, + serviceClient: server.client as never, + localMode: false, + keep: folder.ctx.keep, + branch: DEV, + orgId: folder.ctx.orgId, + projectId: folder.ctx.projectId, + projectKey: KEY, + configuredTargets: [], + }); + const parts = { ...folder.ctx }; + const ctx = { + saveLocalEdits: (set: Record, bound?: Parameters[4]) => saveEdits(parts, folder.ctx.keep, set, noLog, bound), + reload: load, + }; + return { server, folder, load, ctx, before: server.state() }; +} + +const strip = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const DOWN = '\x1b[B'; + +/** Runs the real screen on `state`; `press` sends a key chunk, `frame` is the last frame drawn (colour removed). */ +function screenOn(state: EditState, ctx: ReturnType['ctx']) { + const out = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); + const done = new EditScreen().run(state, ctx); + const frame = (): string => strip(String(out.mock.calls[out.mock.calls.length - 1]?.[0] ?? '')); + return { + press: (key: string) => { + process.stdin.emit('data', Buffer.from(key)); + }, + frame, + /** Waits until the last frame satisfies `cond`. */ + until: async (cond: (frame: string) => boolean) => { + for (const _ of Array.from({ length: 400 })) { + if (cond(frame())) return frame(); + await sleep(5); + } + throw new Error(`the screen never showed what was expected:\n${frame()}`); + }, + finish: async () => { + process.stdin.emit('data', Buffer.from('\x03')); + await done; + out.mockRestore(); + }, + }; +} + +/** The frame line of a variable's row. */ +const rowLine = (frame: string, key: string): string => frame.split('\n').find((l) => l.includes(` ${key} `) && l.includes('●')) ?? ''; + +async function open(w: ReturnType) { + const loaded = await w.load(); + const state: EditState = { projectName: 'demo', branch: DEV, rows: loaded.rows, remoteAvailable: loaded.remoteAvailable, seen: loaded.seen }; + return { loaded, screen: screenOn(state, w.ctx) }; +} + +/** Rows are W, X, Y, Z: the cursor starts on W. */ +const goTo = (screen: ReturnType, key: 'W' | 'X' | 'Y' | 'Z') => Array.from({ length: 'WXYZ'.indexOf(key) }).forEach(() => screen.press(DOWN)); + +/** Edits Z to `value` and leaves the cursor on Z. */ +function editZ(screen: ReturnType, value: string): void { + goTo(screen, 'Z'); + screen.press('e'); + screen.press(value); + screen.press('\r'); +} + +describe('the rows compare against the server\'s latest, three ways', () => { + test('a stale folder: the server\'s change is `remote`, both-changed is `conflict`, yours alone is `local`, the rest is `in sync`', async () => { + const w = world(); + const { rows } = await w.load(); + expect(Object.fromEntries(rows.map((r) => [r.key, r.status]))).toEqual({ W: 'in sync', X: 'conflict', Y: 'remote', Z: 'local' }); + }); + + test('the old behaviour is gone: Y is NOT "in sync" just because the folder\'s pinned snapshot agrees with its .env', async () => { + const w = world(); + const { rows } = await w.load(); + // Against the pinned snapshot Y reads "y-old" on both sides. + expect(w.folder.env().Y).toBe(OLD.Y); + expect(rows.find((r) => r.key === 'Y')?.status).not.toBe('in sync'); + }); + + test('the screen says so: the status column and the summary cells count drift and conflicts truthfully', async () => { + const w = world(); + const { screen } = await open(w); + const frame = screen.frame(); + expect(rowLine(frame, 'X')).toContain('conflict'); + expect(rowLine(frame, 'Y')).toContain('remote'); + expect(rowLine(frame, 'Z')).toContain('local'); + expect(rowLine(frame, 'W')).toContain('in sync'); + expect(frame).toContain('2 drift'); // Y and Z + expect(frame).toContain('1 conflicts'); + expect(frame).toContain('need resolution'); + await screen.finish(); + }); + + test('one read of the server for the first screen (the one the commit is planned against)', async () => { + const w = world(); + await open(w).then(({ screen }) => screen.finish()); + expect(w.server.getDecryptData.mock.calls).toHaveLength(1); + }); + + test('the shown read is kept: the screen state carries it for the commit', async () => { + const w = world(); + const { loaded } = await open(w).then(async (o) => (await o.screen.finish(), o)); + expect(loaded.seen).toBeDefined(); + expect(loaded.seen?.entries.X.plaintext).toBe(SERVER.X); + }); +}); + +describe('a row\'s status is the commit path\'s own test (property over every pin / yours / theirs combination)', () => { + const values = [undefined, 'a', 'b', 'c'] as const; + const row = (pinned: string | undefined, local: string | undefined, remote: string | undefined): EditRow => ({ + key: 'N', + localValue: local, + remoteValue: remote, + status: 'unknown', + updatedLabel: '—', + pinnedHash: pinned === undefined ? null : hashValue(pinned), + }); + + test('`conflict` exactly when findConflicts reports the name (a value you hold, over a server value that moved and differs)', () => { + const combos = values.flatMap((pinned) => values.flatMap((local) => values.map((remote) => ({ pinned, local, remote })))); + expect(combos).toHaveLength(64); + combos.forEach(({ pinned, local, remote }) => { + const status = reclassifyRow(row(pinned, local, remote), { remoteAvailable: true }); + const latest = remote === undefined ? {} : { N: { line: 'l', plaintext: remote, hash: hashValue(remote) } }; + const contested = + local !== undefined && + findConflicts({ + pinned: pinned === undefined ? {} : { N: hashValue(pinned) }, + latest, + changes: { set: { N: local }, remove: [] }, + }).length > 0; + // A value you changed (not just still hold from the pin) is what a conflict is about. + const youChanged = local !== undefined && local !== pinned; + expect(status === 'conflict', JSON.stringify({ pinned, local, remote, status })).toBe(youChanged && contested); + }); + }); + + test('the other statuses: in sync, local, remote', () => { + const status = (pinned: string | undefined, local: string | undefined, remote: string | undefined) => + reclassifyRow(row(pinned, local, remote), { remoteAvailable: true }); + expect(status('a', 'a', 'a')).toBe('in sync'); + expect(status('a', 'b', 'a')).toBe('local'); + expect(status('a', 'a', 'b')).toBe('remote'); + // Both changed to the same value: nothing to do. + expect(status('a', 'b', 'b')).toBe('in sync'); + // Both changed to different values. + expect(status('a', 'b', 'c')).toBe('conflict'); + // The server dropped it and you edited it. + expect(status('a', 'b', undefined)).toBe('conflict'); + // A brand-new variable on both sides, different. + expect(status(undefined, 'b', 'c')).toBe('conflict'); + }); +}); + +describe('boundResolutions: a decision applies only to the values it was made against', () => { + const seen = snapshotFrom( + { env_content: `X=capy:rid:${Encryptor.encrypt('x-A', KEY)}`, keep_file: JSON.stringify(NEW_SEED.keep) }, + DEV, + KEY, + ); + const decision = (choice: 'mine' | 'theirs', theirs: string | null, yours: string | null) => ({ X: { choice, theirs, yours } }); + + test('kept while the server\'s value and your value are the ones decided against', () => { + expect(boundResolutions(decision('mine', hashValue('x-A'), hashValue('x-B')), seen, { X: 'x-B' })).toEqual({ X: 'mine' }); + }); + test('dropped when the server\'s value is not the one decided against', () => { + expect(boundResolutions(decision('mine', hashValue('x-older'), hashValue('x-B')), seen, { X: 'x-B' })).toEqual({}); + }); + test('dropped when your value is not the one decided with', () => { + expect(boundResolutions(decision('mine', hashValue('x-A'), hashValue('x-B')), seen, { X: 'x-other' })).toEqual({}); + }); + test('dropped when there is no read to bind to', () => { + expect(boundResolutions(decision('theirs', hashValue('x-A'), hashValue('x-B')), undefined, { X: 'x-B' })).toEqual({}); + }); +}); + +describe('committing from the screen', () => { + test('`c` with an unresolved conflict uploads NOTHING: the server is exactly as it was, the status names the variable', async () => { + const w = world(); + const { screen } = await open(w); + editZ(screen, 'z-new'); + screen.press('c'); + await screen.until((f) => f.includes('Nothing was uploaded')); + expect(screen.frame()).toContain('X'); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.server.state()).toEqual(w.before); + // The folder is untouched too. + expect(w.folder.env()).toEqual(FOLDER); + await screen.finish(); + }); + + test('quit-with-commit follows the same rule: blocked, nothing uploaded', async () => { + const w = world(); + const { screen } = await open(w); + editZ(screen, 'z-new'); + screen.press('q'); + expect(screen.frame()).toContain('uncommitted change'); + screen.press('c'); + await screen.until((f) => f.includes('Nothing was uploaded')); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.server.state()).toEqual(w.before); + // Still on the screen: Ctrl-C leaves it. + await screen.finish(); + }); + + test('mine: the server decrypts to the folder\'s value, the edit goes with it, and the remote row is pulled', async () => { + const w = world(); + const { screen } = await open(w); + editZ(screen, 'z-new'); + // From Z (where the edit left the cursor) back up to X. + Array.from({ length: 2 }).forEach(() => screen.press('\x1b[A')); + screen.press('m'); + expect(rowLine(screen.frame(), 'X')).toContain('conflict: mine'); + screen.press('c'); + await screen.until((f) => f.includes('Committed')); + const values = serverValues(w.server.state(), DEV, KEY); + expect(values.X).toBe(FOLDER.X); + expect(values.Z).toBe('z-new'); + // Y was only changed by the other side: it stays theirs on the server, and the folder pulled it. + expect(values.Y).toBe(SERVER.Y); + expect(w.folder.env()).toEqual({ W: 'w-same', X: FOLDER.X, Y: SERVER.Y, Z: 'z-new' }); + // The screen re-read: nothing left to decide. + expect(rowLine(screen.frame(), 'X')).toContain('in sync'); + expect(rowLine(screen.frame(), 'Y')).toContain('in sync'); + await screen.finish(); + }); + + test('theirs: nothing is uploaded for it, the server keeps theirs, and the folder\'s .env takes it', async () => { + const w = world(); + const { screen } = await open(w); + goTo(screen, 'X'); + screen.press('t'); + expect(rowLine(screen.frame(), 'X')).toContain('conflict: theirs'); + screen.press('c'); + await screen.until((f) => f.includes('Committed')); + expect(w.server.pushSecrets.mock.calls).toHaveLength(0); + expect(w.server.state()).toEqual(w.before); + expect(w.folder.env().X).toBe(SERVER.X); + expect(w.folder.env().Y).toBe(SERVER.Y); + await screen.finish(); + }); + + test('the footer offers m / t on a conflict row, and not elsewhere', async () => { + const w = world(); + const { screen } = await open(w); + expect(screen.frame()).not.toContain('mine'); + goTo(screen, 'X'); + expect(screen.frame()).toContain('mine'); + expect(screen.frame()).toContain('theirs'); + screen.press('\x1b[B'); + expect(screen.frame()).not.toContain(' m mine'); + await screen.finish(); + }); + + test('`capy edit NAME` opens this screen on NAME: a conflicting NAME starts with m / t on offer', async () => { + const w = world(); + const loaded = await w.load(); + const state = focusedOn({ projectName: 'demo', branch: DEV, rows: loaded.rows, remoteAvailable: loaded.remoteAvailable, seen: loaded.seen }, 'X'); + const screen = screenOn(state, w.ctx); + expect(screen.frame()).toContain('mine'); + expect(screen.frame()).toMatch(/▶ X /); + await screen.finish(); + }); + + test('m and t do nothing on a row that is not a conflict', async () => { + const w = world(); + const { screen } = await open(w); + goTo(screen, 'Y'); + screen.press('m'); + expect(rowLine(screen.frame(), 'Y')).toContain('remote'); + await screen.finish(); + }); + + test('the server moves between showing and committing: the conflict is shown again, the old decision is NOT applied, nothing is overwritten', async () => { + // Someone else saves X again right after the screen\'s first read. + const w = world({ racing: (n) => (n === 1 ? [pushOf({ projectKey: KEY, branch: DEV, values: { ...SERVER, X: 'x-C' } })] : []) }); + const { screen } = await open(w); + editZ(screen, 'z-typed'); + Array.from({ length: 2 }).forEach(() => screen.press('\x1b[A')); + screen.press('m'); + screen.press('c'); + await screen.until((f) => f.includes('X changed on the server')); + // What was typed is still there, uncommitted, across the re-read. + expect(rowLine(screen.frame(), 'Z')).toContain('local'); + expect(screen.frame()).toContain('z-t...ped'); + // Nothing of ours is on the server: X is the newest value someone else saved. + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-C'); + expect(rowLine(screen.frame(), 'X')).toContain('conflict'); + expect(rowLine(screen.frame(), 'X')).not.toContain('mine'); + expect(w.folder.env().X).toBe(FOLDER.X); + // Deciding again, against what is shown now, goes through. + screen.press('m'); + screen.press('c'); + await screen.until((f) => f.includes('Committed')); + expect(serverValues(w.server.state(), DEV, KEY).X).toBe(FOLDER.X); + expect(serverValues(w.server.state(), DEV, KEY).Z).toBe('z-typed'); + await screen.finish(); + }); + + test('theirs when the server moved again: only the server\'s value is taken, nothing is uploaded, and the screen shows the newer change as `remote`', async () => { + const w = world({ racing: (n) => (n === 1 ? [pushOf({ projectKey: KEY, branch: DEV, values: { ...SERVER, X: 'x-C' } })] : []) }); + const { screen } = await open(w); + goTo(screen, 'X'); + screen.press('t'); + screen.press('c'); + await screen.until((f) => f.includes('Committed')); + // The server is as the other writer left it; we wrote nothing. + expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-C'); + expect(w.server.accepted()).toHaveLength(1); + // The folder took what was decided on (x-A), so the newer x-C is still to be pulled: the row says so. + expect(rowLine(screen.frame(), 'X')).toContain('remote'); + await screen.finish(); + }); + + test('editing a `remote` row makes it a conflict with the server\'s value', async () => { + const w = world(); + const { screen } = await open(w); + goTo(screen, 'Y'); + screen.press('e'); + screen.press('y-typed'); + screen.press('\r'); + expect(rowLine(screen.frame(), 'Y')).toContain('conflict'); + await screen.finish(); + }); +}); + +describe('editLoader / buildEditRows', () => { + test('a read the server cannot answer leaves the rows `unknown` (nothing to compare against)', async () => { + const w = world(); + const broken = { getDecryptData: async () => { throw new Error('offline'); }, pushSecrets: async () => ({ keep_hash: '' }) }; + const load = editLoader({ + pm: w.folder.pm, + fileManager: w.folder.fileManager, + serviceClient: broken as never, + localMode: false, + keep: w.folder.ctx.keep, + branch: DEV, + orgId: w.folder.ctx.orgId, + projectId: w.folder.ctx.projectId, + projectKey: KEY, + configuredTargets: [], + }); + const loaded = await load(); + expect(loaded.remoteAvailable).toBe(false); + expect(loaded.rows.every((r) => r.status === 'unknown')).toBe(true); + }); + + test('UPDATED comes from the server\'s keep (when the value last changed there)', async () => { + const w = world(); + const snapshot = await readSnapshot(w.server.client, w.folder.ctx.projectId, DEV, KEY); + const rows = buildEditRows({ + localMode: false, + branch: DEV, + keep: w.folder.ctx.keep, + localPlaintext: w.folder.env(), + baseline: { entries: {}, keep: { ...(snapshot.keep as KeepFile), variables: { X: [{ resource_id: 'r', branch: DEV, value_hash: 'h', changed_at: new Date().toISOString() }] } }, available: true, seen: snapshot }, + configuredTargets: [], + }); + expect(rows.find((r) => r.key === 'X')?.updatedLabel).not.toBe('—'); + }); +}); + +describe('local-only mode is unchanged: committed-vs-working, no server', () => { + test('a save is read back as committed, with local-mode wording', async () => { + const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: OLD, client: undefined }); + made(folder); + const load = editLoader({ + pm: folder.pm, + fileManager: folder.fileManager, + serviceClient: undefined, + localMode: true, + keep: folder.ctx.keep, + branch: DEV, + orgId: folder.ctx.orgId, + projectId: folder.ctx.projectId, + projectKey: KEY, + configuredTargets: [], + }); + const before = await load(); + expect(before.remoteAvailable).toBe(false); + expect(before.rows.every((r) => r.status === 'local' && r.updatedLabel === 'uncommitted')).toBe(true); + await saveEdits({ ...folder.ctx }, folder.ctx.keep, { Z: 'z-local' }, noLog); + const after = await load(); + expect(after.rows.find((r) => r.key === 'Z')).toMatchObject({ localValue: 'z-local', remoteValue: 'z-local', status: 'in sync', updatedLabel: 'committed' }); + // No server read, no three-way pin in local mode. + expect(after.seen).toBeUndefined(); + expect(after.rows.every((r) => r.pinnedHash === undefined)).toBe(true); + }); +}); diff --git a/tests/commands/secretsEditConflicts.test.ts b/tests/commands/secretsEditConflicts.test.ts new file mode 100644 index 00000000..8417cf89 --- /dev/null +++ b/tests/commands/secretsEditConflicts.test.ts @@ -0,0 +1,166 @@ +/** + * `capy secrets` edit and conflicts (CAP-714), against the real edit actions and engine (`createEditActionsWith` -> + * `runSecretSet`) over the fake service of tests/helpers/secretsWorld.ts. The properties: + * - a location someone else changed since the row was listed is reported, BEFORE any push: nothing is written + * until every such location is decided; + * - mine writes that location, bound to the value that was seen; theirs skips it and writes the rest; + * - a decision made against a value that has since moved is not applied: the conflict comes back, nothing is written; + * - a location that moves between the check and its push is refused (SECRETS_CONFLICT), not overwritten; + * - 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: 'mine' | 'theirs', theirs: string, yours: string = hashValue(SENTINEL)) => ({ choice, theirs, yours }); +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).toMatchObject({ + kind: 'source', + name: NAME, + project: PROJECT_NAMES.pA, + branch: 'staging', + pinned: hashValue(OLD_VALUE), + yours: hashValue(SENTINEL), + theirs: 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('mine and theirs', () => { + const key = 'pA/staging'; + + test('mine: the moved location is written too, with the new value', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('mine', 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('theirs: 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('theirs', 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('a decision made against another value of the location is NOT applied: the conflict comes back and nothing is pushed', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('mine', hashValue('the-value-it-had-when-decided')) } })); + expect(out.ok).toBe(false); + if (out.ok) throw new Error('expected a conflict'); + expect(out.conflicts?.[0].conflict.theirs).toBe(hashValue(MOVED)); + expect(r.service.pushSecrets.mock.calls).toHaveLength(0); + }); + + test('a decision made for another new value is NOT applied either', async () => { + const r = rig(); + const out = await r.actions.run(request({ decisions: { [key]: decision('mine', 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('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('the allowed fingerprints of a location are: the new value, the one listed, and the one that was decided against', () => { + const settled = settleLocations(request({ decisions: { 'pA/staging': decision('mine', hashValue(MOVED)) } }), reads); + if (settled.kind !== 'ready' || settled.allowed === undefined) throw new Error('expected ready'); + expect(settled.allowed(A_STAGING)).toEqual([hashValue(SENTINEL), hashValue(OLD_VALUE), hashValue(MOVED)]); + }); +}); 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/ui/secretsDeployConflicts.test.ts b/tests/ui/secretsDeployConflicts.test.ts new file mode 100644 index 00000000..1e8cd13e --- /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; + * - mine delivers Capy's value, theirs 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 = (theirs: string = LIVE): TargetConflict => ({ kind: 'target', name: NAME, target: 'api', pinned: null, yours: hashOf(NAME), theirs }); +const decision = (choice: 'mine' | 'theirs', theirs: string = LIVE) => ({ 'API_KEY@api': { choice, theirs, yours: 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 = + (theirs: string, liveAtWrite: string = theirs) => + (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 ? theirs : liveAtWrite; + const seen = ctx.targetCheck.resolutions[NAME]; + const answer = seen !== undefined && seen.theirs === now && seen.yours === 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 === 'theirs' + ? { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }], skipped: [NAME] } + : { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; + }; + +const liveAdapter = (theirs: string = LIVE, liveAtWrite: string = theirs) => Object.assign(fakeAdapter({ deploy: liveScript(theirs, 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('mine: 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('mine')); + 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: 'mine', theirs: LIVE, yours: hashOf(NAME) } }); + expect(r.github.createPull.mock.calls).toHaveLength(3); + }); + + test('theirs: 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('theirs')); + 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('mine', 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: 'mine', theirs: LIVE, yours: '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('theirs'))([scope])).toEqual({ 'API_KEY@api': 'theirs' }); + }); + test('an undecided or moved conflict is a refusal (nothing written) listing them', () => { + expect(() => decideFrom(undefined)([scope])).toThrow(); + const thrown = (() => { + try { + decideFrom(decision('mine', '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('m mine'); + expect(strip(renderDeploy(flow, 120).footer)).toContain('t theirs'); + }); + + 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, 'm').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('mine') }); + }); + + test('Esc leaves with nothing written: no effect, the cancelled note', () => { + const left = stepDeploy(stepDeploy(atConflicts(), 'm').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(), 'm').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': 'mine' }); + }); + + 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('mine, Enter: delivered', async () => { + const s = await session(['m', '\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('theirs, Enter: api is skipped (kept on the platform), the others are delivered', async () => { + const s = await session(['t', '\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(['m', '\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..acad1aec --- /dev/null +++ b/tests/ui/secretsEditConflicts.test.ts @@ -0,0 +1,183 @@ +/** + * 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, + 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', theirs: string, changedAt?: string): LocationConflict => ({ + location: loc(p), + conflict: { + kind: 'source', + name: 'API_KEY', + project: loc(p).project_name, + branch: loc(p).branch, + pinned: ROW_HASH, + yours: 'yours-fp', + theirs, + ...(changedAt === undefined ? {} : { theirs_changed_at: 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('m mine'); + expect(text).toContain('t theirs'); + // Never a value, never a fingerprint. + expect(text).not.toContain(VALUE); + expect(text).not.toContain('aaaa'); + expect(text).not.toContain('yours-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(), 'm', DOWN, 't'); + const lines = frame(decided).split('\n'); + expect(lines.find((l) => l.includes('web production'))).toContain('mine'); + expect(lines.find((l) => l.includes('api development'))).toContain('theirs'); + }); + + test('Enter does nothing until every location is decided, and the footer offers it only then', () => { + const half = press(atConflicts(), 'm'); + 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, 't'))).toContain('enter continue'); + }); + + test('Enter runs again with each decision bound to the fingerprints it was made against', () => { + const decided = press(atConflicts(), 'm', DOWN, 't'); + 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: 'mine', theirs: 'aaaa', yours: 'yours-fp' }, + 'p2/development': { choice: 'theirs', theirs: 'bbbb', yours: 'yours-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(), 'm'), 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('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'))), 'm', DOWN, 't'), 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(/mine|theirs/); + expect(lines.find((l) => l.includes('api development'))).toContain('theirs'); + // 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..e227cecc --- /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: mine writes it, theirs 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('mine: both locations are written, and the confirmation says so', async () => { + const s = await session(['m', '\r']); + expect(s.service.pushSecrets.mock.calls.map((c) => c[0]).toSorted()).toEqual(['pA', 'pC']); + expect(`${s.logged}\n${s.afterRun}`).toContain(NAME); + }); + + test('theirs: only the other location is written; the confirmation notes the one that kept its value', async () => { + const s = await session(['t', '\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.'); + }); +}); From c3e8e3e491852bcbb7001b0a95c24db37942639a Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 03:33:48 -0700 Subject: [PATCH 05/12] feat(deploy): capy deploy is strict about unsaved edits capy deploy never delivers a value Capy does not hold and never saves one unasked. A folder edit nobody else changed asks "save and deploy?" in a terminal; off a terminal it stops with exit 3 and {ok:false, code:DEPLOY_UNSAVED_CHANGES, names} (names only). Source conflicts are reported first; choosing mine saves without a second question. --dry-run reports unsaved edits and writes nothing. --- docs/cli-reference.md | 3 +- src/commands/conflictPrompt.ts | 13 ++ src/commands/deployCommand.ts | 8 +- src/commands/deployConflicts.ts | 62 +++++++- src/core/cliHelpDoc.ts | 2 +- src/types/index.ts | 6 + tests/commands/deployCiTargetsRecord.test.ts | 112 +++++++-------- tests/commands/deployConflicts.test.ts | 140 +++++++++++++++++-- 8 files changed, 264 insertions(+), 82 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index be94037e..c6a22f68 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -902,6 +902,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `SECRETS_STALE_BASE` - `SECRETS_CONFLICT` - `SECRETS_CONFLICT_STALE` +- `DEPLOY_UNSAVED_CHANGES` - `SECRETS_RESOLVE_INVALID` - `SECRETS_RESOLVE_NEEDS_CONFLICT` - `REMOVE_LOCAL_DRIFT` @@ -956,4 +957,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**: A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all. +- **Conflicts**: A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all. `capy deploy` never delivers a value Capy does not hold and never saves one unasked: off a terminal, folder edits Capy lacks stop it with exit 3 and stdout {ok:false, code:"DEPLOY_UNSAVED_CHANGES", names:[...]} (names only, never values; source conflicts are reported first). Save them with `capy push`, then rerun. diff --git a/src/commands/conflictPrompt.ts b/src/commands/conflictPrompt.ts index 0e8bd61b..6c9c4a15 100644 --- a/src/commands/conflictPrompt.ts +++ b/src/commands/conflictPrompt.ts @@ -12,6 +12,19 @@ function questionFor(conflict: Conflict): string { : `${conflict.name} was changed in Capy since you last synced. Which value?`; } +// COPY-FLAG: minimal, neutral wording; pending approval. +const unsavedQuestionFor = (names: readonly string[]): string => `${names.join(', ')} changed in this folder and is not saved to Capy. Save and deploy?`; + +/** + * `capy deploy` found folder edits Capy does not hold (CAP-717): one question naming them (never a value). + * Enter saves them to Capy and delivers; declining stops the deploy with nothing saved and nothing delivered. + */ +export async function askSaveAndDeploy(names: readonly string[]): Promise { + const inquirer = (await import('inquirer')).default; + const { save } = (await inquirer.prompt([{ type: 'confirm', name: 'save', message: unsavedQuestionFor(names), default: true }])) as { save: boolean }; + return save; +} + /** Asks about each conflict in turn (one prompt, then the rest) and returns the answers by decision key. */ export async function askConflicts(conflicts: readonly Conflict[]): Promise { const inquirer = (await import('inquirer')).default; diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 2107687f..04d2b399 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -95,7 +95,7 @@ import { printConflictRefusal, type RawConflictFlags, } from '../sync/conflicts'; -import { reconcileDeployConflicts } from './deployConflicts'; +import { isUnsavedChangesRefusal, printUnsavedChangesRefusal, 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'; @@ -2615,7 +2615,7 @@ export async function deployCommand( adapter, devMode: options.devMode, dryRun: !!options.dryRun, - interactive: process.stdin.isTTY === true && !options.yes, + interactive: process.stdin.isTTY === true && !options.yes && !options.json, flags: conflictFlags, adapterCtx: adapterCallCtx, showFailure: renderResult, @@ -2625,6 +2625,10 @@ export async function deployCommand( printConflictRefusal(err); return { ok: false, code: EXIT_NEEDS_INPUT }; } + if (isUnsavedChangesRefusal(err)) { + printUnsavedChangesRefusal(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 diff --git a/src/commands/deployConflicts.ts b/src/commands/deployConflicts.ts index fb5c0dfd..99e4aa2a 100644 --- a/src/commands/deployConflicts.ts +++ b/src/commands/deployConflicts.ts @@ -5,8 +5,10 @@ * SOURCE (folder against the Capy server). The folder's `.env` may lag Capy (a teammate saved since this folder's * pin) or be ahead of it. The same planner every other write uses (`sync/commit.ts`) decides per variable: * - only the server moved (folder unchanged since its pin) → deliver the server's latest, never the stale value; - * - only the folder changed → committed to Capy first (the same path as `mine`), then delivered, so Capy holds - * what the platform will hold; + * - only the folder changed (an UNSAVED EDIT) → never delivered unasked: a terminal is asked "save and deploy?" + * (yes: committed to Capy first, then delivered, so Capy holds what the platform will hold; no: nothing saved, + * nothing delivered); off a terminal the deploy is refused with `DEPLOY_UNSAVED_CHANGES` (names only); + * - both changed to the same value → delivered, the folder adopts the pin; * - both changed, differently → a conflict: mine commits the folder's value (through the commit path) and * delivers it, theirs delivers Capy's latest and the folder takes it like a pull. * @@ -52,7 +54,7 @@ import { type ConflictView, type Resolutions, } from '../sync/conflicts'; -import { askConflicts } from './conflictPrompt'; +import { askConflicts, askSaveAndDeploy } from './conflictPrompt'; export interface ReconcileInput { readonly cwd: string; @@ -84,6 +86,35 @@ export type Reconciled = const NOTHING_TO_COMPARE: Reconciled = { ok: true, skipped: [] }; +/** Exactly what stdout carries when a deploy is stopped for folder edits Capy does not hold: names only, never a value. */ +export interface UnsavedChangesPayload { + readonly ok: false; + readonly code: typeof ERROR_CODES.DEPLOY_UNSAVED_CHANGES; + readonly names: readonly string[]; + /** A preview (`--dry-run`) reports the same payload. */ + readonly dry_run?: true; +} + +/** A deploy was stopped because the folder holds values Capy does not, and nobody at a terminal can be asked. Nothing was saved or delivered. */ +export class UnsavedChangesRefusal extends CapyError { + constructor(readonly payload: UnsavedChangesPayload) { + super(`${payload.names.join(', ')} changed in this folder and is not saved to Capy. Nothing was deployed.`, payload.code, { names: payload.names }); // COPY-FLAG + this.name = 'UnsavedChangesRefusal'; + } +} + +export const isUnsavedChangesRefusal = (err: unknown): err is UnsavedChangesRefusal => err instanceof UnsavedChangesRefusal; + +const unsavedRefusalFor = (names: readonly string[], dryRun: boolean): UnsavedChangesRefusal => + new UnsavedChangesRefusal({ ok: false, code: ERROR_CODES.DEPLOY_UNSAVED_CHANGES, names: [...names],...(dryRun ? { dry_run: true as const } : {}) }); + +/** stdout: the payload as JSON, nothing else. stderr: what to do. */ +export function printUnsavedChangesRefusal(refusal: UnsavedChangesRefusal): void { + console.log(JSON.stringify(refusal.payload, null, 2)); + console.error(refusal.message); + console.error('Save them with `capy push`, then run the deploy again.'); // COPY-FLAG +} + /** The folder, signed in without a prompt, with its key: what a commit needs. `undefined` when this is not a Capy project folder. */ async function openFolder(cwd: string, branch: string, devMode: boolean | undefined): Promise<(FolderContext & { serviceClient: ServiceClient }) | undefined> { const pm = new ProjectManager(cwd); @@ -167,6 +198,22 @@ export async function reconcileDeployConflicts(input: ReconcileInput): Promise 0 ? await askConflicts(source) : flagged; + // Unsaved edits: a name the folder changed, that is no conflict, and that Capy does not already hold. Delivering + // one would deliver a value Capy lacks, so it is never delivered unasked (and never saved unasked). + const conflicting = new Set(source.map((c) => c.name)); + const unsaved = edited.filter((v) => !conflicting.has(v) && snapshot.entries[v]?.plaintext !== folder.localPlaintext[v]); + // A person at a terminal is asked once, naming them; declining stops the deploy with nothing saved and nothing delivered. + const mayAsk = input.interactive && !input.dryRun; + if (unsaved.length > 0 && mayAsk && !(await askSaveAndDeploy(unsaved))) { + console.log('Cancelled.'); + return { ok: false, code: 0 }; + } + // Off a terminal (or in a preview) nobody can be asked. With no source conflict to report, refuse now, before the + // platform is contacted at all. ORDER: source conflicts come first. When both kinds are present the caller gets + // the SECRETS_CONFLICT payload (exit 3) alone; once those are resolved, a rerun that still has unsaved edits + // reaches the check after the decision step below and gets DEPLOY_UNSAVED_CHANGES (exit 3). One response each time. + if (unsaved.length > 0 && !mayAsk && source.length === 0) throw unsavedRefusalFor(unsaved, input.dryRun); + // What is delivered once the source is answered: Capy's latest for a name answered `theirs`; else the folder's // edit, else Capy's latest, else the folder's value (a conflict not yet answered is read as `mine`). const valueFor = (name: string): string | undefined => @@ -200,10 +247,15 @@ export async function reconcileDeployConflicts(input: ReconcileInput): Promise 0 ? await askConflicts(liveConflicts) : {}) } : gateConflicts(input.flags, scopes); + // Every conflict is answered. Unsaved edits that nobody was asked about stop the deploy here, before anything is + // written (see ORDER above: reached only when the source conflicts were resolved by the flags). + if (unsaved.length > 0 && !mayAsk) throw unsavedRefusalFor(unsaved, false); + // The folder takes what will be delivered. A variable the folder did not change is never delivered stale: Capy's // latest is pulled in. Every variable the folder changed goes through the commit path, so Capy holds what the - // platform will hold: a conflict with its answer (mine writes the folder's value to Capy, theirs takes Capy's), - // an edit nobody else touched as a plain write. + // platform will hold: a conflict with its answer (mine writes the folder's value to Capy, theirs takes Capy's; + // choosing mine IS the decision, no second question), an unsaved edit the person just said to save, a value Capy + // already holds (the folder adopts its pin). const behind = target.vars.some( (v) => !edited.includes(v) && snapshot.entries[v]?.plaintext !== undefined && snapshot.entries[v].plaintext !== folder.localPlaintext[v], ); diff --git a/src/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 0d61e4d6..d2639569 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -177,7 +177,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { 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: - 'A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all.', + 'A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all. `capy deploy` never delivers a value Capy does not hold and never saves one unasked: off a terminal, folder edits Capy lacks stop it with exit 3 and stdout {ok:false, code:"DEPLOY_UNSAVED_CHANGES", names:[...]} (names only, never values; source conflicts are reported first). Save them with `capy push`, then rerun.', }, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 343fd696..156fadeb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -648,6 +648,12 @@ export const ERROR_CODES = { * with it. Exit 3. */ SECRETS_CONFLICT_STALE: 'SECRETS_CONFLICT_STALE', + /** + * `capy deploy` found values in the folder that Capy does not hold (edited since the folder's pin, Capy's latest unchanged) + * and no person at a terminal to ask whether to save them. Nothing was saved or delivered. `names` lists the variables + * (names only, never values); save them with `capy push`, then rerun. Exit 3. + */ + DEPLOY_UNSAVED_CHANGES: 'DEPLOY_UNSAVED_CHANGES', /** `--resolve` is not `NAME=mine|theirs` (or `NAME@TARGET=mine|theirs`), or `--conflict` is not a conflict id. Exit 1. */ SECRETS_RESOLVE_INVALID: 'SECRETS_RESOLVE_INVALID', /** `--resolve` was given without `--conflict `: a decision is only valid for the conflicts it was made against. Exit 1. */ diff --git a/tests/commands/deployCiTargetsRecord.test.ts b/tests/commands/deployCiTargetsRecord.test.ts index 7ddf79bb..dc5ce1a8 100644 --- a/tests/commands/deployCiTargetsRecord.test.ts +++ b/tests/commands/deployCiTargetsRecord.test.ts @@ -35,10 +35,10 @@ * env blob is the server's OWN blob passed through unchanged; * - 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 to a variable being delivered is committed to Capy - * FIRST (CAP-717: Capy must hold what the platform will hold, or the record reads - * as "behind"), then the delivery is recorded against that pin (the keep-only - * record itself still never rebuilds the blob from `.env`); + * An unpushed local `.env` edit to a variable being delivered is never delivered + * unasked and never saved unasked (CAP-717, strict): off a terminal the deploy is + * refused with `DEPLOY_UNSAVED_CHANGES` (exit 3) and nothing is pushed (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); @@ -380,9 +380,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)); @@ -440,76 +441,65 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { expect(after.stash).toBe(''); }, 30_000); - test('CI mode: an unpushed local `.env` edit is COMMITTED to Capy before it is delivered (push 1), then the delivery is recorded against it (push 2); a never-synced variable is never pushed', async () => { + /** Whether any request the scripted Dokploy fetch saw wrote the service's env. */ + const wroteEnv = (fetchMock: ReturnType): boolean => + fetchMock.mock.calls.some(([url]) => new URL(String(url)).pathname.endsWith('application.saveEnvironment')); + + /** Everything printed (stdout and stderr), flattened: the sentinel scan reads this. */ + const printed = (...mocks: readonly ReturnType[]): string => mocks.flatMap((m) => m.mock.calls.map((c) => c.map(String).join(' '))).join('\n'); + + // CAP-717, strict: an unsaved edit is never delivered and never saved unasked. Off a terminal the deploy is refused + // (exit 3, DEPLOY_UNSAVED_CHANGES, names only); nothing is pushed to Capy, nothing reaches Dokploy, no PR, and the + // folder is byte-identical. (The terminal "save and deploy?" path is in deployConflicts.test.ts.) + test.each([ + ['dokploy-ci', { yes: true }], + ['dokploy-direct', { yes: true, noDeploy: true }], + ] as const)('%s: an unpushed local `.env` edit is REFUSED off a terminal (exit 3, DEPLOY_UNSAVED_CHANGES, names only): nothing pushed to Capy, nothing written to Dokploy, the folder untouched', async (targetName, options) => { setUpRepo(); process.chdir(REPO); - // The server's stored keep/blob agree with local keep.lock (same hash), but local `.env` has moved on: an EDITED - // value for the tracked var, plus a variable that was never synced (it is not in `target.vars`/keep.lock). + // The server agrees with local keep.lock, but local `.env` has moved on: an EDITED value for the tracked var, + // plus a variable that was never synced (it is not in `target.vars`/keep.lock). writeServerSnapshot('main'); writeFileSync(join(REPO, '.env'), 'STRIPE_KEY=new-unpushed-value\nUNPUSHED_OTHER=stray\n'); + const before = snapshotWorkingTree(); - // Dokploy receives the CURRENT value, and so does Capy: it must not hold an older one than the platform. - const code = await runScriptedDeploy( - 'dokploy-ci', - dokployFetchMock({ stripeKeyValue: 'new-unpushed-value' }), - { yes: true }, - ); + const fetchMock = dokployFetchMock({ stripeKeyValue: 'new-unpushed-value' }); + const errors = mock((..._a: unknown[]) => {}); + const logs = mock((..._a: unknown[]) => {}); + const code = await runScriptedDeploy(targetName, fetchMock, options, errors, logs); - expect(code).toBe(0); - expect(pushSecretsMock).toHaveBeenCalledTimes(2); - const [commit, record] = pushSecretsMock.mock.calls; - - // Push 1 is the edit, through the commit path: the pin moves to the edit, and the blob carries a line for it - // (encrypted: never the value) and for nothing the folder never synced. - const committedKeep = JSON.parse(commit[1]); - expect(committedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); - expect(commit[2]).toContain('STRIPE_KEY=capy:'); - expect(commit[2]).not.toContain('new-unpushed-value'); - expect(commit[2]).not.toContain('UNPUSHED_OTHER'); - expect(commit[2]).not.toContain('stray'); - - // Push 2 records the delivery against that pin, so the target reads as current, not behind. It re-sends the - // server's own (now committed) blob unchanged. - const recordedKeep = JSON.parse(record[1]); - expect(recordedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); - expect(recordedKeep.variables.STRIPE_KEY[0].targets[0].deployed_value_hash).toBe(hashValue('new-unpushed-value')); - expect(record[2]).toBe(commit[2]); + expect(code).toBe(3); + expect(pushSecretsMock).not.toHaveBeenCalled(); + expect(wroteEnv(fetchMock)).toBe(false); + expect(JSON.parse(String(logs.mock.calls[0][0]))).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['STRIPE_KEY'] }); + expect(printed(errors, logs)).not.toContain('new-unpushed-value'); + expect(printed(errors, logs)).not.toContain('stray'); + expect(snapshotWorkingTree()).toEqual(before); }, 30_000); - test('direct mode: the same — the edit is committed first, the delivery recorded against it; a never-synced variable is never pushed', 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(2); - const [commit, record] = pushSecretsMock.mock.calls; - - const committedKeep = JSON.parse(commit[1]); - expect(committedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); - expect(commit[2]).not.toContain('new-unpushed-value'); - expect(commit[2]).not.toContain('UNPUSHED_OTHER'); - expect(commit[2]).not.toContain('stray'); - - const recordedKeep = JSON.parse(record[1]); - expect(recordedKeep.variables.STRIPE_KEY[0].value_hash).toBe(hashValue('new-unpushed-value')); - expect(recordedKeep.variables.STRIPE_KEY[0].targets[0]).toMatchObject({ - provider: 'dokploy', - target: 'dokploy-direct', - deployed: false, // --no-deploy - deployed_value_hash: hashValue('new-unpushed-value'), - }); - expect(record[2]).toBe(commit[2]); + 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(keepFileJson).not.toContain('UNPUSHED_OTHER'); + expect(envBlob).not.toContain('stray'); }, 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/deployConflicts.test.ts b/tests/commands/deployConflicts.test.ts index 927fe112..7bfbce4f 100644 --- a/tests/commands/deployConflicts.test.ts +++ b/tests/commands/deployConflicts.test.ts @@ -7,12 +7,16 @@ * The properties: a variable the folder did not change is never delivered stale (Capy's latest is pulled in); a * variable both sides changed is a conflict that stops the deploy off a terminal (exit-3 payload, nothing uploaded, * the folder untouched) and, once answered, is either committed first and delivered (mine) or taken from Capy - * (theirs); a folder edit nobody else touched is committed to Capy, then delivered; source and target - * conflicts are answered in one step under one conflict_id. + * (theirs); source and target conflicts are answered in one step under one conflict_id. + * + * Strict about unsaved edits (CAP-717): a folder edit Capy does not hold is never delivered unasked and never saved + * unasked. At a terminal one question ("save and deploy?"): yes commits it to Capy, then delivers; no stops with + * nothing saved and nothing delivered. Off a terminal (and in a preview) the deploy is refused with + * DEPLOY_UNSAVED_CHANGES (names only), and source conflicts are reported first. * * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). */ -import { describe, test, expect, mock, afterEach } from 'bun:test'; +import { describe, test, expect, mock, afterEach, spyOn } from 'bun:test'; import type { DeployAdapter } from '../../src/deploy/adapter'; import { FIXED_PROJECT_KEY, fixedKeyResolver } from '../helpers/deployServer'; import { fakeCapyServer, seedServer, serverValues } from '../helpers/fakeCapyServer'; @@ -47,9 +51,10 @@ mock.module('../../src/service/serviceClient', () => ({ })); mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); const ask = mock(async (_conflicts: readonly unknown[]) => ({}) as Record); -mock.module('../../src/commands/conflictPrompt', () => ({ askConflicts: ask })); +const askSave = mock(async (_names: readonly string[]) => true); +mock.module('../../src/commands/conflictPrompt', () => ({ askConflicts: ask, askSaveAndDeploy: askSave })); -import { reconcileDeployConflicts, type ReconcileInput } from '../../src/commands/deployConflicts'; +import { printUnsavedChangesRefusal, reconcileDeployConflicts, UnsavedChangesRefusal, type ReconcileInput } from '../../src/commands/deployConflicts'; import { ConflictRefusal, parseConflictFlags, type Conflict } from '../../src/sync/conflicts'; import { hashValue } from '../../src/sync/compareSecrets'; import type { KeepFile } from '../../src/types/index'; @@ -69,6 +74,7 @@ afterEach(() => { made.mock.calls.forEach(([folder]) => removeFolder(folder)); made.mockClear(); ask.mockClear(); + askSave.mockClear(); }); /** A folder pinned to the old snapshot holding `env`, and a server holding `server` (default: a teammate's newer X). */ @@ -116,6 +122,19 @@ async function refusal(run: () => Promise): Promise { return err as ConflictRefusal; } +/** The refusal an unsaved edit raises where nobody can be asked. */ +async function unsavedRefusal(run: () => Promise): Promise { + const err = await run().then( + () => undefined, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(UnsavedChangesRefusal); + return err as UnsavedChangesRefusal; +} + +/** Whether any adapter call was a real delivery (not the read-only look for live conflicts). */ +const delivered = (adapter: { readonly deploy: ReturnType }): boolean => adapter.deploy.mock.calls.some(([, ctx]) => (ctx as { dryRun: boolean }).dryRun === false); + describe('capy deploy: source conflicts', () => { test('the folder changed X, a teammate changed X: off a terminal the deploy stops with the payload — nothing uploaded, the folder untouched, no value in it', async () => { const { fake, folder } = world({ X: MINE, Y: OLD.Y }); @@ -152,17 +171,31 @@ describe('capy deploy: source conflicts', () => { test('a variable the folder did NOT change, that Capy moved, is not a conflict: Capy\'s latest is pulled in, never the stale value', async () => { const { fake, folder } = world({ X: OLD.X, Y: OLD.Y }); // the folder is simply behind - const done = await reconcileDeployConflicts(input(folder, adapterWith())); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); expect(done.ok).toBe(true); expect(fake.pushSecrets.mock.calls).toHaveLength(0); expect(folder.env().X).toBe(THEIRS); expect(folder.pin('X')).toBe(hashValue(THEIRS)); + expect(ask).not.toHaveBeenCalled(); // no question of any kind: the folder simply takes Capy's latest + expect(askSave).not.toHaveBeenCalled(); }); - test('a folder edit nobody else touched is COMMITTED to Capy before it is delivered: Capy holds what the platform will hold, and the target is not read as behind', async () => { + test('both changed X to the SAME value: nothing to decide and nothing to save — delivered, the folder adopts the pin, no question', async () => { + const { fake, folder } = world({ X: THEIRS, Y: OLD.Y }); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); + expect(done.ok).toBe(true); + expect(ask).not.toHaveBeenCalled(); + expect(askSave).not.toHaveBeenCalled(); + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: THEIRS, Y: OLD.Y }); + expect(folder.pin('X')).toBe(hashValue(THEIRS)); + }); + + test('a folder edit Capy does not hold: at a terminal ONE question names it; Enter commits it to Capy first (Capy now holds what the platform will hold), then it is delivered', async () => { const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); - const done = await reconcileDeployConflicts(input(folder, adapterWith())); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); expect(done.ok).toBe(true); + expect(askSave.mock.calls).toEqual([[['Y']]]); + expect(ask).not.toHaveBeenCalled(); // an unsaved edit is no conflict expect(fake.pushSecrets.mock.calls).toHaveLength(1); expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: OLD.X, Y: 'y-edited' }); // Capy's latest decrypts to the folder's value expect(folder.env().Y).toBe('y-edited'); @@ -170,18 +203,100 @@ describe('capy deploy: source conflicts', () => { // What `recordDeployTargets` writes next: deployed_value_hash of the delivered value. Against Capy's pin it is not behind. const entry = fake.state().keep?.variables.Y.find((e) => e.branch === BRANCH); expect(entry).toBeDefined(); - const delivered = { provider: 'dokploy', target: 'dokploy-production', deployed_value_hash: hashValue('y-edited') }; - expect(staleTargets({ ...entry!, targets: [delivered] } as never)).toEqual([]); + const recorded = { provider: 'dokploy', target: 'dokploy-production', deployed_value_hash: hashValue('y-edited') }; + expect(staleTargets({ ...entry!, targets: [recorded] } as never)).toEqual([]); + }); + + test('declining the question stops the deploy: nothing saved to Capy, the folder untouched, the platform never contacted', async () => { + const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); + askSave.mockImplementationOnce(async () => false); + const adapter = adapterWith(() => []); + const before = JSON.stringify(folder.keep()); + const stopped = await reconcileDeployConflicts(input(folder, adapter, { interactive: true })); + expect(stopped).toEqual({ ok: false, code: 0 }); + expect(askSave).toHaveBeenCalledTimes(1); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual(OLD); // Capy's latest is unchanged + expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited' }); + expect(JSON.stringify(folder.keep())).toBe(before); + expect(adapter.deploy.mock.calls).toHaveLength(0); // no read of the platform, no write }); - test('an edit nobody else touched on a name Capy did not move, next to one a teammate moved: both land, nothing the folder did not change is written', async () => { + test('off a terminal an unsaved edit REFUSES the deploy: DEPLOY_UNSAVED_CHANGES with the names only — nothing saved, nothing delivered, the platform never contacted, no value anywhere in the output', async () => { + const { fake, folder } = world({ X: OLD.X, Y: 'y-edited-sentinel' }, OLD_SEED); + const adapter = adapterWith(() => []); + const before = JSON.stringify(folder.keep()); + const stopped = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapter))); + expect(stopped.payload).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'] }); + expect(stopped.code).toBe('DEPLOY_UNSAVED_CHANGES'); + // What the command prints: JSON on stdout, prose on stderr — neither holds the value. + const out = spyOn(console, 'log').mockImplementation((() => {}) as never); + const err = spyOn(console, 'error').mockImplementation((() => {}) as never); + printUnsavedChangesRefusal(stopped); + const printed = [...out.mock.calls, ...err.mock.calls].map((c) => c.join(' ')).join('\n'); + expect(JSON.parse(String(out.mock.calls[0][0]))).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'] }); + out.mockRestore(); + err.mockRestore(); + expect(printed).not.toContain('y-edited-sentinel'); + expect(stopped.message).not.toContain('y-edited-sentinel'); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual(OLD); + expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited-sentinel' }); + expect(JSON.stringify(folder.keep())).toBe(before); + expect(adapter.deploy.mock.calls).toHaveLength(0); + expect(askSave).not.toHaveBeenCalled(); + }); + + test('an unsaved edit next to a name a teammate moved: refused off a terminal naming ONLY the unsaved one; nothing is pulled or saved either', async () => { const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }); // the server moved X; the folder only changed Y - const done = await reconcileDeployConflicts(input(folder, adapterWith())); + const stopped = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapterWith()))); + expect(stopped.payload.names).toEqual(['Y']); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited' }); + }); + + test('at a terminal, an unsaved edit next to a name a teammate moved: Enter lands both (Capy\'s X pulled in, the folder\'s Y saved)', async () => { + const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); expect(done.ok).toBe(true); + expect(askSave.mock.calls).toEqual([[['Y']]]); expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: THEIRS, Y: 'y-edited' }); expect(folder.env()).toEqual({ X: THEIRS, Y: 'y-edited' }); }); + test('source conflicts AND an unsaved edit at a terminal: the conflict is asked first, then the unsaved edit once; `mine` needs no second question and both are saved', async () => { + const { fake, folder } = world({ X: MINE, Y: 'y-edited' }); + ask.mockImplementationOnce(async () => ({ X: 'mine' })); + const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); + expect(done.ok).toBe(true); + expect((ask.mock.calls[0][0] as readonly Conflict[]).map((c) => c.name)).toEqual(['X']); + expect(askSave.mock.calls).toEqual([[['Y']]]); // the conflicting name is not asked about again + expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: MINE, Y: 'y-edited' }); + }); + + test('source conflict AND unsaved edit off a terminal: SECRETS_CONFLICT comes first (alone); once resolved, the rerun answers DEPLOY_UNSAVED_CHANGES — and nothing was saved at either step', async () => { + const { fake, folder } = world({ X: MINE, Y: 'y-edited' }); + const adapter = adapterWith(() => []); + const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); + expect(first.payload.code).toBe('SECRETS_CONFLICT'); + expect(first.payload.conflicts.map((c) => c.name)).toEqual(['X']); // Y is not in it: one response at a time + const flags = parseConflictFlags({ resolve: ['X=mine'], conflict: first.payload.conflict_id }); + const second = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapter, { flags }))); + expect(second.payload).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'] }); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); // even the `mine` answer saved nothing: the deploy did not go on + expect(folder.env()).toEqual({ X: MINE, Y: 'y-edited' }); + expect(delivered(adapter)).toBe(false); + }); + + test('--dry-run reports the unsaved names (dry_run: true) and neither asks nor writes — even at a terminal', async () => { + const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); + const preview = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapterWith(), { dryRun: true, interactive: true }))); + expect(preview.payload).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'], dry_run: true }); + expect(askSave).not.toHaveBeenCalled(); + expect(fake.pushSecrets.mock.calls).toHaveLength(0); + expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited' }); + }); + test('a folder that agrees with Capy is left alone: no write of any kind', async () => { const { fake, folder } = world({ X: OLD.X, Y: OLD.Y }, OLD_SEED); const before = JSON.stringify(folder.keep()); @@ -204,6 +319,7 @@ describe('capy deploy: source conflicts', () => { const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); expect(done.ok).toBe(true); expect(ask).toHaveBeenCalledTimes(1); + expect(askSave).not.toHaveBeenCalled(); // choosing mine IS the decision to save: no second question expect(serverValues(fake.state(), BRANCH, KEY).X).toBe(MINE); }); }); From f217e9998b3642626fc4fd6d5bec682e16430762 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 03:42:10 -0700 Subject: [PATCH 06/12] chore: mark new prompt strings for copy review; pass dry-run through the unsaved refusal --- src/commands/deployConflicts.ts | 4 ++-- src/ui/secretsEditFlow.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/deployConflicts.ts b/src/commands/deployConflicts.ts index 99e4aa2a..62bcffea 100644 --- a/src/commands/deployConflicts.ts +++ b/src/commands/deployConflicts.ts @@ -205,7 +205,7 @@ export async function reconcileDeployConflicts(input: ReconcileInput): Promise 0 && mayAsk && !(await askSaveAndDeploy(unsaved))) { - console.log('Cancelled.'); + console.log('Cancelled.'); // COPY-FLAG return { ok: false, code: 0 }; } // Off a terminal (or in a preview) nobody can be asked. With no source conflict to report, refuse now, before the @@ -249,7 +249,7 @@ export async function reconcileDeployConflicts(input: ReconcileInput): Promise 0 && !mayAsk) throw unsavedRefusalFor(unsaved, false); + if (unsaved.length > 0 && !mayAsk) throw unsavedRefusalFor(unsaved, input.dryRun); // The folder takes what will be delivered. A variable the folder did not change is never delivered stale: Capy's // latest is pulled in. Every variable the folder changed goes through the commit path, so Capy holds what the diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index aacf7a00..cd8af2d6 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -588,7 +588,7 @@ function renderConflicts(flow: Extract, maxWidt const row = (c: LocationConflict, i: number): string => { const choice = choiceAt(flow, c); const when = c.conflict.theirs_changed_at === undefined ? '' : ` · changed ${formatRelativeTime(c.conflict.theirs_changed_at)}`; // COPY-FLAG - const line = `${i === flow.cursor ? '▶' : ' '} ${c.location.project_name} ${c.location.branch}${when} ${choice === undefined ? '—' : choice}`; + 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 { From dfa1e72937d98e892d8b5f6a311d8c9d978ad43b Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 03:42:17 -0700 Subject: [PATCH 07/12] chore(release): v0.9.12 --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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": { From 1eb355bb12804b7bc23e0f2865b8618947bbebe8 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 22:14:34 -0700 Subject: [PATCH 08/12] refactor(sync): bare capy runs first in every folder write Every command that writes from a folder (edit, edit NAME, add, remove, connect, rotate, push, deploy) runs bare capy's own sync as its first step and repeats it until the folder and Capy agree, or the person stops (nothing written). Then it applies only its own change on top of Capy's latest with base_keep_hash; a stale refusal re-runs the sync. Bare capy is restored to its documented behaviour (its tests match v0.9.11); its upload carries base_keep_hash. Off a terminal the differences are bare capy's compareSecrets rows: exit 3 with a conflict_id, answered with --resolve NAME=pinned|local|remote|delete and --conflict. Writes without a folder (capy secrets, batch deploy) compare the shown value, the new value and Capy's latest with the same choices. The Dokploy live-value check uses local/remote. Removed the conflict logic invented on this branch: findConflicts, mine/theirs and the m/t keys, DEPLOY_UNSAVED_CHANGES, the separate capy secrets check, and the narrowed commit-local. --- docs/cli-reference.md | 27 +- src/commands/addCommand.ts | 21 +- src/commands/capyCommand.ts | 429 +++++++------- src/commands/conflictPrompt.ts | 39 +- src/commands/connectCommand.ts | 41 +- src/commands/connectors/dokploy.ts | 6 +- src/commands/connectors/shared.ts | 36 +- src/commands/deployCommand.ts | 63 +- src/commands/deployConflicts.ts | 227 ++------ src/commands/deployTokenCommand.ts | 30 +- src/commands/editCommand.ts | 75 ++- src/commands/editPiped.ts | 6 +- src/commands/pipedWrite.ts | 41 +- src/commands/pushCommand.ts | 229 +------- src/commands/removeCommand.ts | 33 +- src/commands/rotateCommand.ts | 58 +- src/commands/secretsDeployActions.ts | 25 +- src/commands/secretsEditActions.ts | 58 +- src/commands/secretsSet.ts | 293 +++++----- src/commands/secretsSetCommand.ts | 61 +- src/commands/statusCommand.ts | 4 +- src/core/cliHelpDoc.ts | 4 +- src/deploy/adapter.ts | 2 +- src/deploy/batchDeploy.ts | 4 +- src/deploy/configuredTargets.ts | 25 +- src/deploy/deliveryRecord.ts | 71 +-- src/deploy/dokployApi.ts | 42 +- src/index-dev.ts | 8 +- src/index.ts | 8 +- src/sync/commit.ts | 541 +++++++----------- src/sync/conflicts.ts | 175 +++--- src/sync/gate.ts | 161 ++++++ src/sync/noFolder.ts | 87 +++ src/sync/syncEngine.ts | 244 ++++++-- src/system/systemStore.ts | 13 +- src/types/index.ts | 31 +- src/ui/editScreen.ts | 232 +++----- src/ui/secretsDeployFlow.ts | 36 +- src/ui/secretsEditFlow.ts | 45 +- src/ui/spinner.ts | 19 +- tests/commands/addPiped.test.ts | 20 +- tests/commands/bareCapyCommit.test.ts | 351 ++++++++++++ tests/commands/branchKeepFile.test.ts | 5 + tests/commands/capyCommand.test.ts | 5 + .../{sync => commands}/compareSecrets.test.ts | 2 +- tests/commands/conflictProtocol.test.ts | 426 +++++++------- tests/commands/connectDokploy.test.ts | 5 + tests/commands/deployBranchCheck.test.ts | 71 ++- tests/commands/deployCiChangeGate.test.ts | 7 +- tests/commands/deployCiTargetsRecord.test.ts | 47 +- tests/commands/deployCommand.test.ts | 123 ++-- tests/commands/deployConflicts.test.ts | 409 ------------- tests/commands/deployGate.test.ts | 276 +++++++++ tests/commands/deployTargetConflict.test.ts | 25 +- tests/commands/dryRunE2E.test.ts | 7 +- tests/commands/editConflicts.test.ts | 450 --------------- tests/commands/editPiped.test.ts | 31 +- tests/commands/gateFirst.test.ts | 256 +++++++++ tests/commands/localOnlyFlow.test.ts | 32 +- tests/commands/pipedLeak.test.ts | 12 +- tests/commands/removeCommand.test.ts | 6 + tests/commands/rotateConflicts.test.ts | 156 ----- .../rotatePromotesThenRotates.test.ts | 4 + tests/commands/rotateRefusals.test.ts | 7 +- tests/commands/secretsDeployConflicts.test.ts | 30 +- tests/commands/secretsEditConflicts.test.ts | 76 ++- tests/commands/secretsSetEngine.test.ts | 6 +- tests/deploy/dokployTargetConflict.test.ts | 108 ++-- tests/helpers/gateMock.ts | 32 ++ tests/helpers/pipedHarness.ts | 17 + tests/integration/cliIntegration.test.ts | 114 ++++ tests/run-tests.sh | 8 +- tests/sync/commit.test.ts | 250 ++++++++ tests/sync/commitOct2.test.ts | 424 -------------- tests/sync/commitPlan.test.ts | 200 ------- tests/sync/conflicts.test.ts | 318 +++++----- tests/sync/gate.test.ts | 250 ++++++++ tests/sync/noFolder.test.ts | 65 +++ tests/sync/syncEngine.test.ts | 499 +++++++++++++++- tests/system/systemStore.test.ts | 14 +- tests/ui/editScreenReducer.test.ts | 130 +++++ tests/ui/secretsDeployConflicts.test.ts | 58 +- tests/ui/secretsEditConflicts.test.ts | 40 +- tests/ui/secretsEditConflictsDriver.test.ts | 10 +- 84 files changed, 4754 insertions(+), 4178 deletions(-) create mode 100644 src/sync/gate.ts create mode 100644 src/sync/noFolder.ts create mode 100644 tests/commands/bareCapyCommit.test.ts rename tests/{sync => commands}/compareSecrets.test.ts (96%) delete mode 100644 tests/commands/deployConflicts.test.ts create mode 100644 tests/commands/deployGate.test.ts delete mode 100644 tests/commands/editConflicts.test.ts create mode 100644 tests/commands/gateFirst.test.ts delete mode 100644 tests/commands/rotateConflicts.test.ts create mode 100644 tests/helpers/gateMock.ts create mode 100644 tests/sync/commit.test.ts delete mode 100644 tests/sync/commitOct2.test.ts delete mode 100644 tests/sync/commitPlan.test.ts create mode 100644 tests/sync/gate.test.ts create mode 100644 tests/sync/noFolder.test.ts create mode 100644 tests/ui/editScreenReducer.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c6a22f68..f9df12d4 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,7 +48,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -89,7 +89,7 @@ JSON support: yes (`--json`) ## `capy push` -Push your changes to Keep: edited, new and deleted variables +Push encrypted values to Keep ``` capy push [options] @@ -97,8 +97,8 @@ capy push [options] | Option | Description | Default | |---|---|---| -| `--non-tty` | treat stdin as not a terminal; a conflict stops the push with the conflict_id (agents/CI) | | -| `--resolve ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--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 (secrets set: local or remote); 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 @@ -129,7 +129,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -559,7 +559,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -581,7 +581,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -665,7 +665,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -688,7 +688,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -721,7 +721,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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`) @@ -744,7 +744,7 @@ 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 ` | decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict) | `[]` | +| `--resolve ` | answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); 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 @@ -902,7 +902,6 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `SECRETS_STALE_BASE` - `SECRETS_CONFLICT` - `SECRETS_CONFLICT_STALE` -- `DEPLOY_UNSAVED_CHANGES` - `SECRETS_RESOLVE_INVALID` - `SECRETS_RESOLVE_NEEDS_CONFLICT` - `REMOVE_LOCAL_DRIFT` @@ -957,4 +956,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**: A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all. `capy deploy` never delivers a value Capy does not hold and never saves one unasked: off a terminal, folder edits Capy lacks stop it with exit 3 and stdout {ok:false, code:"DEPLOY_UNSAVED_CHANGES", names:[...]} (names only, never values; source conflicts are reported first). Save them with `capy push`, then rerun. +- **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, deploy) runs bare `capy` first. 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/commands/addCommand.ts b/src/commands/addCommand.ts index d52253bc..ff3cc3a2 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -3,6 +3,7 @@ import { resolveContext, writeAndSync, type ResolvedContext } from './connectors 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, @@ -27,12 +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 decide a conflict of a piped value. */ + /** `--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, @@ -92,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); @@ -141,7 +152,7 @@ export class AddCommand { // 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. - const conflicts = conflictFlagsOrExit(opts.conflictFlags ?? {}, json); + conflictFlagsOrExit(opts.conflictFlags ?? {}, json); const piped = await readPipedValue(process.stdin, MAX_PIPED_BYTES); if (!piped.ok) return refusePiped(json, piped.code, piped.error); @@ -153,8 +164,8 @@ export class AddCommand { command: 'add', pr: opts.pr, dryRun: opts.dryRun === true, - conflicts, - 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 24f7c518..e85fa774 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'; @@ -44,10 +44,17 @@ import { resolveLocalProjectKey } from '../core/localUnlock'; import { isMembershipRevokedError } from '../errors/membershipRevoked'; import { cleanupOrgData } from '../cleanup/orgCleanup'; import { formatSnippet } from './statusCommand'; -import { compareSecrets, hashValue } from '../sync/compareSecrets'; -import { commitLocalValues, envBlobOf, findConflicts, keepForEnv, localChanges, pinnedHashes, snapshotFrom, type Snapshot } from '../sync/commit'; -import { conflictFlagsOrExit, conflictRefusalFor, exitOnConflictRefusal, protocolDecider, type ConflictView } from '../sync/conflicts'; -import { isInteractive } from '../ui/interactive'; +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'; @@ -86,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; @@ -147,31 +166,46 @@ 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 detected = await this.projectManager.detectProjectState(); + + // Check if .env has metadata we can recover from (e.g. keep.lock was deleted) + const envMeta = detected.initialized ? undefined : this.fileManager.readEnvMeta(this.options.envPath); + const recovered: ProjectState | undefined = + envMeta?.org_id && envMeta.project_id + ? { ...detected, initialized: true, organizationId: envMeta.org_id, projectId: envMeta.project_id, activeBranch: envMeta.branch ?? null } + : undefined; + + if (!detected.initialized && recovered === 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; } + const projectState = recovered ?? detected; await this.syncProject(projectState); // CAP-697: auth is established by now. Reporting never changes this command's output or exit code. @@ -196,6 +230,16 @@ export class CapyCommand { } } + /** + * 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.projectManager.detectProjectState(); + return projectState.initialized ? this.syncProject(projectState) : 'up-to-date'; + } + /** * Resolve the branch this run operates on. Local signals first — the .env * header (what the secrets on disk were actually encrypted for) outranks @@ -280,18 +324,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)}.`); @@ -308,6 +341,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 { @@ -920,15 +967,17 @@ 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) { + 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 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)) { fetchSpinner.stop(); @@ -950,6 +999,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. @@ -972,25 +1022,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); @@ -1061,9 +1112,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); @@ -1074,7 +1125,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(''); }; @@ -1107,7 +1157,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; @@ -1131,13 +1181,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, @@ -1157,8 +1207,8 @@ export class CapyCommand { const { authResult, branch } = await (async (): Promise<{ authResult: AuthResult; branch: string }> => { if (localMode) { const localBranch = await this.resolveActiveBranch(projectState, true); - // The banner is for a person: off a terminal stdout stays free for the conflict payload (CAP-717). - if (isInteractive()) { + // 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)', @@ -1175,7 +1225,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; @@ -1196,6 +1246,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...'; @@ -1219,8 +1275,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); } @@ -1235,7 +1291,7 @@ export class CapyCommand { || (result.organizations?.length === 0 ? 'not yet created' : result.organization_id) || 'not yet created'; - if (isInteractive()) { + if (!this.protocol) { this.displayHeader( projectState.projectName || 'not yet created', orgName, @@ -1346,7 +1402,7 @@ export class CapyCommand { 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 @@ -1463,12 +1519,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) @@ -1539,43 +1597,24 @@ export class CapyCommand { project_name: projectState.projectName!, variables: {}, }; - - // Off a terminal nobody can answer the menu (CAP-717). When both sides changed a name the protocol decides it: - // the local changes are committed on top of the server's latest, and each conflicting name needs --resolve - // (exit 3 with the payload otherwise). Without a conflict nothing changes: the menu below is what it was. - const protocolCommit = !isInteractive() && !localMode && networkAvailable && !isOnboarding && seenSnapshot !== undefined && - findConflicts({ pinned, latest: seenSnapshot.entries, changes: localChanges(localPlaintext, pinned, { removals: true }) }).length > 0; - if (protocolCommit) { - const flags = conflictFlagsOrExit(this.options.conflictFlags ?? {}, false); - const { changed } = await commitLocalValues( - { - pm: this.projectManager, - fileManager: this.fileManager, - serviceClient: this.serviceClient, - orgId: projectState.organizationId!, - projectId: projectState.projectId!, - branch, - userId: authResult.user_id!, - projectKey: encryptionKey, - keep, - localPlaintext, - }, - { pinned, remoteHashes, seen: seenSnapshot }, - protocolDecider(flags), - ); - console.error(`Pushed ${changed} change(s) to Keep.`); // COPY-FLAG - this.installGitHooks(); - return; - } + // 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; @@ -1660,12 +1699,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 @@ -1722,55 +1763,74 @@ 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); + // `pinned` for a value whose pinned snapshot cannot be read here would drop the variable (the table marks it + // "unresolvable" and never picks it for anyone): off a terminal that is refused, not guessed. `delete` says delete. + const unreadable = Object.entries(answers).filter(([name, choice]) => choice === 'pinned' && pinned[name] !== undefined && !Object.hasOwn(answered, name)); + if (unreadable.length > 0) { + throw new CapyError( + `${unreadable.map(([name]) => name).join(', ')}: the pinned value cannot be read here, so pinned would delete it. Answer local, remote or delete.`, // COPY-FLAG + ERROR_CODES.SECRETS_RESOLVE_INVALID, + { names: unreadable.map(([name]) => name) }, + ); + } + return { kind: 'proceed', finalEnv: answered }; + } // Individual resolution. - const resolved = await this.resolveIndividually( - diffs, - showLocal, - showRemote, - pinned, - localPlaintext, - remotePlaintext, - pinnedPlaintext, - { - project: keep.project_name, - branch, - serverKeepHash: seenSnapshot?.keep === undefined ? '' : SyncEngine.computeKeepHash(seenSnapshot.keep, branch), - }, - ); + 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; // Commit + push are coupled: choosing "commit local" pushes too — except // in local-only mode, where there is no remote and the local writes ARE the commit. - if (action === 'commit_local') { - // What the user committed is what they CHANGED, on top of the server's latest (sync/commit.ts). - const { changed: changeCount } = await commitLocalValues( - { - 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, - }, - { pinned, remoteHashes, seen: seenSnapshot }, - ); - console.log(`\n> keep.lock updated (${diffs.length} changes)`); - console.log( + // 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 ${changeCount} change(s) locally (local-only mode).` - : `\nPushed ${changeCount} change(s) to Keep.`, + ? `\nStored ${pushedCount} change(s) locally (local-only mode).` + : `\nPushed ${pushedCount} change(s) to Keep.`, ); this.installGitHooks(); - return; + return 'changed'; } // Retrieving (pinned or remote values) or resolving row by row only moves this folder: @@ -1801,10 +1861,11 @@ export class CapyCommand { keep_hash: setSyncKeepHash(existingSyncState, branch, SyncEngine.computeKeepHash(finalKeep, branch)), }); - console.log(`\n> keep.lock updated (${diffs.length} changes)`); + this.say(`\n> keep.lock updated (${diffs.length} changes)`); // Install hooks on every run (idempotent) this.installGitHooks(); + return 'changed'; } /** @@ -1826,7 +1887,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, @@ -1853,32 +1914,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(''); @@ -1886,24 +1939,14 @@ 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, @@ -1966,23 +2009,7 @@ export class CapyCommand { // 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: diffs.map((d) => ({ - kind: 'source' as const, - name: d.variable, - project: view.project, - branch: view.branch, - pinned: d.pinned ?? null, - yours: d.local ?? null, - theirs: d.remote ?? null, - })), - }, - ], - ); + throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ ...view, conflicts: sourceRowsOf(diffs, view) }]); } if (outcome === 'cancelled') { diff --git a/src/commands/conflictPrompt.ts b/src/commands/conflictPrompt.ts index 6c9c4a15..29458142 100644 --- a/src/commands/conflictPrompt.ts +++ b/src/commands/conflictPrompt.ts @@ -1,32 +1,17 @@ /** - * The terminal's answer to a conflict (CAP-717): one question per conflicting name, mine or theirs. - * Off a terminal nothing here runs: `--resolve` / `--conflict` decide (sync/conflicts.ts). Never shows a - * value, only the name and, for a deploy target, where. + * 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 { resolveKeyOf, type Conflict, type Resolution, type Resolutions } from '../sync/conflicts'; +import { isTargetConflict, resolveKeyOf, type Conflict, type Resolutions, type TargetResolution } from '../sync/conflicts'; // COPY-FLAG: minimal, neutral wording; pending approval. -function questionFor(conflict: Conflict): string { - return conflict.kind === 'target' - ? `${conflict.name} on ${conflict.target} was changed outside Capy. Which value?` - : `${conflict.name} was changed in Capy since you last synced. Which value?`; -} - -// COPY-FLAG: minimal, neutral wording; pending approval. -const unsavedQuestionFor = (names: readonly string[]): string => `${names.join(', ')} changed in this folder and is not saved to Capy. Save and deploy?`; - -/** - * `capy deploy` found folder edits Capy does not hold (CAP-717): one question naming them (never a value). - * Enter saves them to Capy and delivers; declining stops the deploy with nothing saved and nothing delivered. - */ -export async function askSaveAndDeploy(names: readonly string[]): Promise { - const inquirer = (await import('inquirer')).default; - const { save } = (await inquirer.prompt([{ type: 'confirm', name: 'save', message: unsavedQuestionFor(names), default: true }])) as { save: boolean }; - return save; -} +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 conflict in turn (one prompt, then the rest) and returns the answers by decision key. */ -export async function askConflicts(conflicts: readonly Conflict[]): Promise { +/** 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; @@ -37,11 +22,11 @@ export async function askConflicts(conflicts: readonly Conflict[]): Promise `\x1b[1m${s}\x1b[0m`; @@ -79,6 +79,19 @@ 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); + const gate: GateOptions = { + devMode: this.devMode, + json: effective.json === true, + nonTty: effective.nonTty, + conflictFlags: effective.conflictFlags, + dryRun: effective.dryRun === true, + }; + // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). + // Discovery is the one exception: it writes other checkouts than this folder, and applies its own plan there. + if (!(effective.discover && mod.discover)) await gateFirst(gate); + if (mod.precheck) mod.precheck(); // Discovery mode (CAP-657 follow-up, `dokploy` only): finds every @@ -100,10 +113,7 @@ export class ConnectCommand { return await this.executeDiscovery(mod, provider, discoveryCtx, discoveryOpts); } - // Off a terminal (or given --resolve), a conflict follows the protocol: nothing is written, exit 3, the payload on stdout. - const flags = conflictFlagsOrExit(effective.conflictFlags ?? {}, effective.json === true); - const protocol = effective.json === true || !isInteractive(effective.nonTty) || hasConflictFlags(flags); - const ctx = await resolveContext({ devMode: this.devMode, dryRun: effective.dryRun === true, ...(protocol ? { conflicts: flags } : {}) }); + 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 @@ -220,24 +230,7 @@ export class ConnectCommand { // `writeImportOutcome` never auto-commits keep.lock and never prints // anything itself, so there is nothing left for a `quiet` flag to // suppress here. - // A name someone else changed on the server since this folder's pin is a refusal naming it (nothing was uploaded). - const written = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }).then( - (result) => ({ conflict: undefined, wrote: result.wrote }), - (err: unknown) => { - exitOnConflictRefusal(err); - return isSecretsConflict(err) ? { conflict: err, wrote: false } : Promise.reject(err); - }, - ); - if (written.conflict) { - if (opts.json) { - console.log(JSON.stringify({ ok: false, code: written.conflict.code, message: written.conflict.message, names: written.conflict.details?.names })); - } else { - console.error(`\n ${written.conflict.message}\n`); - } - process.exitCode = 1; - return { linked: false }; - } - const { wrote } = written; + const { wrote } = await writeImportOutcome(ctx, outcome, { push: !opts.noPush, dryRun }); const pushed = !opts.noPush && wrote; const cleared = outcome.cleared ?? []; const hasChange = outcome.imported.length > 0 || cleared.length > 0; diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index 01d027f2..3773cf8a 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'; @@ -64,7 +65,6 @@ import { listOrgProjectsOrUnavailable } from '../capyCommand'; import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { BaseUrlStore, Unanswered, offerSaveAskedBaseUrl, realBaseUrlStoreOpener, resolveDokployBaseUrl, settingsUnanswered } from '../../deploy/dokployBaseUrl'; import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; -import { isSecretsConflict } from '../../sync/commit'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; import { discoveryProjectQuestion } from '../../ui/projectQuestions'; import { @@ -1899,8 +1899,8 @@ async function importIntoBranchSafe( try { await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); } catch (err) { - // A conflict keeps its own code (and names the variables); anything else is the push failing. - return { ok: false, code: isSecretsConflict(err) ? err.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: isStaleBase(err) ? ERROR_CODES.SECRETS_STALE_BASE : 'DOKPLOY_PUSH_FAILED', message: err instanceof Error ? err.message : String(err) }; } return outcome; } diff --git a/src/commands/connectors/shared.ts b/src/commands/connectors/shared.ts index a478065c..448a823f 100644 --- a/src/commands/connectors/shared.ts +++ b/src/commands/connectors/shared.ts @@ -2,8 +2,8 @@ import { ProjectManager } from '../../core/projectManager'; import { FileManager } from '../../files/fileManager'; import { AuthService, silentAuthFailureMessage } from '../../auth/authService'; import { ServiceClient } from '../../service/serviceClient'; -import { commitFolder, readLocalPlaintext, type Resolutions } from '../../sync/commit'; -import { protocolDecider, type ConflictDecider, type ConflictFlags } from '../../sync/conflicts'; +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'; @@ -25,11 +25,10 @@ export interface ResolvedContext { /** CAP-697: warnings from reporting this project's repo link (empty unless the keep.lock looks copied). */ repoLinkWarnings?: readonly RepoLinkWarning[]; /** - * How a write settles a conflict (CAP-717): set by a command that follows the non-TTY protocol, so - * every save through this context refuses with the payload (or applies `--resolve`). Absent: - * a conflict is a plain `SECRETS_CONFLICT`. + * 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. */ - decide?: ConflictDecider; + gate?: GateOptions; } /** @@ -74,8 +73,8 @@ export async function resolveContext( json?: boolean; /** The caller is a dry run: the repo link is not reported. */ dryRun?: boolean; - /** The caller follows the conflict protocol (off a terminal, or given `--resolve`): its flags decide every conflict. */ - conflicts?: ConflictFlags; + /** 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; @@ -145,7 +144,7 @@ export async function resolveContext( keep, localPlaintext: readLocalPlaintext(fileManager, projectKey), repoLinkWarnings: repoLink.warnings, - ...(opts.conflicts === undefined ? {} : { decide: protocolDecider(opts.conflicts) }), + ...(opts.gate === undefined ? {} : { gate: opts.gate }), }; } @@ -194,10 +193,10 @@ async function resolveProjectKeyOrRefuse(args: { * is the caller that passes a value, because replacing a credential is what * rotate is for. * - * The save is `commitFolder` (sync/commit.ts): it changes ONLY `varName` (plus the - * connector entries) on top of the server's latest, so nothing else in this folder's - * `.env` or keep.lock can revert anyone else's value. If the server changed `varName` - * since this folder's pin, it throws `SECRETS_CONFLICT` and uploads nothing. + * 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. @@ -215,8 +214,6 @@ export async function writeAndSync( connector?: ConnectorMetadata; /** Additional (varName, connector) pairs to mark managed in the same write. */ alsoConnect?: ReadonlyArray<{ varName: string; entry: ConnectorMetadata }>; - /** Decisions already made for names the server changed (e.g. settled before a rotation issued its key). */ - resolutions?: Resolutions; }, ): Promise { const { fileManager, branch, projectKey, keep, localPlaintext } = ctx; @@ -233,16 +230,15 @@ export async function writeAndSync( return; } - await commitFolder(ctx, { set: value === undefined ? {} : { [varName]: value }, remove: [] }, { transformKeep: attach, resolutions: opts.resolutions }); + await commitGated(ctx, { set: value === undefined ? {} : { [varName]: value }, remove: [] }, { transformKeep: attach }); } /** * `capy remove NAME...` — drop `names` from the active branch. Only those names - * move: everything else on the server, and every local edit in `.env`, is left - * alone. A name the server changed since this folder's pin is a `SECRETS_CONFLICT`. + * move: everything else on the server is left alone. */ export async function removeAndSync(ctx: ResolvedContext, names: readonly string[]): Promise { - await commitFolder(ctx, { set: {}, remove: names }); + await commitGated(ctx, { set: {}, remove: names }); } /** @@ -290,7 +286,7 @@ export async function writeImportedAndSync( return; } - await commitFolder(ctx, { set, remove: clear }, { transformKeep: attachAll }); + await commitGated(ctx, { set, remove: clear }, { transformKeep: attachAll }); } /** diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 04d2b399..8a60c187 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -59,7 +59,9 @@ import { VarDelivery, } from '../deploy/targetsGate'; import { KeepFile, ERROR_CODES, AuthResult, ErrorCode } from '../types/index'; -import { adoptedKeepFor, commitToServer } from '../sync/commit'; +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'; @@ -95,7 +97,7 @@ import { printConflictRefusal, type RawConflictFlags, } from '../sync/conflicts'; -import { isUnsavedChangesRefusal, printUnsavedChangesRefusal, reconcileDeployConflicts } from './deployConflicts'; +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'; @@ -416,7 +418,7 @@ async function loadDeploySecrets( * 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. * - * The write is the one commit path (sync/commit.ts): `commitToServer` reads the server's latest for `branch`, + * 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 @@ -424,7 +426,7 @@ async function loadDeploySecrets( * move a secret's value under an unchanged hash). * * Writes only the untracked working copy (`writeKeepFile`), and only the branch's slice of the server's keep - * (`adoptedKeepFor`): it never auto-commits the tracked keep.lock onto whatever branch the caller happens to be + * (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. * @@ -467,22 +469,16 @@ async function pushKeepTransform( const fresh = await resolveFreshSnapshot(cwd, branch, devMode, `${label} in keep.lock`, opts.warnCode); if (!fresh) return { ok: false }; - const { resolveProjectKey } = await import('../crypto/keyResolver'); - const projectKey = await resolveProjectKey(fresh.orgId, fresh.projectId, fresh.userId, { - coDecrypt: (o, c) => fresh.serviceClient.coDecrypt(o, c).then((r) => r.plaintext), - wrapOuterLayer: (o, p) => fresh.serviceClient.wrapOuterLayer(o, p).then((r) => r.ciphertext), - }); - const committed = await commitToServer({ - client: fresh.serviceClient, - projectId: fresh.projectId, - branch, - projectKey, - startKeep: keep, - changes: { set: {}, remove: [] }, - transformKeep: transform, - }); + // 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) new FileManager(cwd).writeKeepFile(adoptedKeepFor(keep, committed, branch)); + if (writeLocal) { + new FileManager(cwd).writeKeepFile(SyncEngine.spliceKeepBranch(keep, SyncEngine.adoptServerKeep(done.serverKeepFile, done.keep, branch), branch)); + } return { ok: true }; } catch (err: any) { console.error(` ${YELLOW('!')} could not ${label} in keep.lock: ${codePrefix}${err?.message ?? err}`); @@ -519,9 +515,7 @@ async function resolveFreshSnapshot( ): Promise<{ keep: KeepFile; latest: { env_file: string; keep_hash: string; keep_file?: string }; - orgId: string; projectId: string; - userId: string; serviceClient: ServiceClient; } | null> { const codePrefix = warnCode ? `${warnCode}: ` : ''; @@ -565,7 +559,7 @@ async function resolveFreshSnapshot( ); return null; } - return { keep, latest, orgId: projectState.organizationId, projectId: projectState.projectId, userId: authResult.user_id, serviceClient }; + return { keep, latest, projectId: projectState.projectId, serviceClient }; } catch (err: any) { console.error(` ${YELLOW('!')} could not ${label}: ${codePrefix}${err?.message ?? err}`); return null; @@ -2475,6 +2469,17 @@ export async function deployCommand( // 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( @@ -2603,10 +2608,10 @@ export async function deployCommand( const msg = `chore(deploy): ${target.name} → ${target.branch} (${target.kind})`; const baseBranch = target.gitBaseBranch ?? 'main'; - // ── Conflicts (CAP-717) — before the gate, a mint, a decrypt or any write. The values about to be delivered - // are compared with Capy's latest (source) and, on Dokploy, with the service's live env (target); every - // conflict is answered (a question per variable in a terminal, --resolve off one) and the folder takes the - // values that will be delivered. Nothing is written until then. + // ── 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({ @@ -2625,10 +2630,6 @@ export async function deployCommand( printConflictRefusal(err); return { ok: false, code: EXIT_NEEDS_INPUT }; } - if (isUnsavedChangesRefusal(err)) { - printUnsavedChangesRefusal(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 @@ -2640,7 +2641,7 @@ export async function deployCommand( renderPlan(target, adapter); showPreflightWarnings(); } - // What this delivery records: the target without the variables whose platform value was kept (`theirs`). + // 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) { diff --git a/src/commands/deployConflicts.ts b/src/commands/deployConflicts.ts index 62bcffea..b245ee04 100644 --- a/src/commands/deployConflicts.ts +++ b/src/commands/deployConflicts.ts @@ -1,67 +1,44 @@ /** - * Conflicts for `capy deploy` (CAP-717): before a delivery writes anything, the values it is about to deliver - * are compared with the two other places that hold them. + * Platform values for `capy deploy` (CAP-717; the contract is docs/conflict-resolution.md). * - * SOURCE (folder against the Capy server). The folder's `.env` may lag Capy (a teammate saved since this folder's - * pin) or be ahead of it. The same planner every other write uses (`sync/commit.ts`) decides per variable: - * - only the server moved (folder unchanged since its pin) → deliver the server's latest, never the stale value; - * - only the folder changed (an UNSAVED EDIT) → never delivered unasked: a terminal is asked "save and deploy?" - * (yes: committed to Capy first, then delivered, so Capy holds what the platform will hold; no: nothing saved, - * nothing delivered); off a terminal the deploy is refused with `DEPLOY_UNSAVED_CHANGES` (names only); - * - both changed to the same value → delivered, the folder adopts the pin; - * - both changed, differently → a conflict: mine commits the folder's value (through the commit path) and - * delivers it, theirs delivers Capy's latest and the folder takes it like a pull. + * 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): * - * TARGET (Capy against the platform, Dokploy only: `adapter.readsLiveValues`). A value changed on the platform - * outside Capy since Capy last delivered it is a conflict: mine overwrites it, theirs skips that variable. + * 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. * - * The target comparison runs with the values that will really be delivered, i.e. after the source answers (a source - * `theirs` delivers Capy's latest), and every target decision is bound to the fingerprints it was made against. - * - * One decision step for both kinds: in a terminal one question per variable; off a terminal the protocol - * (`sync/conflicts.ts`: nothing is written, exit 3, the payload on stdout, `--resolve` + `--conflict` answer it). - * This module writes nothing until every conflict has an answer. Names and fingerprints only, never a value. + * 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 { ServiceClient } from '../service/serviceClient'; 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 { - commitFolder, - findConflicts, - pinnedHashes, - readLocalPlaintext, - readSnapshot, - sourceConflictsOf, - type Changes, - type FolderContext, - type Snapshot, -} from '../sync/commit'; -import { hashValue } from '../sync/compareSecrets'; -import { SyncEngine } from '../sync/syncEngine'; +import { baseHashOf, readLocalPlaintext, readSnapshot } from '../sync/commit'; import { conflictRefusalFor, gateConflicts, - protocolDecider, seenResolutions, - sourceResolutions, targetResolutions, type Conflict, type ConflictFlags, type ConflictView, type Resolutions, } from '../sync/conflicts'; -import { askConflicts, askSaveAndDeploy } from './conflictPrompt'; +import { askPlatformConflicts } from './conflictPrompt'; export interface ReconcileInput { readonly cwd: string; readonly target: TargetConfig; readonly adapter: DeployAdapter; readonly devMode?: boolean; - /** `--dry-run`: report the conflicts (the preview), write nothing, ask nothing. */ + /** `--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; @@ -75,48 +52,19 @@ export interface ReconcileInput { export type Reconciled = | { readonly ok: true; - /** Names whose platform value was kept (`theirs`): not delivered, not recorded. */ + /** 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 decisions. Absent: the adapter does not compare. */ + /** Hand to the adapter: what Capy last delivered, and the answers. Absent: the adapter does not compare. */ readonly targetCheck?: TargetCheck; - /** Where the conflicts were looked for (the id's scope); absent when nothing was compared. */ + /** 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: [] }; -/** Exactly what stdout carries when a deploy is stopped for folder edits Capy does not hold: names only, never a value. */ -export interface UnsavedChangesPayload { - readonly ok: false; - readonly code: typeof ERROR_CODES.DEPLOY_UNSAVED_CHANGES; - readonly names: readonly string[]; - /** A preview (`--dry-run`) reports the same payload. */ - readonly dry_run?: true; -} - -/** A deploy was stopped because the folder holds values Capy does not, and nobody at a terminal can be asked. Nothing was saved or delivered. */ -export class UnsavedChangesRefusal extends CapyError { - constructor(readonly payload: UnsavedChangesPayload) { - super(`${payload.names.join(', ')} changed in this folder and is not saved to Capy. Nothing was deployed.`, payload.code, { names: payload.names }); // COPY-FLAG - this.name = 'UnsavedChangesRefusal'; - } -} - -export const isUnsavedChangesRefusal = (err: unknown): err is UnsavedChangesRefusal => err instanceof UnsavedChangesRefusal; - -const unsavedRefusalFor = (names: readonly string[], dryRun: boolean): UnsavedChangesRefusal => - new UnsavedChangesRefusal({ ok: false, code: ERROR_CODES.DEPLOY_UNSAVED_CHANGES, names: [...names],...(dryRun ? { dry_run: true as const } : {}) }); - -/** stdout: the payload as JSON, nothing else. stderr: what to do. */ -export function printUnsavedChangesRefusal(refusal: UnsavedChangesRefusal): void { - console.log(JSON.stringify(refusal.payload, null, 2)); - console.error(refusal.message); - console.error('Save them with `capy push`, then run the deploy again.'); // COPY-FLAG -} - -/** The folder, signed in without a prompt, with its key: what a commit needs. `undefined` when this is not a Capy project folder. */ -async function openFolder(cwd: string, branch: string, devMode: boolean | undefined): Promise<(FolderContext & { serviceClient: ServiceClient }) | undefined> { +/** 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(); @@ -138,140 +86,53 @@ async function openFolder(cwd: string, branch: string, devMode: boolean | undefi coDecrypt: (o, c) => serviceClient.coDecrypt(o, c).then((r) => r.plaintext), wrapOuterLayer: (o, p) => serviceClient.wrapOuterLayer(o, p).then((r) => r.ciphertext), }); - const fileManager = new FileManager(cwd); + 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 { - pm, - fileManager, - serviceClient, - orgId: state.organizationId, - projectId: state.projectId, - branch, - userId: signedIn.user_id, - projectKey, keep, - localPlaintext: readLocalPlaintext(fileManager, projectKey), + localPlaintext: readLocalPlaintext(new FileManager(cwd), projectKey), + view: { project: keep.project_name, branch, serverKeepHash: baseHashOf(snapshot, branch) ?? '' }, }; } -/** The server's latest for the branch; a branch the server has nothing for yet is an empty snapshot. */ -async function readLatest(folder: FolderContext & { serviceClient: ServiceClient }): Promise { - try { - return await readSnapshot(folder.serviceClient, folder.projectId, folder.branch, folder.projectKey); - } catch (err) { - if (err instanceof CapyError && err.details?.status === 404) return { keep: undefined, entries: {} }; - throw err; - } -} - /** - * Settles every conflict `capy deploy ` would meet, and brings the folder in line with the values that - * will be delivered. Throws a `ConflictRefusal` off a terminal when the flags do not decide every conflict - * (nothing has been written); any other error is a failure to compare, and the caller does not deliver. - * - * The order matters: the target comparison is made with the values that will actually be delivered, and what is - * delivered for a name depends on its SOURCE answer (`theirs` delivers Capy's latest). So the source answers come - * first (asked in a terminal, read from `--resolve` off one), then the platform is compared with those values. + * 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. - if (adapter.needsDeployToken || target.vars.length === 0) return NOTHING_TO_COMPARE; + // 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 snapshot = await readLatest(folder); - const view: ConflictView = { - project: folder.keep.project_name, - branch: target.branch, - serverKeepHash: snapshot.keep === undefined ? '' : SyncEngine.computeKeepHash(snapshot.keep, target.branch), - }; - - // The folder's own changes to the variables being delivered, and what the server says about them. - const pinned = pinnedHashes(folder.keep, target.branch); - const edited = target.vars.filter((v) => folder.localPlaintext[v] !== undefined && hashValue(folder.localPlaintext[v]) !== pinned[v]); - const changes: Changes = { set: Object.fromEntries(edited.map((v) => [v, folder.localPlaintext[v]])), remove: [] }; - const source = sourceConflictsOf(findConflicts({ pinned, latest: snapshot.entries, changes }), snapshot, view); + 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: {} }; - // The source answers: asked in a terminal (never for a preview), else whatever the flags already say. - const flagged: Resolutions = Object.fromEntries( - Object.entries(sourceResolutions(input.flags.resolutions)).filter(([name]) => source.some((c) => c.name === name)), - ); - const sourceAnswers: Resolutions = input.interactive && !input.dryRun && source.length > 0 ? await askConflicts(source) : flagged; - - // Unsaved edits: a name the folder changed, that is no conflict, and that Capy does not already hold. Delivering - // one would deliver a value Capy lacks, so it is never delivered unasked (and never saved unasked). - const conflicting = new Set(source.map((c) => c.name)); - const unsaved = edited.filter((v) => !conflicting.has(v) && snapshot.entries[v]?.plaintext !== folder.localPlaintext[v]); - // A person at a terminal is asked once, naming them; declining stops the deploy with nothing saved and nothing delivered. - const mayAsk = input.interactive && !input.dryRun; - if (unsaved.length > 0 && mayAsk && !(await askSaveAndDeploy(unsaved))) { - console.log('Cancelled.'); // COPY-FLAG - return { ok: false, code: 0 }; - } - // Off a terminal (or in a preview) nobody can be asked. With no source conflict to report, refuse now, before the - // platform is contacted at all. ORDER: source conflicts come first. When both kinds are present the caller gets - // the SECRETS_CONFLICT payload (exit 3) alone; once those are resolved, a rerun that still has unsaved edits - // reaches the check after the decision step below and gets DEPLOY_UNSAVED_CHANGES (exit 3). One response each time. - if (unsaved.length > 0 && !mayAsk && source.length === 0) throw unsavedRefusalFor(unsaved, input.dryRun); - - // What is delivered once the source is answered: Capy's latest for a name answered `theirs`; else the folder's - // edit, else Capy's latest, else the folder's value (a conflict not yet answered is read as `mine`). - const valueFor = (name: string): string | undefined => - sourceAnswers[name] === 'theirs' - ? snapshot.entries[name]?.plaintext - : Object.hasOwn(changes.set, name) - ? changes.set[name] - : (snapshot.entries[name]?.plaintext ?? folder.localPlaintext[name]); - const env = Object.fromEntries(target.vars.flatMap((v) => (valueFor(v) === undefined ? [] : [[v, valueFor(v) as string] as const]))); - - const targetCheck: TargetCheck | undefined = adapter.readsLiveValues - ? { pinned: deployedHashesFor(folder.keep, target.branch, adapter.id, target.name), resolutions: {} } - : undefined; - const live = targetCheck === undefined ? undefined : await adapter.deploy(target, { ...input.adapterCtx, env, dryRun: true, cwd: input.cwd, targetCheck }); - if (live !== undefined && !live.ok) { + 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 liveConflicts: readonly Conflict[] = live?.conflicts ?? []; - const conflicts: readonly Conflict[] = [...source, ...liveConflicts]; - const scopes = [{ ...view, conflicts }]; + const rows: readonly Conflict[] = live.conflicts ?? []; + const scopes = [{ ...folder.view, conflicts: rows }]; if (input.dryRun) { - if (conflicts.length > 0) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }); - return { ok: true, skipped: [], ...(targetCheck === undefined ? {} : { targetCheck, view }) }; + if (rows.length > 0) throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }); + return { ok: true, skipped: [], targetCheck, view: folder.view }; } - // The rest of the decision step: the platform's conflicts in a terminal (the source ones were just answered), - // or the whole set against the flags off one (the conflict id binds every conflict, both kinds). - const decisions: Resolutions = input.interactive - ? { ...sourceAnswers, ...(liveConflicts.length > 0 ? await askConflicts(liveConflicts) : {}) } - : gateConflicts(input.flags, scopes); - - // Every conflict is answered. Unsaved edits that nobody was asked about stop the deploy here, before anything is - // written (see ORDER above: reached only when the source conflicts were resolved by the flags). - if (unsaved.length > 0 && !mayAsk) throw unsavedRefusalFor(unsaved, input.dryRun); - - // The folder takes what will be delivered. A variable the folder did not change is never delivered stale: Capy's - // latest is pulled in. Every variable the folder changed goes through the commit path, so Capy holds what the - // platform will hold: a conflict with its answer (mine writes the folder's value to Capy, theirs takes Capy's; - // choosing mine IS the decision, no second question), an unsaved edit the person just said to save, a value Capy - // already holds (the folder adopts its pin). - const behind = target.vars.some( - (v) => !edited.includes(v) && snapshot.entries[v]?.plaintext !== undefined && snapshot.entries[v].plaintext !== folder.localPlaintext[v], - ); - if (edited.length > 0 || behind) { - await commitFolder( - { ...folder, ...(input.interactive ? {} : { decide: protocolDecider(input.flags) }) }, - { set: Object.fromEntries(edited.map((name) => [name, folder.localPlaintext[name]])), remove: [] }, - { resolutions: sourceResolutions(decisions), seen: snapshot }, - ); - } + const answers: Resolutions = input.interactive ? await askPlatformConflicts(rows) : gateConflicts(input.flags, scopes); - // The platform decisions, each bound to the fingerprints they were made against (the adapter re-reads the service). - const platform = targetResolutions(seenResolutions(decisions, liveConflicts), target.name); + // 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 === 'theirs' ? [name] : [])), - ...(targetCheck === undefined ? {} : { targetCheck: { ...targetCheck, resolutions: platform }, view }), + skipped: Object.entries(platform).flatMap(([name, seen]) => (seen.choice === 'remote' ? [name] : [])), + targetCheck: { ...targetCheck, resolutions: platform }, + view: folder.view, }; } diff --git a/src/commands/deployTokenCommand.ts b/src/commands/deployTokenCommand.ts index ca80756f..5cff6138 100644 --- a/src/commands/deployTokenCommand.ts +++ b/src/commands/deployTokenCommand.ts @@ -18,7 +18,8 @@ import inquirer from 'inquirer'; import { generateDeployHtml } from '../ui/deployPage/html'; import { hashValue } from '../deploy/keepGate'; import { stripTargetsForDeployId } from '../deploy/targetsGate'; -import { adoptedKeepFor, commitToServer } from '../sync/commit'; +import { uploadKeepOnly } from '../sync/commit'; +import { SyncEngine } from '../sync/syncEngine'; import { ERROR_CODES } from '../types/index'; import type { ProjectState } from '../types/index'; @@ -65,23 +66,16 @@ async function stripRevokedTargets( return; } - const { resolveProjectKey: resolveKey } = await import('../crypto/keyResolver'); - 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), - }); - // A keep-only change on the SERVER's latest (sync/commit.ts): no value moves, so nothing - // in this folder's `.env` can leak into the blob or revert anyone's value. - const committed = await commitToServer({ - client: serviceClient, - projectId: projectState.projectId, - branch, - projectKey, - startKeep: keep, - changes: { set: {}, remove: [] }, - transformKeep: (latest) => stripTargetsForDeployId(latest, deployId), - }); - if (committed.result) new FileManager().writeKeepFile(adoptedKeepFor(keep, committed, 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}`); } diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 43c4d230..e733f121 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -7,12 +7,13 @@ import { readSecretsLocal, LOCAL_USER_ID } from '../config/globalConfig'; import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { hashValue } from './statusCommand'; -import { EditScreen, EditRow, EditState, EditLoaded, classifiedRow, focusedOn } 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 { KeepFile } from '../types/index'; import { commitFolder, pinnedHashes, readLocalPlaintext, readSnapshot, type FolderContext, type Snapshot } from '../sync/commit'; -import { conflictFlagsOrExit, type RawConflictFlags, type Resolutions, type SeenResolutions } from '../sync/conflicts'; +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'; @@ -22,9 +23,11 @@ import { printRepoLinkWarnings, reportRepoLink } from '../core/repoLinkReporter' const B = (s: string) => `\x1b[1m${s}\x1b[0m`; +const has = (record: object, name: string): boolean => Object.hasOwn(record, name); + /** * 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 the commit path plans against (`seen`), never the folder's pinned + * 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. */ export interface Baseline { @@ -92,7 +95,7 @@ function loadLocalBaseline(args: { /** * The rows: every variable known to the folder's pin, its `.env` or the other side, compared three ways (pinned, - * yours, theirs) in server mode, committed-vs-working in local mode. Pure. + * local, remote) in server mode, committed-vs-working in local mode. Pure. */ export function buildEditRows(input: { readonly localMode: boolean; @@ -165,41 +168,21 @@ export function editLoader(args: { }; } -const has = (record: object, name: string): boolean => Object.hasOwn(record, name); - -/** - * The decisions that still hold: each is kept only if the fingerprints it was made against are the ones in this read of - * the server (`seen`) and in the value about to be written. With no read to bind to, none holds. - */ -export function boundResolutions( - decisions: SeenResolutions, - seen: Snapshot | undefined, - set: Readonly>, -): Resolutions { - if (seen === undefined) return {}; - return Object.fromEntries( - Object.entries(decisions) - .filter(([name, d]) => d.theirs === (seen.entries[name]?.hash ?? null) && d.yours === (has(set, name) ? hashValue(set[name]) : null)) - .map(([name, d]) => [name, d.choice] as const), - ); -} - /** - * One save from the edit screen (`c`, or commit on quit). The one commit path (sync/commit.ts): - * only the edited rows (and the conflict rows the person decided) change, on top of the server's - * latest. A decision applies only to the values it was made against (`boundResolutions`); a name - * someone else changed since the screen read it is a `SECRETS_CONFLICT` (nothing uploaded), which - * the screen answers by reading again and showing it as a conflict. 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. + * 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 decisions made on the screen, with the server read they were made against (CAP-714). */ - bound?: { readonly decisions: SeenResolutions; readonly seen: Snapshot | undefined }, + /** The server read the screen's rows were drawn from. */ + seen?: Snapshot, ): Promise> { const { branch } = folder; const committed = await commitFolder( @@ -209,7 +192,7 @@ export async function saveEdits( localPlaintext: readLocalPlaintext(folder.fileManager, folder.projectKey), }, { set: edits, remove: [] }, - { resolutions: boundResolutions(bound?.decisions ?? {}, bound?.seen, edits), seen: bound?.seen }, + { seen }, ); const adoptedKeep = committed.keep; @@ -250,7 +233,7 @@ 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 decide a conflict of a piped value. The TUI shows conflicts on its own screen (CAP-714). */ + /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ conflictFlags?: RawConflictFlags; } @@ -327,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, @@ -334,11 +320,13 @@ export class EditCommand { devMode: this.devMode, pr: opts.pr, dryRun: opts.dryRun === true, - // A bad flag is refused here, before stdin is read or anything is authenticated. - conflicts: conflictFlagsOrExit(opts.conflictFlags ?? {}, opts.json === 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(); @@ -403,8 +391,8 @@ export class EditCommand { const saveLog = startSaveLog(); const editContext = { - saveLocalEdits: (edits: Record, bound?: Parameters[4]) => - saveEdits({ pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey }, keep, edits, saveLog, bound), + saveLocalEdits: (edits: Record, seen?: Snapshot) => + saveEdits({ pm, fileManager, serviceClient, orgId, projectId, branch, userId, projectKey }, keep, edits, saveLog, seen), reload: load, }; @@ -413,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 8d53b9e3..14a542d5 100644 --- a/src/commands/editPiped.ts +++ b/src/commands/editPiped.ts @@ -22,7 +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 { ConflictFlags } from '../sync/conflicts'; +import type { GateOptions } from '../sync/gate'; export type EditMode = 'tui' | 'piped' | 'refuse'; @@ -57,8 +57,8 @@ export interface EditPipedOpts { readonly pr?: PrFlags; /** `--dry-run`: say what would happen; change nothing. */ readonly dryRun?: boolean; - /** `--resolve` / `--conflict`, already validated. */ - readonly conflicts?: ConflictFlags; + /** 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/pipedWrite.ts b/src/commands/pipedWrite.ts index 8ea3df4c..f0c274ea 100644 --- a/src/commands/pipedWrite.ts +++ b/src/commands/pipedWrite.ts @@ -16,8 +16,7 @@ import { CapyError, ERROR_CODES } from '../types/index'; import { hashValue } from './statusCommand'; import { resolveContext, writeAndSync, type ResolvedContext } from './connectors/shared'; -import { checkFolder } from '../sync/commit'; -import { NO_CONFLICT_FLAGS, exitOnConflictRefusal, previewDecider, type ConflictFlags } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import { refusePiped, reportPipedDryRun, reportPipedSuccess, type PipedAction } from './pipedValue'; import { repoLinkWarningsField } from '../core/repoLinkReporter'; import { @@ -75,9 +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; - /** `--resolve` / `--conflict` (CAP-717): a piped write is never at a terminal, so a conflict stops it unless these decide it. */ - readonly conflicts?: ConflictFlags; + 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. */ @@ -96,22 +99,10 @@ async function writeOrFail( await writeAndSync(ctx, name, value, { push }); return undefined; } catch (err) { - // A conflict is not a failure: it is the protocol's answer (exit 3, the payload on stdout). - exitOnConflictRefusal(err); return describeFailure(err); } } -/** `--dry-run`: would this write meet a value someone else changed? Exits with the payload if so; writes nothing either way. */ -async function previewOrExit(ctx: ResolvedContext, name: string, value: string): Promise { - try { - await checkFolder(ctx, { set: { [name]: value }, remove: [] }, { decide: previewDecider }); - } catch (err) { - exitOnConflictRefusal(err); - throw err; - } -} - /** The `warnings` JSON field for the repo link, or nothing. */ function warningsExtra(ctx: ResolvedContext): object { return repoLinkWarningsField({ status: 'reported', warnings: ctx.repoLinkWarnings ?? [] }); @@ -119,18 +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, - // A piped write is never at a terminal: a conflict stops it unless --resolve + --conflict decide it. - conflicts: opts.conflicts ?? NO_CONFLICT_FLAGS, + 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); @@ -138,7 +138,6 @@ export async function runPipedWrite(name: string, value: string, opts: PipedWrit // Everything above only read. Nothing below this line runs: no write, no push, no PR. const pinned = ctx.keep.variables[name]?.find((e) => e.branch === ctx.branch)?.value_hash; const wouldChange = action !== 'unchanged' && opts.push && pinned !== hashValue(value); - if (wouldChange) await previewOrExit(ctx, name, value); const preview = await previewKeepLockPrStep({ changed: wouldChange, cwd: process.cwd(), flags: opts.pr ?? {} }); return reportPipedDryRun(opts.json, { name, branch: ctx.branch, action }, preview); } diff --git a/src/commands/pushCommand.ts b/src/commands/pushCommand.ts index 2d3719e5..483f8896 100644 --- a/src/commands/pushCommand.ts +++ b/src/commands/pushCommand.ts @@ -1,226 +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 { commitFolder, localChanges, pinnedHashes } from '../sync/commit'; -import { debugLine } from '../ui/debug'; -import { CapyError, ERROR_CODES } from '../types/index'; -import { resolveProjectKey, KeyServiceOps } from '../crypto/keyResolver'; -import { 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'; -import { isInteractive } from '../ui/interactive'; -import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags, protocolDecider, type RawConflictFlags } from '../sync/conflicts'; - -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: a conflict then follows the protocol. */ + /** 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(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(opts); - } catch (error: any) { - this.debugError('push execute caught', error); - // A conflict is not a failure: it is the protocol's answer (exit 3, the payload on stdout). - exitOnConflictRefusal(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(opts: PushOpts): Promise { - // A bad --resolve / --conflict is refused before anything is read or authenticated. - const flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, false); - // Off a terminal (or given --resolve) a conflict stops the push with the payload; at a terminal it is the plain error. - const protocol = !isInteractive(opts.nonTty) || hasConflictFlags(flags); - 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, - }); - } - - 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; - } - - // What this folder has to commit is what it CHANGED: a value that differs from the one - // keep.lock pins (an edit, or a new name) and a pinned name it no longer has (a deletion; kept - // out, `.env` would get it back from the server). Values it merely still holds from an older - // sync are not pushed, so a stale folder cannot revert what someone else saved since. Every - // change, deletions included, is conflict-checked against what the server holds now. - const localPlaintext = Object.fromEntries( - Object.entries(rawLocal).map(([key, value]) => [key, value.startsWith('capy:') ? this.fileManager.decryptValue(value, encryptionKey) : value]), - ); - const changes = localChanges(localPlaintext, pinnedHashes(keep, branch), { removals: true }); - this.debug('local edits', { set: Object.keys(changes.set), removed: changes.remove }); - - const committed = await commitFolder( - { - pm: this.projectManager, - fileManager: this.fileManager, - serviceClient: localMode ? undefined : this.serviceClient, - orgId: projectState.organizationId!, - projectId: projectState.projectId!, - branch, - userId, - projectKey: encryptionKey, - keep, - localPlaintext, - ...(protocol ? { decide: protocolDecider(flags) } : {}), - }, - changes, - ); - this.debug('push complete', { localMode, uploaded: committed.uploaded, written: committed.written }); - - const count = Object.keys(changes.set).length + changes.remove.length; - pushSpinner.succeed( - localMode - ? `Stored ${count} secret(s) locally (local-only mode)` - : `Pushed ${count} secret(s) to Keep` - ); - - const { printExpiryWarnings } = await import('./connectors/shared'); - printExpiryWarnings(); - } } diff --git a/src/commands/removeCommand.ts b/src/commands/removeCommand.ts index e0058e65..ff6b5b92 100644 --- a/src/commands/removeCommand.ts +++ b/src/commands/removeCommand.ts @@ -19,8 +19,8 @@ import { isInteractive, EXIT_NEEDS_INPUT } from '../ui/interactive'; import { DRY_RUN_UNSUPPORTED_MESSAGES } from './pipedValue'; import { listTargets } from '../deploy/config'; import { CapyError, ERROR_CODES } from '../types/index'; -import { checkFolder, isSecretsConflict } from '../sync/commit'; -import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags, previewDecider, type RawConflictFlags } from '../sync/conflicts'; +import { conflictFlagsOrExit, type RawConflictFlags } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import { previewKeepLockPrStep, recordsForRemoval, @@ -39,7 +39,7 @@ 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 decide a conflict on a name being removed. */ + /** `--resolve` / `--conflict` as typed (CAP-717): they answer the differences bare `capy` finds first. */ conflictFlags?: RawConflictFlags; } @@ -107,11 +107,6 @@ export async function proceedWithRemoval( const warnings = deployTargetWarnings(opts.cwd, names); if (opts.dryRun === true) { - // A name someone else changed since this folder's pin is reported with its conflict_id; nothing is written either way. - await checkFolder(ctx, { set: {}, remove: names }, { decide: previewDecider }).catch((err: unknown) => { - exitOnConflictRefusal(err); - return Promise.reject(err); - }); // Everything above only read. Nothing below this line runs: no removal, no push, no PR. const preview = await previewKeepLockPrStep({ changed: true, cwd: opts.cwd, flags: opts.pr ?? {} }); if (opts.json) { @@ -137,12 +132,8 @@ export async function proceedWithRemoval( return; } - // A name someone else changed on the server since this folder's pin is a refusal, not a silent overwrite: - // the protocol's payload when the command follows it (exit 3), the plain refusal otherwise. - await removeAndSync(ctx, names).catch((err: unknown) => { - exitOnConflictRefusal(err); - return isSecretsConflict(err) ? refuse(opts.json, err) : Promise.reject(err); - }); + // 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). const outcome = await runKeepLockPrStep({ @@ -190,7 +181,7 @@ export class RemoveCommand { const json = opts.json === true; // Contradictory PR flags are refused before anything is changed. refuseBadPrFlags(opts.pr ?? {}, json); - const flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, 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)); @@ -200,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(); @@ -245,11 +241,8 @@ export class RemoveCommand { } } - // Everything above is local-only — no auth, no network. Only now do we - // authenticate and decrypt `.env`, which the push needs. - // Off a terminal (or given --resolve), a conflict follows the protocol: nothing is removed, exit 3, the payload on stdout. - const protocol = json || !isInteractive(opts.nonTty) || hasConflictFlags(flags); - const ctx = await resolveContext({ devMode: this.devMode, json, dryRun: opts.dryRun === true, ...(protocol ? { conflicts: flags } : {}) }); + // 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 163f21c7..183cb164 100644 --- a/src/commands/rotateCommand.ts +++ b/src/commands/rotateCommand.ts @@ -14,9 +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 { checkFolder, type Resolutions } from '../sync/commit'; -import { conflictFlagsOrExit, exitOnConflictRefusal, hasConflictFlags, type ConflictDecider } from '../sync/conflicts'; -import { askConflicts } from './conflictPrompt'; +import { conflictFlagsOrExit } from '../sync/conflicts'; +import { gateFirst, type GateOptions } from '../sync/gate'; import type { RotatePlanStop } from '../ui/screens/contract'; import { writeSync } from 'fs'; @@ -153,12 +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(); @@ -469,15 +476,9 @@ export class RotateCommand { return []; } - // Off a terminal (or given --resolve), a conflict follows the protocol. It is settled for every variable - // BEFORE the first new key is issued: a key the provider hands out once must not be lost to a refused write. - const flags = conflictFlagsOrExit(opts.conflictFlags ?? {}, false); - const protocol = !isInteractive(opts.nonTty) || hasConflictFlags(flags); - const ctx = await resolveContext({ devMode: this.devMode, ...(protocol ? { conflicts: flags } : {}) }); - const settled = await this.settleConflicts(ctx, toRotate, opts); - if (settled.rotate.length === 0) return []; + const ctx = await resolveContext({ devMode: this.devMode, gate: this.gateFor(opts) }); - const result = await this.rotateSequentially(settled.rotate, ctx, opts, { succeeded: [], failed: [] }, settled.resolutions); + const result = await this.rotateSequentially(toRotate, ctx, opts, { succeeded: [], failed: [] }); if (opts.all && (result.succeeded.length > 0 || result.failed.length > 0)) { console.log(''); @@ -493,29 +494,6 @@ export class RotateCommand { return [...result.succeeded]; } - /** - * With a push: the variables whose server value moved since this folder's pin are decided now, before the first - * key is issued. Under the conflict protocol the flags decide (exit 3 with the payload when they do not); at a - * terminal each one is asked. `theirs` drops the variable from the run: nothing is rotated that would not be - * written. With `--no-push` nothing changes. - */ - private async settleConflicts( - ctx: ResolvedContext, - toRotate: readonly { varName: string; connector: ConnectorMetadata }[], - opts: RotateOpts, - ): Promise<{ rotate: readonly { varName: string; connector: ConnectorMetadata }[]; resolutions: Resolutions }> { - if (opts.noPush) return { rotate: toRotate, resolutions: {} }; - // A rotation replaces the value whatever it was: for the check it is a change to the name, its new value not yet known. - // At a terminal (no deciding context) each conflict is a question, asked now: the new key is issued once and a - // write refused afterwards would lose it. Cancelling the question ends the command before anything is issued. - const asker: ConflictDecider = (found) => askConflicts(found); - const checked = await checkFolder(ctx, { set: {}, remove: toRotate.map((t) => t.varName) }, ctx.decide === undefined ? { decide: asker } : {}).catch((err: unknown) => { - exitOnConflictRefusal(err); - return Promise.reject(err); - }); - return { rotate: toRotate.filter((t) => checked.resolutions[t.varName] !== 'theirs'), resolutions: checked.resolutions }; - } - /** * The dev-mode live-key firewall, applied once before any rotation starts: * `capy-dev` refuses live keys outright — `--all` skips them and carries @@ -570,7 +548,6 @@ export class RotateCommand { ctx: ResolvedContext, opts: RotateOpts & { all?: boolean }, state: RotateLoopState, - resolutions: Resolutions = {}, ): Promise { if (items.length === 0) return state; const [{ varName: name, connector }, ...rest] = items; @@ -594,7 +571,7 @@ export class RotateCommand { ...state, failed: [...state.failed, { name, err: new Error('confirmation declined') }], }; - if (opts.all) return this.rotateSequentially(rest, ctx, opts, declinedState, resolutions); + if (opts.all) return this.rotateSequentially(rest, ctx, opts, declinedState); process.exit(1); } } @@ -604,8 +581,8 @@ export class RotateCommand { noPush: opts.noPush, }); - const freshCtx = await resolveContext({ devMode: this.devMode }); - await writeAndSync(freshCtx, name, value, { push: !opts.noPush, connector: updated, resolutions }); + const freshCtx = await resolveContext({ devMode: this.devMode, gate: this.gateFor(opts) }); + await writeAndSync(freshCtx, name, value, { push: !opts.noPush, connector: updated }); const succeededState: RotateLoopState = { ...state, @@ -619,9 +596,8 @@ export class RotateCommand { ); } console.log(''); - return this.rotateSequentially(rest, ctx, opts, succeededState, resolutions); + return this.rotateSequentially(rest, ctx, opts, succeededState); } catch (err) { - exitOnConflictRefusal(err); const failState: RotateLoopState = { ...state, failed: [...state.failed, { name, err }], @@ -629,7 +605,7 @@ export class RotateCommand { console.error(''); console.error(` ✗ Failed to rotate ${B(name)}: ${(err as Error).message}`); console.error(''); - if (opts.all) return this.rotateSequentially(rest, ctx, opts, failState, resolutions); + if (opts.all) return this.rotateSequentially(rest, ctx, opts, failState); process.exit(1); } } diff --git a/src/commands/secretsDeployActions.ts b/src/commands/secretsDeployActions.ts index 42b04f97..078a44d6 100644 --- a/src/commands/secretsDeployActions.ts +++ b/src/commands/secretsDeployActions.ts @@ -3,12 +3,12 @@ // (deploy/batchDeploy.ts), so the two cannot drift. Neither ever rejects: a failure comes // back as a code. // -// CONFLICTS (CAP-714): 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 conflict, with nothing written; the screen asks mine or theirs for -// each and runs again with the decisions. A decision is used only where the live value is still the -// one it was made against, otherwise the conflict comes back. Mine delivers Capy's value; theirs -// leaves the service's value and records nothing for it. +// 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 @@ -21,6 +21,7 @@ import { conflictRefusalFor, isConflictRefusal, resolveKeyOf, + isTargetConflict, type ConflictScope, type Resolutions, type SeenResolutions, @@ -33,7 +34,7 @@ const codeOf = (err: unknown): string => (err instanceof CapyError ? err.code : type CancelPhase = Parameters[0]; /** - * The decider of a run (the batch engine's `decide`): given the conflicts its comparison pass found, the decisions that + * 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. */ @@ -42,16 +43,16 @@ export function decideFrom(decisions: SeenResolutions | undefined): (scopes: rea const found = scopes.flatMap((s) => s.conflicts); const holds = (c: (typeof found)[number]): boolean => { const d = decisions?.[resolveKeyOf(c)]; - return d !== undefined && d.theirs === c.theirs && d.yours === c.yours; + 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 target conflicts of a refusal, in the order it carries them. */ -const targetConflictsOf = (conflicts: readonly { readonly kind: string }[]): readonly TargetConflict[] => - conflicts.filter((c): c is TargetConflict => c.kind === 'target'); +/** 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 @@ -101,7 +102,7 @@ export function createDeployActionsWith( 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 conflicts that are undecided, or decided against a value that has moved. + // 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) }; } diff --git a/src/commands/secretsEditActions.ts b/src/commands/secretsEditActions.ts index cdfaf9c9..4df0364a 100644 --- a/src/commands/secretsEditActions.ts +++ b/src/commands/secretsEditActions.ts @@ -8,12 +8,12 @@ // (the branches, GitHub's default branch and keep.lock) to say what would // happen, and writes nothing: no push, no cache, no GitHub write. // -// CONFLICTS (CAP-714): before any push, `run` reads every location. One whose value is neither -// the new value nor the one the row listed was changed by someone else since the person looked: -// `run` returns those as conflicts and writes nothing. The screen asks mine or theirs for each and -// runs again with the decisions; a decision holds only for the fingerprints it was made against, -// so a location that moved again comes back as a conflict. Mine writes the location bound to the -// value seen (`allowed`); theirs skips it. +// 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'; @@ -25,13 +25,13 @@ import { SetLocation, createSetEnv, locationKeyOf, + movedLocations, previewLocations, repoLabel, resolveBases, runSecretSet, - secretLocationConflict, } from './secretsSet'; -import { hashValue } from '../deploy/keepGate'; +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'; @@ -65,44 +65,36 @@ 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 decisions. */ +/** 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 fingerprints a location may hold for its push to go on: what was seen and decided against. Absent: not compared. */ - readonly allowed?: (location: SetLocation) => readonly string[]; + /** 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 holding neither the new value nor the one - * the row listed is a conflict. All decided, each against the fingerprints it holds now: the locations to push (theirs - * is skipped) and the binding for the push. Any undecided, or decided against a value that has moved since: the - * conflicts, and nothing is pushed. + * 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 yours = hashValue(request.value); - const conflicts = request.locations.flatMap((location, i): LocationConflict[] => { - const conflict = secretLocationConflict(request.name, yours, seen, location, reads[i]); - return conflict === undefined ? [] : [{ location, conflict }]; - }); - const decisionFor = (c: LocationConflict) => request.decisions?.[locationKeyOf(c.location)]; - const holds = (c: LocationConflict): boolean => { - const d = decisionFor(c); - return d !== undefined && d.theirs === c.conflict.theirs && d.yours === c.conflict.yours; - }; - if (conflicts.some((c) => !holds(c))) return { kind: 'conflicts', conflicts }; - const kept = new Set(conflicts.filter((c) => decisionFor(c)?.choice === 'theirs').map((c) => locationKeyOf(c.location))); + 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) => !kept.has(locationKeyOf(l))), - allowed: (loc) => { - const at = request.locations.findIndex((l) => locationKeyOf(l) === locationKeyOf(loc)); - return [yours, seen, reads[at]?.current?.hash ?? '']; - }, + locations: request.locations.filter((l) => !left.has(locationKeyOf(l))), + shown, + answers: request.decisions, }; } @@ -152,7 +144,7 @@ export function createEditActionsWith( 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, allowed: settled.allowed, keys }, env, { onProgress, stopPushes, stopPrs }); + 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 56342c36..99889e99 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,12 +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 { isStaleBase } from '../sync/commit'; +import { UploadIo, envBlobOf, isStaleBase, snapshotFrom, uploadChanges } from '../sync/commit'; import type { SourceConflict } from '../sync/conflicts'; +import { BoundAnswer, actionFor, holdsFor, movedRow } from '../sync/noFolder'; import { hashValue } from '../deploy/keepGate'; import type { EditSaveRecord } from '../deploy/keepGate'; import { writeKeepCache } from '../config/globalConfig'; @@ -94,11 +98,13 @@ export interface SetRequest { /** Each repo's PR base, by `repoKey`. Absent: the repo's default branch. */ readonly bases?: Readonly>; /** - * The fingerprints a location's current value may have for this push to go on (CAP-717): what the caller - * saw and decided against. A location holding anything else was changed by someone else since, and is - * `failed` with `SECRETS_CONFLICT` (nothing pushed to it). Absent: the location is not checked. + * 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 allowed?: (location: SetLocation) => readonly string[]; + readonly shown?: Readonly>; + /** The answers for locations that moved, by `locationKeyOf`, each bound to the state it was made against. */ + readonly answers?: Readonly>; /** The run's key resolver, when the caller already unlocked (the unlock is once per run). Absent: opened here. */ readonly keys?: KeyResolver; } @@ -284,14 +290,6 @@ type LocationOutcome = | { readonly kind: 'unchanged'; 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 { @@ -304,48 +302,52 @@ 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); +/** 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, write the cache. Never throws; reports the rate-limit - * reading it saw. The push names the keep it was built from (`base_keep_hash`); if the server says someone saved - * since the read (`SECRETS_STALE_BASE`), the location is read and replaced once more from the new latest. + * 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, @@ -353,55 +355,67 @@ async function pushLocation( loc: SetLocation, name: string, value: string, - allowed?: (location: SetLocation) => readonly string[], + check: { readonly shown?: string; readonly answer?: BoundAnswer }, 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); - // Someone else changed it since the caller looked (or again, since it decided): not overwritten. - if (allowed !== undefined && !allowed(loc).includes(hashValue(current.value.plaintext))) { - return failedItem(loc, new CapyError('changed since it was read', ERROR_CODES.SECRETS_CONFLICT), read.rate); + 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), + }); + if (moved !== undefined) { + if (!holdsFor(check.answer, moved)) return failedItem(loc, new CapyError('changed since it was read', ERROR_CODES.SECRETS_CONFLICT), readRate); + const action = attemptAction(check.answer.choice); + if (!action.ok) return failedItem(loc, action.error, readRate); + if (action.value === 'leave') return withRate({ kind: 'unchanged', location: loc }, readRate); } - 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, - SyncEngine.computeKeepHash(current.value.serverKeep, loc.branch), - ), - env.sleep, + const uploaded = await settled( + uploadChanges({ + client: env.client, + projectId: loc.project_id, + branch: loc.branch, + projectKey: key.value, + startKeep: snapshot.keep, + changes: { set: { [name]: value }, remove: [] }, + seen: snapshot, + io, + }), ); - if (!pushed.ok && !retried && isStaleBase(pushed.error)) return pushLocation(env, keys, loc, name, value, allowed, true); - 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); @@ -413,46 +427,61 @@ async function pushLocation( ); } +/** `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 }; + } +} + +const attemptAction = (choice: Parameters[0]): Tried> => attemptSync(() => actionFor(choice)); + 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. */ - readonly current?: { readonly hash: string; readonly serverKeepHash: string; readonly changedAt?: string }; + /** 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 in conflict: its value was changed by someone else since the caller looked (CAP-717, CAP-714). */ +/** 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 a decision for a location is kept under. */ +/** The identity an answer for a location is kept under. */ export const locationKeyOf = (l: Pick): string => `${l.project_id}/${l.branch}`; /** - * The conflict at `loc`, if any: the location holds neither the value being set (`yours`) nor the one its row listed - * (`seen`), so someone else changed it since the caller looked. Fingerprints only. `undefined` when the location could - * not be read (nothing known to conflict with) or holds one of the two. + * 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 secretLocationConflict( +export function movedLocations( name: string, - yours: string, - seen: string, - loc: SetLocation, - preview: LocationPreview | undefined, -): SourceConflict | undefined { - const current = preview?.current; - if (current === undefined || current.hash === yours || current.hash === seen) return undefined; - return { - kind: 'source', - name, - project: loc.project_name, - branch: loc.branch, - pinned: seen, - yours, - theirs: current.hash, - ...(current.changedAt === undefined ? {} : { theirs_changed_at: current.changedAt }), - }; + 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 }; @@ -470,19 +499,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, + serverKeep === undefined || (entry !== undefined && entry.plaintext === undefined) + ? UNKNOWN_PREVIEW + : { + action: entry?.plaintext === value ? 'unchanged' : 'update', + serverKeep, current: { - hash: hashValue(current.value.plaintext), - serverKeepHash: SyncEngine.computeKeepHash(current.value.serverKeep, loc.branch), - changedAt: current.value.serverKeep.variables[name]?.find((e) => e.branch === loc.branch)?.changed_at, + hash: entry?.hash, + serverKeepHash: SyncEngine.computeKeepHash(serverKeep, loc.branch), + changedAt: serverKeep.variables[name]?.find((e) => e.branch === loc.branch)?.changed_at, }, - } - : UNKNOWN_PREVIEW, + }, read.rate, ); } @@ -647,7 +678,11 @@ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks const pushed = await runPool({ items: req.locations, limit: LOCATION_CONCURRENCY, - run: (loc) => pushLocation(env, keys, loc, req.name, req.value, req.allowed), + run: (loc) => + pushLocation(env, keys, loc, req.name, req.value, { + shown: req.shown?.[locationKeyOf(loc)], + answer: req.answers?.[locationKeyOf(loc)], + }), stop: hooks.stopPushes, reserve: PACING_RESERVE, now: env.now, diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index 30888518..1db4e403 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -67,8 +67,10 @@ import { repoLabel, resolveBases, runSecretSet, - secretLocationConflict, + locationKeyOf, + movedLocations, } from './secretsSet'; +import { actionFor, bindAnswer, type BoundAnswer } from '../sync/noFolder'; import { renderSecretSetConfirmation, stoppingAfter, TERMINAL_STYLE } from './secretsSetText'; import { PoolItem, runPool } from '../utils/pool'; @@ -155,7 +157,7 @@ export function exitWithConflicts(refusal: ConflictRefusal, extra: object = {}): return process.exit(EXIT_NEEDS_INPUT); } -/** The decisions for `scopes` (the flags', when they cover them), or the command ends with the payload. */ +/** 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); @@ -165,6 +167,16 @@ export function decideOrExit(scopes: readonly ConflictScope[], flags: ConflictFl } } +/** The refusal for an answer that setting a value cannot honour (`pinned`, `delete`), or `undefined` when it can. */ +function unusableAnswer(choice: Parameters[0]): { readonly code: string; readonly message: string } | undefined { + try { + actionFor(choice); + return undefined; + } catch (err) { + return describe(err); + } +} + export function describe(err: unknown): { readonly code: string; readonly message: string } { return err instanceof CapyError ? { code: err.code, message: err.message } @@ -175,7 +187,7 @@ 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 saw (the pin of the conflict check). */ + /** The fingerprint the row listed for this location: what the caller was shown (`pinned`). */ readonly seen: string; } @@ -191,11 +203,15 @@ const withRowId = (row: SecretIndexRow): readonly Located[] => { })); }; -// ── Conflicts (CAP-717) ───────────────────────────────────────────────────── +// ── 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 where the value is no longer the one the row listed AND is not the value being set: someone - * else changed it since the caller looked. One scope per location (each is its own project and branch). + * 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, @@ -203,13 +219,9 @@ export function secretConflictScopes( located: readonly Located[], previews: readonly LocationPreview[], ): readonly ConflictScope[] { - const yours = hashValue(value); - return located.flatMap((l, i): ConflictScope[] => { - const conflict = secretLocationConflict(name, yours, l.seen, l, previews[i]); - const current = previews[i]?.current; - return conflict === undefined || current === undefined - ? [] - : [{ project: l.project_name, branch: l.branch, serverKeepHash: current.serverKeepHash, conflicts: [conflict] }]; + 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] }]; }); } @@ -470,7 +482,7 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr if (opts.dryRun === true) { 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 conflict with yet. + // 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); @@ -503,18 +515,23 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr } const value = await requireValue(io, json); - // Before anything is pushed: a location someone else changed since its row was listed needs a decision. + // 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 yours = hashValue(value); - const pushTo = located.filter((l) => decided[name] !== 'theirs' || !scopes.some((sc) => sc.project === l.project_name && sc.branch === l.branch)); - const allowed = (loc: SetLocation): readonly string[] => { - const at = located.findIndex((l) => l.project_id === loc.project_id && l.branch === loc.branch); - return [yours, located[at]?.seen ?? '', reads[at]?.current?.hash ?? '']; - }; - const result = await runSecretSet({ name, value, locations: pushTo, repos: targets, bases, allowed, keys }, io.env, { + const moved = movedLocations(name, value, located, shownOf(located), reads); + const choice = decided[name]; + const unusable = choice === undefined || moved.length === 0 ? undefined : unusableAnswer(choice); + if (unusable !== undefined) return refuse(json, unusable.code, unusable.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, 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. diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index 9890648c..7931309c 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -50,7 +50,9 @@ export function formatSnippet(value: string): string { return `${value.slice(0, 3)}...${value.slice(-3)}`; } -export { hashValue }; +// 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/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index d2639569..82986fe5 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -72,7 +72,7 @@ export interface CliHelpDoc { conventions: { json: string; codes: string; - /** What a write does when someone else changed the value it would replace (CAP-717). */ + /** What a command that writes from a folder does first, and how a difference is answered off a terminal (CAP-717). */ conflicts?: string; }; } @@ -177,7 +177,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { 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: - 'A write that would replace a value someone else changed stops: nothing is written, the exit code is 3, and stdout is {ok:false, code:"SECRETS_CONFLICT", conflict_id, conflicts:[...]} (fingerprints only, never values). Ask the person, then rerun the same command with --resolve NAME=mine|theirs for every conflicting name (NAME@TARGET=... for a deploy target) and --conflict . A state that moved since is SECRETS_CONFLICT_STALE with the fresh conflicts. There is no resolve-all. `capy deploy` never delivers a value Capy does not hold and never saves one unasked: off a terminal, folder edits Capy lacks stop it with exit 3 and stdout {ok:false, code:"DEPLOY_UNSAVED_CHANGES", names:[...]} (names only, never values; source conflicts are reported first). Save them with `capy push`, then rerun.', + 'Every command that writes from a folder (edit, add, remove, connect, rotate, push, deploy) runs bare `capy` first. 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/deploy/adapter.ts b/src/deploy/adapter.ts index 217518af..cc1d4bac 100644 --- a/src/deploy/adapter.ts +++ b/src/deploy/adapter.ts @@ -139,7 +139,7 @@ export interface DeployResult { * 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 (`theirs`): not written, not to be recorded as delivered. */ + /** Names the delivery left alone because the platform's value was kept (`remote`): not written, not to be recorded as delivered. */ skipped?: readonly string[]; } diff --git a/src/deploy/batchDeploy.ts b/src/deploy/batchDeploy.ts index d6e370e5..34e1a49b 100644 --- a/src/deploy/batchDeploy.ts +++ b/src/deploy/batchDeploy.ts @@ -535,7 +535,7 @@ type TargetPass = { readonly kind: 'check' } | { readonly kind: 'deliver'; reado * 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`): `theirs` leaves that variable on the service as it is and records nothing for it. + * (`NAME@TARGET`): `remote` leaves that variable on the service as it is and records nothing for it. */ export async function pushTarget( env: BatchEnv, @@ -644,7 +644,7 @@ async function passOverTarget( 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 (`theirs`) was not written: it is not recorded as delivered either. + // 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); diff --git a/src/deploy/configuredTargets.ts b/src/deploy/configuredTargets.ts index 2c97d5fc..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'; @@ -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, undefined, latest.keep_hash); - 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 dcc6b5f0..3cfa8155 100644 --- a/src/deploy/deliveryRecord.ts +++ b/src/deploy/deliveryRecord.ts @@ -13,9 +13,7 @@ */ import type { DeployAdapter, TargetConfig } from './adapter'; import { TargetDeliveryDescriptor, VarDelivery, recordTargetDeliveries } from './targetsGate'; -import { CapyError, ERROR_CODES, KeepFile } from '../types/index'; -import { SyncEngine } from '../sync/syncEngine'; -import { isStaleBase } from '../sync/commit'; +import { uploadKeepOnly } from '../sync/commit'; import type { RateInfo } from '../utils/pool'; /** @@ -77,37 +75,6 @@ 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[]; @@ -115,35 +82,25 @@ type Deliveries = ReadonlyArray<{ readonly deliveredAt: string; }>; -async function recordOnce(client: RecordClient, projectId: string, branch: string, deliveries: Deliveries, mayRetry: boolean): Promise { - const snapshot = await readServerSnapshot(client, projectId, branch); - if (!snapshot.ok) return snapshot; - const next = deliveries.reduce( - (keep, d) => (d.values.length === 0 ? keep : recordTargetDeliveries(keep, branch, d.delivery, d.deliveredAt, d.values)), - snapshot.keep, - ); - if (next === snapshot.keep) return { ok: true, pushed: false }; - try { - await client.pushSecrets(projectId, JSON.stringify(next), snapshot.envFile, branch, undefined, SyncEngine.computeKeepHash(snapshot.keep, branch)); - return { ok: true, pushed: true }; - } catch (err) { - // Someone saved between the read and the push: read again and record on top of that, once. - return mayRetry && isStaleBase(err) ? recordOnce(client, projectId, branch, deliveries, false) : { ok: false, code: codeOf(err) }; - } -} - /** * Fetches the server's keep for (project, branch), applies every delivery in * `deliveries` to it, and pushes the result back server-only with the blob - * unchanged. Nothing is pushed when every delivery is already recorded. The push - * names the keep it was built from; 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. + * 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 function recordDeliveriesOnServer(client: RecordClient, projectId: string, branch: string, deliveries: Deliveries): Promise { - return recordOnce(client, projectId, branch, deliveries, true); +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, + ), + ); + return done.ok ? { ok: true, pushed: done.pushed } : done; } diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index d49c7ff7..b24a5868 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -11,7 +11,7 @@ */ import { parse as parseDotenv } from 'dotenv'; import { hashValue } from '../sync/compareSecrets'; -import type { Resolution, SeenResolutions, TargetConflict } from '../sync/conflicts'; +import type { SeenResolutions, TargetConflict, TargetResolution } from '../sync/conflicts'; import { ERROR_CODES } from '../types/index'; /** @@ -1392,7 +1392,7 @@ 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 (`theirs`), and for a batch deploy that sends only the picked names. + * because the platform's value was kept (`remote`), and for a batch deploy that sends only the picked names. */ readonly keep?: (name: string) => boolean; } @@ -1509,19 +1509,19 @@ 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. */ - readonly yours: string; - /** What the service would see now. */ - readonly theirs: string; + /** 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: - * - theirs = the value the service would read: `dotenv.parse` of the WHOLE current env (inside Capy's block, + * - 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); - * - yours = the value about to be delivered. - * A conflict is a live value that exists and differs from BOTH pinned and yours. A name the service does not + * - 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. */ @@ -1533,9 +1533,9 @@ export function dokployTargetConflicts( const live = parseDotenv(env ?? ''); return values.flatMap(({ name, value }) => { if (!Object.hasOwn(live, name)) return []; - const theirs = hashValue(live[name]); - const yours = hashValue(value); - return theirs === yours || theirs === pinned[name] ? [] : [{ name, pinned: pinned[name], yours, theirs }]; + const remote = hashValue(live[name]); + const local = hashValue(value); + return remote === local || remote === pinned[name] ? [] : [{ name, pinned: pinned[name], local, remote }]; }); } @@ -1544,9 +1544,9 @@ export interface TargetCheck { /** `deployed_value_hash` by name for this target. */ readonly pinned: Readonly>; /** - * Decisions already made, by bare NAME, each bound to the `theirs` / `yours` fingerprints it was made against: - * a decision 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 decision does not count and the conflict is open again. + * 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; } @@ -1554,9 +1554,9 @@ export interface TargetCheck { export type TargetPlan = | { readonly kind: 'clear'; - /** The values to write: all of them minus the names kept as `theirs`. */ + /** 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 (`theirs`): not written, not recorded. */ + /** 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[]; @@ -1571,19 +1571,19 @@ export function planTargetDelivery( ): TargetPlan { if (check === undefined) return { kind: 'clear', deliver: values, skipped: [], found: [] }; const found = dokployTargetConflicts(env, values, check.pinned); - const choiceFor = (c: LiveConflict): Resolution | undefined => { + const choiceFor = (c: LiveConflict): TargetResolution | undefined => { const seen = check.resolutions[c.name]; - return seen !== undefined && seen.theirs === c.theirs && seen.yours === c.yours ? seen.choice : undefined; + 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) === 'theirs').map((c) => c.name); + 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) => ({ kind: 'target', name: c.name, target, pinned: c.pinned ?? null, yours: c.yours, theirs: c.theirs })); + 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 diff --git a/src/index-dev.ts b/src/index-dev.ts index 475b4cad..cfc02314 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -11,6 +11,7 @@ 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'; @@ -114,6 +115,8 @@ program force: options.force, 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); @@ -270,9 +273,8 @@ program program .command('push') - // COPY-FLAG: names what a push covers, deletions included. - .description('Push your changes to Keep: edited, new and deleted variables') - .option('--non-tty', 'treat stdin as not a terminal; a conflict stops the push with the conflict_id (agents/CI)') + .description('Push encrypted values to Keep') + .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) => { diff --git a/src/index.ts b/src/index.ts index 6d29075a..7bbe8aa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ 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 @@ -99,6 +100,8 @@ program force: options.force, 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); @@ -333,9 +336,8 @@ program program .command('push') - // COPY-FLAG: names what a push covers, deletions included. - .description('Push your changes to Keep: edited, new and deleted variables') - .option('--non-tty', 'treat stdin as not a terminal; a conflict stops the push with the conflict_id (agents/CI)') + .description('Push encrypted values to Keep') + .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) => { diff --git a/src/sync/commit.ts b/src/sync/commit.ts index edb31c76..29c09bc4 100644 --- a/src/sync/commit.ts +++ b/src/sync/commit.ts @@ -1,24 +1,21 @@ /** - * The one way a folder (or the system store) saves secrets to Keep (CAP-712). + * The one way anything uploads secrets to Keep (CAP-712). * - * WHAT IT REPLACES. Every save used to upload the folder's WHOLE local env, - * merged into the folder's OWN keep.lock, and let the server take it. A folder - * pinned to an old snapshot therefore silently reverted every value someone - * else had saved since, deleted server-only variables, and (the server's keep - * being project-wide) rolled other branches back with it. + * 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 INSTEAD. + * WHAT IT DOES. * 1. Reads the server's latest for (project, branch). - * 2. `planCommit` (pure): the caller's intended changes applied to that latest. - * A name the caller is not changing is never touched, and keeps the server's - * ciphertext line byte for byte. A name the caller IS changing, that someone - * else changed on the server since the caller's pin, is a conflict: nothing is - * uploaded unless the caller already resolved it. - * 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`. If the server - * says the base is stale (`SECRETS_STALE_BASE`), it re-reads and re-plans ONCE. - * 4. 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. + * 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. */ @@ -30,8 +27,8 @@ 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 type { ConflictDecider, ConflictView, Resolution, Resolutions, SourceConflict } from './conflicts'; import { SyncEngine } from './syncEngine'; +import type { RateInfo } from '../utils/pool'; // ── Vocabulary ────────────────────────────────────────────────────────────── @@ -41,8 +38,6 @@ export interface Changes { readonly remove: readonly string[]; } -export type { Resolution, Resolutions }; - /** One variable as the server holds it for the branch. */ export interface LatestEntry { /** `capy::`, exactly as stored. */ @@ -61,20 +56,6 @@ export interface Snapshot { readonly entries: Readonly>; } -export type Plan = - | { readonly kind: 'conflict'; readonly names: readonly string[] } - | { - readonly kind: 'ok'; - /** 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[]; - }; - /** value_hash by name for `branch`: what a keep.lock pins. */ export function pinnedHashes(keep: KeepFile | null | undefined, branch: string): Record { return Object.fromEntries( @@ -85,87 +66,38 @@ export function pinnedHashes(keep: KeepFile | null | undefined, branch: string): ); } -// ── The planner (pure) ────────────────────────────────────────────────────── - const has = (record: object, name: string): boolean => Object.hasOwn(record, name); -/** A change that meets a value the server moved since the caller's pin. Fingerprints only. */ -export interface Contest { - readonly name: string; - /** What the caller last agreed with the server (`undefined`: the caller never had it). */ - readonly pinned: string | undefined; - /** The intended value's fingerprint (`undefined`: a deletion). */ - readonly yours: string | undefined; - /** The server's fingerprint now (`undefined`: the server no longer has it). */ - readonly theirs: string | undefined; -} +/** 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); -/** - * Whether writing `yours` (a value, or `undefined` for a deletion) over the server's `latest` meets a value the - * server moved since `pinned`: the server's value differs from the pin (including added or removed since) AND - * writing would actually change it (the intended value is not already the server's; a deletion of a value the - * server still has). The one definition `findConflicts` and a screen's row status share. - */ -export function isContested( - pinned: string | undefined, - yours: string | undefined, - latest: Pick | undefined, -): boolean { - const changesIt = yours === undefined ? latest !== undefined : latest?.plaintext !== yours; - return changesIt && latest?.hash !== pinned; -} +// ── Applying a change (pure) ──────────────────────────────────────────────── -/** - * Every change in `changes` that meets a value the server moved since `pinned` (different hash, - * including added or removed on the server since) and that would actually change it: a name in - * `set` whose intended value is not already the server's, a name in `remove` the server still has. - * Independent of any resolution, so a decision can be bound to exactly this set. In name order. - */ -export function findConflicts(input: { - readonly pinned: Readonly>; - readonly latest: Readonly>; - readonly changes: Changes; -}): readonly Contest[] { - const { pinned, latest, changes } = input; - const writes = Object.entries(changes.set).map(([name, value]) => ({ name, value })); - const removals = changes.remove.filter((name) => !has(changes.set, name)).map((name) => ({ name, value: undefined as string | undefined })); - return [...writes, ...removals] - .filter(({ name, value }) => isContested(pinned[name], value, has(latest, name) ? latest[name] : undefined)) - .map(({ name, value }) => ({ - name, - pinned: pinned[name], - yours: value === undefined ? undefined : hashValue(value), - theirs: latest[name]?.hash, - })) - .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +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 in `set` is a conflict when the server's value moved since `pinned` - * (different hash, including added or removed on the server since) AND differs from - * the intended value AND the caller gave no resolution. A name in `remove` is a - * conflict when the server's value moved since `pinned`; removing what the server no - * longer has is a no-op. `resolutions`: `theirs` leaves the server's value alone, - * `mine` overwrites it. + * `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 planCommit(input: { - readonly pinned: Readonly>; +export function applyChanges(input: { readonly latest: Readonly>; readonly changes: Changes; - readonly resolutions?: Resolutions; /** Encrypts one value into its blob line. */ readonly encryptLine: (name: string, value: string) => string; -}): Plan { - const { pinned, latest, changes, encryptLine } = input; - const resolutions = input.resolutions ?? {}; - - const open = findConflicts({ pinned, latest, changes }).filter((c) => resolutions[c.name] === undefined); - if (open.length > 0) return { kind: 'conflict', names: open.map((c) => c.name) }; - - const toWrite = Object.entries(changes.set).filter(([name, value]) => resolutions[name] !== 'theirs' && latest[name]?.plaintext !== value); - const toRemove = changes.remove.filter((name) => !has(changes.set, name) && has(latest, name) && resolutions[name] !== 'theirs'); +}): 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)])); @@ -173,7 +105,6 @@ export function planCommit(input: { const kept = Object.entries(latest).filter(([name]) => !gone.has(name)); return { - kind: 'ok', lines: Object.fromEntries([ ...kept.map(([name, entry]) => [name, encrypted[name] ?? entry.line] as const), ...Object.entries(encrypted).filter(([name]) => !has(latest, name)), @@ -187,20 +118,21 @@ export function planCommit(input: { }; } -// ── Errors ────────────────────────────────────────────────────────────────── - -/** Nothing was uploaded: `names` (never values) changed on the server since this folder last synced. */ -export function conflictError(names: readonly string[]): CapyError { - return new CapyError( - `${names.join(', ')} changed on the server since this was last synced. Nothing was uploaded.`, // COPY-FLAG - ERROR_CODES.SECRETS_CONFLICT, - { names }, - ); +/** + * 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), + }; } -export function isSecretsConflict(err: unknown): err is CapyError { - return err instanceof CapyError && err.code === ERROR_CODES.SECRETS_CONFLICT; -} +// ── Errors ────────────────────────────────────────────────────────────────── export const isStaleBase = (err: unknown): boolean => err instanceof CapyError && err.code === ERROR_CODES.SECRETS_STALE_BASE; @@ -255,7 +187,7 @@ export function snapshotFrom( return { keep, entries: Object.fromEntries( - Object.entries(fm.parseEnvContent(data.env_content ?? '')).map(([name, line]) => { + 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) }]; @@ -311,145 +243,204 @@ export const envBlobOf = (lines: Readonly>): string => .map(([name, line]) => `${name}=${line}`) .join('\n'); -// ── Committing to the server ──────────────────────────────────────────────── +/** `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 ServerCommitInput { +export interface UploadInput { readonly client: CommitClient; readonly projectId: string; readonly branch: string; readonly projectKey: string; - /** The keep the caller started from: its branch slice is the pin. */ + /** 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; - readonly resolutions?: Resolutions; /** 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 and showed its user: used for the first attempt instead of reading again. */ + /** A read the caller already made (bare `capy`'s): used for this attempt instead of reading again. */ readonly seen?: Snapshot; - /** - * Asked for the decision on every conflict the changes meet that `resolutions` does not already settle - * (CAP-717). It returns the decisions or throws (a `ConflictRefusal`: nothing is uploaded). Absent: an - * unsettled conflict is `SECRETS_CONFLICT`. - */ - readonly decide?: ConflictDecider; - /** Read, plan and decide, but upload nothing: for a caller that wants to know whether the write would go through. */ - readonly check?: boolean; -} - -export interface ServerCommit { - /** The server state this commit was planned against (after a stale retry, the re-read). */ +} + +export interface Uploaded { + /** The server state this upload was planned against. */ readonly snapshot: Snapshot; - /** The server's keep after the commit. */ + /** The server's keep after the upload. */ readonly keep: KeepFile; readonly lines: Readonly>; readonly env: Readonly>; readonly written: readonly string[]; readonly removed: readonly string[]; - /** Every decision this commit was planned with: the caller's, and what `decide` added. */ - readonly resolutions: Resolutions; /** The push response; `undefined` when nothing needed uploading. */ - readonly result: { readonly keep_hash: string; readonly keep_file?: string } | undefined; -} - -/** A resolution holds only for a name whose server value is the one the caller resolved against. */ -function stillValid(resolutions: Resolutions | undefined, before: Snapshot, after: Snapshot): Resolutions { - return Object.fromEntries( - Object.entries(resolutions ?? {}).filter(([name]) => before.entries[name]?.hash === after.entries[name]?.hash), - ); + 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 conflicts `decide` is asked about, as the protocol reports them (fingerprints only). */ -export function sourceConflictsOf(contests: readonly Contest[], snapshot: Snapshot, view: ConflictView): readonly SourceConflict[] { - return contests.map((c) => { - const changedAt = snapshot.keep?.variables[c.name]?.find((e) => e.branch === view.branch)?.changed_at; - return { - kind: 'source', - name: c.name, - project: view.project, - branch: view.branch, - pinned: c.pinned ?? null, - yours: c.yours ?? null, - theirs: c.theirs ?? null, - ...(changedAt === undefined ? {} : { theirs_changed_at: changedAt }), - }; - }); -} - -/** The resolutions `input.decide` gives for the conflicts that `resolutions` leaves open (none when nothing is open or nobody decides). */ -async function decidedFor(input: ServerCommitInput, snapshot: Snapshot, resolutions: Resolutions | undefined, latestKeep: KeepFile): Promise { - const { branch, startKeep, changes } = input; - const open = findConflicts({ pinned: pinnedHashes(startKeep, branch), latest: snapshot.entries, changes }).filter((c) => resolutions?.[c.name] === undefined); - if (open.length === 0 || input.decide === undefined) return {}; - const view: ConflictView = { - project: latestKeep.project_name, - branch, - serverKeepHash: snapshot.keep === undefined ? '' : SyncEngine.computeKeepHash(snapshot.keep, branch), +/** 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) }), }; - return input.decide(sourceConflictsOf(open, snapshot, view), view); } -async function attempt(input: ServerCommitInput, snapshot: Snapshot, resolutions: Resolutions | undefined, mayRetry: boolean): Promise { - const { client, projectId, branch, projectKey, startKeep, changes, transformKeep } = input; +/** + * 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 decided = await decidedFor(input, snapshot, resolutions, latestKeep); - const settled: Resolutions = { ...resolutions, ...decided }; - const plan = planCommit({ - pinned: pinnedHashes(startKeep, branch), + const applied = applyChanges({ latest: snapshot.entries, changes, - resolutions: settled, encryptLine: (name, value) => `capy:${resourceIdOf(name)}:${Encryptor.encrypt(value, projectKey)}`, }); - if (plan.kind === 'conflict') throw conflictError(plan.names); - - const changed = keepWithChanges(latestKeep, branch, plan.written, plan.removed, resourceIdOf); + const changed = keepWithChanges(latestKeep, branch, applied.written, applied.removed, resourceIdOf); const keep = transformKeep ? transformKeep(changed) : changed; - const base = { snapshot, keep, lines: plan.lines, env: plan.env, written: Object.keys(plan.written), removed: plan.removed, resolutions: settled }; - if (keep === latestKeep || input.check === true) return { ...base, result: undefined }; + 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 { - const result = await client.pushSecrets( - projectId, - JSON.stringify(keep), - envBlobOf(plan.lines), - branch, - undefined, - snapshot.keep === undefined ? undefined : SyncEngine.computeKeepHash(snapshot.keep, branch), - ); - return { ...base, result }; + return await uploadChanges(input); } catch (err) { - if (!mayRetry || !isStaleBase(err)) throw err; - const fresh = await readSnapshot(client, projectId, branch, projectKey); - return attempt(input, fresh, stillValid(resolutions, snapshot, fresh), false); + if (!isStaleBase(err)) throw err; + return uploadChanges({ ...input, seen: undefined }); } } /** - * Applies `input.changes` to the server's latest and uploads the result. Throws - * `SECRETS_CONFLICT` (nothing uploaded) when a change would overwrite a value the - * server changed since the caller's pin, and `SECRETS_STALE_BASE` if the server moved - * again after the one re-read. Does not touch the local disk. + * `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 commitToServer(input: ServerCommitInput): Promise { - const snapshot = input.seen ?? (await readSnapshot(input.client, input.projectId, input.branch, input.projectKey)); - return attempt(input, snapshot, input.resolutions, true); +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 `committed`: `local` with this + * 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, committed: ServerCommit, branch: string): KeepFile { +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 (committed.result === undefined && committed.snapshot.keep === undefined) return local; - const serverKeep = SyncEngine.adoptServerKeep(committed.result?.keep_file, committed.keep, branch); + 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. */ @@ -467,16 +458,11 @@ export interface FolderContext { readonly keep: KeepFile; /** The folder's decrypted `.env` when the command started. */ readonly localPlaintext: Readonly>; - /** How the command settles a conflict (the non-TTY protocol sets this); absent: a conflict is `SECRETS_CONFLICT`. */ - readonly decide?: ConflictDecider; } export interface FolderCommitOptions { - readonly resolutions?: Resolutions; readonly transformKeep?: (keep: KeepFile) => KeepFile; readonly seen?: Snapshot; - /** Overrides the context's `decide` for this one commit. */ - readonly decide?: ConflictDecider; } export interface FolderCommit { @@ -486,44 +472,8 @@ export interface FolderCommit { readonly removed: readonly string[]; /** The keep.lock now on disk. */ readonly keep: KeepFile; -} - -/** - * What a folder changed since its pin: the values that differ from it (an edit, or a new - * name) and, with `removals`, the pinned names gone from `.env`. Values the folder merely - * still holds from an older sync are not its changes. - */ -export function localChanges(local: Readonly>, pinned: Readonly>, opts: { readonly removals: boolean }): Changes { - return { - set: Object.fromEntries(Object.entries(local).filter(([name, value]) => hashValue(value) !== pinned[name])), - remove: opts.removals ? Object.keys(pinned).filter((name) => !has(local, name)) : [], - }; -} - -/** - * Bare `capy`'s "commit local values": the folder's changes, committed with the answer the - * user gave by choosing it. The table showed every changed name with the server's value next - * to it, so for each one the server also moved, the answer is `mine`. `seen` is the read the - * table was drawn from; the commit is planned against it, so a save in between is caught by - * the server (`SECRETS_STALE_BASE`) rather than overwritten. - * - * Off a terminal there is no table to answer for (CAP-717): the caller passes `decide` and the same - * commit grants nothing up front, so a name the server also changed is a conflict that `decide` settles. - */ -export async function commitLocalValues( - ctx: FolderContext, - shown: { readonly pinned: Readonly>; readonly remoteHashes: Readonly>; readonly seen: Snapshot | undefined }, - decide?: ConflictDecider, -): Promise<{ readonly changed: number }> { - const changes = localChanges(ctx.localPlaintext, shown.pinned, { removals: true }); - const names = [...Object.keys(changes.set), ...changes.remove]; - // Choosing "commit local" in the menu grants `mine` to removals too, exactly as it does to edits: the table showed both. - const resolutions = - decide === undefined - ? Object.fromEntries(names.filter((name) => shown.remoteHashes[name] !== shown.pinned[name]).map((name) => [name, 'mine' as const])) - : {}; - await commitFolder(ctx, changes, { resolutions, seen: shown.seen, decide }); - return { changed: names.length }; + /** The plaintext env now in `.env`. */ + readonly env: Readonly>; } /** `changes` applied to a plain env. */ @@ -535,31 +485,7 @@ function applyToEnv(env: Readonly>, changes: Changes): Re }; } -/** `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)); -} - -/** `keep` with `names`' entries for `branch` put back as `original` has them (or dropped when it has none). */ -function restorePins(keep: KeepFile, original: KeepFile, branch: string, names: readonly string[]): KeepFile { - if (names.length === 0) return keep; - const restored = new Set(names); - return { - ...keep, - variables: Object.fromEntries( - [...new Set([...Object.keys(keep.variables), ...names])].flatMap((name) => { - if (!restored.has(name)) return [[name, keep.variables[name]]] as const; - const others = (keep.variables[name] ?? []).filter((e) => e.branch !== branch); - const own = (original.variables[name] ?? []).filter((e) => e.branch === branch); - const entries = [...others, ...own]; - return entries.length > 0 ? ([[name, entries]] as const) : []; - }), - ), - }; -} - -/** The local-only "server": the working copy is the truth, there is no one to conflict with. */ +/** 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( @@ -570,7 +496,6 @@ function commitLocalCopy(ctx: FolderContext, changes: Changes, transformKeep: Fo return { env, lines, - keep, adopted: keep, cacheHash: SyncEngine.computeKeepHash(keep, ctx.branch), uploaded: false, @@ -579,68 +504,36 @@ function commitLocalCopy(ctx: FolderContext, changes: Changes, transformKeep: Fo }; } -/** What a folder commit asks the server commit to do. */ -function serverInputFor(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions): ServerCommitInput { - return { +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, - resolutions: opts.resolutions, transformKeep: opts.transformKeep, seen: opts.seen, - decide: opts.decide ?? ctx.decide, - }; -} - -/** The server commit, and what the folder should adopt from it. */ -async function commitToServerFor(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions) { - const committed = await commitToServer(serverInputFor(ctx, changes, opts)); - const pinned = pinnedHashes(ctx.keep, ctx.branch); - // A local edit nobody committed stays an edit: its value stays in `.env` and its pin stays - // where it was, so the next bare `capy` still sees what the server changed under it. - const preserved = Object.keys(ctx.localPlaintext).filter( - (name) => - !has(changes.set, name) && - !changes.remove.includes(name) && - hashValue(ctx.localPlaintext[name]) !== pinned[name] && - ctx.localPlaintext[name] !== committed.env[name], - ); - const adopted = restorePins(adoptedKeepFor(ctx.keep, committed, ctx.branch), ctx.keep, ctx.branch, preserved); + }); return { - env: { ...committed.env, ...Object.fromEntries(preserved.map((name) => [name, ctx.localPlaintext[name]])) }, - lines: committed.lines, - keep: committed.keep, - adopted, - cacheHash: committed.result?.keep_hash ?? SyncEngine.computeKeepHash(committed.keep, ctx.branch), - uploaded: committed.result !== undefined, - written: committed.written, - removed: committed.removed, + 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, }; } /** - * Reads, plans and decides exactly as `commitFolder(ctx, changes, opts)` would, and writes nothing, on the - * server or in the folder: a `--dry-run` that can say "this would conflict", or a command that must settle - * its conflicts BEFORE it does something it cannot take back (`capy rotate` issues a new key first). Throws what - * the commit would throw; returns the decisions it was planned with. A folder with no server has nothing to - * conflict with. - */ -export async function checkFolder(ctx: FolderContext, changes: Changes, opts: FolderCommitOptions = {}): Promise<{ readonly resolutions: Resolutions }> { - if (ctx.serviceClient === undefined) return { resolutions: opts.resolutions ?? {} }; - const checked = await commitToServer({ ...serverInputFor(ctx, changes, opts), check: true }); - return { resolutions: checked.resolutions }; -} - -/** - * 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. `.env` afterwards is the - * server's latest plus `changes`, except that local edits nobody committed are kept as edits. + * 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 commitToServerFor(ctx, changes, opts); + 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); @@ -655,5 +548,5 @@ export async function commitFolder(ctx: FolderContext, changes: Changes, opts: F 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 }; + return { uploaded: done.uploaded, written: done.written, removed: done.removed, keep: done.adopted, env: done.env }; } diff --git a/src/sync/conflicts.ts b/src/sync/conflicts.ts index 2f05522c..78340691 100644 --- a/src/sync/conflicts.ts +++ b/src/sync/conflicts.ts @@ -1,75 +1,84 @@ /** * Conflicts everywhere (CAP-717; the contract is docs/conflict-resolution.md). * - * ONE module for what every write command shares when two sides changed the same name: - * - the vocabulary: a conflict is a NAME where the other side moved since `pinned` - * and differs from `yours`; fingerprints only (the 16-hex `value_hash`), never a value; - * - `conflictIdOf`: the id that binds a decision to the exact conflicts it was made against; - * - `parseConflictFlags`: `--resolve NAME=mine|theirs` (target form `NAME@TARGET=...`) and - * `--conflict `, validated with coded errors; - * - `gateConflicts`: "apply the resolutions or refuse". Off a terminal a conflict stops the - * write (nothing is written, exit 3, JSON on stdout, prose on stderr); a rerun with the - * right id and a decision for every conflicting name goes on. + * 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. * - * No command keeps a copy of any of this. Nothing here ever holds, prints or throws a value. + * 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 ────────────────────────────────────────────────────────────── -/** The caller's answer for a name the other side changed under it: keep theirs, or overwrite with mine. */ -export type Resolution = 'mine' | 'theirs'; -/** Decisions by key: `NAME` for a source conflict, `NAME@TARGET` for a target conflict. */ +/** 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>; -/** Folder (or earlier read) against the Capy server. Fingerprints are `null` where there is no value (absent, or a deletion). */ +/** One `compareSecrets` row, as the protocol reports it. Fingerprints are `null` where there is no value. */ export interface SourceConflict { - readonly kind: 'source'; readonly name: string; readonly project: string; readonly branch: string; + readonly type: DiffResult['type']; readonly pinned: string | null; - readonly yours: string | null; - readonly theirs: string | null; - /** When the server's value last changed, if the server says. */ - readonly theirs_changed_at?: string; + readonly local: string | null; + readonly remote: string | null; } -/** Capy against the platform's live value (Dokploy). */ +/** A platform row (Dokploy): Capy's value against the one live on the platform. Only `local` / `remote` are allowed answers. */ export interface TargetConflict { - readonly kind: 'target'; readonly name: string; readonly target: string; readonly pinned: string | null; - readonly yours: string | null; - readonly theirs: string | null; + readonly local: string | null; + readonly remote: string | null; } export type Conflict = SourceConflict | TargetConflict; -/** The key a decision for `conflict` is given under. */ -export const resolveKeyOf = (conflict: Conflict): string => (conflict.kind === 'source' ? conflict.name : `${conflict.name}@${conflict.target}`); +export const isTargetConflict = (c: Conflict): c is TargetConflict => 'target' in c; -/** Where conflicts were found: what the id binds. `serverKeepHash` is the server's keep hash for the branch at the read. */ -export interface ConflictScope { - readonly project: string; - readonly branch: string; - readonly serverKeepHash: string; - readonly conflicts: readonly Conflict[]; -} +/** The key an answer for `conflict` is given under. */ +export const resolveKeyOf = (conflict: Conflict): string => (isTargetConflict(conflict) ? `${conflict.name}@${conflict.target}` : conflict.name); -/** What a commit path hands its decider: where it is looking. */ +/** 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; } -/** Decides the conflicts a write found (or throws a `ConflictRefusal`). Receives only the source conflicts of one scope. */ -export type ConflictDecider = (found: readonly SourceConflict[], view: ConflictView) => Resolutions | Promise; +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 ────────────────────────────────────────────────────────────────── @@ -78,18 +87,19 @@ 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 => [c.kind, c.name, c.kind === 'target' ? c.target : '', c.kind === 'source' ? c.project : '', c.kind === 'source' ? c.branch : ''].join('\u0000'); +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 conflicts in the one order the id and the payload use. */ +/** 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) => ({ - kind: c.kind, name: c.name, - target: c.kind === 'target' ? c.target : null, + target: isTargetConflict(c) ? c.target : null, + type: isTargetConflict(c) ? null : c.type, pinned: c.pinned, - yours: c.yours, - theirs: c.theirs, + local: c.local, + remote: c.remote, }); /** Scopes of one (project, branch, server keep hash) become one scope. */ @@ -102,10 +112,9 @@ function mergedScopes(scopes: readonly ConflictScope[]): readonly ConflictScope[ } /** - * `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 conflicts (kind, name, target, pinned, yours, theirs). - * 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. + * `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( @@ -120,7 +129,7 @@ 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 conflicts. A preview adds `dry_run: true`. */ +/** 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; @@ -142,9 +151,9 @@ export class ConflictRefusal extends CapyError { export const isConflictRefusal = (err: unknown): err is ConflictRefusal => err instanceof ConflictRefusal; -const namesOf = (conflicts: readonly Conflict[]): string => [...new Set(conflicts.map((c) => (c.kind === 'target' ? `${c.name} on ${c.target}` : c.name)))].join(', '); +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 conflicts in `scopes`, whose id it carries). */ +/** The refusal for `listed` (of the rows in `scopes`, whose id it carries). */ export function conflictRefusalFor( code: ConflictPayload['code'], scopes: readonly ConflictScope[], @@ -153,8 +162,8 @@ export function conflictRefusalFor( ): ConflictRefusal { const message = code === ERROR_CODES.SECRETS_CONFLICT_STALE - ? `${namesOf(listed)}: the conflict changed since that id was issued. Nothing was written.` // COPY-FLAG - : `${namesOf(listed)} changed since this was last synced. Nothing was written.`; // COPY-FLAG + ? `${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, @@ -165,7 +174,7 @@ export function conflictRefusalFor( 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=mine|theirs for each name (NAME@TARGET for a target) and --conflict .'); // COPY-FLAG + 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. */ @@ -193,19 +202,21 @@ export const NO_CONFLICT_FLAGS: ConflictFlags = { resolutions: {}, conflictId: u /** 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]+))?=(mine|theirs)$/; +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=mine or NAME=theirs (NAME@TARGET=mine|theirs for a target).'); // COPY-FLAG + 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 decided twice, differently, or a malformed id), and + * 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 { @@ -215,7 +226,7 @@ export function parseConflictFlags(raw: RawConflictFlags): ConflictFlags { 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 : a decision is only valid for the conflicts it was made against.', ERROR_CODES.SECRETS_RESOLVE_NEEDS_CONFLICT); // COPY-FLAG + 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 }; } @@ -235,23 +246,24 @@ export function conflictFlagsOrExit(raw: RawConflictFlags, json: boolean): Confl } } -// ── Apply the resolutions, or refuse ──────────────────────────────────────── +// ── Apply the answers, or refuse ──────────────────────────────────────────── /** - * The decision step of every command. `scopes` hold every conflict the write found (resolved or not). + * The decision step off a terminal. `scopes` hold every row found. * - * - none → nothing to decide; + * - 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 conflicting name has no `--resolve` → `SECRETS_CONFLICT` with the remaining ones + * - 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 decisions, keyed as given. + * - otherwise the answers, keyed as given. A platform row's answer must be `local` or `remote`. * - * Throws `ConflictRefusal`; returns only decisions that cover every conflict. + * `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); @@ -259,44 +271,37 @@ export function gateConflicts(flags: ConflictFlags, scopes: readonly ConflictSco return Object.fromEntries(all.map((c) => [resolveKeyOf(c), flags.resolutions[resolveKeyOf(c)]])); } -/** The decider of a `--dry-run`: any conflict is reported (with `dry_run: true`) and nothing is decided. */ -export const previewDecider: ConflictDecider = (found, view) => { - throw conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, [{ ...view, conflicts: found }], found, { dryRun: true }); -}; - -/** The decider for a command that follows the protocol: the flags decide, or the write is refused. */ -export const protocolDecider = (flags: ConflictFlags): ConflictDecider => (found, view) => gateConflicts(flags, [{ ...view, conflicts: found }]); - -/** The source decisions of `resolutions` (keys without a target). */ +/** 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 decision together with the fingerprints it was made against: it applies to exactly that state and no other. */ +/** 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: Resolution; - /** What the other side held when the caller decided (`null`: nothing). */ - readonly theirs: string | null; - /** What was about to be written when the caller decided. */ - readonly yours: string | null; + 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 key, in the same key form as `Resolutions`. */ +/** `SeenResolution`s by NAME, for one target. */ export type SeenResolutions = Readonly>; /** - * Binds `resolutions` to the conflicts they answered: each decision carries the `theirs` / `yours` fingerprints of - * the conflict it was made for. A key that answers no listed conflict is dropped. Pure; fingerprints only. + * 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 === undefined ? [] : [[resolveKeyOf(c), { choice, theirs: c.theirs, yours: c.yours }] as const]; + return choice === 'local' || choice === 'remote' ? [[resolveKeyOf(c), { choice, local: c.local, remote: c.remote }] as const] : []; }), ); } -/** The decisions for `target`, keyed by bare NAME. */ +/** The answers for `target`, keyed by bare NAME. */ export const targetResolutions = (resolutions: Readonly>, target: string): Readonly> => Object.fromEntries( Object.entries(resolutions) @@ -308,16 +313,16 @@ export const targetResolutions = (resolutions: Readonly>, t const collectResolve = (value: string, previous: string[]): string[] => [...previous, value]; -/** `--resolve NAME=mine|theirs` (repeatable). Every write command takes it; the root program takes it for bare `capy`. */ +/** `--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 ', - 'decide a conflict: keep your value (mine) or theirs; NAME@TARGET=... for a deploy target (repeatable, needs --conflict)', // COPY-FLAG + '--resolve ', + 'answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict)', // COPY-FLAG ) .default([]) .argParser(collectResolve); -/** `--conflict `: binds `--resolve` to the conflicts that were seen. */ +/** `--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 diff --git a/src/sync/gate.ts b/src/sync/gate.ts new file mode 100644 index 00000000..a41b8668 --- /dev/null +++ b/src/sync/gate.ts @@ -0,0 +1,161 @@ +/** + * 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`, `rotate`, `push`, `deploy`) + * calls `gateFirst` as its VERY FIRST step, before any prompt, preflight, plan or confirm screen, and nothing + * lets a command skip it. 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 { isInteractive } from '../ui/interactive'; +import { exitOnConflictRefusal, hasConflictFlags, parseConflictFlags, 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; +} + +/** `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 ?? {}); +} + +/** The real runner: a fresh `CapyCommand` doing exactly what `capy` does. */ +const runBareCapy: SyncRunner = async (opts, protocol) => { + const { CapyCommand } = await import('../commands/capyCommand'); + return new CapyCommand({ protocol, silentAuthOnly: protocol, quiet: true, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun }, opts.devMode === true).runSync(); +}; + +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; + exitOnConflictRefusal(done.error); + throw done.error; +} + +/** 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); + if (opts.json === true) { + const coded = err instanceof CapyError ? err : new CapyError('The sync did not complete.', ERROR_CODES.SERVICE_ERROR); // COPY-FLAG + console.log(JSON.stringify({ ok: false, code: coded.code, error: coded.message }, null, 2)); + 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` (a folder other than the working directory, which bare + * `capy` would not be run for) throws the stale refusal as it is. + */ +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..119c44f0 --- /dev/null +++ b/src/sync/noFolder.ts @@ -0,0 +1,87 @@ +/** + * 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, + * pinned, delete → not available here (`actionFor`): setting a value has nothing to write back from a fingerprint and + * no meaning for a removal. + * + * 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'; + +/** + * `local` writes the new value, `remote` leaves the location as it is. These two are what setting a value can mean; + * `pinned` (the value that was shown is a fingerprint only: nothing to write back) and `delete` (removing a variable is + * not setting it) are refused with a code, never guessed. + */ +export function actionFor(choice: Resolution): LocationAction { + if (choice === 'pinned' || choice === 'delete') { + throw new CapyError(`${choice} is not available when setting a value without a folder; answer local or remote.`, ERROR_CODES.SECRETS_RESOLVE_INVALID); // COPY-FLAG + } + return choice === 'local' ? 'write' : 'leave'; +} + +/** 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 a12eab12..70979d8b 100644 --- a/src/sync/syncEngine.ts +++ b/src/sync/syncEngine.ts @@ -1,7 +1,128 @@ import { createHash } from 'crypto'; -import { KeepFile, KeepVariableEntry } from '../types/index'; +import { + EnvVariable, + ChangeSet, + ConflictVariable, + UserDecisions, + SyncResult, + KeepFile, + KeepVariableEntry, + SyncState, +} from '../types/index'; export class SyncEngine { + /** + * Extract resource_id from capy:{resource_id}:{encrypted_value} format + */ + private extractResourceId(value: string): string | null { + if (value.startsWith('capy:')) { + const parts = value.split(':'); + if (parts.length >= 3) { + return parts[1]; // Return the resource_id part + } + } + return null; + } + + compareEnvironments( + local: Record, + remote: Record, + localEncrypted?: Record, + remoteEncrypted?: Record, + syncState?: SyncState | null + ): ChangeSet { + 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 }); + + // 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'; + + const conflicts: ConflictVariable[] = both + .filter((key) => !isDeletedMarker(key) && local[key] !== remote[key]) + .map((key) => { + // Conflict - check if resource_ids differ using encrypted versions + 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 }; + }); + + // 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: 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: 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)), + }; + } + + applyDecisions( + local: Record, + remote: Record, + decisions: UserDecisions + ): Record { + // 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 + const gone = new Set([...decisions.deleteLocal, ...decisions.deleteRemote]); + return Object.fromEntries(Object.entries({ ...base, ...fromLocal, ...pulled }).filter(([name]) => !gone.has(name))); + } + + generateSyncResult( + changeSet: ChangeSet, + decisions: UserDecisions, + errors: string[] = [] + ): SyncResult { + const pushed = decisions.pushVariables; + const pulled = decisions.pullVariables; + const resolved = [...decisions.keepLocal, ...decisions.keepRemote]; + + // Deduplicate: pushed conflicts are in both pushVariables and keepLocal + const allNames = new Set([ + ...changeSet.unchanged.map(v => v.name), + ...pushed, + ...pulled, + ...resolved, + ]); + const totalVariables = allNames.size; + + return { + success: errors.length === 0, + pushed, + pulled, + conflicts: resolved, + errors, + totalVariables + }; + } + /** * Merge pushed variables into a v3 keep.lock file. * Updates the value hash for the specified branch. @@ -11,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); - } + 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 })); - updatedKeep.variables[varName] = entries; - } + // 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 updatedKeep; + return [varName, idx >= 0 ? entries.map((e, i) => (i === idx ? entry : e)) : [...entries, entry]] as const; + }), + ); + + return { ...keep, variables: { ...keep.variables, ...merged } }; } /** @@ -60,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 }; } @@ -99,6 +209,40 @@ export class SyncEngine { } } + formatSyncSummary(changeSet: ChangeSet): string { + 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 localVarNames = changeSet.newLocal.map(v => v.name); + const conflictNames = changeSet.conflicts.map(c => c.name); + const deletedVarNames = changeSet.deleted.map(v => v.name); + const remoteVarNames = changeSet.newRemote.map(v => v.name); + const deletedLocalVarNames = changeSet.deletedLocal.map(v => v.name); + const allResolutions = [...decisions.keepLocal, ...decisions.keepRemote]; + + 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'; /** @@ -107,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 93e58b10..1a4b1440 100644 --- a/src/system/systemStore.ts +++ b/src/system/systemStore.ts @@ -30,7 +30,7 @@ import { resolveOrgContext } from '../core/orgContext'; import { resolveProjectKey } from '../crypto/keyResolver'; import { Encryptor } from '../crypto/encryptor'; import { SyncEngine } from '../sync/syncEngine'; -import { adoptedKeepFor, commitToServer, envBlobOf, type Changes } from '../sync/commit'; +import { adoptedKeepFor, envBlobOf, uploadChangesRereading, type Changes } from '../sync/commit'; import { writeKeepCache, readSystemKeepFile, @@ -206,16 +206,15 @@ function decryptStoredValue(raw: string, projectKey: string): string | null { } /** - * Commit `changes` to the system store's `ctx.branch` (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 a name another admin changed since this store was opened is a - * `SECRETS_CONFLICT`. The pin is the store's keep as last written locally, so several writes - * through one handle each build on the one before. + * 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. */ async function commitSystemStore(ctx: SystemStoreContext, changes: Changes): Promise { const startKeep = readSystemKeepFile(ctx.orgId) ?? ctx.keep; const committed = await withAdminOnlyTranslation(() => - commitToServer({ + uploadChangesRereading({ client: ctx.serviceClient, projectId: ctx.projectId, branch: ctx.branch, diff --git a/src/types/index.ts b/src/types/index.ts index 156fadeb..d1fdeeef 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -385,8 +385,17 @@ export interface CliOptions { verbose?: boolean; force?: boolean; dryRun?: boolean; - /** `--resolve` / `--conflict` as typed (CAP-717): off a terminal they decide the names both sides changed. */ + /** `--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; } export interface ProjectInitResult { @@ -636,27 +645,21 @@ export const ERROR_CODES = { */ SECRETS_STALE_BASE: 'SECRETS_STALE_BASE', /** - * A save would overwrite a value someone else changed on the server since this - * folder last synced. Nothing was uploaded. `details.names` lists the - * variables (names only, never values); never auto-resolved. + * 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 decision - * no longer applies. Nothing was written; the fresh conflicts (with a new id) come back + * 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', - /** - * `capy deploy` found values in the folder that Capy does not hold (edited since the folder's pin, Capy's latest unchanged) - * and no person at a terminal to ask whether to save them. Nothing was saved or delivered. `names` lists the variables - * (names only, never values); save them with `capy push`, then rerun. Exit 3. - */ - DEPLOY_UNSAVED_CHANGES: 'DEPLOY_UNSAVED_CHANGES', - /** `--resolve` is not `NAME=mine|theirs` (or `NAME@TARGET=mine|theirs`), or `--conflict` is not a conflict id. Exit 1. */ + /** `--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 `: a decision is only valid for the conflicts it was made against. Exit 1. */ + /** `--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` used to refuse when other variables had unpushed local edits. diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index bc835d61..9071e088 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -7,15 +7,16 @@ // 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, yours, theirs); see `reclassifyRow`. A row both -// sides changed is a `conflict`: `m` keeps yours (mine), `t` takes the server's (theirs), -// and nothing is committed while one is undecided. +// 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 { isContested, isSecretsConflict, type Snapshot } from '../sync/commit'; -import type { Resolution, SeenResolution, SeenResolutions } from '../sync/conflicts'; +import { isStaleBase, type Snapshot } from '../sync/commit'; import { statusBadge, statusLabel } from './statusBadge'; import { formatRelativeTime } from './relativeTime'; import { isRevealKey, stepEditBuffer } from './editBuffer'; @@ -69,14 +70,12 @@ export interface EditRow { 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, yours (`localValue`), theirs (the + * 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; - /** The decision for a `conflict` row, with the fingerprints it was made against (CAP-714). Valid only while they still match. */ - readonly resolution?: SeenResolution; } export interface EditState { @@ -100,8 +99,10 @@ export interface EditState { * this key opens straight away (its row was added by {@link withNewEntryRow}). */ readonly entryKey?: string; - /** The server read the rows were drawn from: a commit is planned against it, so a decision binds to what was shown. */ + /** 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>; } /** @@ -124,12 +125,25 @@ export function focusedOn(state: EditState, name: string): EditState { return { ...state, rows: sortedRows([...state.rows, placeholder]), focusKey: name, entryKey: name }; } -/** What a commit carries (CAP-714). */ +/** + * `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 { - /** Every value the commit is about: the edits typed this session, and each conflict row decided (its own value, so the decision has something to bind to). */ + /** The values typed this session and not committed yet: the commit's own change. */ readonly set: Readonly>; - /** The decisions of the conflict rows, with the fingerprints they were made against. */ - readonly decisions: SeenResolutions; /** The server read the rows were drawn from. */ readonly seen: Snapshot | undefined; } @@ -143,41 +157,23 @@ export interface EditLoaded { export interface EditContext { /** - * Commit (and in server mode, push) `set`; `bound` carries the decisions and the server read they were - * made against. 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_CONFLICT` (nothing uploaded) when the server moved under a change. + * 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. */ - readonly saveLocalEdits: ( - set: Record, - bound?: { readonly decisions: SeenResolutions; readonly seen: Snapshot | undefined }, - ) => Promise>; - /** Re-reads the folder and the server (after a save, or a conflict). Absent: rows are updated from what the save returned. */ + 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, decisions ─────────────────────────────────── +// ── Rows: fingerprints, status ────────────────────────────────────────────── /** The server's fingerprint for the row (`null`: the server has no such variable). */ -const theirsOf = (row: EditRow): string | null => row.remoteHash ?? (row.remoteValue === undefined ? null : hashValue(row.remoteValue)); +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 yoursOf = (row: EditRow): string | null => (row.localValue === undefined ? null : hashValue(row.localValue)); - -/** The decision for a conflict row, if it was made against the values the row holds now. */ -export function choiceOf(row: EditRow): Resolution | undefined { - const decided = row.resolution; - if (row.status !== 'conflict' || decided === undefined) return undefined; - return decided.theirs === theirsOf(row) && decided.yours === yoursOf(row) ? decided.choice : undefined; -} - -/** `row` decided: `mine` keeps its working value, `theirs` takes the server's. Bound to the values it holds now. */ -export const resolvedAs = (row: EditRow, choice: Resolution): EditRow => ({ - ...row, - resolution: { choice, theirs: theirsOf(row), yours: yoursOf(row) }, -}); - -const isUnresolved = (row: EditRow): boolean => row.status === 'conflict' && choiceOf(row) === undefined; +const localHashOf = (row: EditRow): string | null => (row.localValue === undefined ? null : hashValue(row.localValue)); /** * Local-only row classification: committed-vs-working. `working` is the value @@ -197,7 +193,7 @@ export function classifyLocalRow( /** * 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; the same test the commit path applies, `isContested`), `in sync`. + * 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" @@ -242,16 +238,14 @@ function twoWayStatus(row: EditRow): EditRow['status'] { } function threeWayStatus(row: EditRow, pinned: string | undefined): EditRow['status'] { - const yours = yoursOf(row) ?? undefined; - const theirs = theirsOf(row) ?? undefined; - if (yours === theirs) return 'in sync'; - const youChanged = yours !== pinned; - const theyChanged = theirs !== pinned; - // A value you hold that the server moved under: the commit path's own test. (A variable missing from the folder is not a - // change to keep: the server's value is pulled.) - const latest = theirs === undefined ? undefined : { plaintext: row.remoteValue, hash: theirs }; - if (youChanged && theyChanged && row.localValue !== undefined && isContested(pinned, row.localValue, latest)) return 'conflict'; - return youChanged && !theyChanged ? 'local' : 'remote'; + 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. */ @@ -346,7 +340,7 @@ export function initialModel(state: EditState): EditModel { : null, status: null, scrollOffset: 0, - pending: {}, + pending: state.pending ?? {}, quitPrompt: false, popupOpen: entering, popupPan: 0, @@ -355,10 +349,8 @@ export function initialModel(state: EditState): EditModel { }; } -/** Names with something to commit: edits typed, and conflict rows decided. */ -const dirtyKeys = (model: EditModel): readonly string[] => [ - ...new Set([...Object.keys(model.pending), ...model.state.rows.filter((r) => choiceOf(r) !== undefined).map((r) => r.key)]), -]; +/** Names with something to commit: the edits typed. */ +const dirtyKeys = (model: EditModel): readonly string[] => Object.keys(model.pending); const withRows = (model: EditModel, rows: readonly EditRow[]): EditModel => ({ ...model, state: { ...model.state, rows } }); @@ -375,8 +367,8 @@ const modeOf = (model: EditModel): { localMode?: boolean; remoteAvailable: boole /** What a save came back with. Never carries a value. */ export type SaveResult = | { readonly kind: 'saved'; readonly changedAt: Readonly>; readonly loaded: EditLoaded | undefined } - /** The server moved under a change (`SECRETS_CONFLICT`): nothing was uploaded. */ - | { readonly kind: 'conflict'; readonly message: string; 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 }; export type EditAction = @@ -390,8 +382,13 @@ export interface EditOutcome { 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; } +/** 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 }); @@ -449,33 +446,10 @@ function onEditKey(model: EditModel, key: string): EditModel { return next !== editing.buffer ? { ...model, editing: { ...editing, buffer: next } } : model; } -/** The unresolved conflicts' names, in row order. */ -const unresolvedNames = (model: EditModel): readonly string[] => model.state.rows.filter(isUnresolved).map((r) => r.key); - -/** - * `c`: the commit this screen would make, or why it cannot be made yet. A conflict nobody decided blocks it - * (nothing is uploaded). Otherwise: the edits, plus each decided conflict row with its value and its decision. - */ +/** `c`: the commit this screen would make: the edits typed, on top of Capy's latest. */ function requestCommit(model: EditModel, quit: boolean): EditOutcome { - const open = unresolvedNames(model); - if (open.length > 0) { - return stay({ - ...model, - quitPrompt: false, - status: { text: `Choose mine or theirs for ${open.join(', ')}. Nothing was uploaded.`, isError: true }, // COPY-FLAG - }); - } - const decided = model.state.rows.filter((r) => choiceOf(r) !== undefined); - const set = { - ...model.pending, - ...Object.fromEntries(decided.flatMap((r) => (r.localValue === undefined ? [] : [[r.key, r.localValue] as const]))), - }; - const request: EditCommitRequest = { - set, - decisions: Object.fromEntries(decided.flatMap((r) => (r.resolution === undefined ? [] : [[r.key, r.resolution] as const]))), - seen: model.state.seen, - }; - const keys = Object.keys(set); + 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: { @@ -498,13 +472,6 @@ function onQuitPromptKey(model: EditModel, key: string): EditOutcome { const movedTo = (model: EditModel, cursorIndex: number): EditModel => ({ ...model, cursorIndex, popupPan: 0 }); -/** `m` / `t` on a conflict row: the decision, shown on the row until it is committed. */ -function decideRow(model: EditModel, choice: Resolution): EditModel { - const row = model.state.rows[model.cursorIndex]; - if (!row || row.status !== 'conflict') return model; - return { ...replaceRow(model, row.key, (r) => resolvedAs(r, choice)), status: null }; -} - function onListKey(model: EditModel, key: string): EditOutcome { if (key === 'q' || key === 'Q') { return dirtyKeys(model).length > 0 ? stay({ ...model, quitPrompt: true }) : finished(model); @@ -548,10 +515,6 @@ function onListKey(model: EditModel, key: string): EditOutcome { }); } - // m / t — mine or theirs for the conflict under the cursor (no other key on this screen is bound to either). - if (key === 'm' || key === 'M') return stay(decideRow(model, 'mine')); - if (key === 't' || key === 'T') return stay(decideRow(model, 'theirs')); - if (key === 'c' || key === 'C') return dirtyKeys(model).length > 0 ? requestCommit(model, false) : stay(model); return stay(model); } @@ -573,14 +536,13 @@ function onData(model: EditModel, key: string): EditOutcome { // ── A save coming back ────────────────────────────────────────────────────── -/** `loaded`'s rows, with what this screen holds that the disk and the server cannot know: typed values, and decisions (valid only while the values they were made against stand). */ -function mergedRows(model: EditModel, loaded: EditLoaded, pending: Readonly>, keepDecisions: boolean): readonly EditRow[] { +/** `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 old = model.state.rows.find((r) => r.key === row.key); const value = Object.hasOwn(pending, row.key) ? pending[row.key] : row.localValue; - return classifiedRow({ ...row, localValue: value, ...(keepDecisions && old?.resolution !== undefined ? { resolution: old.resolution } : {}) }, mode); + return classifiedRow({ ...row, localValue: value }, mode); }; // A variable typed here that neither the folder nor the server has yet. const added = Object.keys(pending) @@ -590,8 +552,8 @@ function mergedRows(model: EditModel, loaded: EditLoaded, pending: Readonly>, keepDecisions: boolean): EditModel { - const rows = mergedRows(model, loaded, pending, keepDecisions); +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 { @@ -614,7 +576,6 @@ function committedLocally(model: EditModel, keys: readonly string[], changedAt: remoteValue: row.localValue, remoteHash: undefined, pinnedHash: hashValue(row.localValue), - resolution: undefined, }; return classifiedRow(agreed, modeOf(model)); }), @@ -630,22 +591,11 @@ function onSaved(model: EditModel, result: SaveResult): EditOutcome { const refreshed = result.loaded === undefined ? committedLocally({ ...settled, pending: {} }, keys, result.changedAt) - : withLoaded({ ...settled, pending: {} }, result.loaded, {}, false); + : withLoaded({ ...settled, pending: {} }, result.loaded, {}); return stay({ ...refreshed, status: { text: `Committed ${keys.length} change(s)`, isError: false } }); } - if (result.kind === 'conflict' && result.loaded !== undefined) { - // The server moved between showing and committing: the rows are drawn from the new read, and a decision - // stands only where the values it was made against do (never an old decision on a new value). - const refreshed = withLoaded(settled, result.loaded, settled.pending, true); - const open = unresolvedNames(refreshed); - return stay({ - ...refreshed, - status: { - text: open.length > 0 ? `${open.join(', ')} changed on the server. Nothing was uploaded.` : 'The server changed. Nothing was uploaded.', // COPY-FLAG - isError: true, - }, - }); - } + // 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 } }); } @@ -681,18 +631,14 @@ function maskedSnippet(row: EditRow): string { return renderInlineValue(formatSnippet(value)); } -/** The status cell: the coloured badge, saying which side was chosen once a conflict is decided. */ -function rowBadge(row: EditRow): string { - const choice = choiceOf(row); - return statusBadge(row.status, choice === undefined ? statusLabel(row.status) : `conflict: ${choice}`); // COPY-FLAG -} +/** 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 unresolved = rows.filter(isUnresolved).length; const unknown = countOf(['unknown']); return [ @@ -704,7 +650,7 @@ function buildTopCells(model: EditModel): { value: string; action: string }[] { }, { value: `${conflicts} conflicts`, - action: unresolved > 0 ? 'need resolution' : conflicts > 0 ? 'all resolved' : 'all clear', // COPY-FLAG (all resolved) + action: conflicts > 0 ? 'need resolution' : 'all clear', }, ]; } @@ -749,8 +695,6 @@ function valueField(raw: string | undefined, revealed: boolean, width: number, p return { text: leftIndicator + value.slice(offset, offset + visibleWidth) + rightIndicator, pan: offset }; } -const maskedOf = (value: string | undefined): string => (value === undefined || value === '' ? NO_VALUE : renderInlineValue(formatSnippet(value))); - /** 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]; @@ -782,16 +726,11 @@ function buildPopup(model: EditModel, width: number): { lines: string[]; pan: nu 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 mine = valueField(row.localValue ?? row.remoteValue, revealed, valueContentWidth, model.popupPan, maskedSnippet(row)); - // A conflict shows both sides: the working value above, the server's under it (masked until revealed, like the first). - const theirs = - row.status === 'conflict' - ? [fieldVal('theirs', valueField(row.remoteValue, revealed, valueContentWidth, mine.pan, maskedOf(row.remoteValue)).text)] // COPY-FLAG - : []; + 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(row.status === 'conflict' ? 'yours' : 'value', mine.text), ...theirs, '', rule], // COPY-FLAG (yours) - pan: mine.pan, + lines: [...head, fieldVal('status', rowBadge(row)), fieldVal('updated', row.updatedLabel), '', fieldVal('value', value.text), '', rule], + pan: value.pan, }; } @@ -813,9 +752,6 @@ function footerOf(model: EditModel): string { 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}` : ''; - // On a conflict row, the two ways to settle it. - const onConflict = model.state.rows[model.cursorIndex]?.status === 'conflict'; - const conflictHint = onConflict ? `${DIM} · ${RESET}${BOLD}m${RESET}${DIM} mine · ${RESET}${BOLD}t${RESET}${DIM} theirs${RESET}` : ''; // COPY-FLAG if (model.quitPrompt) { const n = dirty; @@ -831,9 +767,9 @@ function footerOf(model: EditModel): string { 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}${conflictHint}${panHint}${dirtyHint}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM} close${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}${conflictHint}${dirtyHint}${DIM} · ${RESET}${BOLD}q${RESET}${DIM} quit${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. */ @@ -888,17 +824,15 @@ async function reloaded(ctx: EditContext): Promise { } /** - * Commits `request` through the context. Never rejects: a server that moved under a change is `conflict` - * (decided by the error's code), with the rows re-read so the screen can show them as conflicts again. + * 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 }, { decisions: request.decisions, seen: request.seen }); + const changedAt = await ctx.saveLocalEdits({ ...request.set }, request.seen); return { kind: 'saved', changedAt, loaded: await reloaded(ctx) }; } catch (err) { - return isSecretsConflict(err) - ? { kind: 'conflict', message: messageOf(err), loaded: await reloaded(ctx) } - : { kind: 'failed', message: messageOf(err) }; + return isStaleBase(err) ? { kind: 'stale' } : { kind: 'failed', message: messageOf(err) }; } } @@ -909,8 +843,8 @@ function drawn(model: EditModel): EditModel { } export class EditScreen { - run(state: EditState, ctx: EditContext): Promise { - return new Promise((resolve) => { + 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. @@ -926,7 +860,7 @@ export class EditScreen { bus.emit('action', { kind: 'data', data: '\x03' } satisfies EditAction); }; - const leave = (): void => { + 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(); @@ -935,7 +869,7 @@ export class EditScreen { process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); bus.removeAllListeners(); - resolve(); + resolve(exit); }; const perform = (effect: EditEffect): void => { @@ -947,7 +881,7 @@ export class EditScreen { bus.removeListener('action', onAction); bus.removeListener('redraw', onRedraw); const outcome = reduce(model, action); - if (outcome.done === true) return leave(); + 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); }; diff --git a/src/ui/secretsDeployFlow.ts b/src/ui/secretsDeployFlow.ts index 551481d4..14853b8d 100644 --- a/src/ui/secretsDeployFlow.ts +++ b/src/ui/secretsDeployFlow.ts @@ -21,17 +21,17 @@ // 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 -// conflict and the flow goes to `conflicts` (nothing has been written). -// conflicts one line per variable and target. m delivers Capy's value (mine), t leaves the -// service's (theirs: that variable is not delivered or recorded); Enter runs once every -// one is decided, Esc cancels with nothing written. A decision holds only for the live +// 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 Resolution, type SeenResolutions, type TargetConflict } from '../sync/conflicts'; +import { resolveKeyOf, seenResolutions, type SeenResolutions, type TargetConflict, type TargetResolution } from '../sync/conflicts'; import { DRY_RUN_DEPLOY_NOTE, PLAN_HEADINGS, @@ -90,8 +90,8 @@ export type DeployFlow = readonly row: SecretIndexRow; readonly plan: BatchPlan; readonly conflicts: readonly TargetConflict[]; - /** The decision for each conflict, by `NAME@TARGET`. */ - readonly choices: Readonly>; + /** The answer for each row, by `NAME@TARGET`. */ + readonly choices: Readonly>; readonly cursor: number; } | { @@ -182,15 +182,15 @@ function stepRunning(flow: Extract, key: string // ── Conflicts: variables someone changed on the service outside Capy ─────── -const choiceAt = (flow: Extract, c: TargetConflict): Resolution | undefined => flow.choices[resolveKeyOf(c)]; +const choiceAt = (flow: Extract, c: TargetConflict): TargetResolution | undefined => flow.choices[resolveKeyOf(c)]; -/** Whether every conflict has been decided. */ +/** Whether every row has been answered. */ export const allDecided = (flow: Extract): boolean => flow.conflicts.every((c) => choiceAt(flow, c) !== undefined); -/** The decisions made, each bound to the fingerprints it was made against (`seenResolutions`). */ +/** 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 Resolution] as const]))), + Object.fromEntries(flow.conflicts.flatMap((c) => (choiceAt(flow, c) === undefined ? [] : [[resolveKeyOf(c), choiceAt(flow, c) as TargetResolution] as const]))), flow.conflicts, ); @@ -198,12 +198,12 @@ function stepConflicts(flow: Extract, key: st 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: Resolution): DeployStep => { + 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 === 'm' || key === 'M') return decide('mine'); - if (key === 't' || key === 'T') return decide('theirs'); + 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 } }; @@ -280,18 +280,18 @@ export const prUrlsOf = (result: BatchResult): readonly string[] => export type DeployFinished = | { readonly ok: true; readonly result: BatchResult } - /** `conflicts`: nothing was delivered, because these variables were changed on the service outside Capy (CAP-714). */ + /** `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) { - // A decision made earlier stays only where the live value it was made against is still the one on the service. + // 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.theirs === c.theirs && earlier.yours === c.yours ? [[resolveKeyOf(c), earlier.choice] as const] : []; + 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 }; @@ -387,7 +387,7 @@ function renderConflicts(flow: Extract, width }; 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(['m', 'mine'], ['t', 'theirs'], ...(allDecided(flow) ? ([['enter', 'continue']] as const) : []), ['esc', 'cancel']), // COPY-FLAG + footer: hint(['l', 'local'], ['r', 'remote'], ...(allDecided(flow) ? ([['enter', 'continue']] as const) : []), ['esc', 'cancel']), // COPY-FLAG }; } diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index cd8af2d6..569cf9ea 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -18,11 +18,12 @@ // 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. Before any push, every // location is read; one whose value someone else changed since the row was listed comes -// back as a conflict and the flow goes to `conflicts` (nothing has been written). -// conflicts one line per such location. m keeps your value there (mine), t leaves theirs (that -// location is skipped); Enter runs once every one is decided, Esc cancels with nothing -// written. A decision holds only for the value it was made against: if the location -// moved again, the run comes back here with it. +// 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 choices as far as setting a value +// allows: l writes the new value there (local), r leaves the location (remote). 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. @@ -34,7 +35,8 @@ 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 { LocationConflict, RepoTarget, RunProgress, SecretSetResult, SetLocation, locationKeyOf, planRepos, repoKey, repoLabel } from '../commands/secretsSet'; -import { seenResolutions, type Resolution, type SeenResolution } from '../sync/conflicts'; +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'; @@ -121,7 +123,7 @@ export type EditFlow = readonly step: 'conflicts'; readonly request: RunRequest; readonly conflicts: readonly LocationConflict[]; - /** The decision at each conflicting location, by `locationKeyOf`. */ + /** The answer at each moved location, by `locationKeyOf`: `local` or `remote`. */ readonly choices: Readonly>; readonly cursor: number; } & Carried) @@ -148,8 +150,8 @@ export interface RunRequest { * the new value) was changed by someone else since (CAP-714). Absent: locations are not compared. */ readonly seen?: string; - /** The decisions made for conflicting locations, by `locationKeyOf`, with the fingerprints they were made against. */ - readonly decisions?: Readonly>; + /** The answers for moved locations, by `locationKeyOf`, with the fingerprints they were made against. */ + readonly decisions?: Readonly>; } export type EditEffect = @@ -400,15 +402,14 @@ const choiceAt = (flow: Extract, c: LocationCon /** 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 decision bound to the fingerprints it was made against (`seenResolutions`). */ +/** The run again, with each answer bound to the fingerprints it was made against. */ function withDecisions(flow: Extract): RunRequest { return { ...flow.request, decisions: Object.fromEntries( flow.conflicts.flatMap((c) => { const choice = choiceAt(flow, c); - const bound = choice === undefined ? undefined : seenResolutions({ [c.conflict.name]: choice }, [c.conflict])[c.conflict.name]; - return bound === undefined ? [] : [[locationKeyOf(c.location), bound] as const]; + return choice === undefined ? [] : [[locationKeyOf(c.location), bindAnswer(choice, c.conflict)] as const]; }), ), }; @@ -422,11 +423,11 @@ function stepConflicts(flow: Extract, key: stri const at = flow.conflicts[flow.cursor]; return at === undefined ? stay(flow) : stay({ ...flow, choices: { ...flow.choices, [locationKeyOf(at.location)]: choice } }); }; - if (key === 'm' || key === 'M') return decide('mine'); - if (key === 't' || key === 'T') return decide('theirs'); + 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 request = withDecisions(flow); - const kept = flow.conflicts.filter((c) => choiceAt(flow, c) === 'theirs').length; + const kept = flow.conflicts.filter((c) => choiceAt(flow, c) === 'remote').length; return { flow: { step: 'running', @@ -503,17 +504,15 @@ function finishedText(name: string, finished: RunFinished): string { return 'plan' in finished ? renderDryRunPlan(finished.plan) : renderSecretSetConfirmation(finished.result, TERMINAL_STYLE); } -/** Locations where the person kept the other value (theirs): said under the result, since nothing else mentions them. */ +/** 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. A decision made earlier stays only where the value it was made against is still the one there. */ +/** 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 earlier !== undefined && earlier.theirs === c.conflict.theirs && earlier.yours === c.conflict.yours - ? [[locationKeyOf(c.location), earlier.choice] as const] - : []; + return holdsFor(earlier, c.conflict) ? [[locationKeyOf(c.location), earlier.choice] as const] : []; }), ); return { @@ -533,7 +532,7 @@ function conflictsStep(flow: Extract, conflicts: export function applyRunFinished(flow: EditFlow | null, finished: RunFinished): EditFlow | null { if (flow === null || flow.step !== 'running') return flow; 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 === 'theirs').length; + 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) }; } @@ -587,7 +586,7 @@ function renderConflicts(flow: Extract, maxWidt const n = flow.conflicts.length; const row = (c: LocationConflict, i: number): string => { const choice = choiceAt(flow, c); - const when = c.conflict.theirs_changed_at === undefined ? '' : ` · changed ${formatRelativeTime(c.conflict.theirs_changed_at)}`; // COPY-FLAG + 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); }; @@ -597,7 +596,7 @@ function renderConflicts(flow: Extract, maxWidt '', ...flow.conflicts.map(row), ], - footer: hint(['m', 'mine'], ['t', 'theirs'], ...(allDecided(flow) ? ([['enter', 'continue']] as const) : []), ['esc', 'cancel']), // COPY-FLAG + footer: hint(['l', 'local'], ['r', 'remote'], ...(allDecided(flow) ? ([['enter', 'continue']] as const) : []), ['esc', 'cancel']), // COPY-FLAG }; } 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/branchKeepFile.test.ts b/tests/commands/branchKeepFile.test.ts index 23176561..f1fafd05 100644 --- a/tests/commands/branchKeepFile.test.ts +++ b/tests/commands/branchKeepFile.test.ts @@ -517,7 +517,12 @@ describe('Direction detection — branch-aware keep_hash', () => { listProjects: mock(() => Promise.resolve([])), }; mockSyncEngine = { + compareEnvironments: mock(() => undefined), + formatSyncSummary: mock(() => undefined), + validateDecisions: mock(() => []), + applyDecisions: mock(() => undefined), mergeWithKeep: mock(() => undefined), + generateSyncResult: mock(() => undefined), }; mockPromptEngine = { promptForProjectName: mock(() => undefined), diff --git a/tests/commands/capyCommand.test.ts b/tests/commands/capyCommand.test.ts index f9a68e0a..cc80625f 100644 --- a/tests/commands/capyCommand.test.ts +++ b/tests/commands/capyCommand.test.ts @@ -199,7 +199,12 @@ describe('CapyCommand', () => { } as any; mockSyncEngine = { + compareEnvironments: mock(() => undefined), + formatSyncSummary: mock(() => undefined), + validateDecisions: mock(() => []), + applyDecisions: mock(() => undefined), mergeWithKeep: mock(() => undefined), + generateSyncResult: mock(() => undefined) } as any; mockPromptEngine = { diff --git a/tests/sync/compareSecrets.test.ts b/tests/commands/compareSecrets.test.ts similarity index 96% rename from tests/sync/compareSecrets.test.ts rename to tests/commands/compareSecrets.test.ts index 165b4e46..3d656bf5 100644 --- a/tests/sync/compareSecrets.test.ts +++ b/tests/commands/compareSecrets.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { compareSecrets, hashValue } from '../../src/sync/compareSecrets'; +import { compareSecrets, hashValue } from '../../src/commands/statusCommand'; // compareSecrets takes maps of variable -> value_hash for pinned, and // variable -> plaintext for local/remote (it hashes local/remote internally... diff --git a/tests/commands/conflictProtocol.test.ts b/tests/commands/conflictProtocol.test.ts index 9fe23bc2..5c2784e9 100644 --- a/tests/commands/conflictProtocol.test.ts +++ b/tests/commands/conflictProtocol.test.ts @@ -1,15 +1,23 @@ /** - * The non-TTY conflict 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. + * 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. * - * The world is the Oct 2 incident in miniature. The folder is pinned to an old snapshot of the branch and holds - * ITS OWN edit of X (a teammate's newer X is already on the server). Every command that writes must: - * - stop at the conflict: exit 3, `{ ok:false, code:'SECRETS_CONFLICT', conflict_id, conflicts }` on stdout and - * nothing else there, the explanation on stderr, no value anywhere, NOTHING uploaded and the folder untouched; - * - on a rerun with `--resolve X=mine|theirs --conflict `: write what was chosen and nothing else; - * - refuse a rerun whose state moved (`SECRETS_CONFLICT_STALE`, a new id, nothing written), answer a partial - * set of decisions with the names still open, and refuse `--resolve` without `--conflict`. + * 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 } from 'node:fs'; @@ -29,7 +37,8 @@ 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 ALL_SENTINELS = [OLD.X, OLD.Y, MINE, THEIRS, THEIRS_AGAIN]; +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', @@ -55,24 +64,40 @@ function seedFolder(h: Harness, pinned: Readonly>, local: /** 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_file: JSON.stringify(keepOf(values)) }); + 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 last accepted push left on the server for `name`, decrypted. */ -function pushedValue(h: Harness, name: string): string | undefined { +/** 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'); - if (!existsSync(file)) return undefined; - const store = JSON.parse(readFileSync(file, 'utf8')) as { keep?: KeepFile; blobs: Record }; - if (store.keep === undefined) return undefined; - const blob = store.blobs[SyncEngine.computeKeepHash(store.keep, BRANCH)] ?? ''; - const line = blob.split('\n').find((l) => l.startsWith(`${name}=`)); - return line === undefined ? undefined : new FileManager(h.project).decryptValue(line.slice(name.length + 1), h.projectKey); + 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)); -describe('the non-TTY conflict protocol (built cli)', () => { +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'); @@ -80,15 +105,15 @@ describe('the non-TTY conflict protocol (built cli)', () => { }; beforeEach(async () => { Object.assign(state, { h: await createHarness() }); - // Folder: pinned to the old snapshot, with its own edit of X. Server: a teammate already saved a newer X. - seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); + // 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(); }); - /** The conflict a first run reports, and the folder/server facts every refusal must leave untouched. */ + /** 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); @@ -96,182 +121,205 @@ describe('the non-TTY conflict protocol (built cli)', () => { 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(MINE); + expect(h().envValue('X')).toBe(OLD.X); return { result, id: out.conflict_id as string }; } - describe('capy edit NAME < value (piped)', () => { - test('stops at the conflict: exit 3, the payload on stdout, fingerprints only, prose on stderr, nothing uploaded', async () => { - const { result } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); - expect(json(result).conflicts).toEqual([ - { - kind: 'source', - name: 'X', - project: 'piped-test', - branch: BRANCH, - pinned: hash16(OLD.X), - yours: hash16(MINE), - theirs: hash16(THEIRS), - theirs_changed_at: '2026-10-01T21:44:18.000Z', - }, - ]); + 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'); - expect(result.stderr).toContain('Nothing was written'); + 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('without --json it is the same protocol: still JSON on stdout, exit 3', async () => { - const result = await h().run(['edit', 'X'], `${MINE}\n`); - expect(result.code).toBe(3); - expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); - expect(h().pushCount()).toBe(0); + 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('rerun with mine: MY value is what the server decrypts to', async () => { - const { id } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); - const rerun = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine', '--conflict', id], `${MINE}\n`); + 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(json(rerun)).toMatchObject({ ok: true, name: 'X', pushed: true }); - expect(h().pushCount()).toBe(1); - expect(pushedValue(h(), 'X')).toBe(MINE); - expect(pushedValue(h(), 'Y')).toBe(OLD.Y); + expect(serverValue(h(), 'Y')).toBeUndefined(); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(h().envValue('X')).toBe(THEIRS); }); - test('rerun with theirs: nothing is uploaded and the folder takes THEIR value', async () => { - const { id } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); - const rerun = await h().run(['edit', 'X', '--json', '--resolve', 'X=theirs', '--conflict', id], `${MINE}\n`); + 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(h().pushCount()).toBe(0); + expect(serverValue(h(), 'X')).toBe(THEIRS); expect(h().envValue('X')).toBe(THEIRS); }); - test('a rerun whose state moved is SECRETS_CONFLICT_STALE: a new id, the fresh conflict, nothing written', async () => { - const { id } = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); - seedServer(h(), { X: THEIRS_AGAIN, Y: OLD.Y }); // another writer moved X again - const rerun = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine', '--conflict', id], `${MINE}\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[0]).toMatchObject({ name: 'X', theirs: hash16(THEIRS_AGAIN) }); - noValuesIn(rerun); + 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); - expect(h().envValue('X')).toBe(MINE); - // ... and the NEW id works. - const again = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine', '--conflict', out.conflict_id], `${MINE}\n`); + }); + + 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(pushedValue(h(), 'X')).toBe(MINE); + expect(again.stdout).toBe(''); }); - test('--resolve without --conflict is refused with a code, and nothing is read or written', async () => { - const result = await h().run(['edit', 'X', '--json', '--resolve', 'X=mine'], `${MINE}\n`); - expect(result.code).toBe(1); - expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_NEEDS_CONFLICT' }); - expect(h().pushCount()).toBe(0); + 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); }); + }); - test('a malformed --resolve is SECRETS_RESOLVE_INVALID', async () => { - const result = await h().run(['edit', 'X', '--json', '--resolve', 'X:mine', '--conflict', 'c_9f2e41a07b3d'], `${MINE}\n`); - expect(result.code).toBe(1); - expect(json(result)).toMatchObject({ ok: false, code: 'SECRETS_RESOLVE_INVALID' }); + describe('the four choices', () => { + beforeEach(() => { + // EDITED: the folder also holds its own X. + seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); }); - test('--dry-run previews the same conflict_id and writes nothing', async () => { - const real = await firstRefusal(['edit', 'X', '--json'], `${MINE}\n`); - const preview = await h().run(['edit', 'X', '--json', '--dry-run'], `${MINE}\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); + 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); }); - }); - describe('capy add NAME --force < value (piped)', () => { - test('stops at the conflict, and a rerun with mine writes my value', async () => { - const { id } = await firstRefusal(['add', 'X', '--force', '--json'], `${MINE}\n`); - const rerun = await h().run(['add', 'X', '--force', '--json', '--resolve', 'X=mine', '--conflict', id], `${MINE}\n`); + test('remote: THEIR value stays in Capy and the folder takes it', async () => { + const rerun = await rerunWith('remote'); expect(rerun.code).toBe(0); - expect(pushedValue(h(), 'X')).toBe(MINE); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(h().envValue('X')).toBe(THEIRS); }); - }); - describe('capy remove', () => { - test('stops at the conflict, with --dry-run previewing the same id; a rerun with mine removes only what was named', async () => { - const args = ['remove', 'X', '--yes', '--json']; - const { id } = await firstRefusal(args); - const preview = await h().run([...args, '--dry-run']); - expect(preview.code).toBe(3); - expect(json(preview)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', dry_run: true, conflict_id: id }); - expect(json(preview).conflicts[0]).toMatchObject({ name: 'X', yours: null, theirs: hash16(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); + }); - const rerun = await h().run([...args, '--resolve', 'X=mine', '--conflict', id]); + test('delete: X goes from Capy and from the folder', async () => { + const rerun = await rerunWith('delete'); expect(rerun.code).toBe(0); - expect(h().pushCount()).toBe(1); - expect(pushedValue(h(), 'X')).toBeUndefined(); - expect(pushedValue(h(), 'Y')).toBe(OLD.Y); + expect(serverValue(h(), 'X')).toBeUndefined(); + expect(h().envValue('X')).toBeUndefined(); + expect(serverValue(h(), 'Z')).toBe(NEW_Z); }); + }); - test('a name nobody else touched is removed without a question', async () => { - const result = await h().run(['remove', 'Y', '--yes', '--json']); - expect(result.code).toBe(0); - expect(pushedValue(h(), 'Y')).toBeUndefined(); - expect(pushedValue(h(), 'X')).toBe(THEIRS); // the teammate's X survives + 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('capy push', () => { - test('stops at the conflict (stdin is not a terminal), and a rerun with mine pushes my edit', async () => { - const { id } = await firstRefusal(['push']); - const rerun = await h().run(['push', '--resolve', 'X=mine', '--conflict', id]); - expect(rerun.code).toBe(0); - expect(pushedValue(h(), 'X')).toBe(MINE); - expect(pushedValue(h(), 'Y')).toBe(OLD.Y); + 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('partial decisions get the names still open, under the id of the whole set', async () => { - seedFolder(h(), OLD, { X: MINE, Y: 'SENTINEL-y-mine-77aa' }); + 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=mine', '--conflict', out.conflict_id]); + 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=mine', '--resolve', 'Y=theirs', '--conflict', out.conflict_id]); + const full = await h().run(['push', '--resolve', 'X=remote', '--resolve', 'Y=pinned', '--conflict', out.conflict_id]); expect(full.code).toBe(0); - expect(pushedValue(h(), 'X')).toBe(MINE); - expect(pushedValue(h(), 'Y')).toBe('SENTINEL-y-theirs-91bc'); + expect(serverValue(h(), 'X')).toBe(THEIRS); + expect(serverValue(h(), 'Y')).toBe(OLD.Y); }); - test('a variable deleted from .env is pushed as a removal and is not resurrected (CAP-712 follow-up)', async () => { - // Nobody else touched anything: the folder is current and only deleted Y. - seedFolder(h(), OLD, { X: OLD.X }); - seedServer(h(), OLD); - const result = await h().run(['push']); - expect(result.code).toBe(0); - expect(pushedValue(h(), 'Y')).toBeUndefined(); - expect(pushedValue(h(), 'X')).toBe(OLD.X); - expect(h().envValue('Y')).toBeUndefined(); + 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 deletion is conflict-checked like an edit: Y changed on the server since this folder pinned it', async () => { - seedFolder(h(), OLD, { X: OLD.X }); // Y deleted locally - seedServer(h(), { X: OLD.X, Y: 'SENTINEL-y-theirs-91bc' }); - const result = await h().run(['push']); - expect(result.code).toBe(3); - expect(json(result).conflicts[0]).toMatchObject({ name: 'Y', yours: null, theirs: hash16('SENTINEL-y-theirs-91bc') }); + 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 (built cli)', () => { +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'); @@ -299,14 +347,15 @@ describe('capy secrets set NAME (built cli)', () => { }); 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 conflict and the plan_id; the real run is stopped the same way before anything is pushed', async () => { + 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[0]).toMatchObject({ kind: 'source', name: 'X', pinned: hash16(OLD.X), yours: hash16(MINE), theirs: hash16(THEIRS) }); + expect(out.conflicts).toEqual([row]); noValuesIn(preview); const real = await h().run([...SET, '--confirm', out.plan_id], `${MINE}\n`); @@ -316,24 +365,36 @@ describe('capy secrets set NAME (built cli)', () => { expect(h().pushCount()).toBe(0); }); - test('rerun with mine pushes MY value; with theirs it pushes nothing and leaves their value', async () => { + 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 theirs = await h().run([...base, '--resolve', 'X=theirs'], `${MINE}\n`); - expect(theirs.code).toBe(0); + 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 mine = await h().run([...base, '--resolve', 'X=mine'], `${MINE}\n`); - expect(mine.code).toBe(0); + const local = await h().run([...base, '--resolve', 'X=local'], `${MINE}\n`); + expect(local.code).toBe(0); expect(h().pushCount()).toBe(1); - expect(pushedValue(h(), 'X')).toBe(MINE); + 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 and delete are not answers to setting a value: refused with a code, 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' }); + expect(h().pushCount()).toBe(0); }); 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=mine'], `${MINE}\n`); + 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); @@ -346,71 +407,6 @@ describe('capy secrets set NAME (built cli)', () => { 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(pushedValue(h(), 'X')).toBe(MINE); - }); -}); - -describe('bare capy 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() }); - seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); - seedServer(h(), { X: THEIRS, Y: OLD.Y }); - }); - afterEach(async () => { - await state.h?.dispose(); - }); - - test('both sides changed X: exit 3 with the payload alone on stdout (no banner), prose on stderr, nothing uploaded', async () => { - const result = await h().run([], ''); - expect(result.code).toBe(3); - const out = json(result); // stdout is JSON and nothing else - expect(Object.keys(out).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); - expect(out).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); - expect(out.conflicts).toHaveLength(1); - expect(out.conflicts[0]).toMatchObject({ kind: 'source', name: 'X', pinned: hash16(OLD.X), yours: hash16(MINE), theirs: hash16(THEIRS) }); - noValuesIn(result); - expect(h().pushCount()).toBe(0); - expect(h().envValue('X')).toBe(MINE); - }); - - test('rerun with mine commits MY change on top of the server\'s latest, and pulls what others changed', async () => { - seedFolder(h(), OLD, { X: MINE, Y: OLD.Y }); - seedServer(h(), { X: THEIRS, Y: 'SENTINEL-y-theirs-91bc' }); // a teammate also moved Y, which this folder did not touch - const first = await h().run([], ''); - const id = json(first).conflict_id as string; - const rerun = await h().run(['--resolve', 'X=mine', '--conflict', id], ''); - expect(rerun.code).toBe(0); - expect(h().pushCount()).toBe(1); - expect(pushedValue(h(), 'X')).toBe(MINE); - expect(pushedValue(h(), 'Y')).toBe('SENTINEL-y-theirs-91bc'); // untouched by this folder - expect(h().envValue('Y')).toBe('SENTINEL-y-theirs-91bc'); - }); - - test('rerun with theirs uploads nothing and the folder takes their value', async () => { - const id = json(await h().run([], '')).conflict_id as string; - const rerun = await h().run(['--resolve', 'X=theirs', '--conflict', id], ''); - expect(rerun.code).toBe(0); - expect(h().pushCount()).toBe(0); - expect(h().envValue('X')).toBe(THEIRS); - }); - - test('a stale id gets a fresh conflict and a new id; --resolve without --conflict is refused with a code', async () => { - const id = json(await h().run([], '')).conflict_id as string; - seedServer(h(), { X: THEIRS_AGAIN, Y: OLD.Y }); - const stale = await h().run(['--resolve', 'X=mine', '--conflict', id], ''); - expect(stale.code).toBe(3); - expect(json(stale)).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT_STALE' }); - expect(json(stale).conflict_id).not.toBe(id); - expect(h().pushCount()).toBe(0); - - const bare = await h().run(['--resolve', 'X=mine'], ''); - expect(bare.code).toBe(1); - expect(bare.stderr).toContain('--conflict'); - expect(h().pushCount()).toBe(0); + expect(serverValue(h(), 'X')).toBe(MINE); }); }); diff --git a/tests/commands/connectDokploy.test.ts b/tests/commands/connectDokploy.test.ts index 6272cb48..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'; diff --git a/tests/commands/deployBranchCheck.test.ts b/tests/commands/deployBranchCheck.test.ts index e1cf9b69..6238fb6d 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,38 +114,53 @@ 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 - - const r = capy(['deploy', 'dokploy-target', '--yes']); - expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); - expect(r.stderr).toContain('preflight'); + test('no active-branch signal at all: bare capy, which runs first, cannot pick a branch, so the deploy never reaches its own check', async () => { + await withHarness(async (h) => { + // two branches — nothing to pick one by: no header, no .capy/branch (and the mock service lists no branches) + syncedProject(h, { branchFile: null }); + const r = await h.run(['deploy', 'dokploy-target', '--yes'], undefined, undefined, { DOKPLOY_API_KEY: '' }); + expect(r.code).toBe(1); + expect(r.stderr).not.toContain('DEPLOY_BRANCH_MISMATCH'); + expect(r.stderr).not.toContain('preflight'); + }); }); }); diff --git a/tests/commands/deployCiChangeGate.test.ts b/tests/commands/deployCiChangeGate.test.ts index 2b99fad9..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,8 +49,7 @@ 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). +// The server answers in agreement with the folder (the keep-only record writes read it). mock.module('../../src/service/serviceClient', () => ({ ServiceClient: class { setTokenProvider() {} diff --git a/tests/commands/deployCiTargetsRecord.test.ts b/tests/commands/deployCiTargetsRecord.test.ts index dc5ce1a8..b5bd469d 100644 --- a/tests/commands/deployCiTargetsRecord.test.ts +++ b/tests/commands/deployCiTargetsRecord.test.ts @@ -35,10 +35,9 @@ * env blob is the server's OWN blob passed through unchanged; * - 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 to a variable being delivered is never delivered - * unasked and never saved unasked (CAP-717, strict): off a terminal the deploy is - * refused with `DEPLOY_UNSAVED_CHANGES` (exit 3) and nothing is pushed (the keep-only - * record itself never rebuilds the blob from `.env`); + * (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); @@ -73,6 +72,10 @@ 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 @@ -441,42 +444,6 @@ describe('capy deploy — server-side target recording (CAP-687)', () => { expect(after.stash).toBe(''); }, 30_000); - /** Whether any request the scripted Dokploy fetch saw wrote the service's env. */ - const wroteEnv = (fetchMock: ReturnType): boolean => - fetchMock.mock.calls.some(([url]) => new URL(String(url)).pathname.endsWith('application.saveEnvironment')); - - /** Everything printed (stdout and stderr), flattened: the sentinel scan reads this. */ - const printed = (...mocks: readonly ReturnType[]): string => mocks.flatMap((m) => m.mock.calls.map((c) => c.map(String).join(' '))).join('\n'); - - // CAP-717, strict: an unsaved edit is never delivered and never saved unasked. Off a terminal the deploy is refused - // (exit 3, DEPLOY_UNSAVED_CHANGES, names only); nothing is pushed to Capy, nothing reaches Dokploy, no PR, and the - // folder is byte-identical. (The terminal "save and deploy?" path is in deployConflicts.test.ts.) - test.each([ - ['dokploy-ci', { yes: true }], - ['dokploy-direct', { yes: true, noDeploy: true }], - ] as const)('%s: an unpushed local `.env` edit is REFUSED off a terminal (exit 3, DEPLOY_UNSAVED_CHANGES, names only): nothing pushed to Capy, nothing written to Dokploy, the folder untouched', async (targetName, options) => { - setUpRepo(); - process.chdir(REPO); - // The server agrees with local keep.lock, but local `.env` has moved on: an EDITED value for the tracked var, - // plus a variable that was never synced (it is not in `target.vars`/keep.lock). - writeServerSnapshot('main'); - writeFileSync(join(REPO, '.env'), 'STRIPE_KEY=new-unpushed-value\nUNPUSHED_OTHER=stray\n'); - const before = snapshotWorkingTree(); - - const fetchMock = dokployFetchMock({ stripeKeyValue: 'new-unpushed-value' }); - const errors = mock((..._a: unknown[]) => {}); - const logs = mock((..._a: unknown[]) => {}); - const code = await runScriptedDeploy(targetName, fetchMock, options, errors, logs); - - expect(code).toBe(3); - expect(pushSecretsMock).not.toHaveBeenCalled(); - expect(wroteEnv(fetchMock)).toBe(false); - expect(JSON.parse(String(logs.mock.calls[0][0]))).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['STRIPE_KEY'] }); - expect(printed(errors, logs)).not.toContain('new-unpushed-value'); - expect(printed(errors, logs)).not.toContain('stray'); - expect(snapshotWorkingTree()).toEqual(before); - }, 30_000); - // 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). 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/deployConflicts.test.ts b/tests/commands/deployConflicts.test.ts deleted file mode 100644 index 7bfbce4f..00000000 --- a/tests/commands/deployConflicts.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -/** - * `capy deploy` and conflicts (CAP-717): before a delivery writes anything, the values it is about to deliver are - * compared with Capy's latest for the branch (SOURCE, the folder against the server) and, on Dokploy, with the - * service's live env (TARGET). This drives `reconcileDeployConflicts`, the step `deployCommand` takes before the CI - * gate, a mint or a write, against the in-memory Capy server of tests/helpers/fakeCapyServer.ts and a real folder. - * - * The properties: a variable the folder did not change is never delivered stale (Capy's latest is pulled in); a - * variable both sides changed is a conflict that stops the deploy off a terminal (exit-3 payload, nothing uploaded, - * the folder untouched) and, once answered, is either committed first and delivered (mine) or taken from Capy - * (theirs); source and target conflicts are answered in one step under one conflict_id. - * - * Strict about unsaved edits (CAP-717): a folder edit Capy does not hold is never delivered unasked and never saved - * unasked. At a terminal one question ("save and deploy?"): yes commits it to Capy, then delivers; no stops with - * nothing saved and nothing delivered. Off a terminal (and in a preview) the deploy is refused with - * DEPLOY_UNSAVED_CHANGES (names only), and source conflicts are reported first. - * - * `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). - */ -import { describe, test, expect, mock, afterEach, spyOn } from 'bun:test'; -import type { DeployAdapter } from '../../src/deploy/adapter'; -import { FIXED_PROJECT_KEY, fixedKeyResolver } from '../helpers/deployServer'; -import { fakeCapyServer, seedServer, serverValues } from '../helpers/fakeCapyServer'; -import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; - -const KEY = FIXED_PROJECT_KEY; -const currentServer = mock((): ReturnType => { - throw new Error('no server for this test'); -}); - -mock.module('../../src/auth/authService', () => ({ - AuthService: class { - async authenticateSilent() { - return { success: true, user_id: 'user-b' }; - } - async getValidToken() { - return 'fake-session-token'; - } - }, - silentAuthFailureMessage: () => 'auth failed', -})); -mock.module('../../src/service/serviceClient', () => ({ - ServiceClient: class { - setTokenProvider() {} - getDecryptData(...args: unknown[]) { - return (currentServer().getDecryptData as (...a: unknown[]) => unknown)(...args); - } - pushSecrets(...args: unknown[]) { - return (currentServer().pushSecrets as (...a: unknown[]) => unknown)(...args); - } - }, -})); -mock.module('../../src/crypto/keyResolver', () => fixedKeyResolver()); -const ask = mock(async (_conflicts: readonly unknown[]) => ({}) as Record); -const askSave = mock(async (_names: readonly string[]) => true); -mock.module('../../src/commands/conflictPrompt', () => ({ askConflicts: ask, askSaveAndDeploy: askSave })); - -import { printUnsavedChangesRefusal, reconcileDeployConflicts, UnsavedChangesRefusal, type ReconcileInput } from '../../src/commands/deployConflicts'; -import { ConflictRefusal, parseConflictFlags, type Conflict } from '../../src/sync/conflicts'; -import { hashValue } from '../../src/sync/compareSecrets'; -import type { KeepFile } from '../../src/types/index'; -import { staleTargets } from '../../src/deploy/targetsGate'; -import { planTargetDelivery } from '../../src/deploy/dokployApi'; - -const BRANCH = 'production'; -const OLD = { X: 'x-old', Y: 'y-old' } as const; -const THEIRS = 'x-theirs-from-A'; -const MINE = 'x-mine-from-B'; - -const OLD_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: OLD } }); -const NEW_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: { X: THEIRS, Y: OLD.Y } } }); - -const made = mock((_: Folder) => undefined); -afterEach(() => { - made.mock.calls.forEach(([folder]) => removeFolder(folder)); - made.mockClear(); - ask.mockClear(); - askSave.mockClear(); -}); - -/** A folder pinned to the old snapshot holding `env`, and a server holding `server` (default: a teammate's newer X). */ -function world(env: Record, server: typeof NEW_SEED = NEW_SEED) { - const fake = fakeCapyServer({ initial: server }); - currentServer.mockImplementation(() => fake); - const folder = makeFolder({ projectKey: KEY, branch: BRANCH, keep: OLD_SEED.keep as KeepFile, env, client: fake.client }); - made(folder); - return { fake, folder }; -} - -/** An adapter that delivers plain values and, optionally, reports live-value conflicts the way Dokploy does. */ -function adapterWith(live: readonly Conflict[] | ((env: Readonly>) => readonly Conflict[]) = []): DeployAdapter & { readonly deploy: ReturnType } { - const deploy = mock(async (_target: unknown, ctx: { dryRun: boolean; env: Readonly>; targetCheck?: { resolutions: Record } }) => ({ - ok: true, - steps: [], - conflicts: ctx.dryRun ? (typeof live === 'function' ? live(ctx.env) : live) : undefined, - })); - return { id: 'dokploy', label: 'x', description: '', varKind: 'runtime', defaultMode: 'ci', readsLiveValues: typeof live === 'function' || live.length > 0, requires: { binaries: [] }, detect: async () => ({}), preflight: async () => ({ ok: true }), deploy } as unknown as DeployAdapter & { - readonly deploy: ReturnType; - }; -} - -const target = (vars: readonly string[] = ['X', 'Y']) => ({ name: 'dokploy-production', kind: 'dokploy', branch: BRANCH, vars: [...vars], options: {}, mode: 'ci' as const }); - -const input = (folder: Folder, adapter: DeployAdapter, over: Partial = {}): ReconcileInput => ({ - cwd: folder.dir, - target: target(), - adapter, - dryRun: false, - interactive: false, - flags: { resolutions: {}, conflictId: undefined }, - adapterCtx: {}, - showFailure: () => undefined, - ...over, -}); - -/** The refusal a non-interactive reconcile throws. */ -async function refusal(run: () => Promise): Promise { - const err = await run().then( - () => undefined, - (e: unknown) => e, - ); - expect(err).toBeInstanceOf(ConflictRefusal); - return err as ConflictRefusal; -} - -/** The refusal an unsaved edit raises where nobody can be asked. */ -async function unsavedRefusal(run: () => Promise): Promise { - const err = await run().then( - () => undefined, - (e: unknown) => e, - ); - expect(err).toBeInstanceOf(UnsavedChangesRefusal); - return err as UnsavedChangesRefusal; -} - -/** Whether any adapter call was a real delivery (not the read-only look for live conflicts). */ -const delivered = (adapter: { readonly deploy: ReturnType }): boolean => adapter.deploy.mock.calls.some(([, ctx]) => (ctx as { dryRun: boolean }).dryRun === false); - -describe('capy deploy: source conflicts', () => { - test('the folder changed X, a teammate changed X: off a terminal the deploy stops with the payload — nothing uploaded, the folder untouched, no value in it', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - const stopped = await refusal(() => reconcileDeployConflicts(input(folder, adapterWith()))); - expect(stopped.payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); - expect(stopped.payload.conflicts).toEqual([ - expect.objectContaining({ kind: 'source', name: 'X', pinned: hashValue(OLD.X), yours: hashValue(MINE), theirs: hashValue(THEIRS) }), - ]); - expect(JSON.stringify(stopped.payload)).not.toContain(MINE); - expect(JSON.stringify(stopped.payload)).not.toContain(THEIRS); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(folder.env()).toEqual({ X: MINE, Y: OLD.Y }); - }); - - test('mine: the folder\'s value is committed through the commit path FIRST (the server now holds it), and that is what gets delivered', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - const id = (await refusal(() => reconcileDeployConflicts(input(folder, adapterWith())))).payload.conflict_id; - const flags = parseConflictFlags({ resolve: ['X=mine'], conflict: id }); - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { flags })); - expect(done.ok).toBe(true); - expect(fake.pushSecrets.mock.calls).toHaveLength(1); - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: MINE, Y: OLD.Y }); - expect(folder.env().X).toBe(MINE); - }); - - test('theirs: nothing is uploaded and the folder takes Capy\'s latest (so Capy\'s latest is what gets delivered)', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - const id = (await refusal(() => reconcileDeployConflicts(input(folder, adapterWith())))).payload.conflict_id; - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { flags: parseConflictFlags({ resolve: ['X=theirs'], conflict: id }) })); - expect(done.ok).toBe(true); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(folder.env().X).toBe(THEIRS); - }); - - test('a variable the folder did NOT change, that Capy moved, is not a conflict: Capy\'s latest is pulled in, never the stale value', async () => { - const { fake, folder } = world({ X: OLD.X, Y: OLD.Y }); // the folder is simply behind - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); - expect(done.ok).toBe(true); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(folder.env().X).toBe(THEIRS); - expect(folder.pin('X')).toBe(hashValue(THEIRS)); - expect(ask).not.toHaveBeenCalled(); // no question of any kind: the folder simply takes Capy's latest - expect(askSave).not.toHaveBeenCalled(); - }); - - test('both changed X to the SAME value: nothing to decide and nothing to save — delivered, the folder adopts the pin, no question', async () => { - const { fake, folder } = world({ X: THEIRS, Y: OLD.Y }); - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); - expect(done.ok).toBe(true); - expect(ask).not.toHaveBeenCalled(); - expect(askSave).not.toHaveBeenCalled(); - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: THEIRS, Y: OLD.Y }); - expect(folder.pin('X')).toBe(hashValue(THEIRS)); - }); - - test('a folder edit Capy does not hold: at a terminal ONE question names it; Enter commits it to Capy first (Capy now holds what the platform will hold), then it is delivered', async () => { - const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); - expect(done.ok).toBe(true); - expect(askSave.mock.calls).toEqual([[['Y']]]); - expect(ask).not.toHaveBeenCalled(); // an unsaved edit is no conflict - expect(fake.pushSecrets.mock.calls).toHaveLength(1); - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: OLD.X, Y: 'y-edited' }); // Capy's latest decrypts to the folder's value - expect(folder.env().Y).toBe('y-edited'); - expect(folder.pin('Y')).toBe(hashValue('y-edited')); - // What `recordDeployTargets` writes next: deployed_value_hash of the delivered value. Against Capy's pin it is not behind. - const entry = fake.state().keep?.variables.Y.find((e) => e.branch === BRANCH); - expect(entry).toBeDefined(); - const recorded = { provider: 'dokploy', target: 'dokploy-production', deployed_value_hash: hashValue('y-edited') }; - expect(staleTargets({ ...entry!, targets: [recorded] } as never)).toEqual([]); - }); - - test('declining the question stops the deploy: nothing saved to Capy, the folder untouched, the platform never contacted', async () => { - const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); - askSave.mockImplementationOnce(async () => false); - const adapter = adapterWith(() => []); - const before = JSON.stringify(folder.keep()); - const stopped = await reconcileDeployConflicts(input(folder, adapter, { interactive: true })); - expect(stopped).toEqual({ ok: false, code: 0 }); - expect(askSave).toHaveBeenCalledTimes(1); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual(OLD); // Capy's latest is unchanged - expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited' }); - expect(JSON.stringify(folder.keep())).toBe(before); - expect(adapter.deploy.mock.calls).toHaveLength(0); // no read of the platform, no write - }); - - test('off a terminal an unsaved edit REFUSES the deploy: DEPLOY_UNSAVED_CHANGES with the names only — nothing saved, nothing delivered, the platform never contacted, no value anywhere in the output', async () => { - const { fake, folder } = world({ X: OLD.X, Y: 'y-edited-sentinel' }, OLD_SEED); - const adapter = adapterWith(() => []); - const before = JSON.stringify(folder.keep()); - const stopped = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapter))); - expect(stopped.payload).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'] }); - expect(stopped.code).toBe('DEPLOY_UNSAVED_CHANGES'); - // What the command prints: JSON on stdout, prose on stderr — neither holds the value. - const out = spyOn(console, 'log').mockImplementation((() => {}) as never); - const err = spyOn(console, 'error').mockImplementation((() => {}) as never); - printUnsavedChangesRefusal(stopped); - const printed = [...out.mock.calls, ...err.mock.calls].map((c) => c.join(' ')).join('\n'); - expect(JSON.parse(String(out.mock.calls[0][0]))).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'] }); - out.mockRestore(); - err.mockRestore(); - expect(printed).not.toContain('y-edited-sentinel'); - expect(stopped.message).not.toContain('y-edited-sentinel'); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual(OLD); - expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited-sentinel' }); - expect(JSON.stringify(folder.keep())).toBe(before); - expect(adapter.deploy.mock.calls).toHaveLength(0); - expect(askSave).not.toHaveBeenCalled(); - }); - - test('an unsaved edit next to a name a teammate moved: refused off a terminal naming ONLY the unsaved one; nothing is pulled or saved either', async () => { - const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }); // the server moved X; the folder only changed Y - const stopped = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapterWith()))); - expect(stopped.payload.names).toEqual(['Y']); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited' }); - }); - - test('at a terminal, an unsaved edit next to a name a teammate moved: Enter lands both (Capy\'s X pulled in, the folder\'s Y saved)', async () => { - const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }); - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); - expect(done.ok).toBe(true); - expect(askSave.mock.calls).toEqual([[['Y']]]); - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: THEIRS, Y: 'y-edited' }); - expect(folder.env()).toEqual({ X: THEIRS, Y: 'y-edited' }); - }); - - test('source conflicts AND an unsaved edit at a terminal: the conflict is asked first, then the unsaved edit once; `mine` needs no second question and both are saved', async () => { - const { fake, folder } = world({ X: MINE, Y: 'y-edited' }); - ask.mockImplementationOnce(async () => ({ X: 'mine' })); - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); - expect(done.ok).toBe(true); - expect((ask.mock.calls[0][0] as readonly Conflict[]).map((c) => c.name)).toEqual(['X']); - expect(askSave.mock.calls).toEqual([[['Y']]]); // the conflicting name is not asked about again - expect(serverValues(fake.state(), BRANCH, KEY)).toEqual({ X: MINE, Y: 'y-edited' }); - }); - - test('source conflict AND unsaved edit off a terminal: SECRETS_CONFLICT comes first (alone); once resolved, the rerun answers DEPLOY_UNSAVED_CHANGES — and nothing was saved at either step', async () => { - const { fake, folder } = world({ X: MINE, Y: 'y-edited' }); - const adapter = adapterWith(() => []); - const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); - expect(first.payload.code).toBe('SECRETS_CONFLICT'); - expect(first.payload.conflicts.map((c) => c.name)).toEqual(['X']); // Y is not in it: one response at a time - const flags = parseConflictFlags({ resolve: ['X=mine'], conflict: first.payload.conflict_id }); - const second = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapter, { flags }))); - expect(second.payload).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'] }); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); // even the `mine` answer saved nothing: the deploy did not go on - expect(folder.env()).toEqual({ X: MINE, Y: 'y-edited' }); - expect(delivered(adapter)).toBe(false); - }); - - test('--dry-run reports the unsaved names (dry_run: true) and neither asks nor writes — even at a terminal', async () => { - const { fake, folder } = world({ X: OLD.X, Y: 'y-edited' }, OLD_SEED); - const preview = await unsavedRefusal(() => reconcileDeployConflicts(input(folder, adapterWith(), { dryRun: true, interactive: true }))); - expect(preview.payload).toEqual({ ok: false, code: 'DEPLOY_UNSAVED_CHANGES', names: ['Y'], dry_run: true }); - expect(askSave).not.toHaveBeenCalled(); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(folder.env()).toEqual({ X: OLD.X, Y: 'y-edited' }); - }); - - test('a folder that agrees with Capy is left alone: no write of any kind', async () => { - const { fake, folder } = world({ X: OLD.X, Y: OLD.Y }, OLD_SEED); - const before = JSON.stringify(folder.keep()); - await reconcileDeployConflicts(input(folder, adapterWith())); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - expect(JSON.stringify(folder.keep())).toBe(before); - }); - - test('--dry-run reports the conflict (dry_run: true, the same id) and writes nothing', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - const real = await refusal(() => reconcileDeployConflicts(input(folder, adapterWith()))); - const preview = await refusal(() => reconcileDeployConflicts(input(folder, adapterWith(), { dryRun: true }))); - expect(preview.payload).toMatchObject({ dry_run: true, conflict_id: real.payload.conflict_id }); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - }); - - test('in a terminal every conflict is a question (mine / theirs), and the answer is applied', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - ask.mockImplementationOnce(async () => ({ X: 'mine' })); - const done = await reconcileDeployConflicts(input(folder, adapterWith(), { interactive: true })); - expect(done.ok).toBe(true); - expect(ask).toHaveBeenCalledTimes(1); - expect(askSave).not.toHaveBeenCalled(); // choosing mine IS the decision to save: no second question - expect(serverValues(fake.state(), BRANCH, KEY).X).toBe(MINE); - }); -}); - -describe('capy deploy: source and target conflicts in one step', () => { - const live: Conflict = { kind: 'target', name: 'Y', target: 'dokploy-production', pinned: null, yours: hashValue(OLD.Y), theirs: 'deadbeefdeadbeef' }; - - test('one payload carries both kinds under one conflict_id; the answers split between the folder and the adapter', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - const adapter = adapterWith([live]); - const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); - expect(first.payload.conflicts.map((c) => `${c.kind}:${c.name}`)).toEqual(['source:X', 'target:Y']); - // Nothing is written before every conflict is answered: the adapter was only asked to LOOK. - expect(adapter.deploy.mock.calls.every(([, ctx]) => (ctx as { dryRun: boolean }).dryRun)).toBe(true); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - - const flags = parseConflictFlags({ resolve: ['X=mine', 'Y@dokploy-production=theirs'], conflict: first.payload.conflict_id }); - const done = await reconcileDeployConflicts(input(folder, adapter, { flags })); - expect(done).toMatchObject({ ok: true, skipped: ['Y'] }); - // The decision carries the fingerprints it was made against (what the adapter re-checks at the write). - expect(done.ok && done.targetCheck?.resolutions).toEqual({ Y: { choice: 'theirs', theirs: live.theirs, yours: live.yours } }); - expect(serverValues(fake.state(), BRANCH, KEY).X).toBe(MINE); - }); - - test('the decision handed to the write only counts for the state it was made against: the live value moved afterwards, so the plan is a conflict again', async () => { - const { folder } = world({ X: MINE, Y: OLD.Y }); - // The platform holds 'seen-live' for Y when the answer is given; the adapter reports it against the env it was handed. - const adapter = adapterWith((env) => [{ kind: 'target', name: 'Y', target: 'dokploy-production', pinned: null, yours: hashValue(env.Y), theirs: hashValue('seen-live') }]); - const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); - const flags = parseConflictFlags({ resolve: ['X=mine', 'Y@dokploy-production=mine'], conflict: first.payload.conflict_id }); - const done = await reconcileDeployConflicts(input(folder, adapter, { flags })); - const check = done.ok ? done.targetCheck : undefined; - expect(check).toBeDefined(); - const plan = (liveValue: string) => planTargetDelivery(`Y=${liveValue}\n`, [{ name: 'Y', value: OLD.Y }], check); - expect(plan('seen-live').kind).toBe('clear'); // still what was seen: the answer applies - expect(plan('moved-live').kind).toBe('conflict'); // moved after the answer: asked again, nothing to apply - }); - - test('source answered theirs: the target comparison runs with Capy\'s latest, so the target conflict reports the value that will really be delivered', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - // The platform holds an X that is neither Capy's nor the folder's; the adapter reports it against the env it was given. - const platform = hashValue('x-on-the-platform'); - const adapter = adapterWith((env) => [{ kind: 'target', name: 'X', target: 'dokploy-production', pinned: null, yours: hashValue(env.X), theirs: platform }]); - - const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); - expect(first.payload.conflicts.find((c) => c.kind === 'target')).toMatchObject({ yours: hashValue(MINE) }); // unanswered: read as mine - - // Answering the source side `theirs` changes what is delivered, so the id from the first look no longer binds: STALE, with the real yours. - const answered = parseConflictFlags({ resolve: ['X=theirs', 'X@dokploy-production=theirs'], conflict: first.payload.conflict_id }); - const stale = await refusal(() => reconcileDeployConflicts(input(folder, adapter, { flags: answered }))); - expect(stale.payload.code).toBe('SECRETS_CONFLICT_STALE'); - expect(stale.payload.conflicts.find((c) => c.kind === 'target')).toMatchObject({ yours: hashValue(THEIRS), theirs: platform }); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - - // The rerun with the fresh id goes through; the target decision is bound to the real yours. - const again = parseConflictFlags({ resolve: ['X=theirs', 'X@dokploy-production=mine'], conflict: stale.payload.conflict_id }); - const done = await reconcileDeployConflicts(input(folder, adapter, { flags: again })); - expect(done.ok && done.targetCheck?.resolutions).toEqual({ X: { choice: 'mine', theirs: platform, yours: hashValue(THEIRS) } }); - expect(folder.env().X).toBe(THEIRS); - }); - - test('in a terminal the source is asked first and the platform is compared with its answer: the target question is about Capy\'s latest, not the folder\'s value', async () => { - const { folder } = world({ X: MINE, Y: OLD.Y }); - const platform = hashValue('x-on-the-platform'); - const adapter = adapterWith((env) => [{ kind: 'target', name: 'X', target: 'dokploy-production', pinned: null, yours: hashValue(env.X), theirs: platform }]); - ask.mockImplementationOnce(async () => ({ X: 'theirs' })).mockImplementationOnce(async () => ({ 'X@dokploy-production': 'theirs' })); - const done = await reconcileDeployConflicts(input(folder, adapter, { interactive: true })); - expect(ask).toHaveBeenCalledTimes(2); - expect((ask.mock.calls[0][0] as readonly Conflict[]).map((c) => c.kind)).toEqual(['source']); - expect(ask.mock.calls[1][0]).toEqual([expect.objectContaining({ kind: 'target', name: 'X', yours: hashValue(THEIRS), theirs: platform })]); - expect(done).toMatchObject({ ok: true, skipped: ['X'] }); - expect(done.ok && done.targetCheck?.resolutions).toEqual({ X: { choice: 'theirs', theirs: platform, yours: hashValue(THEIRS) } }); - }); - - test('answering only the source conflict leaves the target one open: the remaining names come back and nothing is written', async () => { - const { fake, folder } = world({ X: MINE, Y: OLD.Y }); - const adapter = adapterWith([live]); - const first = await refusal(() => reconcileDeployConflicts(input(folder, adapter))); - const partial = await refusal(() => - reconcileDeployConflicts(input(folder, adapter, { flags: parseConflictFlags({ resolve: ['X=mine'], conflict: first.payload.conflict_id }) })), - ); - expect(partial.payload.code).toBe('SECRETS_CONFLICT'); - expect(partial.payload.conflict_id).toBe(first.payload.conflict_id); - expect(partial.payload.conflicts.map((c) => `${c.kind}:${c.name}`)).toEqual(['target:Y']); - expect(fake.pushSecrets.mock.calls).toHaveLength(0); - }); -}); 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/deployTargetConflict.test.ts b/tests/commands/deployTargetConflict.test.ts index 77304e60..08247bbb 100644 --- a/tests/commands/deployTargetConflict.test.ts +++ b/tests/commands/deployTargetConflict.test.ts @@ -1,15 +1,20 @@ /** * A REAL `deployCommand()` run against a Dokploy CI-mode target whose live env holds a value changed outside Capy - * (CAP-717). Same vehicle as deployDokploySystemStoreToken.test.ts: scripted Dokploy over `fetch`, mocked auth and - * Capy server (in agreement with the folder, so the only conflict here is the TARGET one). + * (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=mine --conflict ` delivers and - * comments the outside line out; `theirs` writes nothing and records no delivery; a moved service is STALE. + * 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 { @@ -151,7 +156,7 @@ describe('capy deploy: a Dokploy value changed outside Capy', () => { expect(payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT' }); expect(payload.conflict_id).toMatch(/^c_[0-9a-f]{12}$/); expect(payload.conflicts).toEqual([ - { kind: 'target', name: 'STRIPE_KEY', target: 'dokploy-ci', pinned: null, yours: hashValue(DELIVERED), theirs: hashValue(OUTSIDE) }, + { 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); @@ -160,23 +165,23 @@ describe('capy deploy: a Dokploy value changed outside Capy', () => { rmSync(ROOT, { recursive: true, force: true }); }, 30_000); - test('mine: delivered, the outside line commented out as `# capy:off`', async () => { + 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=mine'], conflict: first.conflict_id } }); + 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('theirs: nothing is written to the service and no delivery is recorded', async () => { + 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=theirs'], conflict: first.conflict_id } }); + 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(); @@ -188,7 +193,7 @@ describe('capy deploy: a Dokploy value changed outside Capy', () => { 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=mine'], conflict: first.conflict_id } }); + 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'); diff --git a/tests/commands/dryRunE2E.test.ts b/tests/commands/dryRunE2E.test.ts index 7233cb54..1dc1898a 100644 --- a/tests/commands/dryRunE2E.test.ts +++ b/tests/commands/dryRunE2E.test.ts @@ -310,15 +310,16 @@ describe('capy remove NAME --dry-run', () => { }); }); - test('a local-only variable no longer blocks a removal (CAP-712): only the named variable is ever removed, and nothing is changed', 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): a removal does not carry it along any more. + // 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({ removed: [NAME], dry_run: true }); + 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/editConflicts.test.ts b/tests/commands/editConflicts.test.ts deleted file mode 100644 index 53a04a7f..00000000 --- a/tests/commands/editConflicts.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** - * Conflicts inside `capy edit` (CAP-714), asserted as properties on the real screen, the real loader and the real - * commit path, against the in-memory server of tests/helpers/fakeCapyServer.ts: - * - the "remote" column is the server's LATEST, not the folder's pinned snapshot: a stale folder shows what the - * server changed as `remote`, and what both changed as `conflict`; - * - nothing is committed while a conflict is undecided (the server is byte-for-byte what it was); - * - mine writes the folder's value, theirs keeps the server's and `.env` takes it, remote rows are pulled; - * - the server moving between showing and committing reopens the conflict and overwrites nothing. - * - * World: the folder is pinned to an OLD snapshot. Since then the server changed X and Y (someone else saved). - * In the folder: X was edited (so X is a conflict), Y is as pinned (so Y is `remote`), Z was edited and the server - * did not touch it (`local`), W is the same everywhere (`in sync`). - */ -import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'; -import { rmSync } from 'fs'; -import { Encryptor } from '../../src/crypto/encryptor'; -import { getGlobalCapyDir } from '../../src/config/globalConfig'; -import { boundResolutions, buildEditRows, editLoader, saveEdits } from '../../src/commands/editCommand'; -import { findConflicts, readSnapshot, snapshotFrom } from '../../src/sync/commit'; -import { hashValue } from '../../src/sync/compareSecrets'; -import { EditScreen, focusedOn, reclassifyRow, type EditRow, type EditState } from '../../src/ui/editScreen'; -import type { KeepFile } from '../../src/types/index'; -import { fakeCapyServer, pushOf, seedServer, serverValues, type FakeServerOpts } from '../helpers/fakeCapyServer'; -import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; - -const KEY = Encryptor.generateKey(); -const DEV = 'development'; - -const OLD = { W: 'w-same', X: 'x-old', Y: 'y-old', Z: 'z-old' } as const; -const SERVER = { W: 'w-same', X: 'x-A', Y: 'y-A', Z: 'z-old' } as const; -const FOLDER = { W: 'w-same', X: 'x-B', Y: 'y-old', Z: 'z-B' } as const; - -const OLD_SEED = seedServer({ projectKey: KEY, values: { [DEV]: OLD } }); -const NEW_SEED = seedServer({ projectKey: KEY, values: { [DEV]: SERVER } }); - -const globalDirName = `.capy-test-editconf-${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 noLog = { record: mock((_: unknown) => undefined) }; - -function world(opts: Pick = {}) { - const server = fakeCapyServer({ initial: NEW_SEED, racing: opts.racing }); - const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: FOLDER, client: server.client }); - made(folder); - const load = editLoader({ - pm: folder.pm, - fileManager: folder.fileManager, - serviceClient: server.client as never, - localMode: false, - keep: folder.ctx.keep, - branch: DEV, - orgId: folder.ctx.orgId, - projectId: folder.ctx.projectId, - projectKey: KEY, - configuredTargets: [], - }); - const parts = { ...folder.ctx }; - const ctx = { - saveLocalEdits: (set: Record, bound?: Parameters[4]) => saveEdits(parts, folder.ctx.keep, set, noLog, bound), - reload: load, - }; - return { server, folder, load, ctx, before: server.state() }; -} - -const strip = (s: string): string => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ''); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const DOWN = '\x1b[B'; - -/** Runs the real screen on `state`; `press` sends a key chunk, `frame` is the last frame drawn (colour removed). */ -function screenOn(state: EditState, ctx: ReturnType['ctx']) { - const out = spyOn(process.stdout, 'write').mockImplementation((() => true) as never); - const done = new EditScreen().run(state, ctx); - const frame = (): string => strip(String(out.mock.calls[out.mock.calls.length - 1]?.[0] ?? '')); - return { - press: (key: string) => { - process.stdin.emit('data', Buffer.from(key)); - }, - frame, - /** Waits until the last frame satisfies `cond`. */ - until: async (cond: (frame: string) => boolean) => { - for (const _ of Array.from({ length: 400 })) { - if (cond(frame())) return frame(); - await sleep(5); - } - throw new Error(`the screen never showed what was expected:\n${frame()}`); - }, - finish: async () => { - process.stdin.emit('data', Buffer.from('\x03')); - await done; - out.mockRestore(); - }, - }; -} - -/** The frame line of a variable's row. */ -const rowLine = (frame: string, key: string): string => frame.split('\n').find((l) => l.includes(` ${key} `) && l.includes('●')) ?? ''; - -async function open(w: ReturnType) { - const loaded = await w.load(); - const state: EditState = { projectName: 'demo', branch: DEV, rows: loaded.rows, remoteAvailable: loaded.remoteAvailable, seen: loaded.seen }; - return { loaded, screen: screenOn(state, w.ctx) }; -} - -/** Rows are W, X, Y, Z: the cursor starts on W. */ -const goTo = (screen: ReturnType, key: 'W' | 'X' | 'Y' | 'Z') => Array.from({ length: 'WXYZ'.indexOf(key) }).forEach(() => screen.press(DOWN)); - -/** Edits Z to `value` and leaves the cursor on Z. */ -function editZ(screen: ReturnType, value: string): void { - goTo(screen, 'Z'); - screen.press('e'); - screen.press(value); - screen.press('\r'); -} - -describe('the rows compare against the server\'s latest, three ways', () => { - test('a stale folder: the server\'s change is `remote`, both-changed is `conflict`, yours alone is `local`, the rest is `in sync`', async () => { - const w = world(); - const { rows } = await w.load(); - expect(Object.fromEntries(rows.map((r) => [r.key, r.status]))).toEqual({ W: 'in sync', X: 'conflict', Y: 'remote', Z: 'local' }); - }); - - test('the old behaviour is gone: Y is NOT "in sync" just because the folder\'s pinned snapshot agrees with its .env', async () => { - const w = world(); - const { rows } = await w.load(); - // Against the pinned snapshot Y reads "y-old" on both sides. - expect(w.folder.env().Y).toBe(OLD.Y); - expect(rows.find((r) => r.key === 'Y')?.status).not.toBe('in sync'); - }); - - test('the screen says so: the status column and the summary cells count drift and conflicts truthfully', async () => { - const w = world(); - const { screen } = await open(w); - const frame = screen.frame(); - expect(rowLine(frame, 'X')).toContain('conflict'); - expect(rowLine(frame, 'Y')).toContain('remote'); - expect(rowLine(frame, 'Z')).toContain('local'); - expect(rowLine(frame, 'W')).toContain('in sync'); - expect(frame).toContain('2 drift'); // Y and Z - expect(frame).toContain('1 conflicts'); - expect(frame).toContain('need resolution'); - await screen.finish(); - }); - - test('one read of the server for the first screen (the one the commit is planned against)', async () => { - const w = world(); - await open(w).then(({ screen }) => screen.finish()); - expect(w.server.getDecryptData.mock.calls).toHaveLength(1); - }); - - test('the shown read is kept: the screen state carries it for the commit', async () => { - const w = world(); - const { loaded } = await open(w).then(async (o) => (await o.screen.finish(), o)); - expect(loaded.seen).toBeDefined(); - expect(loaded.seen?.entries.X.plaintext).toBe(SERVER.X); - }); -}); - -describe('a row\'s status is the commit path\'s own test (property over every pin / yours / theirs combination)', () => { - const values = [undefined, 'a', 'b', 'c'] as const; - const row = (pinned: string | undefined, local: string | undefined, remote: string | undefined): EditRow => ({ - key: 'N', - localValue: local, - remoteValue: remote, - status: 'unknown', - updatedLabel: '—', - pinnedHash: pinned === undefined ? null : hashValue(pinned), - }); - - test('`conflict` exactly when findConflicts reports the name (a value you hold, over a server value that moved and differs)', () => { - const combos = values.flatMap((pinned) => values.flatMap((local) => values.map((remote) => ({ pinned, local, remote })))); - expect(combos).toHaveLength(64); - combos.forEach(({ pinned, local, remote }) => { - const status = reclassifyRow(row(pinned, local, remote), { remoteAvailable: true }); - const latest = remote === undefined ? {} : { N: { line: 'l', plaintext: remote, hash: hashValue(remote) } }; - const contested = - local !== undefined && - findConflicts({ - pinned: pinned === undefined ? {} : { N: hashValue(pinned) }, - latest, - changes: { set: { N: local }, remove: [] }, - }).length > 0; - // A value you changed (not just still hold from the pin) is what a conflict is about. - const youChanged = local !== undefined && local !== pinned; - expect(status === 'conflict', JSON.stringify({ pinned, local, remote, status })).toBe(youChanged && contested); - }); - }); - - test('the other statuses: in sync, local, remote', () => { - const status = (pinned: string | undefined, local: string | undefined, remote: string | undefined) => - reclassifyRow(row(pinned, local, remote), { remoteAvailable: true }); - expect(status('a', 'a', 'a')).toBe('in sync'); - expect(status('a', 'b', 'a')).toBe('local'); - expect(status('a', 'a', 'b')).toBe('remote'); - // Both changed to the same value: nothing to do. - expect(status('a', 'b', 'b')).toBe('in sync'); - // Both changed to different values. - expect(status('a', 'b', 'c')).toBe('conflict'); - // The server dropped it and you edited it. - expect(status('a', 'b', undefined)).toBe('conflict'); - // A brand-new variable on both sides, different. - expect(status(undefined, 'b', 'c')).toBe('conflict'); - }); -}); - -describe('boundResolutions: a decision applies only to the values it was made against', () => { - const seen = snapshotFrom( - { env_content: `X=capy:rid:${Encryptor.encrypt('x-A', KEY)}`, keep_file: JSON.stringify(NEW_SEED.keep) }, - DEV, - KEY, - ); - const decision = (choice: 'mine' | 'theirs', theirs: string | null, yours: string | null) => ({ X: { choice, theirs, yours } }); - - test('kept while the server\'s value and your value are the ones decided against', () => { - expect(boundResolutions(decision('mine', hashValue('x-A'), hashValue('x-B')), seen, { X: 'x-B' })).toEqual({ X: 'mine' }); - }); - test('dropped when the server\'s value is not the one decided against', () => { - expect(boundResolutions(decision('mine', hashValue('x-older'), hashValue('x-B')), seen, { X: 'x-B' })).toEqual({}); - }); - test('dropped when your value is not the one decided with', () => { - expect(boundResolutions(decision('mine', hashValue('x-A'), hashValue('x-B')), seen, { X: 'x-other' })).toEqual({}); - }); - test('dropped when there is no read to bind to', () => { - expect(boundResolutions(decision('theirs', hashValue('x-A'), hashValue('x-B')), undefined, { X: 'x-B' })).toEqual({}); - }); -}); - -describe('committing from the screen', () => { - test('`c` with an unresolved conflict uploads NOTHING: the server is exactly as it was, the status names the variable', async () => { - const w = world(); - const { screen } = await open(w); - editZ(screen, 'z-new'); - screen.press('c'); - await screen.until((f) => f.includes('Nothing was uploaded')); - expect(screen.frame()).toContain('X'); - expect(w.server.pushSecrets.mock.calls).toHaveLength(0); - expect(w.server.state()).toEqual(w.before); - // The folder is untouched too. - expect(w.folder.env()).toEqual(FOLDER); - await screen.finish(); - }); - - test('quit-with-commit follows the same rule: blocked, nothing uploaded', async () => { - const w = world(); - const { screen } = await open(w); - editZ(screen, 'z-new'); - screen.press('q'); - expect(screen.frame()).toContain('uncommitted change'); - screen.press('c'); - await screen.until((f) => f.includes('Nothing was uploaded')); - expect(w.server.pushSecrets.mock.calls).toHaveLength(0); - expect(w.server.state()).toEqual(w.before); - // Still on the screen: Ctrl-C leaves it. - await screen.finish(); - }); - - test('mine: the server decrypts to the folder\'s value, the edit goes with it, and the remote row is pulled', async () => { - const w = world(); - const { screen } = await open(w); - editZ(screen, 'z-new'); - // From Z (where the edit left the cursor) back up to X. - Array.from({ length: 2 }).forEach(() => screen.press('\x1b[A')); - screen.press('m'); - expect(rowLine(screen.frame(), 'X')).toContain('conflict: mine'); - screen.press('c'); - await screen.until((f) => f.includes('Committed')); - const values = serverValues(w.server.state(), DEV, KEY); - expect(values.X).toBe(FOLDER.X); - expect(values.Z).toBe('z-new'); - // Y was only changed by the other side: it stays theirs on the server, and the folder pulled it. - expect(values.Y).toBe(SERVER.Y); - expect(w.folder.env()).toEqual({ W: 'w-same', X: FOLDER.X, Y: SERVER.Y, Z: 'z-new' }); - // The screen re-read: nothing left to decide. - expect(rowLine(screen.frame(), 'X')).toContain('in sync'); - expect(rowLine(screen.frame(), 'Y')).toContain('in sync'); - await screen.finish(); - }); - - test('theirs: nothing is uploaded for it, the server keeps theirs, and the folder\'s .env takes it', async () => { - const w = world(); - const { screen } = await open(w); - goTo(screen, 'X'); - screen.press('t'); - expect(rowLine(screen.frame(), 'X')).toContain('conflict: theirs'); - screen.press('c'); - await screen.until((f) => f.includes('Committed')); - expect(w.server.pushSecrets.mock.calls).toHaveLength(0); - expect(w.server.state()).toEqual(w.before); - expect(w.folder.env().X).toBe(SERVER.X); - expect(w.folder.env().Y).toBe(SERVER.Y); - await screen.finish(); - }); - - test('the footer offers m / t on a conflict row, and not elsewhere', async () => { - const w = world(); - const { screen } = await open(w); - expect(screen.frame()).not.toContain('mine'); - goTo(screen, 'X'); - expect(screen.frame()).toContain('mine'); - expect(screen.frame()).toContain('theirs'); - screen.press('\x1b[B'); - expect(screen.frame()).not.toContain(' m mine'); - await screen.finish(); - }); - - test('`capy edit NAME` opens this screen on NAME: a conflicting NAME starts with m / t on offer', async () => { - const w = world(); - const loaded = await w.load(); - const state = focusedOn({ projectName: 'demo', branch: DEV, rows: loaded.rows, remoteAvailable: loaded.remoteAvailable, seen: loaded.seen }, 'X'); - const screen = screenOn(state, w.ctx); - expect(screen.frame()).toContain('mine'); - expect(screen.frame()).toMatch(/▶ X /); - await screen.finish(); - }); - - test('m and t do nothing on a row that is not a conflict', async () => { - const w = world(); - const { screen } = await open(w); - goTo(screen, 'Y'); - screen.press('m'); - expect(rowLine(screen.frame(), 'Y')).toContain('remote'); - await screen.finish(); - }); - - test('the server moves between showing and committing: the conflict is shown again, the old decision is NOT applied, nothing is overwritten', async () => { - // Someone else saves X again right after the screen\'s first read. - const w = world({ racing: (n) => (n === 1 ? [pushOf({ projectKey: KEY, branch: DEV, values: { ...SERVER, X: 'x-C' } })] : []) }); - const { screen } = await open(w); - editZ(screen, 'z-typed'); - Array.from({ length: 2 }).forEach(() => screen.press('\x1b[A')); - screen.press('m'); - screen.press('c'); - await screen.until((f) => f.includes('X changed on the server')); - // What was typed is still there, uncommitted, across the re-read. - expect(rowLine(screen.frame(), 'Z')).toContain('local'); - expect(screen.frame()).toContain('z-t...ped'); - // Nothing of ours is on the server: X is the newest value someone else saved. - expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-C'); - expect(rowLine(screen.frame(), 'X')).toContain('conflict'); - expect(rowLine(screen.frame(), 'X')).not.toContain('mine'); - expect(w.folder.env().X).toBe(FOLDER.X); - // Deciding again, against what is shown now, goes through. - screen.press('m'); - screen.press('c'); - await screen.until((f) => f.includes('Committed')); - expect(serverValues(w.server.state(), DEV, KEY).X).toBe(FOLDER.X); - expect(serverValues(w.server.state(), DEV, KEY).Z).toBe('z-typed'); - await screen.finish(); - }); - - test('theirs when the server moved again: only the server\'s value is taken, nothing is uploaded, and the screen shows the newer change as `remote`', async () => { - const w = world({ racing: (n) => (n === 1 ? [pushOf({ projectKey: KEY, branch: DEV, values: { ...SERVER, X: 'x-C' } })] : []) }); - const { screen } = await open(w); - goTo(screen, 'X'); - screen.press('t'); - screen.press('c'); - await screen.until((f) => f.includes('Committed')); - // The server is as the other writer left it; we wrote nothing. - expect(serverValues(w.server.state(), DEV, KEY).X).toBe('x-C'); - expect(w.server.accepted()).toHaveLength(1); - // The folder took what was decided on (x-A), so the newer x-C is still to be pulled: the row says so. - expect(rowLine(screen.frame(), 'X')).toContain('remote'); - await screen.finish(); - }); - - test('editing a `remote` row makes it a conflict with the server\'s value', async () => { - const w = world(); - const { screen } = await open(w); - goTo(screen, 'Y'); - screen.press('e'); - screen.press('y-typed'); - screen.press('\r'); - expect(rowLine(screen.frame(), 'Y')).toContain('conflict'); - await screen.finish(); - }); -}); - -describe('editLoader / buildEditRows', () => { - test('a read the server cannot answer leaves the rows `unknown` (nothing to compare against)', async () => { - const w = world(); - const broken = { getDecryptData: async () => { throw new Error('offline'); }, pushSecrets: async () => ({ keep_hash: '' }) }; - const load = editLoader({ - pm: w.folder.pm, - fileManager: w.folder.fileManager, - serviceClient: broken as never, - localMode: false, - keep: w.folder.ctx.keep, - branch: DEV, - orgId: w.folder.ctx.orgId, - projectId: w.folder.ctx.projectId, - projectKey: KEY, - configuredTargets: [], - }); - const loaded = await load(); - expect(loaded.remoteAvailable).toBe(false); - expect(loaded.rows.every((r) => r.status === 'unknown')).toBe(true); - }); - - test('UPDATED comes from the server\'s keep (when the value last changed there)', async () => { - const w = world(); - const snapshot = await readSnapshot(w.server.client, w.folder.ctx.projectId, DEV, KEY); - const rows = buildEditRows({ - localMode: false, - branch: DEV, - keep: w.folder.ctx.keep, - localPlaintext: w.folder.env(), - baseline: { entries: {}, keep: { ...(snapshot.keep as KeepFile), variables: { X: [{ resource_id: 'r', branch: DEV, value_hash: 'h', changed_at: new Date().toISOString() }] } }, available: true, seen: snapshot }, - configuredTargets: [], - }); - expect(rows.find((r) => r.key === 'X')?.updatedLabel).not.toBe('—'); - }); -}); - -describe('local-only mode is unchanged: committed-vs-working, no server', () => { - test('a save is read back as committed, with local-mode wording', async () => { - const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: OLD, client: undefined }); - made(folder); - const load = editLoader({ - pm: folder.pm, - fileManager: folder.fileManager, - serviceClient: undefined, - localMode: true, - keep: folder.ctx.keep, - branch: DEV, - orgId: folder.ctx.orgId, - projectId: folder.ctx.projectId, - projectKey: KEY, - configuredTargets: [], - }); - const before = await load(); - expect(before.remoteAvailable).toBe(false); - expect(before.rows.every((r) => r.status === 'local' && r.updatedLabel === 'uncommitted')).toBe(true); - await saveEdits({ ...folder.ctx }, folder.ctx.keep, { Z: 'z-local' }, noLog); - const after = await load(); - expect(after.rows.find((r) => r.key === 'Z')).toMatchObject({ localValue: 'z-local', remoteValue: 'z-local', status: 'in sync', updatedLabel: 'committed' }); - // No server read, no three-way pin in local mode. - expect(after.seen).toBeUndefined(); - expect(after.rows.every((r) => r.pinnedHash === undefined)).toBe(true); - }); -}); 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..d24fae6e --- /dev/null +++ b/tests/commands/gateFirst.test.ts @@ -0,0 +1,256 @@ +/** + * 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. +mock.module('../../src/auth/authService', () => ({ + AuthService: class { + constructor() { + event('auth'); + throw new Halt('auth'); + } + }, + 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 } from '../../src/commands/deployCommand'; + +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], +]; + +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 621e3a0d..1bef860f 100644 --- a/tests/commands/removeCommand.test.ts +++ b/tests/commands/removeCommand.test.ts @@ -26,6 +26,12 @@ 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 { deployTargetWarnings, confirmationMessage, diff --git a/tests/commands/rotateConflicts.test.ts b/tests/commands/rotateConflicts.test.ts deleted file mode 100644 index 137eda5d..00000000 --- a/tests/commands/rotateConflicts.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * `capy rotate` and conflicts (CAP-717). A rotation replaces a value with a key the provider issues ONCE, so a - * conflict on the variable has to be settled BEFORE the new key is issued: after it, a refused write would lose - * the only copy. `settleConflicts` is that step (the first thing `rotateMany` does once it has a deciding context). - * - * The properties: with the protocol on and the server's value moved since the folder's pin, nothing is rotated and the - * payload comes back (exit 3); `theirs` drops the variable from the run (nothing is issued for a value that would not - * be written); `mine` keeps it and hands the answer to the write; a variable nobody changed passes untouched. - * At a terminal (no deciding context) the same step ASKS (mine / theirs per variable) before anything is issued; - * cancelling the question ends it with nothing rotated or written. - */ -import { describe, test, expect, afterEach, mock, spyOn } from 'bun:test'; - -// The terminal's question. `mock.module()` is process-wide: this file runs isolated (tests/run-tests.sh). -const ask = mock(async (_conflicts: readonly unknown[]) => ({}) as Record); -mock.module('../../src/commands/conflictPrompt', () => ({ askConflicts: ask })); - -import { RotateCommand } from '../../src/commands/rotateCommand'; -import { parseConflictFlags, protocolDecider } from '../../src/sync/conflicts'; -import { hashValue } from '../../src/sync/compareSecrets'; -import { Encryptor } from '../../src/crypto/encryptor'; -import type { ConnectorMetadata, KeepFile } from '../../src/types/index'; -import { fakeCapyServer, seedServer } from '../helpers/fakeCapyServer'; -import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; - -const KEY = Encryptor.generateKey(); -const BRANCH = 'development'; -const OLD = { STRIPE: 'sk_old', OTHER: 'o_old' } as const; -const THEIRS = 'sk_rotated_by_a_teammate'; - -const OLD_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: OLD } }); -const NEW_SEED = seedServer({ projectKey: KEY, values: { [BRANCH]: { STRIPE: THEIRS, OTHER: OLD.OTHER } } }); - -const made = mock((_: Folder) => undefined); -afterEach(() => { - made.mock.calls.forEach(([folder]) => removeFolder(folder)); - ask.mockReset(); - ask.mockImplementation(async () => ({})); -}); - -const connector = (): ConnectorMetadata => ({ provider: 'stripe', source: 'cli', created_at: 1, fingerprint: 'fp' }) as unknown as ConnectorMetadata; -const targets = (...names: string[]) => names.map((varName) => ({ varName, connector: connector() })); - -type Settled = { rotate: Array<{ varName: string }>; resolutions: Record }; - -function world(flags: Parameters[0] | undefined) { - const server = fakeCapyServer({ initial: NEW_SEED }); - const folder = makeFolder({ projectKey: KEY, branch: BRANCH, keep: OLD_SEED.keep as KeepFile, env: OLD, client: server.client }); - made(folder); - const ctx = { ...folder.ctx, ...(flags === undefined ? {} : { decide: protocolDecider(parseConflictFlags(flags)) }) }; - const settle = (list: ReturnType, opts: { noPush?: boolean } = {}): Promise => - (new RotateCommand() as unknown as { settleConflicts: (c: unknown, t: unknown, o: unknown) => Promise }).settleConflicts(ctx, list, opts); - return { server, settle }; -} - -class ExitSignal extends Error { - constructor(public readonly code: number | undefined) { - super('exit'); - } -} - -/** Runs `fn` with `process.exit` and the console captured: what it printed, and the code it exited with (if it did). */ -async function captured(fn: () => Promise) { - const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new ExitSignal(code); - }) as never); - const logSpy = spyOn(console, 'log').mockImplementation(() => {}); - const errSpy = spyOn(console, 'error').mockImplementation(() => {}); - const outcome = await fn().then( - (value) => ({ value, thrown: undefined as unknown }), - (thrown: unknown) => ({ value: undefined, thrown }), - ); - const stdout = logSpy.mock.calls.map((a) => a.map(String).join(' ')).join('\n'); - exitSpy.mockRestore(); - logSpy.mockRestore(); - errSpy.mockRestore(); - if (outcome.thrown !== undefined && !(outcome.thrown instanceof ExitSignal)) throw outcome.thrown; - return { value: outcome.value, exitCode: outcome.thrown instanceof ExitSignal ? outcome.thrown.code : undefined, stdout }; -} - -describe('capy rotate: conflicts are settled before the new key is issued', () => { - test('a variable the server changed since this folder\'s pin: nothing is rotated, exit 3 with the payload', async () => { - const { server, settle } = world({}); - const out = await captured(() => settle(targets('STRIPE'))); - expect(out.exitCode).toBe(3); - const payload = JSON.parse(out.stdout) as { code: string; conflicts: unknown[] }; - expect(payload.code).toBe('SECRETS_CONFLICT'); - expect(payload.conflicts).toEqual([ - expect.objectContaining({ kind: 'source', name: 'STRIPE', pinned: hashValue(OLD.STRIPE), yours: null, theirs: hashValue(THEIRS) }), - ]); - expect(out.stdout).not.toContain(THEIRS); - expect(server.pushSecrets.mock.calls).toHaveLength(0); - }); - - test('theirs drops the variable from the run; mine keeps it and carries the answer to the write', async () => { - const conflict = (JSON.parse((await captured(() => world({}).settle(targets('STRIPE')))).stdout) as { conflict_id: string }).conflict_id; - - const theirs = await captured(() => world({ resolve: ['STRIPE=theirs'], conflict }).settle(targets('STRIPE', 'OTHER'))); - expect(theirs.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); - - const mine = await captured(() => world({ resolve: ['STRIPE=mine'], conflict }).settle(targets('STRIPE', 'OTHER'))); - expect(mine.value?.rotate.map((t) => t.varName)).toEqual(['STRIPE', 'OTHER']); - expect(mine.value?.resolutions).toEqual({ STRIPE: 'mine' }); - }); - - test('a variable nobody else changed passes untouched', async () => { - const done = await captured(() => world({}).settle(targets('OTHER'))); - expect(done.exitCode).toBeUndefined(); - expect(done.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); - expect(done.value?.resolutions).toEqual({}); - }); - - test('with --no-push the step does nothing', async () => { - expect((await captured(() => world({}).settle(targets('STRIPE'), { noPush: true }))).value?.rotate).toHaveLength(1); - expect((await captured(() => world(undefined).settle(targets('STRIPE'), { noPush: true }))).value?.rotate).toHaveLength(1); - expect(ask).not.toHaveBeenCalled(); - }); -}); - -describe('capy rotate at a terminal: the conflict is asked BEFORE the new key is issued', () => { - test('a variable the server changed: the question comes first (names and fingerprints only), and nothing is written before the answer', async () => { - const { server, settle } = world(undefined); - ask.mockImplementationOnce(async () => ({ STRIPE: 'mine' })); - const done = await captured(() => settle(targets('STRIPE'))); - expect(ask).toHaveBeenCalledTimes(1); - expect(ask.mock.calls[0][0]).toEqual([expect.objectContaining({ kind: 'source', name: 'STRIPE', pinned: hashValue(OLD.STRIPE), theirs: hashValue(THEIRS) })]); - expect(done.exitCode).toBeUndefined(); - expect(server.pushSecrets.mock.calls).toHaveLength(0); - }); - - test('theirs drops the variable from the run (no key is issued for it); mine keeps it and carries the answer to the write', async () => { - ask.mockImplementationOnce(async () => ({ STRIPE: 'theirs' })); - const theirs = await captured(() => world(undefined).settle(targets('STRIPE', 'OTHER'))); - expect(theirs.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); - - ask.mockImplementationOnce(async () => ({ STRIPE: 'mine' })); - const mine = await captured(() => world(undefined).settle(targets('STRIPE', 'OTHER'))); - expect(mine.value?.rotate.map((t) => t.varName)).toEqual(['STRIPE', 'OTHER']); - expect(mine.value?.resolutions).toEqual({ STRIPE: 'mine' }); - }); - - test('cancelling the question ends the step with the prompt\'s own error: nothing is rotated and nothing is written', async () => { - const { server, settle } = world(undefined); - ask.mockImplementationOnce(async () => { - throw new Error('prompt cancelled'); - }); - await expect(settle(targets('STRIPE'))).rejects.toThrow('prompt cancelled'); - expect(server.pushSecrets.mock.calls).toHaveLength(0); - }); - - test('a variable nobody else changed is not asked about', async () => { - const done = await captured(() => world(undefined).settle(targets('OTHER'))); - expect(ask).not.toHaveBeenCalled(); - expect(done.value?.rotate.map((t) => t.varName)).toEqual(['OTHER']); - }); -}); 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 index 839eb601..9f26459b 100644 --- a/tests/commands/secretsDeployConflicts.test.ts +++ b/tests/commands/secretsDeployConflicts.test.ts @@ -7,7 +7,7 @@ * 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=mine|theirs --conflict ` delivers, or skips that variable and + * - 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. */ @@ -64,23 +64,23 @@ async function capture(fn: () => Promise): Promise { /** 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 = (theirs: string = LIVE) => ({ kind: 'target' as const, name: NAME, target: 'api', pinned: null, yours: hashOf(NAME), theirs }); +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 `theirs`). + * `liveAtWrite`: what the service holds when the delivery pass reads it (the comparison pass sees `remote`). */ -function liveAdapter(theirs: string = LIVE, liveAtWrite: string = theirs) { +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 ? theirs : liveAtWrite; + const now = ctx.dryRun ? remote : liveAtWrite; const seen = ctx.targetCheck.resolutions[NAME]; - const answer = seen !== undefined && seen.theirs === now && seen.yours === hashOf(NAME) ? seen.choice : undefined; + 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 === 'theirs' + return answer === 'remote' ? { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }], skipped: [NAME] } : { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; }, @@ -145,22 +145,22 @@ describe('capy secrets deploy: target conflicts', () => { Object.values(VALUES).forEach((v) => expect(`${out.stdout}${out.stderr}`).not.toContain(v)); }); - test('mine: every target is delivered, with the answer handed to the adapter by bare name', async () => { + 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=mine`], conflict: conflictId } }); + 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: 'mine', theirs: LIVE, yours: hashOf(NAME) } }); + 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('theirs: the variable is skipped, not recorded as delivered, and the target reports KEPT_ON_PLATFORM (no PR for it)', async () => { + 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=theirs`], conflict: conflictId } }); + 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']); @@ -173,7 +173,7 @@ describe('capy secrets deploy: target conflicts', () => { 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=mine`], conflict: conflictId } }); + 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); @@ -184,7 +184,7 @@ describe('capy secrets deploy: target conflicts', () => { 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=mine`], conflict: conflictId } }); + 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'); @@ -195,7 +195,7 @@ describe('capy secrets deploy: target conflicts', () => { 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=mine`], conflict: conflictId } }); + 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()]); diff --git a/tests/commands/secretsEditConflicts.test.ts b/tests/commands/secretsEditConflicts.test.ts index 8417cf89..93eecdd1 100644 --- a/tests/commands/secretsEditConflicts.test.ts +++ b/tests/commands/secretsEditConflicts.test.ts @@ -1,11 +1,14 @@ /** - * `capy secrets` edit and conflicts (CAP-714), against the real edit actions and engine (`createEditActionsWith` -> - * `runSecretSet`) over the fake service of tests/helpers/secretsWorld.ts. The properties: - * - a location someone else changed since the row was listed is reported, BEFORE any push: nothing is written - * until every such location is decided; - * - mine writes that location, bound to the value that was seen; theirs skips it and writes the rest; - * - a decision made against a value that has since moved is not applied: the conflict comes back, nothing is written; + * `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'; @@ -42,7 +45,7 @@ function rig(movedValue: string | undefined = MOVED) { return { service, actions, env }; } -const decision = (choice: 'mine' | 'theirs', theirs: string, yours: string = hashValue(SENTINEL)) => ({ choice, theirs, yours }); +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', () => { @@ -54,14 +57,14 @@ describe('the run reads every location first', () => { 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).toMatchObject({ - kind: 'source', + expect(out.conflicts?.[0].conflict).toEqual({ name: NAME, project: PROJECT_NAMES.pA, branch: 'staging', + type: 'changed', pinned: hashValue(OLD_VALUE), - yours: hashValue(SENTINEL), - theirs: hashValue(MOVED), + local: hashValue(SENTINEL), + remote: hashValue(MOVED), }); expect(r.service.pushSecrets.mock.calls).toHaveLength(0); // Fingerprints and names: no value anywhere. @@ -83,12 +86,12 @@ describe('the run reads every location first', () => { }); }); -describe('mine and theirs', () => { +describe('local and remote', () => { const key = 'pA/staging'; - test('mine: the moved location is written too, with the new value', async () => { + 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('mine', hashValue(MOVED)) } })); + 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()); @@ -100,9 +103,9 @@ describe('mine and theirs', () => { expect(keep.variables[NAME].find((e) => e.branch === 'staging')?.value_hash).toBe(hashValue(SENTINEL)); }); - test('theirs: the moved location is skipped (never pushed), the other is written', async () => { + 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('theirs', hashValue(MOVED)) } })); + 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']); @@ -110,18 +113,18 @@ describe('mine and theirs', () => { expect(out.result.failed).toEqual([]); }); - test('a decision made against another value of the location is NOT applied: the conflict comes back and nothing is pushed', async () => { + 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('mine', hashValue('the-value-it-had-when-decided')) } })); + 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.theirs).toBe(hashValue(MOVED)); + expect(out.conflicts?.[0].conflict.remote).toBe(hashValue(MOVED)); expect(r.service.pushSecrets.mock.calls).toHaveLength(0); }); - test('a decision made for another new value is NOT applied either', async () => { + 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('mine', hashValue(MOVED), hashValue('a-different-new-value')) } })); + 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); }); @@ -150,6 +153,15 @@ describe('mine and theirs', () => { }); }); +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' } }]; @@ -158,9 +170,23 @@ describe('settleLocations (pure)', () => { expect(settled).toMatchObject({ kind: 'ready', locations: [A_STAGING, C_DEV] }); }); - test('the allowed fingerprints of a location are: the new value, the one listed, and the one that was decided against', () => { - const settled = settleLocations(request({ decisions: { 'pA/staging': decision('mine', hashValue(MOVED)) } }), reads); - if (settled.kind !== 'ready' || settled.allowed === undefined) throw new Error('expected ready'); - expect(settled.allowed(A_STAGING)).toEqual([hashValue(SENTINEL), hashValue(OLD_VALUE), hashValue(MOVED)]); + 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/deploy/dokployTargetConflict.test.ts b/tests/deploy/dokployTargetConflict.test.ts index 8e0a46c7..c5f5eb3b 100644 --- a/tests/deploy/dokployTargetConflict.test.ts +++ b/tests/deploy/dokployTargetConflict.test.ts @@ -1,9 +1,9 @@ /** - * Target conflicts (CAP-717): before a Dokploy delivery writes the service's env it compares each delivered - * name's LIVE value with what Capy last delivered (keep.lock's `deployed_value_hash`) and with what it is about - * to deliver. A value changed on the platform outside Capy is a conflict that needs an answer: - * mine → Capy's value is delivered (the outside line is commented out as `# capy:off`, as always); - * theirs → the variable is skipped: not written, its outside line not commented, no delivery recorded for it. + * 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 @@ -29,12 +29,12 @@ describe('dokployTargetConflicts (pure)', () => { 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'), yours: h('new-capy'), theirs: h('edited-outside') }]); + 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, yours: h('new-capy'), theirs: h('already-there') }, + { name: 'STRIPE_KEY', pinned: undefined, local: h('new-capy'), remote: h('already-there') }, ]); }); @@ -45,9 +45,9 @@ describe('dokployTargetConflicts (pure)', () => { expect(dokployTargetConflicts(null, values, {})).toEqual([]); }); - test('theirs is what the service would READ: the whole env through dotenv, inside Capy\'s block or an active line outside it', () => { + 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]?.theirs).toBe(h('edited in the block')); + 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([]); @@ -59,19 +59,19 @@ describe('dokployTargetConflicts (pure)', () => { const check = { pinned: { STRIPE_KEY: h('old-delivered') }, resolutions: {} }; const open = planTargetDelivery(env, both, check); expect(open.kind).toBe('conflict'); - const seen = (choice: 'mine' | 'theirs') => ({ STRIPE_KEY: { choice, theirs: h('edited-outside'), yours: h('new-capy') } }); - const mine = planTargetDelivery(env, both, { ...check, resolutions: seen('mine') }); - expect(mine).toMatchObject({ kind: 'clear', skipped: [] }); - expect(mine.kind === 'clear' && mine.deliver.map((v) => v.name)).toEqual(['STRIPE_KEY', 'PORT']); - const theirs = planTargetDelivery(env, both, { ...check, resolutions: seen('theirs') }); - expect(theirs).toMatchObject({ kind: 'clear', skipped: ['STRIPE_KEY'] }); - expect(theirs.kind === 'clear' && theirs.deliver.map((v) => v.name)).toEqual(['PORT']); + 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('mine') }); + 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.theirs)).toEqual([h('moved-after-the-answer')]); - const otherYours = planTargetDelivery(env, [{ name: 'STRIPE_KEY', value: 'another-capy-value' }], { ...check, resolutions: seen('theirs') }); - expect(otherYours.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: [] }); }); @@ -177,9 +177,9 @@ const adapterWith = (fetch: FetchLike) => createDokployAdapter({ fetch, log: () 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 = {}) => ({ +const check = (resolutions: Record = {}, pinned: Record = {}) => ({ pinned, - resolutions: Object.fromEntries(Object.entries(resolutions).map(([name, choice]) => [name, { choice, theirs: h('already-there'), yours: h('new-capy') }])), + 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', () => { @@ -189,7 +189,7 @@ describe('the Dokploy adapter and target conflicts', () => { expect(result.ok).toBe(false); expect(result.steps.at(-1)?.code).toBe('SECRETS_CONFLICT'); expect(result.conflicts).toEqual([ - { kind: 'target', name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, yours: h('new-capy'), theirs: h('already-there') }, + { name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, local: h('new-capy'), remote: h('already-there') }, ]); expect(script.done()).toBe(true); }); @@ -199,63 +199,63 @@ describe('the Dokploy adapter and target conflicts', () => { 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'), yours: h('new-capy'), theirs: h('hand-edited') }); + expect(result.conflicts?.[0]).toMatchObject({ pinned: h('old-delivered'), local: h('new-capy'), remote: h('hand-edited') }); expect(script.done()).toBe(true); }); - test('mine delivers Capy\'s value and comments the outside line out as `# capy:off`, as it always did', async () => { + 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: 'mine' }) })); + 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 (mine and theirs alike)', async () => { + 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 answeredMine = scripted([read(MOVED)]); - const mine = await adapterWith(answeredMine.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'mine' }) })); - expect(mine.ok).toBe(false); - expect(mine.steps.at(-1)?.code).toBe('SECRETS_CONFLICT'); - expect(mine.conflicts).toEqual([ - { kind: 'target', name: 'STRIPE_KEY', target: 'dokploy-production', pinned: null, yours: h('new-capy'), theirs: h('moved-after-the-answer') }, + 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(answeredMine.done()).toBe(true); + expect(answeredLocal.done()).toBe(true); - const answeredTheirs = scripted([read(MOVED)]); - const theirs = await adapterWith(answeredTheirs.fetch).deploy(target(), ctx({ targetCheck: check({ STRIPE_KEY: 'theirs' }) })); - expect(theirs.ok).toBe(false); - expect(theirs.skipped).toBeUndefined(); - expect(answeredTheirs.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 changedYours = scripted([read(OUTSIDE)]); - const other = await adapterWith(changedYours.fetch).deploy(target(), ctx({ env: { STRIPE_KEY: 'newer-capy', PORT: '3000' }, targetCheck: check({ STRIPE_KEY: 'mine' }) })); + 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({ yours: h('newer-capy'), theirs: h('already-there') }); - expect(changedYours.done()).toBe(true); + expect(other.conflicts?.[0]).toMatchObject({ local: h('newer-capy'), remote: h('already-there') }); + expect(changedLocal.done()).toBe(true); }); - test('theirs 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 () => { + 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: 'theirs' }) })); + 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('theirs for one variable still delivers the others, and the kept one stays exactly as it was (outside line active, not commented)', async () => { + 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: 'theirs' }) })); + 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); @@ -318,7 +318,7 @@ describe('the Dokploy adapter, Compose services', () => { json: true, }); - test('an unanswered conflict writes nothing; mine writes; theirs keeps the service\'s value', async () => { + 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); @@ -327,13 +327,13 @@ describe('the Dokploy adapter, Compose services', () => { const merged = mergeManagedValuesBlock(OUTSIDE, [{ name: 'STRIPE_KEY', value: 'new-capy' }]); if (!merged.ok) throw new Error('fixture'); - const mine = scripted([readCompose(OUTSIDE), saveCompose(merged.env), readCompose(merged.env)]); - expect((await adapterWith(mine.fetch).deploy(composeTarget(), ctx({ targetCheck: check({ STRIPE_KEY: 'mine' }) }))).ok).toBe(true); - expect(mine.done()).toBe(true); + 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 theirs = scripted([readCompose(OUTSIDE)]); - const kept = await adapterWith(theirs.fetch).deploy(composeTarget(), ctx({ targetCheck: check({ STRIPE_KEY: 'theirs' }) })); + 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(theirs.done()).toBe(true); + expect(remote.done()).toBe(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 796eb0b8..b03635d3 100644 --- a/tests/helpers/pipedHarness.ts +++ b/tests/helpers/pipedHarness.ts @@ -225,6 +225,9 @@ async function startMockService(requestLog: string, failFlag: string, root: stri 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' }); @@ -314,3 +317,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/integration/cliIntegration.test.ts b/tests/integration/cliIntegration.test.ts index 27e981e7..a3adce67 100644 --- a/tests/integration/cliIntegration.test.ts +++ b/tests/integration/cliIntegration.test.ts @@ -134,6 +134,56 @@ DEBUG=true`; }); describe('Sync Engine Integration', () => { + test('should correctly compare local and remote environments', () => { + const localEnv = { + LOCAL_ONLY: 'local_value', + CONFLICT_VAR: 'local_conflict', + SAME_VAR: 'same_value' + }; + + const remoteEnv = { + REMOTE_ONLY: 'remote_value', + CONFLICT_VAR: 'remote_conflict', + SAME_VAR: 'same_value' + }; + + const changeSet = syncEngine.compareEnvironments(localEnv, remoteEnv); + + expect(changeSet.newLocal).toHaveLength(1); + expect(changeSet.newLocal[0].name).toBe('LOCAL_ONLY'); + + expect(changeSet.newRemote).toHaveLength(1); + expect(changeSet.newRemote[0].name).toBe('REMOTE_ONLY'); + + expect(changeSet.conflicts).toHaveLength(1); + expect(changeSet.conflicts[0].name).toBe('CONFLICT_VAR'); + + expect(changeSet.unchanged).toHaveLength(1); + expect(changeSet.unchanged[0].name).toBe('SAME_VAR'); + }); + + test('should apply user decisions correctly', () => { + const localEnv = { LOCAL_VAR: 'local' }; + const remoteEnv = { REMOTE_VAR: 'remote', CONFLICT: 'remote_val' }; + + const decisions = { + pushVariables: ['LOCAL_VAR'], + pullVariables: ['REMOTE_VAR'], + keepLocal: [], + keepRemote: ['CONFLICT'], + deleteLocal: [], + deleteRemote: [] + }; + + const result = syncEngine.applyDecisions(localEnv, remoteEnv, decisions); + + expect(result).toEqual({ + LOCAL_VAR: 'local', + REMOTE_VAR: 'remote', + CONFLICT: 'remote_val' + }); + }); + test('should merge keep file with pushed variables', () => { const originalKeep: KeepFile = { version: '3.0', @@ -159,6 +209,44 @@ DEBUG=true`; expect(updatedKeep.variables.EXISTING_VAR[0].resource_id).toBe('res_updated'); }); + test('should validate user decisions correctly', () => { + const changeSet = { + newLocal: [{ name: 'LOCAL_VAR', value: 'val', source: 'local' as const, encrypted: false }], + newRemote: [{ name: 'REMOTE_VAR', value: 'val', source: 'remote' as const, encrypted: true }], + conflicts: [{ name: 'CONFLICT_VAR', localValue: 'local', remoteValue: 'remote' }], + unchanged: [], + deleted: [], + deletedLocal: [] + }; + + // Valid decisions + const validDecisions = { + pushVariables: ['LOCAL_VAR'], + pullVariables: ['REMOTE_VAR'], + keepLocal: ['CONFLICT_VAR'], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const validErrors = syncEngine.validateDecisions(validDecisions, changeSet); + expect(validErrors).toHaveLength(0); + + // Invalid decisions + const invalidDecisions = { + pushVariables: ['INVALID_VAR'], + pullVariables: ['REMOTE_VAR'], + keepLocal: ['CONFLICT_VAR'], + keepRemote: ['CONFLICT_VAR'], // Duplicate conflict resolution + deleteLocal: [], + deleteRemote: [] + }; + + const invalidErrors = syncEngine.validateDecisions(invalidDecisions, changeSet); + expect(invalidErrors.length).toBeGreaterThan(0); + expect(invalidErrors).toContain('Cannot push INVALID_VAR: not a new local variable, conflict, or deleted variable'); + expect(invalidErrors).toContain('Duplicate conflict resolutions detected'); + }); }); describe('Error Handling Integration', () => { @@ -183,6 +271,32 @@ DEBUG=true`; expect(() => projectManager.readKeepFile()).toThrow(CapyError); }); + test('should generate proper error messages for sync failures', () => { + const changeSet = { + newLocal: [], + newRemote: [], + conflicts: [], + unchanged: [], + deleted: [], + deletedLocal: [] + }; + + const decisions = { + pushVariables: [], + pullVariables: [], + keepLocal: [], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = ['Network timeout', 'Permission denied']; + + const syncResult = syncEngine.generateSyncResult(changeSet, decisions, errors); + + expect(syncResult.success).toBe(false); + expect(syncResult.errors).toEqual(errors); + }); }); describe('Project Name Generation Integration', () => { diff --git a/tests/run-tests.sh b/tests/run-tests.sh index cb4266c3..cf6f39a7 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -43,8 +43,6 @@ ISOLATED_FILES=( tests/commands/deployDokploySystemStoreToken.test.ts tests/commands/deployCiChangeGate.test.ts tests/commands/deployCiTargetsRecord.test.ts - tests/commands/deployConflicts.test.ts - tests/commands/rotateConflicts.test.ts tests/commands/deployTargetConflict.test.ts tests/commands/deployRevokeWiring.test.ts tests/commands/deployDirectModeTiming.test.ts @@ -65,6 +63,12 @@ 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/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/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/commitOct2.test.ts b/tests/sync/commitOct2.test.ts deleted file mode 100644 index 5c5761b8..00000000 --- a/tests/sync/commitOct2.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -/** - * The Oct 2 incident (CAP-712), reproduced on an in-memory server and asserted as a - * PROPERTY: a folder pinned to an old snapshot saves something, and what someone else - * saved since is still what the server holds afterwards. Each save path gets its own test. - * - * World: the server holds A's newer state for `development` (X changed, a server-only - * variable added) and a `staging` branch. Folder B is pinned to the OLDER snapshot and its - * `.env` still holds the old values. - */ -import { describe, test, expect, beforeAll, afterAll, afterEach, mock, spyOn } from 'bun:test'; -import { rmSync } from 'fs'; -import { Encryptor } from '../../src/crypto/encryptor'; -import { getGlobalCapyDir } from '../../src/config/globalConfig'; -import { saveEdits } from '../../src/commands/editCommand'; -import { removeAndSync, writeAndSync, writeImportedAndSync } from '../../src/commands/connectors/shared'; -import { runSecretSet, type SetEnv, type SetRequest } from '../../src/commands/secretsSet'; -import { proceedWithRemoval } from '../../src/commands/removeCommand'; -import { hashValue, compareSecrets } from '../../src/sync/compareSecrets'; -import { commitFolder, commitLocalValues, commitToServer, localChanges, pinnedHashes, readSnapshot } from '../../src/sync/commit'; -import { SyncEngine } from '../../src/sync/syncEngine'; -import { CapyError, ERROR_CODES, type KeepFile } from '../../src/types/index'; -import { fakeCapyServer, pushOf, seedServer, serverHash, serverLine, serverValues, type FakeServerOpts } from '../helpers/fakeCapyServer'; -import { makeFolder, removeFolder, type Folder } from '../helpers/folderWorld'; - -const KEY = Encryptor.generateKey(); -const DEV = 'development'; -const STG = 'staging'; - -const OLD_DEV = { X: 'x-old', Y: 'y-old', KEEP: 'k-keep' } as const; -const NEW_X = 'x-NEW-from-A'; -const NEW_DEV = { X: NEW_X, Y: 'y-old', KEEP: 'k-keep', SERVER_ONLY: 'only-on-the-server' } as const; -const STAGING = { X: 'x-stg', Y: 'y-stg' } as const; - -const OLD_SEED = seedServer({ projectKey: KEY, values: { [DEV]: OLD_DEV, [STG]: STAGING } }); -const NEW_SEED = seedServer({ projectKey: KEY, values: { [DEV]: NEW_DEV, [STG]: STAGING } }); - -const stagingEntries = (keep: KeepFile | undefined) => - Object.fromEntries( - Object.entries(keep?.variables ?? {}) - .map(([name, entries]) => [name, entries.filter((e) => e.branch === STG)] as const) - .filter(([, entries]) => entries.length > 0), - ); - -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))); - -/** The server holds A's newer state; B is pinned to the older one. */ -function world(opts: { env?: Record } & Pick = {}) { - const server = fakeCapyServer({ initial: NEW_SEED, racing: opts.racing }); - const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: opts.env ?? OLD_DEV, client: server.client }); - made(folder); - return { server, folder, before: server.state() }; -} - -const valuesOf = (server: ReturnType, branch = DEV) => serverValues(server.state(), branch, KEY); - -/** Everything the incident put at risk, asserted after a save that did not touch it. */ -function expectOthersIntact(w: ReturnType, touched: readonly string[]): void { - const after = w.server.state(); - const values = serverValues(after, DEV, KEY); - // A's newer value of X and the server-only variable survive, and so does everything else B did not touch. - Object.entries(NEW_DEV) - .filter(([name]) => !touched.includes(name)) - .forEach(([name, value]) => expect(values[name]).toBe(value)); - // ... down to the very ciphertext line the server holds. - Object.keys(NEW_DEV) - .filter((name) => !touched.includes(name)) - .forEach((name) => expect(serverLine(after, DEV, name)).toBe(serverLine(w.before, DEV, name))); - // Another branch's keep entries and blob are exactly as they were. - expect(stagingEntries(after.keep)).toEqual(stagingEntries(w.before.keep)); - expect(serverValues(after, STG, KEY)).toEqual(STAGING); -} - -const folderParts = (f: Folder) => ({ ...f.ctx }); -const noLog = { record: mock((_: unknown) => undefined) } as never; - -describe('Oct 2: a folder pinned to an old snapshot saves — what others saved since survives', () => { - test('capy edit (TUI save) of a DIFFERENT variable', async () => { - const w = world(); - await saveEdits(folderParts(w.folder), w.folder.ctx.keep, { Y: 'y-B' }, noLog); - expect(valuesOf(w.server).Y).toBe('y-B'); - expectOthersIntact(w, ['Y']); - // The folder now holds the server's state: A's X, the server-only variable, and B's own save. - expect(w.folder.env()).toMatchObject({ X: NEW_X, Y: 'y-B', SERVER_ONLY: NEW_DEV.SERVER_ONLY }); - expect(w.folder.pin('X')).toBe(hashValue(NEW_X)); - }); - - test('capy add Z', async () => { - const w = world(); - await writeAndSync(w.folder.ctx as never, 'Z', 'z-value', { push: true }); - expect(valuesOf(w.server).Z).toBe('z-value'); - expectOthersIntact(w, []); - expect(w.folder.env().Z).toBe('z-value'); - }); - - test('capy remove Y', async () => { - const w = world(); - await removeAndSync(w.folder.ctx as never, ['Y']); - expect(valuesOf(w.server).Y).toBeUndefined(); - expectOthersIntact(w, ['Y']); - expect(serverHash(w.server.state(), DEV, 'Y')).toBeUndefined(); - // Y's entry on the other branch stays. - expect(serverHash(w.server.state(), STG, 'Y')).toBe(hashValue(STAGING.Y)); - expect(w.folder.env().Y).toBeUndefined(); - }); - - test('capy remove Y, human mode: the removal command itself', async () => { - const w = world(); - const log = spyOn(console, 'log').mockImplementation(() => {}); - try { - await proceedWithRemoval(w.folder.ctx as never, ['Y'], { json: false, cwd: w.folder.dir, pr: { noPr: true } }); - } finally { - log.mockRestore(); - } - expect(valuesOf(w.server).Y).toBeUndefined(); - expectOthersIntact(w, ['Y']); - }); - - test('capy edit X (piped): a conflict, and NOTHING is uploaded', async () => { - const w = world(); - const err = (await writeAndSync(w.folder.ctx as never, 'X', 'x-from-B', { push: true }).catch((e: unknown) => e)) as CapyError; - expect(err).toBeInstanceOf(CapyError); - expect(err.code).toBe(ERROR_CODES.SECRETS_CONFLICT); - expect(err.details).toEqual({ names: ['X'] }); - // Names, never values. - expect(`${err.message} ${JSON.stringify(err.details)}`).not.toContain('x-from-B'); - expect(`${err.message} ${JSON.stringify(err.details)}`).not.toContain(NEW_X); - expect(w.server.pushSecrets.mock.calls).toHaveLength(0); - expect(w.server.state()).toEqual(w.before); - // ... and the folder is untouched. - expect(w.folder.env().X).toBe(OLD_DEV.X); - expect(w.folder.pin('X')).toBe(hashValue(OLD_DEV.X)); - }); - - test('capy edit X (piped) from a folder that is NOT behind: saves', async () => { - const server = fakeCapyServer({ initial: NEW_SEED }); - const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: NEW_SEED.keep as KeepFile, env: NEW_DEV, client: server.client }); - made(folder); - await writeAndSync(folder.ctx as never, 'X', 'x-from-B', { push: true }); - expect(serverValues(server.state(), DEV, KEY)).toEqual({ ...NEW_DEV, X: 'x-from-B' }); - }); - - test('bare capy: commit local values after changing Y', async () => { - const w = world({ env: { ...OLD_DEV, Y: 'y-B' } }); - const seen = await readSnapshot(w.server.client, 'proj', DEV, KEY); - const remoteHashes = Object.fromEntries(Object.entries(seen.entries).map(([name, e]) => [name, e.hash])); - const { changed } = await commitLocalValues(w.folder.ctx, { pinned: pinnedHashes(w.folder.ctx.keep, DEV), remoteHashes, seen }); - expect(changed).toBe(1); - expect(valuesOf(w.server).Y).toBe('y-B'); - expectOthersIntact(w, ['Y']); - // The folder's untouched X is now A's. - expect(w.folder.env().X).toBe(NEW_X); - }); - - test('bare capy: B also changed X, the table showed A\'s X next to it, and B committed local: B\'s X is the answer', async () => { - const w = world({ env: { ...OLD_DEV, X: 'x-from-B' } }); - const seen = await readSnapshot(w.server.client, 'proj', DEV, KEY); - const remoteHashes = Object.fromEntries(Object.entries(seen.entries).map(([name, e]) => [name, e.hash])); - await commitLocalValues(w.folder.ctx, { pinned: pinnedHashes(w.folder.ctx.keep, DEV), remoteHashes, seen }); - expect(valuesOf(w.server).X).toBe('x-from-B'); - expectOthersIntact(w, ['X']); - }); - - test('bare capy: someone saves while the table is up — the push is refused, re-planned from the new latest, and succeeds', async () => { - const w = world({ env: { ...OLD_DEV, Y: 'y-B' } }); - const seen = await readSnapshot(w.server.client, 'proj', DEV, KEY); - const remoteHashes = Object.fromEntries(Object.entries(seen.entries).map(([name, e]) => [name, e.hash])); - // C saves a new variable while B reads the table. - w.server.tape(pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, LATE: 'late' } })); - await commitLocalValues(w.folder.ctx, { pinned: pinnedHashes(w.folder.ctx.keep, DEV), remoteHashes, seen }); - expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, LATE: 'late', Y: 'y-B' }); - expect(w.server.pushSecrets.mock.calls).toHaveLength(2); - }); - - test('capy push: pushes the folder\'s edits, not the whole env it still holds from the old snapshot', async () => { - const w = world({ env: { ...OLD_DEV, Y: 'y-B', LOCAL_NEW: 'ln' } }); - const changes = localChanges(w.folder.ctx.localPlaintext, pinnedHashes(w.folder.ctx.keep, DEV), { removals: false }); - expect(changes).toEqual({ set: { Y: 'y-B', LOCAL_NEW: 'ln' }, remove: [] }); - await commitFolder(w.folder.ctx, changes); - expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, Y: 'y-B', LOCAL_NEW: 'ln' }); - expectOthersIntact(w, ['Y']); - }); - - test('capy secrets set Y', async () => { - const w = world(); - const env: SetEnv = { - client: w.server.client as SetEnv['client'], - orgId: 'org', - openKeys: () => async () => KEY, - writeCache: () => undefined, - github: () => undefined, - branchName: () => 'capy/test', - }; - const req: SetRequest = { - name: 'Y', - value: 'y-set', - locations: [{ project_id: 'proj', project_name: 'demo', branch: DEV, protected: false }], - repos: [], - }; - const result = await runSecretSet(req, env); - expect(result.failed).toEqual([]); - expect(valuesOf(w.server).Y).toBe('y-set'); - expectOthersIntact(w, ['Y']); - }); - - test('capy connect import: many variables in one write, the rest of the server untouched', async () => { - const w = world(); - await writeImportedAndSync( - w.folder.ctx as never, - [ - { varName: 'IMPORTED_1', value: 'i1', entry: { provider: 'dokploy' } as never }, - { varName: 'IMPORTED_2', value: 'i2', entry: { provider: 'dokploy' } as never }, - ], - { push: true }, - ); - expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, IMPORTED_1: 'i1', IMPORTED_2: 'i2' }); - expectOthersIntact(w, []); - expect(w.server.pushSecrets.mock.calls).toHaveLength(1); - // The connector marker rides on the new entries only. - const keep = w.server.state().keep as KeepFile; - expect(keep.variables.IMPORTED_1.find((e) => e.branch === DEV)?.connector).toEqual({ provider: 'dokploy' }); - expect(keep.variables.X.find((e) => e.branch === DEV)?.connector).toBeUndefined(); - }); - - test('a save of the value the server already holds uploads nothing, and still syncs the folder', async () => { - const w = world(); - await writeAndSync(w.folder.ctx as never, 'Y', OLD_DEV.Y, { push: true }); - expect(w.server.pushSecrets.mock.calls).toHaveLength(0); - expect(w.folder.env().X).toBe(NEW_X); - }); -}); - -describe('uncommitted local edits stay edits', () => { - test('a new local variable and an edited one survive another variable\'s save, un-pinned', async () => { - const w = world({ env: { ...OLD_DEV, LOCAL_NEW: 'ln', KEEP: 'k-edited' } }); - await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); - expect(w.folder.env()).toMatchObject({ LOCAL_NEW: 'ln', KEEP: 'k-edited', Y: 'y-B', X: NEW_X }); - // Never uploaded. - expect(valuesOf(w.server).LOCAL_NEW).toBeUndefined(); - expect(valuesOf(w.server).KEEP).toBe(OLD_DEV.KEEP); - expect(w.folder.pin('LOCAL_NEW')).toBeUndefined(); - }); - - test('a local edit of a variable the server ALSO changed is still a conflict for the next bare `capy` (its pin does not move)', async () => { - const w = world({ env: { ...OLD_DEV, X: 'x-edited-by-B' } }); - await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); - expect(w.folder.env().X).toBe('x-edited-by-B'); - expect(w.folder.pin('X')).toBe(hashValue(OLD_DEV.X)); - const local = { ...w.folder.env() }; - const remote = valuesOf(w.server); - const hashes = (m: Record) => Object.fromEntries(Object.entries(m).map(([k, v]) => [k, hashValue(v)])); - const { diffs } = compareSecrets(pinnedHashes(w.folder.keep(), DEV), hashes(local), hashes(remote)); - expect(diffs.find((d) => d.variable === 'X')).toMatchObject({ pinned: hashValue(OLD_DEV.X), local: hashValue('x-edited-by-B'), remote: hashValue(NEW_X) }); - }); - - test('capy remove carries no one\'s unrelated edit along: it removes Y, uploads nothing else, and the edit stays in .env', async () => { - const w = world({ env: { ...OLD_DEV, LOCAL_NEW: 'ln', KEEP: 'k-edited' } }); - await removeAndSync(w.folder.ctx as never, ['Y']); - expect(valuesOf(w.server)).toEqual({ X: NEW_X, KEEP: OLD_DEV.KEEP, SERVER_ONLY: NEW_DEV.SERVER_ONLY }); - expect(w.folder.env()).toMatchObject({ LOCAL_NEW: 'ln', KEEP: 'k-edited' }); - }); - - test('capy remove --json of a variable the server changed: a coded refusal naming it, exit 1, nothing uploaded', async () => { - const w = world(); - const exit = spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`exit ${code}`); - }) as never); - const out = spyOn(console, 'log').mockImplementation(() => {}); - const result = await proceedWithRemoval(w.folder.ctx as never, ['X'], { json: true, cwd: w.folder.dir }).catch((e: unknown) => e); - const printed = out.mock.calls.map((c) => String(c[0])).join('\n'); - exit.mockRestore(); - out.mockRestore(); - expect((result as Error).message).toBe('exit 1'); - expect(JSON.parse(printed)).toEqual({ ok: false, code: ERROR_CODES.SECRETS_CONFLICT, error: expect.any(String), names: ['X'] }); - expect(printed).not.toContain(NEW_X); - expect(w.server.pushSecrets.mock.calls).toHaveLength(0); - }); - - test('a local value that already equals the server\'s new value is not an edit: the pin moves', async () => { - const w = world({ env: { ...OLD_DEV, X: NEW_X } }); - await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); - expect(w.folder.pin('X')).toBe(hashValue(NEW_X)); - }); -}); - -describe('stale base: the server moved between the read and the push', () => { - const w1 = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, W1: 'w1' } }); - const w2 = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, W1: 'w1', W2: 'w2' } }); - - test('409 -> one re-read -> success, on top of what landed in between', async () => { - const w = world({ racing: (n) => (n === 1 ? [w1] : []) }); - await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); - expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, W1: 'w1', Y: 'y-B' }); - expect(w.server.getDecryptData.mock.calls).toHaveLength(2); - expect(w.server.pushSecrets.mock.calls).toHaveLength(2); - // The second push was built from the keep that was latest at the second read. - const [, , , , , firstBase] = w.server.pushSecrets.mock.calls[0]; - const [, , , , , secondBase] = w.server.pushSecrets.mock.calls[1]; - expect(firstBase).not.toBe(secondBase); - expect(w.folder.env().W1).toBe('w1'); - }); - - test('capy secrets set: the push names its base, and a stale base is read again once', async () => { - const w = world({ racing: (n) => (n === 1 ? [w1] : []) }); - const env: SetEnv = { - client: w.server.client as SetEnv['client'], - orgId: 'org', - openKeys: () => async () => KEY, - writeCache: () => undefined, - github: () => undefined, - branchName: () => 'capy/test', - }; - const req: SetRequest = { - name: 'Y', - value: 'y-set', - locations: [{ project_id: 'proj', project_name: 'demo', branch: DEV, protected: false }], - repos: [], - }; - const result = await runSecretSet(req, env); - expect(result.failed).toEqual([]); - expect(valuesOf(w.server)).toEqual({ ...NEW_DEV, W1: 'w1', Y: 'y-set' }); - expect(w.server.getDecryptData.mock.calls).toHaveLength(2); - expect(w.server.pushSecrets.mock.calls.map((c) => c[5])).toEqual([expect.stringMatching(/^[0-9a-f]{64}$/), expect.stringMatching(/^[0-9a-f]{64}$/)]); - }); - - test('refused again: stops with the coded error, uploads nothing, folder untouched', async () => { - const w = world({ racing: (n) => (n === 1 ? [w1] : n === 2 ? [w2] : []) }); - const err = (await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }).catch((e: unknown) => e)) as CapyError; - expect(err.code).toBe(ERROR_CODES.SECRETS_STALE_BASE); - expect(w.server.getDecryptData.mock.calls).toHaveLength(2); - expect(w.server.pushSecrets.mock.calls).toHaveLength(2); - expect(valuesOf(w.server).Y).toBe(OLD_DEV.Y); - expect(w.folder.env()).toEqual(OLD_DEV); - }); - - test('the re-plan finds a conflict: SECRETS_CONFLICT, nothing uploaded', async () => { - const racing = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, Y: 'y-from-C' } }); - const w = world({ racing: (n) => (n === 1 ? [racing] : []) }); - const err = (await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }).catch((e: unknown) => e)) as CapyError; - expect(err.code).toBe(ERROR_CODES.SECRETS_CONFLICT); - expect(err.details).toEqual({ names: ['Y'] }); - expect(valuesOf(w.server).Y).toBe('y-from-C'); - }); - - test('a resolution answers the value the user saw, not a newer one', async () => { - const mine = { set: { X: 'x-mine' }, remove: [] }; - const answered = world(); - await commitFolder(answered.folder.ctx, mine, { resolutions: { X: 'mine' } }); - expect(valuesOf(answered.server).X).toBe('x-mine'); - - const third = pushOf({ projectKey: KEY, branch: DEV, values: { ...NEW_DEV, X: 'x-from-C' } }); - const raced = world({ racing: (n) => (n === 1 ? [third] : []) }); - const err = (await commitFolder(raced.folder.ctx, mine, { resolutions: { X: 'mine' } }).catch((e: unknown) => e)) as CapyError; - expect(err.code).toBe(ERROR_CODES.SECRETS_CONFLICT); - expect(valuesOf(raced.server).X).toBe('x-from-C'); - }); -}); - -describe('the commit path', () => { - test('a server with no keep yet: the first save creates it and sends no base', async () => { - const server = fakeCapyServer(); - const keep: KeepFile = { version: '3.0', org_id: 'org', project_id: 'proj', project_name: 'demo', variables: {} }; - const committed = await commitToServer({ client: server.client, projectId: 'proj', branch: DEV, projectKey: KEY, startKeep: keep, changes: { set: { A: 'a' }, remove: [] } }); - expect(committed.result?.keep_hash).toBeDefined(); - expect(serverValues(server.state(), DEV, KEY)).toEqual({ A: 'a' }); - expect(server.pushSecrets.mock.calls[0][5]).toBeUndefined(); - }); - - test('every push after a read names the keep it was built from', async () => { - const w = world(); - await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); - expect(w.server.pushSecrets.mock.calls[0][5]).toMatch(/^[0-9a-f]{64}$/); - }); - - test('the keep that is uploaded is the server\'s, plus this branch\'s changed names only', async () => { - const w = world(); - await writeAndSync(w.folder.ctx as never, 'Y', 'y-B', { push: true }); - const uploaded = JSON.parse(w.server.pushSecrets.mock.calls[0][1] as string) as KeepFile; - expect(uploaded.variables.SERVER_ONLY).toBeDefined(); - expect(uploaded.variables.X.find((e) => e.branch === DEV)?.value_hash).toBe(hashValue(NEW_X)); - expect(stagingEntries(uploaded)).toEqual(stagingEntries(w.before.keep)); - }); - - test('local-only mode commits the working copy without a server', async () => { - const folder = makeFolder({ projectKey: KEY, branch: DEV, keep: OLD_SEED.keep as KeepFile, env: { ...OLD_DEV, Y: 'y-local' }, client: undefined }); - made(folder); - const committed = await commitFolder(folder.ctx, { set: { N: 'n' }, remove: [] }); - expect(committed.uploaded).toBe(false); - expect(folder.env()).toEqual({ ...OLD_DEV, Y: 'y-local', N: 'n' }); - // The whole working copy is what local-only mode commits: every name is pinned to its value. - expect(folder.pin('Y')).toBe(hashValue('y-local')); - expect(folder.pin('N')).toBe(hashValue('n')); - }); - - test('a line this profile cannot decrypt is left on the server exactly as it is', async () => { - const sealedLine = `capy:rid-sealed:${Encryptor.encrypt('for-someone-else', Encryptor.generateKey())}`; - const base = seedServer({ projectKey: KEY, values: { [DEV]: OLD_DEV } }); - const baseKeep = base.keep as KeepFile; - const keep: KeepFile = { - ...baseKeep, - variables: { ...baseKeep.variables, SEALED: [{ resource_id: 'rid-sealed', branch: DEV, value_hash: 'h-sealed' }] }, - }; - const blob = `${base.blobs[SyncEngine.computeKeepHash(baseKeep, DEV)]}\nSEALED=${sealedLine}`; - const server = fakeCapyServer({ initial: { keep, blobs: { [SyncEngine.computeKeepHash(keep, DEV)]: blob } } }); - const folder = makeFolder({ projectKey: KEY, branch: DEV, keep, env: OLD_DEV, client: server.client }); - made(folder); - await writeAndSync(folder.ctx as never, 'Y', 'y-B', { push: true }); - expect(serverLine(server.state(), DEV, 'SEALED')).toBe(`SEALED=${sealedLine}`); - expect(serverValues(server.state(), DEV, KEY)).toEqual({ ...OLD_DEV, Y: 'y-B' }); - expect(serverHash(server.state(), DEV, 'SEALED')).toBe('h-sealed'); - }); -}); diff --git a/tests/sync/commitPlan.test.ts b/tests/sync/commitPlan.test.ts deleted file mode 100644 index a7e88903..00000000 --- a/tests/sync/commitPlan.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * The pure planner of the one commit path (CAP-712): what happens to each name when - * the caller's changes meet the server's latest, given the caller's pin. - */ -import { describe, test, expect, mock } from 'bun:test'; -import { hashValue } from '../../src/sync/compareSecrets'; -import { localChanges, planCommit, pinnedHashes, type LatestEntry, type Plan } from '../../src/sync/commit'; -import type { KeepFile } from '../../src/types/index'; - -const entry = (value: string): LatestEntry => ({ line: `capy:r:${value}`, plaintext: value, hash: hashValue(value) }); -const latestOf = (values: Record): Record => - Object.fromEntries(Object.entries(values).map(([name, value]) => [name, entry(value)])); -const pinOf = (values: Record): Record => - Object.fromEntries(Object.entries(values).map(([name, value]) => [name, hashValue(value)])); -const encryptLine = (name: string, value: string): string => `capy:new:${name}:${value}`; - -function plan(args: { - pinned: Record; - latest: Record; - set?: Record; - remove?: string[]; - resolutions?: Record; -}): Plan { - return planCommit({ - pinned: pinOf(args.pinned), - latest: latestOf(args.latest), - changes: { set: args.set ?? {}, remove: args.remove ?? [] }, - resolutions: args.resolutions, - encryptLine, - }); -} - -const okPlan = (p: Plan) => { - if (p.kind !== 'ok') throw new Error('expected an ok plan'); - return p; -}; - -describe('planCommit — set', () => { - test('a name the server has not moved since the pin is written; every other name keeps the server line verbatim', () => { - const p = okPlan(plan({ pinned: { X: 'x0', Y: 'y0' }, latest: { X: 'x0', Y: 'y0' }, set: { Y: 'y1' } })); - expect(p.written).toEqual({ Y: 'y1' }); - expect(p.lines.X).toBe('capy:r:x0'); - expect(p.lines.Y).toBe('capy:new:Y:y1'); - expect(p.env).toEqual({ X: 'x0', Y: 'y1' }); - }); - - test('only the changed names are encrypted', () => { - const encrypt = mock(encryptLine); - planCommit({ - pinned: pinOf({ A: 'a', B: 'b', C: 'c' }), - latest: latestOf({ A: 'a', B: 'b', C: 'c' }), - changes: { set: { B: 'b2' }, remove: [] }, - encryptLine: encrypt, - }); - expect(encrypt.mock.calls.map((c) => c[0])).toEqual(['B']); - }); - - test('the server moved a name this caller does NOT change: not a conflict, and the server value stays', () => { - const p = okPlan(plan({ pinned: { X: 'x0', Y: 'y0' }, latest: { X: 'x-new', Y: 'y0' }, set: { Y: 'y1' } })); - expect(p.lines.X).toBe('capy:r:x-new'); - expect(p.env.X).toBe('x-new'); - }); - - test('the server moved the name since the pin and holds a different value: conflict', () => { - expect(plan({ pinned: { X: 'x0' }, latest: { X: 'x-new' }, set: { X: 'x-mine' } })).toEqual({ kind: 'conflict', names: ['X'] }); - }); - - test('the server moved the name but already holds the intended value: nothing to write, no conflict', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, set: { X: 'x1' } })); - expect(p.written).toEqual({}); - expect(p.lines.X).toBe('capy:r:x1'); - }); - - test('added on the server since the pin (the pin does not have it): conflict when the values differ', () => { - expect(plan({ pinned: {}, latest: { Z: 'z-theirs' }, set: { Z: 'z-mine' } })).toEqual({ kind: 'conflict', names: ['Z'] }); - }); - - test('removed on the server since the pin: setting it is a conflict', () => { - expect(plan({ pinned: { Z: 'z0' }, latest: {}, set: { Z: 'z1' } })).toEqual({ kind: 'conflict', names: ['Z'] }); - }); - - test('a new name that is neither pinned nor on the server is appended', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x0' }, set: { N: 'n' } })); - expect(Object.keys(p.lines)).toEqual(['X', 'N']); - expect(p.written).toEqual({ N: 'n' }); - }); - - test('conflicts name every contested variable, sorted, once', () => { - const p = plan({ pinned: { B: 'b0', A: 'a0', C: 'c0' }, latest: { B: 'b1', A: 'a1', C: 'c0' }, set: { B: 'b2', A: 'a2', C: 'c2' } }); - expect(p).toEqual({ kind: 'conflict', names: ['A', 'B'] }); - }); -}); - -describe('planCommit — remove', () => { - test('removes a name the server has not moved', () => { - const p = okPlan(plan({ pinned: { X: 'x0', Y: 'y0' }, latest: { X: 'x0', Y: 'y0' }, remove: ['Y'] })); - expect(p.removed).toEqual(['Y']); - expect(Object.keys(p.lines)).toEqual(['X']); - expect(p.env).toEqual({ X: 'x0' }); - }); - - test('the server moved it since the pin: conflict', () => { - expect(plan({ pinned: { Y: 'y0' }, latest: { Y: 'y1' }, remove: ['Y'] })).toEqual({ kind: 'conflict', names: ['Y'] }); - }); - - test('the server already removed it: a no-op, not a conflict', () => { - const p = okPlan(plan({ pinned: { Y: 'y0', X: 'x0' }, latest: { X: 'x0' }, remove: ['Y'] })); - expect(p.removed).toEqual([]); - expect(Object.keys(p.lines)).toEqual(['X']); - }); - - test('added on the server since the pin: removing it is a conflict', () => { - expect(plan({ pinned: {}, latest: { Z: 'z' }, remove: ['Z'] })).toEqual({ kind: 'conflict', names: ['Z'] }); - }); -}); - -describe('planCommit — resolutions', () => { - test('mine overwrites the value the server moved', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, set: { X: 'x-mine' }, resolutions: { X: 'mine' } })); - expect(p.written).toEqual({ X: 'x-mine' }); - expect(p.env.X).toBe('x-mine'); - }); - - test('theirs leaves the server value alone', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, set: { X: 'x-mine' }, resolutions: { X: 'theirs' } })); - expect(p.written).toEqual({}); - expect(p.lines.X).toBe('capy:r:x1'); - expect(p.env.X).toBe('x1'); - }); - - test('mine removes a name the server moved; theirs keeps it', () => { - const mine = okPlan(plan({ pinned: { Y: 'y0' }, latest: { Y: 'y1' }, remove: ['Y'], resolutions: { Y: 'mine' } })); - expect(mine.removed).toEqual(['Y']); - const theirs = okPlan(plan({ pinned: { Y: 'y0' }, latest: { Y: 'y1' }, remove: ['Y'], resolutions: { Y: 'theirs' } })); - expect(theirs.removed).toEqual([]); - expect(theirs.env.Y).toBe('y1'); - }); - - test('a resolution for a name nobody changes does nothing', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x1' }, resolutions: { X: 'mine' } })); - expect(p.lines.X).toBe('capy:r:x1'); - }); -}); - -describe('planCommit — edge cases', () => { - test('a line this profile cannot decrypt is kept verbatim and left out of env', () => { - const latest = { ...latestOf({ X: 'x0' }), SEALED: { line: 'capy:r:sealed-for-someone-else', plaintext: undefined, hash: 'h-sealed' } }; - const p = okPlan(planCommit({ pinned: { X: hashValue('x0'), SEALED: 'h-sealed' }, latest, changes: { set: { X: 'x1' }, remove: [] }, encryptLine })); - expect(p.lines.SEALED).toBe('capy:r:sealed-for-someone-else'); - expect(p.env).toEqual({ X: 'x1' }); - }); - - test('a name in both set and remove is set', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x0' }, set: { X: 'x1' }, remove: ['X'] })); - expect(p.env.X).toBe('x1'); - expect(p.removed).toEqual([]); - }); - - test('an empty value is a value', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x0' }, set: { X: '' } })); - expect(p.written).toEqual({ X: '' }); - }); - - test('no changes: the server is left exactly as it is', () => { - const p = okPlan(plan({ pinned: { X: 'x0' }, latest: { X: 'x9', Y: 'y' } })); - expect(p.written).toEqual({}); - expect(p.removed).toEqual([]); - expect(p.lines).toEqual({ X: 'capy:r:x9', Y: 'capy:r:y' }); - }); -}); - -describe('localChanges / pinnedHashes', () => { - const pinned = pinOf({ A: 'a', B: 'b', GONE: 'g' }); - - test('a folder changed only what differs from its pin', () => { - expect(localChanges({ A: 'a', B: 'b2', NEW: 'n' }, pinned, { removals: false })).toEqual({ set: { B: 'b2', NEW: 'n' }, remove: [] }); - }); - - test('with removals, a pinned name gone from .env is a removal', () => { - expect(localChanges({ A: 'a', B: 'b' }, pinned, { removals: true })).toEqual({ set: {}, remove: ['GONE'] }); - }); - - test('pinnedHashes reads one branch', () => { - const keep: KeepFile = { - version: '3.0', - org_id: 'o', - project_id: 'p', - project_name: 'demo', - variables: { - A: [ - { resource_id: 'r1', branch: 'development', value_hash: 'h-dev' }, - { resource_id: 'r1b', branch: 'staging', value_hash: 'h-stg' }, - ], - B: [{ resource_id: 'r2', branch: 'staging', value_hash: 'h-b' }], - }, - }; - expect(pinnedHashes(keep, 'development')).toEqual({ A: 'h-dev' }); - expect(pinnedHashes(null, 'development')).toEqual({}); - }); -}); diff --git a/tests/sync/conflicts.test.ts b/tests/sync/conflicts.test.ts index f4ad11f0..a9f05104 100644 --- a/tests/sync/conflicts.test.ts +++ b/tests/sync/conflicts.test.ts @@ -1,213 +1,203 @@ /** - * The shared conflict module (CAP-717, src/sync/conflicts.ts): the id that binds a decision to what was seen, - * the flags, and the "apply the resolutions or refuse" step every write command goes through. Pure: no server, - * no folder. Each test asserts the PROPERTY a caller relies on. + * 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 } from 'bun:test'; +import { describe, test, expect, spyOn } from 'bun:test'; +import { compareSecrets } from '../../src/sync/compareSecrets'; import { CONFLICT_ID_FORMAT, ConflictRefusal, conflictIdOf, conflictRefusalFor, + exitOnConflictRefusal, gateConflicts, parseConflictFlags, - protocolDecider, - resolveKeyOf, + printConflictRefusal, seenResolutions, - sourceResolutions, + sourceRowsOf, targetResolutions, - type Conflict, type ConflictScope, + type SourceConflict, + type TargetConflict, } from '../../src/sync/conflicts'; import { CapyError, ERROR_CODES } from '../../src/types/index'; -const source = (name: string, theirs: string, over: Partial> = {}): Conflict => ({ - kind: 'source', - name, - project: 'demo', - branch: 'production', - pinned: '1111111111111111', - yours: '2222222222222222', - theirs, - ...over, -}); - -const target = (name: string, over: Partial> = {}): Conflict => ({ - kind: 'target', - name, - target: 'dokploy-production', - pinned: '1111111111111111', - yours: '2222222222222222', - theirs: '3333333333333333', - ...over, -}); - -const scope = (conflicts: readonly Conflict[], over: Partial = {}): ConflictScope => ({ - project: 'demo', - branch: 'production', - serverKeepHash: 'abcdabcdabcdabcd', - conflicts, - ...over, +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 }); + }); }); -const refusalOf = (run: () => unknown): ConflictRefusal => { - try { - run(); - } catch (err) { - if (err instanceof ConflictRefusal) return err; - throw err; - } - throw new Error('expected a ConflictRefusal'); -}; - describe('conflictIdOf', () => { - const base = [scope([source('A', 'aaaaaaaaaaaaaaaa'), source('B', 'bbbbbbbbbbbbbbbb')])]; - - test('is c_ + 12 hex, and does not depend on the order the conflicts were found in', () => { - const id = conflictIdOf(base); + 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([source('B', 'bbbbbbbbbbbbbbbb'), source('A', 'aaaaaaaaaaaaaaaa')])])).toBe(id); + expect(conflictIdOf([scope([...two].reverse())])).toBe(id); }); - test('changes when anything it binds changes: a fingerprint, the server keep hash, the branch, the project, a name, a kind', () => { - const id = conflictIdOf(base); - const others = [ - conflictIdOf([scope([source('A', 'cccccccccccccccc'), source('B', 'bbbbbbbbbbbbbbbb')])]), // theirs moved again - conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa', { yours: '9999999999999999' }), source('B', 'bbbbbbbbbbbbbbbb')])]), // yours - conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa', { pinned: null }), source('B', 'bbbbbbbbbbbbbbbb')])]), // pinned - conflictIdOf([scope(base[0].conflicts, { serverKeepHash: 'ffffffffffffffff' })]), - conflictIdOf([scope(base[0].conflicts, { branch: 'staging' })]), - conflictIdOf([scope(base[0].conflicts, { project: 'other' })]), - conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa')])]), // a name fewer - conflictIdOf([scope([target('A'), source('B', 'bbbbbbbbbbbbbbbb')])]), // a kind - ]; - expect(new Set([id, ...others]).size).toBe(others.length + 1); + 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('theirs_changed_at is not part of it (the spec binds fingerprints only)', () => { - const a = conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa')])]); - const b = conflictIdOf([scope([source('A', 'aaaaaaaaaaaaaaaa', { theirs_changed_at: '2026-10-01T21:44:18Z' })])]); - expect(b).toBe(a); + 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('NAME=mine|theirs, and NAME@TARGET=... for a target, repeatable', () => { - const flags = parseConflictFlags({ resolve: ['A=mine', 'B=theirs', 'C@dokploy-production=mine'], conflict: ID }); - expect(flags).toEqual({ resolutions: { A: 'mine', B: 'theirs', 'C@dokploy-production': 'mine' }, conflictId: ID }); - }); - - test('a bad form is SECRETS_RESOLVE_INVALID (code, not prose)', () => { - ['A', 'A=', 'A=ours', '=mine', 'A=mine=theirs', '1A=mine', 'A@=mine'].forEach((bad) => { - const err = (() => { - try { - parseConflictFlags({ resolve: [bad], conflict: ID }); - return undefined; - } catch (e) { - return e as CapyError; - } - })(); - expect(err?.code).toBe(ERROR_CODES.SECRETS_RESOLVE_INVALID); - }); - }); - - test('--resolve without --conflict is SECRETS_RESOLVE_NEEDS_CONFLICT; a malformed id is SECRETS_RESOLVE_INVALID', () => { - expect(() => parseConflictFlags({ resolve: ['A=mine'] })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_NEEDS_CONFLICT })); - expect(() => parseConflictFlags({ resolve: ['A=mine'], conflict: 'c_nothex' })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); - expect(() => parseConflictFlags({ conflict: 'whatever' })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); - }); - - test('one name decided two ways is refused; the same answer twice is fine; there is no resolve-all', () => { - expect(() => parseConflictFlags({ resolve: ['A=mine', 'A=theirs'], conflict: ID })).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); - expect(parseConflictFlags({ resolve: ['A=mine', 'A=mine'], conflict: ID }).resolutions).toEqual({ A: 'mine' }); - // (A variable may be NAMED `all`; the point is that no spelling means "every conflict".) - ['*=mine', '--all', 'ALL', '=mine'].forEach((x) => expect(() => parseConflictFlags({ resolve: [x], conflict: ID })).toThrow()); - }); - - test('no flags is no decisions', () => { - expect(parseConflictFlags({})).toEqual({ resolutions: {}, conflictId: undefined }); - expect(parseConflictFlags({ resolve: [] })).toEqual({ resolutions: {}, conflictId: undefined }); + 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([source('A', 'aaaaaaaaaaaaaaaa'), source('B', 'bbbbbbbbbbbbbbbb'), target('A')])]; + const scopes = [scope(gemini)]; const id = conflictIdOf(scopes); - const flags = (resolve: readonly string[], conflict: string | undefined = id) => parseConflictFlags({ resolve, conflict }); + const refusalOf = (run: () => unknown): ConflictRefusal => { + try { + run(); + } catch (err) { + if (err instanceof ConflictRefusal) return err; + throw err; + } + throw new Error('expected a refusal'); + }; - test('nothing in conflict decides nothing', () => { - expect(gateConflicts(flags([]), [scope([])])).toEqual({}); + test('no rows: nothing to answer', () => { + expect(gateConflicts({ resolutions: {}, conflictId: undefined }, [scope([])])).toEqual({}); }); - test('without --conflict every conflict is reported, with the id: SECRETS_CONFLICT', () => { - const refusal = refusalOf(() => gateConflicts(flags([], undefined), scopes)); - expect(refusal.code).toBe(ERROR_CODES.SECRETS_CONFLICT); - expect(refusal.payload).toMatchObject({ ok: false, code: 'SECRETS_CONFLICT', conflict_id: id }); - expect(refusal.payload.conflicts).toHaveLength(3); + 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('a --conflict that is not the id of exactly these is stale: the FRESH conflicts and a NEW id come back', () => { - const refusal = refusalOf(() => gateConflicts(flags(['A=mine'], 'c_000000000000'), scopes)); - expect(refusal.code).toBe(ERROR_CODES.SECRETS_CONFLICT_STALE); - expect(refusal.payload.conflict_id).toBe(id); - expect(refusal.payload.conflicts).toHaveLength(3); + 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('the id matches but a conflicting name has no answer: the REMAINING ones come back (the id of the whole set, so a rerun keeps it)', () => { - const refusal = refusalOf(() => gateConflicts(flags(['A=mine', 'B=theirs']), scopes)); - expect(refusal.code).toBe(ERROR_CODES.SECRETS_CONFLICT); - expect(refusal.payload.conflict_id).toBe(id); - expect(refusal.payload.conflicts.map(resolveKeyOf)).toEqual(['A@dokploy-production']); + 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('every conflict answered: the decisions, source and target apart', () => { - const decided = gateConflicts(flags(['A=mine', 'B=theirs', 'A@dokploy-production=theirs']), scopes); - expect(decided).toEqual({ A: 'mine', B: 'theirs', 'A@dokploy-production': 'theirs' }); - expect(sourceResolutions(decided)).toEqual({ A: 'mine', B: 'theirs' }); - expect(targetResolutions(decided, 'dokploy-production')).toEqual({ A: 'theirs' }); - expect(targetResolutions(decided, 'elsewhere')).toEqual({}); + 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('seenResolutions binds each decision to the fingerprints of the conflict it answered, and drops answers to nothing', () => { - const decided = gateConflicts(flags(['A=mine', 'B=theirs', 'A@dokploy-production=theirs']), scopes); - const seen = seenResolutions({ ...decided, 'ZZZ@dokploy-production': 'mine' }, scopes.flatMap((s) => s.conflicts)); - const target = scopes.flatMap((s) => s.conflicts).find((c) => c.kind === 'target')!; - expect(seen['A@dokploy-production']).toEqual({ choice: 'theirs', theirs: target.theirs, yours: target.yours }); - expect(Object.keys(seen).toSorted()).toEqual(['A', 'A@dokploy-production', 'B']); - expect(targetResolutions(seen, 'dokploy-production')).toEqual({ A: seen['A@dokploy-production'] }); + 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('answers for names that are not in the conflict are ignored, never applied', () => { - expect(gateConflicts(flags(['A=mine', 'B=mine', 'A@dokploy-production=mine', 'ZZZ=theirs']), scopes)).toEqual({ - A: 'mine', - B: 'mine', - 'A@dokploy-production': 'mine', - }); - }); - - test('several places bind one id, and moving any one of them makes the answer stale', () => { - const two = [scope([source('A', 'aaaaaaaaaaaaaaaa')]), scope([source('A', 'aaaaaaaaaaaaaaaa', { branch: 'staging' })], { branch: 'staging' })]; - const both = conflictIdOf(two); - expect(gateConflicts(parseConflictFlags({ resolve: ['A=mine'], conflict: both }), two)).toEqual({ A: 'mine' }); - const moved = [two[0], scope([source('A', 'dddddddddddddddd', { branch: 'staging' })], { branch: 'staging' })]; - expect(refusalOf(() => gateConflicts(parseConflictFlags({ resolve: ['A=mine'], conflict: both }), moved)).code).toBe(ERROR_CODES.SECRETS_CONFLICT_STALE); - }); - - test('a payload names fingerprints and the code, never anything else: exactly ok, code, conflict_id, conflicts (dry_run on a preview)', () => { - const refusal = refusalOf(() => gateConflicts(flags([], undefined), scopes)); - expect(Object.keys(refusal.payload).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'ok']); - const preview = conflictRefusalFor(ERROR_CODES.SECRETS_CONFLICT, scopes, undefined, { dryRun: true }); - expect(Object.keys(preview.payload).toSorted()).toEqual(['code', 'conflict_id', 'conflicts', 'dry_run', 'ok']); - expect(preview.payload.conflict_id).toBe(id); + 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' } }); }); +}); - test('protocolDecider asks the same gate for one scope', () => { - const found = [source('A', 'aaaaaaaaaaaaaaaa') as Extract]; - const view = { project: 'demo', branch: 'production', serverKeepHash: 'abcdabcdabcdabcd' }; - const here = conflictIdOf([scope(found)]); - expect(protocolDecider(parseConflictFlags({ resolve: ['A=theirs'], conflict: here }))(found, view)).toEqual({ A: 'theirs' }); - expect(() => protocolDecider(parseConflictFlags({}))(found, view)).toThrow(ConflictRefusal); +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..ec867563 --- /dev/null +++ b/tests/sync/gate.test.ts @@ -0,0 +1,250 @@ +/** + * 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 { rmSync } from 'fs'; +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 { answersByProtocol, commitGated, ensureInSync, gateFirst, stopWithNothingWritten, 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 context with no gate (a folder other than the working directory) 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('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/noFolder.test.ts b/tests/sync/noFolder.test.ts new file mode 100644 index 00000000..fb757577 --- /dev/null +++ b/tests/sync/noFolder.test.ts @@ -0,0 +1,65 @@ +/** + * 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, 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('local writes, remote leaves the location; pinned and delete are refused with a code', () => { + expect(actionFor('local')).toBe('write'); + expect(actionFor('remote')).toBe('leave'); + expect(() => actionFor('pinned')).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); + expect(() => actionFor('delete')).toThrow(CapyError); + }); + + 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/sync/syncEngine.test.ts b/tests/sync/syncEngine.test.ts index 9291f3c8..abc87382 100644 --- a/tests/sync/syncEngine.test.ts +++ b/tests/sync/syncEngine.test.ts @@ -1,5 +1,5 @@ import { SyncEngine } from '../../src/sync/syncEngine'; -import { KeepFile } from '../../src/types/index'; +import { ChangeSet, UserDecisions, KeepFile, ConflictVariable } from '../../src/types/index'; describe('SyncEngine', () => { let syncEngine: SyncEngine; @@ -8,6 +8,349 @@ describe('SyncEngine', () => { syncEngine = new SyncEngine(); }); + describe('compareEnvironments', () => { + test('should identify new local variables', () => { + const local = { LOCAL_VAR: 'local_value', SHARED_VAR: 'shared' }; + const remote = { SHARED_VAR: 'shared' }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.newLocal).toHaveLength(1); + expect(result.newLocal[0]).toEqual({ + name: 'LOCAL_VAR', + value: 'local_value', + source: 'local', + encrypted: false + }); + }); + + test('should identify new remote variables', () => { + const local = { SHARED_VAR: 'shared' }; + const remote = { REMOTE_VAR: 'remote_value', SHARED_VAR: 'shared' }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.newRemote).toHaveLength(1); + expect(result.newRemote[0]).toEqual({ + name: 'REMOTE_VAR', + value: 'remote_value', + source: 'remote', + encrypted: true + }); + }); + + test('should identify conflicts', () => { + const local = { CONFLICT_VAR: 'local_value', SHARED_VAR: 'shared' }; + const remote = { CONFLICT_VAR: 'remote_value', SHARED_VAR: 'shared' }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toEqual({ + name: 'CONFLICT_VAR', + localValue: 'local_value', + remoteValue: 'remote_value', + isNew: false + }); + }); + + test('should mark conflict as isNew when resource_ids differ', () => { + const local = { + VAR1: 'capy:res_var1_abc123:encrypted_local_data' + }; + const remote = { + VAR1: 'capy:res_var1_xyz789:encrypted_remote_data' + }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toEqual({ + name: 'VAR1', + localValue: 'capy:res_var1_abc123:encrypted_local_data', + remoteValue: 'capy:res_var1_xyz789:encrypted_remote_data', + isNew: true + }); + }); + + test('should not mark conflict as isNew when resource_ids match', () => { + const local = { + VAR1: 'capy:res_var1_abc123:encrypted_local_data' + }; + const remote = { + VAR1: 'capy:res_var1_abc123:encrypted_remote_data' + }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toEqual({ + name: 'VAR1', + localValue: 'capy:res_var1_abc123:encrypted_local_data', + remoteValue: 'capy:res_var1_abc123:encrypted_remote_data', + isNew: false + }); + }); + + test('should not mark conflict as isNew when values are not encrypted', () => { + const local = { VAR1: 'plain_local_value' }; + const remote = { VAR1: 'plain_remote_value' }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toEqual({ + name: 'VAR1', + localValue: 'plain_local_value', + remoteValue: 'plain_remote_value', + isNew: false + }); + }); + + test('should mark conflict as isNew when local is plaintext but remote has resource_id', () => { + const local = { + VAR1: 'plaintext_value' + }; + const remote = { + VAR1: 'capy:res_var1_abc123:encrypted_remote_data' + }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]).toEqual({ + name: 'VAR1', + localValue: 'plaintext_value', + remoteValue: 'capy:res_var1_abc123:encrypted_remote_data', + isNew: true + }); + }); + + test('should identify unchanged variables', () => { + const local = { SAME_VAR: 'same_value', DIFF_VAR: 'local' }; + const remote = { SAME_VAR: 'same_value', DIFF_VAR: 'remote' }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.unchanged).toHaveLength(1); + expect(result.unchanged[0]).toEqual({ + name: 'SAME_VAR', + value: 'same_value', + source: 'both', + encrypted: false + }); + }); + + test('should handle empty environments', () => { + const result = syncEngine.compareEnvironments({}, {}); + + expect(result.newLocal).toHaveLength(0); + expect(result.newRemote).toHaveLength(0); + expect(result.conflicts).toHaveLength(0); + expect(result.unchanged).toHaveLength(0); + }); + + test('should handle complex scenario with all types', () => { + const local = { + ONLY_LOCAL: 'local', + CONFLICT_VAR: 'local_conflict', + UNCHANGED: 'same' + }; + const remote = { + ONLY_REMOTE: 'remote', + CONFLICT_VAR: 'remote_conflict', + UNCHANGED: 'same' + }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.newLocal).toHaveLength(1); + expect(result.newRemote).toHaveLength(1); + expect(result.conflicts).toHaveLength(1); + expect(result.unchanged).toHaveLength(1); + }); + + test('should identify deleted variables marked as capy:deleted', () => { + const local = { + DELETED_VAR: 'local_value', + NORMAL_VAR: 'normal' + }; + const remote = { + DELETED_VAR: 'capy:deleted', + NORMAL_VAR: 'normal' + }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.deleted).toHaveLength(1); + expect(result.deleted[0]).toEqual({ + name: 'DELETED_VAR', + value: 'local_value', + source: 'local', + encrypted: false + }); + expect(result.unchanged).toHaveLength(1); + }); + + test('should not show capy:deleted markers for non-existent local variables', () => { + const local = { + NORMAL_VAR: 'normal' + }; + const remote = { + DELETED_VAR: 'capy:deleted', + NORMAL_VAR: 'normal' + }; + + const result = syncEngine.compareEnvironments(local, remote); + + expect(result.deleted).toHaveLength(0); + expect(result.newRemote).toHaveLength(0); // capy:deleted shouldn't show as new remote + expect(result.unchanged).toHaveLength(1); + }); + }); + + describe('applyDecisions', () => { + test('should apply pull decisions', () => { + const local = { LOCAL_VAR: 'local' }; + const remote = { REMOTE_VAR: 'remote' }; + const decisions: UserDecisions = { + pushVariables: ['LOCAL_VAR'], + pullVariables: ['REMOTE_VAR'], + keepLocal: [], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const result = syncEngine.applyDecisions(local, remote, decisions); + + expect(result).toEqual({ + LOCAL_VAR: 'local', + REMOTE_VAR: 'remote' + }); + }); + + test('should apply conflict resolutions', () => { + const local = { CONFLICT: 'local_value' }; + const remote = { CONFLICT: 'remote_value' }; + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: [], + keepLocal: [], + keepRemote: ['CONFLICT'], + deleteLocal: [], + deleteRemote: [] + }; + + const result = syncEngine.applyDecisions(local, remote, decisions); + + expect(result.CONFLICT).toBe('remote_value'); + }); + + test('should handle empty decisions', () => { + const local = { VAR1: 'value1' }; + const remote = { VAR2: 'value2' }; + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: [], + keepLocal: [], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const result = syncEngine.applyDecisions(local, remote, decisions); + + // With no decisions, result is remote base (local-only vars not included) + expect(result).toEqual(remote); + }); + + test('should delete local variables when user confirms deletion', () => { + const local = { + DELETE_ME: 'local_value', + KEEP_ME: 'keep_value' + }; + const remote = { + DELETE_ME: 'capy:deleted', + KEEP_ME: 'keep_value' + }; + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: [], + keepLocal: [], + keepRemote: [], + deleteLocal: ['DELETE_ME'], + deleteRemote: [] + }; + + const result = syncEngine.applyDecisions(local, remote, decisions); + + expect(result).toEqual({ + KEEP_ME: 'keep_value' + }); + expect(result.DELETE_ME).toBeUndefined(); + }); + }); + + describe('generateSyncResult', () => { + test('should generate successful sync result', () => { + const changeSet: ChangeSet = { + newLocal: [{ name: 'LOCAL', value: 'val', source: 'local', encrypted: false }], + newRemote: [{ name: 'REMOTE', value: 'val', source: 'remote', encrypted: true }], + conflicts: [{ name: 'CONFLICT', localValue: 'local', remoteValue: 'remote' }], + unchanged: [{ name: 'SAME', value: 'val', source: 'both', encrypted: false }], + deleted: [], + deletedLocal: [] + }; + + const decisions: UserDecisions = { + pushVariables: ['LOCAL'], + pullVariables: ['REMOTE'], + keepLocal: [], + keepRemote: ['CONFLICT'], + deleteLocal: [], + deleteRemote: [] + }; + + const result = syncEngine.generateSyncResult(changeSet, decisions); + + expect(result).toEqual({ + success: true, + pushed: ['LOCAL'], + pulled: ['REMOTE'], + conflicts: ['CONFLICT'], + errors: [], + totalVariables: 4 + }); + }); + + test('should generate failed sync result with errors', () => { + const changeSet: ChangeSet = { + newLocal: [], + newRemote: [], + conflicts: [], + unchanged: [], + deleted: [], + deletedLocal: [] + }; + + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: [], + keepLocal: [], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = ['Network error', 'Permission denied']; + const result = syncEngine.generateSyncResult(changeSet, decisions, errors); + + expect(result.success).toBe(false); + expect(result.errors).toEqual(errors); + }); + }); + describe('mergeWithKeep', () => { test('should update existing variables in keep', () => { const keep: KeepFile = { @@ -52,6 +395,160 @@ describe('SyncEngine', () => { }); }); + describe('formatSyncSummary', () => { + test('should format comprehensive sync summary', () => { + const changeSet: ChangeSet = { + newLocal: [ + { name: 'LOCAL1', value: 'val1', source: 'local', encrypted: false }, + { name: 'LOCAL2', value: 'val2', source: 'local', encrypted: false } + ], + newRemote: [ + { name: 'REMOTE1', value: 'val1', source: 'remote', encrypted: true } + ], + conflicts: [ + { name: 'CONFLICT1', localValue: 'local', remoteValue: 'remote' } + ], + unchanged: [ + { name: 'SAME1', value: 'val1', source: 'both', encrypted: false }, + { name: 'SAME2', value: 'val2', source: 'both', encrypted: false }, + { name: 'SAME3', value: 'val3', source: 'both', encrypted: false } + ], + deleted: [], + deletedLocal: [] + }; + + const result = syncEngine.formatSyncSummary(changeSet); + + expect(result).toContain('2 new local variable(s)'); + expect(result).toContain('1 new remote variable(s)'); + expect(result).toContain('1 conflict(s)'); + expect(result).toContain('3 unchanged'); + }); + + test('should handle empty change set', () => { + const changeSet: ChangeSet = { + newLocal: [], + newRemote: [], + conflicts: [], + unchanged: [], + deleted: [], + deletedLocal: [] + }; + + const result = syncEngine.formatSyncSummary(changeSet); + + expect(result).toBe(''); + }); + + test('should handle partial change set', () => { + const changeSet: ChangeSet = { + newLocal: [{ name: 'LOCAL', value: 'val', source: 'local', encrypted: false }], + newRemote: [], + conflicts: [], + unchanged: [], + deleted: [], + deletedLocal: [] + }; + + const result = syncEngine.formatSyncSummary(changeSet); + + expect(result).toBe('1 new local variable(s)'); + }); + }); + + describe('validateDecisions', () => { + const changeSet: ChangeSet = { + newLocal: [ + { name: 'LOCAL1', value: 'val1', source: 'local', encrypted: false }, + { name: 'LOCAL2', value: 'val2', source: 'local', encrypted: false } + ], + newRemote: [ + { name: 'REMOTE1', value: 'val1', source: 'remote', encrypted: true } + ], + conflicts: [ + { name: 'CONFLICT1', localValue: 'local', remoteValue: 'remote' } + ], + unchanged: [], + deleted: [], + deletedLocal: [] + }; + + test('should validate correct decisions', () => { + const decisions: UserDecisions = { + pushVariables: ['LOCAL1'], + pullVariables: ['REMOTE1'], + keepLocal: ['CONFLICT1'], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = syncEngine.validateDecisions(decisions, changeSet); + + expect(errors).toHaveLength(0); + }); + + test('should detect invalid push variables', () => { + const decisions: UserDecisions = { + pushVariables: ['INVALID_VAR'], + pullVariables: [], + keepLocal: [], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = syncEngine.validateDecisions(decisions, changeSet); + + expect(errors).toContain('Cannot push INVALID_VAR: not a new local variable, conflict, or deleted variable'); + }); + + test('should detect invalid pull variables', () => { + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: ['INVALID_VAR'], + keepLocal: [], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = syncEngine.validateDecisions(decisions, changeSet); + + expect(errors).toContain('Cannot pull INVALID_VAR: not a new remote variable or locally deleted variable'); + }); + + test('should detect invalid conflict resolutions', () => { + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: [], + keepLocal: ['INVALID_CONFLICT'], + keepRemote: [], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = syncEngine.validateDecisions(decisions, changeSet); + + expect(errors).toContain('Cannot resolve INVALID_CONFLICT: not a conflict'); + }); + + test('should detect duplicate conflict resolutions', () => { + const decisions: UserDecisions = { + pushVariables: [], + pullVariables: [], + keepLocal: ['CONFLICT1'], + keepRemote: ['CONFLICT1'], + deleteLocal: [], + deleteRemote: [] + }; + + const errors = syncEngine.validateDecisions(decisions, changeSet); + + expect(errors).toContain('Duplicate conflict resolutions detected'); + }); + }); + describe('computeKeepHash', () => { it('produces deterministic hash', () => { const keep: KeepFile = { diff --git a/tests/system/systemStore.test.ts b/tests/system/systemStore.test.ts index 69c4e784..b0e26edb 100644 --- a/tests/system/systemStore.test.ts +++ b/tests/system/systemStore.test.ts @@ -302,21 +302,23 @@ describe('systemStore', () => { expect(reopened.get('_CONNECTOR_THEIRS_KEY')).toBe('theirs'); }); - it('CAP-712: an entry another admin changed since this handle opened is a SECRETS_CONFLICT, and nothing is pushed', async () => { + 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_SHARED_KEY', 'v-theirs'); + await theirs.set('_CONNECTOR_OTHER_KEY', 'o-theirs'); gc.saveSystemKeepFile(ORG_A, mineOnDisk); - const pushesBefore = serverFor(ORG_A).pushCount; - await expect(mine.set('_CONNECTOR_SHARED_KEY', 'v-mine')).rejects.toMatchObject({ code: ERROR_CODES.SECRETS_CONFLICT, details: { names: ['_CONNECTOR_SHARED_KEY'] } }); - expect(serverFor(ORG_A).pushCount).toBe(pushesBefore); - expect((await openSystemStore({ orgId: ORG_A, devMode: true })).get('_CONNECTOR_SHARED_KEY')).toBe('v-theirs'); + 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)', () => { 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 index 1e8cd13e..b6d4b2cf 100644 --- a/tests/ui/secretsDeployConflicts.test.ts +++ b/tests/ui/secretsDeployConflicts.test.ts @@ -4,7 +4,7 @@ * 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; - * - mine delivers Capy's value, theirs delivers nothing for that variable and records nothing for it; + * - 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. */ @@ -28,25 +28,25 @@ const [row] = indexRows(); const LIVE = 'deadbeefdeadbeef'; const MOVED_AGAIN = 'cafecafecafecafe'; -const conflictFor = (theirs: string = LIVE): TargetConflict => ({ kind: 'target', name: NAME, target: 'api', pinned: null, yours: hashOf(NAME), theirs }); -const decision = (choice: 'mine' | 'theirs', theirs: string = LIVE) => ({ 'API_KEY@api': { choice, theirs, yours: hashOf(NAME) } }); +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 = - (theirs: string, liveAtWrite: string = theirs) => + (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 ? theirs : liveAtWrite; + const now = ctx.dryRun ? remote : liveAtWrite; const seen = ctx.targetCheck.resolutions[NAME]; - const answer = seen !== undefined && seen.theirs === now && seen.yours === hashOf(NAME) ? seen.choice : undefined; + 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 === 'theirs' + return answer === 'remote' ? { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }], skipped: [NAME] } : { ok: true, steps: [{ label: 'application.saveEnvironment', status: 'ok' }] }; }; -const liveAdapter = (theirs: string = LIVE, liveAtWrite: string = theirs) => Object.assign(fakeAdapter({ deploy: liveScript(theirs, liveAtWrite) }), { readsLiveValues: true }); +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() { @@ -95,18 +95,18 @@ describe('the real actions: found before anything is delivered', () => { Object.values(VALUES).forEach((v) => expect(JSON.stringify(finished)).not.toContain(v)); }); - test('mine: every target is delivered, the answer handed to the adapter bound to the live value it was made against', async () => { + 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('mine')); + 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: 'mine', theirs: LIVE, yours: hashOf(NAME) } }); + expect(api?.[1].targetCheck?.resolutions).toEqual({ [NAME]: { choice: 'local', remote: LIVE, local: hashOf(NAME) } }); expect(r.github.createPull.mock.calls).toHaveLength(3); }); - test('theirs: that variable is not delivered and not recorded for api (the target reports it kept on the platform)', async () => { + 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('theirs')); + 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']); @@ -116,14 +116,14 @@ describe('the real actions: found before anything is delivered', () => { 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('mine', LIVE)); + 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: 'mine', theirs: LIVE, yours: 'someotherhash0000' } }); + 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); }); @@ -140,13 +140,13 @@ 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('theirs'))([scope])).toEqual({ 'API_KEY@api': 'theirs' }); + 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('mine', 'old'))([scope]); + decideFrom(decision('local', 'old'))([scope]); return undefined; } catch (e) { return e; @@ -170,37 +170,37 @@ describe('the flow step', () => { 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('m mine'); - expect(strip(renderDeploy(flow, 120).footer)).toContain('t theirs'); + 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, 'm').flow as DeployFlow; + 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('mine') }); + 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(), 'm').flow as DeployFlow, ESC); + 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(), 'm').flow as DeployFlow, '\r').flow as DeployFlow; + 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': 'mine' }); + expect(same.choices).toEqual({ 'API_KEY@api': 'local' }); }); test('a plain failure is still the failure line', () => { @@ -248,21 +248,21 @@ describe('the whole TUI: Ctrl+D, Enter, then the conflict', () => { nothingWritten(s.r); }); - test('mine, Enter: delivered', async () => { - const s = await session(['m', '\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('theirs, Enter: api is skipped (kept on the platform), the others are delivered', async () => { - const s = await session(['t', '\r']); + 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(['m', '\r'], movingAdapter()); + 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); diff --git a/tests/ui/secretsEditConflicts.test.ts b/tests/ui/secretsEditConflicts.test.ts index acad1aec..864d989c 100644 --- a/tests/ui/secretsEditConflicts.test.ts +++ b/tests/ui/secretsEditConflicts.test.ts @@ -60,18 +60,18 @@ const loc = (p: 'p1' | 'p2'): SetLocation => ? { 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', theirs: string, changedAt?: string): LocationConflict => ({ +const conflict = (p: 'p1' | 'p2', remote: string, changedAt?: string): LocationConflict => ({ location: loc(p), conflict: { - kind: 'source', name: 'API_KEY', project: loc(p).project_name, branch: loc(p).branch, + type: 'changed', pinned: ROW_HASH, - yours: 'yours-fp', - theirs, - ...(changedAt === undefined ? {} : { theirs_changed_at: changedAt }), + local: 'local-fp', + remote, }, + ...(changedAt === undefined ? {} : { changedAt }), }); const found = (...conflicts: LocationConflict[]) => ({ ok: false as const, code: ERROR_CODES.SECRETS_CONFLICT, conflicts }); @@ -90,12 +90,12 @@ describe('a run that finds conflicts', () => { expect(text).toContain('web production'); expect(text).toContain('api development'); expect(text).toContain('changed since you looked in 2 locations'); - expect(text).toContain('m mine'); - expect(text).toContain('t theirs'); + 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('yours-fp'); + expect(text).not.toContain('local-fp'); }); test('says when the other value was set, if the service says', () => { @@ -113,30 +113,30 @@ 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(), 'm', DOWN, 't'); + const decided = press(atConflicts(), 'l', DOWN, 'r'); const lines = frame(decided).split('\n'); - expect(lines.find((l) => l.includes('web production'))).toContain('mine'); - expect(lines.find((l) => l.includes('api development'))).toContain('theirs'); + 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(), 'm'); + 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, 't'))).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(), 'm', DOWN, 't'); + 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: 'mine', theirs: 'aaaa', yours: 'yours-fp' }, - 'p2/development': { choice: 'theirs', theirs: 'bbbb', yours: 'yours-fp' }, + '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']); @@ -144,7 +144,7 @@ describe('deciding', () => { }); test('Esc leaves with nothing written: no effect, the list is back with the cancelled note', () => { - const left = handleKey(press(atConflicts(), 'm'), ESC); + const left = handleKey(press(atConflicts(), 'l'), ESC); expect(left.effect).toBeNull(); expect(left.state.edit).toBeNull(); expect(frame(left.state)).toContain('Cancelled'); @@ -158,7 +158,7 @@ describe('deciding', () => { }); 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'))), 'm', DOWN, 't'), ENTER); + 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(); @@ -166,8 +166,8 @@ describe('a decision never carries over to a value it was not made against', () 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(/mine|theirs/); - expect(lines.find((l) => l.includes('api development'))).toContain('theirs'); + 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(); }); diff --git a/tests/ui/secretsEditConflictsDriver.test.ts b/tests/ui/secretsEditConflictsDriver.test.ts index e227cecc..182145d2 100644 --- a/tests/ui/secretsEditConflictsDriver.test.ts +++ b/tests/ui/secretsEditConflictsDriver.test.ts @@ -1,7 +1,7 @@ /** * 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: mine writes it, theirs leaves it alone. + * 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'; @@ -64,14 +64,14 @@ describe('capy secrets edit, with a location someone else changed', () => { expect(s.frames).not.toContain('moved-by-someone-else-2222'); }); - test('mine: both locations are written, and the confirmation says so', async () => { - const s = await session(['m', '\r']); + 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('theirs: only the other location is written; the confirmation notes the one that kept its value', async () => { - const s = await session(['t', '\r']); + 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.'); }); From d79a596bb32ab595f6e386e3e537dfae8022d9f8 Mon Sep 17 00:00:00 2001 From: cvince Date: Thu, 15 Oct 2026 22:43:32 -0700 Subject: [PATCH 09/12] fix(sync): gate every remaining write; refuse answers with no value behind them - capy deploy (token + docs), deploy revoke, deploy targets-remove and deploy --discover run bare capy first. - capy connect --discover gates unconditionally, and runs bare capy in each discovered folder before writing into it. - Off a terminal, an answer whose source has no value for that row is refused (SECRETS_RESOLVE_INVALID) instead of deleting the variable. - capy secrets set offers pinned / local / remote / delete; pinned writes back the shown value when it can be recovered. - No active branch off a terminal is NO_ACTIVE_BRANCH with a hint. - Bootstrap branches on the NO_SECRETS code, not the message. --- docs/cli-reference.md | 31 ++- src/commands/capyCommand.ts | 26 ++- src/commands/connectCommand.ts | 29 ++- src/commands/connectors/dokploy.ts | 27 ++- src/commands/connectors/dokployDiscovery.ts | 25 ++ src/commands/deployCommand.ts | 7 +- src/commands/deployDiscover/route.ts | 9 + src/commands/deployTokenCommand.ts | 28 ++- src/commands/secretsSet.ts | 65 +++++- src/commands/secretsSetCommand.ts | 55 +++-- src/commands/secretsSetText.ts | 4 + src/core/cliHelpDoc.ts | 2 +- src/index-dev.ts | 19 +- src/index.ts | 17 +- src/sync/conflicts.ts | 2 +- src/sync/gate.ts | 99 +++++++- src/sync/noFolder.ts | 26 ++- src/ui/secretsEditFlow.ts | 23 +- tests/commands/bootstrapNoSecrets.test.ts | 72 ++++++ tests/commands/conflictProtocol.test.ts | 177 +++++++++++++- .../commands/connectDokployDiscovery.test.ts | 220 +++++++++++++++++- tests/commands/deployBranchCheck.test.ts | 217 +++++++++-------- tests/commands/gateFirst.test.ts | 12 +- tests/commands/secretsEditConflicts.test.ts | 45 ++++ tests/helpers/pipedHarness.ts | 7 + tests/run-tests.sh | 1 + tests/sync/gate.test.ts | 91 +++++++- tests/sync/noFolder.test.ts | 15 +- tests/ui/secretsEditConflicts.test.ts | 36 +++ 29 files changed, 1185 insertions(+), 202 deletions(-) create mode 100644 tests/commands/bootstrapNoSecrets.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f9df12d4..270ab2a0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -48,7 +48,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -98,7 +98,7 @@ capy push [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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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 @@ -129,7 +129,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -150,10 +150,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 @@ -192,6 +196,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 @@ -559,7 +566,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -581,7 +588,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -665,7 +672,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -688,7 +695,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -721,7 +728,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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`) @@ -744,7 +751,7 @@ 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 (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict) | `[]` | +| `--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 @@ -956,4 +963,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, deploy) runs bare `capy` first. 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. +- **Conflicts**: Every command that writes from a folder (edit, add, remove, connect, rotate, push, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first. 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/commands/capyCommand.ts b/src/commands/capyCommand.ts index e85fa774..0221c2c7 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -288,6 +288,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([{ @@ -978,8 +986,8 @@ export class CapyCommand { ); if (!fetched.ok) { const err = fetched.err; - // 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)) { + // 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', @@ -1771,14 +1779,16 @@ export class CapyCommand { { dryRun: this.options.dryRun === true }, ); const answered = this.mapResolveChoicesToEnv(answers, diffs, pinned, localPlaintext, remotePlaintext, pinnedPlaintext); - // `pinned` for a value whose pinned snapshot cannot be read here would drop the variable (the table marks it - // "unresolvable" and never picks it for anyone): off a terminal that is refused, not guessed. `delete` says delete. - const unreadable = Object.entries(answers).filter(([name, choice]) => choice === 'pinned' && pinned[name] !== undefined && !Object.hasOwn(answered, name)); - if (unreadable.length > 0) { + // 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( - `${unreadable.map(([name]) => name).join(', ')}: the pinned value cannot be read here, so pinned would delete it. Answer local, remote or delete.`, // COPY-FLAG + `${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: unreadable.map(([name]) => name) }, + { names: valueless.map(([name]) => name) }, ); } return { kind: 'proceed', finalEnv: answered }; diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 68ff0113..dd66202a 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -13,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`, @@ -81,16 +92,19 @@ export class ConnectCommand { // 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: effective.json === true, - nonTty: effective.nonTty, + json: discovering || effective.json === true, + nonTty: discovering || effective.nonTty, conflictFlags: effective.conflictFlags, dryRun: effective.dryRun === true, }; - // Bare `capy` runs before anything else: the folder and Capy must agree before this writes (docs/conflict-resolution.md). - // Discovery is the one exception: it writes other checkouts than this folder, and applies its own plan there. - if (!(effective.discover && mod.discover)) await gateFirst(gate); + // 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(); @@ -393,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, @@ -421,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 3773cf8a..c03d2e07 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -64,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'; @@ -370,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; @@ -1244,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, @@ -1815,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 { @@ -1874,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 }), @@ -1900,11 +1912,17 @@ async function importIntoBranchSafe( await writeImportOutcome(resolvedCtx, outcome, { push: !runOpts.noPush, dryRun: false }); } catch (err) { // A refused-as-stale upload keeps its own code; anything else is the push failing. - return { ok: false, code: isStaleBase(err) ? ERROR_CODES.SECRETS_STALE_BASE : 'DOKPLOY_PUSH_FAILED', message: err instanceof Error ? err.message : String(err) }; + 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 @@ -1920,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; /** @@ -1943,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/deployCommand.ts b/src/commands/deployCommand.ts index 8a60c187..32451983 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -1626,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. 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 5cff6138..6f9ed6a8 100644 --- a/src/commands/deployTokenCommand.ts +++ b/src/commands/deployTokenCommand.ts @@ -22,6 +22,8 @@ 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 @@ -330,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. */ @@ -483,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(); @@ -598,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/secretsSet.ts b/src/commands/secretsSet.ts index 99889e99..72df0434 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -34,9 +34,9 @@ * and nothing here ever prints, returns or throws the value or any hash of it. */ import { SyncEngine } from '../sync/syncEngine'; -import { UploadIo, envBlobOf, isStaleBase, snapshotFrom, uploadChanges } from '../sync/commit'; +import { UploadIo, envBlobOf, isStaleBase, snapshotFrom, uploadChanges, type Changes } from '../sync/commit'; import type { SourceConflict } from '../sync/conflicts'; -import { BoundAnswer, actionFor, holdsFor, movedRow } from '../sync/noFolder'; +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'; @@ -105,6 +105,8 @@ export interface SetRequest { 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; } @@ -126,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' }>; @@ -288,6 +292,7 @@ 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 }; function parseKeepJson(raw: string | undefined): KeepFile | undefined { @@ -355,7 +360,7 @@ async function pushLocation( loc: SetLocation, name: string, value: string, - check: { readonly shown?: string; readonly answer?: BoundAnswer }, + check: { readonly shown?: string; readonly answer?: BoundAnswer; readonly restore?: string }, retried: boolean = false, ): Promise> { const key = await settled(keys(loc.project_id)); @@ -385,12 +390,13 @@ async function pushLocation( remote: entry?.hash, serverKeepHash: SyncEngine.computeKeepHash(snapshot.keep, loc.branch), }); - if (moved !== undefined) { - if (!holdsFor(check.answer, moved)) return failedItem(loc, new CapyError('changed since it was read', ERROR_CODES.SECRETS_CONFLICT), readRate); - const action = attemptAction(check.answer.choice); - if (!action.ok) return failedItem(loc, action.error, readRate); - if (action.value === 'leave') return withRate({ kind: 'unchanged', location: loc }, readRate); - } + 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({ @@ -399,7 +405,7 @@ async function pushLocation( branch: loc.branch, projectKey: key.value, startKeep: snapshot.keep, - changes: { set: { [name]: value }, remove: [] }, + changes: changesFor(action, name, value, check.restore), seen: snapshot, io, }), @@ -419,6 +425,7 @@ async function pushLocation( 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 } @@ -436,7 +443,41 @@ function attemptSync(f: () => T): Tried { } } -const attemptAction = (choice: Parameters[0]): Tried> => attemptSync(() => actionFor(choice)); +/** 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'; @@ -682,6 +723,7 @@ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks 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, @@ -737,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 1db4e403..ee4feb34 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -51,6 +51,8 @@ import { MAX_PIPED_BYTES, PipedValueResult, readPipedValue, refuseInvalidName, e import { isValidVarName } from './pipedWrite'; import { rowIdOf } from './secretsRowId'; import { + KeyResolver, + LocationConflict, LocationPreview, LocationRef, RepoTarget, @@ -69,8 +71,9 @@ import { runSecretSet, locationKeyOf, movedLocations, + recoverShownValue, } from './secretsSet'; -import { actionFor, bindAnswer, type BoundAnswer } from '../sync/noFolder'; +import { bindAnswer, shownValueUnavailable, type BoundAnswer } from '../sync/noFolder'; import { renderSecretSetConfirmation, stoppingAfter, TERMINAL_STYLE } from './secretsSetText'; import { PoolItem, runPool } from '../utils/pool'; @@ -167,16 +170,6 @@ export function decideOrExit(scopes: readonly ConflictScope[], flags: ConflictFl } } -/** The refusal for an answer that setting a value cannot honour (`pinned`, `delete`), or `undefined` when it can. */ -function unusableAnswer(choice: Parameters[0]): { readonly code: string; readonly message: string } | undefined { - try { - actionFor(choice); - return undefined; - } catch (err) { - return describe(err); - } -} - export function describe(err: unknown): { readonly code: string; readonly message: string } { return err instanceof CapyError ? { code: err.code, message: err.message } @@ -441,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 @@ -467,7 +485,8 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr 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 } @@ -524,14 +543,19 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr const decided = await decideOrExit(scopes, flags); const moved = movedLocations(name, value, located, shownOf(located), reads); const choice = decided[name]; - const unusable = choice === undefined || moved.length === 0 ? undefined : unusableAnswer(choice); - if (unusable !== undefined) return refuse(json, unusable.code, unusable.message); + // `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, keys }, io.env, { + 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. @@ -551,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/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index 82986fe5..eb593b84 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -177,7 +177,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { 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, deploy) runs bare `capy` first. 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.', + 'Every command that writes from a folder (edit, add, remove, connect, rotate, push, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first. 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/index-dev.ts b/src/index-dev.ts index cfc02314..e70cd7d7 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -351,7 +351,7 @@ const deploy = program // `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). @@ -377,6 +377,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(); }); @@ -384,10 +387,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 @@ -416,10 +422,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) }), ); }); diff --git a/src/index.ts b/src/index.ts index 7bbe8aa1..9cdc7e71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -390,7 +390,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. @@ -427,6 +427,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(); }); @@ -434,11 +437,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 @@ -464,6 +470,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'); @@ -471,6 +480,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), }), ); }); diff --git a/src/sync/conflicts.ts b/src/sync/conflicts.ts index 78340691..4906647a 100644 --- a/src/sync/conflicts.ts +++ b/src/sync/conflicts.ts @@ -317,7 +317,7 @@ const collectResolve = (value: string, previous: string[]): string[] => [...prev export const resolveOption = (): Option => new Option( '--resolve ', - 'answer a difference from Capy: pinned, local, remote or delete (secrets set: local or remote); NAME@TARGET=local|remote for a platform value (repeatable, needs --conflict)', // COPY-FLAG + '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); diff --git a/src/sync/gate.ts b/src/sync/gate.ts index a41b8668..49c7203c 100644 --- a/src/sync/gate.ts +++ b/src/sync/gate.ts @@ -1,8 +1,8 @@ /** * 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`, `rotate`, `push`, `deploy`) - * calls `gateFirst` as its VERY FIRST step, before any prompt, preflight, plan or confirm screen, and nothing + * Every command that writes from a folder (`capy edit`, `add`, `remove`, `connect` (discovery too), `rotate`, `push`, + * `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. 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. @@ -15,8 +15,16 @@ * 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, parseConflictFlags, type RawConflictFlags } from './conflicts'; +import { + exitOnConflictRefusal, + hasConflictFlags, + isConflictRefusal, + parseConflictFlags, + type ConflictPayload, + type RawConflictFlags, +} from './conflicts'; import { commitFolder, isStaleBase, @@ -41,6 +49,13 @@ export interface GateOptions { 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). */ @@ -63,11 +78,33 @@ export function answersByProtocol(opts: GateOptions): boolean { return opts.dryRun === true || opts.json === true || !isInteractive(opts.nonTty) || givesFlags(opts.conflictFlags ?? {}); } -/** The real runner: a fresh `CapyCommand` doing exactly what `capy` does. */ -const runBareCapy: SyncRunner = async (opts, protocol) => { - const { CapyCommand } = await import('../commands/capyCommand'); - return new CapyCommand({ protocol, silentAuthOnly: protocol, quiet: true, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun }, opts.devMode === true).runSync(); -}; +/** + * `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'); + return new CapyCommand({ protocol, silentAuthOnly: protocol, quiet: true, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun }, opts.devMode === true).runSync(); + }, + 'up-to-date', + ); async function repeat(run: () => Promise): Promise { const outcome = await run(); @@ -94,10 +131,42 @@ const outcomeOf = (work: Promise) => export async function ensureInSync(opts: GateOptions = {}, runner: SyncRunner = runBareCapy): Promise { const done = await outcomeOf(syncUntilInSync(opts, runner)); if (done.ok) return done.value; - exitOnConflictRefusal(done.error); + 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 @@ -107,11 +176,16 @@ export function stopWithNothingWritten(): never { /** 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) { - const coded = err instanceof CapyError ? err : new CapyError('The sync did not complete.', ERROR_CODES.SERVICE_ERROR); // COPY-FLAG 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); } @@ -138,8 +212,9 @@ function reread(ctx: C): C { * `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` (a folder other than the working directory, which bare - * `capy` would not be run for) throws the stale refusal as it is. + * (`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, diff --git a/src/sync/noFolder.ts b/src/sync/noFolder.ts index 119c44f0..96aa7001 100644 --- a/src/sync/noFolder.ts +++ b/src/sync/noFolder.ts @@ -13,9 +13,11 @@ * 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, - * pinned, delete → not available here (`actionFor`): setting a value has nothing to write back from a fingerprint and - * no meaning for a removal. + * 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. */ @@ -58,20 +60,22 @@ export function movedRow(w: WriteComparison): SourceConflict | undefined { } /** What an answer does at a location. */ -export type LocationAction = 'write' | 'leave'; +export type LocationAction = 'write' | 'leave' | 'restore' | 'remove'; /** - * `local` writes the new value, `remote` leaves the location as it is. These two are what setting a value can mean; - * `pinned` (the value that was shown is a fingerprint only: nothing to write back) and `delete` (removing a variable is - * not setting it) are refused with a code, never guessed. + * `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 === 'pinned' || choice === 'delete') { - throw new CapyError(`${choice} is not available when setting a value without a folder; answer local or remote.`, ERROR_CODES.SECRETS_RESOLVE_INVALID); // COPY-FLAG - } - return choice === 'local' ? 'write' : 'leave'; + 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; diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index 569cf9ea..a68a7ccf 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -20,10 +20,12 @@ // 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 choices as far as setting a value -// allows: l writes the new value there (local), r leaves the location (remote). 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. +// 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. @@ -152,6 +154,8 @@ export interface RunRequest { 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 = @@ -404,6 +408,7 @@ export const allDecided = (flow: Extract): bool /** 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( @@ -412,6 +417,11 @@ function withDecisions(flow: Extract): RunReque 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)), }; } @@ -425,6 +435,9 @@ function stepConflicts(flow: Extract, key: stri }; 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; @@ -596,7 +609,7 @@ function renderConflicts(flow: Extract, maxWidt '', ...flow.conflicts.map(row), ], - footer: hint(['l', 'local'], ['r', 'remote'], ...(allDecided(flow) ? ([['enter', 'continue']] as const) : []), ['esc', 'cancel']), // COPY-FLAG + 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 }; } 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 index 5c2784e9..40032ed0 100644 --- a/tests/commands/conflictProtocol.test.ts +++ b/tests/commands/conflictProtocol.test.ts @@ -67,6 +67,24 @@ function seedServer(h: Harness, values: Readonly>): void 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'); @@ -382,13 +400,56 @@ describe('capy secrets set NAME with no folder (built cli)', () => { expect(JSON.parse(push?.body ?? '{}').base_keep_hash).toMatch(/^[0-9a-f]{64}$/); }); - test('pinned and delete are not answers to setting a value: refused with a code, nothing pushed', async () => { + 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 () => { @@ -410,3 +471,117 @@ describe('capy secrets set NAME with no folder (built cli)', () => { 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); + }); +}); 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/deployBranchCheck.test.ts b/tests/commands/deployBranchCheck.test.ts index 6238fb6d..aee35605 100644 --- a/tests/commands/deployBranchCheck.test.ts +++ b/tests/commands/deployBranchCheck.test.ts @@ -152,129 +152,152 @@ describe('capy deploy — branch check (CAP-679)', () => { }); }); - test('no active-branch signal at all: bare capy, which runs first, cannot pick a branch, so the deploy never reaches its own check', async () => { + 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 — nothing to pick one by: no header, no .capy/branch (and the mock service lists no branches) + // 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'); }); }); + + 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', - }); - 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 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 = capy(['deploy', 'targets-remove', 'dokploy-target']); - expect(r.code).toBe(0); // local removal still succeeds - expect(r.stderr).toContain('DEPLOY_BRANCH_MISMATCH'); + 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 = 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); + 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); + }); }); - 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/gateFirst.test.ts b/tests/commands/gateFirst.test.ts index d24fae6e..4052830b 100644 --- a/tests/commands/gateFirst.test.ts +++ b/tests/commands/gateFirst.test.ts @@ -120,7 +120,9 @@ 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 } from '../../src/commands/deployCommand'; +import { deployCommand, deployRemove } from '../../src/commands/deployCommand'; +import { DeployCommand, DeployRevokeCommand } from '../../src/commands/deployTokenCommand'; +import { routeDeployDiscover } from '../../src/commands/deployDiscover/route'; class ExitSignal extends Error { constructor(public readonly code: number | undefined) { @@ -202,6 +204,14 @@ const COMMANDS: ReadonlyArray Promise, boolean] ['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], ]; describe.each(COMMANDS)('%s', (_name, run, tty) => { diff --git a/tests/commands/secretsEditConflicts.test.ts b/tests/commands/secretsEditConflicts.test.ts index 93eecdd1..ca1fea64 100644 --- a/tests/commands/secretsEditConflicts.test.ts +++ b/tests/commands/secretsEditConflicts.test.ts @@ -153,6 +153,51 @@ describe('local and remote', () => { }); }); +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); diff --git a/tests/helpers/pipedHarness.ts b/tests/helpers/pipedHarness.ts index b03635d3..eda340dc 100644 --- a/tests/helpers/pipedHarness.ts +++ b/tests/helpers/pipedHarness.ts @@ -79,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). */ @@ -203,6 +205,10 @@ 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') ?? ''; @@ -291,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() diff --git a/tests/run-tests.sh b/tests/run-tests.sh index cf6f39a7..ef59e404 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -67,6 +67,7 @@ ISOLATED_FILES=( 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 diff --git a/tests/sync/gate.test.ts b/tests/sync/gate.test.ts index ec867563..d962aa3f 100644 --- a/tests/sync/gate.test.ts +++ b/tests/sync/gate.test.ts @@ -4,12 +4,14 @@ * built cli, and tests/commands/bareCapyCommit.test.ts through `CapyCommand`). */ import { describe, test, expect, spyOn, mock, beforeAll, afterAll, afterEach } from 'bun:test'; -import { rmSync } from 'fs'; +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 { answersByProtocol, commitGated, ensureInSync, gateFirst, stopWithNothingWritten, type SyncRunner } from '../../src/sync/gate'; +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'; @@ -226,7 +228,23 @@ describe('commitGated: the command\'s own change, and on a stale refusal bare ca expect(serverValues(w.server.state(), DEV, KEY).Z).toBeUndefined(); }); - test('a context with no gate (a folder other than the working directory) is not retried: the stale refusal is thrown', async () => { + 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 }); @@ -234,6 +252,73 @@ describe('commitGated: the command\'s own change, and on a stale refusal bare ca }); }); + +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(); diff --git a/tests/sync/noFolder.test.ts b/tests/sync/noFolder.test.ts index fb757577..09369828 100644 --- a/tests/sync/noFolder.test.ts +++ b/tests/sync/noFolder.test.ts @@ -4,7 +4,7 @@ * 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, type WriteComparison } from '../../src/sync/noFolder'; +import { bindAnswer, actionFor, holdsFor, movedRow, shownValueUnavailable, type WriteComparison } from '../../src/sync/noFolder'; import { CapyError, ERROR_CODES } from '../../src/types/index'; const base: WriteComparison = { @@ -46,11 +46,18 @@ describe('movedRow', () => { describe('answers', () => { const row = movedRow({ ...base, remote: 'cccccccccccccccc' }); - test('local writes, remote leaves the location; pinned and delete are refused with a code', () => { + 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')).toThrow(expect.objectContaining({ code: ERROR_CODES.SECRETS_RESOLVE_INVALID })); - expect(() => actionFor('delete')).toThrow(CapyError); + 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', () => { diff --git a/tests/ui/secretsEditConflicts.test.ts b/tests/ui/secretsEditConflicts.test.ts index 864d989c..9340e0e2 100644 --- a/tests/ui/secretsEditConflicts.test.ts +++ b/tests/ui/secretsEditConflicts.test.ts @@ -9,6 +9,7 @@ import { SecretsScreenState, applyRepos, applyRunDone, + applyValueResult, handleKey, initialSecretsScreenState, render, @@ -157,6 +158,41 @@ describe('deciding', () => { }); }); +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); From fa148b8823ee420cc10f646009c2938f827f2577 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 16 Oct 2026 06:21:19 -0700 Subject: [PATCH 10/12] fix(sync): capy checkout and capy org run bare capy first; checkout -b is the one exception Switching to an existing branch and re-linking the folder to another organization both rewrite the folder's keep.lock, so they run bare capy first like every folder write. capy checkout -b does not: creating a branch carries the folder's uncommitted values onto a brand-new branch, and bare capy offers to push them there on the next sync. --- docs/cli-reference.md | 13 +++++++++--- src/commands/checkoutCommand.ts | 16 ++++++++++++++ src/commands/orgCommand.ts | 11 +++++++++- src/core/cliHelpDoc.ts | 2 +- src/index-dev.ts | 12 +++++++++-- src/index.ts | 13 ++++++++++-- tests/commands/gateFirst.test.ts | 28 ++++++++++++++++++++++--- tests/commands/roleAccessGuards.test.ts | 8 +++++++ 8 files changed, 91 insertions(+), 12 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 270ab2a0..a500a06f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -84,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`) @@ -467,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 @@ -963,4 +970,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, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first. 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. +- **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/commands/checkoutCommand.ts b/src/commands/checkoutCommand.ts index 8ddb28ee..713d3cbe 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,6 +144,14 @@ 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). + if (!options.create) { + conflictFlagsOrExit(options.conflictFlags ?? {}, options.json === true); + 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) { 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/core/cliHelpDoc.ts b/src/core/cliHelpDoc.ts index eb593b84..acaa0606 100644 --- a/src/core/cliHelpDoc.ts +++ b/src/core/cliHelpDoc.ts @@ -177,7 +177,7 @@ export function buildCliHelpDoc(program: Command): CliHelpDoc { 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, deploy, deploy revoke, deploy targets-remove) runs bare `capy` first. 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.', + '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/index-dev.ts b/src/index-dev.ts index e70cd7d7..4092b790 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -262,12 +262,17 @@ 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), }); }); @@ -731,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 diff --git a/src/index.ts b/src/index.ts index 9cdc7e71..fc8f8d76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,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'); @@ -331,6 +334,9 @@ program await cmd.execute(branch, { create: options.create, protected: options.protected, + json: options.json === true, + nonTty: options.nonTty === true, + conflictFlags: rawConflictFlagsOf(command), }); }); @@ -760,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 diff --git a/tests/commands/gateFirst.test.ts b/tests/commands/gateFirst.test.ts index 4052830b..66e89ee8 100644 --- a/tests/commands/gateFirst.test.ts +++ b/tests/commands/gateFirst.test.ts @@ -49,12 +49,20 @@ mock.module('../../src/commands/connectors/shared', () => ({ 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. +// 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() { - event('auth'); - throw new Halt('auth'); + return new Proxy(this, { + get: (_target, prop) => + typeof prop === 'string' && prop !== 'then' + ? () => { + event('auth'); + throw new Halt('auth'); + } + : undefined, + }); } }, silentAuthFailureMessage: () => 'no session', @@ -123,6 +131,8 @@ 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) { @@ -212,8 +222,20 @@ const COMMANDS: ReadonlyArray Promise, boolean] ['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(); 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(); }); }); From 647dd00e5c7f90836f54065d0644f83ca675d8bb Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 16 Oct 2026 07:34:37 -0700 Subject: [PATCH 11/12] fix(sync): recover interrupted checkouts through the gate; per-folder discovery failures; recovery-mode checkout - As another command's first step, bare capy compares against the branch the .env was written for when .capy/branch disagrees, so capy checkout can recover an interrupted checkout. Run directly it keeps the recovery hint. - Inside connect --discover that state is BRANCH_STATE_INCONSISTENT for the folder, and the run continues. - capy checkout in seed-phrase recovery mode switches offline. - The gate recovers a deleted keep.lock from .env metadata like bare capy does. - Real-gate tests for rotate, connect, deploy targets-remove, checkout and org. --- docs/cli-reference.md | 1 + src/commands/capyCommand.ts | 44 ++++--- src/commands/checkoutCommand.ts | 10 +- src/sync/gate.ts | 17 ++- src/types/index.ts | 11 ++ tests/commands/conflictProtocol.test.ts | 145 +++++++++++++++++++++++- tests/helpers/syncFoldersChild.ts | 20 ++++ tests/sync/gateTornFolders.test.ts | 112 ++++++++++++++++++ 8 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 tests/helpers/syncFoldersChild.ts create mode 100644 tests/sync/gateTornFolders.test.ts diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a500a06f..f3e60ab7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -864,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` diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index 0221c2c7..9062640c 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -185,17 +185,9 @@ export class CapyCommand { // A bad --resolve / --conflict is refused before anything is read. conflictFlagsOrExit(this.options.conflictFlags ?? {}, false); try { - // Detect project state - const detected = await this.projectManager.detectProjectState(); + const projectState = await this.detectOrRecoverProjectState(); - // Check if .env has metadata we can recover from (e.g. keep.lock was deleted) - const envMeta = detected.initialized ? undefined : this.fileManager.readEnvMeta(this.options.envPath); - const recovered: ProjectState | undefined = - envMeta?.org_id && envMeta.project_id - ? { ...detected, initialized: true, organizationId: envMeta.org_id, projectId: envMeta.project_id, activeBranch: envMeta.branch ?? null } - : undefined; - - if (!detected.initialized && recovered === undefined) { + if (projectState === undefined) { if (isLocalOnly()) { // Local-only mode: bootstrap a project entirely on this machine // (synthetic org, generated projectId) instead of server onboarding. @@ -205,7 +197,6 @@ export class CapyCommand { await this.initializeProject(); return; } - const projectState = recovered ?? detected; await this.syncProject(projectState); // CAP-697: auth is established by now. Reporting never changes this command's output or exit code. @@ -236,8 +227,23 @@ export class CapyCommand { * alone: the command that called it refuses that on its own. */ async runSync(): Promise { - const projectState = await this.projectManager.detectProjectState(); - return projectState.initialized ? this.syncProject(projectState) : 'up-to-date'; + 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; } /** @@ -340,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)}`); diff --git a/src/commands/checkoutCommand.ts b/src/commands/checkoutCommand.ts index 713d3cbe..8bb4878e 100644 --- a/src/commands/checkoutCommand.ts +++ b/src/commands/checkoutCommand.ts @@ -147,16 +147,20 @@ export class CheckoutCommand { // 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); - const gate: GateOptions = { devMode: this.devMode, json: options.json, nonTty: options.nonTty, conflictFlags: options.conflictFlags }; - await gateFirst(gate); + 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/sync/gate.ts b/src/sync/gate.ts index 49c7203c..3ed9a189 100644 --- a/src/sync/gate.ts +++ b/src/sync/gate.ts @@ -2,10 +2,13 @@ * 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`, - * `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. 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. + * `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 @@ -101,7 +104,11 @@ const runBareCapy: SyncRunner = (opts, protocol) => opts.cwd, async () => { const { CapyCommand } = await import('../commands/capyCommand'); - return new CapyCommand({ protocol, silentAuthOnly: protocol, quiet: true, conflictFlags: opts.conflictFlags, dryRun: opts.dryRun }, opts.devMode === true).runSync(); + // 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', ); diff --git a/src/types/index.ts b/src/types/index.ts index d1fdeeef..56d195dc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -396,6 +396,15 @@ export interface CliOptions { 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 { @@ -454,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', diff --git a/tests/commands/conflictProtocol.test.ts b/tests/commands/conflictProtocol.test.ts index 40032ed0..6b12e75f 100644 --- a/tests/commands/conflictProtocol.test.ts +++ b/tests/commands/conflictProtocol.test.ts @@ -20,7 +20,7 @@ * 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 } from 'node:fs'; +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'; @@ -584,4 +584,147 @@ describe('every other write path meets bare capy first (built cli)', () => { 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/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/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); + }); +}); From 328d9acba2e7720b4a671f440be7f072ea280c49 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 18 Oct 2026 02:10:30 -0700 Subject: [PATCH 12/12] docs: 0.9.12 conflict resolution in the README, capy agents block and deploy --yes help --- README.md | 21 +++++++++++++++++++-- docs/cli-reference.md | 2 +- src/core/agentsBlockPlan.ts | 3 +++ src/index-dev.ts | 2 +- src/index.ts | 2 +- tests/core/agentsBlockPlan.test.ts | 3 +++ 6 files changed, 28 insertions(+), 5 deletions(-) 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/docs/cli-reference.md b/docs/cli-reference.md index f3e60ab7..94f9aac0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -117,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 | | 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/index-dev.ts b/src/index-dev.ts index 4092b790..339a1b25 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -336,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)') diff --git a/src/index.ts b/src/index.ts index fc8f8d76..981d2a82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -370,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') 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.', '',