@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21

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 (47) hide show
  1. package/README.md +12 -9
  2. package/bin/tot.mjs +219 -44
  3. package/package.json +7 -2
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +2 -2
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +137 -0
  8. package/src/commands/accept.mjs +736 -0
  9. package/src/commands/app/dev.mjs +7 -3
  10. package/src/commands/app/index.mjs +2 -2
  11. package/src/commands/branches.mjs +297 -0
  12. package/src/commands/cleanup.mjs +269 -0
  13. package/src/commands/clone.mjs +713 -0
  14. package/src/commands/dev.mjs +441 -93
  15. package/src/commands/doctor.mjs +4 -3
  16. package/src/commands/git-credential.mjs +180 -0
  17. package/src/commands/go-live.mjs +486 -0
  18. package/src/commands/grants.mjs +14 -7
  19. package/src/commands/hotfix.mjs +428 -0
  20. package/src/commands/link.mjs +225 -0
  21. package/src/commands/login.mjs +12 -8
  22. package/src/commands/pr.mjs +425 -0
  23. package/src/commands/preview-build.mjs +225 -0
  24. package/src/commands/preview.mjs +80 -0
  25. package/src/commands/retire.mjs +203 -0
  26. package/src/commands/revert.mjs +322 -0
  27. package/src/commands/rollback.mjs +403 -0
  28. package/src/commands/ship.mjs +517 -0
  29. package/src/commands/start.mjs +91 -29
  30. package/src/commands/submit.mjs +1360 -131
  31. package/src/commands/sync.mjs +203 -0
  32. package/src/commands/validate.mjs +11 -5
  33. package/src/commands/whoami.mjs +6 -2
  34. package/src/context.mjs +2 -2
  35. package/src/dev-heartbeat.mjs +2 -1
  36. package/src/errors.mjs +8 -4
  37. package/src/git-credential.mjs +185 -0
  38. package/src/mcp.mjs +6 -1
  39. package/src/no-gitea-links.test.mjs +55 -0
  40. package/src/oauth.mjs +26 -11
  41. package/src/obstacle-beacon.cjs +3 -3
  42. package/src/obstacle.mjs +1 -1
  43. package/src/plan.mjs +262 -0
  44. package/src/sample.mjs +30 -4
  45. package/src/validate.mjs +56 -0
  46. package/src/viewer-session.mjs +118 -0
  47. package/src/commands/checkout.mjs +0 -330
@@ -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 checkout <tenant> --clone <dir> (then `cd` in and re-run), pass --workspace <dir>, or try `tot dev --sample`",
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 checkout <tenant> --clone <dir> (produces a runnable checkout)",
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 checkout <tenant> --clone <dir> (produces a runnable checkout)",
283
+ next: "tot clone <tenant> (produces a runnable checkout)",
284
284
  exitCode: 2,
285
285
  });
286
286
  }
