@tokenoftrust/cli 1.0.1 → 1.2.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/bin/tot.mjs +61 -4
- package/package.json +2 -1
- package/src/activity-log.mjs +112 -0
- package/src/auth.mjs +1 -1
- package/src/commands/dev.mjs +274 -17
- package/src/commands/feedback.mjs +176 -0
- package/src/commands/login.mjs +39 -2
- package/src/commands/logout.mjs +39 -0
- package/src/commands/start.mjs +173 -15
- package/src/oauth.mjs +77 -0
- package/src/sample.mjs +202 -0
- package/src/token-store.mjs +17 -1
- package/src/update-check-worker.mjs +12 -0
- package/src/update-check.mjs +217 -0
- package/template/sample-store/content/chrome.html +152 -0
- package/template/sample-store/content/chrome.json +94 -0
- package/template/sample-store/content/home.html +194 -0
- package/template/sample-store/content/home.json +50 -0
- package/template/sample-store/content/pages/about.json +10 -0
- package/template/sample-store/content/pages/privacy.json +6 -0
- package/template/sample-store/content/pages/shipping-returns.json +6 -0
- package/template/sample-store/content/pages-html/blogs/news.html +68 -0
- package/template/sample-store/content/pages-html/pages/about-us.html +95 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +68 -0
- package/template/sample-store/content/pages-html/pages/privacy-policy.html +65 -0
- package/template/sample-store/content/pages-html/pages/shipping-returns.html +77 -0
- package/template/sample-store/content/themes/giant-navy.json +73 -0
- package/template/sample-store/public/img/hero-suicide-bunny.jpg +0 -0
- package/template/sample-store/public/img/hero.webp +0 -0
- package/template/sample-store/public/img/og.jpg +0 -0
- package/template/sample-store/public/logo-wordmark.png +0 -0
- package/template/sample-store/public/pages/home.css +120 -0
- package/template/sample-store/public/pages/mkt.css +185 -0
- package/template/sample-store/public/pages/page.css +155 -0
- package/template/sample-store/public/themes/giant-navy.css +76 -0
- 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
|
+
}
|
package/src/commands/login.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/start.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
*
|
|
227
|
-
*
|
|
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
|
/**
|