@tokenoftrust/cli 1.3.3-rc.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/checkout.mjs +16 -8
- package/src/commands/dev.mjs +37 -14
- package/src/commands/doctor.mjs +7 -0
- package/src/commands/start.mjs +4 -54
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.3
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Token of Trust developer CLI — check out a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Dependency-free (global fetch + `git` via child_process).
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
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"],
|
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";
|
|
@@ -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
|