run402 4.40.0 → 4.42.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 (59) hide show
  1. package/README.md +7 -4
  2. package/cli.mjs +10 -2
  3. package/lib/argparse.mjs +1 -1
  4. package/lib/assets.mjs +10 -14
  5. package/lib/command-manifest.mjs +58 -49
  6. package/lib/deploy-v2.mjs +7 -10
  7. package/lib/doctor.mjs +12 -5
  8. package/lib/gitvault-scaffold.mjs +1 -1
  9. package/lib/gitvault-target.mjs +7 -9
  10. package/lib/gitvault.mjs +117 -1163
  11. package/lib/init.mjs +2 -2
  12. package/lib/notifications.mjs +4 -8
  13. package/lib/operator.mjs +1 -1
  14. package/lib/org.mjs +3 -3
  15. package/lib/repos.mjs +1261 -249
  16. package/lib/rooms-context.mjs +2 -3
  17. package/lib/rooms.mjs +11 -14
  18. package/lib/update-check.mjs +6 -8
  19. package/package.json +1 -1
  20. package/sdk/dist/errors.d.ts +5 -2
  21. package/sdk/dist/errors.d.ts.map +1 -1
  22. package/sdk/dist/errors.js.map +1 -1
  23. package/sdk/dist/namespaces/admin.d.ts +1 -1
  24. package/sdk/dist/namespaces/admin.js +1 -1
  25. package/sdk/dist/namespaces/deploy.types.d.ts +2 -2
  26. package/sdk/dist/namespaces/gitvault.crypto.d.ts +10 -0
  27. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/gitvault.crypto.js +11 -1
  29. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  30. package/sdk/dist/namespaces/gitvault.d.ts +267 -31
  31. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  32. package/sdk/dist/namespaces/gitvault.js +299 -51
  33. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  34. package/sdk/dist/namespaces/projects.d.ts +1 -1
  35. package/sdk/dist/namespaces/projects.js +1 -1
  36. package/sdk/dist/namespaces/projects.types.d.ts +1 -1
  37. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  38. package/sdk/dist/node/gitvault-apply.d.ts +1 -1
  39. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  40. package/sdk/dist/node/gitvault-apply.js +6 -6
  41. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  42. package/sdk/dist/node/gitvault-creation-journal.js +1 -1
  43. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  44. package/sdk/dist/node/gitvault-deploy.js +2 -2
  45. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  46. package/sdk/dist/node/gitvault-mirror-backend.js +1 -1
  47. package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
  48. package/sdk/dist/node/gitvault-mirror.js +1 -1
  49. package/sdk/dist/node/gitvault-mirror.js.map +1 -1
  50. package/sdk/dist/node/gitvault-publication.d.ts +70 -20
  51. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-publication.js +44 -17
  53. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  54. package/sdk/dist/node/gitvault-recover.d.ts +15 -1
  55. package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
  56. package/sdk/dist/node/gitvault-recover.js +8 -4
  57. package/sdk/dist/node/gitvault-recover.js.map +1 -1
  58. package/sdk/dist/scoped.d.ts +2 -2
  59. package/sdk/dist/scoped.js +1 -1
package/lib/repos.mjs CHANGED
@@ -1,43 +1,35 @@
1
1
  /**
2
- * `run402 repos` — vault-only porcelain (repo-first-onramp design D8, task 2.6).
2
+ * `run402 repos` — the consolidated encrypted-repository family. One noun,
3
+ * twelve verbs, each one either a `gh repo` verb, a `git` verb meaning what
4
+ * it means in git, or a plain-English verb for an operation with no analog.
5
+ * `repo` singular resolves identically (`cli.mjs` dispatches both spellings
6
+ * here).
3
7
  *
4
- * CLI + OpenClaw ONLY no MCP tool exists for this family, and none should
5
- * be added. documentation.md's gitvault row records the law:
6
- * "Mutating verbs are CLI-only by design (immutable generations with no
7
- * undo, the one-shot recovery receipt, ..., destructive prune, owner+step-up
8
- * policy)." `create` mints a vault's one-shot recovery receipt; `delete` is
9
- * destructive. `list` is read-only and could in principle get an MCP tool
10
- * later, but ships alongside its two siblings here rather than splitting a
11
- * three-verb family across two client surfaces on day one.
8
+ * ARCHITECTURAL LAW: every piece of protocol behavior crypto core,
9
+ * keystore, creation journal, snapshot + capture, publication state
10
+ * machines, ref transactions, verification budget, repair lives ONCE in
11
+ * `@run402/sdk` under `r.gitvault` (the SDK keeps that name; it is
12
+ * infrastructure language). This module is a THIN ADAPTER: argument
13
+ * parsing, TTY output, exit codes, local file I/O. It adds zero protocol
14
+ * behavior of its own.
12
15
  *
13
- * `create` composes provision + vault ALLOCATE (not lazy the whole point
14
- * of this command is a repo that exists the moment it returns) + remote
15
- * scaffold, with ZERO deploy ceremony: no manifest, no plan, no release.
16
- * This is D1 (`origin` claimed additively) and D4's `gitvault.init` primitive
17
- * end to end — `repos create` adds no protocol behavior of its own, only
18
- * argument parsing and output shaping (the architectural law every shim in
19
- * this repo follows).
16
+ * Pipe contract (docs/style.md): the payload is JSON on stdout; every human
17
+ * line (progress, the terminal-loss statement, advisories) goes to stderr,
18
+ * so `run402 repos view | jq` stays clean.
20
19
  *
21
- * `list` is the org's vault-bearing projects, cross-referenced CLIENT-SIDE:
22
- * list the org's projects, then read each one's gitvault status. There is no
23
- * bulk "vaults by org" gateway read yet (rung 2 territory), so this is
24
- * sequential N+1 — fine for a one-shot CLI call against a person's or
25
- * agent's own project count, not something to build a server round-trip
26
- * budget around. A project whose vault status cannot be read is skipped
27
- * silently rather than failing the whole listing.
28
- *
29
- * `delete` refuses while the vault holds any admitted generation unless
30
- * --force is passed, after naming exactly what would be lost (repo id,
31
- * generation count, encrypted-source byte count). It then calls the SAME
32
- * `projects.delete` primitive `run402 projects delete` uses — --force here
33
- * IS the explicit confirmation; there is no second --confirm to pass.
20
+ * `cli/lib/gitvault.mjs` is a tombstone that answers every `gitvault <verb>`
21
+ * spelling with a typed `COMMAND_MOVED` (naming its `repos` successor) or
22
+ * `COMMAND_REMOVED` (for `reconcile`, which has none) error.
34
23
  */
24
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { basename, join } from "node:path";
35
26
  import { getSdk } from "./sdk.mjs";
36
27
  import { reportSdkError, fail } from "./sdk-errors.mjs";
37
28
  import { withAutoApprove } from "./operator.mjs";
38
29
  import { allowanceAuthHeaders, isCoreApiTarget, resolveProjectId } from "./config.mjs";
39
30
  import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
40
31
  import { resolveOrgId, resolveOwningOrgId } from "./org-context.mjs";
32
+ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
41
33
  import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
42
34
  import { printKeystoreLocation } from "./gitvault.mjs";
43
35
  import { gitvaultRemoteUrlForRepo } from "#sdk";
@@ -45,104 +37,400 @@ import {
45
37
  normalizeArgv,
46
38
  hasHelp,
47
39
  assertKnownFlags,
40
+ parseIntegerFlag,
48
41
  flagValue,
49
42
  requirePositionalCount,
50
43
  resolveProjectSelector,
51
44
  failUnknownSubcommand,
52
45
  } from "./argparse.mjs";
53
46
 
54
- export const HELP = `run402 repos — vault-only hosted encrypted repos, zero deploy ceremony
47
+ /** Value-taking flags every vault-targeting subcommand accepts. */
48
+ const COMMON_VALUE_FLAGS = ["--project", "--repo"];
49
+
50
+ export const HELP = `run402 repos — your source, encrypted before it leaves the machine
55
51
 
56
52
  Usage:
