@tokenoftrust/cli 1.3.4-rc.4 → 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.
- package/bin/tot.mjs +8 -0
- package/package.json +2 -1
- package/src/app-scaffold.mjs +84 -0
- package/src/banner.mjs +46 -0
- package/src/commands/app/dev.mjs +268 -0
- package/src/commands/app/index.mjs +35 -0
- package/src/commands/app/scaffold.mjs +57 -0
- package/src/commands/dev.mjs +57 -13
- package/src/commands/start.mjs +76 -17
- package/src/commands/validate.mjs +4 -0
- package/src/dev-logs.mjs +34 -7
- package/src/validate.mjs +77 -8
- package/src/vendor/private-apps-devkit.mjs +490 -0
- package/template/private-app/.env.example +10 -0
- package/template/private-app/Dockerfile +12 -0
- package/template/private-app/README.md +45 -0
- package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
- package/template/private-app/server.js +81 -0
- package/template/private-app/tot-app.json +29 -0
- package/template/sample-store/content/chrome.html +140 -0
- package/template/sample-store/content/chrome.json +86 -0
- package/template/sample-store/content/home.html +121 -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 +10 -0
- package/template/sample-store/content/pages/shipping-returns.json +10 -0
- package/template/sample-store/content/pages-html/blogs/news.html +26 -0
- package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
- package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
- package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
- package/template/sample-store/public/logo.svg +6 -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/sample.css +76 -0
- 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.
|
|
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
|
+
}
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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
|
|
987
|
-
* this
|
|
988
|
-
*
|
|
989
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|