run402 4.38.2 → 4.39.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.
@@ -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` never changes the vault's HEAD target; the SDK carries it forward.
72
- * A fresh vault defaults to `refs/heads/main`, so a first push of some
73
- * other branch leaves HEAD naming a ref that does not exist yet. Use
74
- * `run402 gitvault snapshot`, which sets the HEAD target from the local HEAD.
75
- * - `option dry-run` is `unsupported`: this helper cannot rehearse a
76
- * publication, and reporting a fake success would be worse than refusing.
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
@@ -81,7 +98,9 @@
81
98
  * the set `git ls-remote <url>` outside a checkout needs.
82
99
  */
83
100
 
101
+ import { realpathSync } from "node:fs";
84
102
  import { createInterface } from "node:readline";
103
+ import { pathToFileURL } from "node:url";
85
104
  import { getSdk } from "./lib/sdk.mjs";
86
105
  import { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } from "./lib/wallet-context.mjs";
87
106
  import { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } from "#sdk";
@@ -214,6 +233,62 @@ function parsePushSpec(spec) {
214
233
  return { src: body.slice(0, colon), dst: body.slice(colon + 1), force: forced };
215
234
  }
216
235
 
236
+ /**
237
+ * Decide whether THIS push must repair a DANGLING vault HEAD, and to what
238
+ * (kychee-com/run402#568 — the first-clone empty-tree hazard). The rule:
239
+ *
240
+ * WHEN the vault's current materialized HEAD target is absent, OR is a
241
+ * symref naming a ref this push's own batch does not leave present, set
242
+ * `head_target` to one of the branches THIS push is publishing — this
243
+ * repository's own HEAD branch when it is among them, else the first
244
+ * branch in the batch (git's own order) — and say which, and why, in a
245
+ * one-line note. No silent magic.
246
+ *
247
+ * WHEN HEAD is already set and healthy (a symref naming a ref this push
248
+ * leaves present, or a detached target), it is NEVER touched — push
249
+ * moving history never means push moving HEAD. That stays the documented
250
+ * rule (`vault.push`'s own `head_target ?? base.head_target` carry-forward
251
+ * already guarantees this at the SDK layer; this function just decides
252
+ * WHEN to override that default).
253
+ *
254
+ * Pure — no I/O, no git, no network — so it is unit-testable directly.
255
+ * `updates` is this push's own ref-transaction updates (`{ ref, new_oid }`,
256
+ * `new_oid: null` for a deletion); `baseRefs`/`baseHeadTarget` are what the
257
+ * vault materialized BEFORE this push; `localHeadRef` is this repository's
258
+ * own HEAD branch (`refs/heads/<name>`), or `null` when detached/unknown.
259
+ *
260
+ * Returns `{ head_target: undefined }` (never publish an override — the SDK
261
+ * carries the base forward) when HEAD needs no repair, or when this batch
262
+ * has no branch update to repair it WITH (a tags-only or deletion-only
263
+ * batch cannot fix a dangling HEAD by itself).
264
+ */
265
+ export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, updates, localHeadRef }) {
266
+ const postPushRefs = { ...(baseRefs ?? {}) };
267
+ for (const u of updates) {
268
+ if (u.new_oid === null) delete postPushRefs[u.ref];
269
+ else postPushRefs[u.ref] = u.new_oid;
270
+ }
271
+
272
+ const dangling =
273
+ !baseHeadTarget ||
274
+ (baseHeadTarget.kind === "symref" && !Object.prototype.hasOwnProperty.call(postPushRefs, baseHeadTarget.ref));
275
+ if (!dangling) return { head_target: undefined, note: null };
276
+
277
+ const pushedBranches = updates.filter((u) => u.new_oid !== null && u.ref.startsWith("refs/heads/")).map((u) => u.ref);
278
+ if (pushedBranches.length === 0) return { head_target: undefined, note: null };
279
+
280
+ const localIsPushed = Boolean(localHeadRef) && pushedBranches.includes(localHeadRef);
281
+ const chosen = localIsPushed ? localHeadRef : pushedBranches[0];
282
+ const why = localIsPushed
283
+ ? "this repository's own HEAD branch"
284
+ : pushedBranches.length > 1
285
+ ? `the first of ${pushedBranches.length} branches pushed in this batch`
286
+ : "the branch this push publishes";
287
+ const priorState = baseHeadTarget ? `dangling (named '${baseHeadTarget.ref}', which this push does not publish)` : "unset";
288
+ const note = `vault HEAD was ${priorState} — setting it to '${chosen}' (${why}). A healthy HEAD is never moved by push.`;
289
+ return { head_target: { kind: "symref", ref: chosen }, note };
290
+ }
291
+
217
292
  async function main(argv) {
218
293
  const address = resolveRemoteAddress(argv);
219
294
  if (!address) {
@@ -230,6 +305,11 @@ async function main(argv) {
230
305
  const addressForm = gitvaultRemoteAddressForm(address);
231
306
  const target = { project_id: address.project_id, org_id: address.org_id };
232
307
  let verbosity = 1;
308
+ // kychee-com/run402#565: `option dry-run true` used to be honestly
309
+ // `unsupported` (this helper could not rehearse a publication, and
310
+ // reporting a fake success would be worse than refusing). It now IS
311
+ // real — see `handleOption`'s `dry-run` case and `runPush` below.
312
+ let dryRun = false;
233
313
 
234
314
  /**
235
315
  * The repository git invoked us for, resolved once and PROVEN.
@@ -245,6 +325,16 @@ async function main(argv) {
245
325
  return resolvedRepo.repo_dir;
246
326
  }
247
327
 
328
+ /** This repository's own HEAD branch (`refs/heads/<name>`), or `null` when detached, unborn, or unreadable — never a failure by itself. */
329
+ async function localHeadBranchRef(repoDir) {
330
+ try {
331
+ const out = (await hardenedGit(repoDir, ["symbolic-ref", "--quiet", "HEAD"])).text().trim();
332
+ return out.length > 0 ? out : null;
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
248
338
  /**
249
339
  * What to tell a human when we refuse. `git clone` is the case that used to
250
340
  * fail; naming the working alternative beats a bare error.
@@ -380,6 +470,52 @@ async function main(argv) {
380
470
  ? null
381
471
  : (await hardenedGit(repoDir, ["rev-parse", "--verify", "--end-of-options", spec.src])).text().trim());
382
472
  }
473
+
474
+ if (dryRun) {
475
+ // kychee-com/run402#565: READ-ONLY resolution — `openVault`, never
476
+ // `openOrCreateVault` — so a push-to-create dry run allocates
477
+ // NOTHING. An unresolved vault means there is nothing to preview a
478
+ // push against yet (no repo_id ⇒ no encryption key ⇒ sizing is
479
+ // genuinely unknowable, not merely unreported); still report success
480
+ // per-ref, since a real push here WOULD succeed (it would allocate
481
+ // first) — only the sizing is unavailable.
482
+ let vault;
483
+ try {
484
+ vault = await openVault(repoDir);
485
+ } catch (err) {
486
+ if (!isVaultNotFound(err)) throw err;
487
+ 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");
488
+ for (const spec of specs) out(`ok ${spec.dst}`);
489
+ endBlock();
490
+ return 0;
491
+ }
492
+ const base = await vault.materialize();
493
+ const updates = [];
494
+ for (const spec of specs) {
495
+ const expectedOld = base.refs?.[spec.dst] ?? null;
496
+ updates.push({
497
+ ref: spec.dst,
498
+ expected_old_oid: expectedOld,
499
+ new_oid: newOids.get(spec),
500
+ force: spec.force && expectedOld !== null,
501
+ });
502
+ }
503
+ // Same evaluation, pack building, and sealing/encryption a real push
504
+ // runs — stops before the two network mutations (upload, admit). A
505
+ // refusal here (non-fast-forward, tag immutability, ...) throws the
506
+ // SAME way a real push's would, caught below and reported as
507
+ // `error`, never a fake `ok`.
508
+ const plan = await vault.planPush({ transaction: { updates } });
509
+ note(
510
+ `dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
511
+ `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw), ` +
512
+ `${Object.keys(plan.refs).length} ref(s); no allocation needed`,
513
+ );
514
+ for (const spec of specs) out(`ok ${spec.dst}`);
515
+ endBlock();
516
+ return 0;
517
+ }
518
+
383
519
  const vault = await openOrCreateVault(repoDir);
