run402 4.36.1 → 4.37.1

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 (41) hide show
  1. package/README.md +15 -0
  2. package/cli.mjs +6 -0
  3. package/git-remote-run402.mjs +63 -3
  4. package/lib/command-manifest.mjs +13 -1
  5. package/lib/gitvault-scaffold.mjs +59 -0
  6. package/lib/gitvault.mjs +69 -22
  7. package/lib/init.mjs +8 -3
  8. package/lib/projects.mjs +16 -0
  9. package/lib/repos.mjs +324 -0
  10. package/lib/up.mjs +167 -29
  11. package/package.json +1 -1
  12. package/sdk/dist/errors.d.ts +1 -1
  13. package/sdk/dist/errors.d.ts.map +1 -1
  14. package/sdk/dist/errors.js.map +1 -1
  15. package/sdk/dist/index.d.ts +1 -1
  16. package/sdk/dist/index.d.ts.map +1 -1
  17. package/sdk/dist/index.js +1 -1
  18. package/sdk/dist/index.js.map +1 -1
  19. package/sdk/dist/namespaces/deploy.types.d.ts +9 -0
  20. package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/deploy.types.js.map +1 -1
  22. package/sdk/dist/namespaces/gitvault.d.ts +133 -16
  23. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  24. package/sdk/dist/namespaces/gitvault.js +207 -16
  25. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  26. package/sdk/dist/node/gitvault-apply.d.ts +18 -1
  27. package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
  28. package/sdk/dist/node/gitvault-apply.js +38 -1
  29. package/sdk/dist/node/gitvault-apply.js.map +1 -1
  30. package/sdk/dist/node/gitvault-creation-journal.d.ts +22 -0
  31. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  32. package/sdk/dist/node/gitvault-creation-journal.js +28 -0
  33. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  34. package/sdk/dist/node/gitvault-open-or-create.d.ts +59 -0
  35. package/sdk/dist/node/gitvault-open-or-create.d.ts.map +1 -0
  36. package/sdk/dist/node/gitvault-open-or-create.js +62 -0
  37. package/sdk/dist/node/gitvault-open-or-create.js.map +1 -0
  38. package/sdk/dist/node/index.d.ts +3 -1
  39. package/sdk/dist/node/index.d.ts.map +1 -1
  40. package/sdk/dist/node/index.js +4 -1
  41. package/sdk/dist/node/index.js.map +1 -1
package/README.md CHANGED
@@ -52,6 +52,21 @@ For app manifests with `verify.http[]`, `up` runs HTTP checks after deploy. Fres
52
52
 
53
53
  For typed `run402.deploy.ts` configs, pass `--manifest` explicitly because TypeScript/JavaScript configs execute local code. Use `--check` for local-only import/normalize/file validation, `--print-spec` to inspect the normalized `ReleaseSpec`, `--plan` for a gateway-reviewed non-deploying plan, and `--require-plan <plan_id>` to apply only that reviewed intent. Warning flags are not used with `--require-plan`; the reviewed plan binds the exact warning/destructive set. Run402 Core skips Cloud allowance/tier prerequisites and fails closed when no Core project is selected.
54
54
 
55
+ ### Vault-only repos (zero deploy ceremony)
56
+
57
+ ```bash
58
+ run402 init # once per machine
59
+ git remote add origin run402::<org_id>/<project_id>
60
+ git push -u origin main # allocates the vault on first push, publishes
61
+
62
+ # or, one call: provision + allocate + scaffold, nothing deployed
63
+ run402 repos create my-notes
64
+ run402 repos list --org org_1a2b3c
65
+ run402 repos delete prj_xyz --force # refuses without --force while the vault holds generations
66
+ ```
67
+
68
+ A hosted git remote, encrypted before it leaves the machine — no deploy, no manifest, no app. `origin` is claimed additively — an existing `origin` is never touched, the run402 remote falls back to `run402` instead. `repos create|list|delete` and `gitvault`'s mutating verbs (`init`, `snapshot`, `policy`, `compact`, `prune`) are CLI/OpenClaw-only by design — no MCP tool exists or will exist for them (one-shot recovery receipts, immutable generations, irreversible delete). See `run402 gitvault --help` and `run402 repos --help` for the full surface, and the CLI reference's `gitvault` / `repos` sections for the terminal-loss statement and the progressive backup warning.
69
+
55
70
  ### Allowance
