Files
2026-05-27 23:47:53 +01:00

130 lines
4.7 KiB
TypeScript

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<boolean | string>;
},
context?: {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
signal?: AbortSignal;
}
) => Promise<string>;
/** 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<string> {
/* 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<string> {
/* 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;
}