@web-my-money/studio-consumer 2.2.0 → 2.4.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.
@@ -0,0 +1,103 @@
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
+ // No --clone: it would clone into ./<name>, and this folder already holds
52
+ // the command's progress. The command pulls the repository in itself.
53
+ await must("gh", [
54
+ "repo",
55
+ "create",
56
+ `Web-My-Money/${name}`,
57
+ "--private",
58
+ "--template",
59
+ "Web-My-Money/wmm-site-template",
60
+ ]);
61
+ },
62
+ async createRepoFromSource(name) {
63
+ await must("gh", ["repo", "create", `Web-My-Money/${name}`, "--private", "--source", ".", "--remote", "origin"]);
64
+ },
65
+ async linkVercel(project) {
66
+ // --scope pins the WMM team (a personal Vercel login would otherwise link
67
+ // to the wrong account); --project names the project instead of guessing
68
+ // it from the folder name.
69
+ await must("vercel", ["link", "--yes", "--project", project, "--scope", scope]);
70
+ // `vercel link` usually connects the GitHub repo itself; then this answers
71
+ // "already connected", which is the state we want (seen in the real run).
72
+ const connect = await run("vercel", ["git", "connect", "--yes", "--scope", scope], { cwd });
73
+ if (connect.code !== 0 && !/already connected/i.test(`${connect.stderr}\n${connect.stdout}`)) {
74
+ const why = (connect.stderr || connect.stdout).trim().split(/\r?\n/).slice(-1)[0] ?? "";
75
+ throw new Error(`\`vercel git connect\` failed${why ? `: ${why}` : ""}`);
76
+ }
77
+ },
78
+ async hasEnv(name) {
79
+ if (!envs) {
80
+ const r = await must("vercel", ["env", "ls", "--format", "json", "--scope", scope]);
81
+ envs = new Map();
82
+ const parsed = /** @type {{ envs?: { key: string, target?: string[] | string }[] }} */ (JSON.parse(r.stdout || "{}"));
83
+ for (const e of parsed.envs ?? []) {
84
+ const targets = Array.isArray(e.target) ? e.target : e.target ? [e.target] : [];
85
+ envs.set(e.key, [...(envs.get(e.key) ?? []), ...targets]);
86
+ }
87
+ }
88
+ const targets = envs.get(name) ?? [];
89
+ return targets.includes("production") || targets.includes("preview");
90
+ },
91
+ async setEnv(name, value, targets, opts = {}) {
92
+ for (const target of targets) {
93
+ const args = ["env", "add", name, target, "--yes"];
94
+ if (opts.sensitive) args.push("--sensitive");
95
+ if (opts.force) args.push("--force");
96
+ args.push("--scope", scope);
97
+ // The value goes on stdin: never in the argument list, never on screen.
98
+ await must("vercel", args, { input: value }, `vercel env add ${name} ${target}`);
99
+ }
100
+ if (envs) envs.set(name, [...(envs.get(name) ?? []), ...targets]);
101
+ },
102
+ };
103
+ }
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
+ }
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isOwnGitignore } from "./state.mjs";
5
+
6
+ /**
7
+ * A new site from WMM's template (spec 2026-10-09 §3.2): pull the repository
8
+ * GitHub created from `Web-My-Money/wmm-site-template` into the developer's
9
+ * folder, then make it this site's.
10
+ */
11
+
12
+ /** @typedef {import("./run.mjs").Runner} Runner */
13
+
14
+ /** The only two strings in the template that become this site's (see its README). */
15
+ export const MARKERS = { key: "wmm-template-site", name: "WMM Template Site" };
16
+
17
+ const FETCH_TRIES = 10;
18
+ const FETCH_WAIT_MS = 3000;
19
+
20
+ /**
21
+ * GitHub copies a template into the new repository in the background, so for a
22
+ * few seconds the repository has no `main`. That case is waited out; any other
23
+ * failure (usually git not signed in to GitHub) stops at once with the fix.
24
+ *
25
+ * @param {{ cwd: string, run: Runner, sleep: (ms: number) => Promise<void>, name: string }} ctx
26
+ * @returns {Promise<{ ok: true } | { ok: false, message: string, nextCommand?: string }>}
27
+ */
28
+ export async function pullTemplateRepo({ cwd, run, sleep, name }) {
29
+ const url = `https://github.com/Web-My-Money/${name}.git`;
30
+ if (!existsSync(path.join(cwd, ".git"))) {
31
+ const init = await run("git", ["init", "-b", "main"], { cwd });
32
+ if (init.code !== 0) return { ok: false, message: `git could not start a repository here: ${lastLine(init)}` };
33
+ }
34
+ const origin = await run("git", ["remote", "get-url", "origin"], { cwd });
35
+ if (origin.code !== 0) {
36
+ const add = await run("git", ["remote", "add", "origin", url], { cwd });
37
+ if (add.code !== 0) return { ok: false, message: `git could not add the new repository: ${lastLine(add)}` };
38
+ } else {
39
+ // A folder already tied to some other repository would fetch the wrong one forever.
40
+ const current = origin.stdout.trim();
41
+ const ours = new RegExp(`github\\.com[/:]Web-My-Money/${name}(\\.git)?$`, "i");
42
+ if (!ours.test(current)) {
43
+ return {
44
+ ok: false,
45
+ message: `This folder's git remote points at ${current}, not at Web-My-Money/${name}. Point it at the new site's repository, then run this again.`,
46
+ nextCommand: `git remote set-url origin ${url}`,
47
+ };
48
+ }
49
+ }
50
+
51
+ for (let i = 0; i < FETCH_TRIES; i++) {
52
+ const r = await run("git", ["fetch", "origin", "main"], { cwd });
53
+ if (r.code === 0) {
54
+ // The progress file's one-line .gitignore would block checking out the
55
+ // template's own (which already ignores the progress folder).
56
+ if (isOwnGitignore(cwd)) rmSync(path.join(cwd, ".gitignore"));
57
+ const co = await run("git", ["checkout", "-B", "main", "origin/main"], { cwd });
58
+ if (co.code !== 0) return { ok: false, message: `git could not check out the new site: ${lastLine(co)}` };
59
+ return { ok: true };
60
+ }
61
+ if (!/couldn't find remote ref/i.test(r.stderr)) {
62
+ return {
63
+ ok: false,
64
+ message: `git could not download the new repository: ${lastLine(r)}. If git is not signed in to GitHub, this fixes it; then run this again.`,
65
+ nextCommand: "gh auth setup-git",
66
+ };
67
+ }
68
+ if (i < FETCH_TRIES - 1) await sleep(FETCH_WAIT_MS);
69
+ }
70
+ return { ok: false, message: "GitHub is still preparing the repository. Run this again in a minute; it continues from here." };
71
+ }
72
+
73
+ // The same rule and reserved ids as defineSiteBrand (src/brand/index.ts): in a
74
+ // template site the key IS the theme id, so it must be a valid client theme.
75
+ const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
76
+ const WMM_THEMES = ["wmm", "site", "light", "dark"];
77
+
78
+ /**
79
+ * Why a site key cannot be used for a site made from the template, or null.
80
+ * Checked before anything is created, so a bad key never leaves a repository behind.
81
+ *
82
+ * @param {string} siteKey
83
+ * @returns {string | null}
84
+ */
85
+ export function templateKeyProblem(siteKey) {
86
+ if (THEME_ID.test(siteKey) && !WMM_THEMES.includes(siteKey)) return null;
87
+ return `The site key "${siteKey}" cannot be used for a site made from WMM's template: it becomes the brand theme id, so it must start with a letter, use only lowercase letters, digits and dashes, be at most 41 characters, and not be one of ${WMM_THEMES.join(", ")}. Add the site again in Studio with a key like that.`;
88
+ }
89
+
90
+ /** @param {{ stdout: string, stderr: string }} r */
91
+ function lastLine(r) {
92
+ return (r.stderr || r.stdout).trim().split(/\r?\n/).slice(-1)[0] ?? "";
93
+ }
94
+
95
+ /**
96
+ * Replace the template's markers in every tracked file, escaped for where they
97
+ * land, and swap the template's README for one about this site.
98
+ *
99
+ * @param {{ cwd: string, run: Runner, siteKey: string, siteName: string }} ctx
100
+ * @returns {Promise<string[]>} the files that changed
101
+ */
102
+ export async function personalise({ cwd, run, siteKey, siteName }) {
103
+ const problem = templateKeyProblem(siteKey);
104
+ if (problem) throw new Error(problem);
105
+ const listed = await run("git", ["ls-files"], { cwd });
106
+ const files = listed.stdout.split(/\r?\n/).map((f) => f.trim()).filter(Boolean);
107
+
108
+ /** @type {string[]} */
109
+ const changed = [];
110
+ for (const rel of files) {
111
+ const abs = path.join(cwd, rel);
112
+ if (!existsSync(abs)) continue;
113
+ if (rel === "README.md") {
114
+ writeFileSync(abs, siteReadme(siteName));
115
+ changed.push(rel);
116
+ continue;
117
+ }
118
+ const body = readFileSync(abs, "utf8");
119
+ if (body.includes("\0")) continue; // binary
120
+ if (!body.includes(MARKERS.key) && !body.includes(MARKERS.name)) continue;
121
+ const next = body.replaceAll(MARKERS.key, siteKey).replaceAll(MARKERS.name, nameFor(rel, siteName));
122
+ writeFileSync(abs, next);
123
+ changed.push(rel);
124
+ }
125
+ return changed;
126
+ }
127
+
128
+ /**
129
+ * The display name, escaped for the file it lands in: inside a JSON or code
130
+ * string it must not end the string; inside a CSS comment it must not end the
131
+ * comment.
132
+ *
133
+ * @param {string} rel
134
+ * @param {string} name
135
+ */
136
+ function nameFor(rel, name) {
137
+ if (/\.(json|[cm]?[jt]sx?)$/.test(rel)) return JSON.stringify(name).slice(1, -1);
138
+ if (rel.endsWith(".css")) return name.replaceAll("*/", "");
139
+ return name;
140
+ }
141
+
142
+ /** @param {string} name */
143
+ function siteReadme(name) {
144
+ return `# ${name}
145
+
146
+ ${name}'s website, built from WMM's site template and connected to WMM Studio.
147
+
148
+ - Copy: \`dictionaries/en.json\` and \`es.json\` (same keys in both).
149
+ - Brand colours: \`app/client-theme.css\`.
150
+ - What the client can edit in Studio: \`lib/content-manifest.ts\`.
151
+ - Rules for working on this site: \`CLAUDE.md\`.
152
+
153
+ Before every push: \`npm run verify\`.
154
+ `;
155
+ }
package/cli/state.mjs ADDED
@@ -0,0 +1,49 @@
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
+ * mode?: "new" | "existing", repoCreated?: boolean
15
+ * }} OnboardingState
16
+ */
17
+
18
+ const DIR = ".wmm-onboarding";
19
+
20
+ /**
21
+ * True when `.gitignore` is the one-line file saveState wrote into a folder that
22
+ * had none: the command's own file, safe to treat as absent.
23
+ *
24
+ * @param {string} cwd
25
+ */
26
+ export function isOwnGitignore(cwd) {
27
+ const file = path.join(cwd, ".gitignore");
28
+ return existsSync(file) && readFileSync(file, "utf8").trim() === `${DIR}/`;
29
+ }
30
+
31
+ /** @param {string} cwd @returns {OnboardingState | null} */
32
+ export function loadState(cwd) {
33
+ const file = path.join(cwd, DIR, "state.json");
34
+ if (!existsSync(file)) return null;
35
+ return JSON.parse(readFileSync(file, "utf8"));
36
+ }
37
+
38
+ /** @param {string} cwd @param {OnboardingState} state */
39
+ export function saveState(cwd, state) {
40
+ const { ingestKey: _never, ...safe } = /** @type {OnboardingState & { ingestKey?: unknown }} */ (state);
41
+ mkdirSync(path.join(cwd, DIR), { recursive: true });
42
+ writeFileSync(path.join(cwd, DIR, "state.json"), `${JSON.stringify(safe, null, 2)}\n`);
43
+ const gitignore = path.join(cwd, ".gitignore");
44
+ const current = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
45
+ if (!current.split(/\r?\n/).includes(`${DIR}/`)) {
46
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
47
+ writeFileSync(gitignore, `${current}${sep}${DIR}/\n`);
48
+ }
49
+ }
@@ -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
+ `;