@@ -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);
@@ -576,6 +576,11 @@ function compareStableAsc(a, b) {
576
576
  * override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
577
577
  * overridable via env for testing.
578
578
  */
579
+ /**
580
+ * @param {any} args
581
+ * @param {NodeJS.ProcessEnv} [env]
582
+ * @param {{ declaredVersion?: string|null }} [opts]
583
+ */
579
584
  export async function resolvePublicRendererSource(args, env = process.env, { declaredVersion = null } = {}) {
580
585
  const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
581
586
  const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
@@ -615,9 +620,19 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
615
620
  `(or pin with --renderer-version to silence).`,
616
621
  );
617
622
  }
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}` };
623
+ const dist = meta?.versions?.[version]?.dist;
624
+ if (!dist?.tarball) throw new Error(`no published ${pkg}@${version} on npm`);
625
+ // `integrity` is the artifact's CONTENT identity (npm dist.integrity, else the
626
+ // legacy shasum) — installRunnerTarball verifies the downloaded bytes against
627
+ // it and records it so a same-version corrected republish busts the cache.
628
+ return {
629
+ kind: "public",
630
+ version,
631
+ url: dist.tarball,
632
+ strip: 1,
633
+ cacheKey: `public-${version}`,
634
+ integrity: dist.integrity || dist.shasum || null,
635
+ };
621
636
  }
622
637
 
623
638
  /**
@@ -625,6 +640,7 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
625
640
  * developer entitlement (same gate as the Docker pull token). Reuses an
626
641
  * already-authenticated `client` when provided (C1/F3), else establishes its own.
627
642
  */
643
+ /** @param {any} args @param {{ client?: any }} [opts] */
628
644
  export async function resolveEntitledRendererSource(args, { client: providedClient } = {}) {
629
645
  const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
630
646
  const client = providedClient || createMcpClient(baseUrl);
@@ -637,7 +653,17 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
637
653
  if (!res?.url || !res?.version) {
638
654
  throw new Error(res?.error || "no renderer-artifact URL returned");
639
655
  }
640
- return { kind: "entitled", version: res.version, url: res.url, strip: 0, cacheKey: res.version };
656
+ // Opportunistic content identity: recorded/verified when the MCP declares one
657
+ // (integrity/sha256); a server that doesn't is simply unverified (null), never
658
+ // an error — the cache then busts on version changes only, as before.
659
+ return {
660
+ kind: "entitled",
661
+ version: res.version,
662
+ url: res.url,
663
+ strip: 0,
664
+ cacheKey: res.version,
665
+ integrity: res.integrity || res.sha256 || null,
666
+ };
641
667
  }
642
668
 
643
669
  /**
@@ -654,6 +680,8 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
654
680
  * instead of paying for a second client.initialize()+establishSession() — same
655
681
  * pattern as ensureRegistryLogin's `providedClient`). `tot dev` standalone
656
682
  * omits it and this establishes its own, as before.
683
+ * @param {any} args
684
+ * @param {{ client?: any }} [opts]
657
685
  * @returns {Promise<string>} the cached, installed runner tree's root directory.
658
686
  */
659
687
  export async function ensureRendererArtifact(args, { client: providedClient } = {}) {
@@ -676,7 +704,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
676
704
 
677
705
  try {
678
706
  const runnerDir = await installRunnerTarball(
679
- { source: credential.url, version: credential.version, isUrl: true },
707
+ {
708
+ source: credential.url,
709
+ version: credential.version,
710
+ isUrl: true,
711
+ integrity: credential.integrity || credential.sha256 || null,
712
+ },
680
713
  { log: (m) => console.error(m) },
681
714
  );
682
715
  setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
@@ -700,6 +733,66 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
700
733
  return existsSync(join(dir, ".tot-cache-complete")) ? dir : null;
701
734
  }
702
735
 
736
+ /**
737
+ * List the installed package dirs that could carry a platform-native binary,
738
+ * normalised so a scoped package reads as `@scope+name` (mirroring pnpm's virtual
739
+ * store naming) regardless of the on-disk layout. Prefers the pnpm virtual store
740
+ * (`node_modules/.pnpm/*`); falls back to a shallow `node_modules` scan (one level
741
+ * into `@scope/`) for an npm/flat install. Best-effort — returns [] on any error.
742
+ */
743
+ function collectNativePackageDirs(runnerDir) {
744
+ const pnpmDir = join(runnerDir, "node_modules", ".pnpm");
745
+ if (existsSync(pnpmDir)) return readdirSync(pnpmDir);
746
+ const nm = join(runnerDir, "node_modules");
747
+ if (!existsSync(nm)) return [];
748
+ const names = [];
749
+ for (const e of readdirSync(nm, { withFileTypes: true })) {
750
+ if (e.name.startsWith("@") && e.isDirectory()) {
751
+ for (const s of readdirSync(join(nm, e.name))) names.push(`${e.name}+${s}`);
752
+ } else {
753
+ names.push(e.name);
754
+ }
755
+ }
756
+ return names;
757
+ }
758
+
759
+ /**
760
+ * True unless the installed renderer is POISONED for this host's CPU arch — the
761
+ * "the dev server didn't come up" root cause. Platform-native binding packages
762
+ * are named `<family>-<os>-<cpu>[-<abi>]` (e.g. `@esbuild/darwin-arm64`,
763
+ * `lightningcss-linux-x64-gnu`, `@rollup/rollup-win32-x64-msvc`). npm's optional-
764
+ * deps bug (npm/cli#4828) can materialise a DIFFERENT arch's variant than the host
765
+ * needs (e.g. darwin-x64 on an arm64 Mac) — pnpm gets it right. We flag the tree
766
+ * as poisoned exactly when a family ships a binding for our OS but NOT our OS+CPU,
767
+ * which is the precise shape of the `Cannot find native binding` boot crash. A
768
+ * family with no variant for our OS at all is a cross-platform optional dep that's
769
+ * correctly absent, so it never trips the check. Never throws; on any probe error
770
+ * it returns true (trust the cache) so a health probe can't itself break `tot dev`.
771
+ *
772
+ * `platform`/`arch` are injectable so this is testable off the host's real arch.
773
+ */
774
+ export function rendererCacheHealthy(runnerDir, { platform = process.platform, arch = process.arch } = {}) {
775
+ try {
776
+ const re = /^(.*?)[-+](darwin|linux|win32|freebsd|android|openharmony)-([a-z0-9]+)/;
777
+ const families = new Map(); // family -> Set("<os>-<cpu>")
778
+ for (const name of collectNativePackageDirs(runnerDir)) {
779
+ const m = name.match(re);
780
+ if (!m) continue;
781
+ const [, family, os, cpu] = m;
782
+ if (!families.has(family)) families.set(family, new Set());
783
+ families.get(family).add(`${os}-${cpu}`);
784
+ }
785
+ const want = `${platform}-${arch}`;
786
+ for (const variants of families.values()) {
787
+ const hasOurOs = [...variants].some((v) => v.startsWith(`${platform}-`));
788
+ if (hasOurOs && !variants.has(want)) return false; // wrong-arch binary present, ours missing
789
+ }
790
+ return true;
791
+ } catch {
792
+ return true;
793
+ }
794
+ }
795
+
703
796
  /**
704
797
  * Delete every OTHER public-runner cache dir (`public-*` except `keepVersion`)
705
798
  * once we've resolved the version this CLI pins to — so a pre-alignment dir
@@ -769,11 +862,11 @@ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
769
862
  * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
770
863
  */
