run402 4.37.1 → 4.38.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 (65) hide show
  1. package/README.md +3 -1
  2. package/core-dist/keystore.js +56 -4
  3. package/git-remote-run402.mjs +136 -14
  4. package/lib/command-manifest.mjs +7 -0
  5. package/lib/doctor.mjs +19 -6
  6. package/lib/gitvault-target.mjs +145 -0
  7. package/lib/gitvault.mjs +81 -18
  8. package/lib/next-actions.mjs +25 -0
  9. package/lib/org.mjs +70 -0
  10. package/lib/repos.mjs +130 -5
  11. package/lib/up.mjs +19 -2
  12. package/lib/wallet-context.mjs +88 -12
  13. package/package.json +1 -1
  14. package/sdk/core-dist/keystore.js +56 -4
  15. package/sdk/dist/errors.d.ts +6 -2
  16. package/sdk/dist/errors.d.ts.map +1 -1
  17. package/sdk/dist/errors.js.map +1 -1
  18. package/sdk/dist/index.d.ts +1 -1
  19. package/sdk/dist/index.d.ts.map +1 -1
  20. package/sdk/dist/index.js +1 -1
  21. package/sdk/dist/index.js.map +1 -1
  22. package/sdk/dist/namespaces/gitvault.crypto.d.ts +11 -2
  23. package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
  24. package/sdk/dist/namespaces/gitvault.crypto.js +11 -2
  25. package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
  26. package/sdk/dist/namespaces/gitvault.d.ts +109 -3
  27. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/gitvault.js +161 -12
  29. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  30. package/sdk/dist/namespaces/org.d.ts +18 -1
  31. package/sdk/dist/namespaces/org.d.ts.map +1 -1
  32. package/sdk/dist/namespaces/org.js +38 -0
  33. package/sdk/dist/namespaces/org.js.map +1 -1
  34. package/sdk/dist/namespaces/org.types.d.ts +16 -0
  35. package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
  36. package/sdk/dist/namespaces/projects.d.ts +11 -1
  37. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  38. package/sdk/dist/namespaces/projects.js +16 -0
  39. package/sdk/dist/namespaces/projects.js.map +1 -1
  40. package/sdk/dist/namespaces/projects.types.d.ts +7 -0
  41. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  42. package/sdk/dist/node/gitvault-address.d.ts +89 -0
  43. package/sdk/dist/node/gitvault-address.d.ts.map +1 -0
  44. package/sdk/dist/node/gitvault-address.js +113 -0
  45. package/sdk/dist/node/gitvault-address.js.map +1 -0
  46. package/sdk/dist/node/gitvault-creation-journal.d.ts +81 -9
  47. package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
  48. package/sdk/dist/node/gitvault-creation-journal.js +93 -17
  49. package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
  50. package/sdk/dist/node/gitvault-publication.d.ts +13 -0
  51. package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
  52. package/sdk/dist/node/gitvault-publication.js +1 -0
  53. package/sdk/dist/node/gitvault-publication.js.map +1 -1
  54. package/sdk/dist/node/gitvault-push-to-create.d.ts +75 -0
  55. package/sdk/dist/node/gitvault-push-to-create.d.ts.map +1 -0
  56. package/sdk/dist/node/gitvault-push-to-create.js +132 -0
  57. package/sdk/dist/node/gitvault-push-to-create.js.map +1 -0
  58. package/sdk/dist/node/index.d.ts +6 -2
  59. package/sdk/dist/node/index.d.ts.map +1 -1
  60. package/sdk/dist/node/index.js +8 -1
  61. package/sdk/dist/node/index.js.map +1 -1
  62. package/sdk/dist/scoped.d.ts +2 -1
  63. package/sdk/dist/scoped.d.ts.map +1 -1
  64. package/sdk/dist/scoped.js +3 -0
  65. package/sdk/dist/scoped.js.map +1 -1
package/README.md CHANGED
@@ -65,7 +65,9 @@ run402 repos list --org org_1a2b3c
65
65
  run402 repos delete prj_xyz --force # refuses without --force while the vault holds generations
