/** * The presentation surface the setup wizard ({@link setup-wizard.ts}) codes * against. Nothing about it is terminal-specific: any driver that satisfies this * exact shape can drive the wizard, and today that driver is the terminal * `omp-conductor setup` verb (#309 deleted the in-session dialog). * * Three of the four methods are prompt-bearing. The load-bearing contracts, * each of which the wizard already relies on and a future refactor would break * silently: * * - `input` resolves the placeholder on an empty submit ("Enter accepts what * you see"), so a re-run that Enters through a prompt re-affirms the shown * default instead of blanking it. * - `select` resolves the chosen option's **label**, never its index — the * label *is* the config value for the closed-vocabulary questions, so a * lookup table that disagreed with the vocabulary it was built from would * corrupt the written config. * - `select` honours `dialogOptions.initialIndex` so a bare Enter re-affirms * the current setting (see the "cannot be a confirm" note in the wizard). * - `confirm` is `y/N`, defaulting to no. * * Every prompt resolves `undefined` when the operator dismisses it (Ctrl-C / * EOF), which every wizard caller already treats as the cancel path * (`Cancelled`). The terminal UI guarantees the readline interface is closed on * every exit path so the verb cannot hang the terminal. */ export interface PromptOptions { /** Stable answer-file key. This is a permanent CLI API, not display copy. */ key: string; } export interface SelectOptions extends PromptOptions { initialIndex?: number; } export interface WizardUi { notify(message: string, type?: "info" | "warning" | "error"): void; /** * Yes/no. `undefined` means the operator *dismissed* the prompt rather than * answering it, which is a different thing from "no": the wizard turns it into * `Cancelled` and abandons the run. Collapsing the two is how Ctrl-C at a * confirm used to record a silent "no" and carry on to the next question. */ confirm(title: string, message: string, promptOptions: PromptOptions): Promise; /** Single-line text prompt. `undefined` dismisses it; an empty submit accepts * the placeholder. */ input(title: string, placeholder: string | undefined, promptOptions: PromptOptions): Promise; /** Single-choice list. On an interactive TTY the current option is rendered * inline and moved with ↑/↓ or j/k; on a pipe it stays the numbered wall. * Resolves the chosen option's label, or `undefined` when dismissed. */ select( title: string, options: { label: string; description?: string }[], dialogOptions: SelectOptions, ): Promise; } import { createInterface, type Interface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import type { Readable, Writable } from "node:stream"; /** * A `WizardUi` over a real terminal — or a pipe, which is how the verb is * scripted in tests — backed by `node:readline/promises`. * * **One interface for the whole wizard, closed once.** The first version of this * created and closed a readline interface per prompt, on the theory that * releasing stdin after each question could not leak. It cannot: closing a * readline interface ends the shared stdin stream, so the *second* prompt of a * piped run had no readable input left and the run hung indefinitely — the exact * failure this comment used to claim it prevented. The interface therefore lives * as long as the wizard, and {@link TerminalUi.close} releases it once, from the * caller's `finally`. * * The one exception is {@link TerminalUi.select} on a real TTY, which takes the * terminal over in raw mode. A live readline interface echoes every typed * character back to the output and folds Enter into a `line` event, so it would * smear the inline option row with pressed keys and drop stray lines into the * buffer underneath it. That prompt closes the interface, reads the terminal * byte by byte, and then re-creates the interface so the line protocol is still * alive for the next prompt. The re-opening works on a TTY (the stream itself * is untouched; closing only pauses it, and it is resumed), and the closed * interface's key listeners are dropped, so nothing leaks across repeated * passes of a menu. * * Dismissal is the interface's own `close`: `rl.question()` on an exhausted * stream never settles on its own, so EOF (a script that ran out of answers) and * Ctrl-C both close the interface, and the line reader turns that into the * `undefined` every wizard caller reads as cancel. * * `io` exists so the contracts below can be tested in-process against a pipe * rather than only through a spawned CLI; production callers pass nothing. */ export interface TerminalUi extends WizardUi { /** Releases stdin; interactive drivers may render the outcome message. Idempotent. */ close(message?: string): void; } export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi { const input = io.input ?? stdin; const output = io.output ?? stdout; let rl: Interface = createInterface({ input, output }); let closed = false; let buffered: string[] = []; let waiting: ((value: string | undefined) => void) | undefined; let ended = false; const deliver = (value: string | undefined): boolean => { const resolve = waiting; if (resolve === undefined) return false; waiting = undefined; resolve(value); return true; }; // Lines are buffered as they arrive rather than read on demand, because on a // non-TTY stdin readline drains the whole pipe at once and emits `line` for // every one of them immediately. `rl.question()` only captures the line that // arrives *after* it is called, so a scripted run lost every answer that had // already flown past and then hit EOF — three prompts resolving `undefined` // and a wizard that cancelled itself. Buffering makes a pipe and a terminal // behave the same: nothing is read before it is asked for, nothing is lost. const attach = (iface: Interface): void => { // Ctrl-C on the line protocol is a cancel, not a signal to the whole // process: closing the interface (on SIGINT or on EOF) makes every pending // and subsequent prompt resolve `undefined`, which the wizard already turns // into `Cancelled`. The raw-mode select handles Ctrl-C itself; every other // prompt relies on this. iface.once("SIGINT", close); iface.on("line", (text: string) => { if (!deliver(text)) buffered.push(text); }); iface.once("close", () => { ended = true; deliver(undefined); }); }; const close = (): void => { if (closed) return; closed = true; rl.close(); }; // The line protocol runs on the *current* interface. An interactive select // takes the terminal over in raw mode, so the interface that was echoing // keystrokes and folding Enter into lines has to be closed out of the way and // a fresh one opened for the prompts that follow (see the lifecycle comment // at the top of the interface declaration). const reopen = (): void => { rl.close(); buffered = []; waiting = undefined; ended = false; rl = createInterface({ input, output }); attach(rl); }; attach(rl); /** One line, or `undefined` when the operator dismissed the prompt. */ const line = async (query: string): Promise => { if (closed && buffered.length === 0) return undefined; output.write(query); const ready = buffered.shift(); if (ready !== undefined) return ready; if (ended) return undefined; return await new Promise((resolve) => { waiting = resolve; }); }; // -------- the interactive select: raw keys, one changing row -------- // // On a real TTY the numbered wall is replaced by a single row that shows the // option under the cursor and follows ↑/↓ or j/k. The keys are read from the // raw stream byte by byte rather than through readline's keypress decode: // readline only emits a lone Escape after its own escape-sequence timeout, // and CSI sequences split across chunks have to be reassembled anyway. The // terminal is taken over only for the duration of the prompt and restored on // every exit path. type TtyInput = Readable & { isTTY: boolean; isRaw?: boolean; setRawMode(mode: boolean): void; }; const asTty = (stream: Readable): TtyInput | undefined => { const candidate = stream as Readable & Partial; return candidate.isTTY === true && typeof candidate.setRawMode === "function" ? (candidate as TtyInput) : undefined; }; const ESC = 27; const ENTERS = new Set([13, 10]); const CTRL_C = 3; const UP = 65; // CSI A after ESC [ const DOWN = 66; // CSI B after ESC [ const KEY_J = 106; // lower-case j: down const KEY_K = 107; // lower-case k: up const KEY_J_UP = 74; // J const KEY_K_UP = 75; // K return { close, notify(message) { output.write(`${message}\n`); }, async confirm(title, message) { for (;;) { const answer = await line(`${title} — ${message} (y/N) `); // Dismissal is NOT "no". Ctrl-C or EOF here means the operator left, and // reporting `false` made the interview march on to the next question with // a silent "no" recorded — so `Ctrl-C abandons the run` was untrue for // every yes/no prompt. The wizard turns this into `Cancelled`. if (answer === undefined) return undefined; const a = answer.trim().toLowerCase(); if (a === "y" || a === "yes") return true; // Enter is still the safe answer: no, and the interview continues. if (a === "n" || a === "no" || a === "") return false; output.write("Please answer y or n.\n"); } }, async input(title, placeholder) { const query = placeholder !== undefined && placeholder.length > 0 ? `${title} [${placeholder}]: ` : `${title}: `; const answer = await line(query); if (answer === undefined) return undefined; // "Enter accepts what you see": an empty submit keeps the placeholder. return answer.trim().length === 0 ? (placeholder ?? "") : answer; }, async select(title, options, dialogOptions) { const tty = asTty(input); if (tty === undefined) { // Non-TTY stdin (a pipe, a test harness, CI): the numbered wall, kept // byte-identical so the scripted protocol cannot drift. output.write(`${title}\n`); options.forEach((o, i) => { output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`); }); const initial = dialogOptions?.initialIndex ?? 0; const defaultLabel = options[initial]?.label ?? ""; for (;;) { const answer = await line( `Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `, ); if (answer === undefined) return undefined; const a = answer.trim(); if (a === "") return defaultLabel; // a bare Enter re-affirms the current row const n = Number(a); if (Number.isInteger(n)) { const picked = options[n - 1]; if (picked !== undefined) return picked.label; } output.write(`Please enter a number between 1 and ${options.length}.\n`); } } if (options.length === 0) return undefined; // Clamp rather than wrap: the row is the operator's place in the list and // pressing past an edge should not fly from bottom to top (or the other // way); a bare Enter is "keep the current row", so the position is a // setting, not a spinner. const bound = (index: number): number => Math.max(0, Math.min(index, options.length - 1)); let index = bound(dialogOptions?.initialIndex ?? 0); const row = (at: number): string => { const o = options[at]; return ` › ${o?.label ?? ""}${o?.description !== undefined ? ` — ${o.description}` : ""}`; }; let prevLen = 0; const paint = (): void => { const text = row(index); output.write(`\r${" ".repeat(prevLen)}\r${text}`); prevLen = text.length; }; let settled: ((value: string | undefined) => void) | undefined; let failed: ((err: unknown) => void) | undefined; let escapeState: "idle" | "esc" | "seq" = "idle"; let escapeTimer: ReturnType | undefined; const clearEscape = (): void => { if (escapeTimer !== undefined) { clearTimeout(escapeTimer); escapeTimer = undefined; } }; // Escape sequences arrive byte by byte and a lone ESC cannot be told // apart from the start of `ESC [ A` until the next byte lands; reading // the whole chunk at once handles the common atomic write, and a short // timer turns an unfinished sequence into a plain Escape. const armEscape = (): void => { if (escapeTimer !== undefined) return; escapeTimer = setTimeout(() => { escapeTimer = undefined; escapeState = "idle"; settled?.(undefined); }, 30); }; const onData = (chunk: Buffer): void => { try { for (const byte of chunk) { switch (escapeState) { case "esc": { clearEscape(); if (byte === 91 || byte === 79) { // ESC [ … or ESC O … — wait for the direction letter. escapeState = "seq"; armEscape(); continue; } // ESC followed by anything else: a plain Escape press. escapeState = "idle"; settled?.(undefined); continue; } case "seq": { clearEscape(); escapeState = "idle"; if (byte === 65) index = bound(index - 1); else if (byte === 66) index = bound(index + 1); else continue; // an unknown CSI sequence is not a key we know paint(); continue; } case "idle": { if (ENTERS.has(byte)) { settled?.(options[index]?.label); continue; } if (byte === CTRL_C) { settled?.(undefined); continue; } if (byte === ESC) { escapeState = "esc"; armEscape(); continue; } if (byte === KEY_J || byte === KEY_J_UP) { index = bound(index + 1); paint(); continue; } if (byte === KEY_K || byte === KEY_K_UP) { index = bound(index - 1); paint(); continue; } // Any other byte (typos, mouse reports) is ignored. continue; } } } } catch (err) { failed?.(err); } }; const onSignal = (signal: NodeJS.Signals): void => { // A real signal (not the Ctrl-C byte, which raw mode turns into the // cancel above) still kills the process — but only after the terminal // has been handed back, so the operator's shell is never left raw. clearEscape(); input.removeListener("data", onData); tty.setRawMode(wasRaw); process.removeListener("SIGINT", onSignal); process.removeListener("SIGTERM", onSignal); process.removeListener("SIGHUP", onSignal); process.kill(process.pid, signal); }; let wasRaw = false; try { // Take the terminal over. The shared interface would echo every pressed // key back into the row and fold Enter into a `line` the next prompt // would swallow, so it is closed for the duration and re-opened below. rl.close(); input.resume(); // Captured *after* the close: while the interface is alive it keeps the // terminal raw (bun's readline does), and restoring to that state would // leave the operator's shell raw after a signal. wasRaw = tty.isRaw ?? false; tty.setRawMode(true); output.write(`${title}\n`); output.write(" (↑/↓ or j/k to move · Enter accepts · Ctrl-C or Esc cancels)\n"); paint(); const done = new Promise((resolve, reject) => { settled = resolve; failed = reject; }); input.on("data", onData); process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal); process.once("SIGHUP", onSignal); const picked = await done; output.write("\n"); return picked; } finally { // Every exit path — accept, cancel, an exception in rendering, a signal // that did not kill us — restores the terminal exactly as it was found // and re-opens the line interface for the prompts that follow. clearEscape(); input.removeListener("data", onData); tty.setRawMode(wasRaw); process.removeListener("SIGINT", onSignal); process.removeListener("SIGTERM", onSignal); process.removeListener("SIGHUP", onSignal); reopen(); } }, }; } /** * The test seam: a `WizardUi` driven by a script keyed by a substring of each * prompt's title. * * **Anything unscripted takes the answer an operator gets by pressing Enter** — * an empty `input` (which the wizard reads as "accept the shown default"), `no` * on a `confirm`, and the row the caller put the cursor on for a `select`. That * leniency is deliberate and is itself the thing under test: "Enter accepts what * you see" is the contract the whole wizard is built on, so a test that has to * script an answer is a test whose default moved. Throwing on an unscripted * prompt would make that property untestable. * * Asking an *unintended* question is caught a stronger way instead: the suite * pins the full ordered list of prompts, so a question that appears or vanishes * fails on the transcript rather than on a missing script entry. * * - `input`: the scripted string, else `""` (Enter → the wizard's fallback). * - `confirm`: the scripted boolean, else `false`. `null` dismisses the prompt * (`undefined`), which the wizard reads as cancel — the same spelling * `select` uses. * - `select`: a number picks that option's **label**; a string matches an * offered label by prefix; `null` dismisses the prompt (`undefined`, which * the wizard reads as cancel); unscripted takes `initialIndex`'s label. * - An array answers successive prompts sharing the same title; the last entry * repeats. */ export interface ScriptedAnswers { input?: Record; confirm?: Record; /** The array variant is what lets one script drive a repeated menu (#417): * the review loop's consent menu can be answered "decline, then apply". */ select?: Record; } export function scriptedUi(script: ScriptedAnswers = {}): WizardUi { const seen = new Map(); const answer = (table: Record | undefined, title: string): T | undefined => { for (const [key, value] of Object.entries(table ?? {})) { if (!title.includes(key)) continue; if (!Array.isArray(value)) return value; const n = seen.get(key) ?? 0; seen.set(key, n + 1); return (value[Math.min(n, value.length - 1)] ?? value.at(-1)) as T; } return undefined; }; return { notify() { /* recorded by the caller's wrapper when a test asserts on notices */ }, async confirm(title) { const scripted = answer(script.confirm, title); if (scripted === null) return undefined; return Array.isArray(scripted) ? false : (scripted ?? false); }, async input(title) { return answer(script.input, title) ?? ""; }, async select(title, options, dialogOptions) { const chosen = answer(script.select, title); if (chosen === null) return undefined; if (typeof chosen === "number") return options[chosen]?.label; if (typeof chosen === "string") return options.find((o) => o.label.startsWith(chosen))?.label ?? chosen; // Enter: the row the caller put the cursor on. return options[dialogOptions?.initialIndex ?? 0]?.label; }, }; }