@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 +1 -1
- package/src/auth.mjs +71 -4
- package/src/commands/checkout.mjs +16 -8
- package/src/commands/dev.mjs +37 -14
- package/src/commands/doctor.mjs +7 -0
- package/src/commands/feedback.mjs +37 -6
- package/src/commands/login.mjs +62 -8
- package/src/commands/start.mjs +5 -55
- package/src/commands/submit.mjs +100 -14
- package/src/dev-heartbeat.mjs +7 -1
- package/src/dev-logs.mjs +67 -0
- package/src/errors.mjs +25 -2
- package/src/mcp.mjs +42 -0
- package/src/token-store.mjs +11 -2
- package/src/validate.mjs +220 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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(`~
|
|
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(`~
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
/**
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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})` : ""}`);
|
package/src/commands/login.mjs
CHANGED
|
@@ -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
|
|
72
|
-
command (report local dev-loop activity to your hosted /dev panel
|
|
73
|
-
|
|
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
|
|
166
|
+
* credential and trace no longer apply).
|
|
132
167
|
*/
|
|
133
168
|
export function mergeActivityBridge(prior, mcpUrl, creds) {
|
|
134
|
-
if (prior?.mcpUrl === mcpUrl
|
|
135
|
-
|
|
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
|
-
|
|
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;
|
package/src/commands/start.mjs
CHANGED
|
@@ -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() {
|
package/src/commands/submit.mjs
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 {
|
package/src/dev-heartbeat.mjs
CHANGED
|
@@ -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
|
package/src/dev-logs.mjs
ADDED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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/token-store.mjs
CHANGED
|
@@ -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"
|
|
205
|
-
findings.push(
|
|
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,
|