@tokenoftrust/cli 1.3.4-rc.4 → 1.3.4

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 (37) hide show
  1. package/bin/tot.mjs +8 -0
  2. package/package.json +2 -1
  3. package/src/app-scaffold.mjs +84 -0
  4. package/src/banner.mjs +46 -0
  5. package/src/commands/app/dev.mjs +268 -0
  6. package/src/commands/app/index.mjs +35 -0
  7. package/src/commands/app/scaffold.mjs +57 -0
  8. package/src/commands/dev.mjs +57 -13
  9. package/src/commands/start.mjs +76 -17
  10. package/src/commands/validate.mjs +4 -0
  11. package/src/dev-logs.mjs +34 -7
  12. package/src/validate.mjs +77 -8
  13. package/src/vendor/private-apps-devkit.mjs +490 -0
  14. package/template/private-app/.env.example +10 -0
  15. package/template/private-app/Dockerfile +12 -0
  16. package/template/private-app/README.md +45 -0
  17. package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
  18. package/template/private-app/server.js +81 -0
  19. package/template/private-app/tot-app.json +29 -0
  20. package/template/sample-store/content/chrome.html +140 -0
  21. package/template/sample-store/content/chrome.json +86 -0
  22. package/template/sample-store/content/home.html +121 -0
  23. package/template/sample-store/content/home.json +50 -0
  24. package/template/sample-store/content/pages/about.json +10 -0
  25. package/template/sample-store/content/pages/privacy.json +10 -0
  26. package/template/sample-store/content/pages/shipping-returns.json +10 -0
  27. package/template/sample-store/content/pages-html/blogs/news.html +26 -0
  28. package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
  29. package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
  30. package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
  31. package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
  32. package/template/sample-store/public/logo.svg +6 -0
  33. package/template/sample-store/public/pages/home.css +120 -0
  34. package/template/sample-store/public/pages/mkt.css +185 -0
  35. package/template/sample-store/public/pages/page.css +155 -0
  36. package/template/sample-store/public/themes/sample.css +76 -0
  37. package/template/sample-store/theme.json +38 -0
package/bin/tot.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  * tot doctor check this machine is ready
16
16
  * tot ideas copy-paste AI prompts that reliably wow
17
17
  * tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
18
+ * tot app scaffold/dev build + locally exercise a Private App ← built (offline; see src/commands/app/)
18
19
  * tot help this help
19
20
  *
20
21
  * Context-aware (see src/context.mjs): the same `tot` does the right thing from
@@ -58,6 +59,8 @@ tot — Token of Trust developer CLI
58
59
  tot doctor check this machine is ready
59
60
  tot ideas copy-paste AI prompts that reliably wow
60
61
  tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
62
+ tot app scaffold <name> scaffold a Storefront Private App
63
+ tot app dev ... local, offline Private App webhook/manifest/JWT harness
61
64
  tot help show this help
62
65
  tot --version print the CLI version
63
66
 
@@ -134,6 +137,11 @@ async function dispatch(cmd, rest, ctx) {
134
137
  return run(rest, ctx);
135
138
  }
136
139
 
