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.
- package/core-dist/keystore.js +56 -4
- package/git-remote-run402.mjs +87 -4
- package/lib/doctor.mjs +19 -6
- package/lib/gitvault-target.mjs +145 -0
- package/lib/gitvault.mjs +32 -14
- package/lib/next-actions.mjs +25 -0
- package/lib/repos.mjs +24 -3
- package/lib/up.mjs +19 -2
- package/lib/wallet-context.mjs +88 -12
- package/package.json +1 -1
- package/sdk/core-dist/keystore.js +56 -4
- package/sdk/dist/errors.d.ts +6 -2
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +47 -11
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
package/core-dist/keystore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/git-remote-run402.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
413
|
-
|
|
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({
|
|
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 ${
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
|
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
|
|
182
|
-
//
|
|
183
|
-
if (repoId == null || project != null)
|
|
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 {
|
package/lib/next-actions.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|