@web-my-money/studio-consumer 2.1.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.
@@ -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 */