@tokenoftrust/cli 1.0.0 → 1.1.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.
Files changed (34) hide show
  1. package/bin/tot.mjs +57 -4
  2. package/package.json +2 -1
  3. package/src/activity-log.mjs +112 -0
  4. package/src/auth.mjs +1 -1
  5. package/src/commands/dev.mjs +274 -17
  6. package/src/commands/feedback.mjs +176 -0
  7. package/src/commands/login.mjs +39 -2
  8. package/src/commands/logout.mjs +39 -0
  9. package/src/commands/start.mjs +173 -15
  10. package/src/oauth.mjs +77 -0
  11. package/src/sample.mjs +202 -0
  12. package/src/token-store.mjs +17 -1
  13. package/template/sample-store/content/chrome.html +152 -0
  14. package/template/sample-store/content/chrome.json +94 -0
  15. package/template/sample-store/content/home.html +194 -0
  16. package/template/sample-store/content/home.json +50 -0
  17. package/template/sample-store/content/pages/about.json +10 -0
  18. package/template/sample-store/content/pages/privacy.json +6 -0
  19. package/template/sample-store/content/pages/shipping-returns.json +6 -0
  20. package/template/sample-store/content/pages-html/blogs/news.html +68 -0
  21. package/template/sample-store/content/pages-html/pages/about-us.html +95 -0
  22. package/template/sample-store/content/pages-html/pages/contact-us.html +68 -0
  23. package/template/sample-store/content/pages-html/pages/privacy-policy.html +65 -0
  24. package/template/sample-store/content/pages-html/pages/shipping-returns.html +77 -0
  25. package/template/sample-store/content/themes/giant-navy.json +73 -0
  26. package/template/sample-store/public/img/hero-suicide-bunny.jpg +0 -0
  27. package/template/sample-store/public/img/hero.webp +0 -0
  28. package/template/sample-store/public/img/og.jpg +0 -0
  29. package/template/sample-store/public/logo-wordmark.png +0 -0
  30. package/template/sample-store/public/pages/home.css +120 -0
  31. package/template/sample-store/public/pages/mkt.css +185 -0
  32. package/template/sample-store/public/pages/page.css +155 -0
  33. package/template/sample-store/public/themes/giant-navy.css +76 -0
  34. package/template/sample-store/theme.json +39 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `tot feedback` — send a note to Token of Trust, with your recent CLI activity
