run402 4.24.1 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/cli.mjs +6 -6
  2. package/git-remote-run402.mjs +328 -0
  3. package/lib/command-manifest.mjs +13 -1
  4. package/lib/credentials.mjs +4 -0
  5. package/lib/deploy-v2.mjs +78 -1
  6. package/lib/doctor.mjs +80 -1
  7. package/lib/domains.mjs +0 -14
  8. package/lib/gitvault.mjs +582 -0
  9. package/lib/init.mjs +153 -5
  10. package/lib/org-context.mjs +44 -1
  11. package/lib/pay.test.mjs +0 -4
  12. package/package.json +8 -2
  13. package/sdk/dist/index.d.ts +20 -3
  14. package/sdk/dist/index.d.ts.map +1 -1
  15. package/sdk/dist/index.js +22 -3
  16. package/sdk/dist/index.js.map +1 -1
  17. package/sdk/dist/namespaces/admin.d.ts +0 -5
  18. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  19. package/sdk/dist/namespaces/admin.js +0 -9
  20. package/sdk/dist/namespaces/admin.js.map +1 -1
  21. package/sdk/dist/namespaces/assets.d.ts +1 -20
  22. package/sdk/dist/namespaces/assets.d.ts.map +1 -1
  23. package/sdk/dist/namespaces/assets.js +0 -27
  24. package/sdk/dist/namespaces/assets.js.map +1 -1
  25. package/sdk/dist/namespaces/domains.d.ts +0 -19
  26. package/sdk/dist/namespaces/domains.d.ts.map +1 -1
  27. package/sdk/dist/namespaces/domains.js +0 -19
  28. package/sdk/dist/namespaces/domains.js.map +1 -1
  29. package/sdk/dist/namespaces/gitvault.crypto.d.ts +319 -0
  30. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -0
  31. package/sdk/dist/namespaces/gitvault.crypto.js +690 -0
  32. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -0
  33. package/sdk/dist/namespaces/gitvault.d.ts +521 -0
  34. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -0
  35. package/sdk/dist/namespaces/gitvault.js +786 -0
  36. package/sdk/dist/namespaces/gitvault.js.map +1 -0
  37. package/sdk/dist/namespaces/gitvault.types.d.ts +481 -0
  38. package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -0
  39. package/sdk/dist/namespaces/gitvault.types.js +19 -0
  40. package/sdk/dist/namespaces/gitvault.types.js.map +1 -0
  41. package/sdk/dist/namespaces/org.d.ts +1 -3
  42. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  43. package/sdk/dist/namespaces/org.js +6 -10
  44. package/sdk/dist/namespaces/org.js.map +1 -1
  45. package/sdk/dist/namespaces/pay.d.ts +0 -8
  46. package/sdk/dist/namespaces/pay.d.ts.map +1 -1
  47. package/sdk/dist/namespaces/pay.js.map +1 -1
  48. package/sdk/dist/namespaces/projects.d.ts +2 -2
  49. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  50. package/sdk/dist/namespaces/projects.js +4 -12
  51. package/sdk/dist/namespaces/projects.js.map +1 -1
  52. package/sdk/dist/namespaces/secrets.d.ts +0 -2
  53. package/sdk/dist/namespaces/secrets.d.ts.map +1 -1
  54. package/sdk/dist/namespaces/secrets.js +3 -10
  55. package/sdk/dist/namespaces/secrets.js.map +1 -1
  56. package/sdk/dist/namespaces/subdomains.d.ts +0 -2
  57. package/sdk/dist/namespaces/subdomains.d.ts.map +1 -1
  58. package/sdk/dist/namespaces/subdomains.js +5 -16
  59. package/sdk/dist/namespaces/subdomains.js.map +1 -1
  60. package/sdk/dist/namespaces/transfers.d.ts +0 -2
  61. package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
  62. package/sdk/dist/namespaces/transfers.js +8 -10
  63. package/sdk/dist/namespaces/transfers.js.map +1 -1
  64. package/sdk/dist/namespaces/wallets.d.ts +0 -12
  65. package/sdk/dist/namespaces/wallets.d.ts.map +1 -1
  66. package/sdk/dist/namespaces/wallets.js +0 -14
  67. package/sdk/dist/namespaces/wallets.js.map +1 -1
  68. package/sdk/dist/node/gitvault-creation-journal.d.ts +222 -0
  69. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -0
  70. package/sdk/dist/node/gitvault-creation-journal.js +365 -0
  71. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -0
  72. package/sdk/dist/node/gitvault-deploy.d.ts +211 -0
  73. package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -0
  74. package/sdk/dist/node/gitvault-deploy.js +292 -0
  75. package/sdk/dist/node/gitvault-deploy.js.map +1 -0
  76. package/sdk/dist/node/gitvault-keystore.d.ts +207 -0
  77. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -0
  78. package/sdk/dist/node/gitvault-keystore.js +529 -0
  79. package/sdk/dist/node/gitvault-keystore.js.map +1 -0
  80. package/sdk/dist/node/gitvault-prune.d.ts +341 -0
  81. package/sdk/dist/node/gitvault-prune.d.ts.map +1 -0
  82. package/sdk/dist/node/gitvault-prune.js +410 -0
  83. package/sdk/dist/node/gitvault-prune.js.map +1 -0
  84. package/sdk/dist/node/gitvault-publication.d.ts +730 -0
  85. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -0
  86. package/sdk/dist/node/gitvault-publication.js +1475 -0
  87. package/sdk/dist/node/gitvault-publication.js.map +1 -0
  88. package/sdk/dist/node/gitvault-snapshot.d.ts +227 -0
  89. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -0
  90. package/sdk/dist/node/gitvault-snapshot.js +556 -0
  91. package/sdk/dist/node/gitvault-snapshot.js.map +1 -0
  92. package/sdk/dist/node/index.d.ts +11 -0
  93. package/sdk/dist/node/index.d.ts.map +1 -1
  94. package/sdk/dist/node/index.js +9 -0
  95. package/sdk/dist/node/index.js.map +1 -1
  96. package/sdk/dist/node/paid-fetch.d.ts.map +1 -1
  97. package/sdk/dist/node/paid-fetch.js +0 -4
  98. package/sdk/dist/node/paid-fetch.js.map +1 -1
  99. package/sdk/dist/node/sites-node.d.ts +1 -8
  100. package/sdk/dist/node/sites-node.d.ts.map +1 -1
  101. package/sdk/dist/node/sites-node.js +1 -7
  102. package/sdk/dist/node/sites-node.js.map +1 -1
  103. package/sdk/dist/scoped.d.ts +4 -35
  104. package/sdk/dist/scoped.d.ts.map +1 -1
  105. package/sdk/dist/scoped.js +9 -74
  106. package/sdk/dist/scoped.js.map +1 -1
  107. package/lib/sender-domain.mjs +0 -36
  108. package/sdk/dist/deprecate.d.ts +0 -23
  109. package/sdk/dist/deprecate.d.ts.map +0 -1
  110. package/sdk/dist/deprecate.js +0 -42
  111. package/sdk/dist/deprecate.js.map +0 -1
  112. package/sdk/dist/namespaces/sender-domain.d.ts +0 -47
  113. package/sdk/dist/namespaces/sender-domain.d.ts.map +0 -1
  114. package/sdk/dist/namespaces/sender-domain.js +0 -45
  115. package/sdk/dist/namespaces/sender-domain.js.map +0 -1