140
+ if (cmd === "app") {
141
+ const { run } = await import("../src/commands/app/index.mjs");
142
+ return run(rest, ctx);
143
+ }
144
+
137
145
  if (BUILD_ORDER.includes(cmd)) {
138
146
  console.error(
139
147
  `\`tot ${cmd}\` isn't built yet.\n\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.4-rc.4",
3
+ "version": "1.3.4",
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,84 @@
1
+ /**
2
+ * Private App scaffolding — `tot app scaffold <name>` (PrivateApps epic D6
3
+ * Chunk C). Mirrors `sample.mjs`'s `scaffoldSample`: materialize a bundled
4
+ * template onto disk, idempotently, fully offline. No login, no MCP, no
5
+ * network — a developer building a Storefront Private App gets a runnable
6
+ * skeleton before they've registered anything with Token of Trust.
7
+ *
8
+ * The scaffolded tree also gets its own copy of `src/vendor/private-apps-devkit.mjs`
9
+ * (as `lib/private-apps-devkit.mjs`) so the stub `server.js` can verify webhook
10
+ * signatures with zero runtime dependencies — see that file's module doc for
11
+ * why this is a vendored copy rather than a package.json dependency.
12
+ */
13
+ import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { dirname, join, resolve } from "node:path";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
18
+
19
+ /** Absolute path to the bundled private-app template (ships via package.json `files`). */
20
+ export function appTemplateDir() {
21
+ return resolve(here, "..", "template", "private-app");
22
+ }
23
+
24
+ /** Absolute path to the vendored devkit copied into every scaffolded app. */
25
+ export function vendoredDevkitPath() {
26
+ return resolve(here, "vendor", "private-apps-devkit.mjs");
27
+ }
28
+
29
+ /** A directory carries a scaffolded app if it has a `tot-app.json` manifest. */
30
+ export function isAppScaffold(dir) {
31
+ return existsSync(join(dir, "tot-app.json"));
32
+ }
33
+
34
+ /** A directory is "empty enough" to scaffold into if it's absent or has no entries. */
35
+ function isEmptyDir(dir) {
36
+ if (!existsSync(dir)) return true;
37
+ try {
38
+ return readdirSync(dir).length === 0;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Materialize a Private App skeleton at `destDir`: copy the bundled template
46
+ * (`tot-app.json`, `fixtures/`, `README.md`, `Dockerfile`, `.env.example`,
47
+ * `server.js`) in, then drop this package's vendored devkit at `lib/`.
48
+ * Idempotent — re-running against an already-scaffolded directory is a no-op
49
+ * that returns it. Refuses to clobber a non-scaffold, non-empty directory.
50
+ *
51
+ * @param {string} destDir
52
+ * @param {{ force?: boolean, log?: (m: string) => void }} [opts]
53
+ * @returns {{ dir: string, created: boolean }}
54
+ */
55
+ export function scaffoldApp(destDir, { force = false, log = () => {} } = {}) {
56
+ const dir = resolve(destDir);
57
+
58
+ if (isAppScaffold(dir) && !force) {
59
+ log(` ✓ reusing existing app scaffold ${dir}`);
60
+ return { dir, created: false };
61
+ }
62
+ if (!force && !isEmptyDir(dir)) {
63
+ const err = new Error(
64
+ `${dir} isn't empty and isn't an app scaffold — scaffold into an empty directory (or pass a new name)`,
65
+ );
66
+ err.code = "ENOTEMPTY_APP";
67
+ throw err;
68
+ }
69
+
70
+ const template = appTemplateDir();
71
+ if (!existsSync(template)) {
72
+ const err = new Error("the private-app template isn't available in this release yet — it's coming soon.");
73
+ err.code = "TEMPLATE_UNAVAILABLE";
74
+ throw err;
75
+ }
76
+
77
+ mkdirSync(dir, { recursive: true });
78
+ cpSync(template, dir, { recursive: true });
79
+ mkdirSync(join(dir, "lib"), { recursive: true });
80
+ cpSync(vendoredDevkitPath(), join(dir, "lib", "private-apps-devkit.mjs"));
81
+
82
+ log(` ✓ scaffolded a private app → ${dir}`);
83
+ return { dir, created: true };
84
+ }
package/src/banner.mjs ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared, dependency-free milestone banners — the emphasized, rule-delimited
3
+ * blocks for the first-run "look back at your Developer Cockpit" moments:
4
+ * "You're live" (start.mjs) and the first save→reload beat (dev-logs.mjs).
5
+ *
6
+ * Deliberately NO figlet/ASCII-art dependency — a horizontal rule frames the
7
+ * block so the moment reads as a distinct milestone (not another log line),
8
+ * while staying consistent with printSampleBanner/printDevBanner's indented,
9
+ * business voice — just louder. Node built-ins only (URL).
10
+ */
11
+
12
+ /** The product name every terminal milestone points the developer back to. */
13
+ export const DEVELOPER_COCKPIT = "Developer Cockpit";
14
+
15
+ const RULE = "━".repeat(56);
16
+
17
+ /**
18
+ * Render an emphasized milestone banner as a single string (leading + trailing
19
+ * blank line included). Each line is indented two spaces and framed by a heavy
20
+ * horizontal rule top and bottom — no side borders, so emoji/Unicode content
21
+ * never mis-aligns a right edge. Pure + exported so it's unit-tested.
22
+ * @param {string[]} lines content lines (already free of the two-space indent)
23
+ * @returns {string}
24
+ */
25
+ export function milestoneBanner(lines) {
26
+ const body = (lines || []).map((l) => ` ${l}`).join("\n");
27
+ return `\n ${RULE}\n${body}\n ${RULE}\n`;
28
+ }
29
+
30
+ /**
31
+ * Derive the hosted Developer Cockpit URL (`<origin>/cockpit`) from the cached
32
+ * activity-bridge URL (creds.activityUrl / TOT_DEV_ACTIVITY_URL). Returns null
33
+ * when there's no bridge URL (a bare `tot login`, an older session, or the
34
+ * zero-login `--sample` path) so callers cleanly omit the cockpit line.
35
+ * Pure + exported so it's unit-tested.
36
+ * @param {string|null|undefined} activityUrl
37
+ * @returns {string|null}
38
+ */
39
+ export function cockpitUrlFrom(activityUrl) {
40
+ if (!activityUrl) return null;
41
+ try {
42
+ return new URL("/cockpit", String(activityUrl)).toString();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * `tot app dev` — the local Private App harness (PrivateApps epic D6 Chunk D).
3
+ * Everything here is OFFLINE: no network beyond the localhost URL you point
4
+ * it at, no MCP, no real ToT credentials. It generates and reuses its own
5
+ * throwaway RS256 keypair per app directory (`.tot/dev-keys.json`) so signing
6
+ * and verifying webhook deliveries — and minting widget-launch JWTs — works
7
+ * before you've registered anything with Token of Trust.
8
+ *
9
+ * tot app dev validate [<manifest>] lint tot-app.json against the contract
10
+ * tot app dev emit <topic> sign a fixture CloudEvent and POST it
11
+ * tot app dev verify --url ... --body ... verify a captured webhook delivery
12
+ * tot app dev mint mint a dev widget-launch JWT
13
+ *
14
+ * Uses this package's vendored copy of `@tokenoftrust/private-apps-devkit`
15
+ * (../../vendor/private-apps-devkit.mjs — see that file for why it's vendored
16
+ * rather than a dependency) for every crypto operation, so a scaffolded app's
17
+ * own `server.js` (which imports the SAME functions from its copy at
18
+ * `lib/private-apps-devkit.mjs`) verifies exactly what this harness signs.
19
+ */
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join, resolve } from "node:path";
22
+ import { fail } from "../../errors.mjs";
23
+ import {
24
+ signWebhookRequest,
25
+ verifyWebhookSignature,
26
+ mintJwt,
27
+ generateDevRs256KeyPair,
28
+ validateManifest,
29
+ } from "../../vendor/private-apps-devkit.mjs";
30
+
31
+ const USAGE = `tot app dev — run your Private App's webhook loop locally, offline
32
+
33
+ tot app dev validate [<manifest>] lint tot-app.json (default ./tot-app.json)
34
+ tot app dev emit <topic> [--url <url>] sign a fixtures/<topic>.cloudevent.json and POST it
35
+ [--fixture <path>] [--app <dir>]
36
+ tot app dev verify --url <url> --body <file> verify a captured webhook delivery
37
+ --content-digest <v> --signature-input <v> --signature <v> [--key <jwk-file>]
38
+ tot app dev mint [--claims <json>] [--exp <secs>] [--app <dir>] mint a dev widget-launch JWT
39
+
40
+ All subcommands accept --app <dir> (default: cwd) to point at a scaffolded app.`;
41
+
42
+ export function makeRequestId() {
43
+ return `req_${randHex(6)}`;
44
+ }
45
+ export function makeTraceparent() {
46
+ return `00-${randHex(16)}-${randHex(8)}-01`;
47
+ }
48
+ function randHex(bytes) {
49
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("hex");
50
+ }
51
+
52
+ /** Load or create the app's throwaway RS256 keypair at `<appDir>/.tot/dev-keys.json`. */
53
+ export async function ensureDevKeys(appDir, { kid } = {}) {
54
+ const keysPath = join(appDir, ".tot", "dev-keys.json");
55
+ const alg = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
56
+
57
+ if (existsSync(keysPath)) {
58
+ const stored = JSON.parse(readFileSync(keysPath, "utf8"));
59
+ const privateKey = await crypto.subtle.importKey("jwk", stored.privateKeyJwk, alg, true, ["sign"]);
60
+ const publicKey = await crypto.subtle.importKey("jwk", stored.publicKeyJwk, alg, true, ["verify"]);
61
+ return { ...stored, privateKey, publicKey, keysPath };
62
+ }
63
+
64
+ const { publicKey, privateKey } = await generateDevRs256KeyPair();
65
+ const publicKeyJwk = await crypto.subtle.exportKey("jwk", publicKey);
66
+ const privateKeyJwk = await crypto.subtle.exportKey("jwk", privateKey);
67
+ const record = { kid: kid || "dev-key-1", publicKeyJwk, privateKeyJwk };
68
+ mkdirSync(dirname(keysPath), { recursive: true });
69
+ writeFileSync(keysPath, JSON.stringify(record, null, 2) + "\n");
70
+ return { ...record, privateKey, publicKey, keysPath };
71
+ }
72
+
73
+ /** The manifest's declared signatureKeyId, if any — used as the kid for freshly-generated keys. */
74
+ export function manifestKeyId(appDir) {
75
+ try {
76
+ const manifest = JSON.parse(readFileSync(join(appDir, "tot-app.json"), "utf8"));
77
+ return manifest?.webhooks?.signatureKeyId || null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function readInput(pathOrDash) {
84
+ if (pathOrDash === "-") {
85
+ return readFileSync(0, "utf8"); // stdin
86
+ }
87
+ return readFileSync(resolve(pathOrDash), "utf8");
88
+ }
89
+
90
+ // ── validate ─────────────────────────────────────────────────────────────
91
+
92
+ function runValidate(argv, { appDir }) {
93
+ const manifestPath = resolve(argv[0] || join(appDir, "tot-app.json"));
94
+ if (!existsSync(manifestPath)) {
95
+ console.error(fail(`no manifest at ${manifestPath}`, "tot app scaffold <name>, or pass a path"));
96
+ return 2;
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
101
+ } catch (e) {
102
+ console.error(fail(`${manifestPath} is not valid JSON: ${e.message}`));
103
+ return 1;
104
+ }
105
+ const result = validateManifest(parsed);
106
+ if (result.ok) {
107
+ console.log(`✔ ${manifestPath} is a valid tot-app.json (contract v${result.manifest.contractVersion})`);
108
+ return 0;
109
+ }
110
+ console.log(`✖ ${manifestPath} — ${result.errors.length} error(s):`);
111
+ for (const e of result.errors) console.log(` - ${e}`);
112
+ return 1;
113
+ }
114
+
115
+ // ── emit ─────────────────────────────────────────────────────────────────
116
+
117
+ export function parseEmitArgs(argv) {
118
+ const a = { topic: null, url: "http://localhost:8787/webhooks", fixture: null };
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const t = argv[i];
121
+ if (t === "--url") a.url = argv[++i];
122
+ else if (t === "--fixture") a.fixture = argv[++i];
123
+ else if (!t.startsWith("--") && !a.topic) a.topic = t;
124
+ }
125
+ return a;
126
+ }
127
+
128
+ async function runEmit(argv, { appDir }) {
129
+ const args = parseEmitArgs(argv);
130
+ if (!args.topic) {
131
+ console.error(fail("tot app dev emit needs a topic", "tot app dev emit order.created"));
132
+ return 2;
133
+ }
134
+ const fixturePath = resolve(args.fixture || join(appDir, "fixtures", `${args.topic}.cloudevent.json`));
135
+ if (!existsSync(fixturePath)) {
136
+ console.error(fail(`no fixture at ${fixturePath}`, "pass --fixture <path>, or add one under fixtures/"));
137
+ return 2;
138
+ }
139
+ const body = readFileSync(fixturePath, "utf8");
140
+
141
+ const keys = await ensureDevKeys(appDir, { kid: manifestKeyId(appDir) });
142
+ const { headers } = await signWebhookRequest({ method: "POST", url: args.url, body, key: keys.privateKey, kid: keys.kid });
143
+
144
+ const requestId = makeRequestId();
145
+ const traceparent = makeTraceparent();
146
+ console.log(`→ POST ${args.url} (topic: ${args.topic}, keyid: ${keys.kid})`);
147
+ console.log(` tot-request-id: ${requestId}`);
148
+ console.log(` traceparent: ${traceparent}`);
149
+
150
+ let response;
151
+ try {
152
+ response = await fetch(args.url, {
153
+ method: "POST",
154
+ headers: { ...headers, "content-type": "application/json", "tot-request-id": requestId, traceparent },
155
+ body,
156
+ });
157
+ } catch (e) {
158
+ console.error(fail(`could not reach ${args.url}: ${e.message}`, "start your receiver first (e.g. `node server.js`)"));
159
+ return 1;
160
+ }
161
+ const text = await response.text();
162
+ console.log(`← ${response.status} ${text}`);
163
+ return response.ok ? 0 : 1;
164
+ }
165
+
166
+ // ── verify ───────────────────────────────────────────────────────────────
167
+
168
+ export function parseVerifyArgs(argv) {
169
+ const a = { method: "POST", url: null, body: null, contentDigest: null, signatureInput: null, signature: null, key: null };
170
+ for (let i = 0; i < argv.length; i++) {
171
+ const t = argv[i];
172
+ if (t === "--method") a.method = argv[++i];
173
+ else if (t === "--url") a.url = argv[++i];
174
+ else if (t === "--body") a.body = argv[++i];
175
+ else if (t === "--content-digest") a.contentDigest = argv[++i];
176
+ else if (t === "--signature-input") a.signatureInput = argv[++i];
177
+ else if (t === "--signature") a.signature = argv[++i];
178
+ else if (t === "--key") a.key = argv[++i];
179
+ }
180
+ return a;
181
+ }
182
+
183
+ async function runVerify(argv, { appDir }) {
184
+ const args = parseVerifyArgs(argv);
185
+ if (!args.url || !args.body || !args.contentDigest || !args.signatureInput || !args.signature) {
186
+ console.error(
187
+ fail(
188
+ "tot app dev verify needs --url --body --content-digest --signature-input --signature",
189
+ "tot app dev verify --help",
190
+ ),
191
+ );
192
+ return 2;
193
+ }
194
+ const body = readInput(args.body);
195
+ const keyJwk = args.key
196
+ ? JSON.parse(readInput(args.key)).publicKeyJwk ?? JSON.parse(readInput(args.key))
197
+ : JSON.parse(readFileSync(join(appDir, ".tot", "dev-keys.json"), "utf8")).publicKeyJwk;
198
+ const publicKey = await crypto.subtle.importKey("jwk", keyJwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, [
199
+ "verify",
200
+ ]);
201
+
202
+ const result = await verifyWebhookSignature({
203
+ method: args.method,
204
+ url: args.url,
205
+ body,
206
+ headers: { "content-digest": args.contentDigest, "signature-input": args.signatureInput, signature: args.signature },
207
+ publicKey,
208
+ });
209
+
210
+ if (result.ok) {
211
+ console.log(`✔ signature verified (keyid: ${result.keyid}, created: ${result.created})`);
212
+ return 0;
213
+ }
214
+ console.log(`✖ signature verification failed: ${result.code} — ${result.reason}`);
215
+ return 1;
216
+ }
217
+
218
+ // ── mint ─────────────────────────────────────────────────────────────────
219
+
220
+ export function parseMintArgs(argv) {
221
+ const a = { claims: null, exp: 300 };
222
+ for (let i = 0; i < argv.length; i++) {
223
+ const t = argv[i];
224
+ if (t === "--claims") a.claims = argv[++i];
225
+ else if (t === "--exp") a.exp = Number(argv[++i]);
226
+ }
227
+ return a;
228
+ }
229
+
230
+ async function runMint(argv, { appDir }) {
231
+ const args = parseMintArgs(argv);
232
+ const claims = args.claims ? JSON.parse(args.claims) : { scope: "widgets:launch", tenant: "example", env: "dev" };
233
+ const keys = await ensureDevKeys(appDir, { kid: manifestKeyId(appDir) });
234
+ const expiresAt = Math.floor(Date.now() / 1000) + args.exp;
235
+ const token = await mintJwt({ claims, privateKey: keys.privateKey, kid: keys.kid, expiresAt });
236
+
237
+ console.log(token);
238
+ console.log(`\n(dev-only — signed with the throwaway key at ${keys.keysPath}, kid: ${keys.kid}, expires in ${args.exp}s)`);
239
+ return 0;
240
+ }
241
+
242
+ // ── dispatch ─────────────────────────────────────────────────────────────
243
+
244
+ export function parseTopArgs(argv) {
245
+ const appIdx = argv.indexOf("--app");
246
+ const appDir = appIdx >= 0 ? resolve(argv[appIdx + 1]) : resolve(process.cwd());
247
+ const rest = appIdx >= 0 ? [...argv.slice(0, appIdx), ...argv.slice(appIdx + 2)] : argv;
248
+ return { appDir, rest };
249
+ }
250
+
251
+ /** @param {string[]} argv */
252
+ export async function run(argv) {
253
+ const [sub, ...subArgv] = argv;
254
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
255
+ console.log(USAGE);
256
+ return sub ? 0 : 2;
257
+ }
258
+
259
+ const { appDir, rest } = parseTopArgs(subArgv);
260
+
261
+ if (sub === "validate") return runValidate(rest, { appDir });
262
+ if (sub === "emit") return runEmit(rest, { appDir });
263
+ if (sub === "verify") return runVerify(rest, { appDir });
264
+ if (sub === "mint") return runMint(rest, { appDir });
265
+
266
+ console.error(fail(`unknown \`tot app dev\` subcommand: ${sub}`, "tot app dev --help"));
267
+ return 2;
268
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `tot app` — the Storefront Private App developer subcommand group
3
+ * (PrivateApps epic D6 Chunks C+D): scaffold a new app, then exercise its
4
+ * webhook/manifest/widget-launch loop entirely offline.
5
+ *
6
+ * tot app scaffold <name> materialize a runnable Private App skeleton
7
+ * tot app dev ... local, offline webhook/manifest/JWT harness
8
+ */
9
+ import { fail } from "../../errors.mjs";
10
+
11
+ const USAGE = `tot app — build a Storefront Private App
12
+
13
+ tot app scaffold <name> scaffold a runnable Private App skeleton
14
+ tot app dev ... local, offline webhook/manifest/JWT harness (see \`tot app dev --help\`)`;
15
+
16
+ /** @param {string[]} argv @param {any} ctx */
17
+ export async function run(argv, ctx) {
18
+ const [sub, ...rest] = argv;
19
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
20
+ console.log(USAGE);
21
+ return sub ? 0 : 2;
22
+ }
23
+
24
+ if (sub === "scaffold") {
25
+ const { run: runScaffold } = await import("./scaffold.mjs");
26
+ return runScaffold(rest, ctx);
27
+ }
28
+ if (sub === "dev") {
29
+ const { run: runDev } = await import("./dev.mjs");
30
+ return runDev(rest, ctx);
31
+ }
32
+
33
+ console.error(fail(`unknown \`tot app\` subcommand: ${sub}`, "tot app --help"));
34
+ return 2;
35
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `tot app scaffold <name>` — materialize a runnable Storefront Private App
3
+ * skeleton on disk (PrivateApps epic D6 Chunk C). See ../../app-scaffold.mjs
4
+ * for the offline, idempotent copy logic this wraps.
5
+ *
6
+ * tot app scaffold <name> scaffold ./<name>
7
+ * tot app scaffold <name> --force overwrite a non-empty, non-scaffold dir
8
+ *
9
+ * Exit 0 = scaffolded (or already scaffolded); 2 = usage / refused.
10
+ */
11
+ import { resolve } from "node:path";
12
+ import { scaffoldApp } from "../../app-scaffold.mjs";
13
+ import { fail } from "../../errors.mjs";
14
+
15
+ function parseArgs(argv) {
16
+ const a = { name: null, force: false, help: false };
17
+ for (const t of argv) {
18
+ if (t === "--force") a.force = true;
19
+ else if (t === "--help" || t === "-h") a.help = true;
20
+ else if (!t.startsWith("--") && !a.name) a.name = t;
21
+ }
22
+ return a;
23
+ }
24
+
25
+ const USAGE = `tot app scaffold <name> — scaffold a Storefront Private App
26
+
27
+ tot app scaffold <name> scaffold ./<name>
28
+ tot app scaffold <name> --force overwrite a non-empty, non-scaffold directory`;
29
+
30
+ /** @param {string[]} argv */
31
+ export function run(argv) {
32
+ const args = parseArgs(argv);
33
+ if (args.help) {
34
+ console.log(USAGE);
35
+ return 0;
36
+ }
37
+ if (!args.name) {
38
+ console.error(fail("tot app scaffold needs a directory name", "tot app scaffold <name>"));
39
+ return 2;
40
+ }
41
+
42
+ try {
43
+ const { dir, created } = scaffoldApp(resolve(process.cwd(), args.name), {
44
+ force: args.force,
45
+ log: (m) => console.log(m),
46
+ });
47
+ console.log(
48
+ created
49
+ ? `\nNext:\n cd ${args.name}\n tot app dev validate\n tot app dev --help\n`
50
+ : `\n${dir} is already scaffolded.\n`,
51
+ );
52
+ return 0;
53
+ } catch (e) {
54
+ console.error(fail(e.message));
55
+ return 2;
56
+ }
57
+ }
@@ -52,6 +52,7 @@ import {
52
52
  } from "../sample.mjs";