3
+ * attached, straight through the ToT MCP's `feedback_submit` tool.
4
+ *
5
+ * Why it exists: when the `tot` dev loop surprises you, the fastest useful report is
6
+ * "here's what I was doing + what happened." The bounded, secret-redacted activity
7
+ * log (~/.tot/activity.log) is attached in the report's `sensitive` field — private
8
+ * to ToT admins, never clustered or shared — so triage sees the breadcrumbs without
9
+ * the developer copy-pasting anything.
10
+ *
11
+ * Auth: the MCP feedback tool is authenticated, so this reuses the signed-in session
12
+ * (`resolveSession` → operator creds if present, else the `tot login` developer
13
+ * token). Not signed in → a clear "run `tot login`" instead of a stack trace.
14
+ *
15
+ * Sending publishes to Token of Trust, so we PREVIEW the report and ask before
16
+ * sending (skippable with --yes; required in a non-interactive shell).
17
+ *
18
+ * Usage:
19
+ * tot feedback "the sign-in code kept opening a browser"
20
+ * tot feedback --bug --severity high "tot dev crashed on save"
21
+ * tot feedback --no-activity "just a doc nit"
22
+ */
23
+ import { createInterface } from "node:readline/promises";
24
+ import { createMcpClient } from "../mcp.mjs";
25
+ import { resolveSession, AuthUnavailableError } from "../auth.mjs";
26
+ import { readActivity, formatActivity } from "../activity-log.mjs";
27
+ import { fail } from "../errors.mjs";
28
+
29
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
+ const TYPES = new Set(["bug", "feature", "improvement"]);
31
+ const SEVERITIES = new Set(["critical", "high", "medium", "low"]);
32
+ const CATEGORIES = new Set([
33
+ "doc-gap", "api-ambiguity", "setup-confusion", "platform-bug",
34
+ "ai-agent-confusion", "product-ux", "scenario-instruction-gap",
35
+ ]);
36
+
37
+ const USAGE = `tot feedback — send a note to Token of Trust (with your recent CLI activity)
38
+
39
+ tot feedback "<what happened>" report friction; attaches your recent
40
+ CLI activity (secret-redacted, admin-only)
41
+ tot feedback --bug "<what broke>" file it as a bug (default: improvement)
42
+ tot feedback --feature "<idea>" file it as a feature request
43
+
44
+ --severity <critical|high|medium|low> default: medium
45
+ --category <doc-gap|api-ambiguity|setup-confusion|platform-bug|
46
+ ai-agent-confusion|product-ux|scenario-instruction-gap> default: product-ux
47
+ --no-activity don't attach the activity log
48
+ --yes, -y send without the confirmation prompt (required non-interactively)
49
+ --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
50
+
51
+ Requires a signed-in session — run \`tot login\` first if needed.`;
52
+
53
+ function parseArgs(argv) {
54
+ const a = {
55
+ mcp: null, type: "improvement", category: "product-ux", severity: "medium",
56
+ activity: true, yes: false, help: false,
57
+ };
58
+ const words = [];
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const t = argv[i];
61
+ if (t === "--mcp") a.mcp = argv[++i];
62
+ else if (t === "--type") a.type = argv[++i];
63
+ else if (t === "--bug") a.type = "bug";
64
+ else if (t === "--feature") a.type = "feature";
65
+ else if (t === "--category") a.category = argv[++i];
66
+ else if (t === "--severity") a.severity = argv[++i];
67
+ else if (t === "--no-activity") a.activity = false;
68
+ else if (t === "--yes" || t === "-y") a.yes = true;
69
+ else if (t === "--help" || t === "-h") a.help = true;
70
+ else words.push(t);
71
+ }
72
+ a.message = words.join(" ").trim() || null;
73
+ return a;
74
+ }
75
+
76
+ /** @param {string[]} argv */
77
+ export async function run(argv) {
78
+ const env = process.env;
79
+ const args = parseArgs(argv);
80
+ if (args.help) {
81
+ console.log(USAGE);
82
+ return 0;
83
+ }
84
+ if (!TYPES.has(args.type)) {
85
+ console.error(fail(`--type must be one of: ${[...TYPES].join(", ")}.`, "e.g. --bug"));
86
+ return 2;
87
+ }
88
+ if (!SEVERITIES.has(args.severity)) {
89
+ console.error(fail(`--severity must be one of: ${[...SEVERITIES].join(", ")}.`));
90
+ return 2;
91
+ }
92
+ if (!CATEGORIES.has(args.category)) {
93
+ console.error(fail(`--category must be one of: ${[...CATEGORIES].join(", ")}.`));
94
+ return 2;
95
+ }
96
+
97
+ const isTty = Boolean(process.stdin.isTTY);
98
+
99
+ // The message: prompt in a TTY, otherwise it must be an argument.
100
+ let message = args.message;
101
+ if (!message && isTty) {
102
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
103
+ message = (await rl.question("What happened? (a sentence or two)\n> ")).trim();
104
+ rl.close();
105
+ }
106
+ if (!message) {
107
+ console.error(fail(
108
+ "a feedback message is required.",
109
+ 'e.g. tot feedback "the sign-in code opened a browser instead of redeeming"',
110
+ ));
111
+ return 2;
112
+ }
113
+
114
+ const entries = args.activity ? readActivity(env, { limit: 40 }) : [];
115
+ const activityText = entries.length ? formatActivity(entries) : null;
116
+ const title = message.length > 80 ? `${message.slice(0, 79)}…` : message;
117
+
118
+ // Preview — sending publishes to Token of Trust, so show exactly what goes out.
119
+ console.error("\nAbout to send to Token of Trust:");
120
+ console.error(` ${args.type} · ${args.category} · ${args.severity}`);
121
+ console.error(` "${title}"`);
122
+ console.error(
123
+ activityText
124
+ ? ` + your last ${entries.length} CLI command(s) — secret-redacted, ToT-admins-only`
125
+ : " (no activity log attached)",
126
+ );
127
+
128
+ if (!args.yes) {
129
+ if (!isTty) {
130
+ console.error(fail(
131
+ "refusing to send without confirmation in a non-interactive shell.",
132
+ "re-run with --yes to send.",
133
+ ));
134
+ return 2;
135
+ }
136
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
137
+ const ans = (await rl.question("Send it? [y/N] ")).trim().toLowerCase();
138
+ rl.close();
139
+ if (ans !== "y" && ans !== "yes") {
140
+ console.error("- not sent.");
141
+ return 0;
142
+ }
143
+ }
144
+
145
+ const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
146
+ const client = createMcpClient(mcpUrl);
147
+ try {
148
+ await resolveSession(client);
149
+ await client.initialize({ name: "tot-cli", version: "feedback" });
150
+ const payload = {
151
+ type: args.type,
152
+ category: args.category,
153
+ severity: args.severity,
154
+ title,
155
+ description: message,
156
+ scenario: "tot-cli",
157
+ // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
158
+ // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
159
+ ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
160
+ };
161
+ const res = await client.callTool("feedback_submit", payload);
162
+ const id = res?.reportId || res?.id || null;
163
+ console.log(`\n+ sent — thank you.${id ? ` (report ${id})` : ""}`);
164
+ return 0;
165
+ } catch (e) {
166
+ if (e instanceof AuthUnavailableError) {
167
+ console.error(fail(`can't send feedback: ${e.message}`, e.hint || "run `tot login` first."));
168
+ return 1;
169
+ }
170
+ console.error(fail(
171
+ `couldn't send feedback: ${e?.message || e}`,
172
+ "your note was NOT sent — try again in a moment.",
173
+ ));
174
+ return 1;
175
+ }
176
+ }
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * Dependency-free (node built-ins via oauth.mjs).
21
21
  */
