@tokenoftrust/cli 1.4.0-rc.17 → 1.4.0-rc.19

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.
@@ -615,9 +615,19 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
615
615
  `(or pin with --renderer-version to silence).`,
616
616
  );
617
617
  }
618
- const tarball = meta?.versions?.[version]?.dist?.tarball;
619
- if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
620
- return { kind: "public", version, url: tarball, strip: 1, cacheKey: `public-${version}` };
618
+ const dist = meta?.versions?.[version]?.dist;
619
+ if (!dist?.tarball) throw new Error(`no published ${pkg}@${version} on npm`);
620
+ // `integrity` is the artifact's CONTENT identity (npm dist.integrity, else the
621
+ // legacy shasum) — installRunnerTarball verifies the downloaded bytes against
622
+ // it and records it so a same-version corrected republish busts the cache.
623
+ return {
624
+ kind: "public",
625
+ version,
626
+ url: dist.tarball,
627
+ strip: 1,
628
+ cacheKey: `public-${version}`,
629
+ integrity: dist.integrity || dist.shasum || null,
630
+ };
621
631
  }
622
632
 
623
633
  /**
@@ -637,7 +647,17 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
637
647
  if (!res?.url || !res?.version) {
638
648
  throw new Error(res?.error || "no renderer-artifact URL returned");
639
649
  }
640
- return { kind: "entitled", version: res.version, url: res.url, strip: 0, cacheKey: res.version };
650
+ // Opportunistic content identity: recorded/verified when the MCP declares one
651
+ // (integrity/sha256); a server that doesn't is simply unverified (null), never
652
+ // an error — the cache then busts on version changes only, as before.
653
+ return {
654
+ kind: "entitled",
655
+ version: res.version,
656
+ url: res.url,
657
+ strip: 0,
658
+ cacheKey: res.version,
659
+ integrity: res.integrity || res.sha256 || null,
660
+ };
641
661
  }
642
662
 
643
663
  /**
@@ -676,7 +696,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
676
696
 
677
697
  try {
678
698
  const runnerDir = await installRunnerTarball(
679
- { source: credential.url, version: credential.version, isUrl: true },
699
+ {
700
+ source: credential.url,
701
+ version: credential.version,
702
+ isUrl: true,
703
+ integrity: credential.integrity || credential.sha256 || null,
704
+ },
680
705
  { log: (m) => console.error(m) },
681
706
  );
682
707
  setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
@@ -851,7 +876,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
851
876
  console.error(`~ renderer: ${src.why}`);
852
877
  return installRunnerTarball(
853
878
  { source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
854
- { log: (m) => console.error(m) },
879
+ { log: (m) => console.error(m), cacheRoot },
855
880
  );
856
881
  }
857
882
 
@@ -862,12 +887,16 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
862
887
  const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
863
888
  const wantVersion = declared || CLI_VERSION;
864
889
 
865
- // Fully-offline fast path: when the WANTED version (declared, else lockstep) is
866
- // already cached, reuse it without touching npm — honouring "don't hit npm when
867
- // the RIGHT version is already cached" without ever reusing a version the
868
- // resolution wouldn't choose. Safe because `public-<version>` only exists if a
869
- // prior run fetched exactly that version. Skipped when an explicit pin is set
870
- // (that must go through resolution).
890
+ // Offline-safe fast path: when the WANTED version (declared, else lockstep) is
891
+ // already cached, reuse it — honouring "don't hit npm when the RIGHT version is
892
+ // already cached" without ever reusing a version the resolution wouldn't choose.
893
+ // Safe because `public-<version>` only exists if a prior run fetched exactly
894
+ // that version. Skipped when an explicit pin is set (that must go through
895
+ // resolution). One refinement over fully-offline: a QUICK, soft-fail registry
896
+ // probe (publishedRunnerIntegrity) revalidates the cached CONTENT identity when
897
+ // npm is reachable, so a corrected republish under the same version string is
898
+ // picked up automatically; offline/slow/unanswerable → trust the cache exactly
899
+ // as before (the probe can never block or fail the run).
871
900
  if (!explicitPin) {
872
901
  const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
873
902
  if (exact) {
@@ -878,21 +907,31 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
878
907
  // host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
879
908
  // bindings (npm/cli#4828) would otherwise be reused forever and crash astro
880
909
  // at boot with the swallowed "dev server didn't come up". When both hold,
881
- // reuse it (fully offline-safe).
910
+ // reuse it (offline-safe).
882
911
  if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
912
+ const published = await publishedRunnerIntegrity(env, wantVersion);
913
+ const recorded = readCacheMarker(exact)?.integrity || null;
914
+ if (!published || !recorded || published === recorded) {
915
+ console.error(
916
+ `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
917
+ );
918
+ setRunnerVersion(wantVersion);
919
+ prunePublicRunnerCache(cacheRoot, wantVersion);
920
+ return exact;
921
+ }
922
+ // Same version string, different published contents — a corrected
923
+ // republish. Fall through to resolution + a fresh install (which also
924
+ // verifies the new bytes against the new integrity).
925
+ console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
926
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
927
+ } else {
883
928
  console.error(
884
- `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
929
+ rendererCacheHealthy(exact)
930
+ ? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
931
+ : `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
885
932
  );
886
- setRunnerVersion(wantVersion);
887
- prunePublicRunnerCache(cacheRoot, wantVersion);
888
- return exact;
933
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
889
934
  }
890
- console.error(
891
- rendererCacheHealthy(exact)
892
- ? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
893
- : `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
894
- );
895
- prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
896
935
  }
897
936
  }
898
937
 
@@ -923,8 +962,8 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
923
962
  // The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
924
963
  // out of the user's way; the setup spinner below is the visible progress.
925
964
  const dir = await installRunnerTarball(
926
- { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
927
- { log: (m) => console.error(m) },
965
+ { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip, integrity: pub.integrity },
966
+ { log: (m) => console.error(m), cacheRoot },
928
967
  );
929
968
  setRunnerVersion(pub.version); // telemetry: the runner version running this session
930
969
  // The pinned version is now installed under public-<version> — drop any other
@@ -949,92 +988,269 @@ function sourceVersionKey(source) {
949
988
  * authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
950
989
  * paths so they cache identically.
951
990
  *
952
- * @param {{ source: string, version: string, isUrl?: boolean }} spec
953
- * @param {{ log?: (m: string) => void }} [opts]
991
+ * Cache-poisoning invariants (the 2026-08-18 first-run hardening):
992
+ * promote-on-success only the install runs in a per-attempt staging dir and
993
+ * is renamed into the canonical slot ONLY after it fully succeeds, so a failed
994
+ * install can never become the cached artifact a later run resumes from.
995
+ * • the completion marker is a manifest carrying the source's CONTENT identity
996
+ * (`integrity`), so a corrected republish under the SAME version string is a
997
+ * cache miss (rebuild), not a stale hit — no manual `rm -rf` ever required.
998
+ * • a failed attempt auto-cleans and retries ONCE from a clean slate before
999
+ * surfacing the error (transient blips heal themselves); deterministic
1000
+ * failures (`e.permanent`) skip the retry and fail loud immediately.
1001
+ *
1002
+ * @param {{ source: string, version: string, isUrl?: boolean, strip?: number, integrity?: string|null }} spec
1003
+ * `integrity` is the source artifact's content identity when the resolver knows
1004
+ * it (npm `dist.integrity`/`dist.shasum`); used to verify the downloaded bytes
1005
+ * and to bust a cached entry whose recorded identity no longer matches.
1006
+ * @param {{ log?: (m: string) => void, cacheRoot?: string }} [opts]
954
1007
  * @returns {Promise<string>} the cached, installed runner tree's root directory.
955
1008
  */
