@tokenoftrust/cli 1.3.0-rc.4 → 1.3.1-rc.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.0-rc.4",
3
+ "version": "1.3.1-rc.1",
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",
@@ -166,14 +166,15 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
166
166
  await client.callTool("client_switch", { tenant });
167
167
  const checkout = await client.callTool("tenant_checkout", { tenant, tag });
168
168
 
169
- const gitRemote = checkout?.gitRemote;
170
- const cloneUrl = checkout?.cloneUrl ?? null;
171
- if (!gitRemote) {
172
- throw new CliError(
173
- `tenant_checkout returned no gitRemote for ${tenant}: ${redact(JSON.stringify(checkout))}`,
174
- { next: "confirm you're entitled to this store — `tot checkout` (lists your stores)" },
175
- );
169
+ // A non-checkout result (not provisioned / not entitled / failed) must surface
170
+ // the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
171
+ // dump (James's 2026-07-19 first-experience failure).
172
+ const err = checkoutError(checkout);
173
+ if (err) {
174
+ throw new CliError(redact(err.message), { next: err.next });
176
175
  }
176
+ const gitRemote = checkout.gitRemote;
177
+ const cloneUrl = checkout.cloneUrl ?? null;
177
178
  const u = new URL(gitRemote);
178
179
  const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
179
180
 
@@ -200,6 +201,42 @@ function cloneRepo(gitRemote, dir, redact) {
200
201
  return { dir, head };
201
202
  }
202
203
 
204
+ /**
205
+ * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
206
+ * checkout (carries a gitRemote), otherwise a human { message, next } pair so
207
+ * callers surface the MCP's own words + a concrete next step instead of dumping
208
+ * raw JSON (James's 2026-07-19 first-experience failure: a not-provisioned store
209
+ * printed JSON.stringify(checkout)). callTool unwraps the tool result to its
210
+ * structuredContent / parsed text, so a failure surfaces as an error-ish status
211
+ * ('forbidden' | 'invalid_input' | 'checkout_failed' | 'error'), a `message`, or
212
+ * simply a missing gitRemote. When the repo isn't provisioned yet we speak to the
213
+ * INVITED DEVELOPER ("your store isn't set up yet"), not the operator — dropping
214
+ * the `repo_provision` jargon the MCP aims at whoever provisions. Any other
215
+ * failure surfaces the MCP's own message. Pure + exported so it's unit-tested
216
+ * without any I/O.
217
+ * @param {unknown} checkout
218
+ * @returns {{ message: string, next: string }|null}
219
+ */
220
+ export function checkoutError(checkout) {
221
+ const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
222
+ if (c && c.gitRemote) return null; // a usable checkout — never an error
223
+ const msg =
224
+ (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
225
+ (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
226
+ null;
227
+ // The store's repo isn't provisioned yet — it isn't set up for this developer.
228
+ if (typeof msg === "string" && /not provisioned/i.test(msg)) {
229
+ return {
230
+ message: "your store isn't set up on Token of Trust yet",
231
+ next: "ask your Token of Trust contact to finish setting up your store, then re-run",
232
+ };
233
+ }
234
+ return {
235
+ message: msg || "the store checkout couldn't be completed",
236
+ next: "confirm you're entitled to this store — `tot checkout` (lists your stores)",
237
+ };
238
+ }
239
+
203
240
  /**
204
241
  * Normalize the (shape-varying) `client_list` response into a plain, sorted
205
242
  * list of the stores this identity can build on. Shared by `tot checkout`'s
@@ -32,18 +32,19 @@
32
32
  * either way.
33
33
  */
34
34
  import { spawn, spawnSync, execFileSync } from "node:child_process";
35
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, readdirSync, createWriteStream } from "node:fs";
35
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, readdirSync, createWriteStream, openSync, closeSync } 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";
39
39
  import { Readable } from "node:stream";
40
40
  import { pipeline } from "node:stream/promises";
41
41
  import { setTimeout as delay } from "node:timers/promises";
42
- import { createMcpClient, CLI_VERSION } from "../mcp.mjs";
42
+ import { createMcpClient, CLI_VERSION, setRunnerVersion } from "../mcp.mjs";
43
43
  import { establishSession } from "../auth.mjs";
44
44
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
45
45
  import { CliError, fail, formatError } from "../errors.mjs";
46
46
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
47
+ import { startProgress } from "../progress.mjs";
47
48
  import {
48
49
  scaffoldSample, isSampleCheckout, sampleConfig,
49
50
  resolveRendererSource as resolveLocalRendererSource, newestCachedRunner, SAMPLE_DIR_NAME,
@@ -417,7 +418,21 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
417
418
  const distTags = meta?.["dist-tags"] || {};
418
419
  const versions = Object.keys(meta?.versions || {});
419
420
 
420
- // 1) Explicit pin (flag/env): resolve a dist-tag name, else take it verbatim.
421
+ // INVARIANT: the runner must never run AHEAD of the CLI's major.minor. A runner
422
+ // published for a newer minor can expect CLI features this CLI doesn't have, so a
423
+ // too-new runner is a silent-skew bug. `cliMM` is this CLI's ceiling; `withinCeiling`
424
+ // gates every non-explicit path below. (numeric major.minor compare; a prerelease
425
+ // suffix on the CLI, e.g. 1.3.0-rc.2, is ignored — 1.3 is still the ceiling.)
426
+ const cliMM = majorMinor(cliVersion);
427
+ const withinCeiling = (v) => {
428
+ if (!cliMM) return true; // unparseable CLI version → don't block resolution
429
+ const mm = majorMinor(v);
430
+ return !!mm && (mm.major < cliMM.major || (mm.major === cliMM.major && mm.minor <= cliMM.minor));
431
+ };
432
+
433
+ // 1) Explicit pin (flag/env): the deliberate escape hatch — honored verbatim,
434
+ // INCLUDING above the ceiling (someone testing a newer runner on purpose). This is
435
+ // the ONLY way past the ceiling; every automatic path below respects it.
421
436
  if (explicitPin) {
422
437
  return { version: distTags[explicitPin] || explicitPin, reason: `pinned ${explicitPin}` };
423
438
  }
@@ -426,11 +441,13 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
426
441
  // is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
427
442
  // which the stable-only minor match below deliberately skips). This is what makes
428
443
  // `tot@1.3.0-rc.0` pull `runner@1.3.0-rc.0` instead of falling back to stale latest.
444
+ // (Same version ⇒ same major.minor ⇒ always within the ceiling.)
429
445
  if (versions.includes(cliVersion)) {
430
446
  return { version: cliVersion, reason: "exact CLI-version match" };
431
447
  }
432
448
 
433
449
  // 3) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
450
+ // Constrained to the CLI's exact minor, so this is within the ceiling by construction.
434
451
  const m = /^(\d+)\.(\d+)\./.exec(cliVersion || "");
435
452
  if (m) {
436
453
  const prefix = `${m[1]}.${m[2]}.`;
@@ -442,9 +459,40 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
442
459
  }
443
460
  }
444
461
 
445
- // 4) Fallback: the `latest` dist-tag (pre-alignment safety net).
446
- if (distTags.latest) return { version: distTags.latest, reason: "fell back to latest (no CLI-minor match)" };
447
- throw new Error("no publishable runner version (no CLI-minor match and no `latest` dist-tag)");
462
+ // 4) Fallback: the highest STABLE version AT OR BELOW the CLI's major.minor ceiling.
463
+ // This replaces a blind `latest` `latest` can be a HIGHER minor than this CLI
464
+ // (e.g. CLI 1.2.x but runner latest 1.3.0), which would run the runner ahead of the
465
+ // CLI. Running slightly BEHIND (a lower minor) is the safe direction.
466
+ const capped = versions
467
+ .filter((v) => /^\d+\.\d+\.\d+$/.test(v) && withinCeiling(v))
468
+ .sort(compareStableAsc);
469
+ if (capped.length) {
470
+ const version = capped[capped.length - 1];
471
+ return { version, reason: `highest ≤ CLI major.minor ${cliMM ? `${cliMM.major}.${cliMM.minor}` : "?"}` };
472
+ }
473
+ // Last resort: `latest`, but ONLY if it doesn't breach the ceiling.
474
+ if (distTags.latest && withinCeiling(distTags.latest)) {
475
+ return { version: distTags.latest, reason: "fell back to latest (within CLI ceiling)" };
476
+ }
477
+ throw new Error(
478
+ `no runner version at or below the CLI's major.minor (${cliMM ? `${cliMM.major}.${cliMM.minor}` : cliVersion})`,
479
+ );
480
+ }
481
+
482
+ /** Parse the numeric {major, minor} from a semver (prerelease suffix ignored). Null if unparseable. */
483
+ export function majorMinor(v) {
484
+ const m = /^(\d+)\.(\d+)\./.exec(String(v || ""));
485
+ return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
486
+ }
487
+
488
+ /** Ascending comparator for STABLE x.y.z strings (numeric per segment). */
489
+ function compareStableAsc(a, b) {
490
+ const pa = a.split(".").map(Number);
491
+ const pb = b.split(".").map(Number);
492
+ for (let i = 0; i < 3; i++) {
493
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
494
+ }
495
+ return 0;
448
496
  }
