@tokenoftrust/cli 3.10.2 → 3.10.4

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 CHANGED
@@ -29,10 +29,6 @@ import {
29
29
  recordDiagnostic,
30
30
  resetInvocationDiagnostics,
31
31
  } from "../src/diagnostics.mjs";
32
- import {
33
- nativeWindowsRefuseMessage,
34
- shouldRefuseNativeWindows,
35
- } from "../src/platform.mjs";
36
32
 
37
33
  const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
38
34
 
@@ -120,14 +116,6 @@ async function dispatch(cmd, rest, ctx) {
120
116
  return cmd ? 0 : 2;
121
117
  }
122
118
 
123
- // Native Windows: refuse before login/--device/start burn cycles on a host the
124
- // local loop cannot run on. doctor/feedback/ideas stay available so the gate
125
- // itself is discoverable. WSL reports as linux and passes through.
126
- if (shouldRefuseNativeWindows(cmd)) {
127
- process.stderr.write(nativeWindowsRefuseMessage(cmd));
128
- return 2;
129
- }
130
-
131
119
  // A mounted publication space's checkout (spaces/<id>/, recorded by `tot clone`) is its own
132
120
  // repository: the store-root verbs below would push it with the ROOT's credential or merge the
133
121
  // root's preview branch into it. Refuse them there, naming the space's own path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "3.10.2",
