Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 20 additions & 36 deletions src/commands/deployCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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\`.`,
Expand Down
47 changes: 38 additions & 9 deletions src/commands/editCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<KeepFile> {
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 {
Expand Down Expand Up @@ -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<string>([
...Object.keys(pinned),
Expand Down Expand Up @@ -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 = {
Expand Down
21 changes: 20 additions & 1 deletion src/commands/secretsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number> {
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;
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/commands/secretsSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) })),
Expand All @@ -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 } : {}),
};
}
Expand Down
5 changes: 3 additions & 2 deletions src/commands/secretsSetCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
28 changes: 25 additions & 3 deletions src/commands/secretsSetText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;

Expand Down Expand Up @@ -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
Expand All @@ -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
? [
Expand All @@ -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`) ──────────────────────────────────────
Expand Down
Loading
Loading