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/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
|
-
|
|
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})`);
|
package/lib/update-check.mjs
CHANGED
|
@@ -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
|
-
|
|
250
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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": {
|