@tokenoftrust/cli 1.3.3-rc.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.3-rc.0",
3
+ "version": "1.3.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",
@@ -18,11 +18,14 @@
18
18
  *
19
19
  * Dependency-free (global fetch + `git` via child_process).
20
20
  */
21
- import { execFileSync } from "node:child_process";
21
+ import { execFile } from "node:child_process";
22
+ import { promisify } from "node:util";
22
23
  import { createMcpClient } from "../mcp.mjs";
23
24
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
24
25
  import { CliError, fail, formatError } from "../errors.mjs";
25
26
 
27
+ const execFileP = promisify(execFile);
28
+
26
29
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
27
30
 
28
31
  function parseArgs(argv) {
@@ -181,23 +184,28 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
181
184
  if (!cloneDir) {
182
185
  return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
183
186
  }
184
- const { dir, head } = cloneRepo(gitRemote, cloneDir, redact);
187
+ const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact);
185
188
  return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
186
189
  }
187
190
 
188
- /** git clone the authenticated remote into `dir`. Throws CliError on failure. */
189
- function cloneRepo(gitRemote, dir, redact) {
190
- const git = (cargs) =>
191
- execFileSync("git", cargs, { stdio: ["ignore", "pipe", "pipe"] }).toString();
191
+ /**
192
+ * git clone the authenticated remote into `dir`. Throws CliError on failure.
193
+ * Runs git as a NON-BLOCKING child process (promisified execFile) so the clone
194
+ * doesn't stall the Node event loop — `tot start` runs this inside a
195
+ * `Promise.all([...])` alongside the renderer prefetch, and a synchronous clone
196
+ * would serialize what's meant to overlap.
197
+ */
198
+ async function cloneRepo(gitRemote, dir, redact) {
199
+ const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
192
200
  console.log(`+ git clone → ${dir}`);
193
201
  try {
194
- git(["clone", gitRemote, dir]);
202
+ await git(["clone", gitRemote, dir]);
195
203
  } catch (e) {
196
204
  throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
197
205
  next: `check the target dir is empty and you can reach the remote, then re-run`,
198
206
  });
199
207
  }
200
- const head = git(["-C", dir, "log", "-1", "--oneline"]).trim();
208
+ const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
201
209
  return { dir, head };
202
210
  }
203
211
 
@@ -39,7 +39,7 @@ 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, setRunnerVersion } from "../mcp.mjs";
42
+ import { createMcpClient, CLI_VERSION, setRunnerVersion, versionStamp } 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";
@@ -50,6 +50,7 @@ import {
50
50
  resolveRendererSource as resolveLocalRendererSource, newestCachedRunner, SAMPLE_DIR_NAME,
51
51
  } from "../sample.mjs";
52
52
  import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
53
+ import { streamDevLogs } from "../dev-logs.mjs";
53
54
 
54
55
  /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
55
56
  const DEFAULT_DEV_IMAGE =
