@tokenoftrust/cli 1.0.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.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * `tot start` — the zero-arg, value-first first run. One command from an invite
3
+ * to a running store in your browser, ending at the AI-wow prompt.
4
+ *
5
+ * It COMPOSES the other commands' cores in-process (no shelling out to `tot …`):
6
+ *
7
+ * preflight collectChecks() (F — self-healing where it can be)
8
+ * login resolveSession() (operator today; device-code is the B seam)
9
+ * (A2 — run concurrently: neither gates the other; wait on the slower)
10
+ * ↓
11
+ * store normalizeStores(client_list) → auto-pick if exactly one, else
12
+ * use --tenant, else the remembered last tenant, else choose (A4)
13
+ * ↓
14
+ * checkout ─┬─ checkoutTenant() → ./<tenant>
15
+ * runner ─┘ prefetch the runner: the native renderer artifact by default
16
+ * (F3 — no Docker prerequisite), or the Docker registry
17
+ * credential on --docker / as the automatic fallback if the
18
+ * native artifact can't be fetched (same fallback `tot dev`
19
+ * uses — see dev.mjs#NativeArtifactUnavailableError)
20
+ * (C1 — run concurrently with checkout: both just need the
21
+ * session resolved above)
22
+ * ↓
23
+ * dev spawnNativeDev() or spawnDevContainer() → wait for ready →
24
+ * open the browser (C/D)
25
+ * ↓
26
+ * you're live crafted ending (elapsed time printed — A3), seeded with
27
+ * IDEAS[0] (G3), THEN the optional "Connect Claude?" prompt: on
28
+ * yes, `claude mcp add` AND drop straight into that seeded
29
+ * prompt (G2) — the payoff, not a pointer to go write one.
30
+ *
31
+ * Value-first ordering (C): the running site + browser come BEFORE any mention of
32
+ * connecting the MCP; Claude is the supercharge offered after the aha, not a gate.
33
+ * ONE felt browser login (B2): `claude mcp add` only ever runs behind that final
34
+ * opt-in — the core loop above needs nothing but the cached `tot login` session.
35
+ * Every failure ends with the exact next command via the shared formatter (F).
36
+ *
37
+ * Dependency-free (node:readline/promises + the cores above).
38
+ */
39
+ import { spawnSync } from "node:child_process";
40
+ import { existsSync } from "node:fs";
41
+ import { resolve } from "node:path";
42
+ import { createInterface } from "node:readline/promises";
43
+
44
+ import { detectContext } from "../context.mjs";
45
+ import { createMcpClient } from "../mcp.mjs";
46
+ import { resolveSession } from "../auth.mjs";
47
+ import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
48
+ import { openBrowser, waitForServer } from "../open.mjs";
49
+ import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
50
+ import { collectChecks } from "./doctor.mjs";
51
+ import { normalizeStores, checkoutTenant } from "./checkout.mjs";
52
+ import {
53
+ buildContainerPlan, spawnDevContainer, dockerAvailable, tryStartDocker,
54
+ resolveDevImage, isPrivateRegistryImage, ensureRegistryLogin,
55
+ ensureRendererArtifact, spawnNativeDev, deriveUrl, NativeArtifactUnavailableError,
56
+ } from "./dev.mjs";
57
+ import { IDEAS } from "./ideas.mjs";
58
+
59
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
60
+ const CLAUDE_MCP_ARGS = ["mcp", "add", "--transport", "http", "tot", `${DEFAULT_MCP_URL}/mcp`];
61
+
62
+ function parseArgs(argv) {
63
+ const a = {
64
+ mcp: null, identity: null, tenant: null, port: "4321",
65
+ noOpen: false, noConnect: false, yes: false, docker: false, help: false,
66
+ };
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const t = argv[i];
69
+ if (t === "--mcp") a.mcp = argv[++i];
70
+ else if (t === "--identity") a.identity = argv[++i];
71
+ else if (t === "--tenant") a.tenant = argv[++i];
72
+ else if (t === "--port") a.port = argv[++i];
73
+ else if (t === "--no-open") a.noOpen = true;
74
+ else if (t === "--no-connect") a.noConnect = true;
75
+ else if (t === "--yes" || t === "-y") a.yes = true;
76
+ else if (t === "--docker") a.docker = true;
77
+ else if (t === "--help" || t === "-h") a.help = true;
78
+ }
79
+ return a;
80
+ }
81
+
82
+ const USAGE = `tot start — go from invite to a running store in one command
83
+
84
+ tot start preflight → sign in → pick your store → check out →
85
+ run it → open your browser → connect Claude
86
+ Options:
87
+ --tenant <id> use this store (skips auto-pick/prompt; remembered for next time)
88
+ --port <n> host port for the dev server (default 4321)
89
+ --docker use the Docker runner instead of the native runtime (F3
90
+ default); also the automatic fallback if the native
91
+ artifact can't be fetched
92
+ --no-open don't auto-open the browser
93
+ --no-connect skip the "Connect Claude for AI editing?" prompt
94
+ --yes, -y assume yes for prompts (non-interactive)
95
+ --identity <who> force "operator" or "developer" auth (default: auto)
96
+ --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
97
+
98
+ On a multi-store identity, the tenant you pick (or pass via --tenant) is
99
+ remembered in ~/.tot/ — the next bare \`tot start\` reuses it automatically.`;
100
+
101
+ /**
102
+ * Decide what to do with the stores this identity can build on.
103
+ * Pure + exported so the auto-pick rule is unit-tested without any I/O.
104
+ * @param {Array<{id:string,name:string}>} stores
105
+ * @returns {{ kind: "none" } | { kind: "one", store: object } | { kind: "many", stores: object[] }}
106
+ */
107
+ export function pickStore(stores) {
108
+ if (!Array.isArray(stores) || stores.length === 0) return { kind: "none" };
109
+ if (stores.length === 1) return { kind: "one", store: stores[0] };
110
+ return { kind: "many", stores };
111
+ }
112
+
113
+ /**
114
+ * Decide which tenant to use, given the identity's available stores, an
115
+ * explicit `--tenant` override, and a remembered "last tenant" from a prior
116
+ * run (A4). Pure + exported so the precedence is unit-tested without any I/O.
117
+ * Precedence: explicit > single store (pickStore) > remembered (if still one
118
+ * of the available stores) > ambiguous ("many" — caller must prompt).
119
+ * @param {Array<{id:string,name:string}>} stores
120
+ * @param {{ explicit?: string|null, lastTenant?: string|null }} [opts]
121
+ * @returns {{ kind: "none" } | { kind: "one", store: object }
122
+ * | { kind: "explicit"|"remembered", tenant: string } | { kind: "many", stores: object[] }}
123
+ */
124
+ export function pickTenant(stores, { explicit = null, lastTenant = null } = {}) {
125
+ if (explicit) return { kind: "explicit", tenant: explicit };
126
+ const pick = pickStore(stores);
127
+ if (pick.kind !== "many") return pick;
128
+ if (lastTenant && stores.some((s) => s.id === lastTenant)) {
129
+ return { kind: "remembered", tenant: lastTenant };
130
+ }
131
+ return pick;
132
+ }
133
+
134
+ /** @param {string[]} argv @param {any} ctx */
135
+ export async function run(argv, ctx) {
136
+ const env = process.env;
137
+ const args = parseArgs(argv);
138
+ if (args.help) {
139
+ console.log(USAGE);
140
+ return 0;
141
+ }
142
+
143
+ const startedAt = Date.now();
144
+ 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
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
149
+ const client = createMcpClient(baseUrl);
150
+ const [, session] = await Promise.all([
151
+ preflight(ctx, env),
152
+ loginStep(client, env, args),
153
+ ]);
154
+ console.log(` ✓ signed in as ${session.identity}${session.appDomain ? ` (${session.appDomain})` : ""}`);
155
+
156
+ // 3. store — auto-pick, use --tenant, use the remembered one, or choose (A4).
157
+ const stores = normalizeStores(await client.callTool("client_list", {}));
158
+ const tenant = await resolveTenant(stores, args, env, baseUrl);
159
+
160
+ // 4. checkout → ./<tenant> (reuse an existing checkout on a re-run),
161
+ // OVERLAPPED (C1) with prefetching the runner: the native artifact by
162
+ // default (F3 — no Docker prerequisite), or the Docker registry
163
+ // credential on --docker / as the automatic native-unavailable fallback.
164
+ // Both are independent authenticated calls once login (above) has
165
+ // resolved, so there's no reason to pay for them serially.
166
+ const dir = resolve(process.cwd(), tenant);
167
+ const devArgs = {
168
+ image: null, port: String(args.port || "4321"), mcp: args.mcp,
169
+ noLogin: false, noOpen: args.noOpen, docker: args.docker,
170
+ };
171
+ const runtime = { useDocker: args.docker, runnerDir: null };
172
+ await Promise.all([
173
+ ensureCheckout(client, tenant, dir, env),
174
+ prefetchRuntime(client, devArgs, env, runtime),
175
+ ]);
176
+
177
+ // 5. dev — native by default (F3), Docker on --docker or the fallback
178
+ // above; wait for the server, open the browser (C/D).
179
+ const ctxDev = detectContext(dir);
180
+ let url, handle;
181
+ if (runtime.useDocker) {
182
+ const plan = buildContainerPlan(dir, devArgs, ctxDev);
183
+ url = plan.url;
184
+ console.log(` → starting dev … ${url}`);
185
+ handle = await spawnDevContainer(plan, devArgs, { stdio: "piped" });
186
+ } else {
187
+ url = deriveUrl(ctxDev.config || {}, devArgs.port).url;
188
+ console.log(` → starting dev … ${url}`);
189
+ handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped" });
190
+ }
191
+
192
+ const up = await Promise.race([
193
+ waitForServer(url, { until: () => handle.exited }),
194
+ handle.done.then(() => "exited"),
195
+ ]);
196
+ if (up !== true) {
197
+ throw new CliError("the dev server didn't come up", {
198
+ next: `cd ${tenant} && tot dev (to watch the runner logs)`,
199
+ });
200
+ }
201
+ if (!args.noOpen) {
202
+ openBrowser(url);
203
+ console.log(" ✓ opened your browser");
204
+ }
205
+
206
+ // 6. you're live (G) — end at the AI-wow, THEN offer the MCP (C). Print the
207
+ // elapsed time (A3) so the "instant" claim is measured, not just felt.
208
+ printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
209
+ if (!args.noConnect) {
210
+ const yes = args.yes || (await promptYesNo(" Connect Claude for AI editing?", true));
211
+ if (yes) connectClaude();
212
+ }
213
+
214
+ // 7. hand the terminal to the running dev server until Ctrl-C.
215
+ console.log("\n Streaming dev logs — edit + save to see reloads. Ctrl-C to stop.\n");
216
+ handle.child.stdout?.pipe(process.stdout);
217
+ handle.child.stderr?.pipe(process.stderr);
218
+ return handle.done;
219
+ } catch (e) {
220
+ console.error(formatError(e));
221
+ return exitCodeFor(e);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * MCP init + session resolve — the "login" half of preflight (A2: runs
227
+ * concurrently with the local machine checks in preflight(), below).
228
+ */
229
+ async function loginStep(client, env, args) {
230
+ try {
231
+ await client.initialize();
232
+ } catch (e) {
233
+ throw new CliError(`can't reach the Token of Trust MCP at ${client.mcpUrl} (${String(e?.message || e)})`, {
234
+ next: "check your network, then re-run — or point elsewhere with --mcp <url>",
235
+ });
236
+ }
237
+ return resolveSession(client, { env, prefer: args.identity || undefined });
238
+ }
239
+
240
+ /**
241
+ * Self-healing preflight (F): run the readiness checks; a blocking failure
242
+ * ends with the next command. Docker is informational-only here (F3: native
243
+ * is the default runtime, so Docker not running doesn't block `tot start`
244
+ * anymore) — its own readiness is handled lazily in prefetchDockerLogin(),
245
+ * 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
+ */
249
+ async function preflight(ctx, env) {
250
+ const checks = collectChecks(ctx, env);
251
+ const blocked = checks.find((c) => c.blocking && !c.pass);
252
+ if (blocked) {
253
+ throw new CliError(`${blocked.name} — ${blocked.detail}`, {
254
+ next: "install/fix the above, then re-run `tot start`",
255
+ exitCode: 2,
256
+ });
257
+ }
258
+ console.log(" ✓ preflight ok");
259
+ }
260
+
261
+ /**
262
+ * Prefetch the runner (C1) so it's overlapped with checkout instead of paid
263
+ * for serially afterward: the native renderer artifact by default (F3 — no
264
+ * Docker prerequisite), or the Docker registry pull credential on --docker.
265
+ * On a native fetch failure, falls back to Docker automatically — the same
266
+ * fallback `tot dev` uses (dev.mjs#runStandalone) — flipping
267
+ * `runtime.useDocker` so the caller's subsequent dev step picks it up.
268
+ */
269
+ async function prefetchRuntime(client, devArgs, env, runtime) {
270
+ if (runtime.useDocker) {
271
+ await prefetchDockerLogin(client, devArgs, env);
272
+ return;
273
+ }
274
+ try {
275
+ runtime.runnerDir = await ensureRendererArtifact(devArgs, { client });
276
+ } catch (e) {
277
+ if (!(e instanceof NativeArtifactUnavailableError)) throw e;
278
+ console.log(` ~ native runtime unavailable (${e.message}) — falling back to the Docker runner.`);
279
+ runtime.useDocker = true;
280
+ await prefetchDockerLogin(client, devArgs, env);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * The Docker-path half of prefetchRuntime: registry login (reusing the
286
+ * already-authenticated client, C1) plus a best-effort Docker auto-start
287
+ * (self-healing — F) so buildContainerPlan doesn't have to hard-fail on a
288
+ * Docker Desktop that just needs a nudge.
289
+ */
290
+ async function prefetchDockerLogin(client, devArgs, env) {
291
+ const devImage = resolveDevImage(devArgs, env);
292
+ if (isPrivateRegistryImage(devImage)) {
293
+ await ensureRegistryLogin(devImage, devArgs, { client });
294
+ devArgs.noLogin = true;
295
+ }
296
+ if (!dockerAvailable()) await tryStartDocker();
297
+ }
298
+
299
+ /**
300
+ * Resolve the tenant to work on from the (normalized) store list, args, and
301
+ * the remembered last tenant (A4) — then remember whatever was decided so the
302
+ * next bare `tot start` doesn't have to ask again.
303
+ */
304
+ async function resolveTenant(stores, args, env, baseUrl) {
305
+ const lastTenantPath = defaultLastTenantPath(env);
306
+ const pick = pickTenant(stores, {
307
+ explicit: args.tenant || null,
308
+ lastTenant: readLastTenant(lastTenantPath, baseUrl),
309
+ });
310
+
311
+ let tenant;
312
+ if (pick.kind === "none") {
313
+ 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",
315
+ });
316
+ } else if (pick.kind === "explicit") {
317
+ tenant = pick.tenant;
318
+ console.log(` → your store: ${tenant} (--tenant)`);
319
+ } else if (pick.kind === "one") {
320
+ tenant = pick.store.id;
321
+ console.log(` → your store: ${tenant} (auto-picked — you have one)`);
322
+ } else if (pick.kind === "remembered") {
323
+ tenant = pick.tenant;
324
+ console.log(` → your store: ${tenant} (remembered from last run)`);
325
+ } else {
326
+ // Many stores, nothing remembered — choose. Non-interactive (no TTY /
327
+ // --yes without a name) can't guess.
328
+ if (!isInteractive() || args.yes) {
329
+ throw new CliError("you can build on several stores — pick one", {
330
+ next: `tot start --tenant <tenant> (one of: ${pick.stores.map((s) => s.id).join(", ")})`,
331
+ });
332
+ }
333
+ console.log(" Which store do you want to work on?\n");
334
+ pick.stores.forEach((s, i) => console.log(` ${i + 1}. ${s.id}${s.name ? ` — ${s.name}` : ""}`));
335
+ const idx = await promptChoice(pick.stores.length);
336
+ console.log("");
337
+ tenant = pick.stores[idx].id;
338
+ }
339
+
340
+ writeLastTenant(lastTenantPath, { mcpUrl: baseUrl, tenant });
341
+ return tenant;
342
+ }
343
+
344
+ /** Clone ./<tenant> unless a usable checkout is already there (re-run friendly). */
345
+ async function ensureCheckout(client, tenant, dir, env) {
346
+ if (existsSync(dir)) {
347
+ const existing = detectContext(dir);
348
+ if (existing.mode === "checkout") {
349
+ console.log(` ✓ reusing existing checkout ./${tenant}`);
350
+ return;
351
+ }
352
+ throw new CliError(`./${tenant} already exists and isn't a tot checkout`, {
353
+ next: `remove it (or run \`tot start\` from an empty directory)`,
354
+ exitCode: 2,
355
+ });
356
+ }
357
+ await checkoutTenant(client, { tenant, cloneDir: dir, redact: makeRedactor(env) });
358
+ console.log(` ✓ checked out ./${tenant}`);
359
+ }
360
+
361
+ /**
362
+ * Format elapsed milliseconds as a short human string for the "you're live"
363
+ * ending (A3 — measure, don't just claim, "instant"). Pure + exported so it's
364
+ * unit-tested without any I/O.
365
+ * @param {number} ms
366
+ * @returns {string|null}
367
+ */
368
+ export function formatElapsed(ms) {
369
+ if (!Number.isFinite(ms) || ms < 0) return null;
370
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
371
+ }
372
+
373
+ /** The crafted "you're live" ending — leads with the AI-wow (G), seeded with
374
+ * IDEAS[0] (G2/G3 — one prompt list shared with `tot ideas`, no drift). */
375
+ function printLiveEnding(tenant, url, elapsed) {
376
+ console.log("");
377
+ console.log(` ✨ You're live.${elapsed ? ` (${elapsed})` : ""}`);
378
+ console.log(` ${url}`);
379
+ console.log(" Edit content/home.html + save → it reloads.");
380
+ console.log("");
381
+ console.log(" Now try, in Claude:");
382
+ console.log(` "${IDEAS[0]}"`);
383
+ console.log("");
384
+ console.log(" More ideas: tot ideas");
385
+ console.log("");
386
+ }
387
+
388
+ /**
389
+ * Offer + wire Claude (G2): `claude mcp add`, then — the payoff, not just a
390
+ * pointer — drop the user straight into an interactive Claude session already
391
+ * seeded with IDEAS[0], so there's no blank slate to stare at.
392
+ */
393
+ function connectClaude() {
394
+ const cmd = `claude ${CLAUDE_MCP_ARGS.join(" ")}`;
395
+ console.log(`\n → ${cmd}`);
396
+ if (spawnSync("claude", ["--version"], { stdio: "ignore" }).status !== 0) {
397
+ console.log(" (install Claude Code, then run the command above to connect Claude.)");
398
+ return;
399
+ }
400
+ const r = spawnSync("claude", CLAUDE_MCP_ARGS, { stdio: "inherit" });
401
+ if (r.status !== 0) {
402
+ console.log(" (couldn't add it automatically — run the command above to connect Claude.)");
403
+ return;
404
+ }
405
+ console.log(` ✓ Claude connected — dropping you into: "${IDEAS[0]}"\n`);
406
+ spawnSync("claude", [IDEAS[0]], { stdio: "inherit" });
407
+ }
408
+
409
+ // ── small prompt helpers (respect non-TTY so nothing hangs in CI) ────────────
410
+
411
+ function isInteractive() {
412
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
413
+ }
414
+
415
+ async function promptYesNo(question, defaultYes) {
416
+ if (!isInteractive()) return defaultYes;
417
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
418
+ try {
419
+ const ans = (await rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
420
+ if (!ans) return defaultYes;
421
+ return ans === "y" || ans === "yes";
422
+ } finally {
423
+ rl.close();
424
+ }
425
+ }
426
+
427
+ /** Prompt for a 1..n choice; returns a 0-based index (defaults to first). */
428
+ async function promptChoice(n) {
429
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
430
+ try {
431
+ for (;;) {
432
+ const ans = (await rl.question(` Enter a number [1-${n}]: `)).trim();
433
+ if (!ans) return 0;
434
+ const k = Number(ans);
435
+ if (Number.isInteger(k) && k >= 1 && k <= n) return k - 1;
436
+ }
437
+ } finally {
438
+ rl.close();
439
+ }
440
+ }
441
+
442
+ /** Redact operator secrets from any string before it can reach the terminal. */
443
+ function makeRedactor(env) {
444
+ const secrets = [env.TOT_API_KEY, env.TOT_SECRET_KEY].filter(Boolean);
445
+ return (s) => {
446
+ let o = String(s);
447
+ for (const x of secrets) o = o.split(x).join("***");
448
+ return o;
449
+ };
450
+ }