run402 4.38.2 → 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/doctor.mjs +155 -47
- package/lib/gitvault.mjs +177 -9
- 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 +102 -9
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- 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 +64 -0
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/index.d.ts +1 -1
- package/sdk/dist/node/index.d.ts.map +1 -1
- 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/doctor.mjs
CHANGED
|
@@ -27,12 +27,53 @@ import { fail } from "./sdk-errors.mjs";
|
|
|
27
27
|
import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
|
|
28
28
|
|
|
29
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"];
|
|
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
|
+
}
|
|
31
71
|
|
|
32
72
|
const HELP = `run402 doctor — Health and config diagnostics
|
|
33
73
|
|
|
34
74
|
Usage:
|
|
35
75
|
run402 doctor [--verbose] [--refresh] [--no-scan] [--scan-dir <D>] [--project <id>]
|
|
76
|
+
[--only <check> ...]
|
|
36
77
|
run402 --wallet <profile> doctor --buzz --buzz-agent <npub-or-hex>
|
|
37
78
|
|
|
38
79
|
Output:
|
|
@@ -42,17 +83,37 @@ Output:
|
|
|
42
83
|
|
|
43
84
|
Options:
|
|
44
85
|
--verbose Include extra detail (timing, error messages)
|
|
45
|
-
--refresh
|
|
46
|
-
|
|
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.
|
|
47
96
|
--scan-dir D Scan a custom directory instead of \`<cwd>/src\`
|
|
48
97
|
--project <id> Target THIS project's gitvault check instead of the repo-standing
|
|
49
98
|
default (the 4.38.0 pin / run402 remote / RUN402_PROJECT_ID / active
|
|
50
99
|
project, in that order — see \`gitvault-target.mjs\`). Scoped to the
|
|
51
100
|
gitvault check only; every other check is wallet/machine-wide, not
|
|
52
|
-
per-project, and is unaffected by this flag.
|
|
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).
|
|
53
110
|
--buzz Run only the zero-mutation Buzz setup preflight
|
|
54
111
|
--buzz-agent P Bind Buzz mode to the intended public agent npub or hex key
|
|
55
112
|
|
|
113
|
+
--only check names (ordinary mode; see "Checks performed" below for what each
|
|
114
|
+
one reports):
|
|
115
|
+
${DOCTOR_CHECK_NAMES.join(", ")}
|
|
116
|
+
|
|
56
117
|
Any flag not listed above is rejected (BAD_USAGE / UNKNOWN_FLAG), never
|
|
57
118
|
silently ignored.
|
|
58
119
|
|
|
@@ -141,6 +202,38 @@ export async function run(sub, args = []) {
|
|
|
141
202
|
// wallet/machine-wide, not per-project.
|
|
142
203
|
const projectOverride = flagValue(all, "--project");
|
|
143
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);
|
|
236
|
+
|
|
144
237
|
const buzzArgs = parseBuzzDoctorArgs(all);
|
|
145
238
|
if (buzzArgs.error) fail(buzzArgs.error);
|
|
146
239
|
if (buzzArgs.buzz) {
|
|
@@ -156,7 +249,7 @@ export async function run(sub, args = []) {
|
|
|
156
249
|
const CONFIG_DIR = configDir();
|
|
157
250
|
|
|
158
251
|
// 1. Config directory.
|
|
159
|
-
try {
|
|
252
|
+
if (wanted("config_dir")) try {
|
|
160
253
|
if (existsSync(CONFIG_DIR) && statSync(CONFIG_DIR).isDirectory()) {
|
|
161
254
|
checks.push({ name: "config_dir", status: "ok", value: CONFIG_DIR });
|
|
162
255
|
} else {
|
|
@@ -177,7 +270,7 @@ export async function run(sub, args = []) {
|
|
|
177
270
|
|
|
178
271
|
// 1b. CLI version/update state. This is advisory: stale or unknown version
|
|
179
272
|
// state should help the user, not hide the rest of doctor.
|
|
180
|
-
try {
|
|
273
|
+
if (wanted("cli_update")) try {
|
|
181
274
|
checks.push(await doctorUpdateCheck({ refresh }));
|
|
182
275
|
} catch (err) {
|
|
183
276
|
checks.push({
|
|
@@ -189,7 +282,7 @@ export async function run(sub, args = []) {
|
|
|
189
282
|
|
|
190
283
|
// 2. Allowance.
|
|
191
284
|
let allowanceConfigured = false;
|
|
192
|
-
try {
|
|
285
|
+
if (wanted("allowance")) try {
|
|
193
286
|
const allowance = readAllowance();
|
|
194
287
|
if (allowance) {
|
|
195
288
|
allowanceConfigured = true;
|
|
@@ -224,7 +317,7 @@ export async function run(sub, args = []) {
|
|
|
224
317
|
// service_key) that `run402 projects provision` writes. An empty store is
|
|
225
318
|
// normal for fresh installs that haven't provisioned a project yet, so
|
|
226
319
|
// report informationally as `ok` rather than warning.
|
|
227
|
-
try {
|
|
320
|
+
if (wanted("projects")) try {
|
|
228
321
|
const keystore = loadKeyStore();
|
|
229
322
|
const projectCount = Object.keys(keystore?.projects ?? {}).length;
|
|
230
323
|
checks.push({
|
|
@@ -249,7 +342,7 @@ export async function run(sub, args = []) {
|
|
|
249
342
|
}
|
|
250
343
|
|
|
251
344
|
// 4. API base reachability.
|
|
252
|
-
try {
|
|
345
|
+
if (wanted("api_reachable")) try {
|
|
253
346
|
const sdk = getSdk();
|
|
254
347
|
// Use the service.status endpoint (read-only, unauthenticated).
|
|
255
348
|
const t0 = Date.now();
|
|
@@ -270,7 +363,7 @@ export async function run(sub, args = []) {
|
|
|
270
363
|
}
|
|
271
364
|
|
|
272
365
|
// 5. Active tier.
|
|
273
|
-
try {
|
|
366
|
+
if (wanted("tier")) try {
|
|
274
367
|
const sdk = getSdk();
|
|
275
368
|
const tier = await sdk.tier.status();
|
|
276
369
|
const tierName = tier?.tier ?? null;
|
|
@@ -313,7 +406,12 @@ export async function run(sub, args = []) {
|
|
|
313
406
|
}
|
|
314
407
|
|
|
315
408
|
// 6. Operator health snapshot (v1.55 + v1.56 verification attempt detail).
|
|
316
|
-
|
|
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 {
|
|
317
415
|
const sdk = getSdk();
|
|
318
416
|
const status = await sdk.admin.getOperatorStatus();
|
|
319
417
|
const gaps = [];
|
|
@@ -355,15 +453,17 @@ export async function run(sub, args = []) {
|
|
|
355
453
|
gaps.push(`${item.kind}: ${item.detail}`);
|
|
356
454
|
}
|
|
357
455
|
}
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
}
|
|
367
467
|
}
|
|
368
468
|
|
|
369
469
|
// 6b. Function runtime staleness (v1.69, capability
|
|
@@ -373,45 +473,47 @@ export async function run(sub, args = []) {
|
|
|
373
473
|
// NOT refresh it (apply's release diff keys on the source code_hash, not
|
|
374
474
|
// the wrapper). Read-only signal; refreshing is strictly opt-in. Reuses
|
|
375
475
|
// the operator status fetched above to avoid a second round-trip.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (runtime.stale_function_count
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
}
|
|
388
496
|
} else {
|
|
497
|
+
// Gateway older than v1.69 doesn't surface the runtime block.
|
|
389
498
|
checks.push({
|
|
390
499
|
name: "runtime_staleness",
|
|
391
|
-
status: "
|
|
392
|
-
|
|
500
|
+
status: "skipped",
|
|
501
|
+
...(verbose && { hint: "operator status has no 'runtime' block; requires v1.69+ gateway." }),
|
|
393
502
|
});
|
|
394
503
|
}
|
|
395
|
-
} else {
|
|
396
|
-
// Gateway older than v1.69 doesn't surface the runtime block.
|
|
397
|
-
checks.push({
|
|
398
|
-
name: "runtime_staleness",
|
|
399
|
-
status: "skipped",
|
|
400
|
-
...(verbose && { hint: "operator status has no 'runtime' block; requires v1.69+ gateway." }),
|
|
401
|
-
});
|
|
402
504
|
}
|
|
403
505
|
} catch (err) {
|
|
404
506
|
// Operator status endpoint may not be reachable if the operator-binding
|
|
405
507
|
// substrate isn't deployed yet on the target API. Don't fail the whole
|
|
406
508
|
// doctor over it — emit as a soft warning. The runtime-staleness check
|
|
407
509
|
// rides on the same fetch, so skip it for the same reason.
|
|
408
|
-
checks.push({
|
|
510
|
+
if (wanted("operator_health")) checks.push({
|
|
409
511
|
name: "operator_health",
|
|
410
512
|
status: "skipped",
|
|
411
513
|
message: describeCheckFailure("operator status check", err),
|
|
412
514
|
...(verbose && { hint: "GET /agent/v1/operator/status not reachable; requires v1.55+ gateway." }),
|
|
413
515
|
});
|
|
414
|
-
checks.push({
|
|
516
|
+
if (wanted("runtime_staleness")) checks.push({
|
|
415
517
|
name: "runtime_staleness",
|
|
416
518
|
status: "skipped",
|
|
417
519
|
message: describeCheckFailure("operator status check", err),
|
|
@@ -439,7 +541,7 @@ export async function run(sub, args = []) {
|
|
|
439
541
|
// follows (`gitvault-target.mjs`). An explicit `--project <id>` outranks
|
|
440
542
|
// all of that (the resolver's own top tier), same as every other gitvault
|
|
441
543
|
// verb's `--project`.
|
|
442
|
-
{
|
|
544
|
+
if (wanted("gitvault")) {
|
|
443
545
|
const target = await resolveGitvaultTarget({ repoDir: process.cwd(), explicitProjectId: projectOverride ?? undefined });
|
|
444
546
|
const projectId = target.project_id ?? null;
|
|
445
547
|
const repoId = target.repo_id ?? null;
|
|
@@ -481,7 +583,11 @@ export async function run(sub, args = []) {
|
|
|
481
583
|
if (gv.pending_overrides > 0) {
|
|
482
584
|
gaps.push(`${gv.pending_overrides} unvaulted-override journal(s) are still open — run 'run402 gitvault push' to drain them`);
|
|
483
585
|
}
|
|
484
|
-
|
|
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) {
|
|
485
591
|
gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
|
|
486
592
|
}
|
|
487
593
|
// Echoed exactly as the SDK reported them — including the
|
|
@@ -511,8 +617,10 @@ export async function run(sub, args = []) {
|
|
|
511
617
|
// SDK names, state-changing GETs, auth.* in prerendered pages, and
|
|
512
618
|
// direct mutation of internal.sessions.authz_version. Hits with severity
|
|
513
619
|
// `error` block deploy (`run402 deploy` wraps doctor and respects exit
|
|
514
|
-
// code). Skipped via --no-scan when the user wants config-only checks
|
|
515
|
-
|
|
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")) {
|
|
516
624
|
try {
|
|
517
625
|
const scanRoot = scanDirOverride ?? resolveScanRoot(process.cwd());
|
|
518
626
|
const findings = scanSourceTree(scanRoot, { cwd: process.cwd() });
|