956
- export async function installRunnerTarball({ source, version, isUrl = true, strip = 0 }, { log = (m) => console.error(m) } = {}) {
957
- const runnerDir = join(RENDERER_CACHE_ROOT, version);
958
- const marker = join(runnerDir, ".tot-cache-complete");
959
- if (existsSync(marker)) {
960
- // Reuse ONLY if the cached tree carries native bindings for this host's arch.
961
- // A cache poisoned with the wrong-arch binaries (npm/cli#4828 e.g. darwin-x64
962
- // on an arm64 Mac) is otherwise trusted forever, and astro crashes at boot with
963
- // `Cannot find native binding`, swallowed as "the dev server didn't come up".
964
- // Unhealthy → fall through and rebuild from the source below.
965
- if (rendererCacheHealthy(runnerDir)) return runnerDir; // already downloaded + installed
966
- log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} rebuilding it…`);
1009
+ export async function installRunnerTarball(
1010
+ { source, version, isUrl = true, strip = 0, integrity = null },
1011
+ { log = (m) => console.error(m), cacheRoot = RENDERER_CACHE_ROOT } = {},
1012
+ ) {
1013
+ const runnerDir = join(cacheRoot, version);
1014
+ // Staging dirs from DEAD runs (crashed/killed installs) must not leak disk
1015
+ // forever reap them here, the one funnel every install path goes through.
1016
+ sweepStaleStagingDirs(cacheRoot);
1017
+
1018
+ const localSource = isUrl ? null : resolveLocalTarball(source);
1019
+ // The EXPECTED content identity of the source artifact. A local tarball with no
1020
+ // caller-provided integrity is cheap to hash on every run, so a same-path
1021
+ // republish (new contents, same file name) busts the cache too.
1022
+ const expected = integrity || (isUrl ? null : fileIntegrity(localSource));
1023
+
1024
+ const cached = readCacheMarker(runnerDir);
1025
+ if (cached) {
1026
+ if (!rendererCacheHealthy(runnerDir)) {
1027
+ // A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g.
1028
+ // darwin-x64 on an arm64 Mac) is otherwise trusted forever, and astro
1029
+ // crashes at boot with `Cannot find native binding`, swallowed as "the dev
1030
+ // server didn't come up". Fall through and rebuild from the source below.
1031
+ log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
1032
+ } else if (expected && cached.integrity && expected !== cached.integrity) {
1033
+ // CONTENT-HASH BUST: same version string, different artifact contents — a
1034
+ // corrected republish. The cached entry is stale by identity, not by label;
1035
+ // rebuild from the corrected source instead of serving the stale cache.
1036
+ log(`~ the preview engine's ${version} artifact changed upstream (same version, new contents) — rebuilding…`);
1037
+ } else {
1038
+ return runnerDir; // already downloaded + installed (and contents still match)
1039
+ }
967
1040
  }
