From 9cc03187b0d8015d3dcffbd1bb404d79f3a35dd6 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 09:28:19 -0800 Subject: [PATCH] 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 --- src/cli/setup/prompts.test.ts | 92 +++++++++++++++++++++++++++++++++++ src/cli/setup/prompts.ts | 52 ++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/cli/setup/prompts.test.ts create mode 100644 src/cli/setup/prompts.ts diff --git a/src/cli/setup/prompts.test.ts b/src/cli/setup/prompts.test.ts new file mode 100644 index 0000000..0a37c0f --- /dev/null +++ b/src/cli/setup/prompts.test.ts @@ -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'); + }); + }); +}); diff --git a/src/cli/setup/prompts.ts b/src/cli/setup/prompts.ts new file mode 100644 index 0000000..cd4444f --- /dev/null +++ b/src/cli/setup/prompts.ts @@ -0,0 +1,52 @@ +import type { Interface as ReadlineInterface } from 'readline/promises'; + +export interface ChoiceOption { + label: string; + value: T; +} + +export interface Prompter { + ask(question: string, defaultValue?: string): Promise; + confirm(question: string, defaultYes?: boolean): Promise; + choose(question: string, options: ChoiceOption[]): Promise; + password(question: string): Promise; + println(msg?: string): void; +} + +export function createPrompter(rl: ReadlineInterface): Prompter { + return { + async ask(question: string, defaultValue?: string): Promise { + const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : ''; + const answer = await rl.question(`${question}${suffix}: `); + return answer.trim() || defaultValue || ''; + }, + + async confirm(question: string, defaultYes = true): Promise { + 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(question: string, options: ChoiceOption[]): Promise { + 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 { + const answer = await rl.question(`${question}: `); + return answer.trim(); + }, + + println(msg = ''): void { + process.stdout.write(msg + '\n'); + }, + }; +}