From fc7fc515391dc8c48a98a35aedeb01a4770ec9c0 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 27 Jun 2026 10:57:38 +0000 Subject: [PATCH 1/3] 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. --- packages/inquirerer/src/prompt.ts | 108 +++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index 3167b04c..b43e50fe 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -184,6 +184,20 @@ function generatePromptMessage(question: Question, ctx: PromptContext): string { return lines.join('\n') + '\n'; } +export class PromptTimeoutError extends Error { + public readonly currentQuestion: Question; + public readonly allQuestions: Question[]; + + constructor(message: string, currentQuestion: Question, allQuestions: Question[]) { + super(message); + this.name = 'PromptTimeoutError'; + this.currentQuestion = currentQuestion; + this.allQuestions = allQuestions; + } +} + +const DEFAULT_NON_TTY_TIMEOUT = 30_000; + export interface InquirererOptions { noTty?: boolean; input?: Readable; @@ -192,6 +206,7 @@ export interface InquirererOptions { globalMaxLines?: number; mutateArgs?: boolean; resolverRegistry?: DefaultResolverRegistry; + timeout?: number; } export class Inquirerer { private rl: readline.Interface | null; @@ -203,6 +218,7 @@ export class Inquirerer { private globalMaxLines: number; private mutateArgs: boolean; private resolverRegistry: DefaultResolverRegistry; + private timeout: number | undefined; private handledKeys: Set = new Set(); @@ -216,7 +232,8 @@ export class Inquirerer { useDefaults = false, globalMaxLines = 10, mutateArgs = true, - resolverRegistry = globalResolverRegistry + resolverRegistry = globalResolverRegistry, + timeout } = options ?? {} this.useDefaults = useDefaults; @@ -227,6 +244,12 @@ export class Inquirerer { this.globalMaxLines = globalMaxLines; this.resolverRegistry = resolverRegistry; + if (timeout !== undefined) { + this.timeout = timeout; + } else if (!noTty && !(input as any).isTTY) { + this.timeout = DEFAULT_NON_TTY_TIMEOUT; + } + if (!noTty) { this.rl = readline.createInterface({ input, @@ -501,7 +524,11 @@ export class Inquirerer { } while (ctx.needsInput) { - obj[question.name] = await this.handleQuestionType(question, ctx); + obj[question.name] = await this.withTimeout( + this.handleQuestionType(question, ctx), + question, + ordered + ); if (!this.isValid(question, obj, ctx)) { if (this.noTty) { @@ -1375,6 +1402,83 @@ export class Inquirerer { return Math.min(this.globalMaxLines, defaultLength); } + private withTimeout(promise: Promise, currentQuestion: Question, allQuestions: Question[]): Promise { + if (this.timeout === undefined) { + return promise; + } + + const ms = this.timeout; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new PromptTimeoutError( + this.formatTimeoutError(currentQuestion, allQuestions, ms), + currentQuestion, + allQuestions + )); + }, ms); + + promise.then( + value => { clearTimeout(timer); resolve(value); }, + err => { clearTimeout(timer); reject(err); } + ); + }); + } + + private formatTimeoutError(currentQuestion: Question, allQuestions: Question[], ms: number): string { + const seconds = Math.round(ms / 1000); + const lines: string[] = []; + + lines.push(''); + lines.push(`PROMPT TIMEOUT: No input received for "${currentQuestion.name}" after ${seconds}s.`); + lines.push(''); + lines.push('This usually happens when running in a non-interactive environment'); + lines.push('(CI, scripts, AI agents) without the proper flags.'); + lines.push(''); + lines.push('REQUIRED ARGUMENTS:'); + + for (const q of allQuestions) { + const flag = `--${q.name}`; + const aliases = q.alias + ? (Array.isArray(q.alias) ? q.alias : [q.alias]) + .map(a => a.length === 1 ? `-${a}` : `--${a}`) + .join(', ') + : ''; + const aliasStr = aliases ? ` (${aliases})` : ''; + const label = q.message || q.name; + const req = q.required ? ' [REQUIRED]' : ''; + const def = 'default' in q && q.default !== undefined ? ` (default: ${JSON.stringify(q.default)})` : ''; + lines.push(` ${flag}${aliasStr} ${label}${req}${def}`); + } + + lines.push(''); + lines.push('HOW TO FIX:'); + lines.push(' 1. Pass all required arguments as CLI flags:'); + + const requiredFlags = allQuestions + .filter(q => q.required) + .map(q => `--${q.name} `); + if (requiredFlags.length > 0) { + lines.push(` $ command ${requiredFlags.join(' ')}`); + } else { + lines.push(' $ command --flag1 --flag2 '); + } + + lines.push(''); + lines.push(' 2. Or enable non-interactive mode:'); + lines.push(' new Inquirerer({ noTty: true, useDefaults: true })'); + lines.push(''); + lines.push(' 3. Or pass --no-tty if the CLI supports it.'); + lines.push(''); + lines.push('WHY THIS HAPPENED:'); + lines.push(' The prompt expected interactive TTY input (keyboard), but no input'); + lines.push(' was received. AI agents and CI pipelines must pass arguments via'); + lines.push(' CLI flags instead of interactive prompts.'); + lines.push(''); + + return lines.join('\n'); + } + // Method to cleanly close the readline interface // NOTE: use exit() to close! public close() { From 851660cd26d453a4758d9e6b8626860e5f19896f Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 27 Jun 2026 11:02:12 +0000 Subject: [PATCH 2/3] chore: reduce default non-TTY timeout to 15s --- packages/inquirerer/src/prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index b43e50fe..16d2be8d 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -196,7 +196,7 @@ export class PromptTimeoutError extends Error { } } -const DEFAULT_NON_TTY_TIMEOUT = 30_000; +const DEFAULT_NON_TTY_TIMEOUT = 15_000; export interface InquirererOptions { noTty?: boolean; From f75330f045abe3b94bb441a2cd2286a17bb335e1 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 27 Jun 2026 11:05:42 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20make=20timeout=20resettable=20?= =?UTF-8?q?=E2=80=94=20resets=20on=20every=20user=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inactivity timer now resets each time the user does anything (types a character, navigates with arrow keys, selects an option). This means a user stepping through 15 options won't hit the timeout as long as they keep interacting. The 15s timeout only fires after 15 seconds of zero input activity. --- packages/inquirerer/src/prompt.ts | 73 +++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index 16d2be8d..c686e9c8 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -220,6 +220,10 @@ export class Inquirerer { private resolverRegistry: DefaultResolverRegistry; private timeout: number | undefined; + private _inactivityTimer: ReturnType | null = null; + private _inactivityReject: ((err: PromptTimeoutError) => void) | null = null; + private _inactivityContext: { question: Question; allQuestions: Question[]; ms: number } | null = null; + private handledKeys: Set = new Set(); constructor( @@ -524,11 +528,17 @@ export class Inquirerer { } while (ctx.needsInput) { - obj[question.name] = await this.withTimeout( - this.handleQuestionType(question, ctx), - question, - ordered - ); + const timeoutPromise = this.startInactivityTimeout(question, ordered); + const removeActivityListener = this.setupInputActivityListener(); + try { + const questionPromise = this.handleQuestionType(question, ctx); + obj[question.name] = timeoutPromise + ? await Promise.race([questionPromise, timeoutPromise]) + : await questionPromise; + } finally { + removeActivityListener(); + this.clearInactivityTimeout(); + } if (!this.isValid(question, obj, ctx)) { if (this.noTty) { @@ -1402,29 +1412,56 @@ export class Inquirerer { return Math.min(this.globalMaxLines, defaultLength); } - private withTimeout(promise: Promise, currentQuestion: Question, allQuestions: Question[]): Promise { - if (this.timeout === undefined) { - return promise; - } + private startInactivityTimeout(question: Question, allQuestions: Question[]): Promise | null { + if (this.timeout === undefined) return null; const ms = this.timeout; + this._inactivityContext = { question, allQuestions, ms }; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + return new Promise((_, reject) => { + this._inactivityReject = reject; + this._inactivityTimer = setTimeout(() => { reject(new PromptTimeoutError( - this.formatTimeoutError(currentQuestion, allQuestions, ms), - currentQuestion, + this.formatTimeoutError(question, allQuestions, ms), + question, allQuestions )); }, ms); - - promise.then( - value => { clearTimeout(timer); resolve(value); }, - err => { clearTimeout(timer); reject(err); } - ); }); } + private resetInactivityTimeout(): void { + if (this._inactivityTimer === null || this._inactivityReject === null || this._inactivityContext === null) return; + + clearTimeout(this._inactivityTimer); + const { question, allQuestions, ms } = this._inactivityContext; + const rejectFn = this._inactivityReject; + this._inactivityTimer = setTimeout(() => { + rejectFn(new PromptTimeoutError( + this.formatTimeoutError(question, allQuestions, ms), + question, + allQuestions + )); + }, ms); + } + + private clearInactivityTimeout(): void { + if (this._inactivityTimer !== null) { + clearTimeout(this._inactivityTimer); + this._inactivityTimer = null; + } + this._inactivityReject = null; + this._inactivityContext = null; + } + + private setupInputActivityListener(): () => void { + if (this.timeout === undefined) return () => {}; + + const onData = () => this.resetInactivityTimeout(); + this.input.on('data', onData); + return () => this.input.removeListener('data', onData); + } + private formatTimeoutError(currentQuestion: Question, allQuestions: Question[], ms: number): string { const seconds = Math.round(ms / 1000); const lines: string[] = [];