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/README.md +7 -4
- package/cli.mjs +10 -2
- package/lib/command-manifest.mjs +56 -45
- package/lib/doctor.mjs +4 -4
- package/lib/gitvault-scaffold.mjs +1 -1
- package/lib/gitvault.mjs +118 -1163
- package/lib/init.mjs +2 -2
- package/lib/repos.mjs +1237 -248
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +5 -2
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +154 -11
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +154 -16
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-apply.d.ts +1 -1
- package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.js +6 -6
- package/sdk/dist/node/gitvault-apply.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.js +2 -2
- package/sdk/dist/node/gitvault-deploy.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror-backend.js +1 -1
- package/sdk/dist/node/gitvault-mirror-backend.js.map +1 -1
- package/sdk/dist/node/gitvault-mirror.js +1 -1
- package/sdk/dist/node/gitvault-mirror.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +22 -3
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +37 -12
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
package/lib/repos.mjs
CHANGED
|
@@ -1,43 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `run402 repos` —
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
* `
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
run402 repos name
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
--
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
--
|
|
100
|
-
|
|
101
|
-
--
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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).
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
194
|
-
hint: `Pass --org <org_id> next time, or finish by hand: run402
|
|
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
|
-
|
|
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
|
|
609
|
+
async function create(args) {
|
|
280
610
|
const a = normalizeArgv(args);
|
|
281
|
-
assertKnownFlags(a, [...
|
|
282
|
-
requirePositionalCount(a,
|
|
283
|
-
min: 0, max:
|
|
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
|
-
|
|
615
|
+
let name = positionals[0] ?? null;
|
|
616
|
+
const dir = flagValue(a, "--dir") ?? process.cwd();
|
|
617
|
+
const adoptProjectId = flagValue(a, "--project");
|
|
286
618
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
316
|
-
source_bytes:
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
329
|
-
} catch {
|
|
330
|
-
|
|
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)
|
|
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, [
|
|
340
|
-
const
|
|
341
|
-
|
|
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
|
-
`
|
|
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
|
|
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"
|
|
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
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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
|
|
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, [...
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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.
|
|
411
|
-
? `
|
|
412
|
-
: `
|
|
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 "
|
|
441
|
-
await
|
|
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:
|