22
- import { loginFlow, deviceLoginFlow, NoOpenerError } from "../oauth.mjs";
22
+ import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oauth.mjs";
23
23
  import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
24
24
  import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
@@ -27,11 +27,12 @@ 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, help: false };
30
+ const a = { mcp: null, device: false, code: null, help: false };
31
31
  for (let i = 0; i < argv.length; i++) {
32
32
  const t = argv[i];
33
33
  if (t === "--mcp") a.mcp = argv[++i];
34
34
  else if (t === "--device") a.device = true;
35
+ else if (t === "--code" || t === "--token") a.code = argv[++i];
35
36
  else if (t === "--help" || t === "-h") a.help = true;
36
37
  }
37
38
  return a;
@@ -40,6 +41,8 @@ function parseArgs(argv) {
40
41
  const USAGE = `tot login — sign in to Token of Trust
41
42
 
42
43
  tot login open the browser, sign in, cache your session
44
+ tot login --code <token> paste the sign-in token from your invite — no browser,
45
+ no email code (alias: --token)
43
46
  tot login --device headless/SSH: print a code, poll until approved
44
47
  elsewhere (also the automatic fallback when no
45
48
  browser opener exists on this box)
@@ -77,6 +80,21 @@ export async function loginAndCache(mcpUrl, env = process.env, { log = () => {},
77
80
  return creds;
78
81
  }
79
82
 
83
+ /**
84
+ * The core of `tot login --code`: run the browserless redemption (POST the invite
85
+ * token to the MCP, get a grant back) and cache it, reusing a previously-registered
86
+ * client for THIS MCP so we don't re-register on every login.
87
+ * @returns {Promise<object>} the credentials written to disk.
88
+ */
89
+ export async function redeemAndCache(mcpUrl, code, env = process.env) {
90
+ const path = defaultCredentialsPath(env);
91
+ const prior = readCredentials(path);
92
+ const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
93
+ const creds = await redeemCodeFlow({ mcpUrl, clientId, code });
94
+ writeCredentials(path, creds);
95
+ return creds;
96
+ }
97
+
80
98
  /** @param {string[]} argv @param {any} _ctx */
81
99
  export async function run(argv, _ctx) {
82
100
  const env = process.env;
@@ -87,6 +105,25 @@ export async function run(argv, _ctx) {
87
105
  }
88
106
 
89
107
  const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
108
+
109
+ if (args.code) {
110
+ console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
111
+ try {
112
+ await redeemAndCache(mcpUrl, args.code, env);
113
+ console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
114
+ console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
115
+ return 0;
116
+ } catch (e) {
117
+ console.error(
118
+ fail(
119
+ `sign-in didn't complete: ${e?.message || e}`,
120
+ "double-check the sign-in token from your invite — it's single-use and expires, so ask for a fresh invite if needed.",
121
+ ),
122
+ );
123
+ return 1;
124
+ }
125
+ }
126
+
90
127
  console.error(`~ signing in to Token of Trust (${mcpUrl})`);
