run402 4.40.0 → 4.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gitvault.mjs CHANGED
@@ -1,1150 +1,119 @@
1
1
  /**
2
- * `run402 gitvault` — the host-blind encrypted Git remote (wire tag `r402s/v0`).
2
+ * `run402 gitvault` — RETIRED (repo-surface-consolidation, design D7).
3
3
  *
4
- * ARCHITECTURAL LAW (gitvault-client-surface, "All protocol logic lives in the
5
- * SDK"): every piece of vault protocol behaviour crypto core, keystore,
6
- * creation journal, snapshot + capture, publication state machines, ref
7
- * transactions, verification budget, token exchange, repair lives ONCE in
8
- * `@run402/sdk` under `r.gitvault`. This module is a THIN ADAPTER: argument
9
- * parsing, TTY output, exit codes, local file I/O. It adds zero protocol
10
- * behaviour, and imports only the SDK (via `./sdk.mjs`) plus the CLI's own
11
- * argument/error helpers never a crypto, HTTP, or git library.
4
+ * The 19-command dispatcher this module used to hold is gone. Every old
5
+ * spelling now answers a proper stderr JSON error envelope: typed
6
+ * `next_actions`, non-zero exit, EMPTY stdout never silence, never new
7
+ * behavior. Renamed verbs answer `COMMAND_MOVED` naming their `repos`
8
+ * successor; verbs with no behavioral successor answer `COMMAND_REMOVED`
9
+ * (never a `COMMAND_MOVED` that would lie about equivalence design
10
+ * D7/D10). The freed spellings are RESERVED for one release, never
11
+ * re-pointed at different behavior the `rooms`→`messages` precedent.
12
12
  *
13
- * Pipe contract (docs/style.md): the payload is JSON on stdout; every human
14
- * line progress, the terminal-loss statement, advisories goes to stderr, so
15
- * `run402 gitvault status | jq` stays clean.
16
- *
17
- * Run these from inside the git working tree: `repo_dir` is `process.cwd()`.
13
+ * `gitvault` itself survives only as protocol/infrastructure language: the
14
+ * SDK keeps `r.gitvault.*` (design D1 "gitvault is what the thing IS;
15
+ * repos is what the user HAS"). This module's one surviving export,
16
+ * `printKeystoreLocation`, is a shared print helper `cli/lib/repos.mjs`
17
+ * still composes moving it there instead would make repos.mjs restate
18
+ * logic this file already owns for zero benefit.
18
19
  */
19
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
- import { dirname, join } from "node:path";
21
- import { resolveProjectId } from "./config.mjs";
22
- import { resolveOwningOrgId } from "./org-context.mjs";
23
- import { resolveGitvaultTarget } from "./gitvault-target.mjs";
24
- import { getSdk } from "./sdk.mjs";
25
- import { reportSdkError, fail } from "./sdk-errors.mjs";
26
- import {
27
- normalizeArgv,
28
- hasHelp,
29
- assertKnownFlags,
30
- parseIntegerFlag,
31
- flagValue,
32
- requirePositionalCount,
33
- failUnknownSubcommand,
34
- } from "./argparse.mjs";
35
-
36
- /** Value-taking flags every gitvault subcommand accepts. */
37
- const COMMON_VALUE_FLAGS = ["--project", "--repo"];
20
+ import { fail } from "./sdk-errors.mjs";
21
+ import { hasHelp } from "./argparse.mjs";
38
22
 
39
- export const HELP = `run402 gitvault — your source, encrypted before it leaves the machine
23
+ export const HELP = `run402 gitvault — RETIRED (repo-surface-consolidation)
40
24
 
41
25
  Usage:
