@tokenoftrust/cli 1.3.4-rc.3 → 1.3.4-rc.5

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 (41) hide show
  1. package/bin/tot.cjs +44 -16
  2. package/bin/tot.mjs +8 -0
  3. package/package.json +2 -1
  4. package/src/app-scaffold.mjs +84 -0
  5. package/src/banner.mjs +46 -0
  6. package/src/commands/app/dev.mjs +268 -0
  7. package/src/commands/app/index.mjs +35 -0
  8. package/src/commands/app/scaffold.mjs +57 -0
  9. package/src/commands/checkout.mjs +5 -0
  10. package/src/commands/dev.mjs +61 -14
  11. package/src/commands/start.mjs +76 -17
  12. package/src/commands/validate.mjs +4 -0
  13. package/src/dev-logs.mjs +34 -7
  14. package/src/obstacle-beacon.cjs +111 -0
  15. package/src/obstacle.mjs +35 -0
  16. package/src/validate.mjs +77 -8
  17. package/src/vendor/private-apps-devkit.mjs +490 -0
  18. package/template/private-app/.env.example +10 -0
  19. package/template/private-app/Dockerfile +12 -0
  20. package/template/private-app/README.md +45 -0
  21. package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
  22. package/template/private-app/server.js +81 -0
  23. package/template/private-app/tot-app.json +29 -0
  24. package/template/sample-store/content/chrome.html +140 -0
  25. package/template/sample-store/content/chrome.json +86 -0
  26. package/template/sample-store/content/home.html +121 -0
  27. package/template/sample-store/content/home.json +50 -0
  28. package/template/sample-store/content/pages/about.json +10 -0
  29. package/template/sample-store/content/pages/privacy.json +10 -0
  30. package/template/sample-store/content/pages/shipping-returns.json +10 -0
  31. package/template/sample-store/content/pages-html/blogs/news.html +26 -0
  32. package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
  33. package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
  34. package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
  35. package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
  36. package/template/sample-store/public/logo.svg +6 -0
  37. package/template/sample-store/public/pages/home.css +120 -0
  38. package/template/sample-store/public/pages/mkt.css +185 -0
  39. package/template/sample-store/public/pages/page.css +155 -0
  40. package/template/sample-store/public/themes/sample.css +76 -0
  41. package/template/sample-store/theme.json +38 -0
package/bin/tot.cjs CHANGED
@@ -40,21 +40,49 @@ if (!meets) {
40
40
  " → next: install Node " + RECOMMENDED_NODE + " (LTS) — nvm: `nvm install " + RECOMMENDED_NODE +
41
41
  " && nvm use " + RECOMMENDED_NODE + "`, or https://nodejs.org/ — then re-run the same command.\n"
42
42
  );
43
- process.exit(1);
44
- }
43
+ // Beacon the hosted cockpit that this machine dead-ended on the Node floor, then
44
+ // exit. This is THE headline obstacle: an invited dev whose `tot login` (the
45
+ // pasted setup command) can't even run because their Node is too old. We're
46
+ // pre-login, pre-ESM, maybe pre-`fetch` — so the beacon reads the bridge
47
+ // credential straight from the pasted flags and posts over require("https").
48
+ // Best-effort and time-boxed: the ✗ message is already printed; we only linger
49
+ // (≤ the beacon's own timeout) to deliver telemetry before exit(1).
50
+ beaconNodeTooOld(nodeVersion, function () { process.exit(1); });
51
+ } else {
52
+ // Supported Node from here on. Hand off to the ESM CLI; pathToFileURL keeps
53
+ // the import specifier correct on Windows drive-letter paths too.
54
+ var path = require("path");
55
+ var pathToFileURL = require("url").pathToFileURL;
56
+ var entry = pathToFileURL(path.join(__dirname, "tot.mjs")).href;
45
57
 
46
- // Supported Node from here on. Hand off to the ESM CLI; pathToFileURL keeps
47
- // the import specifier correct on Windows drive-letter paths too.
48
- var path = require("path");
49
- var pathToFileURL = require("url").pathToFileURL;
50
- var entry = pathToFileURL(path.join(__dirname, "tot.mjs")).href;
58
+ // new Function with a CONSTANT body ("return import(u)") nothing is ever
59
+ // interpolated into the code string; the entry URL travels as an argument. This
60
+ // indirection exists only so pre-import() parsers never see the import syntax.
61
+ new Function("u", "return import(u)")(entry).catch(function (e) {
62
+ // tot.mjs formats + exits on its own errors; landing here means the CLI
63
+ // itself failed to LOAD on a supported Node — a packaging bug, worth the detail.
64
+ process.stderr.write("✗ tot failed to start: " + ((e && e.message) || e) + "\n");
65
+ process.exit(1);
66
+ });
67
+ }
51
68
 
52
- // new Function with a CONSTANT body ("return import(u)") nothing is ever
53
- // interpolated into the code string; the entry URL travels as an argument. This
54
- // indirection exists only so pre-import() parsers never see the import syntax.
55
- new Function("u", "return import(u)")(entry).catch(function (e) {
56
- // tot.mjs formats + exits on its own errors; landing here means the CLI
57
- // itself failed to LOAD on a supported Node — a packaging bug, worth the detail.
58
- process.stderr.write("✗ tot failed to start: " + ((e && e.message) || e) + "\n");
59
- process.exit(1);
60
- });
69
+ // Fire the node-too-old obstacle beacon, then always call `done` (exactly once,
70
+ // bounded by the beacon's timeout). Isolated + fully guarded: any hiccup
71
+ // missing flags, an old Node that can't require a .cjs, a network stall — just
72
+ // falls through to `done`, so the exit path is never blocked or altered. The
73
+ // heavy lifting lives in the shared ES5 helper so the wire shape can't drift.
74
+ function beaconNodeTooOld(have, done) {
75
+ try {
76
+ var mod = require("../src/obstacle-beacon.cjs");
77
+ var act = mod.parseActivityArgs(process.argv);
78
+ if (!act.url || !act.token) { done(); return; }
79
+ var cliVersion;
80
+ try { cliVersion = require("../package.json").version; } catch (e) {}
81
+ mod.beacon(
82
+ { url: act.url, token: act.token, kind: "node-too-old", have: have, need: MIN_NODE, cliVersion: cliVersion },
83
+ done
84
+ );
85
+ } catch (e) {
86
+ done();
87
+ }
88
+ }
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.3",
3
+ "version": "1.3.4-rc.5",
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
+ }
@@ -24,6 +24,7 @@ import { createMcpClient } from "../mcp.mjs";
24
24
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
25
25
  import { CliError, fail, formatError } from "../errors.mjs";
26
26
  import { writeNvmrc } from "../sample.mjs";
27
+ import { emitObstacle } from "../obstacle.mjs";
27
28
 
28
29
  const execFileP = promisify(execFile);
29
30
 
@@ -202,6 +203,10 @@ async function cloneRepo(gitRemote, dir, redact) {
202
203
  try {
203
204
  await git(["clone", gitRemote, dir]);
204
205
  } catch (e) {
206
+ // Beacon the cockpit before we surface the error — covers this path for both
207
+ // `tot checkout` and `tot start` (which clones through here). Awaited so the
208
+ // packet lands before the process prints + exits; swallowed either way.
209
+ await emitObstacle("clone-failed");
205
210
  throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
206
211
  next: `check the target dir is empty and you can reach the remote, then re-run`,
207
212
  });