run402 4.38.0 → 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.
@@ -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
@@ -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
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
+ }
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 {
@@ -168,19 +169,36 @@ Examples:
168
169
  * Resolve which vault to act on, plus the local git tree.
169
170
  *
170
171
  * `--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.
172
+ * knows its repo_id needs no project lookup). Otherwise the project targets,
173
+ * highest first: `--project` > the repo's own pin/remote > RUN402_PROJECT_ID
174
+ * > the active project (repo-first-onramp follow-up, kychee-com/run402#559
175
+ * see `gitvault-target.mjs`'s module doc for the full targeting order and
176
+ * why it exists: a stale active-project pointer used to silently outrank the
177
+ * repository this command is actually standing in).
174
178
  */
175
- function vaultTarget(a) {
179
+ async function vaultTarget(a) {
176
180
  const repoId = flagValue(a, "--repo");
177
181
  const project = flagValue(a, "--project");
178
- const target = { repo_dir: process.cwd() };
182
+ const repoDir = process.cwd();
183
+ const resolved = await resolveGitvaultTarget({
184
+ repoDir,
185
+ explicitProjectId: project ?? undefined,
186
+ explicitRepoId: repoId ?? undefined,
187
+ });
188
+ const target = { repo_dir: repoDir };
179
189
  if (repoId != null) target.repo_id = repoId;
180
190
  // 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);
191
+ // complete address, and requiring one on top of it would make the
192
+ // cold-restart path fail for no reason.
193
+ if (repoId == null || project != null) {
194
+ if ("repo_id" in resolved && project == null) target.repo_id = resolved.repo_id;
195
+ // `resolveGitvaultTarget` reports its last (env/active) tier
196
+ // non-throwingly (`run402 doctor`'s call site needs that) — this call
197
+ // site is the one that historically failed closed with PROJECT_REQUIRED
198
+ // when nothing resolves anywhere, and still does: `resolveProjectId`
199
+ // re-derives the exact same env/active check and throws.
200
+ if ("project_id" in resolved) target.project_id = resolved.project_id ?? resolveProjectId(project);
201
+ }
184
202
  return target;
185
203
  }
186
204
 
@@ -337,7 +355,7 @@ async function policy(args) {
337
355
  });
338
356
  }
339
357
 
340
- const target = vaultTarget(a);
358
+ const target = await vaultTarget(a);
341
359
  try {
342
360
  const sdk = getSdk();
343
361
  const repoId = target.repo_id ?? (await sdk.gitvault.forProject(target.project_id)).repo_id;
@@ -363,7 +381,7 @@ async function status(args) {
363
381
  requirePositionalCount(a, COMMON_VALUE_FLAGS, {
364
382
  min: 0, max: 0, command: "run402 gitvault status", missing: "",
365
383
  });
366
- const target = vaultTarget(a);
384
+ const target = await vaultTarget(a);
367
385
  if (a.includes("--refs")) target.refs = true;
368
386
  try {
369
387
  const s = await getSdk().gitvault.status(target);
@@ -452,7 +470,7 @@ async function snapshot(args) {
452
470
  // it is skipped there, matching `open()`'s own precedence. Skipped
453
471
  // entirely for a slug-form remote (`address` above) — that resolves
454
472
  // through the address, not a project_id, and needs no separate org_id.
455
- const target = address ? { repo_dir: repoDir } : vaultTarget(a);
473
+ const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
456
474
  const orgId = !address && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
457
475
  const opts = {
458
476
  ...target,
@@ -495,7 +513,7 @@ async function compact(args) {
495
513
  min: 0, max: 0, command: "run402 gitvault compact", missing: "",
496
514
  });
497
515
  try {
498
- const result = await getSdk().gitvault.compact(vaultTarget(a));
516
+ const result = await getSdk().gitvault.compact(await vaultTarget(a));
499
517
  console.log(JSON.stringify(result, null, 2));
500
518
  console.error(
501
519
  `checkpoint published at generation ${result.generation}: ` +
@@ -564,7 +582,7 @@ async function prune(args) {
564
582
  hint: "Add --submit, or drop the flags to plan.",
565
583
  });
566
584
  }
567
- const opts = vaultTarget(a);
585
+ const opts = await vaultTarget(a);
568
586
  if (submitting) {
569
587
  opts.submit = {
570
588
  core: readJsonFile("--intent-core", corePath),
@@ -610,7 +628,7 @@ async function verify(args) {
610
628
  requirePositionalCount(a, valueFlags, {
611
629
  min: 0, max: 0, command: "run402 gitvault verify", missing: "",
612
630
  });
613
- const target = vaultTarget(a);
631
+ const target = await vaultTarget(a);
614
632
  const budget = flagValue(a, "--budget");
615
633
  if (budget != null) target.verification_budget = parseIntegerFlag("--budget", budget, { min: 1 });
616
634
  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
+ }
package/lib/repos.mjs CHANGED
@@ -38,7 +38,7 @@ import { withAutoApprove } from "./operator.mjs";
38
38
  import { allowanceAuthHeaders, isCoreApiTarget, resolveProjectId } from "./config.mjs";
39
39
  import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.js";
40
40
  import { resolveOrgId, resolveOwningOrgId } from "./org-context.mjs";
41
- import { nextAction } from "./next-actions.mjs";
41
+ import { nextAction, claimOrgSlugAction, claimRepoNameAction } from "./next-actions.mjs";
42
42
  import { printKeystoreLocation } from "./gitvault.mjs";
43
43
  import { gitvaultRemoteUrlForRepo } from "#sdk";
44
44
  import {
@@ -214,19 +214,32 @@ async function create(args) {
214
214
  // a missing slug, or any other refusal just means no address this time;
215
215
  // `run402 repos name <name>` claims it explicitly later.
216
216
  let address = null;
217
+ let orgSlug = null;
217
218
  try {
218
219
  const orgRecord = await getSdk().org(effectiveOrgId).get();
219
- if (orgRecord.slug) {
220
+ orgSlug = orgRecord.slug ?? null;
221
+ if (orgSlug) {
220
222
  const candidate = slugifyRepoName(name);
221
223
  if (candidate) {
222
224
  const named = await getSdk().projects.setRepoName(provisioned.project_id, candidate);
223
- address = gitvaultRemoteUrlForRepo(orgRecord.slug, named.repo_name);
225
+ address = gitvaultRemoteUrlForRepo(orgSlug, named.repo_name);
224
226
  }
225
227
  }
226
228
  } catch (err) {
227
229
  console.error(`repo name not claimed (non-fatal): ${err?.message ?? String(err)}`);
228
230
  }
229
231
 
232
+ // `address: null` used to have no pointer to WHY, or to the
233
+ // named-addressing feature at all (kychee-com/run402#560): an agent
234
+ // reading the output had no path from "address is null" to
235
+ // `run402 org slug`/`run402 repos name`. One typed next_actions entry,
236
+ // pointing at whichever half is actually missing.
237
+ const nextActions = address
238
+ ? []
239
+ : orgSlug
240
+ ? [claimRepoNameAction(provisioned.project_id)]
241
+ : [claimOrgSlugAction()];
242
+
230
243
  const out = {
231
244
  project_id: provisioned.project_id,
232
245
  repo_id: vault.repo_id,
@@ -237,6 +250,7 @@ async function create(args) {
237
250
  recovery_receipt: vault.recovery_receipt,
238
251
  terminal_loss_statement: vault.terminal_loss_statement,
239
252
  deployed: false,
253
+ next_actions: nextActions,
240
254
  };
241
255
  console.log(JSON.stringify(out, null, 2));
242
256
  console.error(
@@ -244,6 +258,13 @@ async function create(args) {
244
258
  (vault.deduplicated ? "already existed — nothing was re-allocated" : `allocated (genesis ${vault.genesis_sha256})`),
245
259
  );
246
260
  if (address) console.error(`address: ${address}`);
261
+ else if (!orgSlug) {
262
+ console.error(
263
+ "no named address yet — claim an org slug (run402 org slug <slug>, one-time $1) to get run402::<slug>/<name> addresses",
264
+ );
265
+ } else {
266
+ console.error(`no address claimed — run 'run402 repos name <name> --project ${provisioned.project_id}' to claim one`);
267
+ }
247
268
  if (vault.remote) console.error(`remote '${vault.remote.name}' -> ${vault.remote.url} (${vault.remote.reason})`);
248
269
  console.error("");
249
270
  console.error(vault.terminal_loss_statement);
package/lib/up.mjs CHANGED
@@ -585,14 +585,31 @@ function looksLikeGitRemoteUrl(source) {
585
585
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(source) || /^[^\s@]+@[^\s:]+:/.test(source);
586
586
  }
587
587
 
588
- /** `git init` only when `dir` is not a repository yet. Returns whether it did. */
588
+ /**
589
+ * `git init` only when `dir` is not a repository yet. Returns whether it did.
590
+ *
591
+ * `-b main`, not whatever `init.defaultBranch` (or the pre-2.28 hardcoded
592
+ * `master`) happens to be — the docs teach `git push origin main`, and the
593
+ * gitvault remote helper's own dangling-HEAD hazard note (a first push of
594
+ * any OTHER branch leaves HEAD naming a ref that does not exist yet) is
595
+ * exactly what a mismatched default branch here would walk `up` straight
596
+ * into. `-b` needs git 2.28+ (2020); an older git falls back to the same
597
+ * result by a different route — `symbolic-ref` on a still-empty repository
598
+ * has no existing ref to disturb, so it is exactly as safe as `-b main`
599
+ * would have been. Mirrors `Gitvault.scaffoldRemote`'s identical fallback.
600
+ */
589
601
  async function gitInitIfNeeded(dir) {
590
602
  const { hardenedGit } = await import("#sdk/node");
591
603
  try {
592
604
  await hardenedGit(dir, ["rev-parse", "--git-dir"]);
593
605
  return false;
594
606
  } catch {
595
- await hardenedGit(dir, ["init", "-q", "-b", "main", "."]);
607
+ try {
608
+ await hardenedGit(dir, ["init", "-q", "-b", "main", "."]);
609
+ } catch {
610
+ await hardenedGit(dir, ["init", "-q", "."]);
611
+ await hardenedGit(dir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
612
+ }
596
613
  return true;
597
614
  }
598
615
  }