@tokenoftrust/cli 1.4.0 → 1.5.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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. package/src/commands/retire.mjs +0 -203
@@ -147,11 +147,11 @@ export async function run(argv, ctx) {
147
147
  }
148
148
 
149
149
  /**
150
- * Standalone-checkout entrypoint: native runtime by default (F3 — no Docker
150
+ * Standalone-checkout entrypoint: native runtime by default (no Docker
151
151
  * prerequisite), Docker on explicit request, and Docker as an automatic
152
152
  * fallback when the native artifact can't be fetched (e.g. the MCP's
153
153
  * dev_renderer_artifact isn't configured on this deployment yet) — so `tot dev`
154
- * keeps working through the WS3 rollout instead of hard-failing.
154
+ * keeps working instead of hard-failing.
155
155
  */
156
156
  async function runStandalone(workspace, args, ctx) {
157
157
  if (args.docker) return runContainer(workspace, args, ctx);
@@ -266,7 +266,7 @@ function runMonorepo(ctx, argv) {
266
266
  * Thrown when the native runtime can't be used for a reason that's about the
267
267
  * artifact/service, not the developer's checkout — runStandalone catches this
268
268
  * specifically and falls back to Docker instead of hard-failing. Exported so
269
- * `tot start` (C1/F3 integration) can catch it too and fall back the same way.
269
+ * `tot start` can catch it too and fall back the same way.
270
270
  */
271
271
  export class NativeArtifactUnavailableError extends Error {}
272
272
 
@@ -326,7 +326,7 @@ export function bootNative(runnerDir, workspace, port, url, args) {
326
326
  const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "piped", env: bridgeEnv });
327
327
  streamDevLogs(handle.child, { cockpitUrl: cockpitUrlFrom(bridgeEnv.TOT_DEV_ACTIVITY_URL) });
328
328
 
329
- // Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
329
+ // Heartbeat the hosted cockpit with the CLI version + this live localhost
330
330
  // URL while the runner runs — CLI-side, using the SAME bridge credential the
331
331
  // runner's file-save path uses (no-op in --sample, which threads no credential).
332
332
  // `cwd` (the checkout root) lets the cockpit show the FULL path to edit.
@@ -409,7 +409,7 @@ export function printSampleBanner({ url }, mode = "sample") {
409
409
  * Exported so sample/tests can drive the selection directly.
410
410
  * @param {ReturnType<typeof parseArgs>} args
411
411
  * @param {{ client?: any }} [opts] an already-authenticated MCP client (entitled path)
412
- * @returns {Promise<{kind:"public"|"entitled",version:string,url:string,strip:number,cacheKey:string}>}
412
+ * @returns {Promise<{kind:string,version:string,url:string,strip:number,cacheKey:string,integrity:string|null}>}
413
413
  */
414
414
  export async function resolveRendererSource(args, { client } = {}) {
415
415
  if (args.sample) return resolvePublicRendererSource(args);
@@ -427,15 +427,18 @@ export async function resolveRendererSource(args, { client } = {}) {
427
427
  * is keyed by version, upgrading the CLI busts the stale-runner cache automatically
428
428
  * (the 2026-07-14 "cached runner (prior tot dev)" staleness).
429
429
  *
430
- * EXACT-FIRST (the CLI requests its OWN version): with lockstep publishing the runner
431
- * is published at the SAME version as the CLI, so the exact match (step 2) is the normal
432
- * path. An explicit pin (`--renderer-version` / TOT_RUNNER_VERSION) always wins. When no
433
- * exact match exists we degrade ONLY DOWNWARD a runner at or below the CLI's major.minor
434
- * (never ahead) and NEVER to a floating dist-tag like `latest` (a channel can point at a
435
- * version this CLI doesn't expect; that floating-tag drift is the exact bug this avoids).
436
- * The caller emits a LOUD skew warning whenever the resolved version isn't the exact CLI
437
- * version, so a mismatch is visible, not silent. If nothing exact/minor/≤-ceiling is
438
- * published, we THROW a release gap to fix by publishing the aligned runner, not paper over.
430
+ * STABLE PATCHES FLOAT within the CLI's own major.minor: a published patch is a
431
+ * compatible maintenance release, and is precisely how a bad runner artifact is
432
+ * replaced (npm tarballs are immutable). Therefore stable CLI 1.4.0 automatically
433
+ * takes runner 1.4.1 instead of remaining pinned forever to a broken 1.4.0 tarball.
434
+ * Prerelease CLIs still prefer their exact prerelease runner because stable-only
435
+ * patch selection deliberately excludes prereleases. An explicit pin
436
+ * (`--renderer-version` / TOT_RUNNER_VERSION) always wins.
437
+ *
438
+ * We still never cross the CLI's major.minor ceiling automatically and never use a
439
+ * floating dist-tag like `latest`: a newer minor can require a newer CLI contract.
440
+ * If no stable same-minor or lower compatible release exists, we THROW rather than
441
+ * silently running a too-new runner.
439
442
  *
440
443
  * PIN-DIRECTION DECISION (ADR 0011 — don't relitigate inline): the runner version
441
444
  * should be DECLARED BY THE PRODUCT, not derived from the CLI's identity. The
@@ -443,11 +446,9 @@ export async function resolveRendererSource(args, { client } = {}) {
443
446
  * 1. explicit flag/env pin — developer intent, always wins
444
447
  * 2. declaredVersion — the STORE's own `.tot/config.json#runnerVersion`
445
448
  * (rust-toolchain.toml-style; the target state)
446
- * 3. exact CLI-version match TRANSITIONAL lockstep rung. Delete it (and the
447
- * lockstep publish regime) once tenant_checkout
448
- * stamps runnerVersion into every checkout
449
- * see ADR 0011 for the exit criteria.
450
- * 4. CLI-minor / ≤-ceiling — degraded-but-safe fallbacks (never a floating tag)
449
+ * 3. exact prerelease match keeps an RC paired with its runner RC
450
+ * 4. newest stable CLI-minor patch — maintenance releases float within x.y
451
+ * 5. ≤-ceiling — degraded-but-safe fallback (never a floating tag)
451
452
  *
452
453
  * Pure (no I/O) for testability.
453
454
  * @param {any} meta npm packument (`dist-tags` + `versions`)
@@ -486,17 +487,19 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin, declaredVersi
486
487
  return { version: declaredVersion, reason: "declared by the store checkout (runnerVersion)" };
487
488
  }
488
489
 
489
- // 2) EXACT CLI-version match the primary path for a lockstep release: the runner
490
- // is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
491
- // which the stable-only minor match below deliberately skips). This is what makes
492
- // `tot@1.3.0-rc.0` pull `runner@1.3.0-rc.0` instead of falling back to stale latest.
493
- // (Same version ⇒ same major.minor always within the ceiling.)
494
- if (versions.includes(cliVersion)) {
490
+ // 2) EXACT PRERELEASE match. Stable releases intentionally continue to the
491
+ // same-minor maintenance selection below, where a corrected immutable artifact
492
+ // (runner 1.4.1 for CLI 1.4.0) can supersede a broken exact-version tarball.
493
+ // RCs cannot safely float across prerelease builds, so they remain exact-paired.
494
+ if (cliVersion.includes("-") && versions.includes(cliVersion)) {
495
495
  return { version: cliVersion, reason: "exact CLI-version match" };
496
496
  }
497
497
 
498
- // 3) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
499
- // Constrained to the CLI's exact minor, so this is within the ceiling by construction.
498
+ // 3) CLI-minor maintenance match: highest published stable <major>.<minor>.*
499
+ // (numeric patch order), INCLUDING patches newer than the CLI's own patch.
500
+ // Patch releases are compatibility fixes; allowing them to float is what lets us
501
+ // replace a broken immutable runner without asking every developer to know a pin.
502
+ // Constrained to the CLI's exact minor, so it stays within the ceiling.
500
503
  const m = /^(\d+)\.(\d+)\./.exec(cliVersion || "");
501
504
  if (m) {
502
505
  const prefix = `${m[1]}.${m[2]}.`;
@@ -520,11 +523,9 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin, declaredVersi
520
523
  return { version, reason: `highest ≤ CLI major.minor ${cliMM ? `${cliMM.major}.${cliMM.minor}` : "?"}` };
521
524
  }
522
525
  // NO floating-tag last resort. We deliberately do NOT fall back to the `latest`
523
- // dist-tag: a channel can drift behind/ahead of what THIS CLI expects (the exact
524
- // failure mode that shipped stale CLIs see the copy-paste pin in the storefront
525
- // cockpit). If nothing exact/minor/≤-ceiling is published, that's a release gap to
526
- // fix by publishing the runner at the CLI's version — not something to paper over
527
- // with whatever `latest` happens to point at.
526
+ // dist-tag: a channel can drift behind/ahead of what THIS CLI expects. If nothing
527
+ // stable within the CLI minor or below its ceiling is published, that's a release
528
+ // gap to fix not something to paper over with whatever `latest` points at.
528
529
  throw new Error(
529
530
  `no runner version at or below the CLI's major.minor (${cliMM ? `${cliMM.major}.${cliMM.minor}` : cliVersion})`,
530
531
  );
@@ -536,7 +537,7 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin, declaredVersi
536
537
  * rust-toolchain.toml of the storefront: the PRODUCT (via tenant_checkout
537
538
  * stamping it server-side) owns which runtime the store runs; the CLI just
538
539
  * resolves it. Returns null when absent/malformed/not-semver — silence is
539
- * correct: an undeclared checkout falls back to the transitional lockstep rung.
540
+ * correct: an undeclared checkout falls back to the compatible CLI-minor rung.
540
541
  * Pure-ish (one file read) + exported for tests.
541
542
  * @param {string|null|undefined} workspaceDir
542
543
  * @returns {string|null}
@@ -576,6 +577,11 @@ function compareStableAsc(a, b) {
576
577
  * override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
577
578
  * overridable via env for testing.
578
579
  */
580
+ /**
581
+ * @param {any} args
582
+ * @param {NodeJS.ProcessEnv} [env]
583
+ * @param {{ declaredVersion?: string|null }} [opts]
584
+ */
579
585
  export async function resolvePublicRendererSource(args, env = process.env, { declaredVersion = null } = {}) {
580
586
  const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
581
587
  const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
@@ -605,26 +611,47 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
605
611
  `using ${version} (${reason}). The store's runnerVersion needs a published release.`,
606
612
  );
607
613
  }
608
- } else if (!explicitPin && version !== CLI_VERSION) {
609
- // No declaration (transitional lockstep regime ADR 0011): exact is the goal.
610
- // Resolving something ELSE means CLI/runner releases are skewed say so LOUDLY
611
- // instead of silently running a mismatched runner.
614
+ } else if (!explicitPin && version !== CLI_VERSION && !isCompatibleMaintenancePatch(CLI_VERSION, version)) {
615
+ // A stable same-minor maintenance patch is the normal self-healing path, not
616
+ // skew. Anything else still deserves a loud warning: it means resolution had
617
+ // to leave the CLI's compatibility line.
612
618
  console.warn(
613
619
  ` ⚠ runner ${version} — no exact @${CLI_VERSION} published (${reason}). ` +
614
620
  `CLI/runner versions are SKEWED; publish the runner at ${CLI_VERSION} to align ` +
615
621
  `(or pin with --renderer-version to silence).`,
616
622
  );
617
623
  }
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}` };
624
+ const dist = meta?.versions?.[version]?.dist;
625
+ if (!dist?.tarball) throw new Error(`no published ${pkg}@${version} on npm`);
626
+ // `integrity` is the artifact's CONTENT identity (npm dist.integrity, else the
627
+ // legacy shasum) — installRunnerTarball verifies the downloaded bytes against
628
+ // it and records it so a same-version corrected republish busts the cache.
629
+ return {
630
+ kind: "public",
631
+ version,
632
+ url: dist.tarball,
633
+ strip: 1,
634
+ cacheKey: `public-${version}`,
635
+ integrity: dist.integrity || dist.shasum || null,
636
+ };
637
+ }
638
+
639
+ /** Stable x.y patches share the runner/CLI contract; prereleases do not. */
640
+ function isCompatibleMaintenancePatch(cliVersion, runnerVersion) {
641
+ if (!/^\d+\.\d+\.\d+$/.test(String(cliVersion)) || !/^\d+\.\d+\.\d+$/.test(String(runnerVersion))) {
642
+ return false;
643
+ }
644
+ const cliMM = majorMinor(cliVersion);
645
+ const runnerMM = majorMinor(runnerVersion);
646
+ return !!cliMM && !!runnerMM && cliMM.major === runnerMM.major && cliMM.minor === runnerMM.minor;
621
647
  }
622
648
 
623
649
  /**
624
650
  * ENTITLED source: the MCP's `dev_renderer_artifact` signed URL, gated on the
625
651
  * developer entitlement (same gate as the Docker pull token). Reuses an
626
- * already-authenticated `client` when provided (C1/F3), else establishes its own.
652
+ * already-authenticated `client` when provided, else establishes its own.
627
653
  */
654
+ /** @param {any} args @param {{ client?: any }} [opts] */
628
655
  export async function resolveEntitledRendererSource(args, { client: providedClient } = {}) {
629
656
  const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
630
657
  const client = providedClient || createMcpClient(baseUrl);
@@ -637,7 +664,17 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
637
664
  if (!res?.url || !res?.version) {
638
665
  throw new Error(res?.error || "no renderer-artifact URL returned");
639
666
  }
640
- return { kind: "entitled", version: res.version, url: res.url, strip: 0, cacheKey: res.version };
667
+ // Opportunistic content identity: recorded/verified when the MCP declares one
668
+ // (integrity/sha256); a server that doesn't is simply unverified (null), never
669
+ // an error — the cache then busts on version changes only, as before.
670
+ return {
671
+ kind: "entitled",
672
+ version: res.version,
673
+ url: res.url,
674
+ strip: 0,
675
+ cacheKey: res.version,
676
+ integrity: res.integrity || res.sha256 || null,
677
+ };
641
678
  }
642
679
 
643
680
  /**
@@ -649,11 +686,13 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
649
686
  * anything that should fall back to Docker (MCP unreachable, not entitled, not
650
687
  * configured, no corepack/pnpm on this host) rather than hard-failing `tot dev`.
651
688
  *
652
- * Exported + accepts an already-authenticated `client` (C1/F3 integration:
653
- * `tot start` reuses its own session and overlaps this with the checkout clone
689
+ * Exported + accepts an already-authenticated `client` (`tot start` reuses
690
+ * its own session and overlaps this with the checkout clone
654
691
  * instead of paying for a second client.initialize()+establishSession() — same
655
692
  * pattern as ensureRegistryLogin's `providedClient`). `tot dev` standalone
656
693
  * omits it and this establishes its own, as before.
694
+ * @param {any} args
695
+ * @param {{ client?: any }} [opts]
657
696
  * @returns {Promise<string>} the cached, installed runner tree's root directory.
658
697
  */
659
698
  export async function ensureRendererArtifact(args, { client: providedClient } = {}) {
@@ -676,7 +715,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
676
715
 
677
716
  try {
678
717
  const runnerDir = await installRunnerTarball(
679
- { source: credential.url, version: credential.version, isUrl: true },
718
+ {
719
+ source: credential.url,
720
+ version: credential.version,
721
+ isUrl: true,
722
+ integrity: credential.integrity || credential.sha256 || null,
723
+ },
680
724
  { log: (m) => console.error(m) },
681
725
  );
682
726
  setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
@@ -829,11 +873,11 @@ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
829
873
  * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
830
874
  */
831
875
  export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
832
- const src = resolveLocalRendererSource({
876
+ const src = /** @type {any} */ (resolveLocalRendererSource({
833
877
  env,
834
878
  mode: ctx?.mode,
835
879
  repoRoot: ctx?.repoRoot,
836
- });
880
+ }));
837
881
 
838
882
  if (src.kind === "dir") {
839
883
  if (!existsSync(join(src.dir, "scripts", "tot-dev.mjs"))) {
@@ -851,24 +895,32 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
851
895
  console.error(`~ renderer: ${src.why}`);
852
896
  return installRunnerTarball(
853
897
  { source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
854
- { log: (m) => console.error(m) },
898
+ { log: (m) => console.error(m), cacheRoot },
855
899
  );
856
900
  }
857
901
 
858
902
  // kind === "none" — no override, not in the monorepo: fetch the PUBLIC runner
859
- // from npm. Version = what the STORE declares (ADR 0011), else pinned to this
860
- // CLI's version (transitional lockstep). No MCP, no entitlement, no login.
903
+ // from npm. Version = what the STORE declares (ADR 0011), else the newest stable
904
+ // maintenance patch in this CLI's major.minor. No MCP, no entitlement, no login.
861
905
  const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
862
906
  const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
863
907
  const wantVersion = declared || CLI_VERSION;
864
908
 
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).
871
- if (!explicitPin) {
909
+ // Offline-safe fast path for a product-declared exact version: when that version
910
+ // is already cached, reuse it — honouring "don't hit npm when the RIGHT version
911
+ // is already cached" without reusing a version the declaration did not choose.
912
+ // Safe because `public-<version>` only exists if a prior run fetched exactly
913
+ // that version. Skipped when an explicit pin is set (that must go through
914
+ // resolution). One refinement over fully-offline: a QUICK, soft-fail registry
915
+ // probe (publishedRunnerIntegrity) revalidates the cached CONTENT identity when
916
+ // npm is reachable, so a corrected republish under the same version string is
917
+ // picked up automatically; offline/slow/unanswerable → trust the cache exactly
918
+ // as before (the probe can never block or fail the run).
919
+ // A store declaration is an exact product pin, so it is safe to reuse directly.
920
+ // Without a declaration, do not short-circuit on runner@CLI_VERSION: registry
921
+ // resolution may have a newer compatible patch that repairs an immutable bad
922
+ // artifact. The normal resolution path below still reuses its installed cache.
923
+ if (!explicitPin && declared) {
872
924
  const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
873
925
  if (exact) {
874
926
  // Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
@@ -878,21 +930,31 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
878
930
  // host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
879
931
  // bindings (npm/cli#4828) would otherwise be reused forever and crash astro
880
932
  // at boot with the swallowed "dev server didn't come up". When both hold,
881
- // reuse it (fully offline-safe).
933
+ // reuse it (offline-safe).
882
934
  if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
935
+ const published = await publishedRunnerIntegrity(env, wantVersion);
936
+ const recorded = readCacheMarker(exact)?.integrity || null;
937
+ if (!published || !recorded || published === recorded) {
938
+ console.error(
939
+ `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
940
+ );
941
+ setRunnerVersion(wantVersion);
942
+ prunePublicRunnerCache(cacheRoot, wantVersion);
943
+ return exact;
944
+ }
945
+ // Same version string, different published contents — a corrected
946
+ // republish. Fall through to resolution + a fresh install (which also
947
+ // verifies the new bytes against the new integrity).
948
+ console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
949
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
950
+ } else {
883
951
  console.error(
884
- `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
952
+ rendererCacheHealthy(exact)
953
+ ? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
954
+ : `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
885
955
  );
886
- setRunnerVersion(wantVersion);
887
- prunePublicRunnerCache(cacheRoot, wantVersion);
888
- return exact;
956
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
889
957
  }
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
958
  }
897
959
  }