66
66
  ```
67
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.
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|name` 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
+
70
+ **Named addressing (design D6).** `run402 org slug <slug>` (owner-only, small one-time fee) claims an org's globally-unique, address-form slug, after which `run402::<slug>/<name>` addresses any repo under it — `git push` to a name that doesn't exist yet push-to-creates it. `run402 repos name <name> [--project <id>]` claims the per-org-unique `<name>` half explicitly (no fee); `repos create` claims one automatically, best-effort, when the org already has a slug. Also CLI/SDK-only — no MCP tool.
69
71
 
70
72
  ### Allowance
71
73
 
@@ -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
@@ -28,6 +28,21 @@
28
28
  * completes the push. One command, no prior `gitvault init`. `git ls-remote`
29
29
  * / `fetch` stay pure reads and allocate nothing.
30
30
  *
31
+ * NAMED ADDRESSING + PUSH-TO-CREATE (repo-first-onramp task 4, design D6).
32
+ * `run402::<org>/<name>` admits TWO forms in the same slot — id-form
33
+ * (`org_id`/`prj_...`, unchanged: resolved via `r.gitvault.openOrCreate`
34
+ * above) and slug-form (`run402::<org-slug>/<name>`, e.g.
35
+ * `run402::acme/my-notes`) — discriminated by
36
+ * `gitvaultRemoteAddressForm`. A slug-form remote resolves through
37
+ * `r.gitvault.resolveOrCreateAddress`, which ALSO drives push-to-create on a
38
+ * miss (`push` only; `list`/`fetch` pass `allow_create: false`, same "reads
39
+ * never allocate" discipline as the id-form path) and PINS the resolved
40
+ * `repo_id` in this checkout's local git config the first time it resolves
41
+ * (task 4.5) — every later invocation on THIS checkout goes straight to the
42
+ * pinned id, skipping the address resolution round-trip entirely and
43
+ * surviving a later rename of either half. `SLUG_RELEASED` is never
44
+ * auto-followed: it refuses, naming the successor slug.
45
+ *
31
46
  * WHICH REPOSITORY (the fail-closed rule). `process.cwd()` is NOT the
32
47
  * repository. git identifies the repository with `GIT_DIR`, and during
33
48
  * `git clone` cwd is the directory clone was RUN FROM — routinely some other,
@@ -68,7 +83,8 @@
68
83
 
69
84
  import { createInterface } from "node:readline";
70
85
  import { getSdk } from "./lib/sdk.mjs";
71
- import { parseGitvaultRemoteUrl } from "#sdk";
86
+ import { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } from "./lib/wallet-context.mjs";
87
+ import { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } from "#sdk";
72
88
  import { hardenedGit, resolveGitInvocationRepo } from "#sdk/node";
73
89
 
74
90
  const out = (line) => process.stdout.write(`${line}\n`);
@@ -76,15 +92,91 @@ const out = (line) => process.stdout.write(`${line}\n`);
76
92
  const endBlock = () => process.stdout.write("\n");
77
93
  const note = (line) => process.stderr.write(`git-remote-run402: ${line}\n`);
78
94
 
79
- /** Protocol lines are single-line: collapse anything that could break framing. */
80
- function oneLine(value) {
81
- 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
+ );
82
165
  }
83
166
 
84
167
  function describeError(err) {
85
168
  const code = err?.code ?? err?.body?.code ?? null;
86
- const message = err?.message ?? err?.body?.message ?? String(err);
87
- return oneLine(code ? `${code}: ${message}` : message);
169
+ const message = enrichAllowanceError(err?.message ?? err?.body?.message ?? String(err));
170
+ // SLUG_RELEASED is never auto-followed but the successor slug (design D6)
171
+ // is exactly the fact a human/agent reading stderr needs to act on it.
172
+ const released = gitvaultSlugReleasedInfo(err);
173
+ const suffix = released?.successor_slug ? ` (renamed to "${released.successor_slug}" — update the remote and re-run)` : "";
174
+ return oneLine(code ? `${code}: ${message}${suffix}` : message);
175
+ }
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);
88
180
  }
89
181
 