3
+ "version": "3.10.4",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -913,7 +913,20 @@ export function rendererNativeBindingGaps(runnerDir, { platform = process.platfo
913
913
  * `platform`/`arch` are injectable so this is testable off the host's real arch.
914
914
  */
915
915
  export function rendererCacheHealthy(runnerDir, opts = {}) {
916
- return rendererNativeBindingGaps(runnerDir, opts).length === 0;
916
+ return rendererNativeBindingGaps(runnerDir, opts).length === 0 && rendererDepsResolve(runnerDir);
917
+ }
918
+
919
+ /**
920
+ * Does the renderer's own dependency tree resolve from where it sits? `existsSync`
921
+ * follows links, so a `node_modules` entry whose target is gone (a junction left
922
+ * pointing into a renamed staging directory) reads as absent. A tree with no
923
+ * `apps/storefront/package.json` has nothing to check and passes.
924
+ * @param {string} runnerDir
925
+ */
926
+ export function rendererDepsResolve(runnerDir) {
927
+ const app = join(runnerDir, "apps", "storefront");
928
+ if (!existsSync(join(app, "package.json"))) return true;
929
+ return existsSync(join(app, "node_modules", "astro", "package.json"));
917
930
  }
918
931
 
919
932
  /**
@@ -1266,7 +1279,7 @@ export async function installRunnerTarball(
1266
1279
  // Bounded to one retry so a genuinely-broken source still fails loudly.
1267
1280
  for (let attempt = 1; ; attempt++) {
1268
1281
  const archivePath = /** @type {string} */ (isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource);
1269
- const stagingDir = `${runnerDir}.staging-${process.pid}`;
1282
+ const stagingDir = installsInPlace() ? runnerDir : `${runnerDir}.staging-${process.pid}`;
1270
1283
  try {
1271
1284
  if (isUrl) {
1272
1285
  // The fetch itself is otherwise silent (no per-byte output) and can run
@@ -1339,8 +1352,10 @@ export async function installRunnerTarball(
1339
1352
  // Atomic-ish: only rename into the final, discoverable path once install
1340
1353
  // succeeded, so a crashed/interrupted run never leaves a half-built cache
1341
1354
  // entry that a later `tot dev` would treat as ready.
1342
- rmSync(runnerDir, { recursive: true, force: true });
1343
- renameSync(stagingDir, runnerDir);
1355
+ if (stagingDir !== runnerDir) {
1356
+ rmSync(runnerDir, { recursive: true, force: true });
1357
+ renameSync(stagingDir, runnerDir);
1358
+ }
1344
1359
  // Fence a fresh install against npm/cli#4828: if the installer left the wrong
1345
1360
  // arch's native bindings (or none) for this host, DON'T stamp the completion
1346
1361
  // marker — an unmarked tree is never reused, so the next run reinstalls cleanly
@@ -1368,6 +1383,19 @@ export async function installRunnerTarball(
1368
1383
  /** @type {any} */ (err).permanent = true;
1369
1384
  throw err;
1370
1385
  }
1386
+ if (!rendererDepsResolve(runnerDir)) {
1387
+ await emitObstacle("install-failed");
1388
+ const err = new CliError(
1389
+ `the store preview engine installed but its dependencies don't resolve from where it was installed` +
1390
+ `\n details: ${installLog}`,
1391
+ {
1392
+ next: `re-run \`tot start --reset-cache\`; if it repeats, file feedback naming preview engine ${version} and your OS`,
1393
+ exitCode: 2,
1394
+ },
1395
+ );
1396
+ /** @type {any} */ (err).permanent = true;
1397
+ throw err;
1398
+ }
1371
1399
  writeCacheMarker(runnerDir, { version, integrity: contentId });
1372
1400
  return runnerDir;
1373
1401
  } catch (e) {
@@ -1547,9 +1575,9 @@ function extractTarball(archivePath, destDir, { strip = 0 } = {}) {
1547
1575
  * missing or disabled (a locked-down host), pnpm install below falls through to
1548
1576
  * the pnpm-missing obstacle instead of failing here.
1549
1577
  * @param {string} runnerDir
1550
- * @param {{ logPath?: string, spawnFn?: typeof spawnSync }} [opts]
1578
+ * @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
1551
1579
  */
1552
- export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } = {}) {
1580
+ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnLauncherSync } = {}) {
1553
1581
  const pkgPath = join(runnerDir, "package.json");
1554
1582
  if (!existsSync(pkgPath)) return;
1555
1583
  let pm;
@@ -1598,13 +1626,68 @@ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } =
1598
1626
  * @param {string} cmd @param {string[]} args @param {object} opts
1599
1627
  * @returns {Promise<{ status: number|null, error: Error|null, signal?: string|null }>}
1600
1628
  */
1629
+ /**
1630
+ * Windows installs the renderer where it will live. pnpm there writes `node_modules`
1631
+ * junctions with ABSOLUTE targets into the directory it installs in, so renaming a
1632
+ * staging directory into place leaves every one dangling. The completion marker is still
1633
+ * written last and an unmarked tree is never reused, so a half install cannot be served.
1634
+ * @param {string} [platform]
1635
+ */
1636
+ export function installsInPlace(platform = process.platform) {
1637
+ return platform === "win32";
1638
+ }
1639
+
1640
+ /**
1641
+ * On Windows `pnpm`, `corepack` and `npm` are `.cmd` shims, which Node refuses to
1642
+ * spawn without a shell.
1643
+ * @param {string} [platform]
1644
+ */
1645
+ export function launcherNeedsShell(platform = process.platform) {
1646
+ return platform === "win32";
1647
+ }
1648
+
1649
+ const SAFE_LAUNCHER_ARG = /^[\w@.:+=/-]+$/;
1650
+
1651
+ /**
1652
+ * `[file, args, opts]` to spawn a package-manager launcher. On Windows that is one
1653
+ * command string run through a shell (an args array alongside `shell: true` trips
1654
+ * Node's DEP0190 warning). The shell sees every argument, so each must match a safe
1655
+ * alphabet; anything else throws instead of reaching it.
1656
+ * @param {string} cmd @param {string[]} args @param {object} [opts] @param {string} [platform]
1657
+ * @returns {[string, string[], object]}
1658
+ */
1659
+ export function launcherSpawn(cmd, args, opts = {}, platform = process.platform) {
1660
+ if (!launcherNeedsShell(platform)) return [cmd, args, opts];
1661
+ for (const a of args) {
1662
+ if (!SAFE_LAUNCHER_ARG.test(a)) throw new Error(`refusing to pass an unsafe argument to ${cmd} through a shell: ${a}`);
1663
+ }
1664
+ return [[cmd, ...args].join(" "), [], { ...opts, shell: true }];
1665
+ }
1666
+
1667
+ /**
1668
+ * Is the launcher on PATH? POSIX answers with ENOENT at spawn, so this is always true
1669
+ * there. Through cmd.exe a missing launcher is an ordinary non-zero exit that cannot be
1670
+ * told from a failed install, so Windows asks `where` (a real executable, no shell).
1671
+ * @param {string} cmd @param {string} [platform]
1672
+ */
1673
+ export function launcherAvailable(cmd, platform = process.platform) {
1674
+ if (!launcherNeedsShell(platform)) return true;
1675
+ return spawnSync("where", [cmd], { stdio: "ignore" }).status === 0;
1676
+ }
1677
+
1678
+ /** @param {string} cmd @param {string[]} args @param {object} [opts] */
1679
+ function spawnLauncherSync(cmd, args, opts = {}) {
1680
+ const [file, fileArgs, fileOpts] = launcherSpawn(cmd, args, opts);
1681
+ return spawnSync(file, fileArgs, fileOpts);
1682
+ }
1683
+
1601
1684
  function spawnAsyncResult(cmd, args, opts = {}) {
1602
1685
  return new Promise((resolvePromise) => {
1603
1686
  let settled = false;
1604
1687
  const settle = (v) => { if (!settled) { settled = true; resolvePromise(v); } };
1605
1688
  let child;
1606
1689
  try {
1607
- child = spawn(cmd, args, opts);
1690
+ child = spawn(...launcherSpawn(cmd, args, opts));
1608
1691
  } catch (error) {
1609
1692
  settle({ status: null, error });
1610
1693
  return;
@@ -1616,10 +1699,10 @@ function spawnAsyncResult(cmd, args, opts = {}) {
1616
1699
 
1617
1700
  /**
1618
1701
  * @param {string} runnerDir
1619
- * @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
1702
+ * @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any, available?: (cmd: string) => boolean }} [opts]
1620
1703
  * @returns {Promise<string>} the launcher that completed the install ("pnpm" | "corepack" | "npm").
1621
1704
  */
1622
- export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
1705
+ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult, available = launcherAvailable } = {}) {
1623
1706
  const fd = logPath ? openSync(logPath, "a") : null;
1624
1707
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
1625
1708
  // pnpm FIRST: the renderer tree is a bundle of native optional deps (rolldown,
@@ -1644,6 +1727,10 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1644
1727
  ];
1645
1728
  try {
1646
1729
  for (const { cmd, args } of attempts) {
1730
+ if (!available(cmd)) {
1731
+ if (fd !== null) writeSync(fd, `[tot] ${cmd} not found on PATH — trying the next launcher\n`);
1732
+ continue;
1733
+ }
1647
1734
  // Awaited: the default spawnFn (spawnAsyncResult) runs the launcher
1648
1735
  // NON-BLOCKING so the caller's "installing…" spinner keeps ticking during
1649
1736
  // the (long, one-time) npm install instead of freezing at "(0s)" — a
@@ -1661,6 +1748,13 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1661
1748
  if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
1662
1749
  continue;
1663
1750
  }
1751
+ // corepack is the one launcher that can run and still fail for a reason that has
1752
+ // nothing to do with the install (no write access to its shim directory, no
1753
+ // network to fetch pnpm); npm below is the escape hatch that always exists.
1754
+ if (cmd === "corepack") {
1755
+ if (fd !== null) writeSync(fd, `[tot] corepack exited ${r.status} — trying npm\n`);
1756
+ continue;
1757
+ }
1664
1758
  // The launcher ran; the install itself failed. That's the actionable error.
1665
1759
  // installRunnerTarball retries transient failures once from a clean slate;
1666
1760
  // deterministic broken-release 404s are marked permanent below and skip it.
@@ -25,11 +25,7 @@ import { join } from "node:path";
25
25
  import { hasLegacyOperatorEnv, legacyOperatorEnvAdvisory } from "../auth.mjs";
26
26
  import { MIN_NODE, nodeMeetsFloor } from "../ensure-node.mjs";
27
27
  import { clientPackages, osLabel } from "../mcp.mjs";
28
- import {
29
- isNativeWindows,
30
- nativeWindowsDetail,
31
- shouldAttemptDoctorAutoLogin,
32
- } from "../platform.mjs";
28
+ import { isNativeWindows, nativeWindowsDetail } from "../platform.mjs";
33
29
  import { readSessionCredentials, isExpired } from "../token-store.mjs";
34
30
  import { dockerAvailable, tryStartDocker, RENDERER_CACHE_ROOT, rendererArtifactComplete } from "./dev.mjs";
35
31
  import { loginAndCache } from "./login.mjs";
@@ -71,15 +67,15 @@ export function collectChecks(_ctx, env = process.env) {
71
67
  blocking: true,
72
68
  });
73
69
 
74
- // Native Windows isn't a supported host for the local loop (the runner install
75
- // + tar extraction assume a POSIX toolchain) — WSL is, and reports as linux
76
- // here. Say so up front instead of letting `tot login` / `tot dev` fail opaquely.
70
+ // Native Windows runs everything except the local renderer (`dev` / `start`: the
71
+ // runner install + tar extraction assume a POSIX toolchain) — WSL is the host for
72
+ // those, and reports as linux here. Informational, so the gap is visible up front.
77
73
  const nativeWindows = isNativeWindows();
78
74
  checks.push({
79
- name: "supported platform",
80
- pass: !nativeWindows,
75
+ name: "platform",
76
+ pass: true,
81
77
  detail: nativeWindows ? nativeWindowsDetail() : "macOS / Linux / WSL",
82
- blocking: true,
78
+ blocking: false,
83
79
  });
84
80
 
85
81
  // Informational: the versions + OS this invocation is running on — the same
@@ -181,17 +177,13 @@ async function applyFixes(checks, env) {
181
177
 
182
178
  const auth = checks.find((c) => c.name === "auth identity");
183
179
  if (auth && !auth.pass) {
184
- if (!shouldAttemptDoctorAutoLogin()) {
185
- console.log(` ~ not signed in — skipping auto-login (${nativeWindowsDetail()})`);
186
- } else {
187
- const mcpUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
188
- console.log(" ~ not signed in — opening the browser to sign in…");
189
- try {
190
- await loginAndCache(mcpUrl, env, { log: (m) => console.log(` ${m}`) });
191
- console.log(" ✓ signed in");
192
- } catch (e) {
193
- console.log(` ~ couldn't sign in automatically (${e?.message || e}) — run \`tot login\``);
194
- }
180
+ const mcpUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
181
+ console.log(" ~ not signed in — opening the browser to sign in…");
182
+ try {
183
+ await loginAndCache(mcpUrl, env, { log: (m) => console.log(` ${m}`) });
184
+ console.log(" ✓ signed in");
185
+ } catch (e) {
186
+ console.log(` ~ couldn't sign in automatically (${e?.message || e}) — run \`tot login\``);
195
187
  }
196
188
  }
197
189
  }
@@ -252,6 +252,13 @@ export function forgeShimPath(env = process.env) {
252
252
  return join(home, ".tot", "bin", "tot-git-credential");
253
253
  }
254
254
 
255
+ /** The shim path as git's `!` helper runs it (through sh). Git for Windows' sh reads
256
+ * forward slashes, not backslashes, and a home directory may contain spaces, so a
257
+ * Windows path is slash-normalized and single-quoted. POSIX paths pass through. Pure. */
258
+ export function helperCommandPath(shim, platform = process.platform) {
259
+ return platform === "win32" ? shq(shim.replace(/\\/g, "/")) : shim;
260
+ }
261
+
255
262
  /** The shim's contents: pin the ABSOLUTE node + CLI entry active at write time, with a
256
263
  * PATH-search fallback for node, and FAIL LOUD to stderr (never silently) when no node
257
264
  * is found — so a broken helper says how to fix itself instead of yielding an opaque
@@ -324,7 +331,7 @@ export function installForgeCredentialHelper(git, {
324
331
  } = {}) {
325
332
  if (!host) return false;
326
333
  const shim = writeShim(env, nodePath, entryPath);
327
- const helperValue = shim ? `!${shim}` : CREDENTIAL_HELPER;
334
+ const helperValue = shim ? `!${helperCommandPath(shim)}` : CREDENTIAL_HELPER;
328
335
  const key = `credential.https://${host}.helper`;
329
336
  let raw = null;
330
337
  try { raw = git(["config", "--local", "--get-all", key]); } catch { raw = null; }
package/src/open.mjs CHANGED
@@ -58,6 +58,17 @@ export async function firstFreePort(preferred, maxTries = 64) {
58
58
  return preferred;
59
59
  }
60
60
 
61
+ /**
62
+ * The OS opener as [command, args]. Windows goes through `rundll32` with no shell:
63
+ * `cmd /c start` would treat the `&` in an OAuth URL as a command separator.
64
+ * @param {string} url @param {string} platform @returns {[string, string[]]}
65
+ */
66
+ export function openerCommand(url, platform) {
67
+ if (platform === "darwin") return ["open", [url]];
68
+ if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
69
+ return ["xdg-open", [url]];
70
+ }
71
+
61
72
  /**
62
73
  * Open `url` in the user's default browser. Best-effort and non-blocking: the
63
74
  * child is detached + unref'd so it never holds `tot` open, and any failure
@@ -65,21 +76,9 @@ export async function firstFreePort(preferred, maxTries = 64) {
65
76
  * @returns {boolean} whether we managed to launch an opener.
66
77
  */
67
78
  export function openBrowser(url, platform = process.platform) {
68
- let cmd, args, shell = false;
69
- if (platform === "darwin") {
70
- cmd = "open";
71
- args = [url];
72
- } else if (platform === "win32") {
73
- // `start` is a shell builtin; the empty first arg is the window title.
74
- cmd = "start";
75
- args = ["", url];
76
- shell = true;
77
- } else {
78
- cmd = "xdg-open";
79
- args = [url];
80
- }
79
+ const [cmd, args] = openerCommand(url, platform);
81
80
  try {
82
- const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell });
81
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
83
82
  child.on("error", () => {}); // swallow "opener not found" async errors
84
83
  child.unref();
85
84
  return true;
package/src/platform.mjs CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
- * Host-platform readiness for the local loop.
2
+ * Host-platform copy shared by `tot doctor` and login recovery hints.
3
3
  *
4
- * Native Windows is unsupported (runner install + tar extraction assume a POSIX
5
- * toolchain). WSL reports as `linux` and is the supported path on a Windows box.
6
- * Keep the check + copy in ONE place so doctor, the CLI gate, and login recovery
7
- * hints cannot drift.
4
+ * Every command runs on native Windows. WSL reports as `linux` and remains the
5
+ * better-trodden path for the local renderer (`tot dev` / `tot start`).
8
6
  */
9
7
 
10
8
  export const WSL_INSTALL_URL = "https://learn.microsoft.com/windows/wsl/install";
@@ -17,75 +15,17 @@ export function isNativeWindows(platform = process.platform) {
17
15
  return platform === "win32";
18
16
  }
19
17
 
20
- /**
21
- * One-line detail used by `tot doctor` and CliError footers.
22
- * @param {{ url?: string }} [opts]
23
- */
24
- export function nativeWindowsDetail(opts = {}) {
25
- const url = opts.url ?? WSL_INSTALL_URL;
26
- return `native Windows isn't supported yet — run tot inside WSL (${url})`;
27
- }
28
-
29
- /**
30
- * Multi-line refuse message printed before exit. Names the attempted command so
31
- * a pasted invite (`tot login --code … && tot start`) still points at WSL.
32
- * @param {string} [command] e.g. "login", "start"
33
- */
34
- export function nativeWindowsRefuseMessage(command) {
35
- const verb = command ? `tot ${command}` : "tot";
36
- return (
37
- `✗ ${nativeWindowsDetail()}\n` +
38
- ` → next: install WSL, open an Ubuntu (or other Linux) terminal, install Node 24 LTS + git there,\n` +
39
- ` then re-run \`${verb}\` inside WSL — don't keep retrying login/--device on native Windows.\n`
40
- );
18
+ /** One-line detail used by `tot doctor`. */
19
+ export function nativeWindowsDetail() {
20
+ return `native Windows — if \`tot dev\` or \`tot start\` misbehaves, WSL is the better-trodden path (${WSL_INSTALL_URL})`;
41
21
  }
42
22
 
43
23
  /**
44
- * Commands that are safe / useful to run on native Windows so the developer can
45
- * learn the gate (`doctor`) or report friction (`feedback`) without a false start.
46
- */
47
- export const NATIVE_WINDOWS_ALLOWED_COMMANDS = new Set([
48
- "help",
49
- "--help",
50
- "-h",
51
- "--version",
52
- "-v",
53
- "version",
54
- "doctor",
55
- "feedback",
56
- "ideas",
57
- ]);
58
-
59
- /**
60
- * @param {string | null | undefined} command
61
- * @param {string} [platform] Node `process.platform` value (defaults to current).
62
- * @returns {boolean} true when this command must refuse on native Windows
63
- */
64
- export function shouldRefuseNativeWindows(command, platform = process.platform) {
65
- if (!isNativeWindows(/** @type {string} */ (platform))) return false;
66
- const cmd = String(command ?? "").trim();
67
- if (!cmd) return false; // bare `tot` → help
68
- return !NATIVE_WINDOWS_ALLOWED_COMMANDS.has(cmd);
69
- }
70
-
71
- /**
72
- * Recovery hint after a failed `tot login`. Never steers native Windows at
73
- * `--device` — that path still can't run the local loop on win32.
74
- * @param {{ device?: boolean, platform?: string }} [opts]
24
+ * Recovery hint after a failed `tot login`.
25
+ * @param {{ device?: boolean }} [opts]
75
26
  */
76
27
  export function loginFailureNext(opts = {}) {
77
- const platform = /** @type {string} */ (opts.platform ?? process.platform);
78
- if (isNativeWindows(platform)) return nativeWindowsDetail();
79
28
  return opts.device
80
29
  ? "re-run `tot login --device` and approve the code."
81
30
  : "re-run `tot login` and finish the browser sign-in (approve the request) — or try `tot login --device`.";
82
31
  }
83
-
84
- /**
85
- * `tot doctor --fix` must not open OAuth on native Windows — the session
86
- * would be unusable for the local loop, and the browser hop burns a cycle.
87
- * @param {string} [platform] Node `process.platform` value (defaults to current).
88
- */
89
- export function shouldAttemptDoctorAutoLogin(platform = process.platform) {
90
- return !isNativeWindows(/** @type {string} */ (platform));
91
- }