@tokenoftrust/cli 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tot.mjs +115 -3
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/commands/accept.mjs +445 -33
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +289 -10
- package/src/commands/dev.mjs +401 -135
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +30 -7
- package/src/commands/revert.mjs +322 -0
- package/src/commands/ship.mjs +24 -4
- package/src/commands/start.mjs +40 -8
- package/src/commands/submit.mjs +839 -135
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +6 -1
- package/src/git-credential.mjs +184 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/plan.mjs +75 -2
- package/src/validate.mjs +52 -0
package/src/commands/dev.mjs
CHANGED
|
@@ -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
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
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
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
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>.*
|
|
499
|
-
//
|
|
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
|
|
524
|
-
//
|
|
525
|
-
//
|
|
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
|
|
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}
|
|
@@ -605,19 +606,39 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
|
|
|
605
606
|
`using ${version} (${reason}). The store's runnerVersion needs a published release.`,
|
|
606
607
|
);
|
|
607
608
|
}
|
|
608
|
-
} else if (!explicitPin && version !== CLI_VERSION) {
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
609
|
+
} else if (!explicitPin && version !== CLI_VERSION && !isCompatibleMaintenancePatch(CLI_VERSION, version)) {
|
|
610
|
+
// A stable same-minor maintenance patch is the normal self-healing path, not
|
|
611
|
+
// skew. Anything else still deserves a loud warning: it means resolution had
|
|
612
|
+
// to leave the CLI's compatibility line.
|
|
612
613
|
console.warn(
|
|
613
614
|
` ⚠ runner ${version} — no exact @${CLI_VERSION} published (${reason}). ` +
|
|
614
615
|
`CLI/runner versions are SKEWED; publish the runner at ${CLI_VERSION} to align ` +
|
|
615
616
|
`(or pin with --renderer-version to silence).`,
|
|
616
617
|
);
|
|
617
618
|
}
|
|
618
|
-
const
|
|
619
|
-
if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
620
|
-
|
|
619
|
+
const dist = meta?.versions?.[version]?.dist;
|
|
620
|
+
if (!dist?.tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
621
|
+
// `integrity` is the artifact's CONTENT identity (npm dist.integrity, else the
|
|
622
|
+
// legacy shasum) — installRunnerTarball verifies the downloaded bytes against
|
|
623
|
+
// it and records it so a same-version corrected republish busts the cache.
|
|
624
|
+
return {
|
|
625
|
+
kind: "public",
|
|
626
|
+
version,
|
|
627
|
+
url: dist.tarball,
|
|
628
|
+
strip: 1,
|
|
629
|
+
cacheKey: `public-${version}`,
|
|
630
|
+
integrity: dist.integrity || dist.shasum || null,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Stable x.y patches share the runner/CLI contract; prereleases do not. */
|
|
635
|
+
function isCompatibleMaintenancePatch(cliVersion, runnerVersion) {
|
|
636
|
+
if (!/^\d+\.\d+\.\d+$/.test(String(cliVersion)) || !/^\d+\.\d+\.\d+$/.test(String(runnerVersion))) {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
const cliMM = majorMinor(cliVersion);
|
|
640
|
+
const runnerMM = majorMinor(runnerVersion);
|
|
641
|
+
return !!cliMM && !!runnerMM && cliMM.major === runnerMM.major && cliMM.minor === runnerMM.minor;
|
|
621
642
|
}
|
|
622
643
|
|
|
623
644
|
/**
|
|
@@ -637,7 +658,17 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
|
|
|
637
658
|
if (!res?.url || !res?.version) {
|
|
638
659
|
throw new Error(res?.error || "no renderer-artifact URL returned");
|
|
639
660
|
}
|
|
640
|
-
|
|
661
|
+
// Opportunistic content identity: recorded/verified when the MCP declares one
|
|
662
|
+
// (integrity/sha256); a server that doesn't is simply unverified (null), never
|
|
663
|
+
// an error — the cache then busts on version changes only, as before.
|
|
664
|
+
return {
|
|
665
|
+
kind: "entitled",
|
|
666
|
+
version: res.version,
|
|
667
|
+
url: res.url,
|
|
668
|
+
strip: 0,
|
|
669
|
+
cacheKey: res.version,
|
|
670
|
+
integrity: res.integrity || res.sha256 || null,
|
|
671
|
+
};
|
|
641
672
|
}
|
|
642
673
|
|
|
643
674
|
/**
|
|
@@ -676,7 +707,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
|
|
|
676
707
|
|
|
677
708
|
try {
|
|
678
709
|
const runnerDir = await installRunnerTarball(
|
|
679
|
-
{
|
|
710
|
+
{
|
|
711
|
+
source: credential.url,
|
|
712
|
+
version: credential.version,
|
|
713
|
+
isUrl: true,
|
|
714
|
+
integrity: credential.integrity || credential.sha256 || null,
|
|
715
|
+
},
|
|
680
716
|
{ log: (m) => console.error(m) },
|
|
681
717
|
);
|
|
682
718
|
setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
|
|
@@ -851,24 +887,32 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
851
887
|
console.error(`~ renderer: ${src.why}`);
|
|
852
888
|
return installRunnerTarball(
|
|
853
889
|
{ source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
|
|
854
|
-
{ log: (m) => console.error(m) },
|
|
890
|
+
{ log: (m) => console.error(m), cacheRoot },
|
|
855
891
|
);
|
|
856
892
|
}
|
|
857
893
|
|
|
858
894
|
// kind === "none" — no override, not in the monorepo: fetch the PUBLIC runner
|
|
859
|
-
// from npm. Version = what the STORE declares (ADR 0011), else
|
|
860
|
-
// CLI's
|
|
895
|
+
// from npm. Version = what the STORE declares (ADR 0011), else the newest stable
|
|
896
|
+
// maintenance patch in this CLI's major.minor. No MCP, no entitlement, no login.
|
|
861
897
|
const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
|
|
862
898
|
const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
|
|
863
899
|
const wantVersion = declared || CLI_VERSION;
|
|
864
900
|
|
|
865
|
-
//
|
|
866
|
-
// already cached, reuse it
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
901
|
+
// Offline-safe fast path for a product-declared exact version: when that version
|
|
902
|
+
// is already cached, reuse it — honouring "don't hit npm when the RIGHT version
|
|
903
|
+
// is already cached" without reusing a version the declaration did not choose.
|
|
904
|
+
// Safe because `public-<version>` only exists if a prior run fetched exactly
|
|
905
|
+
// that version. Skipped when an explicit pin is set (that must go through
|
|
906
|
+
// resolution). One refinement over fully-offline: a QUICK, soft-fail registry
|
|
907
|
+
// probe (publishedRunnerIntegrity) revalidates the cached CONTENT identity when
|
|
908
|
+
// npm is reachable, so a corrected republish under the same version string is
|
|
909
|
+
// picked up automatically; offline/slow/unanswerable → trust the cache exactly
|
|
910
|
+
// as before (the probe can never block or fail the run).
|
|
911
|
+
// A store declaration is an exact product pin, so it is safe to reuse directly.
|
|
912
|
+
// Without a declaration, do not short-circuit on runner@CLI_VERSION: registry
|
|
913
|
+
// resolution may have a newer compatible patch that repairs an immutable bad
|
|
914
|
+
// artifact. The normal resolution path below still reuses its installed cache.
|
|
915
|
+
if (!explicitPin && declared) {
|
|
872
916
|
const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
|
|
873
917
|
if (exact) {
|
|
874
918
|
// Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
|
|
@@ -878,21 +922,31 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
878
922
|
// host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
|
|
879
923
|
// bindings (npm/cli#4828) would otherwise be reused forever and crash astro
|
|
880
924
|
// at boot with the swallowed "dev server didn't come up". When both hold,
|
|
881
|
-
// reuse it (
|
|
925
|
+
// reuse it (offline-safe).
|
|
882
926
|
if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
|
|
927
|
+
const published = await publishedRunnerIntegrity(env, wantVersion);
|
|
928
|
+
const recorded = readCacheMarker(exact)?.integrity || null;
|
|
929
|
+
if (!published || !recorded || published === recorded) {
|
|
930
|
+
console.error(
|
|
931
|
+
`~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
|
|
932
|
+
);
|
|
933
|
+
setRunnerVersion(wantVersion);
|
|
934
|
+
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
935
|
+
return exact;
|
|
936
|
+
}
|
|
937
|
+
// Same version string, different published contents — a corrected
|
|
938
|
+
// republish. Fall through to resolution + a fresh install (which also
|
|
939
|
+
// verifies the new bytes against the new integrity).
|
|
940
|
+
console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
|
|
941
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
|
|
942
|
+
} else {
|
|
883
943
|
console.error(
|
|
884
|
-
|
|
944
|
+
rendererCacheHealthy(exact)
|
|
945
|
+
? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
|
|
946
|
+
: `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
|
|
885
947
|
);
|
|
886
|
-
|
|
887
|
-
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
888
|
-
return exact;
|
|
948
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
889
949
|
}
|
|
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
950
|
}
|
|
897
951
|
}
|
|
898
952
|
|
|
@@ -923,8 +977,8 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
923
977
|
// The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
|
|
924
978
|
// out of the user's way; the setup spinner below is the visible progress.
|
|
925
979
|
const dir = await installRunnerTarball(
|
|
926
|
-
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
|
|
927
|
-
{ log: (m) => console.error(m) },
|
|
980
|
+
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip, integrity: pub.integrity },
|
|
981
|
+
{ log: (m) => console.error(m), cacheRoot },
|
|
928
982
|
);
|
|
929
983
|
setRunnerVersion(pub.version); // telemetry: the runner version running this session
|
|
930
984
|
// The pinned version is now installed under public-<version> — drop any other
|
|
@@ -949,92 +1003,269 @@ function sourceVersionKey(source) {
|
|
|
949
1003
|
* authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
|
|
950
1004
|
* paths so they cache identically.
|
|
951
1005
|
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
1006
|
+
* Cache-poisoning invariants (the 2026-08-18 first-run hardening):
|
|
1007
|
+
* • promote-on-success only — the install runs in a per-attempt staging dir and
|
|
1008
|
+
* is renamed into the canonical slot ONLY after it fully succeeds, so a failed
|
|
1009
|
+
* install can never become the cached artifact a later run resumes from.
|
|
1010
|
+
* • the completion marker is a manifest carrying the source's CONTENT identity
|
|
1011
|
+
* (`integrity`), so a corrected republish under the SAME version string is a
|
|
1012
|
+
* cache miss (rebuild), not a stale hit — no manual `rm -rf` ever required.
|
|
1013
|
+
* • a failed attempt auto-cleans and retries ONCE from a clean slate before
|
|
1014
|
+
* surfacing the error (transient blips heal themselves); deterministic
|
|
1015
|
+
* failures (`e.permanent`) skip the retry and fail loud immediately.
|
|
1016
|
+
*
|
|
1017
|
+
* @param {{ source: string, version: string, isUrl?: boolean, strip?: number, integrity?: string|null }} spec
|
|
1018
|
+
* `integrity` is the source artifact's content identity when the resolver knows
|
|
1019
|
+
* it (npm `dist.integrity`/`dist.shasum`); used to verify the downloaded bytes
|
|
1020
|
+
* and to bust a cached entry whose recorded identity no longer matches.
|
|
1021
|
+
* @param {{ log?: (m: string) => void, cacheRoot?: string }} [opts]
|
|
954
1022
|
* @returns {Promise<string>} the cached, installed runner tree's root directory.
|
|
955
1023
|
*/
|
|
956
|
-
export async function installRunnerTarball(
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1024
|
+
export async function installRunnerTarball(
|
|
1025
|
+
{ source, version, isUrl = true, strip = 0, integrity = null },
|
|
1026
|
+
{ log = (m) => console.error(m), cacheRoot = RENDERER_CACHE_ROOT } = {},
|
|
1027
|
+
) {
|
|
1028
|
+
const runnerDir = join(cacheRoot, version);
|
|
1029
|
+
// Staging dirs from DEAD runs (crashed/killed installs) must not leak disk
|
|
1030
|
+
// forever — reap them here, the one funnel every install path goes through.
|
|
1031
|
+
sweepStaleStagingDirs(cacheRoot);
|
|
1032
|
+
|
|
1033
|
+
const localSource = isUrl ? null : resolveLocalTarball(source);
|
|
1034
|
+
// The EXPECTED content identity of the source artifact. A local tarball with no
|
|
1035
|
+
// caller-provided integrity is cheap to hash on every run, so a same-path
|
|
1036
|
+
// republish (new contents, same file name) busts the cache too.
|
|
1037
|
+
const expected = integrity || (isUrl ? null : fileIntegrity(localSource));
|
|
1038
|
+
|
|
1039
|
+
const cached = readCacheMarker(runnerDir);
|
|
1040
|
+
if (cached) {
|
|
1041
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1042
|
+
// A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g.
|
|
1043
|
+
// darwin-x64 on an arm64 Mac) is otherwise trusted forever, and astro
|
|
1044
|
+
// crashes at boot with `Cannot find native binding`, swallowed as "the dev
|
|
1045
|
+
// server didn't come up". Fall through and rebuild from the source below.
|
|
1046
|
+
log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
|
|
1047
|
+
} else if (expected && cached.integrity && expected !== cached.integrity) {
|
|
1048
|
+
// CONTENT-HASH BUST: same version string, different artifact contents — a
|
|
1049
|
+
// corrected republish. The cached entry is stale by identity, not by label;
|
|
1050
|
+
// rebuild from the corrected source instead of serving the stale cache.
|
|
1051
|
+
log(`~ the preview engine's ${version} artifact changed upstream (same version, new contents) — rebuilding…`);
|
|
1052
|
+
} else {
|
|
1053
|
+
return runnerDir; // already downloaded + installed (and contents still match)
|
|
1054
|
+
}
|
|
967
1055
|
}
|
|
968
1056
|
|
|
969
1057
|
// First run only — set the expectation so the one-time cost doesn't read as a
|
|
970
1058
|
// hang: this downloads + installs the renderer once, then every later run of
|
|
971
1059
|
// this version is a no-network cache hit.
|
|
972
1060
|
log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1061
|
+
// AUTO-CLEAN-AND-RETRY-ONCE: a transient failure (network blip mid-download, a
|
|
1062
|
+
// registry hiccup mid-install) heals itself with one clean re-attempt instead
|
|
1063
|
+
// of stopping a first run at an error only `rm -rf` folklore could clear.
|
|
1064
|
+
// Bounded to one retry so a genuinely-broken source still fails loudly.
|
|
1065
|
+
for (let attempt = 1; ; attempt++) {
|
|
1066
|
+
const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource;
|
|
1067
|
+
const stagingDir = `${runnerDir}.staging-${process.pid}`;
|
|
1068
|
+
try {
|
|
1069
|
+
if (isUrl) {
|
|
1070
|
+
// The fetch itself is otherwise silent (no per-byte output) and can run
|
|
1071
|
+
// tens of seconds on a cold cache — tick a spinner so it never looks hung.
|
|
1072
|
+
const spin = startProgress("downloading the store preview engine…");
|
|
1073
|
+
try {
|
|
1074
|
+
await downloadFile(source, archivePath);
|
|
1075
|
+
} finally {
|
|
1076
|
+
spin.stop();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (!existsSync(archivePath)) {
|
|
1080
|
+
throw new Error(`renderer tarball not found: ${archivePath}`);
|
|
1081
|
+
}
|
|
1082
|
+
// Refuse to install bytes that don't match the source's declared identity —
|
|
1083
|
+
// a truncated/corrupted download would otherwise be cached as if complete.
|
|
1084
|
+
// (Transient by nature, so the retry above gets a fresh download.)
|
|
1085
|
+
if (expected && !tarballMatchesIntegrity(archivePath, expected)) {
|
|
1086
|
+
throw new Error(`the downloaded preview-engine tarball failed its integrity check (expected ${expected})`);
|
|
1087
|
+
}
|
|
1088
|
+
// The identity recorded in the completion manifest below — what future runs
|
|
1089
|
+
// compare against to detect a same-version republish. Hash the actual bytes
|
|
1090
|
+
// when the resolver couldn't tell us (e.g. the entitled signed-URL path).
|
|
1091
|
+
const contentId = expected || fileIntegrity(archivePath);
|
|
1092
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
1093
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
1094
|
+
extractTarball(archivePath, stagingDir, { strip });
|
|
1095
|
+
// Pin the runner install to PUBLIC npm. The moat-free runner has only public
|
|
1096
|
+
// deps, but the HOST's global ~/.npmrc may point `registry` at a private
|
|
1097
|
+
// mirror (an internal proxy that 502s, or one an invited developer can't
|
|
1098
|
+
// reach) — an invited dev's machine config must never decide where the
|
|
1099
|
+
// runner's public deps come from. A project-level .npmrc wins over the user's.
|
|
1100
|
+
writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
|
|
1101
|
+
// The install is the long, noisy step — tick a spinner while its output goes
|
|
1102
|
+
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
1103
|
+
// corepack setup logs to the SAME file so its failures aren't invisible (they
|
|
1104
|
+
// were the silent cause of "couldn't set up the store preview engine").
|
|
1105
|
+
const installLog = join(cacheRoot, `${version}.install.log`);
|
|
1106
|
+
ensureCorepackPnpm(stagingDir, { logPath: installLog });
|
|
1107
|
+
const installSpin = startProgress("installing the store preview engine…", {
|
|
1108
|
+
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
1109
|
+
});
|
|
980
1110
|
try {
|
|
981
|
-
await
|
|
1111
|
+
await runPnpmInstall(stagingDir, { logPath: installLog });
|
|
982
1112
|
} finally {
|
|
983
|
-
|
|
1113
|
+
installSpin.stop();
|
|
984
1114
|
}
|
|
1115
|
+
// Atomic-ish: only rename into the final, discoverable path once install
|
|
1116
|
+
// succeeded, so a crashed/interrupted run never leaves a half-built cache
|
|
1117
|
+
// entry that a later `tot dev` would treat as ready.
|
|
1118
|
+
rmSync(runnerDir, { recursive: true, force: true });
|
|
1119
|
+
renameSync(stagingDir, runnerDir);
|
|
1120
|
+
// Fence a fresh install against npm/cli#4828: if the installer left the wrong
|
|
1121
|
+
// arch's native bindings (or none) for this host, DON'T stamp the completion
|
|
1122
|
+
// marker — an unmarked tree is never reused, so the next run reinstalls cleanly
|
|
1123
|
+
// instead of caching the poison and crashing astro at boot. Fail loud + actionable
|
|
1124
|
+
// rather than swallow it as "the dev server didn't come up". Permanent: the
|
|
1125
|
+
// same installer on the same host would just produce the same result, so the
|
|
1126
|
+
// auto-retry is skipped.
|
|
1127
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1128
|
+
await emitObstacle("renderer-native-bindings-missing");
|
|
1129
|
+
const err = new CliError(
|
|
1130
|
+
`the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
|
|
1131
|
+
{
|
|
1132
|
+
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)",
|
|
1133
|
+
exitCode: 2,
|
|
1134
|
+
},
|
|
1135
|
+
);
|
|
1136
|
+
err.permanent = true;
|
|
1137
|
+
throw err;
|
|
1138
|
+
}
|
|
1139
|
+
writeCacheMarker(runnerDir, { version, integrity: contentId });
|
|
1140
|
+
return runnerDir;
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
// A failed attempt must never survive on disk — not as staging debris, and
|
|
1143
|
+
// (by promote-on-success) it never reached the canonical slot at all.
|
|
1144
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
1145
|
+
if (e?.permanent === true || attempt >= 2) throw e;
|
|
1146
|
+
log(`~ that didn't work (${String(e?.message || e).split("\n")[0]}) — retrying once from a clean slate…`);
|
|
1147
|
+
} finally {
|
|
1148
|
+
if (isUrl) rmSync(archivePath, { force: true });
|
|
985
1149
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Read a cache entry's completion marker (`.tot-cache-complete`). Returns the
|
|
1155
|
+
* manifest object (at least `{ integrity: string|null }`), or null when the
|
|
1156
|
+
* marker is absent — i.e. the entry is incomplete/partial and must be treated
|
|
1157
|
+
* as if it didn't exist. A legacy pre-manifest marker (a bare timestamp string)
|
|
1158
|
+
* reads as complete-with-unknown-identity, so existing healthy caches survive
|
|
1159
|
+
* the upgrade without a forced rebuild.
|
|
1160
|
+
*/
|
|
1161
|
+
export function readCacheMarker(dir) {
|
|
1162
|
+
try {
|
|
1163
|
+
const raw = readFileSync(join(dir, ".tot-cache-complete"), "utf8");
|
|
1008
1164
|
try {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1165
|
+
const m = JSON.parse(raw);
|
|
1166
|
+
if (m && typeof m === "object") return { integrity: null, ...m };
|
|
1167
|
+
} catch {
|
|
1168
|
+
/* legacy timestamp-string marker */
|
|
1012
1169
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
//
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1170
|
+
return { integrity: null };
|
|
1171
|
+
} catch {
|
|
1172
|
+
return null; // no marker → never treat the entry as installed
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Stamp a cache entry complete: version + source content identity + when. */
|
|
1177
|
+
function writeCacheMarker(dir, { version, integrity }) {
|
|
1178
|
+
writeFileSync(
|
|
1179
|
+
join(dir, ".tot-cache-complete"),
|
|
1180
|
+
JSON.stringify({ version, integrity: integrity || null, completedAt: new Date().toISOString() }) + "\n",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** sha512 SRI (`sha512-<base64>`, npm's `dist.integrity` format) of a file; null when unreadable. */
|
|
1185
|
+
function fileIntegrity(path) {
|
|
1186
|
+
try {
|
|
1187
|
+
return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`;
|
|
1188
|
+
} catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Do the tarball's bytes match `expected` — an SRI string (`sha512-<b64>`, npm's
|
|
1195
|
+
* `dist.integrity`) or npm's legacy `dist.shasum` (bare 40-hex sha1)? Unknown
|
|
1196
|
+
* formats and probe errors return true: this check exists to catch corrupted
|
|
1197
|
+
* bytes, never to block an install on a format we can't verify.
|
|
1198
|
+
*/
|
|
1199
|
+
export function tarballMatchesIntegrity(archivePath, expected) {
|
|
1200
|
+
try {
|
|
1201
|
+
const want = String(expected).trim();
|
|
1202
|
+
const sri = /^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/=]+)$/.exec(want);
|
|
1203
|
+
if (sri) {
|
|
1204
|
+
return createHash(sri[1]).update(readFileSync(archivePath)).digest("base64") === sri[2];
|
|
1032
1205
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1206
|
+
if (/^[0-9a-f]{40}$/i.test(want)) {
|
|
1207
|
+
return createHash("sha1").update(readFileSync(archivePath)).digest("hex") === want.toLowerCase();
|
|
1208
|
+
}
|
|
1209
|
+
return true;
|
|
1210
|
+
} catch {
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Reap `<entry>.staging-<pid>` dirs left by DEAD processes — failed/killed
|
|
1217
|
+
* installs used to accumulate one orphaned staging tree per attempt, leaking
|
|
1218
|
+
* disk forever. A staging dir whose pid is still alive belongs to a concurrent
|
|
1219
|
+
* `tot dev` mid-install and is left alone. Best-effort: never throws, and never
|
|
1220
|
+
* touches this process's own staging dir (created fresh after this sweep).
|
|
1221
|
+
*/
|
|
1222
|
+
export function sweepStaleStagingDirs(cacheRoot, { pidAlive = processAlive } = {}) {
|
|
1223
|
+
try {
|
|
1224
|
+
if (!cacheRoot || !existsSync(cacheRoot)) return;
|
|
1225
|
+
for (const name of readdirSync(cacheRoot)) {
|
|
1226
|
+
const m = /\.staging-(\d+)$/.exec(name);
|
|
1227
|
+
if (!m) continue;
|
|
1228
|
+
const pid = Number(m[1]);
|
|
1229
|
+
if (pid === process.pid || pidAlive(pid)) continue;
|
|
1230
|
+
rmSync(join(cacheRoot, name), { recursive: true, force: true });
|
|
1231
|
+
}
|
|
1232
|
+
} catch {
|
|
1233
|
+
/* best-effort cache hygiene */
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
|
|
1238
|
+
function processAlive(pid) {
|
|
1239
|
+
try {
|
|
1240
|
+
process.kill(pid, 0);
|
|
1241
|
+
return true;
|
|
1242
|
+
} catch (e) {
|
|
1243
|
+
return e?.code === "EPERM";
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* The registry-declared content identity (`dist.integrity`, else `dist.shasum`)
|
|
1249
|
+
* of the public runner at `version` — or null when npm can't answer QUICKLY
|
|
1250
|
+
* (offline, slow, 4xx/5xx, malformed). Used by ensureSampleRenderer's cached
|
|
1251
|
+
* fast path to detect a same-version republish without ever making the network
|
|
1252
|
+
* a hard dependency: null means "can't verify right now — trust the cache",
|
|
1253
|
+
* preserving the offline-reuse behavior exactly.
|
|
1254
|
+
*/
|
|
1255
|
+
export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000, fetchFn = fetch } = {}) {
|
|
1256
|
+
try {
|
|
1257
|
+
const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
|
|
1258
|
+
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
1259
|
+
const res = await fetchFn(`${registry}/${pkg.replace("/", "%2f")}`, {
|
|
1260
|
+
headers: { accept: "application/json" },
|
|
1261
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1262
|
+
});
|
|
1263
|
+
if (!res.ok) return null;
|
|
1264
|
+
const dist = (await res.json())?.versions?.[version]?.dist;
|
|
1265
|
+
return dist?.integrity || dist?.shasum || null;
|
|
1266
|
+
} catch {
|
|
1267
|
+
return null;
|
|
1036
1268
|
}
|
|
1037
|
-
return runnerDir;
|
|
1038
1269
|
}
|
|
1039
1270
|
|
|
1040
1271
|
/** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
|
|
@@ -1163,8 +1394,8 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1163
1394
|
// If a launcher isn't installed at all (ENOENT) we move on; a launcher that
|
|
1164
1395
|
// RAN but whose install failed is the real error and stops the loop.
|
|
1165
1396
|
// REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
|
|
1166
|
-
// `workspace:*` deps npm rejects (harmless here:
|
|
1167
|
-
//
|
|
1397
|
+
// `workspace:*` deps npm rejects (harmless here: resolution stays on this
|
|
1398
|
+
// CLI's compatible release line, whose runner artifacts are installable).
|
|
1168
1399
|
const attempts = [
|
|
1169
1400
|
{ cmd: "pnpm", args: installArgs },
|
|
1170
1401
|
{ cmd: "corepack", args: ["pnpm", ...installArgs] },
|
|
@@ -1190,22 +1421,33 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1190
1421
|
continue;
|
|
1191
1422
|
}
|
|
1192
1423
|
// The launcher ran; the install itself failed. That's the actionable error.
|
|
1424
|
+
// installRunnerTarball retries transient failures once from a clean slate;
|
|
1425
|
+
// deterministic broken-release 404s are marked permanent below and skip it.
|
|
1193
1426
|
await emitObstacle("install-failed");
|
|
1194
|
-
|
|
1427
|
+
const brokenRelease = missingPackage404(logPath);
|
|
1428
|
+
const err = new CliError(
|
|
1195
1429
|
`couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
|
|
1196
1430
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1197
|
-
{ next: "check the details log above, then re-run `tot start` (it
|
|
1431
|
+
{ next: "check the details log above, then re-run `tot start` (it retries from a clean slate — no cache to clear)" },
|
|
1198
1432
|
);
|
|
1433
|
+
// A package 404 is deterministic metadata baked into this immutable runner
|
|
1434
|
+
// tarball. Retrying the same bytes wastes another full install; return at once
|
|
1435
|
+
// so the caller can transparently fall back to the compatible public patch.
|
|
1436
|
+
if (brokenRelease) err.permanent = true;
|
|
1437
|
+
throw err;
|
|
1199
1438
|
}
|
|
1200
1439
|
// Every launcher ENOENT'd → there's no pnpm on this machine and corepack
|
|
1201
1440
|
// couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
|
|
1202
1441
|
// every Node, so `npm i -g pnpm` is the escape hatch that always exists.
|
|
1442
|
+
// Permanent: retrying can't conjure a launcher — skip the clean-slate retry.
|
|
1203
1443
|
await emitObstacle("pnpm-missing");
|
|
1204
|
-
|
|
1444
|
+
const err = new CliError(
|
|
1205
1445
|
"couldn't set up the store preview engine — pnpm isn't available on this machine" +
|
|
1206
1446
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1207
1447
|
{ next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
|
|
1208
1448
|
);
|
|
1449
|
+
err.permanent = true;
|
|
1450
|
+
throw err;
|
|
1209
1451
|
} finally {
|
|
1210
1452
|
if (fd !== null) closeSync(fd);
|
|
1211
1453
|
}
|
|
@@ -1219,6 +1461,19 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1219
1461
|
function pnpmFailureHint(logPath) {
|
|
1220
1462
|
if (!logPath) return " — is pnpm/corepack available on this host?";
|
|
1221
1463
|
try {
|
|
1464
|
+
// A 404 means the registry answered — a specific package/version doesn't
|
|
1465
|
+
// exist there. Since installs now run from a clean slate every attempt
|
|
1466
|
+
// (promote-on-success + auto-retry), this is a BROKEN RUNNER RELEASE (it
|
|
1467
|
+
// references an unpublished package), not the user's cache — no `rm -rf`
|
|
1468
|
+
// will help. Check this BEFORE the generic ERR_PNPM_FETCH match, since
|
|
1469
|
+
// pnpm's 404 error text also contains "ERR_PNPM_FETCH".
|
|
1470
|
+
const missing404 = missingPackage404(logPath);
|
|
1471
|
+
if (missing404) {
|
|
1472
|
+
return (
|
|
1473
|
+
` — the preview engine references a package that isn't published (${missing404});` +
|
|
1474
|
+
" that's a broken preview-engine release, not your machine"
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1222
1477
|
const tail = readFileSync(logPath, "utf8").slice(-8000);
|
|
1223
1478
|
if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
|
|
1224
1479
|
return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
|
|
@@ -1229,6 +1484,17 @@ function pnpmFailureHint(logPath) {
|
|
|
1229
1484
|
return "";
|
|
1230
1485
|
}
|
|
1231
1486
|
|
|
1487
|
+
/** Return the missing package URL from a pnpm 404, else null. */
|
|
1488
|
+
function missingPackage404(logPath) {
|
|
1489
|
+
if (!logPath) return null;
|
|
1490
|
+
try {
|
|
1491
|
+
const tail = readFileSync(logPath, "utf8").slice(-8000);
|
|
1492
|
+
return tail.match(/ERR_PNPM_FETCH_404[^\n]*GET\s+(\S+)/i)?.[1] || null;
|
|
1493
|
+
} catch {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1232
1498
|
/**
|
|
1233
1499
|
* Run the cached runner's own scripts/tot-dev.mjs in standalone (--workspace)
|
|
1234
1500
|
* mode — the exact code path the WS3 spike verified gives native, non-polled
|