feat(setup): add prompt helpers for setup wizard

Created a Prompter interface and factory function for interactive CLI prompts:
- ask(): text input with optional default values
- confirm(): yes/no confirmation with default
- choose(): numbered menu selection with fallback
- password(): text input (no echo planned in TUI)
- println(): simple output helper

All 9 tests pass (ask, confirm, choose, password scenarios).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-10 09:28:19 -08:00
parent 213dba855a
commit 9cc03187b0
2 changed files with 144 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { createPrompter } from './prompts.js';
import { createInterface } from 'readline/promises';
import { Readable, Writable } from 'stream';
function mockReadline(inputs: string[]) {
let idx = 0;
const input = new Readable({
read() {
if (idx < inputs.length) {
this.push(inputs[idx++] + '\n');
} else {
this.push(null);
}
},
});
const output = new Writable({ write(_, __, cb) { cb(); } });
return createInterface({ input, output });
}
describe('prompts', () => {
describe('ask', () => {
it('returns user input', async () => {
const rl = mockReadline(['hello']);
const p = createPrompter(rl);
expect(await p.ask('Name?')).toBe('hello');
});
it('returns default on empty input', async () => {
const rl = mockReadline(['']);
const p = createPrompter(rl);
expect(await p.ask('Name?', 'world')).toBe('world');
});
});
describe('confirm', () => {
it('returns true for Y', async () => {
const rl = mockReadline(['Y']);
const p = createPrompter(rl);
expect(await p.confirm('Ok?')).toBe(true);
});
it('returns false for n', async () => {
const rl = mockReadline(['n']);
const p = createPrompter(rl);
expect(await p.confirm('Ok?')).toBe(false);
});
it('defaults to true when defaultYes=true and input empty', async () => {
const rl = mockReadline(['']);
const p = createPrompter(rl);
expect(await p.confirm('Ok?', true)).toBe(true);
});
it('defaults to false when defaultYes=false and input empty', async () => {
const rl = mockReadline(['']);
const p = createPrompter(rl);
expect(await p.confirm('Ok?', false)).toBe(false);
});
});
describe('choose', () => {
it('returns selected option by number', async () => {
const rl = mockReadline(['2']);
const p = createPrompter(rl);
const result = await p.choose('Pick:', [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
{ label: 'C', value: 'c' },
]);
expect(result).toBe('b');
});
it('returns first option on empty input', async () => {
const rl = mockReadline(['']);
const p = createPrompter(rl);
const result = await p.choose('Pick:', [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
]);
expect(result).toBe('a');
});
});
describe('password', () => {
it('returns input', async () => {
const rl = mockReadline(['secret123']);
const p = createPrompter(rl);
expect(await p.password('Key:')).toBe('secret123');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { Interface as ReadlineInterface } from 'readline/promises';
export interface ChoiceOption<T = string> {
label: string;
value: T;
}
export interface Prompter {
ask(question: string, defaultValue?: string): Promise<string>;
confirm(question: string, defaultYes?: boolean): Promise<boolean>;
choose<T = string>(question: string, options: ChoiceOption<T>[]): Promise<T>;
password(question: string): Promise<string>;
println(msg?: string): void;
}
export function createPrompter(rl: ReadlineInterface): Prompter {
return {
async ask(question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : '';
const answer = await rl.question(`${question}${suffix}: `);
return answer.trim() || defaultValue || '';
},
async confirm(question: string, defaultYes = true): Promise<boolean> {
const hint = defaultYes ? '[Y/n]' : '[y/N]';
const answer = await rl.question(`${question} ${hint} `);
const trimmed = answer.trim().toLowerCase();
if (trimmed === '') return defaultYes;
return trimmed === 'y' || trimmed === 'yes';
},
async choose<T = string>(question: string, options: ChoiceOption<T>[]): Promise<T> {
this.println(question);
for (let i = 0; i < options.length; i++) {
this.println(` ${i + 1}. ${options[i].label}`);
}
const answer = await rl.question(`> `);
const idx = parseInt(answer.trim(), 10) - 1;
if (idx >= 0 && idx < options.length) return options[idx].value;
return options[0].value;
},
async password(question: string): Promise<string> {
const answer = await rl.question(`${question}: `);
return answer.trim();
},
println(msg = ''): void {
process.stdout.write(msg + '\n');
},
};
}