@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20
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/README.md +12 -9
- package/bin/tot.mjs +191 -14
- package/package.json +2 -2
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +313 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +682 -0
- package/src/commands/dev.mjs +414 -84
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/grants.mjs +8 -3
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +9 -4
- package/src/commands/pr.mjs +424 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +35 -21
- package/src/commands/submit.mjs +1129 -129
- package/src/commands/sync.mjs +192 -0
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +14 -3
- package/src/obstacle-beacon.cjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/commands/checkout.mjs +0 -330
package/src/commands/dev.mjs
CHANGED
|
@@ -138,7 +138,7 @@ export async function run(argv, ctx) {
|
|
|
138
138
|
console.error(
|
|
139
139
|
fail(
|
|
140
140
|
"nothing to run — you're not inside a tenant checkout",
|
|
141
|
-
"tot
|
|
141
|
+
"tot clone <tenant> (then `cd` in and re-run), pass --workspace <dir>, or try `tot dev --sample`",
|
|
142
142
|
),
|
|
143
143
|
);
|
|
144
144
|
return 2;
|
|
@@ -194,7 +194,7 @@ async function runNativePublic(workspace, args, ctx) {
|
|
|
194
194
|
const cfg = readWorkspaceConfig(workspace);
|
|
195
195
|
if (!cfg) {
|
|
196
196
|
throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
|
|
197
|
-
next: "tot
|
|
197
|
+
next: "tot clone <tenant> (produces a runnable checkout)",
|
|
198
198
|
exitCode: 2,
|
|
199
199
|
});
|
|
200
200
|
}
|
|
@@ -280,7 +280,7 @@ async function runNative(workspace, args, ctx) {
|
|
|
280
280
|
const cfg = readWorkspaceConfig(workspace);
|
|
281
281
|
if (!cfg) {
|
|
282
282
|
throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
|
|
283
|
-
next: "tot
|
|
283
|
+
next: "tot clone <tenant> (produces a runnable checkout)",
|
|
284
284
|
exitCode: 2,
|
|
285
285
|
});
|
|
286
286
|
}
|
|
@@ -615,9 +615,19 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
|
|
|
615
615
|
`(or pin with --renderer-version to silence).`,
|
|
616
616
|
);
|
|
617
617
|
}
|
|
618
|
-
const
|
|
619
|
-
if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
620
|
-
|
|
618
|
+
const dist = meta?.versions?.[version]?.dist;
|
|
619
|
+
if (!dist?.tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
620
|
+
// `integrity` is the artifact's CONTENT identity (npm dist.integrity, else the
|
|
621
|
+
// legacy shasum) — installRunnerTarball verifies the downloaded bytes against
|
|
622
|
+
// it and records it so a same-version corrected republish busts the cache.
|
|
623
|
+
return {
|
|
624
|
+
kind: "public",
|
|
625
|
+
version,
|
|
626
|
+
url: dist.tarball,
|
|
627
|
+
strip: 1,
|
|
628
|
+
cacheKey: `public-${version}`,
|
|
629
|
+
integrity: dist.integrity || dist.shasum || null,
|
|
630
|
+
};
|
|
621
631
|
}
|
|
622
632
|
|
|
623
633
|
/**
|
|
@@ -637,7 +647,17 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
|
|
|
637
647
|
if (!res?.url || !res?.version) {
|
|
638
648
|
throw new Error(res?.error || "no renderer-artifact URL returned");
|
|
639
649
|
}
|
|
640
|
-
|
|
650
|
+
// Opportunistic content identity: recorded/verified when the MCP declares one
|
|
651
|
+
// (integrity/sha256); a server that doesn't is simply unverified (null), never
|
|
652
|
+
// an error — the cache then busts on version changes only, as before.
|
|
653
|
+
return {
|
|
654
|
+
kind: "entitled",
|
|
655
|
+
version: res.version,
|
|
656
|
+
url: res.url,
|
|
657
|
+
strip: 0,
|
|
658
|
+
cacheKey: res.version,
|
|
659
|
+
integrity: res.integrity || res.sha256 || null,
|
|
660
|
+
};
|
|
641
661
|
}
|
|
642
662
|
|
|
643
663
|
/**
|
|
@@ -676,7 +696,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
|
|
|
676
696
|
|
|
677
697
|
try {
|
|
678
698
|
const runnerDir = await installRunnerTarball(
|
|
679
|
-
{
|
|
699
|
+
{
|
|
700
|
+
source: credential.url,
|
|
701
|
+
version: credential.version,
|
|
702
|
+
isUrl: true,
|
|
703
|
+
integrity: credential.integrity || credential.sha256 || null,
|
|
704
|
+
},
|
|
680
705
|
{ log: (m) => console.error(m) },
|
|
681
706
|
);
|
|
682
707
|
setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
|
|
@@ -700,6 +725,66 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
|
|
|
700
725
|
return existsSync(join(dir, ".tot-cache-complete")) ? dir : null;
|
|
701
726
|
}
|
|
702
727
|
|
|
728
|
+
/**
|
|
729
|
+
* List the installed package dirs that could carry a platform-native binary,
|
|
730
|
+
* normalised so a scoped package reads as `@scope+name` (mirroring pnpm's virtual
|
|
731
|
+
* store naming) regardless of the on-disk layout. Prefers the pnpm virtual store
|
|
732
|
+
* (`node_modules/.pnpm/*`); falls back to a shallow `node_modules` scan (one level
|
|
733
|
+
* into `@scope/`) for an npm/flat install. Best-effort — returns [] on any error.
|
|
734
|
+
*/
|
|
735
|
+
function collectNativePackageDirs(runnerDir) {
|
|
736
|
+
const pnpmDir = join(runnerDir, "node_modules", ".pnpm");
|
|
737
|
+
if (existsSync(pnpmDir)) return readdirSync(pnpmDir);
|
|
738
|
+
const nm = join(runnerDir, "node_modules");
|
|
739
|
+
if (!existsSync(nm)) return [];
|
|
740
|
+
const names = [];
|
|
741
|
+
for (const e of readdirSync(nm, { withFileTypes: true })) {
|
|
742
|
+
if (e.name.startsWith("@") && e.isDirectory()) {
|
|
743
|
+
for (const s of readdirSync(join(nm, e.name))) names.push(`${e.name}+${s}`);
|
|
744
|
+
} else {
|
|
745
|
+
names.push(e.name);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return names;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* True unless the installed renderer is POISONED for this host's CPU arch — the
|
|
753
|
+
* "the dev server didn't come up" root cause. Platform-native binding packages
|
|
754
|
+
* are named `<family>-<os>-<cpu>[-<abi>]` (e.g. `@esbuild/darwin-arm64`,
|
|
755
|
+
* `lightningcss-linux-x64-gnu`, `@rollup/rollup-win32-x64-msvc`). npm's optional-
|
|
756
|
+
* deps bug (npm/cli#4828) can materialise a DIFFERENT arch's variant than the host
|
|
757
|
+
* needs (e.g. darwin-x64 on an arm64 Mac) — pnpm gets it right. We flag the tree
|
|
758
|
+
* as poisoned exactly when a family ships a binding for our OS but NOT our OS+CPU,
|
|
759
|
+
* which is the precise shape of the `Cannot find native binding` boot crash. A
|
|
760
|
+
* family with no variant for our OS at all is a cross-platform optional dep that's
|
|
761
|
+
* correctly absent, so it never trips the check. Never throws; on any probe error
|
|
762
|
+
* it returns true (trust the cache) so a health probe can't itself break `tot dev`.
|
|
763
|
+
*
|
|
764
|
+
* `platform`/`arch` are injectable so this is testable off the host's real arch.
|
|
765
|
+
*/
|
|
766
|
+
export function rendererCacheHealthy(runnerDir, { platform = process.platform, arch = process.arch } = {}) {
|
|
767
|
+
try {
|
|
768
|
+
const re = /^(.*?)[-+](darwin|linux|win32|freebsd|android|openharmony)-([a-z0-9]+)/;
|
|
769
|
+
const families = new Map(); // family -> Set("<os>-<cpu>")
|
|
770
|
+
for (const name of collectNativePackageDirs(runnerDir)) {
|
|
771
|
+
const m = name.match(re);
|
|
772
|
+
if (!m) continue;
|
|
773
|
+
const [, family, os, cpu] = m;
|
|
774
|
+
if (!families.has(family)) families.set(family, new Set());
|
|
775
|
+
families.get(family).add(`${os}-${cpu}`);
|
|
776
|
+
}
|
|
777
|
+
const want = `${platform}-${arch}`;
|
|
778
|
+
for (const variants of families.values()) {
|
|
779
|
+
const hasOurOs = [...variants].some((v) => v.startsWith(`${platform}-`));
|
|
780
|
+
if (hasOurOs && !variants.has(want)) return false; // wrong-arch binary present, ours missing
|
|
781
|
+
}
|
|
782
|
+
return true;
|
|
783
|
+
} catch {
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
703
788
|
/**
|
|
704
789
|
* Delete every OTHER public-runner cache dir (`public-*` except `keepVersion`)
|
|
705
790
|
* once we've resolved the version this CLI pins to — so a pre-alignment dir
|
|
@@ -791,7 +876,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
791
876
|
console.error(`~ renderer: ${src.why}`);
|
|
792
877
|
return installRunnerTarball(
|
|
793
878
|
{ source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
|
|
794
|
-
{ log: (m) => console.error(m) },
|
|
879
|
+
{ log: (m) => console.error(m), cacheRoot },
|
|
795
880
|
);
|
|
796
881
|
}
|
|
797
882
|
|
|
@@ -802,29 +887,51 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
802
887
|
const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
|
|
803
888
|
const wantVersion = declared || CLI_VERSION;
|
|
804
889
|
|
|
805
|
-
//
|
|
806
|
-
// already cached, reuse it
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
890
|
+
// Offline-safe fast path: when the WANTED version (declared, else lockstep) is
|
|
891
|
+
// already cached, reuse it — honouring "don't hit npm when the RIGHT version is
|
|
892
|
+
// already cached" without ever reusing a version the resolution wouldn't choose.
|
|
893
|
+
// Safe because `public-<version>` only exists if a prior run fetched exactly
|
|
894
|
+
// that version. Skipped when an explicit pin is set (that must go through
|
|
895
|
+
// resolution). One refinement over fully-offline: a QUICK, soft-fail registry
|
|
896
|
+
// probe (publishedRunnerIntegrity) revalidates the cached CONTENT identity when
|
|
897
|
+
// npm is reachable, so a corrected republish under the same version string is
|
|
898
|
+
// picked up automatically; offline/slow/unanswerable → trust the cache exactly
|
|
899
|
+
// as before (the probe can never block or fail the run).
|
|
811
900
|
if (!explicitPin) {
|
|
812
901
|
const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
|
|
813
902
|
if (exact) {
|
|
814
903
|
// Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
|
|
815
904
|
// but if it can't report its own version it predates the --version surface
|
|
816
905
|
// (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
|
|
817
|
-
// runner we can't identify.
|
|
818
|
-
|
|
906
|
+
// runner we can't identify. It must ALSO carry native bindings for THIS
|
|
907
|
+
// host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
|
|
908
|
+
// bindings (npm/cli#4828) would otherwise be reused forever and crash astro
|
|
909
|
+
// at boot with the swallowed "dev server didn't come up". When both hold,
|
|
910
|
+
// reuse it (offline-safe).
|
|
911
|
+
if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
|
|
912
|
+
const published = await publishedRunnerIntegrity(env, wantVersion);
|
|
913
|
+
const recorded = readCacheMarker(exact)?.integrity || null;
|
|
914
|
+
if (!published || !recorded || published === recorded) {
|
|
915
|
+
console.error(
|
|
916
|
+
`~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
|
|
917
|
+
);
|
|
918
|
+
setRunnerVersion(wantVersion);
|
|
919
|
+
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
920
|
+
return exact;
|
|
921
|
+
}
|
|
922
|
+
// Same version string, different published contents — a corrected
|
|
923
|
+
// republish. Fall through to resolution + a fresh install (which also
|
|
924
|
+
// verifies the new bytes against the new integrity).
|
|
925
|
+
console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
|
|
926
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
|
|
927
|
+
} else {
|
|
819
928
|
console.error(
|
|
820
|
-
|
|
929
|
+
rendererCacheHealthy(exact)
|
|
930
|
+
? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
|
|
931
|
+
: `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
|
|
821
932
|
);
|
|
822
|
-
|
|
823
|
-
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
824
|
-
return exact;
|
|
933
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
825
934
|
}
|
|
826
|
-
console.error(`~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`);
|
|
827
|
-
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
828
935
|
}
|
|
829
936
|
}
|
|
830
937
|
|
|
@@ -855,8 +962,8 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
855
962
|
// The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
|
|
856
963
|
// out of the user's way; the setup spinner below is the visible progress.
|
|
857
964
|
const dir = await installRunnerTarball(
|
|
858
|
-
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
|
|
859
|
-
{ log: (m) => console.error(m) },
|
|
965
|
+
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip, integrity: pub.integrity },
|
|
966
|
+
{ log: (m) => console.error(m), cacheRoot },
|
|
860
967
|
);
|
|
861
968
|
setRunnerVersion(pub.version); // telemetry: the runner version running this session
|
|
862
969
|
// The pinned version is now installed under public-<version> — drop any other
|
|
@@ -881,69 +988,269 @@ function sourceVersionKey(source) {
|
|
|
881
988
|
* authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
|
|
882
989
|
* paths so they cache identically.
|
|
883
990
|
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
991
|
+
* Cache-poisoning invariants (the 2026-08-18 first-run hardening):
|
|
992
|
+
* • promote-on-success only — the install runs in a per-attempt staging dir and
|
|
993
|
+
* is renamed into the canonical slot ONLY after it fully succeeds, so a failed
|
|
994
|
+
* install can never become the cached artifact a later run resumes from.
|
|
995
|
+
* • the completion marker is a manifest carrying the source's CONTENT identity
|
|
996
|
+
* (`integrity`), so a corrected republish under the SAME version string is a
|
|
997
|
+
* cache miss (rebuild), not a stale hit — no manual `rm -rf` ever required.
|
|
998
|
+
* • a failed attempt auto-cleans and retries ONCE from a clean slate before
|
|
999
|
+
* surfacing the error (transient blips heal themselves); deterministic
|
|
1000
|
+
* failures (`e.permanent`) skip the retry and fail loud immediately.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {{ source: string, version: string, isUrl?: boolean, strip?: number, integrity?: string|null }} spec
|
|
1003
|
+
* `integrity` is the source artifact's content identity when the resolver knows
|
|
1004
|
+
* it (npm `dist.integrity`/`dist.shasum`); used to verify the downloaded bytes
|
|
1005
|
+
* and to bust a cached entry whose recorded identity no longer matches.
|
|
1006
|
+
* @param {{ log?: (m: string) => void, cacheRoot?: string }} [opts]
|
|
886
1007
|
* @returns {Promise<string>} the cached, installed runner tree's root directory.
|
|
887
1008
|
*/
|
|
888
|
-
export async function installRunnerTarball(
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1009
|
+
export async function installRunnerTarball(
|
|
1010
|
+
{ source, version, isUrl = true, strip = 0, integrity = null },
|
|
1011
|
+
{ log = (m) => console.error(m), cacheRoot = RENDERER_CACHE_ROOT } = {},
|
|
1012
|
+
) {
|
|
1013
|
+
const runnerDir = join(cacheRoot, version);
|
|
1014
|
+
// Staging dirs from DEAD runs (crashed/killed installs) must not leak disk
|
|
1015
|
+
// forever — reap them here, the one funnel every install path goes through.
|
|
1016
|
+
sweepStaleStagingDirs(cacheRoot);
|
|
1017
|
+
|
|
1018
|
+
const localSource = isUrl ? null : resolveLocalTarball(source);
|
|
1019
|
+
// The EXPECTED content identity of the source artifact. A local tarball with no
|
|
1020
|
+
// caller-provided integrity is cheap to hash on every run, so a same-path
|
|
1021
|
+
// republish (new contents, same file name) busts the cache too.
|
|
1022
|
+
const expected = integrity || (isUrl ? null : fileIntegrity(localSource));
|
|
1023
|
+
|
|
1024
|
+
const cached = readCacheMarker(runnerDir);
|
|
1025
|
+
if (cached) {
|
|
1026
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1027
|
+
// A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g.
|
|
1028
|
+
// darwin-x64 on an arm64 Mac) is otherwise trusted forever, and astro
|
|
1029
|
+
// crashes at boot with `Cannot find native binding`, swallowed as "the dev
|
|
1030
|
+
// server didn't come up". Fall through and rebuild from the source below.
|
|
1031
|
+
log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
|
|
1032
|
+
} else if (expected && cached.integrity && expected !== cached.integrity) {
|
|
1033
|
+
// CONTENT-HASH BUST: same version string, different artifact contents — a
|
|
1034
|
+
// corrected republish. The cached entry is stale by identity, not by label;
|
|
1035
|
+
// rebuild from the corrected source instead of serving the stale cache.
|
|
1036
|
+
log(`~ the preview engine's ${version} artifact changed upstream (same version, new contents) — rebuilding…`);
|
|
1037
|
+
} else {
|
|
1038
|
+
return runnerDir; // already downloaded + installed (and contents still match)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
892
1041
|
|
|
893
1042
|
// First run only — set the expectation so the one-time cost doesn't read as a
|
|
894
1043
|
// hang: this downloads + installs the renderer once, then every later run of
|
|
895
1044
|
// this version is a no-network cache hit.
|
|
896
1045
|
log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1046
|
+
// AUTO-CLEAN-AND-RETRY-ONCE: a transient failure (network blip mid-download, a
|
|
1047
|
+
// registry hiccup mid-install) heals itself with one clean re-attempt instead
|
|
1048
|
+
// of stopping a first run at an error only `rm -rf` folklore could clear.
|
|
1049
|
+
// Bounded to one retry so a genuinely-broken source still fails loudly.
|
|
1050
|
+
for (let attempt = 1; ; attempt++) {
|
|
1051
|
+
const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource;
|
|
1052
|
+
const stagingDir = `${runnerDir}.staging-${process.pid}`;
|
|
1053
|
+
try {
|
|
1054
|
+
if (isUrl) {
|
|
1055
|
+
// The fetch itself is otherwise silent (no per-byte output) and can run
|
|
1056
|
+
// tens of seconds on a cold cache — tick a spinner so it never looks hung.
|
|
1057
|
+
const spin = startProgress("downloading the store preview engine…");
|
|
1058
|
+
try {
|
|
1059
|
+
await downloadFile(source, archivePath);
|
|
1060
|
+
} finally {
|
|
1061
|
+
spin.stop();
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (!existsSync(archivePath)) {
|
|
1065
|
+
throw new Error(`renderer tarball not found: ${archivePath}`);
|
|
1066
|
+
}
|
|
1067
|
+
// Refuse to install bytes that don't match the source's declared identity —
|
|
1068
|
+
// a truncated/corrupted download would otherwise be cached as if complete.
|
|
1069
|
+
// (Transient by nature, so the retry above gets a fresh download.)
|
|
1070
|
+
if (expected && !tarballMatchesIntegrity(archivePath, expected)) {
|
|
1071
|
+
throw new Error(`the downloaded preview-engine tarball failed its integrity check (expected ${expected})`);
|
|
1072
|
+
}
|
|
1073
|
+
// The identity recorded in the completion manifest below — what future runs
|
|
1074
|
+
// compare against to detect a same-version republish. Hash the actual bytes
|
|
1075
|
+
// when the resolver couldn't tell us (e.g. the entitled signed-URL path).
|
|
1076
|
+
const contentId = expected || fileIntegrity(archivePath);
|
|
1077
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
1078
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
1079
|
+
extractTarball(archivePath, stagingDir, { strip });
|
|
1080
|
+
// Pin the runner install to PUBLIC npm. The moat-free runner has only public
|
|
1081
|
+
// deps, but the HOST's global ~/.npmrc may point `registry` at a private
|
|
1082
|
+
// mirror (an internal proxy that 502s, or one an invited developer can't
|
|
1083
|
+
// reach) — an invited dev's machine config must never decide where the
|
|
1084
|
+
// runner's public deps come from. A project-level .npmrc wins over the user's.
|
|
1085
|
+
writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
|
|
1086
|
+
// The install is the long, noisy step — tick a spinner while its output goes
|
|
1087
|
+
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
1088
|
+
// corepack setup logs to the SAME file so its failures aren't invisible (they
|
|
1089
|
+
// were the silent cause of "couldn't set up the store preview engine").
|
|
1090
|
+
const installLog = join(cacheRoot, `${version}.install.log`);
|
|
1091
|
+
ensureCorepackPnpm(stagingDir, { logPath: installLog });
|
|
1092
|
+
const installSpin = startProgress("installing the store preview engine…", {
|
|
1093
|
+
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
1094
|
+
});
|
|
904
1095
|
try {
|
|
905
|
-
await
|
|
1096
|
+
await runPnpmInstall(stagingDir, { logPath: installLog });
|
|
906
1097
|
} finally {
|
|
907
|
-
|
|
1098
|
+
installSpin.stop();
|
|
908
1099
|
}
|
|
1100
|
+
// Atomic-ish: only rename into the final, discoverable path once install
|
|
1101
|
+
// succeeded, so a crashed/interrupted run never leaves a half-built cache
|
|
1102
|
+
// entry that a later `tot dev` would treat as ready.
|
|
1103
|
+
rmSync(runnerDir, { recursive: true, force: true });
|
|
1104
|
+
renameSync(stagingDir, runnerDir);
|
|
1105
|
+
// Fence a fresh install against npm/cli#4828: if the installer left the wrong
|
|
1106
|
+
// arch's native bindings (or none) for this host, DON'T stamp the completion
|
|
1107
|
+
// marker — an unmarked tree is never reused, so the next run reinstalls cleanly
|
|
1108
|
+
// instead of caching the poison and crashing astro at boot. Fail loud + actionable
|
|
1109
|
+
// rather than swallow it as "the dev server didn't come up". Permanent: the
|
|
1110
|
+
// same installer on the same host would just produce the same result, so the
|
|
1111
|
+
// auto-retry is skipped.
|
|
1112
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1113
|
+
await emitObstacle("renderer-native-bindings-missing");
|
|
1114
|
+
const err = new CliError(
|
|
1115
|
+
`the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
|
|
1116
|
+
{
|
|
1117
|
+
next: "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)",
|
|
1118
|
+
exitCode: 2,
|
|
1119
|
+
},
|
|
1120
|
+
);
|
|
1121
|
+
err.permanent = true;
|
|
1122
|
+
throw err;
|
|
1123
|
+
}
|
|
1124
|
+
writeCacheMarker(runnerDir, { version, integrity: contentId });
|
|
1125
|
+
return runnerDir;
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
// A failed attempt must never survive on disk — not as staging debris, and
|
|
1128
|
+
// (by promote-on-success) it never reached the canonical slot at all.
|
|
1129
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
1130
|
+
if (e?.permanent === true || attempt >= 2) throw e;
|
|
1131
|
+
log(`~ that didn't work (${String(e?.message || e).split("\n")[0]}) — retrying once from a clean slate…`);
|
|
1132
|
+
} finally {
|
|
1133
|
+
if (isUrl) rmSync(archivePath, { force: true });
|
|
909
1134
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
925
|
-
// corepack setup logs to the SAME file so its failures aren't invisible (they
|
|
926
|
-
// were the silent cause of "couldn't set up the store preview engine").
|
|
927
|
-
const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
|
|
928
|
-
ensureCorepackPnpm(stagingDir, { logPath: installLog });
|
|
929
|
-
const installSpin = startProgress("installing the store preview engine…", {
|
|
930
|
-
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
931
|
-
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Read a cache entry's completion marker (`.tot-cache-complete`). Returns the
|
|
1140
|
+
* manifest object (at least `{ integrity: string|null }`), or null when the
|
|
1141
|
+
* marker is absent — i.e. the entry is incomplete/partial and must be treated
|
|
1142
|
+
* as if it didn't exist. A legacy pre-manifest marker (a bare timestamp string)
|
|
1143
|
+
* reads as complete-with-unknown-identity, so existing healthy caches survive
|
|
1144
|
+
* the upgrade without a forced rebuild.
|
|
1145
|
+
*/
|
|
1146
|
+
export function readCacheMarker(dir) {
|
|
1147
|
+
try {
|
|
1148
|
+
const raw = readFileSync(join(dir, ".tot-cache-complete"), "utf8");
|
|
932
1149
|
try {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1150
|
+
const m = JSON.parse(raw);
|
|
1151
|
+
if (m && typeof m === "object") return { integrity: null, ...m };
|
|
1152
|
+
} catch {
|
|
1153
|
+
/* legacy timestamp-string marker */
|
|
936
1154
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1155
|
+
return { integrity: null };
|
|
1156
|
+
} catch {
|
|
1157
|
+
return null; // no marker → never treat the entry as installed
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Stamp a cache entry complete: version + source content identity + when. */
|
|
1162
|
+
function writeCacheMarker(dir, { version, integrity }) {
|
|
1163
|
+
writeFileSync(
|
|
1164
|
+
join(dir, ".tot-cache-complete"),
|
|
1165
|
+
JSON.stringify({ version, integrity: integrity || null, completedAt: new Date().toISOString() }) + "\n",
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** sha512 SRI (`sha512-<base64>`, npm's `dist.integrity` format) of a file; null when unreadable. */
|
|
1170
|
+
function fileIntegrity(path) {
|
|
1171
|
+
try {
|
|
1172
|
+
return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`;
|
|
1173
|
+
} catch {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Do the tarball's bytes match `expected` — an SRI string (`sha512-<b64>`, npm's
|
|
1180
|
+
* `dist.integrity`) or npm's legacy `dist.shasum` (bare 40-hex sha1)? Unknown
|
|
1181
|
+
* formats and probe errors return true: this check exists to catch corrupted
|
|
1182
|
+
* bytes, never to block an install on a format we can't verify.
|
|
1183
|
+
*/
|
|
1184
|
+
export function tarballMatchesIntegrity(archivePath, expected) {
|
|
1185
|
+
try {
|
|
1186
|
+
const want = String(expected).trim();
|
|
1187
|
+
const sri = /^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/=]+)$/.exec(want);
|
|
1188
|
+
if (sri) {
|
|
1189
|
+
return createHash(sri[1]).update(readFileSync(archivePath)).digest("base64") === sri[2];
|
|
1190
|
+
}
|
|
1191
|
+
if (/^[0-9a-f]{40}$/i.test(want)) {
|
|
1192
|
+
return createHash("sha1").update(readFileSync(archivePath)).digest("hex") === want.toLowerCase();
|
|
1193
|
+
}
|
|
1194
|
+
return true;
|
|
1195
|
+
} catch {
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Reap `<entry>.staging-<pid>` dirs left by DEAD processes — failed/killed
|
|
1202
|
+
* installs used to accumulate one orphaned staging tree per attempt, leaking
|
|
1203
|
+
* disk forever. A staging dir whose pid is still alive belongs to a concurrent
|
|
1204
|
+
* `tot dev` mid-install and is left alone. Best-effort: never throws, and never
|
|
1205
|
+
* touches this process's own staging dir (created fresh after this sweep).
|
|
1206
|
+
*/
|
|
1207
|
+
export function sweepStaleStagingDirs(cacheRoot, { pidAlive = processAlive } = {}) {
|
|
1208
|
+
try {
|
|
1209
|
+
if (!cacheRoot || !existsSync(cacheRoot)) return;
|
|
1210
|
+
for (const name of readdirSync(cacheRoot)) {
|
|
1211
|
+
const m = /\.staging-(\d+)$/.exec(name);
|
|
1212
|
+
if (!m) continue;
|
|
1213
|
+
const pid = Number(m[1]);
|
|
1214
|
+
if (pid === process.pid || pidAlive(pid)) continue;
|
|
1215
|
+
rmSync(join(cacheRoot, name), { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
} catch {
|
|
1218
|
+
/* best-effort cache hygiene */
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
|
|
1223
|
+
function processAlive(pid) {
|
|
1224
|
+
try {
|
|
1225
|
+
process.kill(pid, 0);
|
|
1226
|
+
return true;
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
return e?.code === "EPERM";
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The registry-declared content identity (`dist.integrity`, else `dist.shasum`)
|
|
1234
|
+
* of the public runner at `version` — or null when npm can't answer QUICKLY
|
|
1235
|
+
* (offline, slow, 4xx/5xx, malformed). Used by ensureSampleRenderer's cached
|
|
1236
|
+
* fast path to detect a same-version republish without ever making the network
|
|
1237
|
+
* a hard dependency: null means "can't verify right now — trust the cache",
|
|
1238
|
+
* preserving the offline-reuse behavior exactly.
|
|
1239
|
+
*/
|
|
1240
|
+
export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000, fetchFn = fetch } = {}) {
|
|
1241
|
+
try {
|
|
1242
|
+
const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
|
|
1243
|
+
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
1244
|
+
const res = await fetchFn(`${registry}/${pkg.replace("/", "%2f")}`, {
|
|
1245
|
+
headers: { accept: "application/json" },
|
|
1246
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1247
|
+
});
|
|
1248
|
+
if (!res.ok) return null;
|
|
1249
|
+
const dist = (await res.json())?.versions?.[version]?.dist;
|
|
1250
|
+
return dist?.integrity || dist?.shasum || null;
|
|
1251
|
+
} catch {
|
|
1252
|
+
return null;
|
|
945
1253
|
}
|
|
946
|
-
return runnerDir;
|
|
947
1254
|
}
|
|
948
1255
|
|
|
949
1256
|
/** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
|
|
@@ -1059,21 +1366,25 @@ function spawnAsyncResult(cmd, args, opts = {}) {
|
|
|
1059
1366
|
export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
|
|
1060
1367
|
const fd = logPath ? openSync(logPath, "a") : null;
|
|
1061
1368
|
const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1369
|
+
// pnpm FIRST: the renderer tree is a bundle of native optional deps (rolldown,
|
|
1370
|
+
// esbuild, sharp, @rollup, lightningcss, @tailwindcss/oxide, @astrojs/compiler,
|
|
1371
|
+
// workerd), and npm's optional-deps bug (npm/cli#4828) routinely installs the
|
|
1372
|
+
// WRONG arch's binary or none — poisoning the cache so astro crashes at boot with
|
|
1373
|
+
// `Cannot find native binding`. pnpm resolves per-platform optional bindings
|
|
1374
|
+
// correctly (proven end-to-end). ensureCorepackPnpm() ran just above, so on the
|
|
1375
|
+
// Node floor (22.12, which bundles corepack) pnpm is available; `corepack pnpm`
|
|
1376
|
+
// is the shim path if a bare `pnpm` isn't on PATH. npm stays LAST as the escape
|
|
1377
|
+
// hatch for hosts with neither pnpm nor corepack — where rendererCacheHealthy()
|
|
1378
|
+
// then fences a poisoned result rather than shipping a broken cache silently.
|
|
1068
1379
|
// If a launcher isn't installed at all (ENOENT) we move on; a launcher that
|
|
1069
1380
|
// RAN but whose install failed is the real error and stops the loop.
|
|
1070
1381
|
// REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
|
|
1071
1382
|
// `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
|
|
1072
1383
|
// runner to this CLI's version, so this CLI never installs those).
|
|
1073
1384
|
const attempts = [
|
|
1074
|
-
{ cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
|
|
1075
1385
|
{ cmd: "pnpm", args: installArgs },
|
|
1076
1386
|
{ cmd: "corepack", args: ["pnpm", ...installArgs] },
|
|
1387
|
+
{ cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
|
|
1077
1388
|
];
|
|
1078
1389
|
try {
|
|
1079
1390
|
for (const { cmd, args } of attempts) {
|
|
@@ -1095,22 +1406,27 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1095
1406
|
continue;
|
|
1096
1407
|
}
|
|
1097
1408
|
// The launcher ran; the install itself failed. That's the actionable error.
|
|
1409
|
+
// (installRunnerTarball auto-retries this ONCE from a clean slate before it
|
|
1410
|
+
// reaches the user — so by the time this surfaces, it failed twice.)
|
|
1098
1411
|
await emitObstacle("install-failed");
|
|
1099
1412
|
throw new CliError(
|
|
1100
1413
|
`couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
|
|
1101
1414
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1102
|
-
{ next: "check the details log above, then re-run `tot start` (it
|
|
1415
|
+
{ next: "check the details log above, then re-run `tot start` (it retries from a clean slate — no cache to clear)" },
|
|
1103
1416
|
);
|
|
1104
1417
|
}
|
|
1105
1418
|
// Every launcher ENOENT'd → there's no pnpm on this machine and corepack
|
|
1106
1419
|
// couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
|
|
1107
1420
|
// every Node, so `npm i -g pnpm` is the escape hatch that always exists.
|
|
1421
|
+
// Permanent: retrying can't conjure a launcher — skip the clean-slate retry.
|
|
1108
1422
|
await emitObstacle("pnpm-missing");
|
|
1109
|
-
|
|
1423
|
+
const err = new CliError(
|
|
1110
1424
|
"couldn't set up the store preview engine — pnpm isn't available on this machine" +
|
|
1111
1425
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1112
1426
|
{ next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
|
|
1113
1427
|
);
|
|
1428
|
+
err.permanent = true;
|
|
1429
|
+
throw err;
|
|
1114
1430
|
} finally {
|
|
1115
1431
|
if (fd !== null) closeSync(fd);
|
|
1116
1432
|
}
|
|
@@ -1125,6 +1441,20 @@ function pnpmFailureHint(logPath) {
|
|
|
1125
1441
|
if (!logPath) return " — is pnpm/corepack available on this host?";
|
|
1126
1442
|
try {
|
|
1127
1443
|
const tail = readFileSync(logPath, "utf8").slice(-8000);
|
|
1444
|
+
// A 404 means the registry answered — a specific package/version doesn't
|
|
1445
|
+
// exist there. Since installs now run from a clean slate every attempt
|
|
1446
|
+
// (promote-on-success + auto-retry), this is a BROKEN RUNNER RELEASE (it
|
|
1447
|
+
// references an unpublished package), not the user's cache — no `rm -rf`
|
|
1448
|
+
// will help. Check this BEFORE the generic ERR_PNPM_FETCH match, since
|
|
1449
|
+
// pnpm's 404 error text also contains "ERR_PNPM_FETCH".
|
|
1450
|
+
const missing404 = tail.match(/ERR_PNPM_FETCH_404[^\n]*GET\s+(\S+)/i);
|
|
1451
|
+
if (missing404) {
|
|
1452
|
+
return (
|
|
1453
|
+
` — the preview engine references a package that isn't published (${missing404[1]});` +
|
|
1454
|
+
" that's a broken preview-engine release, not your machine — try again later or pin a" +
|
|
1455
|
+
" known-good version with --renderer-version"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1128
1458
|
if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
|
|
1129
1459
|
return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
|
|
1130
1460
|
}
|
|
@@ -1240,7 +1570,7 @@ export function buildContainerPlan(workspace, args, _ctx) {
|
|
|
1240
1570
|
const cfg = readWorkspaceConfig(workspace);
|
|
1241
1571
|
if (!cfg) {
|
|
1242
1572
|
throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
|
|
1243
|
-
next: "tot
|
|
1573
|
+
next: "tot clone <tenant> (produces a runnable checkout)",
|
|
1244
1574
|
exitCode: 2,
|
|
1245
1575
|
});
|
|
1246
1576
|
}
|