42
- run402 gitvault init [--project <id>] [--org <org_id>] [--git-remote] [--no-remote]
43
- run402 gitvault status [--project <id>] [--repo <repo_id>] [--refs] [--human]
44
- run402 gitvault snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run]
45
- run402 gitvault policy <required|grandfathered> [--project <id>] [--repo <repo_id>]
46
- [--reason <why>]
47
- run402 gitvault compact [--project <id>] [--repo <repo_id>]
48
- run402 gitvault prune [--project <id>] [--repo <repo_id>]
49
- [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
50
- run402 gitvault verify [--project <id>] [--repo <repo_id>] [--budget <n>]
51
- run402 gitvault reconcile [--project <id>] [--repo <repo_id>]
52
- run402 gitvault mirror set <destination> [--profile <name> | --ambient]
53
- [--region <r>] [--endpoint <url>] [--project <id>] [--repo <repo_id>]
54
- run402 gitvault mirror remove [--project <id>] [--repo <repo_id>]
55
- run402 gitvault mirror status [--project <id>] [--repo <repo_id>]
56
- run402 gitvault mirror sync [--project <id>] [--repo <repo_id>]
57
- run402 gitvault mirror verify [--project <id>] [--repo <repo_id>]
58
- run402 gitvault recover <source> --out <dir> [--repo <repo_id>]
59
- [--profile <name> | --ambient] [--region <r>] [--endpoint <url>]
60
-
61
- Subcommands:
62
- init ALLOCATE the project's vault. This is the one step that mints key
63
- material on this machine and emits the one-shot recovery receipt,
64
- so it is explicit rather than a side effect of \`run402 init\` (which
65
- only adds the git remote). Idempotent: an existing vault is
66
- reported with \`deduplicated: true\` and nothing is re-minted. Adds
67
- the \`run402\` remote too when the current directory is already a
68
- repository.
69
- status What this machine and the control plane each believe about the
70
- vault: allocation, policy, whether this keystore can sign, the
71
- authenticated and materialized pins, any pending
72
- unvaulted-override journals, and where the keystore lives. Never
73
- reports key material.
74
- policy Set the activation policy — \`required\` (a deploy must present a
75
- vaulted capture) or \`grandfathered\` (it need not). Owner + step-up,
76
- audited. \`grandfathered\` is the documented way out of a deploy
77
- blocked by GITVAULT_CLIENT_UPGRADE_REQUIRED, and leaves a
78
- doctor-persistent warning until the project returns to \`required\`.
79
- snapshot Capture the working tree and publish it. This is NOT gated on a
80
- deploy — a vault-only project snapshots for months without one.
81
- Against a project with no vault yet, this ALLOCATES one inline
82
- (the six-stage creation, same as \`init\`) before publishing — one
83
- command, no prior \`gitvault init\`. When --repo/--project are
84
- both omitted and the local run402/origin remote is a slug-form
85
- address (run402::<org-slug>/<name>), PUSH-TO-CREATES through it
86
- instead (design D6) — same as pushing that name with \`git\`. The
87
- one-shot recovery receipt and keystore path print to stderr the
88
- moment that happens.
89
- Before reporting a snapshot as landed the SDK compares finalization
90
- receipts against the expected manifest and reads the admitted head
91
- back from storage; a 200 alone is never enough. \`push\` is a
92
- deprecation-warning alias for one release — it will be removed
93
- next release. Once \`gitvault\` was the only publish verb; \`git
94
- push\` is now the actual publish path (via the remote helper),
95
- so \`push\` here was renamed to name what it does: one verb per
96
- operation. \`--dry-run\` (kychee-com/run402#565) previews it
97
- instead: the same real local pipeline, publishing nothing and
98
- never allocating.
99
- compact Publish a checkpoint covering the canonical refs, every root
100
- unexpired at the cutoff, and the HEAD target, under a maintenance
101
- lease so a concurrent cycle cannot race it.
102
- prune Plan a prune, and — with both verifier receipts — submit it.
103
- Two phases, because the protocol is two-phase; see below.
104
- verify Verify the head chain from the authenticated pin up to the newest
105
- listed generation. Fails closed on a regression, a gap, or a
106
- transition descriptor this client cannot validate.
107
- reconcile Wrap this vault's current epoch key to every org member who has
108
- published an encryption key but has no envelope on this vault
109
- yet (gitvault-human-envelopes task 4.1's ADD-path workaround).
110
- \`snapshot\`/\`push\` already run this themselves, best-effort,
111
- after every successful publish — run it explicitly at session
112
- start, or to see the per-recipient breakdown on its own. Pins
113
- each recipient's fingerprint on first wrap (TOFU); a directory
114
- fingerprint that no longer matches its pin is refused, never
115
- silently re-wrapped — reported loudly, one recipient at a time.
116
- THIS IS A WORKAROUND, not full epoch rotation: a newly-wrapped
117
- member gets this vault's ENTIRE history under V0's single fixed
118
- epoch, not \"from here forward\" — the org directory route also
119
- does not yet publish the raw key bytes this needs, so every
120
- recipient reports \`skipped\` (reason \`missing_public_key\`)
121
- until the gateway ships that field.
122
- mirror The exit ramp (gitvault-mirror-and-recover): a client-side,
123
- customer-owned ciphertext mirror. run402 never holds a
124
- credential to it. \`set\` configures the destination (config
125
- lives beside the keystore, never in run402.config.json, never a
126
- raw secret); \`remove\` drops the config only — it NEVER touches
127
- the mirror's own bytes; \`status\` reports whether the mirror is
128
- current against the live vault; \`sync\` lists+diffs+copies what
129
- the mirror is missing (idempotent, resumable); \`verify\` is the
130
- KEYLESS integrity probe — discovery + chain verification +
131
- absence adjudication, never decryption. Every deploy/snapshot
132
- dual-pushes to a configured mirror automatically; a mirror
133
- failure NEVER blocks the deploy — it is reported as a separate
134
- \`mirror_push\` field.
135
- recover \`r402s-recover\`: rebuild a working git repository straight from
136
- a mirrored prefix, with NO SERVER INVOLVED. Proves this mirror's
137
- validity, never freshness — read both honesty statements in the
138
- output before relying on the result.
139
-
140
- Options:
141
- --project <id> Project whose vault to act on (defaults to the active project)
142
- --org <org_id> init: the owning organization (resolved from the project
143
- when omitted)
144
- --git-remote init: 'git init' the current directory when it is not a
145
- repository yet, so the run402 remote can be added there.
146
- Opt-in: creating a repository where you did not ask for one
147
- is a bad surprise, so without it a non-repository directory
148
- allocates the vault and adds no remote.
149
- --no-remote init: allocate the vault only; touch no git configuration
150
- --reason <why> policy: why the policy is changing — recorded in the audit
151
- event. REQUIRED for \`grandfathered\`, which is a deliberate
152
- weakening of the activation guarantee.
153
- --refs status: also materialize and report the vault's ref map and
154
- HEAD target. This is a VERIFICATION (it walks the head
155
- chain and advances the local materialized pin), which is
156
- why plain \`status\` — an observation — does not do it.
157
- --human status: a five/six-line human summary on stdout instead of
158
- the JSON dump (kychee-com/run402#569; explicit opt-in per
159
- the cli-output-contract). Address, remote; HEAD + ref count
160
- (needs --refs too — otherwise the line names the omission);
161
- generations in decimal; storage bytes/object count (from
162
- this SAME status() call — no extra network read); whether
163
- THIS machine can decrypt, and the policy; standing warnings,
164
- if any, verbatim (a live terminal-loss risk may be the sixth
165
- line). Rejected together with --json. No effect on plain
166
- \`status\`'s own output, which is unchanged.
167
- --repo <repo_id> Address the vault directly by id, skipping project lookup
168
- --message <text> snapshot: commit message for the synthetic commit a dirty tree
169
- produces (a clean tree pushes HEAD itself, no message used)
170
- --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
171
- --dry-run snapshot: a REAL preview (kychee-com/run402#565) — runs the actual
172
- local pipeline (capture, pack building, encryption sizing) and
173
- reports objects, encrypted bytes, refs, and the generation it
174
- would admit as. Publishes NOTHING, and never allocates a vault
175
- that does not exist yet (reports allocation_needed instead).
176
- --budget <n> verify: heads to verify in this call. The verified prefix is
177
- persisted, so a budget-exceeded run resumes where it stopped
178
- instead of restarting.
179
- --submit prune: submit the planned intent. Requires --intent-core and
180
- --verifier-receipt.
181
- --intent-core <path>
182
- prune: the plan's \`intent_core\`, saved verbatim from a prior
183
- planning run. A rebuilt core carries a different nonce, so
184
- the r402s-verify receipt would no longer bind to it.
185
- --verifier-receipt <path>
186
- prune: r402s-verify's \`verifier_receipt\` over that core.
187
- --wait prune: poll the submitted intent until the control-plane-
188
- signed completion appears, instead of returning immediately.
189
- --profile <name> mirror set / recover: the AWS credential profile name for an
190
- s3:// destination (read from ~/.aws/credentials at USE time —
191
- never stored). Mutually exclusive with --ambient.
192
- --ambient mirror set / recover: use the ambient AWS_ACCESS_KEY_ID /
193
- AWS_SECRET_ACCESS_KEY environment chain instead of a profile.
194
- --region <r> mirror set / recover: AWS region for an s3:// destination
195
- (defaults to AWS_REGION / AWS_DEFAULT_REGION).
196
- --endpoint <url> mirror set / recover: an S3-compatible endpoint override.
197
- --out <dir> recover: where to materialize the recovered repository.
198
- --json No-op: stdout is already JSON.
199
-
200
- prune is TWO PHASES, because the protocol is:
201
- 1. \`run402 gitvault prune\` plans. It walks the verified chain, computes the GC
202
- root set, subtracts it, and prints a SIGNED \`intent_core\` plus its
203
- \`intent_core_sha256\`. Nothing is submitted and nothing is deleted.
204
- 2. Run \`r402s-verify\` against that core, then re-run with
205
- \`--submit --intent-core <core.json> --verifier-receipt <receipt.json>\`.
206
- The intent carries TWO receipts over the same core, one per implementation:
207
- this CLI produces the \`run402-cli\` half by restoring the latest checkpoint
208
- and recomputing its commitments, and \`r402s-verify\` produces the other.
209
- A second receipt from this lineage would prove nothing, so it is never
210
- synthesized here.
211
- Only the control-plane-signed completion says what was deleted, and only its
212
- \`deleted\` result means the bytes are gone — \`present_after_attempt\` is a
213
- FAILED deletion, never counted as a success. There is deliberately no purge
214
- verb in V0 at all. Retention is an operational promise of the platform, not a
215
- cryptographic guarantee against it.
216
-
217
- Expiry is permissive, by design:
218
- A retention root whose \`effective_admitted_at\` this client cannot resolve is
219
- RETAINED, and a compact that cannot obtain a retention-cutoff ticket keeps
220
- every root. That costs storage, never history.
221
-
222
- Terminal loss (protocol §0):
223
- In V0-A, whole-machine or whole-keystore loss is terminal for vault history
224
- until human envelopes ship. \`status\` prints the full statement verbatim on
225
- stderr and carries it in its JSON — read it before you rely on this.
226
-
227
- Examples:
228
- run402 gitvault init
229
- run402 gitvault status --refs
230
- run402 gitvault status --human
231
- run402 gitvault snapshot --message "wip: refactor the parser"
232
- run402 gitvault snapshot --dry-run
233
- run402 gitvault policy grandfathered --reason "migrating CI to a vaulted client"
234
- run402 gitvault verify --budget 500
235
- run402 gitvault reconcile
236
- run402 gitvault prune --project prj_1a2b3c
237
- run402 gitvault mirror set s3://acme-vault-mirror --profile acme
238
- run402 gitvault mirror sync
239
- run402 gitvault mirror status
240
- run402 gitvault mirror verify
241
- run402 gitvault recover s3://acme-vault-mirror --out ./restored
242
- # \`gitvault push\` still works as a deprecation-warning alias for \`snapshot\`
243
- # for one release; it will be removed next release.
26
+ run402 gitvault <anything> answers a structured error naming its repos/git successor; see below.
27
+
28
+ Every "run402 gitvault <verb>" spelling has moved to "run402 repos <verb>",
29
+ except "reconcile" and "push", which are REMOVED outright (see below). Each
30
+ one answers a structured COMMAND_MOVED/COMMAND_REMOVED error for exactly one
31
+ release; after that the spelling is reserved and answers nothing at all.
32
+
33
+ Moved:
34
+ gitvault init -> repos create --project <id> (or repos create <name> for a NEW project)
35
+ gitvault status -> repos view
36
+ gitvault snapshot -> repos snapshot
37
+ gitvault policy -> repos policy
38
+ gitvault compact -> repos gc
39
+ gitvault prune -> repos gc
40
+ gitvault verify -> repos fsck
41
+ gitvault mirror -> repos mirror
42
+ gitvault recover -> repos recover
43
+
44
+ Removed, no successor:
45
+ gitvault push -> git push (its one-release alias window is over); repos snapshot is the capture lane
46
+ gitvault reconcile -> repos access (read-only inspection; the workaround it approximated is gone, not renamed)
47
+
48
+ Run \`run402 repos --help\` for the current surface.
244
49
  `;
245
50
 
246
- /**
247
- * Resolve which vault to act on, plus the local git tree.
248
- *
249
- * `--repo` addresses the vault directly (the cold-restart path: an agent that
250
- * knows its repo_id needs no project lookup). Otherwise the project targets,
251
- * highest first: `--project` > the repo's own pin/remote > RUN402_PROJECT_ID
252
- * > the active project (repo-first-onramp follow-up, kychee-com/run402#559
253
- * see `gitvault-target.mjs`'s module doc for the full targeting order and
254
- * why it exists: a stale active-project pointer used to silently outrank the
255
- * repository this command is actually standing in).
256
- */
257
- async function vaultTarget(a) {
258
- const repoId = flagValue(a, "--repo");
259
- const project = flagValue(a, "--project");
260
- const repoDir = process.cwd();
261
- const resolved = await resolveGitvaultTarget({
262
- repoDir,
263
- explicitProjectId: project ?? undefined,
264
- explicitRepoId: repoId ?? undefined,
51
+ /** Renamed verbs: `gitvault <key>` -> the exact `repos` command that means the same thing now. */
52
+ const MOVED = {
53
+ init: { command: "run402 repos create --project <id>", why: "repos create absorbs allocation — pass --project to adopt an existing project, or a name to provision a new one." },
54
+ status: { command: "run402 repos view", why: "repos view is the side-effect-free repo inspection command; it never materializes refs the way status --refs used to." },
55
+ snapshot: { command: "run402 repos snapshot", why: "same verb, new noun." },
56
+ policy: { command: "run402 repos policy", why: "same verb, new noun." },
57
+ compact: { command: "run402 repos gc", why: "gc is git gc's own two halves checkpoint publication and prune planning — in one verb." },
58
+ prune: { command: "run402 repos gc", why: "gc is git gc's own two halves checkpoint publication and prune planning — in one verb." },
59
+ verify: { command: "run402 repos fsck", why: "fsck is git fsck's own job: walk the object graph and fail closed on corruption." },
60
+ mirror: { command: "run402 repos mirror", why: "one flag-driven verb replaces the five mirror subcommands (set/remove/status/sync/verify)." },
61
+ recover: { command: "run402 repos recover", why: "same verb, new noun." },
62
+ };
63
+
64
+ /** Verbs with NO behavioral successor — a lying COMMAND_MOVED would be worse than an honest COMMAND_REMOVED. */
65
+ const REMOVED = {
66
+ push: {
67
+ message: "`run402 gitvault push` was a deprecation-warning alias for exactly one release, and that release is over.",
68
+ hint: "`push` now means exactly one thing everywhere: `git push`. The capture lane is `run402 repos snapshot`.",
69
+ next_actions: [
70
+ { type: "use_moved_command", command: "git push", why: "Publish your branches and tags the ordinary way." },
71
+ { type: "use_moved_command", command: "run402 repos snapshot", why: "The capture lane: publish the protocol-owned deploy ref outside a deploy." },
72
+ ],
73
+ },
74
+ reconcile: {
75
+ message: "`run402 gitvault reconcile` is removed. It was a workaround — its own help text said so — a newly-wrapped member got the vault's entire history under a single fixed epoch, not real epoch rotation, and a temporary mechanism does not get a permanent verb.",
76
+ hint: "`run402 repos access` reports what the read surface has today (recipients, coverage, this machine's local TOFU pins). `repos access repair` will replace the mutating half once real epoch rotation ships — it does not exist yet either.",
77
+ next_actions: [{ type: "use_moved_command", command: "run402 repos access", why: "Inspect recipients and coverage — there is no equivalent mutating successor yet." }],
78
+ },
79
+ };
80
+
81
+ function movedResponse(sub) {
82
+ const m = MOVED[sub];
83
+ fail({
84
+ code: "COMMAND_MOVED",
85
+ message: `run402 gitvault ${sub} moved to ${m.command}.`,
86
+ hint: m.why,
87
+ details: { was: `gitvault ${sub}`, now: m.command },
88
+ next_actions: [{ type: "use_moved_command", command: m.command, why: m.why }],
265
89
  });
266
- const target = { repo_dir: repoDir };
267
- if (repoId != null) target.repo_id = repoId;
268
- // Only demand a project when one is actually needed: `--repo` alone is a
269
- // complete address, and requiring one on top of it would make the
270
- // cold-restart path fail for no reason.
271
- if (repoId == null || project != null) {
272
- if ("repo_id" in resolved && project == null) target.repo_id = resolved.repo_id;
273
- // `resolveGitvaultTarget` reports its last (env/active) tier
274
- // non-throwingly (`run402 doctor`'s call site needs that) — this call
275
- // site is the one that historically failed closed with PROJECT_REQUIRED
276
- // when nothing resolves anywhere, and still does: `resolveProjectId`
277
- // re-derives the exact same env/active check and throws.
278
- if ("project_id" in resolved) target.project_id = resolved.project_id ?? resolveProjectId(project);
279
- }
280
- return target;
281
90
  }
282
91
 
283
- /**
284
- * Print the protocol §0 terminal-loss statement.
285
- *
286
- * NORMATIVE COPY, printed verbatim straight from the SDK's own constants and
287
- * never paraphrased, summarized, or reassembled here. Both lines also ride in
288
- * the JSON payload on stdout.
289
- *
290
- * The PATH is printed with it. "Whole-keystore loss is terminal" appeared three
291
- * times across this surface while the directory to back up appeared nowhere
292
- * (dogfood #1, finding D2) — a warning nobody can act on.
293
- */
294
- function printTerminalLoss(status) {
295
- console.error("");
296
- console.error(status.terminal_loss_statement);
297
- console.error(status.terminal_loss_detail);
298
- console.error(`Back up this directory: ${status.keystore.root}`);
299
- console.error("");
92
+ function removedResponse(sub) {
93
+ const r = REMOVED[sub];
94
+ fail({
95
+ code: "COMMAND_REMOVED",
96
+ message: r.message,
97
+ hint: r.hint,
98
+ details: { was: `gitvault ${sub}` },
99
+ next_actions: r.next_actions,
100
+ });
300
101
  }
301
102
 
302
103
  /**
303
- * Where the keystore lives — for verbs whose payload is not a `status`.
304
- * Exported: `repos create` (repo-first-onramp task 2.6) prints the same
305
- * line after allocating a vault, and must not restate this logic.
104
+ * Where the keystore lives — for verbs whose payload is not a `view`.
105
+ * Exported: `repos create` and `repos snapshot` print the same line after
106
+ * allocating/publishing, and must not restate this logic.
306
107
  */
307
108
  export async function printKeystoreLocation() {
308
109
  try {
309
110
  const { getGitvaultKeystoreRoot } = await import("#sdk/node");
310
- console.error(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-keystore loss is terminal for vault history`);
111
+ console.error(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-keystore loss is terminal for repo history`);
311
112
  } catch {
312
113
  // Never let a diagnostic line fail a command that already succeeded.
313
114
  }
314
115
  }
315
116
 
316
- /**
317
- * `run402 gitvault init` — allocate the project's vault.
318
- *
319
- * WHY THIS EXISTS AS ITS OWN VERB (dogfood #1, finding A). Until it did, the
320
- * only way to allocate was `sdk.gitvault.init()` through the vendored SDK:
321
- * `gitvault status` pointed at `run402 init`, which scaffolds the remote and
322
- * says so in a comment; `gitvault push` and `git push run402` both 404'd and
323
- * handed the user a raw `POST /gitvault/v1/vaults`. A published CLI that can
324
- * do everything except start is not a usable product.
325
- *
326
- * It stays SEPARATE from `run402 init` on purpose: this is the step that mints
327
- * key material on this machine and emits a one-shot recovery receipt, and
328
- * whole-keystore loss is terminal for vault history. That belongs to a command
329
- * the user typed, not to a setup command's side effects.
330
- */
331
- async function init(args) {
332
- const a = normalizeArgv(args);
333
- const valueFlags = ["--project", "--org"];
334
- assertKnownFlags(a, [...valueFlags, "--git-remote", "--no-remote", "--help", "-h"], valueFlags);
335
- requirePositionalCount(a, valueFlags, {
336
- min: 0, max: 0, command: "run402 gitvault init", missing: "",
337
- });
338
- if (a.includes("--git-remote") && a.includes("--no-remote")) {
339
- fail({
340
- code: "BAD_USAGE",
341
- message: "--git-remote and --no-remote contradict each other.",
342
- hint: "--git-remote creates a repository to add the remote to; --no-remote touches no git configuration at all.",
343
- });
344
- }
345
- const projectId = resolveProjectId(flagValue(a, "--project"));
346
- const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
347
- if (!orgId) {
348
- fail({
349
- code: "ORG_UNRESOLVED",
350
- message: `Could not resolve the organization that owns ${projectId}.`,
351
- hint: "Pass --org <org_id>, or check that this wallet can see the project (`run402 projects list`).",
352
- details: { project_id: projectId },
353
- });
354
- }
355
-
356
- // Whether to touch git at all. Mirrors `run402 init`: adding a remote inside
357
- // an EXISTING repository is pure addition and is the default; CREATING a
358
- // repository is opt-in, because a vault can be allocated from anywhere and
359
- // `git init`-ing whatever directory you happened to be in is a bad surprise.
360
- let scaffold = !a.includes("--no-remote");
361
- let remoteSkipped = null;
362
- if (scaffold && !a.includes("--git-remote")) {
363
- const { hardenedGit } = await import("#sdk/node");
364
- try {
365
- await hardenedGit(process.cwd(), ["rev-parse", "--git-dir"]);
366
- } catch {
367
- scaffold = false;
368
- remoteSkipped = "not a git repository — the vault was allocated; re-run with --git-remote to create one and add the remote";
369
- }
370
- }
371
-
372
- try {
373
- const result = await getSdk().gitvault.init({
374
- org_id: orgId,
375
- project_id: projectId,
376
- ...(scaffold ? { repo_dir: process.cwd() } : { scaffold_git: false }),
377
- });
378
- console.log(JSON.stringify(remoteSkipped ? { ...result, remote_skipped: remoteSkipped } : result, null, 2));
379
- console.error(
380
- result.deduplicated
381
- ? `vault ${result.repo_id} already existed — nothing was re-allocated and no new key material was minted`
382
- : `allocated vault ${result.repo_id} (genesis ${result.genesis_sha256})`,
383
- );
384
- if (result.remote) console.error(`remote '${result.remote.name}' -> ${result.remote.url} (${result.remote.reason})`);
385
- if (remoteSkipped) console.error(`remote not added: ${remoteSkipped}`);
386
- // The recovery receipt is integrity data, not a secret, and it is worth
387
- // exactly as much as the number of copies you keep. It is persisted into
388
- // the keystore automatically; say where, because "keep many copies" is
389
- // advice nobody can act on without a path.
390
- console.error("");
391
- console.error(result.terminal_loss_statement);
392
- await printKeystoreLocation();
393
- console.error("");
394
- } catch (err) {
395
- reportSdkError(err);
396
- }
397
- }
398
-
399
- /**
400
- * `run402 gitvault policy <required|grandfathered>` — the activation gate.
401
- *
402
- * The gateway's own `GITVAULT_CLIENT_UPGRADE_REQUIRED` envelope names
403
- * `run402 gitvault policy grandfathered --reason <why>` as the second way out
404
- * of a blocked deploy. Until this verb existed, running exactly what the
405
- * platform told you to run returned UNKNOWN_SUBCOMMAND, so a user could
406
- * allocate themselves into a blocked-deploy state with no way back.
407
- */
408
- async function policy(args) {
409
- const a = normalizeArgv(args);
410
- const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
411
- assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
412
- const [requested] = requirePositionalCount(a, valueFlags, {
413
- min: 1, max: 1, command: "run402 gitvault policy <required|grandfathered>",
414
- missing: "Missing <policy>. Expected `required` or `grandfathered`.",
415
- });
416
- if (requested !== "required" && requested !== "grandfathered") {
417
- fail({
418
- code: "BAD_USAGE",
419
- message: `Unknown policy: ${requested}.`,
420
- hint: "Expected `required` (a deploy must present a vaulted capture) or `grandfathered` (it need not).",
421
- details: { policy: requested, known_policies: ["required", "grandfathered"] },
422
- });
423
- }
424
- const reason = flagValue(a, "--reason");
425
- // Required only for the weakening direction. Returning to `required` is
426
- // restoring the default and needs no justification; leaving it does.
427
- if (requested === "grandfathered" && (reason == null || reason.trim() === "")) {
428
- fail({
429
- code: "BAD_USAGE",
430
- message: "`grandfathered` needs --reason <why>.",
431
- hint: "It weakens the activation guarantee for this project and is recorded in the audit event. Say why, e.g. --reason \"migrating CI to a vaulted client\".",
432
- details: { policy: requested },
433
- });
434
- }
435
-
436
- const target = await vaultTarget(a);
437
- try {
438
- const sdk = getSdk();
439
- const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
440
- const result = await sdk.gitvault.setPolicy(repoId, {
441
- gitvault_policy: requested,
442
- ...(reason != null ? { reason } : {}),
443
- });
444
- console.log(JSON.stringify({ repo_id: repoId, ...result }, null, 2));
445
- console.error(
446
- result.changed
447
- ? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
448
- : `gitvault_policy was already ${result.gitvault_policy} — nothing changed`,
449
- );
450
- for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
451
- } catch (err) {
452
- reportSdkError(err);
453
- }
454
- }
455
-
456
- /**
457
- * The vault's address in the form a human would actually type it: named
458
- * (`run402::<org-slug>/<name>`) when this checkout's local pin resolved from
459
- * one — an id-form pin buys nothing and is never written (see
460
- * `gitvault-address.ts`'s own doc comment), so a non-null `s.pinned` always
461
- * carries `resolved_from` — else id-form (`run402::<org_id>/<project_id>`),
462
- * falling back to whichever of project_id/repo_id is known when the vault
463
- * record itself is unavailable.
464
- */
465
- function formatGitvaultAddress(s) {
466
- if (s.pinned?.resolved_from) {
467
- return `run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name}`;
468
- }
469
- const orgId = s.vault?.org_id ?? null;
470
- const projectId = s.project_id ?? s.vault?.project_id ?? null;
471
- if (orgId && projectId) return `run402::${orgId}/${projectId}`;
472
- if (projectId) return projectId;
473
- if (s.repo_id) return `repo ${s.repo_id}`;
474
- return "(unresolved)";
475
- }
476
-
477
- /**
478
- * `run402 gitvault status --human` (kychee-com/run402#569) — the five-liner:
479
- * "status --refs is an admission-debugging protocol dump; the human question
480
- * is five lines — remote URL, branch/HEAD, generation, bytes,
481
- * can-this-machine-decrypt." Renders from `s` alone — the SAME status() call
482
- * the JSON path already made, so `--human` costs no extra network read.
483
- *
484
- * Generations render DECIMAL, not the wire's 16-hex-digit form — a hex
485
- * generation is a protocol detail, not something a human reads at a glance.
486
- *
487
- * The HEAD/ref-count line needs the vault's OWN ref map, which `status`
488
- * fetches only when `--refs` is ALSO passed (materializing is a verification
489
- * that advances local state — `status` alone stays a pure observation, see
490
- * that option's own doc comment). Composing `--human --refs` gets the full
491
- * line; `--human` alone names the omission rather than guessing from the
492
- * local git checkout, which could easily disagree with what the vault holds.
493
- *
494
- * Warnings — including the progressive terminal-loss risk warning — are
495
- * echoed EXACTLY as the SDK reported them (never reworded) and become an
496
- * optional sixth line, present only when `s.warnings` is non-empty. Without
497
- * `--human`, `run402 gitvault status` is unchanged: it always prints the
498
- * FULL terminal-loss statement verbatim on stderr regardless of warnings;
499
- * this compact view surfaces it only when it is actually live advice.
500
- */
501
- async function formatGitvaultHuman(s) {
502
- const lines = [];
503
- const remotePart = s.remote
504
- ? ` (remote '${s.remote.name}'${s.remote.matches ? "" : " — points at a DIFFERENT project"})`
505
- : " (no local remote)";
506
- lines.push(`Address: ${formatGitvaultAddress(s)}${remotePart}`);
507
-
508
- if (!s.vault) {
509
- // A normal shape (protocol D183) — no vault allocated for this project
510
- // yet. Nothing below this line is knowable, so it is not fabricated.
511
- lines.push("Vault: not allocated yet for this project — run 'run402 gitvault init' to allocate one.");
512
- if (s.warnings.length > 0) lines.push(`Warnings: ${s.warnings.map((w) => w.message).join(" ")}`);
513
- return lines.join("\n");
514
- }
515
-
516
- if (s.refs) {
517
- const count = Object.keys(s.refs).length;
518
- const head = !s.head_target
519
- ? "(none yet)"
520
- : s.head_target.kind === "symref"
521
- ? s.head_target.ref
522
- : `detached @ ${s.head_target.oid}`;
523
- lines.push(`HEAD: ${head} (${count} ref${count === 1 ? "" : "s"})`);
524
- } else {
525
- lines.push("HEAD: (not materialized — pass --refs to see HEAD/ref count)");
526
- }
527
-
528
- const { generationToBigInt } = await import("#sdk/node");
529
- const decimal = (g) => (g ? generationToBigInt(g).toString() : "none");
530
- lines.push(`Generations: authenticated ${decimal(s.pins.highest_authenticated)}, materialized ${decimal(s.pins.highest_materialized)}`);
531
-
532
- // Bytes + object count — pulled from the vault record `status()` ALREADY
533
- // fetched (no new network read, per the ask). `objects` is per-object-kind
534
- // counts; summed for one number a human can glance at.
535
- const storage = s.vault.storage;
536
- const objectCount = storage?.objects ? Object.values(storage.objects).reduce((sum, n) => sum + Number(n), 0) : null;
537
- lines.push(storage ? `Storage: ${storage.source_bytes} byte(s)${objectCount != null ? ` across ${objectCount} object(s)` : ""}` : "Storage: unknown");
538
-
539
- const decryptPart = !s.keystore.holds_repo_key
540
- ? "CANNOT decrypt (no key in this machine's keystore)"
541
- : s.keystore.can_sign
542
- ? "can decrypt and publish"
543
- : "can decrypt (read-only — no signing key)";
544
- lines.push(`This machine: ${decryptPart}. Policy: ${s.gitvault_policy ?? "(none)"}`);
545
-
546
- if (s.warnings.length > 0) lines.push(`Warnings: ${s.warnings.map((w) => w.message).join(" ")}`);
547
-
548
- return lines.join("\n");
549
- }
550
-
551
- async function status(args) {
552
- const a = normalizeArgv(args);
553
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--refs", "--human", "--help", "-h"], COMMON_VALUE_FLAGS);
554
- requirePositionalCount(a, COMMON_VALUE_FLAGS, {
555
- min: 0, max: 0, command: "run402 gitvault status", missing: "",
556
- });
557
- // kychee-com/run402#569 — an explicit opt-in per the cli-output-contract
558
- // (openspec/specs/cli-output-contract/spec.md: raw/human stdout REQUIRES
559
- // one), the same shape `run402 up`'s own `--human` already uses. Without
560
- // it, behavior is byte-identical to before this flag existed.
561
- const human = a.includes("--human");
562
- if (human && a.includes("--json")) {
563
- fail({
564
- code: "BAD_USAGE",
565
- message: "--human cannot be combined with --json.",
566
- details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") },
567
- });
568
- }
569
- const target = await vaultTarget(a);
570
- if (a.includes("--refs")) target.refs = true;
571
- try {
572
- const s = await getSdk().gitvault.status(target);
573
- if (human) {
574
- // The human view REPLACES the JSON dump — it is the sanctioned
575
- // exception the CLI-wide `--json` no-op convention already carves out
576
- // for a command's OWN `--human` flag (see argparse.mjs's header
577
- // comment). No new network read: everything below is already present
578
- // on `s`, the SAME status() call the JSON path made.
579
- console.log(await formatGitvaultHuman(s));
580
- return;
581
- }
582
- console.log(JSON.stringify(s, null, 2));
583
- printTerminalLoss(s);
584
- // Two facts the user otherwise has to leave the CLI for: which vault this
585
- // checkout is wired to, and what the control plane says is in it.
586
- if (s.remote) {
587
- // `matches` is a TRI-STATE (kychee-com/run402#562): `false` is a real
588
- // mismatch; `null` only means a slug-form remote has not resolved on
589
- // this machine yet — that is NOT evidence of anything wrong, so it
590
- // gets a neutral note, never the mismatch warning.
591
- const suffix =
592
- s.remote.matches === false ? " ← points at a DIFFERENT project than this status"
593
- : s.remote.matches === null ? ` (${s.remote.reason})`
594
- : "";
595
- console.error(`remote '${s.remote.name}': ${s.remote.url}${suffix}`);
596
- }
597
- // The id-pinning state (design D6, task 4.5): a slug-form remote pins
598
- // repo_id in local git state the first time it resolves; id-form pins
599
- // nothing (it needs no pin — see resolveGitvaultAddress's doc comment).
600
- if (s.pinned) {
601
- console.error(
602
- `pinned: repo_id ${s.pinned.repo_id}` +
603
- (s.pinned.resolved_from ? ` (resolved from run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name})` : ""),
604
- );
605
- }
606
- if (s.refs) {
607
- const names = Object.keys(s.refs).sort();
608
- console.error(names.length === 0 ? "refs: (none yet)" : `refs (${names.length}):`);
609
- for (const ref of names) console.error(` ${s.refs[ref]} ${ref}`);
610
- if (s.head_target) {
611
- console.error(s.head_target.kind === "symref" ? ` HEAD -> ${s.head_target.ref}` : ` HEAD ${s.head_target.oid} (detached)`);
612
- }
613
- }
614
- // Advisories are echoed EXACTLY as the SDK reported them. Nothing is
615
- // synthesized here — in particular a project that has never deployed gets
616
- // no deploy-related warning, because a vault-only project is a first-class
617
- // shape (protocol D183), not a half-configured deploy.
618
- for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
619
- for (const n of s.next_actions) console.error(`next: ${n.action}${n.command ? ` — ${n.command}` : ""}`);
620
- } catch (err) {
621
- reportSdkError(err);
622
- }
623
- }
624
-
625
- /**
626
- * D5 (repo-first-onramp task 2.5): one verb per operation. Renamed from
627
- * `push` — `push` now means exactly one thing everywhere: `git push`. The
628
- * capture lane keeps its old function name internally to minimize churn;
629
- * only the dispatched SUBCOMMAND name changed (see `run()` below, where
630
- * `gitvault push` survives one release as a deprecation-warning alias).
631
- */
632
- /**
633
- * D6 (repo-first-onramp task 4): when neither `--repo` nor `--project` was
634
- * given explicitly, look at the local `run402`/`origin` remote (in that
635
- * order, mirroring `scaffoldRemote`'s own naming) and, if it is a SLUG-form
636
- * address (`run402::<org-slug>/<name>`), return the parsed address so
637
- * `snapshot` can push-to-create through it — the same address-form
638
- * resolution `git push` drives via the remote helper. `null` for an
639
- * id-form remote, no remote at all, or an explicit `--repo`/`--project`.
640
- */
641
- async function detectSlugFormRemote(a, repoDir) {
642
- if (flagValue(a, "--repo") != null || flagValue(a, "--project") != null) return null;
643
- const { hardenedGit } = await import("#sdk/node");
644
- const { parseGitvaultRemoteUrl, gitvaultRemoteAddressForm } = await import("#sdk");
645
- for (const name of ["run402", "origin"]) {
646
- let url;
647
- try {
648
- url = (await hardenedGit(repoDir, ["remote", "get-url", name])).text().trim();
649
- } catch {
650
- continue;
651
- }
652
- if (!url) continue;
653
- const address = parseGitvaultRemoteUrl(url);
654
- if (address && gitvaultRemoteAddressForm(address) === "slug") return address;
655
- }
656
- return null;
657
- }
658
-
659
- async function snapshot(args) {
660
- const a = normalizeArgv(args);
661
- const valueFlags = [...COMMON_VALUE_FLAGS, "--message"];
662
- assertKnownFlags(a, [...valueFlags, "--checkpoint", "--dry-run", "--help", "-h"], valueFlags);
663
- requirePositionalCount(a, valueFlags, {
664
- min: 0, max: 0, command: "run402 gitvault snapshot", missing: "",
665
- });
666
- const dryRun = a.includes("--dry-run");
667
- const message = flagValue(a, "--message");
668
- const repoDir = process.cwd();
669
- const address = await detectSlugFormRemote(a, repoDir);
670
- // D2: lazily allocate the vault on first push when there is a project to
671
- // resolve the owning org from — the same resolution `gitvault init` uses.
672
- // `--repo`-only addressing has nothing to create FROM (no project_id), so
673
- // it is skipped there, matching `open()`'s own precedence. Skipped
674
- // entirely for a slug-form remote (`address` above) — that resolves
675
- // through the address, not a project_id, and needs no separate org_id.
676
- //
677
- // Skipped ENTIRELY for --dry-run (kychee-com/run402#565): org resolution
678
- // exists only to feed lazy allocation, and a dry run never allocates — the
679
- // read would cost a network round-trip for a fact `planPush` never uses.
680
- const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
681
- const orgId = !address && !dryRun && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
682
- const opts = {
683
- ...target,
684
- ...(address ? { address } : {}),
685
- ...(orgId ? { org_id: orgId } : {}),
686
- // The gitvault_commit line is progress, not payload: print it the moment
687
- // the snapshot exists, well before the publication round-trips finish, so
688
- // a human watching a slow push sees what is being pushed. Fires for a
689
- // dry run too — the capture itself is real, local work.
690
- onCommitLine: (line) => console.error(line),
691
- // Fires synchronously, BEFORE the capture/publish that follows — printed
692
- // here rather than deferred past `push()`'s return so the receipt is
693
- // never lost if a later step in the SAME push fails after allocation
694
- // already landed on the server. Never fires for --dry-run: `planPush`
695
- // never allocates, so this callback is simply unused there.
696
- onVaultCreated: async (created) => {
697
- console.error("");
698
- console.error(`vault allocated (genesis ${created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
699
- console.error(JSON.stringify(created.recovery_receipt));
700
- await printKeystoreLocation();
701
- console.error("");
702
- },
703
- };
704
- // The message rides on `snapshot`, which is what `captureSnapshot` reads —
705
- // and, since 5.12b removed the dead top-level `push({ message })` field, is
706
- // the ONE place it can ride.
707
- if (message != null) opts.snapshot = { message };
708
- if (a.includes("--checkpoint")) opts.checkpoint = true;
709
- try {
710
- if (dryRun) {
711
- // kychee-com/run402#565: a REAL dry run — the same local pipeline
712
- // `push` runs (capture, pack building, encryption sizing), stopping
713
- // before the two network mutations. Nothing is published; the JSON
714
- // report is the entire contract, so it goes on stdout like every other
715
- // gitvault verb's payload.
716
- const plan = await getSdk().gitvault.planPush(opts);
717
- console.log(JSON.stringify(plan, null, 2));
718
- if (plan.allocation_needed) {
719
- console.error("dry-run: no vault allocated for this project yet — a real snapshot would allocate one first; object/byte sizing is not knowable until then");
720
- } else {
721
- console.error(
722
- `dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
723
- `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
724
- );
725
- }
726
- return;
727
- }
728
- const result = await getSdk().gitvault.push(opts);
729
- console.log(JSON.stringify(result, null, 2));
730
- console.error(`published generation ${result.generation} (${result.form})`);
731
- // Design D6: the mirror result is reported BESIDE the vault outcome
732
- // above, on its own line — a mirror failure never blocked the publish.
733
- if (result.mirror_push?.outcome === "pushed") {
734
- console.error(`mirror: pushed generation ${result.generation} (${result.mirror_push.summary?.objects_copied ?? 0} object(s) copied)`);
735
- } else if (result.mirror_push?.outcome === "failed") {
736
- console.error(`mirror: dual-push FAILED (deploy is unaffected) — ${result.mirror_push.error ?? "see mirror_push.summary.errors"}`);
737
- }
738
- } catch (err) {
739
- reportSdkError(err);
740
- }
741
- }
742
-
743
- async function compact(args) {
744
- const a = normalizeArgv(args);
745
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
746
- requirePositionalCount(a, COMMON_VALUE_FLAGS, {
747
- min: 0, max: 0, command: "run402 gitvault compact", missing: "",
748
- });
749
- try {
750
- const result = await getSdk().gitvault.compact(await vaultTarget(a));
751
- console.log(JSON.stringify(result, null, 2));
752
- console.error(
753
- `checkpoint published at generation ${result.generation}: ` +
754
- `${result.covered_refs} ref(s), ${result.covered_roots} retention root(s).`,
755
- );
756
- if (!result.cutoff_bound) {
757
- // Say what actually happened rather than reporting a clean compaction:
758
- // without a ticket no root can leave the map, so this run reclaimed
759
- // nothing from expiry.
760
- console.error(
761
- "no retention-cutoff ticket was obtained, so roots were RETAINED — expiry is permissive. " +
762
- "The checkpoint published, but no expired root left the map; re-run compact once the ticket route answers.",
763
- );
764
- }
765
- } catch (err) {
766
- reportSdkError(err);
767
- }
768
- }
769
-
770
- /** Read a protocol object a prior planning run (or r402s-verify) wrote to disk. */
771
- function readJsonFile(flag, path) {
772
- let text;
773
- try {
774
- text = readFileSync(path, "utf8");
775
- } catch (err) {
776
- fail({
777
- code: "BAD_USAGE",
778
- message: `${flag} ${path} could not be read: ${err?.message ?? String(err)}`,
779
- hint: "Point it at the file a prior `run402 gitvault prune` (or r402s-verify) wrote.",
780
- });
781
- }
782
- try {
783
- return JSON.parse(text);
784
- } catch (err) {
785
- fail({
786
- code: "BAD_USAGE",
787
- message: `${flag} ${path} is not valid JSON: ${err?.message ?? String(err)}`,
788
- hint: "Pass the file verbatim; do not reformat or re-serialize it.",
789
- });
790
- }
791
- }
792
-
793
- async function prune(args) {
794
- const a = normalizeArgv(args);
795
- const valueFlags = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-receipt"];
796
- assertKnownFlags(a, [...valueFlags, "--submit", "--wait", "--help", "-h"], valueFlags);
797
- requirePositionalCount(a, valueFlags, {
798
- min: 0, max: 0, command: "run402 gitvault prune", missing: "",
799
- });
800
- const submitting = a.includes("--submit");
801
- const corePath = flagValue(a, "--intent-core");
802
- const receiptPath = flagValue(a, "--verifier-receipt");
803
- // Refuse the half-specified submit here rather than planning and silently
804
- // discarding the flags — an agent that typed --submit meant to submit.
805
- if (submitting && (corePath == null || receiptPath == null)) {
806
- fail({
807
- code: "BAD_USAGE",
808
- message: "run402 gitvault prune --submit needs both --intent-core and --verifier-receipt.",
809
- hint: "Plan first (`run402 gitvault prune`), save its `intent_core`, run r402s-verify against it, then submit both.",
810
- });
811
- }
812
- if (!submitting && (corePath != null || receiptPath != null)) {
813
- fail({
814
- code: "BAD_USAGE",
815
- message: "--intent-core / --verifier-receipt only apply with --submit.",
816
- hint: "Add --submit, or drop the flags to plan.",
817
- });
818
- }
819
- const opts = await vaultTarget(a);
820
- if (submitting) {
821
- opts.submit = {
822
- core: readJsonFile("--intent-core", corePath),
823
- verifier_receipt: readJsonFile("--verifier-receipt", receiptPath),
824
- };
825
- if (a.includes("--wait")) opts.submit.wait = {};
826
- }
827
- try {
828
- const result = await getSdk().gitvault.prune(opts);
829
- console.log(JSON.stringify(result, null, 2));
830
- // Never imply a deletion. State what actually happened, then reproduce the
831
- // SDK's own note verbatim rather than summarizing it.
832
- if (!result.submitted) {
833
- console.error(
834
- result.blocked_reason
835
- ? `planned — nothing to submit: ${result.blocked_reason}`
836
- : `planned — nothing submitted. ${result.object_candidates.length} object(s) proposed for deletion` +
837
- `${result.deferred_object_count > 0 ? ` (${result.deferred_object_count} more deferred to a later intent)` : ""}` +
838
- `; ${result.eligible_count} retention root(s) past their window, ${result.retained_count} retained.`,
839
- );
840
- if (result.intent_core_sha256) {
841
- console.error(`intent_core_sha256: ${result.intent_core_sha256} — run r402s-verify against this core, then re-run with --submit.`);
842
- }
843
- } else if (result.confirmation?.outcome) {
844
- console.error(
845
- `submitted — the signed completion reports ${result.confirmation.deleted.length} deleted, ` +
846
- `${result.confirmation.present.length} still present` +
847
- `${result.confirmation.unadjudicated.length > 0 ? `, ${result.confirmation.unadjudicated.length} unadjudicated` : ""}.`,
848
- );
849
- } else {
850
- console.error("submitted — no completion yet. Nothing is deleted until the control-plane-signed completion says so; re-run with --wait or poll the intent.");
851
- }
852
- console.error(result.note);
853
- } catch (err) {
854
- reportSdkError(err);
855
- }
856
- }
857
-
858
- async function verify(args) {
859
- const a = normalizeArgv(args);
860
- const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
861
- assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
862
- requirePositionalCount(a, valueFlags, {
863
- min: 0, max: 0, command: "run402 gitvault verify", missing: "",
864
- });
865
- const target = await vaultTarget(a);
866
- const budget = flagValue(a, "--budget");
867
- if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
868
- try {
869
- const state = await getSdk().gitvault.verify(target);
870
- console.log(JSON.stringify(state, null, 2));
871
- console.error(`verified through generation ${state.generation}`);
872
- } catch (err) {
873
- reportSdkError(err);
874
- }
875
- }
876
-
877
- /**
878
- * `run402 gitvault reconcile` — gitvault-human-envelopes task 4.1's ADD-path
879
- * workaround (design D5's "session start" hook; `push`/`snapshot` also runs
880
- * this itself, best-effort, on the "deploy time" hook — see its
881
- * `reconcile_recipients` field).
882
- *
883
- * Wraps this vault's current epoch key to every org member who has published
884
- * an encryption key but has no envelope on this vault yet. This is the
885
- * WORKAROUND shape, not full epoch rotation (blocked on a protocol
886
- * revision) — see `Gitvault.reconcileEnvelopeRecipients`'s doc comment for
887
- * the honest scope: a newly-wrapped member gets this vault's ENTIRE history
888
- * under V0's single epoch, not "from here forward."
889
- */
890
- async function reconcile(args) {
891
- const a = normalizeArgv(args);
892
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
893
- requirePositionalCount(a, COMMON_VALUE_FLAGS, {
894
- min: 0, max: 0, command: "run402 gitvault reconcile", missing: "",
895
- });
896
- const target = await vaultTarget(a);
897
- try {
898
- const result = await getSdk().gitvault.reconcileEnvelopeRecipients(target);
899
- console.log(JSON.stringify(result, null, 2));
900
- if (result.wrapped.length > 0) {
901
- console.error(`wrapped ${result.wrapped.length} new recipient(s) at epoch ${result.epoch}`);
902
- }
903
- if (result.already_covered.length > 0) {
904
- console.error(`${result.already_covered.length} recipient(s) already covered`);
905
- }
906
- for (const s of result.skipped) {
907
- if (s.reason === "pinned_key_mismatch") {
908
- console.error(
909
- `SKIPPED ${s.principal_id}: the org directory's key changed (was ${s.details?.pinned_fingerprint}, now ${s.details?.directory_fingerprint}) — this repo will not silently re-wrap under it; investigate before re-running`,
910
- );
911
- } else if (s.reason === "missing_public_key") {
912
- console.error(`skipped ${s.principal_id}: the org directory has no public key on record yet for ${s.ek_fingerprint}`);
913
- } else {
914
- console.error(`skipped ${s.principal_id} (${s.ek_fingerprint}): ${s.reason}${s.details ? ` ${JSON.stringify(s.details)}` : ""}`);
915
- }
916
- }
917
- if (result.wrapped.length === 0 && result.skipped.length === 0 && result.already_covered.length === 0) {
918
- console.error("nothing to reconcile — the org directory has no envelope-capable members yet");
919
- }
920
- } catch (err) {
921
- reportSdkError(err);
922
- }
923
- }
924
-
925
- // ─── mirror (gitvault-mirror-and-recover) ─────────────────────────────────
926
-
927
- const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
928
-
929
- /**
930
- * docs/agent-response-design.md's CLI pipe-contract row: stdout ALWAYS keeps
931
- * the full JSON (never truncated), and when a result is large it is ALSO
932
- * written to a private 0600 file with a one-line stderr breadcrumb naming the
933
- * path. Best-effort — a spill failure never changes what the command reports.
934
- */
935
- async function spillIfLarge(repoId, verb, payload) {
936
- const json = JSON.stringify(payload, null, 2);
937
- if (Buffer.byteLength(json, "utf8") <= LARGE_OUTPUT_THRESHOLD_BYTES) return;
938
- try {
939
- const { getGitvaultKeystoreRoot } = await import("#sdk/node");
940
- const dir = join(getGitvaultKeystoreRoot(), "reports");
941
- mkdirSync(dir, { recursive: true, mode: 0o700 });
942
- const path = join(dir, `${verb}-${repoId ?? "unknown"}-${Date.now()}.json`);
943
- writeFileSync(path, json, { mode: 0o600 });
944
- console.error(`(this result is large — the full JSON was also written to ${path})`);
945
- } catch {
946
- // best-effort only; stdout already carries the full result regardless
947
- }
948
- }
949
-
950
- /** Design D8: both honesty statements, verbatim, wherever mirror status or recovery success is shown. */
951
- function printMirrorHonesty(result) {
952
- if (result.validity_not_freshness) console.error(result.validity_not_freshness);
953
- if (result.keystore_still_required) console.error(result.keystore_still_required);
954
- }
955
-
956
- function resolveMirrorCredential(a) {
957
- const profile = flagValue(a, "--profile");
958
- const ambient = a.includes("--ambient");
959
- if (profile != null && ambient) {
960
- fail({
961
- code: "BAD_USAGE",
962
- message: "--profile and --ambient contradict each other.",
963
- hint: "Pick one credential source for the s3:// destination.",
964
- });
965
- }
966
- if (profile != null) return { kind: "profile", profile };
967
- if (ambient) return { kind: "ambient" };
968
- return undefined;
969
- }
970
-
971
- function formatMirrorDestination(destination) {
972
- if (!destination) return "(none)";
973
- return destination.kind === "s3" ? `s3://${destination.bucket}/${destination.prefix}` : destination.path;
974
- }
975
-
976
- async function mirrorSetCmd(args) {
977
- const a = normalizeArgv(args);
978
- const valueFlags = [...COMMON_VALUE_FLAGS, "--profile", "--region", "--endpoint"];
979
- assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
980
- const [destination] = requirePositionalCount(a, valueFlags, {
981
- min: 1, max: 1, command: "run402 gitvault mirror set <destination>",
982
- missing: "Missing <destination>. Expected s3://<bucket>[/<prefix>] or a directory path.",
983
- });
984
- const credential = resolveMirrorCredential(a);
985
- const region = flagValue(a, "--region");
986
- const endpoint = flagValue(a, "--endpoint");
987
- const target = await vaultTarget(a);
988
- try {
989
- const result = await getSdk().gitvault.mirrorSet({
990
- ...target,
991
- destination_url: destination,
992
- ...(credential ? { credential } : {}),
993
- ...(region != null ? { region } : {}),
994
- ...(endpoint != null ? { endpoint } : {}),
995
- });
996
- console.log(JSON.stringify(result, null, 2));
997
- console.error(`mirror configured for ${result.repo_id} -> ${formatMirrorDestination(result.destination)}`);
998
- console.error("run `run402 gitvault mirror sync` to backfill it now, then deploys will dual-push automatically.");
999
- } catch (err) {
1000
- reportSdkError(err);
1001
- }
1002
- }
1003
-
1004
- async function mirrorRemoveCmd(args) {
1005
- const a = normalizeArgv(args);
1006
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1007
- requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 gitvault mirror remove", missing: "" });
1008
- const target = await vaultTarget(a);
1009
- try {
1010
- const result = await getSdk().gitvault.mirrorRemove(target);
1011
- console.log(JSON.stringify(result, null, 2));
1012
- console.error(
1013
- result.removed
1014
- ? `mirror config removed for ${result.repo_id} — the mirror's OWN bytes were not touched`
1015
- : `no mirror was configured for ${result.repo_id} — nothing to remove`,
1016
- );
1017
- } catch (err) {
1018
- reportSdkError(err);
1019
- }
1020
- }
1021
-
1022
- async function mirrorStatusCmd(args) {
1023
- const a = normalizeArgv(args);
1024
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1025
- requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 gitvault mirror status", missing: "" });
1026
- const target = await vaultTarget(a);
1027
- try {
1028
- const result = await getSdk().gitvault.mirrorStatus(target);
1029
- console.log(JSON.stringify(result, null, 2));
1030
- if (!result.configured) {
1031
- console.error(`no mirror configured for ${result.repo_id}. Configure one: run402 gitvault mirror set <destination>`);
1032
- } else {
1033
- const currency = result.is_current === true ? "current" : result.is_current === false ? `STALE — ${result.closing_command}` : "unknown (mirror unreachable or vault unread)";
1034
- console.error(`mirror ${result.destination}: mirrored generation ${result.mirrored_generation ?? "(none)"}, vault newest ${result.newest_generation ?? "(none)"} — ${currency}`);
1035
- }
1036
- printMirrorHonesty(result);
1037
- } catch (err) {
1038
- reportSdkError(err);
1039
- }
1040
- }
1041
-
1042
- async function mirrorSyncCmd(args) {
1043
- const a = normalizeArgv(args);
1044
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1045
- requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 gitvault mirror sync", missing: "" });
1046
- const target = await vaultTarget(a);
1047
- try {
1048
- const result = await getSdk().gitvault.mirrorSync(target);
1049
- console.log(JSON.stringify(result, null, 2));
1050
- await spillIfLarge(result.repo_id, "mirror-sync", result);
1051
- console.error(
1052
- `mirror sync for ${result.repo_id}: ${result.objects_copied} copied, ${result.objects_already_present} already present` +
1053
- `${result.objects_skipped_foreign_recipient > 0 ? `, ${result.objects_skipped_foreign_recipient} skipped (envelopes for other recipients — expected)` : ""}` +
1054
- `${result.objects_failed > 0 ? `, ${result.objects_failed} FAILED` : ""} (${result.bytes_copied} byte(s) copied this run).`,
1055
- );
1056
- for (const e of result.errors) console.error(` failed: ${e.key} — ${e.error}`);
1057
- printMirrorHonesty(result);
1058
- } catch (err) {
1059
- reportSdkError(err);
1060
- }
1061
- }
1062
-
1063
- async function mirrorVerifyCmd(args) {
1064
- const a = normalizeArgv(args);
1065
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1066
- requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 gitvault mirror verify", missing: "" });
1067
- const target = await vaultTarget(a);
1068
- try {
1069
- const result = await getSdk().gitvault.mirrorVerify(target);
1070
- console.log(JSON.stringify(result, null, 2));
1071
- await spillIfLarge(result.repo_id, "mirror-verify", result);
1072
- console.error(`mirror keyless verify for ${result.repo_id}: recoverable generation ${result.recovered_generation}${result.chain_break ? ` (chain break at ${result.chain_break.generation}: ${result.chain_break.reason})` : ""}.`);
1073
- if (result.data_loss_detected) {
1074
- console.error(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1075
- }
1076
- printMirrorHonesty(result);
1077
- } catch (err) {
1078
- reportSdkError(err);
1079
- }
1080
- }
1081
-
1082
- async function mirror(args) {
1083
- const a = normalizeArgv(args);
1084
- if (!a[0] || hasHelp([a[0], ...a.slice(1)])) {
1085
- console.log(HELP);
1086
- process.exit(0);
1087
- }
1088
- const action = a[0];
1089
- const rest = a.slice(1);
1090
- // Deliberately if/else, not a switch statement (`policy()`'s own
1091
- // precedent above): sync.test.ts's CLI-command scanner regexes switch
1092
- // labels FLATLY across the whole file with no nesting awareness, so a
1093
- // nested switch here would misattribute "set"/"remove"/"sync" as bogus
1094
- // top-level `gitvault:set` etc. commands. `gitvault mirror <action>` is
1095
- // ONE compound verb (SURFACE's `gitvault_mirror` row), not five leaf
1096
- // commands.
1097
- if (action === "set") return mirrorSetCmd(rest);
1098
- if (action === "remove") return mirrorRemoveCmd(rest);
1099
- if (action === "status") return mirrorStatusCmd(rest);
1100
- if (action === "sync") return mirrorSyncCmd(rest);
1101
- if (action === "verify") return mirrorVerifyCmd(rest);
1102
- failUnknownSubcommand("gitvault mirror", action, {
1103
- hint: "Run `run402 gitvault mirror --help` for usage.",
1104
- extraSubcommands: ["set", "remove", "status", "sync", "verify"],
1105
- });
1106
- }
1107
-
1108
- async function recover(args) {
1109
- const a = normalizeArgv(args);
1110
- const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
1111
- assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
1112
- const [source] = requirePositionalCount(a, valueFlags, {
1113
- min: 1, max: 1, command: "run402 gitvault recover <source> --out <dir>",
1114
- missing: "Missing <source>. Expected s3://<bucket>[/<prefix>] or a directory path.",
1115
- });
1116
- const outDir = flagValue(a, "--out");
1117
- if (outDir == null) {
1118
- fail({
1119
- code: "BAD_USAGE",
1120
- message: "run402 gitvault recover needs --out <dir>.",
1121
- hint: "Where to materialize the recovered repository, e.g. --out ./restored",
1122
- });
1123
- }
1124
- const credential = resolveMirrorCredential(a);
1125
- const repoId = flagValue(a, "--repo");
1126
- const region = flagValue(a, "--region");
1127
- const endpoint = flagValue(a, "--endpoint");
1128
- try {
1129
- const result = await getSdk().gitvault.recover({
1130
- source, out_dir: outDir,
1131
- ...(repoId != null ? { repo_id: repoId } : {}),
1132
- ...(credential ? { credential } : {}),
1133
- ...(region != null ? { region } : {}),
1134
- ...(endpoint != null ? { endpoint } : {}),
1135
- });
1136
- console.log(JSON.stringify(result, null, 2));
1137
- await spillIfLarge(result.repo_id, "recover", result);
1138
- console.error(`recovered generation ${result.recovered_generation} for ${result.repo_id} into ${outDir}` + (result.chain_break ? ` (chain break at ${result.chain_break.generation} — fell back to the newest fully-verified generation)` : "") + ".");
1139
- if (result.data_loss_detected) {
1140
- console.error(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences — see "absences" in the result above.`);
1141
- }
1142
- printMirrorHonesty(result);
1143
- } catch (err) {
1144
- reportSdkError(err);
1145
- }
1146
- }
1147
-
1148
117
  export async function run(sub, args) {
1149
118
  const argv = Array.isArray(args) ? args : [];
1150
119
  if (!sub || hasHelp([sub, ...argv])) {
@@ -1152,58 +121,44 @@ export async function run(sub, args) {
1152
121
  process.exit(0);
1153
122
  }
1154
123
  switch (sub) {
1155
- case "init": {
1156
- await init(argv);
124
+ case "init":
125
+ movedResponse("init");
1157
126
  break;
1158
- }
1159
- case "policy": {
1160
- await policy(argv);
127
+ case "status":
128
+ movedResponse("status");
1161
129
  break;
1162
- }
1163
- case "status": {
1164
- await status(argv);
130
+ case "snapshot":
131
+ movedResponse("snapshot");
1165
132
  break;
1166
- }
1167
- case "snapshot": {
1168
- await snapshot(argv);
133
+ case "policy":
134
+ movedResponse("policy");
1169
135
  break;
1170
- }
1171
- case "push": {
1172
- // D5: one verb per operation — "push" now means exactly one thing,
1173
- // `git push`. Retained as a deprecation-warning alias for ONE release
1174
- // (pre-launch, the benchmark gate prefers the rename now over an
1175
- // alias forever); it will be removed next release.
1176
- console.error("`run402 gitvault push` is deprecated and will be removed in the next release — use `run402 gitvault snapshot` instead.");
1177
- await snapshot(argv);
136
+ case "compact":
137
+ movedResponse("compact");
1178
138
  break;
1179
- }
1180
- case "compact": {
1181
- await compact(argv);
139
+ case "prune":
140
+ movedResponse("prune");
1182
141
  break;
1183
- }
1184
- case "prune": {
1185
- await prune(argv);
142
+ case "verify":
143
+ movedResponse("verify");
1186
144
  break;
1187
- }
1188
- case "verify": {
1189
- await verify(argv);
145
+ case "mirror":
146
+ movedResponse("mirror");
1190
147
  break;
1191
- }
1192
- case "reconcile": {
1193
- await reconcile(argv);
148
+ case "recover":
149
+ movedResponse("recover");
1194
150
  break;
1195
- }
1196
- case "mirror": {
1197
- await mirror(argv);
151
+ case "push":
152
+ removedResponse("push");
1198
153
  break;
1199
- }
1200
- case "recover": {
1201
- await recover(argv);
154
+ case "reconcile":
155
+ removedResponse("reconcile");
1202
156
  break;
1203
- }
1204
157
  default:
1205
- failUnknownSubcommand("gitvault", sub, {
1206
- hint: "Run `run402 gitvault --help` for usage.",
158
+ fail({
159
+ code: "UNKNOWN_SUBCOMMAND",
160
+ message: `run402 gitvault ${sub}: unknown, and gitvault itself is retired.`,
161
+ hint: "Run `run402 repos --help` for the current surface.",
1207
162
  });
1208
163
  }
1209
164
  }