@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 CHANGED
@@ -7,6 +7,22 @@ are Next.js apps that already transpile workspace and scoped packages, so
7
7
  publishing compiled output here would only add a compile-and-publish cycle
8
8
  for no gain.
9
9
 
10
+ ## Connect a site: one command (2.3.0)
11
+
12
+ In Studio, open the site, Site settings, **Get the setup command**, then in the
13
+ app's folder:
14
+
15
+ ```bash
16
+ npx @web-my-money/studio-consumer init <code>
17
+ ```
18
+
19
+ It checks the machine, installs this package, writes the routes, wraps
20
+ `next.config` with `withStudio`, links Vercel and sets the env vars, opens Claude
21
+ Code (the `onboard-site` skill shipped here) to wire the layout and choose the
22
+ editable text, verifies, opens a pull request, waits for the deploy and asks
23
+ Studio to sync. Rerun it after any stop; it resumes. `init --explain` shows the
24
+ plan without changing anything.
25
+
10
26
  ## Entry points
11
27
 
12
28
  - `@web-my-money/studio-consumer/analytics`
@@ -15,6 +31,7 @@ for no gain.
15
31
  - `@web-my-money/studio-consumer/headers`
16
32
  - `@web-my-money/studio-consumer/brand` (2.0.0)
17
33
  - `@web-my-money/studio-consumer/image` (2.1.0)
34
+ - `@web-my-money/studio-consumer/next` (2.3.0): `withStudio(nextConfig)`
18
35
 
19
36
  Each entry point exports `PACKAGE_VERSION`, to prove at runtime that a consumer
20
37
  is resolving this package rather than a stale copy.
@@ -48,7 +65,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
48
65
  import { brand } from "@/lib/brand";
49
66
 
50
67
  export const GET = createManifestHandler(getManifest, brand);
