@tokenoftrust/cli 1.0.1 → 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.
- package/bin/tot.mjs +57 -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/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
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
|
|
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
|
|
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
|
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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
|
|
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),
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
const
|
|
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(
|
|
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
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
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) => {
|