run402 4.38.1 → 4.39.0
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/git-remote-run402.mjs +186 -13
- package/lib/command-manifest.mjs +5 -1
- package/lib/doctor.mjs +185 -54
- package/lib/gitvault.mjs +180 -10
- package/lib/update-check.mjs +74 -11
- package/lib/update-check.test.mjs +169 -16
- package/package.json +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +81 -5
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +103 -10
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.d.ts +17 -0
- package/sdk/dist/node/gitvault-creation-journal.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-creation-journal.js +72 -2
- package/sdk/dist/node/gitvault-creation-journal.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.js +2 -2
- package/sdk/dist/node/gitvault-deploy.js.map +1 -1
- package/sdk/dist/node/gitvault-profile-scan.d.ts +24 -0
- package/sdk/dist/node/gitvault-profile-scan.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-profile-scan.js +73 -0
- package/sdk/dist/node/gitvault-profile-scan.js.map +1 -0
- package/sdk/dist/node/gitvault-publication.d.ts +52 -0
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +80 -4
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/index.d.ts +2 -1
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +4 -0
- package/sdk/dist/node/index.js.map +1 -1
package/git-remote-run402.mjs
CHANGED
|
@@ -68,12 +68,29 @@
|
|
|
68
68
|
* no per-ref object selection, so one batch = one full restore. That is a
|
|
69
69
|
* superset of what git asked for (git writes the refs itself from `list`),
|
|
70
70
|
* never a subset — but it is not incremental.
|
|
71
|
-
* - `push`
|
|
72
|
-
* A fresh vault defaults
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
71
|
+
* - `push` REPAIRS a DANGLING vault HEAD and otherwise never moves it
|
|
72
|
+
* (kychee-com/run402#568). A fresh vault defaults its HEAD symref to
|
|
73
|
+
* `refs/heads/main`; before this fix, a first push of any OTHER branch
|
|
74
|
+
* left that symref naming a ref that would never exist, and the first
|
|
75
|
+
* `git clone` warned "remote HEAD refers to nonexistent ref" and checked
|
|
76
|
+
* out an EMPTY tree — publishing landed, but nothing was reachable from
|
|
77
|
+
* it. Now: when the vault's current HEAD target is unset, or is a
|
|
78
|
+
* symref naming a ref this push's own batch does not leave present, the
|
|
79
|
+
* helper points it at one of the branches THIS push is publishing — the
|
|
80
|
+
* local repository's own HEAD branch when it is among them, else the
|
|
81
|
+
* first branch in the batch — and prints a one-line stderr note saying
|
|
82
|
+
* which and why (see `chooseGitvaultHeadTargetForPush`). A HEALTHY HEAD
|
|
83
|
+
* (one that already names a ref this push leaves present) is NEVER
|
|
84
|
+
* touched — push moving history never means push moving HEAD.
|
|
85
|
+
* - `option dry-run true` (kychee-com/run402#565) runs the REAL local
|
|
86
|
+
* pipeline — pack building, encryption sizing, via `vault.planPush` — and
|
|
87
|
+
* reports the per-ref `ok` lines a real push would, plus a stderr summary
|
|
88
|
+
* (objects, encrypted bytes, refs, the generation it would admit as,
|
|
89
|
+
* whether allocation would be needed). It never uploads or admits, and a
|
|
90
|
+
* push-to-create dry run never allocates. Still honestly refuses
|
|
91
|
+
* anything git's own dry-run negotiation would also refuse (e.g. a
|
|
92
|
+
* non-fast-forward update) — reporting a fake `ok` would be worse than
|
|
93
|
+
* refusing, which is why this was `unsupported` until it could be real.
|
|
77
94
|
* - `fetch` and `push` REQUIRE the `GIT_DIR` git sets when it drives a
|
|
78
95
|
* helper against a repository, so running this binary by hand from a shell
|
|
79
96
|
* is refused rather than silently pointed at the current directory. Only
|
|
@@ -82,6 +99,7 @@
|
|
|
82
99
|
*/
|
|
83
100
|
|
|
84
101
|
import { createInterface } from "node:readline";
|
|
102
|
+
import { pathToFileURL } from "node:url";
|
|
85
103
|
import { getSdk } from "./lib/sdk.mjs";
|
|
86
104
|
import { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } from "./lib/wallet-context.mjs";
|
|
87
105
|
import { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } from "#sdk";
|
|
@@ -214,6 +232,62 @@ function parsePushSpec(spec) {
|
|
|
214
232
|
return { src: body.slice(0, colon), dst: body.slice(colon + 1), force: forced };
|
|
215
233
|
}
|
|
216
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Decide whether THIS push must repair a DANGLING vault HEAD, and to what
|
|
237
|
+
* (kychee-com/run402#568 — the first-clone empty-tree hazard). The rule:
|
|
238
|
+
*
|
|
239
|
+
* WHEN the vault's current materialized HEAD target is absent, OR is a
|
|
240
|
+
* symref naming a ref this push's own batch does not leave present, set
|
|
241
|
+
* `head_target` to one of the branches THIS push is publishing — this
|
|
242
|
+
* repository's own HEAD branch when it is among them, else the first
|
|
243
|
+
* branch in the batch (git's own order) — and say which, and why, in a
|
|
244
|
+
* one-line note. No silent magic.
|
|
245
|
+
*
|
|
246
|
+
* WHEN HEAD is already set and healthy (a symref naming a ref this push
|
|
247
|
+
* leaves present, or a detached target), it is NEVER touched — push
|
|
248
|
+
* moving history never means push moving HEAD. That stays the documented
|
|
249
|
+
* rule (`vault.push`'s own `head_target ?? base.head_target` carry-forward
|
|
250
|
+
* already guarantees this at the SDK layer; this function just decides
|
|
251
|
+
* WHEN to override that default).
|
|
252
|
+
*
|
|
253
|
+
* Pure — no I/O, no git, no network — so it is unit-testable directly.
|
|
254
|
+
* `updates` is this push's own ref-transaction updates (`{ ref, new_oid }`,
|
|
255
|
+
* `new_oid: null` for a deletion); `baseRefs`/`baseHeadTarget` are what the
|
|
256
|
+
* vault materialized BEFORE this push; `localHeadRef` is this repository's
|
|
257
|
+
* own HEAD branch (`refs/heads/<name>`), or `null` when detached/unknown.
|
|
258
|
+
*
|
|
259
|
+
* Returns `{ head_target: undefined }` (never publish an override — the SDK
|
|
260
|
+
* carries the base forward) when HEAD needs no repair, or when this batch
|
|
261
|
+
* has no branch update to repair it WITH (a tags-only or deletion-only
|
|
262
|
+
* batch cannot fix a dangling HEAD by itself).
|
|
263
|
+
*/
|
|
264
|
+
export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, updates, localHeadRef }) {
|
|
265
|
+
const postPushRefs = { ...(baseRefs ?? {}) };
|
|
266
|
+
for (const u of updates) {
|
|
267
|
+
if (u.new_oid === null) delete postPushRefs[u.ref];
|
|
268
|
+
else postPushRefs[u.ref] = u.new_oid;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const dangling =
|
|
272
|
+
!baseHeadTarget ||
|
|
273
|
+
(baseHeadTarget.kind === "symref" && !Object.prototype.hasOwnProperty.call(postPushRefs, baseHeadTarget.ref));
|
|
274
|
+
if (!dangling) return { head_target: undefined, note: null };
|
|
275
|
+
|
|
276
|
+
const pushedBranches = updates.filter((u) => u.new_oid !== null && u.ref.startsWith("refs/heads/")).map((u) => u.ref);
|
|
277
|
+
if (pushedBranches.length === 0) return { head_target: undefined, note: null };
|
|
278
|
+
|
|
279
|
+
const localIsPushed = Boolean(localHeadRef) && pushedBranches.includes(localHeadRef);
|
|
280
|
+
const chosen = localIsPushed ? localHeadRef : pushedBranches[0];
|
|
281
|
+
const why = localIsPushed
|
|
282
|
+
? "this repository's own HEAD branch"
|
|
283
|
+
: pushedBranches.length > 1
|
|
284
|
+
? `the first of ${pushedBranches.length} branches pushed in this batch`
|
|
285
|
+
: "the branch this push publishes";
|
|
286
|
+
const priorState = baseHeadTarget ? `dangling (named '${baseHeadTarget.ref}', which this push does not publish)` : "unset";
|
|
287
|
+
const note = `vault HEAD was ${priorState} — setting it to '${chosen}' (${why}). A healthy HEAD is never moved by push.`;
|
|
288
|
+
return { head_target: { kind: "symref", ref: chosen }, note };
|
|
289
|
+
}
|
|
290
|
+
|
|
217
291
|
async function main(argv) {
|
|
218
292
|
const address = resolveRemoteAddress(argv);
|
|
219
293
|
if (!address) {
|
|
@@ -230,6 +304,11 @@ async function main(argv) {
|
|
|
230
304
|
const addressForm = gitvaultRemoteAddressForm(address);
|
|
231
305
|
const target = { project_id: address.project_id, org_id: address.org_id };
|
|
232
306
|
let verbosity = 1;
|
|
307
|
+
// kychee-com/run402#565: `option dry-run true` used to be honestly
|
|
308
|
+
// `unsupported` (this helper could not rehearse a publication, and
|
|
309
|
+
// reporting a fake success would be worse than refusing). It now IS
|
|
310
|
+
// real — see `handleOption`'s `dry-run` case and `runPush` below.
|
|
311
|
+
let dryRun = false;
|
|
233
312
|
|
|
234
313
|
/**
|
|
235
314
|
* The repository git invoked us for, resolved once and PROVEN.
|
|
@@ -245,6 +324,16 @@ async function main(argv) {
|
|
|
245
324
|
return resolvedRepo.repo_dir;
|
|
246
325
|
}
|
|
247
326
|
|
|
327
|
+
/** This repository's own HEAD branch (`refs/heads/<name>`), or `null` when detached, unborn, or unreadable — never a failure by itself. */
|
|
328
|
+
async function localHeadBranchRef(repoDir) {
|
|
329
|
+
try {
|
|
330
|
+
const out = (await hardenedGit(repoDir, ["symbolic-ref", "--quiet", "HEAD"])).text().trim();
|
|
331
|
+
return out.length > 0 ? out : null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
248
337
|
/**
|
|
249
338
|
* What to tell a human when we refuse. `git clone` is the case that used to
|
|
250
339
|
* fail; naming the working alternative beats a bare error.
|
|
@@ -380,6 +469,52 @@ async function main(argv) {
|
|
|
380
469
|
? null
|
|
381
470
|
: (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
|
|
382
471
|
}
|
|
472
|
+
|
|
473
|
+
if (dryRun) {
|
|
474
|
+
// kychee-com/run402#565: READ-ONLY resolution — `openVault`, never
|
|
475
|
+
// `openOrCreateVault` — so a push-to-create dry run allocates
|
|
476
|
+
// NOTHING. An unresolved vault means there is nothing to preview a
|
|
477
|
+
// push against yet (no repo_id ⇒ no encryption key ⇒ sizing is
|
|
478
|
+
// genuinely unknowable, not merely unreported); still report success
|
|
479
|
+
// per-ref, since a real push here WOULD succeed (it would allocate
|
|
480
|
+
// first) — only the sizing is unavailable.
|
|
481
|
+
let vault;
|
|
482
|
+
try {
|
|
483
|
+
vault = await openVault(repoDir);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (!isVaultNotFound(err)) throw err;
|
|
486
|
+
note("dry-run: no vault allocated for this project yet — a real push would allocate one (push-to-create) before publishing; object/byte sizing is not knowable until then");
|
|
487
|
+
for (const spec of specs) out(`ok ${spec.dst}`);
|
|
488
|
+
endBlock();
|
|
489
|
+
return 0;
|
|
490
|
+
}
|
|
491
|
+
const base = await vault.materialize();
|
|
492
|
+
const updates = [];
|
|
493
|
+
for (const spec of specs) {
|
|
494
|
+
const expectedOld = base.refs?.[spec.dst] ?? null;
|
|
495
|
+
updates.push({
|
|
496
|
+
ref: spec.dst,
|
|
497
|
+
expected_old_oid: expectedOld,
|
|
498
|
+
new_oid: newOids.get(spec),
|
|
499
|
+
force: spec.force && expectedOld !== null,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// Same evaluation, pack building, and sealing/encryption a real push
|
|
503
|
+
// runs — stops before the two network mutations (upload, admit). A
|
|
504
|
+
// refusal here (non-fast-forward, tag immutability, ...) throws the
|
|
505
|
+
// SAME way a real push's would, caught below and reported as
|
|
506
|
+
// `error`, never a fake `ok`.
|
|
507
|
+
const plan = await vault.planPush({ transaction: { updates } });
|
|
508
|
+
note(
|
|
509
|
+
`dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
|
|
510
|
+
`${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw), ` +
|
|
511
|
+
`${Object.keys(plan.refs).length} ref(s); no allocation needed`,
|
|
512
|
+
);
|
|
513
|
+
for (const spec of specs) out(`ok ${spec.dst}`);
|
|
514
|
+
endBlock();
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
383
518
|
const vault = await openOrCreateVault(repoDir);
|
|
384
519
|
const base = await vault.materialize();
|
|
385
520
|
const updates = [];
|
|
@@ -394,10 +529,26 @@ async function main(argv) {
|
|
|
394
529
|
force: spec.force && expectedOld !== null,
|
|
395
530
|
});
|
|
396
531
|
}
|
|
532
|
+
// Repair a DANGLING HEAD from this batch's own branches (#568) — see
|
|
533
|
+
// `chooseGitvaultHeadTargetForPush`'s own doc comment for the exact
|
|
534
|
+
// rule. `localHeadRef` is read from THIS repository (never cwd, same
|
|
535
|
+
// fail-closed resolution as everything else in this function).
|
|
536
|
+
const headFix = chooseGitvaultHeadTargetForPush({
|
|
537
|
+
baseHeadTarget: base.head_target,
|
|
538
|
+
baseRefs: base.refs,
|
|
539
|
+
updates,
|
|
540
|
+
localHeadRef: await localHeadBranchRef(repoDir),
|
|
541
|
+
});
|
|
542
|
+
if (headFix.note) note(headFix.note);
|
|
397
543
|
// ONE transaction for the whole batch: the SDK evaluates fast-forward,
|
|
398
544
|
// tag immutability, protocol-ref refusal and retention roots, builds the
|
|
399
|
-
// packs, and publishes — all or nothing.
|
|
400
|
-
|
|
545
|
+
// packs, and publishes — all or nothing. `head_target` is included ONLY
|
|
546
|
+
// when a repair is called for; omitted, `vault.push` carries the base
|
|
547
|
+
// forward unchanged — a healthy HEAD is never moved.
|
|
548
|
+
const published = await vault.push({
|
|
549
|
+
transaction: { updates },
|
|
550
|
+
...(headFix.head_target ? { head_target: headFix.head_target } : {}),
|
|
551
|
+
});
|
|
401
552
|
if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
|
|
402
553
|
for (const spec of specs) out(`ok ${spec.dst}`);
|
|
403
554
|
} catch (err) {
|
|
@@ -429,8 +580,17 @@ async function main(argv) {
|
|
|
429
580
|
// whichever way git asked for it.
|
|
430
581
|
out("ok");
|
|
431
582
|
return;
|
|
583
|
+
case "dry-run":
|
|
584
|
+
// kychee-com/run402#565: a REAL dry run — `runPush` runs the actual
|
|
585
|
+
// local pipeline (pack building, encryption sizing) and stops before
|
|
586
|
+
// the two network mutations. `value` is git's own boolean spelling
|
|
587
|
+
// ("true"/"false"); anything else is refused rather than guessed.
|
|
588
|
+
if (value === "true") { dryRun = true; out("ok"); return; }
|
|
589
|
+
if (value === "false") { dryRun = false; out("ok"); return; }
|
|
590
|
+
out("unsupported");
|
|
591
|
+
return;
|
|
432
592
|
default:
|
|
433
|
-
// Includes
|
|
593
|
+
// Includes object-format, depth, cloning, check-connectivity,
|
|
434
594
|
// followtags, pushcert: honestly unsupported rather than acknowledged.
|
|
435
595
|
out("unsupported");
|
|
436
596
|
}
|
|
@@ -501,10 +661,23 @@ async function main(argv) {
|
|
|
501
661
|
}
|
|
502
662
|
}
|
|
503
663
|
|
|
664
|
+
// Only run the protocol loop when git (or a human) actually invoked this file
|
|
665
|
+
// as a binary — never on `import` (tests import `chooseGitvaultHeadTargetForPush`
|
|
666
|
+
// directly; without this guard that import would block on stdin forever).
|
|
667
|
+
const invokedDirectly = (() => {
|
|
668
|
+
try {
|
|
669
|
+
return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
670
|
+
} catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
})();
|
|
674
|
+
|
|
504
675
|
// Never `process.exit()` mid-stream: that can truncate a pending stdout write
|
|
505
676
|
// on a pipe, which git reads as a protocol violation. Set the code and let Node
|
|
506
677
|
// flush and exit on its own.
|
|
507
|
-
|
|
508
|
-
(
|
|
509
|
-
|
|
510
|
-
);
|
|
678
|
+
if (invokedDirectly) {
|
|
679
|
+
main(process.argv.slice(2)).then(
|
|
680
|
+
(code) => { process.exitCode = code; },
|
|
681
|
+
(err) => { note(describeError(err)); process.exitCode = 1; },
|
|
682
|
+
);
|
|
683
|
+
}
|
package/lib/command-manifest.mjs
CHANGED
|
@@ -433,7 +433,11 @@ export const COMMAND_MANIFEST = [
|
|
|
433
433
|
{ path: ["service", "health"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
|
|
434
434
|
{ path: ["cache", "inspect"], positionals: [p("url")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["https://example.com/"] },
|
|
435
435
|
{ path: ["cache", "invalidate"], positionals: [p("url", { required: false })], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--all", "--host", "example.com"] },
|
|
436
|
-
|
|
436
|
+
// projectScoped (kychee-com/run402#566): --project targets the gitvault
|
|
437
|
+
// check only (see doctor.mjs's own HELP) — every other check stays
|
|
438
|
+
// wallet/machine-wide, but the gate's contract is "accepts --project
|
|
439
|
+
// without rejecting it," which this satisfies.
|
|
440
|
+
{ path: ["doctor"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--no-scan"], runStyle: "merged" },
|
|
437
441
|
{ path: ["webhook-secret", "rotate"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
|
|
438
442
|
{ path: ["logs"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--request-id", "req_gate123"], runStyle: "merged" },
|
|
439
443
|
];
|
package/lib/doctor.mjs
CHANGED
|
@@ -24,11 +24,56 @@ import { doctorUpdateCheck } from "./update-check.mjs";
|
|
|
24
24
|
import { buildBuzzDoctorReport, parseBuzzDoctorArgs } from "./buzz-doctor.mjs";
|
|
25
25
|
import { queueBuzzDoctorTelemetry } from "./diagnostic-telemetry.mjs";
|
|
26
26
|
import { fail } from "./sdk-errors.mjs";
|
|
27
|
+
import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
|
|
28
|
+
|
|
29
|
+
/** Value-taking flags (kychee-com/run402#566 — the flag set doctor actually parses; anything else is BAD_USAGE via assertKnownFlags, never silently ignored). */
|
|
30
|
+
const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--buzz-agent", "--project", "--only"];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The stable, complete registry of ordinary-mode check names (kychee-com/run402#566,
|
|
34
|
+
* the remaining half). One entry per `checks.push({ name: ... })` call below,
|
|
35
|
+
* in the order each check normally runs. This is the ONE place `--only`
|
|
36
|
+
* validates its argument against and the ONE place its help text is derived
|
|
37
|
+
* from, so a check can never be selectable-but-undocumented or
|
|
38
|
+
* documented-but-unselectable.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately excludes buzz mode's own check names (`session_shell`,
|
|
41
|
+
* `node_runtime`, …) — buzz mode is a wholly separate report shape that
|
|
42
|
+
* returns before this array is ever consulted; see the `--only`/`--buzz`
|
|
43
|
+
* mutual-exclusion check in `run()`.
|
|
44
|
+
*/
|
|
45
|
+
const DOCTOR_CHECK_NAMES = [
|
|
46
|
+
"config_dir",
|
|
47
|
+
"cli_update",
|
|
48
|
+
"allowance",
|
|
49
|
+
"projects",
|
|
50
|
+
"api_reachable",
|
|
51
|
+
"tier",
|
|
52
|
+
"operator_health",
|
|
53
|
+
"runtime_staleness",
|
|
54
|
+
"gitvault",
|
|
55
|
+
"source_scan",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Every value passed to a repeatable flag, in argv order (mirrors the pattern in buzz-notifications.mjs). */
|
|
59
|
+
function collectRepeatableFlag(args, flag) {
|
|
60
|
+
const values = [];
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
if (args[i] !== flag) continue;
|
|
63
|
+
if (i + 1 >= args.length || (typeof args[i + 1] === "string" && args[i + 1].startsWith("--"))) {
|
|
64
|
+
fail({ code: "BAD_FLAG", message: `${flag} requires a value`, details: { flag } });
|
|
65
|
+
}
|
|
66
|
+
values.push(args[i + 1]);
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
return values;
|
|
70
|
+
}
|
|
27
71
|
|
|
28
72
|
const HELP = `run402 doctor — Health and config diagnostics
|
|
29
73
|
|
|
30
74
|
Usage:
|
|
31
|
-
run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>]
|
|
75
|
+
run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>] [--project <id>]
|
|
76
|
+
[--only <check> ...]
|
|
32
77
|
run402 --wallet <profile> doctor --buzz --buzz-agent <npub-or-hex>
|
|
33
78
|
|
|
34
79
|
Output:
|
|
@@ -38,12 +83,40 @@ Output:
|
|
|
38
83
|
|
|
39
84
|
Options:
|
|
40
85
|
--verbose Include extra detail (timing, error messages)
|
|
41
|
-
--refresh
|
|
42
|
-
|
|
86
|
+
--refresh Force a bounded live npm version check for the run402 CLI, even
|
|
87
|
+
if the cache is still within its 24h TTL. The cache self-heals
|
|
88
|
+
without this flag too: a MISSING or EXPIRED cache gets exactly
|
|
89
|
+
one bounded live attempt automatically on a plain \`doctor\` call.
|
|
90
|
+
A failed live check (offline) falls back to the last known-good
|
|
91
|
+
value, clearly labeled with its age — never a silent weeks-old
|
|
92
|
+
"latest" (kychee-com/run402#561). cli_update.value.cache always
|
|
93
|
+
reports fresh/age_ms/refresh_attempted/refresh_failed.
|
|
94
|
+
--no-scan Skip the source-tree scan (config / health checks only). Implied
|
|
95
|
+
by any --only that omits source_scan.
|
|
43
96
|
--scan-dir D Scan a custom directory instead of \`<cwd>/src\`
|
|
97
|
+
--project <id> Target THIS project's gitvault check instead of the repo-standing
|
|
98
|
+
default (the 4.38.0 pin / run402 remote / RUN402_PROJECT_ID / active
|
|
99
|
+
project, in that order — see \`gitvault-target.mjs\`). Scoped to the
|
|
100
|
+
gitvault check only; every other check is wallet/machine-wide, not
|
|
101
|
+
per-project, and is unaffected by this flag. Composes with --only.
|
|
102
|
+
--only <check> Run ONLY the named check (repeatable — pass it more than once
|
|
103
|
+
to run several). Every other check, INCLUDING the monorepo
|
|
104
|
+
source-tree scan, is suppressed rather than merely hidden: a
|
|
105
|
+
skipped check's network/filesystem work never runs at all, so
|
|
106
|
+
\`doctor --only gitvault\` costs one gitvault read, not a
|
|
107
|
+
config/tier/operator/scan sweep. An unknown check name is
|
|
108
|
+
BAD_USAGE listing the valid names below. Not used with --buzz
|
|
109
|
+
(buzz mode is its own separate, always-complete check set).
|
|
44
110
|
--buzz Run only the zero-mutation Buzz setup preflight
|
|
45
111
|
--buzz-agent P Bind Buzz mode to the intended public agent npub or hex key
|
|
46
112
|
|
|
113
|
+
--only check names (ordinary mode; see "Checks performed" below for what each
|
|
114
|
+
one reports):
|
|
115
|
+
${DOCTOR_CHECK_NAMES.join(", ")}
|
|
116
|
+
|
|
117
|
+
Any flag not listed above is rejected (BAD_USAGE / UNKNOWN_FLAG), never
|
|
118
|
+
silently ignored.
|
|
119
|
+
|
|
47
120
|
Telemetry:
|
|
48
121
|
Buzz preflight sends only anonymous allowlisted start/pass/block counters.
|
|
49
122
|
No identity, wallet, relay, domain, path, command output, or installation id
|
|
@@ -109,16 +182,57 @@ function describeCheckFailure(label, err) {
|
|
|
109
182
|
}
|
|
110
183
|
|
|
111
184
|
export async function run(sub, args = []) {
|
|
112
|
-
const all = [sub, ...args].filter(Boolean);
|
|
185
|
+
const all = normalizeArgv([sub, ...args].filter(Boolean));
|
|
113
186
|
if (all.includes("--help") || all.includes("-h")) {
|
|
114
187
|
console.log(HELP);
|
|
115
188
|
return;
|
|
116
189
|
}
|
|
190
|
+
// kychee-com/run402#566 (--project half): doctor used to accept ANY flag
|
|
191
|
+
// silently — an unrecognized one (a typo, or --project before this fix)
|
|
192
|
+
// was simply never looked at. Any flag doctor actually parses is listed
|
|
193
|
+
// here; anything else is now a structured BAD_USAGE/UNKNOWN_FLAG rejection
|
|
194
|
+
// instead of quietly doing nothing.
|
|
195
|
+
assertKnownFlags(all, ["--verbose", "--refresh", "--no-scan", "--buzz", ...DOCTOR_VALUE_FLAGS], DOCTOR_VALUE_FLAGS);
|
|
117
196
|
const verbose = all.includes("--verbose");
|
|
118
197
|
const refresh = all.includes("--refresh");
|
|
119
198
|
const skipScan = all.includes("--no-scan");
|
|
120
199
|
const scanDirArgIdx = all.indexOf("--scan-dir");
|
|
121
200
|
const scanDirOverride = scanDirArgIdx >= 0 ? all[scanDirArgIdx + 1] : null;
|
|
201
|
+
// Scoped to the gitvault check (see HELP): every other check is
|
|
202
|
+
// wallet/machine-wide, not per-project.
|
|
203
|
+
const projectOverride = flagValue(all, "--project");
|
|
204
|
+
|
|
205
|
+
// kychee-com/run402#566 (the remaining half): --only <check>, repeatable.
|
|
206
|
+
// Validated against the stable registry ABOVE the buzz early-return, so an
|
|
207
|
+
// unknown name is BAD_USAGE regardless of which mode was also requested —
|
|
208
|
+
// the same "every accepted flag must work or BAD_USAGE" bar #569 named for
|
|
209
|
+
// doctor's own --human bug.
|
|
210
|
+
const onlyChecks = collectRepeatableFlag(all, "--only");
|
|
211
|
+
for (const name of onlyChecks) {
|
|
212
|
+
if (!DOCTOR_CHECK_NAMES.includes(name)) {
|
|
213
|
+
fail({
|
|
214
|
+
code: "BAD_USAGE",
|
|
215
|
+
message: `Unknown doctor check: '${name}'.`,
|
|
216
|
+
hint: `Valid check names: ${DOCTOR_CHECK_NAMES.join(", ")}.`,
|
|
217
|
+
details: { check: name, known_checks: DOCTOR_CHECK_NAMES },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Buzz mode is a wholly separate, always-complete report shape — an --only
|
|
222
|
+
// that named ordinary-mode checks would be silently ignored under --buzz,
|
|
223
|
+
// exactly the class of bug #569 flagged for --human. Reject the
|
|
224
|
+
// combination instead.
|
|
225
|
+
if (onlyChecks.length > 0 && all.includes("--buzz")) {
|
|
226
|
+
fail({
|
|
227
|
+
code: "BAD_USAGE",
|
|
228
|
+
message: "--only is not used with --buzz — buzz mode runs its own fixed, always-complete check set.",
|
|
229
|
+
hint: "Drop --buzz to scope the ordinary check set with --only, or drop --only to run every buzz check.",
|
|
230
|
+
details: { only: onlyChecks },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const only = new Set(onlyChecks);
|
|
234
|
+
/** `true` when `name` should run — every check when --only was not passed, otherwise exactly the named ones. */
|
|
235
|
+
const wanted = (name) => only.size === 0 || only.has(name);
|
|
122
236
|
|
|
123
237
|
const buzzArgs = parseBuzzDoctorArgs(all);
|
|
124
238
|
if (buzzArgs.error) fail(buzzArgs.error);
|
|
@@ -135,7 +249,7 @@ export async function run(sub, args = []) {
|
|
|
135
249
|
const CONFIG_DIR = configDir();
|
|
136
250
|
|
|
137
251
|
// 1. Config directory.
|
|
138
|
-
try {
|
|
252
|
+
if (wanted("config_dir")) try {
|
|
139
253
|
if (existsSync(CONFIG_DIR) && statSync(CONFIG_DIR).isDirectory()) {
|
|
140
254
|
checks.push({ name: "config_dir", status: "ok", value: CONFIG_DIR });
|
|
141
255
|
} else {
|
|
@@ -156,7 +270,7 @@ export async function run(sub, args = []) {
|
|
|
156
270
|
|
|
157
271
|
// 1b. CLI version/update state. This is advisory: stale or unknown version
|
|
158
272
|
// state should help the user, not hide the rest of doctor.
|
|
159
|
-
try {
|
|
273
|
+
if (wanted("cli_update")) try {
|
|
160
274
|
checks.push(await doctorUpdateCheck({ refresh }));
|
|
161
275
|
} catch (err) {
|
|
162
276
|
checks.push({
|
|
@@ -168,7 +282,7 @@ export async function run(sub, args = []) {
|
|
|
168
282
|
|
|
169
283
|
// 2. Allowance.
|
|
170
284
|
let allowanceConfigured = false;
|
|
171
|
-
try {
|
|
285
|
+
if (wanted("allowance")) try {
|
|
172
286
|
const allowance = readAllowance();
|
|
173
287
|
if (allowance) {
|
|
174
288
|
allowanceConfigured = true;
|
|
@@ -203,7 +317,7 @@ export async function run(sub, args = []) {
|
|
|
203
317
|
// service_key) that `run402 projects provision` writes. An empty store is
|
|
204
318
|
// normal for fresh installs that haven't provisioned a project yet, so
|
|
205
319
|
// report informationally as `ok` rather than warning.
|
|
206
|
-
try {
|
|
320
|
+
if (wanted("projects")) try {
|
|
207
321
|
const keystore = loadKeyStore();
|
|
208
322
|
const projectCount = Object.keys(keystore?.projects ?? {}).length;
|
|
209
323
|
checks.push({
|
|
@@ -228,7 +342,7 @@ export async function run(sub, args = []) {
|
|
|
228
342
|
}
|
|
229
343
|
|
|
230
344
|
// 4. API base reachability.
|
|
231
|
-
try {
|
|
345
|
+
if (wanted("api_reachable")) try {
|
|
232
346
|
const sdk = getSdk();
|
|
233
347
|
// Use the service.status endpoint (read-only, unauthenticated).
|
|
234
348
|
const t0 = Date.now();
|
|
@@ -249,7 +363,7 @@ export async function run(sub, args = []) {
|
|
|
249
363
|
}
|
|
250
364
|
|
|
251
365
|
// 5. Active tier.
|
|
252
|
-
try {
|
|
366
|
+
if (wanted("tier")) try {
|
|
253
367
|
const sdk = getSdk();
|
|
254
368
|
const tier = await sdk.tier.status();
|
|
255
369
|
const tierName = tier?.tier ?? null;
|
|
@@ -292,7 +406,12 @@ export async function run(sub, args = []) {
|
|
|
292
406
|
}
|
|
293
407
|
|
|
294
408
|
// 6. Operator health snapshot (v1.55 + v1.56 verification attempt detail).
|
|
295
|
-
|
|
409
|
+
// Both checks below ride the SAME operator-status read (runtime_staleness
|
|
410
|
+
// reuses the response operator_health already pulled), so the whole block
|
|
411
|
+
// is gated on wanting EITHER — --only runtime_staleness alone still needs
|
|
412
|
+
// this read, but --only-ing neither skips it entirely, same "don't do the work of a
|
|
413
|
+
// check nobody asked for" discipline the rest of --only follows.
|
|
414
|
+
if (wanted("operator_health") || wanted("runtime_staleness")) try {
|
|
296
415
|
const sdk = getSdk();
|
|
297
416
|
const status = await sdk.admin.getOperatorStatus();
|
|
298
417
|
const gaps = [];
|
|
@@ -334,15 +453,17 @@ export async function run(sub, args = []) {
|
|
|
334
453
|
gaps.push(`${item.kind}: ${item.detail}`);
|
|
335
454
|
}
|
|
336
455
|
}
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
456
|
+
if (wanted("operator_health")) {
|
|
457
|
+
if (gaps.length > 0) {
|
|
458
|
+
checks.push({
|
|
459
|
+
name: "operator_health",
|
|
460
|
+
status: "warning",
|
|
461
|
+
value: { gaps },
|
|
462
|
+
hint: "Address the above gaps; they're what 'run402 notifications' is designed to surface.",
|
|
463
|
+
});
|
|
464
|
+
} else {
|
|
465
|
+
checks.push({ name: "operator_health", status: "ok" });
|
|
466
|
+
}
|
|
346
467
|
}
|
|
347
468
|
|
|
348
469
|
// 6b. Function runtime staleness (v1.69, capability
|
|
@@ -352,45 +473,47 @@ export async function run(sub, args = []) {
|
|
|
352
473
|
// NOT refresh it (apply's release diff keys on the source code_hash, not
|
|
353
474
|
// the wrapper). Read-only signal; refreshing is strictly opt-in. Reuses
|
|
354
475
|
// the operator status fetched above to avoid a second round-trip.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (runtime.stale_function_count
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
476
|
+
if (wanted("runtime_staleness")) {
|
|
477
|
+
const runtime = status.runtime;
|
|
478
|
+
if (runtime && typeof runtime.stale_function_count === "number") {
|
|
479
|
+
if (runtime.stale_function_count > 0) {
|
|
480
|
+
checks.push({
|
|
481
|
+
name: "runtime_staleness",
|
|
482
|
+
status: "warning",
|
|
483
|
+
value: {
|
|
484
|
+
stale_function_count: runtime.stale_function_count,
|
|
485
|
+
stale_functions: runtime.stale_functions ?? [],
|
|
486
|
+
},
|
|
487
|
+
hint: `${runtime.stale_function_count} function(s) are running an older platform runtime. Run 'run402 functions rebuild --all' to refresh (re-bundles from your stored source; no source change).`,
|
|
488
|
+
});
|
|
489
|
+
} else {
|
|
490
|
+
checks.push({
|
|
491
|
+
name: "runtime_staleness",
|
|
492
|
+
status: "ok",
|
|
493
|
+
value: { stale_function_count: 0 },
|
|
494
|
+
});
|
|
495
|
+
}
|
|
367
496
|
} else {
|
|
497
|
+
// Gateway older than v1.69 doesn't surface the runtime block.
|
|
368
498
|
checks.push({
|
|
369
499
|
name: "runtime_staleness",
|
|
370
|
-
status: "
|
|
371
|
-
|
|
500
|
+
status: "skipped",
|
|
501
|
+
...(verbose && { hint: "operator status has no 'runtime' block; requires v1.69+ gateway." }),
|
|
372
502
|
});
|
|
373
503
|
}
|
|
374
|
-
} else {
|
|
375
|
-
// Gateway older than v1.69 doesn't surface the runtime block.
|
|
376
|
-
checks.push({
|
|
377
|
-
name: "runtime_staleness",
|
|
378
|
-
status: "skipped",
|
|
379
|
-
...(verbose && { hint: "operator status has no 'runtime' block; requires v1.69+ gateway." }),
|
|
380
|
-
});
|
|
381
504
|
}
|
|
382
505
|
} catch (err) {
|
|
383
506
|
// Operator status endpoint may not be reachable if the operator-binding
|
|
384
507
|
// substrate isn't deployed yet on the target API. Don't fail the whole
|
|
385
508
|
// doctor over it — emit as a soft warning. The runtime-staleness check
|
|
386
509
|
// rides on the same fetch, so skip it for the same reason.
|
|
387
|
-
checks.push({
|
|
510
|
+
if (wanted("operator_health")) checks.push({
|
|
388
511
|
name: "operator_health",
|
|
389
512
|
status: "skipped",
|
|
390
513
|
message: describeCheckFailure("operator status check", err),
|
|
391
514
|
...(verbose && { hint: "GET /agent/v1/operator/status not reachable; requires v1.55+ gateway." }),
|
|
392
515
|
});
|
|
393
|
-
checks.push({
|
|
516
|
+
if (wanted("runtime_staleness")) checks.push({
|
|
394
517
|
name: "runtime_staleness",
|
|
395
518
|
status: "skipped",
|
|
396
519
|
message: describeCheckFailure("operator status check", err),
|
|
@@ -410,14 +533,16 @@ export async function run(sub, args = []) {
|
|
|
410
533
|
// deployed is a first-class shape (protocol D183), so its mere absence of a
|
|
411
534
|
// deploy raises nothing.
|
|
412
535
|
//
|
|
413
|
-
// TARGETING (repo-first-onramp follow-up, kychee-com/run402#559d
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
536
|
+
// TARGETING (repo-first-onramp follow-up, kychee-com/run402#559d, extended
|
|
537
|
+
// by kychee-com/run402#566's --project half): when cwd is a repository
|
|
538
|
+
// with its own pinned repo id or run402/origin remote, doctor checks THAT
|
|
539
|
+
// vault, not the profile's active project — the same pin > remote >
|
|
540
|
+
// RUN402_PROJECT_ID env > active-project order every other gitvault verb
|
|
541
|
+
// follows (`gitvault-target.mjs`). An explicit `--project <id>` outranks
|
|
542
|
+
// all of that (the resolver's own top tier), same as every other gitvault
|
|
543
|
+
// verb's `--project`.
|
|
544
|
+
if (wanted("gitvault")) {
|
|
545
|
+
const target = await resolveGitvaultTarget({ repoDir: process.cwd(), explicitProjectId: projectOverride ?? undefined });
|
|
421
546
|
const projectId = target.project_id ?? null;
|
|
422
547
|
const repoId = target.repo_id ?? null;
|
|
423
548
|
if (!projectId && !repoId) {
|
|
@@ -458,7 +583,11 @@ export async function run(sub, args = []) {
|
|
|
458
583
|
if (gv.pending_overrides > 0) {
|
|
459
584
|
gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
|
|
460
585
|
}
|
|
461
|
-
|
|
586
|
+
// `matches` is a TRI-STATE (kychee-com/run402#562): `false` alone is
|
|
587
|
+
// a real mismatch. `null` (a slug-form remote not yet resolved on
|
|
588
|
+
// this machine) is not evidence of anything wrong — `!gv.remote.matches`
|
|
589
|
+
// used to treat null the same as false and would have warned here.
|
|
590
|
+
if (gv.remote && gv.remote.matches === false) {
|
|
462
591
|
gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
|
|
463
592
|
}
|
|
464
593
|
// Echoed exactly as the SDK reported them — including the
|
|
@@ -488,8 +617,10 @@ export async function run(sub, args = []) {
|
|
|
488
617
|
// SDK names, state-changing GETs, auth.* in prerendered pages, and
|
|
489
618
|
// direct mutation of internal.sessions.authz_version. Hits with severity
|
|
490
619
|
// `error` block deploy (`run402 deploy` wraps doctor and respects exit
|
|
491
|
-
// code). Skipped via --no-scan when the user wants config-only checks
|
|
492
|
-
|
|
620
|
+
// code). Skipped via --no-scan when the user wants config-only checks, and
|
|
621
|
+
// by any --only that omits it (kychee-com/run402#566 — this is the check
|
|
622
|
+
// that used to bury the gitvault diagnosis under ~1,800 monorepo findings).
|
|
623
|
+
if (!skipScan && wanted("source_scan")) {
|
|
493
624
|
try {
|
|
494
625
|
const scanRoot = scanDirOverride ?? resolveScanRoot(process.cwd());
|
|
495
626
|
const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
|