run402 4.24.1 → 4.26.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.
Files changed (115) hide show
  1. package/cli.mjs +6 -6
  2. package/git-remote-run402.mjs +328 -0
  3. package/lib/command-manifest.mjs +13 -1
  4. package/lib/credentials.mjs +4 -0
  5. package/lib/deploy-v2.mjs +78 -1
  6. package/lib/doctor.mjs +80 -1
  7. package/lib/domains.mjs +0 -14
  8. package/lib/gitvault.mjs +582 -0
  9. package/lib/init.mjs +153 -5
  10. package/lib/org-context.mjs +44 -1
  11. package/lib/pay.test.mjs +0 -4
  12. package/package.json +8 -2
  13. package/sdk/dist/index.d.ts +20 -3
  14. package/sdk/dist/index.d.ts.map +1 -1
  15. package/sdk/dist/index.js +22 -3
  16. package/sdk/dist/index.js.map +1 -1
  17. package/sdk/dist/namespaces/admin.d.ts +0 -5
  18. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/admin.js +0 -9
  20. package/sdk/dist/namespaces/admin.js.map +1 -1
  21. package/sdk/dist/namespaces/assets.d.ts +1 -20
  22. package/sdk/dist/namespaces/assets.d.ts.map +1 -1
  23. package/sdk/dist/namespaces/assets.js +0 -27
  24. package/sdk/dist/namespaces/assets.js.map +1 -1
  25. package/sdk/dist/namespaces/domains.d.ts +0 -19
  26. package/sdk/dist/namespaces/domains.d.ts.map +1 -1
  27. package/sdk/dist/namespaces/domains.js +0 -19
  28. package/sdk/dist/namespaces/domains.js.map +1 -1
  29. package/sdk/dist/namespaces/gitvault.crypto.d.ts +319 -0
  30. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -0
  31. package/sdk/dist/namespaces/gitvault.crypto.js +690 -0
  32. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -0
  33. package/sdk/dist/namespaces/gitvault.d.ts +521 -0
  34. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -0
  35. package/sdk/dist/namespaces/gitvault.js +786 -0
  36. package/sdk/dist/namespaces/gitvault.js.map +1 -0
  37. package/sdk/dist/namespaces/gitvault.types.d.ts +481 -0
  38. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -0
  39. package/sdk/dist/namespaces/gitvault.types.js +19 -0
  40. package/sdk/dist/namespaces/gitvault.types.js.map +1 -0
  41. package/sdk/dist/namespaces/org.d.ts +1 -3
  42. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  43. package/sdk/dist/namespaces/org.js +6 -10
  44. package/sdk/dist/namespaces/org.js.map +1 -1
  45. package/sdk/dist/namespaces/pay.d.ts +0 -8
  46. package/sdk/dist/namespaces/pay.d.ts.map +1 -1
  47. package/sdk/dist/namespaces/pay.js.map +1 -1
  48. package/sdk/dist/namespaces/projects.d.ts +2 -2
  49. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  50. package/sdk/dist/namespaces/projects.js +4 -12
  51. package/sdk/dist/namespaces/projects.js.map +1 -1
  52. package/sdk/dist/namespaces/secrets.d.ts +0 -2
  53. package/sdk/dist/namespaces/secrets.d.ts.map +1 -1
  54. package/sdk/dist/namespaces/secrets.js +3 -10
  55. package/sdk/dist/namespaces/secrets.js.map +1 -1
  56. package/sdk/dist/namespaces/subdomains.d.ts +0 -2
  57. package/sdk/dist/namespaces/subdomains.d.ts.map +1 -1
  58. package/sdk/dist/namespaces/subdomains.js +5 -16
  59. package/sdk/dist/namespaces/subdomains.js.map +1 -1
  60. package/sdk/dist/namespaces/transfers.d.ts +0 -2
  61. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  62. package/sdk/dist/namespaces/transfers.js +8 -10
  63. package/sdk/dist/namespaces/transfers.js.map +1 -1
  64. package/sdk/dist/namespaces/wallets.d.ts +0 -12
  65. package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
  66. package/sdk/dist/namespaces/wallets.js +0 -14
  67. package/sdk/dist/namespaces/wallets.js.map +1 -1
  68. package/sdk/dist/node/gitvault-creation-journal.d.ts +222 -0
  69. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -0
  70. package/sdk/dist/node/gitvault-creation-journal.js +365 -0
  71. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -0
  72. package/sdk/dist/node/gitvault-deploy.d.ts +211 -0
  73. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -0
  74. package/sdk/dist/node/gitvault-deploy.js +292 -0
  75. package/sdk/dist/node/gitvault-deploy.js.map +1 -0
  76. package/sdk/dist/node/gitvault-keystore.d.ts +207 -0
  77. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -0
  78. package/sdk/dist/node/gitvault-keystore.js +529 -0
  79. package/sdk/dist/node/gitvault-keystore.js.map +1 -0
  80. package/sdk/dist/node/gitvault-prune.d.ts +341 -0
  81. package/sdk/dist/node/gitvault-prune.d.ts.map +1 -0
  82. package/sdk/dist/node/gitvault-prune.js +410 -0
  83. package/sdk/dist/node/gitvault-prune.js.map +1 -0
  84. package/sdk/dist/node/gitvault-publication.d.ts +730 -0
  85. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -0
  86. package/sdk/dist/node/gitvault-publication.js +1475 -0
  87. package/sdk/dist/node/gitvault-publication.js.map +1 -0
  88. package/sdk/dist/node/gitvault-snapshot.d.ts +227 -0
  89. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -0
  90. package/sdk/dist/node/gitvault-snapshot.js +556 -0
  91. package/sdk/dist/node/gitvault-snapshot.js.map +1 -0
  92. package/sdk/dist/node/index.d.ts +11 -0
  93. package/sdk/dist/node/index.d.ts.map +1 -1
  94. package/sdk/dist/node/index.js +9 -0
  95. package/sdk/dist/node/index.js.map +1 -1
  96. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  97. package/sdk/dist/node/paid-fetch.js +0 -4
  98. package/sdk/dist/node/paid-fetch.js.map +1 -1
  99. package/sdk/dist/node/sites-node.d.ts +1 -8
  100. package/sdk/dist/node/sites-node.d.ts.map +1 -1
  101. package/sdk/dist/node/sites-node.js +1 -7
  102. package/sdk/dist/node/sites-node.js.map +1 -1
  103. package/sdk/dist/scoped.d.ts +4 -35
  104. package/sdk/dist/scoped.d.ts.map +1 -1
  105. package/sdk/dist/scoped.js +9 -74
  106. package/sdk/dist/scoped.js.map +1 -1
  107. package/lib/sender-domain.mjs +0 -36
  108. package/sdk/dist/deprecate.d.ts +0 -23
  109. package/sdk/dist/deprecate.d.ts.map +0 -1
  110. package/sdk/dist/deprecate.js +0 -42
  111. package/sdk/dist/deprecate.js.map +0 -1
  112. package/sdk/dist/namespaces/sender-domain.d.ts +0 -47
  113. package/sdk/dist/namespaces/sender-domain.d.ts.map +0 -1
  114. package/sdk/dist/namespaces/sender-domain.js +0 -45
  115. package/sdk/dist/namespaces/sender-domain.js.map +0 -1
