/** * Masked password prompt — reads from stdin without echoing characters. * * Uses raw mode on stdin so we can intercept each keypress and suppress echo. * Handles: * - printable characters appended to the buffer * - backspace (0x7f / 0x08) removes last character * - Enter (0x0d / 0x0a) completes the read * - Ctrl+C (0x03) throws an error to abort * * Falls back to a plain readline prompt when stdin is not a TTY (e.g. tests / * piped input) so that callers can still provide a value programmatically. */ import { createInterface } from 'node:readline'; /** * Display `label` and read a single masked password from stdin. * * @param label - The prompt text, e.g. "Admin password: " * @returns The password string entered by the user. */ export async function promptMasked(label: string): Promise { // Non-TTY: fall back to plain readline (value will echo, but that's the // caller's concern — headless callers should supply env vars instead). if (!process.stdin.isTTY) { return promptPlain(label); } process.stdout.write(label); return new Promise((resolve, reject) => { const chunks: string[] = []; const onData = (chunk: Buffer): void => { for (let i = 0; i < chunk.length; i++) { const byte = chunk[i] as number; if (byte === 0x03) { // Ctrl+C — restore normal mode and abort cleanUp(); process.stdout.write('\n'); reject(new Error('Aborted by user (Ctrl+C)')); return; } if (byte === 0x0d || byte === 0x0a) { // Enter — done cleanUp(); process.stdout.write('\n'); resolve(chunks.join('')); return; } if (byte === 0x7f || byte === 0x08) { // Backspace / DEL if (chunks.length > 0) { chunks.pop(); // Erase the last '*' on screen process.stdout.write('\b \b'); } continue; } // Printable character if (byte >= 0x20 && byte <= 0x7e) { chunks.push(String.fromCharCode(byte)); process.stdout.write('*'); } } }; function cleanUp(): void { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.removeListener('data', onData); } process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on('data', onData); }); } /** * Prompt for a password twice, re-prompting until both entries match. * Applies the provided `validate` function once the two entries agree. * * @param label - Prompt text for the first entry. * @param confirmLabel - Prompt text for the confirmation entry. * @param validate - Optional validator; return an error string on failure. * @returns The confirmed password. */ export async function promptMaskedConfirmed( label: string, confirmLabel: string, validate?: (value: string) => string | undefined, ): Promise { for (;;) { const first = await promptMasked(label); const second = await promptMasked(confirmLabel); if (first !== second) { console.log('Passwords do not match — please try again.\n'); continue; } if (validate) { const error = validate(first); if (error) { console.log(`${error} — please try again.\n`); continue; } } return first; } } // ── Internal helpers ────────────────────────────────────────────────────────── function promptPlain(label: string): Promise { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false }); rl.question(label, (answer) => { rl.close(); resolve(answer); }); }); }