@tokenoftrust/cli 1.3.3-rc.1 → 1.3.4-rc.0

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.1",
3
+ "version": "1.3.4-rc.0",
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",
package/src/auth.mjs CHANGED
@@ -31,13 +31,74 @@ import { recordServerPolicy } from "./update-check.mjs";
31
31
 
32
32
  /** Thrown when no provider can authenticate — carries actionable guidance. */
33
33
  export class AuthUnavailableError extends Error {
34
- constructor(message, { hint } = {}) {
34
+ constructor(message, { hint, reason } = {}) {
35
35
  super(message);
36
36
  this.name = "AuthUnavailableError";
37
37
  this.hint = hint || null;
38
+ this.reason = reason || null;
38
39
  }
39
40
  }
40
41
 
42
+ const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+ const SAFE_EMAIL_HINT_RE = /^[a-z0-9._%+\-]{1,96}@[a-z0-9.-]{1,96}\.[a-z]{2,63}$/i;
44
+
45
+ function maskEmailPart(part) {
46
+ if (part.length <= 2) return `${part.slice(0, 1)}...`;
47
+ return `${part.slice(0, 1)}...${part.slice(-1)}`;
48
+ }
49
+
50
+ /** Display-only email hint for URLs/log-safe UX. Never returns the full address. */
51
+ export function redactEmailForHint(email) {
52
+ const raw = String(email || "").trim().toLowerCase();
53
+ const at = raw.indexOf("@");
54
+ if (at <= 0) return null;
55
+ const local = raw.slice(0, at);
56
+ const domain = raw.slice(at + 1);
57
+ const parts = domain.split(".").filter(Boolean);
58
+ if (parts.length < 2) return null;
59
+ const suffix = parts.pop();
60
+ const registrable = parts.join(".");
61
+ if (!suffix || !registrable) return null;
62
+ return `${maskEmailPart(local)}@${maskEmailPart(registrable)}.${suffix}`;
63
+ }
64
+
65
+ export function normalizeEmailHint(input) {
66
+ const raw = String(input || "").trim().toLowerCase();
67
+ if (!raw || raw.length > 160 || /\s/.test(raw) || !raw.includes("@")) return null;
68
+ if (FULL_EMAIL_RE.test(raw) && !raw.includes("...")) return redactEmailForHint(raw);
69
+ return SAFE_EMAIL_HINT_RE.test(raw) ? raw : null;
70
+ }
71
+
72
+ export function credentialEmailHint(creds) {
73
+ return (
74
+ normalizeEmailHint(creds?.emailHint) ||
75
+ normalizeEmailHint(creds?.email) ||
76
+ redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "")
77
+ );
78
+ }
79
+
80
+ /** Hosted cockpit URL for regenerating local CLI credentials, when we cached one. */
81
+ export function cockpitRecoveryUrl(activityUrl, emailHint = null) {
82
+ if (!activityUrl) return null;
83
+ try {
84
+ const url = new URL("/cockpit?recover=cli", String(activityUrl));
85
+ const hint = normalizeEmailHint(emailHint);
86
+ if (hint) url.searchParams.set("email_hint", hint);
87
+ return url.toString();
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /** Next-step copy for a stale local developer credential. */
94
+ export function developerCredentialRecoveryHint(creds) {
95
+ const url = cockpitRecoveryUrl(creds?.activityUrl, credentialEmailHint(creds));
96
+ if (url) {
97
+ return `open ${url}, sign in if prompted, click "Generate a fresh setup command", then paste it into your terminal.`;
98
+ }
99
+ return "run `tot login` to sign in again.";
100
+ }
101
+
41
102
  /** True when a full operator credential triple is present in the environment. */
42
103
  export function hasOperatorCreds(env = process.env) {
43
104
  return Boolean(env.TOT_API_KEY && env.TOT_SECRET_KEY && env.TOT_APP_DOMAIN);
@@ -95,7 +156,10 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
95
156
  if (!creds || !creds.accessToken) {
96
157
  throw new AuthUnavailableError(
97
158
  "you're not signed in to Token of Trust.",
98
- { hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run." },
159
+ {
160
+ reason: "missing",
161
+ hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run.",
162
+ },
99
163
  );
100
164
  }
101
165
 
@@ -103,7 +167,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
103
167
  if (!creds.refreshToken || !creds.tokenEndpoint) {
104
168
  throw new AuthUnavailableError(
105
169
  "your Token of Trust session has expired.",
106
- { hint: "run `tot login` to sign in again." },
170
+ { reason: "expired", hint: developerCredentialRecoveryHint(creds) },
107
171
  );
108
172
  }
109
173
  let refreshed;
@@ -116,7 +180,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
116
180
  } catch (e) {
117
181
  throw new AuthUnavailableError(
118
182
  `couldn't refresh your Token of Trust session: ${e?.message || e}`,
119
- { hint: "run `tot login` to sign in again." },
183
+ { reason: "refreshFailed", hint: developerCredentialRecoveryHint(creds) },
120
184
  );
121
185
  }
122
186
  const prior = creds;
@@ -138,6 +202,9 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
138
202
  creds.activityToken = prior.activityToken;
139
203
  creds.activityUrl = prior.activityUrl;
140
204
  }
205
+ if (prior.traceId) creds.traceId = prior.traceId;
206
+ const priorEmailHint = credentialEmailHint(prior);
207
+ if (priorEmailHint) creds.emailHint = priorEmailHint;
141
208
  writeCredentials(path, creds);
142
209
  }
143
210
 
@@ -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"],
@@ -24,6 +24,7 @@ import { createInterface } from "node:readline/promises";
24
24
  import { createMcpClient } from "../mcp.mjs";
25
25
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
26
  import { readActivity, formatActivity } from "../activity-log.mjs";
27
+ import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
27
28
  import { fail } from "../errors.mjs";
28
29
 
29
30
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -50,6 +51,30 @@ const USAGE = `tot feedback — send a note to Token of Trust (with your recent
50
51
 
51
52
  Requires a signed-in session — run \`tot login\` first if needed.`;
52
53
 
54
+ /**
55
+ * Assemble the `feedback_submit` payload. Pure + exported so the wire shape
56
+ * (including the additive `sensitive` activity attachment and the invite→problem
57
+ * `traceId` link) is unit-testable without driving the MCP handshake. Both extras
58
+ * are OMITTED when their source is absent — never sent as null/empty.
59
+ * @param {{ type: string, category: string, severity: string, title: string,
60
+ * description: string, activityText?: string|null, traceId?: string|null }} f
61
+ */
62
+ export function buildFeedbackPayload({ type, category, severity, title, description, activityText, traceId }) {
63
+ return {
64
+ type,
65
+ category,
66
+ severity,
67
+ title,
68
+ description,
69
+ scenario: "tot-cli",
70
+ // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
71
+ // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
72
+ ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
73
+ // Invite→terminal→problem trace link (omitted when this wasn't an invite login).
74
+ ...(traceId ? { traceId } : {}),
75
+ };
76
+ }
77
+
53
78
  function parseArgs(argv) {
54
79
  const a = {
55
80
  mcp: null, type: "improvement", category: "product-ux", severity: "medium",
@@ -115,6 +140,14 @@ export async function run(argv) {
115
140
  const activityText = entries.length ? formatActivity(entries) : null;
116
141
  const title = message.length > 80 ? `${message.slice(0, 79)}…` : message;
117
142
 
143
+ // The invite→terminal→problem trace id `tot login` cached from the pasted invite
144
+ // command (see commands/login.mjs `cacheTraceId`). Stamping it onto the report lets
145
+ // triage join this problem back to the developer's invite + their live heartbeat
146
+ // session. Read it BEFORE establishSession — a silent token refresh rewrites the
147
+ // credential file, so read the cached value first. Absent for a bare (non-invite)
148
+ // login; omitted from the payload then.
149
+ const traceId = readCredentials(defaultCredentialsPath(env))?.traceId || null;
150
+
118
151
  // Preview — sending publishes to Token of Trust, so show exactly what goes out.
119
152
  console.error("\nAbout to send to Token of Trust:");
120
153
  console.error(` ${args.type} · ${args.category} · ${args.severity}`);
@@ -152,17 +185,15 @@ export async function run(argv) {
152
185
  env,
153
186
  initialize: () => client.initialize({ name: "tot-cli", version: "feedback" }),
154
187
  });
155
- const payload = {
188
+ const payload = buildFeedbackPayload({
156
189
  type: args.type,
157
190
  category: args.category,
158
191
  severity: args.severity,
159
192
  title,
160
193
  description: message,
161
- scenario: "tot-cli",
162
- // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
163
- // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
164
- ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
165
- };
194
+ activityText,
195
+ traceId,
196
+ });
166
197
  const res = await client.callTool("feedback_submit", payload);
167
198
  const id = res?.reportId || res?.id || null;
168
199
  console.log(`\n+ sent — thank you.${id ? ` (report ${id})` : ""}`);
@@ -23,13 +23,14 @@ import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oa
23
23
  import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
24
24
  import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
26
+ import { cockpitRecoveryUrl, normalizeEmailHint } from "../auth.mjs";
26
27
 
27
28
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
28
29
 
29
30
  function parseArgs(argv) {
30
31
  const a = {
31
32
  mcp: null, device: false, code: null, help: false,
32
- activityToken: null, activityUrl: null,
33
+ activityToken: null, activityUrl: null, traceId: null, emailHint: null,
33
34
  };
34
35
  for (let i = 0; i < argv.length; i++) {
35
36
  const t = argv[i];
@@ -38,11 +39,44 @@ function parseArgs(argv) {
38
39
  else if (t === "--code" || t === "--token") a.code = argv[++i];
39
40
  else if (t === "--activity-token") a.activityToken = argv[++i];
40
41
  else if (t === "--activity-url") a.activityUrl = argv[++i];
42
+ else if (t === "--trace-id") a.traceId = argv[++i];
43
+ else if (t === "--email-hint") a.emailHint = argv[++i];
44
+ else if (t === "--email") a.emailHint = argv[++i]; // legacy pasted commands
41
45
  else if (t === "--help" || t === "-h") a.help = true;
42
46
  }
43
47
  return a;
44
48
  }
45
49
 
50
+ /** Canonical UUID (8-4-4-4-12 hex). Used to reject a malformed --trace-id paste. */
51
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
52
+
53
+ /**
54
+ * Cache the invite→terminal→problem trace id (storefront mints it when it issues
55
+ * the developer's CLI sign-in code and appends it to the pasted login command as
56
+ * `--trace-id <uuid>` — see apps/storefront/src/lib/dev/cliSignInCode.ts) alongside
57
+ * the MCP creds `tot login` just wrote, so `tot feedback` can stamp a later problem
58
+ * report with it and triage can join the report to the invite + live heartbeat
59
+ * session. Additive, and mirrors cacheActivityBridge. A no-op when the flag is
60
+ * absent (older pastes / a bare `tot login`) or malformed (defensive — skip rather
61
+ * than persist junk that would break the join).
62
+ */
63
+ export function cacheTraceId(env, traceId) {
64
+ if (!traceId || !UUID_RE.test(traceId)) return;
65
+ const path = defaultCredentialsPath(env);
66
+ const current = readCredentials(path) || {};
67
+ writeCredentials(path, { ...current, traceId });
68
+ }
69
+
70
+ /** Cache a display-only email hint from the setup command for later recovery UX. */
71
+ export function cacheEmailHint(env, emailHint) {
72
+ const normalized = normalizeEmailHint(emailHint);
73
+ if (!normalized) return;
74
+ const path = defaultCredentialsPath(env);
75
+ const current = readCredentials(path) || {};
76
+ const { email: _legacyEmail, ...safeCurrent } = current;
77
+ writeCredentials(path, { ...safeCurrent, emailHint: normalized });
78
+ }
79
+
46
80
  /**
47
81
  * Cache the local→hosted activity-bridge credential (storefront's
48
82
  * cli-signin-code mint, piggybacked as two extra login flags — see
@@ -68,9 +102,10 @@ const USAGE = `tot login — sign in to Token of Trust
68
102
  browser opener exists on this box)
69
103
  tot login --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
70
104
 
71
- --activity-token/--activity-url are set automatically by the pasted invite
72
- command (report local dev-loop activity to your hosted /dev panel) — not
73
- meant to be typed by hand.
105
+ --activity-token/--activity-url/--trace-id/--email-hint are set automatically by the pasted
106
+ invite command (report local dev-loop activity to your hosted /dev panel, and
107
+ link a later \`tot feedback\` report to your invite + session) — not meant to be
108
+ typed by hand.
74
109
 
75
110
  After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot submit\`.`;
76
111
 
@@ -128,15 +163,31 @@ export async function redeemAndCache(mcpUrl, code, env = process.env) {
128
163
  * prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
129
164
  * (a full-object overwrite, not a merge) — carry it forward when re-authing against
130
165
  * the SAME mcpUrl (a different MCP means a different session; the old bridge
131
- * credential no longer applies).
166
+ * credential and trace no longer apply).
132
167
  */
133
168
  export function mergeActivityBridge(prior, mcpUrl, creds) {
134
- if (prior?.mcpUrl === mcpUrl && prior.activityToken && prior.activityUrl) {
135
- return { ...creds, activityToken: prior.activityToken, activityUrl: prior.activityUrl };
169
+ if (prior?.mcpUrl === mcpUrl) {
170
+ const emailHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
171
+ return {
172
+ ...creds,
173
+ ...(prior.activityToken && prior.activityUrl
174
+ ? { activityToken: prior.activityToken, activityUrl: prior.activityUrl }
175
+ : {}),
176
+ ...(prior.traceId ? { traceId: prior.traceId } : {}),
177
+ ...(emailHint ? { emailHint } : {}),
178
+ };
136
179
  }
137
180
  return creds;
138
181
  }
139
182
 
183
+ export function inviteCodeRecoveryNext(activityUrl, emailHint = null) {
184
+ const url = cockpitRecoveryUrl(activityUrl, emailHint);
185
+ if (url) {
186
+ return `open ${url}, sign in if prompted, click "Generate a fresh setup command", then paste it into your terminal.`;
187
+ }
188
+ return 'open your developer invite again. If the link has expired, use "Send a fresh link", then generate a new setup command from cockpit.';
189
+ }
190
+
140
191
  /** @param {string[]} argv @param {any} _ctx */
141
192
  export async function run(argv, _ctx) {
142
193
  const env = process.env;
@@ -153,6 +204,8 @@ export async function run(argv, _ctx) {
153
204
  try {
154
205
  await redeemAndCache(mcpUrl, args.code, env);
155
206
  cacheActivityBridge(env, args.activityToken, args.activityUrl);
207
+ cacheTraceId(env, args.traceId);
208
+ cacheEmailHint(env, args.emailHint);
156
209
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
157
210
  console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
158
211
  return 0;
@@ -160,7 +213,7 @@ export async function run(argv, _ctx) {
160
213
  console.error(
161
214
  fail(
162
215
  `sign-in didn't complete: ${e?.message || e}`,
163
- "double-check the sign-in token from your invite — it's single-use and expires, so ask for a fresh invite if needed.",
216
+ inviteCodeRecoveryNext(args.activityUrl, args.emailHint),
164
217
  ),
165
218
  );
166
219
  return 1;
@@ -170,6 +223,7 @@ export async function run(argv, _ctx) {
170
223
  console.error(`~ signing in to Token of Trust (${mcpUrl})`);
171
224
  try {
172
225
  await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
226
+ cacheTraceId(env, args.traceId);
173
227
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
174
228
  console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
175
229
  return 0;
@@ -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";
@@ -373,7 +374,7 @@ async function tryResolveSession(client, env, args) {
373
374
  try {
374
375
  return await loginStep(client, env, args);
375
376
  } catch (e) {
376
- if (e instanceof AuthUnavailableError) return null;
377
+ if (e instanceof AuthUnavailableError && e.reason === "missing") return null;
377
378
  if (e instanceof CliError && /can't reach the Token of Trust MCP/.test(e.message)) return null;
378
379
  throw e;
379
380
  }
@@ -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
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * { mcpUrl, clientId, tokenEndpoint, scope,
9
9
  * accessToken, refreshToken, expiresAt (epoch ms), obtainedAt,
10
- * activityToken, activityUrl }
10
+ * activityToken, activityUrl, traceId, emailHint }
11
11
  *
12
12
  * We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
13
13
  * re-registration, and `mcpUrl` so we never present a token minted for one MCP
@@ -16,7 +16,16 @@
16
16
  * invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
17
17
  * commands/dev.mjs `activityBridgeEnv`, which every runner-spawning path (native
18
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
+ * can report file saves to the developer's hosted /dev panel. `traceId` is the
20
+ * storefront-minted invite→terminal→problem trace id (cached by `tot login`'s
21
+ * `--trace-id` flag, see commands/login.mjs `cacheTraceId`); `tot feedback`
22
+ * stamps it onto a problem report so triage can join it to the invite + the live
23
+ * heartbeat session. `emailHint` is masked, display-only recovery context from
24
+ * the setup command, used to orient cockpit/login after a local credential
25
+ * expires without persisting a raw address.
26
+ *
27
+ * The read/write is a plain JSON pass-through (no schema) — new fields like
28
+ * `traceId` ride along additively; readers simply pick the keys they need.
20
29
  * Dependency-free (node:fs/os/path).
21
30
  *
22
31
  * `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
package/src/validate.mjs CHANGED
@@ -119,6 +119,195 @@ function providesChrome(contentDir) {
119
119
  return true;
120
120
  }
121
121
 
122
+ const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
123
+
124
+ function complianceObligations(config) {
125
+ const compliance = config?.compliance || {};
126
+ return {
127
+ ageVerification: !!compliance.minAge,
128
+ exciseTax: !!compliance.nicotineWarning,
129
+ };
130
+ }
131
+
132
+ function rawHtmlAllowed(config) {
133
+ const obligations = complianceObligations(config);
134
+ const regulated = obligations.ageVerification || obligations.exciseTax;
135
+ return !regulated;
136
+ }
137
+
138
+ function validateCapabilitiesDoc(doc, file, config) {
139
+ const out = [];
140
+ if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
141
+ return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
142
+ }
143
+ for (const [key, value] of Object.entries(doc)) {
144
+ if (!CAPABILITY_KEYS.has(key)) {
145
+ out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
146
+ continue;
147
+ }
148
+ if (value == null || typeof value !== "object" || typeof value.enabled !== "boolean") {
149
+ out.push(mk(ERROR, "capability-shape", file, `${key} must be an object with boolean "enabled"`));
150
+ }
151
+ }
152
+ const obligations = complianceObligations(config);
153
+ if (obligations.ageVerification && doc.ageVerification?.enabled === false) {
154
+ out.push(mk(ERROR, "capability-compliance-floor", file, "ageVerification cannot be disabled for a tenant with compliance.minAge"));
155
+ }
156
+ if (obligations.exciseTax && doc.exciseTax?.enabled === false) {
157
+ out.push(mk(ERROR, "capability-compliance-floor", file, "exciseTax cannot be disabled for a tenant with compliance.nicotineWarning"));
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function validateScriptsDoc(doc, file, config) {
163
+ const out = [];
164
+ if (doc == null || typeof doc !== "object" || !Array.isArray(doc.scripts)) {
165
+ return [mk(ERROR, "scripts-root", file, 'scripts.json must have a "scripts" array')];
166
+ }
167
+ const regulated = !rawHtmlAllowed(config);
168
+ for (const [i, entry] of doc.scripts.entries()) {
169
+ const at = `${file} scripts[${i}]`;
170
+ if (entry == null || typeof entry !== "object") {
171
+ out.push(mk(ERROR, "script-entry", at, "script entry must be an object"));
172
+ continue;
173
+ }
174
+ if (entry.isolation === "inline") {
175
+ out.push(
176
+ regulated
177
+ ? mk(ERROR, "script-inline-regulated", at, 'inline scripts are not allowed for regulated tenants; use isolation:"sandbox"')
178
+ : mk(WARN, "script-inline", at, 'inline scripts run same-origin; prefer isolation:"sandbox"'),
179
+ );
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ const SUPPORTED_BLOCK_CONTRACTS = new Set(["block-palette@1", "block-palette@2", "block-palette@3"]);
186
+ const KNOWN_BLOCKS = new Set([
187
+ "hero",
188
+ "promo_tiles",
189
+ "featured_collections",
190
+ "featured_products",
191
+ "editorial",
192
+ "newsletter",
193
+ "marketing_hero",
194
+ "trust_bar",
195
+ "split_compare",
196
+ "steps",
197
+ "card_grid",
198
+ "integrations",
199
+ "proof_strip",
200
+ "testimonials",
201
+ "faq",
202
+ "cta_band",
203
+ ]);
204
+ const REQUIRED_BLOCK_PROPS = {
205
+ hero: ["headline"],
206
+ promo_tiles: ["tiles"],
207
+ featured_collections: ["handles"],
208
+ editorial: ["title"],
209
+ newsletter: ["title"],
210
+ marketing_hero: ["headline"],
211
+ trust_bar: ["items"],
212
+ split_compare: ["title", "before", "after"],
213
+ steps: ["title", "steps"],
214
+ card_grid: ["title", "cards"],
215
+ integrations: ["title", "platforms"],
216
+ proof_strip: ["title"],
217
+ testimonials: ["quotes"],
218
+ faq: ["title", "items"],
219
+ cta_band: ["title"],
220
+ };
221
+ const PRODUCT_SOURCE_KEYS = new Set([
222
+ "collection",
223
+ "tag",
224
+ "featured",
225
+ "newest",
226
+ "bestSelling",
227
+ "onSale",
228
+ "handles",
229
+ "related",
230
+ "recentlyViewed",
231
+ ]);
232
+ const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
233
+
234
+ function validateProductSource(value, file, at) {
235
+ const out = [];
236
+ if (typeof value === "string") {
237
+ if (!PRODUCT_SOURCE_SHORTCUTS.has(value)) {
238
+ out.push(mk(ERROR, "product-source", file, `${at}.source must be one of featured, newest, bestSelling, onSale or a source object`));
239
+ }
240
+ return out;
241
+ }
242
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
243
+ out.push(mk(ERROR, "product-source", file, `${at}.source must be a source string or object`));
244
+ return out;
245
+ }
246
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined);
247
+ const recognized = keys.filter((k) => PRODUCT_SOURCE_KEYS.has(k));
248
+ if (keys.length !== 1 || recognized.length !== 1) {
249
+ out.push(mk(ERROR, "product-source", file, `${at}.source must declare exactly one recognized source key`));
250
+ return out;
251
+ }
252
+ const key = recognized[0];
253
+ const got = value[key];
254
+ if (key === "handles") {
255
+ if (!Array.isArray(got) || !got.every((v) => typeof v === "string")) {
256
+ out.push(mk(ERROR, "product-source", file, `${at}.source.handles must be a string array`));
257
+ }
258
+ } else if (key === "collection" || key === "tag" || key === "related") {
259
+ if (typeof got !== "string") {
260
+ out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be a string`));
261
+ }
262
+ } else if (got !== true) {
263
+ out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be true`));
264
+ }
265
+ return out;
266
+ }
267
+
268
+ function validateHomeDoc(doc, file) {
269
+ const out = [];
270
+ if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
271
+ return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
272
+ }
273
+ if (doc.contract !== undefined && (typeof doc.contract !== "string" || !SUPPORTED_BLOCK_CONTRACTS.has(doc.contract))) {
274
+ out.push(mk(ERROR, "home-contract", file, `unsupported block contract "${String(doc.contract)}"`));
275
+ }
276
+ if (!Array.isArray(doc.blocks)) {
277
+ out.push(mk(ERROR, "home-blocks", file, "`blocks` must be an array"));
278
+ return out;
279
+ }
280
+ doc.blocks.forEach((block, i) => {
281
+ const at = `blocks[${i}]`;
282
+ if (block == null || typeof block !== "object" || Array.isArray(block)) {
283
+ out.push(mk(ERROR, "block-shape", file, `${at} must be an object`));
284
+ return;
285
+ }
286
+ const component = block.component;
287
+ if (typeof component !== "string") {
288
+ out.push(mk(ERROR, "block-component", file, `${at}.component is required`));
289
+ return;
290
+ }
291
+ if (!KNOWN_BLOCKS.has(component)) {
292
+ out.push(mk(ERROR, "block-unknown", file, `${at}.component "${component}" is not in the supported block palette`));
293
+ return;
294
+ }
295
+ for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
296
+ if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
297
+ }
298
+ if (component === "featured_products") {
299
+ if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
300
+ if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
301
+ out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
302
+ }
303
+ if (block.limit !== undefined && typeof block.limit !== "number") {
304
+ out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
305
+ }
306
+ }
307
+ });
308
+ return out;
309
+ }
310
+
122
311
  // --- filesystem helpers ------------------------------------------------------
123
312
  function readJsonSafe(path) {
124
313
  try {
@@ -181,6 +370,9 @@ export function validateTenant(tenantDir, opts = {}) {
181
370
  );
182
371
  }
183
372
  const scope = opts.scope || config?.scope;
373
+ if (config?.capabilities) {
374
+ findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
375
+ }
184
376
 
185
377
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
186
378
  const themePath = join(tenantDir, "theme.json");
@@ -194,6 +386,26 @@ export function validateTenant(tenantDir, opts = {}) {
194
386
  }
195
387
  }
196
388
 
389
+ const capabilitiesPath = join(tenantDir, "capabilities.json");
390
+ if (existsSync(capabilitiesPath)) {
391
+ const { value, error } = readJsonSafe(capabilitiesPath);
392
+ if (error) {
393
+ findings.push(mk(ERROR, "capabilities-parse", "capabilities.json", `invalid JSON: ${error}`));
394
+ } else {
395
+ findings.push(...validateCapabilitiesDoc(value, "capabilities.json", config));
396
+ }
397
+ }
398
+
399
+ const scriptsPath = join(tenantDir, "scripts.json");
400
+ if (existsSync(scriptsPath)) {
401
+ const { value, error } = readJsonSafe(scriptsPath);
402
+ if (error) {
403
+ findings.push(mk(ERROR, "scripts-parse", "scripts.json", `invalid JSON: ${error}`));
404
+ } else {
405
+ findings.push(...validateScriptsDoc(value, "scripts.json", config));
406
+ }
407
+ }
408
+
197
409
  // 3. content JSON — parse + blocks shape
198
410
  for (const name of ["home.json", "chrome.json"]) {
199
411
  const p = join(contentDir, name);
@@ -201,8 +413,8 @@ export function validateTenant(tenantDir, opts = {}) {
201
413
  const { value, error } = readJsonSafe(p);
202
414
  if (error) {
203
415
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
204
- } else if (name === "home.json" && value && "blocks" in value && !Array.isArray(value.blocks)) {
205
- findings.push(mk(ERROR, "home-blocks", "content/home.json", "`blocks` must be an array (index.astro throws otherwise)"));
416
+ } else if (name === "home.json") {
417
+ findings.push(...validateHomeDoc(value, "content/home.json"));
206
418
  }
207
419
  }
208
420
  }
@@ -222,6 +434,12 @@ export function validateTenant(tenantDir, opts = {}) {
222
434
  findings.push(mk(ERROR, "html-invalid", r, `not a servable document: ${bad}`));
223
435
  continue;
224
436
  }
437
+ if (config && !rawHtmlAllowed(config)) {
438
+ findings.push(
439
+ mk(ERROR, "raw-html-compliance-bypass", r,
440
+ "raw HTML bypasses platform Layout compliance; regulated commerce tenants must use block composition or an extracted runtime"),
441
+ );
442
+ }
225
443
  if (!base && !isFullDocument(html) && !hasChrome) {
226
444
  findings.push(
227
445
  mk(ERROR, "html-fragment", r,