@web-my-money/studio-consumer 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/bin/studio-consumer.mjs +29 -0
- package/cli/claude-step.mjs +48 -0
- package/cli/init.mjs +439 -0
- package/cli/preflight.mjs +78 -0
- package/cli/provisioner.mjs +102 -0
- package/cli/run.mjs +89 -0
- package/cli/state.mjs +37 -0
- package/cli/studio-api.mjs +127 -0
- package/cli/templates.mjs +134 -0
- package/cli/ui.mjs +126 -0
- package/cli/writers.mjs +148 -0
- package/package.json +33 -29
- package/skills/onboard-site/SKILL.md +96 -0
- package/src/analytics/index.ts +37 -37
- package/src/attribution/index.ts +14 -14
- package/src/brand/index.ts +66 -66
- package/src/content/index.ts +27 -27
- package/src/content/manifest-handler.ts +11 -9
- package/src/image/index.ts +14 -14
- package/src/next/index.d.mts +13 -0
- package/src/next/index.mjs +103 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The provisioning actions behind one interface (spec 2026-10-09 §6). This
|
|
5
|
+
* implementation runs the developer's own `gh` and `vercel` CLIs, so their
|
|
6
|
+
* sessions do the work and Studio holds no GitHub or Vercel credentials.
|
|
7
|
+
* Studio-driven provisioning (option 2) is another implementation of the same
|
|
8
|
+
* interface; nothing else changes.
|
|
9
|
+
*
|
|
10
|
+
* Flags checked against Vercel CLI 54 (2026-10-09): `env add` needs `--yes`
|
|
11
|
+
* (it otherwise asks to confirm, which hangs with the value on stdin), and has
|
|
12
|
+
* `--sensitive` and `--force`; `link` takes `--project`; `env ls` has `--format json`.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {"production" | "preview"} EnvTarget
|
|
15
|
+
* @typedef {{
|
|
16
|
+
* createRepoFromTemplate(name: string): Promise<void>,
|
|
17
|
+
* createRepoFromSource(name: string): Promise<void>,
|
|
18
|
+
* linkVercel(project: string): Promise<void>,
|
|
19
|
+
* hasEnv(name: string): Promise<boolean>,
|
|
20
|
+
* setEnv(name: string, value: string, targets: EnvTarget[], opts?: { sensitive?: boolean, force?: boolean }): Promise<void>,
|
|
21
|
+
* }} Provisioner
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @typedef {import("./run.mjs").Runner} Runner */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{ cwd: string, run: Runner, scope: string }} ctx
|
|
28
|
+
* @returns {Provisioner}
|
|
29
|
+
*/
|
|
30
|
+
export function cliProvisioner({ cwd, run, scope }) {
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} cmd
|
|
33
|
+
* @param {string[]} args
|
|
34
|
+
* @param {{ input?: string, cwd?: string }} [opts]
|
|
35
|
+
* @param {string} [shown] what to tell the developer to run by hand (never contains a secret)
|
|
36
|
+
*/
|
|
37
|
+
async function must(cmd, args, opts = {}, shown = `${cmd} ${args.join(" ")}`) {
|
|
38
|
+
const r = await run(cmd, args, { cwd: opts.cwd ?? cwd, input: opts.input });
|
|
39
|
+
if (r.code !== 0) {
|
|
40
|
+
const why = (r.stderr || r.stdout).trim().split(/\r?\n/).slice(-1)[0] ?? "";
|
|
41
|
+
throw new Error(`\`${shown}\` failed${why ? `: ${why}` : ""}`);
|
|
42
|
+
}
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @type {Map<string, string[]> | null} read once per run: name → targets */
|
|
47
|
+
let envs = null;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async createRepoFromTemplate(name) {
|
|
51
|
+
await must("gh", [
|
|
52
|
+
"repo",
|
|
53
|
+
"create",
|
|
54
|
+
`Web-My-Money/${name}`,
|
|
55
|
+
"--private",
|
|
56
|
+
"--template",
|
|
57
|
+
"Web-My-Money/wmm-app-template",
|
|
58
|
+
"--clone",
|
|
59
|
+
]);
|
|
60
|
+
},
|
|
61
|
+
async createRepoFromSource(name) {
|
|
62
|
+
await must("gh", ["repo", "create", `Web-My-Money/${name}`, "--private", "--source", ".", "--remote", "origin"]);
|
|
63
|
+
},
|
|
64
|
+
async linkVercel(project) {
|
|
65
|
+
// --scope pins the WMM team (a personal Vercel login would otherwise link
|
|
66
|
+
// to the wrong account); --project names the project instead of guessing
|
|
67
|
+
// it from the folder name.
|
|
68
|
+
await must("vercel", ["link", "--yes", "--project", project, "--scope", scope]);
|
|
69
|
+
// `vercel link` usually connects the GitHub repo itself; then this answers
|
|
70
|
+
// "already connected", which is the state we want (seen in the real run).
|
|
71
|
+
const connect = await run("vercel", ["git", "connect", "--yes", "--scope", scope], { cwd });
|
|
72
|
+
if (connect.code !== 0 && !/already connected/i.test(`${connect.stderr}\n${connect.stdout}`)) {
|
|
73
|
+
const why = (connect.stderr || connect.stdout).trim().split(/\r?\n/).slice(-1)[0] ?? "";
|
|
74
|
+
throw new Error(`\`vercel git connect\` failed${why ? `: ${why}` : ""}`);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async hasEnv(name) {
|
|
78
|
+
if (!envs) {
|
|
79
|
+
const r = await must("vercel", ["env", "ls", "--format", "json", "--scope", scope]);
|
|
80
|
+
envs = new Map();
|
|
81
|
+
const parsed = /** @type {{ envs?: { key: string, target?: string[] | string }[] }} */ (JSON.parse(r.stdout || "{}"));
|
|
82
|
+
for (const e of parsed.envs ?? []) {
|
|
83
|
+
const targets = Array.isArray(e.target) ? e.target : e.target ? [e.target] : [];
|
|
84
|
+
envs.set(e.key, [...(envs.get(e.key) ?? []), ...targets]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const targets = envs.get(name) ?? [];
|
|
88
|
+
return targets.includes("production") || targets.includes("preview");
|
|
89
|
+
},
|
|
90
|
+
async setEnv(name, value, targets, opts = {}) {
|
|
91
|
+
for (const target of targets) {
|
|
92
|
+
const args = ["env", "add", name, target, "--yes"];
|
|
93
|
+
if (opts.sensitive) args.push("--sensitive");
|
|
94
|
+
if (opts.force) args.push("--force");
|
|
95
|
+
args.push("--scope", scope);
|
|
96
|
+
// The value goes on stdin: never in the argument list, never on screen.
|
|
97
|
+
await must("vercel", args, { input: value }, `vercel env add ${name} ${target}`);
|
|
98
|
+
}
|
|
99
|
+
if (envs) envs.set(name, [...(envs.get(name) ?? []), ...targets]);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/cli/run.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every external command the onboarding command runs goes through here: an
|
|
6
|
+
* argument array, never a shell string. Secrets travel on stdin (`input`), never
|
|
7
|
+
* as arguments, so they never show in a process list or a shell history.
|
|
8
|
+
*
|
|
9
|
+
* Windows: npm, vercel and other npm-installed CLIs are `.cmd` shims, which Node
|
|
10
|
+
* will only start through a shell. So a command that is not found as a real
|
|
11
|
+
* executable is retried through `cmd.exe` with every argument quoted, and an
|
|
12
|
+
* argument cmd could reinterpret (a double quote, %, a newline) is refused rather
|
|
13
|
+
* than escaped by guesswork. Our arguments are names, keys and messages we build.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{ code: number, stdout: string, stderr: string }} RunResult
|
|
18
|
+
* @typedef {{ cwd?: string, input?: string, inherit?: boolean }} RunOptions
|
|
19
|
+
* @typedef {(cmd: string, args: string[], opts?: RunOptions) => Promise<RunResult>} Runner
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} cmd
|
|
24
|
+
* @param {string[]} args
|
|
25
|
+
* @param {RunOptions} opts
|
|
26
|
+
* @param {boolean} viaShell
|
|
27
|
+
* @returns {Promise<RunResult & { notFound?: boolean }>}
|
|
28
|
+
*/
|
|
29
|
+
function once(cmd, args, opts, viaShell) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
/** @type {import("node:child_process").ChildProcess} */
|
|
32
|
+
let child;
|
|
33
|
+
try {
|
|
34
|
+
if (viaShell) {
|
|
35
|
+
// The command NAME is passed bare: quoting it makes cmd.exe resolve a
|
|
36
|
+
// .cmd shim's own folder (%~dp0) to the cwd, which breaks npm on
|
|
37
|
+
// nvm-windows. So the name may only be a plain program name.
|
|
38
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(cmd)) {
|
|
39
|
+
resolve({ code: 2, stdout: "", stderr: `refusing to run ${JSON.stringify(cmd)} through cmd.exe` });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
for (const a of args) {
|
|
43
|
+
if (/["%\r\n]/.test(a)) {
|
|
44
|
+
resolve({ code: 2, stdout: "", stderr: `refusing to pass ${JSON.stringify(a)} through cmd.exe` });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const line = [cmd, ...args.map((a) => `"${a}"`)].join(" ");
|
|
49
|
+
child = spawn(line, { cwd: opts.cwd, shell: true, stdio: opts.inherit ? "inherit" : "pipe", windowsHide: true });
|
|
50
|
+
} else {
|
|
51
|
+
child = spawn(cmd, args, { cwd: opts.cwd, stdio: opts.inherit ? "inherit" : "pipe", windowsHide: true });
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
resolve({ code: 127, stdout: "", stderr: "", notFound: true });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let stdout = "";
|
|
58
|
+
let stderr = "";
|
|
59
|
+
child.stdout?.on("data", (d) => (stdout += String(d)));
|
|
60
|
+
child.stderr?.on("data", (d) => (stderr += String(d)));
|
|
61
|
+
child.on("error", (err) => {
|
|
62
|
+
const notFound = /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT";
|
|
63
|
+
resolve({ code: 127, stdout, stderr: stderr || err.message, notFound });
|
|
64
|
+
});
|
|
65
|
+
child.on("close", (code) => {
|
|
66
|
+
const notRecognised = viaShell && /is not recognized as an internal or external command/i.test(stderr);
|
|
67
|
+
resolve({ code: notRecognised ? 127 : (code ?? 1), stdout, stderr });
|
|
68
|
+
});
|
|
69
|
+
if (!opts.inherit && child.stdin) {
|
|
70
|
+
if (opts.input !== undefined) child.stdin.end(opts.input);
|
|
71
|
+
else child.stdin.end();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {Runner} */
|
|
77
|
+
export function createRunner() {
|
|
78
|
+
return async function run(cmd, args, opts = {}) {
|
|
79
|
+
if (/[&|<>^"%\r\n]/.test(cmd)) {
|
|
80
|
+
return { code: 2, stdout: "", stderr: `refusing to run ${JSON.stringify(cmd)}: not a plain program name` };
|
|
81
|
+
}
|
|
82
|
+
const first = await once(cmd, args, opts, false);
|
|
83
|
+
if (first.notFound && process.platform === "win32") {
|
|
84
|
+
const viaShell = await once(cmd, args, opts, true);
|
|
85
|
+
return { code: viaShell.code, stdout: viaShell.stdout, stderr: viaShell.stderr };
|
|
86
|
+
}
|
|
87
|
+
return { code: first.code, stdout: first.stdout, stderr: first.stderr };
|
|
88
|
+
};
|
|
89
|
+
}
|
package/cli/state.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the onboarding command keeps its progress, so a rerun resumes instead
|
|
7
|
+
* of starting over. Lives in `.wmm-onboarding/` (gitignored by this module).
|
|
8
|
+
* NEVER holds the ingest key: that goes straight into Vercel and nowhere else.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {{
|
|
11
|
+
* siteKey: string, siteName: string, studioUrl: string, manifestUrl: string,
|
|
12
|
+
* revalidateUrl: string, modules: string[], readyToken: string, done: string[],
|
|
13
|
+
* theme?: string, scope?: string, baseBranch?: string, siteHasKey?: boolean
|
|
14
|
+
* }} OnboardingState
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DIR = ".wmm-onboarding";
|
|
18
|
+
|
|
19
|
+
/** @param {string} cwd @returns {OnboardingState | null} */
|
|
20
|
+
export function loadState(cwd) {
|
|
21
|
+
const file = path.join(cwd, DIR, "state.json");
|
|
22
|
+
if (!existsSync(file)) return null;
|
|
23
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} cwd @param {OnboardingState} state */
|
|
27
|
+
export function saveState(cwd, state) {
|
|
28
|
+
const { ingestKey: _never, ...safe } = /** @type {OnboardingState & { ingestKey?: unknown }} */ (state);
|
|
29
|
+
mkdirSync(path.join(cwd, DIR), { recursive: true });
|
|
30
|
+
writeFileSync(path.join(cwd, DIR, "state.json"), `${JSON.stringify(safe, null, 2)}\n`);
|
|
31
|
+
const gitignore = path.join(cwd, ".gitignore");
|
|
32
|
+
const current = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
|
33
|
+
if (!current.split(/\r?\n/).includes(`${DIR}/`)) {
|
|
34
|
+
const sep = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
35
|
+
writeFileSync(gitignore, `${current}${sep}${DIR}/\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two Studio calls the onboarding command makes, with every failure turned
|
|
5
|
+
* into a sentence a developer can act on.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @typedef {(url: string, init: { method: string, headers: Record<string, string>, body: string }) => Promise<Response>} FetchLike */
|
|
9
|
+
/** @typedef {{ ok: true, data: Record<string, unknown> } | { ok: false, message: string }} ApiResult */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {FetchLike} fetchImpl
|
|
13
|
+
* @param {string} url
|
|
14
|
+
* @param {unknown} body
|
|
15
|
+
*/
|
|
16
|
+
async function post(fetchImpl, url, body) {
|
|
17
|
+
const res = await fetchImpl(url, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "content-type": "application/json" },
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
/** @type {Record<string, unknown>} */
|
|
23
|
+
let json = {};
|
|
24
|
+
try {
|
|
25
|
+
json = /** @type {Record<string, unknown>} */ (await res.json());
|
|
26
|
+
} catch {
|
|
27
|
+
json = {};
|
|
28
|
+
}
|
|
29
|
+
return { status: res.status, json };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const unreachable = (/** @type {string} */ base) =>
|
|
33
|
+
`Could not reach Studio at ${base}. Check your internet connection, then run this again.`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} studioUrl
|
|
37
|
+
* @param {string} code
|
|
38
|
+
* @param {FetchLike} [fetchImpl]
|
|
39
|
+
* @returns {Promise<ApiResult>}
|
|
40
|
+
*/
|
|
41
|
+
export async function exchangeCode(studioUrl, code, fetchImpl = fetch) {
|
|
42
|
+
const base = studioUrl.replace(/\/+$/, "");
|
|
43
|
+
let r;
|
|
44
|
+
try {
|
|
45
|
+
r = await post(fetchImpl, `${base}/api/public/setup/exchange`, { code });
|
|
46
|
+
} catch {
|
|
47
|
+
return { ok: false, message: unreachable(base) };
|
|
48
|
+
}
|
|
49
|
+
if (r.status === 200) return { ok: true, data: r.json };
|
|
50
|
+
if (r.status === 429) {
|
|
51
|
+
return { ok: false, message: "Too many attempts from this network. Wait a minute, then run this again." };
|
|
52
|
+
}
|
|
53
|
+
if (r.status === 400) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
message:
|
|
57
|
+
"That setup code has expired or was already used. Get a new one in Studio: open the site, Site settings, Get the setup command.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
message:
|
|
63
|
+
typeof r.json.error === "string"
|
|
64
|
+
? r.json.error
|
|
65
|
+
: "Studio could not finish the setup. Ask for a new code in Site settings.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} studioUrl
|
|
71
|
+
* @param {string} readyToken
|
|
72
|
+
* @param {FetchLike} [fetchImpl]
|
|
73
|
+
* @returns {Promise<ApiResult>}
|
|
74
|
+
*/
|
|
75
|
+
export async function markReady(studioUrl, readyToken, fetchImpl = fetch) {
|
|
76
|
+
const base = studioUrl.replace(/\/+$/, "");
|
|
77
|
+
let r;
|
|
78
|
+
try {
|
|
79
|
+
r = await post(fetchImpl, `${base}/api/public/setup/ready`, { readyToken });
|
|
80
|
+
} catch {
|
|
81
|
+
return { ok: false, message: unreachable(base) };
|
|
82
|
+
}
|
|
83
|
+
if (r.status === 200) return { ok: true, data: r.json };
|
|
84
|
+
if (r.status === 429) {
|
|
85
|
+
return { ok: false, message: "Studio synced this site moments ago. Wait 30 seconds, then run this again." };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
message: typeof r.json.error === "string" ? r.json.error : "Studio could not sync the site. Run this again in a minute.",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The site's analytics key, asked for at the moment it is saved to Vercel.
|
|
95
|
+
* `inUse` means the site already has a key Studio will not replace.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} studioUrl
|
|
98
|
+
* @param {string} readyToken
|
|
99
|
+
* @param {FetchLike} [fetchImpl]
|
|
100
|
+
* @returns {Promise<{ ok: true, ingestKey: string } | { ok: false, inUse: boolean, message: string }>}
|
|
101
|
+
*/
|
|
102
|
+
export async function requestKey(studioUrl, readyToken, fetchImpl = fetch) {
|
|
103
|
+
const base = studioUrl.replace(/\/+$/, "");
|
|
104
|
+
let r;
|
|
105
|
+
try {
|
|
106
|
+
r = await post(fetchImpl, `${base}/api/public/setup/key`, { readyToken });
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, inUse: false, message: unreachable(base) };
|
|
109
|
+
}
|
|
110
|
+
if (r.status === 200 && typeof r.json.ingestKey === "string") return { ok: true, ingestKey: r.json.ingestKey };
|
|
111
|
+
if (r.status === 409) {
|
|
112
|
+
return { ok: false, inUse: true, message: "This site already has an analytics key in use." };
|
|
113
|
+
}
|
|
114
|
+
if (r.status === 400) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
inUse: false,
|
|
118
|
+
message: "This setup has expired (it lasts 24 hours). Get a new command in Studio's Site settings and run it.",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
inUse: false,
|
|
124
|
+
message: typeof r.json.error === "string" ? r.json.error : "Studio could not create the key. Run this again.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The files `init` writes into a site, as functions of the values Studio handed
|
|
5
|
+
* over. They mirror docs/ONBOARD-A-SITE.md in wmm-studio; imports are relative
|
|
6
|
+
* so they work whether or not the app has an `@/` alias.
|
|
7
|
+
*
|
|
8
|
+
* Paths are relative to the app's code root (`.` or `src`), except the test,
|
|
9
|
+
* which lives at the repo root.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HEADER = "// Written by `npx @web-my-money/studio-consumer init`. Safe to edit.\n";
|
|
13
|
+
|
|
14
|
+
/** @param {string} s */
|
|
15
|
+
const q = (s) => JSON.stringify(s);
|
|
16
|
+
|
|
17
|
+
/** @param {{ siteKey: string }} v */
|
|
18
|
+
export const studioTs = (v) => `${HEADER}
|
|
19
|
+
/**
|
|
20
|
+
* This site's key in WMM Studio. A constant on purpose, never an env var: every
|
|
21
|
+
* event and every content slot is filed under it, so it must not differ between
|
|
22
|
+
* environments.
|
|
23
|
+
*/
|
|
24
|
+
export const SITE_KEY = ${q(v.siteKey)};
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/** @param {{ theme: string, style: "flat" | "glass" }} v */
|
|
28
|
+
export const brandTs = (v) => `${HEADER}import { defineSiteBrand } from "@web-my-money/studio-consumer/brand";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The site's brand theme. Spread brandRootAttributes(brand) on <html> in the
|
|
32
|
+
* root layout. A missing or WMM-owned theme fails the build on purpose: a
|
|
33
|
+
* client site must never inherit WMM's look by accident.
|
|
34
|
+
*/
|
|
35
|
+
export const brand = defineSiteBrand({ theme: ${q(v.theme)}, style: ${q(v.style)} });
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export const contentManifestTs = () => `${HEADER}import { SITE_KEY } from "./studio";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What Studio may edit on this site. Starts empty: the onboarding skill
|
|
42
|
+
* (\`/onboard-site\` in Claude Code) proposes the editable text and fills this
|
|
43
|
+
* in once you approve it.
|
|
44
|
+
*/
|
|
45
|
+
export async function getManifest() {
|
|
46
|
+
return {
|
|
47
|
+
siteKey: SITE_KEY,
|
|
48
|
+
generatedAt: new Date().toISOString(),
|
|
49
|
+
slots: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export const contentTs = () => `${HEADER}import "server-only";
|
|
55
|
+
import { createContentClient, type StudioForm } from "@web-my-money/studio-consumer/content";
|
|
56
|
+
import { getManifest } from "./content-manifest";
|
|
57
|
+
import { SITE_KEY } from "./studio";
|
|
58
|
+
|
|
59
|
+
// Built ONCE, at module scope: its methods share one cached fetch per render,
|
|
60
|
+
// so every section of a page agrees on the content and the A/B arm.
|
|
61
|
+
const client = createContentClient({
|
|
62
|
+
studioUrl: process.env.STUDIO_CONTENT_URL,
|
|
63
|
+
siteKey: SITE_KEY,
|
|
64
|
+
getManifest,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type { StudioForm };
|
|
68
|
+
export const getSlot = client.getSlot;
|
|
69
|
+
export const getLocalizedSlot = client.getLocalizedSlot;
|
|
70
|
+
export const applyDictOverrides = client.applyDictOverrides;
|
|
71
|
+
export const activeVariant = client.activeVariant;
|
|
72
|
+
export const getRunningExperiments = client.getRunningExperiments;
|
|
73
|
+
export const getDecidedWinners = client.getDecidedWinners;
|
|
74
|
+
export const getStudioForms = client.getStudioForms;
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
/** @param {string} toLib relative path from the route file to the lib folder */
|
|
78
|
+
export const manifestRouteTs = (toLib) => `${HEADER}import { createManifestHandler } from "@web-my-money/studio-consumer/content";
|
|
79
|
+
import { getManifest } from "${toLib}/content-manifest";
|
|
80
|
+
import { brand } from "${toLib}/brand";
|
|
81
|
+
|
|
82
|
+
// Always answers from the live code (the handler waits for a request), so
|
|
83
|
+
// Studio never syncs against a copy frozen at build time.
|
|
84
|
+
export const GET = createManifestHandler(getManifest, brand);
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
export const revalidateRouteTs = () => `${HEADER}import { createRevalidateHandler } from "@web-my-money/studio-consumer/content";
|
|
88
|
+
|
|
89
|
+
// Built per request so the env is read at request time, not frozen at load.
|
|
90
|
+
export function POST(request: Request): Promise<Response> {
|
|
91
|
+
return createRevalidateHandler({
|
|
92
|
+
studioUrl: process.env.STUDIO_CONTENT_URL,
|
|
93
|
+
})(request);
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} toLib
|
|
99
|
+
* @param {boolean} withExperiments
|
|
100
|
+
*/
|
|
101
|
+
export const collectRouteTs = (toLib, withExperiments) => `${HEADER}import { createCollectHandler } from "@web-my-money/studio-consumer/analytics";
|
|
102
|
+
import { SITE_KEY } from "${toLib}/studio";
|
|
103
|
+
${withExperiments ? `import { getRunningExperiments } from "${toLib}/content";\n` : ""}
|
|
104
|
+
// Built per request so the env is read at request time, not frozen at load.
|
|
105
|
+
export function POST(request: Request): Promise<Response> {
|
|
106
|
+
return createCollectHandler({
|
|
107
|
+
siteKey: SITE_KEY,
|
|
108
|
+
ingestUrl: process.env.FUNNEL_INGEST_URL ?? "",
|
|
109
|
+
ingestKey: process.env.FUNNEL_INGEST_TOKEN ?? "",${withExperiments ? "\n getRunningExperiments," : ""}
|
|
110
|
+
})(request);
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
export const frameAncestorsTestTs = () => `${HEADER}import { describe, expect, it } from "vitest";
|
|
115
|
+
import nextConfig from "../next.config";
|
|
116
|
+
|
|
117
|
+
type Rule = { source: string; headers: { key: string; value: string }[] };
|
|
118
|
+
|
|
119
|
+
// Studio's click-to-edit frames this site's real pages. If this header is
|
|
120
|
+
// lost, editing silently turns into a grey box in Studio while the site and its
|
|
121
|
+
// build stay green. This test is what makes that loud.
|
|
122
|
+
describe("Studio can frame this site, and nothing else can", () => {
|
|
123
|
+
it("every page sends frame-ancestors 'self' plus Studio, and no X-Frame-Options", async () => {
|
|
124
|
+
const config = nextConfig as unknown as { headers?: () => Promise<Rule[]> };
|
|
125
|
+
const rules = (await config.headers?.()) ?? [];
|
|
126
|
+
const all = rules.find((r) => r.source === "/:path*");
|
|
127
|
+
const csp = all?.headers.find((h) => h.key.toLowerCase() === "content-security-policy")?.value ?? "";
|
|
128
|
+
const frameAncestors = csp.split(";").map((p) => p.trim()).find((p) => p.startsWith("frame-ancestors")) ?? "";
|
|
129
|
+
expect(frameAncestors).toMatch(/^frame-ancestors 'self' https:\\/\\/studio\\.webmymoney\\.com/);
|
|
130
|
+
expect(frameAncestors).not.toContain("*");
|
|
131
|
+
for (const r of rules) for (const h of r.headers) expect(h.key.toLowerCase()).not.toBe("x-frame-options");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
`;
|
package/cli/ui.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How the onboarding command talks to a developer who has never done this
|
|
7
|
+
* (spec 2026-10-09 §3.0): numbered steps with one plain sentence each, a
|
|
8
|
+
* recommended default on every question, and every stop ending in the exact
|
|
9
|
+
* next command. `explain` mode answers no to everything and asks nothing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class StopError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {string} [nextCommand]
|
|
16
|
+
*/
|
|
17
|
+
constructor(message, nextCommand) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "StopError";
|
|
20
|
+
this.nextCommand = nextCommand;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ input: NodeJS.ReadableStream, output: NodeJS.WritableStream, explain?: boolean }} io
|
|
26
|
+
*/
|
|
27
|
+
export function createUi({ input, output, explain = false }) {
|
|
28
|
+
/** @type {string[]} */
|
|
29
|
+
const queued = [];
|
|
30
|
+
/** @type {((line: string) => void)[]} */
|
|
31
|
+
const waiting = [];
|
|
32
|
+
// Lines are read without echo from readline; a hidden answer is simply not
|
|
33
|
+
// written back. Interactive terminals echo typed characters themselves, so
|
|
34
|
+
// askSecret also turns raw mode on when it can.
|
|
35
|
+
const sink = new Writable({
|
|
36
|
+
write(_chunk, _enc, cb) {
|
|
37
|
+
cb();
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const rl = createInterface({ input, output: sink, terminal: false });
|
|
41
|
+
rl.on("line", (line) => {
|
|
42
|
+
const next = waiting.shift();
|
|
43
|
+
if (next) next(line);
|
|
44
|
+
else queued.push(line);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** @returns {Promise<string>} */
|
|
48
|
+
const readLine = () =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
const line = queued.shift();
|
|
51
|
+
if (line !== undefined) resolve(line);
|
|
52
|
+
else waiting.push(resolve);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** @param {string} s */
|
|
56
|
+
const say = (s) => output.write(`${s}\n`);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
explain,
|
|
60
|
+
/** @param {number} n @param {number} total @param {string} title @param {string} why */
|
|
61
|
+
step(n, total, title, why) {
|
|
62
|
+
say("");
|
|
63
|
+
say(`Step ${n} of ${total}: ${title}`);
|
|
64
|
+
say(` ${why}`);
|
|
65
|
+
},
|
|
66
|
+
/** @param {string} s */
|
|
67
|
+
info(s) {
|
|
68
|
+
say(` ${s}`);
|
|
69
|
+
},
|
|
70
|
+
/** @param {string} question @param {string} def */
|
|
71
|
+
async ask(question, def) {
|
|
72
|
+
if (explain) return def;
|
|
73
|
+
output.write(` ${question} (${def}): `);
|
|
74
|
+
const answer = (await readLine()).trim();
|
|
75
|
+
return answer === "" ? def : answer;
|
|
76
|
+
},
|
|
77
|
+
/** @param {string} question */
|
|
78
|
+
async askSecret(question) {
|
|
79
|
+
output.write(` ${question} (it will not show as you paste): `);
|
|
80
|
+
const tty = /** @type {NodeJS.ReadStream} */ (input);
|
|
81
|
+
const canHide = typeof tty.setRawMode === "function" && tty.isTTY;
|
|
82
|
+
if (canHide) tty.setRawMode(true);
|
|
83
|
+
try {
|
|
84
|
+
const line = await readLine();
|
|
85
|
+
// In raw mode Ctrl+C arrives as a character instead of stopping the
|
|
86
|
+
// program; honour it.
|
|
87
|
+
if (line.includes("\u0003")) {
|
|
88
|
+
throw new StopError("Stopped. Run this again whenever you are ready; it continues from here.");
|
|
89
|
+
}
|
|
90
|
+
return line.trim();
|
|
91
|
+
} finally {
|
|
92
|
+
if (canHide) tty.setRawMode(false);
|
|
93
|
+
output.write("\n");
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
/** @param {string} question @param {boolean} [defYes] */
|
|
97
|
+
async confirm(question, defYes = true) {
|
|
98
|
+
if (explain) {
|
|
99
|
+
say(` Would ask: ${question}`);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
output.write(` ${question} ${defYes ? "[Y/n]" : "[y/N]"}: `);
|
|
103
|
+
const a = (await readLine()).trim().toLowerCase();
|
|
104
|
+
if (a === "") return defYes;
|
|
105
|
+
return a === "y" || a === "yes";
|
|
106
|
+
},
|
|
107
|
+
/** @param {string} message @param {string} [nextCommand] @returns {never} */
|
|
108
|
+
stop(message, nextCommand) {
|
|
109
|
+
throw new StopError(message, nextCommand);
|
|
110
|
+
},
|
|
111
|
+
/** Hand the terminal to a child process (Claude Code, the app's checks). */
|
|
112
|
+
pause() {
|
|
113
|
+
rl.pause();
|
|
114
|
+
input.pause();
|
|
115
|
+
},
|
|
116
|
+
/** Take the terminal back after the child exits. */
|
|
117
|
+
resume() {
|
|
118
|
+
rl.resume();
|
|
119
|
+
},
|
|
120
|
+
close() {
|
|
121
|
+
rl.close();
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @typedef {ReturnType<typeof createUi>} Ui */
|