Skip to content
Merged
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
145 changes: 143 additions & 2 deletions packages/inquirerer/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -192,6 +206,7 @@ export interface InquirererOptions {
globalMaxLines?: number;
mutateArgs?: boolean;
resolverRegistry?: DefaultResolverRegistry;
timeout?: number;
}
export class Inquirerer {
private rl: readline.Interface | null;
Expand All @@ -203,6 +218,11 @@ export class Inquirerer {
private globalMaxLines: number;
private mutateArgs: boolean;
private resolverRegistry: DefaultResolverRegistry;
private timeout: number | undefined;

private _inactivityTimer: ReturnType<typeof setTimeout> | null = null;
private _inactivityReject: ((err: PromptTimeoutError) => void) | null = null;
private _inactivityContext: { question: Question; allQuestions: Question[]; ms: number } | null = null;

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

Expand All @@ -216,7 +236,8 @@ export class Inquirerer {
useDefaults = false,
globalMaxLines = 10,
mutateArgs = true,
resolverRegistry = globalResolverRegistry
resolverRegistry = globalResolverRegistry,
timeout
} = options ?? {}

this.useDefaults = useDefaults;
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1375,6 +1412,110 @@ export class Inquirerer {
return Math.min(this.globalMaxLines, defaultLength);
}

private startInactivityTimeout(question: Question, allQuestions: Question[]): Promise<never> | null {
if (this.timeout === undefined) return null;

const ms = this.timeout;
this._inactivityContext = { question, allQuestions, ms };

return new Promise<never>((_, 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} <value>`);
if (requiredFlags.length > 0) {
lines.push(` $ command ${requiredFlags.join(' ')}`);
} else {
lines.push(' $ command --flag1 <value> --flag2 <value>');
}

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() {
Expand Down
Loading