130 lines
4.7 KiB
TypeScript
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;
|
|
}
|