@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
package/bin/tot.mjs CHANGED
@@ -5,14 +5,18 @@
5
5
  * One command a developer learns to go from an invite to a running store:
6
6
  *
7
7
  * tot start invite → running store in one command ← built (orchestrates the below, value-first)
8
+ * tot start --sample FREE local preview — no login, no MCP ← built (zero-login quickstart; MCP is the upsell)
8
9
  * tot login sign in to Token of Trust (OAuth) ← built (MCP OAuth PKCE loopback; caches ~/.tot/credentials.json)
10
+ * tot logout sign out (clear the cached session) ← built (deletes ~/.tot/credentials.json; local-only, no server revoke)
9
11
  * tot whoami who you're signed in as ← built
10
12
  * tot checkout [<tenant>] clone a store you can build on ← built
11
13
  * tot validate lint your store before you submit ← built
12
14
  * tot dev run your store locally with save→reload ← built (monorepo: host astro; standalone: runs the published runner image)
15
+ * tot dev --sample run a FREE bundled sample store locally ← built (zero-login, no MCP; compliance rendering still shows)
13
16
  * tot submit submit your store for preview ← built (validate + push preview ref; MCP preview_status read-back)
14
17
  * tot doctor check this machine is ready
15
18
  * tot ideas copy-paste AI prompts that reliably wow
19
+ * tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
16
20
  * tot help this help
17
21
  *
18
22
  * Context-aware (see src/context.mjs): the same `tot` does the right thing from
@@ -23,34 +27,46 @@
23
27
  * bundles the moat-free storefront runner; `tot checkout/validate/submit` are
24
28
  * pure Node. Dependency-free by design so `npm i -g @tokenoftrust/cli` stays light.
25
29
  */
30
+ import { readFileSync } from "node:fs";
26
31
  import { detectContext } from "../src/context.mjs";
27
32
  import { printError } from "../src/errors.mjs";
33
+ import { recordActivity, redactArgs } from "../src/activity-log.mjs";
28
34
 
29
35
  const BUILD_ORDER = ["checkout", "validate", "dev", "submit"];
30
36
 
37
+ // CLI version (stamped into the activity log). Read from our own package.json; best-effort.
38
+ const VERSION = (() => {
39
+ try {
40
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
41
+ } catch {
42
+ return "0.0.0";
43
+ }
44
+ })();
45
+
31
46
  function usage() {
32
47
  console.log(`
33
48
  tot — Token of Trust developer CLI
34
49
 
35
50
  tot start invite → running store in one command (start here)
51
+ tot start --sample free local preview — no login, no account needed
36
52
  tot login sign in to Token of Trust
53
+ tot logout sign out (clear the cached session)
37
54
  tot whoami show who you're signed in as
38
55
  tot checkout [<tenant>] clone a store you can build on
39
56
  tot validate lint your store before you submit
40
57
  tot dev run your store locally with save→reload
58
+ tot dev --sample run a free bundled sample store (no login, no MCP)
41
59
  tot submit submit your store for preview
42
60
  tot doctor check this machine is ready
43
61
  tot ideas copy-paste AI prompts that reliably wow
62
+ tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
44
63
  tot help show this help
45
64
 
46
65
  Run \`tot <command> --help\` for command-specific options.
47
66
  `);
48
67
  }
49
68
 
