import { randomUUID } from "node:crypto"; import { WizardCancelledError, type WizardProgress, type WizardPrompter, } from "./prompts.js"; export type WizardStepOption = { value: unknown; label: string; hint?: string; }; export type WizardStep = { id: string; type: | "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; title?: string; message?: string; options?: WizardStepOption[]; initialValue?: unknown; placeholder?: string; sensitive?: boolean; executor?: "gateway" | "client"; }; export type WizardSessionStatus = "running" | "done" | "cancelled" | "error"; export type WizardNextResult = { done: boolean; step?: WizardStep; status: WizardSessionStatus; error?: string; }; type Deferred = { promise: Promise; resolve: (value: T) => void; reject: (err: unknown) => void; }; function createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (err: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } class WizardSessionPrompter implements WizardPrompter { constructor(private session: WizardSession) {} async intro(title: string): Promise { await this.prompt({ type: "note", title, message: "", executor: "client", }); } async outro(message: string): Promise { await this.prompt({ type: "note", title: "Done", message, executor: "client", }); } async note(message: string, title?: string): Promise { await this.prompt({ type: "note", title, message, executor: "client" }); } async select(params: { message: string; options: Array<{ value: T; label: string; hint?: string }>; initialValue?: T; }): Promise { const res = await this.prompt({ type: "select", message: params.message, options: params.options.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint, })), initialValue: params.initialValue, executor: "client", }); return res as T; } async multiselect(params: { message: string; options: Array<{ value: T; label: string; hint?: string }>; initialValues?: T[]; }): Promise { const res = await this.prompt({ type: "multiselect", message: params.message, options: params.options.map((opt) => ({ value: opt.value, label: opt.label, hint: opt.hint, })), initialValue: params.initialValues, executor: "client", }); return (Array.isArray(res) ? res : []) as T[]; } async text(params: { message: string; initialValue?: string; placeholder?: string; validate?: (value: string) => string | undefined; }): Promise { const res = await this.prompt({ type: "text", message: params.message, initialValue: params.initialValue, placeholder: params.placeholder, executor: "client", }); const value = res === null || res === undefined ? "" : typeof res === "string" ? res : typeof res === "number" || typeof res === "boolean" || typeof res === "bigint" ? String(res) : ""; const error = params.validate?.(value); if (error) { throw new Error(error); } return value; } async confirm(params: { message: string; initialValue?: boolean; }): Promise { const res = await this.prompt({ type: "confirm", message: params.message, initialValue: params.initialValue, executor: "client", }); return Boolean(res); } progress(_label: string): WizardProgress { return { update: (_message) => {}, stop: (_message) => {}, }; } private async prompt(step: Omit): Promise { return await this.session.awaitAnswer({ ...step, id: randomUUID(), }); } } export class WizardSession { private currentStep: WizardStep | null = null; private stepDeferred: Deferred | null = null; private answerDeferred = new Map>(); private status: WizardSessionStatus = "running"; private error: string | undefined; constructor(private runner: (prompter: WizardPrompter) => Promise) { const prompter = new WizardSessionPrompter(this); void this.run(prompter); } async next(): Promise { if (this.currentStep) { return { done: false, step: this.currentStep, status: this.status }; } if (this.status !== "running") { return { done: true, status: this.status, error: this.error }; } if (!this.stepDeferred) { this.stepDeferred = createDeferred(); } const step = await this.stepDeferred.promise; if (step) { return { done: false, step, status: this.status }; } return { done: true, status: this.status, error: this.error }; } async answer(stepId: string, value: unknown): Promise { const deferred = this.answerDeferred.get(stepId); if (!deferred) { throw new Error("wizard: no pending step"); } this.answerDeferred.delete(stepId); this.currentStep = null; deferred.resolve(value); } cancel() { if (this.status !== "running") return; this.status = "cancelled"; this.error = "cancelled"; this.currentStep = null; for (const [, deferred] of this.answerDeferred) { deferred.reject(new WizardCancelledError()); } this.answerDeferred.clear(); this.resolveStep(null); } pushStep(step: WizardStep) { this.currentStep = step; this.resolveStep(step); } private async run(prompter: WizardPrompter) { try { await this.runner(prompter); this.status = "done"; } catch (err) { if (err instanceof WizardCancelledError) { this.status = "cancelled"; this.error = err.message; } else { this.status = "error"; this.error = String(err); } } finally { this.resolveStep(null); } } async awaitAnswer(step: WizardStep): Promise { if (this.status !== "running") { throw new Error("wizard: session not running"); } this.pushStep(step); const deferred = createDeferred(); this.answerDeferred.set(step.id, deferred); return await deferred.promise; } private resolveStep(step: WizardStep | null) { if (!this.stepDeferred) return; const deferred = this.stepDeferred; this.stepDeferred = null; deferred.resolve(step); } getStatus(): WizardSessionStatus { return this.status; } getError(): string | undefined { return this.error; } }