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..aa318077 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -8,7 +8,9 @@ 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 { readDefaultBranchTargets } from '../deploy/configuredTargets'; import { formatRelativeTime } from '../ui/relativeTime'; import { Encryptor } from '../crypto/encryptor'; import { deriveResourceId } from '../crypto/resourceId'; @@ -107,6 +109,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 +314,9 @@ export class EditCommand { 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), @@ -311,15 +345,10 @@ export class EditCommand { 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 { - 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..b9504ff9 100644 --- a/src/commands/secretsCommand.ts +++ b/src/commands/secretsCommand.ts @@ -3,6 +3,17 @@ import { resolveOrgContext } from '../core/orgContext'; import { Spinner } from '../ui/spinner'; import { CapyError, ERROR_CODES } from '../types/index'; import { rowIdOf } from './secretsRowId'; +import { secretRowStatus, secretRowStatusJson, type SecretRowStatus } from '../core/deployStatus'; + +/** + * `status` (`deployed` | `needs deploy` | `no target` | `unknown` — the + * on-screen words). For `deployed` / `needs deploy`, `status_locations` is + * how many of the row's `locations_total` locations are in that state. + */ +function statusFields(status: SecretRowStatus): Record { + if (status.kind === 'no target' || status.kind === 'unknown') return { status: secretRowStatusJson(status) }; + return { status: secretRowStatusJson(status), status_locations: status.locations, locations_total: status.total }; +} export interface SecretsOpts { json?: boolean; @@ -113,7 +124,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/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/core/deployStatus.ts b/src/core/deployStatus.ts new file mode 100644 index 00000000..c4bd79cd --- /dev/null +++ b/src/core/deployStatus.ts @@ -0,0 +1,107 @@ +/** + * CAP-702: the `behind` status (shown as BEHIND_LABEL), 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 + * behind 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 { SecretIndexLocation, SecretIndexRow } from '../service/serviceClient'; +import type { TargetConfig } from '../deploy/adapter'; +import { unrecordedTargetsFor } from '../deploy/configuredTargets'; + +// ── On-screen words: each lives in ONE constant so a wording decision is a +// one-line change. All four are Vince's words (2026-10-04). + +/** A target holds an older value than Capy has, or none (code `behind`). Used by both screens. */ +export const BEHIND_LABEL = 'needs deploy'; +/** `capy secrets`' heading for the target-status column (its details-view label is the lowercase form). */ +export const TARGET_STATUS_HEADING = 'DEPLOY STATUS'; +/** A row with no Capy target. */ +export const NO_TARGET_LABEL = '—'; +/** `capy secrets` only: every Capy target has the current value. (`capy edit` says `in sync`: it compares local with Capy.) */ +export const DEPLOYED_LABEL = 'deployed'; + +/** + * The value hash each of `varName`'s targets on `branch` last received, from a + * keep file — `null` for a target that never received one: a placeholder + * record, or a target configured in `.capy/deploy.json` (`configured`) with + * no record at all. `[]` when there are no targets. + */ +export function deployedHashesFor( + keep: KeepFile | undefined, + varName: string, + branch: string, + configured: readonly TargetConfig[] = [], +): readonly (string | null)[] { + const entry = keep?.variables[varName]?.find((e) => e.branch === branch); + const targets: ReadonlyArray = entry?.targets ?? []; + const recorded = targets.map((t) => t.deployed_value_hash ?? null); + const neverPushed = unrecordedTargetsFor(configured, keep, varName, branch).map(() => null); + return [...recorded, ...neverPushed]; +} + +/** Whether at least one target holds a value other than `valueHash` (a target that never received one always does). */ +export function anyTargetBehind(deployedHashes: readonly (string | null)[] | undefined, valueHash: string | undefined): boolean { + if (valueHash === undefined) return false; + return (deployedHashes ?? []).some((h) => h !== valueHash); +} + +// ── capy secrets ───────────────────────────────────────────────────────────── + +/** + * The DEPLOY STATUS of a `capy secrets` row. `deployed` and `behind` carry + * how many of the row's locations are in that state (`locations`) out of all + * of them (`total`), so a value used in 33 places with one current target + * reads `deployed (1 of 33)`. + */ +export type SecretRowStatus = + | { readonly kind: 'no target' } + | { readonly kind: 'unknown' } + | { readonly kind: 'deployed' | 'behind'; readonly locations: number; readonly total: number }; + +/** One location's DEPLOY STATUS: `none` when it has no Capy target. */ +export type LocationState = 'unknown' | 'none' | 'deployed' | 'behind'; + +export function locationState(loc: SecretIndexLocation): LocationState { + if (loc.targets === undefined) return 'unknown'; + if (loc.targets.length === 0) return 'none'; + return loc.targets.some((t) => t.stale) ? 'behind' : 'deployed'; +} + +/** + * Counted per LOCATION (Vince, 2026-10-04): `behind` when any location has a + * target that lags (older hash, or none); else `deployed` when some location + * has targets and all of them are current; else `no target` — Capy only knows + * its own targets, so a value used elsewhere (or only locally) is never + * called deployed. `unknown` when a location's targets were not sent (a + * server that predates CAP-676), so it can't be told. + */ +export function secretRowStatus(row: SecretIndexRow): SecretRowStatus { + const states = row.locations.map(locationState); + if (states.includes('unknown')) return { kind: 'unknown' }; + const total = states.length; + const behind = states.filter((s) => s === 'behind').length; + if (behind > 0) return { kind: 'behind', locations: behind, total }; + const deployed = states.filter((s) => s === 'deployed').length; + if (deployed > 0) return { kind: 'deployed', locations: deployed, total }; + return { kind: 'no target' }; +} + +/** The text a person sees, without colour: `—` for no target; the state's word, plus `(n of m)` locations when not every location is in that state. */ +export function formatSecretRowStatus(status: SecretRowStatus): string { + if (status.kind === 'no target') return NO_TARGET_LABEL; + if (status.kind === 'unknown') return status.kind; + const word = status.kind === 'behind' ? BEHIND_LABEL : DEPLOYED_LABEL; + return status.locations < status.total ? `${word} (${status.locations} of ${status.total})` : word; +} + +/** The `--json` `status` string: the on-screen words (Vince, 2026-10-04), with `no target` for the `—` row. */ +export function secretRowStatusJson(status: SecretRowStatus): string { + if (status.kind === 'behind') return BEHIND_LABEL; + if (status.kind === 'deployed') return DEPLOYED_LABEL; + return status.kind; +} diff --git a/src/deploy/configuredTargets.ts b/src/deploy/configuredTargets.ts new file mode 100644 index 00000000..8a648a6e --- /dev/null +++ b/src/deploy/configuredTargets.ts @@ -0,0 +1,173 @@ +/** + * CAP-702: deploy targets that are configured but have never received a + * push. They count as behind, in `capy edit` and in `capy secrets`. + * + * "Configured" means the target is in `.capy/deploy.json` as committed on + * the repo's default branch (read from local git, `origin/`), so a + * `capy deploy dokploy --discover` PR that never merges never counts. + * + * `capy edit` reads this directly. `capy secrets` only reads the server, so + * `recordConfiguredTargets` adds one + * placeholder record per configured (variable, target) that has none: + * `{ provider, target }` with no `deployed_value_hash`, which the server's + * index already reports as stale. The first real `capy deploy` to that target + * replaces it (same provider + target). Not `deployed: false`: that means + * "values pushed, release not triggered", and nothing was pushed here. + */ +import type { TargetConfig } from './adapter'; +import type { KeepFile, KeepVariableEntry, TargetDelivery } from '../types/index'; +import { readFileAtRef, repoRelPath, resolveDefaultBranch } from './git'; + +/** Where `.capy/deploy.json` sits relative to the project folder (same place `deployConfigPath` points). */ +const DEPLOY_CONFIG_REL = '.capy/deploy.json'; + +/** The targets in `.capy/deploy.json` on `origin/`; `[]` when there is no such file or it can't be read. */ +export function readDefaultBranchTargets(cwd: string): readonly TargetConfig[] { + try { + const defaultBranch = resolveDefaultBranch(cwd); + if (!defaultBranch) return []; + const raw = readFileAtRef(cwd, `origin/${defaultBranch}`, repoRelPath(cwd, DEPLOY_CONFIG_REL)); + return raw === null ? [] : parseTargets(raw); + } catch { + return []; + } +} + +function parseTargets(raw: string): readonly TargetConfig[] { + try { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || parsed.version !== '1' || !isRecord(parsed.targets)) return []; + return Object.values(parsed.targets).filter(isTargetConfig); + } catch { + return []; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isTargetConfig(value: unknown): value is TargetConfig { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.kind === 'string' && + typeof value.branch === 'string' && + Array.isArray(value.vars) && + value.vars.every((v) => typeof v === 'string') + ); +} + +export interface MissingTargetRecord { + readonly varName: string; + readonly provider: string; + readonly target: string; +} + +function entryFor(keep: KeepFile, varName: string, branch: string): KeepVariableEntry | undefined { + return keep.variables[varName]?.find((e) => e.branch === branch); +} + +function hasRecord(entry: KeepVariableEntry, provider: string, target: string): boolean { + return (entry.targets ?? []).some((t) => t.provider === provider && t.target === target); +} + +/** Configured targets on `branch` that ship `varName` but have no record on that variable's keep entry. */ +export function unrecordedTargetsFor( + targets: readonly TargetConfig[], + keep: KeepFile | undefined, + varName: string, + branch: string, +): readonly TargetConfig[] { + const entry = keep ? entryFor(keep, varName, branch) : undefined; + if (!entry) return []; + return targets.filter((t) => t.branch === branch && t.vars.includes(varName) && !hasRecord(entry, t.kind, t.name)); +} + +/** Every configured (variable, target) on `branch` whose variable exists in Capy but has no record for that target. */ +export function missingTargetRecords(targets: readonly TargetConfig[], keep: KeepFile, branch: string): readonly MissingTargetRecord[] { + return targets + .filter((t) => t.branch === branch) + .flatMap((t) => + t.vars + .filter((varName) => unrecordedTargetsFor([t], keep, varName, branch).length > 0) + .map((varName) => ({ varName, provider: t.kind, target: t.name })), + ); +} + +/** `keep` with a placeholder record added for each missing one. Returns `keep` itself when there is nothing to add. */ +export function withPlaceholderRecords(keep: KeepFile, branch: string, missing: readonly MissingTargetRecord[]): KeepFile { + if (missing.length === 0) return keep; + const variables = Object.fromEntries( + Object.entries(keep.variables).map(([varName, entries]) => { + const added = missing.filter((m) => m.varName === varName); + if (added.length === 0) return [varName, entries]; + return [ + varName, + entries.map((entry) => { + if (entry.branch !== branch) return entry; + const placeholders: readonly TargetDelivery[] = added.map((m) => ({ provider: m.provider, target: m.target })); + return { ...entry, targets: [...(entry.targets ?? []), ...placeholders] }; + }), + ]; + }), + ); + return { ...keep, variables }; +} + +/** 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; +} + +/** + * NOT WIRED: held until Vince approves an automatic server write (2026-10-04). + * Meant to run after a bare `capy` sync, as one call in capyCommand.ts. + * + * Records configured-but-never-pushed targets on + * the server, so `capy secrets` sees them. Builds on the SERVER's own keep + * file (it holds CI-mode records the local keep.lock may not have yet) and + * re-sends that branch's blob unchanged. One write per branch, only when a + * record is missing; nothing on a dry run. Best-effort: a failure never + * changes the command's result. + */ +export async function recordConfiguredTargets(input: { + cwd: string; + projectId: string; + client: ConfiguredTargetsClient; + dryRun: boolean; +}): Promise<{ readonly recorded: number }> { + if (input.dryRun) return { recorded: 0 }; + const targets = readDefaultBranchTargets(input.cwd); + const branches = [...new Set(targets.map((t) => t.branch))]; + const counts = await Promise.all(branches.map((branch) => recordBranch(input, targets, branch))); + return { recorded: counts.reduce((a, b) => a + b, 0) }; +} + +async function recordBranch( + input: { projectId: string; client: ConfiguredTargetsClient }, + targets: readonly TargetConfig[], + branch: string, +): Promise { + try { + const latest = await input.client.getLatestSecrets(input.projectId, branch); + const keep = latest?.keep_file ? parseKeep(latest.keep_file) : undefined; + if (!latest || !keep) return 0; + const missing = missingTargetRecords(targets, keep, branch); + if (missing.length === 0) return 0; + await input.client.pushSecrets(input.projectId, JSON.stringify(withPlaceholderRecords(keep, branch, missing)), latest.env_file, branch); + return missing.length; + } catch { + return 0; + } +} + +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/types/index.ts b/src/types/index.ts index 5879f003..361135d8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -113,10 +113,14 @@ export interface TargetDelivery { target: string; /** Adapter-specific handle for what was written, e.g. `{ composeId }` or `{ applicationId }`. */ ref?: Record; - /** sha256(value).slice(0,16) — same algorithm as `value_hash`, computed at delivery time. */ - deployed_value_hash: string; - /** ISO8601 UTC — when this was delivered to the target's configuration (not "running"). */ - deployed_at: string; + /** + * sha256(value).slice(0,16) — same algorithm as `value_hash`, computed at + * delivery time. Absent on a CAP-702 placeholder: the target is configured + * but has never received a push, so it is always behind. + */ + deployed_value_hash?: string; + /** ISO8601 UTC — when this was delivered to the target's configuration (not "running"). Absent on a CAP-702 placeholder. */ + deployed_at?: string; /** Token deploys only (Dokploy): the deploy id `capy deploy revoke` takes. */ deploy_id?: string; /** diff --git a/src/ui/editScreen.ts b/src/ui/editScreen.ts index e14a4505..e5408c9c 100644 --- a/src/ui/editScreen.ts +++ b/src/ui/editScreen.ts @@ -1,7 +1,9 @@ // 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 { statusBadge } from './statusBadge'; import { formatRelativeTime } from './relativeTime'; import { isRevealKey, stepEditBuffer } from './editBuffer'; import { OldValueView, canReveal, renderInlineValue as inlineValue, valueDialogRows } from './valueDialog'; @@ -29,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 = '—'; @@ -38,8 +39,15 @@ export interface EditRow { key: string; localValue: string | undefined; remoteValue: string | undefined; - status: 'in sync' | 'local' | 'remote' | 'conflict' | 'unknown'; + status: 'in sync' | 'local' | 'remote' | 'conflict' | 'behind' | 'unknown'; 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)[]; /** * 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 +143,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)) ? 'behind' : 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 +177,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 === 'behind' ? 'committed' : 'uncommitted'; return row.changedAt ? formatRelativeTime(row.changedAt) : NO_VALUE; } @@ -815,19 +838,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 'unknown': - default: - return `${DIM}● unknown${RESET}`; - } + return statusBadge(status); } // --- helpers --- 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/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index ab87c49e..eec94351 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -24,6 +24,8 @@ 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 { BEHIND_LABEL, DEPLOYED_LABEL, NO_TARGET_LABEL, TARGET_STATUS_HEADING, formatSecretRowStatus, locationState, secretRowStatus } from '../core/deployStatus'; +import { statusBadge, statusColor } from './statusBadge'; import { EditEffect, EditFlow, @@ -180,9 +182,20 @@ 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']; + +/** What each column is called on screen. `status` describes Capy's own targets only; its heading is TARGET_STATUS_HEADING. */ +const COLUMN_HEADINGS: Readonly> = { + project: 'PROJECT', + branch: 'BRANCH', + status: TARGET_STATUS_HEADING, + connector: 'CONNECTOR', + target: 'TARGET', + integrations: 'INTEGRATIONS', + users: 'USERS', +}; function nextColumn(column: ColumnMode, dir: 1 | -1): ColumnMode { const idx = COLUMN_ORDER.indexOf(column); @@ -204,6 +217,17 @@ export interface PopupState { readonly revealed: boolean; readonly value: ValueState; readonly panOffset: number; + /** The locations table's switchable middle column (Tab / Shift-Tab while the details view is open). */ + readonly locationColumn: LocationColumn; +} + +/** The details view's locations table: Tab cycles its middle column through this order (wrapping); DEPLOY STATUS first. */ +export type LocationColumn = 'status' | 'connector' | 'target'; +export const LOCATION_COLUMN_ORDER: readonly LocationColumn[] = ['status', 'connector', 'target']; + +function nextLocationColumn(column: LocationColumn, dir: 1 | -1): LocationColumn { + const idx = LOCATION_COLUMN_ORDER.indexOf(column); + return LOCATION_COLUMN_ORDER[(idx + dir + LOCATION_COLUMN_ORDER.length) % LOCATION_COLUMN_ORDER.length]; } export interface SearchState { @@ -230,7 +254,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, @@ -463,6 +487,12 @@ function handlePopupKey(state: SecretsScreenState, key: string): SecretsScreenSt if (key === KEY_END || key === KEY_END2) { return { ...state, popup: { ...popup, panOffset: Number.MAX_SAFE_INTEGER } }; } + if (key === KEY_TAB) { + return { ...state, popup: { ...popup, locationColumn: nextLocationColumn(popup.locationColumn, 1) } }; + } + if (key === KEY_SHIFT_TAB) { + return { ...state, popup: { ...popup, locationColumn: nextLocationColumn(popup.locationColumn, -1) } }; + } return state; } @@ -522,6 +552,7 @@ function handleListKey(state: SecretsScreenState, key: string): ReduceResult { revealed: false, value: { status: 'loading' }, panOffset: 0, + locationColumn: LOCATION_COLUMN_ORDER[0], }; return { state: { ...state, popup }, effect: { type: 'fetchValue', row } }; } @@ -741,9 +772,17 @@ 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 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); + if (status.kind === 'no target') return `${DIM}${formatSecretRowStatus(status)}${RESET}`; + 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)); @@ -853,7 +892,7 @@ function buildNameCell(left: string, tag: string, width: number): string { } function columnHeaderLabel(column: ColumnMode): string { - return `${column.toUpperCase()} ⇥`; + return `${COLUMN_HEADINGS[column]} ⇥`; } /** @@ -872,6 +911,19 @@ function searchBarLine(state: SecretsScreenState, matchedCount: number): string return `${DIM}search:${RESET} ${queryDisplay}${caret} ${DIM}${countLabel}${RESET}`; } +/** + * The main list's column widths for `available` columns: NAME, the + * Tab-switched middle column, UPDATED. The details view's locations table + * uses the same widths so its columns line up with the list's (CAP-702). + */ +export function mainTableLayout(available: number): { readonly nameW: number; readonly middleW: number; readonly updatedW: number; readonly gap: string } { + const nameW = Math.max(16, Math.floor(available * 0.4)); + const updatedW = 14; + const gap = ' '; + const middleW = Math.max(10, available - nameW - updatedW - gap.length * 2); + return { nameW, middleW, updatedW, gap }; +} + /** Pure render — a total function of state + terminal size. Never mutates `state`; any "clamping" of a display-only quantity (e.g. panning past the end of a value) is a local `const`, never written back. */ export function render(state: SecretsScreenState, termWidth: number, termHeight: number): string { if (state.edit) return renderEditScreen(state.edit, termWidth, termHeight, state.dryRun); @@ -888,10 +940,7 @@ export function render(state: SecretsScreenState, termWidth: number, termHeight: '', ]; - const nameW = Math.max(16, Math.floor(available * 0.4)); - const updatedW = 14; - const gap = ' '; - const middleW = Math.max(10, available - nameW - updatedW - gap.length * 2); + const { nameW, middleW, updatedW, gap } = mainTableLayout(available); const headerLine = pad('NAME', nameW) + gap + pad(columnHeaderLabel(state.column), middleW) + gap + pad('UPDATED', updatedW); // Everything pushed before the body, in one array — `preBodyLines.length` @@ -912,7 +961,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[] = @@ -977,7 +1027,7 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): const inner = ' '; const ruleWidth = Math.max(10, width - indent.length); const rule = `${indent}${DIM}╶${'─'.repeat(Math.max(0, ruleWidth - 2))}╴${RESET}`; - const labelW = 9; + const labelW = 15; const contentWidth = Math.max(20, ruleWidth - inner.length - 1); const valueWidth = Math.max(10, contentWidth - labelW); @@ -990,29 +1040,12 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): '', field('value', renderPopupValueLine(popup, valueWidth)), field('updated', formatUpdatedCell(row)), + field(TARGET_STATUS_HEADING.toLowerCase(), secretRowStatusBadge(row)), '', `${indent}${inner}${BOLD}locations${RESET}`, ]; - // 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 - // 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) => { - const protMarker = loc.protected ? ` ${DIM}(protected)${RESET}` : ''; - const connectorLabel = formatLocationConnectorLabel(loc); - const updated = loc.changed_at ? formatRelativeTime(loc.changed_at) : '—'; - const targets = loc.targets ?? []; - const targetsLabel = - targets.length > 0 - ? ` · targets: ${targets - .map((t) => `[${t.provider}] ${t.target}${t.stale ? ' (stale)' : ''}${t.pending ? ' (pending)' : ''}`) - .join(', ')}` - : ''; - return `${indent}${inner}${truncate(`${loc.project_name} · ${loc.branch}`, contentWidth)}${protMarker} ${DIM}· ${connectorLabel} · ${updated}${targetsLabel}${RESET}`; - }); + const locationLines = buildLocationTable(row.locations, popup.locationColumn, width, indent.length + inner.length).map((l) => `${indent}${inner}${l}`); const usersHeaderLines: readonly string[] = ['', `${indent}${inner}${BOLD}users${RESET}`]; const userLines: readonly string[] = @@ -1023,6 +1056,81 @@ function buildPopupLines(row: SecretIndexRow, popup: PopupState, width: number): return [...topLines, ...locationLines, ...usersHeaderLines, ...userLines, '', rule]; } +// ── Details view: locations table (CAP-702) ───────────────────────────────── + +const LOCATION_COLUMN_HEADINGS: Readonly> = { + status: TARGET_STATUS_HEADING, + connector: 'CONNECTOR', + target: 'TARGET', +}; + +/** One location's DEPLOY STATUS badge — the row's words and colours. */ +function locationStatusBadge(loc: SecretIndexLocation): string { + const state = locationState(loc); + if (state === 'none') return `${DIM}${NO_TARGET_LABEL}${RESET}`; + if (state === 'unknown') return statusBadge('unknown'); + return statusBadge(state, state === 'behind' ? BEHIND_LABEL : DEPLOYED_LABEL); +} + +/** Every target this location was pushed to, `[provider] target`, a lagging one marked in yellow; `—` if none. */ +function locationTargetsLabel(loc: SecretIndexLocation): string { + const targets = loc.targets ?? []; + if (targets.length === 0) return '—'; + return targets + .map((t) => `[${t.provider}] ${t.target}${t.stale ? ` ${statusColor('behind')}(${BEHIND_LABEL})${RESET}` : ''}${t.pending ? ' (pending)' : ''}`) + .join(', '); +} + +function locationMiddleCell(loc: SecretIndexLocation, column: LocationColumn): string { + if (column === 'status') return locationStatusBadge(loc); + if (column === 'connector') return formatLocationConnectorLabel(loc); + return locationTargetsLabel(loc); +} + +/** + * LOCATION (always) · a Tab-switched middle column · UPDATED, on the main + * list's own column positions (`mainTableLayout(available)`): the middle and + * UPDATED columns start where the list's do, at the same widths, and + * LOCATION fills the NAME column's space less the details view's `indent`. + * When narrow, the project name shrinks first so the branch stays readable. + */ +export function buildLocationTable( + locations: readonly SecretIndexLocation[], + column: LocationColumn, + available: number, + indent: number, +): readonly string[] { + const { nameW, middleW, updatedW, gap } = mainTableLayout(available); + const locationW = Math.max(1, nameW - indent); + const protectedMark = ` ${DIM}(protected)${RESET}`; + const locationTail = (loc: SecretIndexLocation) => ` · ${loc.branch}${loc.protected ? protectedMark : ''}`; + const middleHeading = `${LOCATION_COLUMN_HEADINGS[column]} ⇥`; + + const header = `${DIM}${pad('LOCATION', locationW)}${gap}${pad(middleHeading, middleW)}${gap}${pad('UPDATED', updatedW)}${RESET}`; + const body = locations.map((loc) => { + const location = pad(fitLocation(loc.project_name, [locationTail(loc), ` · ${loc.branch}`], locationW), locationW); + const middle = pad(locationMiddleCell(loc, column), middleW); + const updated = pad(loc.changed_at ? formatRelativeTime(loc.changed_at) : '—', updatedW); + return `${location}${gap}${middle}${gap}${DIM}${updated}${RESET}`; + }); + return [header, ...body]; +} + +/** + * `project · branch` in `width`: the project name shrinks first (with `…`) so + * the branch stays readable. `tails` is tried in order (with the protected + * marker, then without); only when none fits is the whole cell cut. + */ +function fitLocation(project: string, tails: readonly string[], width: number): string { + const [tail, ...rest] = tails; + if (tail === undefined) return truncate(project, width); + const full = `${project}${tail}`; + if (visLen(full) <= width) return full; + const room = width - visLen(tail); + if (room >= 4) return `${project.slice(0, room - 1)}…${tail}`; + return rest.length > 0 ? fitLocation(project, rest, width) : truncate(full, width); +} + function renderPopupValueLine(popup: PopupState, width: number): string { if (popup.value.status === 'loading') return `${DIM}loading…${RESET}`; if (popup.value.status === 'unavailable') return `${RED}unavailable ${DIM}(${popup.value.code})${RESET}`; @@ -1046,7 +1154,7 @@ function footerLine(state: SecretsScreenState): string { if (state.popup) { const revealLabel = state.popup.revealed ? 'hide' : 'reveal'; const panHint = state.popup.revealed ? `${DIM} · ${RESET}${BOLD}←/→${RESET}${DIM} pan${RESET}` : ''; - return `${BOLD}r${RESET}${DIM} ${revealLabel}${RESET}${panHint}${DIM} · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM}/${RESET}${BOLD}q${RESET}${DIM} close${RESET}`; // COPY-FLAG + return `${BOLD}r${RESET}${DIM} ${revealLabel}${RESET}${panHint}${DIM} · ${RESET}${BOLD}tab${RESET}${DIM} column · ${RESET}${BOLD}e${RESET}${DIM} edit${RESET}${DIM} · ${RESET}${BOLD}esc${RESET}${DIM}/${RESET}${BOLD}q${RESET}${DIM} close${RESET}`; // COPY-FLAG } const editHint = state.search.query === '' ? `${BOLD}ctrl+e${RESET}${DIM} edit · ${RESET}` : ''; return `${DIM}↑↓ navigate · ${RESET}${BOLD}tab${RESET}${DIM} column · ${RESET}${BOLD}enter${RESET}${DIM} inspect · ${RESET}${editHint}${BOLD}esc${RESET}${DIM} clear/quit${RESET}`; // COPY-FLAG diff --git a/src/ui/statusBadge.ts b/src/ui/statusBadge.ts new file mode 100644 index 00000000..fdb5ce7d --- /dev/null +++ b/src/ui/statusBadge.ts @@ -0,0 +1,43 @@ +// 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. + +import { BEHIND_LABEL } from '../core/deployStatus'; + +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`; + +/** Status codes. Every code is its own on-screen word except `behind`, shown as BEHIND_LABEL. */ +export type StatusWord = 'in sync' | 'deployed' | 'local' | 'remote' | 'conflict' | 'behind' | 'unknown'; + +export function statusColor(status: StatusWord): string { + switch (status) { + case 'in sync': + case 'deployed': + return GREEN; + case 'local': + case 'behind': + return YELLOW; + case 'remote': + return CYAN; + case 'conflict': + return RED; + case 'unknown': + default: + return DIM; + } +} + +/** `●