@tokenoftrust/cli 1.2.3 → 1.3.0-rc.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/activity-log.mjs +2 -2
- package/src/commands/dev.mjs +76 -8
- package/src/commands/login.mjs +26 -1
- package/src/commands/start.mjs +11 -13
- package/src/open.mjs +33 -0
- package/src/token-store.mjs +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0-rc.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/activity-log.mjs
CHANGED
|
@@ -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;
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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}`);
|
|
@@ -336,24 +354,74 @@ export async function resolveRendererSource(args, { client } = {}) {
|
|
|
336
354
|
return resolveEntitledRendererSource(args, { client });
|
|
337
355
|
}
|
|
338
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) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
|
|
390
|
+
const m = /^(\d+)\.(\d+)\./.exec(cliVersion || "");
|
|
391
|
+
if (m) {
|
|
392
|
+
const prefix = `${m[1]}.${m[2]}.`;
|
|
393
|
+
const inMinor = versions
|
|
394
|
+
.filter((v) => v.startsWith(prefix) && /^\d+\.\d+\.\d+$/.test(v))
|
|
395
|
+
.sort((a, b) => Number(a.slice(prefix.length)) - Number(b.slice(prefix.length)));
|
|
396
|
+
if (inMinor.length) {
|
|
397
|
+
return { version: inMinor[inMinor.length - 1], reason: `matched CLI minor ${m[1]}.${m[2]}` };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 3) Fallback: the `latest` dist-tag (pre-alignment safety net).
|
|
402
|
+
if (distTags.latest) return { version: distTags.latest, reason: "fell back to latest (no CLI-minor match)" };
|
|
403
|
+
throw new Error("no publishable runner version (no CLI-minor match and no `latest` dist-tag)");
|
|
404
|
+
}
|
|
405
|
+
|
|
339
406
|
/**
|
|
340
407
|
* PUBLIC, un-entitled source for sample / zero-login mode: the runner
|
|
341
408
|
* straight from the public npm registry — NO MCP call, NO entitlement. Reads the
|
|
342
409
|
* package's registry metadata (unauthenticated JSON) for the tarball URL + version.
|
|
343
|
-
*
|
|
344
|
-
*
|
|
410
|
+
* Version selection is pinned to the CLI's minor by default (see pickRunnerVersion);
|
|
411
|
+
* override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
|
|
412
|
+
* overridable via env for testing.
|
|
345
413
|
*/
|
|
346
414
|
export async function resolvePublicRendererSource(args, env = process.env) {
|
|
347
415
|
const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
|
|
348
416
|
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
349
|
-
const
|
|
417
|
+
const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
|
|
350
418
|
const metaUrl = `${registry}/${pkg.replace("/", "%2f")}`;
|
|
351
419
|
const res = await fetch(metaUrl, { headers: { accept: "application/json" } });
|
|
352
420
|
if (!res.ok) {
|
|
353
421
|
throw new Error(`npm metadata for ${pkg} failed: HTTP ${res.status} ${res.statusText}`);
|
|
354
422
|
}
|
|
355
423
|
const meta = await res.json();
|
|
356
|
-
const version = meta
|
|
424
|
+
const { version } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin });
|
|
357
425
|
const tarball = meta?.versions?.[version]?.dist?.tarball;
|
|
358
426
|
if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
359
427
|
return { kind: "public", version, url: tarball, strip: 1, cacheKey: `public-${version}` };
|
package/src/commands/login.mjs
CHANGED
|
@@ -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 = {
|
|
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;
|
package/src/commands/start.mjs
CHANGED
|
@@ -52,7 +52,7 @@ 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";
|
|
@@ -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 ||
|
|
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 };
|
|
@@ -248,13 +250,11 @@ export async function run(argv, ctx) {
|
|
|
248
250
|
console.log(" ✓ opened your browser");
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
// 6. you're live (G) —
|
|
252
|
-
//
|
|
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.
|
|
253
257
|
printLiveEnding(tenant, 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
|
-
}
|
|
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");
|
|
@@ -321,12 +321,10 @@ async function runSampleStart(args, ctx, env, startedAt) {
|
|
|
321
321
|
console.log(" ✓ opened your browser");
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
// You're live — the FREE preview
|
|
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).
|
|
325
327
|
printSampleLiveEnding(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
|
-
}
|
|
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
|
package/src/token-store.mjs
CHANGED
|
@@ -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.
|
|
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
|
*/
|