771
864
  export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
772
- const src = resolveLocalRendererSource({
865
+ const src = /** @type {any} */ (resolveLocalRendererSource({
773
866
  env,
774
867
  mode: ctx?.mode,
775
868
  repoRoot: ctx?.repoRoot,
776
- });
869
+ }));
777
870
 
778
871
  if (src.kind === "dir") {
779
872
  if (!existsSync(join(src.dir, "scripts", "tot-dev.mjs"))) {
@@ -791,7 +884,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
791
884
  console.error(`~ renderer: ${src.why}`);
792
885
  return installRunnerTarball(
793
886
  { source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
794
- { log: (m) => console.error(m) },
887
+ { log: (m) => console.error(m), cacheRoot },
795
888
  );
796
889
  }
797
890
 
@@ -802,29 +895,51 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
802
895
  const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
803
896
  const wantVersion = declared || CLI_VERSION;
804
897
 
805
- // Fully-offline fast path: when the WANTED version (declared, else lockstep) is
806
- // already cached, reuse it without touching npm — honouring "don't hit npm when
807
- // the RIGHT version is already cached" without ever reusing a version the
808
- // resolution wouldn't choose. Safe because `public-<version>` only exists if a
809
- // prior run fetched exactly that version. Skipped when an explicit pin is set
810
- // (that must go through resolution).
898
+ // Offline-safe fast path: when the WANTED version (declared, else lockstep) is
899
+ // already cached, reuse it — honouring "don't hit npm when the RIGHT version is
900
+ // already cached" without ever reusing a version the resolution wouldn't choose.
901
+ // Safe because `public-<version>` only exists if a prior run fetched exactly
902
+ // that version. Skipped when an explicit pin is set (that must go through
903
+ // resolution). One refinement over fully-offline: a QUICK, soft-fail registry
904
+ // probe (publishedRunnerIntegrity) revalidates the cached CONTENT identity when
905
+ // npm is reachable, so a corrected republish under the same version string is
906
+ // picked up automatically; offline/slow/unanswerable → trust the cache exactly
907
+ // as before (the probe can never block or fail the run).
811
908
  if (!explicitPin) {
812
909
  const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
813
910
  if (exact) {
814
911
  // Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
815
912
  // but if it can't report its own version it predates the --version surface
816
913
  // (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
817
- // runner we can't identify. When it DOES answer, reuse it (fully offline-safe).
818
- if (probeRunnerVersion(exact)) {
914
+ // runner we can't identify. It must ALSO carry native bindings for THIS
915
+ // host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
916
+ // bindings (npm/cli#4828) would otherwise be reused forever and crash astro
917
+ // at boot with the swallowed "dev server didn't come up". When both hold,
918
+ // reuse it (offline-safe).
919
+ if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
920
+ const published = await publishedRunnerIntegrity(env, wantVersion);
921
+ const recorded = readCacheMarker(exact)?.integrity || null;
922
+ if (!published || !recorded || published === recorded) {
923
+ console.error(
924
+ `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
925
+ );
926
+ setRunnerVersion(wantVersion);
927
+ prunePublicRunnerCache(cacheRoot, wantVersion);
928
+ return exact;
929
+ }
930
+ // Same version string, different published contents — a corrected
931
+ // republish. Fall through to resolution + a fresh install (which also
932
+ // verifies the new bytes against the new integrity).
933
+ console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
934
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
935
+ } else {
819
936
  console.error(
820
- `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
937
+ rendererCacheHealthy(exact)
938
+ ? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
939
+ : `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
821
940
  );
822
- setRunnerVersion(wantVersion);
823
- prunePublicRunnerCache(cacheRoot, wantVersion);
824
- return exact;
941
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
825
942
  }
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
943
  }
829
944
  }
830
945
 
@@ -855,8 +970,8 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
855
970
  // The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
856
971
  // out of the user's way; the setup spinner below is the visible progress.
857
972
  const dir = await installRunnerTarball(
858
- { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
859
- { log: (m) => console.error(m) },
973
+ { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip, integrity: pub.integrity },
974
+ { log: (m) => console.error(m), cacheRoot },
860
975
  );
861
976
  setRunnerVersion(pub.version); // telemetry: the runner version running this session
862
977
  // The pinned version is now installed under public-<version> — drop any other
@@ -881,69 +996,269 @@ function sourceVersionKey(source) {
881
996
  * authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
882
997
  * paths so they cache identically.
883
998
  *
884
- * @param {{ source: string, version: string, isUrl?: boolean }} spec
885
- * @param {{ log?: (m: string) => void }} [opts]
999
+ * Cache-poisoning invariants (the 2026-08-18 first-run hardening):
1000
+ * promote-on-success only the install runs in a per-attempt staging dir and
1001
+ * is renamed into the canonical slot ONLY after it fully succeeds, so a failed
1002
+ * install can never become the cached artifact a later run resumes from.
1003
+ * • the completion marker is a manifest carrying the source's CONTENT identity
1004
+ * (`integrity`), so a corrected republish under the SAME version string is a
1005
+ * cache miss (rebuild), not a stale hit — no manual `rm -rf` ever required.
1006
+ * • a failed attempt auto-cleans and retries ONCE from a clean slate before
1007
+ * surfacing the error (transient blips heal themselves); deterministic
1008
+ * failures (`e.permanent`) skip the retry and fail loud immediately.
1009
+ *
1010
+ * @param {{ source: string, version: string, isUrl?: boolean, strip?: number, integrity?: string|null }} spec
1011
+ * `integrity` is the source artifact's content identity when the resolver knows
1012
+ * it (npm `dist.integrity`/`dist.shasum`); used to verify the downloaded bytes
1013
+ * and to bust a cached entry whose recorded identity no longer matches.
1014
+ * @param {{ log?: (m: string) => void, cacheRoot?: string }} [opts]
886
1015
  * @returns {Promise<string>} the cached, installed runner tree's root directory.
887
1016
  */
888
- export async function installRunnerTarball({ source, version, isUrl = true, strip = 0 }, { log = (m) => console.error(m) } = {}) {
889
- const runnerDir = join(RENDERER_CACHE_ROOT, version);
890
- const marker = join(runnerDir, ".tot-cache-complete");
891
- if (existsSync(marker)) return runnerDir; // already downloaded + installed
1017
+ export async function installRunnerTarball(
1018
+ { source, version, isUrl = true, strip = 0, integrity = null },
1019
+ { log = (m) => console.error(m), cacheRoot = RENDERER_CACHE_ROOT } = {},
1020
+ ) {
1021
+ const runnerDir = join(cacheRoot, version);
1022
+ // Staging dirs from DEAD runs (crashed/killed installs) must not leak disk
1023
+ // forever — reap them here, the one funnel every install path goes through.
1024
+ sweepStaleStagingDirs(cacheRoot);
1025
+
1026
+ const localSource = isUrl ? null : resolveLocalTarball(source);
1027
+ // The EXPECTED content identity of the source artifact. A local tarball with no
1028
+ // caller-provided integrity is cheap to hash on every run, so a same-path
1029
+ // republish (new contents, same file name) busts the cache too.
1030
+ const expected = integrity || (isUrl ? null : fileIntegrity(localSource));
1031
+
1032
+ const cached = readCacheMarker(runnerDir);
1033
+ if (cached) {
1034
+ if (!rendererCacheHealthy(runnerDir)) {
1035
+ // A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g.
1036
+ // darwin-x64 on an arm64 Mac) is otherwise trusted forever, and astro
1037
+ // crashes at boot with `Cannot find native binding`, swallowed as "the dev
1038
+ // server didn't come up". Fall through and rebuild from the source below.
1039
+ log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
1040
+ } else if (expected && cached.integrity && expected !== cached.integrity) {
1041
+ // CONTENT-HASH BUST: same version string, different artifact contents — a
1042
+ // corrected republish. The cached entry is stale by identity, not by label;
1043
+ // rebuild from the corrected source instead of serving the stale cache.
1044
+ log(`~ the preview engine's ${version} artifact changed upstream (same version, new contents) — rebuilding…`);
1045
+ } else {
1046
+ return runnerDir; // already downloaded + installed (and contents still match)
1047
+ }
1048
+ }
892
1049
 
893
1050
  // First run only — set the expectation so the one-time cost doesn't read as a
894
1051
  // hang: this downloads + installs the renderer once, then every later run of
895
1052
  // this version is a no-network cache hit.
896
1053
  log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
897
- const localSource = isUrl ? null : resolveLocalTarball(source);
898
- const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
899
- try {
900
- if (isUrl) {
901
- // The fetch itself is otherwise silent (no per-byte output) and can run
902
- // tens of seconds on a cold cache tick a spinner so it never looks hung.
903
- const spin = startProgress("downloading the store preview engine…");
1054
+ // AUTO-CLEAN-AND-RETRY-ONCE: a transient failure (network blip mid-download, a
1055
+ // registry hiccup mid-install) heals itself with one clean re-attempt instead
1056
+ // of stopping a first run at an error only `rm -rf` folklore could clear.
1057
+ // Bounded to one retry so a genuinely-broken source still fails loudly.
1058
+ for (let attempt = 1; ; attempt++) {
1059
+ const archivePath = /** @type {string} */ (isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource);
1060
+ const stagingDir = `${runnerDir}.staging-${process.pid}`;
1061
+ try {
1062
+ if (isUrl) {
1063
+ // The fetch itself is otherwise silent (no per-byte output) and can run
1064
+ // tens of seconds on a cold cache — tick a spinner so it never looks hung.
1065
+ const spin = startProgress("downloading the store preview engine…");
1066
+ try {
1067
+ await downloadFile(source, archivePath);
1068
+ } finally {
1069
+ spin.stop();
1070
+ }
1071
+ }
1072
+ if (!existsSync(archivePath)) {
1073
+ throw new Error(`renderer tarball not found: ${archivePath}`);
1074
+ }
1075
+ // Refuse to install bytes that don't match the source's declared identity —
1076
+ // a truncated/corrupted download would otherwise be cached as if complete.
1077
+ // (Transient by nature, so the retry above gets a fresh download.)
1078
+ if (expected && !tarballMatchesIntegrity(archivePath, expected)) {
1079
+ throw new Error(`the downloaded preview-engine tarball failed its integrity check (expected ${expected})`);
1080
+ }
1081
+ // The identity recorded in the completion manifest below — what future runs
1082
+ // compare against to detect a same-version republish. Hash the actual bytes
1083
+ // when the resolver couldn't tell us (e.g. the entitled signed-URL path).
1084
+ const contentId = expected || fileIntegrity(archivePath);
1085
+ rmSync(stagingDir, { recursive: true, force: true });
1086
+ mkdirSync(stagingDir, { recursive: true });
1087
+ extractTarball(archivePath, stagingDir, { strip });
1088
+ // Pin the runner install to PUBLIC npm. The moat-free runner has only public
1089
+ // deps, but the HOST's global ~/.npmrc may point `registry` at a private
1090
+ // mirror (an internal proxy that 502s, or one an invited developer can't
1091
+ // reach) — an invited dev's machine config must never decide where the
1092
+ // runner's public deps come from. A project-level .npmrc wins over the user's.
1093
+ writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
1094
+ // The install is the long, noisy step — tick a spinner while its output goes
1095
+ // to a log, so the terminal shows one clean line instead of the pnpm firehose.
1096
+ // corepack setup logs to the SAME file so its failures aren't invisible (they
1097
+ // were the silent cause of "couldn't set up the store preview engine").
1098
+ const installLog = join(cacheRoot, `${version}.install.log`);
1099
+ ensureCorepackPnpm(stagingDir, { logPath: installLog });
1100
+ const installSpin = startProgress("installing the store preview engine…", {
1101
+ stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
1102
+ });
904
1103
  try {
905
- await downloadFile(source, archivePath);
1104
+ await runPnpmInstall(stagingDir, { logPath: installLog });
906
1105
  } finally {
907
- spin.stop();
1106
+ installSpin.stop();
908
1107
  }
1108
+ // Atomic-ish: only rename into the final, discoverable path once install
1109
+ // succeeded, so a crashed/interrupted run never leaves a half-built cache
1110
+ // entry that a later `tot dev` would treat as ready.
1111
+ rmSync(runnerDir, { recursive: true, force: true });
1112
+ renameSync(stagingDir, runnerDir);
1113
+ // Fence a fresh install against npm/cli#4828: if the installer left the wrong
1114
+ // arch's native bindings (or none) for this host, DON'T stamp the completion
1115
+ // marker — an unmarked tree is never reused, so the next run reinstalls cleanly
1116
+ // instead of caching the poison and crashing astro at boot. Fail loud + actionable
1117
+ // rather than swallow it as "the dev server didn't come up". Permanent: the
1118
+ // same installer on the same host would just produce the same result, so the
1119
+ // auto-retry is skipped.
1120
+ if (!rendererCacheHealthy(runnerDir)) {
1121
+ await emitObstacle("renderer-native-bindings-missing");
1122
+ const err = new CliError(
1123
+ `the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
1124
+ {
1125
+ 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)",
1126
+ exitCode: 2,
1127
+ },
1128
+ );
1129
+ /** @type {any} */ (err).permanent = true;
1130
+ throw err;
1131
+ }
1132
+ writeCacheMarker(runnerDir, { version, integrity: contentId });
1133
+ return runnerDir;
1134
+ } catch (e) {
1135
+ // A failed attempt must never survive on disk — not as staging debris, and
1136
+ // (by promote-on-success) it never reached the canonical slot at all.
1137
+ rmSync(stagingDir, { recursive: true, force: true });
1138
+ if (e?.permanent === true || attempt >= 2) throw e;
1139
+ log(`~ that didn't work (${String(e?.message || e).split("\n")[0]}) — retrying once from a clean slate…`);
1140
+ } finally {
1141
+ if (isUrl) rmSync(archivePath, { force: true });
909
1142
  }
910
- if (!existsSync(archivePath)) {
911
- throw new Error(`renderer tarball not found: ${archivePath}`);
912
- }
913
- const stagingDir = `${runnerDir}.staging-${process.pid}`;
914
- rmSync(stagingDir, { recursive: true, force: true });
915
- mkdirSync(stagingDir, { recursive: true });
916
- extractTarball(archivePath, stagingDir, { strip });
917
- // Pin the runner install to PUBLIC npm. The moat-free runner has only public
918
- // deps, but the HOST's global ~/.npmrc may point `registry` at a private
919
- // mirror (an internal proxy that 502s, or one an invited developer can't
920
- // reach) — an invited dev's machine config must never decide where the
921
- // runner's public deps come from. A project-level .npmrc wins over the user's.
922
- writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
923
- // The install is the long, noisy step — tick a spinner while its output goes
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
- });
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Read a cache entry's completion marker (`.tot-cache-complete`). Returns the
1148
+ * manifest object (at least `{ integrity: string|null }`), or null when the
1149
+ * marker is absent — i.e. the entry is incomplete/partial and must be treated
1150
+ * as if it didn't exist. A legacy pre-manifest marker (a bare timestamp string)
1151
+ * reads as complete-with-unknown-identity, so existing healthy caches survive
1152
+ * the upgrade without a forced rebuild.
1153
+ */
1154
+ export function readCacheMarker(dir) {
1155
+ try {
1156
+ const raw = readFileSync(join(dir, ".tot-cache-complete"), "utf8");
932
1157
  try {
933
- await runPnpmInstall(stagingDir, { logPath: installLog });
934
- } finally {
935
- installSpin.stop();
1158
+ const m = JSON.parse(raw);
1159
+ if (m && typeof m === "object") return { integrity: null, ...m };
1160
+ } catch {
1161
+ /* legacy timestamp-string marker */
936
1162
  }
937
- // Atomic-ish: only rename into the final, discoverable path once install
938
- // succeeded, so a crashed/interrupted run never leaves a half-built cache
939
- // entry that a later `tot dev` would treat as ready.
940
- rmSync(runnerDir, { recursive: true, force: true });
941
- renameSync(stagingDir, runnerDir);
942
- writeFileSync(marker, new Date().toISOString());
943
- } finally {
944
- if (isUrl) rmSync(archivePath, { force: true });
1163
+ return { integrity: null };
1164
+ } catch {
1165
+ return null; // no marker never treat the entry as installed
1166
+ }
1167
+ }
1168
+
1169
+ /** Stamp a cache entry complete: version + source content identity + when. */
1170
+ function writeCacheMarker(dir, { version, integrity }) {
1171
+ writeFileSync(
1172
+ join(dir, ".tot-cache-complete"),
1173
+ JSON.stringify({ version, integrity: integrity || null, completedAt: new Date().toISOString() }) + "\n",
1174
+ );
1175
+ }
1176
+
1177
+ /** sha512 SRI (`sha512-<base64>`, npm's `dist.integrity` format) of a file; null when unreadable. */
1178
+ function fileIntegrity(path) {
1179
+ try {
1180
+ return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`;
1181
+ } catch {
1182
+ return null;
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Do the tarball's bytes match `expected` — an SRI string (`sha512-<b64>`, npm's
1188
+ * `dist.integrity`) or npm's legacy `dist.shasum` (bare 40-hex sha1)? Unknown
1189
+ * formats and probe errors return true: this check exists to catch corrupted
1190
+ * bytes, never to block an install on a format we can't verify.
1191
+ */
1192
+ export function tarballMatchesIntegrity(archivePath, expected) {
1193
+ try {
1194
+ const want = String(expected).trim();
1195
+ const sri = /^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/=]+)$/.exec(want);
1196
+ if (sri) {
1197
+ return createHash(sri[1]).update(readFileSync(archivePath)).digest("base64") === sri[2];
1198
+ }
1199
+ if (/^[0-9a-f]{40}$/i.test(want)) {
1200
+ return createHash("sha1").update(readFileSync(archivePath)).digest("hex") === want.toLowerCase();
1201
+ }
1202
+ return true;
1203
+ } catch {
1204
+ return true;
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Reap `<entry>.staging-<pid>` dirs left by DEAD processes — failed/killed
1210
+ * installs used to accumulate one orphaned staging tree per attempt, leaking
1211
+ * disk forever. A staging dir whose pid is still alive belongs to a concurrent
1212
+ * `tot dev` mid-install and is left alone. Best-effort: never throws, and never
1213
+ * touches this process's own staging dir (created fresh after this sweep).
1214
+ */
1215
+ export function sweepStaleStagingDirs(cacheRoot, { pidAlive = processAlive } = {}) {
1216
+ try {
1217
+ if (!cacheRoot || !existsSync(cacheRoot)) return;
1218
+ for (const name of readdirSync(cacheRoot)) {
1219
+ const m = /\.staging-(\d+)$/.exec(name);
1220
+ if (!m) continue;
1221
+ const pid = Number(m[1]);
1222
+ if (pid === process.pid || pidAlive(pid)) continue;
1223
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
1224
+ }
1225
+ } catch {
1226
+ /* best-effort cache hygiene */
1227
+ }
1228
+ }
1229
+
1230
+ /** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
1231
+ function processAlive(pid) {
1232
+ try {
1233
+ process.kill(pid, 0);
1234
+ return true;
1235
+ } catch (e) {
1236
+ return e?.code === "EPERM";
1237
+ }
1238
+ }
1239
+
1240
+ /**
1241
+ * The registry-declared content identity (`dist.integrity`, else `dist.shasum`)
1242
+ * of the public runner at `version` — or null when npm can't answer QUICKLY
1243
+ * (offline, slow, 4xx/5xx, malformed). Used by ensureSampleRenderer's cached
1244
+ * fast path to detect a same-version republish without ever making the network
1245
+ * a hard dependency: null means "can't verify right now — trust the cache",
1246
+ * preserving the offline-reuse behavior exactly.
1247
+ */
1248
+ export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000, fetchFn = fetch } = {}) {
1249
+ try {
1250
+ const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
1251
+ const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
1252
+ const res = await fetchFn(`${registry}/${pkg.replace("/", "%2f")}`, {
1253
+ headers: { accept: "application/json" },
1254
+ signal: AbortSignal.timeout(timeoutMs),
1255
+ });
1256
+ if (!res.ok) return null;
1257
+ const dist = (await res.json())?.versions?.[version]?.dist;
1258
+ return dist?.integrity || dist?.shasum || null;
1259
+ } catch {
1260
+ return null;
945
1261
  }
946
- return runnerDir;
947
1262
  }
948
1263
 
949
1264
  /** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
@@ -963,7 +1278,7 @@ async function downloadFile(url, destPath) {
963
1278
  if (!res.ok || !res.body) {
964
1279
  throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
965
1280
  }
966
- await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
1281
+ await pipeline(Readable.fromWeb(/** @type {any} */ (res.body)), createWriteStream(destPath));
967
1282
  }
968
1283
 
969
1284
  /**
@@ -1012,7 +1327,8 @@ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } =
1012
1327
  for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
1013
1328
  const r = spawnFn("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
1014
1329
  if (fd !== null && (r.error || r.status !== 0)) {
1015
- writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
1330
+ const rerr = /** @type {any} */ (r.error);
1331
+ writeSync(fd, `[tot] corepack ${args.join(" ")} → ${rerr?.code || rerr?.message || `exit ${r.status}`}\n`);
1016
1332
  }
1017
1333
  }
1018
1334
  } finally {
@@ -1056,24 +1372,32 @@ function spawnAsyncResult(cmd, args, opts = {}) {
1056
1372
  });
1057
1373
  }
1058
1374
 
1375
+ /**
1376
+ * @param {string} runnerDir
1377
+ * @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
1378
+ */
1059
1379
  export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
1060
1380
  const fd = logPath ? openSync(logPath, "a") : null;
1061
1381
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
1062
- // npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
1063
- // longer bundled), so it's the launcher with zero machine-specific setup —
1064
- // no global pnpm, no corepack shim dance. The runner tree is built to be
1065
- // npm-installable (build-runner.mjs rewrites `workspace:*` "*" and emits an
1066
- // npm `workspaces` field; verified end-to-end with both installers). pnpm and
1067
- // the corepack-pinned pnpm remain as fallbacks for hosts with a broken npm.
1382
+ // pnpm FIRST: the renderer tree is a bundle of native optional deps (rolldown,
1383
+ // esbuild, sharp, @rollup, lightningcss, @tailwindcss/oxide, @astrojs/compiler,
1384
+ // workerd), and npm's optional-deps bug (npm/cli#4828) routinely installs the
1385
+ // WRONG arch's binary or none poisoning the cache so astro crashes at boot with
1386
+ // `Cannot find native binding`. pnpm resolves per-platform optional bindings
1387
+ // correctly (proven end-to-end). ensureCorepackPnpm() ran just above, so on the
1388
+ // Node floor (22.12, which bundles corepack) pnpm is available; `corepack pnpm`
1389
+ // is the shim path if a bare `pnpm` isn't on PATH. npm stays LAST as the escape
1390
+ // hatch for hosts with neither pnpm nor corepack — where rendererCacheHealthy()
1391
+ // then fences a poisoned result rather than shipping a broken cache silently.
1068
1392
  // If a launcher isn't installed at all (ENOENT) we move on; a launcher that
1069
1393
  // RAN but whose install failed is the real error and stops the loop.
1070
1394
  // REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
1071
1395
  // `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
1072
1396
  // runner to this CLI's version, so this CLI never installs those).
1073
1397
  const attempts = [
1074
- { cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
1075
1398
  { cmd: "pnpm", args: installArgs },
1076
1399
  { cmd: "corepack", args: ["pnpm", ...installArgs] },
1400
+ { cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
1077
1401
  ];
1078
1402
  try {
1079
1403
  for (const { cmd, args } of attempts) {
@@ -1089,28 +1413,33 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1089
1413
  stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
1090
1414
  });
1091
1415
  if (r.status === 0) return; // installed
1092
- if (r.error?.code === "ENOENT") {
1416
+ if (/** @type {any} */ (r.error)?.code === "ENOENT") {
1093
1417
  // This launcher isn't on the machine — record it and try the next one.
1094
1418
  if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
1095
1419
  continue;
1096
1420
  }
1097
1421
  // The launcher ran; the install itself failed. That's the actionable error.
1422
+ // (installRunnerTarball auto-retries this ONCE from a clean slate before it
1423
+ // reaches the user — so by the time this surfaces, it failed twice.)
1098
1424
  await emitObstacle("install-failed");
1099
1425
  throw new CliError(
1100
1426
  `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
1101
1427
  (logPath ? `\n details: ${logPath}` : ""),
1102
- { next: "check the details log above, then re-run `tot start` (it resumes from the cache)" },
1428
+ { next: "check the details log above, then re-run `tot start` (it retries from a clean slate — no cache to clear)" },
1103
1429
  );
1104
1430
  }
1105
1431
  // Every launcher ENOENT'd → there's no pnpm on this machine and corepack
1106
1432
  // couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
1107
1433
  // every Node, so `npm i -g pnpm` is the escape hatch that always exists.
1434
+ // Permanent: retrying can't conjure a launcher — skip the clean-slate retry.
1108
1435
  await emitObstacle("pnpm-missing");
1109
- throw new CliError(
1436
+ const err = new CliError(
1110
1437
  "couldn't set up the store preview engine — pnpm isn't available on this machine" +
1111
1438
  (logPath ? `\n details: ${logPath}` : ""),
1112
1439
  { next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
1113
1440
  );
1441
+ /** @type {any} */ (err).permanent = true;
1442
+ throw err;
1114
1443
  } finally {
1115
1444
  if (fd !== null) closeSync(fd);
1116
1445
  }
@@ -1125,6 +1454,20 @@ function pnpmFailureHint(logPath) {
1125
1454
  if (!logPath) return " — is pnpm/corepack available on this host?";
1126
1455
  try {
1127
1456
  const tail = readFileSync(logPath, "utf8").slice(-8000);
1457
+ // A 404 means the registry answered — a specific package/version doesn't
1458
+ // exist there. Since installs now run from a clean slate every attempt
1459
+ // (promote-on-success + auto-retry), this is a BROKEN RUNNER RELEASE (it
1460
+ // references an unpublished package), not the user's cache — no `rm -rf`
1461
+ // will help. Check this BEFORE the generic ERR_PNPM_FETCH match, since
1462
+ // pnpm's 404 error text also contains "ERR_PNPM_FETCH".
1463
+ const missing404 = tail.match(/ERR_PNPM_FETCH_404[^\n]*GET\s+(\S+)/i);
1464
+ if (missing404) {
1465
+ return (
1466
+ ` — the preview engine references a package that isn't published (${missing404[1]});` +
1467
+ " that's a broken preview-engine release, not your machine — try again later or pin a" +
1468
+ " known-good version with --renderer-version"
1469
+ );
1470
+ }
1128
1471
  if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
1129
1472
  return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
1130
1473
  }
@@ -1140,6 +1483,10 @@ function pnpmFailureHint(logPath) {
1140
1483
  * fs-watch HMR. Returns a handle shaped like spawnDevContainer's, so run()'s
1141
1484
  * auto-open-browser logic works unchanged for either runtime. Exported (and
1142
1485
  * `stdio` overridable) so `tot start` can pipe the logs instead of inheriting.
1486
+ * @param {string} runnerDir
1487
+ * @param {string} workspace
1488
+ * @param {string} port
1489
+ * @param {{ stdio?: any, env?: Record<string, any> }} [opts]
1143
1490
  */
1144
1491
  export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit", env = {} } = {}) {
1145
1492
  const script = join(runnerDir, "scripts", "tot-dev.mjs");
@@ -1160,7 +1507,7 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
1160
1507
  [script, "--workspace", workspace, "--port", port],
1161
1508
  { cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
1162
1509
  );
1163
- const handle = { child, exited: false, done: null };
1510
+ const handle = { child, exited: false, done: /** @type {any} */ (null) };
1164
1511
  handle.done = new Promise((resolvePromise) => {
1165
1512
  child.on("exit", (code) => {
1166
1513
  handle.exited = true;
@@ -1240,7 +1587,7 @@ export function buildContainerPlan(workspace, args, _ctx) {
1240
1587
  const cfg = readWorkspaceConfig(workspace);
1241
1588
  if (!cfg) {
1242
1589
  throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
1243
- next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
1590
+ next: "tot clone <tenant> (produces a runnable checkout)",
1244
1591
  exitCode: 2,
1245
1592
  });
1246
1593
  }
@@ -1315,9 +1662,9 @@ export async function spawnDevContainer(plan, args, { stdio = "inherit" } = {})
1315
1662
 
1316
1663
  const stdioArr =
1317
1664
  stdio === "piped" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"];
1318
- const child = spawn("docker", plan.dockerArgs, { stdio: stdioArr });
1665
+ const child = spawn("docker", plan.dockerArgs, { stdio: /** @type {any} */ (stdioArr) });
1319
1666
 
1320
- const handle = { child, exited: false, done: null };
1667
+ const handle = { child, exited: false, done: /** @type {any} */ (null) };
1321
1668
  handle.done = new Promise((resolvePromise) => {
1322
1669
  child.on("exit", (code) => {
1323
1670
  handle.exited = true;
@@ -1357,6 +1704,7 @@ export function isPrivateRegistryImage(image) {
1357
1704
  * its own session and overlaps this with the checkout clone instead of paying
1358
1705
  * for a second client.initialize()+establishSession() serially afterward).
1359
1706
  * `tot dev` standalone omits it and this establishes its own, as before.
1707
+ * @param {string} image @param {any} args @param {{ client?: any }} [opts]
1360
1708
  */
1361
1709
  export async function ensureRegistryLogin(image, args, { client: providedClient } = {}) {
1362
1710
  const registry = String(image).split("/")[0];