968
1041
 
969
1042
  // First run only — set the expectation so the one-time cost doesn't read as a
970
1043
  // hang: this downloads + installs the renderer once, then every later run of
971
1044
  // this version is a no-network cache hit.
972
1045
  log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
973
- const localSource = isUrl ? null : resolveLocalTarball(source);
974
- const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
975
- try {
976
- if (isUrl) {
977
- // The fetch itself is otherwise silent (no per-byte output) and can run
978
- // tens of seconds on a cold cache — tick a spinner so it never looks hung.
979
- const spin = startProgress("downloading the store preview engine…");
1046
+ // AUTO-CLEAN-AND-RETRY-ONCE: a transient failure (network blip mid-download, a
1047
+ // registry hiccup mid-install) heals itself with one clean re-attempt instead
1048
+ // of stopping a first run at an error only `rm -rf` folklore could clear.
1049
+ // Bounded to one retry so a genuinely-broken source still fails loudly.
1050
+ for (let attempt = 1; ; attempt++) {
1051
+ const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource;
1052
+ const stagingDir = `${runnerDir}.staging-${process.pid}`;
1053
+ try {
1054
+ if (isUrl) {
1055
+ // The fetch itself is otherwise silent (no per-byte output) and can run
1056
+ // tens of seconds on a cold cache — tick a spinner so it never looks hung.
1057
+ const spin = startProgress("downloading the store preview engine…");
1058
+ try {
1059
+ await downloadFile(source, archivePath);
1060
+ } finally {
1061
+ spin.stop();
1062
+ }
1063
+ }
1064
+ if (!existsSync(archivePath)) {
1065
+ throw new Error(`renderer tarball not found: ${archivePath}`);
1066
+ }
1067
+ // Refuse to install bytes that don't match the source's declared identity —
1068
+ // a truncated/corrupted download would otherwise be cached as if complete.
1069
+ // (Transient by nature, so the retry above gets a fresh download.)
1070
+ if (expected && !tarballMatchesIntegrity(archivePath, expected)) {
1071
+ throw new Error(`the downloaded preview-engine tarball failed its integrity check (expected ${expected})`);
1072
+ }
1073
+ // The identity recorded in the completion manifest below — what future runs
1074
+ // compare against to detect a same-version republish. Hash the actual bytes
1075
+ // when the resolver couldn't tell us (e.g. the entitled signed-URL path).
1076
+ const contentId = expected || fileIntegrity(archivePath);
1077
+ rmSync(stagingDir, { recursive: true, force: true });
1078
+ mkdirSync(stagingDir, { recursive: true });
1079
+ extractTarball(archivePath, stagingDir, { strip });
1080
+ // Pin the runner install to PUBLIC npm. The moat-free runner has only public
1081
+ // deps, but the HOST's global ~/.npmrc may point `registry` at a private
1082
+ // mirror (an internal proxy that 502s, or one an invited developer can't
1083
+ // reach) — an invited dev's machine config must never decide where the
1084
+ // runner's public deps come from. A project-level .npmrc wins over the user's.
1085
+ writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
1086
+ // The install is the long, noisy step — tick a spinner while its output goes
1087
+ // to a log, so the terminal shows one clean line instead of the pnpm firehose.
1088
+ // corepack setup logs to the SAME file so its failures aren't invisible (they
1089
+ // were the silent cause of "couldn't set up the store preview engine").
1090
+ const installLog = join(cacheRoot, `${version}.install.log`);
1091
+ ensureCorepackPnpm(stagingDir, { logPath: installLog });
1092
+ const installSpin = startProgress("installing the store preview engine…", {
1093
+ stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
1094
+ });
980
1095
  try {
981
- await downloadFile(source, archivePath);
1096
+ await runPnpmInstall(stagingDir, { logPath: installLog });
982
1097
  } finally {
983
- spin.stop();
1098
+ installSpin.stop();
984
1099
  }
1100
+ // Atomic-ish: only rename into the final, discoverable path once install
1101
+ // succeeded, so a crashed/interrupted run never leaves a half-built cache
1102
+ // entry that a later `tot dev` would treat as ready.
1103
+ rmSync(runnerDir, { recursive: true, force: true });
1104
+ renameSync(stagingDir, runnerDir);
1105
+ // Fence a fresh install against npm/cli#4828: if the installer left the wrong
1106
+ // arch's native bindings (or none) for this host, DON'T stamp the completion
1107
+ // marker — an unmarked tree is never reused, so the next run reinstalls cleanly
1108
+ // instead of caching the poison and crashing astro at boot. Fail loud + actionable
1109
+ // rather than swallow it as "the dev server didn't come up". Permanent: the
1110
+ // same installer on the same host would just produce the same result, so the
1111
+ // auto-retry is skipped.
1112
+ if (!rendererCacheHealthy(runnerDir)) {
1113
+ await emitObstacle("renderer-native-bindings-missing");
1114
+ const err = new CliError(
1115
+ `the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
1116
+ {
1117
+ next: "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)",
1118
+ exitCode: 2,
1119
+ },
1120
+ );
1121
+ err.permanent = true;
1122
+ throw err;
1123
+ }
1124
+ writeCacheMarker(runnerDir, { version, integrity: contentId });
1125
+ return runnerDir;
1126
+ } catch (e) {
1127
+ // A failed attempt must never survive on disk — not as staging debris, and
1128
+ // (by promote-on-success) it never reached the canonical slot at all.
1129
+ rmSync(stagingDir, { recursive: true, force: true });
1130
+ if (e?.permanent === true || attempt >= 2) throw e;
1131
+ log(`~ that didn't work (${String(e?.message || e).split("\n")[0]}) — retrying once from a clean slate…`);
1132
+ } finally {
1133
+ if (isUrl) rmSync(archivePath, { force: true });
985
1134
  }
986
- if (!existsSync(archivePath)) {
987
- throw new Error(`renderer tarball not found: ${archivePath}`);
988
- }
989
- const stagingDir = `${runnerDir}.staging-${process.pid}`;
990
- rmSync(stagingDir, { recursive: true, force: true });
991
- mkdirSync(stagingDir, { recursive: true });
992
- extractTarball(archivePath, stagingDir, { strip });
993
- // Pin the runner install to PUBLIC npm. The moat-free runner has only public
994
- // deps, but the HOST's global ~/.npmrc may point `registry` at a private
995
- // mirror (an internal proxy that 502s, or one an invited developer can't
996
- // reach) — an invited dev's machine config must never decide where the
997
- // runner's public deps come from. A project-level .npmrc wins over the user's.
998
- writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
999
- // The install is the long, noisy step — tick a spinner while its output goes
1000
- // to a log, so the terminal shows one clean line instead of the pnpm firehose.
1001
- // corepack setup logs to the SAME file so its failures aren't invisible (they
1002
- // were the silent cause of "couldn't set up the store preview engine").
1003
- const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
1004
- ensureCorepackPnpm(stagingDir, { logPath: installLog });
1005
- const installSpin = startProgress("installing the store preview engine…", {
1006
- stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
1007
- });
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Read a cache entry's completion marker (`.tot-cache-complete`). Returns the
1140
+ * manifest object (at least `{ integrity: string|null }`), or null when the
1141
+ * marker is absent — i.e. the entry is incomplete/partial and must be treated
1142
+ * as if it didn't exist. A legacy pre-manifest marker (a bare timestamp string)
1143
+ * reads as complete-with-unknown-identity, so existing healthy caches survive
1144
+ * the upgrade without a forced rebuild.
1145
+ */
1146
+ export function readCacheMarker(dir) {
1147
+ try {
1148
+ const raw = readFileSync(join(dir, ".tot-cache-complete"), "utf8");
1008
1149
  try {
1009
- await runPnpmInstall(stagingDir, { logPath: installLog });
1010
- } finally {
1011
- installSpin.stop();
1150
+ const m = JSON.parse(raw);
1151
+ if (m && typeof m === "object") return { integrity: null, ...m };
1152
+ } catch {
1153
+ /* legacy timestamp-string marker */
1012
1154
  }
1013
- // Atomic-ish: only rename into the final, discoverable path once install
1014
- // succeeded, so a crashed/interrupted run never leaves a half-built cache
1015
- // entry that a later `tot dev` would treat as ready.
1016
- rmSync(runnerDir, { recursive: true, force: true });
1017
- renameSync(stagingDir, runnerDir);
1018
- // Fence a fresh install against npm/cli#4828: if the installer left the wrong
1019
- // arch's native bindings (or none) for this host, DON'T stamp the completion
1020
- // marker an unmarked tree is never reused, so the next run reinstalls cleanly
1021
- // instead of caching the poison and crashing astro at boot. Fail loud + actionable
1022
- // rather than swallow it as "the dev server didn't come up".
1023
- if (!rendererCacheHealthy(runnerDir)) {
1024
- await emitObstacle("renderer-native-bindings-missing");
1025
- throw new CliError(
1026
- `the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
1027
- {
1028
- next: "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)",
1029
- exitCode: 2,
1030
- },
1031
- );
1155
+ return { integrity: null };
1156
+ } catch {
1157
+ return null; // no marker never treat the entry as installed
1158
+ }
1159
+ }
1160
+
1161
+ /** Stamp a cache entry complete: version + source content identity + when. */
1162
+ function writeCacheMarker(dir, { version, integrity }) {
1163
+ writeFileSync(
1164
+ join(dir, ".tot-cache-complete"),
1165
+ JSON.stringify({ version, integrity: integrity || null, completedAt: new Date().toISOString() }) + "\n",
1166
+ );
1167
+ }
1168
+
1169
+ /** sha512 SRI (`sha512-<base64>`, npm's `dist.integrity` format) of a file; null when unreadable. */
1170
+ function fileIntegrity(path) {
1171
+ try {
1172
+ return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`;
1173
+ } catch {
1174
+ return null;
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Do the tarball's bytes match `expected` — an SRI string (`sha512-<b64>`, npm's
1180
+ * `dist.integrity`) or npm's legacy `dist.shasum` (bare 40-hex sha1)? Unknown
1181
+ * formats and probe errors return true: this check exists to catch corrupted
1182
+ * bytes, never to block an install on a format we can't verify.
1183
+ */
1184
+ export function tarballMatchesIntegrity(archivePath, expected) {
1185
+ try {
1186
+ const want = String(expected).trim();
1187
+ const sri = /^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/=]+)$/.exec(want);
1188
+ if (sri) {
1189
+ return createHash(sri[1]).update(readFileSync(archivePath)).digest("base64") === sri[2];
1032
1190
  }
1033
- writeFileSync(marker, new Date().toISOString());
1034
- } finally {
1035
- if (isUrl) rmSync(archivePath, { force: true });
1191
+ if (/^[0-9a-f]{40}$/i.test(want)) {
1192
+ return createHash("sha1").update(readFileSync(archivePath)).digest("hex") === want.toLowerCase();
1193
+ }
1194
+ return true;
1195
+ } catch {
1196
+ return true;
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Reap `<entry>.staging-<pid>` dirs left by DEAD processes — failed/killed
1202
+ * installs used to accumulate one orphaned staging tree per attempt, leaking
1203
+ * disk forever. A staging dir whose pid is still alive belongs to a concurrent
1204
+ * `tot dev` mid-install and is left alone. Best-effort: never throws, and never
1205
+ * touches this process's own staging dir (created fresh after this sweep).
1206
+ */
1207
+ export function sweepStaleStagingDirs(cacheRoot, { pidAlive = processAlive } = {}) {
1208
+ try {
1209
+ if (!cacheRoot || !existsSync(cacheRoot)) return;
1210
+ for (const name of readdirSync(cacheRoot)) {
1211
+ const m = /\.staging-(\d+)$/.exec(name);
1212
+ if (!m) continue;
1213
+ const pid = Number(m[1]);
1214
+ if (pid === process.pid || pidAlive(pid)) continue;
1215
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
1216
+ }
1217
+ } catch {
1218
+ /* best-effort cache hygiene */
1219
+ }
1220
+ }
1221
+
1222
+ /** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
1223
+ function processAlive(pid) {
1224
+ try {
1225
+ process.kill(pid, 0);
1226
+ return true;
1227
+ } catch (e) {
1228
+ return e?.code === "EPERM";
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * The registry-declared content identity (`dist.integrity`, else `dist.shasum`)
1234
+ * of the public runner at `version` — or null when npm can't answer QUICKLY
1235
+ * (offline, slow, 4xx/5xx, malformed). Used by ensureSampleRenderer's cached
1236
+ * fast path to detect a same-version republish without ever making the network
1237
+ * a hard dependency: null means "can't verify right now — trust the cache",
1238
+ * preserving the offline-reuse behavior exactly.
1239
+ */
1240
+ export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000, fetchFn = fetch } = {}) {
1241
+ try {
1242
+ const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
1243
+ const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
1244
+ const res = await fetchFn(`${registry}/${pkg.replace("/", "%2f")}`, {
1245
+ headers: { accept: "application/json" },
1246
+ signal: AbortSignal.timeout(timeoutMs),
1247
+ });
1248
+ if (!res.ok) return null;
1249
+ const dist = (await res.json())?.versions?.[version]?.dist;
1250
+ return dist?.integrity || dist?.shasum || null;
1251
+ } catch {
1252
+ return null;
1036
1253
  }
1037
- return runnerDir;
1038
1254
  }
1039
1255
 
1040
1256
  /** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
@@ -1190,22 +1406,27 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1190
1406
  continue;
1191
1407
  }
1192
1408
  // The launcher ran; the install itself failed. That's the actionable error.
1409
+ // (installRunnerTarball auto-retries this ONCE from a clean slate before it
1410
+ // reaches the user — so by the time this surfaces, it failed twice.)
1193
1411
  await emitObstacle("install-failed");
1194
1412
  throw new CliError(
1195
1413
  `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
1196
1414
  (logPath ? `\n details: ${logPath}` : ""),
1197
- { next: "check the details log above, then re-run `tot start` (it resumes from the cache)" },
1415
+ { next: "check the details log above, then re-run `tot start` (it retries from a clean slate — no cache to clear)" },
1198
1416
  );
1199
1417
  }
1200
1418
  // Every launcher ENOENT'd → there's no pnpm on this machine and corepack
1201
1419
  // couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
1202
1420
  // every Node, so `npm i -g pnpm` is the escape hatch that always exists.
1421
+ // Permanent: retrying can't conjure a launcher — skip the clean-slate retry.
1203
1422
  await emitObstacle("pnpm-missing");
1204
- throw new CliError(
1423
+ const err = new CliError(
1205
1424
  "couldn't set up the store preview engine — pnpm isn't available on this machine" +
1206
1425
  (logPath ? `\n details: ${logPath}` : ""),
1207
1426
  { next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
1208
1427
  );
1428
+ err.permanent = true;
1429
+ throw err;
1209
1430
  } finally {
1210
1431
  if (fd !== null) closeSync(fd);
1211
1432
  }
@@ -1220,6 +1441,20 @@ function pnpmFailureHint(logPath) {
1220
1441
  if (!logPath) return " — is pnpm/corepack available on this host?";
1221
1442
  try {
1222
1443
  const tail = readFileSync(logPath, "utf8").slice(-8000);
1444
+ // A 404 means the registry answered — a specific package/version doesn't
1445
+ // exist there. Since installs now run from a clean slate every attempt
1446
+ // (promote-on-success + auto-retry), this is a BROKEN RUNNER RELEASE (it
1447
+ // references an unpublished package), not the user's cache — no `rm -rf`
1448
+ // will help. Check this BEFORE the generic ERR_PNPM_FETCH match, since
1449
+ // pnpm's 404 error text also contains "ERR_PNPM_FETCH".
1450
+ const missing404 = tail.match(/ERR_PNPM_FETCH_404[^\n]*GET\s+(\S+)/i);
1451
+ if (missing404) {
1452
+ return (
1453
+ ` — the preview engine references a package that isn't published (${missing404[1]});` +
1454
+ " that's a broken preview-engine release, not your machine — try again later or pin a" +
1455
+ " known-good version with --renderer-version"
1456
+ );
1457
+ }
1223
1458
  if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
1224
1459
  return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
1225
1460
  }
@@ -42,6 +42,24 @@ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
42
42
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
43
43
  const SUBCOMMANDS = ["list", "view", "close"];
44
44
 
45
+ /**
46
+ * The storefront-owned, shareable `/preview/<tenant>/pr/<N>` link — NEVER the
47
+ * forge/Gitea `url` (2026-08-18 incident: a raw forge PR URL reached an
48
+ * owner). `candidate_status` (the local-checkout MCP tool) has no
49
+ * `previewUrl` field at all, unlike the operator `GET /api/changes` path — so
50
+ * this constructs it the same way `runPrListOperator`'s caller resolves
51
+ * `storefrontUrl`, from the same env/--url override chain. Pure.
52
+ * @param {string} storefrontUrl
53
+ * @param {string} tenant
54
+ * @param {number|null|undefined} prNumber
55
+ * @returns {string|null}
56
+ */
57
+ export function buildPreviewUrl(storefrontUrl, tenant, prNumber) {
58
+ if (typeof prNumber !== "number" || !tenant) return null;
59
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
60
+ return `${base}/preview/${tenant}/pr/${prNumber}`;
61
+ }
62
+
45
63
  const USAGE = `tot pr — see and manage candidate PRs
46
64
 
47
65
  tot pr [list] list your open candidate PRs for this store
@@ -118,9 +136,11 @@ export function matchCandidate(candidates, target) {
118
136
  /**
119
137
  * One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
120
138
  * URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
121
- * branch-bound candidates) and where its preview lives. Prefers the candidate's
122
- * `previewUrl`, falling back to the PR `url`. `active` marks the one THIS checkout's
123
- * branch resolves to. Pure unit-tested.
139
+ * branch-bound candidates) and where its preview lives. ONLY `previewUrl` (the
140
+ * storefront-owned `/preview/<tenant>/pr/<N>` link) NEVER `url` (the forge/
141
+ * Gitea `html_url`), which must never reach a terminal (2026-08-18 incident:
142
+ * a raw forge PR URL reached an owner). `active` marks the one
143
+ * THIS checkout's branch resolves to. Pure — unit-tested.
124
144
  * @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
125
145
  * previewUrl?:string|null, url?:string|null}} c
126
146
  * @param {{ active?: boolean }} [opts]
@@ -128,8 +148,7 @@ export function matchCandidate(candidates, target) {
128
148
  export function formatCandidateLine(c, { active = false } = {}) {
129
149
  const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
130
150
  const branch = c.branch ? c.branch : "(no branch)";
131
- const previewUrl = c.previewUrl || c.url || null;
132
- const urlPart = previewUrl ? ` ${previewUrl}` : "";
151
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
133
152
  const activePart = active ? " ← active" : "";
134
153
  return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
135
154
  }
@@ -326,6 +345,7 @@ export async function run(argv, ctx) {
326
345
  }
327
346
 
328
347
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
348
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
329
349
  const statePath = defaultCandidateStatePath(env);
330
350
  // Branch-bound (u4): the active-pointer namespace is scoped to the current git
331
351
  // branch, so the "← active" marker reflects THIS branch's candidate.
@@ -347,7 +367,8 @@ export async function run(argv, ctx) {
347
367
  const active = readActiveChangeId(statePath, scope);
348
368
  console.log(`Open candidate PRs for ${repo}:`);
349
369
  for (const c of candidates) {
350
- console.log(formatCandidateLine(c, { active: !!active && c.changeId === active }));
370
+ const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
371
+ console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
351
372
  }
352
373
  return 0;
353
374
  }
@@ -366,7 +387,9 @@ export async function run(argv, ctx) {
366
387
  if (match.headSha) console.log(` head: ${match.headSha}`);
367
388
  if (match.baseSha) console.log(` base: ${match.baseSha}`);
368
389
  console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
369
- if (match.url) console.log(` ${match.url}`);
390
+ // ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
391
+ const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
392
+ if (previewUrl) console.log(` ${previewUrl}`);
370
393
  return 0;
371
394
  }
372
395