@tokenoftrust/cli 1.3.0-rc.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.0-rc.4",
3
+ "version": "1.3.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",
@@ -44,6 +44,7 @@ import { establishSession } from "../auth.mjs";
44
44
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
45
45
  import { CliError, fail, formatError } from "../errors.mjs";
46
46
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
47
+ import { startProgress } from "../progress.mjs";
47
48
  import {
48
49
  scaffoldSample, isSampleCheckout, sampleConfig,
49
50
  resolveRendererSource as resolveLocalRendererSource, newestCachedRunner, SAMPLE_DIR_NAME,
@@ -693,11 +694,23 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
693
694
  const marker = join(runnerDir, ".tot-cache-complete");
694
695
  if (existsSync(marker)) return runnerDir; // already downloaded + installed
695
696
 
696
- log(`~ preparing the native renderer (version ${version}, first run only)...`);
697
+ // First run only — set the expectation so the one-time cost doesn't read as a
698
+ // hang: this downloads + installs the renderer once, then every later run of
699
+ // this version is a no-network cache hit.
700
+ log(`~ first run: downloading + installing the renderer (~a minute; cached after this)…`);
697
701
  const localSource = isUrl ? null : resolveLocalTarball(source);
698
702
  const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
699
703
  try {
700
- if (isUrl) await downloadFile(source, archivePath);
704
+ if (isUrl) {
705
+ // The fetch itself is otherwise silent (no per-byte output) and can run
706
+ // tens of seconds on a cold cache — tick a spinner so it never looks hung.
707
+ const spin = startProgress("downloading the renderer…");
708
+ try {
709
+ await downloadFile(source, archivePath);
710
+ } finally {
711
+ spin.stop();
712
+ }
713
+ }
701
714
  if (!existsSync(archivePath)) {
702
715
  throw new Error(`renderer tarball not found: ${archivePath}`);
703
716
  }
@@ -53,6 +53,7 @@ import { createMcpClient } from "../mcp.mjs";
53
53
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
54
54
  import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
55
55
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
56
+ import { startProgress } from "../progress.mjs";
56
57
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
57
58
  import { collectChecks } from "./doctor.mjs";
58
59
  import { normalizeStores, storeListError, checkoutTenant } from "./checkout.mjs";
@@ -236,10 +237,9 @@ export async function run(argv, ctx) {
236
237
  handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped", env: activityBridgeEnv(env) });
237
238
  }
238
239
 
239
- const up = await Promise.race([
240
- waitForServer(url, { until: () => handle.exited }),
241
- handle.done.then(() => "exited"),
242
- ]);
240
+ // The runner's stdio is "piped" (its logs are held until the aha), so this
241
+ // boot would otherwise be a silent 5–60s gap. Tick a spinner over it.
242
+ const up = await waitForBoot(url, handle);
243
243
  if (up !== true) {
244
244
  throw new CliError("the dev server didn't come up", {
245
245
  next: `cd ${tenant} && tot dev (to watch the runner logs)`,
@@ -307,10 +307,7 @@ async function runSampleStart(args, ctx, env, startedAt) {
307
307
  console.log(` → starting the free local preview … ${url}`);
308
308
  const handle = spawnNativeDev(runnerDir, workspace, devArgs.port, { stdio: "piped" });
309
309
 
310
- const up = await Promise.race([
311
- waitForServer(url, { until: () => handle.exited }),
312
- handle.done.then(() => "exited"),
313
- ]);
310
+ const up = await waitForBoot(url, handle);
314
311
  if (up !== true) {
315
312
  throw new CliError("the local preview server didn't come up", {
316
313
  next: `cd ${SAMPLE_DIR_NAME} && tot dev --sample (to watch the runner logs)`,
@@ -569,6 +566,30 @@ async function ensureCheckout(client, tenant, dir, env) {
569
566
  console.log(` ✓ checked out ./${tenant}`);
570
567
  }
571
568
 
569
+ /**
570
+ * Wait for the dev server to answer while showing progress, then return the
571
+ * `waitForServer` result ("exited" if the runner died first). Both the runner
572
+ * (native or Docker) runs with "piped" stdio here, so its boot logs are withheld
573
+ * until the aha — without this the terminal is a silent 5–60s gap that reads as
574
+ * hung (the D1 "perceived speed" problem). The spinner keeps the interval well
575
+ * under 5s and, once past the warm-boot window, explains the first-render cost.
576
+ */
577
+ async function waitForBoot(url, handle) {
578
+ const spin = startProgress("starting the dev server…", {
579
+ stages: [
580
+ { afterMs: 6000, text: "compiling your store — first render can take a moment…" },
581
+ ],
582
+ });
583
+ try {
584
+ return await Promise.race([
585
+ waitForServer(url, { until: () => handle.exited }),
586
+ handle.done.then(() => "exited"),
587
+ ]);
588
+ } finally {
589
+ spin.stop();
590
+ }
591
+ }
592
+
572
593
  /**
573
594
  * Format elapsed milliseconds as a short human string for the "you're live"
574
595
  * ending (A3 — measure, don't just claim, "instant"). Pure + exported so it's
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A dependency-free, non-blocking progress indicator for the "and then it just
3
+ * runs" waits — the first-run renderer download and the dev server's cold boot,
4
+ * where the CLI would otherwise sit SILENT for tens of seconds (piped runner
5
+ * logs, a background fetch) and read as hung. Node built-ins only.
6
+ *
7
+ * Two behaviours, chosen from whether stderr is a TTY:
8
+ * - TTY → an in-place braille spinner rewritten with \r, showing an elapsed
9
+ * seconds counter, so the terminal visibly ticks.
10
+ * - non-TTY (CI, piped) → NO \r animation (that spams a log with control
11
+ * chars); instead one line at start and then a heartbeat line every
12
+ * ~10s, so a CI log shows liveness without a wall of frames.
13
+ *
14
+ * Both surface staged labels: pass `stages: [{ afterMs, text }]` and the label
15
+ * advances as the wait crosses each threshold (e.g. "starting…" → "compiling
16
+ * your store (first render)…"), so a long wait explains itself instead of
17
+ * staring back blankly.
18
+ */
19
+
20
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
+ const CLEAR_LINE = "\r\x1b[K";
22
+
23
+ /**
24
+ * Given the elapsed ms and the (optionally staged) labels, return the label to
25
+ * show right now: the last stage whose `afterMs` has passed, else `initial`.
26
+ * Pure + exported so the staging is unit-tested without any timers or TTY.
27
+ * @param {string} initial
28
+ * @param {Array<{afterMs:number,text:string}>} stages
29
+ * @param {number} elapsedMs
30
+ * @returns {string}
31
+ */
32
+ export function stageLabel(initial, stages, elapsedMs) {
33
+ let label = initial;
34
+ for (const s of stages || []) {
35
+ if (elapsedMs >= s.afterMs) label = s.text;
36
+ }
37
+ return label;
38
+ }
39
+
40
+ /**
41
+ * Start a progress indicator. Returns a handle with `stop(finalText?)` — always
42
+ * call it (a `finally` is ideal) so the interval clears and, on a TTY, the
43
+ * spinner line is erased. Safe to call `stop` more than once.
44
+ *
45
+ * @param {string} initialText the label shown until the first stage (if any).
46
+ * @param {{ stream?: NodeJS.WriteStream, stages?: Array<{afterMs:number,text:string}>,
47
+ * isTTY?: boolean, intervalMs?: number, heartbeatMs?: number, now?: () => number }} [opts]
48
+ * @returns {{ stop: (finalText?: string) => void }}
49
+ */
50
+ export function startProgress(initialText, opts = {}) {
51
+ const {
52
+ stream = process.stderr,
53
+ stages = [],
54
+ isTTY = Boolean(stream.isTTY),
55
+ intervalMs = isTTY ? 90 : 1000,
56
+ heartbeatMs = 10000,
57
+ now = Date.now,
58
+ } = opts;
59
+
60
+ const startedAt = now();
61
+ let frame = 0;
62
+ let lastHeartbeat = startedAt;
63
+ let stopped = false;
64
+
65
+ const secs = () => Math.floor((now() - startedAt) / 1000);
66
+ const label = () => stageLabel(initialText, stages, now() - startedAt);
67
+
68
+ function tick() {
69
+ if (stopped) return;
70
+ if (isTTY) {
71
+ frame = (frame + 1) % FRAMES.length;
72
+ stream.write(`${CLEAR_LINE} ${FRAMES[frame]} ${label()} (${secs()}s)`);
73
+ } else if (now() - lastHeartbeat >= heartbeatMs) {
74
+ lastHeartbeat = now();
75
+ stream.write(` … ${label()} (${secs()}s)\n`);
76
+ }
77
+ }
78
+
79
+ // Announce immediately so there's never a silent lead-in, then tick.
80
+ if (isTTY) {
81
+ stream.write(` ${FRAMES[0]} ${initialText} (0s)`);
82
+ } else {
83
+ stream.write(` … ${initialText}\n`);
84
+ }
85
+ const timer = setInterval(tick, intervalMs);
86
+ if (typeof timer.unref === "function") timer.unref(); // never keep the process alive
87
+
88
+ return {
89
+ stop(finalText) {
90
+ if (stopped) return;
91
+ stopped = true;
92
+ clearInterval(timer);
93
+ if (isTTY) {
94
+ stream.write(CLEAR_LINE); // erase the spinner line
95
+ if (finalText) stream.write(` ${finalText}\n`);
96
+ } else if (finalText) {
97
+ stream.write(` ${finalText}\n`);
98
+ }
99
+ },
100
+ };
101
+ }