90
182
  /**
@@ -132,7 +224,10 @@ async function main(argv) {
132
224
  // `org_id` rides in the parsed address (`run402::<org_id>/<project_id>`),
133
225
  // so it costs nothing extra to carry — it is exactly what D2's lazy
134
226
  // creation needs to allocate an unresolved vault from `runPush` below, with
135
- // no separate lookup.
227
+ // no separate lookup. Only meaningful for an ID-FORM address; a slug-form
228
+ // one resolves through `resolveOrCreateAddress` instead (below), which
229
+ // needs no separate org_id at all — the gateway resolves the slug itself.
230
+ const addressForm = gitvaultRemoteAddressForm(address);
136
231
  const target = { project_id: address.project_id, org_id: address.org_id };
137
232
  let verbosity = 1;
138
233
 
@@ -160,25 +255,42 @@ async function main(argv) {
160
255
  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/*'`);
161
256
  }
162
257
 
163
- /** Open the vault lazily — `capabilities` and `option` must never touch the network. */
164
- const openVault = async (repoDir) => (await getSdk().gitvault.open(repoDir ? { ...target, repo_dir: repoDir } : target)).vault;
165
-
166
258
  /** A 404/absent-vault refusal — the "nothing here yet" shape, never a genuine failure to mask. */
167
259
  function isVaultNotFound(err) {
168
260
  return err?.status === 404 || err?.code === "RESOURCE_NOT_FOUND" || err?.code === "ROUTE_NOT_FOUND";
169
261
  }
170
262
 
171
263
  /**
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).
264
+ * Open the vault lazily `capabilities` and `option` must never touch the
265
+ * network. Dispatches on the address form (design D6): id-form is
266
+ * BYTE-IDENTICAL to before (`gitvault.open` with `{org_id, project_id}`);
267
+ * slug-form resolves (and, on the first successful resolution, PINS
268
+ * `repo_id` in local git state — task 4.5) through
269
+ * `gitvault.resolveOrCreateAddress` with `allow_create: false` — a read
270
+ * never allocates, same discipline the id-form path already had.
271
+ */
272
+ const openVault = async (repoDir) => {
273
+ if (addressForm === "id") return (await getSdk().gitvault.open(repoDir ? { ...target, repo_dir: repoDir } : target)).vault;
274
+ const result = await getSdk().gitvault.resolveOrCreateAddress({ address, allow_create: false, ...(repoDir ? { repo_dir: repoDir } : {}) });
275
+ return result.handle.vault;
276
+ };
277
+
278
+ /**
279
+ * Open the vault, allocating it first when it does not exist yet (D2), and
280
+ * — for a SLUG-form address whose name does not resolve yet —
281
+ * PUSH-TO-CREATE it (design D6, task 4.4/4.5). Used ONLY by `runPush` —
282
+ * `list`/`fetch` stay pure reads and never create anything (see
283
+ * `runList`'s own not-found handling below).
175
284
  *
176
285
  * Prints the one-shot recovery receipt and the keystore path to stderr the
177
286
  * moment allocation happens, per the client-surface spec: an agent reads
178
287
  * stderr, and the receipt is worth exactly as many copies as get kept.
179
288
  */
