run402 4.40.0 → 4.41.0

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