@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 +1 -1
- package/src/commands/dev.mjs +15 -2
- package/src/commands/start.mjs +29 -8
- package/src/progress.mjs +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.0
|
|
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",
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
}
|
package/src/commands/start.mjs
CHANGED
|
@@ -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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
package/src/progress.mjs
ADDED
|
@@ -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
|
+
}
|