import { readFileSync, writeFileSync } from "node:fs"; import { z } from "zod"; import type { PromptOptions, SelectOptions, TerminalUi, WizardUi } from "./wizard-ui.ts"; const answerFileSchema = z.record(z.string(), z.union([z.string(), z.boolean()])); export type SetupAnswerValues = Record; function promptName(title: string, options: PromptOptions): string { return `answer "${options.key}" for "${title}"`; } function requiredAnswer(answers: SetupAnswerValues, title: string, options: PromptOptions): string | boolean { if (!Object.hasOwn(answers, options.key)) { throw new Error(`missing ${promptName(title, options)}; add it to the --answers file`); } return answers[options.key]!; } /** A prompt-free WizardUi backed only by validated answers. */ export function answersUi( answers: SetupAnswerValues, notify: WizardUi["notify"] = (message) => process.stdout.write(`${message}\n`), ): TerminalUi { return { notify, close: () => {}, async confirm(title, _message, options) { const answer = requiredAnswer(answers, title, options); if (typeof answer !== "boolean") { throw new Error(`${promptName(title, options)} must be a boolean`); } return answer; }, async input(title, _placeholder, options) { const answer = requiredAnswer(answers, title, options); if (typeof answer !== "string") { throw new Error(`${promptName(title, options)} must be a string`); } return answer; }, async select(title, choices, options) { const answer = requiredAnswer(answers, title, options); if (typeof answer !== "string") { throw new Error(`${promptName(title, options)} must be a listed label`); } if (!choices.some((choice) => choice.label === answer)) { throw new Error(`${promptName(title, options)} must be a listed label`); } return answer; }, }; } export function loadAnswersFile(path: string): SetupAnswerValues { let raw: string; try { raw = readFileSync(path, "utf8"); } catch (err) { throw new Error(`could not read answers file "${path}": ${err instanceof Error ? err.message : String(err)}`); } let decoded: unknown; try { decoded = JSON.parse(raw); } catch (err) { throw new Error(`answers file "${path}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); } const parsed = answerFileSchema.safeParse(decoded); if (!parsed.success) { throw new Error(`answers file "${path}" must be a JSON object whose values are strings or booleans`); } return parsed.data; } export interface RecordedAnswersUi { ui: TerminalUi; answers: SetupAnswerValues; } type ClosableWizardUi = WizardUi & Partial>; /** Decorates any driver and records only answers that were actually returned. */ export function recordingAnswersUi(inner: ClosableWizardUi): RecordedAnswersUi { const answers: SetupAnswerValues = {}; return { answers, ui: { notify: (message, type) => inner.notify(message, type), close: (message) => inner.close?.(message), async confirm(title, message, options) { const answer = await inner.confirm(title, message, options); if (answer !== undefined) answers[options.key] = answer; return answer; }, async input(title, placeholder, options) { const answer = await inner.input(title, placeholder, options); if (answer !== undefined) answers[options.key] = answer; return answer; }, async select(title, choices, options) { const answer = await inner.select(title, choices, options); if (answer !== undefined) answers[options.key] = answer; return answer; }, }, }; } export function saveAnswersFile(path: string, answers: SetupAnswerValues): void { writeFileSync(path, `${JSON.stringify(answers, null, 2)}\n`, "utf8"); } /** * The resumable setup transaction (#864). * * On 2026-08-21 `setup policy` failed at its post-interview gates several times * and each retry discarded the confirmed answers: the same `runs-settled` value * was typed five times while the real fault was elsewhere. The interview is the * expensive half and it had already succeeded — only the apply phase failed — * so the answers are saved on that failure and named in the retry command. * * The binding is what stops this being a stale cache. A resume file carries the * hash of the config it was answered against, and the version that wrote it: * replaying an old plan over newer policy is precisely the silent fake here, and * refusing loudly is cheaper than a config quietly rolled back. */ export interface SetupResumeFile { /** Config bytes hash at interview time; a change refuses the replay. */ configHash: string; /** The package version that recorded it — a schema fence across upgrades. */ version: string; /** The area/project the answers belong to, for the printed retry command. */ area?: string; project?: string; answers: SetupAnswerValues; } const resumeFileSchema = z.object({ configHash: z.string().min(1), version: z.string().min(1), area: z.string().optional(), project: z.string().optional(), answers: answerFileSchema, }); /** The config generation a resume file is bound to: its exact bytes, or `absent`. */ export function setupConfigHash(read: () => string | undefined): string { const raw = read(); if (raw === undefined) return "absent"; return new Bun.CryptoHasher("sha256").update(raw).digest("hex").slice(0, 32); } /** * Persist the confirmed answers of a failed apply phase, 0600 because it holds * whatever the interview held — no more, and in the same account's state * directory the config itself lives in. */ export function saveSetupResume(path: string, file: SetupResumeFile): void { writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); } export type SetupResumeRead = | { ok: true; file: SetupResumeFile } | { ok: false; problem: string }; /** * Read a resume file and check its binding against the live config. * * A hash mismatch is refused rather than merged: the answers describe a * decision made about a different config, and applying them would overwrite * whatever changed it. So is a version mismatch — the answer keys are a schema, * and an upgraded package may have renamed one. */ export function readSetupResume(path: string, liveConfigHash: string, version: string): SetupResumeRead { let raw: string; try { raw = readFileSync(path, "utf8"); } catch (err) { return { ok: false, problem: `could not read resume file "${path}": ${err instanceof Error ? err.message : String(err)}` }; } let decoded: unknown; try { decoded = JSON.parse(raw); } catch (err) { return { ok: false, problem: `resume file "${path}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }; } const parsed = resumeFileSchema.safeParse(decoded); if (!parsed.success) { return { ok: false, problem: `resume file "${path}" is not a setup resume record` }; } if (parsed.data.version !== version) { return { ok: false, problem: `resume file "${path}" was written by omp-conductor ${parsed.data.version}, this is ${version} — ` + "the answer keys are a schema, so re-run the interview rather than replaying answers across an upgrade", }; } if (parsed.data.configHash !== liveConfigHash) { return { ok: false, problem: `resume file "${path}" was answered against a different config (recorded ${parsed.data.configHash}, ` + `live ${liveConfigHash}) — replaying it would overwrite whatever changed it. Re-run the interview`, }; } return { ok: true, file: parsed.data }; } /** Turns exhausted scripted stdin into an actionable non-interactive failure. */ export function guardNonInteractiveUi(inner: TerminalUi): TerminalUi { const exhausted = (title: string, options: PromptOptions): never => { throw new Error( `stdin ended before ${promptName(title, options)}; provide more piped answers or use setup --answers FILE`, ); }; return { notify: (message, type) => inner.notify(message, type), close: (message) => inner.close(message), async confirm(title, message, options) { return (await inner.confirm(title, message, options)) ?? exhausted(title, options); }, async input(title, placeholder, options) { return (await inner.input(title, placeholder, options)) ?? exhausted(title, options); }, async select(title, choices, options: SelectOptions) { return (await inner.select(title, choices, options)) ?? exhausted(title, options); }, }; }