@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.0-rc.3",
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
 
@@ -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
- function bootNative(runnerDir, workspace, port, url, args) {
293
- // activityBridgeEnv() is a no-op {} for the zero-login sample path (no cached
294
- // credential exists there) safe to always thread it through.
295
- const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit", env: activityBridgeEnv() });
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
- 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)…`);
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) 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
+ }
687
714
  if (!existsSync(archivePath)) {
688
715
  throw new Error(`renderer tarball not found: ${archivePath}`);
689
716
  }
@@ -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
- writeCredentials(path, creds);
104
- return creds;
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
- writeCredentials(path, creds);
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
 
@@ -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)`,
@@ -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
- console.log(` ~ public runner unavailable (${e2?.message || e2}) falling back to the Docker runner.`);
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
@@ -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
+ }