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/lib/gitvault.mjs CHANGED
@@ -39,8 +39,8 @@ export const HELP = `run402 gitvault — your source, encrypted before it leaves
39
39
 
40
40
  Usage:
41
41
  run402 gitvault init [--project <id>] [--org <org_id>] [--git-remote] [--no-remote]
42
- run402 gitvault status [--project <id>] [--repo <repo_id>] [--refs]
43
- run402 gitvault snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint]
42
+ run402 gitvault status [--project <id>] [--repo <repo_id>] [--refs] [--human]
43
+ run402 gitvault snapshot [--project <id>] [--repo <repo_id>] [--message <text>] [--checkpoint] [--dry-run]
44
44
  run402 gitvault policy <required|grandfathered> [--project <id>] [--repo <repo_id>]
45
45
  [--reason <why>]
46
46
  run402 gitvault compact [--project <id>] [--repo <repo_id>]
@@ -83,7 +83,9 @@ Subcommands:
83
83
  next release. Once \`gitvault\` was the only publish verb; \`git
84
84
  push\` is now the actual publish path (via the remote helper),
85
85
  so \`push\` here was renamed to name what it does: one verb per
86
- operation.
86
+ operation. \`--dry-run\` (kychee-com/run402#565) previews it
87
+ instead: the same real local pipeline, publishing nothing and
88
+ never allocating.
87
89
  compact Publish a checkpoint covering the canonical refs, every root
88
90
  unexpired at the cutoff, and the HEAD target, under a maintenance
89
91
  lease so a concurrent cycle cannot race it.
@@ -110,10 +112,25 @@ Options:
110
112
  HEAD target. This is a VERIFICATION (it walks the head
111
113
  chain and advances the local materialized pin), which is
112
114
  why plain \`status\` — an observation — does not do it.
115
+ --human status: a five/six-line human summary on stdout instead of
116
+ the JSON dump (kychee-com/run402#569; explicit opt-in per
117
+ the cli-output-contract). Address, remote; HEAD + ref count
118
+ (needs --refs too — otherwise the line names the omission);
119
+ generations in decimal; storage bytes/object count (from
120
+ this SAME status() call — no extra network read); whether
121
+ THIS machine can decrypt, and the policy; standing warnings,
122
+ if any, verbatim (a live terminal-loss risk may be the sixth
123
+ line). Rejected together with --json. No effect on plain
124
+ \`status\`'s own output, which is unchanged.
113
125
  --repo <repo_id> Address the vault directly by id, skipping project lookup
114
126
  --message <text> snapshot: commit message for the synthetic commit a dirty tree
115
127
  produces (a clean tree pushes HEAD itself, no message used)
116
128
  --checkpoint snapshot: force the checkpoint-bearing form regardless of delta size
129
+ --dry-run snapshot: a REAL preview (kychee-com/run402#565) — runs the actual
130
+ local pipeline (capture, pack building, encryption sizing) and
131
+ reports objects, encrypted bytes, refs, and the generation it
132
+ would admit as. Publishes NOTHING, and never allocates a vault
133
+ that does not exist yet (reports allocation_needed instead).
117
134
  --budget <n> verify: heads to verify in this call. The verified prefix is
118
135
  persisted, so a budget-exceeded run resumes where it stopped
119
136
  instead of restarting.
@@ -159,7 +176,9 @@ Terminal loss (protocol §0):
159
176
  Examples:
160
177
  run402 gitvault init
161
178
  run402 gitvault status --refs
179
+ run402 gitvault status --human
162
180
  run402 gitvault snapshot --message "wip: refactor the parser"
181
+ run402 gitvault snapshot --dry-run
163
182
  run402 gitvault policy grandfathered --reason "migrating CI to a vaulted client"
164
183
  run402 gitvault verify --budget 500
165
184
  run402 gitvault prune --project prj_1a2b3c
@@ -377,22 +396,146 @@ async function policy(args) {
377
396
  }
378
397
  }
379
398
 
399
+ /**
400
+ * The vault's address in the form a human would actually type it: named
401
+ * (`run402::<org-slug>/<name>`) when this checkout's local pin resolved from
402
+ * one — an id-form pin buys nothing and is never written (see
403
+ * `gitvault-address.ts`'s own doc comment), so a non-null `s.pinned` always
404
+ * carries `resolved_from` — else id-form (`run402::<org_id>/<project_id>`),
405
+ * falling back to whichever of project_id/repo_id is known when the vault
406
+ * record itself is unavailable.
407
+ */
408
+ function formatGitvaultAddress(s) {
409
+ if (s.pinned?.resolved_from) {
410
+ return `run402::${s.pinned.resolved_from.org_slug}/${s.pinned.resolved_from.repo_name}`;
411
+ }
412
+ const orgId = s.vault?.org_id ?? null;
413
+ const projectId = s.project_id ?? s.vault?.project_id ?? null;
414
+ if (orgId && projectId) return `run402::${orgId}/${projectId}`;
415
+ if (projectId) return projectId;
416
+ if (s.repo_id) return `repo ${s.repo_id}`;
417
+ return "(unresolved)";
418
+ }
419
+
420
+ /**
421
+ * `run402 gitvault status --human` (kychee-com/run402#569) — the five-liner:
422
+ * "status --refs is an admission-debugging protocol dump; the human question
423
+ * is five lines — remote URL, branch/HEAD, generation, bytes,
424
+ * can-this-machine-decrypt." Renders from `s` alone — the SAME status() call
425
+ * the JSON path already made, so `--human` costs no extra network read.
426
+ *
427
+ * Generations render DECIMAL, not the wire's 16-hex-digit form — a hex
428
+ * generation is a protocol detail, not something a human reads at a glance.
429
+ *
430
+ * The HEAD/ref-count line needs the vault's OWN ref map, which `status`
431
+ * fetches only when `--refs` is ALSO passed (materializing is a verification
432
+ * that advances local state — `status` alone stays a pure observation, see
433
+ * that option's own doc comment). Composing `--human --refs` gets the full
434
+ * line; `--human` alone names the omission rather than guessing from the
435
+ * local git checkout, which could easily disagree with what the vault holds.
436
+ *
437
+ * Warnings — including the progressive terminal-loss risk warning — are
438
+ * echoed EXACTLY as the SDK reported them (never reworded) and become an
439
+ * optional sixth line, present only when `s.warnings` is non-empty. Without
440
+ * `--human`, `run402 gitvault status` is unchanged: it always prints the
441
+ * FULL terminal-loss statement verbatim on stderr regardless of warnings;
442
+ * this compact view surfaces it only when it is actually live advice.
443
+ */
444
+ async function formatGitvaultHuman(s) {
445
+ const lines = [];
446
+ const remotePart = s.remote
447
+ ? ` (remote '${s.remote.name}'${s.remote.matches ? "" : " — points at a DIFFERENT project"})`
448
+ : " (no local remote)";
449
+ lines.push(`Address: ${formatGitvaultAddress(s)}${remotePart}`);
450
+
451
+ if (!s.vault) {
452
+ // A normal shape (protocol D183) — no vault allocated for this project
453
+ // yet. Nothing below this line is knowable, so it is not fabricated.
454
+ lines.push("Vault: not allocated yet for this project — run 'run402 gitvault init' to allocate one.");
455
+ if (s.warnings.length > 0) lines.push(`Warnings: ${s.warnings.map((w) => w.message).join(" ")}`);
456
+ return lines.join("\n");
457
+ }
458
+
459
+ if (s.refs) {
460
+ const count = Object.keys(s.refs).length;
461
+ const head = !s.head_target
462
+ ? "(none yet)"
463
+ : s.head_target.kind === "symref"
464
+ ? s.head_target.ref
465
+ : `detached @ ${s.head_target.oid}`;
466
+ lines.push(`HEAD: ${head} (${count} ref${count === 1 ? "" : "s"})`);
467
+ } else {
468
+ lines.push("HEAD: (not materialized — pass --refs to see HEAD/ref count)");
469
+ }
470
+
471
+ const { generationToBigInt } = await import("#sdk/node");
472
+ const decimal = (g) => (g ? generationToBigInt(g).toString() : "none");
473
+ lines.push(`Generations: authenticated ${decimal(s.pins.highest_authenticated)}, materialized ${decimal(s.pins.highest_materialized)}`);
474
+
475
+ // Bytes + object count — pulled from the vault record `status()` ALREADY
476
+ // fetched (no new network read, per the ask). `objects` is per-object-kind
477
+ // counts; summed for one number a human can glance at.
478
+ const storage = s.vault.storage;
479
+ const objectCount = storage?.objects ? Object.values(storage.objects).reduce((sum, n) => sum + Number(n), 0) : null;
480
+ lines.push(storage ? `Storage: ${storage.source_bytes} byte(s)${objectCount != null ? ` across ${objectCount} object(s)` : ""}` : "Storage: unknown");
481
+
482
+ const decryptPart = !s.keystore.holds_repo_key
483
+ ? "CANNOT decrypt (no key in this machine's keystore)"
484
+ : s.keystore.can_sign
485
+ ? "can decrypt and publish"
486
+ : "can decrypt (read-only — no signing key)";
487
+ lines.push(`This machine: ${decryptPart}. Policy: ${s.gitvault_policy ?? "(none)"}`);
488
+
489
+ if (s.warnings.length > 0) lines.push(`Warnings: ${s.warnings.map((w) => w.message).join(" ")}`);
490
+
491
+ return lines.join("\n");
492
+ }
493
+
380
494
  async function status(args) {
381
495
  const a = normalizeArgv(args);
382
- assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--refs", "--help", "-h"], COMMON_VALUE_FLAGS);
496
+ assertKnownFlags(a, [...COMMON_VALUE_FLAGS, "--refs", "--human", "--help", "-h"], COMMON_VALUE_FLAGS);
383
497
  requirePositionalCount(a, COMMON_VALUE_FLAGS, {
384
498
  min: 0, max: 0, command: "run402 gitvault status", missing: "",
385
499
  });
500
+ // kychee-com/run402#569 — an explicit opt-in per the cli-output-contract
501
+ // (openspec/specs/cli-output-contract/spec.md: raw/human stdout REQUIRES
502
+ // one), the same shape `run402 up`'s own `--human` already uses. Without
503
+ // it, behavior is byte-identical to before this flag existed.
504
+ const human = a.includes("--human");
505
+ if (human && a.includes("--json")) {
506
+ fail({
507
+ code: "BAD_USAGE",
508
+ message: "--human cannot be combined with --json.",
509
+ details: { flags: a.filter((arg) => arg === "--human" || arg === "--json") },
510
+ });
511
+ }
386
512
  const target = await vaultTarget(a);
387
513
  if (a.includes("--refs")) target.refs = true;
388
514
  try {
389
515
  const s = await getSdk().gitvault.status(target);
516
+ if (human) {
517
+ // The human view REPLACES the JSON dump — it is the sanctioned
518
+ // exception the CLI-wide `--json` no-op convention already carves out
519
+ // for a command's OWN `--human` flag (see argparse.mjs's header
520
+ // comment). No new network read: everything below is already present
521
+ // on `s`, the SAME status() call the JSON path made.
522
+ console.log(await formatGitvaultHuman(s));
523
+ return;
524
+ }
390
525
  console.log(JSON.stringify(s, null, 2));
391
526
  printTerminalLoss(s);
392
527
  // Two facts the user otherwise has to leave the CLI for: which vault this
393
528
  // checkout is wired to, and what the control plane says is in it.
394
529
  if (s.remote) {
395
- console.error(`remote '${s.remote.name}': ${s.remote.url}${s.remote.matches ? "" : " ← points at a DIFFERENT project than this status"}`);
530
+ // `matches` is a TRI-STATE (kychee-com/run402#562): `false` is a real
531
+ // mismatch; `null` only means a slug-form remote has not resolved on
532
+ // this machine yet — that is NOT evidence of anything wrong, so it
533
+ // gets a neutral note, never the mismatch warning.
534
+ const suffix =
535
+ s.remote.matches === false ? " ← points at a DIFFERENT project than this status"
536
+ : s.remote.matches === null ? ` (${s.remote.reason})`
537
+ : "";
538
+ console.error(`remote '${s.remote.name}': ${s.remote.url}${suffix}`);
396
539
  }
397
540
  // The id-pinning state (design D6, task 4.5): a slug-form remote pins
398
541
  // repo_id in local git state the first time it resolves; id-form pins
@@ -459,10 +602,11 @@ async function detectSlugFormRemote(a, repoDir) {
459
602
  async function snapshot(args) {
460
603
  const a = normalizeArgv(args);
461
604
  const valueFlags = [...COMMON_VALUE_FLAGS, "--message"];
462
- assertKnownFlags(a, [...valueFlags, "--checkpoint", "--help", "-h"], valueFlags);
605
+ assertKnownFlags(a, [...valueFlags, "--checkpoint", "--dry-run", "--help", "-h"], valueFlags);
463
606
  requirePositionalCount(a, valueFlags, {
464
607
  min: 0, max: 0, command: "run402 gitvault snapshot", missing: "",
465
608
  });
609
+ const dryRun = a.includes("--dry-run");
466
610
  const message = flagValue(a, "--message");
467
611
  const repoDir = process.cwd();
468
612
  const address = await detectSlugFormRemote(a, repoDir);
@@ -472,20 +616,26 @@ async function snapshot(args) {
472
616
  // it is skipped there, matching `open()`'s own precedence. Skipped
473
617
  // entirely for a slug-form remote (`address` above) — that resolves
474
618
  // through the address, not a project_id, and needs no separate org_id.
619
+ //
620
+ // Skipped ENTIRELY for --dry-run (kychee-com/run402#565): org resolution
621
+ // exists only to feed lazy allocation, and a dry run never allocates — the
622
+ // read would cost a network round-trip for a fact `planPush` never uses.
475
623
  const target = address ? { repo_dir: repoDir } : await vaultTarget(a);
476
- const orgId = !address && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
624
+ const orgId = !address && !dryRun && target.project_id ? await resolveOwningOrgId(target.project_id) : null;
477
625
  const opts = {
478
626
  ...target,
479
627
  ...(address ? { address } : {}),
480
628
  ...(orgId ? { org_id: orgId } : {}),
481
629
  // The gitvault_commit line is progress, not payload: print it the moment
482
630
  // the snapshot exists, well before the publication round-trips finish, so
483
- // a human watching a slow push sees what is being pushed.
631
+ // a human watching a slow push sees what is being pushed. Fires for a
632
+ // dry run too — the capture itself is real, local work.
484
633
  onCommitLine: (line) => console.error(line),
485
634
  // Fires synchronously, BEFORE the capture/publish that follows — printed
486
635
  // here rather than deferred past `push()`'s return so the receipt is
487
636
  // never lost if a later step in the SAME push fails after allocation
488
- // already landed on the server.
637
+ // already landed on the server. Never fires for --dry-run: `planPush`
638
+ // never allocates, so this callback is simply unused there.
489
639
  onVaultCreated: async (created) => {
490
640
  console.error("");
491
641
  console.error(`vault allocated (genesis ${created.genesis_sha256}) — one-shot recovery receipt, keep many copies:`);
@@ -500,6 +650,24 @@ async function snapshot(args) {
500
650
  if (message != null) opts.snapshot = { message };
501
651
  if (a.includes("--checkpoint")) opts.checkpoint = true;
502
652
  try {
653
+ if (dryRun) {
654
+ // kychee-com/run402#565: a REAL dry run — the same local pipeline
655
+ // `push` runs (capture, pack building, encryption sizing), stopping
656
+ // before the two network mutations. Nothing is published; the JSON
657
+ // report is the entire contract, so it goes on stdout like every other
658
+ // gitvault verb's payload.
659
+ const plan = await getSdk().gitvault.planPush(opts);
660
+ console.log(JSON.stringify(plan, null, 2));
661
+ if (plan.allocation_needed) {
662
+ console.error("dry-run: no vault allocated for this project yet — a real snapshot would allocate one first; object/byte sizing is not knowable until then");
663
+ } else {
664
+ console.error(
665
+ `dry-run: would publish generation ${plan.would_admit_generation} (${plan.would_admit_generation_decimal}, ${plan.form}) — ` +
666
+ `${plan.object_count} object(s), ${plan.encrypted_bytes} encrypted byte(s) (${plan.raw_bytes} raw)`,
667
+ );
668
+ }
669
+ return;
670
+ }
503
671
  const result = await getSdk().gitvault.push(opts);
504
672
  console.log(JSON.stringify(result, null, 2));
505
673
  console.error(`published generation ${result.generation} (${result.form})`);
@@ -124,6 +124,12 @@ export async function refreshUpdateCheck({
124
124
  timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
125
125
  registry = npmRegistryBase(env),
126
126
  } = {}) {
127
+ // Faithful (kychee-com/run402#561): a failed live check must never ERASE a
128
+ // previously known-good `latest` — reporting `latest: null` in its place
129
+ // would be a confident lie ("nothing is known"), not an honest stale
130
+ // estimate. Read what is already cached BEFORE attempting the network
131
+ // call, so a failure can fall back to it.
132
+ const previous = readUpdateCache({ path: cachePath });
127
133
  try {
128
134
  const latest = await fetchLatestRun402Version({ env, fetchImpl, timeoutMs, registry });
129
135
  const record = {
@@ -137,16 +143,24 @@ export async function refreshUpdateCheck({
137
143
  writeUpdateCache(record, { path: cachePath });
138
144
  return { ok: true, ...record };
139
145
  } catch (err) {
146
+ // `checked_at`/`latest`/`source` are carried forward from the last
147
+ // SUCCESSFUL check (or absent, for a genuine first-ever attempt) —
148
+ // `checked_at` therefore means "the last time we actually knew
149
+ // anything", which is exactly what cache-age labeling needs to report.
150
+ // `last_attempt_at` records THIS failed attempt separately, so a caller
151
+ // can tell "stale, never rechecked" apart from "stale, just tried and
152
+ // failed again".
140
153
  const record = {
141
154
  current,
142
- latest: null,
143
- checked_at: new Date().toISOString(),
144
- source: "registry",
155
+ latest: previous?.latest ?? null,
156
+ checked_at: previous?.checked_at ?? new Date().toISOString(),
157
+ source: previous?.source ?? "registry",
145
158
  registry,
146
159
  error: {
147
160
  code: errorCode(err),
148
161
  message: err instanceof Error ? err.message : String(err),
149
162
  },
163
+ last_attempt_at: new Date().toISOString(),
150
164
  };
151
165
  writeUpdateCache(record, { path: cachePath });
152
166
  return { ok: false, ...record };
@@ -217,6 +231,21 @@ export function createUpdateCheckScheduler({
217
231
  };
218
232
  }
219
233
 
234
+ /** `now - checked_at`, or `null` when there is nothing to measure from. */
235
+ function cacheAgeMs(record, now) {
236
+ const checked = Date.parse(record?.checked_at ?? "");
237
+ return Number.isFinite(checked) ? Math.max(0, now - checked) : null;
238
+ }
239
+
240
+ /** A short, human-readable age for a hint string — hours under two days, days beyond that. */
241
+ function humanAge(ms) {
242
+ if (ms === null) return "an unknown age";
243
+ const hours = ms / (60 * 60 * 1000);
244
+ if (hours < 1) return "under an hour";
245
+ if (hours < 48) return `${Math.round(hours)}h`;
246
+ return `${Math.round(hours / 24)}d`;
247
+ }
248
+
220
249
  export async function doctorUpdateCheck({
221
250
  refresh = false,
222
251
  cwd = process.cwd(),
@@ -246,9 +275,32 @@ export async function doctorUpdateCheck({
246
275
  }
247
276
 
248
277
  let record = readUpdateCache({ path: cachePath });
249
- if (refresh) {
250
- record = await refreshUpdateCheck({ env, fetchImpl, current, cachePath });
278
+
279
+ // A real TTL (24h) that actually causes a refresh (kychee-com/run402#561
280
+ // — grok saw a THREE-minor-version, four-week-old cached "latest" with
281
+ // nothing ever having re-checked it). An explicit `--refresh` always
282
+ // checks live, same as before; now a MISSING or EXPIRED cache also gets
283
+ // exactly ONE bounded live attempt automatically, so a plain
284
+ // `run402 doctor` self-heals a stale cache instead of silently reporting
285
+ // a weeks-old value as if it were current. `refreshUpdateCheck` itself
286
+ // never erases a previously known-good `latest` on failure (see its own
287
+ // doc comment), so a failed attempt here degrades gracefully to the LAST
288
+ // GOOD record — never to a bare unknown when something was already known.
289
+ const cacheWasFreshAtStart = isCacheFresh(record, { now });
290
+ const refreshAttempted = refresh || !cacheWasFreshAtStart;
291
+ let refreshFailed = false;
292
+ if (refreshAttempted) {
293
+ const refreshed = await refreshUpdateCheck({ env, fetchImpl, current, cachePath });
294
+ refreshFailed = !refreshed.ok;
295
+ record = refreshed;
251
296
  }
297
+ // A refresh (success or failure) just happened at REAL wall-clock time —
298
+ // `refreshUpdateCheck` has no `now` override, so the instant used to
299
+ // report freshness/age below is re-derived here rather than reusing a
300
+ // `now` captured before the refresh ran (which would read the brand-new
301
+ // record as "in the future" and misreport it as stale). A call that never
302
+ // triggers a refresh keeps using exactly the `now` it was given.
303
+ const reportNow = refreshAttempted ? Date.now() : now;
252
304
 
253
305
  if (!record) {
254
306
  return {
@@ -260,26 +312,34 @@ export async function doctorUpdateCheck({
260
312
  install_context: install.kind,
261
313
  confidence: install.confidence,
262
314
  package_manager: install.package_manager,
263
- cache: { path: cachePath, fresh: false, source: "none" },
315
+ cache: { path: cachePath, fresh: false, source: "none", refresh_attempted: refreshAttempted, refresh_failed: refreshFailed },
264
316
  },
265
- hint: "No cached npm version check yet. Run 'run402 doctor --refresh' to check now.",
317
+ hint: refreshAttempted
318
+ ? "No cached npm version check yet, and a live check just failed — try 'run402 doctor --refresh' again, or check network access."
319
+ : "No cached npm version check yet. Run 'run402 doctor --refresh' to check now.",
266
320
  };
267
321
  }
268
322
 
269
- const freshness = isCacheFresh(record, { now });
323
+ const freshness = isCacheFresh(record, { now: reportNow });
324
+ const ageMs = cacheAgeMs(record, reportNow);
270
325
  const notice = updateNoticeFromRecord(record, {
271
326
  cwd,
272
327
  env,
273
328
  argv,
274
329
  execPath,
275
330
  current,
276
- now,
331
+ now: reportNow,
277
332
  command: ["run402", "doctor"],
278
333
  source: record.source ?? "cache",
279
334
  });
280
335
  const comparison = compareSemver(current, record.latest);
281
336
  const stale = notice !== null;
282
337
  const status = stale ? "warning" : record.latest && comparison !== null ? "ok" : "unknown";
338
+ // Faithful: an estimate served from a failed-refresh fallback is labeled
339
+ // as one, not presented as if it were current.
340
+ const staleEstimateNote = refreshFailed && ageMs !== null
341
+ ? ` (from a cached check ${humanAge(ageMs)} old — a live check just failed: ${record.error?.code ?? "network error"})`
342
+ : "";
283
343
  return {
284
344
  name: "cli_update",
285
345
  status,
@@ -293,15 +353,18 @@ export async function doctorUpdateCheck({
293
353
  cache: {
294
354
  path: cachePath,
295
355
  fresh: freshness,
356
+ age_ms: ageMs,
296
357
  source: record.source ?? "cache",
297
358
  error: record.error ?? null,
359
+ refresh_attempted: refreshAttempted,
360
+ refresh_failed: refreshFailed,
298
361
  },
299
362
  ...(stale ? { next_actions: notice.next_actions } : {}),
300
363
  },
301
364
  ...(stale
302
- ? { hint: `A newer run402 CLI is available (${current} -> ${record.latest}).` }
365
+ ? { hint: `A newer run402 CLI is available (${current} -> ${record.latest})${staleEstimateNote}.` }
303
366
  : record.error
304
- ? { hint: "Could not check npm for the latest run402 version; other doctor checks still ran." }
367
+ ? { hint: `Could not check npm for the latest run402 version${staleEstimateNote}; other doctor checks still ran.` }
305
368
  : {}),
306
369
  };
307
370
  }
@@ -421,40 +421,193 @@ describe("CLI update notices and scheduler", () => {
421
421
  assert.equal(scheduler.getCompletedLiveNotice().latest, "3.7.16");
422
422
  }));
423
423
 
424
- it("doctor reports stale, unknown, skipped, and refresh states without failing other checks", async () => withTemp(async (dir) => {
424
+ it("doctor reports a cached warning and skipped state without failing other checks", async () => withTemp(async (dir) => {
425
425
  packageJson(dir, { devDependencies: { run402: "3.7.14" } });
426
426
  const cachePath = join(dir, "cache.json");
427
+ const now = Date.parse("2026-07-03T10:30:00.000Z"); // 12min after staleRecord()'s checked_at — inside the 24h TTL
428
+ // Fixed `now` so this stays a within-TTL read: no live check, no
429
+ // fetchImpl needed at all for this half of the test.
427
430
  writeUpdateCache(staleRecord(), { path: cachePath });
428
431
  let check = await doctorUpdateCheck({
429
432
  cwd: dir,
430
433
  execPath: join(dir, "node_modules", ".bin", "run402"),
431
434
  current: "3.7.14",
432
435
  cachePath,
436
+ now,
433
437
  });
434
438
  assert.equal(check.status, "warning");
435
439
  assert.equal(check.value.next_actions[0].mutates_project, true);
436
440
 
437
- check = await doctorUpdateCheck({ cwd: dir, current: "3.7.14", cachePath: join(dir, "missing.json") });
438
- assert.equal(check.status, "unknown");
439
-
440
441
  check = await doctorUpdateCheck({
441
442
  cwd: dir,
442
443
  current: "3.7.14",
443
444
  cachePath,
444
445
  env: { RUN402_NO_UPDATE_CHECK: "1" },
446
+ fetchImpl: async () => { throw new Error("RUN402_NO_UPDATE_CHECK must skip every network attempt"); },
445
447
  });
446
448
  assert.equal(check.status, "skipped");
447
-
448
- check = await doctorUpdateCheck({
449
- cwd: dir,
450
- current: "3.7.14",
451
- cachePath,
452
- refresh: true,
453
- fetchImpl: async () => new Response(JSON.stringify({ version: "3.7.14" }), {
454
- status: 200,
455
- headers: { "content-type": "application/json" },
456
- }),
457
- });
458
- assert.equal(check.status, "ok");
459
449
  }));
450
+
451
+ // ─── the real TTL, and what happens at every edge of it (kychee-com/run402#561) ──
452
+ //
453
+ // THE DEFECT (grok dogfood, 4.38.0 installed): `run402 doctor` reported
454
+ // the latest CLI as 4.17.5 from a cache dated Aug 1 — three minor versions
455
+ // and ~4 weeks stale — because NOTHING besides an explicit `--refresh`
456
+ // ever re-checked it. `UPDATE_CHECK_TTL_MS` was already 24h; the bug was
457
+ // that a plain `doctor` invocation never consulted it to trigger a
458
+ // refresh, only to LABEL the cache `fresh: false` in a field nobody read.
459
+ // Four states, each hermetic (`fetchImpl` is always injected — no real
460
+ // npm registry call from this file, ever):
461
+ describe("doctor's cache TTL actually causes a refresh (kychee-com/run402#561)", () => {
462
+ it("fresh fetch: no cache at all triggers ONE automatic live check, even without --refresh", () => withTemp(async (dir) => {
463
+ const cachePath = join(dir, "cache.json");
464
+ let fetchCalls = 0;
465
+ const check = await doctorUpdateCheck({
466
+ cwd: dir,
467
+ current: "3.7.14",
468
+ cachePath,
469
+ fetchImpl: async () => {
470
+ fetchCalls += 1;
471
+ return new Response(JSON.stringify({ version: "3.7.16" }), { status: 200, headers: { "content-type": "application/json" } });
472
+ },
473
+ });
474
+ assert.equal(fetchCalls, 1, "no cache existed yet — a live check must run automatically, not just on --refresh");
475
+ assert.equal(check.status, "warning");
476
+ assert.equal(check.value.latest, "3.7.16");
477
+ assert.equal(check.value.cache.fresh, true);
478
+ assert.equal(check.value.cache.refresh_attempted, true);
479
+ assert.equal(check.value.cache.refresh_failed, false);
480
+ assert.ok(readUpdateCache({ path: cachePath }), "the fresh result must be persisted for the next call");
481
+ }));
482
+
483
+ it("within-TTL reuse: a cache inside the 24h window is served as-is, with NO live check at all", () => withTemp(async (dir) => {
484
+ const cachePath = join(dir, "cache.json");
485
+ const now = Date.parse("2026-08-01T12:00:00.000Z");
486
+ writeUpdateCache({
487
+ current: "3.7.14",
488
+ latest: "3.7.15",
489
+ checked_at: new Date(now - 60 * 60 * 1000).toISOString(), // 1h old
490
+ source: "cache",
491
+ error: null,
492
+ }, { path: cachePath });
493
+ let fetchCalls = 0;
494
+ const check = await doctorUpdateCheck({
495
+ cwd: dir,
496
+ current: "3.7.14",
497
+ cachePath,
498
+ now,
499
+ fetchImpl: async () => { fetchCalls += 1; throw new Error("a fresh cache must never trigger a live check"); },
500
+ });
501
+ assert.equal(fetchCalls, 0);
502
+ assert.equal(check.status, "warning");
503
+ assert.equal(check.value.latest, "3.7.15");
504
+ assert.equal(check.value.cache.fresh, true);
505
+ assert.equal(check.value.cache.refresh_attempted, false);
506
+ assert.equal(check.value.cache.age_ms, 60 * 60 * 1000);
507
+ }));
508
+
509
+ it("expired-refresh: a cache past the 24h TTL is refreshed automatically, and the FRESH value wins", () => withTemp(async (dir) => {
510
+ const cachePath = join(dir, "cache.json");
511
+ // Real wall-clock throughout (no fixed `now`): a SUCCESSFUL live check
512
+ // stamps `checked_at` with the actual current time internally
513
+ // (`refreshUpdateCheck` has no clock override), so comparing it
514
+ // against a fixed test `now` from the past would read as stale by
515
+ // construction — the "30 days old" fixture only needs to predate the
516
+ // REAL clock, which `Date.now() - 30d` does unconditionally.
517
+ writeUpdateCache({
518
+ current: "3.7.14",
519
+ latest: "3.7.15",
520
+ checked_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days old
521
+ source: "cache",
522
+ error: null,
523
+ }, { path: cachePath });
524
+ let fetchCalls = 0;
525
+ const check = await doctorUpdateCheck({
526
+ cwd: dir,
527
+ current: "3.7.14",
528
+ cachePath,
529
+ fetchImpl: async () => {
530
+ fetchCalls += 1;
531
+ return new Response(JSON.stringify({ version: "3.7.20" }), { status: 200, headers: { "content-type": "application/json" } });
532
+ },
533
+ });
534
+ assert.equal(fetchCalls, 1, "an expired cache must trigger exactly one automatic live check");
535
+ assert.equal(check.value.latest, "3.7.20", "the fresh value wins, not the 30-day-old one");
536
+ assert.equal(check.value.cache.fresh, true);
537
+ assert.equal(check.value.cache.refresh_failed, false);
538
+ }));
539
+
540
+ it("offline-stale-labeled: an expired cache whose live refresh FAILS falls back to the last known-good value, clearly labeled with its age", () => withTemp(async (dir) => {
541
+ const cachePath = join(dir, "cache.json");
542
+ const now = Date.parse("2026-08-26T12:00:00.000Z"); // grok's own dogfood date
543
+ writeUpdateCache({
544
+ current: "3.7.14",
545
+ latest: "4.17.5", // the exact stale value grok saw
546
+ checked_at: "2026-08-01T10:18:20.000Z", // ~25 days old
547
+ source: "cache",
548
+ error: null,
549
+ }, { path: cachePath });
550
+ const check = await doctorUpdateCheck({
551
+ cwd: dir,
552
+ current: "3.7.14",
553
+ cachePath,
554
+ now,
555
+ fetchImpl: async () => { throw new Error("offline"); },
556
+ });
557
+ // Faithful: the network is down, but the LAST KNOWN value is not thrown away as a bare null.
558
+ assert.equal(check.value.latest, "4.17.5", "a failed refresh must not erase the last known-good value");
559
+ assert.equal(check.value.cache.fresh, false, "honestly reported as stale, not silently presented as current");
560
+ assert.equal(check.value.cache.refresh_attempted, true);
561
+ assert.equal(check.value.cache.refresh_failed, true);
562
+ assert.ok(check.value.cache.age_ms >= 24 * 60 * 60 * 1000, "the reported age reflects the ORIGINAL successful check, not the failed attempt just now");
563
+ assert.equal(check.status, "warning", "a known-newer version is still worth flagging, even from stale data");
564
+ assert.match(check.hint, /\d+d old/, "the hint must name the estimate's age");
565
+ assert.match(check.hint, /live check just failed/);
566
+
567
+ // The next run inherits this SAME good value — a failed refresh must
568
+ // never clobber what was already known on disk either.
569
+ const onDisk = readUpdateCache({ path: cachePath });
570
+ assert.equal(onDisk.latest, "4.17.5");
571
+ assert.equal(onDisk.checked_at, "2026-08-01T10:18:20.000Z");
572
+ assert.ok(onDisk.error, "the failed attempt is still recorded, just not destructively");
573
+ }));
574
+
575
+ it("no cache at all AND the auto-refresh fails: unknown, not a crash — and it says a live check was attempted", () => withTemp(async (dir) => {
576
+ const check = await doctorUpdateCheck({
577
+ cwd: dir,
578
+ current: "3.7.14",
579
+ cachePath: join(dir, "missing.json"),
580
+ fetchImpl: async () => { throw new Error("offline"); },
581
+ });
582
+ assert.equal(check.status, "unknown");
583
+ assert.equal(check.value.cache.refresh_attempted, true);
584
+ assert.equal(check.value.cache.refresh_failed, true);
585
+ }));
586
+
587
+ it("--refresh forces a live check even when the cache is still well within the TTL", () => withTemp(async (dir) => {
588
+ const cachePath = join(dir, "cache.json");
589
+ const now = Date.parse("2026-08-01T12:00:00.000Z");
590
+ writeUpdateCache({
591
+ current: "3.7.14",
592
+ latest: "3.7.15",
593
+ checked_at: new Date(now - 60 * 1000).toISOString(), // 1 minute old — as fresh as it gets
594
+ source: "cache",
595
+ error: null,
596
+ }, { path: cachePath });
597
+ let fetchCalls = 0;
598
+ const check = await doctorUpdateCheck({
599
+ cwd: dir,
600
+ current: "3.7.14",
601
+ cachePath,
602
+ now,
603
+ refresh: true,
604
+ fetchImpl: async () => {
605
+ fetchCalls += 1;
606
+ return new Response(JSON.stringify({ version: "3.7.21" }), { status: 200, headers: { "content-type": "application/json" } });
607
+ },
608
+ });
609
+ assert.equal(fetchCalls, 1, "--refresh must check live regardless of freshness");
610
+ assert.equal(check.value.latest, "3.7.21");
611
+ }));
612
+ });
460
613
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.38.2",
3
+ "version": "4.39.0",
4
4
  "description": "CLI for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.",
5
5
  "type": "module",
6
6
  "bin": {