Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/inquirerer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -112,6 +114,8 @@ import {
TextQuestion,
NumberQuestion,
ConfirmQuestion,
BooleanQuestion,
JsonQuestion,
ListQuestion,
AutocompleteQuestion,
CheckboxQuestion,
Expand Down Expand Up @@ -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).
Expand Down
136 changes: 136 additions & 0 deletions packages/inquirerer/__tests__/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ describe('Inquirerer', () => {
}
});

inputQueue = [];
currentInputIndex = 0;

setupReadlineMock();
// Pipe the transform stream to the mock output to intercept writes
// transformStream.pipe(mockOutput);
Expand Down Expand Up @@ -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({
Expand Down
44 changes: 43 additions & 1 deletion packages/inquirerer/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -850,6 +862,36 @@ export class Inquirerer {
});
}

public async json(question: JsonQuestion, ctx: PromptContext): Promise<Record<string, unknown> | null> {
if (this.noTty || !this.rl) {
if ('default' in question) {
return question.default;
}
return;
}

let input = '';

return new Promise<Record<string, unknown> | 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<number | null> {
if (this.noTty || !this.rl) {
if ('default' in question) {
Expand Down
12 changes: 11 additions & 1 deletion packages/inquirerer/src/question/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; // Default JSON value
}

export interface AutocompleteQuestion extends BaseQuestion {
type: 'autocomplete';
Expand Down Expand Up @@ -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;