@@ -162,11 +163,11 @@ async function runStandalone(workspace, args, ctx) {
162
163
  // configured. Docker is the last resort only if the public runner is also
163
164
  // unreachable (offline).
164
165
  try {
165
- console.error(`~ entitled renderer unavailable (${e.message}) — using the public runner (no Docker).`);
166
+ console.error(`~ couldn't set up your store preview the usual way (${e.message}) — using the fallback preview engine instead (no Docker needed).`);
166
167
  return await runNativePublic(workspace, args, ctx);
167
168
  } catch (e2) {
168
169
  if (e2 instanceof NativeArtifactUnavailableError) {
169
- console.error(`~ public runner unavailable (${e2.message}) — falling back to the Docker runner.`);
170
+ console.error(`~ couldn't reach the fallback preview engine either (${e2.message}) — switching to the Docker runner.`);
170
171
  return runContainer(workspace, args, ctx);
171
172
  }
172
173
  console.error(formatError(e2));
@@ -203,7 +204,7 @@ async function runNativePublic(workspace, args, ctx) {
203
204
  } catch (e) {
204
205
  throw new NativeArtifactUnavailableError(String(e?.message || e));
205
206
  }
206
- printDevBanner({ tenant: cfg.tenant || null, url });
207
+ printDevBanner({ tenant: cfg.tenant || null, url }, "native (public)");
207
208
  return bootNative(runnerDir, workspace, port, url, args);
208
209
  }
209
210
 
@@ -244,7 +245,13 @@ function runMonorepo(ctx, argv) {
244
245
  }
245
246
  const env = { ...process.env, ...activityBridgeEnv(process.env) };
246
247
  return new Promise((resolvePromise) => {
247
- const child = spawn(process.execPath, [script, ...argv], { stdio: "inherit", env });
248
+ // Pipe + stream the runner's output in the same quiet, business voice
249
+ // `tot start` uses (save→reload collapsed, vite/astro noise dropped, real
250
+ // errors passed through) instead of inheriting the raw firehose. Ctrl-C
251
+ // still tears the runner down — the child stays in our process group and
252
+ // owns the TTY signal.
253
+ const child = spawn(process.execPath, [script, ...argv], { stdio: ["ignore", "pipe", "pipe"], env });
254
+ streamDevLogs(child);
248
255
  child.on("exit", (code) => resolvePromise(code ?? 0));
249
256
  child.on("error", (e) => {
250
257
  console.error(`✗ could not start the dev runner: ${e.message}`);
@@ -280,7 +287,7 @@ async function runNative(workspace, args, ctx) {
280
287
 
281
288
  const runnerDir = await ensureRendererArtifact(args);
282
289
 
283
- printDevBanner({ tenant: cfg.tenant || null, url });
290
+ printDevBanner({ tenant: cfg.tenant || null, url }, "native");
284
291
 
285
292
  return bootNative(runnerDir, workspace, port, url, args);
286
293
  }
@@ -309,7 +316,13 @@ export function bootNativeEnv(args) {
309
316
 
310
317
  export function bootNative(runnerDir, workspace, port, url, args) {
311
318
  const bridgeEnv = bootNativeEnv(args);
312
- const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit", env: bridgeEnv });
319
+ // Pipe the runner's stdio and stream it in the same quiet, business voice
320
+ // `tot start` uses (spawnNativeDev(..., { stdio: "piped" }) + streamDevLogs):
321
+ // save→reload collapses to "↻ your store reloaded", vite/astro noise is
322
+ // dropped, real errors pass through. Ctrl-C still tears the server down — the
323
+ // child stays in our process group and owns the TTY signals.
324
+ const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "piped", env: bridgeEnv });
325
+ streamDevLogs(handle.child);
313
326
 
314
327
  // Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
315
328
  // URL while the runner runs — CLI-side, using the SAME bridge credential the
@@ -369,12 +382,13 @@ async function runSample(args, ctx) {
369
382
  }
370
383
 
371
384
  /** The free-taste banner — honest about what this is, and what unlocks the real thing. */
372
- export function printSampleBanner({ url }) {
385
+ export function printSampleBanner({ url }, mode = "sample") {
373
386
  console.error(`\n tot dev --sample — free local preview (sample vape store)`);
374
387
  console.error(` ➜ Local: ${url}`);
375
388
  console.error(` ➜ Edit: content/home.html or theme.json + save → the browser reloads`);
376
389
  console.error(` ➜ FREE local preview — no login, no ToT account, nothing published. Ctrl-C to stop.`);
377
- console.error(` Connect the ToT MCP for your REAL store, AI editing & compliance previews.\n`);
390
+ console.error(` Connect the ToT MCP for your REAL store, AI editing & compliance previews.`);
391
+ console.error(" " + versionStamp(mode) + "\n");
378
392
  }
379
393
 
380
394
  /**
@@ -583,10 +597,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
583
597
  }
584
598
 
585
599
  try {
586
- return await installRunnerTarball(
600
+ const runnerDir = await installRunnerTarball(
587
601
  { source: credential.url, version: credential.version, isUrl: true },
588
602
  { log: (m) => console.error(m) },
589
603
  );
604
+ setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
605
+ return runnerDir;
590
606
  } catch (e) {
591
607
  throw new NativeArtifactUnavailableError(String(e?.message || e));
592
608
  }
@@ -991,9 +1007,15 @@ async function runContainer(workspace, args, ctx) {
991
1007
  return e instanceof CliError ? (e.exitCode ?? 2) : 2;
992
1008
  }
993
1009
 
994
- printDevBanner(plan);
1010
+ printDevBanner(plan, "docker");
995
1011
 
996
- const handle = await spawnDevContainer(plan, args, { stdio: "inherit" });
1012
+ // Pipe + stream the container's output in the same quiet, business voice
1013
+ // `tot start` uses for the Docker path (spawnDevContainer(..., { stdio:
1014
+ // "piped" }) + streamDevLogs). Not an interactive attach (no `-it`), so
1015
+ // piping is safe; Ctrl-C still stops the container (docker stays in our
1016
+ // process group and forwards the signal, with `--init` reaping it inside).
1017
+ const handle = await spawnDevContainer(plan, args, { stdio: "piped" });
1018
+ streamDevLogs(handle.child);
997
1019
 
998
1020
  // Heartbeat the hosted cockpit (G1) CLI-side while the container runs — the
999
1021
  // container reports file-saves via the threaded env, but the CLI owns the
@@ -1088,11 +1110,12 @@ export function buildContainerPlan(workspace, args, _ctx) {
1088
1110
  }
1089
1111
 
1090
1112
  /** The crafted "here's your running store" block (Vite/`vercel dev`-grade). */
1091
- export function printDevBanner(plan) {
1113
+ export function printDevBanner(plan, mode) {
1092
1114
  console.error(`\n tot dev — ${plan.tenant || "(tenant)"}`);
1093
1115
  console.error(` ➜ Local: ${plan.url}`);
1094
1116
  console.error(` ➜ Edit: content/home.html + save → the browser reloads`);
1095
- console.error(` ➜ Private local preview — nothing is published. Ctrl-C to stop.\n`);
1117
+ console.error(` ➜ Private local preview — nothing is published. Ctrl-C to stop.`);
1118
+ console.error(" " + versionStamp(mode) + "\n");
1096
1119
  }
1097
1120
 
1098
1121
  /**
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { join } from "node:path";
25
25
  import { hasOperatorCreds } from "../auth.mjs";
26
+ import { clientPackages, osLabel } from "../mcp.mjs";
26
27
  import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
27
28
  import { dockerAvailable, tryStartDocker } from "./dev.mjs";
28
29
  import { loginAndCache } from "./login.mjs";
@@ -60,6 +61,12 @@ export function collectChecks(_ctx, env = process.env) {
60
61
  const nodeMajor = Number(process.versions.node.split(".")[0]);
61
62
  checks.push({ name: "node >= 20", pass: nodeMajor >= 20, detail: `have ${process.versions.node}`, blocking: true });
62
63
 
64
+ // Informational: the versions + OS this invocation is running on — the same
65
+ // context the run banners/error footers stamp, surfaced up front for a bug report.
66
+ // (No resolved runner here — `tot doctor` never spawns the runner.)
67
+ checks.push({ name: "tot CLI", pass: true, detail: `v${clientPackages().cli}`, blocking: false });
68
+ checks.push({ name: "OS", pass: true, detail: osLabel(), blocking: false });
69
+
63
70
  const gitRes = spawnSync("git", ["--version"], {
64
71
  encoding: "utf8",
65
72
  stdio: ["ignore", "pipe", "ignore"],
@@ -49,7 +49,7 @@ import { resolve } from "node:path";
49
49
  import { createInterface } from "node:readline/promises";
50
50
 
51
51
  import { detectContext } from "../context.mjs";
52
- import { createMcpClient } from "../mcp.mjs";
52
+ import { createMcpClient, versionStamp } 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";
@@ -65,6 +65,7 @@ import {
65
65
  } from "./dev.mjs";
66
66
  import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
67
67
  import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
68
+ import { streamDevLogs } from "../dev-logs.mjs";
68
69
  import { IDEAS } from "./ideas.mjs";
69
70
 
70
71
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -617,6 +618,7 @@ function printLiveEnding(tenant, url, elapsed) {
617
618
  console.log(` ✨ You're live.${elapsed ? ` (${elapsed})` : ""}`);
618
619
  console.log(` ${url}`);
619
620
  console.log(" Edit content/home.html + save → it reloads.");
621
+ console.log(" " + versionStamp("native"));
620
622
  console.log("");
621
623
  console.log(" Now try, in Claude:");
622
624
  console.log(` "${IDEAS[0]}"`);
@@ -637,6 +639,7 @@ function printSampleLiveEnding(url, elapsed) {
637
639
  console.log(` ${url}`);
638
640
  console.log(" Edit content/home.html + save → it reloads. The age-gate + nicotine warning");
639
641
  console.log(" you see ARE Token of Trust compliance rendering — live, on your machine.");
642
+ console.log(" " + versionStamp("sample"));
640
643
  console.log("");
641
644
  console.log(" This is a sample store, running locally, for free. Connect the ToT MCP to use");
642
645
  console.log(" your REAL store, AI editing, and live compliance previews:");
@@ -664,59 +667,6 @@ function connectClaude() {
664
667
  spawnSync("claude", [IDEAS[0]], { stdio: "inherit" });
665
668
  }
666
669
 
667
- /**
668
- * Stream the running dev server's output in BUSINESS terms. The runner + Vite +
669
- * Astro emit a lot of internal chatter (dependency optimization, HMR internals,
670
- * build banners, "watching for file changes", pnpm tails). A developer cares
671
- * about two things: that a save took effect, and any real error. So collapse a
672
- * save-reload into one clean "↻ your store reloaded", drop the known internal
673
- * noise, and pass anything else through (indented) so nothing important is
674
- * hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
675
- */
676
- function streamDevLogs(child) {
677
- // Startup churn + tool internals — never user-facing. Matched AFTER stripping
678
- // the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
679
- // timestamped internal line like "10:50:17 [vite] connected" is still dropped.
680
- const NOISE =
681
- /^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
682
- // A real save-triggered reload (not startup "program reload" churn).
683
- const RELOAD = /(hmr update|page reload)/i;
684
- let reloadPending = null;
685
- const emit = (line) => {
686
- const t = line.replace(/\s+$/, "");
687
- if (!t) return;
688
- // The runner/Vite prefix most lines with an "HH:MM:SS " (or ".mmm ")
689
- // timestamp — strip it before matching so the filters catch them.
690
- const body = t.replace(/^\d{1,2}:\d{2}:\d{2}(\.\d+)?\s+/, "").replace(/^\s+/, "");
691
- if (RELOAD.test(body)) {
692
- if (reloadPending) return; // debounce a burst into one line
693
- reloadPending = setTimeout(() => { reloadPending = null; }, 1000);
694
- if (reloadPending.unref) reloadPending.unref();
695
- process.stdout.write(" ↻ your store reloaded\n");
696
- return;
697
- }
698
- if (NOISE.test(body)) return;
699
- process.stdout.write(` ${t}\n`);
700
- };
701
- lineStream(child.stdout, emit);
702
- lineStream(child.stderr, emit);
703
- }
704
-
705
- /** Call `cb` once per complete line of `stream` (dependency-free line buffering). */
706
- function lineStream(stream, cb) {
707
- if (!stream) return;
708
- let buf = "";
709
- stream.on("data", (chunk) => {
710
- buf += chunk.toString();
711
- let nl;
712
- while ((nl = buf.indexOf("\n")) >= 0) {
713
- cb(buf.slice(0, nl));
714
- buf = buf.slice(nl + 1);
715
- }
716
- });
717
- stream.on("end", () => { if (buf.trim()) cb(buf); });
718
- }
719
-
720
670
  // ── small prompt helpers (respect non-TTY so nothing hangs in CI) ────────────
721
671
 
722
672
  function isInteractive() {
@@ -35,18 +35,21 @@ import { createMcpClient } from "../mcp.mjs";
35
35
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
36
36
  import { validateTenant, ERROR } from "../validate.mjs";
37
37
  import { openBrowser } from "../open.mjs";
38
+ import { startProgress } from "../progress.mjs";
38
39
  import { fail } from "../errors.mjs";
39
40
 
40
41
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
41
42
  const DEFAULT_REF = "preview";
42
43
 
43
- function parseArgs(argv) {
44
- const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, help: false };
44
+ export function parseArgs(argv) {
45
+ const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, help: false };
45
46
  for (let i = 0; i < argv.length; i++) {
46
47
  const t = argv[i];
47
48
  if (t === "--mcp") a.mcp = argv[++i];
48
49
  else if (t === "--identity") a.identity = argv[++i];
49
50
  else if (t === "--ref") a.ref = argv[++i];
51
+ else if (t === "-m" || t === "--message") a.message = argv[++i];
52
+ else if (t === "--summary") a.summary = argv[++i];
50
53
  else if (t === "--skip-validate") a.skipValidate = true;
51
54
  else if (t === "--no-wait") a.noWait = true;
52
55
  else if (t === "--watch") a.watch = true;
@@ -62,9 +65,14 @@ const USAGE = `tot submit — submit your store for preview
62
65
  tot submit --watch stay attached through reconcile + compliance + accept (long-poll)
63
66
  tot submit --skip-validate push without the local lint (not recommended)
64
67
  tot submit --ref <name> push ref (default: ${DEFAULT_REF})
68
+ tot submit -m "<title>" one-line summary of what changed (the approver sees this)
69
+ tot submit --summary "<text>" longer description to accompany the title
65
70
  tot submit --no-wait push and exit without polling for the reconcile result
66
71
  tot submit --no-open don't open the preview URL in the browser on success
67
- tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)`;
72
+ tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
73
+
74
+ If you omit -m, a summary is generated from git (commit subject + the diff vs
75
+ what's live in preview) so the change record the approver reviews is never blank.`;
68
76
 
69
77
  // Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
70
78
  // --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
@@ -74,6 +82,38 @@ const WATCH_POLL = { attempts: 24, delayMs: 20_000, waitMs: 20_000, untilShipped
74
82
 
75
83
  const redactUrl = (s) => String(s).replace(/\/\/[^/@\s]*@/g, "//***@");
76
84
 
85
+ const SUMMARY_FILE_LIMIT = 12;
86
+
87
+ /**
88
+ * Build the human "what changed" summary the approver is greeted with. Explicit
89
+ * `--message` (title) / `--summary` (body) always win; when a title is omitted we
90
+ * fall back to the HEAD commit subject, and the body always carries the git delta
91
+ * (shortstat + changed files) so a submit never leaves the approver a blank record.
92
+ * Pure (all git I/O is done by the caller and passed in) so it's unit-tested.
93
+ * @param {{ message?: string|null, summary?: string|null, headSubject?: string,
94
+ * statLine?: string, files?: string[] }} input
95
+ * @returns {{ title: string, body: string[], autoTitle: boolean }}
96
+ */
97
+ export function buildChangeSummary({ message, summary, headSubject = "", statLine = "", files = [] } = {}) {
98
+ const title = (message && message.trim()) || headSubject.trim() || "(untitled change)";
99
+ const body = [];
100
+ if (summary && summary.trim()) body.push(...summary.trim().split(/\r?\n/).map((l) => l.trimEnd()));
101
+ if (statLine) body.push(statLine);
102
+ for (const f of files.slice(0, SUMMARY_FILE_LIMIT)) body.push(`· ${f}`);
103
+ if (files.length > SUMMARY_FILE_LIMIT) body.push(`· … +${files.length - SUMMARY_FILE_LIMIT} more file(s)`);
104
+ return { title, body, autoTitle: !(message && message.trim()) };
105
+ }
106
+
107
+ /** Print the change summary block. Push-only: this is what a human relays to the
108
+ * approver, or the agent carries into change_open — the CLI does not open the
109
+ * change record itself. */
110
+ function printChangeSummary({ title, body, autoTitle }) {
111
+ console.log(`\n Change summary (for the approver / the change record):`);
112
+ console.log(` ${title}`);
113
+ for (const l of body) console.log(` ${l}`);
114
+ if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
115
+ }
116
+
77
117
  /** @param {string[]} argv @param {any} ctx */
78
118
  export async function run(argv, ctx) {
79
119
  const env = process.env;
@@ -118,6 +158,32 @@ export async function run(argv, ctx) {
118
158
  return 1;
119
159
  }
120
160
  const short = commit.slice(0, 9);
161
+
162
+ // Build the "what changed" summary the approver is greeted with — BEFORE the
163
+ // push, because `git push` fast-forwards the local origin/<ref> tracking ref and
164
+ // would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
165
+ // otherwise it's generated from git so the change record is never blank. Push-only:
166
+ // we surface it (the agent carries it into change_open) — the CLI never opens the
167
+ // change record itself.
168
+ const gitSafe = (cargs) => {
169
+ try {
170
+ return git(cargs);
171
+ } catch {
172
+ return "";
173
+ }
174
+ };
175
+ const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
176
+ const trackingRef = `refs/remotes/origin/${args.ref}`;
177
+ const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
178
+ ? trackingRef
179
+ : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
180
+ ? "HEAD~1"
181
+ : "";
182
+ const nameCmd = base ? ["diff", "--name-only", `${base}..HEAD`] : ["show", "--name-only", "--format=", "HEAD"];
183
+ const files = gitSafe(nameCmd).split("\n").map((s) => s.trim()).filter(Boolean);
184
+ const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
185
+ const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
186
+
121
187
  console.error(`~ pushing ${short} → ${args.ref} (origin)`);
122
188
  try {
123
189
  const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
@@ -132,10 +198,12 @@ export async function run(argv, ctx) {
132
198
  return 1;
133
199
  }
134
200
  console.log(`\n+ submitted ${short} to ${args.ref}.`);
201
+ printChangeSummary(changeSummary);
135
202
 
136
203
  // 3. report reconcile + compliance + preview URL from the MCP (graceful seam).
137
204
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
138
205
  const client = createMcpClient(baseUrl);
206
+ let progress = null;
139
207
  try {
140
208
  // Attach auth before the first server call (developer bearer pre-initialize,
141
209
  // operator credential_validate post-initialize) — see establishSession.
@@ -143,20 +211,38 @@ export async function run(argv, ctx) {
143
211
  // Set the active tenant so preview_status reads the right scope (it keys on
144
212
  // the session's tenant + the commit — no tenant arg of its own).
145
213
  await client.callTool("client_switch", { tenant });
146
- const status = args.noWait
147
- ? normalizePreviewStatus(await client.callTool("preview_status", { commit }))
148
- : await pollPreviewStatus(client, commit, {
149
- ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
150
- onTick: (s, i) => {
151
- if (s.status === "pending") console.error(`~ reconcile running for ${short} (${i + 1})`);
152
- else if (s.status === "reconciled" && !s.shipped) {
153
- console.error(`~ reconciled waiting for a ship decision (${i + 1})`);
154
- }
155
- },
156
- });
214
+ let status;
215
+ if (args.noWait) {
216
+ status = normalizePreviewStatus(await client.callTool("preview_status", { commit }));
217
+ } else {
218
+ // One in-place status line (TTY: a spinner with an elapsed-seconds counter;
219
+ // non-TTY: a ~10s heartbeat) instead of a newline per poll the wait reads
220
+ // as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
221
+ // ticking on its own 90ms timer even while a single poll long-polls for
222
+ // waitMs, so elapsed time is real wall-clock, not the attempt count.
223
+ let phase = "reconcile";
224
+ progress = startProgress(`reconcile running for ${short}…`, {
225
+ stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
226
+ });
227
+ status = await pollPreviewStatus(client, commit, {
228
+ ...(args.watch ? WATCH_POLL : DEFAULT_POLL),
229
+ onTick: (s) => {
230
+ // Reconcile is done but we're still waiting on a ship decision (--watch):
231
+ // swap the label so the single line reflects the new phase, timer resets.
232
+ if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
233
+ phase = "ship";
234
+ progress.stop();
235
+ progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
236
+ }
237
+ },
238
+ });
239
+ progress.stop();
240
+ progress = null;
241
+ }
157
242
  reportStatus(status, tenant, { open: !args.noOpen });
158
243
  return status?.status === "failed" ? 1 : 0;
159
244
  } catch (e) {
245
+ progress?.stop();
160
246
  if (e instanceof AuthUnavailableError) {
161
247
  console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
162
248
  } else {
@@ -18,12 +18,18 @@
18
18
  * session, or the zero-login `--sample` path) it's a silent no-op.
19
19
  * Dependency-free (global fetch, Node 20+).
20
20
  */
21
+ import os from "node:os";
21
22
  import { CLI_VERSION, clientPackages } from "./mcp.mjs";
22
23
 
23
24
  /** ~10s between beats — frequent enough that the cockpit's ~30s live window
24
25
  * tolerates a missed beat without the badge flapping, cheap enough to ignore. */
25
26
  const HEARTBEAT_INTERVAL_MS = 10_000;
26
27
 
28
+ /** The developer's OS, structured + queryable for the hosted telemetry. Computed
29
+ * once — the OS doesn't change mid-run. `os`=platform (darwin/linux/win32),
30
+ * `osVersion`=kernel release, `arch`=CPU arch (arm64/x64). */
31
+ const OS_INFO = { os: os.platform(), osVersion: os.release(), arch: os.arch() };
32
+
27
33
  /**
28
34
  * POST one heartbeat body, best-effort. No-op (returns undefined) without both
29
35
  * an activity URL and a bearer token. Never throws — a failed/offline hosted
@@ -34,7 +40,7 @@ const HEARTBEAT_INTERVAL_MS = 10_000;
34
40
  export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd } = {}) {
35
41
  if (!activityUrl || !token) return undefined;
36
42
  /** @type {Record<string, unknown>} */
37
- const body = { event: "heartbeat", cliVersion, at: Date.now() };
43
+ const body = { event: "heartbeat", cliVersion, at: Date.now(), ...OS_INFO };
38
44
  if (runnerVersion) body.runnerVersion = runnerVersion;
39
45
  if (url) body.url = url;
40
46
  // The developer's terminal $EDITOR (if set) — lets the cockpit suggest the exact
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared dev-server log streaming — the single source of the "quiet, business-
3
+ * voice" runner output used by BOTH `tot start` (start.mjs) and `tot dev`
4
+ * (dev.mjs). Extracted so the two entrypoints stream identically instead of one
5
+ * inheriting the raw vite/astro firehose while the other collapses it.
6
+ *
7
+ * Dependency-free (no imports) — pure line buffering + filtering over a child's
8
+ * stdout/stderr.
9
+ */
10
+
11
+ /**
12
+ * Stream the running dev server's output in BUSINESS terms. The runner + Vite +
13
+ * Astro emit a lot of internal chatter (dependency optimization, HMR internals,
14
+ * build banners, "watching for file changes", pnpm tails). A developer cares
15
+ * about two things: that a save took effect, and any real error. So collapse a
16
+ * save-reload into one clean "↻ your store reloaded", drop the known internal
17
+ * noise, and pass anything else through (indented) so nothing important is
18
+ * hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
19
+ * @param {import("node:child_process").ChildProcess} child
20
+ */
21
+ export function streamDevLogs(child) {
22
+ // Startup churn + tool internals — never user-facing. Matched AFTER stripping
23
+ // the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
24
+ // timestamped internal line like "10:50:17 [vite] connected" is still dropped.
25
+ const NOISE =
26
+ /^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
27
+ // A real save-triggered reload (not startup "program reload" churn).
28
+ const RELOAD = /(hmr update|page reload)/i;
29
+ let reloadPending = null;
30
+ const emit = (line) => {
31
+ const t = line.replace(/\s+$/, "");
32
+ if (!t) return;
33
+ // The runner/Vite prefix most lines with an "HH:MM:SS " (or ".mmm ")
34
+ // timestamp — strip it before matching so the filters catch them.
35
+ const body = t.replace(/^\d{1,2}:\d{2}:\d{2}(\.\d+)?\s+/, "").replace(/^\s+/, "");
36
+ if (RELOAD.test(body)) {
37
+ if (reloadPending) return; // debounce a burst into one line
38
+ reloadPending = setTimeout(() => { reloadPending = null; }, 1000);
39
+ if (reloadPending.unref) reloadPending.unref();
40
+ process.stdout.write(" ↻ your store reloaded\n");
41
+ return;
42
+ }
43
+ if (NOISE.test(body)) return;
44
+ process.stdout.write(` ${t}\n`);
45
+ };
46
+ lineStream(child.stdout, emit);
47
+ lineStream(child.stderr, emit);
48
+ }
49
+
50
+ /**
51
+ * Call `cb` once per complete line of `stream` (dependency-free line buffering).
52
+ * @param {import("node:stream").Readable|null|undefined} stream
53
+ * @param {(line: string) => void} cb
54
+ */
55
+ export function lineStream(stream, cb) {
56
+ if (!stream) return;
57
+ let buf = "";
58
+ stream.on("data", (chunk) => {
59
+ buf += chunk.toString();
60
+ let nl;
61
+ while ((nl = buf.indexOf("\n")) >= 0) {
62
+ cb(buf.slice(0, nl));
63
+ buf = buf.slice(nl + 1);
64
+ }
65
+ });
66
+ stream.on("end", () => { if (buf.trim()) cb(buf); });
67
+ }
package/src/errors.mjs CHANGED
@@ -8,9 +8,27 @@
8
8
  * - anything else (thrown Error, AuthUnavailableError, a string) — we render
9
9
  * its message and, when we can recognise it, its remedy.
10
10
  *
11
- * Dependency-free. Kept import-cycle-free by duck-typing AuthUnavailableError
12
- * (matched on `.name`/`.hint`) rather than importing auth.mjs.
11
+ * Kept import-cycle-free by duck-typing AuthUnavailableError (matched on
12
+ * `.name`/`.hint`) rather than importing auth.mjs. The one import it DOES take —
13
+ * versionStamp from mcp.mjs — is safe: mcp.mjs pulls only node builtins, so it
14
+ * can't cycle back here.
13
15
  */
16
+ import { versionStamp } from "./mcp.mjs";
17
+
18
+ /**
19
+ * The version/OS context appended to every failure's stderr — so a bug report
20
+ * pasted from a failed run already carries `tot`/runner/node/OS. No mode (a
21
+ * failure isn't a run mode); runner shows `—` when the failure predates its
22
+ * resolution. Best-effort — never let a stamp problem swallow the real error.
23
+ * @returns {string}
24
+ */
25
+ function versionFooter() {
26
+ try {
27
+ return `\n (${versionStamp()})`;
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
14
32
 
15
33
  /**
16
34
  * A failure worth surfacing with a concrete next step.
@@ -42,6 +60,11 @@ export function fail(what, next) {
42
60
  * @returns {string}
43
61
  */
44
62
  export function formatError(err) {
63
+ return formatErrorBody(err) + versionFooter();
64
+ }
65
+
66
+ /** The house-style failure line(s), WITHOUT the trailing version/OS footer. */
67
+ function formatErrorBody(err) {
45
68
  if (err instanceof CliError) return fail(err.what, err.next);
46
69
  // AuthUnavailableError, duck-typed to avoid an import cycle with auth.mjs.
47
70
  if (err && typeof err === "object" && err.name === "AuthUnavailableError") {
package/src/mcp.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  * `Mcp-Session-Id` header across calls. Dependency-free (global fetch, Node 20+).
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
+ import os from "node:os";
11
12
 
12
13
  // The CLI's REAL version for the MCP handshake — the transmission channel the
13
14
  // server-side support policy (update-awareness Layer 2) decides against. Read from
@@ -39,6 +40,47 @@ export function clientPackages() {
39
40
  return { cli: CLI_VERSION, runner: RUNNER_VERSION };
40
41
  }
41
42
 
43
+ /**
44
+ * A readable OS string for debugging — friendly platform name + the raw release
45
+ * and arch (kept raw so it's honest for a bug report). e.g. `macOS 24.6.0 arm64`,
46
+ * `Linux 6.8.0-generic x64`, `Windows 10.0.22631 x64`.
47
+ * @returns {string}
48
+ */
49
+ export function osLabel() {
50
+ const platform = os.platform();
51
+ const friendly =
52
+ platform === "darwin"
53
+ ? "macOS"
54
+ : platform === "win32"
55
+ ? "Windows"
56
+ : platform === "linux"
57
+ ? "Linux"
58
+ : platform;
59
+ return `${friendly} ${os.release()} ${os.arch()}`;
60
+ }
61
+
62
+ /**
63
+ * A one-line version/OS stamp shown wherever a developer runs the CLI (run
64
+ * banners, live endings, error footers, `tot doctor`) — the exact context a
65
+ * future bug report needs. e.g.:
66
+ * `tot v1.3.3 · runner v1.3.2 · node v24.2.0 · macOS 24.6.0 arm64 · native`
67
+ * The runner shows `—` until it's resolved this invocation (setRunnerVersion),
68
+ * and the trailing `· <mode>` is appended only when a mode label is passed.
69
+ * @param {string} [mode] - the run mode label (e.g. "native", "docker", "sample").
70
+ * @returns {string}
71
+ */
72
+ export function versionStamp(mode) {
73
+ const { cli, runner } = clientPackages();
74
+ const parts = [
75
+ `tot v${cli}`,
76
+ `runner ${runner ? `v${runner}` : "—"}`,
77
+ `node v${process.versions.node}`,
78
+ osLabel(),
79
+ ];
80
+ if (mode) parts.push(String(mode));
81
+ return parts.join(" · ");
82
+ }
83
+
42
84
  /**
43
85
  * @param {string} baseUrl - MCP base URL; `/mcp` is appended if absent.
44
86
  * @param {{ token?: string, clientVersion?: string }} [opts] - optional developer OAuth
package/src/validate.mjs CHANGED
@@ -96,6 +96,28 @@ function validateRawHtmlBody(html) {
96
96
  if (!html.includes("<")) return "no markup tags found";
97
97
  return null;
98
98
  }
99
+ /**
100
+ * Does the tenant provide chrome to wrap body-only fragments? True when it ships
101
+ * EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json` that
102
+ * opts into the canonical shared chrome. Mirrors the serve-time resolution in
103
+ * apps/storefront getChromeHtml() → parseChromeConfig(): an opted-in config is
104
+ * rendered into the wrapper (renderRawChrome), so a fragment is NOT served naked.
105
+ * Kept in step with lib/chrome/parseConfig.ts's opt-in gate.
106
+ */
107
+ function providesChrome(contentDir) {
108
+ if (existsSync(join(contentDir, "chrome.html"))) return true;
109
+ const configPath = join(contentDir, "chrome.json");
110
+ if (!existsSync(configPath)) return false;
111
+ const { value } = readJsonSafe(configPath);
112
+ if (value == null || typeof value !== "object") return false;
113
+ if (value.sharedChrome !== true) return false;
114
+ const brand = value.brand;
115
+ if (brand == null || typeof brand !== "object" || typeof brand.label !== "string") return false;
116
+ if (!Array.isArray(value.nav)) return false;
117
+ const footer = value.footer;
118
+ if (footer == null || typeof footer !== "object" || !Array.isArray(footer.columns)) return false;
119
+ return true;
120
+ }
99
121
 
100
122
  // --- filesystem helpers ------------------------------------------------------
101
123
  function readJsonSafe(path) {
@@ -186,8 +208,7 @@ export function validateTenant(tenantDir, opts = {}) {
186
208
  }
187
209
 
188
210
  // 4. raw HTML documents — validate + portability + resolution
189
- const chromePath = join(contentDir, "chrome.html");
190
- const hasChrome = existsSync(chromePath);
211
+ const hasChrome = providesChrome(contentDir);
191
212
  const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));
192
213
  const pageTargets = buildPageTargetSet(contentDir, pagesDir);
193
214
 
@@ -204,8 +225,8 @@ export function validateTenant(tenantDir, opts = {}) {
204
225
  if (!base && !isFullDocument(html) && !hasChrome) {
205
226
  findings.push(
206
227
  mk(ERROR, "html-fragment", r,
207
- "is an HTML fragment but the tenant ships no content/chrome.html to wrap it — it will serve unwrapped/naked",
208
- "make it a full <!doctype html> document, or add content/chrome.html with <!--PAGE_BODY-->"),
228
+ "is an HTML fragment but the tenant ships no chrome to wrap it — it will serve unwrapped/naked",
229
+ "make it a full <!doctype html> document, add a content/chrome.json with \"sharedChrome\": true (brand + nav + footer), or add content/chrome.html with <!--PAGE_BODY-->"),
209
230
  );
210
231
  }
211
232
  if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {