Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# `prompt`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Thin, test-injectable wrappers around `@inquirer/prompts`' password prompt
for the two interactive flows every server-keys bin needs: a single
silent password entry (`promptForPassword`) and a confirm-twice new
password entry (`promptForNewPassword`). The wrappers add three things
upstream lacks: a structured `ServerKeysError(SERVER_KEYS_PASSWORD_MISMATCH)`
on mismatch (instead of a recursive re-prompt that bypasses the bin's
top-level handler), a typed dependency-injection seam for the prompt
function (so tests can run without touching stdin/stdout), and explicit
threading of `input/output/signal` into inquirer's `context` so the
prompts compose with the bin's `AbortController` and with non-TTY test
runners.
The minimum password length is exported as `MIN_PASSWORD_LENGTH` so callers
and tests share the constant.
## Features
- `mask: "*"` non-echo entry — passwords never appear in the buffered
terminal.
- `validate: (input) => ...` rejects empty input and (on new-password)
enforces a minimum length, surfacing the message through inquirer's
re-prompt loop.
- `SERVER_KEYS.PASSWORD_MISMATCH` raised on confirmation mismatch — the
caller's `runFunction` wrapper translates it into a process-exit
banner.
- Injectable `passwordPrompt` for fully deterministic tests; injectable
`inputStream`, `outputStream`, and `signal` for partial stubs that
still drive the real inquirer prompt under a non-TTY harness.
## Usage: Basic
```ts
import {
promptForPassword,
promptForNewPassword,
} from "@suaveplan/server-keys/prompt/prompt";
const existing = await promptForPassword("Enter root vault password");
const fresh = await promptForNewPassword("Enter password for the new vault");
```
## Usage: Advanced
Inject a fake prompt for tests, then exercise both happy and mismatch
paths without any TTY:
```ts
import { PassThrough } from "node:stream";
import {
promptForNewPassword,
type PasswordPrompt,
} from "@suaveplan/server-keys/prompt/prompt";
const calls: string[] = [];
const responses = ["correcthorse", "correcthorse"];
const fake: PasswordPrompt = async (cfg) => {
calls.push(cfg.message);
return responses.shift() as string;
};
const pw = await promptForNewPassword("Enter password", {
passwordPrompt: fake,
});
expect(pw).toBe("correcthorse");
expect(calls).toEqual(["Enter password", "Re-enter password to confirm"]);
// Mismatch raises a typed error:
await expect(
promptForNewPassword("x", {
passwordPrompt: async () =>
(["abcdefgh", "ABCDEFGH"].shift() ?? "") as string,
}),
).rejects.toMatchObject({ code: "SERVER_KEYS.PASSWORD_MISMATCH" });
// Production with a SIGINT-bound AbortController:
const ac = new AbortController();
process.once("SIGINT", () => ac.abort());
const pw2 = await promptForPassword("Enter vault password", {
signal: ac.signal,
});
```
## API Reference
- `MIN_PASSWORD_LENGTH = 8` — new-password length floor.
- `PasswordPrompt` — function type matching `@inquirer/prompts.password`.
- `PromptOptions = { passwordPrompt?, inputStream?, outputStream?, signal? }`.
- `promptForPassword(message?, options?) => Promise<string>`.
- `promptForNewPassword(message?, options?) => Promise<string>` — throws
`ServerKeysError(SERVER_KEYS_PASSWORD_MISMATCH)` on mismatch.
## Implementation Notes
The validators are kept in-prompt (instead of after the await) so
inquirer's re-prompt UX still works for empty / too-short input. The
mismatch check lives outside the prompt so the failure can be caught by
the top-level handler instead of trapping the user in an infinite re-ask
loop. `buildContext` only assigns keys that were actually provided so
inquirer's defaults apply for the rest, and returns `undefined` for the
empty case so the prompt can resolve its own stdin / stdout / signal
defaults.

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SERVER_KEYS_CODES,
ServerKeysError,
} from "../error-codes/error-codes.js";
import {
MIN_PASSWORD_LENGTH,
type PasswordPrompt,
promptForNewPassword,
promptForPassword,
} from "./prompt.js";
function scriptedPrompt(responses: string[]): {
prompt: PasswordPrompt;
calls: Array<{
message: string;
validate?: (v: string) => boolean | string | Promise<boolean | string>;
}>;
} {
const queue = [...responses];
const calls: Array<{
message: string;
validate?: (v: string) => boolean | string | Promise<boolean | string>;
}> = [];
const prompt: PasswordPrompt = async (config) => {
calls.push({
message: config.message,
...(config.validate !== undefined
? { validate: config.validate }
: {}),
});
const next = queue.shift();
if (next === undefined) {
throw new Error("scriptedPrompt exhausted");
}
return next;
};
return { prompt, calls };
}
describe("promptForPassword", () => {
it("returns the entered password and uses the supplied message", {
timeout: 5000,
}, async () => {
const { prompt, calls } = scriptedPrompt(["hunter2"]);
const out = await promptForPassword("Enter vault password", {
passwordPrompt: prompt,
});
expect(out).toBe("hunter2");
expect(calls[0]?.message).toBe("Enter vault password");
});
it("rejects empty input through the inquirer validate hook", {
timeout: 5000,
}, async () => {
const { prompt, calls } = scriptedPrompt(["nonempty"]);
await promptForPassword("X", { passwordPrompt: prompt });
const validator = calls[0]?.validate;
expect(validator).toBeDefined();
expect(await validator?.("")).toBe("Password cannot be empty");
expect(await validator?.(" ")).toBe("Password cannot be empty");
expect(await validator?.("ok")).toBe(true);
});
it("forwards inputStream, outputStream, and signal into inquirer context", {
timeout: 5000,
}, async () => {
let seenCtx:
| {
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
signal?: AbortSignal;
}
| undefined;
const prompt: PasswordPrompt = async (_cfg, ctx) => {
seenCtx = ctx;
return "pw";
};
const ac = new AbortController();
const input = {} as NodeJS.ReadableStream;
const output = {} as NodeJS.WritableStream;
await promptForPassword("m", {
passwordPrompt: prompt,
inputStream: input,
outputStream: output,
signal: ac.signal,
});
expect(seenCtx?.input).toBe(input);
expect(seenCtx?.output).toBe(output);
expect(seenCtx?.signal).toBe(ac.signal);
});
it("passes no context object when no streams or signal are supplied", {
timeout: 5000,
}, async () => {
let observedCtxArg: unknown = "untouched";
const prompt: PasswordPrompt = async (_cfg, ctx) => {
observedCtxArg = ctx;
return "pw";
};
await promptForPassword("m", { passwordPrompt: prompt });
expect(observedCtxArg).toBeUndefined();
});
it("defaults the message to 'Enter password' when omitted", {
timeout: 5000,
}, async () => {
const { prompt, calls } = scriptedPrompt(["pw"]);
await promptForPassword(undefined, { passwordPrompt: prompt });
expect(calls[0]?.message).toBe("Enter password");
});
});
describe("promptForNewPassword", () => {
it("returns the password when both entries match", {
timeout: 5000,
}, async () => {
const { prompt, calls } = scriptedPrompt([
"correcthorse",
"correcthorse",
]);
const pw = await promptForNewPassword("Enter new password", {
passwordPrompt: prompt,
});
expect(pw).toBe("correcthorse");
expect(calls).toHaveLength(2);
expect(calls[1]?.message).toBe("Re-enter password to confirm");
});
it("raises SERVER_KEYS.PASSWORD_MISMATCH when entries differ", {
timeout: 5000,
}, async () => {
const { prompt } = scriptedPrompt(["abcdefgh", "ABCDEFGH"]);
await expect(
promptForNewPassword("x", { passwordPrompt: prompt })
).rejects.toMatchObject({
code: SERVER_KEYS_CODES.PASSWORD_MISMATCH,
});
});
it("raised mismatch error is an instance of ServerKeysError", {
timeout: 5000,
}, async () => {
const { prompt } = scriptedPrompt(["abcdefgh", "different"]);
const err = await promptForNewPassword("x", {
passwordPrompt: prompt,
}).catch((e) => e);
expect(err).toBeInstanceOf(ServerKeysError);
});
it("first-prompt validator rejects empty input and short passwords", {
timeout: 5000,
}, async () => {
const { prompt, calls } = scriptedPrompt(["longenough", "longenough"]);
await promptForNewPassword("x", { passwordPrompt: prompt });
const v = calls[0]?.validate;
expect(v).toBeDefined();
expect(await v?.("")).toBe("Password cannot be empty");
expect(await v?.("short")).toBe(
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
);
expect(await v?.("longenough")).toBe(true);
});
it("defaults the first message to 'Enter new password'", {
timeout: 5000,
}, async () => {
const { prompt, calls } = scriptedPrompt(["longenough", "longenough"]);
await promptForNewPassword(undefined, { passwordPrompt: prompt });
expect(calls[0]?.message).toBe("Enter new password");
});
});

View File

@@ -0,0 +1,129 @@
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;
}