@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.
- package/README.md +111 -88
- package/bin/studio-consumer.mjs +29 -0
- package/cli/claude-step.mjs +48 -0
- package/cli/init.mjs +489 -0
- package/cli/preflight.mjs +103 -0
- package/cli/provisioner.mjs +103 -0
- package/cli/run.mjs +89 -0
- package/cli/scaffold.mjs +155 -0
- package/cli/state.mjs +49 -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 +125 -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,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
|
+
}
|
package/cli/scaffold.mjs
ADDED
|
@@ -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
|
+
`;
|