898
960
 
@@ -923,8 +985,8 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
923
985
  // The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
924
986
  // out of the user's way; the setup spinner below is the visible progress.
925
987
  const dir = await installRunnerTarball(
926
- { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
927
- { log: (m) => console.error(m) },
988
+ { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip, integrity: pub.integrity },
989
+ { log: (m) => console.error(m), cacheRoot },
928
990
  );
929
991
  setRunnerVersion(pub.version); // telemetry: the runner version running this session
930
992
  // The pinned version is now installed under public-<version> — drop any other
@@ -949,92 +1011,269 @@ function sourceVersionKey(source) {
949
1011
  * authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
950
1012
  * paths so they cache identically.
951
1013
  *
952
- * @param {{ source: string, version: string, isUrl?: boolean }} spec
953
- * @param {{ log?: (m: string) => void }} [opts]
1014
+ * Cache-poisoning invariants (the 2026-08-18 first-run hardening):
1015
+ * promote-on-success only the install runs in a per-attempt staging dir and
1016
+ * is renamed into the canonical slot ONLY after it fully succeeds, so a failed
1017
+ * install can never become the cached artifact a later run resumes from.
1018
+ * • the completion marker is a manifest carrying the source's CONTENT identity
1019
+ * (`integrity`), so a corrected republish under the SAME version string is a
1020
+ * cache miss (rebuild), not a stale hit — no manual `rm -rf` ever required.
1021
+ * • a failed attempt auto-cleans and retries ONCE from a clean slate before
1022
+ * surfacing the error (transient blips heal themselves); deterministic
1023
+ * failures (`e.permanent`) skip the retry and fail loud immediately.
1024
+ *
1025
+ * @param {{ source: string, version: string, isUrl?: boolean, strip?: number, integrity?: string|null }} spec
1026
+ * `integrity` is the source artifact's content identity when the resolver knows
1027
+ * it (npm `dist.integrity`/`dist.shasum`); used to verify the downloaded bytes
1028
+ * and to bust a cached entry whose recorded identity no longer matches.
1029
+ * @param {{ log?: (m: string) => void, cacheRoot?: string }} [opts]
954
1030
  * @returns {Promise<string>} the cached, installed runner tree's root directory.
955
1031
  */
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…`);
1032
+ export async function installRunnerTarball(
1033
+ { source, version, isUrl = true, strip = 0, integrity = null },
1034
+ { log = (m) => console.error(m), cacheRoot = RENDERER_CACHE_ROOT } = {},
1035
+ ) {
1036
+ const runnerDir = join(cacheRoot, version);
1037
+ // Staging dirs from DEAD runs (crashed/killed installs) must not leak disk
1038
+ // forever reap them here, the one funnel every install path goes through.
1039
+ sweepStaleStagingDirs(cacheRoot);
1040
+
1041
+ const localSource = isUrl ? null : resolveLocalTarball(source);
1042
+ // The EXPECTED content identity of the source artifact. A local tarball with no
1043
+ // caller-provided integrity is cheap to hash on every run, so a same-path
1044
+ // republish (new contents, same file name) busts the cache too.
1045
+ const expected = integrity || (isUrl ? null : fileIntegrity(localSource));
1046
+
1047
+ const cached = readCacheMarker(runnerDir);
1048
+ if (cached) {
1049
+ if (!rendererCacheHealthy(runnerDir)) {
1050
+ // A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g.
1051
+ // darwin-x64 on an arm64 Mac) is otherwise trusted forever, and astro
1052
+ // crashes at boot with `Cannot find native binding`, swallowed as "the dev
1053
+ // server didn't come up". Fall through and rebuild from the source below.
1054
+ log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
1055
+ } else if (expected && cached.integrity && expected !== cached.integrity) {
1056
+ // CONTENT-HASH BUST: same version string, different artifact contents — a
1057
+ // corrected republish. The cached entry is stale by identity, not by label;
1058
+ // rebuild from the corrected source instead of serving the stale cache.
1059
+ log(`~ the preview engine's ${version} artifact changed upstream (same version, new contents) — rebuilding…`);
1060
+ } else {
1061
+ return runnerDir; // already downloaded + installed (and contents still match)
1062
+ }
967
1063
  }
