@tokenoftrust/cli 1.3.0-rc.1 → 1.3.0-rc.2

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
@@ -58,12 +58,21 @@ tot — Token of Trust developer CLI
58
58
  tot ideas copy-paste AI prompts that reliably wow
59
59
  tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
60
60
  tot help show this help
61
+ tot --version print the CLI version
61
62
 
62
63
  Run \`tot <command> --help\` for command-specific options.
63
64
  `);
64
65
  }
65
66
 
66
67
  async function dispatch(cmd, rest, ctx) {
68
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
69
+ // Bare version so it's greppable + machine-parseable (e.g. `tot --version` →
70
+ // `1.3.0-rc.2`). The FIRST thing to check when diagnosing "am I on the new
71
+ // CLI?" — a bare `npm i -g @tokenoftrust/cli` lands `latest`, not `@next`.
72
+ console.log(VERSION);
73
+ return 0;
74
+ }
75
+
67
76
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
68
77
  usage();
69
78
  return cmd ? 0 : 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.0-rc.1",
3
+ "version": "1.3.0-rc.2",
4
4
  "description": "Token of Trust developer CLI — check out 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",
@@ -32,7 +32,7 @@
32
32
  * either way.
33
33
  */
34
34
  import { spawn, spawnSync, execFileSync } from "node:child_process";
35
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, createWriteStream } from "node:fs";
35
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, readdirSync, createWriteStream } from "node:fs";
36
36
  import { homedir, tmpdir } from "node:os";
37
37
  import { join, resolve } from "node:path";
38
38
  import { createHash } from "node:crypto";
@@ -46,7 +46,7 @@ import { CliError, fail, formatError } from "../errors.mjs";
46
46
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
47
47
  import {
48
48
  scaffoldSample, isSampleCheckout, sampleConfig,
49
- resolveRendererSource as resolveLocalRendererSource, SAMPLE_DIR_NAME,
49
+ resolveRendererSource as resolveLocalRendererSource, newestCachedRunner, SAMPLE_DIR_NAME,
50
50
  } from "../sample.mjs";
51
51
 
52
52
  /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
@@ -180,9 +180,10 @@ async function runStandalone(workspace, args, ctx) {
180
180
  * Native boot against the PUBLIC/local renderer (no MCP, no
181
181
  * entitlement) — the fallback for a standalone checkout when the entitled
182
182
  * artifact isn't available. Mirrors runNative but resolves the renderer via
183
- * ensureSampleRenderer (env override → cached runner → in-tree monorepo →
184
- * public npm), which throws NativeArtifactUnavailableError when even the public
185
- * runner can't be fetched (offline) so the caller can drop to Docker.
183
+ * ensureSampleRenderer (env override → in-tree monorepo → version-pinned public
184
+ * npm, reusing the matching `public-<version>` cache), which throws
185
+ * NativeArtifactUnavailableError when even the public runner can't be fetched
186
+ * (offline) so the caller can drop to Docker.
186
187
  */
187
188
  async function runNativePublic(workspace, args, ctx) {
188
189
  const cfg = readWorkspaceConfig(workspace);
@@ -500,20 +501,64 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
500
501
  }
501
502
 
502
503
  /**
503
- * Resolve the renderer for the ZERO-LOGIN sample path WITHOUT any MCP call the
504
- * whole point of the free taste. Uses resolveRendererSource() (env override →
505
- * cached runner from a prior `tot dev` in-tree monorepo), and only when none of
506
- * those exist does it fail with the exact seam WS3b fills (a public tarball URL).
504
+ * The version-pinned public-runner cache dir for `version` IFF it's present and
505
+ * fully installed (carries the `.tot-cache-complete` marker), else null. This is
506
+ * the VERSION-AWARE reuse that replaced the old "newest dir by mtime" reuse: it
507
+ * only ever returns the dir for the version the pin actually chose, so a stale
508
+ * pre-pin dir (public-0.1.0) can never be served in place of the pinned one
509
+ * (the 2026-07-14 "cached runner keeps being reused" bug). Pure fs probe.
510
+ */
511
+ export function pinnedPublicCacheDir(cacheRoot, version) {
512
+ if (!cacheRoot || !version) return null;
513
+ const dir = join(cacheRoot, `public-${version}`);
514
+ return existsSync(join(dir, ".tot-cache-complete")) ? dir : null;
515
+ }
516
+
517
+ /**
518
+ * Delete every OTHER public-runner cache dir (`public-*` except `keepVersion`)
519
+ * once we've resolved the version this CLI pins to — so a pre-alignment dir
520
+ * (e.g. public-0.1.0 from before the version pin existed) can't linger to be
521
+ * picked up by the offline last-resort reuse, and disk doesn't accumulate one
522
+ * runner tree per version forever. Only touches `public-*` dirs: the entitled
523
+ * path caches by bare version and sample tarball runs by `sample-*`, both left
524
+ * alone. Best-effort — a failure here never breaks `tot dev`.
525
+ */
526
+ function prunePublicRunnerCache(cacheRoot, keepVersion) {
527
+ try {
528
+ if (!cacheRoot || !existsSync(cacheRoot)) return;
529
+ const keep = `public-${keepVersion}`;
530
+ for (const name of readdirSync(cacheRoot)) {
531
+ if (!name.startsWith("public-") || name === keep) continue;
532
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
533
+ }
534
+ } catch {
535
+ /* best-effort cache hygiene */
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Resolve the renderer for the ZERO-LOGIN sample / public-fallback path WITHOUT
541
+ * any MCP call — the whole point of the free taste. Resolution order:
542
+ * 1. env override (TOT_RUNNER_DIR/TARBALL/URL) or the in-tree monorepo runner
543
+ * (resolveLocalRendererSource) — used verbatim.
544
+ * 2. otherwise the PUBLIC npm runner, VERSION-PINNED to this CLI (pickRunnerVersion):
545
+ * reuse the matching `public-<version>` cache dir if present, else download it.
546
+ *
547
+ * The cache is keyed `public-<version>`, so we reuse the RIGHT version offline and
548
+ * NEVER a stale one. This is the fix for the 2026-07-14 bug: the resolver used to
549
+ * return the newest cache dir by mtime with no version check, short-circuiting
550
+ * BEFORE the pin ran, so a pre-pin public-0.1.0 was reused forever and the CLI
551
+ * never asked npm for the aligned runner.
552
+ *
507
553
  * Unlike ensureRendererArtifact it does NOT fall back to Docker — the sample is
508
554
  * deliberately Docker-free and login-free.
509
555
  * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
510
556
  */
511
- export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}) {
557
+ export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
512
558
  const src = resolveLocalRendererSource({
513
559
  env,
514
560
  mode: ctx?.mode,
515
561
  repoRoot: ctx?.repoRoot,
516
- cacheRoot: RENDERER_CACHE_ROOT,
517
562
  });
518
563
 
519
564
  if (src.kind === "dir") {
@@ -535,12 +580,39 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}
535
580
  );
536
581
  }
