@tokenoftrust/cli 1.3.0-rc.3 → 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/auth.mjs +10 -0
- package/src/commands/dev.mjs +34 -7
- package/src/commands/login.mjs +21 -3
- package/src/commands/start.mjs +35 -9
- 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/auth.mjs
CHANGED
|
@@ -119,6 +119,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
119
119
|
{ hint: "run `tot login` to sign in again." },
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
|
+
const prior = creds;
|
|
122
123
|
creds = credentialsFromToken({
|
|
123
124
|
mcpUrl: creds.mcpUrl,
|
|
124
125
|
clientId: creds.clientId,
|
|
@@ -128,6 +129,15 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
128
129
|
token: { ...refreshed, refresh_token: refreshed.refresh_token || creds.refreshToken },
|
|
129
130
|
now,
|
|
130
131
|
});
|
|
132
|
+
// credentialsFromToken only returns the OAuth shape — it knows nothing about
|
|
133
|
+
// activityToken/activityUrl, a SEPARATE credential (storefront-issued, cached
|
|
134
|
+
// by `tot login --code`; see token-store.mjs). Carry it across a silent
|
|
135
|
+
// refresh, or a routine near-expiry refresh permanently and silently kills
|
|
136
|
+
// the activity bridge (the invite code that could re-mint it is single-use).
|
|
137
|
+
if (prior.activityToken && prior.activityUrl) {
|
|
138
|
+
creds.activityToken = prior.activityToken;
|
|
139
|
+
creds.activityUrl = prior.activityUrl;
|
|
140
|
+
}
|
|
131
141
|
writeCredentials(path, creds);
|
|
132
142
|
}
|
|
133
143
|
|
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,
|
|
@@ -240,7 +241,7 @@ function runMonorepo(ctx, argv) {
|
|
|
240
241
|
console.error(`✗ expected the dev runner at ${script} but it's missing.`);
|
|
241
242
|
return 2;
|
|
242
243
|
}
|
|
243
|
-
const env = { ...process.env, ...activityBridgeEnv() };
|
|
244
|
+
const env = { ...process.env, ...activityBridgeEnv(process.env) };
|
|
244
245
|
return new Promise((resolvePromise) => {
|
|
245
246
|
const child = spawn(process.execPath, [script, ...argv], { stdio: "inherit", env });
|
|
246
247
|
child.on("exit", (code) => resolvePromise(code ?? 0));
|
|
@@ -289,10 +290,24 @@ async function runNative(workspace, args, ctx) {
|
|
|
289
290
|
* promise. Shared by the authenticated native path (runNative) and the
|
|
290
291
|
* zero-login sample path (runSample) so both boot identically.
|
|
291
292
|
*/
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
293
|
+
/**
|
|
294
|
+
* The env bootNative threads into the spawned runner. NEVER thread a real
|
|
295
|
+
* developer's activity-bridge credential into the zero-login --sample run:
|
|
296
|
+
* activityBridgeEnv() reads ~/.tot/credentials.json unconditionally, so a
|
|
297
|
+
* developer who's ever run `tot login --code` and then runs `tot dev --sample`
|
|
298
|
+
* (the "free taste"/demo path) would otherwise leak their real credential,
|
|
299
|
+
* posting fake sample-store activity to their real hosted /dev panel —
|
|
300
|
+
* contradicting the sample banner's "no login, no ToT account, nothing
|
|
301
|
+
* published" claim. Exported + pure (besides the credentials-file read) so
|
|
302
|
+
* this gate is testable without spawning a real process.
|
|
303
|
+
* @param {{ sample?: boolean }} args
|
|
304
|
+
*/
|
|
305
|
+
export function bootNativeEnv(args) {
|
|
306
|
+
return args.sample ? {} : activityBridgeEnv();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function bootNative(runnerDir, workspace, port, url, args) {
|
|
310
|
+
const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit", env: bootNativeEnv(args) });
|
|
296
311
|
|
|
297
312
|
// Auto-open the browser the moment the server answers (D). Non-blocking so
|
|
298
313
|
// Ctrl-C / logs are unaffected; --no-open suppresses it.
|
|
@@ -679,11 +694,23 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
|
|
|
679
694
|
const marker = join(runnerDir, ".tot-cache-complete");
|
|
680
695
|
if (existsSync(marker)) return runnerDir; // already downloaded + installed
|
|
681
696
|
|
|
682
|
-
|
|
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)…`);
|
|
683
701
|
const localSource = isUrl ? null : resolveLocalTarball(source);
|
|
684
702
|
const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
|
|
685
703
|
try {
|
|
686
|
-
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
|
+
}
|
|
687
714
|
if (!existsSync(archivePath)) {
|
|
688
715
|
throw new Error(`renderer tarball not found: ${archivePath}`);
|
|
689
716
|
}
|
package/src/commands/login.mjs
CHANGED
|
@@ -100,8 +100,9 @@ export async function loginAndCache(mcpUrl, env = process.env, { log = () => {},
|
|
|
100
100
|
creds = await deviceLoginFlow({ mcpUrl, clientId, log });
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
const merged = mergeActivityBridge(prior, mcpUrl, creds);
|
|
104
|
+
writeCredentials(path, merged);
|
|
105
|
+
return merged;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
@@ -115,7 +116,24 @@ export async function redeemAndCache(mcpUrl, code, env = process.env) {
|
|
|
115
116
|
const prior = readCredentials(path);
|
|
116
117
|
const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
|
|
117
118
|
const creds = await redeemCodeFlow({ mcpUrl, clientId, code });
|
|
118
|
-
|
|
119
|
+
const merged = mergeActivityBridge(prior, mcpUrl, creds);
|
|
120
|
+
writeCredentials(path, merged);
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `loginFlow`/`deviceLoginFlow`/`redeemCodeFlow` all return the bare OAuth shape —
|
|
126
|
+
* none of them know about activityToken/activityUrl, a SEPARATE credential this
|
|
127
|
+
* same file caches via cacheActivityBridge. A bare re-login (no --code) after a
|
|
128
|
+
* prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
|
|
129
|
+
* (a full-object overwrite, not a merge) — carry it forward when re-authing against
|
|
130
|
+
* the SAME mcpUrl (a different MCP means a different session; the old bridge
|
|
131
|
+
* credential no longer applies).
|
|
132
|
+
*/
|
|
133
|
+
export function mergeActivityBridge(prior, mcpUrl, creds) {
|
|
134
|
+
if (prior?.mcpUrl === mcpUrl && prior.activityToken && prior.activityUrl) {
|
|
135
|
+
return { ...creds, activityToken: prior.activityToken, activityUrl: prior.activityUrl };
|
|
136
|
+
}
|
|
119
137
|
return creds;
|
|
120
138
|
}
|
|
121
139
|
|
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)`,
|
|
@@ -436,7 +433,12 @@ async function prefetchRuntime(client, devArgs, env, runtime, ctx) {
|
|
|
436
433
|
console.log(` ~ entitled renderer unavailable (${e.message}) — using the public runner (no Docker).`);
|
|
437
434
|
runtime.runnerDir = await ensureSampleRenderer(devArgs, ctx, { env });
|
|
438
435
|
} catch (e2) {
|
|
439
|
-
|
|
436
|
+
// Only an actual "can't reach the public runner" failure should fall to
|
|
437
|
+
// Docker — anything else (a real bug in ensureSampleRenderer/pickRunnerVersion,
|
|
438
|
+
// say) would otherwise be silently masked behind a confusing Docker fallback
|
|
439
|
+
// that may not even be installed. Mirrors dev.mjs's runStandalone.
|
|
440
|
+
if (!(e2 instanceof NativeArtifactUnavailableError)) throw e2;
|
|
441
|
+
console.log(` ~ public runner unavailable (${e2.message}) — falling back to the Docker runner.`);
|
|
440
442
|
runtime.useDocker = true;
|
|
441
443
|
await prefetchDockerLogin(client, devArgs, env);
|
|
442
444
|
}
|
|
@@ -564,6 +566,30 @@ async function ensureCheckout(client, tenant, dir, env) {
|
|
|
564
566
|
console.log(` ✓ checked out ./${tenant}`);
|
|
565
567
|
}
|
|
566
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
|
+
|
|
567
593
|
/**
|
|
568
594
|
* Format elapsed milliseconds as a short human string for the "you're live"
|
|
569
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
|
+
}
|