968
1064
 
969
1065
  // First run only — set the expectation so the one-time cost doesn't read as a
970
1066
  // hang: this downloads + installs the renderer once, then every later run of
971
1067
  // this version is a no-network cache hit.
972
1068
  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…");
1069
+ // AUTO-CLEAN-AND-RETRY-ONCE: a transient failure (network blip mid-download, a
1070
+ // registry hiccup mid-install) heals itself with one clean re-attempt instead
1071
+ // of stopping a first run at an error only `rm -rf` folklore could clear.
1072
+ // Bounded to one retry so a genuinely-broken source still fails loudly.
1073
+ for (let attempt = 1; ; attempt++) {
1074
+ const archivePath = /** @type {string} */ (isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource);
1075
+ const stagingDir = `${runnerDir}.staging-${process.pid}`;
1076
+ try {
1077
+ if (isUrl) {
1078
+ // The fetch itself is otherwise silent (no per-byte output) and can run
1079
+ // tens of seconds on a cold cache — tick a spinner so it never looks hung.
1080
+ const spin = startProgress("downloading the store preview engine…");
1081
+ try {
1082
+ await downloadFile(source, archivePath);
1083
+ } finally {
1084
+ spin.stop();
1085
+ }
1086
+ }
1087
+ if (!existsSync(archivePath)) {
1088
+ throw new Error(`renderer tarball not found: ${archivePath}`);
1089
+ }
1090
+ // Refuse to install bytes that don't match the source's declared identity —
1091
+ // a truncated/corrupted download would otherwise be cached as if complete.
1092
+ // (Transient by nature, so the retry above gets a fresh download.)
1093
+ if (expected && !tarballMatchesIntegrity(archivePath, expected)) {
1094
+ throw new Error(`the downloaded preview-engine tarball failed its integrity check (expected ${expected})`);
1095
+ }
1096
+ // The identity recorded in the completion manifest below — what future runs
1097
+ // compare against to detect a same-version republish. Hash the actual bytes
1098
+ // when the resolver couldn't tell us (e.g. the entitled signed-URL path).
1099
+ const contentId = expected || fileIntegrity(archivePath);
1100
+ rmSync(stagingDir, { recursive: true, force: true });
1101
+ mkdirSync(stagingDir, { recursive: true });
1102
+ extractTarball(archivePath, stagingDir, { strip });
1103
+ // Pin the runner install to PUBLIC npm. The moat-free runner has only public
1104
+ // deps, but the HOST's global ~/.npmrc may point `registry` at a private
1105
+ // mirror (an internal proxy that 502s, or one an invited developer can't
1106
+ // reach) — an invited dev's machine config must never decide where the
1107
+ // runner's public deps come from. A project-level .npmrc wins over the user's.
1108
+ writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
1109
+ // The install is the long, noisy step — tick a spinner while its output goes
1110
+ // to a log, so the terminal shows one clean line instead of the pnpm firehose.
1111
+ // corepack setup logs to the SAME file so its failures aren't invisible (they
1112
+ // were the silent cause of "couldn't set up the store preview engine").
1113
+ const installLog = join(cacheRoot, `${version}.install.log`);
1114
+ ensureCorepackPnpm(stagingDir, { logPath: installLog });
1115
+ const installSpin = startProgress("installing the store preview engine…", {
1116
+ stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
1117
+ });
980
1118
  try {
981
- await downloadFile(source, archivePath);
1119
+ await runPnpmInstall(stagingDir, { logPath: installLog });
982
1120
  } finally {
983
- spin.stop();
1121
+ installSpin.stop();
984
1122
  }
1123
+ // Atomic-ish: only rename into the final, discoverable path once install
1124
+ // succeeded, so a crashed/interrupted run never leaves a half-built cache
1125
+ // entry that a later `tot dev` would treat as ready.
1126
+ rmSync(runnerDir, { recursive: true, force: true });
1127
+ renameSync(stagingDir, runnerDir);
1128
+ // Fence a fresh install against npm/cli#4828: if the installer left the wrong
1129
+ // arch's native bindings (or none) for this host, DON'T stamp the completion
1130
+ // marker — an unmarked tree is never reused, so the next run reinstalls cleanly
1131
+ // instead of caching the poison and crashing astro at boot. Fail loud + actionable
1132
+ // rather than swallow it as "the dev server didn't come up". Permanent: the
1133
+ // same installer on the same host would just produce the same result, so the
1134
+ // auto-retry is skipped.
1135
+ if (!rendererCacheHealthy(runnerDir)) {
1136
+ await emitObstacle("renderer-native-bindings-missing");
1137
+ const err = new CliError(
1138
+ `the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
1139
+ {
1140
+ 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)",
1141
+ exitCode: 2,
1142
+ },
1143
+ );
1144
+ /** @type {any} */ (err).permanent = true;
1145
+ throw err;
1146
+ }
1147
+ writeCacheMarker(runnerDir, { version, integrity: contentId });
1148
+ return runnerDir;
1149
+ } catch (e) {
1150
+ // A failed attempt must never survive on disk — not as staging debris, and
1151
+ // (by promote-on-success) it never reached the canonical slot at all.
1152
+ rmSync(stagingDir, { recursive: true, force: true });
1153
+ if (e?.permanent === true || attempt >= 2) throw e;
1154
+ log(`~ that didn't work (${String(e?.message || e).split("\n")[0]}) — retrying once from a clean slate…`);
1155
+ } finally {
1156
+ if (isUrl) rmSync(archivePath, { force: true });
985
1157
  }
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
- });
1158
+ }
1159
+ }
1160
+
1161
+ /**
1162
+ * Read a cache entry's completion marker (`.tot-cache-complete`). Returns the
1163
+ * manifest object (at least `{ integrity: string|null }`), or null when the
1164
+ * marker is absent — i.e. the entry is incomplete/partial and must be treated
1165
+ * as if it didn't exist. A legacy pre-manifest marker (a bare timestamp string)
1166
+ * reads as complete-with-unknown-identity, so existing healthy caches survive
1167
+ * the upgrade without a forced rebuild.
1168
+ */
1169
+ export function readCacheMarker(dir) {
1170
+ try {
1171
+ const raw = readFileSync(join(dir, ".tot-cache-complete"), "utf8");
1008
1172
  try {
1009
- await runPnpmInstall(stagingDir, { logPath: installLog });
1010
- } finally {
1011
- installSpin.stop();
1173
+ const m = JSON.parse(raw);
1174
+ if (m && typeof m === "object") return { integrity: null, ...m };
1175
+ } catch {
1176
+ /* legacy timestamp-string marker */
1012
1177
  }
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
- );
1178
+ return { integrity: null };
1179
+ } catch {
1180
+ return null; // no marker never treat the entry as installed
1181
+ }
1182
+ }
1183
+
1184
+ /** Stamp a cache entry complete: version + source content identity + when. */
1185
+ function writeCacheMarker(dir, { version, integrity }) {
1186
+ writeFileSync(
1187
+ join(dir, ".tot-cache-complete"),
1188
+ JSON.stringify({ version, integrity: integrity || null, completedAt: new Date().toISOString() }) + "\n",
1189
+ );
1190
+ }
1191
+
1192
+ /** sha512 SRI (`sha512-<base64>`, npm's `dist.integrity` format) of a file; null when unreadable. */
1193
+ function fileIntegrity(path) {
1194
+ try {
1195
+ return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`;
1196
+ } catch {
1197
+ return null;
1198
+ }
1199
+ }
1200
+
1201
+ /**
1202
+ * Do the tarball's bytes match `expected` — an SRI string (`sha512-<b64>`, npm's
1203
+ * `dist.integrity`) or npm's legacy `dist.shasum` (bare 40-hex sha1)? Unknown
1204
+ * formats and probe errors return true: this check exists to catch corrupted
1205
+ * bytes, never to block an install on a format we can't verify.
1206
+ */
1207
+ export function tarballMatchesIntegrity(archivePath, expected) {
1208
+ try {
1209
+ const want = String(expected).trim();
1210
+ const sri = /^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/=]+)$/.exec(want);
1211
+ if (sri) {
1212
+ return createHash(sri[1]).update(readFileSync(archivePath)).digest("base64") === sri[2];
1032
1213
  }
1033
- writeFileSync(marker, new Date().toISOString());
1034
- } finally {
1035
- if (isUrl) rmSync(archivePath, { force: true });
1214
+ if (/^[0-9a-f]{40}$/i.test(want)) {
1215
+ return createHash("sha1").update(readFileSync(archivePath)).digest("hex") === want.toLowerCase();
1216
+ }
1217
+ return true;
1218
+ } catch {
1219
+ return true;
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * Reap `<entry>.staging-<pid>` dirs left by DEAD processes — failed/killed
1225
+ * installs used to accumulate one orphaned staging tree per attempt, leaking
1226
+ * disk forever. A staging dir whose pid is still alive belongs to a concurrent
1227
+ * `tot dev` mid-install and is left alone. Best-effort: never throws, and never
1228
+ * touches this process's own staging dir (created fresh after this sweep).
1229
+ */
1230
+ export function sweepStaleStagingDirs(cacheRoot, { pidAlive = processAlive } = {}) {
1231
+ try {
1232
+ if (!cacheRoot || !existsSync(cacheRoot)) return;
1233
+ for (const name of readdirSync(cacheRoot)) {
1234
+ const m = /\.staging-(\d+)$/.exec(name);
1235
+ if (!m) continue;
1236
+ const pid = Number(m[1]);
1237
+ if (pid === process.pid || pidAlive(pid)) continue;
1238
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
1239
+ }
1240
+ } catch {
1241
+ /* best-effort cache hygiene */
1242
+ }
1243
+ }
1244
+
1245
+ /** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
1246
+ function processAlive(pid) {
1247
+ try {
1248
+ process.kill(pid, 0);
1249
+ return true;
1250
+ } catch (e) {
1251
+ return e?.code === "EPERM";
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * The registry-declared content identity (`dist.integrity`, else `dist.shasum`)
1257
+ * of the public runner at `version` — or null when npm can't answer QUICKLY
1258
+ * (offline, slow, 4xx/5xx, malformed). Used by ensureSampleRenderer's cached
1259
+ * fast path to detect a same-version republish without ever making the network
1260
+ * a hard dependency: null means "can't verify right now — trust the cache",
1261
+ * preserving the offline-reuse behavior exactly.
1262
+ */
1263
+ export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000, fetchFn = fetch } = {}) {
1264
+ try {
1265
+ const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
1266
+ const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
1267
+ const res = await fetchFn(`${registry}/${pkg.replace("/", "%2f")}`, {
1268
+ headers: { accept: "application/json" },
1269
+ signal: AbortSignal.timeout(timeoutMs),
1270
+ });
1271
+ if (!res.ok) return null;
1272
+ const dist = (await res.json())?.versions?.[version]?.dist;
1273
+ return dist?.integrity || dist?.shasum || null;
1274
+ } catch {
1275
+ return null;
1036
1276
  }
1037
- return runnerDir;
1038
1277
  }
1039
1278
 
1040
1279
  /** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
@@ -1054,7 +1293,7 @@ async function downloadFile(url, destPath) {
1054
1293
  if (!res.ok || !res.body) {
1055
1294
  throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
1056
1295
  }
1057
- await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
1296
+ await pipeline(Readable.fromWeb(/** @type {any} */ (res.body)), createWriteStream(destPath));
1058
1297
  }
1059
1298
 
1060
1299
  /**
@@ -1103,7 +1342,8 @@ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } =
1103
1342
  for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
1104
1343
  const r = spawnFn("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
1105
1344
  if (fd !== null && (r.error || r.status !== 0)) {
1106
- writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
1345
+ const rerr = /** @type {any} */ (r.error);
1346
+ writeSync(fd, `[tot] corepack ${args.join(" ")} → ${rerr?.code || rerr?.message || `exit ${r.status}`}\n`);
1107
1347
  }
1108
1348
  }
1109
1349
  } finally {
@@ -1147,6 +1387,10 @@ function spawnAsyncResult(cmd, args, opts = {}) {
1147
1387
  });
1148
1388
  }
1149
1389
 
1390
+ /**
1391
+ * @param {string} runnerDir
1392
+ * @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
1393
+ */
1150
1394
  export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
1151
1395
  const fd = logPath ? openSync(logPath, "a") : null;
1152
1396
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
@@ -1163,8 +1407,8 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1163
1407
  // If a launcher isn't installed at all (ENOENT) we move on; a launcher that
1164
1408
  // RAN but whose install failed is the real error and stops the loop.
1165
1409
  // REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
1166
- // `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
1167
- // runner to this CLI's version, so this CLI never installs those).
1410
+ // `workspace:*` deps npm rejects (harmless here: resolution stays on this
1411
+ // CLI's compatible release line, whose runner artifacts are installable).
1168
1412
  const attempts = [
1169
1413
  { cmd: "pnpm", args: installArgs },
1170
1414
  { cmd: "corepack", args: ["pnpm", ...installArgs] },
@@ -1184,28 +1428,39 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1184
1428
  stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
1185
1429
  });
1186
1430
  if (r.status === 0) return; // installed
1187
- if (r.error?.code === "ENOENT") {
1431
+ if (/** @type {any} */ (r.error)?.code === "ENOENT") {
1188
1432
  // This launcher isn't on the machine — record it and try the next one.
1189
1433
  if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
1190
1434
  continue;
1191
1435
  }
1192
1436
  // The launcher ran; the install itself failed. That's the actionable error.
1437
+ // installRunnerTarball retries transient failures once from a clean slate;
1438
+ // deterministic broken-release 404s are marked permanent below and skip it.
1193
1439
  await emitObstacle("install-failed");
1194
- throw new CliError(
1440
+ const brokenRelease = missingPackage404(logPath);
1441
+ const err = new CliError(
1195
1442
  `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
1196
1443
  (logPath ? `\n details: ${logPath}` : ""),
1197
- { next: "check the details log above, then re-run `tot start` (it resumes from the cache)" },
1444
+ { next: "check the details log above, then re-run `tot start` (it retries from a clean slate — no cache to clear)" },
1198
1445
  );
1446
+ // A package 404 is deterministic metadata baked into this immutable runner
1447
+ // tarball. Retrying the same bytes wastes another full install; return at once
1448
+ // so the caller can transparently fall back to the compatible public patch.
1449
+ if (brokenRelease) err.permanent = true;
1450
+ throw err;
1199
1451
  }
1200
1452
  // Every launcher ENOENT'd → there's no pnpm on this machine and corepack
1201
1453
  // couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
1202
1454
  // every Node, so `npm i -g pnpm` is the escape hatch that always exists.
1455
+ // Permanent: retrying can't conjure a launcher — skip the clean-slate retry.
1203
1456
  await emitObstacle("pnpm-missing");
1204
- throw new CliError(
1457
+ const err = new CliError(
1205
1458
  "couldn't set up the store preview engine — pnpm isn't available on this machine" +
1206
1459
  (logPath ? `\n details: ${logPath}` : ""),
1207
1460
  { next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
1208
1461
  );
1462
+ /** @type {any} */ (err).permanent = true;
1463
+ throw err;
1209
1464
  } finally {
1210
1465
  if (fd !== null) closeSync(fd);
1211
1466
  }
@@ -1219,6 +1474,19 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1219
1474
  function pnpmFailureHint(logPath) {
1220
1475
  if (!logPath) return " — is pnpm/corepack available on this host?";
1221
1476
  try {
1477
+ // A 404 means the registry answered — a specific package/version doesn't
1478
+ // exist there. Since installs now run from a clean slate every attempt
1479
+ // (promote-on-success + auto-retry), this is a BROKEN RUNNER RELEASE (it
1480
+ // references an unpublished package), not the user's cache — no `rm -rf`
1481
+ // will help. Check this BEFORE the generic ERR_PNPM_FETCH match, since
1482
+ // pnpm's 404 error text also contains "ERR_PNPM_FETCH".
1483
+ const missing404 = missingPackage404(logPath);
1484
+ if (missing404) {
1485
+ return (
1486
+ ` — the preview engine references a package that isn't published (${missing404});` +
1487
+ " that's a broken preview-engine release, not your machine"
1488
+ );
1489
+ }
1222
1490
  const tail = readFileSync(logPath, "utf8").slice(-8000);
1223
1491
  if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
1224
1492
  return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
@@ -1229,12 +1497,27 @@ function pnpmFailureHint(logPath) {
1229
1497
  return "";
1230
1498
  }
1231
1499
 
1500
+ /** Return the missing package URL from a pnpm 404, else null. */
1501
+ function missingPackage404(logPath) {
1502
+ if (!logPath) return null;
1503
+ try {
1504
+ const tail = readFileSync(logPath, "utf8").slice(-8000);
1505
+ return tail.match(/ERR_PNPM_FETCH_404[^\n]*GET\s+(\S+)/i)?.[1] || null;
1506
+ } catch {
1507
+ return null;
1508
+ }
1509
+ }
1510
+
1232
1511
  /**
1233
1512
  * Run the cached runner's own scripts/tot-dev.mjs in standalone (--workspace)
1234
1513
  * mode — the exact code path the WS3 spike verified gives native, non-polled
1235
1514
  * fs-watch HMR. Returns a handle shaped like spawnDevContainer's, so run()'s
1236
1515
  * auto-open-browser logic works unchanged for either runtime. Exported (and
1237
1516
  * `stdio` overridable) so `tot start` can pipe the logs instead of inheriting.
1517
+ * @param {string} runnerDir
1518
+ * @param {string} workspace
1519
+ * @param {string} port
1520
+ * @param {{ stdio?: any, env?: Record<string, any> }} [opts]
1238
1521
  */
1239
1522
  export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit", env = {} } = {}) {
1240
1523
  const script = join(runnerDir, "scripts", "tot-dev.mjs");
@@ -1246,7 +1529,7 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
1246
1529
  // `env` (e.g. activityBridgeEnv()) merges OVER process.env — {} is a pure
1247
1530
  // passthrough, identical to the old no-env-key behavior. Append `--no-warnings`
1248
1531
  // to NODE_OPTIONS so the runner's Node process never emits the scary
1249
- // `(node:NNNN) ExperimentalWarning: …` boot noise to the developer (b4);
1532
+ // `(node:NNNN) ExperimentalWarning: …` boot noise to the developer;
1250
1533
  // appended (not replaced) so any host-set NODE_OPTIONS is preserved.
1251
1534
  const mergedEnv = { ...process.env, ...env };
1252
1535
  mergedEnv.NODE_OPTIONS = `${mergedEnv.NODE_OPTIONS ? `${mergedEnv.NODE_OPTIONS} ` : ""}--no-warnings`;
@@ -1255,7 +1538,7 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
1255
1538
  [script, "--workspace", workspace, "--port", port],
1256
1539
  { cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
1257
1540
  );
1258
- const handle = { child, exited: false, done: null };
1541
+ const handle = { child, exited: false, done: /** @type {any} */ (null) };
1259
1542
  handle.done = new Promise((resolvePromise) => {
1260
1543
  child.on("exit", (code) => {
1261
1544
  handle.exited = true;
@@ -1289,7 +1572,7 @@ async function runContainer(workspace, args, ctx) {
1289
1572
  const handle = await spawnDevContainer(plan, args, { stdio: "piped" });
1290
1573
  streamDevLogs(handle.child, { cockpitUrl: cockpitUrlFrom(activityBridgeEnv().TOT_DEV_ACTIVITY_URL) });
1291
1574
 
1292
- // Heartbeat the hosted cockpit (G1) CLI-side while the container runs — the
1575
+ // Heartbeat the hosted cockpit CLI-side while the container runs — the
1293
1576
  // container reports file-saves via the threaded env, but the CLI owns the
1294
1577
  // version + live URL. Same bridge credential (activityBridgeEnv), no-op absent.
1295
1578
  const stopHeartbeat = startHeartbeatFromEnv(activityBridgeEnv(), { url: plan.url, cwd: plan.workspace });
@@ -1314,7 +1597,7 @@ async function runContainer(workspace, args, ctx) {
1314
1597
 
1315
1598
  /**
1316
1599
  * Resolve the runner image ref the same way buildContainerPlan does — exported
1317
- * so a caller (`tot start`, for C1) can know the image, and therefore prefetch
1600
+ * so a caller (`tot start`) can know the image, and therefore prefetch
1318
1601
  * a registry login (below), before the checkout dir exists.
1319
1602
  */
1320
1603
  export function resolveDevImage(args, env = process.env) {
@@ -1410,9 +1693,9 @@ export async function spawnDevContainer(plan, args, { stdio = "inherit" } = {})
1410
1693
 
1411
1694
  const stdioArr =
1412
1695
  stdio === "piped" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"];
1413
- const child = spawn("docker", plan.dockerArgs, { stdio: stdioArr });
1696
+ const child = spawn("docker", plan.dockerArgs, { stdio: /** @type {any} */ (stdioArr) });
1414
1697
 
1415
- const handle = { child, exited: false, done: null };
1698
+ const handle = { child, exited: false, done: /** @type {any} */ (null) };
1416
1699
  handle.done = new Promise((resolvePromise) => {
1417
1700
  child.on("exit", (code) => {
1418
1701
  handle.exited = true;
@@ -1448,10 +1731,11 @@ export function isPrivateRegistryImage(image) {
1448
1731
  * the session, or the tool isn't available yet, warn softly and continue (an
1449
1732
  * existing `docker login`, or the docker pull error, takes over).
1450
1733
  *
1451
- * Exported + accepts an already-authenticated `client` (C1: `tot start` reuses
1734
+ * Exported + accepts an already-authenticated `client` (`tot start` reuses
1452
1735
  * its own session and overlaps this with the checkout clone instead of paying
1453
1736
  * for a second client.initialize()+establishSession() serially afterward).
1454
1737
  * `tot dev` standalone omits it and this establishes its own, as before.
1738
+ * @param {string} image @param {any} args @param {{ client?: any }} [opts]
1455
1739
  */
1456
1740
  export async function ensureRegistryLogin(image, args, { client: providedClient } = {}) {
1457
1741
  const registry = String(image).split("/")[0];
@@ -1501,7 +1785,7 @@ export function dockerAvailable() {
1501
1785
  * thing `open -a` can launch unattended; other platforms report false and
1502
1786
  * leave it to the caller's usual next-command message). Returns true once
1503
1787
  * Docker answers, false if it doesn't within the wait window. Shared by
1504
- * `tot start`'s preflight and `tot doctor --fix` (F2).
1788
+ * `tot start`'s preflight and `tot doctor --fix`.
1505
1789
  */
1506
1790
  export async function tryStartDocker() {
1507
1791
  if (process.platform !== "darwin") return false;