537
582
 
538
- // kind === "none" — nothing local: fetch the PUBLIC runner from npm (the seam
539
- // ws1b left for WS3b). No MCP, no entitlement, no login — the point of --sample.
583
+ // kind === "none" — no override, not in the monorepo: fetch the PUBLIC runner
584
+ // from npm, PINNED to this CLI's version. No MCP, no entitlement, no login.
585
+ const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
586
+
587
+ // Fully-offline fast path: when the runner published lockstep at this exact CLI
588
+ // version is already cached, reuse it without touching npm — honouring "don't
589
+ // hit npm when the RIGHT version is already cached" without ever reusing a
590
+ // version the pin didn't choose. Safe because `public-<CLI_VERSION>` only exists
591
+ // if a prior run fetched exactly that (pinned) version. Skipped when an explicit
592
+ // pin is set (that must go through resolution).
593
+ if (!explicitPin) {
594
+ const exact = pinnedPublicCacheDir(cacheRoot, CLI_VERSION);
595
+ if (exact) {
596
+ console.error(`~ renderer: cached public runner ${CLI_VERSION} (matches this CLI)`);
597
+ prunePublicRunnerCache(cacheRoot, CLI_VERSION);
598
+ return exact;
599
+ }
600
+ }
601
+
540
602
  let pub;
541
603
  try {
542
604
  pub = await resolvePublicRendererSource(args, env);
543
605
  } catch (e) {
606
+ // OFFLINE / npm unreachable — no way to resolve the pinned version. Last
607
+ // resort: reuse ANY complete cached runner rather than hard-failing `tot dev`.
608
+ // This is the ONLY place a version-unchecked reuse survives, and only because
609
+ // there's no network to pin against. (Once one online run has aligned + pruned
610
+ // the cache, this reuses the correct version anyway.)
611
+ const cached = newestCachedRunner(cacheRoot);
612
+ if (cached && existsSync(join(cached, "scripts", "tot-dev.mjs"))) {
613
+ console.error(`~ renderer: offline — reusing cached runner ${cached} (couldn't reach npm to pin the version)`);
614
+ return cached;
615
+ }
544
616
  throw new CliError(
545
617
  `can't run the sample — no local runner and the public runner is unavailable: ${e?.message || e}`,
546
618
  {
@@ -551,10 +623,14 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}
551
623
  );
552
624
  }
553
625
  console.error(`~ renderer: public npm ${pub.version} (${PUBLIC_RUNNER_PACKAGE})`);
