@tokenoftrust/cli 1.3.0-rc.0 → 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.0",
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);
@@ -386,7 +387,15 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
386
387
  return { version: distTags[explicitPin] || explicitPin, reason: `pinned ${explicitPin}` };
387
388
  }
388
389
 
389
- // 2) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
390
+ // 2) EXACT CLI-version match the primary path for a lockstep release: the runner
391
+ // is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
392
+ // which the stable-only minor match below deliberately skips). This is what makes
393
+ // `tot@1.3.0-rc.0` pull `runner@1.3.0-rc.0` instead of falling back to stale latest.
394
+ if (versions.includes(cliVersion)) {
395
+ return { version: cliVersion, reason: "exact CLI-version match" };
396
+ }
397
+
398
+ // 3) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
390
399
  const m = /^(\d+)\.(\d+)\./.exec(cliVersion || "");
391
400
  if (m) {
392
401
  const prefix = `${m[1]}.${m[2]}.`;
@@ -398,7 +407,7 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
398
407
  }
399
408
  }
400
409
 
401
- // 3) Fallback: the `latest` dist-tag (pre-alignment safety net).
410
+ // 4) Fallback: the `latest` dist-tag (pre-alignment safety net).
402
411
  if (distTags.latest) return { version: distTags.latest, reason: "fell back to latest (no CLI-minor match)" };
403
412
  throw new Error("no publishable runner version (no CLI-minor match and no `latest` dist-tag)");
404
413
  }
@@ -492,20 +501,64 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
492
501
  }
493
502
 
494
503
  /**
495
- * Resolve the renderer for the ZERO-LOGIN sample path WITHOUT any MCP call the
496
- * whole point of the free taste. Uses resolveRendererSource() (env override →
497
- * cached runner from a prior `tot dev` in-tree monorepo), and only when none of
498
- * 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
+ *
499
553
  * Unlike ensureRendererArtifact it does NOT fall back to Docker — the sample is
500
554
  * deliberately Docker-free and login-free.
501
555
  * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
502
556
  */
503
- export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}) {
557
+ export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
504
558
  const src = resolveLocalRendererSource({
505
559
  env,
506
560
  mode: ctx?.mode,
507
561
  repoRoot: ctx?.repoRoot,
508
- cacheRoot: RENDERER_CACHE_ROOT,
509
562
  });
510
563
 
511
564
  if (src.kind === "dir") {
@@ -527,12 +580,39 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}
527
580
  );
528
581
  }
529
582
 
530
- // kind === "none" — nothing local: fetch the PUBLIC runner from npm (the seam
531
- // 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
+
532
602
  let pub;
533
603
  try {
534
604
  pub = await resolvePublicRendererSource(args, env);
535
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
+ }
536
616
  throw new CliError(
537
617
  `can't run the sample — no local runner and the public runner is unavailable: ${e?.message || e}`,
538
618
  {
@@ -543,10 +623,14 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}
543
623
  );
544
624
  }
545
625
  console.error(`~ renderer: public npm ${pub.version} (${PUBLIC_RUNNER_PACKAGE})`);
546
- return installRunnerTarball(
626
+ const dir = await installRunnerTarball(
547
627
  { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
548
628
  { log: (m) => console.error(m) },
549
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;
550
634
  }
551
635
 
552
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;