UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

143 lines (128 loc) 3.92 kB
import { stdin as input, stdout as output } from "node:process"; import { createInterface as createPromptInterface } from "node:readline/promises"; import { $inject, Alepha, AlephaError, coerceScalar, type Static, type TSchema, type TString, z, } from "alepha"; import { $logger } from "alepha/logger"; export interface AskOptions<T extends TSchema = TString> { /** * Response schema expected. * * Recommended schemas: * - z.text() - for free text input * - z.number() - for numeric input * - z.boolean() - for yes/no input (accepts "true", "false", "1", "0") * - z.enum(["option1", "option2"]) - for predefined options * * You can use schema.default to provide a default value. * * @example * ```ts * ask("What is your name?", { schema: z.text({ default: "John Doe" }) }) * ``` * * @default TString */ schema?: T; /** * Custom validation function. * Throws an AlephaError in case of validation failure. */ validate?: (value: Static<T>) => void; } export interface AskMethod { <T extends TSchema = TString>( question: string, options?: AskOptions<T>, ): Promise<Static<T>>; permission: (question: string) => Promise<boolean>; intro: (title: string) => void; outro: (message: string) => void; } /** * Reads interactive input from the terminal using plain readline prompts. * * One straightforward code path: questions are printed through the logger * and answers are read with Node's `readline`. No raw-mode cursor control, * no ANSI framing — output stays greppable and works the same in a TTY, * under CI, or when piped. */ export class Asker { protected readonly log = $logger(); public readonly ask: AskMethod; protected readonly alepha = $inject(Alepha); constructor() { this.ask = this.createAskMethod(); } protected createAskMethod(): AskMethod { const askFn: AskMethod = async <T extends TSchema = TString>( question: string, options: AskOptions<T> = {}, ) => { return await this.prompt<T>(question, options); }; askFn.permission = async (question: string) => { const response = await this.prompt(`${question} [Y/n]`, { schema: z.enum(["Y", "y", "n", "no", "yes"]).default("Y"), }); return response.charAt(0).toLowerCase() === "y"; }; askFn.intro = (title: string) => { this.log.info(title); }; askFn.outro = (message: string) => { if (message) this.log.info(message); }; return askFn; } protected async prompt<T extends TSchema = TString>( question: string, options: AskOptions<T>, ): Promise<Static<T>> { const rl = this.createPromptInterface(); let value: any; try { do { try { this.log.info(question); const answer = await rl.question("> "); if (options.schema) { // The terminal is a string-only boundary (like HTTP query/env), so // coerce the answer to the schema's scalar type before strict // decoding — otherwise `z.number()` would reject the string "41". const raw = answer ? answer.trim() : undefined; value = this.alepha.codec.decode( options.schema, raw === undefined ? undefined : coerceScalar(options.schema, raw), ); } else { value = String(answer.trim()); } if (options.validate) { options.validate(value); } } catch (error) { if (error instanceof AlephaError) { this.log.error(`${error.message}\n`); value = undefined; } else { throw error; } } } while (value === undefined); } finally { rl.close(); } return value; } protected createPromptInterface() { return createPromptInterface({ input, output }); } }