Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user