package/cli.mjs CHANGED
@@ -49,6 +49,7 @@ Commands:
49
49
  rooms Coordinate with the other agents on your project (who/send/ack)
50
50
  escalations Page a human when you judge you need one (raise/list/ack)
51
51
  claims Say what you're working on before you collide (advisory)
52
+ gitvault Host-blind encrypted Git remote (init/status/push/policy/compact/prune/verify)
52
53
  errors Grouped error fingerprints + a promote/revert verdict (release-baselined)
53
54
  jobs Submit and inspect platform-managed jobs
54
55
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
@@ -64,7 +65,6 @@ Commands:
64
65
  email Send template-based emails from your project
65
66
  message Send messages to Run402 developers
66
67
  auth Manage project user authentication (magic link, passwords, settings)
67
- sender-domain Removed; use domains connect/status/repair
68
68
  billing Email organizations, Stripe tier checkout, email packs
69
69
  contracts KMS signers ($0.04/day rental + $0.000005/sign)
70
70
  agent Manage agent identity (contact info)
@@ -292,6 +292,11 @@ switch (cmd) {
292
292
  await run(sub, rest);
293
293
  break;
294
294
  }
295
+ case "gitvault": {
296
+ const { run } = await import("./lib/gitvault.mjs");
297
+ await run(sub, rest);
298
+ break;
299
+ }
295
300
  case "escalations": {
296
301
  const { run } = await import("./lib/escalations.mjs");
297
302
  await run(sub, rest);
@@ -382,11 +387,6 @@ switch (cmd) {
382
387
  await run(sub, rest);
383
388
  break;
384
389
  }
385
- case "sender-domain": {
386
- const { run } = await import("./lib/sender-domain.mjs");
387
- await run(sub, rest);
388
- break;
389
- }
390
390
  case "billing": {
391
391
  const { run } = await import("./lib/billing.mjs");
392
392
  await run(sub, rest);
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `git-remote-run402` — the git remote helper for gitvault, so plain
4
+ * `git clone|fetch|push run402::<org_id>/<project_id>` speaks to a host-blind
5
+ * encrypted vault with no run402-specific git ceremony.
6
+ *
7
+ * ARCHITECTURAL LAW (gitvault-client-surface, "All protocol logic lives in the
8
+ * SDK"): this file is a THIN ADAPTER over `r.gitvault`. It translates git's
9
+ * remote-helper wire protocol into SDK calls and back, and does nothing else.
10
+ * No crypto, no HTTP, no ref-policy decisions, no pack building — those live
11
+ * once in `@run402/sdk`. Even git itself is invoked only through the SDK's own
12
+ * `hardenedGit` (hooks, fsmonitor, replace-refs and filter autodetection are
13
+ * neutralized there), so this helper hand-rolls no git behaviour either.
14
+ *
15
+ * PROTOCOL SURFACE (gitremote-helpers(1)). Implemented:
16
+ * capabilities → advertises fetch, push, option
17
+ * list [for-push] → the vault's canonical ref map + the HEAD target
18
+ * fetch <sha1> <name> → restore the vault's object database into this repo
19
+ * push [+]<src>:<dst> → publish one atomic ref transaction
20
+ * option <name> <value> → ok / unsupported, never a silent lie
21
+ *
22
+ * WHICH REPOSITORY (the fail-closed rule). `process.cwd()` is NOT the
23
+ * repository. git identifies the repository with `GIT_DIR`, and during
24
+ * `git clone` cwd is the directory clone was RUN FROM — routinely some other,
25
+ * unrelated repository. Discovering the repository from cwd therefore wrote a
26
+ * vault's DECRYPTED objects into a repository the user never named, silently,
27
+ * on every clone (dogfood #1). Every repository-touching command now resolves
28
+ * through the SDK's `resolveGitInvocationRepo`, which proves `GIT_DIR` names a
29
+ * real repository and refuses otherwise; a refusal writes nothing at all.
30
+ * `capabilities`, `option` and `list` need no repository and are unaffected,
31
+ * so a repository-free `git ls-remote run402::<org>/<project>` still works.
32
+ *
33
+ * NOT advertised, deliberately: `list` is a COMMAND in this protocol, not a
34
+ * capability keyword — git's capability vocabulary is fetch/push/import/export/
35
+ * connect/stateless-connect/option/refspec/check-connectivity/object-format/
36
+ * signed-tags/bidi-import/get. Advertising `list` would be a line git silently
37
+ * discards; the command itself is implemented above. `connect`,
38
+ * `stateless-connect`, `import`, and `export` are NOT implemented: the vault is
39
+ * not a git-protocol endpoint you can tunnel to, and pretending otherwise would
40
+ * hand git a transport that cannot answer.
41
+ *
42
+ * KNOWN LIMITS, stated rather than papered over:
43
+ * - `fetch` restores the vault's object database WHOLESALE. The SDK exposes
44
+ * no per-ref object selection, so one batch = one full restore. That is a
45
+ * superset of what git asked for (git writes the refs itself from `list`),
46
+ * never a subset — but it is not incremental.
47
+ * - `push` never changes the vault's HEAD target; the SDK carries it forward.
48
+ * A fresh vault defaults to `refs/heads/main`, so a first push of some
49
+ * other branch leaves HEAD naming a ref that does not exist yet. Use
50
+ * `run402 gitvault push`, which sets the HEAD target from the local HEAD.
51
+ * - `option dry-run` is `unsupported`: this helper cannot rehearse a
52
+ * publication, and reporting a fake success would be worse than refusing.
53
+ * - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
54
+ * helper against a repository, so running this binary by hand from a shell
55
+ * is refused rather than silently pointed at the current directory. Only
56
+ * `capabilities`, `option` and `list` work without one, which is exactly
57
+ * the set `git ls-remote <url>` outside a checkout needs.
58
+ */
59
+
60
+ import { createInterface } from "node:readline";
61
+ import { getSdk } from "./lib/sdk.mjs";
62
+ import { parseGitvaultRemoteUrl } from "#sdk";
63
+ import { hardenedGit, resolveGitInvocationRepo } from "#sdk/node";
64
+
65
+ const out = (line) => process.stdout.write(`${line}\n`);
66
+ /** Every helper response block is terminated by a blank line. */
67
+ const endBlock = () => process.stdout.write("\n");
68
+ const note = (line) => process.stderr.write(`git-remote-run402: ${line}\n`);
69
+
70
+ /** Protocol lines are single-line: collapse anything that could break framing. */
71
+ function oneLine(value) {
72
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
73
+ }
74
+
75
+ function describeError(err) {
76
+ const code = err?.code ?? err?.body?.code ?? null;
77
+ const message = err?.message ?? err?.body?.message ?? String(err);
78
+ return oneLine(code ? `${code}: ${message}` : message);
79
+ }
80
+
81
+ /**
82
+ * Resolve the vault address from git's argv.
83
+ *
84
+ * Git invokes `git-remote-<transport> <remote> <url>`, and for a
85
+ * `<transport>::<address>` URL it passes the BARE `<address>` as the second
86
+ * argument — the `run402::` prefix is already stripped. The SDK's parser is the
87
+ * only thing that understands the address grammar, so both spellings are handed
88
+ * to it rather than re-implemented here.
89
+ */
90
+ function resolveRemoteAddress(argv) {
91
+ for (const raw of [argv[1], argv[0]]) {
92
+ if (typeof raw !== "string" || raw.length === 0) continue;
93
+ const direct = parseGitvaultRemoteUrl(raw);
94
+ if (direct) return direct;
95
+ // Re-add the prefix ONLY for a bare, colon-free address. Without this
96
+ // guard `https://example.com/x` parses as org `https:` / project
97
+ // `/example.com/x`, and `git@github.com:x/y.git` as org `git@github.com:x`
98
+ // — a confidently wrong answer is worse than no answer here. The colon is
99
+ // git's own URL punctuation, never part of a bare `<org_id>/<project_id>`.
100
+ if (raw.includes(":")) continue;
101
+ const prefixed = parseGitvaultRemoteUrl(`run402::${raw}`);
102
+ if (prefixed) return prefixed;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** `[+]<src>:<dst>` — an empty `<src>` is a deletion. */
108
+ function parsePushSpec(spec) {
109
+ const forced = spec.startsWith("+");
110
+ const body = forced ? spec.slice(1) : spec;
111
+ const colon = body.indexOf(":");
112
+ if (colon === -1) return { src: body, dst: body, force: forced };
113
+ return { src: body.slice(0, colon), dst: body.slice(colon + 1), force: forced };
114
+ }
115
+
116
+ async function main(argv) {
117
+ const address = resolveRemoteAddress(argv);
118
+ if (!address) {
119
+ note(`could not read a run402 remote address from ${JSON.stringify(argv.join(" "))} — expected run402::<org_id>/<project_id>`);
120
+ return 1;
121
+ }
122
+
123
+ const target = { project_id: address.project_id };
124
+ let verbosity = 1;
125
+
126
+ /**
127
+ * The repository git invoked us for, resolved once and PROVEN.
128
+ *
129
+ * Deliberately lazy: `list` needs no repository, so `git ls-remote` outside
130
+ * any checkout keeps working. Deliberately not cached across a failure
131
+ * either — a refusal is terminal for the command that asked, and there is
132
+ * nothing to retry.
133
+ */
134
+ let resolvedRepo = null;
135
+ async function requireRepo() {
136
+ if (!resolvedRepo) resolvedRepo = await resolveGitInvocationRepo(process.env, process.cwd());
137
+ return resolvedRepo.repo_dir;
138
+ }
139
+
140
+ /**
141
+ * What to tell a human when we refuse. `git clone` is the case that used to
142
+ * fail; naming the working alternative beats a bare error.
143
+ */
144
+ function repoRefusalNote(err) {
145
+ note(describeError(err));
146
+ note("refusing to touch a repository git did not name — nothing was read or written.");
147
+ note(`if you meant to restore this vault: git init --bare <dir> && git -C <dir> remote add run402 run402::${address.org_id}/${address.project_id} && git -C <dir> fetch run402 '+refs/heads/*:refs/heads/*'`);
148
+ }
149
+
150
+ /** Open the vault lazily — `capabilities` and `option` must never touch the network. */
151
+ const openVault = async (repoDir) => (await getSdk().gitvault.open(repoDir ? { ...target, repo_dir: repoDir } : target)).vault;
152
+
153
+ async function runList() {
154
+ const state = await (await openVault()).materialize();
155
+ const refs = state.refs ?? {};
156
+ for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
157
+ const head = state.head_target;
158
+ // A symref is only advertised when its target is actually present:
159
+ // pointing HEAD at a ref that does not exist is what an empty repository
160
+ // looks like, and git reads the empty list correctly on its own.
161
+ if (head?.kind === "symref" && Object.prototype.hasOwnProperty.call(refs, head.ref)) out(`@${head.ref} HEAD`);
162
+ else if (head?.kind === "detached") out(`${head.oid} HEAD`);
163
+ endBlock();
164
+ }
165
+
166
+ async function runFetch(batch) {
167
+ // Resolve the target repository BEFORE a single byte is decrypted: a
168
+ // refusal here must leave no objects anywhere. This is what makes `clone`
169
+ // work (git names the fresh repo in `GIT_DIR`) and what stops a clone run
170
+ // from inside an unrelated checkout from writing into that checkout.
171
+ let repoDir;
172
+ try {
173
+ repoDir = await requireRepo();
174
+ } catch (err) {
175
+ repoRefusalNote(err);
176
+ return 1;
177
+ }
178
+ if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
179
+ const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
180
+ if (verbosity >= 1) note(`restored generation ${restored.generation}`);
181
+ endBlock();
182
+ return 0;
183
+ }
184
+
185
+ async function runPush(batch) {
186
+ const specs = batch.map(parsePushSpec);
187
+ try {
188
+ // Repository first, then every source revision, and only then the
189
+ // network: a push that names a ref this repository does not have must
190
+ // fail locally rather than after opening the vault.
191
+ const repoDir = await requireRepo();
192
+ const newOids = new Map();
193
+ for (const spec of specs) {
194
+ // A deletion carries an empty <src>. Everything else is resolved by
195
+ // git itself; `--end-of-options` keeps a hostile refname from being
196
+ // read as a flag.
197
+ newOids.set(spec, spec.src === ""
198
+ ? null
199
+ : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
200
+ }
201
+ const vault = await openVault(repoDir);
202
+ const base = await vault.materialize();
203
+ const updates = [];
204
+ for (const spec of specs) {
205
+ const expectedOld = base.refs?.[spec.dst] ?? null;
206
+ updates.push({
207
+ ref: spec.dst,
208
+ expected_old_oid: expectedOld,
209
+ new_oid: newOids.get(spec),
210
+ // Force-with-lease still requires a lease, so a CREATE is never
211
+ // forced. The SDK owns what force actually permits.
212
+ force: spec.force && expectedOld !== null,
213
+ });
214
+ }
215
+ // ONE transaction for the whole batch: the SDK evaluates fast-forward,
216
+ // tag immutability, protocol-ref refusal and retention roots, builds the
217
+ // packs, and publishes — all or nothing.
218
+ const published = await vault.push({ transaction: { updates } });
219
+ if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
220
+ for (const spec of specs) out(`ok ${spec.dst}`);
221
+ } catch (err) {
222
+ // The transaction is atomic, so a failure failed every ref in it. Report
223
+ // it against each one rather than letting some look like they landed.
224
+ if (err?.code === "GIT_INVOCATION_REPO_UNRESOLVED") repoRefusalNote(err);
225
+ const reason = describeError(err);
226
+ for (const spec of specs) out(`error ${spec.dst} ${reason}`);
227
+ }
228
+ endBlock();
229
+ return 0;
230
+ }
231
+
232
+ function handleOption(name, value) {
233
+ switch (name) {
234
+ case "verbosity": {
235
+ const parsed = Number.parseInt(value, 10);
236
+ if (!Number.isFinite(parsed)) { out("error expected an integer verbosity"); return; }
237
+ verbosity = parsed;
238
+ out("ok");
239
+ return;
240
+ }
241
+ case "progress":
242
+ // Progress is stderr chatter, which `verbosity` already governs.
243
+ out("ok");
244
+ return;
245
+ case "atomic":
246
+ // Every push here is a single ref transaction, so the guarantee holds
247
+ // whichever way git asked for it.
248
+ out("ok");
249
+ return;
250
+ default:
251
+ // Includes dry-run, object-format, depth, cloning, check-connectivity,
252
+ // followtags, pushcert: honestly unsupported rather than acknowledged.
253
+ out("unsupported");
254
+ }
255
+ }
256
+
257
+ let fetchBatch = [];
258
+ let pushBatch = [];
259
+
260
+ /** Returns the process exit code the flushed batch demands (0 = keep going). */
261
+ async function flushBatches() {
262
+ if (fetchBatch.length > 0) {
263
+ const batch = fetchBatch;
264
+ fetchBatch = [];
265
+ return await runFetch(batch);
266
+ }
267
+ if (pushBatch.length > 0) {
268
+ const batch = pushBatch;
269
+ pushBatch = [];
270
+ return await runPush(batch);
271
+ }
272
+ return 0;
273
+ }
274
+
275
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
276
+ try {
277
+ for await (const raw of rl) {
278
+ const line = raw.replace(/\r$/, "");
279
+ if (line === "") {
280
+ const code = await flushBatches();
281
+ if (code !== 0) return code;
282
+ continue;
283
+ }
284
+ const space = line.indexOf(" ");
285
+ const command = space === -1 ? line : line.slice(0, space);
286
+ const rest = space === -1 ? "" : line.slice(space + 1);
287
+ switch (command) {
288
+ case "capabilities":
289
+ out("fetch");
290
+ out("push");
291
+ out("option");
292
+ endBlock();
293
+ break;
294
+ case "list":
295
+ await runList();
296
+ break;
297
+ case "option": {
298
+ const optSpace = rest.indexOf(" ");
299
+ handleOption(optSpace === -1 ? rest : rest.slice(0, optSpace), optSpace === -1 ? "" : rest.slice(optSpace + 1));
300
+ break;
301
+ }
302
+ case "fetch":
303
+ fetchBatch.push(rest);
304
+ break;
305
+ case "push":
306
+ pushBatch.push(rest);
307
+ break;
308
+ default:
309
+ note(`unknown command: ${oneLine(line)}`);
310
+ return 1;
311
+ }
312
+ }
313
+ // EOF. Git always terminates a batch with a blank line, but flushing here
314
+ // means a truncated stream still does the work it already asked for
315
+ // instead of silently dropping it.
316
+ return await flushBatches();
317
+ } finally {
318
+ rl.close();
319
+ }
320
+ }
321
+
322
+ // Never `process.exit()` mid-stream: that can truncate a pending stdout write
323
+ // on a pipe, which git reads as a protocol violation. Set the code and let Node
324
+ // flush and exit on its own.
325
+ main(process.argv.slice(2)).then(
326
+ (code) => { process.exitCode = code; },
327
+ (err) => { note(describeError(err)); process.exitCode = 1; },
328
+ );
@@ -232,6 +232,19 @@ export const COMMAND_MANIFEST = [
232
232
  { path: ["claims", "create"], positionals: [p("resource")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["deploy"], runStyle: "sub" },
233
233
  { path: ["claims", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub" },
234
234
  { path: ["claims", "release"], positionals: [p("claim_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["clm_1"], runStyle: "sub" },
235
+
236
+ // ── gitvault (host-blind encrypted Git remote) ───────────────────────────
237
+ // Every verb needs a real principal keystore, an allocated vault, and (for
238
+ // all but `status`) a local git working tree, so the gate runs structural
239
+ // checks only — an in-process behavioral run would either no-op against the
240
+ // universal `{}` fetch mock or touch the gate's own checkout.
241
+ { path: ["gitvault", "init"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "allocates a vault: mints key material on this machine and runs the six-stage creation journal" },
242
+ { path: ["gitvault", "status"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "reads the local principal keystore and the live vault record" },
243
+ { path: ["gitvault", "push"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and publishes a signed head" },
244
+ { path: ["gitvault", "policy"], positionals: [p("gitvault_policy")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["required"], runStyle: "sub", skipBehavioral: "owner + step-up mutation of the live project's activation policy" },
245
+ { path: ["gitvault", "compact"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "takes a maintenance lease and builds a checkpoint from the local repository" },
246
+ { path: ["gitvault", "prune"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "materializes the live vault head to enumerate retention roots" },
247
+ { path: ["gitvault", "verify"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "walks the live head chain against the keystore's authenticated pin" },
235
248
  { path: ["errors"], positionals: [p("fingerprint_id", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
236
249
 
237
250
  // ── jobs ─────────────────────────────────────────────────────────────────
@@ -403,6 +416,5 @@ export const COMMAND_MANIFEST = [
403
416
  // completeness check against cli.mjs's dispatch switch.
404
417
  export const SKIPPED_FAMILIES = {
405
418
  "apply": "pure alias for `deploy apply` (covered by the deploy family)",
406
- "sender-domain": "removed command — every subcommand errors with COMMAND_REMOVED",
407
419
  "dev": "interactive wrapper that spawns `astro dev`",
408
420
  };
@@ -324,6 +324,10 @@ async function importKey(args) {
324
324
  site_url: existing?.site_url,
325
325
  deployed_at: existing?.deployed_at,
326
326
  last_deployment_id: existing?.last_deployment_id,
327
+ // Carried, not re-derived: a key import says nothing about which org owns
328
+ // the project, and dropping the cache here would make `run402 init` reach
329
+ // for the control plane again on a machine that already knew.
330
+ org_id: existing?.org_id,
327
331
  source: "manual_import",
328
332
  cached_at: new Date().toISOString(),
329
333
  });
package/lib/deploy-v2.mjs CHANGED
@@ -1349,11 +1349,88 @@ const CI_DEPLOY_ERROR_GUIDANCE = {
1349
1349
  };
1350
1350
 
1351
1351
  function reportDeployApplyError(err, useGithubActionsOidc) {
1352
- const warningEnhanced = enhanceDeployWarningError(err);
1352
+ const warningEnhanced = enhanceGitvaultDeployError(enhanceDeployWarningError(err));
1353
1353
  if (!useGithubActionsOidc) return reportSdkError(warningEnhanced);
1354
1354
  return reportSdkError(enhanceCiDeployError(warningEnhanced));
1355
1355
  }
1356
1356
 
1357
+ /**
1358
+ * Whether THIS client's deploy lane speaks the gitvault protocol.
1359
+ *
1360
+ * `"unsupported"` is a statement of fact about the code in this file: it
1361
+ * declares no capture at plan time and sends no gitvault block on commit, so a
1362
+ * project with `gitvault_policy: required` refuses its commits. The vault
1363
+ * itself works — `run402 gitvault push` and `git push run402` publish captures
1364
+ * — it is the DEPLOY lane that has no capture wiring.
1365
+ *
1366
+ * RETIREMENT CONDITION: flip this to `"supported"` in the same change that
1367
+ * makes `applyCmd` declare `{capture_id, snapshot_oid_hmac}` on
1368
+ * `POST /apply/v1/plans` and present an activation token on
1369
+ * `POST /apply/v1/plans/:id/commit` (protocol §6.5). Doing that faithfully also
1370
+ * means building the deploy artifacts from an isolated materialization of the
1371
+ * snapshot commit, which is why it is a design change and not a wiring one:
1372
+ * build output is normally gitignored, so it is not in the snapshot at all.
1373
+ * `cli-deploy-gitvault-advisory.test.mjs` fails if the flag and the advisory
1374
+ * ever disagree.
1375
+ */
1376
+ export const GITVAULT_DEPLOY_LANE = "unsupported";
1377
+
1378
+ /**
1379
+ * Tell the truth about a deploy the vault gate refused.
1380
+ *
1381
+ * The gateway's `GITVAULT_CLIENT_UPGRADE_REQUIRED` envelope leads with
1382
+ * `upgrade_client` / `npm i -g run402@latest`, which is correct in principle
1383
+ * and WRONG right now: no published `run402` has a gitvault-capable deploy
1384
+ * lane, so upgrading changes nothing about this refusal (dogfood #1, finding
1385
+ * C). Relaying it unedited sends an agent into an upgrade loop. The gateway's
1386
+ * SECOND action — grandfather the policy — is real and reachable, and this
1387
+ * client now has the verb for it.
1388
+ *
1389
+ * The gateway's own actions are PRESERVED, never dropped: they are re-ordered
1390
+ * behind an honest one, and the upgrade action keeps its place with its
1391
+ * promise corrected. When the lane ships, the flag above retires this whole
1392
+ * function's rewrite and the envelope passes through untouched.
1393
+ */
1394
+ export function enhanceGitvaultDeployError(err) {
1395
+ const body = err?.body && typeof err.body === "object" && !Array.isArray(err.body) ? err.body : {};
1396
+ const code = body.code || err?.code || null;
1397
+ if (code !== "GITVAULT_CLIENT_UPGRADE_REQUIRED") return err;
1398
+ if (GITVAULT_DEPLOY_LANE === "supported") return err;
1399
+
1400
+ const gatewayActions = Array.isArray(body.next_actions) ? body.next_actions : [];
1401
+ const enhanced = Object.assign(new Error(err?.message || body.message || code), err);
1402
+ enhanced.body = {
1403
+ ...body,
1404
+ hint:
1405
+ "This project requires a vaulted capture on deploy, and this run402 CLI's deploy lane does not produce one — " +
1406
+ "upgrading the CLI does not currently fix it. Either grandfather the policy (owner + step-up) and deploy, or " +
1407
+ "keep the project vaulted and deploy later. Your source can still be vaulted today: `run402 gitvault push` and " +
1408
+ "`git push run402 <branch>` are not gated on a deploy.",
1409
+ next_actions: [
1410
+ editRequestAction(
1411
+ "run402 gitvault policy grandfathered --reason \"<why>\"",
1412
+ "Un-gate this project so deploys activate without a vaulted capture. Owner + step-up, audited, and it leaves a doctor-persistent warning until you return the project to `required`.",
1413
+ ),
1414
+ editRequestAction(
1415
+ "run402 gitvault push",
1416
+ "Capture and publish your source into the vault. Independent of deploy — a vault-only project pushes for months without one.",
1417
+ ),
1418
+ editRequestAction(
1419
+ "run402 gitvault policy required",
1420
+ "Restore the gate once a gitvault-capable deploy lane is available.",
1421
+ ),
1422
+ // Preserved verbatim in shape, with the promise corrected: the gateway
1423
+ // is describing the eventual client, not one you can install today.
1424
+ ...gatewayActions.map((action) =>
1425
+ action?.type === "upgrade_client"
1426
+ ? { ...action, why: "Tracks the eventual gitvault-capable deploy lane. No published run402 has one yet, so this does not resolve the refusal today." }
1427
+ : action,
1428
+ ),
1429
+ ],
1430
+ };
1431
+ return enhanced;
1432
+ }
1433
+
1357
1434
  function enhanceDeployWarningError(err) {
1358
1435
  const existingBody = err?.body && typeof err.body === "object" && !Array.isArray(err.body)
1359
1436
  ? err.body
package/lib/doctor.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, statSync } from "node:fs";
15
- import { configDir, readAllowance, loadKeyStore } from "./config.mjs";
15
+ import { configDir, readAllowance, loadKeyStore, getActiveProjectId } from "./config.mjs";
16
16
  import { getSdk } from "./sdk.mjs";
17
17
  import {
18
18
  resolveScanRoot,
@@ -58,6 +58,10 @@ Checks performed:
58
58
  - Function runtime staleness: deployed functions running an older platform
59
59
  runtime than the current gateway build (refresh with 'run402 functions
60
60
  rebuild --all'; re-bundles from your stored source, no source change)
61
+ - gitvault: the active project's vault — activation policy, whether THIS
62
+ machine can produce the capture a 'required' policy demands, open
63
+ unvaulted-override journals, and where the keystore lives (back it up:
64
+ whole-keystore loss is terminal for vault history)
61
65
  - Source scan: hallucinated SDK auth names (R402_AUTH_UNKNOWN_EXPORT),
62
66
  state-changing GET handlers (R402_AUTH_STATE_CHANGING_GET),
63
67
  auth.* calls in prerendered pages (R402_AUTH_PRERENDERED),
@@ -392,6 +396,81 @@ export async function run(sub, args = []) {
392
396
  });
393
397
  }
394
398
 
399
+ // 6c. gitvault (add-gitvault). Doctor was completely silent about the vault
400
+ // even when `gitvault_policy: required` was the single thing that would break
401
+ // the project's next deploy (dogfood #1, finding D1) — and doctor is where a
402
+ // user looks when something is wrong. It also prints WHERE the keystore is:
403
+ // "whole-keystore loss is terminal" was stated three times across this
404
+ // surface while the directory to back up was stated nowhere (finding D2).
405
+ //
406
+ // Read-only and best-effort in every branch: no project, no vault, or a
407
+ // gateway that does not know gitvault are all ordinary and report `skipped`
408
+ // or `ok`, never a doctor failure. A vault-only project that has never
409
+ // deployed is a first-class shape (protocol D183), so its mere absence of a
410
+ // deploy raises nothing.
411
+ {
412
+ const projectId = (process.env.RUN402_PROJECT_ID || "").trim() || getActiveProjectId() || null;
413
+ if (!projectId) {
414
+ checks.push({
415
+ name: "gitvault",
416
+ status: "skipped",
417
+ ...(verbose && { hint: "no active project — run 'run402 projects use <project_id>' to check its vault." }),
418
+ });
419
+ } else {
420
+ try {
421
+ const gv = await getSdk().gitvault.status({ project_id: projectId, repo_dir: process.cwd() });
422
+ const value = {
423
+ project_id: projectId,
424
+ repo_id: gv.repo_id,
425
+ vault: gv.vault === null ? null : "allocated",
426
+ gitvault_policy: gv.gitvault_policy,
427
+ keystore_root: gv.keystore.root,
428
+ can_sign: gv.keystore.can_sign,
429
+ holds_repo_key: gv.keystore.holds_repo_key,
430
+ pending_overrides: gv.pending_overrides,
431
+ pins: gv.pins,
432
+ remote: gv.remote,
433
+ };
434
+ const gaps = [];
435
+ // The one that actually breaks the next deploy: the project demands a
436
+ // vaulted capture and THIS machine cannot produce one.
437
+ if (gv.gitvault_policy === "required" && !gv.keystore.holds_repo_key) {
438
+ gaps.push(
439
+ "gitvault_policy is 'required' but this machine holds no key for the vault — a deploy from here is refused with GITVAULT_CLIENT_UPGRADE_REQUIRED. " +
440
+ "Run 'run402 gitvault init' (idempotent; resolves to the existing vault), or 'run402 gitvault policy grandfathered --reason <why>' to un-gate the project.",
441
+ );
442
+ } else if (gv.gitvault_policy === "required" && !gv.keystore.can_sign) {
443
+ gaps.push("gitvault_policy is 'required' and this keystore is read-only (no signing key) — it can verify but cannot publish the capture a deploy needs");
444
+ }
445
+ if (gv.pending_overrides > 0) {
446
+ gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
447
+ }
448
+ if (gv.remote && !gv.remote.matches) {
449
+ gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${projectId} (${gv.remote.url})`);
450
+ }
451
+ // Echoed exactly as the SDK reported them — including the
452
+ // doctor-persistent `grandfathered` advisory it owns.
453
+ for (const w of gv.warnings ?? []) gaps.push(`${w.kind}: ${w.message}`);
454
+ checks.push({
455
+ name: "gitvault",
456
+ status: gaps.length > 0 ? "warning" : "ok",
457
+ value: gaps.length > 0 ? { ...value, gaps } : value,
458
+ hint: gv.vault === null
459
+ ? `No vault for this project (that is a normal shape). Allocate one with 'run402 gitvault init'. Keystore: ${gv.keystore.root}`
460
+ : `Back up ${gv.keystore.root} — whole-machine or whole-keystore loss is terminal for vault history.`,
461
+ });
462
+ } catch (err) {
463
+ // A gateway without gitvault, an unreachable API, or a project this
464
+ // wallet cannot see. None of those is a local health problem.
465
+ checks.push({
466
+ name: "gitvault",
467
+ status: "skipped",
468
+ message: describeCheckFailure("gitvault status check", err),
469
+ });
470
+ }
471
+ }
472
+ }
473
+
395
474
  // 7. Source-tree scan (auth-aware-ssr Section 9). Detects hallucinated
396
475
  // SDK names, state-changing GETs, auth.* in prerendered pages, and
397
476
  // direct mutation of internal.sessions.authz_version. Hits with severity
package/lib/domains.mjs CHANGED
@@ -20,9 +20,6 @@ Subcommands:
20
20
  wait <domain> [--project <id>] [--until active|safe|receive-active] [--timeout-ms <n>] [--interval-ms <n>]
21
21
  activate <domain> [--project <id>]
22
22
  disconnect <domain> --confirm [--project <id>]
23
-
24
- Removed:
25
- add, delete. Use connect/disconnect.
26
23
  `;
27
24
 
28
25
  const SUB_HELP = {
@@ -135,15 +132,6 @@ function printHandoff(data) {
135
132
  console.error(lines.join("\n"));
136
133
  }
137
134
 
138
- function removed(command, replacement) {
139
- fail({
140
- code: "COMMAND_REMOVED",
141
- message: `${command} has been removed. Use ${replacement}.`,
142
- details: { command, replacement },
143
- next_actions: [{ type: "use_replacement_command", command: replacement }],
144
- });
145
- }
146
-
147
135
  function parseCommon(args, extraKnown = [], valueFlags = COMMON_VALUE_FLAGS) {
148
136
  const parsed = normalizeArgv(args);
149
137
  assertKnownFlags(parsed, [...valueFlags, ...extraKnown, "--help", "-h"], valueFlags);
@@ -447,8 +435,6 @@ export async function run(sub, args) {
447
435
  case "wait": await action("wait", args); break;
448
436
  case "activate": await action("activate", args); break;
449
437
  case "disconnect": await disconnect(args); break;
450
- case "add": removed("run402 domains add", "run402 domains connect <domain> --project <id> --web"); break;
451
- case "delete": removed("run402 domains delete", "run402 domains disconnect <domain> --project <id> --confirm"); break;
452
438
  default:
453
439
  failUnknownSubcommand("domains", sub);
454
440
  }