From 7fb72201fb678892380e4683ca7299472860a01d Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 4 Oct 2026 11:24:33 -0700 Subject: [PATCH 01/10] feat(secrets): list repos that needed no PR under a Skipped heading A multi-edit only opens a PR in a repo where something changed. Repos where every location already had the value, or whose keep.lock on the base branch was already up to date, used to vanish from the confirmation. They are now listed under a "Skipped" heading with a one-line description, repo names grey in a terminal (plain for agents). `capy secrets set --json` gains `skipped` (only when non-empty). --- src/commands/secretsSet.ts | 21 ++++++++++++ src/commands/secretsSetCommand.ts | 5 +-- src/commands/secretsSetText.ts | 28 +++++++++++++-- src/ui/secretsEditFlow.ts | 4 +-- tests/commands/secretsParallel.test.ts | 31 +++++++++++++++++ tests/ui/secretsEditFlow.test.ts | 47 +++++++++++++++++++++++++- 6 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/commands/secretsSet.ts b/src/commands/secretsSet.ts index 744e2655..325de722 100644 --- a/src/commands/secretsSet.ts +++ b/src/commands/secretsSet.ts @@ -114,6 +114,11 @@ export interface SecretSetResult { /** 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' }>; readonly failed: readonly FailedItem[]; + /** + * Repos that were asked for but needed no PR because none of their locations + * changed (every one already had this value). Left out when there are none. + */ + readonly skipped?: ReadonlyArray<{ readonly repo: string; readonly reason: 'NOTHING_CHANGED' }>; /** * What a cancel left undone: locations never pushed and repos whose PR was never opened. * Empty (and left out of JSON) unless the run was stopped. @@ -589,6 +594,21 @@ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks }); const present = opened.results.flatMap((o) => (o === undefined ? [] : [o])); + // A repo with no pushed location gets no PR. Name it as skipped only when every one of its + // locations ended unchanged: a failed or cancelled location is already reported as such. + const inTarget = (t: RepoTarget) => (o: LocationOutcome) => t.files.some((f) => f.project_id === o.location.project_id); + const cancelledLocations = new Set(pushed.cancelled.map((l) => `${l.project_id}\u0000${l.branch}`)); + const skipped = req.repos + .filter((t) => !planned.includes(t)) + .filter((t) => { + const mine = outcomes.filter(inTarget(t)); + const anyCancelled = req.locations.some( + (l) => t.files.some((f) => f.project_id === l.project_id) && cancelledLocations.has(`${l.project_id}\u0000${l.branch}`), + ); + return mine.length > 0 && !anyCancelled && mine.every((o) => o.kind === 'unchanged'); + }) + .map((t) => ({ repo: repoLabel(t), reason: 'NOTHING_CHANGED' as const })); + const cancelled: readonly CancelledItem[] = [ ...pushed.cancelled.map((l): CancelledItem => ({ kind: 'location', ...locationRef(l) })), ...opened.cancelled.map((t): CancelledItem => ({ kind: 'repo', repo: repoLabel(t) })), @@ -604,6 +624,7 @@ export async function runSecretSet(req: SetRequest, env: SetEnv, hooks: RunHooks ...failedLocations, ...present.flatMap((o): FailedItem[] => (o.kind === 'failed' ? [{ kind: 'repo', repo: o.repo, code: o.code }] : [])), ], + ...(skipped.length > 0 ? { skipped } : {}), ...(cancelled.length > 0 ? { cancelled } : {}), }; } diff --git a/src/commands/secretsSetCommand.ts b/src/commands/secretsSetCommand.ts index f1dee880..06c43548 100644 --- a/src/commands/secretsSetCommand.ts +++ b/src/commands/secretsSetCommand.ts @@ -55,7 +55,7 @@ import { resolveBases, runSecretSet, } from './secretsSet'; -import { renderSecretSetConfirmation, stoppingAfter } from './secretsSetText'; +import { renderSecretSetConfirmation, stoppingAfter, TERMINAL_STYLE } from './secretsSetText'; import { PoolItem, runPool } from '../utils/pool'; export interface SecretsSetOpts { @@ -453,6 +453,7 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr unchanged: result.unchanged, prs: result.prs, no_pr: result.no_pr, + ...(result.skipped !== undefined ? { skipped: result.skipped } : {}), failed: result.failed, ...(cancelled.length > 0 ? { cancelled } : {}), not_linked: planning.notLinked, @@ -463,7 +464,7 @@ export async function runSecretsSet(name: string, opts: SecretsSetOpts, io: Secr ), ); } else { - console.log(renderSecretSetConfirmation(result)); + console.log(renderSecretSetConfirmation(result, process.stdout.isTTY ? TERMINAL_STYLE : undefined)); } return failed ? 1 : 0; } diff --git a/src/commands/secretsSetText.ts b/src/commands/secretsSetText.ts index 1d620e56..4a5a5b76 100644 --- a/src/commands/secretsSetText.ts +++ b/src/commands/secretsSetText.ts @@ -34,6 +34,17 @@ export const stoppingAfter = (inFlight: number): string => `Stopping after the $ export const STOPPING_NOW = 'Stopping…'; // COPY-FLAG export const STOPPING_AGAIN = 'Still waiting for the push in flight.'; // COPY-FLAG const CANCELLED_LABEL = 'Cancelled:'; // COPY-FLAG +const SKIPPED_HEADING = 'Skipped'; // COPY-FLAG +const SKIPPED_DESCRIPTION = 'Nothing changed in these repos, so no pull request was opened.'; // COPY-FLAG + +/** How a skipped repo is drawn: grey in a terminal, plain for agents and pipes. */ +export interface ConfirmationStyle { + readonly muted: (text: string) => string; +} + +const PLAIN: ConfirmationStyle = { muted: (text) => text }; +/** Grey, the same 90 the rest of the CLI uses for secondary text. */ +export const TERMINAL_STYLE: ConfirmationStyle = { muted: (text) => `\x1b[90m${text}\x1b[0m` }; const plural = (n: number): string => `${n} location${n === 1 ? '' : 's'}`; @@ -87,8 +98,14 @@ function repoBlocks(result: SecretSetResult): readonly RepoBlock[] { return [...prs, ...failed]; } -/** The whole confirmation, without a trailing newline. */ -export function renderSecretSetConfirmation(result: SecretSetResult): string { +/** Repos that needed no PR: none of their values changed, or their keep.lock was already up to date. */ +const skippedRepos = (result: SecretSetResult): readonly string[] => [ + ...result.no_pr.map((n) => n.repo), + ...(result.skipped ?? []).map((n) => n.repo), +]; + +/** The whole confirmation, without a trailing newline. `style` greys the skipped repos in a terminal. */ +export function renderSecretSetConfirmation(result: SecretSetResult, style: ConfirmationStyle = PLAIN): string { const blocks = repoBlocks(result); const prSection = blocks.length === 0 @@ -98,6 +115,11 @@ export function renderSecretSetConfirmation(result: SecretSetResult): string { "Pull requests (merge each to update that repo's keep.lock):", ...blocks.flatMap((b) => ['', ` ${b.repo}`, ` ${b.line}`]), ]; + const skipped = skippedRepos(result); + const skippedSection = + skipped.length === 0 + ? [] + : ['', SKIPPED_HEADING, SKIPPED_DESCRIPTION, '', ...skipped.map((repo) => style.muted(` ${repo}`))]; const closing = result.prs.length > 0 ? [ @@ -108,7 +130,7 @@ export function renderSecretSetConfirmation(result: SecretSetResult): string { : result.updated.length > 0 ? ['', SAVED_NO_PR] : []; - return [header(result), ...failedLocationLines(result.failed), ...prSection, ...closing, ...cancelledLines(result)].join('\n'); + return [header(result), ...failedLocationLines(result.failed), ...prSection, ...skippedSection, ...closing, ...cancelledLines(result)].join('\n'); } // ── Dry run (`capy --dry-run secrets`) ────────────────────────────────────── diff --git a/src/ui/secretsEditFlow.ts b/src/ui/secretsEditFlow.ts index 5b0bf735..14d174b6 100644 --- a/src/ui/secretsEditFlow.ts +++ b/src/ui/secretsEditFlow.ts @@ -25,7 +25,7 @@ import { OldValueView, canReveal, revealHint, valueDialogRows } from './valueDia 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 } from '../commands/secretsSetText'; +import { CANCELLED_NOTHING, STOPPING_AGAIN, STOPPING_NOW, stoppingAfter, DryRunView, renderDryRunPlan, renderSecretSetConfirmation, TERMINAL_STYLE } from '../commands/secretsSetText'; import { RepoTarget, RunProgress, SecretSetResult, SetLocation, planRepos, repoKey, repoLabel } from '../commands/secretsSet'; import { TableColumn, clipLine, pickerTableLines } from './pickerTable'; import type { OrgRepoLink, SecretIndexRow } from '../service/serviceClient'; @@ -402,7 +402,7 @@ export type RunFinished = function finishedText(name: string, finished: RunFinished): string { if (!finished.ok) return `✗ ${name} was not updated. (${finished.code})`; // COPY-FLAG - return 'plan' in finished ? renderDryRunPlan(finished.plan) : renderSecretSetConfirmation(finished.result); + return 'plan' in finished ? renderDryRunPlan(finished.plan) : renderSecretSetConfirmation(finished.result, TERMINAL_STYLE); } /** The run finished. Dropped unless the flow is still running. */ diff --git a/tests/commands/secretsParallel.test.ts b/tests/commands/secretsParallel.test.ts index 52835973..2617ef47 100644 --- a/tests/commands/secretsParallel.test.ts +++ b/tests/commands/secretsParallel.test.ts @@ -188,6 +188,37 @@ describe('PRs: bounded concurrency, steps within a repo in order', () => { expect(maxReposOpen(github.events(), repos.targets.map((t) => t.name))).toBe(PR_CONCURRENCY); }); + test('a repo whose locations all already had the value gets no PR and is reported as skipped', async () => { + const repos = manyRepos(2); + const github = gaugedGithub(repos); + const result = await runSecretSet( + requestFor([], { locations: onePush, repos: repos.targets, bases: repos.bases }), + makeEnv(fakeService({ locs: [{ project: 'pC', branch: 'br0', value: SENTINEL }] }), github.api), + ); + expect(result.prs).toEqual([]); + expect(result.unchanged).toHaveLength(1); + expect(result.skipped).toEqual([ + { repo: 'Acme/repo1', reason: 'NOTHING_CHANGED' }, + { repo: 'Acme/repo2', reason: 'NOTHING_CHANGED' }, + ]); + expect(github.events().some((e) => e.includes('createPull'))).toBe(false); + }); + + test('a repo is not called skipped when its location failed or was pushed', async () => { + const repos = manyRepos(1); + const failed = await runSecretSet( + requestFor([], { locations: onePush, repos: repos.targets, bases: repos.bases }), + makeEnv(fakeService({ locs: [{ project: 'pC', branch: 'br0' }], failPush: { 'pC/br0': ERROR_CODES.PERMISSION_DENIED } }), gaugedGithub(repos).api), + ); + expect(failed.skipped).toBeUndefined(); + const pushed = await runSecretSet( + requestFor([], { locations: onePush, repos: repos.targets, bases: repos.bases }), + makeEnv(fakeService({ locs: [{ project: 'pC', branch: 'br0' }] }), gaugedGithub(repos).api), + ); + expect(pushed.prs).toHaveLength(1); + expect(pushed.skipped).toBeUndefined(); + }); + test('inside each repo the calls are strictly one after the other, in the same order for every repo', async () => { const repos = manyRepos(6); const github = gaugedGithub(repos, (repo) => 2 + (repo.length % 3)); diff --git a/tests/ui/secretsEditFlow.test.ts b/tests/ui/secretsEditFlow.test.ts index f569e60a..6938e1d7 100644 --- a/tests/ui/secretsEditFlow.test.ts +++ b/tests/ui/secretsEditFlow.test.ts @@ -16,7 +16,7 @@ import { tokenizeKeys, } from '../../src/ui/secretsScreen'; import type { EditFlow } from '../../src/ui/secretsEditFlow'; -import { renderSecretSetConfirmation } from '../../src/commands/secretsSetText'; +import { renderSecretSetConfirmation, TERMINAL_STYLE } from '../../src/commands/secretsSetText'; import type { SecretSetResult } from '../../src/commands/secretsSet'; import type { OrgRepoLink, SecretIndexRow } from '../../src/service/serviceClient'; import { ERROR_CODES } from '../../src/types/index'; @@ -276,6 +276,51 @@ describe('the confirmation', () => { ); }); + test('repos that needed no PR are listed under a Skipped heading and description, after the PRs', () => { + const text = renderSecretSetConfirmation({ + ...base, + prs: [pr('SlideSpeak/slidespeak-monorepo', 8401)], + no_pr: [{ repo: 'SlideSpeak/onbrand', reason: 'NO_DIFF_VS_BASE' }], + skipped: [{ repo: 'SlideSpeak/voice-cloner-server', reason: 'NOTHING_CHANGED' }], + }); + expect(text).toBe(`✓ ANTHROPIC_API_KEY updated in 6 locations. + +Pull requests (merge each to update that repo's keep.lock): + + SlideSpeak/slidespeak-monorepo + https://github.com/SlideSpeak/slidespeak-monorepo/pull/8401 + +Skipped +Nothing changed in these repos, so no pull request was opened. + + SlideSpeak/onbrand + SlideSpeak/voice-cloner-server + +The new value is saved in Capy now. Each repo keeps using the old value +until its PR is merged and pulled.`); + }); + + test('in a terminal only the skipped repos are grey; the heading and description are not', () => { + const text = renderSecretSetConfirmation( + { ...base, prs: [pr('SlideSpeak/slidespeak-monorepo', 1)], skipped: [{ repo: 'SlideSpeak/onbrand', reason: 'NOTHING_CHANGED' }] }, + TERMINAL_STYLE, + ); + expect(text).toContain('\nSkipped\nNothing changed in these repos, so no pull request was opened.\n'); + expect(text).toContain('\x1b[90m SlideSpeak/onbrand\x1b[0m'); + expect(text).not.toContain('\x1b[90m SlideSpeak/slidespeak-monorepo'); + }); + + test('every repo skipped: no pull request section, just the Skipped list', () => { + const text = renderSecretSetConfirmation({ + ...base, + updated: [], + unchanged: refs(2), + skipped: [{ repo: 'SlideSpeak/onbrand', reason: 'NOTHING_CHANGED' }], + }); + expect(text).not.toContain('Pull requests'); + expect(text).toContain('Skipped\nNothing changed in these repos, so no pull request was opened.\n\n SlideSpeak/onbrand'); + }); + test('the two-repo example from the brief, exactly', () => { const text = renderSecretSetConfirmation({ ...base, From 32041862395cb66369a4e468f638c337040269b9 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 4 Oct 2026 13:58:05 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20honest=20deploy=20status=20?= =?UTF-8?q?=E2=80=94=20'not=20deployed'=20in=20capy=20edit=20and=20capy=20?= =?UTF-8?q?secrets=20MIME-Version:=201.0=20Content-Type:=20text/plain;=20c?= =?UTF-8?q?harset=3DUTF-8=20Content-Transfer-Encoding:=208bit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capy edit STATUS gains 'not deployed': in sync, but a deploy target holds an older value (deployed_value_hash != value hash). Priority: conflict > local/remote > not deployed > in sync. Targets read from the server's keep file (CI-mode records live there), local keep.lock as fallback. - capy secrets gets a STATUS column; Tab order is now PROJECT > BRANCH > STATUS > CONNECTOR > TARGET > INTEGRATIONS > USERS (PROJECT default). Multi-target rows read 'not deployed (1 of 3)'; details view shows it. - --json: rows gain status (+ targets_not_deployed/targets_total), targets gain up_to_date. Existing fields unchanged. - Deploy PR body no longer calls merging 'the deploy signal'; result line says 'pushed to ' / 'pushed, release not triggered'. --- src/commands/deployCommand.ts | 56 +++++-------- src/commands/editCommand.ts | 45 ++++++++--- src/commands/secretsCommand.ts | 17 +++- src/core/deployStatus.ts | 54 +++++++++++++ src/ui/editScreen.ts | 30 ++++++- src/ui/secretsScreen.ts | 15 ++-- tests/commands/deployHonestStatus.test.ts | 75 +++++++++++++++++ tests/commands/secretsCommand.test.ts | 36 ++++++++- tests/ui/secretsScreen.test.ts | 99 ++++++++++++++--------- 9 files changed, 331 insertions(+), 96 deletions(-) create mode 100644 src/core/deployStatus.ts create mode 100644 tests/commands/deployHonestStatus.test.ts diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index 926d52ea..ecf7bff5 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -1641,6 +1641,15 @@ function renderPlan(target: TargetConfig, adapter: DeployAdapter): void { console.log(''); } +/** + * CAP-702: what a successful push means, and nothing more — capy put the + * values in the target's store; it does not track whether a release ran. + * Wording approved by Vince (2026-10-04). + */ +export function pushedLine(target: TargetConfig, noDeploy: boolean): string { + return noDeploy ? 'pushed, release not triggered' : `pushed to ${target.name}`; +} + function renderResult(result: DeployResult): void { console.log(''); for (const step of result.steps) { @@ -2764,6 +2773,7 @@ export async function deployCommand( return 1; } if (mode === 'direct') await unwindGitState(cwd, null, directStashed); + if (!options.dryRun) console.log(` ${GREEN('✓')} ${pushedLine(target, !!options.noDeploy)}`); // ── Record targets (CAP-679, CI mode CAP-687) ──────────────────────────── // Direct mode: record against the user's own branch, same as always. @@ -2845,14 +2855,14 @@ async function unwindGitState( } } -function buildDeployPrBody(target: TargetConfig): string { +export function buildDeployPrBody(target: TargetConfig): string { const adapter = getAdapter(target.kind); const adapterLabel = adapter ? adapter.label : target.kind; const optionsTable = Object.entries(target.options) .map(([k, v]) => `- \`${k}\`: \`${String(v)}\``) .join('\n'); const baseLine = target.gitBaseBranch - ? `- **Git base:** \`${target.gitBaseBranch}\` — merging this PR is the deploy signal for that branch.` + ? `- **Git base:** \`${target.gitBaseBranch}\`` : ''; // Secret-delivery wording depends on the adapter. Blob adapters (Vercel) push @@ -2876,40 +2886,14 @@ function buildDeployPrBody(target: TargetConfig): string { target.vars.map((v) => `- \`${v}\``).join('\n'), ].join('\n'); - // Dokploy CI mode (CAP-682): not `needsDeployToken` (plain values now) and - // not `ciOnly` (direct mode still exists) — but for THIS section it reads - // exactly like a `ciOnly` adapter: Dokploy's OWN auto-deploy is what merging - // triggers, capy never calls `compose.redeploy`/`application.deploy` in CI - // mode. Checked before the generic `ciOnly` branch so it wins. - const mergeSection = adapter?.needsDeployToken - ? [ - `Merging this PR is the deploy signal. ${adapterLabel}'s git CI builds on`, - `merge, and \`capy run\` injects your secrets from \`SECRETS_BLOB\` at build`, - `time. capy does **not** ship code from the local machine — only the`, - `keep.lock pin lands here.`, - ].join('\n') - : adapter?.id === 'dokploy' - ? // COPY-FLAG: new user-facing string, minimal/neutral wording. - [ - `Merging this PR is the deploy signal. Dokploy's own auto-deploy builds`, - `and deploys on merge, reading the env vars written above directly from`, - `its store — no decrypt step at build. capy does **not** call`, - `\`compose.redeploy\`/\`application.deploy\` in CI mode — only the`, - `keep.lock pin lands here.`, - ].join('\n') - : adapter?.ciOnly - ? [ - `Merging this PR is the deploy signal. ${adapterLabel}'s git integration`, - `builds and deploys on merge, reading the env vars pushed above directly`, - `from its store — no decrypt step at build. capy does **not** ship code`, - `from the local machine — only the keep.lock pin lands here.`, - ].join('\n') - : [ - `Merging this PR is the deploy signal. Your CI pipeline runs the actual`, - `code deploy (e.g. \`capy run -- wrangler deploy\` for cf-worker) using`, - `the secrets that were pushed above. capy itself does **not** ship code`, - `from the local machine in CI mode — only the keep.lock pin lands here.`, - ].join('\n'); + // CAP-702: capy does not track releases. The values are already in the + // vendor's store; merging records them in keep.lock and starts a release + // only where the vendor builds on merge. Wording approved by Vince + // (2026-10-04), verbatim from the ticket. + const mergeSection = [ + `The new values are already in ${adapterLabel}. The next release of this branch will use them.`, + `Merging this PR records them in keep.lock, and it starts a release if ${adapterLabel} builds on merge.`, + ].join(' '); return [ `Automated deploy PR opened by \`capy deploy\`.`, diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 75befe98..57bd9b8a 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -8,7 +8,8 @@ import { fetchSecretsWithCache, readKeepCache, writeKeepCache, readSecretsLocal, import { isLocalOnly } from '../config/profileConfig'; import { resolveLocalProjectKey } from '../core/localUnlock'; import { hashValue } from './statusCommand'; -import { EditScreen, EditRow, EditState, classifyLocalRow, focusedOn } from '../ui/editScreen'; +import { EditScreen, EditRow, EditState, classifyLocalRow, focusedOn, withDeployStatus } from '../ui/editScreen'; +import { deployedHashesFor } from '../core/deployStatus'; import { formatRelativeTime } from '../ui/relativeTime'; import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; @@ -107,6 +108,35 @@ async function loadBaseline(args: { } } +/** + * 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. + */ +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; + } +} + +function isKeepWithVariables(value: unknown): value is KeepFile { + return typeof value === 'object' && value !== null && typeof (value as { variables?: unknown }).variables === 'object'; +} + /** 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 { @@ -283,6 +313,8 @@ export class EditCommand { projectId, }); + const deployKeep = await loadDeployKeep({ localMode, serviceClient, projectId, branch, localKeep: keep }); + // Build rows for every variable known to any source const allKeys = new Set([ ...Object.keys(pinned), @@ -311,15 +343,10 @@ export class EditCommand { status: classifyStatus(pinnedHash, localHash, remoteHash, remoteAvailable), updatedLabel: changedAt ? formatRelativeTime(changedAt) : '—', }; + const deployedHashes = deployedHashesFor(deployKeep, key, branch); + const base: EditRow = { key, localValue: localVal, remoteValue: remoteVal, status, updatedLabel, changedAt, deployedHashes }; - return { - key, - localValue: localVal, - remoteValue: remoteVal, - status, - updatedLabel, - changedAt, - }; + return { ...base, status: withDeployStatus(base, status) }; }); const baseState: EditState = { diff --git a/src/commands/secretsCommand.ts b/src/commands/secretsCommand.ts index 58102bb3..683170b1 100644 --- a/src/commands/secretsCommand.ts +++ b/src/commands/secretsCommand.ts @@ -3,6 +3,13 @@ import { resolveOrgContext } from '../core/orgContext'; import { Spinner } from '../ui/spinner'; import { CapyError, ERROR_CODES } from '../types/index'; import { rowIdOf } from './secretsRowId'; +import { secretRowStatus, type SecretRowStatus } from '../core/deployStatus'; + +/** `status` plus, when some target lags, how many of how many. */ +function statusFields(status: SecretRowStatus): Record { + if (status.kind !== 'not deployed') return { status: status.kind }; + return { status: status.kind, targets_not_deployed: status.behind, targets_total: status.total }; +} export interface SecretsOpts { json?: boolean; @@ -113,7 +120,15 @@ export class SecretsCommand { private emitJsonOutput(rows: readonly SecretIndexRow[], skipped: readonly SecretIndexSkipped[], orgId: string): void { // `row_id`: an opaque, stable handle for (name, value) that `capy secrets set --row` takes. Never a slice of the value's hash. - const withIds = rows.map((row) => ({ ...row, row_id: rowIdOf(row.name, row.value_hash) })); + // CAP-702, additive: `status` is the STATUS column's value; each target's `up_to_date` says it has the current value (`deployed` already means "not --no-deploy"). + const withIds = rows.map((row) => ({ + ...row, + row_id: rowIdOf(row.name, row.value_hash), + ...statusFields(secretRowStatus(row)), + locations: row.locations.map((loc) => + loc.targets === undefined ? loc : { ...loc, targets: loc.targets.map((t) => ({ ...t, up_to_date: !t.stale })) }, + ), + })); console.log(JSON.stringify({ ok: true, org_id: orgId, rows: withIds, skipped }, null, 2)); // Names only, never a value — always stderr, so stdout stays pure JSON diff --git a/src/core/deployStatus.ts b/src/core/deployStatus.ts new file mode 100644 index 00000000..8b5b03b7 --- /dev/null +++ b/src/core/deployStatus.ts @@ -0,0 +1,54 @@ +/** + * CAP-702: the `not deployed` status, shared by `capy edit` and `capy secrets`. + * + * Capy does not track whether a release happened. `capy deploy` pushes the + * new values to the target's store and records `deployed_value_hash` on the + * keep entry; the next release of that repo uses them. So a target is + * "not deployed" exactly when Capy holds a newer value than the one it last + * pushed there: `deployed_value_hash !== value_hash`. Nothing here polls a + * platform or claims a release ran. + */ +import type { KeepFile, TargetDelivery } from '../types/index'; +import type { SecretIndexRow } from '../service/serviceClient'; + +export const NOT_DEPLOYED = 'not deployed'; + +/** Every target that received `varName` on `branch`, from a keep file. `[]` when there are none. */ +export function deployedHashesFor(keep: KeepFile | undefined, varName: string, branch: string): readonly string[] { + const entry = keep?.variables[varName]?.find((e) => e.branch === branch); + const targets: ReadonlyArray = entry?.targets ?? []; + return targets.map((t) => t.deployed_value_hash); +} + +/** Whether at least one target holds a value other than `valueHash`. */ +export function anyTargetBehind(deployedHashes: readonly string[] | undefined, valueHash: string | undefined): boolean { + if (valueHash === undefined) return false; + return (deployedHashes ?? []).some((h) => h !== valueHash); +} + +// ── capy secrets ───────────────────────────────────────────────────────────── + +/** The STATUS a `capy secrets` row can have: an org-wide row has no local copy, so only these three. */ +export type SecretRowStatus = + | { readonly kind: 'in sync' } + | { readonly kind: 'unknown' } + | { readonly kind: 'not deployed'; readonly behind: number; readonly total: number }; + +/** + * `not deployed` when at least one target on any location lags this row's + * value; `unknown` when a location's targets were not sent (a server that + * predates CAP-676), so it can't be told; `in sync` otherwise — including a + * row with no targets at all, since nothing waits to be pushed. + */ +export function secretRowStatus(row: SecretIndexRow): SecretRowStatus { + if (row.locations.some((loc) => loc.targets === undefined)) return { kind: 'unknown' }; + const targets = row.locations.flatMap((loc) => loc.targets ?? []); + const behind = targets.filter((t) => t.stale).length; + return behind > 0 ? { kind: 'not deployed', behind, total: targets.length } : { kind: 'in sync' }; +} + +/** The cell text: `not deployed (1 of 3)` when more than one target is involved, plain `not deployed` for one. */ +export function formatSecretRowStatus(status: SecretRowStatus): string { + if (status.kind !== 'not deployed') return status.kind; + return status.total > 1 ? `${NOT_DEPLOYED} (${status.behind} of ${status.total})` : NOT_DEPLOYED; +} diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index e14a4505..957b83a9 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -1,7 +1,8 @@ // 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. -import { formatSnippet } from '../commands/statusCommand'; +import { formatSnippet, hashValue } from '../commands/statusCommand'; +import { anyTargetBehind } from '../core/deployStatus'; import { formatRelativeTime } from './relativeTime'; import { isRevealKey, stepEditBuffer } from './editBuffer'; import { OldValueView, canReveal, renderInlineValue as inlineValue, valueDialogRows } from './valueDialog'; @@ -38,8 +39,14 @@ export interface EditRow { key: string; localValue: string | undefined; remoteValue: string | undefined; - status: 'in sync' | 'local' | 'remote' | 'conflict' | 'unknown'; + status: 'in sync' | 'local' | 'remote' | 'conflict' | 'not deployed' | 'unknown'; updatedLabel: string; + /** + * CAP-702: `deployed_value_hash` of every deploy target this variable was + * pushed to on the active branch. A row that is otherwise in sync reads + * `not deployed` while any of these differs from its value's hash. + */ + deployedHashes?: readonly string[]; /** * keep.lock changed_at for this variable on the active branch (ISO8601, * server-assigned) — when the value last changed server-side. Drives the @@ -135,6 +142,21 @@ export function reclassifyRow( row: EditRow, mode: { localMode?: boolean; remoteAvailable: boolean }, ): EditRow['status'] { + return withDeployStatus(row, syncStatus(row, mode)); +} + +/** + * CAP-702: one status per variable, the one that needs action first — + * `conflict` → `local`/`remote` → `not deployed` → `in sync`. So only a row + * that is otherwise in sync can read `not deployed`. + */ +export function withDeployStatus(row: EditRow, status: EditRow['status']): EditRow['status'] { + if (status !== 'in sync') return status; + const value = row.localValue ?? row.remoteValue; + return anyTargetBehind(row.deployedHashes, value === undefined ? undefined : hashValue(value)) ? 'not deployed' : status; +} + +function syncStatus(row: EditRow, mode: { localMode?: boolean; remoteAvailable: boolean }): EditRow['status'] { // 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'; @@ -154,7 +176,7 @@ export function reclassifyRow( * 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' ? 'committed' : 'uncommitted'; + if (mode.localMode) return row.status === 'in sync' || row.status === 'not deployed' ? 'committed' : 'uncommitted'; return row.changedAt ? formatRelativeTime(row.changedAt) : NO_VALUE; } @@ -824,6 +846,8 @@ export class EditScreen { return `${CYAN}● remote${RESET}`; case 'conflict': return `${RED}● conflict${RESET}`; + case 'not deployed': + return `${YELLOW}● not deployed${RESET}`; case 'unknown': default: return `${DIM}● unknown${RESET}`; diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index ab87c49e..b45b4d19 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -24,6 +24,7 @@ import { normalizeQuery, textMatches } from './searchMatch'; import { CANCELLED_NOTHING, DRY_RUN_LABEL } from '../commands/secretsSetText'; import type { RunProgress } from '../commands/secretsSet'; import { clipLine } from './pickerTable'; +import { NOT_DEPLOYED, formatSecretRowStatus, secretRowStatus } from '../core/deployStatus'; import { EditEffect, EditFlow, @@ -180,9 +181,9 @@ function tokenizeFrom(chunk: string, index: number): readonly string[] { // ── Column cycling ─────────────────────────────────────────────────────────── -export type ColumnMode = 'connector' | 'target' | 'integrations' | 'users' | 'branch' | 'project'; -/** Tab cycles forward through this order (wrapping); Shift-Tab backward. CONNECTOR is the default/first-shown column (CAP-679 — "service" was a misnomer for what's really an inbound connector, and is gone as a column). */ -export const COLUMN_ORDER: readonly ColumnMode[] = ['connector', 'target', 'integrations', 'users', 'branch', 'project']; +export type ColumnMode = 'project' | 'branch' | 'status' | 'connector' | 'target' | 'integrations' | 'users'; +/** Tab cycles forward through this order (wrapping); Shift-Tab backward. PROJECT is the default/first-shown column (CAP-702, Vince 2026-10-03). */ +export const COLUMN_ORDER: readonly ColumnMode[] = ['project', 'branch', 'status', 'connector', 'target', 'integrations', 'users']; function nextColumn(column: ColumnMode, dir: 1 | -1): ColumnMode { const idx = COLUMN_ORDER.indexOf(column); @@ -230,7 +231,7 @@ export interface SecretsScreenState { export function initialSecretsScreenState(rows: readonly SecretIndexRow[], dryRun: boolean = false): SecretsScreenState { return { rows, - column: 'connector', + column: COLUMN_ORDER[0], cursorIndex: 0, search: { query: '' }, popup: null, @@ -741,6 +742,7 @@ export function formatMiddleCell(row: SecretIndexRow, column: ColumnMode, width: if (column === 'integrations') return formatIntegrationsCellFitted(row, width); if (column === 'users') return formatUsersCell(row); if (column === 'branch') return formatBranchCell(row); + if (column === 'status') return formatSecretRowStatus(secretRowStatus(row)); return formatProjectCell(row); } @@ -990,6 +992,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): '', field('value', renderPopupValueLine(popup, valueWidth)), field('updated', formatUpdatedCell(row)), + field('status', formatSecretRowStatus(secretRowStatus(row))), '', `${indent}${inner}${BOLD}locations${RESET}`, ]; @@ -997,7 +1000,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): // Per-location: project · branch (protected marker), the CONNECTOR that // brought the value IN (renamed from "service" — CAP-679), when it // changed, and — only when this location has any — every TARGET it was - // pushed OUT to, each spelled out with its full "(stale)" wording (the + // pushed OUT to, each spelled out with its full "(not deployed)" wording (the // table's own TARGET column uses a compact `*` instead; there's no room // pressure here to justify that shorthand). const locationLines: readonly string[] = row.locations.map((loc) => { @@ -1008,7 +1011,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): const targetsLabel = targets.length > 0 ? ` · targets: ${targets - .map((t) => `[${t.provider}] ${t.target}${t.stale ? ' (stale)' : ''}${t.pending ? ' (pending)' : ''}`) + .map((t) => `[${t.provider}] ${t.target}${t.stale ? ` (${NOT_DEPLOYED})` : ''}${t.pending ? ' (pending)' : ''}`) .join(', ')}` : ''; return `${indent}${inner}${truncate(`${loc.project_name} · ${loc.branch}`, contentWidth)}${protMarker} ${DIM}· ${connectorLabel} · ${updated}${targetsLabel}${RESET}`; diff --git a/tests/commands/deployHonestStatus.test.ts b/tests/commands/deployHonestStatus.test.ts new file mode 100644 index 00000000..8325a758 --- /dev/null +++ b/tests/commands/deployHonestStatus.test.ts @@ -0,0 +1,75 @@ +// CAP-702: honest deploy wording, and the `not deployed` status in `capy edit`. +// Capy pushes values to the target's store; it never claims a release ran. +import { describe, it, expect } from 'bun:test'; +import { buildDeployPrBody, pushedLine } from '../../src/commands/deployCommand'; +import { reclassifyRow, withDeployStatus, type EditRow } from '../../src/ui/editScreen'; +import { hashValue } from '../../src/commands/statusCommand'; +import type { TargetConfig } from '../../src/deploy/adapter'; + +function target(kind: string, over: Partial = {}): TargetConfig { + return { name: `${kind}-prod`, kind, branch: 'production', vars: ['API_KEY'], options: {}, ...over }; +} + +describe('deploy PR body (CAP-702)', () => { + for (const kind of ['dokploy', 'vercel', 'aws-ssm', 'cf-worker']) { + it(`${kind}: says the values are already in the vendor, and never calls merging "the deploy signal"`, () => { + const body = buildDeployPrBody(target(kind, { gitBaseBranch: 'main' })); + expect(body).toContain('The next release of this branch will use them.'); + expect(body).toMatch(/The new values are already in .+\. The next release/); + expect(body).toMatch(/Merging this PR records them in keep\.lock, and it starts a release if .+ builds on merge\./); + expect(body).not.toContain('deploy signal'); + }); + } +}); + +describe('capy deploy result line (CAP-702)', () => { + it('a push names the target, never "deployed"', () => { + expect(pushedLine(target('dokploy'), false)).toBe('pushed to dokploy-prod'); + }); + it('--no-deploy says the release was not triggered', () => { + expect(pushedLine(target('dokploy'), true)).toBe('pushed, release not triggered'); + }); +}); + +describe('capy edit STATUS: not deployed (CAP-702)', () => { + const value = 'fake-value-one'; + const row = (over: Partial = {}): EditRow => ({ + key: 'API_KEY', + localValue: value, + remoteValue: value, + status: 'in sync', + updatedLabel: '—', + ...over, + }); + const server = { remoteAvailable: true }; + + it('in sync with every target current stays in sync', () => { + expect(reclassifyRow(row({ deployedHashes: [hashValue(value)] }), server)).toBe('in sync'); + }); + + it('in sync, no targets, stays in sync', () => { + expect(reclassifyRow(row({ deployedHashes: [] }), server)).toBe('in sync'); + expect(reclassifyRow(row(), server)).toBe('in sync'); + }); + + it('in sync but one target holds an older value reads not deployed', () => { + expect(reclassifyRow(row({ deployedHashes: [hashValue(value), hashValue('older')] }), server)).toBe('not deployed'); + }); + + it('a value edited in the session becomes not deployed once committed, since no target has it yet', () => { + const edited = row({ localValue: 'fake-new', remoteValue: 'fake-new', deployedHashes: [hashValue(value)] }); + expect(reclassifyRow(edited, server)).toBe('not deployed'); + }); + + it('conflict, local and remote outrank not deployed', () => { + const behind = [hashValue('older')]; + expect(reclassifyRow(row({ localValue: 'a', remoteValue: 'b', deployedHashes: behind }), server)).toBe('conflict'); + expect(reclassifyRow(row({ remoteValue: undefined, deployedHashes: behind }), server)).toBe('local'); + expect(reclassifyRow(row({ localValue: undefined, deployedHashes: behind }), server)).toBe('remote'); + expect(withDeployStatus(row({ deployedHashes: behind }), 'unknown')).toBe('unknown'); + }); + + it('remote unavailable stays unknown', () => { + expect(reclassifyRow(row({ deployedHashes: [hashValue('older')] }), { remoteAvailable: false })).toBe('unknown'); + }); +}); diff --git a/tests/commands/secretsCommand.test.ts b/tests/commands/secretsCommand.test.ts index de6a5a65..9639a42b 100644 --- a/tests/commands/secretsCommand.test.ts +++ b/tests/commands/secretsCommand.test.ts @@ -188,8 +188,8 @@ describe('SecretsCommand', () => { expect(payload).toEqual({ ok: true, org_id: 'org_9', - // `row_id` is the one field this command adds to each server row (CAP-698). - rows: [{ ...row({ name: 'X' }), row_id: rowIdOf('X', row({ name: 'X' }).value_hash) }], + // The fields this command adds to each server row: `row_id` (CAP-698) and `status` (CAP-702; `unknown` because this fixture's server sent no targets). + rows: [{ ...row({ name: 'X' }), row_id: rowIdOf('X', row({ name: 'X' }).value_hash), status: 'unknown' }], skipped: [], }); }); @@ -349,7 +349,37 @@ describe('SecretsCommand', () => { // the fake row's own known-safe fields made it into the payload. const payload = JSON.parse(stdout); expect(payload.rows[0].value_hash).toBe('deadbeefdeadbeef'); - expect(Object.keys(payload.rows[0])).toEqual(['name', 'value_hash', 'locations', 'users', 'row_id']); + expect(Object.keys(payload.rows[0])).toEqual(['name', 'value_hash', 'locations', 'users', 'row_id', 'status']); + }); + + it('CAP-702: --json gives each row a STATUS and each target `up_to_date`', async () => { + getSecretIndexImpl.mockImplementation(async () => ({ + org_id: 'org_1', + rows: [ + row({ + name: 'BEHIND', + locations: [ + loc({ targets: [{ provider: 'dokploy', target: 'prod', stale: true }, { provider: 'vercel', target: 'main', stale: false }] }), + loc({ branch: 'staging', targets: [{ provider: 'dokploy', target: 'staging', stale: false }] }), + ], + }), + row({ name: 'CURRENT', locations: [loc({ targets: [{ provider: 'dokploy', target: 'prod', stale: false }] })] }), + row({ name: 'NO_TARGETS', locations: [loc({ targets: [] })] }), + ], + skipped: [], + })); + + const { stdout } = await capture(() => new SecretsCommand().execute({ json: true })); + const payload = JSON.parse(stdout); + const [behind, current, noTargets] = payload.rows; + expect(behind.status).toBe('not deployed'); + expect(behind.targets_not_deployed).toBe(1); + expect(behind.targets_total).toBe(3); + expect(behind.locations[0].targets.map((t: { up_to_date: boolean }) => t.up_to_date)).toEqual([false, true]); + expect(current.status).toBe('in sync'); + expect(current.targets_not_deployed).toBeUndefined(); + expect(current.locations[0].targets[0].up_to_date).toBe(true); + expect(noTargets.status).toBe('in sync'); }); }); diff --git a/tests/ui/secretsScreen.test.ts b/tests/ui/secretsScreen.test.ts index 968cbfab..a5203da1 100644 --- a/tests/ui/secretsScreen.test.ts +++ b/tests/ui/secretsScreen.test.ts @@ -6,6 +6,7 @@ import { applyValueResult, resolveSecretValue, render, + formatMiddleCell, filteredRows, filteredRowsWithReasons, formatUsersCell, @@ -182,42 +183,64 @@ describe('multi-key chunks reach the reducer in order (paste / fast-typing bug)' }); }); -describe('handleKey — column cycling (CAP-679: CONNECTOR/TARGET/INTEGRATIONS replace SERVICE)', () => { - test('CONNECTOR is the default (first-shown) column', () => { +describe('handleKey — column cycling (CAP-702: PROJECT first, STATUS added)', () => { + test('PROJECT is the default (first-shown) column', () => { const s0 = initialSecretsScreenState([row()]); - expect(s0.column).toBe('connector'); + expect(s0.column).toBe('project'); }); - test('Tab cycles CONNECTOR -> TARGET -> INTEGRATIONS -> USERS -> BRANCH -> PROJECT -> CONNECTOR (wrapping)', () => { - const s0 = initialSecretsScreenState([row()]); - const s1 = handleKey(s0, KEY_TAB).state; - expect(s1.column).toBe('target'); - const s2 = handleKey(s1, KEY_TAB).state; - expect(s2.column).toBe('integrations'); - const s3 = handleKey(s2, KEY_TAB).state; - expect(s3.column).toBe('users'); - const s4 = handleKey(s3, KEY_TAB).state; - expect(s4.column).toBe('branch'); - const s5 = handleKey(s4, KEY_TAB).state; - expect(s5.column).toBe('project'); - const s6 = handleKey(s5, KEY_TAB).state; - expect(s6.column).toBe('connector'); + test('Tab cycles PROJECT -> BRANCH -> STATUS -> CONNECTOR -> TARGET -> INTEGRATIONS -> USERS -> PROJECT (wrapping)', () => { + const order = ['branch', 'status', 'connector', 'target', 'integrations', 'users', 'project']; + const states = order.reduce( + (acc) => [...acc, handleKey(acc[acc.length - 1], KEY_TAB).state], + [initialSecretsScreenState([row()])], + ); + expect(states.slice(1).map((st) => st.column)).toEqual(order as SecretsScreenState['column'][]); }); test('Shift-Tab cycles backwards, wrapping the other way', () => { - const s0 = initialSecretsScreenState([row()]); - const s1 = handleKey(s0, KEY_SHIFT_TAB).state; - expect(s1.column).toBe('project'); - const s2 = handleKey(s1, KEY_SHIFT_TAB).state; - expect(s2.column).toBe('branch'); - const s3 = handleKey(s2, KEY_SHIFT_TAB).state; - expect(s3.column).toBe('users'); - const s4 = handleKey(s3, KEY_SHIFT_TAB).state; - expect(s4.column).toBe('integrations'); - const s5 = handleKey(s4, KEY_SHIFT_TAB).state; - expect(s5.column).toBe('target'); - const s6 = handleKey(s5, KEY_SHIFT_TAB).state; - expect(s6.column).toBe('connector'); + const order = ['users', 'integrations', 'target', 'connector', 'status', 'branch', 'project']; + const states = order.reduce( + (acc) => [...acc, handleKey(acc[acc.length - 1], KEY_SHIFT_TAB).state], + [initialSecretsScreenState([row()])], + ); + expect(states.slice(1).map((st) => st.column)).toEqual(order as SecretsScreenState['column'][]); + }); +}); + +describe('STATUS column (CAP-702)', () => { + const stale = (target: string) => ({ provider: 'dokploy', target, stale: true }); + const current = (target: string) => ({ provider: 'dokploy', target, stale: false }); + + test('a row with one target that lags reads "not deployed"', () => { + expect(formatMiddleCell(row({ locations: [loc({ targets: [stale('prod')] })] }), 'status', 30)).toBe('not deployed'); + }); + + test('a multi-target row counts lagging targets across every location', () => { + const r = row({ locations: [loc({ targets: [stale('prod'), current('preview')] }), loc({ branch: 'staging', targets: [current('staging')] })] }); + expect(formatMiddleCell(r, 'status', 30)).toBe('not deployed (1 of 3)'); + }); + + test('every target current, or no targets at all, reads "in sync"', () => { + expect(formatMiddleCell(row({ locations: [loc({ targets: [current('prod')] })] }), 'status', 30)).toBe('in sync'); + expect(formatMiddleCell(row({ locations: [loc({ targets: [] })] }), 'status', 30)).toBe('in sync'); + }); + + test('a server that sent no targets reads "unknown" — it cannot be told', () => { + expect(formatMiddleCell(row({ locations: [loc()] }), 'status', 30)).toBe('unknown'); + }); + + test('the STATUS header and cell render when the column is selected', () => { + const r = row({ name: 'ROW', locations: [loc({ targets: [stale('prod'), current('preview')] })] }); + const frame = stripAnsiForTest(render({ ...initialSecretsScreenState([r]), column: 'status' }, 100, 20)); + expect(frame).toContain('STATUS ⇥'); + expect(frame.split('\n').find((l) => l.includes('ROW'))).toContain('not deployed (1 of 2)'); + }); + + test('the details view shows the row status', () => { + const r = row({ name: 'ROW', locations: [loc({ targets: [stale('prod')] })] }); + const frame = stripAnsiForTest(render(handleKey(initialSecretsScreenState([r]), ENTER).state, 100, 30)); + expect(frame).toMatch(/status\s+not deployed/); }); }); @@ -938,7 +961,7 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po loc({ connector: { provider: 'dokploy' }, service: { provider: 'dokploy', name: 'other' } }), ], }); - const frame = render(initialSecretsScreenState([target]), 100, 20); + const frame = render({ ...initialSecretsScreenState([target]), column: 'connector' }, 100, 20); const stripped = stripAnsiForTest(frame); const line = stripped.split('\n').find((l) => l.includes('ROW')); expect(line).toBeDefined(); @@ -960,7 +983,7 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po }), ], }); - const s = pressKeys(initialSecretsScreenState([target]), KEY_TAB); // connector -> target + const s: SecretsScreenState = { ...initialSecretsScreenState([target]), column: 'target' }; const frame = render(s, 100, 20); const stripped = stripAnsiForTest(frame); const line = stripped.split('\n').find((l) => l.includes('ROW')); @@ -985,7 +1008,7 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po }), ], }); - const s = pressKeys(initialSecretsScreenState([manyProvidersRow]), KEY_TAB, KEY_TAB); // connector -> target -> integrations + const s: SecretsScreenState = { ...initialSecretsScreenState([manyProvidersRow]), column: 'integrations' }; const frame = render(s, 100, 20); const stripped = stripAnsiForTest(frame); const line = stripped.split('\n').find((l) => l.includes('ROW')); @@ -997,7 +1020,7 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po test('INTEGRATIONS shows "—" when a row has neither a connector nor a target', () => { const target = row({ name: 'ROW', locations: [loc({ service: null })] }); - const s = pressKeys(initialSecretsScreenState([target]), KEY_TAB, KEY_TAB); + const s: SecretsScreenState = { ...initialSecretsScreenState([target]), column: 'integrations' }; const frame = render(s, 100, 20); const stripped = stripAnsiForTest(frame); const line = stripped.split('\n').find((l) => l.includes('ROW')); @@ -1014,14 +1037,14 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po expect(frame).toContain('[dokploy] backend-preview'); }); - test('the details popup spells out a stale target with "(stale)"', () => { + test('the details popup spells out a stale target with "(not deployed)" (CAP-702)', () => { const target = row({ name: 'ROW', locations: [loc({ targets: [{ provider: 'aws-ecs', target: 'prod-cluster', stale: true }] })], }); const s1 = handleKey(initialSecretsScreenState([target]), ENTER).state; const frame = render(s1, 100, 30); - expect(frame).toContain('targets: [aws-ecs] prod-cluster (stale)'); + expect(frame).toContain('targets: [aws-ecs] prod-cluster (not deployed)'); }); test('the details popup spells out a pending target with "(pending)"', () => { @@ -1034,7 +1057,7 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po expect(frame).toContain('targets: [dokploy] backend-preview (pending)'); }); - test('the details popup lists a non-stale target with no "(stale)" marker', () => { + test('the details popup lists a non-stale target with no "(not deployed)" marker', () => { const target = row({ name: 'ROW', locations: [loc({ targets: [{ provider: 'aws-ecs', target: 'prod-cluster', stale: false }] })], @@ -1042,7 +1065,7 @@ describe('CAP-679 rendering: "+N" survives truncation, INTEGRATIONS overflow, po const s1 = handleKey(initialSecretsScreenState([target]), ENTER).state; const frame = render(s1, 100, 30); expect(frame).toContain('targets: [aws-ecs] prod-cluster'); - expect(frame).not.toContain('(stale)'); + expect(frame).not.toContain('(not deployed)'); }); test('the details popup shows nothing target-related for a location with no targets', () => { From 8315075ba448a97e7e0b0389748c5a8f5e0596e4 Mon Sep 17 00:00:00 2001 From: cvince Date: Sun, 4 Oct 2026 13:58:08 -0700 Subject: [PATCH 03/10] feat: colour capy secrets STATUS like capy edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shared ui/statusBadge.ts: the '● status' badge both screens use (in sync green, local/not deployed yellow, remote cyan, conflict red, unknown dim). EditScreen.statusBadge delegates to it. - capy secrets STATUS cell and details-view status use the badge; 'not deployed (1 of 3)' gets the yellow dot too. A lagging target in the details view is yellow. - The highlighted row is re-highlighted after a coloured cell's reset. - JSON unchanged (plain strings). --- src/ui/editScreen.ts | 18 ++---------- src/ui/secretsScreen.ts | 16 ++++++++--- src/ui/statusBadge.ts | 35 +++++++++++++++++++++++ tests/ui/secretsScreen.test.ts | 51 ++++++++++++++++++++++------------ 4 files changed, 83 insertions(+), 37 deletions(-) create mode 100644 src/ui/statusBadge.ts diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index 957b83a9..ebb3fe33 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -3,6 +3,7 @@ import { formatSnippet, hashValue } from '../commands/statusCommand'; import { anyTargetBehind } from '../core/deployStatus'; +import { statusBadge } from './statusBadge'; import { formatRelativeTime } from './relativeTime'; import { isRevealKey, stepEditBuffer } from './editBuffer'; import { OldValueView, canReveal, renderInlineValue as inlineValue, valueDialogRows } from './valueDialog'; @@ -30,7 +31,6 @@ const BOLD = `${ESC}[1m`; const GREEN = `${ESC}[32m`; const YELLOW = `${ESC}[33m`; const RED = `${ESC}[31m`; -const CYAN = `${ESC}[36m`; const MARGIN = 2; const NO_VALUE = '—'; @@ -837,21 +837,7 @@ export class EditScreen { } private statusBadge(status: EditRow['status']): string { - switch (status) { - case 'in sync': - return `${GREEN}● in sync${RESET}`; - case 'local': - return `${YELLOW}● local${RESET}`; - case 'remote': - return `${CYAN}● remote${RESET}`; - case 'conflict': - return `${RED}● conflict${RESET}`; - case 'not deployed': - return `${YELLOW}● not deployed${RESET}`; - case 'unknown': - default: - return `${DIM}● unknown${RESET}`; - } + return statusBadge(status); } // --- helpers --- diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index b45b4d19..98bf57bb 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -25,6 +25,7 @@ import { CANCELLED_NOTHING, DRY_RUN_LABEL } from '../commands/secretsSetText'; import type { RunProgress } from '../commands/secretsSet'; import { clipLine } from './pickerTable'; import { NOT_DEPLOYED, formatSecretRowStatus, secretRowStatus } from '../core/deployStatus'; +import { statusBadge, statusColor } from './statusBadge'; import { EditEffect, EditFlow, @@ -742,10 +743,16 @@ export function formatMiddleCell(row: SecretIndexRow, column: ColumnMode, width: if (column === 'integrations') return formatIntegrationsCellFitted(row, width); if (column === 'users') return formatUsersCell(row); if (column === 'branch') return formatBranchCell(row); - if (column === 'status') return formatSecretRowStatus(secretRowStatus(row)); + if (column === 'status') return secretRowStatusBadge(row); return formatProjectCell(row); } +/** The STATUS cell: the same coloured `● status` badge `capy edit` shows (CAP-702). */ +function secretRowStatusBadge(row: SecretIndexRow): string { + const status = secretRowStatus(row); + return statusBadge(status.kind, formatSecretRowStatus(status)); +} + /** Most recent `changed_at` across a row's locations, or undefined if none carry one. */ export function mostRecentChangedAt(row: SecretIndexRow): string | undefined { const timestamps = row.locations.map((l) => l.changed_at).filter((v): v is string => Boolean(v)); @@ -914,7 +921,8 @@ export function render(state: SecretsScreenState, termWidth: number, termHeight: const middleCell = pad(formatMiddleCell(row, state.column, middleW), middleW); const updatedCell = pad(formatUpdatedCell(row), updatedW); const line = nameCell + gap + middleCell + gap + updatedCell; - return isSelected ? INVERSE + padVis(line, available) + RESET : line; + // A coloured cell's own RESET would end the highlight mid-row, so the highlight is re-applied after each one. + return isSelected ? INVERSE + padVis(line, available).replaceAll(RESET, RESET + INVERSE) + RESET : line; }); const withPopup: readonly string[] = @@ -992,7 +1000,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): '', field('value', renderPopupValueLine(popup, valueWidth)), field('updated', formatUpdatedCell(row)), - field('status', formatSecretRowStatus(secretRowStatus(row))), + field('status', secretRowStatusBadge(row)), '', `${indent}${inner}${BOLD}locations${RESET}`, ]; @@ -1011,7 +1019,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): const targetsLabel = targets.length > 0 ? ` · targets: ${targets - .map((t) => `[${t.provider}] ${t.target}${t.stale ? ` (${NOT_DEPLOYED})` : ''}${t.pending ? ' (pending)' : ''}`) + .map((t) => `[${t.provider}] ${t.target}${t.stale ? ` ${statusColor(NOT_DEPLOYED)}(${NOT_DEPLOYED})${DIM}` : ''}${t.pending ? ' (pending)' : ''}`) .join(', ')}` : ''; return `${indent}${inner}${truncate(`${loc.project_name} · ${loc.branch}`, contentWidth)}${protMarker} ${DIM}· ${connectorLabel} · ${updated}${targetsLabel}${RESET}`; diff --git a/src/ui/statusBadge.ts b/src/ui/statusBadge.ts new file mode 100644 index 00000000..5b440a8d --- /dev/null +++ b/src/ui/statusBadge.ts @@ -0,0 +1,35 @@ +// The coloured `● status` badge shared by `capy edit`'s STATUS column and +// `capy secrets`' STATUS column (CAP-702), so both screens colour a status +// the same way. + +const ESC = '\x1b'; +const RESET = `${ESC}[0m`; +const DIM = `${ESC}[90m`; +const GREEN = `${ESC}[32m`; +const YELLOW = `${ESC}[33m`; +const RED = `${ESC}[31m`; +const CYAN = `${ESC}[36m`; + +export type StatusWord = 'in sync' | 'local' | 'remote' | 'conflict' | 'not deployed' | 'unknown'; + +export function statusColor(status: StatusWord): string { + switch (status) { + case 'in sync': + return GREEN; + case 'local': + case 'not deployed': + return YELLOW; + case 'remote': + return CYAN; + case 'conflict': + return RED; + case 'unknown': + default: + return DIM; + } +} + +/** `●