50
- async function main() {
51
- const [cmd, ...rest] = process.argv.slice(2);
52
- const ctx = detectContext();
53
-
69
+ async function dispatch(cmd, rest, ctx) {
54
70
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
55
71
  usage();
56
72
  return cmd ? 0 : 2;
@@ -66,6 +82,16 @@ async function main() {
66
82
  return run(rest, ctx);
67
83
  }
68
84
 
85
+ if (cmd === "logout") {
86
+ const { run } = await import("../src/commands/logout.mjs");
87
+ return run(rest, ctx);
88
+ }
89
+
90
+ if (cmd === "feedback") {
91
+ const { run } = await import("../src/commands/feedback.mjs");
92
+ return run(rest, ctx);
93
+ }
94
+
69
95
  if (cmd === "whoami") {
70
96
  const { run } = await import("../src/commands/whoami.mjs");
71
97
  return run(rest, ctx);
@@ -116,6 +142,33 @@ async function main() {
116
142
  return 2;
117
143
  }
118
144
 
145
+ async function main() {
146
+ const [cmd, ...rest] = process.argv.slice(2);
147
+ const ctx = detectContext();
148
+ const startedAt = Date.now();
149
+ let code = 0;
150
+ let errMsg = null;
151
+ try {
152
+ code = await dispatch(cmd, rest, ctx);
153
+ return code;
154
+ } catch (e) {
155
+ errMsg = e?.message || String(e);
156
+ throw e;
157
+ } finally {
158
+ // Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
159
+ // free-text message is omitted — it's user-typed and belongs only in the report.
160
+ recordActivity({
161
+ ts: new Date().toISOString(),
162
+ v: VERSION,
163
+ cmd: cmd || "(none)",
164
+ args: cmd === "feedback" ? ["«omitted»"] : redactArgs(rest),
165
+ code: errMsg ? 1 : (code ?? 0),
166
+ ms: Date.now() - startedAt,
167
+ ...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
168
+ });
169
+ }
170
+ }
171
+
119
172
  main()
120
173
  .then((code) => process.exit(code ?? 0))
121
174
  .catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "bin",
27
27
  "src",
28
+ "template",
28
29
  "README.md",
29
30
  "LICENSE"
30
31
  ],
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Activity log for `tot` — a bounded, secret-safe breadcrumb trail of what the CLI
3
+ * did, so a developer who hits friction can attach it to a `tot feedback` report
4
+ * (sent through the Token of Trust MCP's `feedback_submit` tool).
5
+ *
6
+ * ONE file, `~/.tot/activity.log` (JSON-lines), beside the credential cache. Each
7
+ * line is one command invocation: { ts, v, cmd, args, code, ms, err? }. Bounded to
8
+ * the most recent MAX_ENTRIES so it never grows without limit.
9
+ *
10
+ * NEVER records secrets: the value after `--code` / `--token` (the single-use
11
+ * sign-in code) is redacted, and we only ever store argv — never env, never file
12
+ * contents, never the cached OAuth token. `tot feedback`'s own message is omitted
13
+ * by the dispatcher (it's free text the user typed).
14
+ *
15
+ * Best-effort by contract: recording NEVER throws and never blocks a command.
16
+ * `TOT_HOME` overrides the home dir (tests point it at a temp dir), like token-store.
17
+ */
18
+ import {
19
+ readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
20
+ } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join, dirname } from "node:path";
23
+
24
+ /** Keep at most this many recent invocations. */
25
+ export const MAX_ENTRIES = 300;
26
+
27
+ // Flags whose FOLLOWING token is a secret and must never be logged.
28
+ const SECRET_VALUE_FLAGS = new Set(["--code", "--token"]);
29
+
30
+ /** Absolute path to the activity log for this environment. */
31
+ export function activityLogPath(env = process.env) {
32
+ const home = env.TOT_HOME || homedir();
33
+ return join(home, ".tot", "activity.log");
34
+ }
35
+
36
+ /**
37
+ * Redact secret values from an argv array (never mutates the input). Handles both
38
+ * the split form (`--code TOKEN`) and the `=` form (`--code=TOKEN`).
39
+ */
40
+ export function redactArgs(args) {
41
+ const out = [];
42
+ for (let i = 0; i < args.length; i++) {
43
+ const a = String(args[i]);
44
+ const eq = a.match(/^(--code|--token)=/);
45
+ if (eq) {
46
+ out.push(`${eq[1]}=«redacted»`);
47
+ continue;
48
+ }
49
+ out.push(a);
50
+ if (SECRET_VALUE_FLAGS.has(a) && i + 1 < args.length) {
51
+ out.push("«redacted»");
52
+ i++; // skip the secret value we just masked
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function readRawLines(filePath) {
59
+ try {
60
+ return readFileSync(filePath, "utf8").split("\n").filter(Boolean);
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Append one activity entry, keeping only the most recent MAX_ENTRIES. Atomic
68
+ * (temp-file rename) + owner-only (0600 in a 0700 dir), like the credential cache.
69
+ * Never throws — activity logging must never break a command.
70
+ */
71
+ export function recordActivity(entry, env = process.env) {
72
+ try {
73
+ const filePath = activityLogPath(env);
74
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
75
+ const lines = readRawLines(filePath);
76
+ lines.push(JSON.stringify(entry));
77
+ const kept = lines.slice(-MAX_ENTRIES);
78
+ const tmp = `${filePath}.tmp`;
79
+ writeFileSync(tmp, `${kept.join("\n")}\n`, { mode: 0o600 });
80
+ renameSync(tmp, filePath);
81
+ chmodSync(filePath, 0o600);
82
+ } catch {
83
+ /* best-effort: never throw */
84
+ }
85
+ }
86
+
87
+ /** Read recent activity entries (most recent last), parsed. Never throws. */
88
+ export function readActivity(env = process.env, { limit = MAX_ENTRIES } = {}) {
89
+ const out = [];
90
+ for (const line of readRawLines(activityLogPath(env)).slice(-limit)) {
91
+ try {
92
+ out.push(JSON.parse(line));
93
+ } catch {
94
+ /* skip a malformed line */
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** A compact, human-readable rendering of recent activity, for a feedback attachment. */
101
+ export function formatActivity(entries) {
102
+ return entries
103
+ .map((e) => {
104
+ const when = e.ts || "?";
105
+ const cmd = [e.cmd, ...(e.args || [])].join(" ").trim();
106
+ const status = e.code === 0 ? "ok" : `exit ${e.code}`;
107
+ const ms = typeof e.ms === "number" ? ` ${e.ms}ms` : "";
108
+ const err = e.err ? ` — ${e.err}` : "";
109
+ return `${when} tot ${cmd} [${status}${ms}]${err}`;
110
+ })
111
+ .join("\n");
112
+ }
package/src/auth.mjs CHANGED
@@ -91,7 +91,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
91
91
  if (!creds || !creds.accessToken) {
92
92
  throw new AuthUnavailableError(
93
93
  "you're not signed in to Token of Trust.",
94
- { hint: "run `tot login` to sign in, then re-run." },
94
+ { hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run." },
95
95
  );
96
96
  }
97
97
 
@@ -20,6 +20,12 @@
20
20
  * (--docker, or automatically if the native artifact can't be
21
21
  * fetched) — see runContainer below.
22
22
  *
23
+ * --sample — the ZERO-LOGIN, NO-MCP free taste. Scaffolds a bundled sample store
24
+ * onto disk and runs it natively WITHOUT any authenticated
25
+ * dev_renderer_artifact call — the renderer is resolved offline via
26
+ * resolveRendererSource() (env / cache / in-tree monorepo). Compliance
27
+ * rendering (age-gate + nicotine warning) still shows. See sample.mjs.
28
+ *
23
29
  * IP note: the artifact is the pruned runner (control plane physically absent),
24
30
  * vended by a short-lived signed URL (`dev_renderer_artifact`) gated on the same
25
31
  * developer entitlement as the Docker pull token — no hand-provisioned AWS creds
@@ -29,6 +35,7 @@ import { spawn, spawnSync, execFileSync } from "node:child_process";
29
35
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, createWriteStream } from "node:fs";
30
36
  import { homedir, tmpdir } from "node:os";
31
37
  import { join, resolve } from "node:path";
38
+ import { createHash } from "node:crypto";
32
39
  import { Readable } from "node:stream";
33
40
  import { pipeline } from "node:stream/promises";
34
41
  import { setTimeout as delay } from "node:timers/promises";
@@ -36,6 +43,10 @@ import { createMcpClient } from "../mcp.mjs";
36
43
  import { resolveSession } from "../auth.mjs";
37
44
  import { CliError, fail, formatError } from "../errors.mjs";
38
45
  import { openBrowser, waitForServer } from "../open.mjs";
46
+ import {
47
+ scaffoldSample, isSampleCheckout, sampleConfig,
48
+ resolveRendererSource as resolveLocalRendererSource, SAMPLE_DIR_NAME,
49
+ } from "../sample.mjs";
39
50
 
40
51
  /** The published moat-free runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
41
52
  const DEFAULT_DEV_IMAGE =
@@ -43,11 +54,15 @@ const DEFAULT_DEV_IMAGE =
43
54
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
44
55
  /** Where downloaded+installed renderer-artifact versions are cached, one dir per version. */
45
56
  const RENDERER_CACHE_ROOT = join(homedir(), ".tot", "cache", "renderer");
57
+ /** The PUBLIC, un-entitled runner published to npm (sample / zero-login mode). */
58
+ const PUBLIC_RUNNER_PACKAGE = "@tokenoftrust/storefront-runner";
59
+ const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
46
60
 
47
61
  export function parseArgs(argv) {
48
62
  const a = {
49
63
  workspace: null, port: "4321", image: null, mcp: null,
50
- noLogin: false, noOpen: false, docker: false, help: false,
64
+ noLogin: false, noOpen: false, docker: false, sample: false, help: false,
65
+ rendererVersion: null,
51
66
  };
52
67
  for (let i = 0; i < argv.length; i++) {
53
68
  const t = argv[i];
@@ -58,6 +73,8 @@ export function parseArgs(argv) {
58
73
  else if (t === "--no-login") a.noLogin = true;
59
74
  else if (t === "--no-open") a.noOpen = true;
60
75
  else if (t === "--docker") a.docker = true;
76
+ else if (t === "--sample") a.sample = true;
77
+ else if (t === "--renderer-version") a.rendererVersion = argv[++i];
61
78
  else if (t === "--help" || t === "-h") a.help = true;
62
79
  }
63
80
  return a;
@@ -67,6 +84,8 @@ const USAGE = `tot dev — run your store locally with save→reload
67
84
 
68
85
  tot dev <tenant> (in the monorepo) run tenants/<tenant>/
69
86
  tot dev (in a checkout) run this store natively (no Docker)
87
+ tot dev --sample scaffold + run a FREE local sample store — no login,
88
+ no MCP, no account (the free first taste)
70
89
  tot dev --workspace <dir> run a specific checkout directory
71
90
  tot dev --port <n> host port (default 4321)
72
91
  tot dev --docker use the Docker runner image instead of the native path
@@ -74,8 +93,8 @@ const USAGE = `tot dev — run your store locally with save→reload
74
93
  tot dev --no-open don't auto-open the browser when the server is up
75
94
 
76
95
  Edit content/*.html or the theme + save → the browser reloads. Private local
77
- preview — nothing is published. Prerequisites: Node.js and an invite no Docker,
78
- no hand-provisioned AWS creds.`;
96
+ preview — nothing is published. Prerequisites: Node.js and an invite (or just
97
+ --sample, which needs neither) — no Docker, no hand-provisioned AWS creds.`;
79
98
 
80
99
  /** @param {string[]} argv @param {any} ctx */
81
100
  export function run(argv, ctx) {
@@ -85,6 +104,12 @@ export function run(argv, ctx) {
85
104
  return 0;
86
105
  }
87
106
 
107
+ // Zero-login free taste: scaffold + run a bundled sample store, no MCP. Wins
108
+ // over every context (works in the monorepo, a checkout, or a loose dir).
109
+ if (args.sample) {
110
+ return runSample(args, ctx);
111
+ }
112
+
88
113
  // Monorepo: delegate to the in-tree runner (host astro dev + HMR).
89
114
  if (ctx.mode === "monorepo" && !args.workspace) {
90
115
  return runMonorepo(ctx, argv);
@@ -101,7 +126,7 @@ export function run(argv, ctx) {
101
126
  console.error(
102
127
  fail(
103
128
  "nothing to run — you're not inside a tenant checkout",
104
- "tot checkout <tenant> --clone <dir> (then `cd` in and re-run), or pass --workspace <dir>",
129
+ "tot checkout <tenant> --clone <dir> (then `cd` in and re-run), pass --workspace <dir>, or try `tot dev --sample`",
105
130
  ),
106
131
  );
107
132
  return 2;
@@ -171,12 +196,22 @@ async function runNative(workspace, args, ctx) {
171
196
  });
172
197
  }
173
198
  const port = String(args.port || "4321");
174
- const { domain, url } = deriveUrl(cfg, port);
199
+ const { url } = deriveUrl(cfg, port);
175
200
 
176
201
  const runnerDir = await ensureRendererArtifact(args);
177
202
 
178
203
  printDevBanner({ tenant: cfg.tenant || null, url });
179
204
 
205
+ return bootNative(runnerDir, workspace, port, url, args);
206
+ }
207
+
208
+ /**
209
+ * Spawn the native runner against a workspace and (unless --no-open) auto-open
210
+ * the browser the moment the server answers. Returns the spawn handle's `done`
211
+ * promise. Shared by the authenticated native path (runNative) and the
212
+ * zero-login sample path (runSample) so both boot identically.
213
+ */
214
+ function bootNative(runnerDir, workspace, port, url, args) {
180
215
  const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit" });
181
216
 
182
217
  // Auto-open the browser the moment the server answers (D). Non-blocking so
@@ -196,6 +231,113 @@ async function runNative(workspace, args, ctx) {
196
231
  return handle.done;
197
232
  }
198
233
 
234
+ /**
235
+ * `tot dev --sample` — the ZERO-LOGIN, NO-MCP free taste. Scaffolds a bundled
236
+ * sample store (unless we're already standing in one), resolves a renderer
237
+ * OFFLINE (no dev_renderer_artifact call), and boots it natively. Compliance
238
+ * rendering still shows because the sample borrows a registered regulated
239
+ * tenant's identity (see sample.mjs).
240
+ */
241
+ async function runSample(args, ctx) {
242
+ let workspace;
243
+ try {
244
+ if (ctx.mode === "checkout" && isSampleCheckout(ctx.workspacePath)) {
245
+ workspace = ctx.workspacePath;
246
+ console.error(`~ reusing sample checkout: ${workspace}`);
247
+ } else {
248
+ const dest = args.workspace ? resolve(args.workspace) : resolve(process.cwd(), SAMPLE_DIR_NAME);
249
+ const r = scaffoldSample(dest, { log: (m) => console.error(m) });
250
+ workspace = r.dir;
251
+ }
252
+
253
+ const cfg = readWorkspaceConfig(workspace) || sampleConfig();
254
+ const port = String(args.port || "4321");
255
+ const { url } = deriveUrl(cfg, port);
256
+
257
+ const runnerDir = await ensureSampleRenderer(args, ctx);
258
+
259
+ printSampleBanner({ url });
260
+ return await bootNative(runnerDir, workspace, port, url, args);
261
+ } catch (e) {
262
+ console.error(formatError(e));
263
+ return e instanceof CliError ? (e.exitCode ?? 2) : 2;
264
+ }
265
+ }
266
+
267
+ /** The free-taste banner — honest about what this is, and what unlocks the real thing. */
268
+ export function printSampleBanner({ url }) {
269
+ console.error(`\n tot dev --sample — free local preview (sample vape store)`);
270
+ console.error(` ➜ Local: ${url}`);
271
+ console.error(` ➜ Edit: content/home.html or theme.json + save → the browser reloads`);
272
+ console.error(` ➜ FREE local preview — no login, no ToT account, nothing published. Ctrl-C to stop.`);
273
+ console.error(` Connect the ToT MCP for your REAL store, AI editing & compliance previews.\n`);
274
+ }
275
+
276
+ /**
277
+ * Resolve WHERE to get the moat-free runner tarball for this run — the seam that
278
+ * splits the entitled and the public (zero-login sample) delivery paths. Returns
279
+ * `{ kind, version, url, strip, cacheKey }`; callers download + extract + install +
280
+ * cache it uniformly (installRunnerTarball).
281
+ *
282
+ * • SAMPLE mode (args.sample) → the PUBLIC npm package @tokenoftrust/storefront-runner.
283
+ * NO dev_renderer_artifact MCP call, NO entitlement, NO session. The npm tarball
284
+ * wraps the tree under `package/`, so `strip: 1` yields the same tree the entitled
285
+ * artifact ships.
286
+ * • ENTITLED mode (default) → the MCP's `dev_renderer_artifact` signed URL, gated on
287
+ * the developer entitlement. `strip: 0` (the raw build-runner tarball).
288
+ *
289
+ * Exported so sample/tests can drive the selection directly.
290
+ * @param {ReturnType<typeof parseArgs>} args
291
+ * @param {{ client?: any }} [opts] an already-authenticated MCP client (entitled path)
292
+ * @returns {Promise<{kind:"public"|"entitled",version:string,url:string,strip:number,cacheKey:string}>}
293
+ */
294
+ export async function resolveRendererSource(args, { client } = {}) {
295
+ if (args.sample) return resolvePublicRendererSource(args);
296
+ return resolveEntitledRendererSource(args, { client });
297
+ }
298
+
299
+ /**
300
+ * PUBLIC, un-entitled source for sample / zero-login mode: the moat-free runner
301
+ * straight from the public npm registry — NO MCP call, NO entitlement. Reads the
302
+ * package's registry metadata (unauthenticated JSON) for the tarball URL + version.
303
+ * Pin with `--renderer-version` / TOT_RUNNER_VERSION, else the `latest` dist-tag.
304
+ * Package/registry overridable via env for testing.
305
+ */
306
+ export async function resolvePublicRendererSource(args, env = process.env) {
307
+ const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
308
+ const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
309
+ const pin = args.rendererVersion || env.TOT_RUNNER_VERSION || "latest";
310
+ const metaUrl = `${registry}/${pkg.replace("/", "%2f")}`;
311
+ const res = await fetch(metaUrl, { headers: { accept: "application/json" } });
312
+ if (!res.ok) {
313
+ throw new Error(`npm metadata for ${pkg} failed: HTTP ${res.status} ${res.statusText}`);
314
+ }
315
+ const meta = await res.json();
316
+ const version = meta?.["dist-tags"]?.[pin] || pin;
317
+ const tarball = meta?.versions?.[version]?.dist?.tarball;
318
+ if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
319
+ return { kind: "public", version, url: tarball, strip: 1, cacheKey: `public-${version}` };
320
+ }
321
+
322
+ /**
323
+ * ENTITLED source: the MCP's `dev_renderer_artifact` signed URL, gated on the
324
+ * developer entitlement (same gate as the Docker pull token). Reuses an
325
+ * already-authenticated `client` when provided (C1/F3), else establishes its own.
326
+ */
327
+ export async function resolveEntitledRendererSource(args, { client: providedClient } = {}) {
328
+ const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
329
+ const client = providedClient || createMcpClient(baseUrl);
330
+ if (!providedClient) {
331
+ await client.initialize();
332
+ await resolveSession(client, { env: process.env });
333
+ }
334
+ const res = await client.callTool("dev_renderer_artifact", {});
335
+ if (!res?.url || !res?.version) {
336
+ throw new Error(res?.error || "no renderer-artifact URL returned");
337
+ }
338
+ return { kind: "entitled", version: res.version, url: res.url, strip: 0, cacheKey: res.version };
339
+ }
340
+
199
341
  /**
200
342
  * Get the moat-free renderer artifact for this host — a signed URL from the MCP
201
343
  * (dev_renderer_artifact, same entitlement gate as the Docker pull token),
@@ -230,18 +372,111 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
230
372
  throw new NativeArtifactUnavailableError(String(e?.message || e));
231
373
  }
232
374
 
233
- const runnerDir = join(RENDERER_CACHE_ROOT, credential.version);
375
+ try {
376
+ return await installRunnerTarball(
377
+ { source: credential.url, version: credential.version, isUrl: true },
378
+ { log: (m) => console.error(m) },
379
+ );
380
+ } catch (e) {
381
+ throw new NativeArtifactUnavailableError(String(e?.message || e));
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Resolve the renderer for the ZERO-LOGIN sample path WITHOUT any MCP call — the
387
+ * whole point of the free taste. Uses resolveRendererSource() (env override →
388
+ * cached runner from a prior `tot dev` → in-tree monorepo), and only when none of
389
+ * those exist does it fail with the exact seam WS3b fills (a public tarball URL).
390
+ * Unlike ensureRendererArtifact it does NOT fall back to Docker — the sample is
391
+ * deliberately Docker-free and login-free.
392
+ * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
393
+ */
394
+ export async function ensureSampleRenderer(args, ctx, { env = process.env } = {}) {
395
+ const src = resolveLocalRendererSource({
396
+ env,
397
+ mode: ctx?.mode,
398
+ repoRoot: ctx?.repoRoot,
399
+ cacheRoot: RENDERER_CACHE_ROOT,
400
+ });
401
+
402
+ if (src.kind === "dir") {
403
+ if (!existsSync(join(src.dir, "scripts", "tot-dev.mjs"))) {
404
+ throw new CliError(`the renderer at ${src.dir} has no scripts/tot-dev.mjs (source: ${src.why})`, {
405
+ next: "point TOT_RUNNER_DIR at a built runner tree, or unset it to fetch a tarball",
406
+ exitCode: 2,
407
+ });
408
+ }
409
+ console.error(`~ renderer: ${src.why} (${src.dir})`);
410
+ return src.dir;
411
+ }
412
+
413
+ if (src.kind === "tarball") {
414
+ console.error(`~ renderer: ${src.why}`);
415
+ return installRunnerTarball(
416
+ { source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
417
+ { log: (m) => console.error(m) },
418
+ );
419
+ }
420
+
421
+ // kind === "none" — nothing local: fetch the PUBLIC runner from npm (the seam
422
+ // ws1b left for WS3b). No MCP, no entitlement, no login — the point of --sample.
423
+ let pub;
424
+ try {
425
+ pub = await resolvePublicRendererSource(args, env);
426
+ } catch (e) {
427
+ throw new CliError(
428
+ `can't run the sample — no local runner and the public runner is unavailable: ${e?.message || e}`,
429
+ {
430
+ next:
431
+ "check your network, set TOT_RUNNER_URL=<public renderer tarball>, or run `tot dev --sample` inside the storefront monorepo",
432
+ exitCode: 2,
433
+ },
434
+ );
435
+ }
436
+ console.error(`~ renderer: public npm ${pub.version} (${PUBLIC_RUNNER_PACKAGE})`);
437
+ return installRunnerTarball(
438
+ { source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
439
+ { log: (m) => console.error(m) },
440
+ );
441
+ }
442
+
443
+ /** A stable, filesystem-safe cache key for a renderer resolved from a tarball source. */
444
+ function sourceVersionKey(source) {
445
+ const base =
446
+ String(source).split(/[?#]/)[0].split("/").pop()?.replace(/\.(tar\.gz|tgz)$/i, "") || "runner";
447
+ const h = createHash("sha1").update(String(source)).digest("hex").slice(0, 8);
448
+ return `sample-${base}-${h}`.replace(/[^A-Za-z0-9._-]/g, "_");
449
+ }
450
+
451
+ /**
452
+ * Download (if a URL) or read (if a local path) a renderer tarball, extract it,
453
+ * `pnpm install` it, and cache the installed tree under
454
+ * ~/.tot/cache/renderer/<version>/ — atomically, so an interrupted run never
455
+ * leaves a half-built cache entry a later run treats as ready. Shared by the
456
+ * authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
457
+ * paths so they cache identically.
458
+ *
459
+ * @param {{ source: string, version: string, isUrl?: boolean }} spec
460
+ * @param {{ log?: (m: string) => void }} [opts]
461
+ * @returns {Promise<string>} the cached, installed runner tree's root directory.
462
+ */
463
+ export async function installRunnerTarball({ source, version, isUrl = true, strip = 0 }, { log = (m) => console.error(m) } = {}) {
464
+ const runnerDir = join(RENDERER_CACHE_ROOT, version);
234
465
  const marker = join(runnerDir, ".tot-cache-complete");
235
466
  if (existsSync(marker)) return runnerDir; // already downloaded + installed
236
467
 
237
- console.error(`~ fetching the native renderer (version ${credential.version}, first run only)...`);
238
- const archivePath = join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`);
468
+ log(`~ preparing the native renderer (version ${version}, first run only)...`);
469
+ const localSource = isUrl ? null : resolveLocalTarball(source);
470
+ const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
239
471
  try {
240
- await downloadFile(credential.url, archivePath);
472
+ if (isUrl) await downloadFile(source, archivePath);
473
+ if (!existsSync(archivePath)) {
474
+ throw new Error(`renderer tarball not found: ${archivePath}`);
475
+ }
241
476
  const stagingDir = `${runnerDir}.staging-${process.pid}`;
242
477
  rmSync(stagingDir, { recursive: true, force: true });
243
478
  mkdirSync(stagingDir, { recursive: true });
244
- extractTarball(archivePath, stagingDir);
479
+ extractTarball(archivePath, stagingDir, { strip });
245
480
  ensureCorepackPnpm(stagingDir);
246
481
  runPnpmInstall(stagingDir);
247
482
  // Atomic-ish: only rename into the final, discoverable path once install
@@ -250,14 +485,23 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
250
485
  rmSync(runnerDir, { recursive: true, force: true });
251
486
  renameSync(stagingDir, runnerDir);
252
487
  writeFileSync(marker, new Date().toISOString());
253
- } catch (e) {
254
- throw new NativeArtifactUnavailableError(String(e?.message || e));
255
488
  } finally {
256
- rmSync(archivePath, { force: true });
489
+ if (isUrl) rmSync(archivePath, { force: true });
257
490
  }
258
491
  return runnerDir;
259
492
  }
260
493
 
494
+ /** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
495
+ function resolveLocalTarball(source) {
496
+ const s = String(source).startsWith("file://") ? fileURLToPathSafe(source) : source;
497
+ return resolve(s);
498
+ }
499
+
500
+ /** Minimal file:// → path (avoids importing node:url just for this one call). */
501
+ function fileURLToPathSafe(u) {
502
+ return decodeURIComponent(String(u).replace(/^file:\/\//, "").replace(/^\/([A-Za-z]:)/, "$1"));
503
+ }
504
+
261
505
  /** Stream `url` to `destPath`. Throws on a non-2xx response or a network failure. */
262
506
  async function downloadFile(url, destPath) {
263
507
  const res = await fetch(url);
@@ -267,9 +511,17 @@ async function downloadFile(url, destPath) {
267
511
  await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
268
512
  }
269
513
 
270
- /** Extract a .tar.gz into `destDir` using the system `tar` (present on macOS/Linux/WSL). */
271
- function extractTarball(archivePath, destDir) {
272
- const r = spawnSync("tar", ["xzf", archivePath, "-C", destDir], { stdio: ["ignore", "ignore", "pipe"] });
514
+ /**
515
+ * Extract a .tar.gz into `destDir` using the system `tar` (present on
516
+ * macOS/Linux/WSL). `strip` drops leading path components the entitled artifact
517
+ * is a raw tree (strip 0), while an npm package tarball wraps everything under
518
+ * `package/` (strip 1), so both yield an identical tree root.
519
+ */
520
+ function extractTarball(archivePath, destDir, { strip = 0 } = {}) {
521
+ const stripArgs = strip > 0 ? [`--strip-components=${strip}`] : [];
522
+ const r = spawnSync("tar", ["xzf", archivePath, "-C", destDir, ...stripArgs], {
523
+ stdio: ["ignore", "ignore", "pipe"],
524
+ });
273
525
  if (r.status !== 0) {
274
526
  throw new Error(`tar extraction failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
275
527
  }
@@ -315,10 +567,15 @@ function runPnpmInstall(runnerDir) {
315
567
  */
316
568
  export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit" } = {}) {
317
569
  const script = join(runnerDir, "scripts", "tot-dev.mjs");
570
+ // Translate the same "inherit"|"piped" contract spawnDevContainer honors:
571
+ // "piped" means stdin ignored + stdout/stderr captured so `tot start` can hold
572
+ // the prompt on stdin and stream the logs after the aha. child_process.spawn
573
+ // doesn't understand the bare string "piped", so map it to the array here.
574
+ const stdioArr = stdio === "piped" ? ["ignore", "pipe", "pipe"] : stdio;
318
575
  const child = spawn(
319
576
  process.execPath,
320
577
  [script, "--workspace", workspace, "--port", port],
321
- { cwd: runnerDir, stdio },
578
+ { cwd: runnerDir, stdio: stdioArr },
322
579
  );
323
580
  const handle = { child, exited: false, done: null };
324
581
  handle.done = new Promise((resolvePromise) => {