@tokenoftrust/cli 1.2.4 → 1.3.0-rc.1

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.2.4",
3
+ "version": "1.3.0-rc.1",
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",
@@ -25,7 +25,7 @@ import { join, dirname } from "node:path";
25
25
  export const MAX_ENTRIES = 300;
26
26
 
27
27
  // Flags whose FOLLOWING token is a secret and must never be logged.
28
- const SECRET_VALUE_FLAGS = new Set(["--code", "--token"]);
28
+ const SECRET_VALUE_FLAGS = new Set(["--code", "--token", "--activity-token"]);
29
29
 
30
30
  /** Absolute path to the activity log for this environment. */
31
31
  export function activityLogPath(env = process.env) {
@@ -41,7 +41,7 @@ export function redactArgs(args) {
41
41
  const out = [];
42
42
  for (let i = 0; i < args.length; i++) {
43
43
  const a = String(args[i]);
44
- const eq = a.match(/^(--code|--token)=/);
44
+ const eq = a.match(/^(--code|--token|--activity-token)=/);
45
45
  if (eq) {
46
46
  out.push(`${eq[1]}=«redacted»`);
47
47
  continue;
@@ -39,10 +39,11 @@ 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 } from "../mcp.mjs";
42
+ import { createMcpClient, CLI_VERSION } from "../mcp.mjs";
43
43
  import { establishSession } from "../auth.mjs";
44
+ import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
44
45
  import { CliError, fail, formatError } from "../errors.mjs";
45
- import { openBrowser, waitForServer } from "../open.mjs";
46
+ import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
46
47
  import {
47
48
  scaffoldSample, isSampleCheckout, sampleConfig,
48
49
  resolveRendererSource as resolveLocalRendererSource, SAMPLE_DIR_NAME,
@@ -97,13 +98,19 @@ preview — nothing is published. Prerequisites: Node.js and an invite (or just
97
98
  --sample, which needs neither) — no Docker, no hand-provisioned AWS creds.`;
98
99
 
99
100
  /** @param {string[]} argv @param {any} ctx */
100
- export function run(argv, ctx) {
101
+ export async function run(argv, ctx) {
101
102
  const args = parseArgs(argv);
102
103
  if (args.help) {
103
104
  console.log(USAGE);
104
105
  return 0;
105
106
  }
106
107
 
108
+ // Resolve a free port up front so the URL we derive/open/print matches the port
109
+ // the runner actually binds — Vite's strictPort is off, so a busy default would
110
+ // silently drift and strand the browser/liveness poll on the wrong port. No-op
111
+ // when the requested port is free. (Monorepo re-resolves inside tot-dev.mjs.)
112
+ args.port = String(await firstFreePort(Number(args.port || 4321)));
113
+
107
114
  // Zero-login free taste: scaffold + run a bundled sample store, no MCP. Wins
108
115
  // over every context (works in the monorepo, a checkout, or a loose dir).
109
116
  if (args.sample) {
@@ -203,8 +210,19 @@ function runMonorepo(ctx, argv) {
203
210
  console.error(`✗ expected the dev runner at ${script} but it's missing.`);
204
211
  return 2;
205
212
  }
213
+ // Thread the hosted-activity-bridge credential (if this cached session has one —
214
+ // minted alongside the invite's cli-signin-code paste) so the spawned tot-dev.mjs
215
+ // can report file-save activity up to the developer's own hosted /dev panel.
216
+ // Best-effort: an older cached session (or a bare `tot login`) simply has
217
+ // neither field, and the local loop runs exactly as before with no hosted signal.
218
+ const creds = readCredentials(defaultCredentialsPath(process.env));
219
+ const env = { ...process.env };
220
+ if (creds?.activityToken && creds?.activityUrl) {
221
+ env.TOT_DEV_ACTIVITY_TOKEN = creds.activityToken;
222
+ env.TOT_DEV_ACTIVITY_URL = creds.activityUrl;
223
+ }
206
224
  return new Promise((resolvePromise) => {
207
- const child = spawn(process.execPath, [script, ...argv], { stdio: "inherit" });
225
+ const child = spawn(process.execPath, [script, ...argv], { stdio: "inherit", env });
208
226
  child.on("exit", (code) => resolvePromise(code ?? 0));
209
227
  child.on("error", (e) => {
210
228
  console.error(`✗ could not start the dev runner: ${e.message}`);
@@ -227,17 +245,6 @@ export function deriveUrl(cfg, port) {
227
245
  return { domain, url: `http://localhost:${port}/${domain ? `${domain}/` : ""}` };
228
246
  }
229
247
 
230
- /**
231
- * The URL the developer should LAND on: the guided /dev cockpit, not the bare
232
- * store root. The cockpit embeds a live preview of the store (so they still see
233
- * it) PLUS the stepper, live Monitor, in-browser file tree/editor/diff, and
234
- * click-to-source — the whole first-run experience. `deriveUrl().url` (the store
235
- * root) stays the liveness-poll target (waitForServer); this is what we open + show.
236
- */
237
- export function cockpitUrl(baseUrl) {
238
- return `${baseUrl}dev`;
239
- }
240
-
241
248
  async function runNative(workspace, args, ctx) {
242
249
  const cfg = readWorkspaceConfig(workspace);
243
250
  if (!cfg) {
@@ -273,7 +280,7 @@ function bootNative(runnerDir, workspace, port, url, args) {
273
280
  .then((up) => {
274
281
  if (up && !opened) {
275
282
  opened = true;
276
- openBrowser(cockpitUrl(url));
283
+ openBrowser(url);
277
284
  }
278
285
  })
279
286
  .catch(() => {});
@@ -347,24 +354,82 @@ export async function resolveRendererSource(args, { client } = {}) {
347
354
  return resolveEntitledRendererSource(args, { client });
348
355
  }
349
356
 
357
+ /**
358
+ /**
359
+ * Choose which published runner version to fetch, given npm registry metadata.
360
+ *
361
+ * The CLI and runner are COUPLED (the CLI spawns the runner's scripts/tot-dev.mjs
362
+ * + shares the /__tot contract), so by default we pin the runner to the CLI's OWN
363
+ * minor — the highest published `<major>.<minor>.x` — NOT npm's `latest`. That
364
+ * keeps a `tot@X.Y` always driving a `runner@X.Y.*`, and because the on-disk cache
365
+ * is keyed by version, upgrading the CLI busts the stale-runner cache automatically
366
+ * (the 2026-07-14 "cached runner (prior tot dev)" staleness).
367
+ *
368
+ * GRACEFUL + SEQUENCING-SAFE: an explicit pin (`--renderer-version` / TOT_RUNNER_VERSION)
369
+ * always wins. Otherwise we prefer the CLI-minor match, and FALL BACK to the `latest`
370
+ * dist-tag when no aligned version is published yet — so this is safe to ship BEFORE
371
+ * the runner is republished at CLI-aligned versions (today runner=0.1.x, cli=1.2.x →
372
+ * no 1.2 match → falls back to latest). Once the coupled publish lands, the pin
373
+ * activates on its own with no further code change.
374
+ *
375
+ * Pure (no I/O) for testability.
376
+ * @param {any} meta npm packument (`dist-tags` + `versions`)
377
+ * @param {{ cliVersion: string, explicitPin?: string|null }} opts
378
+ * @returns {{ version: string, reason: string }}
379
+ */
380
+ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
381
+ const distTags = meta?.["dist-tags"] || {};
382
+ const versions = Object.keys(meta?.versions || {});
383
+
384
+ // 1) Explicit pin (flag/env): resolve a dist-tag name, else take it verbatim.
385
+ if (explicitPin) {
386
+ return { version: distTags[explicitPin] || explicitPin, reason: `pinned ${explicitPin}` };
387
+ }
388
+
389
+ // 2) EXACT CLI-version match — the primary path for a lockstep release: the runner
390
+ // is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
391
+ // which the stable-only minor match below deliberately skips). This is what makes
392
+ // `tot@1.3.0-rc.0` pull `runner@1.3.0-rc.0` instead of falling back to stale latest.
393
+ if (versions.includes(cliVersion)) {
394
+ return { version: cliVersion, reason: "exact CLI-version match" };
395
+ }
396
+
397
+ // 3) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
398
+ const m = /^(\d+)\.(\d+)\./.exec(cliVersion || "");
399
+ if (m) {
400
+ const prefix = `${m[1]}.${m[2]}.`;
401
+ const inMinor = versions
402
+ .filter((v) => v.startsWith(prefix) && /^\d+\.\d+\.\d+$/.test(v))
403
+ .sort((a, b) => Number(a.slice(prefix.length)) - Number(b.slice(prefix.length)));
404
+ if (inMinor.length) {
405
+ return { version: inMinor[inMinor.length - 1], reason: `matched CLI minor ${m[1]}.${m[2]}` };
406
+ }
407
+ }
408
+
409
+ // 4) Fallback: the `latest` dist-tag (pre-alignment safety net).
410
+ if (distTags.latest) return { version: distTags.latest, reason: "fell back to latest (no CLI-minor match)" };
411
+ throw new Error("no publishable runner version (no CLI-minor match and no `latest` dist-tag)");
412
+ }
413
+
350
414
  /**
351
415
  * PUBLIC, un-entitled source for sample / zero-login mode: the runner
352
416
  * straight from the public npm registry — NO MCP call, NO entitlement. Reads the
353
417
  * package's registry metadata (unauthenticated JSON) for the tarball URL + version.
354
- * Pin with `--renderer-version` / TOT_RUNNER_VERSION, else the `latest` dist-tag.
355
- * Package/registry overridable via env for testing.
418
+ * Version selection is pinned to the CLI's minor by default (see pickRunnerVersion);
419
+ * override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
420
+ * overridable via env for testing.
356
421
  */
357
422
  export async function resolvePublicRendererSource(args, env = process.env) {
358
423
  const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
359
424
  const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
360
- const pin = args.rendererVersion || env.TOT_RUNNER_VERSION || "latest";
425
+ const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
361
426
  const metaUrl = `${registry}/${pkg.replace("/", "%2f")}`;
362
427
  const res = await fetch(metaUrl, { headers: { accept: "application/json" } });
363
428
  if (!res.ok) {
364
429
  throw new Error(`npm metadata for ${pkg} failed: HTTP ${res.status} ${res.statusText}`);
365
430
  }
366
431
  const meta = await res.json();
367
- const version = meta?.["dist-tags"]?.[pin] || pin;
432
+ const { version } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin });
368
433
  const tarball = meta?.versions?.[version]?.dist?.tarball;
369
434
  if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
370
435
  return { kind: "public", version, url: tarball, strip: 1, cacheKey: `public-${version}` };
@@ -665,7 +730,7 @@ async function runContainer(workspace, args, ctx) {
665
730
  .then((up) => {
666
731
  if (up && !opened) {
667
732
  opened = true;
668
- openBrowser(cockpitUrl(plan.url));
733
+ openBrowser(plan.url);
669
734
  }
670
735
  })
671
736
  .catch(() => {});
@@ -742,7 +807,7 @@ export function buildContainerPlan(workspace, args, _ctx) {
742
807
  /** The crafted "here's your running store" block (Vite/`vercel dev`-grade). */
743
808
  export function printDevBanner(plan) {
744
809
  console.error(`\n tot dev — ${plan.tenant || "(tenant)"}`);
745
- console.error(` ➜ Local: ${cockpitUrl(plan.url)}`);
810
+ console.error(` ➜ Local: ${plan.url}`);
746
811
  console.error(` ➜ Edit: content/home.html + save → the browser reloads`);
747
812
  console.error(` ➜ Private local preview — nothing is published. Ctrl-C to stop.\n`);
748
813
  }
@@ -27,17 +27,37 @@ import { fail } from "../errors.mjs";
27
27
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
28
28
 
29
29
  function parseArgs(argv) {
30
- const a = { mcp: null, device: false, code: null, help: false };
30
+ const a = {
31
+ mcp: null, device: false, code: null, help: false,
32
+ activityToken: null, activityUrl: null,
33
+ };
31
34
  for (let i = 0; i < argv.length; i++) {
32
35
  const t = argv[i];
33
36
  if (t === "--mcp") a.mcp = argv[++i];
34
37
  else if (t === "--device") a.device = true;
35
38
  else if (t === "--code" || t === "--token") a.code = argv[++i];
39
+ else if (t === "--activity-token") a.activityToken = argv[++i];
40
+ else if (t === "--activity-url") a.activityUrl = argv[++i];
36
41
  else if (t === "--help" || t === "-h") a.help = true;
37
42
  }
38
43
  return a;
39
44
  }
40
45
 
46
+ /**
47
+ * Cache the local→hosted activity-bridge credential (storefront's
48
+ * cli-signin-code mint, piggybacked as two extra login flags — see
49
+ * apps/storefront/src/lib/dev/cliSignInCode.ts) alongside the MCP creds
50
+ * `tot login` just wrote. `tot dev` reads these two fields to report file
51
+ * saves to the developer's own hosted /dev panel. A no-op when either flag is
52
+ * absent (older invite links, or a bare `tot login`).
53
+ */
54
+ function cacheActivityBridge(env, activityToken, activityUrl) {
55
+ if (!activityToken || !activityUrl) return;
56
+ const path = defaultCredentialsPath(env);
57
+ const current = readCredentials(path) || {};
58
+ writeCredentials(path, { ...current, activityToken, activityUrl });
59
+ }
60
+
41
61
  const USAGE = `tot login — sign in to Token of Trust
42
62
 
43
63
  tot login open the browser, sign in, cache your session
@@ -48,6 +68,10 @@ const USAGE = `tot login — sign in to Token of Trust
48
68
  browser opener exists on this box)
49
69
  tot login --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
50
70
 
71
+ --activity-token/--activity-url are set automatically by the pasted invite
72
+ command (report local dev-loop activity to your hosted /dev panel) — not
73
+ meant to be typed by hand.
74
+
51
75
  After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot submit\`.`;
52
76
 
53
77
  /**
@@ -110,6 +134,7 @@ export async function run(argv, _ctx) {
110
134
  console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
111
135
  try {
112
136
  await redeemAndCache(mcpUrl, args.code, env);
137
+ cacheActivityBridge(env, args.activityToken, args.activityUrl);
113
138
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
114
139
  console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
115
140
  return 0;
@@ -52,14 +52,14 @@ import { detectContext } from "../context.mjs";
52
52
  import { createMcpClient } from "../mcp.mjs";
53
53
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
54
54
  import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
55
- import { openBrowser, waitForServer } from "../open.mjs";
55
+ import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
56
56
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
57
57
  import { collectChecks } from "./doctor.mjs";
58
58
  import { normalizeStores, storeListError, checkoutTenant } from "./checkout.mjs";
59
59
  import {
60
60
  buildContainerPlan, spawnDevContainer, dockerAvailable, tryStartDocker,
61
61
  resolveDevImage, isPrivateRegistryImage, ensureRegistryLogin,
62
- ensureRendererArtifact, ensureSampleRenderer, spawnNativeDev, deriveUrl, cockpitUrl,
62
+ ensureRendererArtifact, ensureSampleRenderer, spawnNativeDev, deriveUrl,
63
63
  NativeArtifactUnavailableError,
64
64
  } from "./dev.mjs";
65
65
  import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
@@ -209,8 +209,10 @@ export async function run(argv, ctx) {
209
209
  // Both are independent authenticated calls once login (above) has
210
210
  // resolved, so there's no reason to pay for them serially.
211
211
  const dir = resolve(process.cwd(), tenant);
212
+ // Resolve a free port so the URL we open/poll/print matches what the runner
213
+ // binds (Vite strictPort is off → a busy port would drift). No-op if free.
212
214
  const devArgs = {
213
- image: null, port: String(args.port || "4321"), mcp: args.mcp,
215
+ image: null, port: String(await firstFreePort(Number(args.port || 4321))), mcp: args.mcp,
214
216
  noLogin: false, noOpen: args.noOpen, docker: args.docker,
215
217
  };
216
218
  const runtime = { useDocker: args.docker, runnerDir: null };
@@ -244,17 +246,15 @@ export async function run(argv, ctx) {
244
246
  });
245
247
  }
246
248
  if (!args.noOpen) {
247
- openBrowser(cockpitUrl(url));
249
+ openBrowser(url);
248
250
  console.log(" ✓ opened your browser");
249
251
  }
250
252
 
251
- // 6. you're live (G) — end at the AI-wow, THEN offer the MCP (C). Print the
252
- // elapsed time (A3) so the "instant" claim is measured, not just felt.
253
- printLiveEnding(tenant, cockpitUrl(url), formatElapsed(Date.now() - startedAt));
254
- if (!args.noConnect) {
255
- const yes = args.yes || (await promptYesNo(" Connect Claude for AI editing?", true));
256
- if (yes) connectClaude();
257
- }
253
+ // 6. you're live (G) — land on the store ROOT (not /dev), print elapsed time
254
+ // (A3) so the "instant" claim is measured. The "Connect Claude" step is
255
+ // intentionally removed for now — a blocking prompt here meant Ctrl-C'ing it
256
+ // tore down the dev server; revisit AI-connect as a non-blocking step later.
257
+ printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
258
258
 
259
259
  // 7. hand the terminal to the running dev server until Ctrl-C.
260
260
  console.log("\n Streaming dev logs — edit + save to see reloads. Ctrl-C to stop.\n");
@@ -317,16 +317,14 @@ async function runSampleStart(args, ctx, env, startedAt) {
317
317
  });
318
318
  }
319
319
  if (!args.noOpen) {
320
- openBrowser(cockpitUrl(url));
320
+ openBrowser(url);
321
321
  console.log(" ✓ opened your browser");
322
322
  }
323
323
 
324
- // You're live — the FREE preview. End on the MCP upsell (the whole point).
325
- printSampleLiveEnding(cockpitUrl(url), formatElapsed(Date.now() - startedAt));
326
- if (!args.noConnect) {
327
- const yes = args.yes || (await promptYesNo(" Connect the ToT MCP for your real store + AI editing?", true));
328
- if (yes) connectClaude();
329
- }
324
+ // You're live — the FREE preview, landing on the store ROOT (not /dev). The
325
+ // AI/MCP connect step is intentionally removed for now revisit as a
326
+ // non-blocking step later (see the run() note above).
327
+ printSampleLiveEnding(url, formatElapsed(Date.now() - startedAt));
330
328
 
331
329
  console.log("\n Streaming preview logs — edit content/*.html + save to see reloads. Ctrl-C to stop.\n");
332
330
  handle.child.stdout?.pipe(process.stdout);
package/src/open.mjs CHANGED
@@ -8,8 +8,41 @@
8
8
  * Dependency-free (global fetch, node:child_process, Node 20+).
9
9
  */
10
10
  import { spawn } from "node:child_process";
11
+ import net from "node:net";
11
12
  import { setTimeout as delay } from "node:timers/promises";
12
13
 
14
+ /**
15
+ * Is `port` free to bind? Resolves false if anything already holds it. Host omitted
16
+ * → Node binds dual-stack (::/0.0.0.0), matching how the dev server grabs the port.
17
+ * Mirror of scripts/dev/port-check.mjs (separate package — kept dependency-free).
18
+ * @param {number} port @returns {Promise<boolean>}
19
+ */
20
+ export function isPortFree(port) {
21
+ return new Promise((resolve) => {
22
+ const srv = net.createServer();
23
+ srv.once("error", () => resolve(false));
24
+ srv.once("listening", () => srv.close(() => resolve(true)));
25
+ try {
26
+ srv.listen(port);
27
+ } catch {
28
+ resolve(false);
29
+ }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * First free port at or after `preferred`. Lets the CLI derive the URL it opens +
35
+ * polls from the SAME port the runner will bind — Vite's strictPort is off, so a
36
+ * busy default port would otherwise drift and strand the liveness poll (2026-07-14).
37
+ * @param {number} preferred @param {number} [maxTries] @returns {Promise<number>}
38
+ */
39
+ export async function firstFreePort(preferred, maxTries = 64) {
40
+ for (let p = preferred; p < preferred + maxTries; p++) {
41
+ if (await isPortFree(p)) return p;
42
+ }
43
+ return preferred;
44
+ }
45
+
13
46
  /**
14
47
  * Open `url` in the user's default browser. Best-effort and non-blocking: the
15
48
  * child is detached + unref'd so it never holds `tot` open, and any failure
@@ -6,11 +6,17 @@
6
6
  * a later command needs to authenticate AND to silently refresh:
7
7
  *
8
8
  * { mcpUrl, clientId, tokenEndpoint, scope,
9
- * accessToken, refreshToken, expiresAt (epoch ms), obtainedAt }
9
+ * accessToken, refreshToken, expiresAt (epoch ms), obtainedAt,
10
+ * activityToken, activityUrl }
10
11
  *
11
12
  * We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
12
13
  * re-registration, and `mcpUrl` so we never present a token minted for one MCP
13
- * to a different one. Dependency-free (node:fs/os/path).
14
+ * to a different one. `activityToken`/`activityUrl` are a SEPARATE credential
15
+ * (storefront-issued, not MCP OAuth) that `tot login --code` caches when the
16
+ * invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
17
+ * commands/dev.mjs `runMonorepo`, which threads them to the local dev-loop
18
+ * process so it can report file saves to the developer's hosted /dev panel.
19
+ * Dependency-free (node:fs/os/path).
14
20
  *
15
21
  * `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
16
22
  */