51
- export const dynamic = "force-dynamic";
68
+ // No `export const dynamic` (2.3.0+): the handler waits for a real request, so the
69
+ // manifest is always live, and cacheComponents apps refuse that export anyway.
52
70
  ```
53
71
 
54
72
  `createManifestHandler` now REQUIRES the brand (the breaking change in 2.0.0), so
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ /**
5
+ * `npx @web-my-money/studio-consumer <command>`. Plain ESM: Node will not strip
6
+ * TypeScript inside node_modules, so nothing on this path may be `.ts`.
7
+ */
8
+
9
+ const USAGE = `Usage:
10
+ npx @web-my-money/studio-consumer init <code> Connect this app to WMM Studio, step by step.
11
+ npx @web-my-money/studio-consumer init Continue where the last run stopped.
12
+ npx @web-my-money/studio-consumer init --explain Show every step without changing anything.
13
+
14
+ Get <code> in Studio: open the site, Site settings, Get the setup command.`;
15
+
16
+ const [command, ...rest] = process.argv.slice(2);
17
+
18
+ if (!command || command === "--help" || command === "-h" || command === "help") {
19
+ process.stdout.write(`${USAGE}\n`);
20
+ process.exit(0);
21
+ }
22
+
23
+ if (command !== "init") {
24
+ process.stderr.write(`Unknown command "${command}".\n\n${USAGE}\n`);
25
+ process.exit(1);
26
+ }
27
+
28
+ const { runInit } = await import("../cli/init.mjs");
29
+ process.exitCode = await runInit(rest);
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Step 7 of `init`: the edits that need judgement on someone else's code (root
8
+ * layout, proxy/middleware, which text is editable) are done by Claude Code, on
9
+ * the developer's own subscription, through the `onboard-site` skill this
10
+ * package ships. Each edit is shown and approved before it is written.
11
+ */
12
+
13
+ /** @typedef {import("./run.mjs").Runner} Runner */
14
+ /** @typedef {import("./ui.mjs").Ui} Ui */
15
+
16
+ const SKILL_SOURCE = fileURLToPath(new URL("../skills/onboard-site/SKILL.md", import.meta.url));
17
+
18
+ /**
19
+ * @param {{ cwd: string, run: Runner, ui: Ui }} ctx
20
+ */
21
+ export async function claudeStep({ cwd, run, ui }) {
22
+ const target = path.join(cwd, ".claude", "skills", "onboard-site", "SKILL.md");
23
+ const source = readFileSync(SKILL_SOURCE, "utf8");
24
+ if (!existsSync(target) || readFileSync(target, "utf8") !== source) {
25
+ mkdirSync(path.dirname(target), { recursive: true });
26
+ copyFileSync(SKILL_SOURCE, target);
27
+ }
28
+
29
+ const version = await run("claude", ["--version"], { cwd });
30
+ if (version.code !== 0) {
31
+ ui.stop(
32
+ "This step needs Claude Code, to wire your layout and choose the editable text with you. Install it, then run this command again; it continues from here.",
33
+ "Install Claude Code from https://claude.com/claude-code",
34
+ );
35
+ }
36
+
37
+ ui.info("Opening Claude Code. It will show each change and wait for your yes.");
38
+ ui.info("When it says it is done, type /exit and this command carries on.");
39
+ if (ui.explain) return;
40
+ // Claude Code owns the terminal while it runs: the setup stops reading input,
41
+ // or keystrokes would be split between the two and replayed later as answers.
42
+ ui.pause();
43
+ try {
44
+ await run("claude", ["/onboard-site"], { cwd, inherit: true });
45
+ } finally {
46
+ ui.resume();
47
+ }
48
+ }
package/cli/init.mjs ADDED
@@ -0,0 +1,439 @@
1
+ // @ts-check
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createRunner } from "./run.mjs";
5
+ import { createUi, StopError } from "./ui.mjs";
6
+ import { loadState, saveState } from "./state.mjs";
7
+ import { exchangeCode, markReady, requestKey } from "./studio-api.mjs";
8
+ import { preflight } from "./preflight.mjs";
9
+ import { cliProvisioner } from "./provisioner.mjs";
10
+ import { writeSiteFiles, wrapNextConfig } from "./writers.mjs";
11
+ import { claudeStep } from "./claude-step.mjs";
12
+
13
+ /**
14
+ * `npx @web-my-money/studio-consumer init <code>`: connect an existing Next.js
15
+ * app to WMM Studio, one guided step at a time (spec 2026-10-09 §1, §3).
16
+ *
17
+ * Every step is resumable: progress is saved after each one, so a rerun picks up
18
+ * where the last run stopped. The ingest key lives only in memory for the run in
19
+ * which Studio hands it over, and goes straight into Vercel.
20
+ */
21
+
22
+ /** @typedef {import("./run.mjs").Runner} Runner */
23
+ /** @typedef {import("./ui.mjs").Ui} Ui */
24
+ /** @typedef {import("./state.mjs").OnboardingState} OnboardingState */
25
+ /** @typedef {import("./provisioner.mjs").Provisioner} AnyProvisioner */
26
+ /** @typedef {(url: string, init?: RequestInit) => Promise<Response>} Fetch */
27
+
28
+ const DEFAULT_STUDIO = "https://studio.webmymoney.com";
29
+ const DEFAULT_SCOPE = "web-my-money-team";
30
+ const TARGETS = /** @type {("production" | "preview")[]} */ (["production", "preview"]);
31
+ const BRANCH = "studio-onboarding";
32
+ const POLL_EVERY_MS = 10_000;
33
+ const POLL_TIMES = 60;
34
+
35
+ /**
36
+ * @typedef {{
37
+ * cwd: string, ui: Ui, run: Runner, fetchImpl: Fetch, sleep: (ms: number) => Promise<void>,
38
+ * studioUrl: string, code: string | null, state: OnboardingState | null,
39
+ * provisioner: AnyProvisioner | null, provisionerScope?: string,
40
+ * injectedProvisioner: AnyProvisioner | null,
41
+ * }} Ctx
42
+ */
43
+
44
+ /** @type {{ id: string, title: string, why: string, run: (ctx: Ctx) => Promise<void> }[]} */
45
+ const STEPS = [
46
+ {
47
+ id: "preflight",
48
+ title: "Check your setup",
49
+ why: "Makes sure Node, GitHub, Vercel and this app are ready before anything changes.",
50
+ async run(ctx) {
51
+ await preflight({ cwd: ctx.cwd, run: ctx.run, ui: ctx.ui, resuming: ctx.state !== null });
52
+ },
53
+ },
54
+ {
55
+ id: "connect",
56
+ title: "Connect to Studio",
57
+ why: "Uses your one-time code to fetch this site's settings from WMM Studio.",
58
+ async run(ctx) {
59
+ if (!ctx.code) {
60
+ if (ctx.state) return ctx.ui.info(`Continuing setup for ${ctx.state.siteName}.`);
61
+ ctx.ui.stop(
62
+ "This needs the one-time setup code from Studio.",
63
+ "In Studio: open the site, Site settings, Get the setup command, then run the command it shows.",
64
+ );
65
+ }
66
+ const r = await exchangeCode(ctx.studioUrl, /** @type {string} */ (ctx.code), ctx.fetchImpl);
67
+ if (!r.ok) return ctx.ui.stop(r.message);
68
+ const d = /** @type {Record<string, unknown>} */ (r.data);
69
+ const modules = Array.isArray(d.modules) ? d.modules.map(String) : [];
70
+ const before = ctx.state;
71
+ // A new code with different modules means the files, the settings and the
72
+ // layout work are redone for them. The writers are idempotent, so redoing
73
+ // them is safe.
74
+ let done = before?.done ?? [];
75
+ const changed = before !== null && [...modules].sort().join() !== [...before.modules].sort().join();
76
+ if (changed) done = done.filter((id) => !["files", "env", "claude"].includes(id));
77
+ ctx.state = {
78
+ ...(before ?? {}),
79
+ done,
80
+ siteKey: String(d.siteKey),
81
+ siteName: String(d.siteName ?? d.siteKey),
82
+ studioUrl: String(d.studioUrl ?? ctx.studioUrl),
83
+ manifestUrl: String(d.manifestUrl ?? ""),
84
+ revalidateUrl: String(d.revalidateUrl ?? ""),
85
+ modules,
86
+ siteHasKey: d.siteHasKey === true,
87
+ readyToken: String(d.readyToken),
88
+ };
89
+ ctx.ui.info(`Connected to ${ctx.state.siteName} (${ctx.state.siteKey}). Modules: ${ctx.state.modules.join(", ")}.`);
90
+ },
91
+ },
92
+ {
93
+ id: "install",
94
+ title: "Install the package",
95
+ why: "Adds WMM Studio's package to this app.",
96
+ async run(ctx) {
97
+ const r = await ctx.run("npm", ["install", "--install-links", "@web-my-money/studio-consumer@^2.3"], { cwd: ctx.cwd });
98
+ if (r.code !== 0) ctx.ui.stop("npm could not install the package.", "npm install --install-links @web-my-money/studio-consumer@^2.3");
99
+ },
100
+ },
101
+ {
102
+ id: "files",
103
+ title: "Add Studio's files",
104
+ why: "Writes the small files Studio talks to, and lets Studio show this site inside its editor.",
105
+ async run(ctx) {
106
+ const s = need(ctx);
107
+ if (s.modules.includes("content")) {
108
+ s.theme = await ctx.ui.ask(
109
+ "Brand theme id for this site (the client's theme in WMM's design system)",
110
+ s.theme ?? s.siteKey,
111
+ );
112
+ }
113
+ const report = await writeSiteFiles({
114
+ cwd: ctx.cwd,
115
+ values: { siteKey: s.siteKey, theme: s.theme ?? s.siteKey, style: "flat" },
116
+ modules: s.modules,
117
+ });
118
+ for (const f of report.written) ctx.ui.info(`Added ${f}`);
119
+ for (const f of report.skipped) ctx.ui.info(`Left ${f.path} alone: ${f.reason}.`);
120
+ const wrapped = await wrapNextConfig({ cwd: ctx.cwd });
121
+ if (wrapped === "wrapped") ctx.ui.info("Wrapped next.config with withStudio.");
122
+ else if (wrapped === "already") ctx.ui.info("next.config already uses withStudio.");
123
+ else ctx.ui.info(wrapped.manual);
124
+ },
125
+ },
126
+ {
127
+ id: "vercel",
128
+ title: "Connect Vercel",
129
+ why: "Links this folder to its Vercel project under the WMM team, so settings and deploys land in the right place.",
130
+ async run(ctx) {
131
+ const s = need(ctx);
132
+ s.scope = await ctx.ui.ask("Vercel team", s.scope ?? DEFAULT_SCOPE);
133
+ const p = provisioner(ctx);
134
+
135
+ // Vercel deploys from GitHub, so the app needs a repository first.
136
+ const origin = await ctx.run("git", ["remote", "get-url", "origin"], { cwd: ctx.cwd });
137
+ if (origin.code !== 0) {
138
+ const create = await ctx.ui.confirm(
139
+ `This app is not on GitHub yet. Create the private repository Web-My-Money/${s.siteKey} for it?`,
140
+ );
141
+ if (!create) {
142
+ ctx.ui.stop(
143
+ "Vercel needs the app on GitHub to deploy it. Create a repository, then run this again.",
144
+ `gh repo create Web-My-Money/${s.siteKey} --private --source . --remote origin`,
145
+ );
146
+ }
147
+ await p.createRepoFromSource(s.siteKey);
148
+ ctx.ui.info(`Created Web-My-Money/${s.siteKey}.`);
149
+ }
150
+
151
+ const ok = await ctx.ui.confirm(`Link this folder to the Vercel project "${s.siteKey}" in "${s.scope}"?`);
152
+ if (!ok) ctx.ui.stop("Setup needs a Vercel project. Run this again when you are ready to link it.");
153
+ await p.linkVercel(s.siteKey);
154
+ ctx.ui.info(`Linked to the Vercel project "${s.siteKey}" (${s.scope}).`);
155
+ },
156
+ },
157
+ {
158
+ id: "env",
159
+ title: "Set environment variables",
160
+ why: "Tells the site where Studio is and gives it its private analytics key. The key goes straight to Vercel and is never shown.",
161
+ async run(ctx) {
162
+ const s = need(ctx);
163
+ const p = provisioner(ctx);
164
+ /** @type {[string, string][]} */
165
+ const plain = [];
166
+ if (s.modules.includes("content")) {
167
+ plain.push(["STUDIO_CONTENT_URL", s.studioUrl], ["NEXT_PUBLIC_STUDIO_ORIGIN", s.studioUrl]);
168
+ }
169
+ if (s.modules.includes("analytics")) plain.push(["NEXT_PUBLIC_FUNNEL_ANALYTICS", "1"]);
170
+ for (const [name, value] of plain) {
171
+ if (await p.hasEnv(name)) ctx.ui.info(`${name} is already set; kept.`);
172
+ else {
173
+ await p.setEnv(name, value, TARGETS);
174
+ ctx.ui.info(`Set ${name}.`);
175
+ }
176
+ }
177
+ if (!s.modules.includes("analytics")) return;
178
+
179
+ const has = await p.hasEnv("FUNNEL_INGEST_TOKEN");
180
+ if (!s.siteHasKey) {
181
+ // A new site: Studio creates its key now, at the moment it is saved to
182
+ // Vercel, so an earlier stop cannot lose it.
183
+ if (has) {
184
+ const replace = await ctx.ui.confirm(
185
+ "FUNNEL_INGEST_TOKEN is already set on Vercel, but this site gets a new key from Studio. Replace the old value (recommended)?",
186
+ );
187
+ if (!replace) return ctx.ui.info("Kept the existing FUNNEL_INGEST_TOKEN. Analytics only work if it matches Studio's key.");
188
+ }
189
+ const key = await requestKey(s.studioUrl, s.readyToken, ctx.fetchImpl);
190
+ if (key.ok) {
191
+ await p.setEnv("FUNNEL_INGEST_TOKEN", key.ingestKey, TARGETS, { sensitive: true, force: has });
192
+ return ctx.ui.info("Set FUNNEL_INGEST_TOKEN (this site's analytics key).");
193
+ }
194
+ if (!key.inUse) return ctx.ui.stop(key.message);
195
+ } else if (has) {
196
+ return ctx.ui.info("FUNNEL_INGEST_TOKEN is already set; kept.");
197
+ }
198
+
199
+ ctx.ui.info("This site already has an analytics key in Studio, so Studio will not create another one.");
200
+ ctx.ui.info(`Get a fresh one: in Studio open ${s.siteName}, Site settings, Rotate key, and copy the key it shows.`);
201
+ const pasted = await ctx.ui.askSecret("Paste the key");
202
+ if (!/^wmmk_[A-Za-z0-9_-]{20,}$/.test(pasted)) {
203
+ ctx.ui.stop("That does not look like a Studio analytics key (it starts with wmmk_). Run this again and paste it once more.");
204
+ }
205
+ await p.setEnv("FUNNEL_INGEST_TOKEN", pasted, TARGETS, { sensitive: true });
206
+ ctx.ui.info("Set FUNNEL_INGEST_TOKEN.");
207
+ },
208
+ },
209
+ {
210
+ id: "claude",
211
+ title: "Wire the layout and choose the editable text",
212
+ why: "Claude Code reads your app, shows each change it suggests, and writes it only after your yes.",
213
+ async run(ctx) {
214
+ await claudeStep({ cwd: ctx.cwd, run: ctx.run, ui: ctx.ui });
215
+ },
216
+ },
217
+ {
218
+ id: "verify",
219
+ title: "Check everything",
220
+ why: "Runs the app's checks and build, so nothing broken gets deployed.",
221
+ async run(ctx) {
222
+ const pkg = JSON.parse(readFileSync(path.join(ctx.cwd, "package.json"), "utf8"));
223
+ const script = pkg.scripts?.verify ? "verify" : "build";
224
+ // The terminal belongs to the checks while they run.
225
+ ctx.ui.pause();
226
+ const r = await ctx.run("npm", ["run", script], { cwd: ctx.cwd, inherit: true }).finally(() => ctx.ui.resume());
227
+ if (r.code !== 0) {
228
+ ctx.ui.stop(
229
+ `\`npm run ${script}\` failed. Fix what it reported (Claude Code can help: run \`claude\` here), then run this again; it continues from this step.`,
230
+ `npm run ${script}`,
231
+ );
232
+ }
233
+ },
234
+ },
235
+ {
236
+ id: "publish",
237
+ title: "Publish",
238
+ why: "Puts these changes on their own branch and opens a pull request for you to merge.",
239
+ async run(ctx) {
240
+ const ok = await ctx.ui.confirm(`Commit everything on a new branch "${BRANCH}", push it and open a pull request?`);
241
+ if (!ok) {
242
+ ctx.ui.stop("Commit, push and deploy these changes your usual way, then run this again; it will wait for the site.");
243
+ }
244
+ // Every part is safe to rerun: an existing branch is reused, an empty
245
+ // commit is skipped, an open pull request is kept.
246
+ const s = need(ctx);
247
+ const head = await ctx.run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.cwd });
248
+ // The branch the pull request goes into: the one the app was on before
249
+ // this step, remembered so a rerun (already on the onboarding branch)
250
+ // still knows it.
251
+ if (!s.baseBranch) {
252
+ const current = head.stdout.trim();
253
+ s.baseBranch = current && current !== BRANCH && current !== "HEAD" ? current : "main";
254
+ saveState(ctx.cwd, s);
255
+ }
256
+ const base = s.baseBranch;
257
+ // A repository this command just created has nothing on GitHub yet. Push
258
+ // the base first, so it becomes the default branch and the pull request
259
+ // has something to go into. (Found in the real end-to-end run.)
260
+ const remoteBase = await ctx.run("git", ["ls-remote", "--heads", "origin", base], { cwd: ctx.cwd });
261
+ if (remoteBase.code === 0 && remoteBase.stdout.trim() === "" && head.stdout.trim() === base) {
262
+ await must(ctx, "git", ["push", "-u", "origin", base]);
263
+ }
264
+ if (head.stdout.trim() !== BRANCH) {
265
+ const exists = await ctx.run("git", ["rev-parse", "--verify", "--quiet", BRANCH], { cwd: ctx.cwd });
266
+ await must(ctx, "git", exists.code === 0 ? ["checkout", BRANCH] : ["checkout", "-b", BRANCH]);
267
+ }
268
+ await must(ctx, "git", ["add", "-A"]);
269
+ // Secrets and machine-local settings never go into the commit, even in an
270
+ // app whose .gitignore does not cover them.
271
+ const staged = await ctx.run("git", ["diff", "--cached", "--name-only"], { cwd: ctx.cwd });
272
+ const files = staged.stdout.split(/\r?\n/).map((f) => f.trim()).filter(Boolean);
273
+ for (const f of files.filter(isPrivateFile)) {
274
+ await must(ctx, "git", ["reset", "-q", "--", f]);
275
+ ctx.ui.info(`Left ${f} out of the commit (it holds secrets or local settings).`);
276
+ }
277
+ if (files.some((f) => !isPrivateFile(f))) {
278
+ await must(ctx, "git", ["commit", "-m", "Connect this site to WMM Studio"]);
279
+ } else {
280
+ ctx.ui.info("Nothing new to commit.");
281
+ }
282
+ await must(ctx, "git", ["push", "-u", "origin", BRANCH]);
283
+ const pr = await ctx.run("gh", ["pr", "list", "--head", BRANCH, "--json", "number"], { cwd: ctx.cwd });
284
+ const open = pr.code === 0 && pr.stdout.trim() !== "" && pr.stdout.trim() !== "[]";
285
+ if (open) ctx.ui.info("The pull request is already open.");
286
+ else await must(ctx, "gh", ["pr", "create", "--fill", "--base", base, "--head", BRANCH]);
287
+ ctx.ui.info("Merge the pull request (or deploy your usual way); the next step waits for the live site.");
288
+ },
289
+ },
290
+ {
291
+ id: "wait",
292
+ title: "Wait for the site",
293
+ why: "Watches the live site until it answers Studio, so the sync in the last step sees the new code.",
294
+ async run(ctx) {
295
+ const s = need(ctx);
296
+ if (!s.manifestUrl) {
297
+ ctx.ui.stop(
298
+ `Studio has no address for ${s.siteName} yet. In Studio open the site, Site settings, set its manifest URL (https://<domain>/api/content/manifest), then run this again.`,
299
+ );
300
+ }
301
+ for (let i = 0; i < POLL_TIMES; i++) {
302
+ try {
303
+ const res = await ctx.fetchImpl(s.manifestUrl, { cache: "no-store" });
304
+ if (res.ok) {
305
+ const body = /** @type {{ siteKey?: unknown }} */ (await res.json());
306
+ if (body.siteKey === s.siteKey) return ctx.ui.info(`The site is answering at ${s.manifestUrl}.`);
307
+ ctx.ui.stop(
308
+ `${s.manifestUrl} answers, but for "${String(body.siteKey)}", not "${s.siteKey}". Check SITE_KEY in lib/studio.ts and the address in Studio's Site settings.`,
309
+ );
310
+ }
311
+ } catch (e) {
312
+ if (e instanceof StopError) throw e;
313
+ }
314
+ if (i > 0 && i % 6 === 0) ctx.ui.info(`Still waiting for ${s.manifestUrl} (${i / 6} min)…`);
315
+ await ctx.sleep(POLL_EVERY_MS);
316
+ }
317
+ ctx.ui.stop(
318
+ `The site is not answering at ${s.manifestUrl} yet. Merge the pull request and let it deploy, then run this again; it continues from here.`,
319
+ );
320
+ },
321
+ },
322
+ {
323
+ id: "ready",
324
+ title: "Tell Studio",
325
+ why: "Asks Studio to read the site's editable content, so it appears in the editor.",
326
+ async run(ctx) {
327
+ const s = need(ctx);
328
+ const r = await markReady(s.studioUrl, s.readyToken, ctx.fetchImpl);
329
+ if (!r.ok) return ctx.ui.stop(r.message);
330
+ const report = /** @type {{ created?: unknown[], updated?: unknown[] }} */ (r.data.report ?? {});
331
+ const count = (report.created?.length ?? 0) + (report.updated?.length ?? 0);
332
+ ctx.ui.info("");
333
+ ctx.ui.info(`Done. ${s.siteName} is connected to WMM Studio${count ? ` with ${count} editable item${count === 1 ? "" : "s"}` : ""}.`);
334
+ ctx.ui.info(`Edit it at ${s.studioUrl.replace(/\/+$/, "")}/sites/${s.siteKey}`);
335
+ ctx.ui.info("The onboarding checklist there fills in as real visitors arrive.");
336
+ ctx.ui.info("Still for a person, if they apply: the client's brand colours in WMM's design system, and the CRM and ads hand-offs.");
337
+ },
338
+ },
339
+ ];
340
+
341
+ /** @param {Ctx} ctx @returns {OnboardingState} */
342
+ function need(ctx) {
343
+ if (!ctx.state) throw new StopError("Setup lost its place. Run this again with a new code from Studio.");
344
+ return ctx.state;
345
+ }
346
+
347
+ /** @param {Ctx} ctx @returns {AnyProvisioner} */
348
+ function provisioner(ctx) {
349
+ if (ctx.injectedProvisioner) return ctx.injectedProvisioner;
350
+ // The team the developer chose is saved in state, so a rerun uses it too.
351
+ const scope = ctx.state?.scope ?? DEFAULT_SCOPE;
352
+ if (!ctx.provisioner || ctx.provisionerScope !== scope) {
353
+ ctx.provisioner = cliProvisioner({ cwd: ctx.cwd, run: ctx.run, scope });
354
+ ctx.provisionerScope = scope;
355
+ }
356
+ return ctx.provisioner;
357
+ }
358
+
359
+ /** Env files and machine-local settings: never committed by this command. @param {string} file */
360
+ function isPrivateFile(file) {
361
+ const base = file.split("/").pop() ?? file;
362
+ if (base.startsWith(".env") && !/\.(example|sample|template)$/.test(base)) return true;
363
+ return file === ".claude/settings.local.json" || file.startsWith(".wmm-onboarding/") || file.startsWith(".vercel/");
364
+ }
365
+
366
+ /** @param {Ctx} ctx @param {string} cmd @param {string[]} args */
367
+ async function must(ctx, cmd, args) {
368
+ const r = await ctx.run(cmd, args, { cwd: ctx.cwd });
369
+ if (r.code !== 0) ctx.ui.stop(`\`${cmd} ${args.join(" ")}\` failed: ${(r.stderr || r.stdout).trim()}`, `${cmd} ${args.join(" ")}`);
370
+ }
371
+
372
+ /**
373
+ * @param {string[]} args
374
+ * @param {Partial<{ cwd: string, input: NodeJS.ReadableStream, output: NodeJS.WritableStream, run: Runner, fetchImpl: Fetch, provisioner: AnyProvisioner, sleep: (ms: number) => Promise<void> }>} [deps]
375
+ * @returns {Promise<number>} exit code
376
+ */
377
+ export async function runInit(args, deps = {}) {
378
+ const explain = args.includes("--explain");
379
+ const studioAt = args.indexOf("--studio");
380
+ const studioUrl = studioAt >= 0 && args[studioAt + 1] ? args[studioAt + 1] : DEFAULT_STUDIO;
381
+ const code = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--studio") ?? null;
382
+ const cwd = deps.cwd ?? process.cwd();
383
+ const output = deps.output ?? process.stdout;
384
+ const ui = createUi({ input: deps.input ?? process.stdin, output, explain });
385
+
386
+ output.write("\nWMM Studio setup\n");
387
+ if (explain) {
388
+ output.write("Showing every step without changing anything.\n");
389
+ STEPS.forEach((s, i) => ui.step(i + 1, STEPS.length, s.title, s.why));
390
+ ui.close();
391
+ return 0;
392
+ }
393
+
394
+ const saved = loadState(cwd);
395
+ /** @type {Ctx} */
396
+ const ctx = {
397
+ cwd,
398
+ ui,
399
+ run: deps.run ?? createRunner(),
400
+ fetchImpl: deps.fetchImpl ?? fetch,
401
+ sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
402
+ studioUrl,
403
+ code,
404
+ state: saved,
405
+ provisioner: null,
406
+ injectedProvisioner: deps.provisioner ?? null,
407
+ };
408
+
409
+ try {
410
+ for (const [i, step] of STEPS.entries()) {
411
+ const done = ctx.state?.done.includes(step.id) ?? false;
412
+ // The code is re-exchanged when a new one is given, even mid-setup.
413
+ const redo = step.id === "connect" && ctx.code !== null;
414
+ if (done && !redo) {
415
+ ui.step(i + 1, STEPS.length, step.title, "Already done.");
416
+ continue;
417
+ }
418
+ ui.step(i + 1, STEPS.length, step.title, step.why);
419
+ await step.run(ctx);
420
+ if (step.id !== "preflight" && ctx.state) {
421
+ if (!ctx.state.done.includes(step.id)) ctx.state.done.push(step.id);
422
+ saveState(cwd, ctx.state);
423
+ }
424
+ }
425
+ return 0;
426
+ } catch (err) {
427
+ if (err instanceof StopError) {
428
+ output.write(`\n ${err.message}\n`);
429
+ if (err.nextCommand) output.write(` Next: ${err.nextCommand}\n`);
430
+ } else {
431
+ output.write(`\n Something went wrong: ${err instanceof Error ? err.message : String(err)}\n`);
432
+ output.write(" Run this again; it continues from where it stopped.\n");
433
+ }
434
+ return 1;
435
+ } finally {
436
+ ui.close();
437
+ }
438
+ }
439
+
@@ -0,0 +1,78 @@
1
+ // @ts-check
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Step 1 of the onboarding command: check the machine and the app BEFORE
7
+ * changing anything, and stop with the exact next command when something is
8
+ * missing (spec 2026-10-09 §3.1). Nothing here writes.
9
+ */
10
+
11
+ /** @typedef {import("./run.mjs").Runner} Runner */
12
+ /** @typedef {import("./ui.mjs").Ui} Ui */
13
+
14
+ /** @param {string} version */
15
+ function majorOf(version) {
16
+ const m = /(\d+)/.exec(version);
17
+ return m ? Number(m[1]) : null;
18
+ }
19
+
20
+ /**
21
+ * @param {{ cwd: string, run: Runner, ui: Ui, resuming?: boolean }} ctx
22
+ * @returns {Promise<{ mode: "existing", nextMajor: number | null }>}
23
+ */
24
+ export async function preflight({ cwd, run, ui, resuming = false }) {
25
+ const node = majorOf(process.versions.node) ?? 0;
26
+ if (node < 22) {
27
+ ui.stop(`This needs Node.js 22 or newer; you have ${process.versions.node}.`, "Install Node 22 from https://nodejs.org");
28
+ }
29
+
30
+ const pkgPath = path.join(cwd, "package.json");
31
+ if (!existsSync(pkgPath)) {
32
+ const entries = readdirSync(cwd).filter((f) => f !== ".git");
33
+ if (entries.length === 0) {
34
+ ui.stop(
35
+ "This folder is empty, so it would be a new site from WMM's template. That path opens once the template is upgraded to Next.js 16; for now, run this inside an existing Next.js app.",
36
+ );
37
+ }
38
+ ui.stop("Run this inside the app's folder: the one that has package.json.");
39
+ }
40
+
41
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
42
+ const nextVersion = pkg.dependencies?.next ?? pkg.devDependencies?.next ?? null;
43
+ if (!nextVersion) ui.stop("This app does not use Next.js. Studio's package needs a Next.js 16 app.");
44
+ const nextMajor = majorOf(nextVersion);
45
+ if (nextMajor !== null && nextMajor < 16) {
46
+ ui.stop(
47
+ `This app uses Next.js ${nextMajor}. Studio needs Next.js 16 or newer. Upgrade first (this command will not do it for you), then run this again.`,
48
+ "npx @next/codemod@canary upgrade latest",
49
+ );
50
+ }
51
+
52
+ const gh = await run("gh", ["auth", "status"], { cwd });
53
+ if (gh.code === 127) ui.stop("The GitHub CLI is not installed.", "Install it from https://cli.github.com");
54
+ if (gh.code !== 0) ui.stop("You are not signed in to GitHub.", "gh auth login");
55
+
56
+ const vercel = await run("vercel", ["whoami"], { cwd });
57
+ if (vercel.code === 127) ui.stop("The Vercel CLI is not installed.", "npm install -g vercel");
58
+ if (vercel.code !== 0) ui.stop("You are not signed in to Vercel.", "vercel login");
59
+
60
+ const git = await run("git", ["status", "--porcelain"], { cwd });
61
+ if (git.code !== 0) ui.stop("This folder is not a git repository yet.", "git init");
62
+ // A resumed run expects the changes its own earlier steps made.
63
+ if (git.stdout.trim() !== "" && !resuming) {
64
+ ui.stop(
65
+ "You have uncommitted changes. Commit or stash them first, so the changes this command makes are easy to review on their own.",
66
+ "git stash",
67
+ );
68
+ }
69
+
70
+ const claude = await run("claude", ["--version"], { cwd });
71
+ if (claude.code !== 0) {
72
+ ui.info(
73
+ "Heads up: Claude Code is not installed. You will need it at step 7, where it helps wire your layout and choose the editable text. Install: https://claude.com/claude-code",
74
+ );
75
+ }
76
+
77
+ return { mode: "existing", nextMajor };
78
+ }