384
520
  const base = await vault.materialize();
385
521
  const updates = [];
@@ -394,10 +530,26 @@ async function main(argv) {
394
530
  force: spec.force && expectedOld !== null,
395
531
  });
396
532
  }
533
+ // Repair a DANGLING HEAD from this batch's own branches (#568) — see
534
+ // `chooseGitvaultHeadTargetForPush`'s own doc comment for the exact
535
+ // rule. `localHeadRef` is read from THIS repository (never cwd, same
536
+ // fail-closed resolution as everything else in this function).
537
+ const headFix = chooseGitvaultHeadTargetForPush({
538
+ baseHeadTarget: base.head_target,
539
+ baseRefs: base.refs,
540
+ updates,
541
+ localHeadRef: await localHeadBranchRef(repoDir),
542
+ });
543
+ if (headFix.note) note(headFix.note);
397
544
  // ONE transaction for the whole batch: the SDK evaluates fast-forward,
398
545
  // tag immutability, protocol-ref refusal and retention roots, builds the
399
- // packs, and publishes — all or nothing.
400
- const published = await vault.push({ transaction: { updates } });
546
+ // packs, and publishes — all or nothing. `head_target` is included ONLY
547
+ // when a repair is called for; omitted, `vault.push` carries the base
548
+ // forward unchanged — a healthy HEAD is never moved.
549
+ const published = await vault.push({
550
+ transaction: { updates },
551
+ ...(headFix.head_target ? { head_target: headFix.head_target } : {}),
552
+ });
401
553
  if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
