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

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.3",
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);
@@ -204,23 +205,42 @@ async function runNativePublic(workspace, args, ctx) {
204
205
  return bootNative(runnerDir, workspace, port, url, args);
205
206
  }
206
207
 
208
+ /**
209
+ * Read the hosted-activity-bridge credential (minted alongside the invite's
210
+ * browserless `tot login --code` paste — see login.mjs's cacheActivityBridge)
211
+ * and shape it as the two env vars dev-loop-state.mjs's postActivity reads.
212
+ * Returns {} when absent (an older cached session, a bare `tot login`, or the
213
+ * zero-login --sample path) — the local loop runs exactly the same either way,
214
+ * it just has no hosted signal to report. EVERY path that spawns the runner
215
+ * (native monorepo, native standalone, Docker) must thread this in, or the
216
+ * developer's hosted /dev panel never lights up even though they cached a
217
+ * valid bridge credential at login.
218
+ * @param {NodeJS.ProcessEnv} [env]
219
+ * @returns {{TOT_DEV_ACTIVITY_TOKEN?: string, TOT_DEV_ACTIVITY_URL?: string}}
220
+ */
221
+ export function activityBridgeEnv(env = process.env) {
222
+ const creds = readCredentials(defaultCredentialsPath(env));
223
+ if (!creds?.activityToken || !creds?.activityUrl) return {};
224
+ return { TOT_DEV_ACTIVITY_TOKEN: creds.activityToken, TOT_DEV_ACTIVITY_URL: creds.activityUrl };
225
+ }
226
+
227
+ /**
228
+ * Flatten an env object into repeated `-e KEY=VAL` docker-run args. Pure, so the
229
+ * Docker activity-bridge threading is testable without a real Docker daemon.
230
+ * @param {Record<string, string>} env
231
+ * @returns {string[]}
232
+ */
233
+ export function dockerEnvArgs(env) {
234
+ return Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
235
+ }
236
+
207
237
  function runMonorepo(ctx, argv) {
208
238
  const script = join(ctx.repoRoot, "scripts", "tot-dev.mjs");
209
239
  if (!existsSync(script)) {
210
240
  console.error(`✗ expected the dev runner at ${script} but it's missing.`);
211
241
  return 2;
212
242
  }
213
- // Thread the hosted-activity-bridge credential (if this cached session has one —
214
- // minted alongside the invite's cli-signin-code paste) so the spawned tot-dev.mjs
215
- // can report file-save activity up to the developer's own hosted /dev panel.
216
- // Best-effort: an older cached session (or a bare `tot login`) simply has
217
- // neither field, and the local loop runs exactly as before with no hosted signal.
218
- const creds = readCredentials(defaultCredentialsPath(process.env));
219
- const env = { ...process.env };
220
- if (creds?.activityToken && creds?.activityUrl) {
221
- env.TOT_DEV_ACTIVITY_TOKEN = creds.activityToken;
222
- env.TOT_DEV_ACTIVITY_URL = creds.activityUrl;
223
- }
243
+ const env = { ...process.env, ...activityBridgeEnv() };
224
244
  return new Promise((resolvePromise) => {
225
245
  const child = spawn(process.execPath, [script, ...argv], { stdio: "inherit", env });
226
246
  child.on("exit", (code) => resolvePromise(code ?? 0));
@@ -270,7 +290,9 @@ async function runNative(workspace, args, ctx) {
270
290
  * zero-login sample path (runSample) so both boot identically.
271
291
  */
272
292
  function bootNative(runnerDir, workspace, port, url, args) {
273
- const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit" });
293
+ // activityBridgeEnv() is a no-op {} for the zero-login sample path (no cached
294
+ // credential exists there) — safe to always thread it through.
295
+ const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit", env: activityBridgeEnv() });
274
296
 
275
297
  // Auto-open the browser the moment the server answers (D). Non-blocking so
276
298
  // Ctrl-C / logs are unaffected; --no-open suppresses it.
@@ -500,20 +522,64 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
500
522
  }
501
523
 
502
524
  /**
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).
525
+ * The version-pinned public-runner cache dir for `version` IFF it's present and
526
+ * fully installed (carries the `.tot-cache-complete` marker), else null. This is
527
+ * the VERSION-AWARE reuse that replaced the old "newest dir by mtime" reuse: it
528
+ * only ever returns the dir for the version the pin actually chose, so a stale
529
+ * pre-pin dir (public-0.1.0) can never be served in place of the pinned one
530
+ * (the 2026-07-14 "cached runner keeps being reused" bug). Pure fs probe.
531
+ */
532
+ export function pinnedPublicCacheDir(cacheRoot, version) {
533
+ if (!cacheRoot || !version) return null;
534
+ const dir = join(cacheRoot, `public-${version}`);
535
+ return existsSync(join(dir, ".tot-cache-complete")) ? dir : null;
536
+ }
537
+
538
+ /**
539
+ * Delete every OTHER public-runner cache dir (`public-*` except `keepVersion`)
540
+ * once we've resolved the version this CLI pins to — so a pre-alignment dir
541
+ * (e.g. public-0.1.0 from before the version pin existed) can't linger to be
542
+ * picked up by the offline last-resort reuse, and disk doesn't accumulate one
543
+ * runner tree per version forever. Only touches `public-*` dirs: the entitled
544
+ * path caches by bare version and sample tarball runs by `sample-*`, both left
545
+ * alone. Best-effort — a failure here never breaks `tot dev`.
546
+ */
547
+ function prunePublicRunnerCache(cacheRoot, keepVersion) {
548
+ try {
549
+ if (!cacheRoot || !existsSync(cacheRoot)) return;
550
+ const keep = `public-${keepVersion}`;
551
+ for (const name of readdirSync(cacheRoot)) {
552
+ if (!name.startsWith("public-") || name === keep) continue;
553
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
554
+ }
555
+ } catch {
556
+ /* best-effort cache hygiene */
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Resolve the renderer for the ZERO-LOGIN sample / public-fallback path WITHOUT
562
+ * any MCP call — the whole point of the free taste. Resolution order:
563
+ * 1. env override (TOT_RUNNER_DIR/TARBALL/URL) or the in-tree monorepo runner
564
+ * (resolveLocalRendererSource) — used verbatim.
565
+ * 2. otherwise the PUBLIC npm runner, VERSION-PINNED to this CLI (pickRunnerVersion):
566
+ * reuse the matching `public-<version>` cache dir if present, else download it.
567
+ *
568
+ * The cache is keyed `public-<version>`, so we reuse the RIGHT version offline and
569
+ * NEVER a stale one. This is the fix for the 2026-07-14 bug: the resolver used to
570
+ * return the newest cache dir by mtime with no version check, short-circuiting
571
+ * BEFORE the pin ran, so a pre-pin public-0.1.0 was reused forever and the CLI
572
+ * never asked npm for the aligned runner.
573
+ *
507
574
  * Unlike ensureRendererArtifact it does NOT fall back to Docker — the sample is
508
575
  * deliberately Docker-free and login-free.
509
576
  * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
510
577
  */
511
- export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}) {
578
+ export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
512
579
  const src = resolveLocalRendererSource({
513
580
  env,
514
581
  mode: ctx?.mode,
515
582
  repoRoot: ctx?.repoRoot,
516
- cacheRoot: RENDERER_CACHE_ROOT,
517
583
  });
518
584
 
519
585
  if (src.kind === "dir") {
@@ -535,12 +601,39 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}
535
601
  );
536
602
  }
537
603
 
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.
604
+ // kind === "none" — no override, not in the monorepo: fetch the PUBLIC runner
605
+ // from npm, PINNED to this CLI's version. No MCP, no entitlement, no login.
606
+ const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
607
+
608
+ // Fully-offline fast path: when the runner published lockstep at this exact CLI
609
+ // version is already cached, reuse it without touching npm — honouring "don't
610
+ // hit npm when the RIGHT version is already cached" without ever reusing a
611
+ // version the pin didn't choose. Safe because `public-<CLI_VERSION>` only exists
612
+ // if a prior run fetched exactly that (pinned) version. Skipped when an explicit
613
+ // pin is set (that must go through resolution).
614
+ if (!explicitPin) {
615
+ const exact = pinnedPublicCacheDir(cacheRoot, CLI_VERSION);
616
+ if (exact) {
617
+ console.error(`~ renderer: cached public runner ${CLI_VERSION} (matches this CLI)`);
618
+ prunePublicRunnerCache(cacheRoot, CLI_VERSION);
619
+ return exact;
620
+ }
621
+ }
622
+
540
623
  let pub;
541
624
  try {
542
625
  pub = await resolvePublicRendererSource(args, env);
543
626
  } catch (e) {
627
+ // OFFLINE / npm unreachable — no way to resolve the pinned version. Last
628
+ // resort: reuse ANY complete cached runner rather than hard-failing `tot dev`.
629
+ // This is the ONLY place a version-unchecked reuse survives, and only because
630
+ // there's no network to pin against. (Once one online run has aligned + pruned
631
+ // the cache, this reuses the correct version anyway.)
632
+ const cached = newestCachedRunner(cacheRoot);
633
+ if (cached && existsSync(join(cached, "scripts", "tot-dev.mjs"))) {
634
+ console.error(`~ renderer: offline — reusing cached runner ${cached} (couldn't reach npm to pin the version)`);
635
+ return cached;
636
+ }
544
637
  throw new CliError(
545
638
  `can't run the sample — no local runner and the public runner is unavailable: ${e?.message || e}`,
546
639
  {
@@ -551,10 +644,14 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}
551
644
  );
552
645
  }
553
646
  console.error(`~ renderer: public npm ${pub.version} (${PUBLIC_RUNNER_PACKAGE})`);
554
- return installRunnerTarball(
647
+ const dir = await installRunnerTarball(
555
648
  { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
556
649
  { log: (m) => console.error(m) },
557
650
  );
651
+ // The pinned version is now installed under public-<version> — drop any other
652
+ // public-* dirs (e.g. the stale public-0.1.0) so they can never be reused.
653
+ prunePublicRunnerCache(cacheRoot, pub.version);
654
+ return dir;
558
655
  }
559
656
 
560
657
  /** A stable, filesystem-safe cache key for a renderer resolved from a tarball source. */
@@ -682,17 +779,19 @@ function runPnpmInstall(runnerDir) {
682
779
  * auto-open-browser logic works unchanged for either runtime. Exported (and
683
780
  * `stdio` overridable) so `tot start` can pipe the logs instead of inheriting.
684
781
  */
685
- export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit" } = {}) {
782
+ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit", env = {} } = {}) {
686
783
  const script = join(runnerDir, "scripts", "tot-dev.mjs");
687
784
  // Translate the same "inherit"|"piped" contract spawnDevContainer honors:
688
785
  // "piped" means stdin ignored + stdout/stderr captured so `tot start` can hold
689
786
  // the prompt on stdin and stream the logs after the aha. child_process.spawn
690
787
  // doesn't understand the bare string "piped", so map it to the array here.
691
788
  const stdioArr = stdio === "piped" ? ["ignore", "pipe", "pipe"] : stdio;
789
+ // `env` (e.g. activityBridgeEnv()) merges OVER process.env — {} is a pure
790
+ // passthrough, identical to the old no-env-key behavior.
692
791
  const child = spawn(
693
792
  process.execPath,
694
793
  [script, "--workspace", workspace, "--port", port],
695
- { cwd: runnerDir, stdio: stdioArr },
794
+ { cwd: runnerDir, stdio: stdioArr, env: { ...process.env, ...env } },
696
795
  );
697
796
  const handle = { child, exited: false, done: null };
698
797
  handle.done = new Promise((resolvePromise) => {
@@ -798,6 +897,10 @@ export function buildContainerPlan(workspace, args, _ctx) {
798
897
  "CHOKIDAR_USEPOLLING=1",
799
898
  "-e",
800
899
  "CHOKIDAR_INTERVAL=300",
900
+ // Docker containers don't inherit the host env — thread the activity-bridge
901
+ // credential explicitly (see activityBridgeEnv), or the Docker runtime never
902
+ // reports to the developer's hosted /dev panel even when native would.
903
+ ...dockerEnvArgs(activityBridgeEnv()),
801
904
  image,
802
905
  ];
803
906
 
@@ -60,7 +60,7 @@ import {
60
60
  buildContainerPlan, spawnDevContainer, dockerAvailable, tryStartDocker,
61
61
  resolveDevImage, isPrivateRegistryImage, ensureRegistryLogin,
62
62
  ensureRendererArtifact, ensureSampleRenderer, spawnNativeDev, deriveUrl,
63
- NativeArtifactUnavailableError,
63
+ activityBridgeEnv, NativeArtifactUnavailableError,
64
64
  } from "./dev.mjs";
65
65
  import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
66
66
  import { IDEAS } from "./ideas.mjs";
@@ -233,7 +233,7 @@ export async function run(argv, ctx) {
233
233
  } else {
234
234
  url = deriveUrl(ctxDev.config || {}, devArgs.port).url;
235
235
  console.log(` → starting dev … ${url}`);
236
- handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped" });
236
+ handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped", env: activityBridgeEnv(env) });
237
237
  }
238
238
 
239
239
  const up = await Promise.race([
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;
@@ -14,8 +14,9 @@
14
14
  * to a different one. `activityToken`/`activityUrl` are a SEPARATE credential
15
15
  * (storefront-issued, not MCP OAuth) that `tot login --code` caches when the
16
16
  * invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
17
- * commands/dev.mjs `runMonorepo`, which threads them to the local dev-loop
18
- * process so it can report file saves to the developer's hosted /dev panel.
17
+ * commands/dev.mjs `activityBridgeEnv`, which every runner-spawning path (native
18
+ * monorepo, native standalone, Docker) threads in so the local dev-loop process
19
+ * can report file saves to the developer's hosted /dev panel.
19
20
  * Dependency-free (node:fs/os/path).
20
21
  *
21
22
  * `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).