diff --git a/packages/inquirerer/README.md b/packages/inquirerer/README.md index 4ec9637..3c76d98 100644 --- a/packages/inquirerer/README.md +++ b/packages/inquirerer/README.md @@ -45,6 +45,8 @@ npm install inquirerer - [Text Question](#text-question) - [Number Question](#number-question) - [Confirm Question](#confirm-question) + - [Boolean Question](#boolean-question) + - [JSON Question](#json-question) - [List Question](#list-question) - [Autocomplete Question](#autocomplete-question) - [Checkbox Question](#checkbox-question) @@ -112,6 +114,8 @@ import { TextQuestion, NumberQuestion, ConfirmQuestion, + BooleanQuestion, + JsonQuestion, ListQuestion, AutocompleteQuestion, CheckboxQuestion, @@ -320,6 +324,42 @@ Yes/no questions. } ``` +#### Boolean Question + +Alias for `confirm` — provides a semantic name for boolean fields. Behaves identically to `confirm` (y/n prompt). + +```typescript +{ + type: 'boolean', + name: 'isActive', + message: 'Is this record active?', + default: true +} +``` + +Useful when generating CLI prompts from schema types where the field type is `Boolean` rather than a yes/no confirmation. + +#### JSON Question + +Collect structured JSON input. Validates input with `JSON.parse()` — invalid JSON returns `null`. + +```typescript +{ + type: 'json', + name: 'metadata', + message: 'Enter metadata', + default: { key: 'value' } +} +``` + +The prompt displays a `(JSON)` hint. Users enter raw JSON strings: +```bash +$ Enter metadata (JSON) +> {"email":"user@example.com","role":"admin"} +``` + +In non-interactive mode, returns the `default` value if provided, otherwise `undefined`. + #### List Question Select one option from a list (no search). diff --git a/packages/inquirerer/__tests__/prompt.test.ts b/packages/inquirerer/__tests__/prompt.test.ts index fb268da..1f11aba 100644 --- a/packages/inquirerer/__tests__/prompt.test.ts +++ b/packages/inquirerer/__tests__/prompt.test.ts @@ -86,6 +86,9 @@ describe('Inquirerer', () => { } }); + inputQueue = []; + currentInputIndex = 0; + setupReadlineMock(); // Pipe the transform stream to the mock output to intercept writes // transformStream.pipe(mockOutput); @@ -334,6 +337,139 @@ describe('Inquirerer', () => { }); }); + describe('boolean type (alias for confirm)', () => { + it('should treat boolean type as confirm — yes input', async () => { + enqueueInputResponse({ type: 'read', value: 'y' }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: false + }); + const questions: Question[] = [ + { name: 'isActive', type: 'boolean' }, + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ isActive: true }); + }); + + it('should treat boolean type as confirm — no input', async () => { + enqueueInputResponse({ type: 'read', value: 'n' }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: false + }); + const questions: Question[] = [ + { name: 'isActive', type: 'boolean' }, + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ isActive: false }); + }); + + it('should use default for boolean type in noTty mode', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + const questions: Question[] = [ + { name: 'isActive', type: 'boolean', default: true }, + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ isActive: true }); + }); + + it('should accept CLI flag override for boolean type', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: false + }); + const questions: Question[] = [ + { name: 'isActive', type: 'boolean' }, + ]; + + const result = await prompter.prompt({ isActive: true }, questions); + + expect(result).toEqual({ isActive: true }); + }); + }); + + describe('json type', () => { + it('should parse valid JSON input', async () => { + enqueueInputResponse({ type: 'read', value: '{"email":"test@example.com","password":"secret"}' }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: false + }); + const questions: Question[] = [ + { name: 'input', type: 'json' }, + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ input: { email: 'test@example.com', password: 'secret' } }); + }); + + it('should return null for invalid JSON input', async () => { + enqueueInputResponse({ type: 'read', value: 'not valid json' }); + + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: false + }); + const questions: Question[] = [ + { name: 'data', type: 'json' }, + ]; + + const result = await prompter.prompt({}, questions); + + // Invalid JSON returns null from the handler + expect(result).toEqual({ data: null }); + }); + + it('should use default for json type in noTty mode', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + const questions: Question[] = [ + { name: 'config', type: 'json', default: { key: 'value' } }, + ]; + + const result = await prompter.prompt({}, questions); + + expect(result).toEqual({ config: { key: 'value' } }); + }); + + it('should accept CLI flag override for json type', async () => { + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: false + }); + const questions: Question[] = [ + { name: 'data', type: 'json' }, + ]; + + const result = await prompter.prompt({ data: { foo: 'bar' } }, questions); + + expect(result).toEqual({ data: { foo: 'bar' } }); + }); + }); + it('handles readline inputs', async () => { const prompter = new Inquirerer({ diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index fb910fb..3167b04 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -3,7 +3,7 @@ import readline from 'readline'; import { Readable, Writable } from 'stream'; import { KEY_CODES, TerminalKeypress } from './keypress'; -import { AutocompleteQuestion, CheckboxQuestion, ConfirmQuestion, ListQuestion, NumberQuestion, OptionValue, PasswordQuestion, Question, TextQuestion, Validation, Value } from './question'; +import { AutocompleteQuestion, BooleanQuestion, CheckboxQuestion, ConfirmQuestion, JsonQuestion, ListQuestion, NumberQuestion, OptionValue, PasswordQuestion, Question, TextQuestion, Validation, Value } from './question'; import { DefaultResolverRegistry, globalResolverRegistry } from './resolvers'; // import { writeFileSync } from 'fs'; @@ -136,12 +136,20 @@ function generatePromptMessage(question: Question, ctx: PromptContext): string { // 2. Append default inline (only if present) switch (type) { case 'confirm': + case 'boolean': promptLine += ' (y/n)'; if (def !== undefined) { promptLine += ` ${yellow(`[${def ? 'y' : 'n'}]`)}`; } break; + case 'json': + promptLine += dim(' (JSON)'); + if (def !== undefined) { + promptLine += ` ${yellow(`[${JSON.stringify(def)}]`)}`; + } + break; + case 'text': case 'number': if (def !== undefined) { @@ -789,6 +797,10 @@ export class Inquirerer { switch (question.type) { case 'confirm': return this.confirm(question as ConfirmQuestion, ctx); + case 'boolean': + return this.confirm(question as unknown as ConfirmQuestion, ctx); + case 'json': + return this.json(question as JsonQuestion, ctx); case 'checkbox': return this.checkbox(question as CheckboxQuestion, ctx); case 'list': @@ -850,6 +862,36 @@ export class Inquirerer { }); } + public async json(question: JsonQuestion, ctx: PromptContext): Promise | null> { + if (this.noTty || !this.rl) { + if ('default' in question) { + return question.default; + } + return; + } + + let input = ''; + + return new Promise | null>((resolve) => { + this.clearScreen(); + this.rl.question(this.getPrompt(question, ctx, input), (answer) => { + input = answer.trim(); + if (input !== '') { + try { + const parsed = JSON.parse(input); + resolve(parsed); + } catch { + resolve(null); // Let validation handle invalid JSON + } + } else if ('default' in question) { + resolve(question.default); + } else { + resolve(null); + } + }); + }); + } + public async number(question: NumberQuestion, ctx: PromptContext): Promise { if (this.noTty || !this.rl) { if ('default' in question) { diff --git a/packages/inquirerer/src/question/types.ts b/packages/inquirerer/src/question/types.ts index d68e6c7..102f746 100644 --- a/packages/inquirerer/src/question/types.ts +++ b/packages/inquirerer/src/question/types.ts @@ -39,6 +39,16 @@ export interface BaseQuestion { type: 'confirm'; default?: boolean; // Defaults are typically boolean for confirm types } + + export interface BooleanQuestion extends BaseQuestion { + type: 'boolean'; + default?: boolean; // Alias for confirm — same behavior, semantic name + } + + export interface JsonQuestion extends BaseQuestion { + type: 'json'; + default?: Record; // Default JSON value + } export interface AutocompleteQuestion extends BaseQuestion { type: 'autocomplete'; @@ -78,4 +88,4 @@ export interface BaseQuestion { mask?: string; // Character to use for masking (default: '*') } - export type Question = ConfirmQuestion | ListQuestion | AutocompleteQuestion | CheckboxQuestion | TextQuestion | NumberQuestion | PasswordQuestion; + export type Question = ConfirmQuestion | BooleanQuestion | JsonQuestion | ListQuestion | AutocompleteQuestion | CheckboxQuestion | TextQuestion | NumberQuestion | PasswordQuestion;