402
554
  for (const spec of specs) out(`ok ${spec.dst}`);
403
555
  } catch (err) {
@@ -429,8 +581,17 @@ async function main(argv) {
429
581
  // whichever way git asked for it.
430
582
  out("ok");
431
583
  return;
584
+ case "dry-run":
585
+ // kychee-com/run402#565: a REAL dry run — `runPush` runs the actual
586
+ // local pipeline (pack building, encryption sizing) and stops before
587
+ // the two network mutations. `value` is git's own boolean spelling
588
+ // ("true"/"false"); anything else is refused rather than guessed.
589
+ if (value === "true") { dryRun = true; out("ok"); return; }
590
+ if (value === "false") { dryRun = false; out("ok"); return; }
591
+ out("unsupported");
592
+ return;
432
593
  default:
433
- // Includes dry-run, object-format, depth, cloning, check-connectivity,
594
+ // Includes object-format, depth, cloning, check-connectivity,
434
595
  // followtags, pushcert: honestly unsupported rather than acknowledged.
435
596
  out("unsupported");
436
597
  }
@@ -501,10 +662,36 @@ async function main(argv) {
501
662
  }
502
663
  }
503
664
 
665
+ // Only run the protocol loop when git (or a human) actually invoked this file
666
+ // as a binary — never on `import` (tests import `chooseGitvaultHeadTargetForPush`
667
+ // directly; without this guard that import would block on stdin forever).
668
+ const invokedDirectly = (() => {
669
+ try {
670
+ if (process.argv[1] === undefined) return false;
671
+ // SYMLINK-SAFE, or every real install is dead (4.39.0 shipped without
672
+ // this and the helper silently no-opped for everyone): npm installs the
673
+ // bin as a SYMLINK (`/opt/homebrew/bin/git-remote-run402 -> ../lib/...`),
674
+ // and Node's ESM loader resolves `import.meta.url` through the symlink
675
+ // to the REAL file while `process.argv[1]` keeps the symlink path — a
676
+ // naive equality check therefore fails exactly and only in production,
677
+ // where `git push` then reads zero capabilities and aborts the session.
678
+ // Compare realpaths on both sides; a vanished argv[1] path falls through
679
+ // to the plain comparison rather than crashing the guard.
680
+ const argvReal = (() => {
681
+ try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; }
682
+ })();
683
+ return import.meta.url === pathToFileURL(argvReal).href;
684
+ } catch {
685
+ return false;
686
+ }
687
+ })();
688
+
504
689
  // Never `process.exit()` mid-stream: that can truncate a pending stdout write
505
690
  // on a pipe, which git reads as a protocol violation. Set the code and let Node
506
691
  // flush and exit on its own.
507
- main(process.argv.slice(2)).then(
508
- (code) => { process.exitCode = code; },
509
- (err) => { note(describeError(err)); process.exitCode = 1; },
510
- );
692
+ if (invokedDirectly) {
693
+ main(process.argv.slice(2)).then(
694
+ (code) => { process.exitCode = code; },
695
+ (err) => { note(describeError(err)); process.exitCode = 1; },
696
+ );
697
+ }
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 Wait for a bounded live npm version check for the run402 CLI
46
- --no-scan Skip the source-tree scan (config / health checks only)
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
- try {
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 (gaps.length > 0) {
359
- checks.push({
360
- name: "operator_health",
361
- status: "warning",
362
- value: { gaps },
363
- hint: "Address the above gaps; they're what 'run402 notifications' is designed to surface.",
364
- });
365
- } else {
366
- checks.push({ name: "operator_health", status: "ok" });
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
- const runtime = status.runtime;
377
- if (runtime && typeof runtime.stale_function_count === "number") {
378
- if (runtime.stale_function_count > 0) {
379
- checks.push({
380
- name: "runtime_staleness",
381
- status: "warning",
382
- value: {
383
- stale_function_count: runtime.stale_function_count,
384
- stale_functions: runtime.stale_functions ?? [],
385
- },
386
- 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).`,
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: "ok",
392
- value: { stale_function_count: 0 },
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
- if (gv.remote && !gv.remote.matches) {
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
- if (!skipScan) {
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() });