@@ -0,0 +1,582 @@
1
+ /**
2
+ * `run402 gitvault` — the host-blind encrypted Git remote (wire tag `r402s/v0`).
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.
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()`.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { resolveProjectId } from "./config.mjs";
21
+ import { resolveOwningOrgId } from "./org-context.mjs";
22
+ import { getSdk } from "./sdk.mjs";
23
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
24
+ import {
25
+ normalizeArgv,
26
+ hasHelp,
27
+ assertKnownFlags,
28
+ parseIntegerFlag,
29
+ flagValue,
30
+ requirePositionalCount,
31
+ failUnknownSubcommand,
32
+ } from "./argparse.mjs";
33
+
34
+ /** Value-taking flags every gitvault subcommand accepts. */
35
+ const COMMON_VALUE_FLAGS = ["--project", "--repo"];
36
+
37
+ export const HELP = `run402 gitvault — your source, encrypted before it leaves the machine
38
+
39
+ Usage:
40
+ run402 gitvault init [--project <id>] [--org <org_id>] [--git-remote] [--no-remote]
41
+ run402 gitvault status [--project <id>] [--repo <repo_id>] [--refs]
42
+ run402 gitvault push [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint]
43
+ run402 gitvault policy <required|grandfathered> [--project <id>] [--repo <repo_id>]
44
+ [--reason <why>]
45
+ run402 gitvault compact [--project <id>] [--repo <repo_id>]
46
+ run402 gitvault prune [--project <id>] [--repo <repo_id>]
47
+ [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
48
+ run402 gitvault verify [--project <id>] [--repo <repo_id>] [--budget <n>]
49
+
50
+ Subcommands:
51
+ init ALLOCATE the project's vault. This is the one step that mints key
52
+ material on this machine and emits the one-shot recovery receipt,
53
+ so it is explicit rather than a side effect of \`run402 init\` (which
54
+ only adds the git remote). Idempotent: an existing vault is
55
+ reported with \`deduplicated: true\` and nothing is re-minted. Adds
56
+ the \`run402\` remote too when the current directory is already a
57
+ repository.
58
+ status What this machine and the control plane each believe about the
59
+ vault: allocation, policy, whether this keystore can sign, the
60
+ authenticated and materialized pins, any pending
61
+ unvaulted-override journals, and where the keystore lives. Never
62
+ reports key material.
63
+ policy Set the activation policy — \`required\` (a deploy must present a
64
+ vaulted capture) or \`grandfathered\` (it need not). Owner + step-up,
65
+ audited. \`grandfathered\` is the documented way out of a deploy
66
+ blocked by GITVAULT_CLIENT_UPGRADE_REQUIRED, and leaves a
67
+ doctor-persistent warning until the project returns to \`required\`.
68
+ push Capture the working tree and publish it. This is NOT gated on a
69
+ deploy — a vault-only project pushes for months without one.
70
+ Before reporting a push as landed the SDK compares finalization
71
+ receipts against the expected manifest and reads the admitted head
72
+ back from storage; a 200 alone is never enough.
73
+ compact Publish a checkpoint covering the canonical refs, every root
74
+ unexpired at the cutoff, and the HEAD target, under a maintenance
75
+ lease so a concurrent cycle cannot race it.
76
+ prune Plan a prune, and — with both verifier receipts — submit it.
77
+ Two phases, because the protocol is two-phase; see below.
78
+ verify Verify the head chain from the authenticated pin up to the newest
79
+ listed generation. Fails closed on a regression, a gap, or a
80
+ transition descriptor this client cannot validate.
81
+
82
+ Options:
83
+ --project <id> Project whose vault to act on (defaults to the active project)
84
+ --org <org_id> init: the owning organization (resolved from the project
85
+ when omitted)
86
+ --git-remote init: 'git init' the current directory when it is not a
87
+ repository yet, so the run402 remote can be added there.
88
+ Opt-in: creating a repository where you did not ask for one
89
+ is a bad surprise, so without it a non-repository directory
90
+ allocates the vault and adds no remote.
91
+ --no-remote init: allocate the vault only; touch no git configuration
92
+ --reason <why> policy: why the policy is changing — recorded in the audit
93
+ event. REQUIRED for \`grandfathered\`, which is a deliberate
94
+ weakening of the activation guarantee.
95
+ --refs status: also materialize and report the vault's ref map and
96
+ HEAD target. This is a VERIFICATION (it walks the head
97
+ chain and advances the local materialized pin), which is
98
+ why plain \`status\` — an observation — does not do it.
99
+ --repo <repo_id> Address the vault directly by id, skipping project lookup
100
+ --message <text> push: commit message for the synthetic commit a dirty tree
101
+ produces (a clean tree pushes HEAD itself, no message used)
102
+ --checkpoint push: force the checkpoint-bearing form regardless of delta size
103
+ --budget <n> verify: heads to verify in this call. The verified prefix is
104
+ persisted, so a budget-exceeded run resumes where it stopped
105
+ instead of restarting.
106
+ --submit prune: submit the planned intent. Requires --intent-core and
107
+ --verifier-receipt.
108
+ --intent-core <path>
109
+ prune: the plan's \`intent_core\`, saved verbatim from a prior
110
+ planning run. A rebuilt core carries a different nonce, so
111
+ the r402s-verify receipt would no longer bind to it.
112
+ --verifier-receipt <path>
113
+ prune: r402s-verify's \`verifier_receipt\` over that core.
114
+ --wait prune: poll the submitted intent until the control-plane-
115
+ signed completion appears, instead of returning immediately.
116
+ --json No-op: stdout is already JSON.
117
+
118
+ prune is TWO PHASES, because the protocol is:
119
+ 1. \`run402 gitvault prune\` plans. It walks the verified chain, computes the GC
120
+ root set, subtracts it, and prints a SIGNED \`intent_core\` plus its
121
+ \`intent_core_sha256\`. Nothing is submitted and nothing is deleted.
122
+ 2. Run \`r402s-verify\` against that core, then re-run with
123
+ \`--submit --intent-core <core.json> --verifier-receipt <receipt.json>\`.
124
+ The intent carries TWO receipts over the same core, one per implementation:
125
+ this CLI produces the \`run402-cli\` half by restoring the latest checkpoint
126
+ and recomputing its commitments, and \`r402s-verify\` produces the other.
127
+ A second receipt from this lineage would prove nothing, so it is never
128
+ synthesized here.
129
+ Only the control-plane-signed completion says what was deleted, and only its
130
+ \`deleted\` result means the bytes are gone — \`present_after_attempt\` is a
131
+ FAILED deletion, never counted as a success. There is deliberately no purge
132
+ verb in V0 at all. Retention is an operational promise of the platform, not a
133
+ cryptographic guarantee against it.
134
+
135
+ Expiry is permissive, by design:
136
+ A retention root whose \`effective_admitted_at\` this client cannot resolve is
137
+ RETAINED, and a compact that cannot obtain a retention-cutoff ticket keeps
138
+ every root. That costs storage, never history.
139
+
140
+ Terminal loss (protocol §0):
141
+ In V0-A, whole-machine or whole-keystore loss is terminal for vault history
142
+ until human envelopes ship. \`status\` prints the full statement verbatim on
143
+ stderr and carries it in its JSON — read it before you rely on this.
144
+
145
+ Examples:
146
+ run402 gitvault init
147
+ run402 gitvault status --refs
148
+ run402 gitvault push --message "wip: refactor the parser"
149
+ run402 gitvault policy grandfathered --reason "migrating CI to a vaulted client"
150
+ run402 gitvault verify --budget 500
151
+ run402 gitvault prune --project prj_1a2b3c
152
+ `;
153
+
154
+ /**
155
+ * Resolve which vault to act on, plus the local git tree.
156
+ *
157
+ * `--repo` addresses the vault directly (the cold-restart path: an agent that
158
+ * knows its repo_id needs no project lookup). Otherwise the project is
159
+ * resolved the CLI-wide way — `--project`, then RUN402_PROJECT_ID, then the
160
+ * active project — and the SDK resolves the vault from it.
161
+ */
162
+ function vaultTarget(a) {
163
+ const repoId = flagValue(a, "--repo");
164
+ const project = flagValue(a, "--project");
165
+ const target = { repo_dir: process.cwd() };
166
+ if (repoId != null) target.repo_id = repoId;
167
+ // Only demand a project when one is actually needed: `--repo` alone is a
168
+ // complete address, and requiring an active project on top of it would make
169
+ // the cold-restart path fail for no reason.
170
+ if (repoId == null || project != null) target.project_id = resolveProjectId(project);
171
+ return target;
172
+ }
173
+
174
+ /**
175
+ * Print the protocol §0 terminal-loss statement.
176
+ *
177
+ * NORMATIVE COPY, printed verbatim straight from the SDK's own constants and
178
+ * never paraphrased, summarized, or reassembled here. Both lines also ride in
179
+ * the JSON payload on stdout.
180
+ *
181
+ * The PATH is printed with it. "Whole-keystore loss is terminal" appeared three
182
+ * times across this surface while the directory to back up appeared nowhere
183
+ * (dogfood #1, finding D2) — a warning nobody can act on.
184
+ */
185
+ function printTerminalLoss(status) {
186
+ console.error("");
187
+ console.error(status.terminal_loss_statement);
188
+ console.error(status.terminal_loss_detail);
189
+ console.error(`Back up this directory: ${status.keystore.root}`);
190
+ console.error("");
191
+ }
192
+
193
+ /** Where the keystore lives — for verbs whose payload is not a `status`. */
194
+ async function printKeystoreLocation() {
195
+ try {
196
+ const { getGitvaultKeystoreRoot } = await import("#sdk/node");
197
+ console.error(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-keystore loss is terminal for vault history`);
198
+ } catch {
199
+ // Never let a diagnostic line fail a command that already succeeded.
200
+ }
201
+ }
202
+
203
+ /**
204
+ * `run402 gitvault init` — allocate the project's vault.
205
+ *
206
+ * WHY THIS EXISTS AS ITS OWN VERB (dogfood #1, finding A). Until it did, the
207
+ * only way to allocate was `sdk.gitvault.init()` through the vendored SDK:
208
+ * `gitvault status` pointed at `run402 init`, which scaffolds the remote and
209
+ * says so in a comment; `gitvault push` and `git push run402` both 404'd and
210
+ * handed the user a raw `POST /gitvault/v1/vaults`. A published CLI that can
211
+ * do everything except start is not a usable product.
212
+ *
213
+ * It stays SEPARATE from `run402 init` on purpose: this is the step that mints
214
+ * key material on this machine and emits a one-shot recovery receipt, and
215
+ * whole-keystore loss is terminal for vault history. That belongs to a command
216
+ * the user typed, not to a setup command's side effects.
217
+ */
218
+ async function init(args) {
219
+ const a = normalizeArgv(args);
220
+ const valueFlags = ["--project", "--org"];
221
+ assertKnownFlags(a, [...valueFlags, "--git-remote", "--no-remote", "--help", "-h"], valueFlags);
222
+ requirePositionalCount(a, valueFlags, {
223
+ min: 0, max: 0, command: "run402 gitvault init", missing: "",
224
+ });
225
+ if (a.includes("--git-remote") && a.includes("--no-remote")) {
226
+ fail({
227
+ code: "BAD_USAGE",
228
+ message: "--git-remote and --no-remote contradict each other.",
229
+ hint: "--git-remote creates a repository to add the remote to; --no-remote touches no git configuration at all.",
230
+ });
231
+ }
232
+ const projectId = resolveProjectId(flagValue(a, "--project"));
233
+ const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
234
+ if (!orgId) {
235
+ fail({
236
+ code: "ORG_UNRESOLVED",
237
+ message: `Could not resolve the organization that owns ${projectId}.`,
238
+ hint: "Pass --org <org_id>, or check that this wallet can see the project (`run402 projects list`).",
239
+ details: { project_id: projectId },
240
+ });
241
+ }
242
+
243
+ // Whether to touch git at all. Mirrors `run402 init`: adding a remote inside
244
+ // an EXISTING repository is pure addition and is the default; CREATING a
245
+ // repository is opt-in, because a vault can be allocated from anywhere and
246
+ // `git init`-ing whatever directory you happened to be in is a bad surprise.
247
+ let scaffold = !a.includes("--no-remote");
248
+ let remoteSkipped = null;
249
+ if (scaffold && !a.includes("--git-remote")) {
250
+ const { hardenedGit } = await import("#sdk/node");
251
+ try {
252
+ await hardenedGit(process.cwd(), ["rev-parse", "--git-dir"]);
253
+ } catch {
254
+ scaffold = false;
255
+ remoteSkipped = "not a git repository — the vault was allocated; re-run with --git-remote to create one and add the remote";
256
+ }
257
+ }
258
+
259
+ try {
260
+ const result = await getSdk().gitvault.init({
261
+ org_id: orgId,
262
+ project_id: projectId,
263
+ ...(scaffold ? { repo_dir: process.cwd() } : { scaffold_git: false }),
264
+ });
265
+ console.log(JSON.stringify(remoteSkipped ? { ...result, remote_skipped: remoteSkipped } : result, null, 2));
266
+ console.error(
267
+ result.deduplicated
268
+ ? `vault ${result.repo_id} already existed — nothing was re-allocated and no new key material was minted`
269
+ : `allocated vault ${result.repo_id} (genesis ${result.genesis_sha256})`,
270
+ );
271
+ if (result.remote) console.error(`remote '${result.remote.name}' -> ${result.remote.url}`);
272
+ if (remoteSkipped) console.error(`remote not added: ${remoteSkipped}`);
273
+ // The recovery receipt is integrity data, not a secret, and it is worth
274
+ // exactly as much as the number of copies you keep. It is persisted into
275
+ // the keystore automatically; say where, because "keep many copies" is
276
+ // advice nobody can act on without a path.
277
+ console.error("");
278
+ console.error(result.terminal_loss_statement);
279
+ await printKeystoreLocation();
280
+ console.error("");
281
+ } catch (err) {
282
+ reportSdkError(err);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * `run402 gitvault policy <required|grandfathered>` — the activation gate.
288
+ *
289
+ * The gateway's own `GITVAULT_CLIENT_UPGRADE_REQUIRED` envelope names
290
+ * `run402 gitvault policy grandfathered --reason <why>` as the second way out
291
+ * of a blocked deploy. Until this verb existed, running exactly what the
292
+ * platform told you to run returned UNKNOWN_SUBCOMMAND, so a user could
293
+ * allocate themselves into a blocked-deploy state with no way back.
294
+ */
295
+ async function policy(args) {
296
+ const a = normalizeArgv(args);
297
+ const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
298
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
299
+ const [requested] = requirePositionalCount(a, valueFlags, {
300
+ min: 1, max: 1, command: "run402 gitvault policy <required|grandfathered>",
301
+ missing: "Missing <policy>. Expected `required` or `grandfathered`.",
302
+ });
303
+ if (requested !== "required" && requested !== "grandfathered") {
304
+ fail({
305
+ code: "BAD_USAGE",
306
+ message: `Unknown policy: ${requested}.`,
307
+ hint: "Expected `required` (a deploy must present a vaulted capture) or `grandfathered` (it need not).",
308
+ details: { policy: requested, known_policies: ["required", "grandfathered"] },
309
+ });
310
+ }
311
+ const reason = flagValue(a, "--reason");
312
+ // Required only for the weakening direction. Returning to `required` is
313
+ // restoring the default and needs no justification; leaving it does.
314
+ if (requested === "grandfathered" && (reason == null || reason.trim() === "")) {
315
+ fail({
316
+ code: "BAD_USAGE",
317
+ message: "`grandfathered` needs --reason <why>.",
318
+ 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\".",
319
+ details: { policy: requested },
320
+ });
321
+ }
322
+
323
+ const target = vaultTarget(a);
324
+ try {
325
+ const sdk = getSdk();
326
+ const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
327
+ const result = await sdk.gitvault.setPolicy(repoId, {
328
+ gitvault_policy: requested,
329
+ ...(reason != null ? { reason } : {}),
330
+ });
331
+ console.log(JSON.stringify({ repo_id: repoId, ...result }, null, 2));
332
+ console.error(
333
+ result.changed
334
+ ? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
335
+ : `gitvault_policy was already ${result.gitvault_policy} — nothing changed`,
336
+ );
337
+ for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
338
+ } catch (err) {
339
+ reportSdkError(err);
340
+ }
341
+ }
342
+
343
+ async function status(args) {
344
+ const a = normalizeArgv(args);
345
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--refs", "--help", "-h"], COMMON_VALUE_FLAGS);
346
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, {
347
+ min: 0, max: 0, command: "run402 gitvault status", missing: "",
348
+ });
349
+ const target = vaultTarget(a);
350
+ if (a.includes("--refs")) target.refs = true;
351
+ try {
352
+ const s = await getSdk().gitvault.status(target);
353
+ console.log(JSON.stringify(s, null, 2));
354
+ printTerminalLoss(s);
355
+ // Two facts the user otherwise has to leave the CLI for: which vault this
356
+ // checkout is wired to, and what the control plane says is in it.
357
+ if (s.remote) {
358
+ console.error(`remote '${s.remote.name}': ${s.remote.url}${s.remote.matches ? "" : " ← points at a DIFFERENT project than this status"}`);
359
+ }
360
+ if (s.refs) {
361
+ const names = Object.keys(s.refs).sort();
362
+ console.error(names.length === 0 ? "refs: (none yet)" : `refs (${names.length}):`);
363
+ for (const ref of names) console.error(` ${s.refs[ref]} ${ref}`);
364
+ if (s.head_target) {
365
+ console.error(s.head_target.kind === "symref" ? ` HEAD -> ${s.head_target.ref}` : ` HEAD ${s.head_target.oid} (detached)`);
366
+ }
367
+ }
368
+ // Advisories are echoed EXACTLY as the SDK reported them. Nothing is
369
+ // synthesized here — in particular a project that has never deployed gets
370
+ // no deploy-related warning, because a vault-only project is a first-class
371
+ // shape (protocol D183), not a half-configured deploy.
372
+ for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
373
+ for (const n of s.next_actions) console.error(`next: ${n.action}${n.command ? ` — ${n.command}` : ""}`);
374
+ } catch (err) {
375
+ reportSdkError(err);
376
+ }
377
+ }
378
+
379
+ async function push(args) {
380
+ const a = normalizeArgv(args);
381
+ const valueFlags = [...COMMON_VALUE_FLAGS, "--message"];
382
+ assertKnownFlags(a, [...valueFlags, "--checkpoint", "--help", "-h"], valueFlags);
383
+ requirePositionalCount(a, valueFlags, {
384
+ min: 0, max: 0, command: "run402 gitvault push", missing: "",
385
+ });
386
+ const message = flagValue(a, "--message");
387
+ const opts = {
388
+ ...vaultTarget(a),
389
+ // The gitvault_commit line is progress, not payload: print it the moment
390
+ // the snapshot exists, well before the publication round-trips finish, so
391
+ // a human watching a slow push sees what is being pushed.
392
+ onCommitLine: (line) => console.error(line),
393
+ };
394
+ // The message rides on `snapshot`, which is what `captureSnapshot` reads —
395
+ // and, since 5.12b removed the dead top-level `push({ message })` field, is
396
+ // the ONE place it can ride.
397
+ if (message != null) opts.snapshot = { message };
398
+ if (a.includes("--checkpoint")) opts.checkpoint = true;
399
+ try {
400
+ const result = await getSdk().gitvault.push(opts);
401
+ console.log(JSON.stringify(result, null, 2));
402
+ console.error(`published generation ${result.generation} (${result.form})`);
403
+ } catch (err) {
404
+ reportSdkError(err);
405
+ }
406
+ }
407
+
408
+ async function compact(args) {
409
+ const a = normalizeArgv(args);
410
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
411
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, {
412
+ min: 0, max: 0, command: "run402 gitvault compact", missing: "",
413
+ });
414
+ try {
415
+ const result = await getSdk().gitvault.compact(vaultTarget(a));
416
+ console.log(JSON.stringify(result, null, 2));
417
+ console.error(
418
+ `checkpoint published at generation ${result.generation}: ` +
419
+ `${result.covered_refs} ref(s), ${result.covered_roots} retention root(s).`,
420
+ );
421
+ if (!result.cutoff_bound) {
422
+ // Say what actually happened rather than reporting a clean compaction:
423
+ // without a ticket no root can leave the map, so this run reclaimed
424
+ // nothing from expiry.
425
+ console.error(
426
+ "no retention-cutoff ticket was obtained, so roots were RETAINED — expiry is permissive. " +
427
+ "The checkpoint published, but no expired root left the map; re-run compact once the ticket route answers.",
428
+ );
429
+ }
430
+ } catch (err) {
431
+ reportSdkError(err);
432
+ }
433
+ }
434
+
435
+ /** Read a protocol object a prior planning run (or r402s-verify) wrote to disk. */
436
+ function readJsonFile(flag, path) {
437
+ let text;
438
+ try {
439
+ text = readFileSync(path, "utf8");
440
+ } catch (err) {
441
+ fail({
442
+ code: "BAD_USAGE",
443
+ message: `${flag} ${path} could not be read: ${err?.message ?? String(err)}`,
444
+ hint: "Point it at the file a prior `run402 gitvault prune` (or r402s-verify) wrote.",
445
+ });
446
+ }
447
+ try {
448
+ return JSON.parse(text);
449
+ } catch (err) {
450
+ fail({
451
+ code: "BAD_USAGE",
452
+ message: `${flag} ${path} is not valid JSON: ${err?.message ?? String(err)}`,
453
+ hint: "Pass the file verbatim; do not reformat or re-serialize it.",
454
+ });
455
+ }
456
+ }
457
+
458
+ async function prune(args) {
459
+ const a = normalizeArgv(args);
460
+ const valueFlags = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-receipt"];
461
+ assertKnownFlags(a, [...valueFlags, "--submit", "--wait", "--help", "-h"], valueFlags);
462
+ requirePositionalCount(a, valueFlags, {
463
+ min: 0, max: 0, command: "run402 gitvault prune", missing: "",
464
+ });
465
+ const submitting = a.includes("--submit");
466
+ const corePath = flagValue(a, "--intent-core");
467
+ const receiptPath = flagValue(a, "--verifier-receipt");
468
+ // Refuse the half-specified submit here rather than planning and silently
469
+ // discarding the flags — an agent that typed --submit meant to submit.
470
+ if (submitting && (corePath == null || receiptPath == null)) {
471
+ fail({
472
+ code: "BAD_USAGE",
473
+ message: "run402 gitvault prune --submit needs both --intent-core and --verifier-receipt.",
474
+ hint: "Plan first (`run402 gitvault prune`), save its `intent_core`, run r402s-verify against it, then submit both.",
475
+ });
476
+ }
477
+ if (!submitting && (corePath != null || receiptPath != null)) {
478
+ fail({
479
+ code: "BAD_USAGE",
480
+ message: "--intent-core / --verifier-receipt only apply with --submit.",
481
+ hint: "Add --submit, or drop the flags to plan.",
482
+ });
483
+ }
484
+ const opts = vaultTarget(a);
485
+ if (submitting) {
486
+ opts.submit = {
487
+ core: readJsonFile("--intent-core", corePath),
488
+ verifier_receipt: readJsonFile("--verifier-receipt", receiptPath),
489
+ };
490
+ if (a.includes("--wait")) opts.submit.wait = {};
491
+ }
492
+ try {
493
+ const result = await getSdk().gitvault.prune(opts);
494
+ console.log(JSON.stringify(result, null, 2));
495
+ // Never imply a deletion. State what actually happened, then reproduce the
496
+ // SDK's own note verbatim rather than summarizing it.
497
+ if (!result.submitted) {
498
+ console.error(
499
+ result.blocked_reason
500
+ ? `planned — nothing to submit: ${result.blocked_reason}`
501
+ : `planned — nothing submitted. ${result.object_candidates.length} object(s) proposed for deletion` +
502
+ `${result.deferred_object_count > 0 ? ` (${result.deferred_object_count} more deferred to a later intent)` : ""}` +
503
+ `; ${result.eligible_count} retention root(s) past their window, ${result.retained_count} retained.`,
504
+ );
505
+ if (result.intent_core_sha256) {
506
+ console.error(`intent_core_sha256: ${result.intent_core_sha256} — run r402s-verify against this core, then re-run with --submit.`);
507
+ }
508
+ } else if (result.confirmation?.outcome) {
509
+ console.error(
510
+ `submitted — the signed completion reports ${result.confirmation.deleted.length} deleted, ` +
511
+ `${result.confirmation.present.length} still present` +
512
+ `${result.confirmation.unadjudicated.length > 0 ? `, ${result.confirmation.unadjudicated.length} unadjudicated` : ""}.`,
513
+ );
514
+ } else {
515
+ 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.");
516
+ }
517
+ console.error(result.note);
518
+ } catch (err) {
519
+ reportSdkError(err);
520
+ }
521
+ }
522
+
523
+ async function verify(args) {
524
+ const a = normalizeArgv(args);
525
+ const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
526
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
527
+ requirePositionalCount(a, valueFlags, {
528
+ min: 0, max: 0, command: "run402 gitvault verify", missing: "",
529
+ });
530
+ const target = vaultTarget(a);
531
+ const budget = flagValue(a, "--budget");
532
+ if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
533
+ try {
534
+ const state = await getSdk().gitvault.verify(target);
535
+ console.log(JSON.stringify(state, null, 2));
536
+ console.error(`verified through generation ${state.generation}`);
537
+ } catch (err) {
538
+ reportSdkError(err);
539
+ }
540
+ }
541
+
542
+ export async function run(sub, args) {
543
+ const argv = Array.isArray(args) ? args : [];
544
+ if (!sub || hasHelp([sub, ...argv])) {
545
+ console.log(HELP);
546
+ process.exit(0);
547
+ }
548
+ switch (sub) {
549
+ case "init": {
550
+ await init(argv);
551
+ break;
552
+ }
553
+ case "policy": {
554
+ await policy(argv);
555
+ break;
556
+ }
557
+ case "status": {
558
+ await status(argv);
559
+ break;
560
+ }
561
+ case "push": {
562
+ await push(argv);
563
+ break;
564
+ }
565
+ case "compact": {
566
+ await compact(argv);
567
+ break;
568
+ }
569
+ case "prune": {
570
+ await prune(argv);
571
+ break;
572
+ }
573
+ case "verify": {
574
+ await verify(argv);
575
+ break;
576
+ }
577
+ default:
578
+ failUnknownSubcommand("gitvault", sub, {
579
+ hint: "Run `run402 gitvault --help` for usage.",
580
+ });
581
+ }
582
+ }