56
71
 
57
72
  ```bash
package/cli.mjs CHANGED
@@ -32,6 +32,7 @@ SET UP — get an agent funded and entitled
32
32
  BUILD & SHIP — the app and everything it serves
33
33
  up Provision/link/deploy the current app with SDK orchestration
34
34
  projects Manage projects (provision, list, get, sql, delete)
35
+ repos Vault-only hosted encrypted repos, zero deploy ceremony (create, list, delete)
35
36
  deploy Unified deploy operations (requires active tier)
36
37
  apply Alias for deploy apply; supports --rehearse for migration rehearsal
37
38
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
@@ -221,6 +222,11 @@ switch (cmd) {
221
222
  await run(sub, rest);
222
223
  break;
223
224
  }
225
+ case "repos": {
226
+ const { run } = await import("./lib/repos.mjs");
227
+ await run(sub, rest);
228
+ break;
229
+ }
224
230
  case "apply": {
225
231
  const { runDeployV2 } = await import("./lib/deploy-v2.mjs");
226
232
  await runDeployV2("apply", [sub, ...rest].filter(Boolean));
@@ -19,6 +19,15 @@
19
19
  * push [+]<src>:<dst> → publish one atomic ref transaction
20
20
  * option <name> <value> → ok / unsupported, never a silent lie
21
21
  *
22
+ * LAZY ALLOCATION ON FIRST PUSH (design D2). `list` never creates anything —
23
+ * an unallocated vault reports as an empty ref set, exactly what a fresh
24
+ * repository looks like — but `push` does: `git push origin main` against a
25
+ * project whose vault does not exist yet runs the six-stage creation journal
26
+ * inline (`r.gitvault.openOrCreate`, the SDK-owned primitive), prints the
27
+ * one-shot recovery receipt and the keystore path to stderr, and then
28
+ * completes the push. One command, no prior `gitvault init`. `git ls-remote`
29
+ * / `fetch` stay pure reads and allocate nothing.
30
+ *
22
31
  * WHICH REPOSITORY (the fail-closed rule). `process.cwd()` is NOT the
23
32
  * repository. git identifies the repository with `GIT_DIR`, and during
24
33
  * `git clone` cwd is the directory clone was RUN FROM — routinely some other,
@@ -120,7 +129,11 @@ async function main(argv) {
120
129
  return 1;
121
130
  }
122
131
 
123
- const target = { project_id: address.project_id };
132
+ // `org_id` rides in the parsed address (`run402::<org_id>/<project_id>`),
133
+ // so it costs nothing extra to carry — it is exactly what D2's lazy
134
+ // creation needs to allocate an unresolved vault from `runPush` below, with
135
+ // no separate lookup.
136
+ const target = { project_id: address.project_id, org_id: address.org_id };
124
137
  let verbosity = 1;
125
138
 
126
139
  /**
@@ -150,8 +163,55 @@ async function main(argv) {
150
163
  /** Open the vault lazily — `capabilities` and `option` must never touch the network. */
151
164
  const openVault = async (repoDir) => (await getSdk().gitvault.open(repoDir ? { ...target, repo_dir: repoDir } : target)).vault;
152
165
 
166
+ /** A 404/absent-vault refusal — the "nothing here yet" shape, never a genuine failure to mask. */
167
+ function isVaultNotFound(err) {
168
+ return err?.status === 404 || err?.code === "RESOURCE_NOT_FOUND" || err?.code === "ROUTE_NOT_FOUND";
169
+ }
170
+
171
+ /**
172
+ * Open the vault, allocating it first when it does not exist yet (D2). Used
173
+ * ONLY by `runPush` — `list`/`fetch` stay pure reads and never create
174
+ * anything (see `runList`'s own not-found handling below).
175
+ *
176
+ * Prints the one-shot recovery receipt and the keystore path to stderr the
177
+ * moment allocation happens, per the client-surface spec: an agent reads
178
+ * stderr, and the receipt is worth exactly as many copies as get kept.
179
+ */
180
+ async function openOrCreateVault(repoDir) {
181
+ const result = await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir });
182
+ if (!result.found && result.created) {
183
+ note("");
184
+ note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
185
+ note(JSON.stringify(result.created.recovery_receipt));
186
+ try {
187
+ const { getGitvaultKeystoreRoot } = await import("#sdk/node");
188
+ note(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship`);
189
+ } catch {
190
+ // Never let a diagnostic line fail a push that already allocated successfully.
191
+ }
192
+ note("");
193
+ }
194
+ return result.handle.vault;
195
+ }
196
+
153
197
  async function runList() {
154
- const state = await (await openVault()).materialize();
198
+ let state;
199
+ try {
200
+ state = await (await openVault()).materialize();
201
+ } catch (err) {
202
+ // An unallocated vault is not an error here: `list` is the read half of
203
+ // the protocol dance and must never create anything on its own (D2
204
+ // scopes lazy creation to `push`). Reporting it as an EMPTY ref set is
205
+ // exactly what a fresh repository looks like to git, and `push` still
206
+ // runs `list` first either way — this is what lets a first push land in
207
+ // one command instead of `list` failing the whole exchange before
208
+ // `push` ever gets a turn.
209
+ if (isVaultNotFound(err)) {
210
+ endBlock();
211
+ return;
212
+ }
213
+ throw err;
214
+ }
155
215
  const refs = state.refs ?? {};
156
216
  for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
157
217
  const head = state.head_target;
@@ -198,7 +258,7 @@ async function main(argv) {
198
258
  ? null
199
259
  : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
200
260
  }
201
- const vault = await openVault(repoDir);
261
+ const vault = await openOrCreateVault(repoDir);
202
262
  const base = await vault.materialize();
203
263
  const updates = [];
204
264
  for (const spec of specs) {
@@ -257,11 +257,23 @@ export const COMMAND_MANIFEST = [
257
257
  // universal `{}` fetch mock or touch the gate's own checkout.
258
258
  { 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" },
259
259
  { path: ["gitvault", "status"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "reads the local principal keystore and the live vault record" },
260
- { path: ["gitvault", "push"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and publishes a signed head" },
260
+ { path: ["gitvault", "snapshot"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and publishes a signed head" },
261
+ // D5 (repo-first-onramp task 2.5): `push` is a deprecation-warning alias
262
+ // for `snapshot`, retained for exactly one release — same structural
263
+ // shape, same skip reason, so it stays covered by this gate too.
264
+ { path: ["gitvault", "push"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "deprecated alias for `snapshot` — captures the cwd git working tree and publishes a signed head" },
261
265
  { 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" },
262
266
  { path: ["gitvault", "compact"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "takes a maintenance lease and builds a checkpoint from the local repository" },
263
267
  { path: ["gitvault", "prune"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "materializes the live vault head to enumerate retention roots" },
264
268
  { path: ["gitvault", "verify"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "walks the live head chain against the keystore's authenticated pin" },
269
+
270
+ // ── repos (vault-only porcelain, repo-first-onramp D8, task 2.6) ────────
271
+ // `create` writes real git state into cwd and allocates a vault; `list`
272
+ // and `delete` read/mutate the live gitvault record — same structural-
273
+ // checks-only rationale as the gitvault family just above.
274
+ { path: ["repos", "create"], positionals: [p("name")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["my-notes"], runStyle: "sub", skipBehavioral: "provisions a project, allocates a vault, and scaffolds a real git remote into cwd" },
275
+ { path: ["repos", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "cross-references live projects against their live gitvault status, one call per project" },
276
+ { path: ["repos", "delete"], positionals: [], projectScoped: true, legacyPositionalProject: true, minimalArgs: [], runStyle: "sub", skipBehavioral: "irreversibly deletes a project after reading its live vault generation count" },
265
277
  { path: ["errors"], positionals: [p("fingerprint_id", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
266
278
 
267
279
  // ── jobs ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared git-remote-scaffold fold-in (repo-first-onramp D4, task 2.4).
3
+ *
4
+ * `run402 init`'s 5b block pioneered this pattern; this module is the same
5
+ * additive-only, non-fatal-in-every-branch scaffold factored out so
6
+ * `projects provision`, `run402 up`, and `run402 repos create` all report
7
+ * through the IDENTICAL `gitvault` / `gitvault_skipped` / `gitvault_error`
8
+ * summary keys `init` already uses, instead of three near-copies drifting
9
+ * apart. `init.mjs` itself is left untouched — its own scaffold block is
10
+ * shipped and tested; this module exists for the NEW callers task 2.4 adds.
11
+ *
12
+ * Never creates a repository on its own unless `createRepoIfMissing` is
13
+ * explicit (mirrors `init`'s `--git-remote` opt-in): a directory the caller
14
+ * did not ask to turn into a repository is left alone. `origin` is never
15
+ * touched or reclaimed (D1, via `gitvault.scaffoldRemote` itself). Every
16
+ * branch is non-fatal — a missing git, an unresolvable org, or an
17
+ * unreachable gateway all report and return, never throw.
18
+ */
19
+ import { getSdk } from "./sdk.mjs";
20
+ import { resolveOwningOrgId } from "./org-context.mjs";
21
+
22
+ /**
23
+ * @param {object} options
24
+ * @param {string} [options.repoDir] Working tree to scaffold. Defaults to `process.cwd()`.
25
+ * @param {string} options.projectId The project the remote should point at.
26
+ * @param {string} [options.orgId] Explicit owning org. Resolved via `resolveOwningOrgId` when omitted.
27
+ * @param {boolean} [options.createRepoIfMissing] Opt into `git init`-ing `repoDir` when it is not a repository yet.
28
+ * @returns {Promise<{gitvault: object|null, gitvault_skipped?: string, gitvault_error?: {code: string, message: string}}>}
29
+ */
30
+ export async function scaffoldGitvaultRemote({ repoDir = process.cwd(), projectId, orgId, createRepoIfMissing = false } = {}) {
31
+ const out = { gitvault: null };
32
+ try {
33
+ const { hardenedGit } = await import("#sdk/node");
34
+ let insideRepo = true;
35
+ try {
36
+ await hardenedGit(repoDir, ["rev-parse", "--git-dir"]);
37
+ } catch {
38
+ insideRepo = false;
39
+ }
40
+ if (!insideRepo && !createRepoIfMissing) {
41
+ out.gitvault_skipped = "not a git repository — re-run with --git-remote to create one and add the remote";
42
+ return out;
43
+ }
44
+ const resolvedOrgId = orgId ?? (await resolveOwningOrgId(projectId));
45
+ if (!resolvedOrgId) {
46
+ out.gitvault_skipped = `could not resolve the owning org for ${projectId} — the run402 remote was not added`;
47
+ return out;
48
+ }
49
+ const remote = await getSdk().gitvault.scaffoldRemote({ repo_dir: repoDir, org_id: resolvedOrgId, project_id: projectId });
50
+ // `allocated: false` is stated, not left to be inferred: this is local
51
+ // git only — no vault exists for the project yet (allocation happens
52
+ // lazily on first push, or explicitly via `run402 gitvault init`).
53
+ out.gitvault = { ...remote, allocated: false };
54
+ } catch (err) {
55
+ out.gitvault = null;
56
+ out.gitvault_error = { code: err?.body?.code ?? err?.code ?? "GITVAULT_SCAFFOLD_FAILED", message: err?.message ?? String(err) };
57
+ }
58
+ return out;
59
+ }
package/lib/gitvault.mjs CHANGED
@@ -37,15 +37,15 @@ const COMMON_VALUE_FLAGS = ["--project", "--repo"];
37
37
  export const HELP = `run402 gitvault — your source, encrypted before it leaves the machine
38
38
 
39
39
  Usage:
40
- run402 gitvault init [--project <id>] [--org <org_id>] [--git-remote] [--no-remote]
41
- run402 gitvault status [--project <id>] [--repo <repo_id>] [--refs]
42
- run402 gitvault push [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint]
43
- run402 gitvault policy <required|grandfathered> [--project <id>] [--repo <repo_id>]
44
- [--reason <why>]
45
- run402 gitvault compact [--project <id>] [--repo <repo_id>]
46
- run402 gitvault prune [--project <id>] [--repo <repo_id>]
47
- [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
48
- run402 gitvault verify [--project <id>] [--repo <repo_id>] [--budget <n>]
40
+ run402 gitvault init [--project <id>] [--org <org_id>] [--git-remote] [--no-remote]
41
+ run402 gitvault status [--project <id>] [--repo <repo_id>] [--refs]
42
+ run402 gitvault snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint]
43
+ run402 gitvault policy <required|grandfathered> [--project <id>] [--repo <repo_id>]
44
+ [--reason <why>]
45
+ run402 gitvault compact [--project <id>] [--repo <repo_id>]
46
+ run402 gitvault prune [--project <id>] [--repo <repo_id>]
47
+ [--submit --intent-core <path> --verifier-receipt <path> [--wait]]
48
+ run402 gitvault verify [--project <id>] [--repo <repo_id>] [--budget <n>]
49
49
 
50
50
  Subcommands:
51
51
  init ALLOCATE the project's vault. This is the one step that mints key
@@ -65,11 +65,20 @@ Subcommands:
65
65
  audited. \`grandfathered\` is the documented way out of a deploy
66
66
  blocked by GITVAULT_CLIENT_UPGRADE_REQUIRED, and leaves a
67
67
  doctor-persistent warning until the project returns to \`required\`.
68
- push Capture the working tree and publish it. This is NOT gated on a
69
- deploy — a vault-only project pushes for months without one.
70
- Before reporting a push as landed the SDK compares finalization
68
+ snapshot Capture the working tree and publish it. This is NOT gated on a
69
+ deploy — a vault-only project snapshots for months without one.
70
+ Against a project with no vault yet, this ALLOCATES one inline
71
+ (the six-stage creation, same as \`init\`) before publishing — one
72
+ command, no prior \`gitvault init\`. The one-shot recovery receipt
73
+ and keystore path print to stderr the moment that happens.
74
+ Before reporting a snapshot as landed the SDK compares finalization
71
75
  receipts against the expected manifest and reads the admitted head
72
- back from storage; a 200 alone is never enough.
76
+ back from storage; a 200 alone is never enough. \`push\` is a
77
+ deprecation-warning alias for one release — it will be removed
78
+ next release. Once \`gitvault\` was the only publish verb; \`git
79
+ push\` is now the actual publish path (via the remote helper),
80
+ so \`push\` here was renamed to name what it does: one verb per
81
+ operation.
73
82
  compact Publish a checkpoint covering the canonical refs, every root
74
83
  unexpired at the cutoff, and the HEAD target, under a maintenance
75
84
  lease so a concurrent cycle cannot race it.
@@ -97,9 +106,9 @@ Options:
97
106
  chain and advances the local materialized pin), which is
98
107
  why plain \`status\` — an observation — does not do it.
99
108
  --repo <repo_id> Address the vault directly by id, skipping project lookup
100
- --message <text> push: commit message for the synthetic commit a dirty tree
109
+ --message <text> snapshot: commit message for the synthetic commit a dirty tree
101
110
  produces (a clean tree pushes HEAD itself, no message used)
102
- --checkpoint push: force the checkpoint-bearing form regardless of delta size
111
+ --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
103
112
  --budget <n> verify: heads to verify in this call. The verified prefix is
104
113
  persisted, so a budget-exceeded run resumes where it stopped
105
114
  instead of restarting.
@@ -190,8 +199,12 @@ function printTerminalLoss(status) {
190
199
  console.error("");
191
200
  }
192
201
 
193
- /** Where the keystore lives — for verbs whose payload is not a `status`. */
194
- async function printKeystoreLocation() {
202
+ /**
203
+ * Where the keystore lives — for verbs whose payload is not a `status`.
204
+ * Exported: `repos create` (repo-first-onramp task 2.6) prints the same
205
+ * line after allocating a vault, and must not restate this logic.
206
+ */
207
+ export async function printKeystoreLocation() {
195
208
  try {
196
209
  const { getGitvaultKeystoreRoot } = await import("#sdk/node");
197
210
  console.error(`keystore: ${getGitvaultKeystoreRoot()} — back this up; whole-keystore loss is terminal for vault history`);
@@ -268,7 +281,7 @@ async function init(args) {
268
281
  ? `vault ${result.repo_id} already existed — nothing was re-allocated and no new key material was minted`
269
282
  : `allocated vault ${result.repo_id} (genesis ${result.genesis_sha256})`,
270
283
  );
271
- if (result.remote) console.error(`remote '${result.remote.name}' -> ${result.remote.url}`);
284
+ if (result.remote) console.error(`remote '${result.remote.name}' -> ${result.remote.url} (${result.remote.reason})`);
272
285
  if (remoteSkipped) console.error(`remote not added: ${remoteSkipped}`);
273
286
  // The recovery receipt is integrity data, not a secret, and it is worth
274
287
  // exactly as much as the number of copies you keep. It is persisted into
@@ -376,20 +389,45 @@ async function status(args) {
376
389
  }
377
390
  }
378
391
 
379
- async function push(args) {
392
+ /**
393
+ * D5 (repo-first-onramp task 2.5): one verb per operation. Renamed from
394
+ * `push` — `push` now means exactly one thing everywhere: `git push`. The
395
+ * capture lane keeps its old function name internally to minimize churn;
396
+ * only the dispatched SUBCOMMAND name changed (see `run()` below, where
397
+ * `gitvault push` survives one release as a deprecation-warning alias).
398
+ */
399
+ async function snapshot(args) {
380
400
  const a = normalizeArgv(args);
381
401
  const valueFlags = [...COMMON_VALUE_FLAGS, "--message"];
382
402
  assertKnownFlags(a, [...valueFlags, "--checkpoint", "--help", "-h"], valueFlags);
383
403
  requirePositionalCount(a, valueFlags, {
384
- min: 0, max: 0, command: "run402 gitvault push", missing: "",
404
+ min: 0, max: 0, command: "run402 gitvault snapshot", missing: "",
385
405
  });
386
406
  const message = flagValue(a, "--message");
407
+ const target = vaultTarget(a);
408
+ // D2: lazily allocate the vault on first push when there is a project to
409
+ // resolve the owning org from — the same resolution `gitvault init` uses.
410
+ // `--repo`-only addressing has nothing to create FROM (no project_id), so
411
+ // it is skipped there, matching `open()`'s own precedence.
412
+ const orgId = target.project_id ? await resolveOwningOrgId(target.project_id) : null;
387
413
  const opts = {
388
- ...vaultTarget(a),
414
+ ...target,
415
+ ...(orgId ? { org_id: orgId } : {}),
389
416
  // The gitvault_commit line is progress, not payload: print it the moment
390
417
  // the snapshot exists, well before the publication round-trips finish, so
391
418
  // a human watching a slow push sees what is being pushed.
392
419
  onCommitLine: (line) => console.error(line),
420
+ // Fires synchronously, BEFORE the capture/publish that follows — printed
421
+ // here rather than deferred past `push()`'s return so the receipt is
422
+ // never lost if a later step in the SAME push fails after allocation
423
+ // already landed on the server.
424
+ onVaultCreated: async (created) => {
425
+ console.error("");
426
+ console.error(`vault allocated (genesis ${created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
427
+ console.error(JSON.stringify(created.recovery_receipt));
428
+ await printKeystoreLocation();
429
+ console.error("");
430
+ },
393
431
  };
394
432
  // The message rides on `snapshot`, which is what `captureSnapshot` reads —
395
433
  // and, since 5.12b removed the dead top-level `push({ message })` field, is
@@ -558,8 +596,17 @@ export async function run(sub, args) {
558
596
  await status(argv);
559
597
  break;
560
598
  }
599
+ case "snapshot": {
600
+ await snapshot(argv);
601
+ break;
602
+ }
561
603
  case "push": {
562
- await push(argv);
604
+ // D5: one verb per operation — "push" now means exactly one thing,
605
+ // `git push`. Retained as a deprecation-warning alias for ONE release
606
+ // (pre-launch, the benchmark gate prefers the rename now over an
607
+ // alias forever); it will be removed next release.
608
+ console.error("`run402 gitvault push` is deprecated and will be removed in the next release — use `run402 gitvault snapshot` instead.");
609
+ await snapshot(argv);
563
610
  break;
564
611
  }
565
612
  case "compact": {
package/lib/init.mjs CHANGED
@@ -557,7 +557,10 @@ export async function run(args = []) {
557
557
  // Purely LOCAL git. No vault is allocated and no key material is written
558
558
  // here — the spec is explicit that neither exists until first capture, so the
559
559
  // cold-start path gains no prompt and no new failure mode. Allocation happens
560
- // on the first `run402 gitvault push` (or deploy).
560
+ // on the first `git push origin <branch>` or `run402 gitvault snapshot` (D2,
561
+ // repo-first-onramp) — NOT on deploy: `applyWithGitvault` only ever reads an
562
+ // EXISTING vault's policy (D3) and never allocates one that does not exist,
563
+ // so a project with no vault deploys exactly as it always did.
561
564
  //
562
565
  // Adding the remote is the DEFAULT inside a repository that already exists,
563
566
  // because it is pure addition: `origin` is never modified or claimed, no file
@@ -608,11 +611,13 @@ export async function run(args = []) {
608
611
  if (remote.already_present && remote.existing_url !== remote.url) {
609
612
  // Left exactly as it was. Name the URL that is actually in place
610
613
  // rather than implying the remote now points at this project.
611
- line("Gitvault", `remote '${remote.name}' already points at ${remote.existing_url} — left unchanged`);
614
+ line("Gitvault", `remote '${remote.name}' already points at ${remote.existing_url} — left unchanged (${remote.reason})`);
612
615
  } else if (remote.already_present) {
613
616
  line("Gitvault", `remote '${remote.name}' already set (${remote.url})`);
614
617
  } else {
615
- line("Gitvault", `${remote.created_repository ? "initialized a repository and added" : "added"} remote '${remote.name}' -> ${remote.url}`);
618
+ // D1: `origin` when it was free, `run402` when it was already
619
+ // taken by something else — `remote.reason` says which happened.
620
+ line("Gitvault", `${remote.created_repository ? "initialized a repository and added" : "added"} remote '${remote.name}' -> ${remote.url} (${remote.reason})`);
616
621
  }
617
622
  }
618
623
  }
package/lib/projects.mjs CHANGED
@@ -6,6 +6,7 @@ import { withAutoApprove } from "./operator.mjs";
6
6
  import { getSdk } from "./sdk.mjs";
7
7
  import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
8
8
  import { stampOrgFromProject } from "./org-context.mjs";
9
+ import { scaffoldGitvaultRemote } from "./gitvault-scaffold.mjs";
9
10
  import { assertKnownFlags, failBadProjectId, flagValue, hasHelp, normalizeArgv, positionalArgs, resolveProjectSelector, validateRegularFile, failUnknownSubcommand } from "./argparse.mjs";
10
11
 
11
12
  const HELP = `run402 projects — Manage your deployed Run402 projects
@@ -299,6 +300,20 @@ async function quote() {
299
300
  }
300
301
  }
301
302
 
303
+ /**
304
+ * D4 (repo-first-onramp task 2.4): `projects provision` already knows the
305
+ * project and (usually) the org, and just set the active project —
306
+ * scaffolding the git remote here is a pure Anticipatory fold, zero extra
307
+ * round-trips beyond the one org lookup provision needed anyway when `--org`
308
+ * was not given explicitly. `provision` has no `--git-remote` opt-in, so a
309
+ * directory that is not already a repository is left untouched, exactly like
310
+ * `run402 init`'s own scaffold (shared via `gitvault-scaffold.mjs`, which
311
+ * emits the SAME `gitvault` / `gitvault_skipped` / `gitvault_error` keys).
312
+ */
313
+ async function foldGitvaultScaffold(out, projectId, explicitOrgId) {
314
+ Object.assign(out, await scaffoldGitvaultRemote({ repoDir: process.cwd(), projectId, orgId: explicitOrgId }));
315
+ }
316
+
302
317
  async function provision(args) {
303
318
  const opts = { tier: "prototype", name: undefined, orgId: undefined, idempotencyKey: undefined };
304
319
  for (let i = 0; i < args.length; i++) {
@@ -373,6 +388,7 @@ async function provision(args) {
373
388
  out.note = `active project changed: ${activeBefore} -> ${activeAfter}`;
374
389
  out.previous_active_project_id = activeBefore;
375
390
  }
391
+ await foldGitvaultScaffold(out, data.project_id, opts.orgId);
376
392
  console.log(JSON.stringify(out, null, 2));
377
393
  } catch (err) {
378
394
  reportSdkError(err);