Skip to content

Commit 922947d

Browse files
authored
Merge pull request #69 from constructive-io/devin/1772751964-inquirerer-boolean-json-types
feat(inquirerer): add boolean alias and json question types
2 parents 52e4d3e + ea6fe03 commit 922947d

4 files changed

Lines changed: 230 additions & 2 deletions

File tree

packages/inquirerer/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ npm install inquirerer
4545
- [Text Question](#text-question)
4646
- [Number Question](#number-question)
4747
- [Confirm Question](#confirm-question)
48+
- [Boolean Question](#boolean-question)
49+
- [JSON Question](#json-question)
4850
- [List Question](#list-question)
4951
- [Autocomplete Question](#autocomplete-question)
5052
- [Checkbox Question](#checkbox-question)
@@ -112,6 +114,8 @@ import {
112114
TextQuestion,
113115
NumberQuestion,
114116
ConfirmQuestion,
117+
BooleanQuestion,
118+
JsonQuestion,
115119
ListQuestion,
116120
AutocompleteQuestion,
117121
CheckboxQuestion,
@@ -320,6 +324,42 @@ Yes/no questions.
320324
}
321325
```
322326

327+
#### Boolean Question
328+
329+
Alias for `confirm` — provides a semantic name for boolean fields. Behaves identically to `confirm` (y/n prompt).
330+
331+
```typescript
332+
{
333+
type: 'boolean',
334+
name: 'isActive',
335+
message: 'Is this record active?',
336+
default: true
337+
}
338+
```
339+
340+
Useful when generating CLI prompts from schema types where the field type is `Boolean` rather than a yes/no confirmation.
341+
342+
#### JSON Question
343+
344+
Collect structured JSON input. Validates input with `JSON.parse()` — invalid JSON returns `null`.
345+
346+
```typescript
347+
{
348+
type: 'json',
349+
name: 'metadata',
350+
message: 'Enter metadata',
351+
default: { key: 'value' }
352+
}
353+
```
354+
355+
The prompt displays a `(JSON)` hint. Users enter raw JSON strings:
356+
```bash
357+
$ Enter metadata (JSON)
358+
> {"email":"user@example.com","role":"admin"}
359+
```
360+
361+
In non-interactive mode, returns the `default` value if provided, otherwise `undefined`.
362+
323363
#### List Question
324364

325365
Select one option from a list (no search).

packages/inquirerer/__tests__/prompt.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ describe('Inquirerer', () => {
8686
}
8787
});
8888

89+
inputQueue = [];
90+
currentInputIndex = 0;
91+
8992
setupReadlineMock();
9093
// Pipe the transform stream to the mock output to intercept writes
9194
// transformStream.pipe(mockOutput);
@@ -334,6 +337,139 @@ describe('Inquirerer', () => {
334337
});
335338
});
336339

340+
describe('boolean type (alias for confirm)', () => {
341+
it('should treat boolean type as confirm — yes input', async () => {
342+
enqueueInputResponse({ type: 'read', value: 'y' });
343+
344+
const prompter = new Inquirerer({
345+
input: mockInput,
346+
output: mockOutput,
347+
noTty: false
348+
});
349+
const questions: Question[] = [
350+
{ name: 'isActive', type: 'boolean' },
351+
];
352+
353+
const result = await prompter.prompt({}, questions);
354+
355+
expect(result).toEqual({ isActive: true });
356+
});
357+
358+
it('should treat boolean type as confirm — no input', async () => {
359+
enqueueInputResponse({ type: 'read', value: 'n' });
360+
361+
const prompter = new Inquirerer({
362+
input: mockInput,
363+
output: mockOutput,
364+
noTty: false
365+
});
366+
const questions: Question[] = [
367+
{ name: 'isActive', type: 'boolean' },
368+
];
369+
370+
const result = await prompter.prompt({}, questions);
371+
372+
expect(result).toEqual({ isActive: false });
373+
});
374+
375+
it('should use default for boolean type in noTty mode', async () => {
376+
const prompter = new Inquirerer({
377+
input: mockInput,
378+
output: mockOutput,
379+
noTty: true
380+
});
381+
const questions: Question[] = [
382+
{ name: 'isActive', type: 'boolean', default: true },
383+
];
384+
385+
const result = await prompter.prompt({}, questions);
386+
387+
expect(result).toEqual({ isActive: true });
388+
});
389+
390+
it('should accept CLI flag override for boolean type', async () => {
391+
const prompter = new Inquirerer({
392+
input: mockInput,
393+
output: mockOutput,
394+
noTty: false
395+
});
396+
const questions: Question[] = [
397+
{ name: 'isActive', type: 'boolean' },
398+
];
399+
400+
const result = await prompter.prompt({ isActive: true }, questions);
401+
402+
expect(result).toEqual({ isActive: true });
403+
});
404+
});
405+
406+
describe('json type', () => {
407+
it('should parse valid JSON input', async () => {
408+
enqueueInputResponse({ type: 'read', value: '{"email":"test@example.com","password":"secret"}' });
409+
410+
const prompter = new Inquirerer({
411+
input: mockInput,
412+
output: mockOutput,
413+
noTty: false
414+
});
415+
const questions: Question[] = [
416+
{ name: 'input', type: 'json' },
417+
];
418+
419+
const result = await prompter.prompt({}, questions);
420+
421+
expect(result).toEqual({ input: { email: 'test@example.com', password: 'secret' } });
422+
});
423+
424+
it('should return null for invalid JSON input', async () => {
425+
enqueueInputResponse({ type: 'read', value: 'not valid json' });
426+
427+
const prompter = new Inquirerer({
428+
input: mockInput,
429+
output: mockOutput,
430+
noTty: false
431+
});
432+
const questions: Question[] = [
433+
{ name: 'data', type: 'json' },
434+
];
435+
436+
const result = await prompter.prompt({}, questions);
437+
438+
// Invalid JSON returns null from the handler
439+
expect(result).toEqual({ data: null });
440+
});
441+
442+
it('should use default for json type in noTty mode', async () => {
443+
const prompter = new Inquirerer({
444+
input: mockInput,
445+
output: mockOutput,
446+
noTty: true
447+
});
448+
const questions: Question[] = [
449+
{ name: 'config', type: 'json', default: { key: 'value' } },
450+
];
451+
452+
const result = await prompter.prompt({}, questions);
453+
454+
expect(result).toEqual({ config: { key: 'value' } });
455+
});
456+
457+
it('should accept CLI flag override for json type', async () => {
458+
const prompter = new Inquirerer({
459+
input: mockInput,
460+
output: mockOutput,
461+
noTty: false
462+
});
463+
const questions: Question[] = [
464+
{ name: 'data', type: 'json' },
465+
];
466+
467+
const result = await prompter.prompt({ data: { foo: 'bar' } }, questions);
468+
469+
expect(result).toEqual({ data: { foo: 'bar' } });
470+
});
471+
});
472+
337473
it('handles readline inputs', async () => {
338474

339475
const prompter = new Inquirerer({

packages/inquirerer/src/prompt.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import readline from 'readline';
33
import { Readable, Writable } from 'stream';
44

55
import { KEY_CODES, TerminalKeypress } from './keypress';
6-
import { AutocompleteQuestion, CheckboxQuestion, ConfirmQuestion, ListQuestion, NumberQuestion, OptionValue, PasswordQuestion, Question, TextQuestion, Validation, Value } from './question';
6+
import { AutocompleteQuestion, BooleanQuestion, CheckboxQuestion, ConfirmQuestion, JsonQuestion, ListQuestion, NumberQuestion, OptionValue, PasswordQuestion, Question, TextQuestion, Validation, Value } from './question';
77
import { DefaultResolverRegistry, globalResolverRegistry } from './resolvers';
88
// import { writeFileSync } from 'fs';
99

@@ -136,12 +136,20 @@ function generatePromptMessage(question: Question, ctx: PromptContext): string {
136136
// 2. Append default inline (only if present)
137137
switch (type) {
138138
case 'confirm':
139+
case 'boolean':
139140
promptLine += ' (y/n)';
140141
if (def !== undefined) {
141142
promptLine += ` ${yellow(`[${def ? 'y' : 'n'}]`)}`;
142143
}
143144
break;
144145

146+
case 'json':
147+
promptLine += dim(' (JSON)');
148+
if (def !== undefined) {
149+
promptLine += ` ${yellow(`[${JSON.stringify(def)}]`)}`;
150+
}
151+
break;
152+
145153
case 'text':
146154
case 'number':
147155
if (def !== undefined) {
@@ -789,6 +797,10 @@ export class Inquirerer {
789797
switch (question.type) {
790798
case 'confirm':
791799
return this.confirm(question as ConfirmQuestion, ctx);
800+
case 'boolean':
801+
return this.confirm(question as unknown as ConfirmQuestion, ctx);
802+
case 'json':
803+
return this.json(question as JsonQuestion, ctx);
792804
case 'checkbox':
793805
return this.checkbox(question as CheckboxQuestion, ctx);
794806
case 'list':
@@ -850,6 +862,36 @@ export class Inquirerer {
850862
});
851863
}
852864

865+
public async json(question: JsonQuestion, ctx: PromptContext): Promise<Record<string, unknown> | null> {
866+
if (this.noTty || !this.rl) {
867+
if ('default' in question) {
868+
return question.default;
869+
}
870+
return;
871+
}
872+
873+
let input = '';
874+
875+
return new Promise<Record<string, unknown> | null>((resolve) => {
876+
this.clearScreen();
877+
this.rl.question(this.getPrompt(question, ctx, input), (answer) => {
878+
input = answer.trim();
879+
if (input !== '') {
880+
try {
881+
const parsed = JSON.parse(input);
882+
resolve(parsed);
883+
} catch {
884+
resolve(null); // Let validation handle invalid JSON
885+
}
886+
} else if ('default' in question) {
887+
resolve(question.default);
888+
} else {
889+
resolve(null);
890+
}
891+
});
892+
});
893+
}
894+
853895
public async number(question: NumberQuestion, ctx: PromptContext): Promise<number | null> {
854896
if (this.noTty || !this.rl) {
855897
if ('default' in question) {

packages/inquirerer/src/question/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ export interface BaseQuestion {
3939
type: 'confirm';
4040
default?: boolean; // Defaults are typically boolean for confirm types
4141
}
42+
43+
export interface BooleanQuestion extends BaseQuestion {
44+
type: 'boolean';
45+
default?: boolean; // Alias for confirm — same behavior, semantic name
46+
}
47+
48+
export interface JsonQuestion extends BaseQuestion {
49+
type: 'json';
50+
default?: Record<string, unknown>; // Default JSON value
51+
}
4252

4353
export interface AutocompleteQuestion extends BaseQuestion {
4454
type: 'autocomplete';
@@ -78,4 +88,4 @@ export interface BaseQuestion {
7888
mask?: string; // Character to use for masking (default: '*')
7989
}
8090

81-
export type Question = ConfirmQuestion | ListQuestion | AutocompleteQuestion | CheckboxQuestion | TextQuestion | NumberQuestion | PasswordQuestion;
91+
export type Question = ConfirmQuestion | BooleanQuestion | JsonQuestion | ListQuestion | AutocompleteQuestion | CheckboxQuestion | TextQuestion | NumberQuestion | PasswordQuestion;

0 commit comments

Comments
 (0)