run402 4.41.0 → 4.43.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/README.md +2 -2
- package/lib/argparse.mjs +1 -1
- package/lib/assets.mjs +10 -14
- package/lib/command-manifest.mjs +28 -27
- package/lib/deploy-v2.mjs +34 -14
- package/lib/doctor.mjs +8 -1
- package/lib/gitvault-target.mjs +7 -9
- package/lib/gitvault.mjs +7 -8
- package/lib/next-actions.mjs +5 -2
- package/lib/notifications.mjs +4 -8
- package/lib/operator.mjs +1 -1
- package/lib/org.mjs +3 -3
- package/lib/repos.mjs +536 -164
- package/lib/rooms-context.mjs +2 -3
- package/lib/rooms.mjs +11 -14
- package/lib/stats.mjs +57 -0
- package/lib/update-check.mjs +6 -8
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +10 -1
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +11 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/kernel.d.ts +31 -0
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +116 -3
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/admin.d.ts +1 -1
- package/sdk/dist/namespaces/admin.js +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +2 -2
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +188 -5
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +277 -8
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +239 -44
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +327 -49
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.js +2 -1
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
- package/sdk/dist/namespaces/projects.d.ts +1 -1
- package/sdk/dist/namespaces/projects.js +1 -1
- package/sdk/dist/namespaces/projects.types.d.ts +1 -1
- package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.d.ts +8 -0
- package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.js +1 -0
- package/sdk/dist/node/gitvault-apply.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
- package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-deploy.js +28 -0
- package/sdk/dist/node/gitvault-deploy.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +20 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +376 -21
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +567 -19
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-recover.d.ts +15 -1
- package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-recover.js +8 -4
- package/sdk/dist/node/gitvault-recover.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +67 -19
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
- package/sdk/dist/scoped.d.ts +2 -2
- package/sdk/dist/scoped.js +1 -1
package/lib/repos.mjs
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `run402 repos` — the consolidated encrypted-repository family
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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).
|
|
7
7
|
*
|
|
8
|
-
* ARCHITECTURAL LAW
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* protocol behavior of its own.
|
|
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.
|
|
16
15
|
*
|
|
17
16
|
* Pipe contract (docs/style.md): the payload is JSON on stdout; every human
|
|
18
17
|
* line (progress, the terminal-loss statement, advisories) goes to stderr,
|
|
19
18
|
* so `run402 repos view | jq` stays clean.
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* successor) or `COMMAND_REMOVED` (for `reconcile`, which has none) error.
|
|
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.
|
|
25
23
|
*/
|
|
26
24
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
25
|
import { basename, join } from "node:path";
|
|
@@ -35,6 +33,7 @@ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
|
|
|
35
33
|
import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
|
|
36
34
|
import { printKeystoreLocation } from "./gitvault.mjs";
|
|
37
35
|
import { gitvaultRemoteUrlForRepo } from "#sdk";
|
|
36
|
+
import { sdkStats, printVerboseStats } from "./stats.mjs";
|
|
38
37
|
import {
|
|
39
38
|
normalizeArgv,
|
|
40
39
|
hasHelp,
|
|
@@ -57,34 +56,39 @@ Usage:
|
|
|
57
56
|
Common:
|
|
58
57
|
run402 repos create [name] [--org <org_id>] [--dir <path>] [--tier <tier>] [--project <id>]
|
|
59
58
|
run402 repos view [--project <id>] [--repo <repo_id>] [--human]
|
|
60
|
-
run402 repos list [--org <org_id>]
|
|
59
|
+
run402 repos list [--org <org_id>] [--human]
|
|
61
60
|
|
|
62
61
|
Then plain git, forever:
|
|
63
62
|
git push
|
|
64
63
|
git clone run402::<org>/<repo>
|
|
65
64
|
|
|
66
65
|
Occasional:
|
|
67
|
-
run402 repos snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run]
|
|
66
|
+
run402 repos snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run] [--allow-dirty]
|
|
68
67
|
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>]
|
|
68
|
+
run402 repos recover <source> --out <dir> [--repo <repo_id>] [--profile <name> | --ambient] [--region <r>] [--endpoint <url>] [--human]
|
|
70
69
|
|
|
71
70
|
Lifecycle:
|
|
72
71
|
run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]
|
|
73
72
|
run402 repos delete [--project <id>] [--repo <repo_id>] [--force]
|
|
74
73
|
|
|
75
74
|
Maintenance:
|
|
76
|
-
run402 repos fsck [--project <id>] [--repo <repo_id>] [--mirror] [--budget <n>] [--no-write]
|
|
75
|
+
run402 repos fsck [--project <id>] [--repo <repo_id>] [--mirror] [--budget <n>] [--no-write] [--human]
|
|
77
76
|
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>]
|
|
77
|
+
run402 repos access [--project <id>] [--repo <repo_id>] [--human]
|
|
78
|
+
run402 repos access repair [--project <id>] [--repo <repo_id>] --recipient-state-version <n> --recipient-revocation-version <n>
|
|
79
|
+
run402 repos access revoke-key <principal_id> [--project <id>] [--repo <repo_id>]
|
|
80
|
+
run402 repos access declare-exposure [--project <id>] [--repo <repo_id>]
|
|
80
81
|
run402 repos policy <required|grandfathered> [--project <id>] [--repo <repo_id>] [--reason <why>]
|
|
81
82
|
|
|
83
|
+
Every verb above also accepts -v/--verbose (a stderr summary line of request
|
|
84
|
+
stats — round trips, wire time, bytes — coexists with --human) and always
|
|
85
|
+
carries a \`stats\` block in its JSON result.
|
|
86
|
+
|
|
82
87
|
Subcommands:
|
|
83
88
|
create Provision (or, with --project, ADOPT an existing project), ALLOCATE
|
|
84
89
|
its vault (mints key material and, on first allocation, a one-shot
|
|
85
90
|
recovery receipt), and scaffold the git remote — origin when free,
|
|
86
|
-
run402 when taken
|
|
87
|
-
\`--project <id>\` allocates for a project that already exists,
|
|
91
|
+
run402 when taken. \`--project <id>\` allocates for a project that already exists,
|
|
88
92
|
nothing is provisioned. \`[name]\` is inferred from an existing git
|
|
89
93
|
remote's basename or the directory name when unambiguous — NEVER a
|
|
90
94
|
prompt; if the directory and an existing remote disagree, or
|
|
@@ -96,7 +100,7 @@ Subcommands:
|
|
|
96
100
|
believe about the repo — allocation, policy, whether this keystore
|
|
97
101
|
can sign, the authenticated and materialized pins, the mirror
|
|
98
102
|
summary (when one is configured), and where the keystore lives.
|
|
99
|
-
NEVER materializes refs or advances any local pin
|
|
103
|
+
NEVER materializes refs or advances any local pin —
|
|
100
104
|
that belongs to \`fsck\`, which is why \`refs\` reports
|
|
101
105
|
{known:false, reason:"not_materialized"} with a next_action
|
|
102
106
|
pointing there. \`--human\` renders a short summary instead of JSON.
|
|
@@ -104,12 +108,13 @@ Subcommands:
|
|
|
104
108
|
vaults-by-org read when the gateway has it (one round trip);
|
|
105
109
|
gracefully falls back to the older per-project walk when it
|
|
106
110
|
404s. Not every project in the org — ones with no vault are
|
|
107
|
-
omitted.
|
|
111
|
+
omitted. \`--human\` renders a compact roster (address,
|
|
112
|
+
generation, bytes, policy) instead of JSON.
|
|
108
113
|
rename Claim or rename the repo's per-org-unique, address-form name
|
|
109
|
-
(the <name> half of run402::<org-slug>/<name>)
|
|
110
|
-
|
|
114
|
+
(the <name> half of run402::<org-slug>/<name>). Address by
|
|
115
|
+
--repo or --project (not both).
|
|
111
116
|
delete Deletes a REPO-ONLY project — database, functions, subdomains,
|
|
112
|
-
mailbox, and secrets must all be absent
|
|
117
|
+
mailbox, and secrets must all be absent. When any of
|
|
113
118
|
them is materialized, this REFUSES with
|
|
114
119
|
PROJECT_HAS_NON_REPO_RESOURCES, enumerates refused_resources, and
|
|
115
120
|
points at \`run402 projects delete\` — the verb whose name says
|
|
@@ -123,7 +128,13 @@ Subcommands:
|
|
|
123
128
|
publishing. Push-to-creates through a slug-form remote
|
|
124
129
|
(run402::<org-slug>/<name>) the same way \`git push\` does.
|
|
125
130
|
\`--dry-run\` previews the real local pipeline without publishing.
|
|
126
|
-
|
|
131
|
+
A DIRTY tree (modified/staged tracked paths, or untracked-not-
|
|
132
|
+
ignored paths) REFUSES by default (SNAPSHOT_DIRTY_TREE, before any
|
|
133
|
+
object is created) — commit and retry, or pass \`--allow-dirty\` to
|
|
134
|
+
capture it as-is; the result then discloses exactly what was
|
|
135
|
+
swept in (modified_captured / untracked_captured), printed to
|
|
136
|
+
stderr too. \`--dry-run\` surfaces the same refusal.
|
|
137
|
+
mirror ONE flag-driven verb for the client-side, customer-
|
|
127
138
|
owned ciphertext mirror — run402 never holds a credential to it.
|
|
128
139
|
No argument: READ the configured destination + a keyless
|
|
129
140
|
freshness check against the live vault. \`<destination>\`:
|
|
@@ -139,9 +150,9 @@ Subcommands:
|
|
|
139
150
|
path (normal retrieval is plain \`git clone run402::<org>/<repo>\`,
|
|
140
151
|
no \`repos clone\` verb exists). Proves this mirror's validity,
|
|
141
152
|
never freshness — read both honesty statements before relying on
|
|
142
|
-
the result.
|
|
143
|
-
fsck Walks the head chain
|
|
144
|
-
the ref map
|
|
153
|
+
the result. \`--human\` renders a short summary instead of JSON.
|
|
154
|
+
fsck Walks the head chain AND materializes
|
|
155
|
+
the ref map, advancing BOTH
|
|
145
156
|
local trust pins — reported EXPLICITLY as local_state_changed +
|
|
146
157
|
pin_before + pin_after, never implied. \`--no-write\` is a genuine
|
|
147
158
|
audit mode: the same real walk and decrypt, computing the same
|
|
@@ -150,6 +161,7 @@ Subcommands:
|
|
|
150
161
|
normal writing mode, so a budget-exceeded run resumes). \`--mirror\`
|
|
151
162
|
additionally runs the keyless mirror integrity probe — it proves
|
|
152
163
|
the mirror's VALIDITY, never its FRESHNESS, and says so.
|
|
164
|
+
\`--human\` renders a short summary instead of JSON.
|
|
153
165
|
gc \`git gc\`'s own two halves — checkpoint publication (compact) and
|
|
154
166
|
prune planning — in one verb, NOT described as "exactly git gc":
|
|
155
167
|
the deletion ceremony is stricter. Plans and checkpoints by
|
|
@@ -162,18 +174,41 @@ Subcommands:
|
|
|
162
174
|
safe_to_auto_execute:false as ADDITIVE fields.
|
|
163
175
|
access READ-ONLY: the org's directory of encryption-key-holding members,
|
|
164
176
|
which of the vault's current envelope-recipient fingerprints are
|
|
165
|
-
covered,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
177
|
+
covered, per-recipient envelope_state (converged/pending/
|
|
178
|
+
pending_removal, from the gateway's desired-recipient-state
|
|
179
|
+
substrate), and (best-effort, this machine only) each principal's
|
|
180
|
+
local TOFU pin. stale_access names removed members whose access
|
|
181
|
+
has NOT yet been rotated away — pending_removal is honest
|
|
182
|
+
bookkeeping, not enforcement, until \`access repair\`/\`revoke-key\`
|
|
183
|
+
actually rotates. history_scope (which epochs each recipient can
|
|
184
|
+
read) is not reported by this read — see the \`gap\` field.
|
|
185
|
+
\`--human\` renders a compact roster instead of JSON (the read
|
|
186
|
+
form only — repair/revoke-key/declare-exposure stay JSON-only).
|
|
170
187
|
access repair
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
Epoch rotation (D193-D203, rev 42) with reason:"elective_rekey" —
|
|
189
|
+
re-keys this vault's CURRENT epoch away from every stale_access
|
|
190
|
+
principal at once, and clears a pre-existing vault's one-time
|
|
191
|
+
migration requirement. \`reconcile\`, the workaround this
|
|
192
|
+
replaces, is REMOVED (it never wrapped a key correctly-scoped to
|
|
193
|
+
"from here forward" — this does). Needs
|
|
194
|
+
--recipient-state-version and --recipient-revocation-version:
|
|
195
|
+
the gateway exposes no read route for these two counters outside
|
|
196
|
+
\`revoke-key\`'s own response, so this verb needs them supplied
|
|
197
|
+
explicitly today — refuses cleanly, naming exactly this, when
|
|
198
|
+
omitted. Owner + step-up.
|
|
199
|
+
access revoke-key <principal_id>
|
|
200
|
+
The ONE fully self-contained rotation entry point: declares
|
|
201
|
+
reason:"recipient_key_revoked" for one principal and rotates off
|
|
202
|
+
that declaration's OWN returned counters — no flags needed.
|
|
203
|
+
Owner + step-up. The rekey remedy for "this specific principal's
|
|
204
|
+
key should no longer be trusted."
|
|
205
|
+
access declare-exposure
|
|
206
|
+
Declares reason:"epoch_secret_exposed" for THIS vault
|
|
207
|
+
(vault-scoped, not org-wide) — the rekey remedy for a leaked
|
|
208
|
+
K_repo/K_e. The declaration itself lands immediately; the
|
|
209
|
+
follow-up rotation it authorizes is not auto-run (same counter
|
|
210
|
+
gap as \`access repair\`) — this prints exactly what to do next.
|
|
211
|
+
Owner + step-up.
|
|
177
212
|
policy Set the activation policy — \`required\` (a deploy must present a
|
|
178
213
|
vaulted capture) or \`grandfathered\` (it need not). Owner +
|
|
179
214
|
step-up, audited. \`grandfathered\` is the documented way out of a
|
|
@@ -192,9 +227,17 @@ Options:
|
|
|
192
227
|
--idempotency-key <key>
|
|
193
228
|
create: re-running with the same key resolves to the
|
|
194
229
|
same project instead of creating a second one — new
|
|
195
|
-
projects only (default: derived from the name)
|
|
196
|
-
|
|
197
|
-
|
|
230
|
+
projects only (default: derived from the name).
|
|
231
|
+
access repair/revoke-key: the rotation attempt's OWN
|
|
232
|
+
client_idempotency_key (32-hex) — default: a fresh
|
|
233
|
+
CSPRNG value each call, never resumed across processes.
|
|
234
|
+
--recipient-state-version <n>
|
|
235
|
+
--recipient-revocation-version <n>
|
|
236
|
+
access repair: the D194 frozen watermark pair this
|
|
237
|
+
rotation attempt is fenced against. Required — see
|
|
238
|
+
\`run402 repos access repair --help\` for why.
|
|
239
|
+
--human view/list/access/fsck/recover: a short summary on stdout
|
|
240
|
+
instead of the JSON dump. Rejected together with --json.
|
|
198
241
|
--force delete: proceed even though the repo holds generations
|
|
199
242
|
that would be permanently and irrecoverably lost. Never
|
|
200
243
|
overrides the non-repo-infrastructure refusal.
|
|
@@ -202,7 +245,13 @@ Options:
|
|
|
202
245
|
tree produces (a clean tree pushes HEAD itself, unused)
|
|
203
246
|
--checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
|
|
204
247
|
--dry-run snapshot: a REAL preview — runs the actual local pipeline
|
|
205
|
-
and reports what would publish. Publishes nothing.
|
|
248
|
+
and reports what would publish. Publishes nothing. A
|
|
249
|
+
dirty tree still refuses SNAPSHOT_DIRTY_TREE here (a
|
|
250
|
+
preview that hid the refusal would lie).
|
|
251
|
+
--allow-dirty snapshot: capture a dirty tree as-is instead of refusing.
|
|
252
|
+
The result discloses exactly what was swept in
|
|
253
|
+
(modified_captured / untracked_captured) — even this
|
|
254
|
+
override never captures silently.
|
|
206
255
|
--off mirror: remove the configured destination (config only)
|
|
207
256
|
--backfill mirror: copy every object the configured mirror is missing
|
|
208
257
|
--profile <name> mirror / recover: the AWS credential profile name for an
|
|
@@ -231,6 +280,10 @@ Options:
|
|
|
231
280
|
signed completion appears, instead of returning immediately
|
|
232
281
|
--reason <why> policy: why the policy is changing — recorded in the
|
|
233
282
|
audit event. REQUIRED for \`grandfathered\`.
|
|
283
|
+
-v, --verbose Print one stderr summary line of this call's request
|
|
284
|
+
stats (round trips, wire time, bytes). Coexists with
|
|
285
|
+
--human. The JSON result always carries a \`stats\` block
|
|
286
|
+
regardless of this flag.
|
|
234
287
|
--json No-op: stdout is already JSON.
|
|
235
288
|
|
|
236
289
|
Terminal loss (protocol §0):
|
|
@@ -244,24 +297,24 @@ Examples:
|
|
|
244
297
|
run402 repos create --project prj_1a2b3c # allocate for an existing project
|
|
245
298
|
git push -u origin HEAD # the printed next_action, verbatim
|
|
246
299
|
run402 repos view --human
|
|
247
|
-
run402 repos list --org org_1a2b3c
|
|
300
|
+
run402 repos list --org org_1a2b3c --human
|
|
248
301
|
run402 repos rename my-notes --project prj_1a2b3c
|
|
249
302
|
run402 repos snapshot --dry-run
|
|
303
|
+
run402 repos snapshot --allow-dirty
|
|
250
304
|
run402 repos mirror s3://acme-vault-mirror --profile acme
|
|
251
305
|
run402 repos mirror --backfill
|
|
252
|
-
run402 repos fsck --mirror
|
|
306
|
+
run402 repos fsck --mirror --human
|
|
253
307
|
run402 repos gc
|
|
254
|
-
run402 repos access
|
|
255
|
-
run402 repos recover s3://acme-vault-mirror --out ./restored
|
|
308
|
+
run402 repos access --human
|
|
309
|
+
run402 repos recover s3://acme-vault-mirror --out ./restored --human
|
|
256
310
|
run402 repos delete --project prj_xyz --force
|
|
257
311
|
`;
|
|
258
312
|
|
|
259
|
-
// ─── shared targeting + printing
|
|
313
|
+
// ─── shared targeting + printing ────
|
|
260
314
|
|
|
261
315
|
/**
|
|
262
|
-
* Resolve which repo to act on, plus the local git tree.
|
|
263
|
-
*
|
|
264
|
-
* naming only): explicit `--repo`/`--project` > the repo's own pin/remote
|
|
316
|
+
* Resolve which repo to act on, plus the local git tree. Resolution order:
|
|
317
|
+
* explicit `--repo`/`--project` > the repo's own pin/remote
|
|
265
318
|
* > RUN402_PROJECT_ID > the active project.
|
|
266
319
|
*/
|
|
267
320
|
async function vaultTarget(a) {
|
|
@@ -282,15 +335,40 @@ async function vaultTarget(a) {
|
|
|
282
335
|
return target;
|
|
283
336
|
}
|
|
284
337
|
|
|
285
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* Print the protocol §0 terminal-loss statement, verbatim, from the SDK's own
|
|
340
|
+
* constants — never paraphrased here. The SDK's `status()` already downgrades
|
|
341
|
+
* this to the durability sentence when it has locally proven the vault has
|
|
342
|
+
* >= 2 covering recipients (dogfood item 2: the single-principal terminal-loss
|
|
343
|
+
* claim is false for that vault) — this function only renders whichever
|
|
344
|
+
* statement the SDK selected, it never chooses between them itself.
|
|
345
|
+
*/
|
|
286
346
|
function printTerminalLoss(status) {
|
|
287
347
|
console.error("");
|
|
288
|
-
|
|
289
|
-
|
|
348
|
+
if (status.terminal_loss_statement) {
|
|
349
|
+
console.error(status.terminal_loss_statement);
|
|
350
|
+
console.error(status.terminal_loss_detail);
|
|
351
|
+
} else if (status.durability_statement) {
|
|
352
|
+
console.error(status.durability_statement);
|
|
353
|
+
if (status.covering_recipients != null) console.error(`covering_recipients: ${status.covering_recipients}`);
|
|
354
|
+
}
|
|
290
355
|
console.error(`Back up this directory: ${status.keystore.root}`);
|
|
291
356
|
console.error("");
|
|
292
357
|
}
|
|
293
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Print a verb's JSON result with the always-on `stats` block (Observability:
|
|
361
|
+
* RUN402_TRACE + always-on stats + -v). `sdk.stats()` reflects only calls
|
|
362
|
+
* made through THIS `sdk` instance — every verb below resolves one `sdk =
|
|
363
|
+
* getSdk()` and reuses it for its own direct calls so the count is accurate
|
|
364
|
+
* for the work this function did; calls a shared cross-cutting helper
|
|
365
|
+
* (org/wallet resolution) makes through its own internal instance are not
|
|
366
|
+
* reflected (see `cli/lib/stats.mjs`'s doc comment).
|
|
367
|
+
*/
|
|
368
|
+
function printJson(sdk, payload) {
|
|
369
|
+
console.log(JSON.stringify({ ...payload, stats: sdkStats(sdk) }, null, 2));
|
|
370
|
+
}
|
|
371
|
+
|
|
294
372
|
const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
295
373
|
|
|
296
374
|
/**
|
|
@@ -299,8 +377,7 @@ const LARGE_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
|
299
377
|
* to a private 0600 file with a one-line stderr breadcrumb naming the path.
|
|
300
378
|
* Best-effort. Deliberately NEVER called on `create`'s result — that JSON
|
|
301
379
|
* 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).
|
|
380
|
+
* never spilled into any cache path (agent-response-design's secrets rule).
|
|
304
381
|
*/
|
|
305
382
|
async function spillIfLarge(repoId, verb, payload) {
|
|
306
383
|
const json = JSON.stringify(payload, null, 2);
|
|
@@ -400,7 +477,7 @@ async function formatRepoHuman(s, mirror) {
|
|
|
400
477
|
return lines.join("\n");
|
|
401
478
|
}
|
|
402
479
|
|
|
403
|
-
// ─── name inference
|
|
480
|
+
// ─── name inference ──────────────────
|
|
404
481
|
|
|
405
482
|
function validateProjectName(name) {
|
|
406
483
|
if (name === "") {
|
|
@@ -416,8 +493,8 @@ function validateProjectName(name) {
|
|
|
416
493
|
}
|
|
417
494
|
|
|
418
495
|
/**
|
|
419
|
-
* Best-effort slugify for the address-form repo name (
|
|
420
|
-
* lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars).
|
|
496
|
+
* Best-effort slugify for the address-form repo name (the address-form
|
|
497
|
+
* grammar: lowercase [a-z0-9-], no leading/trailing/double hyphen, <=63 chars).
|
|
421
498
|
*/
|
|
422
499
|
function slugifyRepoName(name) {
|
|
423
500
|
return name
|
|
@@ -458,7 +535,7 @@ function dirBasenameCandidate(dir) {
|
|
|
458
535
|
}
|
|
459
536
|
|
|
460
537
|
/**
|
|
461
|
-
* `repos create [name]`'s inference
|
|
538
|
+
* `repos create [name]`'s inference: the directory or an
|
|
462
539
|
* existing git remote's basename, when unambiguous. NEVER a prompt —
|
|
463
540
|
* ambiguity (the two candidates disagree) or a dead end (neither yields a
|
|
464
541
|
* usable slug) is a structured error naming exactly one next_action.
|
|
@@ -494,17 +571,17 @@ async function inferRepoName(dir) {
|
|
|
494
571
|
|
|
495
572
|
const CREATE_VALUE_FLAGS = ["--org", "--dir", "--tier", "--idempotency-key", "--project"];
|
|
496
573
|
|
|
497
|
-
async function printCreateResult({ projectId, vault, adopted, name }) {
|
|
574
|
+
async function printCreateResult({ sdk, projectId, vault, adopted, name, verboseArgv }) {
|
|
498
575
|
let address = null;
|
|
499
576
|
let orgSlug = null;
|
|
500
577
|
try {
|
|
501
578
|
const owningOrg = await resolveOwningOrgId(projectId);
|
|
502
|
-
const orgRecord = owningOrg ? await
|
|
579
|
+
const orgRecord = owningOrg ? await sdk.org(owningOrg).get() : null;
|
|
503
580
|
orgSlug = orgRecord?.slug ?? null;
|
|
504
581
|
if (orgSlug && name) {
|
|
505
582
|
const candidate = slugifyRepoName(name);
|
|
506
583
|
if (candidate) {
|
|
507
|
-
const named = await
|
|
584
|
+
const named = await sdk.projects.setRepoName(projectId, candidate);
|
|
508
585
|
address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
|
|
509
586
|
}
|
|
510
587
|
}
|
|
@@ -533,13 +610,13 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
|
|
|
533
610
|
deployed: false,
|
|
534
611
|
next_actions: nextActions,
|
|
535
612
|
};
|
|
536
|
-
|
|
613
|
+
printJson(sdk, out);
|
|
537
614
|
console.error(
|
|
538
615
|
`project ${projectId} ${adopted ? "adopted" : "provisioned"}; repo ${vault.repo_id} ` +
|
|
539
616
|
(vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
|
|
540
617
|
);
|
|
541
618
|
if (address) console.error(`address: ${address}`);
|
|
542
|
-
else if (!orgSlug) console.error("no named address yet — claim an org slug (run402 org slug <slug
|
|
619
|
+
else if (!orgSlug) console.error("no named address yet — claim an org slug (run402 org slug <slug>) to get run402::<slug>/<name> addresses");
|
|
543
620
|
else console.error(`no address claimed — run 'run402 repos rename <name> --project ${projectId}' to claim one`);
|
|
544
621
|
if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
|
|
545
622
|
if (pushAction) console.error(`next: ${pushAction.command}`);
|
|
@@ -548,9 +625,11 @@ async function printCreateResult({ projectId, vault, adopted, name }) {
|
|
|
548
625
|
await printKeystoreLocation();
|
|
549
626
|
console.error("");
|
|
550
627
|
console.error("nothing was deployed — this is a vault-only repo. Deploy later with `run402 deploy apply`, or never.");
|
|
628
|
+
printVerboseStats(verboseArgv, sdk);
|
|
551
629
|
}
|
|
552
630
|
|
|
553
631
|
async function createAdopt(projectId, dir, a) {
|
|
632
|
+
const sdk = getSdk();
|
|
554
633
|
const orgId = flagValue(a, "--org") ?? await resolveOwningOrgId(projectId);
|
|
555
634
|
if (!orgId) {
|
|
556
635
|
fail({
|
|
@@ -561,14 +640,15 @@ async function createAdopt(projectId, dir, a) {
|
|
|
561
640
|
});
|
|
562
641
|
}
|
|
563
642
|
try {
|
|
564
|
-
const vault = await
|
|
565
|
-
await printCreateResult({ projectId, vault, adopted: true, name: null });
|
|
643
|
+
const vault = await sdk.gitvault.init({ org_id: orgId, project_id: projectId, repo_dir: dir });
|
|
644
|
+
await printCreateResult({ sdk, projectId, vault, adopted: true, name: null, verboseArgv: a });
|
|
566
645
|
} catch (err) {
|
|
567
646
|
reportSdkError(err);
|
|
568
647
|
}
|
|
569
648
|
}
|
|
570
649
|
|
|
571
650
|
async function createProvision(name, dir, a) {
|
|
651
|
+
const sdk = getSdk();
|
|
572
652
|
const tier = flagValue(a, "--tier") ?? "prototype";
|
|
573
653
|
const idempotencyKey = flagValue(a, "--idempotency-key") ?? `repos-create:${name}`;
|
|
574
654
|
// `optional: true` — a fresh wallet with no org yet is the cold-start path
|
|
@@ -580,7 +660,7 @@ async function createProvision(name, dir, a) {
|
|
|
580
660
|
let provisioned;
|
|
581
661
|
try {
|
|
582
662
|
provisioned = await withAutoApprove(() =>
|
|
583
|
-
|
|
663
|
+
sdk.projects.provision({ tier, name, ...(orgId ? { orgId } : {}), idempotencyKey }),
|
|
584
664
|
);
|
|
585
665
|
} catch (err) {
|
|
586
666
|
reportSdkError(err);
|
|
@@ -599,8 +679,8 @@ async function createProvision(name, dir, a) {
|
|
|
599
679
|
}
|
|
600
680
|
|
|
601
681
|
try {
|
|
602
|
-
const vault = await
|
|
603
|
-
await printCreateResult({ projectId: provisioned.project_id, vault, adopted: false, name });
|
|
682
|
+
const vault = await sdk.gitvault.init({ org_id: effectiveOrgId, project_id: provisioned.project_id, repo_dir: dir });
|
|
683
|
+
await printCreateResult({ sdk, projectId: provisioned.project_id, vault, adopted: false, name, verboseArgv: a });
|
|
604
684
|
} catch (err) {
|
|
605
685
|
reportSdkError(err);
|
|
606
686
|
}
|
|
@@ -608,7 +688,7 @@ async function createProvision(name, dir, a) {
|
|
|
608
688
|
|
|
609
689
|
async function create(args) {
|
|
610
690
|
const a = normalizeArgv(args);
|
|
611
|
-
assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h"], CREATE_VALUE_FLAGS);
|
|
691
|
+
assertKnownFlags(a, [...CREATE_VALUE_FLAGS, "--help", "-h", "-v", "--verbose"], CREATE_VALUE_FLAGS);
|
|
612
692
|
const positionals = requirePositionalCount(a, CREATE_VALUE_FLAGS, {
|
|
613
693
|
min: 0, max: 1, command: "run402 repos create [name]", missing: "",
|
|
614
694
|
});
|
|
@@ -642,8 +722,8 @@ async function create(args) {
|
|
|
642
722
|
// ─── list ───────────────────────────────────────────────────────────────────
|
|
643
723
|
|
|
644
724
|
/** The FROZEN bulk-read shape (task 2.4) — one round trip. */
|
|
645
|
-
async function listViaBulkRead(orgId) {
|
|
646
|
-
const result = await
|
|
725
|
+
async function listViaBulkRead(sdk, orgId) {
|
|
726
|
+
const result = await sdk.gitvault.listByOrg(orgId);
|
|
647
727
|
return Array.isArray(result.vaults) ? result.vaults : [];
|
|
648
728
|
}
|
|
649
729
|
|
|
@@ -654,14 +734,14 @@ async function listViaBulkRead(orgId) {
|
|
|
654
734
|
* function once the bulk route has shipped long enough that no gateway
|
|
655
735
|
* still 404s it.
|
|
656
736
|
*/
|
|
657
|
-
async function listViaFallback(orgId) {
|
|
658
|
-
const result = await
|
|
737
|
+
async function listViaFallback(sdk, orgId) {
|
|
738
|
+
const result = await sdk.projects.list({ org: orgId });
|
|
659
739
|
const projects = Array.isArray(result.projects) ? result.projects : [];
|
|
660
740
|
const repos = [];
|
|
661
741
|
for (const p of projects) {
|
|
662
742
|
let status;
|
|
663
743
|
try {
|
|
664
|
-
status = await
|
|
744
|
+
status = await sdk.gitvault.status({ project_id: p.id });
|
|
665
745
|
} catch {
|
|
666
746
|
continue;
|
|
667
747
|
}
|
|
@@ -682,21 +762,38 @@ async function listViaFallback(orgId) {
|
|
|
682
762
|
return repos;
|
|
683
763
|
}
|
|
684
764
|
|
|
765
|
+
/** `repos list --human`: a compact roster — one line per repo (address, generation, bytes, policy). */
|
|
766
|
+
async function formatRepoListHuman(orgSlug, repos) {
|
|
767
|
+
if (repos.length === 0) return "(no vault-bearing repos in this organization)";
|
|
768
|
+
const { generationToBigInt } = await import("#sdk/node");
|
|
769
|
+
const decimal = (g) => (g ? generationToBigInt(g).toString() : "none");
|
|
770
|
+
const lines = repos.map((r) => {
|
|
771
|
+
const address = orgSlug && r.repo_name ? `run402::${orgSlug}/${r.repo_name}` : (r.repo_name ?? r.project_id);
|
|
772
|
+
return `${address} gen=${decimal(r.newest_generation)} ${r.source_bytes} byte(s) policy=${r.gitvault_policy ?? "(none)"} (${r.repo_id})`;
|
|
773
|
+
});
|
|
774
|
+
return lines.join("\n");
|
|
775
|
+
}
|
|
776
|
+
|
|
685
777
|
async function list(args) {
|
|
686
778
|
const a = normalizeArgv(args);
|
|
687
|
-
assertKnownFlags(a, ["--org", "--help", "-h"], ["--org"]);
|
|
779
|
+
assertKnownFlags(a, ["--org", "--human", "-v", "--verbose", "--help", "-h"], ["--org"]);
|
|
688
780
|
requirePositionalCount(a, ["--org"], { min: 0, max: 0, command: "run402 repos list", missing: "" });
|
|
781
|
+
const human = a.includes("--human");
|
|
782
|
+
if (human && a.includes("--json")) {
|
|
783
|
+
fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
|
|
784
|
+
}
|
|
785
|
+
const sdk = getSdk();
|
|
689
786
|
const orgId = await resolveOrgId(a, { cmd: "repos" });
|
|
690
787
|
|
|
691
788
|
let repos;
|
|
692
789
|
let usedFallback = false;
|
|
693
790
|
try {
|
|
694
|
-
repos = await listViaBulkRead(orgId);
|
|
791
|
+
repos = await listViaBulkRead(sdk, orgId);
|
|
695
792
|
} catch (err) {
|
|
696
793
|
if (err?.status === 404) {
|
|
697
794
|
usedFallback = true;
|
|
698
795
|
try {
|
|
699
|
-
repos = await listViaFallback(orgId);
|
|
796
|
+
repos = await listViaFallback(sdk, orgId);
|
|
700
797
|
} catch (fallbackErr) {
|
|
701
798
|
reportSdkError(fallbackErr);
|
|
702
799
|
return;
|
|
@@ -710,42 +807,50 @@ async function list(args) {
|
|
|
710
807
|
let orgSlug = repos.find((r) => r.org_slug)?.org_slug ?? null;
|
|
711
808
|
if (orgSlug == null) {
|
|
712
809
|
try {
|
|
713
|
-
orgSlug = (await
|
|
810
|
+
orgSlug = (await sdk.org(orgId).get()).slug;
|
|
714
811
|
} catch {
|
|
715
812
|
// best-effort — `list` must not fail over an org-slug lookup
|
|
716
813
|
}
|
|
717
814
|
}
|
|
718
815
|
|
|
719
|
-
|
|
816
|
+
if (human) {
|
|
817
|
+
console.log(await formatRepoListHuman(orgSlug, repos));
|
|
818
|
+
printVerboseStats(a, sdk);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
printJson(sdk, { org_id: orgId, org_slug: orgSlug, repos });
|
|
720
822
|
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)" : ""}`);
|
|
721
823
|
if (orgSlug) console.error(`org slug: ${orgSlug} — a repo with a claimed address-form name is reachable at run402::${orgSlug}/<name>`);
|
|
824
|
+
printVerboseStats(a, sdk);
|
|
722
825
|
}
|
|
723
826
|
|
|
724
827
|
// ─── view ───────────────────────────────────────────────────────────────────
|
|
725
828
|
|
|
726
829
|
async function view(args) {
|
|
727
830
|
const a = normalizeArgv(args);
|
|
728
|
-
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
831
|
+
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
729
832
|
requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos view", missing: "" });
|
|
730
833
|
const human = a.includes("--human");
|
|
731
834
|
if (human && a.includes("--json")) {
|
|
732
835
|
fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
|
|
733
836
|
}
|
|
837
|
+
const sdk = getSdk();
|
|
734
838
|
const target = await vaultTarget(a);
|
|
735
839
|
try {
|
|
736
840
|
// Design D3: `view` NEVER passes `refs: true` — it is side-effect-free
|
|
737
841
|
// by construction, not by convention. Materialization belongs to `fsck`.
|
|
738
|
-
const s = await
|
|
842
|
+
const s = await sdk.gitvault.status(target);
|
|
739
843
|
let mirror = null;
|
|
740
844
|
if (s.repo_id) {
|
|
741
845
|
try {
|
|
742
|
-
mirror = await
|
|
846
|
+
mirror = await sdk.gitvault.mirrorStatus({ ...target, repo_id: s.repo_id });
|
|
743
847
|
} catch {
|
|
744
848
|
// best-effort — a mirror read failure never fails `view`
|
|
745
849
|
}
|
|
746
850
|
}
|
|
747
851
|
if (human) {
|
|
748
852
|
console.log(await formatRepoHuman(s, mirror));
|
|
853
|
+
printVerboseStats(a, sdk);
|
|
749
854
|
return;
|
|
750
855
|
}
|
|
751
856
|
const verifyRefsAction = nextAction("verify_refs", { command: "run402 repos fsck", why: "Walk the signed chain and materialize verified refs." });
|
|
@@ -756,7 +861,7 @@ async function view(args) {
|
|
|
756
861
|
mirror,
|
|
757
862
|
next_actions: combinedNextActions,
|
|
758
863
|
};
|
|
759
|
-
|
|
864
|
+
printJson(sdk, out);
|
|
760
865
|
printTerminalLoss(s);
|
|
761
866
|
if (s.remote) {
|
|
762
867
|
const suffix =
|
|
@@ -774,6 +879,7 @@ async function view(args) {
|
|
|
774
879
|
}
|
|
775
880
|
for (const w of s.warnings) console.error(`warning (${w.kind}): ${w.message}`);
|
|
776
881
|
for (const n of combinedNextActions) console.error(`next: ${n.why ?? n.action ?? n.type}${n.command ? ` — ${n.command}` : ""}`);
|
|
882
|
+
printVerboseStats(a, sdk);
|
|
777
883
|
} catch (err) {
|
|
778
884
|
reportSdkError(err);
|
|
779
885
|
}
|
|
@@ -783,11 +889,12 @@ async function view(args) {
|
|
|
783
889
|
|
|
784
890
|
async function rename(args) {
|
|
785
891
|
const a = normalizeArgv(args);
|
|
786
|
-
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
892
|
+
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
787
893
|
const [repoName] = requirePositionalCount(a, COMMON_VALUE_FLAGS, {
|
|
788
894
|
min: 1, max: 1, command: "run402 repos rename <new_name> [--repo <repo_id> | --project <project_id>]",
|
|
789
895
|
missing: "run402 repos rename <new_name>: a new name is required",
|
|
790
896
|
});
|
|
897
|
+
const sdk = getSdk();
|
|
791
898
|
const repoFlag = flagValue(a, "--repo");
|
|
792
899
|
const projectFlag = flagValue(a, "--project");
|
|
793
900
|
if (repoFlag != null && projectFlag != null) {
|
|
@@ -796,7 +903,7 @@ async function rename(args) {
|
|
|
796
903
|
let projectId;
|
|
797
904
|
if (repoFlag != null) {
|
|
798
905
|
try {
|
|
799
|
-
projectId = (await
|
|
906
|
+
projectId = (await sdk.gitvault.get(repoFlag)).project_id;
|
|
800
907
|
} catch (err) {
|
|
801
908
|
reportSdkError(err);
|
|
802
909
|
return;
|
|
@@ -805,16 +912,16 @@ async function rename(args) {
|
|
|
805
912
|
projectId = resolveProjectId(projectFlag);
|
|
806
913
|
}
|
|
807
914
|
try {
|
|
808
|
-
const result = await
|
|
915
|
+
const result = await sdk.projects.setRepoName(projectId, repoName);
|
|
809
916
|
let address = null;
|
|
810
917
|
try {
|
|
811
918
|
const owningOrg = await resolveOwningOrgId(projectId);
|
|
812
|
-
const orgSlug = owningOrg ? (await
|
|
919
|
+
const orgSlug = owningOrg ? (await sdk.org(owningOrg).get()).slug : null;
|
|
813
920
|
if (orgSlug) address = gitvaultRemoteUrlForRepo(orgSlug, result.repo_name);
|
|
814
921
|
} catch {
|
|
815
922
|
// The claim itself already succeeded — a failed address-preview lookup is never fatal.
|
|
816
923
|
}
|
|
817
|
-
|
|
924
|
+
printJson(sdk, { ...result, address });
|
|
818
925
|
console.error(
|
|
819
926
|
result.previous_repo_name && result.previous_repo_name !== result.repo_name
|
|
820
927
|
? `renamed from "${result.previous_repo_name}" to "${result.repo_name}"`
|
|
@@ -822,12 +929,13 @@ async function rename(args) {
|
|
|
822
929
|
);
|
|
823
930
|
if (address) console.error(`address: ${address}`);
|
|
824
931
|
else console.error("this org has no slug yet — claim one with `run402 org slug <slug>` to get a full run402::<slug>/<name> address");
|
|
932
|
+
printVerboseStats(a, sdk);
|
|
825
933
|
} catch (err) {
|
|
826
934
|
reportSdkError(err);
|
|
827
935
|
}
|
|
828
936
|
}
|
|
829
937
|
|
|
830
|
-
// ─── delete
|
|
938
|
+
// ─── delete ─────────────────────────────────────────────────────
|
|
831
939
|
|
|
832
940
|
/** One non-repo-resource read, `null` when absent (including a clean 404), an entry when present or genuinely unverifiable. */
|
|
833
941
|
async function checkResource(read, resourceName, countOf) {
|
|
@@ -850,22 +958,22 @@ async function checkResource(read, resourceName, countOf) {
|
|
|
850
958
|
* that fails for a reason OTHER than "genuinely absent" (404) is reported
|
|
851
959
|
* `unknown` and REFUSES delete too — D9 never guesses its way to yes.
|
|
852
960
|
*/
|
|
853
|
-
async function checkNonRepoResources(projectId) {
|
|
961
|
+
async function checkNonRepoResources(sdk, projectId) {
|
|
854
962
|
const refused = [];
|
|
855
963
|
try {
|
|
856
|
-
const detail = await
|
|
964
|
+
const detail = await sdk.projects.get(projectId);
|
|
857
965
|
if (Array.isArray(detail.mailbox) && detail.mailbox.length > 0) refused.push({ resource: "mailbox", status: "present", count: detail.mailbox.length });
|
|
858
966
|
if (Array.isArray(detail.custom_domains) && detail.custom_domains.length > 0) refused.push({ resource: "custom_domains", status: "present", count: detail.custom_domains.length });
|
|
859
967
|
} catch (err) {
|
|
860
968
|
refused.push({ resource: "project_detail", status: "unknown", reason: err?.message ?? String(err) });
|
|
861
969
|
}
|
|
862
|
-
const schema = await checkResource(() =>
|
|
970
|
+
const schema = await checkResource(() => sdk.projects.getSchema(projectId), "database_schema", (s) => (Array.isArray(s?.tables) ? s.tables.length : 0));
|
|
863
971
|
if (schema) refused.push(schema);
|
|
864
|
-
const functions = await checkResource(() =>
|
|
972
|
+
const functions = await checkResource(() => sdk.functions.list(projectId), "functions", (r) => (Array.isArray(r?.functions) ? r.functions.length : 0));
|
|
865
973
|
if (functions) refused.push(functions);
|
|
866
|
-
const secrets = await checkResource(() =>
|
|
974
|
+
const secrets = await checkResource(() => sdk.secrets.list(projectId), "secrets", (r) => (Array.isArray(r?.secrets) ? r.secrets.length : 0));
|
|
867
975
|
if (secrets) refused.push(secrets);
|
|
868
|
-
const subdomains = await checkResource(() =>
|
|
976
|
+
const subdomains = await checkResource(() => sdk.subdomains.list(projectId), "subdomains", (r) => (Array.isArray(r) ? r.length : 0));
|
|
869
977
|
if (subdomains) refused.push(subdomains);
|
|
870
978
|
return refused;
|
|
871
979
|
}
|
|
@@ -880,7 +988,8 @@ function stripFlag(args, flag) {
|
|
|
880
988
|
|
|
881
989
|
async function del(args) {
|
|
882
990
|
const a = normalizeArgv(args);
|
|
883
|
-
assertKnownFlags(a, ["--project", "--repo", "--force", "--help", "-h"], ["--project", "--repo"]);
|
|
991
|
+
assertKnownFlags(a, ["--project", "--repo", "--force", "-v", "--verbose", "--help", "-h"], ["--project", "--repo"]);
|
|
992
|
+
const sdk = getSdk();
|
|
884
993
|
const repoFlag = flagValue(a, "--repo");
|
|
885
994
|
let projectId;
|
|
886
995
|
let rest;
|
|
@@ -889,7 +998,7 @@ async function del(args) {
|
|
|
889
998
|
fail({ code: "BAD_USAGE", message: "pass --repo or --project, not both." });
|
|
890
999
|
}
|
|
891
1000
|
try {
|
|
892
|
-
projectId = (await
|
|
1001
|
+
projectId = (await sdk.gitvault.get(repoFlag)).project_id;
|
|
893
1002
|
} catch (err) {
|
|
894
1003
|
reportSdkError(err);
|
|
895
1004
|
return;
|
|
@@ -903,7 +1012,7 @@ async function del(args) {
|
|
|
903
1012
|
|
|
904
1013
|
let status;
|
|
905
1014
|
try {
|
|
906
|
-
status = await
|
|
1015
|
+
status = await sdk.gitvault.status({ project_id: projectId });
|
|
907
1016
|
} catch (err) {
|
|
908
1017
|
reportSdkError(err);
|
|
909
1018
|
return;
|
|
@@ -914,7 +1023,7 @@ async function del(args) {
|
|
|
914
1023
|
|
|
915
1024
|
// D9, checked FIRST and unconditionally: --force below overrides only the
|
|
916
1025
|
// vault-history confirmation, never this refusal.
|
|
917
|
-
const refusedResources = await checkNonRepoResources(projectId);
|
|
1026
|
+
const refusedResources = await checkNonRepoResources(sdk, projectId);
|
|
918
1027
|
if (refusedResources.length > 0) {
|
|
919
1028
|
fail({
|
|
920
1029
|
code: "PROJECT_HAS_NON_REPO_RESOURCES",
|
|
@@ -944,13 +1053,14 @@ async function del(args) {
|
|
|
944
1053
|
}
|
|
945
1054
|
|
|
946
1055
|
try {
|
|
947
|
-
await
|
|
948
|
-
|
|
1056
|
+
await sdk.projects.delete(projectId);
|
|
1057
|
+
printJson(sdk, {
|
|
949
1058
|
project_id: projectId,
|
|
950
1059
|
deleted: true,
|
|
951
1060
|
deleted_resources: ["project", ...(vault ? ["vault_history"] : [])],
|
|
952
1061
|
vault: vault ? { repo_id: status.repo_id, admitted_generations: admittedGenerations, source_bytes: sourceBytes } : null,
|
|
953
|
-
}
|
|
1062
|
+
});
|
|
1063
|
+
printVerboseStats(a, sdk);
|
|
954
1064
|
} catch (err) {
|
|
955
1065
|
reportSdkError(err);
|
|
956
1066
|
}
|
|
@@ -985,12 +1095,26 @@ async function detectSlugFormRemote(a, repoDir) {
|
|
|
985
1095
|
return null;
|
|
986
1096
|
}
|
|
987
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* Dirty-tree disclosure (help people not make mistakes): even an explicit
|
|
1100
|
+
* `--allow-dirty` override never captures silently — every modified/staged
|
|
1101
|
+
* tracked path and every untracked-not-ignored path that got swept into the
|
|
1102
|
+
* capture is named on stderr, one per line.
|
|
1103
|
+
*/
|
|
1104
|
+
function printDirtyDisclosure(snapshot) {
|
|
1105
|
+
if (!snapshot) return;
|
|
1106
|
+
for (const p of snapshot.modified_captured ?? []) console.error(`captured (modified): ${p}`);
|
|
1107
|
+
for (const p of snapshot.untracked_captured ?? []) console.error(`captured (untracked): ${p}`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
988
1110
|
async function snapshot(args) {
|
|
989
1111
|
const a = normalizeArgv(args);
|
|
990
|
-
assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
|
|
1112
|
+
assertKnownFlags(a, [...SNAPSHOT_VALUE_FLAGS, "--checkpoint", "--dry-run", "--allow-dirty", "-v", "--verbose", "--help", "-h"], SNAPSHOT_VALUE_FLAGS);
|
|
991
1113
|
requirePositionalCount(a, SNAPSHOT_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos snapshot", missing: "" });
|
|
1114
|
+
const sdk = getSdk();
|
|
992
1115
|
const dryRun = a.includes("--dry-run");
|
|
993
1116
|
const message = flagValue(a, "--message");
|
|
1117
|
+
const allowDirty = a.includes("--allow-dirty");
|
|
994
1118
|
const repoDir = process.cwd();
|
|
995
1119
|
const address = await detectSlugFormRemote(a, repoDir);
|
|
996
1120
|
const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
|
|
@@ -1008,12 +1132,15 @@ async function snapshot(args) {
|
|
|
1008
1132
|
console.error("");
|
|
1009
1133
|
},
|
|
1010
1134
|
};
|
|
1011
|
-
|
|
1135
|
+
const snapshotOpts = {};
|
|
1136
|
+
if (message != null) snapshotOpts.message = message;
|
|
1137
|
+
if (allowDirty) snapshotOpts.allowDirty = true;
|
|
1138
|
+
if (Object.keys(snapshotOpts).length > 0) opts.snapshot = snapshotOpts;
|
|
1012
1139
|
if (a.includes("--checkpoint")) opts.checkpoint = true;
|
|
1013
1140
|
try {
|
|
1014
1141
|
if (dryRun) {
|
|
1015
|
-
const plan = await
|
|
1016
|
-
|
|
1142
|
+
const plan = await sdk.gitvault.planPush(opts);
|
|
1143
|
+
printJson(sdk, plan);
|
|
1017
1144
|
if (plan.allocation_needed) {
|
|
1018
1145
|
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
1146
|
} else {
|
|
@@ -1022,16 +1149,20 @@ async function snapshot(args) {
|
|
|
1022
1149
|
`${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
|
|
1023
1150
|
);
|
|
1024
1151
|
}
|
|
1152
|
+
printDirtyDisclosure(plan.snapshot);
|
|
1153
|
+
printVerboseStats(a, sdk);
|
|
1025
1154
|
return;
|
|
1026
1155
|
}
|
|
1027
|
-
const result = await
|
|
1028
|
-
|
|
1156
|
+
const result = await sdk.gitvault.push(opts);
|
|
1157
|
+
printJson(sdk, result);
|
|
1029
1158
|
console.error(`published generation ${result.generation} (${result.form})`);
|
|
1030
1159
|
if (result.mirror_push?.outcome === "pushed") {
|
|
1031
1160
|
console.error(`mirror: pushed generation ${result.generation} (${result.mirror_push.summary?.objects_copied ?? 0} object(s) copied)`);
|
|
1032
1161
|
} else if (result.mirror_push?.outcome === "failed") {
|
|
1033
1162
|
console.error(`mirror: dual-push FAILED (deploy is unaffected) — ${result.mirror_push.error ?? "see mirror_push.summary.errors"}`);
|
|
1034
1163
|
}
|
|
1164
|
+
printDirtyDisclosure(result.snapshot);
|
|
1165
|
+
printVerboseStats(a, sdk);
|
|
1035
1166
|
} catch (err) {
|
|
1036
1167
|
reportSdkError(err);
|
|
1037
1168
|
}
|
|
@@ -1042,7 +1173,7 @@ async function snapshot(args) {
|
|
|
1042
1173
|
async function policy(args) {
|
|
1043
1174
|
const a = normalizeArgv(args);
|
|
1044
1175
|
const valueFlags = [...COMMON_VALUE_FLAGS, "--reason"];
|
|
1045
|
-
assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
|
|
1176
|
+
assertKnownFlags(a, [...valueFlags, "-v", "--verbose", "--help", "-h"], valueFlags);
|
|
1046
1177
|
const [requested] = requirePositionalCount(a, valueFlags, {
|
|
1047
1178
|
min: 1, max: 1, command: "run402 repos policy <required|grandfathered>",
|
|
1048
1179
|
missing: "Missing <policy>. Expected `required` or `grandfathered`.",
|
|
@@ -1070,26 +1201,28 @@ async function policy(args) {
|
|
|
1070
1201
|
const sdk = getSdk();
|
|
1071
1202
|
const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
|
|
1072
1203
|
const result = await sdk.gitvault.setPolicy(repoId, { gitvault_policy: requested, ...(reason != null ? { reason } : {}) });
|
|
1073
|
-
|
|
1204
|
+
printJson(sdk, { repo_id: repoId, ...result });
|
|
1074
1205
|
console.error(
|
|
1075
1206
|
result.changed
|
|
1076
1207
|
? `gitvault_policy is now ${result.gitvault_policy} (version ${result.gitvault_policy_version})`
|
|
1077
1208
|
: `gitvault_policy was already ${result.gitvault_policy} — nothing changed`,
|
|
1078
1209
|
);
|
|
1079
1210
|
for (const w of result.warnings ?? []) console.error(`warning (${w.kind}): ${w.message}`);
|
|
1211
|
+
printVerboseStats(a, sdk);
|
|
1080
1212
|
} catch (err) {
|
|
1081
1213
|
reportSdkError(err);
|
|
1082
1214
|
}
|
|
1083
1215
|
}
|
|
1084
1216
|
|
|
1085
|
-
// ─── mirror (
|
|
1217
|
+
// ─── mirror (ONE flag-driven verb) ─────────────────────────────
|
|
1086
1218
|
|
|
1087
1219
|
const MIRROR_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--profile", "--region", "--endpoint"];
|
|
1088
1220
|
|
|
1089
|
-
async function mirrorRead(target) {
|
|
1221
|
+
async function mirrorRead(target, a) {
|
|
1222
|
+
const sdk = getSdk();
|
|
1090
1223
|
try {
|
|
1091
|
-
const result = await
|
|
1092
|
-
|
|
1224
|
+
const result = await sdk.gitvault.mirrorStatus(target);
|
|
1225
|
+
printJson(sdk, result);
|
|
1093
1226
|
if (!result.configured) {
|
|
1094
1227
|
console.error(`no mirror configured for ${result.repo_id}. Configure one: run402 repos mirror <destination>`);
|
|
1095
1228
|
} else {
|
|
@@ -1097,49 +1230,55 @@ async function mirrorRead(target) {
|
|
|
1097
1230
|
console.error(`mirror ${result.destination}: mirrored generation ${result.mirrored_generation ?? "(none)"}, vault newest ${result.newest_generation ?? "(none)"} — ${currency}`);
|
|
1098
1231
|
}
|
|
1099
1232
|
printMirrorHonesty(result);
|
|
1233
|
+
printVerboseStats(a, sdk);
|
|
1100
1234
|
} catch (err) {
|
|
1101
1235
|
reportSdkError(err);
|
|
1102
1236
|
}
|
|
1103
1237
|
}
|
|
1104
1238
|
|
|
1105
1239
|
async function mirrorSet(target, destination, a) {
|
|
1240
|
+
const sdk = getSdk();
|
|
1106
1241
|
const credential = resolveMirrorCredential(a);
|
|
1107
1242
|
const region = flagValue(a, "--region");
|
|
1108
1243
|
const endpoint = flagValue(a, "--endpoint");
|
|
1109
1244
|
try {
|
|
1110
|
-
const result = await
|
|
1245
|
+
const result = await sdk.gitvault.mirrorSet({
|
|
1111
1246
|
...target,
|
|
1112
1247
|
destination_url: destination,
|
|
1113
1248
|
...(credential ? { credential } : {}),
|
|
1114
1249
|
...(region != null ? { region } : {}),
|
|
1115
1250
|
...(endpoint != null ? { endpoint } : {}),
|
|
1116
1251
|
});
|
|
1117
|
-
|
|
1252
|
+
printJson(sdk, result);
|
|
1118
1253
|
console.error(`mirror configured for ${result.repo_id} -> ${formatMirrorDestination(result.destination)}`);
|
|
1119
1254
|
console.error("run `run402 repos mirror --backfill` to catch it up now, then every publish dual-pushes automatically.");
|
|
1255
|
+
printVerboseStats(a, sdk);
|
|
1120
1256
|
} catch (err) {
|
|
1121
1257
|
reportSdkError(err);
|
|
1122
1258
|
}
|
|
1123
1259
|
}
|
|
1124
1260
|
|
|
1125
|
-
async function mirrorOff(target) {
|
|
1261
|
+
async function mirrorOff(target, a) {
|
|
1262
|
+
const sdk = getSdk();
|
|
1126
1263
|
try {
|
|
1127
|
-
const result = await
|
|
1128
|
-
|
|
1264
|
+
const result = await sdk.gitvault.mirrorRemove(target);
|
|
1265
|
+
printJson(sdk, result);
|
|
1129
1266
|
console.error(
|
|
1130
1267
|
result.removed
|
|
1131
1268
|
? `mirror config removed for ${result.repo_id} — the mirror's OWN bytes were not touched`
|
|
1132
1269
|
: `no mirror was configured for ${result.repo_id} — nothing to remove`,
|
|
1133
1270
|
);
|
|
1271
|
+
printVerboseStats(a, sdk);
|
|
1134
1272
|
} catch (err) {
|
|
1135
1273
|
reportSdkError(err);
|
|
1136
1274
|
}
|
|
1137
1275
|
}
|
|
1138
1276
|
|
|
1139
|
-
async function mirrorBackfill(target) {
|
|
1277
|
+
async function mirrorBackfill(target, a) {
|
|
1278
|
+
const sdk = getSdk();
|
|
1140
1279
|
try {
|
|
1141
|
-
const result = await
|
|
1142
|
-
|
|
1280
|
+
const result = await sdk.gitvault.mirrorSync(target);
|
|
1281
|
+
printJson(sdk, result);
|
|
1143
1282
|
await spillIfLarge(result.repo_id, "mirror-backfill", result);
|
|
1144
1283
|
console.error(
|
|
1145
1284
|
`mirror backfill for ${result.repo_id}: ${result.objects_copied} copied, ${result.objects_already_present} already present` +
|
|
@@ -1148,6 +1287,7 @@ async function mirrorBackfill(target) {
|
|
|
1148
1287
|
);
|
|
1149
1288
|
for (const e of result.errors) console.error(` failed: ${e.key} — ${e.error}`);
|
|
1150
1289
|
printMirrorHonesty(result);
|
|
1290
|
+
printVerboseStats(a, sdk);
|
|
1151
1291
|
} catch (err) {
|
|
1152
1292
|
reportSdkError(err);
|
|
1153
1293
|
}
|
|
@@ -1155,7 +1295,7 @@ async function mirrorBackfill(target) {
|
|
|
1155
1295
|
|
|
1156
1296
|
async function mirror(args) {
|
|
1157
1297
|
const a = normalizeArgv(args);
|
|
1158
|
-
assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "--help", "-h"], MIRROR_VALUE_FLAGS);
|
|
1298
|
+
assertKnownFlags(a, [...MIRROR_VALUE_FLAGS, "--off", "--backfill", "--ambient", "-v", "--verbose", "--help", "-h"], MIRROR_VALUE_FLAGS);
|
|
1159
1299
|
const positionals = requirePositionalCount(a, MIRROR_VALUE_FLAGS, {
|
|
1160
1300
|
min: 0, max: 1, command: "run402 repos mirror [<destination>]", missing: "",
|
|
1161
1301
|
});
|
|
@@ -1172,26 +1312,57 @@ async function mirror(args) {
|
|
|
1172
1312
|
}
|
|
1173
1313
|
const target = await vaultTarget(a);
|
|
1174
1314
|
if (destination != null) return mirrorSet(target, destination, a);
|
|
1175
|
-
if (off) return mirrorOff(target);
|
|
1176
|
-
if (backfill) return mirrorBackfill(target);
|
|
1177
|
-
return mirrorRead(target);
|
|
1315
|
+
if (off) return mirrorOff(target, a);
|
|
1316
|
+
if (backfill) return mirrorBackfill(target, a);
|
|
1317
|
+
return mirrorRead(target, a);
|
|
1178
1318
|
}
|
|
1179
1319
|
|
|
1180
|
-
// ─── fsck (
|
|
1320
|
+
// ─── fsck (verify the head chain + materialize refs) ──────────────────
|
|
1321
|
+
|
|
1322
|
+
/** `repos fsck --human`: the same verdict the stderr lines already carry, condensed into one block. */
|
|
1323
|
+
function formatFsckHuman(result, mirrorRequested) {
|
|
1324
|
+
const lines = [`Repo: ${result.repo_id}`];
|
|
1325
|
+
lines.push(
|
|
1326
|
+
!result.write
|
|
1327
|
+
? `Verified through generation ${result.verified_to_generation} — audit mode, nothing local was persisted.`
|
|
1328
|
+
: result.local_state_changed
|
|
1329
|
+
? `Verified through generation ${result.verified_to_generation} — local pin advanced from ${result.pin_before.highest_authenticated ?? "genesis"} to ${result.pin_after.highest_authenticated}.`
|
|
1330
|
+
: `Verified through generation ${result.verified_to_generation} — already at the newest verified generation.`,
|
|
1331
|
+
);
|
|
1332
|
+
if (mirrorRequested && result.mirror) {
|
|
1333
|
+
lines.push(
|
|
1334
|
+
`Mirror: recoverable generation ${result.mirror.recovered_generation}` +
|
|
1335
|
+
(result.mirror.chain_break ? ` (chain break at ${result.mirror.chain_break.generation}: ${result.mirror.chain_break.reason})` : "") +
|
|
1336
|
+
(result.mirror.data_loss_detected ? ` — DATA LOSS DETECTED (${result.mirror.absences.filter((x) => x.adjudication === "unexplained_absence").length} unexplained absence(s))` : ""),
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
return lines.join("\n");
|
|
1340
|
+
}
|
|
1181
1341
|
|
|
1182
1342
|
async function fsck(args) {
|
|
1183
1343
|
const a = normalizeArgv(args);
|
|
1184
1344
|
const valueFlags = [...COMMON_VALUE_FLAGS, "--budget"];
|
|
1185
|
-
assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--help", "-h"], valueFlags);
|
|
1345
|
+
assertKnownFlags(a, [...valueFlags, "--mirror", "--no-write", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
|
|
1186
1346
|
requirePositionalCount(a, valueFlags, { min: 0, max: 0, command: "run402 repos fsck", missing: "" });
|
|
1347
|
+
const human = a.includes("--human");
|
|
1348
|
+
if (human && a.includes("--json")) {
|
|
1349
|
+
fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
|
|
1350
|
+
}
|
|
1351
|
+
const sdk = getSdk();
|
|
1187
1352
|
const target = await vaultTarget(a);
|
|
1188
1353
|
const budget = flagValue(a, "--budget");
|
|
1189
1354
|
if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
|
|
1190
1355
|
const write = !a.includes("--no-write");
|
|
1191
1356
|
const mirrorRequested = a.includes("--mirror");
|
|
1192
1357
|
try {
|
|
1193
|
-
const result = await
|
|
1194
|
-
|
|
1358
|
+
const result = await sdk.gitvault.fsck({ ...target, write, mirror: mirrorRequested });
|
|
1359
|
+
if (human) {
|
|
1360
|
+
console.log(formatFsckHuman(result, mirrorRequested));
|
|
1361
|
+
if (mirrorRequested && result.mirror) printMirrorHonesty(result.mirror);
|
|
1362
|
+
printVerboseStats(a, sdk);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
printJson(sdk, result);
|
|
1195
1366
|
await spillIfLarge(result.repo_id, "fsck", result);
|
|
1196
1367
|
if (!write) {
|
|
1197
1368
|
console.error(`--no-write: verified through generation ${result.verified_to_generation} — nothing local was persisted (pin_before === pin_after).`);
|
|
@@ -1207,19 +1378,21 @@ async function fsck(args) {
|
|
|
1207
1378
|
}
|
|
1208
1379
|
printMirrorHonesty(result.mirror);
|
|
1209
1380
|
}
|
|
1381
|
+
printVerboseStats(a, sdk);
|
|
1210
1382
|
} catch (err) {
|
|
1211
1383
|
reportSdkError(err);
|
|
1212
1384
|
}
|
|
1213
1385
|
}
|
|
1214
1386
|
|
|
1215
|
-
// ─── gc (
|
|
1387
|
+
// ─── gc (checkpoint + prune) ──────────────────────────────
|
|
1216
1388
|
|
|
1217
1389
|
const GC_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--intent-core", "--verifier-receipt"];
|
|
1218
1390
|
|
|
1219
1391
|
async function gc(args) {
|
|
1220
1392
|
const a = normalizeArgv(args);
|
|
1221
|
-
assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "--help", "-h"], GC_VALUE_FLAGS);
|
|
1393
|
+
assertKnownFlags(a, [...GC_VALUE_FLAGS, "--submit", "--wait", "-v", "--verbose", "--help", "-h"], GC_VALUE_FLAGS);
|
|
1222
1394
|
requirePositionalCount(a, GC_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos gc", missing: "" });
|
|
1395
|
+
const sdk = getSdk();
|
|
1223
1396
|
const submitting = a.includes("--submit");
|
|
1224
1397
|
const corePath = flagValue(a, "--intent-core");
|
|
1225
1398
|
const receiptPath = flagValue(a, "--verifier-receipt");
|
|
@@ -1239,9 +1412,9 @@ async function gc(args) {
|
|
|
1239
1412
|
if (submitting) {
|
|
1240
1413
|
const opts = { ...target, submit: { core: readJsonFile("--intent-core", corePath), verifier_receipt: readJsonFile("--verifier-receipt", receiptPath) } };
|
|
1241
1414
|
if (a.includes("--wait")) opts.submit.wait = {};
|
|
1242
|
-
const prune = await
|
|
1415
|
+
const prune = await sdk.gitvault.prune(opts);
|
|
1243
1416
|
const out = { phase: "submitted", prune };
|
|
1244
|
-
|
|
1417
|
+
printJson(sdk, out);
|
|
1245
1418
|
if (prune.confirmation?.outcome) {
|
|
1246
1419
|
console.error(
|
|
1247
1420
|
`submitted — the signed completion reports ${prune.confirmation.deleted.length} deleted, ` +
|
|
@@ -1252,17 +1425,17 @@ async function gc(args) {
|
|
|
1252
1425
|
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
1426
|
}
|
|
1254
1427
|
console.error(prune.note);
|
|
1428
|
+
printVerboseStats(a, sdk);
|
|
1255
1429
|
return;
|
|
1256
1430
|
}
|
|
1257
1431
|
|
|
1258
|
-
const checkpoint = await
|
|
1259
|
-
const prune = await
|
|
1432
|
+
const checkpoint = await sdk.gitvault.compact(target);
|
|
1433
|
+
const prune = await sdk.gitvault.prune(target);
|
|
1260
1434
|
const nextActions = [];
|
|
1261
1435
|
if (!prune.blocked_reason && prune.object_candidates.length > 0) {
|
|
1262
1436
|
// Additive fields beyond the CLI's usual {type, command, why}: the
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
// so structurally, not just in prose.
|
|
1437
|
+
// `gc` is never described as "exactly git gc," and its submit
|
|
1438
|
+
// next_action must say so structurally, not just in prose.
|
|
1266
1439
|
nextActions.push({
|
|
1267
1440
|
type: "submit_gc",
|
|
1268
1441
|
command: "run402 repos gc --submit --intent-core <core.json> --verifier-receipt <receipt.json>",
|
|
@@ -1273,7 +1446,7 @@ async function gc(args) {
|
|
|
1273
1446
|
});
|
|
1274
1447
|
}
|
|
1275
1448
|
const out = { phase: "planned", checkpoint, prune, next_actions: nextActions };
|
|
1276
|
-
|
|
1449
|
+
printJson(sdk, out);
|
|
1277
1450
|
console.error(`checkpoint published at generation ${checkpoint.generation}: ${checkpoint.covered_refs} ref(s), ${checkpoint.covered_roots} retention root(s).`);
|
|
1278
1451
|
if (!checkpoint.cutoff_bound) {
|
|
1279
1452
|
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.");
|
|
@@ -1291,56 +1464,238 @@ async function gc(args) {
|
|
|
1291
1464
|
}
|
|
1292
1465
|
}
|
|
1293
1466
|
console.error("`gc` is NOT \"exactly git gc\" — the deletion ceremony is stricter: nothing is removed until a control-plane-signed completion confirms it.");
|
|
1467
|
+
printVerboseStats(a, sdk);
|
|
1294
1468
|
} catch (err) {
|
|
1295
1469
|
reportSdkError(err);
|
|
1296
1470
|
}
|
|
1297
1471
|
}
|
|
1298
1472
|
|
|
1299
|
-
// ─── access (
|
|
1473
|
+
// ─── access (read-only; repair gated) ──────────────────────
|
|
1474
|
+
|
|
1475
|
+
/** `repos access --human`: a compact roster of directory recipients and their coverage. */
|
|
1476
|
+
function formatAccessHuman(result) {
|
|
1477
|
+
const lines = [`Repo: ${result.repo_id}`];
|
|
1478
|
+
lines.push(`Recipients: ${result.recipients.length} directory, ${result.recipients.filter((r) => r.covered).length} covered`);
|
|
1479
|
+
for (const r of result.recipients) {
|
|
1480
|
+
lines.push(` ${r.covered ? "covered" : "NOT covered"} ${r.display_name ?? r.principal_id}${r.envelope_state ? ` (${r.envelope_state})` : ""}`);
|
|
1481
|
+
}
|
|
1482
|
+
if (result.this_keystore) lines.push(`This machine's own keystore also covers (writing principal, not in org directory): ${result.this_keystore.fingerprint}`);
|
|
1483
|
+
if (result.unmatched_covered_fingerprints.length > 0) lines.push(`Orphaned/external coverage: ${result.unmatched_covered_fingerprints.join(", ")}`);
|
|
1484
|
+
if (Array.isArray(result.stale_access) && result.stale_access.length > 0) {
|
|
1485
|
+
lines.push(`Stale access (removed members that still decrypt): ${result.stale_access.map((s) => s.display_name ?? s.principal_id).join(", ")}`);
|
|
1486
|
+
}
|
|
1487
|
+
lines.push(result.gap);
|
|
1488
|
+
return lines.join("\n");
|
|
1489
|
+
}
|
|
1300
1490
|
|
|
1301
1491
|
async function accessRead(args) {
|
|
1302
1492
|
const a = normalizeArgv(args);
|
|
1303
|
-
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
1493
|
+
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--human", "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
1304
1494
|
requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access", missing: "" });
|
|
1495
|
+
const human = a.includes("--human");
|
|
1496
|
+
if (human && a.includes("--json")) {
|
|
1497
|
+
fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
|
|
1498
|
+
}
|
|
1499
|
+
const sdk = getSdk();
|
|
1305
1500
|
const target = await vaultTarget(a);
|
|
1306
1501
|
try {
|
|
1307
|
-
const result = await
|
|
1308
|
-
|
|
1502
|
+
const result = await sdk.gitvault.access(target);
|
|
1503
|
+
if (human) {
|
|
1504
|
+
console.log(formatAccessHuman(result));
|
|
1505
|
+
printVerboseStats(a, sdk);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
printJson(sdk, result);
|
|
1309
1509
|
await spillIfLarge(result.repo_id, "access", result);
|
|
1310
1510
|
console.error(`${result.recipients.length} directory recipient(s), ${result.recipients.filter((r) => r.covered).length} covered on this repo.`);
|
|
1511
|
+
if (result.this_keystore) {
|
|
1512
|
+
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}`);
|
|
1513
|
+
}
|
|
1311
1514
|
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(", ")}`);
|
|
1515
|
+
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(", ")}`);
|
|
1516
|
+
}
|
|
1517
|
+
if (Array.isArray(result.stale_access) && result.stale_access.length > 0) {
|
|
1518
|
+
const names = result.stale_access.map((s) => s.display_name ?? s.principal_id).join(", ");
|
|
1519
|
+
console.error(`${result.stale_access.length} removed member(s) STILL decrypt this vault (not yet revocable — no epoch rotation in v0): ${names}`);
|
|
1313
1520
|
}
|
|
1314
1521
|
console.error(result.gap);
|
|
1522
|
+
printVerboseStats(a, sdk);
|
|
1315
1523
|
} catch (err) {
|
|
1316
1524
|
reportSdkError(err);
|
|
1317
1525
|
}
|
|
1318
1526
|
}
|
|
1319
1527
|
|
|
1528
|
+
const ROTATION_VALUE_FLAGS = [...COMMON_VALUE_FLAGS, "--recipient-state-version", "--recipient-revocation-version", "--idempotency-key"];
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* `run402 repos access repair` (D193-D203, rev 42) — a general re-key of
|
|
1532
|
+
* this vault's CURRENT epoch, dropping every principal in `stale_access`
|
|
1533
|
+
* (`pending_removal`, still covered) and clearing a pre-existing vault's
|
|
1534
|
+
* one-time migration requirement. Drives `rotateEpoch({reason:"elective_rekey"})`.
|
|
1535
|
+
*
|
|
1536
|
+
* `--recipient-state-version`/`--recipient-revocation-version` are the D194
|
|
1537
|
+
* frozen watermarks this attempt must be fenced against. They are NOT
|
|
1538
|
+
* discovered automatically here: the live gateway exposes NO general read
|
|
1539
|
+
* route for `internal.gitvault_recipient_state_counters` outside the
|
|
1540
|
+
* `key-revocation` declare route's own response (see
|
|
1541
|
+
* `GitvaultVault.rotateEpoch`'s doc comment, `sdk/src/node/gitvault-
|
|
1542
|
+
* publication.ts`, for the confirmed source-level finding). Until that
|
|
1543
|
+
* route ships, this verb needs the pair supplied explicitly — refusing
|
|
1544
|
+
* cleanly and naming exactly this when they are omitted, rather than
|
|
1545
|
+
* guessing and either failing opaquely or (worse) never converging.
|
|
1546
|
+
*
|
|
1547
|
+
* **`elective_rekey` refuses ANY exclusion** (`EPOCH_ROTATION_INCOMPLETE_ENROLLMENT`
|
|
1548
|
+
* on even one keyless/unconfirmed desired principal) — so a pending
|
|
1549
|
+
* `/confirm`/`/repin` receipt does NOT help here: folding it into THIS
|
|
1550
|
+
* rotation's `pending_confirmations` still leaves that principal
|
|
1551
|
+
* `excluded_unconfirmed` for THIS rotation (D196 — same-head manifest
|
|
1552
|
+
* updates never self-authorize), which `elective_rekey`'s own
|
|
1553
|
+
* completeness check then refuses on. If a directory principal is
|
|
1554
|
+
* unconfirmed when this vault needs to clear its migration requirement,
|
|
1555
|
+
* use `run402 repos access revoke-key`/`declare-exposure` instead (an
|
|
1556
|
+
* urgent reason, which admits with a nonempty partial target set) and
|
|
1557
|
+
* fold the pending receipt into THAT rotation.
|
|
1558
|
+
*/
|
|
1320
1559
|
async function accessRepair(args) {
|
|
1321
1560
|
const a = normalizeArgv(args);
|
|
1322
|
-
assertKnownFlags(a, [...
|
|
1323
|
-
requirePositionalCount(a,
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1561
|
+
assertKnownFlags(a, [...ROTATION_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], ROTATION_VALUE_FLAGS);
|
|
1562
|
+
requirePositionalCount(a, ROTATION_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access repair", missing: "" });
|
|
1563
|
+
const recipientStateVersion = flagValue(a, "--recipient-state-version");
|
|
1564
|
+
const recipientRevocationVersion = flagValue(a, "--recipient-revocation-version");
|
|
1565
|
+
if (recipientStateVersion == null || recipientRevocationVersion == null) {
|
|
1566
|
+
fail({
|
|
1567
|
+
code: "ROTATION_COUNTERS_REQUIRED",
|
|
1568
|
+
message: "`run402 repos access repair` needs --recipient-state-version and --recipient-revocation-version — the gateway does not yet expose a read route for these two counters outside the key-revocation declare route.",
|
|
1569
|
+
hint: "If you know a specific principal whose key should be revoked, use `run402 repos access revoke-key <principal_id>` instead — it is fully self-contained (no flags needed). `access repair` is the general re-key for clearing stale_access / a first-ever migration and needs these two values from platform staff or direct DB access until a gateway read route ships.",
|
|
1570
|
+
next_actions: [nextAction("edit_request", { command: "run402 repos access revoke-key <principal_id>", why: "the ONE fully self-contained rotation entry point today — no counters needed" })],
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
const sdk = getSdk();
|
|
1574
|
+
const target = await vaultTarget(a);
|
|
1575
|
+
try {
|
|
1576
|
+
const result = await sdk.gitvault.rotateEpoch({
|
|
1577
|
+
...target,
|
|
1578
|
+
reason: "elective_rekey",
|
|
1579
|
+
recipient_state_version: recipientStateVersion,
|
|
1580
|
+
recipient_revocation_version: recipientRevocationVersion,
|
|
1581
|
+
...(flagValue(a, "--idempotency-key") != null ? { client_idempotency_key: flagValue(a, "--idempotency-key") } : {}),
|
|
1582
|
+
});
|
|
1583
|
+
printJson(sdk, result);
|
|
1584
|
+
await spillIfLarge(result.rotation_id, "access-repair", result);
|
|
1585
|
+
console.error(`rotated to epoch ${result.new_epoch} at generation ${result.generation}: ${result.included.length} recipient(s) included, ${result.excluded_keyless_principal_ids.length} keyless, ${result.excluded_unconfirmed_principal_ids.length} unconfirmed.`);
|
|
1586
|
+
console.error(`self_check: ${result.self_check}${result.self_check === "not_a_recipient" ? " (this machine's own principal is not itself a vault recipient — nothing to self-verify)" : " (this machine's own opened envelope reproduced the committed epoch key)"}.`);
|
|
1587
|
+
printVerboseStats(a, sdk);
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
reportSdkError(err);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* `run402 repos access revoke-key <principal_id>` (D199) — the ONE fully
|
|
1595
|
+
* self-contained rotation entry point: declares
|
|
1596
|
+
* `reason:"recipient_key_revoked"` for `principal_id` (owner + step-up)
|
|
1597
|
+
* and drives the rotation off that declaration's OWN returned counters.
|
|
1598
|
+
* No flags needed — this is the reason value with a real, working
|
|
1599
|
+
* gateway-side counter read.
|
|
1600
|
+
*/
|
|
1601
|
+
async function accessRevokeKey(args) {
|
|
1602
|
+
const a = normalizeArgv(args);
|
|
1603
|
+
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--idempotency-key", "-v", "--verbose", "--help", "-h"], [...COMMON_VALUE_FLAGS, "--idempotency-key"]);
|
|
1604
|
+
const [principalId] = requirePositionalCount(a, [...COMMON_VALUE_FLAGS, "--idempotency-key"], {
|
|
1605
|
+
min: 1, max: 1, command: "run402 repos access revoke-key <principal_id>",
|
|
1606
|
+
missing: "Missing <principal_id>. This is the principal whose current key should no longer be trusted — the next rotation excludes them from the new epoch.",
|
|
1329
1607
|
});
|
|
1608
|
+
const sdk = getSdk();
|
|
1609
|
+
const target = await vaultTarget(a);
|
|
1610
|
+
try {
|
|
1611
|
+
const result = await sdk.gitvault.rotateEpochForKeyRevocation(principalId, {
|
|
1612
|
+
...target,
|
|
1613
|
+
...(flagValue(a, "--idempotency-key") != null ? { client_idempotency_key: flagValue(a, "--idempotency-key") } : {}),
|
|
1614
|
+
});
|
|
1615
|
+
printJson(sdk, result);
|
|
1616
|
+
await spillIfLarge(result.rotation_id, "access-revoke-key", result);
|
|
1617
|
+
console.error(`declared ${principalId}'s key revoked and rotated to epoch ${result.new_epoch} at generation ${result.generation}: ${result.included.length} recipient(s) included going forward.`);
|
|
1618
|
+
console.error(`self_check: ${result.self_check}.`);
|
|
1619
|
+
printVerboseStats(a, sdk);
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
reportSdkError(err);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* `run402 repos access declare-exposure` (D199) — declares
|
|
1627
|
+
* `reason:"epoch_secret_exposed"` admissible for THIS vault (owner +
|
|
1628
|
+
* step-up), vault-scoped (one vault's leaked key is not evidence any
|
|
1629
|
+
* sibling vault is compromised). The DECLARATION itself is real and
|
|
1630
|
+
* self-contained; the FOLLOW-UP rotation it authorizes is NOT auto-run
|
|
1631
|
+
* here, because — same confirmed gap as `access repair` — the D194
|
|
1632
|
+
* counters it must be fenced against have no client-visible read for this
|
|
1633
|
+
* reason value either. This is the rekey remedy the exposed-key incident
|
|
1634
|
+
* needs: declare here, then rotate (via `--recipient-state-version`/
|
|
1635
|
+
* `--recipient-revocation-version` once known, e.g. from platform staff).
|
|
1636
|
+
*
|
|
1637
|
+
* **If a `/confirm`/`/repin` receipt is already pending** (a directory
|
|
1638
|
+
* principal was confirmed BEFORE this declaration, or gets confirmed while
|
|
1639
|
+
* the rotation is outstanding), do NOT call `publishPinManifestUpdate`
|
|
1640
|
+
* separately — that call is itself an ORDINARY admission and is itself
|
|
1641
|
+
* refused `EPOCH_ROTATION_REQUIRED` for as long as this declaration stays
|
|
1642
|
+
* outstanding (reproduced live in production 2026-08-27). Pass the receipt
|
|
1643
|
+
* to `r.gitvault.rotateEpoch({..., pending_confirmations: [{principal_id,
|
|
1644
|
+
* ek_fingerprint, receipt}]})` instead — it rides the SAME head as the
|
|
1645
|
+
* rotation this declaration requires, publishing durably without needing a
|
|
1646
|
+
* second, separately-gated admission. See `GitvaultVault.rotateEpoch`'s
|
|
1647
|
+
* doc comment for what this does NOT do: the folded principal is still
|
|
1648
|
+
* excluded from THIS rotation's own envelope set (D196) and becomes
|
|
1649
|
+
* eligible starting at the NEXT rotation.
|
|
1650
|
+
*/
|
|
1651
|
+
async function accessDeclareExposure(args) {
|
|
1652
|
+
const a = normalizeArgv(args);
|
|
1653
|
+
assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "-v", "--verbose", "--help", "-h"], COMMON_VALUE_FLAGS);
|
|
1654
|
+
requirePositionalCount(a, COMMON_VALUE_FLAGS, { min: 0, max: 0, command: "run402 repos access declare-exposure", missing: "" });
|
|
1655
|
+
const target = await vaultTarget(a);
|
|
1656
|
+
try {
|
|
1657
|
+
const sdk = getSdk();
|
|
1658
|
+
const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
|
|
1659
|
+
const result = await sdk.gitvault.declareEpochSecretExposed(repoId);
|
|
1660
|
+
printJson(sdk, result);
|
|
1661
|
+
console.error(`declared epoch_secret_exposed for ${repoId} (epoch_secret_exposure_version now ${result.epoch_secret_exposure_version}).`);
|
|
1662
|
+
console.error("THIS DECLARATION DOES NOT ROTATE THE VAULT BY ITSELF — the next ordinary push now refuses EPOCH_ROTATION_REQUIRED until a rotate_epoch with reason:\"epoch_secret_exposed\" commits.");
|
|
1663
|
+
console.error("submit that rotation via r.gitvault.rotateEpoch({repo_id, reason: \"epoch_secret_exposed\", recipient_state_version, recipient_revocation_version}) once you have the two counter values (no CLI shortcut exists for this reason yet — see `run402 repos access repair --help`).");
|
|
1664
|
+
console.error("if a /confirm or /repin receipt is already pending for a directory principal, do NOT publish it separately (publishPinManifestUpdate is itself gated the same way) — pass it as rotateEpoch's pending_confirmations instead so it rides the SAME head as this rotation.");
|
|
1665
|
+
printVerboseStats(a, sdk);
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
reportSdkError(err);
|
|
1668
|
+
}
|
|
1330
1669
|
}
|
|
1331
1670
|
|
|
1332
1671
|
async function access(args) {
|
|
1333
1672
|
const a = normalizeArgv(args);
|
|
1334
1673
|
if (a[0] === "repair") return accessRepair(a.slice(1));
|
|
1674
|
+
if (a[0] === "revoke-key") return accessRevokeKey(a.slice(1));
|
|
1675
|
+
if (a[0] === "declare-exposure") return accessDeclareExposure(a.slice(1));
|
|
1335
1676
|
return accessRead(a);
|
|
1336
1677
|
}
|
|
1337
1678
|
|
|
1338
|
-
// ─── recover
|
|
1679
|
+
// ─── recover ─────────────────────
|
|
1680
|
+
|
|
1681
|
+
/** `repos recover --human`: the same verdict the stderr lines already carry, condensed into one block. */
|
|
1682
|
+
function formatRecoverHuman(result, outDir) {
|
|
1683
|
+
const lines = [
|
|
1684
|
+
`Repo: ${result.repo_id}`,
|
|
1685
|
+
`Recovered generation ${result.recovered_generation} into ${outDir}` +
|
|
1686
|
+
(result.chain_break ? ` (chain break at ${result.chain_break.generation} — fell back to the newest fully-verified generation)` : "") + ".",
|
|
1687
|
+
];
|
|
1688
|
+
if (result.data_loss_detected) {
|
|
1689
|
+
lines.push(`DATA LOSS DETECTED: ${result.absences.filter((x) => x.adjudication === "unexplained_absence").length} object(s) are unexplained absences.`);
|
|
1690
|
+
}
|
|
1691
|
+
lines.push(`Layout: ${result.layout}` + (result.layout === "bare" ? " (no working files — not a failed recovery)" : ""));
|
|
1692
|
+
return lines.join("\n");
|
|
1693
|
+
}
|
|
1339
1694
|
|
|
1340
1695
|
async function recover(args) {
|
|
1341
1696
|
const a = normalizeArgv(args);
|
|
1342
1697
|
const valueFlags = ["--out", "--repo", "--profile", "--region", "--endpoint"];
|
|
1343
|
-
assertKnownFlags(a, [...valueFlags, "--ambient", "--help", "-h"], valueFlags);
|
|
1698
|
+
assertKnownFlags(a, [...valueFlags, "--ambient", "--human", "-v", "--verbose", "--help", "-h"], valueFlags);
|
|
1344
1699
|
const [source] = requirePositionalCount(a, valueFlags, {
|
|
1345
1700
|
min: 1, max: 1, command: "run402 repos recover <source> --out <dir>",
|
|
1346
1701
|
missing: "Missing <source>. Expected s3://<bucket>[/<prefix>] or a directory path.",
|
|
@@ -1349,25 +1704,42 @@ async function recover(args) {
|
|
|
1349
1704
|
if (outDir == null) {
|
|
1350
1705
|
fail({ code: "BAD_USAGE", message: "run402 repos recover needs --out <dir>.", hint: "Where to materialize the recovered repository, e.g. --out ./restored" });
|
|
1351
1706
|
}
|
|
1707
|
+
const human = a.includes("--human");
|
|
1708
|
+
if (human && a.includes("--json")) {
|
|
1709
|
+
fail({ code: "BAD_USAGE", message: "--human cannot be combined with --json.", details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") } });
|
|
1710
|
+
}
|
|
1711
|
+
const sdk = getSdk();
|
|
1352
1712
|
const credential = resolveMirrorCredential(a);
|
|
1353
1713
|
const repoId = flagValue(a, "--repo");
|
|
1354
1714
|
const region = flagValue(a, "--region");
|
|
1355
1715
|
const endpoint = flagValue(a, "--endpoint");
|
|
1356
1716
|
try {
|
|
1357
|
-
const result = await
|
|
1717
|
+
const result = await sdk.gitvault.recover({
|
|
1358
1718
|
source, out_dir: outDir,
|
|
1359
1719
|
...(repoId != null ? { repo_id: repoId } : {}),
|
|
1360
1720
|
...(credential ? { credential } : {}),
|
|
1361
1721
|
...(region != null ? { region } : {}),
|
|
1362
1722
|
...(endpoint != null ? { endpoint } : {}),
|
|
1363
1723
|
});
|
|
1364
|
-
|
|
1724
|
+
if (human) {
|
|
1725
|
+
console.log(formatRecoverHuman(result, outDir));
|
|
1726
|
+
if (result.layout === "bare") for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
|
|
1727
|
+
printMirrorHonesty(result);
|
|
1728
|
+
printVerboseStats(a, sdk);
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
printJson(sdk, result);
|
|
1365
1732
|
await spillIfLarge(result.repo_id, "recover", result);
|
|
1366
1733
|
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
1734
|
if (result.data_loss_detected) {
|
|
1368
1735
|
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
1736
|
}
|
|
1737
|
+
if (result.layout === "bare") {
|
|
1738
|
+
console.error(`layout: bare (no working files in ${outDir} — this is not a failed recovery)`);
|
|
1739
|
+
for (const n of result.next_actions ?? []) console.error(`next: ${n.action} — ${n.command}`);
|
|
1740
|
+
}
|
|
1370
1741
|
printMirrorHonesty(result);
|
|
1742
|
+
printVerboseStats(a, sdk);
|
|
1371
1743
|
} catch (err) {
|
|
1372
1744
|
reportSdkError(err);
|
|
1373
1745
|
}
|