91
128
  try {
92
129
  await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `tot logout` — sign this machine out of Token of Trust.
3
+ *
4
+ * Deletes the cached session (`~/.tot/credentials.json`) so the next command starts
5
+ * clean — the counterpart to `tot login` / `tot login --code`. Idempotent: logging
6
+ * out when already signed out is success, not an error.
7
+ *
8
+ * LOCAL ONLY: this clears the credential cache on THIS machine. It does not revoke
9
+ * the grant server-side — revoke from your Token of Trust account if you need that.
10
+ *
11
+ * Dependency-free (node built-ins via token-store.mjs).
12
+ */
13
+ import { defaultCredentialsPath, readCredentials, clearCredentials } from "../token-store.mjs";
14
+
15
+ const USAGE = `tot logout — sign this machine out of Token of Trust
16
+
17
+ tot logout delete the cached session (~/.tot/credentials.json)
18
+
19
+ Idempotent — safe to run when already signed out. Clears only this machine's cache;
20
+ it does not revoke server-side. Run \`tot whoami\` to confirm you're signed out.`;
21
+
22
+ /** @param {string[]} argv */
23
+ export async function run(argv) {
24
+ if (argv.includes("--help") || argv.includes("-h")) {
25
+ console.log(USAGE);
26
+ return 0;
27
+ }
28
+ const path = defaultCredentialsPath(process.env);
29
+ // Report against the prior state so "signed out" vs "already clean" is honest.
30
+ const wasSignedIn = !!readCredentials(path);
31
+ const removed = clearCredentials(path);
32
+ if (wasSignedIn || removed) {
33
+ console.log(`+ signed out. Cleared ${path}.`);
34
+ console.log(" Run `tot login` (or `tot login --code <token>`) to sign in again.");
35
+ } else {
36
+ console.log("+ already signed out — no cached session to clear.");
37
+ }
38
+ return 0;
39
+ }
@@ -28,6 +28,13 @@
28
28
  * yes, `claude mcp add` AND drop straight into that seeded
29
29
  * prompt (G2) — the payoff, not a pointer to go write one.
30
30
  *
31
+ * ZERO-LOGIN FREE TASTE (--sample / no session): when the user passes --sample,
32
+ * or has no Token of Trust session (and doesn't decline), `tot start` SKIPS
33
+ * login + client_list + tenant_checkout entirely, scaffolds a bundled sample
34
+ * store locally, runs it natively (no Docker, no MCP), opens the browser, and
35
+ * ends on the SAME "connect the ToT MCP for the real power" opt-in. The MCP is
36
+ * repositioned as the upsell, not a gate. See runSampleStart + sample.mjs.
37
+ *
31
38
  * Value-first ordering (C): the running site + browser come BEFORE any mention of
32
39
  * connecting the MCP; Claude is the supercharge offered after the aha, not a gate.
33
40
  * ONE felt browser login (B2): `claude mcp add` only ever runs behind that final
@@ -43,7 +50,7 @@ import { createInterface } from "node:readline/promises";
43
50
 
44
51
  import { detectContext } from "../context.mjs";
45
52
  import { createMcpClient } from "../mcp.mjs";
46
- import { resolveSession } from "../auth.mjs";
53
+ import { resolveSession, AuthUnavailableError } from "../auth.mjs";
47
54
  import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
48
55
  import { openBrowser, waitForServer } from "../open.mjs";
49
56
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
@@ -52,8 +59,10 @@ import { normalizeStores, checkoutTenant } from "./checkout.mjs";
52
59
  import {
53
60
  buildContainerPlan, spawnDevContainer, dockerAvailable, tryStartDocker,
54
61
  resolveDevImage, isPrivateRegistryImage, ensureRegistryLogin,
55
- ensureRendererArtifact, spawnNativeDev, deriveUrl, NativeArtifactUnavailableError,
62
+ ensureRendererArtifact, ensureSampleRenderer, spawnNativeDev, deriveUrl,
63
+ NativeArtifactUnavailableError,
56
64
  } from "./dev.mjs";
65
+ import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
57
66
  import { IDEAS } from "./ideas.mjs";
58
67
 
59
68
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -62,7 +71,7 @@ const CLAUDE_MCP_ARGS = ["mcp", "add", "--transport", "http", "tot", `${DEFAULT_
62
71
  function parseArgs(argv) {
63
72
  const a = {
64
73
  mcp: null, identity: null, tenant: null, port: "4321",
65
- noOpen: false, noConnect: false, yes: false, docker: false, help: false,
74
+ noOpen: false, noConnect: false, yes: false, docker: false, sample: false, help: false,
66
75
  };
67
76
  for (let i = 0; i < argv.length; i++) {
68
77
  const t = argv[i];
@@ -74,6 +83,7 @@ function parseArgs(argv) {
74
83
  else if (t === "--no-connect") a.noConnect = true;
75
84
  else if (t === "--yes" || t === "-y") a.yes = true;
76
85
  else if (t === "--docker") a.docker = true;
86
+ else if (t === "--sample") a.sample = true;
77
87
  else if (t === "--help" || t === "-h") a.help = true;
78
88
  }
79
89
  return a;
@@ -83,9 +93,12 @@ const USAGE = `tot start — go from invite to a running store in one command
83
93
 
84
94
  tot start preflight → sign in → pick your store → check out →
85
95
  run it → open your browser → connect Claude
96
+ tot start --sample the FREE local preview — no login, no MCP, no account:
97
+ scaffold + run a sample store, then offer the MCP upsell
86
98
  Options:
87
99
  --tenant <id> use this store (skips auto-pick/prompt; remembered for next time)
88
100
  --port <n> host port for the dev server (default 4321)
101
+ --sample zero-login free taste (see above); also what a no-session run offers
89
102
  --docker use the Docker runner instead of the native runtime (F3
90
103
  default); also the automatic fallback if the native
91
104
  artifact can't be fetched
@@ -131,6 +144,20 @@ export function pickTenant(stores, { explicit = null, lastTenant = null } = {})
131
144
  return pick;
132
145
  }
133
146
 
147
+ /**
148
+ * Decide whether `tot start` runs the ZERO-LOGIN sample path or the authenticated
149
+ * path. Pure + exported so the branch is unit-tested without any I/O.
150
+ * - `--sample` explicit → always sample (skip login entirely).
151
+ * - otherwise: a usable session → authed; NO session → sample (the graceful
152
+ * free-taste default instead of dead-ending at "ask for an invite").
153
+ * @param {{ sampleFlag: boolean, hasSession: boolean }} args
154
+ * @returns {"sample"|"authed"}
155
+ */
156
+ export function decideStartMode({ sampleFlag, hasSession }) {
157
+ if (sampleFlag) return "sample";
158
+ return hasSession ? "authed" : "sample";
159
+ }
160
+
134
161
  /** @param {string[]} argv @param {any} ctx */
135
162
  export async function run(argv, ctx) {
136
163
  const env = process.env;
@@ -141,16 +168,31 @@ export async function run(argv, ctx) {
141
168
  }
142
169
 
143
170
  const startedAt = Date.now();
171
+
172
+ // Explicit free taste: skip login/store/checkout entirely (A0 zero-login).
173
+ if (decideStartMode({ sampleFlag: args.sample, hasSession: true }) === "sample") {
174
+ return runSampleStart(args, ctx, env, startedAt);
175
+ }
176
+
144
177
  try {
145
- // 1+2. preflight (F) and login (B) run CONCURRENTLY (A2) — the local machine
146
- // checks (node/git/Docker) don't gate the network sign-in and vice versa, so
147
- // gate on the slower of the two instead of paying their sum serially.
148
178
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
149
179
  const client = createMcpClient(baseUrl);
150
- const [, session] = await Promise.all([
151
- preflight(ctx, env),
152
- loginStep(client, env, args),
153
- ]);
180
+
181
+ // Resolve a session, tolerating a no-session / no-network condition so we can
182
+ // offer the free local preview instead of dead-ending (B/A0).
183
+ const session = await tryResolveSession(client, env, args);
184
+ if (decideStartMode({ sampleFlag: false, hasSession: !!session }) === "sample") {
185
+ if (!(await confirmSampleFallback(args))) {
186
+ throw new CliError("no Token of Trust session, and you declined the free local preview", {
187
+ next: "tot login (then re-run `tot start`), or `tot start --sample` for the free local preview",
188
+ });
189
+ }
190
+ return await runSampleStart(args, ctx, env, startedAt);
191
+ }
192
+
193
+ // Authenticated path. Preflight (F) now that we know we're taking it — the
194
+ // local machine checks (node/git/Docker) that the authed loop needs.
195
+ await preflight(ctx, env);
154
196
  console.log(` ✓ signed in as ${session.identity}${session.appDomain ? ` (${session.appDomain})` : ""}`);
155
197
 
156
198
  // 3. store — auto-pick, use --tenant, use the remembered one, or choose (A4).
@@ -223,8 +265,79 @@ export async function run(argv, ctx) {
223
265
  }
224
266
 
225
267
  /**
226
- * MCP init + session resolve the "login" half of preflight (A2: runs
227
- * concurrently with the local machine checks in preflight(), below).
268
+ * The ZERO-LOGIN free taste: scaffold a bundled sample store, run it natively
269
+ * (no Docker, no MCP, no auth), open the browser, and end on the "connect the
270
+ * ToT MCP for the real power" upsell. Skips loginStep + client_list +
271
+ * tenant_checkout entirely. Reuses connectClaude() as the terminal opt-in.
272
+ */
273
+ async function runSampleStart(args, ctx, env, startedAt) {
274
+ try {
275
+ // Minimal preflight — the free path needs ONLY Node (no git, no Docker, no auth).
276
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
277
+ if (nodeMajor < 20) {
278
+ throw new CliError(`Node 20+ is required (have ${process.versions.node})`, {
279
+ next: "upgrade Node, then re-run",
280
+ exitCode: 2,
281
+ });
282
+ }
283
+
284
+ // Scaffold — or reuse a sample checkout we're already standing in.
285
+ let workspace;
286
+ if (ctx.mode === "checkout" && isSampleCheckout(ctx.workspacePath)) {
287
+ workspace = ctx.workspacePath;
288
+ console.log(` ✓ reusing the sample checkout at ${workspace}`);
289
+ } else {
290
+ const dest = resolve(process.cwd(), SAMPLE_DIR_NAME);
291
+ const r = scaffoldSample(dest, { log: (m) => console.log(m) });
292
+ workspace = r.dir;
293
+ }
294
+
295
+ // Resolve a renderer OFFLINE (no dev_renderer_artifact MCP call) + boot it.
296
+ const devArgs = {
297
+ image: null, port: String(args.port || "4321"), mcp: args.mcp,
298
+ noLogin: true, noOpen: args.noOpen, docker: false, sample: true,
299
+ };
300
+ const runnerDir = await ensureSampleRenderer(devArgs, ctx, { env });
301
+
302
+ const ctxDev = detectContext(workspace);
303
+ const url = deriveUrl(ctxDev.config || sampleConfig(), devArgs.port).url;
304
+ console.log(` → starting the free local preview … ${url}`);
305
+ const handle = spawnNativeDev(runnerDir, workspace, devArgs.port, { stdio: "piped" });
306
+
307
+ const up = await Promise.race([
308
+ waitForServer(url, { until: () => handle.exited }),
309
+ handle.done.then(() => "exited"),
310
+ ]);
311
+ if (up !== true) {
312
+ throw new CliError("the local preview server didn't come up", {
313
+ next: `cd ${SAMPLE_DIR_NAME} && tot dev --sample (to watch the runner logs)`,
314
+ });
315
+ }
316
+ if (!args.noOpen) {
317
+ openBrowser(url);
318
+ console.log(" ✓ opened your browser");
319
+ }
320
+
321
+ // You're live — the FREE preview. End on the MCP upsell (the whole point).
322
+ printSampleLiveEnding(url, formatElapsed(Date.now() - startedAt));
323
+ if (!args.noConnect) {
324
+ const yes = args.yes || (await promptYesNo(" Connect the ToT MCP for your real store + AI editing?", true));
325
+ if (yes) connectClaude();
326
+ }
327
+
328
+ console.log("\n Streaming preview logs — edit content/*.html + save to see reloads. Ctrl-C to stop.\n");
329
+ handle.child.stdout?.pipe(process.stdout);
330
+ handle.child.stderr?.pipe(process.stderr);
331
+ return handle.done;
332
+ } catch (e) {
333
+ console.error(formatError(e));
334
+ return exitCodeFor(e);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * MCP init + session resolve — the "login" half of preflight. Throws
340
+ * AuthUnavailableError (no creds) or a CliError (can't reach the MCP).
228
341
  */
229
342
  async function loginStep(client, env, args) {
230
343
  try {
@@ -237,14 +350,41 @@ async function loginStep(client, env, args) {
237
350
  return resolveSession(client, { env, prefer: args.identity || undefined });
238
351
  }
239
352
 
353
+ /**
354
+ * Resolve a session but return null (instead of throwing) for the two conditions
355
+ * the free-taste fallback should absorb: no usable session, or the MCP being
356
+ * unreachable. A blocking machine problem (or any other error) still throws.
357
+ */
358
+ async function tryResolveSession(client, env, args) {
359
+ try {
360
+ return await loginStep(client, env, args);
361
+ } catch (e) {
362
+ if (e instanceof AuthUnavailableError) return null;
363
+ if (e instanceof CliError && /can't reach the Token of Trust MCP/.test(e.message)) return null;
364
+ throw e;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Offer the free local preview when there's no session. Non-interactive (or
370
+ * --yes) defaults to YES — the graceful free path, never a hang. Returns whether
371
+ * to proceed into the sample run.
372
+ */
373
+ async function confirmSampleFallback(args) {
374
+ console.log("");
375
+ console.log(" You're not signed in to Token of Trust — no problem. You can try the FREE");
376
+ console.log(" local preview right now (no account, no MCP), then connect for your real store.");
377
+ if (args.yes) return true;
378
+ if (!isInteractive()) return true;
379
+ return promptYesNo(" Run the free local sample store?", true);
380
+ }
381
+
240
382
  /**
241
383
  * Self-healing preflight (F): run the readiness checks; a blocking failure
242
384
  * ends with the next command. Docker is informational-only here (F3: native
243
385
  * is the default runtime, so Docker not running doesn't block `tot start`
244
386
  * anymore) — its own readiness is handled lazily in prefetchDockerLogin(),
245
387
  * only when the run actually needs it (--docker or the native fallback).
246
- * Runs concurrently with loginStep (A2) via Promise.all in run(), so nothing
247
- * here may assume login has happened.
248
388
  */
249
389
  async function preflight(ctx, env) {
250
390
  const checks = collectChecks(ctx, env);
@@ -311,7 +451,7 @@ async function resolveTenant(stores, args, env, baseUrl) {
311
451
  let tenant;
312
452
  if (pick.kind === "none") {
313
453
  throw new CliError("no stores you can build on yet", {
314
- next: "ask your Token of Trust contact for a store invite, then re-run",
454
+ next: "ask your Token of Trust contact for a store invite, then re-run — or `tot start --sample` for the free local preview",
315
455
  });
316
456
  } else if (pick.kind === "explicit") {
317
457
  tenant = pick.tenant;
@@ -385,6 +525,24 @@ function printLiveEnding(tenant, url, elapsed) {
385
525
  console.log("");
386
526
  }
387
527
 
528
+ /**
529
+ * The crafted "you're live" ending for the FREE local preview — honest about
530
+ * what it is (a sample on your machine) and what unlocks the real thing (the
531
+ * MCP: your real store, AI editing, live compliance previews). The upsell IS the
532
+ * ending — connectClaude() runs right after this on opt-in.
533
+ */
534
+ function printSampleLiveEnding(url, elapsed) {
535
+ console.log("");
536
+ console.log(` ✨ You're live — the FREE local preview.${elapsed ? ` (${elapsed})` : ""}`);
537
+ console.log(` ${url}`);
538
+ console.log(" Edit content/home.html + save → it reloads. The age-gate + nicotine warning");
539
+ console.log(" you see ARE Token of Trust compliance rendering — live, on your machine.");
540
+ console.log("");
541
+ console.log(" This is a sample store, running locally, for free. Connect the ToT MCP to use");
542
+ console.log(" your REAL store, AI editing, and live compliance previews:");
543
+ console.log("");
544
+ }
545
+
388
546
  /**
389
547
  * Offer + wire Claude (G2): `claude mcp add`, then — the payoff, not just a
390
548
  * pointer — drop the user straight into an interactive Claude session already
package/src/oauth.mjs CHANGED
@@ -285,6 +285,83 @@ export async function loginFlow({
285
285
  }
286
286
  }
287
287
 
288
+ // ── WS2b: browserless invite-code redemption (`tot login --code <token>`) ─────
289
+ //
290
+ // The invited developer pastes the single-use sign-in token their invite minted;
291
+ // we hand it to the MCP's redeem endpoint, which validates + BURNS it via ToT and
292
+ // returns a normal MCP OAuth grant. No browser, no loopback, no OTP — the token IS
293
+ // the operator's authorization. Same credentials shape as loginFlow/deviceLoginFlow,
294
+ // and the grant is bound to our registered client_id so later silent refreshes go
295
+ // through the standard token endpoint like any other session.
296
+ //
297
+ // Wire contract (CLI → MCP):
298
+ // POST {mcp-origin}/oauth/redeem-code (application/json)
299
+ // { code: "<invite sign-in token>", client_id, scope? }
300
+ // → 200 { access_token, refresh_token, token_type, expires_in, scope }
301
+ // → 400 { error, error_description } (invalid_grant | invalid_client)
302
+ //
303
+ // The token→CLI binding is inherent to the single request: the tokens are returned
304
+ // only in the direct TLS response to the client that presented the code, and the
305
+ // grant is pinned to our dynamically-registered client_id (only that client can
306
+ // refresh it). A separate PKCE nonce would add nothing here — there is no second
307
+ // exchange step at which a verifier could be presented.
308
+
309
+ /** The MCP's redeem endpoint — the MCP origin (from the AS token endpoint) + a
310
+ * fixed path. Kept beside the flow so the path lives in exactly one place. */
311
+ export function redeemCodeEndpoint(mcpUrl, meta) {
312
+ const origin = meta?.token_endpoint ? new URL(meta.token_endpoint) : new URL(mcpUrl);
313
+ return new URL("/oauth/redeem-code", origin).toString();
314
+ }
315
+
316
+ /** POST the invite token to the MCP redeem endpoint and return the raw token
317
+ * response (→ credentialsFromToken). Throws a clear, non-stack error on rejection. */
318
+ export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope = SCOPE }, fetchImpl = fetch) {
319
+ const res = await fetchImpl(redeemEndpoint, {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
322
+ body: JSON.stringify({ code, client_id: clientId, scope }),
323
+ });
324
+ const text = await res.text();
325
+ let body;
326
+ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
327
+ if (!res.ok) {
328
+ const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
329
+ throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
330
+ }
331
+ if (!body.access_token) throw new Error("the redeem endpoint returned no access_token");
332
+ return body;
333
+ }
334
+
335
+ /**
336
+ * Run the full browserless redemption and return a persistable credentials record
337
+ * — the invite-token sibling of loginFlow()/deviceLoginFlow(), same credentials
338
+ * shape, same dynamically-registered client_id (reused when the caller cached one
339
+ * for this MCP). Injectable (`fetchImpl`, `now`) so it's testable with no network.
340
+ * @returns {Promise<object>} credentials to hand to writeCredentials()
341
+ */
342
+ export async function redeemCodeFlow({
343
+ mcpUrl,
344
+ clientId,
345
+ code,
346
+ fetchImpl = fetch,
347
+ now = () => Date.now(),
348
+ }) {
349
+ const meta = await discoverMetadata(mcpUrl, fetchImpl);
350
+ const resolvedClientId = clientId || (await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl));
351
+ const token = await redeemInviteCode(
352
+ redeemCodeEndpoint(mcpUrl, meta),
353
+ { code, clientId: resolvedClientId },
354
+ fetchImpl,
355
+ );
356
+ return credentialsFromToken({
357
+ mcpUrl,
358
+ clientId: resolvedClientId,
359
+ tokenEndpoint: meta.token_endpoint,
360
+ token,
361
+ now: now(),
362
+ });
363
+ }
364
+
288
365
  // ── B3: device-code grant (RFC 8628) — headless/SSH/no-browser sign-in ────────
289
366
 
290
367
  /**