449
497
 
450
498
  /**
@@ -561,7 +609,9 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
561
609
  function prunePublicRunnerCache(cacheRoot, keepVersion) {
562
610
  try {
563
611
  if (!cacheRoot || !existsSync(cacheRoot)) return;
564
- const keep = `public-${keepVersion}`;
612
+ // keepVersion == null → keep NOTHING (drop every public-* dir); used when a
613
+ // cached runner failed its version probe and none can be trusted.
614
+ const keep = keepVersion == null ? null : `public-${keepVersion}`;
565
615
  for (const name of readdirSync(cacheRoot)) {
566
616
  if (!name.startsWith("public-") || name === keep) continue;
567
617
  rmSync(join(cacheRoot, name), { recursive: true, force: true });
@@ -571,6 +621,32 @@ function prunePublicRunnerCache(cacheRoot, keepVersion) {
571
621
  }
572
622
  }
573
623
 
624
+ /**
625
+ * Probe a runner tree for its version by invoking its own `--version` surface
626
+ * (`node <runner>/scripts/tot-dev.mjs --version`, the net-new rc.2 runner bin).
627
+ * Returns the trimmed version string, or null when the runner is too OLD to answer
628
+ * (pre-`--version`, i.e. pre-rc.2), missing, or errors. Never throws. Short timeout
629
+ * so a hung runner can't stall `tot dev`.
630
+ *
631
+ * This is the detector behind "force an upgrade if it can't tell us its version":
632
+ * a runner that can't report a version is by definition stale and must not be reused.
633
+ */
634
+ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
635
+ try {
636
+ const script = join(runnerDir, "scripts", "tot-dev.mjs");
637
+ if (!existsSync(script)) return null;
638
+ const out = execFileSync(process.execPath, [script, "--version"], {
639
+ timeout: timeoutMs,
640
+ encoding: "utf8",
641
+ stdio: ["ignore", "pipe", "ignore"],
642
+ });
643
+ const v = String(out).trim().split(/\s+/)[0];
644
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
645
+ } catch {
646
+ return null; // old runner (no --version), timeout, or spawn failure → treat as unversioned
647
+ }
648
+ }
649
+
574
650
  /**
575
651
  * Resolve the renderer for the ZERO-LOGIN sample / public-fallback path WITHOUT
576
652
  * any MCP call — the whole point of the free taste. Resolution order:
@@ -604,6 +680,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
604
680
  });
605
681
  }
606
682
  console.error(`~ renderer: ${src.why} (${src.dir})`);
683
+ setRunnerVersion(probeRunnerVersion(src.dir)); // telemetry: version of an override/in-tree runner
607
684
  return src.dir;
608
685
  }
609
686
 
@@ -628,9 +705,18 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
628
705
  if (!explicitPin) {
629
706
  const exact = pinnedPublicCacheDir(cacheRoot, CLI_VERSION);
630
707
  if (exact) {
631
- console.error(`~ renderer: cached public runner ${CLI_VERSION} (matches this CLI)`);
632
- prunePublicRunnerCache(cacheRoot, CLI_VERSION);
633
- return exact;
708
+ // Trust-but-verify: a `public-<CLI_VERSION>` dir SHOULD be a current runner,
709
+ // but if it can't report its own version it predates the --version surface
710
+ // (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
711
+ // runner we can't identify. When it DOES answer, reuse it (fully offline-safe).
712
+ if (probeRunnerVersion(exact)) {
713
+ console.error(`~ renderer: cached public runner ${CLI_VERSION} (matches this CLI)`);
714
+ setRunnerVersion(CLI_VERSION);
715
+ prunePublicRunnerCache(cacheRoot, CLI_VERSION);
716
+ return exact;
717
+ }
718
+ console.error(`~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`);
719
+ prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
634
720
  }
635
721
  }
636
722
 
@@ -646,6 +732,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
646
732
  const cached = newestCachedRunner(cacheRoot);
647
733
  if (cached && existsSync(join(cached, "scripts", "tot-dev.mjs"))) {
648
734
  console.error(`~ renderer: offline — reusing cached runner ${cached} (couldn't reach npm to pin the version)`);
735
+ setRunnerVersion(probeRunnerVersion(cached)); // telemetry: best-effort version of the offline reuse
649
736
  return cached;
650
737
  }
651
738
  throw new CliError(
@@ -657,11 +744,13 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
657
744
  },
658
745
  );
659
746
  }
660
- console.error(`~ renderer: public npm ${pub.version} (${PUBLIC_RUNNER_PACKAGE})`);
747
+ // The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
748
+ // out of the user's way; the setup spinner below is the visible progress.
661
749
  const dir = await installRunnerTarball(
662
750
  { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
663
751
  { log: (m) => console.error(m) },
664
752
  );
753
+ setRunnerVersion(pub.version); // telemetry: the runner version running this session
665
754
  // The pinned version is now installed under public-<version> — drop any other
666
755
  // public-* dirs (e.g. the stale public-0.1.0) so they can never be reused.
667
756
  prunePublicRunnerCache(cacheRoot, pub.version);
@@ -693,11 +782,23 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
693
782
  const marker = join(runnerDir, ".tot-cache-complete");
694
783
  if (existsSync(marker)) return runnerDir; // already downloaded + installed
695
784
 
696
- log(`~ preparing the native renderer (version ${version}, first run only)...`);
785
+ // First run only — set the expectation so the one-time cost doesn't read as a
786
+ // hang: this downloads + installs the renderer once, then every later run of
787
+ // this version is a no-network cache hit.
788
+ log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
697
789
  const localSource = isUrl ? null : resolveLocalTarball(source);
698
790
  const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
699
791
  try {
700
- if (isUrl) await downloadFile(source, archivePath);
792
+ if (isUrl) {
793
+ // The fetch itself is otherwise silent (no per-byte output) and can run
794
+ // tens of seconds on a cold cache — tick a spinner so it never looks hung.
795
+ const spin = startProgress("downloading the store preview engine…");
796
+ try {
797
+ await downloadFile(source, archivePath);
798
+ } finally {
799
+ spin.stop();
800
+ }
801
+ }
701
802
  if (!existsSync(archivePath)) {
702
803
  throw new Error(`renderer tarball not found: ${archivePath}`);
703
804
  }
@@ -705,8 +806,24 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
705
806
  rmSync(stagingDir, { recursive: true, force: true });
706
807
  mkdirSync(stagingDir, { recursive: true });
707
808
  extractTarball(archivePath, stagingDir, { strip });
809
+ // Pin the runner install to PUBLIC npm. The moat-free runner has only public
810
+ // deps, but the HOST's global ~/.npmrc may point `registry` at a private
811
+ // mirror (an internal proxy that 502s, or one an invited developer can't
812
+ // reach) — an invited dev's machine config must never decide where the
813
+ // runner's public deps come from. A project-level .npmrc wins over the user's.
814
+ writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
708
815
  ensureCorepackPnpm(stagingDir);
709
- runPnpmInstall(stagingDir);
816
+ // The install is the long, noisy step — tick a spinner while its output goes
817
+ // to a log, so the terminal shows one clean line instead of the pnpm firehose.
818
+ const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
819
+ const installSpin = startProgress("installing the store preview engine…", {
820
+ stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
821
+ });
822
+ try {
823
+ runPnpmInstall(stagingDir, { logPath: installLog });
824
+ } finally {
825
+ installSpin.stop();
826
+ }
710
827
  // Atomic-ish: only rename into the final, discoverable path once install
711
828
  // succeeded, so a crashed/interrupted run never leaves a half-built cache
712
829
  // entry that a later `tot dev` would treat as ready.
@@ -776,14 +893,48 @@ function ensureCorepackPnpm(runnerDir) {
776
893
  spawnSync("corepack", ["prepare", pm, "--activate"], { stdio: "ignore" });
777
894
  }
778
895
 
779
- function runPnpmInstall(runnerDir) {
780
- const r = spawnSync("pnpm", ["install", "--config.dangerouslyAllowAllBuilds=true"], {
781
- cwd: runnerDir,
782
- stdio: "inherit",
783
- });
784
- if (r.status !== 0) {
785
- throw new Error(`pnpm install failed (exit ${r.status}) — is pnpm/corepack available on this host?`);
896
+ /**
897
+ * Install the runner's deps QUIETLY: the package manager's raw stdout (dozens of
898
+ * "Scope: all N workspace projects" lines, registry retry [WARN]s, dep-graph
899
+ * churn) is captured to a log file instead of flooding the terminal, so the
900
+ * caller's spinner owns the screen. On failure we surface a clean, business-
901
+ * readable message + the log path — never the raw node/pnpm firehose.
902
+ */
903
+ function runPnpmInstall(runnerDir, { logPath } = {}) {
904
+ const fd = logPath ? openSync(logPath, "a") : null;
905
+ try {
906
+ const r = spawnSync("pnpm", ["install", "--config.dangerouslyAllowAllBuilds=true"], {
907
+ cwd: runnerDir,
908
+ // Send both streams to the log fd (or swallow them) — never inherit.
909
+ stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
910
+ });
911
+ if (r.status !== 0) {
912
+ throw new Error(
913
+ `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
914
+ (logPath ? `\n details: ${logPath}` : ""),
915
+ );
916
+ }
917
+ } finally {
918
+ if (fd !== null) closeSync(fd);
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Turn a pnpm-install failure into a human hint by scanning the captured log —
924
+ * the most common cause is the ToT package registry being unreachable, which the
925
+ * raw log buries under retry noise. Best-effort; empty string when we can't tell.
926
+ */
927
+ function pnpmFailureHint(logPath) {
928
+ if (!logPath) return " — is pnpm/corepack available on this host?";
929
+ try {
930
+ const tail = readFileSync(logPath, "utf8").slice(-8000);
931
+ if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
932
+ return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
933
+ }
934
+ } catch {
935
+ /* ignore — fall through to the generic message */
786
936
  }
937
+ return "";
787
938
  }
788
939
 
789
940
  /**
@@ -53,6 +53,7 @@ import { createMcpClient } from "../mcp.mjs";
53
53
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
54
54
  import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
55
55
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
56
+ import { startProgress } from "../progress.mjs";
56
57
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
57
58
  import { collectChecks } from "./doctor.mjs";
58
59
  import { normalizeStores, storeListError, checkoutTenant } from "./checkout.mjs";
@@ -236,10 +237,9 @@ export async function run(argv, ctx) {
236
237
  handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped", env: activityBridgeEnv(env) });
237
238
  }
238
239
 
239
- const up = await Promise.race([
240
- waitForServer(url, { until: () => handle.exited }),
241
- handle.done.then(() => "exited"),
242
- ]);
240
+ // The runner's stdio is "piped" (its logs are held until the aha), so this
241
+ // boot would otherwise be a silent 5–60s gap. Tick a spinner over it.
242
+ const up = await waitForBoot(url, handle);
243
243
  if (up !== true) {
244
244
  throw new CliError("the dev server didn't come up", {
245
245
  next: `cd ${tenant} && tot dev (to watch the runner logs)`,
@@ -257,9 +257,8 @@ export async function run(argv, ctx) {
257
257
  printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
258
258
 
259
259
  // 7. hand the terminal to the running dev server until Ctrl-C.
260
- console.log("\n Streaming dev logs — edit + save to see reloads. Ctrl-C to stop.\n");
261
- handle.child.stdout?.pipe(process.stdout);
262
- handle.child.stderr?.pipe(process.stderr);
260
+ console.log("\n Watching your store — edit content/home.html + save. Ctrl-C to stop.\n");
261
+ streamDevLogs(handle.child);
263
262
  return handle.done;
264
263
  } catch (e) {
265
264
  console.error(formatError(e));
@@ -307,10 +306,7 @@ async function runSampleStart(args, ctx, env, startedAt) {
307
306
  console.log(` → starting the free local preview … ${url}`);
308
307
  const handle = spawnNativeDev(runnerDir, workspace, devArgs.port, { stdio: "piped" });
309
308
 
310
- const up = await Promise.race([
311
- waitForServer(url, { until: () => handle.exited }),
312
- handle.done.then(() => "exited"),
313
- ]);
309
+ const up = await waitForBoot(url, handle);
314
310
  if (up !== true) {
315
311
  throw new CliError("the local preview server didn't come up", {
316
312
  next: `cd ${SAMPLE_DIR_NAME} && tot dev --sample (to watch the runner logs)`,
@@ -326,9 +322,8 @@ async function runSampleStart(args, ctx, env, startedAt) {
326
322
  // non-blocking step later (see the run() note above).
327
323
  printSampleLiveEnding(url, formatElapsed(Date.now() - startedAt));
328
324
 
329
- console.log("\n Streaming preview logs — edit content/*.html + save to see reloads. Ctrl-C to stop.\n");
330
- handle.child.stdout?.pipe(process.stdout);
331
- handle.child.stderr?.pipe(process.stderr);
325
+ console.log("\n Watching your store — edit content/*.html + save. Ctrl-C to stop.\n");
326
+ streamDevLogs(handle.child);
332
327
  return handle.done;
333
328
  } catch (e) {
334
329
  console.error(formatError(e));
@@ -433,7 +428,9 @@ async function prefetchRuntime(client, devArgs, env, runtime, ctx) {
433
428
  if (!(e instanceof NativeArtifactUnavailableError)) throw e;
434
429
  // Entitled artifact unavailable — stay native on the public runner (no Docker).
435
430
  try {
436
- console.log(` ~ entitled renderer unavailable (${e.message}) using the public runner (no Docker).`);
431
+ // Normal path when the entitled artifact isn't configured for this
432
+ // deployment — use the public preview engine (no Docker). Silent: the
433
+ // download/install spinner below is the user-facing progress.
437
434
  runtime.runnerDir = await ensureSampleRenderer(devArgs, ctx, { env });
438
435
  } catch (e2) {
439
436
  // Only an actual "can't reach the public runner" failure should fall to
@@ -441,7 +438,7 @@ async function prefetchRuntime(client, devArgs, env, runtime, ctx) {
441
438
  // say) would otherwise be silently masked behind a confusing Docker fallback
442
439
  // that may not even be installed. Mirrors dev.mjs's runStandalone.
443
440
  if (!(e2 instanceof NativeArtifactUnavailableError)) throw e2;
444
- console.log(` ~ public runner unavailable (${e2.message}) falling back to the Docker runner.`);
441
+ console.log(` ~ couldn't fetch the store preview over the network trying Docker instead.`);
445
442
  runtime.useDocker = true;
446
443
  await prefetchDockerLogin(client, devArgs, env);
447
444
  }
@@ -569,6 +566,30 @@ async function ensureCheckout(client, tenant, dir, env) {
569
566
  console.log(` ✓ checked out ./${tenant}`);
570
567
  }
571
568
 
569
+ /**
570
+ * Wait for the dev server to answer while showing progress, then return the
571
+ * `waitForServer` result ("exited" if the runner died first). Both the runner
572
+ * (native or Docker) runs with "piped" stdio here, so its boot logs are withheld
573
+ * until the aha — without this the terminal is a silent 5–60s gap that reads as
574
+ * hung (the D1 "perceived speed" problem). The spinner keeps the interval well
575
+ * under 5s and, once past the warm-boot window, explains the first-render cost.
576
+ */
577
+ async function waitForBoot(url, handle) {
578
+ const spin = startProgress("starting the dev server…", {
579
+ stages: [
580
+ { afterMs: 6000, text: "compiling your store — first render can take a moment…" },
581
+ ],
582
+ });
583
+ try {
584
+ return await Promise.race([
585
+ waitForServer(url, { until: () => handle.exited }),
586
+ handle.done.then(() => "exited"),
587
+ ]);
588
+ } finally {
589
+ spin.stop();
590
+ }
591
+ }
592
+
572
593
  /**
573
594
  * Format elapsed milliseconds as a short human string for the "you're live"
574
595
  * ending (A3 — measure, don't just claim, "instant"). Pure + exported so it's
@@ -635,6 +656,54 @@ function connectClaude() {
635
656
  spawnSync("claude", [IDEAS[0]], { stdio: "inherit" });
636
657
  }
637
658
 
659
+ /**
660
+ * Stream the running dev server's output in BUSINESS terms. The runner + Vite +
661
+ * Astro emit a lot of internal chatter (dependency optimization, HMR internals,
662
+ * build banners, "watching for file changes", pnpm tails). A developer cares
663
+ * about two things: that a save took effect, and any real error. So collapse a
664
+ * save-reload into one clean "↻ your store reloaded", drop the known internal
665
+ * noise, and pass anything else through (indented) so nothing important is
666
+ * hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
667
+ */
668
+ function streamDevLogs(child) {
669
+ // Startup churn + tool internals — never user-facing.
670
+ const NOISE =
671
+ /^\s*(\[@astrojs|astro\s+v[\d.]|┃|▲|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\[vite\] connected|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
672
+ // A real save-triggered reload (not startup "program reload" churn).
673
+ const RELOAD = /(hmr update|page reload)/i;
674
+ let reloadPending = null;
675
+ const emit = (line) => {
676
+ const t = line.replace(/\s+$/, "");
677
+ if (!t) return;
678
+ if (RELOAD.test(t)) {
679
+ if (reloadPending) return; // debounce a burst into one line
680
+ reloadPending = setTimeout(() => { reloadPending = null; }, 1000);
681
+ if (reloadPending.unref) reloadPending.unref();
682
+ process.stdout.write(" ↻ your store reloaded\n");
683
+ return;
684
+ }
685
+ if (NOISE.test(t)) return;
686
+ process.stdout.write(` ${t}\n`);
687
+ };
688
+ lineStream(child.stdout, emit);
689
+ lineStream(child.stderr, emit);
690
+ }
691
+
692
+ /** Call `cb` once per complete line of `stream` (dependency-free line buffering). */
693
+ function lineStream(stream, cb) {
694
+ if (!stream) return;
695
+ let buf = "";
696
+ stream.on("data", (chunk) => {
697
+ buf += chunk.toString();
698
+ let nl;
699
+ while ((nl = buf.indexOf("\n")) >= 0) {
700
+ cb(buf.slice(0, nl));
701
+ buf = buf.slice(nl + 1);
702
+ }
703
+ });
704
+ stream.on("end", () => { if (buf.trim()) cb(buf); });
705
+ }
706
+
638
707
  // ── small prompt helpers (respect non-TTY so nothing hangs in CI) ────────────
639
708
 
640
709
  function isInteractive() {
package/src/mcp.mjs CHANGED
@@ -23,6 +23,22 @@ export const CLI_VERSION = (() => {
23
23
  }
24
24
  })();
25
25
 
26
+ // The resolved @tokenoftrust/storefront-runner version for THIS invocation, stashed
27
+ // by `tot dev` once it resolves/probes the runner (setRunnerVersion). Null until then
28
+ // — most commands never run the runner. Reported in the handshake (clientInfo.packages)
29
+ // so telemetry sees the version of EVERY package running by default, not just the CLI.
30
+ let RUNNER_VERSION = null;
31
+
32
+ /** Record the runner version resolved this invocation, so the next MCP handshake reports it. */
33
+ export function setRunnerVersion(v) {
34
+ RUNNER_VERSION = v || null;
35
+ }
36
+
37
+ /** The package-version map reported to the server by default: each package + its running version. */
38
+ export function clientPackages() {
39
+ return { cli: CLI_VERSION, runner: RUNNER_VERSION };
40
+ }
41
+
26
42
  /**
27
43
  * @param {string} baseUrl - MCP base URL; `/mcp` is appended if absent.
28
44
  * @param {{ token?: string, clientVersion?: string }} [opts] - optional developer OAuth
@@ -105,6 +121,14 @@ export function createMcpClient(baseUrl, opts = {}) {
105
121
  async function initialize(
106
122
  clientInfo = { name: "tot-cli", version: opts.clientVersion || CLI_VERSION },
107
123
  ) {
124
+ // Report the version of EVERY package running by default (cli + runner), not just
125
+ // the CLI. `version` stays the CLI version for back-compat with the existing
126
+ // server-side support-policy check (update-awareness Layer 2); `packages` is the
127
+ // richer per-package telemetry. Additive: a server that ignores `packages` still
128
+ // reads `version` exactly as before.
129
+ if (clientInfo && clientInfo.packages === undefined) {
130
+ clientInfo = { ...clientInfo, packages: clientPackages() };
131
+ }
108
132
  await callRaw("initialize", {
109
133
  protocolVersion: "2025-06-18",
110
134
  capabilities: {},
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A dependency-free, non-blocking progress indicator for the "and then it just
3
+ * runs" waits — the first-run renderer download and the dev server's cold boot,
4
+ * where the CLI would otherwise sit SILENT for tens of seconds (piped runner
5
+ * logs, a background fetch) and read as hung. Node built-ins only.
6
+ *
7
+ * Two behaviours, chosen from whether stderr is a TTY:
8
+ * - TTY → an in-place braille spinner rewritten with \r, showing an elapsed
9
+ * seconds counter, so the terminal visibly ticks.
10
+ * - non-TTY (CI, piped) → NO \r animation (that spams a log with control
11
+ * chars); instead one line at start and then a heartbeat line every
12
+ * ~10s, so a CI log shows liveness without a wall of frames.
13
+ *
14
+ * Both surface staged labels: pass `stages: [{ afterMs, text }]` and the label
15
+ * advances as the wait crosses each threshold (e.g. "starting…" → "compiling
16
+ * your store (first render)…"), so a long wait explains itself instead of
17
+ * staring back blankly.
18
+ */
19
+
20
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
+ const CLEAR_LINE = "\r\x1b[K";
22
+
23
+ /**
24
+ * Given the elapsed ms and the (optionally staged) labels, return the label to
25
+ * show right now: the last stage whose `afterMs` has passed, else `initial`.
26
+ * Pure + exported so the staging is unit-tested without any timers or TTY.
27
+ * @param {string} initial
28
+ * @param {Array<{afterMs:number,text:string}>} stages
29
+ * @param {number} elapsedMs
30
+ * @returns {string}
31
+ */
32
+ export function stageLabel(initial, stages, elapsedMs) {
33
+ let label = initial;
34
+ for (const s of stages || []) {
35
+ if (elapsedMs >= s.afterMs) label = s.text;
36
+ }
37
+ return label;
38
+ }
39
+
40
+ /**
41
+ * Start a progress indicator. Returns a handle with `stop(finalText?)` — always
42
+ * call it (a `finally` is ideal) so the interval clears and, on a TTY, the
43
+ * spinner line is erased. Safe to call `stop` more than once.
44
+ *
45
+ * @param {string} initialText the label shown until the first stage (if any).
46
+ * @param {{ stream?: NodeJS.WriteStream, stages?: Array<{afterMs:number,text:string}>,
47
+ * isTTY?: boolean, intervalMs?: number, heartbeatMs?: number, now?: () => number }} [opts]
48
+ * @returns {{ stop: (finalText?: string) => void }}
49
+ */
50
+ export function startProgress(initialText, opts = {}) {
51
+ const {
52
+ stream = process.stderr,
53
+ stages = [],
54
+ isTTY = Boolean(stream.isTTY),
55
+ intervalMs = isTTY ? 90 : 1000,
56
+ heartbeatMs = 10000,
57
+ now = Date.now,
58
+ } = opts;
59
+
60
+ const startedAt = now();
61
+ let frame = 0;
62
+ let lastHeartbeat = startedAt;
63
+ let stopped = false;
64
+
65
+ const secs = () => Math.floor((now() - startedAt) / 1000);
66
+ const label = () => stageLabel(initialText, stages, now() - startedAt);
67
+
68
+ function tick() {
69
+ if (stopped) return;
70
+ if (isTTY) {
71
+ frame = (frame + 1) % FRAMES.length;
72
+ stream.write(`${CLEAR_LINE} ${FRAMES[frame]} ${label()} (${secs()}s)`);
73
+ } else if (now() - lastHeartbeat >= heartbeatMs) {
74
+ lastHeartbeat = now();
75
+ stream.write(` … ${label()} (${secs()}s)\n`);
76
+ }
77
+ }
78
+
79
+ // Announce immediately so there's never a silent lead-in, then tick.
80
+ if (isTTY) {
81
+ stream.write(` ${FRAMES[0]} ${initialText} (0s)`);
82
+ } else {
83
+ stream.write(` … ${initialText}\n`);
84
+ }
85
+ const timer = setInterval(tick, intervalMs);
86
+ if (typeof timer.unref === "function") timer.unref(); // never keep the process alive
87
+
88
+ return {
89
+ stop(finalText) {
90
+ if (stopped) return;
91
+ stopped = true;
92
+ clearInterval(timer);
93
+ if (isTTY) {
94
+ stream.write(CLEAR_LINE); // erase the spinner line
95
+ if (finalText) stream.write(` ${finalText}\n`);
96
+ } else if (finalText) {
97
+ stream.write(` ${finalText}\n`);
98
+ }
99
+ },
100
+ };
101
+ }
@@ -7,6 +7,9 @@
7
7
  * Runs standalone (`node src/update-check-worker.mjs`), inheriting the parent env
8
8
  * (TOT_HOME / TOT_NPM_REGISTRY). Best-effort: any failure just exits quietly.
9
9
  */
10
- import { runRefresh } from "./update-check.mjs";
10
+ import { runRefresh, runPolicyRefresh } from "./update-check.mjs";
11
11
 
12
- runRefresh().finally(() => process.exit(0));
12
+ // Both best-effort + independent: the npm `latest` dist-tag (Layer 1) and the
13
+ // storefront-hosted support policy (Layer 2). allSettled so one failing never
14
+ // aborts the other; the process exits regardless.
15
+ Promise.allSettled([runRefresh(), runPolicyRefresh()]).finally(() => process.exit(0));
@@ -35,6 +35,13 @@ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
35
35
 
36
36
  const PKG = "@tokenoftrust/cli";
37
37
  const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
38
+ // Storefront-hosted, KV-dynamic CLI version-support policy (Layer 2 source #2 —
39
+ // see auth.mjs for source #1, the MCP `cliPolicy`). PUBLIC GET returning
40
+ // { minSupported?, recommended?, message? }; overridable like the npm registry.
41
+ // NOTE the TRAILING SLASH: the deployed astro/CF route answers at
42
+ // `/api/cli/policy/` (200 JSON); the slashless form 404s on the Worker. Use the
43
+ // canonical trailing-slash URL so the fetch resolves.
44
+ const DEFAULT_POLICY_URL = "https://storefront.tokenoftrust.workers.dev/api/cli/policy/";
38
45
  const FETCH_TIMEOUT_MS = 3000;
39
46
 
40
47
  /** Absolute path to the update-check cache for this environment. */
@@ -84,6 +91,55 @@ export function isNewer(latest, current) {
84
91
  return false;
85
92
  }
86
93
 
94
+ /**
95
+ * Full-semver precedence compare for the SERVER-POLICY path only. Unlike isNewer
96
+ * (which strips the prerelease suffix, so it never nudges a stable user onto a
97
+ * prerelease), this honors prereleases per semver — so ToT's policy can steer
98
+ * rc→rc (recommend 1.3.1-rc.5 to someone on 1.3.1-rc.3) and treat a full release
99
+ * as newer than any of its prereleases (1.3.1 > 1.3.1-rc.5). Returns -1/0/1;
100
+ * malformed input compares as equal-ish (never throws).
101
+ */
102
+ export function comparePolicyVersion(a, b) {
103
+ const parse = (s) => {
104
+ const [main, pre = ""] = String(s || "").split("-");
105
+ const nums = main.split(".").map((n) => parseInt(n, 10) || 0);
106
+ return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre: pre ? pre.split(".") : [] };
107
+ };
108
+ const x = parse(a);
109
+ const y = parse(b);
110
+ for (let i = 0; i < 3; i++) {
111
+ if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
112
+ }
113
+ // Equal main version: a version WITH a prerelease is LOWER than one without.
114
+ if (x.pre.length === 0 && y.pre.length === 0) return 0;
115
+ if (x.pre.length === 0) return 1;
116
+ if (y.pre.length === 0) return -1;
117
+ // Both prereleases: compare identifier by identifier (semver §11).
118
+ const n = Math.max(x.pre.length, y.pre.length);
119
+ for (let i = 0; i < n; i++) {
120
+ const xi = x.pre[i];
121
+ const yi = y.pre[i];
122
+ if (xi === undefined) return -1; // fewer identifiers → lower precedence
123
+ if (yi === undefined) return 1;
124
+ const xn = /^\d+$/.test(xi);
125
+ const yn = /^\d+$/.test(yi);
126
+ if (xn && yn) {
127
+ const d = parseInt(xi, 10) - parseInt(yi, 10);
128
+ if (d !== 0) return d > 0 ? 1 : -1;
129
+ } else if (xn !== yn) {
130
+ return xn ? -1 : 1; // numeric identifiers are lower precedence than alphanumeric
131
+ } else if (xi !== yi) {
132
+ return xi > yi ? 1 : -1;
133
+ }
134
+ }
135
+ return 0;
136
+ }
137
+
138
+ /** True if `target` is a strictly higher SEMVER (prerelease-aware) than `current`. */
139
+ export function isNewerPolicy(target, current) {
140
+ return comparePolicyVersion(target, current) > 0;
141
+ }
142
+
87
143
  /**
88
144
  * Decide what (if anything) to tell the user, from the cached state only.
89
145
  * Returns null or { level: "required"|"recommended"|"available", latest, message? }.
@@ -95,14 +151,17 @@ export function updateNotice(current, env = process.env) {
95
151
 
96
152
  const policy = cache.policy;
97
153
  if (policy) {
98
- if (policy.minSupported && isNewer(policy.minSupported, current)) {
154
+ // Policy path uses the prerelease-aware compare so ToT can steer rc→rc and
155
+ // hard-gate a bad rc; the Layer-1 npm compare below deliberately stays on
156
+ // isNewer (never surfaces a same-release prerelease to a stable user).
157
+ if (policy.minSupported && isNewerPolicy(policy.minSupported, current)) {
99
158
  return {
100
159
  level: "required",
101
160
  latest: policy.recommended || cache.latest || policy.minSupported,
102
161
  message: policy.message,
103
162
  };
104
163
  }
105
- if (policy.recommended && isNewer(policy.recommended, current)) {
164
+ if (policy.recommended && isNewerPolicy(policy.recommended, current)) {
106
165
  return { level: "recommended", latest: policy.recommended, message: policy.message };
107
166
  }
108
167
  }
@@ -180,6 +239,33 @@ export function recordServerPolicy(policy, env = process.env) {
180
239
  if (policy && typeof policy === "object") writeCache({ policy }, env);
181
240
  }
182
241
 
242
+ /**
243
+ * Fetch the STOREFRONT-hosted CLI version policy (Layer 2, source #2) and record
244
+ * it. A dependency-free public GET, best-effort like runRefresh: never throws,
245
+ * never blocks. URL overridable via TOT_CLI_POLICY_URL (like TOT_NPM_REGISTRY).
246
+ * Only records when the body carries at least one policy field, so an empty
247
+ * `{}` (the route's unset default) is a true no-op and can't clobber an
248
+ * MCP-sourced policy already in the cache.
249
+ */
250
+ export async function runPolicyRefresh(env = process.env) {
251
+ try {
252
+ const url = env.TOT_CLI_POLICY_URL || DEFAULT_POLICY_URL;
253
+ const res = await fetch(url, {
254
+ headers: { accept: "application/json" },
255
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
256
+ });
257
+ if (!res.ok) return;
258
+ const body = await res.json();
259
+ if (!body || typeof body !== "object") return;
260
+ const { minSupported, recommended, message } = body;
261
+ if (minSupported || recommended || message) {
262
+ recordServerPolicy({ minSupported, recommended, message }, env);
263
+ }
264
+ } catch {
265
+ /* best-effort: offline / timeout / bad JSON / gated host — just skip */
266
+ }
267
+ }
268
+
183
269
  /**
184
270
  * Spawn the cache refresh in a DETACHED, unref'd child so it never delays this
185
271
  * process or holds it open. Skips when the cache is still fresh. Never throws.