run402 4.38.0 → 4.38.2

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 (35) hide show
  1. package/core-dist/keystore.js +56 -4
  2. package/git-remote-run402.mjs +88 -5
  3. package/lib/command-manifest.mjs +5 -1
  4. package/lib/doctor.mjs +44 -8
  5. package/lib/gitvault-target.mjs +145 -0
  6. package/lib/gitvault.mjs +35 -15
  7. package/lib/next-actions.mjs +25 -0
  8. package/lib/repos.mjs +24 -3
  9. package/lib/up.mjs +19 -2
  10. package/lib/wallet-context.mjs +88 -12
  11. package/package.json +1 -1
  12. package/sdk/core-dist/keystore.js +56 -4
  13. package/sdk/dist/errors.d.ts +6 -2
  14. package/sdk/dist/errors.d.ts.map +1 -1
  15. package/sdk/dist/errors.js.map +1 -1
  16. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  17. package/sdk/dist/namespaces/gitvault.js +48 -12
  18. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  19. package/sdk/dist/node/gitvault-creation-journal.d.ts +17 -0
  20. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  21. package/sdk/dist/node/gitvault-creation-journal.js +72 -2
  22. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  23. package/sdk/dist/node/gitvault-deploy.js +2 -2
  24. package/sdk/dist/node/gitvault-deploy.js.map +1 -1
  25. package/sdk/dist/node/gitvault-profile-scan.d.ts +24 -0
  26. package/sdk/dist/node/gitvault-profile-scan.d.ts.map +1 -0
  27. package/sdk/dist/node/gitvault-profile-scan.js +73 -0
  28. package/sdk/dist/node/gitvault-profile-scan.js.map +1 -0
  29. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  30. package/sdk/dist/node/gitvault-publication.js +16 -4
  31. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  32. package/sdk/dist/node/index.d.ts +1 -0
  33. package/sdk/dist/node/index.d.ts.map +1 -1
  34. package/sdk/dist/node/index.js +4 -0
  35. package/sdk/dist/node/index.js.map +1 -1
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { getLegacyProjectsPath, getProjectCredentialsPath } from "./config.js";
5
5
  import { clearActiveProjectId as clearProfileActiveProjectId, getActiveProjectId as getProfileActiveProjectId, recordMigration, setActiveProjectId as setProfileActiveProjectId, } from "./profile-state.js";