57
- run402 repos create <name> [--org <org_id>] [--dir <path>] [--tier <tier>]
58
- run402 repos list [--org <org_id>]
59
- run402 repos delete <project_id> [--force]
60
- run402 repos name <name> [--project <id>]
53
+ run402 repos <verb> [options] twelve verbs, tiered by how often you reach for them:
54
+
55
+ Common:
56
+ run402 repos create [name] [--org <org_id>] [--dir <path>] [--tier <tier>] [--project <id>]
57
+ run402 repos view [--project <id>] [--repo <repo_id>] [--human]
58
+ run402 repos list [--org <org_id>]
59
+
60
+ Then plain git, forever:
61
+ git push
62
+ git clone run402::<org>/<repo>
63
+
64
+ Occasional:
65
+ run402 repos snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run]
66
+ run402 repos mirror [<destination>] [--off] [--backfill] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--project <id>] [--repo <repo_id>]
67
+ run402 repos recover <source> --out <dir> [--repo <repo_id>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>]
68
+
69
+ Lifecycle:
70
+ run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]
71
+ run402 repos delete [--project <id>] [--repo <repo_id>] [--force]
72
+
73
+ Maintenance:
74
+ run402 repos fsck [--project <id>] [--repo <repo_id>] [--mirror] [--budget <n>] [--no-write]
75
+ run402 repos gc [--project <id>] [--repo <repo_id>] [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
76
+ run402 repos access [--project <id>] [--repo <repo_id>]
77
+ run402 repos access repair [--project <id>] [--repo <repo_id>]
78
+ run402 repos policy <required|grandfathered> [--project <id>] [--repo <repo_id>] [--reason <why>]
61
79
 
62
80
  Subcommands:
63
- create Provision a project, ALLOCATE its vault (mints key material and a
64
- one-shot recovery receipt), and scaffold the run402 remote
65
- origin when free, run402 when taken (D1). No deploy plan, no
66
- release, nothing deployed: the vault-only track (design D8), for a
67
- project that only ever hosts encrypted source. When the owning org
68
- has a slug (run402 org slug), also claims the project's address-
69
- form repo name (best-effort a name collision or missing slug
70
- never fails the command) and prints the run402::<slug>/<name>
71
- address (design D6).
72
- list The organization's vault-bearing projects those with an
73
- allocated vault, whether or not they have ever deployed. Not
74
- every project in the org; ones with no vault are omitted. Shows
75
- the run402::<slug>/<name> address for a repo that has claimed one.
76
- delete Delete the project and everything in it (database, functions,
77
- subdomains, mailbox, secrets). REFUSES while the vault holds any
78
- admitted generation unless --force is passed this is
79
- irreversible and destroys the vault's entire encrypted history
80
- along with everything else.
81
- name Claim or rename the project's per-org-unique, address-form name
82
- (design D6) the <name> half of run402::<org-slug>/<name>. No
83
- fee, unlike the org slug. Same authority as renaming the project.
81
+ create Provision (or, with --project, ADOPT an existing project), ALLOCATE
82
+ its vault (mints key material and, on first allocation, a one-shot
83
+ recovery receipt), and scaffold the git remote origin when free,
84
+ run402 when taken. \`--project <id>\` allocates for a project that already exists,
85
+ nothing is provisioned. \`[name]\` is inferred from an existing git
86
+ remote's basename or the directory name when unambiguous NEVER a
87
+ prompt; if the directory and an existing remote disagree, or
88
+ nothing usable can be derived, this is a structured error naming
89
+ exactly one next_action, never a guess. The response's next_action
90
+ is the exact \`git push\` to run. Nothing is deployed, ever, unless
91
+ you separately choose to.
92
+ view Side-effect-free: what this machine and the control plane each
93
+ believe about the repo allocation, policy, whether this keystore
94
+ can sign, the authenticated and materialized pins, the mirror
95
+ summary (when one is configured), and where the keystore lives.
96
+ NEVER materializes refs or advances any local pin
97
+ that belongs to \`fsck\`, which is why \`refs\` reports
98
+ {known:false, reason:"not_materialized"} with a next_action
99
+ pointing there. \`--human\` renders a short summary instead of JSON.
100
+ list The organization's vault-bearing repos, via the bulk
101
+ vaults-by-org read when the gateway has it (one round trip);
102
+ gracefully falls back to the older per-project walk when it
103
+ 404s. Not every project in the org — ones with no vault are
104
+ omitted.
105
+ rename Claim or rename the repo's per-org-unique, address-form name
106
+ (the <name> half of run402::<org-slug>/<name>). Address by
107
+ --repo or --project (not both).
108
+ delete Deletes a REPO-ONLY project — database, functions, subdomains,
109
+ mailbox, and secrets must all be absent. When any of
110
+ them is materialized, this REFUSES with
111
+ PROJECT_HAS_NON_REPO_RESOURCES, enumerates refused_resources, and
112
+ points at \`run402 projects delete\` — the verb whose name says
113
+ what it destroys. \`--force\` overrides ONLY the vault-history
114
+ confirmation below it (the repo holds admitted generations); it
115
+ NEVER overrides the non-repo-infra refusal. Success enumerates
116
+ deleted_resources.
117
+ snapshot Capture the working tree and publish it. Not gated on a deploy —
118
+ a vault-only repo snapshots for months without one. Against a
119
+ project with no vault yet, this ALLOCATES one inline before
120
+ publishing. Push-to-creates through a slug-form remote
121
+ (run402::<org-slug>/<name>) the same way \`git push\` does.
122
+ \`--dry-run\` previews the real local pipeline without publishing.
123
+ mirror ONE flag-driven verb for the client-side, customer-
124
+ owned ciphertext mirror — run402 never holds a credential to it.
125
+ No argument: READ the configured destination + a keyless
126
+ freshness check against the live vault. \`<destination>\`:
127
+ configure (idempotent upsert). \`--off\`: remove the config only —
128
+ never touches the mirror's own bytes. \`--backfill\`: copy every
129
+ object the mirror is missing (every publish already dual-pushes
130
+ automatically; backfill exists for a pre-existing vault or a
131
+ mirror that fell behind). Exactly one of these per call. Mirror
132
+ state also renders inside \`repos view\`; mirror INTEGRITY inside
133
+ \`repos fsck --mirror\`.
134
+ recover \`r402s-recover\`: rebuild a working git repository straight from
135
+ a mirrored prefix, with NO SERVER INVOLVED — the offline disaster
136
+ path (normal retrieval is plain \`git clone run402::<org>/<repo>\`,
137
+ no \`repos clone\` verb exists). Proves this mirror's validity,
138
+ never freshness — read both honesty statements before relying on
139
+ the result.
140
+ fsck Walks the head chain AND materializes
141
+ the ref map, advancing BOTH
142
+ local trust pins — reported EXPLICITLY as local_state_changed +
143
+ pin_before + pin_after, never implied. \`--no-write\` is a genuine
144
+ audit mode: the same real walk and decrypt, computing the same
145
+ real answer, but persisting neither pin. \`--budget <n>\` caps
146
+ heads verified per call (the verified prefix persists under
147
+ normal writing mode, so a budget-exceeded run resumes). \`--mirror\`
148
+ additionally runs the keyless mirror integrity probe — it proves
149
+ the mirror's VALIDITY, never its FRESHNESS, and says so.
150
+ gc \`git gc\`'s own two halves — checkpoint publication (compact) and
151
+ prune planning — in one verb, NOT described as "exactly git gc":
152
+ the deletion ceremony is stricter. Plans and checkpoints by
153
+ default; nothing is deleted until \`--submit --intent-core <path>
154
+ --verifier-receipt <path>\` supplies BOTH receipts the two-phase
155
+ protocol requires (this CLI's own + an independent one from
156
+ r402s-verify — ships as prebuilt release binaries, not a
157
+ build-from-source errand). The plan response's submit next_action
158
+ carries destructive:true / requires_approval:true /
159
+ safe_to_auto_execute:false as ADDITIVE fields.
160
+ access READ-ONLY: the org's directory of encryption-key-holding members,
161
+ which of the vault's current envelope-recipient fingerprints are
162
+ covered, per-recipient envelope_state (converged/pending/
163
+ pending_removal, from the gateway's desired-recipient-state
164
+ substrate), and (best-effort, this machine only) each principal's
165
+ local TOFU pin. stale_access names removed members whose access
166
+ was NOT actually revoked — pending_removal is honest bookkeeping,
167
+ not enforcement, until epoch rotation ships. Reports an HONEST
168
+ remaining gap rather than inventing: history_scope (which epochs
169
+ each recipient can read) has no substrate to report — gitvault
170
+ protocol v0 pins a single fixed epoch, so there is no per-epoch
171
+ scope yet; that lands with gitvault-human-envelopes' epoch-
172
+ rotation work, in fold under adversarial review.
173
+ access repair
174
+ NOT YET AVAILABLE — gated on the epoch-rotation mechanism above
175
+ landing. \`reconcile\`, the workaround it replaces, is REMOVED:
176
+ it never wrapped a key correctly-scoped to "from
177
+ here forward," and a temporary mechanism does not get a
178
+ permanent verb. This refuses cleanly and points at \`repos
179
+ access\` for what IS available today.
180
+ policy Set the activation policy — \`required\` (a deploy must present a
181
+ vaulted capture) or \`grandfathered\` (it need not). Owner +
182
+ step-up, audited. \`grandfathered\` is the documented way out of a
183
+ deploy the vault gate refused, and needs \`--reason\`; returning to
184
+ \`required\` does not. Allocating a repo never sets this.
84
185
 
85
186
  Options:
86
- --org <org_id> create/list: the owning organization. create resolves it
87
- the same way 'projects provision' does when omitted
88
- (cold-start); list requires resolving one pass it, or
89
- select an active org first with 'run402 org use <id>'.
187
+ --project <id> Project whose repo to act on (defaults to the active project)
188
+ --repo <repo_id> Address the repo directly by id, skipping project lookup
189
+ --org <org_id> create/list: the owning organization (create resolves it
190
+ the same way \`projects provision\` does when omitted)
90
191
  --dir <path> create: the working tree to scaffold (default: cwd). Not
91
- a git repository yet? One is created — 'repos create' is
92
- a from-a-directory-to-a-hosted-repo verb by definition,
93
- the same way 'gh repo create --source=.' is.
94
- --tier <tier> create: project tier (default: prototype)
192
+ a git repository yet? One is created — \`repos create\` is
193
+ a from-a-directory-to-a-hosted-repo verb by definition.
194
+ --tier <tier> create: project tier (default: prototype) new projects only
95
195
  --idempotency-key <key>
96
- create: re-running with the same key resolves to the
97
- same project instead of creating a second one (default:
98
- derived from the name)
99
- --force delete: proceed even though the vault holds generations
100
- that would be permanently and irrecoverably lost
101
- --project <id> name: project to claim the repo name for (default: the
102
- active project)
196
+ create: re-running with the same key resolves to the
197
+ same project instead of creating a second one — new
198
+ projects only (default: derived from the name)
199
+ --human view: a short summary on stdout instead of the JSON dump.
200
+ Rejected together with --json.
201
+ --force delete: proceed even though the repo holds generations
202
+ that would be permanently and irrecoverably lost. Never
203
+ overrides the non-repo-infrastructure refusal.
204
+ --message <text> snapshot: commit message for the synthetic commit a dirty
205
+ tree produces (a clean tree pushes HEAD itself, unused)
206
+ --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
207
+ --dry-run snapshot: a REAL preview — runs the actual local pipeline
208
+ and reports what would publish. Publishes nothing.
209
+ --off mirror: remove the configured destination (config only)
210
+ --backfill mirror: copy every object the configured mirror is missing
211
+ --profile <name> mirror / recover: the AWS credential profile name for an
212
+ s3:// destination (read from ~/.aws/credentials at USE
213
+ time — never stored). Mutually exclusive with --ambient.
214
+ --ambient mirror / recover: use the ambient AWS_ACCESS_KEY_ID /
215
+ AWS_SECRET_ACCESS_KEY environment chain instead of a profile.
216
+ --region <r> mirror / recover: AWS region for an s3:// destination
217
+ --endpoint <url> mirror / recover: an S3-compatible endpoint override
218
+ --out <dir> recover: where to materialize the recovered repository
219
+ --budget <n> fsck: heads walked in this call (write mode persists the
220
+ verified prefix, so a budget-exceeded run resumes; a
221
+ --no-write run does not, since nothing was persisted)
222
+ --mirror fsck: also run the keyless mirror integrity probe
223
+ --no-write fsck: audit mode — compute and report the real answer,
224
+ persist neither local trust pin
225
+ --submit gc: submit the planned prune intent. Requires
226
+ --intent-core and --verifier-receipt.
227
+ --intent-core <path>
228
+ gc: the plan's intent_core, saved verbatim from a prior
229
+ planning run. A rebuilt core carries a different nonce,
230
+ so a receipt over it would no longer bind.
231
+ --verifier-receipt <path>
232
+ gc: r402s-verify's verifier_receipt over that same core.
233
+ --wait gc: poll the submitted intent until the control-plane-
234
+ signed completion appears, instead of returning immediately
235
+ --reason <why> policy: why the policy is changing — recorded in the
236
+ audit event. REQUIRED for \`grandfathered\`.
103
237
  --json No-op: stdout is already JSON.
104
238
 
105
- There is no separate gitvault price: bytes count against the same
106
- organization-pooled storage budget every project already has.
239
+ Terminal loss (protocol §0):
240
+ In V0-A, whole-machine or whole-keystore loss is terminal for repo history
241
+ until human envelopes ship. \`view\` prints the full statement verbatim on
242
+ stderr and carries it in its JSON — read it before you rely on it.
243
+
244
+ Examples:
245
+ run402 repos create # name inferred from cwd/remote
246
+ run402 repos create my-notes
247
+ run402 repos create --project prj_1a2b3c # allocate for an existing project
248
+ git push -u origin HEAD # the printed next_action, verbatim
249
+ run402 repos view --human
250
+ run402 repos list --org org_1a2b3c
251
+ run402 repos rename my-notes --project prj_1a2b3c
252
+ run402 repos snapshot --dry-run
253
+ run402 repos mirror s3://acme-vault-mirror --profile acme
254
+ run402 repos mirror --backfill
255
+ run402 repos fsck --mirror
256
+ run402 repos gc
257
+ run402 repos access
258
+ run402 repos recover s3://acme-vault-mirror --out ./restored
259
+ run402 repos delete --project prj_xyz --force
107
260
  `;
108
261
 
109
- const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key"];
110
- const LIST_VALUE_FLAGS = ["--org"];
111
- const DELETE_VALUE_FLAGS = ["--project"];
112
- const NAME_VALUE_FLAGS = ["--project"];
262
+ // ─── shared targeting + printing ────
263
+
264
+ /**
265
+ * Resolve which repo to act on, plus the local git tree. Resolution order:
266
+ * explicit `--repo`/`--project` > the repo's own pin/remote
267
+ * > RUN402_PROJECT_ID > the active project.
268
+ */
269
+ async function vaultTarget(a) {
270
+ const repoId = flagValue(a, "--repo");
271
+ const project = flagValue(a, "--project");
272
+ const repoDir = process.cwd();
273
+ const resolved = await resolveGitvaultTarget({
274
+ repoDir,
275
+ explicitProjectId: project ?? undefined,
276
+ explicitRepoId: repoId ?? undefined,
277
+ });
278
+ const target = { repo_dir: repoDir };
279
+ if (repoId != null) target.repo_id = repoId;
280
+ if (repoId == null || project != null) {
281
+ if ("repo_id" in resolved && project == null) target.repo_id = resolved.repo_id;
282
+ if ("project_id" in resolved) target.project_id = resolved.project_id ?? resolveProjectId(project);
283
+ }
284
+ return target;
285
+ }
286
+
287
+ /**
288
+ * Print the protocol §0 terminal-loss statement, verbatim, from the SDK's own
289
+ * constants — never paraphrased here. The SDK's `status()` already downgrades
290
+ * this to the durability sentence when it has locally proven the vault has
291
+ * >= 2 covering recipients (dogfood item 2: the single-principal terminal-loss
292
+ * claim is false for that vault) — this function only renders whichever
293
+ * statement the SDK selected, it never chooses between them itself.
294
+ */
295
+ function printTerminalLoss(status) {
296
+ console.error("");
297
+ if (status.terminal_loss_statement) {
298
+ console.error(status.terminal_loss_statement);
299
+ console.error(status.terminal_loss_detail);
300
+ } else if (status.durability_statement) {
301
+ console.error(status.durability_statement);
302
+ if (status.covering_recipients != null) console.error(`covering_recipients: ${status.covering_recipients}`);
303
+ }
304
+ console.error(`Back up this directory: ${status.keystore.root}`);
305
+ console.error("");
306
+ }
307
+
308
+ const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
309
+
310
+ /**
311
+ * docs/agent-response-design.md's CLI pipe-contract row: stdout ALWAYS keeps
312
+ * the full JSON (never truncated); when a result is large it is ALSO written
313
+ * to a private 0600 file with a one-line stderr breadcrumb naming the path.
314
+ * Best-effort. Deliberately NEVER called on `create`'s result — that JSON
315
+ * carries the one-shot recovery receipt, and a secret-bearing response is
316
+ * never spilled into any cache path (agent-response-design's secrets rule).
317
+ */
318
+ async function spillIfLarge(repoId, verb, payload) {
319
+ const json = JSON.stringify(payload, null, 2);
320
+ if (Buffer.byteLength(json, "utf8") <= LARGE_OUTPUT_THRESHOLD_BYTES) return;
321
+ try {
322
+ const { getGitvaultKeystoreRoot } = await import("#sdk/node");
323
+ const dir = join(getGitvaultKeystoreRoot(), "reports");
324
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
325
+ const path = join(dir, `${verb}-${repoId ?? "unknown"}-${Date.now()}.json`);
326
+ writeFileSync(path, json, { mode: 0o600 });
327
+ console.error(`(this result is large — the full JSON was also written to ${path})`);
328
+ } catch {
329
+ // best-effort only; stdout already carries the full result regardless
330
+ }
331
+ }
332
+
333
+ /** Both mirror honesty statements, verbatim, wherever a mirror/recover result is shown. */
334
+ function printMirrorHonesty(result) {
335
+ if (result?.validity_not_freshness) console.error(result.validity_not_freshness);
336
+ if (result?.keystore_still_required) console.error(result.keystore_still_required);
337
+ }
338
+
339
+ function resolveMirrorCredential(a) {
340
+ const profile = flagValue(a, "--profile");
341
+ const ambient = a.includes("--ambient");
342
+ if (profile != null && ambient) {
343
+ fail({ code: "BAD_USAGE", message: "--profile and --ambient contradict each other.", hint: "Pick one credential source for the s3:// destination." });
344
+ }
345
+ if (profile != null) return { kind: "profile", profile };
346
+ if (ambient) return { kind: "ambient" };
347
+ return undefined;
348
+ }
349
+
350
+ function formatMirrorDestination(destination) {
351
+ if (!destination) return "(none)";
352
+ return destination.kind === "s3" ? `s3://${destination.bucket}/${destination.prefix}` : destination.path;
353
+ }
354
+
355
+ function readJsonFile(flag, path) {
356
+ let text;
357
+ try {
358
+ text = readFileSync(path, "utf8");
359
+ } catch (err) {
360
+ fail({ code: "BAD_USAGE", message: `${flag} ${path} could not be read: ${err?.message ?? String(err)}`, hint: "Point it at the file a prior `run402 repos gc` (or r402s-verify) wrote." });
361
+ }
362
+ try {
363
+ return JSON.parse(text);
364
+ } catch (err) {
365
+ fail({ code: "BAD_USAGE", message: `${flag} ${path} is not valid JSON: ${err?.message ?? String(err)}`, hint: "Pass the file verbatim; do not reformat or re-serialize it." });
366
+ }
367
+ }
368
+
369
+ function formatRepoAddress(s) {
370
+ if (s.pinned?.resolved_from) return `run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name}`;
371
+ const orgId = s.vault?.org_id ?? null;
372
+ const projectId = s.project_id ?? s.vault?.project_id ?? null;
373
+ if (orgId && projectId) return `run402::${orgId}/${projectId}`;
374
+ if (projectId) return projectId;
375
+ if (s.repo_id) return `repo ${s.repo_id}`;
376
+ return "(unresolved)";
377
+ }
378
+
379
+ async function formatRepoHuman(s, mirror) {
380
+ const lines = [];
381
+ const remotePart = s.remote ? ` (remote '${s.remote.name}'${s.remote.matches ? "" : " — points at a DIFFERENT project"})` : " (no local remote)";
382
+ lines.push(`Address: ${formatRepoAddress(s)}${remotePart}`);
383
+
384
+ if (!s.vault) {
385
+ lines.push("Repo: not allocated yet for this project — run 'run402 repos create --project <id>' to allocate one.");
386
+ if (s.warnings.length > 0) lines.push(`Warnings: ${s.warnings.map((w) => w.message).join(" ")}`);
387
+ return lines.join("\n");
388
+ }
389
+
390
+ lines.push("HEAD: (not materialized — run 'run402 repos fsck' to see HEAD/ref count; view never does)");
391
+
392
+ const { generationToBigInt } = await import("#sdk/node");
393
+ const decimal = (g) => (g ? generationToBigInt(g).toString() : "none");
394
+ lines.push(`Generations: authenticated ${decimal(s.pins.highest_authenticated)}, materialized ${decimal(s.pins.highest_materialized)}`);
395
+
396
+ const storage = s.vault.storage;
397
+ const objectCount = storage?.objects ? Object.values(storage.objects).reduce((sum, n) => sum + Number(n), 0) : null;
398
+ lines.push(storage ? `Storage: ${storage.source_bytes} byte(s)${objectCount != null ? ` across ${objectCount} object(s)` : ""}` : "Storage: unknown");
399
+
400
+ const decryptPart = !s.keystore.holds_repo_key
401
+ ? "CANNOT decrypt (no key in this machine's keystore)"
402
+ : s.keystore.can_sign
403
+ ? "can decrypt and publish"
404
+ : "can decrypt (read-only — no signing key)";
405
+ lines.push(`This machine: ${decryptPart}. Policy: ${s.gitvault_policy ?? "(none)"}`);
406
+
407
+ if (mirror?.configured) {
408
+ const currency = mirror.is_current === true ? "current" : mirror.is_current === false ? "STALE" : "unknown";
409
+ lines.push(`Mirror: ${mirror.destination} (${currency})`);
410
+ }
411
+
412
+ if (s.warnings.length > 0) lines.push(`Warnings: ${s.warnings.map((w) => w.message).join(" ")}`);
413
+ return lines.join("\n");
414
+ }
415
+
416
+ // ─── name inference ──────────────────
113
417
 
114
418
  function validateProjectName(name) {
115
419
  if (name === "") {
116
- fail({
117
- code: "BAD_PROJECT_NAME",
118
- message: "the repo name must not be empty.",
119
- details: { field: "name" },
120
- });
420
+ fail({ code: "BAD_PROJECT_NAME", message: "the repo name must not be empty.", details: { field: "name" } });
121
421
  }
122
422
  if (name.length > 128) {
123
- fail({
124
- code: "BAD_PROJECT_NAME",
125
- message: `the repo name must be 1-128 characters, got ${name.length}.`,
126
- details: { field: "name", length: name.length, max: 128 },
127
- });
423
+ fail({ code: "BAD_PROJECT_NAME", message: `the repo name must be 1-128 characters, got ${name.length}.`, details: { field: "name", length: name.length, max: 128 } });
128
424
  }
129
425
  // eslint-disable-next-line no-control-regex
130
426
  if (/[\x00-\x1f\x7f]/.test(name)) {
131
- fail({
132
- code: "BAD_PROJECT_NAME",
133
- message: "the repo name contains control characters (newline, tab, etc).",
134
- details: { field: "name" },
135
- });
427
+ fail({ code: "BAD_PROJECT_NAME", message: "the repo name contains control characters (newline, tab, etc).", details: { field: "name" } });
136
428
  }
137
429
  }
138
430
 
139
431
  /**
140
- * Best-effort slugify for the address-form repo name (design D6's grammar:
141
- * lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars). The
142
- * free-text project display name (`repos create <name>`'s positional) is
143
- * NOT already in this charset, so `create` derives a candidate rather than
144
- * sending the raw name straight to the claim route and failing on the first
145
- * space or capital letter.
432
+ * Best-effort slugify for the address-form repo name (the address-form
433
+ * grammar: lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars).
146
434
  */
147
435
  function slugifyRepoName(name) {
148
436
  return name
@@ -154,26 +442,152 @@ function slugifyRepoName(name) {
154
442
  .replace(/-+$/g, "");
155
443
  }
156
444
 
157
- async function create(args) {
158
- const a = normalizeArgv(args);
159
- assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
160
- const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
161
- min: 1, max: 1, command: "run402 repos create <name>", missing: "run402 repos create <name>: a repo name is required",
162
- });
163
- const name = positionals[0];
164
- validateProjectName(name);
445
+ /** The basename of an existing `run402`/`origin` remote's URL, or `null` when there is no repository or no such remote. Any remote — a GitHub URL parses fine too, not only a gitvault address. */
446
+ async function remoteBasenameCandidate(dir) {
447
+ try {
448
+ const { hardenedGit } = await import("#sdk/node");
449
+ await hardenedGit(dir, ["rev-parse", "--git-dir"]);
450
+ for (const name of ["run402", "origin"]) {
451
+ let url;
452
+ try {
453
+ url = (await hardenedGit(dir, ["remote", "get-url", name])).text().trim();
454
+ } catch {
455
+ continue;
456
+ }
457
+ if (!url) continue;
458
+ const stripped = url.replace(/\.git$/, "");
459
+ const seg = stripped.split(/[/:]/).filter(Boolean).pop();
460
+ if (seg) return seg;
461
+ }
462
+ } catch {
463
+ // not a repository — no candidate
464
+ }
465
+ return null;
466
+ }
165
467
 
166
- const dir = flagValue(a, "--dir") ?? process.cwd();
468
+ function dirBasenameCandidate(dir) {
469
+ const base = basename(dir);
470
+ return base && base !== "/" ? base : null;
471
+ }
472
+
473
+ /**
474
+ * `repos create [name]`'s inference: the directory or an
475
+ * existing git remote's basename, when unambiguous. NEVER a prompt —
476
+ * ambiguity (the two candidates disagree) or a dead end (neither yields a
477
+ * usable slug) is a structured error naming exactly one next_action.
478
+ */
479
+ async function inferRepoName(dir) {
480
+ const remoteCand = await remoteBasenameCandidate(dir);
481
+ const dirCand = dirBasenameCandidate(dir);
482
+ const remoteSlug = remoteCand ? slugifyRepoName(remoteCand) : null;
483
+ const dirSlug = dirCand ? slugifyRepoName(dirCand) : null;
484
+
485
+ if (remoteSlug && dirSlug && remoteSlug !== dirSlug) {
486
+ fail({
487
+ code: "REPOS_NAME_AMBIGUOUS",
488
+ message: `Could not infer a repo name: the directory ("${dirCand}") and the existing git remote ("${remoteCand}") disagree.`,
489
+ hint: "Pass the name explicitly.",
490
+ details: { directory_candidate: dirSlug, remote_candidate: remoteSlug },
491
+ next_actions: [nextAction("edit_request", { command: "run402 repos create <name>", why: "Inference could not pick between the directory and the existing remote — say which name you want." })],
492
+ });
493
+ }
494
+ const picked = remoteSlug ?? dirSlug;
495
+ if (!picked) {
496
+ fail({
497
+ code: "REPOS_NAME_REQUIRED",
498
+ message: "Could not infer a repo name from the directory or an existing git remote.",
499
+ hint: "Pass one explicitly.",
500
+ next_actions: [nextAction("edit_request", { command: "run402 repos create <name>", why: "No usable name could be derived from cwd or a remote." })],
501
+ });
502
+ }
503
+ return picked;
504
+ }
505
+
506
+ // ─── create ─────────────────────────────────────────────────────────────────
507
+
508
+ const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project"];
509
+
510
+ async function printCreateResult({ projectId, vault, adopted, name }) {
511
+ let address = null;
512
+ let orgSlug = null;
513
+ try {
514
+ const owningOrg = await resolveOwningOrgId(projectId);
515
+ const orgRecord = owningOrg ? await getSdk().org(owningOrg).get() : null;
516
+ orgSlug = orgRecord?.slug ?? null;
517
+ if (orgSlug && name) {
518
+ const candidate = slugifyRepoName(name);
519
+ if (candidate) {
520
+ const named = await getSdk().projects.setRepoName(projectId, candidate);
521
+ address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
522
+ }
523
+ }
524
+ } catch (err) {
525
+ if (name) console.error(`repo name not claimed (non-fatal): ${err?.message ?? String(err)}`);
526
+ }
527
+
528
+ const pushAction = vault.remote
529
+ ? nextAction("push_repo", { command: `git push -u ${vault.remote.name} HEAD`, why: "Publish the current branch to the encrypted Run402 remote." })
530
+ : null;
531
+ const claimAction = address ? null : orgSlug ? claimRepoNameAction(projectId) : claimOrgSlugAction();
532
+ const nextActions = [pushAction, claimAction].filter(Boolean);
533
+
534
+ // Secret-bearing (recovery_receipt): built fresh every call, printed once,
535
+ // and never spilled into any cache path — see spillIfLarge's own doc
536
+ // comment for why this function never calls it.
537
+ const out = {
538
+ project_id: projectId,
539
+ repo_id: vault.repo_id,
540
+ address,
541
+ remote: vault.remote,
542
+ deduplicated: vault.deduplicated,
543
+ genesis_sha256: vault.genesis_sha256,
544
+ recovery_receipt: vault.recovery_receipt,
545
+ terminal_loss_statement: vault.terminal_loss_statement,
546
+ deployed: false,
547
+ next_actions: nextActions,
548
+ };
549
+ console.log(JSON.stringify(out, null, 2));
550
+ console.error(
551
+ `project ${projectId} ${adopted ? "adopted" : "provisioned"}; repo ${vault.repo_id} ` +
552
+ (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
553
+ );
554
+ if (address) console.error(`address: ${address}`);
555
+ else if (!orgSlug) console.error("no named address yet — claim an org slug (run402 org slug <slug>, one-time $1) to get run402::<slug>/<name> addresses");
556
+ else console.error(`no address claimed — run 'run402 repos rename <name> --project ${projectId}' to claim one`);
557
+ if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
558
+ if (pushAction) console.error(`next: ${pushAction.command}`);
559
+ console.error("");
560
+ console.error(vault.terminal_loss_statement);
561
+ await printKeystoreLocation();
562
+ console.error("");
563
+ console.error("nothing was deployed — this is a vault-only repo. Deploy later with `run402 deploy apply`, or never.");
564
+ }
565
+
566
+ async function createAdopt(projectId, dir, a) {
567
+ const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
568
+ if (!orgId) {
569
+ fail({
570
+ code: "GITVAULT_ORG_UNRESOLVED",
571
+ message: `Could not resolve the organization that owns ${projectId}.`,
572
+ hint: "Pass --org <org_id>, or check that this wallet can see the project (`run402 projects list`).",
573
+ details: { project_id: projectId },
574
+ });
575
+ }
576
+ try {
577
+ const vault = await getSdk().gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
578
+ await printCreateResult({ projectId, vault, adopted: true, name: null });
579
+ } catch (err) {
580
+ reportSdkError(err);
581
+ }
582
+ }
583
+
584
+ async function createProvision(name, dir, a) {
167
585
  const tier = flagValue(a, "--tier") ?? "prototype";
168
586
  const idempotencyKey = flagValue(a, "--idempotency-key") ?? `repos-create:${name}`;
169
587
  // `optional: true` — a fresh wallet with no org yet is the cold-start path
170
588
  // `projects provision` itself supports; `--org` targets an existing one.
171
589
  const orgId = await resolveOrgId(a, { cmd: "repos", optional: true });
172
590
 
173
- // Same NO_ALLOWANCE gate `projects provision` and `up --repo-only` use:
174
- // provisioning bypasses no action-graph here (there is none to bypass —
175
- // this command never touches sdk.up()), so surface the actionable guidance
176
- // directly rather than an opaque auth error from the gateway.
177
591
  if (!isCoreApiTarget() && !loadLiveControlPlaneSession()) allowanceAuthHeaders("/projects/v1");
178
592
 
179
593
  let provisioned;
@@ -190,114 +604,72 @@ async function create(args) {
190
604
  if (!effectiveOrgId) {
191
605
  fail({
192
606
  code: "GITVAULT_ORG_UNRESOLVED",
193
- message: `Provisioned project ${provisioned.project_id}, but could not resolve its owning organization to allocate the vault.`,
194
- hint: `Pass --org <org_id> next time, or finish by hand: run402 gitvault init --project ${provisioned.project_id} --org <org_id>`,
607
+ message: `Provisioned project ${provisioned.project_id}, but could not resolve its owning organization to allocate the repo.`,
608
+ hint: `Pass --org <org_id> next time, or finish by hand: run402 repos create --project ${provisioned.project_id} --org <org_id>`,
195
609
  details: { project_id: provisioned.project_id },
196
- next_actions: [
197
- nextAction("edit_request", {
198
- command: `run402 gitvault init --project ${provisioned.project_id} --org <org_id>`,
199
- why: "the owning org could not be resolved automatically after provisioning",
200
- }),
201
- ],
610
+ next_actions: [nextAction("edit_request", { command: `run402 repos create --project ${provisioned.project_id} --org <org_id>`, why: "the owning org could not be resolved automatically after provisioning" })],
202
611
  });
203
612
  }
204
613
 
205
614
  try {
206
- const vault = await getSdk().gitvault.init({
207
- org_id: effectiveOrgId,
208
- project_id: provisioned.project_id,
209
- repo_dir: dir,
210
- });
211
- // Best-effort address-form name claim (design D6): when the owning org
212
- // has a slug, name this repo so it is reachable as
213
- // run402::<slug>/<name> too — never fails `create` itself. A collision,
214
- // a missing slug, or any other refusal just means no address this time;
215
- // `run402 repos name <name>` claims it explicitly later.
216
- let address = null;
217
- let orgSlug = null;
218
- try {
219
- const orgRecord = await getSdk().org(effectiveOrgId).get();
220
- orgSlug = orgRecord.slug ?? null;
221
- if (orgSlug) {
222
- const candidate = slugifyRepoName(name);
223
- if (candidate) {
224
- const named = await getSdk().projects.setRepoName(provisioned.project_id, candidate);
225
- address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
226
- }
227
- }
228
- } catch (err) {
229
- console.error(`repo name not claimed (non-fatal): ${err?.message ?? String(err)}`);
230
- }
231
-
232
- // `address: null` used to have no pointer to WHY, or to the
233
- // named-addressing feature at all (kychee-com/run402#560): an agent
234
- // reading the output had no path from "address is null" to
235
- // `run402 org slug`/`run402 repos name`. One typed next_actions entry,
236
- // pointing at whichever half is actually missing.
237
- const nextActions = address
238
- ? []
239
- : orgSlug
240
- ? [claimRepoNameAction(provisioned.project_id)]
241
- : [claimOrgSlugAction()];
242
-
243
- const out = {
244
- project_id: provisioned.project_id,
245
- repo_id: vault.repo_id,
246
- address,
247
- remote: vault.remote,
248
- deduplicated: vault.deduplicated,
249
- genesis_sha256: vault.genesis_sha256,
250
- recovery_receipt: vault.recovery_receipt,
251
- terminal_loss_statement: vault.terminal_loss_statement,
252
- deployed: false,
253
- next_actions: nextActions,
254
- };
255
- console.log(JSON.stringify(out, null, 2));
256
- console.error(
257
- `project ${provisioned.project_id} provisioned; vault ${vault.repo_id} ` +
258
- (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
259
- );
260
- if (address) console.error(`address: ${address}`);
261
- else if (!orgSlug) {
262
- console.error(
263
- "no named address yet — claim an org slug (run402 org slug <slug>, one-time $1) to get run402::<slug>/<name> addresses",
264
- );
265
- } else {
266
- console.error(`no address claimed — run 'run402 repos name <name> --project ${provisioned.project_id}' to claim one`);
267
- }
268
- if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
269
- console.error("");
270
- console.error(vault.terminal_loss_statement);
271
- await printKeystoreLocation();
272
- console.error("");
273
- console.error("nothing was deployed — this is a vault-only repo. Deploy later with `run402 deploy apply`, or never.");
615
+ const vault = await getSdk().gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
616
+ await printCreateResult({ projectId: provisioned.project_id, vault, adopted: false, name });
274
617
  } catch (err) {
275
618
  reportSdkError(err);
276
619
  }
277
620
  }
278
621
 
279
- async function list(args) {
622
+ async function create(args) {
280
623
  const a = normalizeArgv(args);
281
- assertKnownFlags(a, [...LIST_VALUE_FLAGS, "--help", "-h"], LIST_VALUE_FLAGS);
282
- requirePositionalCount(a, LIST_VALUE_FLAGS, {
283
- min: 0, max: 0, command: "run402 repos list", missing: "",
624
+ assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
625
+ const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
626
+ min: 0, max: 1, command: "run402 repos create [name]", missing: "",
284
627
  });
285
- const orgId = await resolveOrgId(a, { cmd: "repos" });
628
+ let name = positionals[0] ?? null;
629
+ const dir = flagValue(a, "--dir") ?? process.cwd();
630
+ const adoptProjectId = flagValue(a, "--project");
286
631
 
287
- let projects;
288
- try {
289
- const result = await getSdk().projects.list({ org: orgId });
290
- projects = Array.isArray(result.projects) ? result.projects : [];
291
- } catch (err) {
292
- reportSdkError(err);
293
- return;
632
+ if (adoptProjectId != null) {
633
+ if (name != null) {
634
+ fail({
635
+ code: "BAD_USAGE",
636
+ message: "a name positional and --project are mutually exclusive — --project adopts an EXISTING project.",
637
+ hint: "run402 repos create --project <id> to adopt, or run402 repos create <name> to provision a new one. Name it afterward with `run402 repos rename`.",
638
+ });
639
+ }
640
+ if (flagValue(a, "--tier") != null || flagValue(a, "--idempotency-key") != null) {
641
+ fail({
642
+ code: "BAD_USAGE",
643
+ message: "--tier / --idempotency-key only apply when provisioning a NEW project — they do not apply with --project.",
644
+ hint: "Drop --project to provision a new project, or drop --tier/--idempotency-key to adopt the existing one.",
645
+ });
646
+ }
647
+ return createAdopt(adoptProjectId, dir, a);
294
648
  }
295
649
 
296
- // N+1 by necessity (see module doc): no bulk vault-by-org read exists yet.
297
- // A project whose vault status cannot be read (unreachable gateway for
298
- // THIS project, revoked keys, ...) is skipped rather than failing the
299
- // whole listing — the same "read, never fail the batch" discipline other
300
- // best-effort list augmentations in this CLI follow.
650
+ if (name == null) name = await inferRepoName(dir);
651
+ validateProjectName(name);
652
+ return createProvision(name, dir, a);
653
+ }
654
+
655
+ // ─── list ───────────────────────────────────────────────────────────────────
656
+
657
+ /** The FROZEN bulk-read shape (task 2.4) — one round trip. */
658
+ async function listViaBulkRead(orgId) {
659
+ const result = await getSdk().gitvault.listByOrg(orgId);
660
+ return Array.isArray(result.vaults) ? result.vaults : [];
661
+ }
662
+
663
+ /**
664
+ * DEPRECATED fallback, kept only until every deployed gateway answers
665
+ * `GET /gitvault/v1/vaults?org_id=`: the old client-side N+1 (list the
666
+ * org's projects, then read each one's gitvault status). Delete this
667
+ * function once the bulk route has shipped long enough that no gateway
668
+ * still 404s it.
669
+ */
670
+ async function listViaFallback(orgId) {
671
+ const result = await getSdk().projects.list({ org: orgId });
672
+ const projects = Array.isArray(result.projects) ? result.projects : [];
301
673
  const repos = [];
302
674
  for (const p of projects) {
303
675
  let status;
@@ -308,37 +680,238 @@ async function list(args) {
308
680
  }
309
681
  if (!status.vault) continue;
310
682
  repos.push({
311
- project_id: p.id,
312
- name: p.name,
313
683
  repo_id: status.repo_id,
684
+ project_id: p.id,
685
+ project_name: p.name ?? null,
686
+ repo_name: null,
687
+ org_slug: null,
314
688
  gitvault_policy: status.vault.gitvault_policy,
315
- admitted_generations: Number(status.vault.admitted_generations ?? "0"),
316
- source_bytes: Number(status.vault.storage?.source_bytes ?? "0"),
689
+ newest_generation: status.vault.newest_generation ?? null,
690
+ source_bytes: String(status.vault.storage?.source_bytes ?? "0"),
317
691
  genesis_admitted_at: status.vault.genesis_admitted_at,
692
+ created_at: null,
318
693
  });
319
694
  }
320
- // The org's slug, when claimed (design D6) — printed so a human/agent can
321
- // construct run402::<slug>/<name> addresses by hand. There is deliberately
322
- // no per-project `address` field here yet: the gateway has no bulk (or
323
- // even single) READ for a project's claimed repo_name today, only the
324
- // WRITE route (`POST /projects/v1/:id/repo-name`) — adding one is gateway
325
- // work, out of scope for this client-only change (see the final report).
326
- let orgSlug = null;
695
+ return repos;
696
+ }
697
+
698
+ async function list(args) {
699
+ const a = normalizeArgv(args);
700
+ assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
701
+ requirePositionalCount(a, ["--org"], { min: 0, max: 0, command: "run402 repos list", missing: "" });
702
+ const orgId = await resolveOrgId(a, { cmd: "repos" });
703
+
704
+ let repos;
705
+ let usedFallback = false;
327
706
  try {
328
- orgSlug = (await getSdk().org(orgId).get()).slug;
329
- } catch {
330
- // Best-effort `list` must not fail over an org-slug lookup.
707
+ repos = await listViaBulkRead(orgId);
708
+ } catch (err) {
709
+ if (err?.status === 404) {
710
+ usedFallback = true;
711
+ try {
712
+ repos = await listViaFallback(orgId);
713
+ } catch (fallbackErr) {
714
+ reportSdkError(fallbackErr);
715
+ return;
716
+ }
717
+ } else {
718
+ reportSdkError(err);
719
+ return;
720
+ }
721
+ }
722
+
723
+ let orgSlug = repos.find((r) => r.org_slug)?.org_slug ?? null;
724
+ if (orgSlug == null) {
725
+ try {
726
+ orgSlug = (await getSdk().org(orgId).get()).slug;
727
+ } catch {
728
+ // best-effort — `list` must not fail over an org-slug lookup
729
+ }
331
730
  }
731
+
332
732
  console.log(JSON.stringify({ org_id: orgId, org_slug: orgSlug, repos }, null, 2));
333
- console.error(`${repos.length} vault-bearing project(s) of ${projects.length} total in this organization`);
733
+ console.error(`${repos.length} vault-bearing project(s) in this organization${usedFallback ? " (per-project fallback read — the bulk vaults-by-org route is not live on this gateway yet)" : ""}`);
334
734
  if (orgSlug) console.error(`org slug: ${orgSlug} — a repo with a claimed address-form name is reachable at run402::${orgSlug}/<name>`);
335
735
  }
336
736
 
737
+ // ─── view ───────────────────────────────────────────────────────────────────
738
+
739
+ async function view(args) {
740
+ const a = normalizeArgv(args);
741
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "--help", "-h"], COMMON_VALUE_FLAGS);
742
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos view", missing: "" });
743
+ const human = a.includes("--human");
744
+ if (human && a.includes("--json")) {
745
+ fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
746
+ }
747
+ const target = await vaultTarget(a);
748
+ try {
749
+ // Design D3: `view` NEVER passes `refs: true` — it is side-effect-free
750
+ // by construction, not by convention. Materialization belongs to `fsck`.
751
+ const s = await getSdk().gitvault.status(target);
752
+ let mirror = null;
753
+ if (s.repo_id) {
754
+ try {
755
+ mirror = await getSdk().gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
756
+ } catch {
757
+ // best-effort — a mirror read failure never fails `view`
758
+ }
759
+ }
760
+ if (human) {
761
+ console.log(await formatRepoHuman(s, mirror));
762
+ return;
763
+ }
764
+ const verifyRefsAction = nextAction("verify_refs", { command: "run402 repos fsck", why: "Walk the signed chain and materialize verified refs." });
765
+ const combinedNextActions = s.vault ? [verifyRefsAction, ...(s.next_actions ?? [])] : (s.next_actions ?? []);
766
+ const out = {
767
+ ...s,
768
+ refs: { known: false, reason: "not_materialized" },
769
+ mirror,
770
+ next_actions: combinedNextActions,
771
+ };
772
+ console.log(JSON.stringify(out, null, 2));
773
+ printTerminalLoss(s);
774
+ if (s.remote) {
775
+ const suffix =
776
+ s.remote.matches === false ? " ← points at a DIFFERENT project than this view"
777
+ : s.remote.matches === null ? ` (${s.remote.reason})`
778
+ : "";
779
+ console.error(`remote '${s.remote.name}': ${s.remote.url}${suffix}`);
780
+ }
781
+ if (s.pinned) {
782
+ console.error(`pinned: repo_id ${s.pinned.repo_id}` + (s.pinned.resolved_from ? ` (resolved from run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name})` : ""));
783
+ }
784
+ if (mirror?.configured) {
785
+ const currency = mirror.is_current === true ? "current" : mirror.is_current === false ? `STALE — ${mirror.closing_command}` : "unknown (mirror unreachable or vault unread)";
786
+ console.error(`mirror ${mirror.destination}: mirrored generation ${mirror.mirrored_generation ?? "(none)"}, vault newest ${mirror.newest_generation ?? "(none)"} — ${currency}`);
787
+ }
788
+ for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
789
+ for (const n of combinedNextActions) console.error(`next: ${n.why ?? n.action ?? n.type}${n.command ? ` — ${n.command}` : ""}`);
790
+ } catch (err) {
791
+ reportSdkError(err);
792
+ }
793
+ }
794
+
795
+ // ─── rename ─────────────────────────────────────────────────────────────────
796
+
797
+ async function rename(args) {
798
+ const a = normalizeArgv(args);
799
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
800
+ const [repoName] = requirePositionalCount(a, COMMON_VALUE_FLAGS, {
801
+ min: 1, max: 1, command: "run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]",
802
+ missing: "run402 repos rename <new_name>: a new name is required",
803
+ });
804
+ const repoFlag = flagValue(a, "--repo");
805
+ const projectFlag = flagValue(a, "--project");
806
+ if (repoFlag != null && projectFlag != null) {
807
+ fail({ code: "BAD_USAGE", message: "pass --repo or --project, not both.", hint: "They address the same repo two different ways." });
808
+ }
809
+ let projectId;
810
+ if (repoFlag != null) {
811
+ try {
812
+ projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
813
+ } catch (err) {
814
+ reportSdkError(err);
815
+ return;
816
+ }
817
+ } else {
818
+ projectId = resolveProjectId(projectFlag);
819
+ }
820
+ try {
821
+ const result = await getSdk().projects.setRepoName(projectId, repoName);
822
+ let address = null;
823
+ try {
824
+ const owningOrg = await resolveOwningOrgId(projectId);
825
+ const orgSlug = owningOrg ? (await getSdk().org(owningOrg).get()).slug : null;
826
+ if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
827
+ } catch {
828
+ // The claim itself already succeeded — a failed address-preview lookup is never fatal.
829
+ }
830
+ console.log(JSON.stringify({ ...result, address }, null, 2));
831
+ console.error(
832
+ result.previous_repo_name && result.previous_repo_name !== result.repo_name
833
+ ? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
834
+ : `name "${result.repo_name}" claimed for ${projectId}`,
835
+ );
836
+ if (address) console.error(`address: ${address}`);
837
+ else console.error("this org has no slug yet — claim one with `run402 org slug <slug>` to get a full run402::<slug>/<name> address");
838
+ } catch (err) {
839
+ reportSdkError(err);
840
+ }
841
+ }
842
+
843
+ // ─── delete ─────────────────────────────────────────────────────
844
+
845
+ /** One non-repo-resource read, `null` when absent (including a clean 404), an entry when present or genuinely unverifiable. */
846
+ async function checkResource(read, resourceName, countOf) {
847
+ try {
848
+ const result = await read();
849
+ const count = countOf(result);
850
+ return count > 0 ? { resource: resourceName, status: "present", count } : null;
851
+ } catch (err) {
852
+ if (err?.status === 404) return null; // genuinely absent, not a check failure
853
+ return { resource: resourceName, status: "unknown", reason: err?.message ?? String(err) };
854
+ }
855
+ }
856
+
857
+ /**
858
+ * D9's guard: a repo-only project has no materialized database schema, no
859
+ * functions, no secrets, no subdomains, no mailbox, no custom domains. Every
860
+ * read here uses the SAME service-key credential `projects.delete` itself
861
+ * requires, so a credential that would make `delete` fail also makes this
862
+ * guard fail the same way — never a silent pass on missing auth. A read
863
+ * that fails for a reason OTHER than "genuinely absent" (404) is reported
864
+ * `unknown` and REFUSES delete too — D9 never guesses its way to yes.
865
+ */
866
+ async function checkNonRepoResources(projectId) {
867
+ const refused = [];
868
+ try {
869
+ const detail = await getSdk().projects.get(projectId);
870
+ if (Array.isArray(detail.mailbox) && detail.mailbox.length > 0) refused.push({ resource: "mailbox", status: "present", count: detail.mailbox.length });
871
+ if (Array.isArray(detail.custom_domains) && detail.custom_domains.length > 0) refused.push({ resource: "custom_domains", status: "present", count: detail.custom_domains.length });
872
+ } catch (err) {
873
+ refused.push({ resource: "project_detail", status: "unknown", reason: err?.message ?? String(err) });
874
+ }
875
+ const schema = await checkResource(() => getSdk().projects.getSchema(projectId), "database_schema", (s) => (Array.isArray(s?.tables) ? s.tables.length : 0));
876
+ if (schema) refused.push(schema);
877
+ const functions = await checkResource(() => getSdk().functions.list(projectId), "functions", (r) => (Array.isArray(r?.functions) ? r.functions.length : 0));
878
+ if (functions) refused.push(functions);
879
+ const secrets = await checkResource(() => getSdk().secrets.list(projectId), "secrets", (r) => (Array.isArray(r?.secrets) ? r.secrets.length : 0));
880
+ if (secrets) refused.push(secrets);
881
+ const subdomains = await checkResource(() => getSdk().subdomains.list(projectId), "subdomains", (r) => (Array.isArray(r) ? r.length : 0));
882
+ if (subdomains) refused.push(subdomains);
883
+ return refused;
884
+ }
885
+
886
+ function stripFlag(args, flag) {
887
+ const idx = args.indexOf(flag);
888
+ if (idx === -1) return args;
889
+ const copy = [...args];
890
+ copy.splice(idx, 2);
891
+ return copy;
892
+ }
893
+
337
894
  async function del(args) {
338
895
  const a = normalizeArgv(args);
339
- assertKnownFlags(a, [...DELETE_VALUE_FLAGS, "--force", "--help", "-h"], DELETE_VALUE_FLAGS);
340
- const { projectId, rest } = resolveProjectSelector(a, { rejectBareFirst: true });
341
- requirePositionalCount(rest, [], { min: 0, max: 0, command: "run402 repos delete <project_id>", missing: "" });
896
+ assertKnownFlags(a, ["--project", "--repo", "--force", "--help", "-h"], ["--project", "--repo"]);
897
+ const repoFlag = flagValue(a, "--repo");
898
+ let projectId;
899
+ let rest;
900
+ if (repoFlag != null) {
901
+ if (flagValue(a, "--project") != null) {
902
+ fail({ code: "BAD_USAGE", message: "pass --repo or --project, not both." });
903
+ }
904
+ try {
905
+ projectId = (await getSdk().gitvault.get(repoFlag)).project_id;
906
+ } catch (err) {
907
+ reportSdkError(err);
908
+ return;
909
+ }
910
+ rest = stripFlag(a, "--repo");
911
+ } else {
912
+ ({ projectId, rest } = resolveProjectSelector(a, { rejectBareFirst: true }));
913
+ }
914
+ requirePositionalCount(rest.filter((x) => x !== "--force"), [], { min: 0, max: 0, command: "run402 repos delete [--project <id>] [--repo <repo_id>] [--force]", missing: "" });
342
915
  const force = a.includes("--force");
343
916
 
344
917
  let status;
@@ -352,21 +925,33 @@ async function del(args) {
352
925
  const admittedGenerations = vault ? Number(vault.admitted_generations ?? "0") : 0;
353
926
  const sourceBytes = vault ? Number(vault.storage?.source_bytes ?? "0") : 0;
354
927
 
928
+ // D9, checked FIRST and unconditionally: --force below overrides only the
929
+ // vault-history confirmation, never this refusal.
930
+ const refusedResources = await checkNonRepoResources(projectId);
931
+ if (refusedResources.length > 0) {
932
+ fail({
933
+ code: "PROJECT_HAS_NON_REPO_RESOURCES",
934
+ message: `project ${projectId} holds non-repo infrastructure; \`repos delete\` only destroys a repo-only project.`,
935
+ hint: "Use `run402 projects delete <project_id>` to destroy the whole project, including what is listed below. --force does NOT override this refusal.",
936
+ details: { project_id: projectId, refused_resources: refusedResources },
937
+ next_actions: [nextAction("edit_request", { command: `run402 projects delete ${projectId}`, why: "Destroys the whole project, including the non-repo resources listed above." })],
938
+ });
939
+ }
940
+
355
941
  if (vault && admittedGenerations > 0 && !force) {
356
942
  fail({
357
943
  code: "CONFIRMATION_REQUIRED",
358
944
  message:
359
- `vault ${status.repo_id} for project ${projectId} holds ${admittedGenerations} admitted generation(s) ` +
945
+ `repo ${status.repo_id} for project ${projectId} holds ${admittedGenerations} admitted generation(s) ` +
360
946
  `(${sourceBytes} bytes of encrypted source, genesis ${vault.genesis_admitted_at ?? "unknown"}) — ` +
361
- "deleting the project destroys its entire encrypted history irrecoverably, along with its database, " +
362
- "functions, subdomains, mailbox, and secrets. Re-run with --force to proceed.",
947
+ "deleting the project destroys its entire encrypted history irrecoverably. Re-run with --force to proceed.",
363
948
  details: {
364
949
  project_id: projectId,
365
950
  repo_id: status.repo_id,
366
951
  admitted_generations: admittedGenerations,
367
952
  source_bytes: sourceBytes,
368
953
  genesis_admitted_at: vault.genesis_admitted_at,
369
- destroys: ["vault_history", "schemas", "functions", "subdomains", "mailbox", "blobs", "secrets"],
954
+ destroys: ["vault_history"],
370
955
  },
371
956
  });
372
957
  }
@@ -376,6 +961,7 @@ async function del(args) {
376
961
  console.log(JSON.stringify({
377
962
  project_id: projectId,
378
963
  deleted: true,
964
+ deleted_resources: ["project", ...(vault ? ["vault_history"] : [])],
379
965
  vault: vault ? { repo_id: status.repo_id, admitted_generations: admittedGenerations, source_bytes: sourceBytes } : null,
380
966
  }, null, 2));
381
967
  } catch (err) {
@@ -383,41 +969,435 @@ async function del(args) {
383
969
  }
384
970
  }
385
971
 
972
+ // ─── snapshot ───────────────────────────────────────────────────────────────
973
+
974
+ const SNAPSHOT_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--message"];
975
+
386
976
  /**
387
- * `run402 repos name <name> [--project <id>]` the explicit address-form
388
- * claim (design D6, task 4.2): a project gets its per-org-unique `<name>`
389
- * half of `run402::<org-slug>/<name>` either at push-to-create time or here.
977
+ * When neither `--repo` nor `--project` was given explicitly, look at the
978
+ * local `run402`/`origin` remote and, if it is a SLUG-form address
979
+ * (`run402::<org-slug>/<name>`), return the parsed address so `snapshot`
980
+ * can push-to-create through it — the same address-form resolution
981
+ * `git push` drives via the remote helper.
390
982
  */
391
- async function name(args) {
983
+ async function detectSlugFormRemote(a, repoDir) {
984
+ if (flagValue(a, "--repo") != null || flagValue(a, "--project") != null) return null;
985
+ const { hardenedGit } = await import("#sdk/node");
986
+ const { parseGitvaultRemoteUrl, gitvaultRemoteAddressForm } = await import("#sdk");
987
+ for (const name of ["run402", "origin"]) {
988
+ let url;
989
+ try {
990
+ url = (await hardenedGit(repoDir, ["remote", "get-url", name])).text().trim();
991
+ } catch {
992
+ continue;
993
+ }
994
+ if (!url) continue;
995
+ const address = parseGitvaultRemoteUrl(url);
996
+ if (address && gitvaultRemoteAddressForm(address) === "slug") return address;
997
+ }
998
+ return null;
999
+ }
1000
+
1001
+ async function snapshot(args) {
1002
+ const a = normalizeArgv(args);
1003
+ assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
1004
+ requirePositionalCount(a, SNAPSHOT_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos snapshot", missing: "" });
1005
+ const dryRun = a.includes("--dry-run");
1006
+ const message = flagValue(a, "--message");
1007
+ const repoDir = process.cwd();
1008
+ const address = await detectSlugFormRemote(a, repoDir);
1009
+ const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
1010
+ const orgId = !address && !dryRun && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
1011
+ const opts = {
1012
+ ...target,
1013
+ ...(address ? { address } : {}),
1014
+ ...(orgId ? { org_id: orgId } : {}),
1015
+ onCommitLine: (line) => console.error(line),
1016
+ onVaultCreated: async (created) => {
1017
+ console.error("");
1018
+ console.error(`repo allocated (genesis ${created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
1019
+ console.error(JSON.stringify(created.recovery_receipt));
1020
+ await printKeystoreLocation();
1021
+ console.error("");
1022
+ },
1023
+ };
1024
+ if (message != null) opts.snapshot = { message };
1025
+ if (a.includes("--checkpoint")) opts.checkpoint = true;
1026
+ try {
1027
+ if (dryRun) {
1028
+ const plan = await getSdk().gitvault.planPush(opts);
1029
+ console.log(JSON.stringify(plan, null, 2));
1030
+ if (plan.allocation_needed) {
1031
+ console.error("dry-run: no repo allocated for this project yet — a real snapshot would allocate one first; object/byte sizing is not knowable until then");
1032
+ } else {
1033
+ console.error(
1034
+ `dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
1035
+ `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
1036
+ );
1037
+ }
1038
+ return;
1039
+ }
1040
+ const result = await getSdk().gitvault.push(opts);
1041
+ console.log(JSON.stringify(result, null, 2));
1042
+ console.error(`published generation ${result.generation} (${result.form})`);
1043
+ if (result.mirror_push?.outcome === "pushed") {
1044
+ console.error(`mirror: pushed generation ${result.generation} (${result.mirror_push.summary?.objects_copied ?? 0} object(s) copied)`);
1045
+ } else if (result.mirror_push?.outcome === "failed") {
1046
+ console.error(`mirror: dual-push FAILED (deploy is unaffected) — ${result.mirror_push.error ?? "see mirror_push.summary.errors"}`);
1047
+ }
1048
+ } catch (err) {
1049
+ reportSdkError(err);
1050
+ }
1051
+ }
1052
+
1053
+ // ─── policy ─────────────────────────────────────────────────────────────────
1054
+
1055
+ async function policy(args) {
392
1056
  const a = normalizeArgv(args);
393
- assertKnownFlags(a, [...NAME_VALUE_FLAGS, "--help", "-h"], NAME_VALUE_FLAGS);
394
- const [repoName] = requirePositionalCount(a, NAME_VALUE_FLAGS, {
395
- min: 1, max: 1, command: "run402 repos name <name> [--project <id>]", missing: "run402 repos name <name>: a name is required",
1057
+ const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
1058
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
1059
+ const [requested] = requirePositionalCount(a, valueFlags, {
1060
+ min: 1, max: 1, command: "run402 repos policy <required|grandfathered>",
1061
+ missing: "Missing <policy>. Expected `required` or `grandfathered`.",
396
1062
  });
397
- const projectId = resolveProjectId(flagValue(a, "--project"));
1063
+ if (requested !== "required" && requested !== "grandfathered") {
1064
+ fail({
1065
+ code: "BAD_USAGE",
1066
+ message: `Unknown policy: ${requested}.`,
1067
+ hint: "Expected `required` (a deploy must present a vaulted capture) or `grandfathered` (it need not).",
1068
+ details: { policy: requested, known_policies: ["required", "grandfathered"] },
1069
+ });
1070
+ }
1071
+ const reason = flagValue(a, "--reason");
1072
+ if (requested === "grandfathered" && (reason == null || reason.trim() === "")) {
1073
+ fail({
1074
+ code: "BAD_USAGE",
1075
+ message: "`grandfathered` needs --reason <why>.",
1076
+ 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\".",
1077
+ details: { policy: requested },
1078
+ });
1079
+ }
1080
+
1081
+ const target = await vaultTarget(a);
398
1082
  try {
399
- const result = await getSdk().projects.setRepoName(projectId, repoName);
400
- let address = null;
401
- try {
402
- const owningOrg = await resolveOwningOrgId(projectId);
403
- const orgSlug = owningOrg ? (await getSdk().org(owningOrg).get()).slug : null;
404
- if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
405
- } catch {
406
- // The claim itself already succeededa failed address-preview lookup is never fatal.
1083
+ const sdk = getSdk();
1084
+ const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
1085
+ const result = await sdk.gitvault.setPolicy(repoId, { gitvault_policy: requested, ...(reason != null ? { reason } : {}) });
1086
+ console.log(JSON.stringify({ repo_id: repoId, ...result }, null, 2));
1087
+ console.error(
1088
+ result.changed
1089
+ ? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
1090
+ : `gitvault_policy was already ${result.gitvault_policy}nothing changed`,
1091
+ );
1092
+ for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
1093
+ } catch (err) {
1094
+ reportSdkError(err);
1095
+ }
1096
+ }
1097
+
1098
+ // ─── mirror (ONE flag-driven verb) ─────────────────────────────
1099
+
1100
+ const MIRROR_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--profile", "--region", "--endpoint"];
1101
+
1102
+ async function mirrorRead(target) {
1103
+ try {
1104
+ const result = await getSdk().gitvault.mirrorStatus(target);
1105
+ console.log(JSON.stringify(result, null, 2));
1106
+ if (!result.configured) {
1107
+ console.error(`no mirror configured for ${result.repo_id}. Configure one: run402 repos mirror <destination>`);
1108
+ } else {
1109
+ const currency = result.is_current === true ? "current" : result.is_current === false ? `STALE — ${result.closing_command}` : "unknown (mirror unreachable or vault unread)";
1110
+ console.error(`mirror ${result.destination}: mirrored generation ${result.mirrored_generation ?? "(none)"}, vault newest ${result.newest_generation ?? "(none)"} — ${currency}`);
407
1111
  }
408
- console.log(JSON.stringify({ ...result, address }, null, 2));
1112
+ printMirrorHonesty(result);
1113
+ } catch (err) {
1114
+ reportSdkError(err);
1115
+ }
1116
+ }
1117
+
1118
+ async function mirrorSet(target, destination, a) {
1119
+ const credential = resolveMirrorCredential(a);
1120
+ const region = flagValue(a, "--region");
1121
+ const endpoint = flagValue(a, "--endpoint");
1122
+ try {
1123
+ const result = await getSdk().gitvault.mirrorSet({
1124
+ ...target,
1125
+ destination_url: destination,
1126
+ ...(credential ? { credential } : {}),
1127
+ ...(region != null ? { region } : {}),
1128
+ ...(endpoint != null ? { endpoint } : {}),
1129
+ });
1130
+ console.log(JSON.stringify(result, null, 2));
1131
+ console.error(`mirror configured for ${result.repo_id} -> ${formatMirrorDestination(result.destination)}`);
1132
+ console.error("run `run402 repos mirror --backfill` to catch it up now, then every publish dual-pushes automatically.");
1133
+ } catch (err) {
1134
+ reportSdkError(err);
1135
+ }
1136
+ }
1137
+
1138
+ async function mirrorOff(target) {
1139
+ try {
1140
+ const result = await getSdk().gitvault.mirrorRemove(target);
1141
+ console.log(JSON.stringify(result, null, 2));
409
1142
  console.error(
410
- result.previous_repo_name && result.previous_repo_name !== result.repo_name
411
- ? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
412
- : `name "${result.repo_name}" claimed for ${projectId}`,
1143
+ result.removed
1144
+ ? `mirror config removed for ${result.repo_id} the mirror's OWN bytes were not touched`
1145
+ : `no mirror was configured for ${result.repo_id} nothing to remove`,
413
1146
  );
414
- if (address) console.error(`address: ${address}`);
415
- else console.error("this org has no slug yet — claim one with `run402 org slug <slug>` to get a full run402::<slug>/<name> address");
416
1147
  } catch (err) {
417
1148
  reportSdkError(err);
418
1149
  }
419
1150
  }
420
1151
 
1152
+ async function mirrorBackfill(target) {
1153
+ try {
1154
+ const result = await getSdk().gitvault.mirrorSync(target);
1155
+ console.log(JSON.stringify(result, null, 2));
1156
+ await spillIfLarge(result.repo_id, "mirror-backfill", result);
1157
+ console.error(
1158
+ `mirror backfill for ${result.repo_id}: ${result.objects_copied} copied, ${result.objects_already_present} already present` +
1159
+ `${result.objects_skipped_foreign_recipient > 0 ? `, ${result.objects_skipped_foreign_recipient} skipped (envelopes for other recipients — expected)` : ""}` +
1160
+ `${result.objects_failed > 0 ? `, ${result.objects_failed} FAILED` : ""} (${result.bytes_copied} byte(s) copied this run).`,
1161
+ );
1162
+ for (const e of result.errors) console.error(` failed: ${e.key} — ${e.error}`);
1163
+ printMirrorHonesty(result);
1164
+ } catch (err) {
1165
+ reportSdkError(err);
1166
+ }
1167
+ }
1168
+
1169
+ async function mirror(args) {
1170
+ const a = normalizeArgv(args);
1171
+ assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "--help", "-h"], MIRROR_VALUE_FLAGS);
1172
+ const positionals = requirePositionalCount(a, MIRROR_VALUE_FLAGS, {
1173
+ min: 0, max: 1, command: "run402 repos mirror [<destination>]", missing: "",
1174
+ });
1175
+ const destination = positionals[0] ?? null;
1176
+ const off = a.includes("--off");
1177
+ const backfill = a.includes("--backfill");
1178
+ const modeCount = [destination != null, off, backfill].filter(Boolean).length;
1179
+ if (modeCount > 1) {
1180
+ fail({
1181
+ code: "BAD_USAGE",
1182
+ message: "pass at most one of: <destination>, --off, --backfill.",
1183
+ hint: "run402 repos mirror (read) | run402 repos mirror <destination> (configure) | run402 repos mirror --off (remove) | run402 repos mirror --backfill (catch up)",
1184
+ });
1185
+ }
1186
+ const target = await vaultTarget(a);
1187
+ if (destination != null) return mirrorSet(target, destination, a);
1188
+ if (off) return mirrorOff(target);
1189
+ if (backfill) return mirrorBackfill(target);
1190
+ return mirrorRead(target);
1191
+ }
1192
+
1193
+ // ─── fsck (verify the head chain + materialize refs) ──────────────────
1194
+
1195
+ async function fsck(args) {
1196
+ const a = normalizeArgv(args);
1197
+ const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
1198
+ assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--help", "-h"], valueFlags);
1199
+ requirePositionalCount(a, valueFlags, { min: 0, max: 0, command: "run402 repos fsck", missing: "" });
1200
+ const target = await vaultTarget(a);
1201
+ const budget = flagValue(a, "--budget");
1202
+ if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
1203
+ const write = !a.includes("--no-write");
1204
+ const mirrorRequested = a.includes("--mirror");
1205
+ try {
1206
+ const result = await getSdk().gitvault.fsck({ ...target, write, mirror: mirrorRequested });
1207
+ console.log(JSON.stringify(result, null, 2));
1208
+ await spillIfLarge(result.repo_id, "fsck", result);
1209
+ if (!write) {
1210
+ console.error(`--no-write: verified through generation ${result.verified_to_generation} — nothing local was persisted (pin_before === pin_after).`);
1211
+ } else if (result.local_state_changed) {
1212
+ console.error(`verified through generation ${result.verified_to_generation} — local pin advanced from ${result.pin_before.highest_authenticated ?? "genesis"} to ${result.pin_after.highest_authenticated}.`);
1213
+ } else {
1214
+ console.error(`verified through generation ${result.verified_to_generation} — already at the newest verified generation, nothing changed.`);
1215
+ }
1216
+ if (mirrorRequested && result.mirror) {
1217
+ console.error(`mirror: recoverable generation ${result.mirror.recovered_generation}${result.mirror.chain_break ? ` (chain break at ${result.mirror.chain_break.generation}: ${result.mirror.chain_break.reason})` : ""}.`);
1218
+ if (result.mirror.data_loss_detected) {
1219
+ console.error(`DATA LOSS DETECTED: ${result.mirror.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
1220
+ }
1221
+ printMirrorHonesty(result.mirror);
1222
+ }
1223
+ } catch (err) {
1224
+ reportSdkError(err);
1225
+ }
1226
+ }
1227
+
1228
+ // ─── gc (checkpoint + prune) ──────────────────────────────
1229
+
1230
+ const GC_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-receipt"];
1231
+
1232
+ async function gc(args) {
1233
+ const a = normalizeArgv(args);
1234
+ assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "--help", "-h"], GC_VALUE_FLAGS);
1235
+ requirePositionalCount(a, GC_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos gc", missing: "" });
1236
+ const submitting = a.includes("--submit");
1237
+ const corePath = flagValue(a, "--intent-core");
1238
+ const receiptPath = flagValue(a, "--verifier-receipt");
1239
+ if (submitting && (corePath == null || receiptPath == null)) {
1240
+ fail({
1241
+ code: "BAD_USAGE",
1242
+ message: "run402 repos gc --submit needs both --intent-core and --verifier-receipt.",
1243
+ hint: "Plan first (`run402 repos gc`), save prune.intent_core, run r402s-verify (prebuilt release binaries) against it, then submit both.",
1244
+ });
1245
+ }
1246
+ if (!submitting && (corePath != null || receiptPath != null)) {
1247
+ fail({ code: "BAD_USAGE", message: "--intent-core / --verifier-receipt only apply with --submit.", hint: "Add --submit, or drop the flags to plan." });
1248
+ }
1249
+ const target = await vaultTarget(a);
1250
+
1251
+ try {
1252
+ if (submitting) {
1253
+ const opts = { ...target, submit: { core: readJsonFile("--intent-core", corePath), verifier_receipt: readJsonFile("--verifier-receipt", receiptPath) } };
1254
+ if (a.includes("--wait")) opts.submit.wait = {};
1255
+ const prune = await getSdk().gitvault.prune(opts);
1256
+ const out = { phase: "submitted", prune };
1257
+ console.log(JSON.stringify(out, null, 2));
1258
+ if (prune.confirmation?.outcome) {
1259
+ console.error(
1260
+ `submitted — the signed completion reports ${prune.confirmation.deleted.length} deleted, ` +
1261
+ `${prune.confirmation.present.length} still present` +
1262
+ `${prune.confirmation.unadjudicated.length > 0 ? `, ${prune.confirmation.unadjudicated.length} unadjudicated` : ""}.`,
1263
+ );
1264
+ } else {
1265
+ 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.");
1266
+ }
1267
+ console.error(prune.note);
1268
+ return;
1269
+ }
1270
+
1271
+ const checkpoint = await getSdk().gitvault.compact(target);
1272
+ const prune = await getSdk().gitvault.prune(target);
1273
+ const nextActions = [];
1274
+ if (!prune.blocked_reason && prune.object_candidates.length > 0) {
1275
+ // Additive fields beyond the CLI's usual {type, command, why}: the
1276
+ // `gc` is never described as "exactly git gc," and its submit
1277
+ // next_action must say so structurally, not just in prose.
1278
+ nextActions.push({
1279
+ type: "submit_gc",
1280
+ command: "run402 repos gc --submit --intent-core <core.json> --verifier-receipt <receipt.json>",
1281
+ why: "Submit the signed prune intent after independent verification with r402s-verify.",
1282
+ safe_to_auto_execute: false,
1283
+ requires_approval: true,
1284
+ destructive: true,
1285
+ });
1286
+ }
1287
+ const out = { phase: "planned", checkpoint, prune, next_actions: nextActions };
1288
+ console.log(JSON.stringify(out, null, 2));
1289
+ console.error(`checkpoint published at generation ${checkpoint.generation}: ${checkpoint.covered_refs} ref(s), ${checkpoint.covered_roots} retention root(s).`);
1290
+ if (!checkpoint.cutoff_bound) {
1291
+ console.error("no retention-cutoff ticket was obtained, so roots were RETAINED — expiry is permissive. The checkpoint published, but no expired root left the map.");
1292
+ }
1293
+ if (prune.blocked_reason) {
1294
+ console.error(`prune: nothing to submit — ${prune.blocked_reason}`);
1295
+ } else {
1296
+ console.error(
1297
+ `prune: ${prune.object_candidates.length} object(s) proposed for deletion` +
1298
+ `${prune.deferred_object_count > 0 ? ` (${prune.deferred_object_count} more deferred to a later intent)` : ""}` +
1299
+ `; ${prune.eligible_count} retention root(s) past their window, ${prune.retained_count} retained.`,
1300
+ );
1301
+ if (prune.intent_core_sha256) {
1302
+ console.error(`intent_core_sha256: ${prune.intent_core_sha256} — run r402s-verify (ships as prebuilt release binaries) against this core, then re-run with --submit.`);
1303
+ }
1304
+ }
1305
+ console.error("`gc` is NOT \"exactly git gc\" — the deletion ceremony is stricter: nothing is removed until a control-plane-signed completion confirms it.");
1306
+ } catch (err) {
1307
+ reportSdkError(err);
1308
+ }
1309
+ }
1310
+
1311
+ // ─── access (read-only; repair gated) ──────────────────────
1312
+
1313
+ async function accessRead(args) {
1314
+ const a = normalizeArgv(args);
1315
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1316
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access", missing: "" });
1317
+ const target = await vaultTarget(a);
1318
+ try {
1319
+ const result = await getSdk().gitvault.access(target);
1320
+ console.log(JSON.stringify(result, null, 2));
1321
+ await spillIfLarge(result.repo_id, "access", result);
1322
+ console.error(`${result.recipients.length} directory recipient(s), ${result.recipients.filter((r) => r.covered).length} covered on this repo.`);
1323
+ if (result.this_keystore) {
1324
+ console.error(`1 covering fingerprint is this machine's own keystore (the vault's writing principal, not in the org directory): ${result.this_keystore.fingerprint}`);
1325
+ }
1326
+ if (result.unmatched_covered_fingerprints.length > 0) {
1327
+ console.error(`${result.unmatched_covered_fingerprints.length} covering fingerprint(s) match no directory entry or desired-state row (orphaned/external): ${result.unmatched_covered_fingerprints.join(", ")}`);
1328
+ }
1329
+ if (Array.isArray(result.stale_access) && result.stale_access.length > 0) {
1330
+ const names = result.stale_access.map((s) => s.display_name ?? s.principal_id).join(", ");
1331
+ console.error(`${result.stale_access.length} removed member(s) STILL decrypt this vault (not yet revocable — no epoch rotation in v0): ${names}`);
1332
+ }
1333
+ console.error(result.gap);
1334
+ } catch (err) {
1335
+ reportSdkError(err);
1336
+ }
1337
+ }
1338
+
1339
+ async function accessRepair(args) {
1340
+ const a = normalizeArgv(args);
1341
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
1342
+ requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access repair", missing: "" });
1343
+ fail({
1344
+ code: "ACCESS_REPAIR_NOT_AVAILABLE",
1345
+ message: "`run402 repos access repair` is not available yet — it is gated on gitvault-human-envelopes' real epoch-rotation work landing.",
1346
+ hint: "Use `run402 repos access` to see what the read surface reports today. Repair is a NAMED, deliberate action for genuine drift once the mechanism ships — never a routine workaround (the `reconcile` verb it replaces was removed for exactly that reason).",
1347
+ next_actions: [nextAction("access_repair_pending", { command: "run402 repos access", why: "See recipients, coverage, and this machine's own TOFU pins today; repair lands once epoch rotation ships." })],
1348
+ });
1349
+ }
1350
+
1351
+ async function access(args) {
1352
+ const a = normalizeArgv(args);
1353
+ if (a[0] === "repair") return accessRepair(a.slice(1));
1354
+ return accessRead(a);
1355
+ }
1356
+
1357
+ // ─── recover ─────────────────────
1358
+
1359
+ async function recover(args) {
1360
+ const a = normalizeArgv(args);
1361
+ const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
1362
+ assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
1363
+ const [source] = requirePositionalCount(a, valueFlags, {
1364
+ min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
1365
+ missing: "Missing <source>. Expected s3://<bucket>[/<prefix>] or a directory path.",
1366
+ });
1367
+ const outDir = flagValue(a, "--out");
1368
+ if (outDir == null) {
1369
+ fail({ code: "BAD_USAGE", message: "run402 repos recover needs --out <dir>.", hint: "Where to materialize the recovered repository, e.g. --out ./restored" });
1370
+ }
1371
+ const credential = resolveMirrorCredential(a);
1372
+ const repoId = flagValue(a, "--repo");
1373
+ const region = flagValue(a, "--region");
1374
+ const endpoint = flagValue(a, "--endpoint");
1375
+ try {
1376
+ const result = await getSdk().gitvault.recover({
1377
+ source, out_dir: outDir,
1378
+ ...(repoId != null ? { repo_id: repoId } : {}),
1379
+ ...(credential ? { credential } : {}),
1380
+ ...(region != null ? { region } : {}),
1381
+ ...(endpoint != null ? { endpoint } : {}),
1382
+ });
1383
+ console.log(JSON.stringify(result, null, 2));
1384
+ await spillIfLarge(result.repo_id, "recover", result);
1385
+ 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)` : "") + ".");
1386
+ if (result.data_loss_detected) {
1387
+ 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.`);
1388
+ }
1389
+ if (result.layout === "bare") {
1390
+ console.error(`layout: bare (no working files in ${outDir} — this is not a failed recovery)`);
1391
+ for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
1392
+ }
1393
+ printMirrorHonesty(result);
1394
+ } catch (err) {
1395
+ reportSdkError(err);
1396
+ }
1397
+ }
1398
+
1399
+ // ─── dispatch ───────────────────────────────────────────────────────────────
1400
+
421
1401
  export async function run(sub, args) {
422
1402
  const argv = Array.isArray(args) ? args : [];
423
1403
  if (!sub || hasHelp([sub, ...argv])) {
@@ -433,12 +1413,44 @@ export async function run(sub, args) {
433
1413
  await list(argv);
434
1414
  break;
435
1415
  }
1416
+ case "view": {
1417
+ await view(argv);
1418
+ break;
1419
+ }
1420
+ case "rename": {
1421
+ await rename(argv);
1422
+ break;
1423
+ }
436
1424
  case "delete": {
437
1425
  await del(argv);
438
1426
  break;
439
1427
  }
440
- case "name": {
441
- await name(argv);
1428
+ case "snapshot": {
1429
+ await snapshot(argv);
1430
+ break;
1431
+ }
1432
+ case "policy": {
1433
+ await policy(argv);
1434
+ break;
1435
+ }
1436
+ case "mirror": {
1437
+ await mirror(argv);
1438
+ break;
1439
+ }
1440
+ case "fsck": {
1441
+ await fsck(argv);
1442
+ break;
1443
+ }
1444
+ case "gc": {
1445
+ await gc(argv);
1446
+ break;
1447
+ }
1448
+ case "access": {
1449
+ await access(argv);
1450
+ break;
1451
+ }
1452
+ case "recover": {
1453
+ await recover(argv);
442
1454
  break;
443
1455
  }
444
1456
  default: