Skip to content

Commit fc7fc51

Browse files
committed
feat(inquirerer): add prompt timeout for non-TTY environments
Adds a configurable timeout to interactive prompts that auto-detects non-TTY stdin and applies a 30s default timeout. When triggered, the PromptTimeoutError prints all available CLI flags, required arguments, and instructions for running in non-interactive mode. This prevents AI agents and CI pipelines from hanging indefinitely when they forget to pass --no-tty or required CLI flags.
1 parent bb249b3 commit fc7fc51

1 file changed

Lines changed: 106 additions & 2 deletions

File tree

packages/inquirerer/src/prompt.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ function generatePromptMessage(question: Question, ctx: PromptContext): string {
184184
return lines.join('\n') + '\n';
185185
}
186186

187+
export class PromptTimeoutError extends Error {
188+
public readonly currentQuestion: Question;
189+
public readonly allQuestions: Question[];
190+
191+
constructor(message: string, currentQuestion: Question, allQuestions: Question[]) {
192+
super(message);
193+
this.name = 'PromptTimeoutError';
194+
this.currentQuestion = currentQuestion;
195+
this.allQuestions = allQuestions;
196+
}
197+
}
198+
199+
const DEFAULT_NON_TTY_TIMEOUT = 30_000;
200+
187201
export interface InquirererOptions {
188202
noTty?: boolean;
189203
input?: Readable;
@@ -192,6 +206,7 @@ export interface InquirererOptions {
192206
globalMaxLines?: number;
193207
mutateArgs?: boolean;
194208
resolverRegistry?: DefaultResolverRegistry;
209+
timeout?: number;
195210
}
196211
export class Inquirerer {
197212
private rl: readline.Interface | null;
@@ -203,6 +218,7 @@ export class Inquirerer {
203218
private globalMaxLines: number;
204219
private mutateArgs: boolean;
205220
private resolverRegistry: DefaultResolverRegistry;
221+
private timeout: number | undefined;
206222

207223
private handledKeys: Set<string> = new Set();
208224

@@ -216,7 +232,8 @@ export class Inquirerer {
216232
useDefaults = false,
217233
globalMaxLines = 10,
218234
mutateArgs = true,
219-
resolverRegistry = globalResolverRegistry
235+
resolverRegistry = globalResolverRegistry,
236+
timeout
220237
} = options ?? {}
221238

222239
this.useDefaults = useDefaults;
@@ -227,6 +244,12 @@ export class Inquirerer {
227244
this.globalMaxLines = globalMaxLines;
228245
this.resolverRegistry = resolverRegistry;
229246

247+
if (timeout !== undefined) {
248+
this.timeout = timeout;
249+
} else if (!noTty && !(input as any).isTTY) {
250+
this.timeout = DEFAULT_NON_TTY_TIMEOUT;
251+
}
252+
230253
if (!noTty) {
231254
this.rl = readline.createInterface({
232255
input,
@@ -501,7 +524,11 @@ export class Inquirerer {
501524
}
502525

503526
while (ctx.needsInput) {
504-
obj[question.name] = await this.handleQuestionType(question, ctx);
527+
obj[question.name] = await this.withTimeout(
528+
this.handleQuestionType(question, ctx),
529+
question,
530+
ordered
531+
);
505532

506533
if (!this.isValid(question, obj, ctx)) {
507534
if (this.noTty) {
@@ -1375,6 +1402,83 @@ export class Inquirerer {
13751402
return Math.min(this.globalMaxLines, defaultLength);
13761403
}
13771404

1405+
private withTimeout<T>(promise: Promise<T>, currentQuestion: Question, allQuestions: Question[]): Promise<T> {
1406+
if (this.timeout === undefined) {
1407+
return promise;
1408+
}
1409+
1410+
const ms = this.timeout;
1411+
1412+
return new Promise<T>((resolve, reject) => {
1413+
const timer = setTimeout(() => {
1414+
reject(new PromptTimeoutError(
1415+
this.formatTimeoutError(currentQuestion, allQuestions, ms),
1416+
currentQuestion,
1417+
allQuestions
1418+
));
1419+
}, ms);
1420+
1421+
promise.then(
1422+
value => { clearTimeout(timer); resolve(value); },
1423+
err => { clearTimeout(timer); reject(err); }
1424+
);
1425+
});
1426+
}
1427+
1428+
private formatTimeoutError(currentQuestion: Question, allQuestions: Question[], ms: number): string {
1429+
const seconds = Math.round(ms / 1000);
1430+
const lines: string[] = [];
1431+
1432+
lines.push('');
1433+
lines.push(`PROMPT TIMEOUT: No input received for "${currentQuestion.name}" after ${seconds}s.`);
1434+
lines.push('');
1435+
lines.push('This usually happens when running in a non-interactive environment');
1436+
lines.push('(CI, scripts, AI agents) without the proper flags.');
1437+
lines.push('');
1438+
lines.push('REQUIRED ARGUMENTS:');
1439+
1440+
for (const q of allQuestions) {
1441+
const flag = `--${q.name}`;
1442+
const aliases = q.alias
1443+
? (Array.isArray(q.alias) ? q.alias : [q.alias])
1444+
.map(a => a.length === 1 ? `-${a}` : `--${a}`)
1445+
.join(', ')
1446+
: '';
1447+
const aliasStr = aliases ? ` (${aliases})` : '';
1448+
const label = q.message || q.name;
1449+
const req = q.required ? ' [REQUIRED]' : '';
1450+
const def = 'default' in q && q.default !== undefined ? ` (default: ${JSON.stringify(q.default)})` : '';
1451+
lines.push(` ${flag}${aliasStr} ${label}${req}${def}`);
1452+
}
1453+
1454+
lines.push('');
1455+
lines.push('HOW TO FIX:');
1456+
lines.push(' 1. Pass all required arguments as CLI flags:');
1457+
1458+
const requiredFlags = allQuestions
1459+
.filter(q => q.required)
1460+
.map(q => `--${q.name} <value>`);
1461+
if (requiredFlags.length > 0) {
1462+
lines.push(` $ command ${requiredFlags.join(' ')}`);
1463+
} else {
1464+
lines.push(' $ command --flag1 <value> --flag2 <value>');
1465+
}
1466+
1467+
lines.push('');
1468+
lines.push(' 2. Or enable non-interactive mode:');
1469+
lines.push(' new Inquirerer({ noTty: true, useDefaults: true })');
1470+
lines.push('');
1471+
lines.push(' 3. Or pass --no-tty if the CLI supports it.');
1472+
lines.push('');
1473+
lines.push('WHY THIS HAPPENED:');
1474+
lines.push(' The prompt expected interactive TTY input (keyboard), but no input');
1475+
lines.push(' was received. AI agents and CI pipelines must pass arguments via');
1476+
lines.push(' CLI flags instead of interactive prompts.');
1477+
lines.push('');
1478+
1479+
return lines.join('\n');
1480+
}
1481+
13781482
// Method to cleanly close the readline interface
13791483
// NOTE: use exit() to close!
13801484
public close() {

0 commit comments

Comments
 (0)