@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.
- package/bin/tot.cjs +44 -16
- 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/checkout.mjs +5 -0
- package/src/commands/dev.mjs +61 -14
- package/src/commands/start.mjs +76 -17
- package/src/commands/validate.mjs +4 -0
- package/src/dev-logs.mjs +34 -7
- package/src/obstacle-beacon.cjs +111 -0
- package/src/obstacle.mjs +35 -0
- 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.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
|
-
|
|
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
|
-
//
|
|
47
|
-
// the
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
"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
|
});
|