alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
143 lines (128 loc) • 3.92 kB
text/typescript
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 });
}
}