53
53
  import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
54
54
  import { streamDevLogs } from "../dev-logs.mjs";
55
+ import { cockpitUrlFrom } from "../banner.mjs";
55
56
 
56
57
  /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
57
58
  const DEFAULT_DEV_IMAGE =
@@ -252,7 +253,7 @@ function runMonorepo(ctx, argv) {
252
253
  // still tears the runner down — the child stays in our process group and
253
254
  // owns the TTY signal.
254
255
  const child = spawn(process.execPath, [script, ...argv], { stdio: ["ignore", "pipe", "pipe"], env });
255
- streamDevLogs(child);
256
+ streamDevLogs(child, { cockpitUrl: cockpitUrlFrom(env.TOT_DEV_ACTIVITY_URL) });
256
257
  child.on("exit", (code) => resolvePromise(code ?? 0));
257
258
  child.on("error", (e) => {
258
259
  console.error(`✗ could not start the dev runner: ${e.message}`);
@@ -323,7 +324,7 @@ export function bootNative(runnerDir, workspace, port, url, args) {
323
324
  // dropped, real errors pass through. Ctrl-C still tears the server down — the
324
325
  // child stays in our process group and owns the TTY signals.
325
326
  const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "piped", env: bridgeEnv });
326
- streamDevLogs(handle.child);
327
+ streamDevLogs(handle.child, { cockpitUrl: cockpitUrlFrom(bridgeEnv.TOT_DEV_ACTIVITY_URL) });
327
328
 
328
329
  // Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
329
330
  // URL while the runner runs — CLI-side, using the SAME bridge credential the
@@ -983,12 +984,15 @@ function extractTarball(archivePath, destDir, { strip = 0 } = {}) {
983
984
 
984
985
  /**
985
986
  * Activate the exact package manager the runner's package.json pins
986
- * (packageManager: "pnpm@x.y.z"), via corepack — Node 20+ ships corepack, so
987
- * this needs no separate pnpm install on the host. Best-effort: if corepack
988
- * itself is missing (very old Node), pnpm install below will surface that
989
- * clearly instead.
987
+ * (packageManager: "pnpm@x.y.z"), via corepack — Node 22.12+ (the CLI's floor)
988
+ * always bundles corepack, so this PINS a real pnpm without any global `npm i -g
989
+ * pnpm` (which floats whatever's latest). Best-effort: if corepack itself is
990
+ * missing or disabled (a locked-down host), pnpm install below falls through to
991
+ * the pnpm-missing obstacle instead of failing here.
992
+ * @param {string} runnerDir
993
+ * @param {{ logPath?: string, spawnFn?: typeof spawnSync }} [opts]
990
994
  */
991
- function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
995
+ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } = {}) {
992
996
  const pkgPath = join(runnerDir, "package.json");
993
997
  if (!existsSync(pkgPath)) return;
994
998
  let pm;
@@ -1006,7 +1010,7 @@ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
1006
1010
  // WITHOUT the global shim (see runPnpmInstall's fallback). Capture both to the
1007
1011
  // log — a silent corepack failure was why the install error carried no cause.
1008
1012
  for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
1009
- const r = spawnSync("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
1013
+ const r = spawnFn("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
1010
1014
  if (fd !== null && (r.error || r.status !== 0)) {
1011
1015
  writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
1012
1016
  }
@@ -1022,8 +1026,37 @@ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
1022
1026
  * churn) is captured to a log file instead of flooding the terminal, so the
1023
1027
  * caller's spinner owns the screen. On failure we surface a clean, business-
1024
1028
  * readable message + the log path — never the raw node/pnpm firehose.
1029
+ * @param {string} runnerDir
1030
+ * @param {{ logPath?: string, spawnFn?: typeof spawnSync }} [opts]
1025
1031
  */
1026
- async function runPnpmInstall(runnerDir, { logPath } = {}) {
1032
+ /**
1033
+ * Run a child process to completion WITHOUT blocking the event loop, resolving
1034
+ * the SAME `{ status, error }` shape spawnSync returns so runPnpmInstall's
1035
+ * launcher-fallback logic reads it unchanged. This non-blocking spawn is what
1036
+ * lets the "installing the store preview engine…" spinner keep ticking during
1037
+ * the long npm install (spawnSync would freeze it at "(0s)"). An ENOENT (launcher
1038
+ * not on PATH) surfaces via the async 'error' event as `{ status: null, error }`,
1039
+ * matching what spawnSync produced for the same case.
1040
+ * @param {string} cmd @param {string[]} args @param {object} opts
1041
+ * @returns {Promise<{ status: number|null, error: Error|null, signal?: string|null }>}
1042
+ */
1043
+ function spawnAsyncResult(cmd, args, opts = {}) {
1044
+ return new Promise((resolvePromise) => {
1045
+ let settled = false;
1046
+ const settle = (v) => { if (!settled) { settled = true; resolvePromise(v); } };
1047
+ let child;
1048
+ try {
1049
+ child = spawn(cmd, args, opts);
1050
+ } catch (error) {
1051
+ settle({ status: null, error });
1052
+ return;
1053
+ }
1054
+ child.on("error", (error) => settle({ status: null, error }));
1055
+ child.on("close", (status, signal) => settle({ status, error: null, signal }));
1056
+ });
1057
+ }
1058
+
1059
+ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
1027
1060
  const fd = logPath ? openSync(logPath, "a") : null;
1028
1061
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
1029
1062
  // npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
@@ -1044,7 +1077,13 @@ async function runPnpmInstall(runnerDir, { logPath } = {}) {
1044
1077
  ];
1045
1078
  try {
1046
1079
  for (const { cmd, args } of attempts) {
1047
- const r = spawnSync(cmd, args, {
1080
+ // Awaited: the default spawnFn (spawnAsyncResult) runs the launcher
1081
+ // NON-BLOCKING so the caller's "installing…" spinner keeps ticking during
1082
+ // the (long, one-time) npm install instead of freezing at "(0s)" — a
1083
+ // synchronous spawnSync would hold the event loop and starve the interval.
1084
+ // Tests inject a synchronous stub returning { status, error }; awaiting a
1085
+ // plain (non-thenable) value is a no-op, so that contract is unchanged.
1086
+ const r = await spawnFn(cmd, args, {
1048
1087
  cwd: runnerDir,
1049
1088
  // Send both streams to the log fd (or swallow them) — never inherit.
1050
1089
  stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
@@ -1110,11 +1149,16 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
1110
1149
  // doesn't understand the bare string "piped", so map it to the array here.
1111
1150
  const stdioArr = stdio === "piped" ? ["ignore", "pipe", "pipe"] : stdio;
1112
1151
  // `env` (e.g. activityBridgeEnv()) merges OVER process.env — {} is a pure
1113
- // passthrough, identical to the old no-env-key behavior.
1152
+ // passthrough, identical to the old no-env-key behavior. Append `--no-warnings`
1153
+ // to NODE_OPTIONS so the runner's Node process never emits the scary
1154
+ // `(node:NNNN) ExperimentalWarning: …` boot noise to the developer (b4);
1155
+ // appended (not replaced) so any host-set NODE_OPTIONS is preserved.
1156
+ const mergedEnv = { ...process.env, ...env };
1157
+ mergedEnv.NODE_OPTIONS = `${mergedEnv.NODE_OPTIONS ? `${mergedEnv.NODE_OPTIONS} ` : ""}--no-warnings`;
1114
1158
  const child = spawn(
1115
1159
  process.execPath,
1116
1160
  [script, "--workspace", workspace, "--port", port],
1117
- { cwd: runnerDir, stdio: stdioArr, env: { ...process.env, ...env } },
1161
+ { cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
1118
1162
  );
1119
1163
  const handle = { child, exited: false, done: null };
1120
1164
  handle.done = new Promise((resolvePromise) => {
@@ -1148,7 +1192,7 @@ async function runContainer(workspace, args, ctx) {
1148
1192
  // piping is safe; Ctrl-C still stops the container (docker stays in our
1149
1193
  // process group and forwards the signal, with `--init` reaping it inside).
1150
1194
  const handle = await spawnDevContainer(plan, args, { stdio: "piped" });
1151
- streamDevLogs(handle.child);
1195
+ streamDevLogs(handle.child, { cockpitUrl: cockpitUrlFrom(activityBridgeEnv().TOT_DEV_ACTIVITY_URL) });
1152
1196
 
1153
1197
  // Heartbeat the hosted cockpit (G1) CLI-side while the container runs — the
1154
1198
  // container reports file-saves via the threaded env, but the CLI owns the