554
- return installRunnerTarball(
626
+ const dir = await installRunnerTarball(
555
627
  { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
556
628
  { log: (m) => console.error(m) },
557
629
  );
630
+ // The pinned version is now installed under public-<version> — drop any other
631
+ // public-* dirs (e.g. the stale public-0.1.0) so they can never be reused.
632
+ prunePublicRunnerCache(cacheRoot, pub.version);
633
+ return dir;
558
634
  }
559
635
 
560
636
  /** A stable, filesystem-safe cache key for a renderer resolved from a tarball source. */
package/src/sample.mjs CHANGED
@@ -142,19 +142,25 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
142
142
  * 3. TOT_RUNNER_URL — a PUBLIC https tarball URL. ← the seam WS3b bakes a
143
143
  * default into (a well-known public CDN URL), so a bare
144
144
  * `npx … dev --sample` on a clean machine Just Works.
145
- * 4. cache reuse a runner a prior authenticated `tot dev` already
146
- * downloaded under ~/.tot/cache/renderer/<version>/.
147
- * Piggybacks on it with no network at all.
148
- * 5. monorepo — inside the storefront monorepo the runner IS the tree;
145
+ * 4. monorepo inside the storefront monorepo the runner IS the tree;
149
146
  * run its in-tree scripts/tot-dev.mjs directly.
150
- * 6. none — nothing local + no public URL yet → caller emits the
151
- * exact seam WS3b must fill.
147
+ * 5. none — no override + not in the monorepo → caller fetches the
148
+ * VERSION-PINNED public runner from npm (dev.mjs).
149
+ *
150
+ * NOTE — cache reuse deliberately does NOT live here anymore. This resolver is
151
+ * sync + offline, but the correct cached runner is the one matching the version
152
+ * the CLI PINS to (pickRunnerVersion), which needs npm metadata. Reusing "the
153
+ * newest cache dir by mtime" here (the pre-2026-07-14 behaviour) short-circuited
154
+ * BEFORE the version pin ever ran, so a stale pre-pin dir (public-0.1.0) got
155
+ * reused forever. Version-aware reuse now happens in dev.mjs `ensureSampleRenderer`
156
+ * (reuse `public-<pinnedVersion>` iff present, else fetch); `newestCachedRunner`
157
+ * is exported below only as an OFFLINE last resort when npm can't be reached at all.
152
158
  *
153
159
  * Pure/deterministic given its inputs (env + fs probes) — no network, no MCP.
154
- * @param {{ env?: NodeJS.ProcessEnv, mode?: string, repoRoot?: string|null, cacheRoot?: string }} [opts]
160
+ * @param {{ env?: NodeJS.ProcessEnv, mode?: string, repoRoot?: string|null }} [opts]
155
161
  * @returns {{ kind: "dir"|"tarball"|"none", dir?: string, source?: string, isUrl?: boolean, why: string }}
156
162
  */
157
- export function resolveRendererSource({ env = process.env, mode = "loose", repoRoot = null, cacheRoot } = {}) {
163
+ export function resolveRendererSource({ env = process.env, mode = "loose", repoRoot = null } = {}) {
158
164
  if (env.TOT_RUNNER_DIR) {
159
165
  return { kind: "dir", dir: resolve(env.TOT_RUNNER_DIR), why: "TOT_RUNNER_DIR" };
160
166
  }
@@ -164,27 +170,25 @@ export function resolveRendererSource({ env = process.env, mode = "loose", repoR
164
170
  if (env.TOT_RUNNER_URL) {
165
171
  return { kind: "tarball", source: env.TOT_RUNNER_URL, isUrl: true, why: "TOT_RUNNER_URL" };
166
172
  }
167
- const cached = newestCachedRunner(cacheRoot);
168
- if (cached) {
169
- return { kind: "dir", dir: cached, why: "cached runner (prior `tot dev`)" };
170
- }
171
173
  if (mode === "monorepo" && repoRoot && existsSync(join(repoRoot, "scripts", "tot-dev.mjs"))) {
172
174
  return { kind: "dir", dir: repoRoot, why: "in-tree monorepo runner" };
173
175
  }
174
176
  return {
175
177
  kind: "none",
176
178
  why:
177
- "no local runner and no public renderer URL configured yet (WS3b seam) set TOT_RUNNER_URL / TOT_RUNNER_TARBALL / TOT_RUNNER_DIR, or run once inside the storefront monorepo",
179
+ "no local runner override and not in the monorepo fetch the version-pinned public runner from npm (set TOT_RUNNER_URL / TOT_RUNNER_TARBALL / TOT_RUNNER_DIR to override)",
178
180
  };
179
181
  }
180
182
 
181
183
  /**
182
184
  * Newest fully-installed runner under the renderer cache (dirs carry a
183
- * `.tot-cache-complete` marker written by dev.mjs). Lets the sample path reuse a
184
- * runner a prior authenticated run already fetched, with no network. Returns the
185
- * absolute dir or null.
185
+ * `.tot-cache-complete` marker written by dev.mjs). Returns the absolute dir or
186
+ * null. NOT part of the normal resolution order it's version-UNAWARE (picks by
187
+ * mtime), so dev.mjs uses it only as an OFFLINE last resort (npm unreachable, so
188
+ * no version to pin against); the online path reuses the version-pinned
189
+ * `public-<version>` dir instead. Exported for that single caller.
186
190
  */
187
- function newestCachedRunner(cacheRoot) {
191
+ export function newestCachedRunner(cacheRoot) {
188
192
  if (!cacheRoot || !existsSync(cacheRoot)) return null;
189
193
  let best = null;
190
194
  let bestMtime = -1;