diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index 3167b04c..c686e9c8 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 = 15_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,11 @@ export class Inquirerer { private globalMaxLines: number; private mutateArgs: boolean; 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(); @@ -216,7 +236,8 @@ export class Inquirerer { useDefaults = false, globalMaxLines = 10, mutateArgs = true, - resolverRegistry = globalResolverRegistry + resolverRegistry = globalResolverRegistry, + timeout } = options ?? {} this.useDefaults = useDefaults; @@ -227,6 +248,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 +528,17 @@ export class Inquirerer { } while (ctx.needsInput) { - obj[question.name] = await this.handleQuestionType(question, ctx); + 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) { @@ -1375,6 +1412,110 @@ export class Inquirerer { return Math.min(this.globalMaxLines, defaultLength); } + 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((_, reject) => { + this._inactivityReject = reject; + this._inactivityTimer = setTimeout(() => { + reject(new PromptTimeoutError( + this.formatTimeoutError(question, allQuestions, ms), + question, + allQuestions + )); + }, ms); + }); + } + + 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[] = []; + + 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() {