180
289
  async function openOrCreateVault(repoDir) {
181
- const result = await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir });
290
+ const result =
291
+ addressForm === "id"
292
+ ? await getSdk().gitvault.openOrCreate({ ...target, repo_dir: repoDir })
293
+ : await getSdk().gitvault.resolveOrCreateAddress({ address, repo_dir: repoDir, allow_create: true });
182
294
  if (!result.found && result.created) {
183
295
  note("");
184
296
  note(`vault ${result.handle.repo_id} allocated (genesis ${result.created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
@@ -195,6 +307,10 @@ async function main(argv) {
195
307
  }
196
308
 
197
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());
198
314
  let state;
199
315
  try {
200
316
  state = await (await openVault()).materialize();
@@ -235,6 +351,10 @@ async function main(argv) {
235
351
  repoRefusalNote(err);
236
352
  return 1;
237
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);
238
358
  if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
239
359
  const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
240
360
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
@@ -249,6 +369,8 @@ async function main(argv) {
249
369
  // network: a push that names a ref this repository does not have must
250
370
  // fail locally rather than after opening the vault.
251
371
  const repoDir = await requireRepo();
372
+ // Same "walk from the resolved repository, not cwd" rule as `fetch`.
373
+ applyWalletForDir(repoDir);
252
374
  const newOids = new Map();
253
375
  for (const spec of specs) {
254
376
  // A deletion carries an empty <src>. Everything else is resolved by
@@ -197,6 +197,11 @@ export const COMMAND_MANIFEST = [
197
197
  { path: ["org", "get"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
198
198
  { path: ["org", "rename"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--name", "Gate"] },
199
199
  { path: ["org", "payout-wallet"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
200
+ // repo-first-onramp task 4.1 (design D6). `--org` (not a positional org_id
201
+ // like rename/payout-wallet above) goes through resolveOrg's SHAPE
202
+ // validation (a real UUID) — "org_gate1" fails that locally, so this needs
203
+ // the same UUID-shaped fixture `org use` below already established.
204
+ { path: ["org", "slug"], positionals: [p("slug")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["acme", "--org", "11111111-2222-3333-4444-555555555555"] },
200
205
  { path: ["org", "whoami"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
201
206
  { path: ["org", "audit"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
202
207
  { path: ["org", "use"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["11111111-2222-3333-4444-555555555555"] },
@@ -274,6 +279,8 @@ export const COMMAND_MANIFEST = [
274
279
  { 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
280
  { 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
281
  { path: ["repos", "delete"], positionals: [], projectScoped: true, legacyPositionalProject: true, minimalArgs: [], runStyle: "sub", skipBehavioral: "irreversibly deletes a project after reading its live vault generation count" },
282
+ // repo-first-onramp task 4.2 (design D6): explicit address-form name claim.
283
+ { path: ["repos", "name"], positionals: [p("name")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["my-notes"], runStyle: "sub", skipBehavioral: "claims a per-org-unique repo name against a live project" },
277
284
  { path: ["errors"], positionals: [p("fingerprint_id", { required: false })], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "merged" },
278
285
 
279
286
  // ── jobs ─────────────────────────────────────────────────────────────────
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,
@@ -408,9 +409,18 @@ export async function run(sub, args = []) {
408
409
  // or `ok`, never a doctor failure. A vault-only project that has never
409
410
  // deployed is a first-class shape (protocol D183), so its mere absence of a
410
411
  // deploy raises nothing.
412
+ //
413
+ // TARGETING (repo-first-onramp follow-up, kychee-com/run402#559d): when
414
+ // cwd is a repository with its own pinned repo id or run402/origin remote,
415
+ // doctor now checks THAT vault, not the profile's active project — the
416
+ // same pin > remote > RUN402_PROJECT_ID env > active-project order every
417
+ // other gitvault verb follows (`gitvault-target.mjs`). Doctor has no
418
+ // `--project`/`--repo` flag of its own, so there is no explicit tier here.
411
419
  {
412
- const projectId = (process.env.RUN402_PROJECT_ID || "").trim() || getActiveProjectId() || null;
413
- if (!projectId) {
420
+ const target = await resolveGitvaultTarget({ repoDir: process.cwd() });
421
+ const projectId = target.project_id ?? null;
422
+ const repoId = target.repo_id ?? null;
423
+ if (!projectId && !repoId) {
414
424
  checks.push({
415
425
  name: "gitvault",
416
426
  status: "skipped",
@@ -418,9 +428,12 @@ export async function run(sub, args = []) {
418
428
  });
419
429
  } else {
420
430
  try {
421
- const gv = await getSdk().gitvault.status({ project_id: projectId, repo_dir: process.cwd() });
431
+ const gv = await getSdk().gitvault.status({
432
+ ...(repoId ? { repo_id: repoId } : { project_id: projectId }),
433
+ repo_dir: process.cwd(),
434
+ });
422
435
  const value = {
423
- project_id: projectId,
436
+ project_id: gv.project_id ?? projectId,
424
437
  repo_id: gv.repo_id,
425
438
  vault: gv.vault === null ? null : "allocated",
426
439
  gitvault_policy: gv.gitvault_policy,
@@ -446,7 +459,7 @@ export async function run(sub, args = []) {
446
459
  gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
447
460
  }
448
461
  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})`);
462
+ gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
450
463
  }
451
464
  // Echoed exactly as the SDK reported them — including the
452
465
  // 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
+ }