6
+ import { readAllowance } from "./allowance.js";
6
7
  function withFileLock(path, fn, { retries = 200, delayMs = 20 } = {}) {
7
8
  const lockDir = path + ".lock";
8
9
  mkdirSync(dirname(path), { recursive: true });
@@ -95,7 +96,11 @@ function migrateLegacyProjectsJson(targetPath) {
95
96
  };
96
97
  saveKeyStore(cache, targetPath);
97
98
  if (legacy.active_project_id) {
98
- setProfileActiveProjectId(legacy.active_project_id);
99
+ // Scoped the same way every other write in this module now is (see
100
+ // `currentPrincipal`'s doc comment) — an unscoped write here is exactly
101
+ // what poisons the "unknown"-principal bucket for good on a machine that
102
+ // migrates before its wallet allowance exists yet.
103
+ setProfileActiveProjectId(legacy.active_project_id, undefined, { principal: currentPrincipal() });
99
104
  }
100
105
  recordMigration("projects_json_import", {
101
106
  legacy_path: legacyPath,
@@ -165,12 +170,59 @@ export function removeProject(projectId, path) {
165
170
  saveKeyStore(store, p);
166
171
  });
167
172
  if (!path)
168
- clearProfileActiveProjectId(projectId);
173
+ clearProfileActiveProjectId(projectId, undefined, { principal: currentPrincipal() });
174
+ }
175
+ /**
176
+ * The active-project scope's `principal`, derived from the CURRENT wallet's
177
+ * allowance — matching exactly what `NodeCredentialsProvider.setActiveProject`
178
+ * (sdk/src/node/credentials.ts) writes after `projects.provision` /
179
+ * `projects.use`. Without this, every reader here used the profile-state
180
+ * module's own default (empty) scope, which resolves to a FIXED
181
+ * "unknown"-principal bucket — the same bucket for every wallet in this
182
+ * profile.
183
+ *
184
+ * That mismatch is silent for a brand-new profile (the flat fallback in
185
+ * `profile-state.ts#getActiveProjectId` happens to agree), but once the
186
+ * "unknown" bucket is EVER populated by any principal-less write — the
187
+ * one-time legacy `projects.json` migration below, or any provision/`use`
188
+ * call made before this machine had a wallet allowance — it never gets
189
+ * updated again (real wallet operations write to the principal-keyed
190
+ * bucket, not "unknown") and PERMANENTLY shadows every later wallet-scoped
191
+ * activation for every caller that reads through this module: `resolveProjectId`
192
+ * / `resolveProject` (cli/lib/config.mjs), `projects current`, and every
193
+ * gitvault-verb target resolution. That is the root cause behind
194
+ * kychee-com/run402#559(a) — `repos create` DID call `projects.provision`,
195
+ * whose `creds.setActiveProject` correctly persisted the new project under
196
+ * the real wallet's scoped bucket AND the flat fallback, but every CLI read
197
+ * of "the active project" kept resolving the OLD "unknown"-bucket entry
198
+ * first and never fell through to the freshly-updated flat value.
199
+ *
200
+ * Best-effort: an unreadable/malformed allowance degrades to `null` (the
201
+ * same "unknown" bucket callers already tolerated before this fix), never a
202
+ * throw — this is a read-scoping concern, not an allowance-validity one.
203
+ */
204
+ function currentPrincipal() {
205
+ try {
206
+ return readAllowance()?.address ?? null;
207
+ }
208
+ catch {
209
+ return null;
210
+ }
169
211
  }
170
212
  export function getActiveProjectId(path) {
171
- return getProfileActiveProjectId(path);
213
+ return getProfileActiveProjectId(path, { principal: currentPrincipal() });
172
214
  }
173
215
  export function setActiveProjectId(projectId, path) {
174
- setProfileActiveProjectId(projectId, path);
216
+ setProfileActiveProjectId(projectId, path, { principal: currentPrincipal() });
217
+ }
218
+ /**
219
+ * Scoped the same way as the getter/setter above — a caller reaching for
220
+ * `profile-state.ts`'s `clearActiveProjectId` directly (unscoped) clears only
221
+ * the "unknown"-principal bucket, leaving a real wallet's own scoped entry
222
+ * (set through `setActiveProjectId` above, or `NodeCredentialsProvider`)
223
+ * untouched and still resolvable.
224
+ */
225
+ export function clearActiveProjectId(projectId, path) {
226
+ clearProfileActiveProjectId(projectId, path, { principal: currentPrincipal() });
175
227
  }
176
228
  //# sourceMappingURL=keystore.js.map
@@ -71,7 +71,7 @@
71
71
  * - `push` never changes the vault's HEAD target; the SDK carries it forward.
72
72
  * A fresh vault defaults to `refs/heads/main`, so a first push of some
73
73
  * other branch leaves HEAD naming a ref that does not exist yet. Use
74
- * `run402 gitvault push`, which sets the HEAD target from the local HEAD.
74
+ * `run402 gitvault snapshot`, which sets the HEAD target from the local HEAD.
75
75
  * - `option dry-run` is `unsupported`: this helper cannot rehearse a
76
76
  * publication, and reporting a fake success would be worse than refusing.
77
77
  * - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
@@ -83,6 +83,7 @@
83
83
 
84
84
  import { createInterface } from "node:readline";
85
85
  import { getSdk } from "./lib/sdk.mjs";
86
+ import { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } from "./lib/wallet-context.mjs";
86
87
  import { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } from "#sdk";
87
88
  import { hardenedGit, resolveGitInvocationRepo } from "#sdk/node";
88
89
 
@@ -91,14 +92,81 @@ const out = (line) => process.stdout.write(`${line}\n`);
91
92
  const endBlock = () => process.stdout.write("\n");
92
93
  const note = (line) => process.stderr.write(`git-remote-run402: ${line}\n`);
93
94
 
94
- /** Protocol lines are single-line: collapse anything that could break framing. */
95
- function oneLine(value) {
96
- return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
95
+ /**
96
+ * Wallet selection (kychee-com/run402#558). Before this, this file called
97
+ * `getSdk()` directly and ran NO wallet selection at all — a `.run402.json`
98
+ * binding, and even the global `wallets use` default, silently never
99
+ * reached it; only the `RUN402_WALLET` env layer worked, so a bound
100
+ * checkout's very next `git push run402 main` after a correctly-bound
101
+ * `run402 repos create` used the WRONG wallet's (usually empty) allowance.
102
+ *
103
+ * Shares `resolveWalletCore`/`enforceWalletExistsCore` with the CLI
104
+ * (`cli/lib/wallet-context.mjs`) — ONE implementation, minus the CLI's
105
+ * `--wallet` flag layer (this binary parses no argv flags at all). Resolved
106
+ * and applied (`process.env.RUN402_WALLET`) once per invocation, right
107
+ * before the first credential-touching call — never for `capabilities` /
108
+ * `option`, which touch neither credentials nor the network.
109
+ *
110
+ * WHICH DIRECTORY the binding walk starts from is NOT uniform, for the same
111
+ * fail-closed reason this file's own header explains for repository
112
+ * resolution: `list` needs no repository (a repository-free `git ls-remote`
113
+ * outside any checkout must keep working), so it walks from `process.cwd()`.
114
+ * `fetch`/`push` DO have a resolved repository by the time wallet selection
115
+ * runs (`requireRepo()` already succeeded) — walking from ITS directory
116
+ * rather than cwd is what makes `git clone` (cwd = wherever clone was RUN
117
+ * FROM, not the target repo) pick up a binding committed in the target
118
+ * repository, not whatever checkout happened to be current.
119
+ */
120
+ let resolvedWallet = null;
121
+
122
+ function applyWalletForDir(dir) {
123
+ const resolved = resolveWalletCore({ env: process.env, cwd: dir });
124
+ enforceWalletExistsCore(resolved);
125
+ process.env.RUN402_WALLET = resolved.name;
126
+ resolvedWallet = resolved;
127
+ return resolved;
128
+ }
129
+
130
+ /** The resolved wallet's selection source, in the same words the CLI's own `--wallet` provenance line uses. `null` for the bare, unselected default. */
131
+ function walletSourceLabel(resolved) {
132
+ if (!resolved) return null;
133
+ if (resolved.source === "env") return "RUN402_WALLET";
134
+ if (resolved.source === "binding") return resolved.sourceDetail; // the .run402.json path
135
+ if (resolved.source === "config") return "wallets use";
136
+ return null; // "default" — nothing selected anything
137
+ }
138
+
139
+ /**
140
+ * The allowance-missing/malformed family (`core/src/allowance.ts`'s own
141
+ * throws) all end with "Back up the file and run 'run402 init' to recreate
142
+ * it." — a remedy that assumes the resolved wallet is the one you meant.
143
+ * That is only true when NOTHING selected a wallet (the bare default); when
144
+ * an env var or a binding DID name one, the remedy is actively harmful —
145
+ * `run402 init` recreates the DEFAULT wallet's allowance, a DIFFERENT
146
+ * wallet than the one that was actually resolved and whose allowance is
147
+ * actually missing/broken (kychee-com/run402#558's second defect). Replace
148
+ * it with the resolved wallet's name and how selection works, so the fix is
149
+ * "correct the selection" rather than "recreate the wrong wallet".
150
+ */
151
+ function enrichAllowanceError(message) {
152
+ if (!resolvedWallet || !/allowance\.json/.test(message)) return message;
153
+ const source = walletSourceLabel(resolvedWallet);
154
+ const stripped = message.replace(/\s*Back up the file and run 'run402 init' to recreate it\.?/, "").trim();
155
+ if (!source) {
156
+ // Genuinely the bare default wallet with no override anywhere — the
157
+ // original remedy already names the right target.
158
+ return `${stripped} Back up the file and run 'run402 init' to recreate it.`;
159
+ }
160
+ return (
161
+ `${stripped} Resolved wallet '${resolvedWallet.name}' via ${source} ` +
162
+ "(order: RUN402_WALLET env > .run402.json binding > 'wallets use' default > default). " +
163
+ `Wrong wallet? Fix selection instead. Right wallet, just no allowance yet? 'run402 wallets new ${resolvedWallet.name}'.`
164
+ );
97
165
  }
98
166
 
99
167
  function describeError(err) {
100
168
  const code = err?.code ?? err?.body?.code ?? null;
101
- const message = err?.message ?? err?.body?.message ?? String(err);
169
+ const message = enrichAllowanceError(err?.message ?? err?.body?.message ?? String(err));
102
170
  // SLUG_RELEASED is never auto-followed — but the successor slug (design D6)
103
171
  // is exactly the fact a human/agent reading stderr needs to act on it.
104
172
  const released = gitvaultSlugReleasedInfo(err);
@@ -106,6 +174,11 @@ function describeError(err) {
106
174
  return oneLine(code ? `${code}: ${message}${suffix}` : message);
107
175
  }
108
176
 
177
+ /** Protocol lines are single-line: collapse anything that could break framing. */
178
+ function oneLine(value) {
179
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
180
+ }
181
+
109
182
  /**
110
183
  * Resolve the vault address from git's argv.
111
184
  *
@@ -234,6 +307,10 @@ async function main(argv) {
234
307
  }
235
308
 
236
309
  async function runList() {
310
+ // `list` needs no repository (a repository-free `git ls-remote` outside
311
+ // any checkout must keep working) — the binding walk falls back to cwd,
312
+ // same as `capabilities`/`option`'s repository-free tier.
313
+ applyWalletForDir(process.cwd());
237
314
  let state;
238
315
  try {
239
316
  state = await (await openVault()).materialize();
@@ -274,6 +351,10 @@ async function main(argv) {
274
351
  repoRefusalNote(err);
275
352
  return 1;
276
353
  }
354
+ // The repository is resolved — walk the binding from ITS directory, not
355
+ // cwd (the "WHICH REPOSITORY" note above: during `git clone` cwd is
356
+ // wherever clone was run FROM, unrelated to the target repository).
357
+ applyWalletForDir(repoDir);
277
358
  if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
278
359
  const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
279
360
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
@@ -288,6 +369,8 @@ async function main(argv) {
288
369
  // network: a push that names a ref this repository does not have must
289
370
  // fail locally rather than after opening the vault.
290
371
  const repoDir = await requireRepo();
372
+ // Same "walk from the resolved repository, not cwd" rule as `fetch`.
373
+ applyWalletForDir(repoDir);
291
374
  const newOids = new Map();
292
375
  for (const spec of specs) {
293
376
  // A deletion carries an empty <src>. Everything else is resolved by
@@ -433,7 +433,11 @@ export const COMMAND_MANIFEST = [
433
433
  { path: ["service", "health"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
434
434
  { path: ["cache", "inspect"], positionals: [p("url")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["https://example.com/"] },
435
435
  { path: ["cache", "invalidate"], positionals: [p("url", { required: false })], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--all", "--host", "example.com"] },
436
- { path: ["doctor"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--no-scan"], runStyle: "merged" },
436
+ // projectScoped (kychee-com/run402#566): --project targets the gitvault
437
+ // check only (see doctor.mjs's own HELP) — every other check stays
438
+ // wallet/machine-wide, but the gate's contract is "accepts --project
439
+ // without rejecting it," which this satisfies.
440
+ { path: ["doctor"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--no-scan"], runStyle: "merged" },
437
441
  { path: ["webhook-secret", "rotate"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
438
442
  { path: ["logs"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--request-id", "req_gate123"], runStyle: "merged" },
439
443
  ];
package/lib/doctor.mjs CHANGED
@@ -12,7 +12,8 @@
12
12
  */
13
13
 
14
14
  import { existsSync, statSync } from "node:fs";
15
- import { configDir, readAllowance, loadKeyStore, getActiveProjectId } from "./config.mjs";
15
+ import { configDir, readAllowance, loadKeyStore } from "./config.mjs";
16
+ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
16
17
  import { getSdk } from "./sdk.mjs";
17
18
  import {
18
19
  resolveScanRoot,
@@ -23,11 +24,15 @@ import { doctorUpdateCheck } from "./update-check.mjs";
23
24
  import { buildBuzzDoctorReport, parseBuzzDoctorArgs } from "./buzz-doctor.mjs";
24
25
  import { queueBuzzDoctorTelemetry } from "./diagnostic-telemetry.mjs";
25
26
  import { fail } from "./sdk-errors.mjs";
27
+ import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
28
+
29
+ /** Value-taking flags (kychee-com/run402#566 — the flag set doctor actually parses; anything else is BAD_USAGE via assertKnownFlags, never silently ignored). */
30
+ const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--buzz-agent", "--project"];
26
31
 
27
32
  const HELP = `run402 doctor — Health and config diagnostics
28
33
 
29
34
  Usage:
30
- run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>]
35
+ run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>] [--project <id>]
31
36
  run402 --wallet <profile> doctor --buzz --buzz-agent <npub-or-hex>
32
37
 
33
38
  Output:
@@ -40,9 +45,17 @@ Options:
40
45
  --refresh Wait for a bounded live npm version check for the run402 CLI
41
46
  --no-scan Skip the source-tree scan (config / health checks only)
42
47
  --scan-dir D Scan a custom directory instead of \`<cwd>/src\`
48
+ --project <id> Target THIS project's gitvault check instead of the repo-standing
49
+ default (the 4.38.0 pin / run402 remote / RUN402_PROJECT_ID / active
50
+ project, in that order — see \`gitvault-target.mjs\`). Scoped to the
51
+ gitvault check only; every other check is wallet/machine-wide, not
52
+ per-project, and is unaffected by this flag.
43
53
  --buzz Run only the zero-mutation Buzz setup preflight
44
54
  --buzz-agent P Bind Buzz mode to the intended public agent npub or hex key
45
55
 
56
+ Any flag not listed above is rejected (BAD_USAGE / UNKNOWN_FLAG), never
57
+ silently ignored.
58
+
46
59
  Telemetry:
47
60
  Buzz preflight sends only anonymous allowlisted start/pass/block counters.
48
61
  No identity, wallet, relay, domain, path, command output, or installation id
@@ -108,16 +121,25 @@ function describeCheckFailure(label, err) {
108
121
  }
109
122
 
110
123
  export async function run(sub, args = []) {
111
- const all = [sub, ...args].filter(Boolean);
124
+ const all = normalizeArgv([sub, ...args].filter(Boolean));
112
125
  if (all.includes("--help") || all.includes("-h")) {
113
126
  console.log(HELP);
114
127
  return;
115
128
  }
129
+ // kychee-com/run402#566 (--project half): doctor used to accept ANY flag
130
+ // silently — an unrecognized one (a typo, or --project before this fix)
131
+ // was simply never looked at. Any flag doctor actually parses is listed
132
+ // here; anything else is now a structured BAD_USAGE/UNKNOWN_FLAG rejection
133
+ // instead of quietly doing nothing.
134
+ assertKnownFlags(all, ["--verbose", "--refresh", "--no-scan", "--buzz", ...DOCTOR_VALUE_FLAGS], DOCTOR_VALUE_FLAGS);
116
135
  const verbose = all.includes("--verbose");
117
136
  const refresh = all.includes("--refresh");
118
137
  const skipScan = all.includes("--no-scan");
119
138
  const scanDirArgIdx = all.indexOf("--scan-dir");
120
139
  const scanDirOverride = scanDirArgIdx >= 0 ? all[scanDirArgIdx + 1] : null;
140
+ // Scoped to the gitvault check (see HELP): every other check is
141
+ // wallet/machine-wide, not per-project.
142
+ const projectOverride = flagValue(all, "--project");
121
143
 
122
144
  const buzzArgs = parseBuzzDoctorArgs(all);
123
145
  if (buzzArgs.error) fail(buzzArgs.error);
@@ -408,9 +430,20 @@ export async function run(sub, args = []) {
408
430
  // or `ok`, never a doctor failure. A vault-only project that has never
409
431
  // deployed is a first-class shape (protocol D183), so its mere absence of a
410
432
  // deploy raises nothing.
433
+ //
434
+ // TARGETING (repo-first-onramp follow-up, kychee-com/run402#559d, extended
435
+ // by kychee-com/run402#566's --project half): when cwd is a repository
436
+ // with its own pinned repo id or run402/origin remote, doctor checks THAT
437
+ // vault, not the profile's active project — the same pin > remote >
438
+ // RUN402_PROJECT_ID env > active-project order every other gitvault verb
439
+ // follows (`gitvault-target.mjs`). An explicit `--project <id>` outranks
440
+ // all of that (the resolver's own top tier), same as every other gitvault
441
+ // verb's `--project`.
411
442
  {
412
- const projectId = (process.env.RUN402_PROJECT_ID || "").trim() || getActiveProjectId() || null;
413
- if (!projectId) {
443
+ const target = await resolveGitvaultTarget({ repoDir: process.cwd(), explicitProjectId: projectOverride ?? undefined });
444
+ const projectId = target.project_id ?? null;
445
+ const repoId = target.repo_id ?? null;
446
+ if (!projectId && !repoId) {
414
447
  checks.push({
415
448
  name: "gitvault",
416
449
  status: "skipped",
@@ -418,9 +451,12 @@ export async function run(sub, args = []) {
418
451
  });
419
452
  } else {
420
453
  try {
421
- const gv = await getSdk().gitvault.status({ project_id: projectId, repo_dir: process.cwd() });
454
+ const gv = await getSdk().gitvault.status({
455
+ ...(repoId ? { repo_id: repoId } : { project_id: projectId }),
456
+ repo_dir: process.cwd(),
457
+ });
422
458
  const value = {
423
- project_id: projectId,
459
+ project_id: gv.project_id ?? projectId,
424
460
  repo_id: gv.repo_id,
425
461
  vault: gv.vault === null ? null : "allocated",
426
462
  gitvault_policy: gv.gitvault_policy,
@@ -446,7 +482,7 @@ export async function run(sub, args = []) {
446
482
  gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
447
483
  }
448
484
  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})`);
485
+ gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
450
486
  }
451
487
  // Echoed exactly as the SDK reported them — including the
452
488
  // doctor-persistent `grandfathered` advisory it owns.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Shared "which vault does this repo mean" resolution for `run402 gitvault`
3
+ * and `run402 doctor`'s gitvault check (repo-first-onramp follow-up,
4
+ * kychee-com/run402#559's design gap): gitvault verbs were purely
5
+ * active-project-scoped even when run standing inside a repository that
6
+ * already names its own vault via a pinned repo id or a run402/origin
7
+ * remote — git muscle memory says a command run inside a repo acts on THAT
8
+ * repo, and a stale active-project pointer silently targeted a DIFFERENT
9
+ * one instead (GITVAULT_ACCESS_DENIED, or worse, a silently WRONG vault).
10
+ *
11
+ * Targeting order for a verb run standing inside a git repository, highest
12
+ * first:
13
+ * 1. an explicit --repo/--project flag (owned by each call site — this
14
+ * module supplies only the fallback chain beneath it, plus the
15
+ * mismatch warning against tier 3)
16
+ * 2. the 4.38.0 pin (`r402.repoId` in local git config) — addresses the
17
+ * vault by repo_id directly, no network read at all
18
+ * 3. the repo's run402/origin remote address — id-form is parsed
19
+ * directly out of the address string (free, no network); slug-form is
20
+ * resolved via the SDK (one read-only network call — `resolveAddress`,
21
+ * never a pin, never a push-to-create; this is a TARGETING read, not a
22
+ * publish)
23
+ * 4. RUN402_PROJECT_ID env
24
+ * 5. the profile's active project
25
+ *
26
+ * Outside a repository (or when repo detection itself fails), only tiers 4
27
+ * and 5 apply — unchanged from before this module existed.
28
+ *
29
+ * ARCHITECTURAL NOTE: this is CLI-edge policy (which flag/env/file wins),
30
+ * not gitvault protocol behavior — the same class of concern
31
+ * `wallet-context.mjs` owns for wallet selection. The protocol reads it
32
+ * composes (`readPinnedGitvaultRepo`, `resolveAddress`) already live once in
33
+ * the SDK; this module adds no protocol logic of its own.
34
+ */
35
+ import { getActiveProjectId } from "./config.mjs";
36
+
37
+ /**
38
+ * Tiers 4/5 (RUN402_PROJECT_ID env, then the active project) — deliberately
39
+ * NON-throwing, unlike `config.mjs#resolveProjectId`. `run402 doctor`'s
40
+ * gitvault check needs to report "skipped" gracefully when nothing resolves
41
+ * anywhere (its pre-existing, tested behavior); a `fail()`-triggered
42
+ * `process.exit()` here would abort doctor's ENTIRE report, not just this
43
+ * one check. Callers that DO want the historical PROJECT_REQUIRED failure
44
+ * (gitvault.mjs's own verbs) get it by falling back to `resolveProjectId`
45
+ * themselves when this returns `null` — see `vaultTarget` in gitvault.mjs.
46
+ */
47
+ function envOrActiveProjectId() {
48
+ return (process.env.RUN402_PROJECT_ID || "").trim() || getActiveProjectId() || null;
49
+ }
50
+
51
+ async function isInsideGitRepo(repoDir) {
52
+ try {
53
+ const { hardenedGit } = await import("#sdk/node");
54
+ await hardenedGit(repoDir, ["rev-parse", "--git-dir"]);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The repo's OWN target, independent of any flag — the pin first, then the
63
+ * `run402`/`origin` remote (in that order, mirroring `scaffoldRemote`'s own
64
+ * naming precedence). `null` when the repo has neither, when this is not a
65
+ * repository at all, or (slug-form only) the remote fails to resolve over
66
+ * the network — a miss here is always ordinary, never thrown.
67
+ */
68
+ export async function repoOwnGitvaultTarget(repoDir) {
69
+ if (!(await isInsideGitRepo(repoDir))) return null;
70
+
71
+ const { hardenedGit, readPinnedGitvaultRepo } = await import("#sdk/node");
72
+ const pinned = await readPinnedGitvaultRepo(repoDir);
73
+ if (pinned) return { repo_id: pinned.repo_id, project_id: null, source: "pin" };
74
+
75
+ const { parseGitvaultRemoteUrl, gitvaultRemoteAddressForm } = await import("#sdk");
76
+ for (const name of ["run402", "origin"]) {
77
+ let url;
78
+ try {
79
+ url = (await hardenedGit(repoDir, ["remote", "get-url", name])).text().trim();
80
+ } catch {
81
+ continue; // no such remote — try the other conventional name
82
+ }
83
+ if (!url) continue;
84
+ const address = parseGitvaultRemoteUrl(url);
85
+ if (!address) continue; // exists, but isn't a run402 address — try the other name
86
+ if (gitvaultRemoteAddressForm(address) === "id") {
87
+ // id-form already carries both halves in the address string — no
88
+ // network read needed at all.
89
+ return { repo_id: null, project_id: address.project_id, source: "remote", remote_name: name };
90
+ }
91
+ try {
92
+ const { getSdk } = await import("./sdk.mjs");
93
+ const resolved = await getSdk().gitvault.resolveAddress(address);
94
+ return { repo_id: resolved.repo_id, project_id: resolved.project_id, source: "remote", remote_name: name };
95
+ } catch {
96
+ // Offline, SLUG_RELEASED, not-found, ... — nothing to target from this
97
+ // rung; a caller falls through to env/active project.
98
+ return null;
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Resolve `{ repo_id?, project_id? }` for a gitvault verb that addresses a
106
+ * vault the same way `--repo`/`--project` already do — pin beats remote
107
+ * beats env beats active project, an explicit flag beats all of them.
108
+ * `explicitProjectId`/`explicitRepoId` are the already-parsed flag values
109
+ * (`undefined`/`null` when absent — this module owns no flag parsing), and
110
+ * either, neither, or both may be set (mirroring `--repo`/`--project`
111
+ * together being valid on the CLI today). `warn` receives the one-line
112
+ * mismatch note when an explicit flag disagrees with the repo's OWN target;
113
+ * the flag still wins either way. The pin carries no project_id to compare
114
+ * for free, so only `--repo` is checked against it; only the remote tier
115
+ * carries a project_id for free (id-form) or resolves one (slug-form), so
116
+ * only `--project` is checked against it — matching the task's own wording
117
+ * ("a mismatch between an explicit flag and the repo's remote").
118
+ */
119
+ export async function resolveGitvaultTarget({
120
+ repoDir = process.cwd(),
121
+ explicitProjectId,
122
+ explicitRepoId,
123
+ warn = (line) => console.error(line),
124
+ } = {}) {
125
+ const needsOwn = explicitRepoId == null || explicitProjectId == null;
126
+ const own = needsOwn ? await repoOwnGitvaultTarget(repoDir) : null;
127
+
128
+ if (explicitRepoId != null && own?.source === "pin" && own.repo_id !== explicitRepoId) {
129
+ warn(`warning: --repo ${explicitRepoId} does not match this repo's pinned vault ${own.repo_id} — using --repo ${explicitRepoId}.`);
130
+ }
131
+ if (explicitProjectId != null && own?.source === "remote" && own.project_id && own.project_id !== explicitProjectId) {
132
+ warn(`warning: --project ${explicitProjectId} does not match this repo's '${own.remote_name}' remote project ${own.project_id} — using --project ${explicitProjectId}.`);
133
+ }
134
+
135
+ if (explicitRepoId != null || explicitProjectId != null) {
136
+ const result = {};
137
+ if (explicitRepoId != null) result.repo_id = explicitRepoId;
138
+ if (explicitProjectId != null) result.project_id = explicitProjectId;
139
+ return result;
140
+ }
141
+
142
+ if (own?.source === "pin") return { repo_id: own.repo_id };
143
+ if (own?.source === "remote" && own.project_id) return { project_id: own.project_id };
144
+ return { project_id: envOrActiveProjectId() };
145
+ }
package/lib/gitvault.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  import { readFileSync } from "node:fs";
20
20
  import { resolveProjectId } from "./config.mjs";
21
21
  import { resolveOwningOrgId } from "./org-context.mjs";
22
+ import { resolveGitvaultTarget } from "./gitvault-target.mjs";
22
23
  import { getSdk } from "./sdk.mjs";
23
24
  import { reportSdkError, fail } from "./sdk-errors.mjs";
24
25
  import {
@@ -158,29 +159,48 @@ Terminal loss (protocol §0):
158
159
  Examples:
159
160
  run402 gitvault init
160
161
  run402 gitvault status --refs
161
- run402 gitvault push --message "wip: refactor the parser"
162
+ run402 gitvault snapshot --message "wip: refactor the parser"
162
163
  run402 gitvault policy grandfathered --reason "migrating CI to a vaulted client"
163
164
  run402 gitvault verify --budget 500
164
165
  run402 gitvault prune --project prj_1a2b3c
166
+ # \`gitvault push\` still works as a deprecation-warning alias for \`snapshot\`
167
+ # for one release; it will be removed next release.
165
168
  `;
166
169
 
167
170
  /**
168
171
  * Resolve which vault to act on, plus the local git tree.
169
172
  *
170
173
  * `--repo` addresses the vault directly (the cold-restart path: an agent that
171
- * knows its repo_id needs no project lookup). Otherwise the project is
172
- * resolved the CLI-wide way `--project`, then RUN402_PROJECT_ID, then the
173
- * active project — and the SDK resolves the vault from it.
174
+ * knows its repo_id needs no project lookup). Otherwise the project targets,
175
+ * highest first: `--project` > the repo's own pin/remote > RUN402_PROJECT_ID
176
+ * > the active project (repo-first-onramp follow-up, kychee-com/run402#559
177
+ * see `gitvault-target.mjs`'s module doc for the full targeting order and
178
+ * why it exists: a stale active-project pointer used to silently outrank the
179
+ * repository this command is actually standing in).
174
180
  */
175
- function vaultTarget(a) {
181
+ async function vaultTarget(a) {
176
182
  const repoId = flagValue(a, "--repo");
177
183
  const project = flagValue(a, "--project");
178
- const target = { repo_dir: process.cwd() };
184
+ const repoDir = process.cwd();
185
+ const resolved = await resolveGitvaultTarget({
186
+ repoDir,
187
+ explicitProjectId: project ?? undefined,
188
+ explicitRepoId: repoId ?? undefined,
189
+ });
190
+ const target = { repo_dir: repoDir };
179
191
  if (repoId != null) target.repo_id = repoId;
180
192
  // Only demand a project when one is actually needed: `--repo` alone is a
181
- // complete address, and requiring an active project on top of it would make
182
- // the cold-restart path fail for no reason.
183
- if (repoId == null || project != null) target.project_id = resolveProjectId(project);
193
+ // complete address, and requiring one on top of it would make the
194
+ // cold-restart path fail for no reason.
195
+ if (repoId == null || project != null) {
196
+ if ("repo_id" in resolved && project == null) target.repo_id = resolved.repo_id;
197
+ // `resolveGitvaultTarget` reports its last (env/active) tier
198
+ // non-throwingly (`run402 doctor`'s call site needs that) — this call
199
+ // site is the one that historically failed closed with PROJECT_REQUIRED
200
+ // when nothing resolves anywhere, and still does: `resolveProjectId`
201
+ // re-derives the exact same env/active check and throws.
202
+ if ("project_id" in resolved) target.project_id = resolved.project_id ?? resolveProjectId(project);
203
+ }
184
204
  return target;
185
205
  }
186
206
 
@@ -337,7 +357,7 @@ async function policy(args) {
337
357
  });
338
358
  }
339
359
 
340
- const target = vaultTarget(a);
360
+ const target = await vaultTarget(a);
341
361
  try {
342
362
  const sdk = getSdk();
343
363
  const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
@@ -363,7 +383,7 @@ async function status(args) {
363
383
  requirePositionalCount(a, COMMON_VALUE_FLAGS, {
364
384
  min: 0, max: 0, command: "run402 gitvault status", missing: "",
365
385
  });
366
- const target = vaultTarget(a);
386
+ const target = await vaultTarget(a);
367
387
  if (a.includes("--refs")) target.refs = true;
368
388
  try {
369
389
  const s = await getSdk().gitvault.status(target);
@@ -452,7 +472,7 @@ async function snapshot(args) {
452
472
  // it is skipped there, matching `open()`'s own precedence. Skipped
453
473
  // entirely for a slug-form remote (`address` above) — that resolves
454
474
  // through the address, not a project_id, and needs no separate org_id.
455
- const target = address ? { repo_dir: repoDir } : vaultTarget(a);
475
+ const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
456
476
  const orgId = !address && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
457
477
  const opts = {
458
478
  ...target,
@@ -495,7 +515,7 @@ async function compact(args) {
495
515
  min: 0, max: 0, command: "run402 gitvault compact", missing: "",
496
516
  });
497
517
  try {
498
- const result = await getSdk().gitvault.compact(vaultTarget(a));
518
+ const result = await getSdk().gitvault.compact(await vaultTarget(a));
499
519
  console.log(JSON.stringify(result, null, 2));
500
520
  console.error(
501
521
  `checkpoint published at generation ${result.generation}: ` +
@@ -564,7 +584,7 @@ async function prune(args) {
564
584
  hint: "Add --submit, or drop the flags to plan.",
565
585
  });
566
586
  }
567
- const opts = vaultTarget(a);
587
+ const opts = await vaultTarget(a);
568
588
  if (submitting) {
569
589
  opts.submit = {
570
590
  core: readJsonFile("--intent-core", corePath),
@@ -610,7 +630,7 @@ async function verify(args) {
610
630
  requirePositionalCount(a, valueFlags, {
611
631
  min: 0, max: 0, command: "run402 gitvault verify", missing: "",
612
632
  });
613
- const target = vaultTarget(a);
633
+ const target = await vaultTarget(a);
614
634
  const budget = flagValue(a, "--budget");
615
635
  if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
616
636
  try {
@@ -66,3 +66,28 @@ export function deployAction() {
66
66
  why: "Apply your release manifest to deploy.",
67
67
  });
68
68
  }
69
+
70
+ /**
71
+ * `repos create` (and `gitvault init`) on an org with no claimed slug: the
72
+ * response's `address: null` had no pointer to WHY, or to the named-addressing
73
+ * feature at all (kychee-com/run402#560). One-time $1 fee, owner-only.
74
+ */
75
+ export function claimOrgSlugAction() {
76
+ return nextAction("claim_org_slug", {
77
+ command: "run402 org slug <slug>",
78
+ why: "This organization has no claimed slug yet, so its repos have no run402::<slug>/<name> address. One-time $1, owner-only.",
79
+ });
80
+ }
81
+
82
+ /**
83
+ * The org already has a slug, but this project's address-form repo name was
84
+ * not claimed this time (a collision, or the best-effort claim failed for
85
+ * some other reason) — point at the explicit claim verb instead of leaving
86
+ * `address: null` unexplained.
87
+ */
88
+ export function claimRepoNameAction(projectId) {
89
+ return nextAction("claim_repo_name", {
90
+ command: `run402 repos name <name> --project ${projectId}`,
91
+ why: "The owning organization has a slug, but this project has no claimed address-form name yet.",
92
+ });
93
+ }