import type { Readable, Writable } from "node:stream"; import { password as inquirerPassword } from "@inquirer/prompts"; import { SERVER_KEYS_CODES, ServerKeysError, } from "../error-codes/error-codes.js"; /** Minimum password length enforced by {@link promptForNewPassword}. */ export const MIN_PASSWORD_LENGTH = 8; /** * The inquirer password-prompt surface, narrowed to the shape this module * actually uses. Injecting a function with this signature lets tests run * deterministically without touching stdin/stdout. */ export type PasswordPrompt = ( config: { message: string; mask?: boolean | string; validate?: ( value: string ) => boolean | string | Promise; }, context?: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; signal?: AbortSignal; } ) => Promise; /** Options accepted by {@link promptForPassword} and {@link promptForNewPassword}. */ export interface PromptOptions { /** Override the password-prompt implementation (defaults to `@inquirer/prompts`). */ readonly passwordPrompt?: PasswordPrompt; /** Override stdin (forwarded to inquirer's `context.input`). */ readonly inputStream?: Readable | NodeJS.ReadableStream; /** Override stdout (forwarded to inquirer's `context.output`). */ readonly outputStream?: Writable | NodeJS.WritableStream; /** Forwarded to inquirer's `context.signal`. */ readonly signal?: AbortSignal; } function buildContext(options: PromptOptions | undefined) { /* istanbul ignore start -- reached only from real bin entry points that call promptForPassword()/promptForNewPassword() without any options; in-process tests cannot exercise this without breaking the test runner's stdin. */ if (!options) return undefined; /* istanbul ignore stop */ const ctx: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; signal?: AbortSignal; } = {}; if (options.inputStream !== undefined) { ctx.input = options.inputStream as NodeJS.ReadableStream; } if (options.outputStream !== undefined) { ctx.output = options.outputStream as NodeJS.WritableStream; } if (options.signal !== undefined) ctx.signal = options.signal; return Object.keys(ctx).length === 0 ? undefined : ctx; } /** * Prompt once for a password with no echo. Empty input is rejected at the * inquirer-validate boundary (the prompt re-asks); any error propagates. */ export async function promptForPassword( message = "Enter password", options?: PromptOptions ): Promise { /* istanbul ignore start -- the `?? inquirerPassword` fallback only fires when called without options from a real bin entry point; in-process tests would need to mock process.stdin which breaks the runner. */ const prompt = options?.passwordPrompt ?? inquirerPassword; /* istanbul ignore stop */ const ctx = buildContext(options); return prompt( { message, mask: "*", validate: (input: string) => { if (input.trim() === "") return "Password cannot be empty"; return true; }, }, ctx ); } /** * Prompt twice for a new password and assert equality. The first prompt * enforces {@link MIN_PASSWORD_LENGTH}; the confirmation re-prompt asks * for the same string and raises `SERVER_KEYS.PASSWORD_MISMATCH` if the * second entry differs. */ export async function promptForNewPassword( message = "Enter new password", options?: PromptOptions ): Promise { /* istanbul ignore start -- the `?? inquirerPassword` fallback only fires when called without options from a real bin entry point; in-process tests would need to mock process.stdin which breaks the runner. */ const prompt = options?.passwordPrompt ?? inquirerPassword; /* istanbul ignore stop */ const ctx = buildContext(options); const first = await prompt( { message, mask: "*", validate: (input: string) => { if (input.trim() === "") return "Password cannot be empty"; if (input.length < MIN_PASSWORD_LENGTH) { return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`; } return true; }, }, ctx ); const second = await prompt( { message: "Re-enter password to confirm", mask: "*", }, ctx ); if (first !== second) { throw new ServerKeysError( SERVER_KEYS_CODES.PASSWORD_MISMATCH, "Password confirmation does not match" ); } return first; }