diff --git a/packages/inquirer/src/ui/prompt.mts b/packages/inquirer/src/ui/prompt.mts index 4fbd36b8fb..292d5929e7 100644 --- a/packages/inquirer/src/ui/prompt.mts +++ b/packages/inquirer/src/ui/prompt.mts @@ -123,6 +123,15 @@ export type PromptFn = ( */ export type PromptCollection = Record; +type ResolvedQuestion = AnyQuestion< + A, + Type +> & { + message: string; + default?: any; + choices?: any; +}; + class TTYError extends Error { override name = 'TTYError'; isTtyError = true; @@ -186,6 +195,138 @@ function isPromptConstructor( ); } +async function shouldRun( + question: AnyQuestion, + answers: Partial, +): Promise { + if (question.askAnswered !== true && answers[question.name] !== undefined) { + return false; + } + + const { when } = question; + if (typeof when === 'function') { + const shouldRun = await runAsync(when)(answers); + return Boolean(shouldRun); + } + + return when !== false; +} + +function createLegacyPromptFn( + prompt: LegacyPromptConstructor, + answers: Partial, +): PromptFn { + return (q, opt) => + new Promise((resolve, reject) => { + let cleanupSignal: (() => void) | undefined; + + const { signal } = opt; + if (signal.aborted) { + reject(new AbortPromptError({ cause: signal.reason })); + return; + } + + const rl = readline.createInterface(setupReadlineOptions(opt)) as InquirerReadline; + + const abort = () => { + reject(new AbortPromptError({ cause: signal.reason })); + cleanup(); + }; + /** + * Handle the ^C exit + */ + const onForceClose = () => { + abort(); + process.kill(process.pid, 'SIGINT'); + console.log(''); + }; + + const onClose = () => { + process.removeListener('exit', onForceClose); + rl.removeListener('SIGINT', onForceClose); + rl.setPrompt(''); + rl.output.unmute(); + rl.output.write(ansiEscapes.cursorShow); + rl.output.end(); + rl.close(); + }; + + // Make sure new prompt start on a newline when closing + process.on('exit', onForceClose); + rl.on('SIGINT', onForceClose); + + const activePrompt = new prompt(q, rl, answers); + + const cleanup = () => { + onClose(); + cleanupSignal?.(); + }; + + signal.addEventListener('abort', abort); + cleanupSignal = () => { + signal.removeEventListener('abort', abort); + cleanupSignal = undefined; + }; + + activePrompt.run().then(resolve, reject).finally(cleanup); + }); +} + +async function prepareQuestion( + question: AnyQuestion, + answers: Partial, +) { + const [message, defaultValue, resolvedChoices] = await Promise.all([ + fetchAsyncQuestionProperty(question, 'message', answers), + fetchAsyncQuestionProperty(question, 'default', answers), + fetchAsyncQuestionProperty(question, 'choices', answers), + ]); + + let choices; + if (Array.isArray(resolvedChoices)) { + choices = resolvedChoices.map((choice: unknown) => { + if (typeof choice === 'string' || typeof choice === 'number') { + return { name: choice, value: choice }; + } else if ( + typeof choice === 'object' && + choice != null && + !('value' in choice) && + 'name' in choice + ) { + return { ...choice, value: choice.name }; + } + return choice; + }); + } + + return Object.assign({}, question, { + message, + default: defaultValue, + choices, + }); +} + +async function fetchAnswer( + prompt: PromptFn | LegacyPromptConstructor, + question: ResolvedQuestion, + answers: Partial, + context: StreamOptions & { signal: AbortSignal }, +) { + if (prompt == null) { + throw new Error(`Prompt for type ${question.type} not found`); + } + + const promptFn: PromptFn = isPromptConstructor(prompt) + ? createLegacyPromptFn(prompt, answers) + : prompt; + + const { filter = (value) => value } = question; + return promptFn(question, context).then((answer: unknown) => ({ + name: question.name, + answer: filter(answer, answers), + })); +} + /** * Base interface class other can inherits from */ @@ -227,6 +368,18 @@ export default class PromptsRunner { ): Promise { this.abortController = new AbortController(); + let cleanupModuleSignal: (() => void) | undefined; + const { signal: moduleSignal } = this.opt; + if (moduleSignal?.aborted) { + this.abortController.abort(moduleSignal.reason); + } else if (moduleSignal) { + const abort = () => this.abortController?.abort(moduleSignal.reason); + moduleSignal.addEventListener('abort', abort); + cleanupModuleSignal = () => { + moduleSignal.removeEventListener('abort', abort); + }; + } + let obs: Observable>; if (isQuestionArray(questions)) { obs = from(questions); @@ -252,153 +405,53 @@ export default class PromptsRunner { .pipe( concatMap((question) => from( - this.shouldRun(question).then((shouldRun: boolean | void) => { - if (shouldRun) { - return question; - } - return; - }), + shouldRun(question, this.answers).then( + (shouldRun: boolean | void) => { + if (shouldRun) { + return question; + } + return; + }, + ), ).pipe(filter((val) => val != null)), ), - concatMap((question) => defer(() => from(this.fetchAnswer(question)))), + concatMap((question) => + defer(() => + from( + prepareQuestion(question, this.answers).then((question) => + fetchAnswer( + this.prompts[question.type] ?? this.prompts['input'], + question, + this.answers, + { ...this.opt, signal: this.abortController.signal }, + ), + ), + ), + ), + ), ) .pipe(tap((answer) => this.process.next(answer))), ), ) .pipe( - reduce((answersObj: Record, answer: { name: string; answer: unknown }) => { - answersObj[answer.name] = answer.answer; - return answersObj; - }, this.answers), + reduce( + ( + answersObj: Record, + answer: { name: string; answer: unknown }, + ) => { + answersObj[answer.name] = answer.answer; + return answersObj; + }, + this.answers, + ), ), ) .then(() => this.answers as A) - .finally(() => this.close()); - } - - private prepareQuestion = async (question: AnyQuestion) => { - const [message, defaultValue, resolvedChoices] = await Promise.all([ - fetchAsyncQuestionProperty(question, 'message', this.answers), - fetchAsyncQuestionProperty(question, 'default', this.answers), - fetchAsyncQuestionProperty(question, 'choices', this.answers), - ]); - - let choices; - if (Array.isArray(resolvedChoices)) { - choices = resolvedChoices.map((choice: unknown) => { - if (typeof choice === 'string' || typeof choice === 'number') { - return { name: choice, value: choice }; - } else if ( - typeof choice === 'object' && - choice != null && - !('value' in choice) && - 'name' in choice - ) { - return { ...choice, value: choice.name }; - } - return choice; - }); - } - - return Object.assign({}, question, { - message, - default: defaultValue, - choices, - type: question.type in this.prompts ? question.type : 'input', - }); - }; - - private fetchAnswer = async (rawQuestion: AnyQuestion) => { - const question = await this.prepareQuestion(rawQuestion); - const prompt = this.prompts[question.type]; - - if (prompt == null) { - throw new Error(`Prompt for type ${question.type} not found`); - } - - let cleanupSignal: (() => void) | undefined; - - const promptFn: PromptFn = isPromptConstructor(prompt) - ? (q, opt) => - new Promise((resolve, reject) => { - const { signal } = opt; - if (signal.aborted) { - reject(new AbortPromptError({ cause: signal.reason })); - return; - } - - const rl = readline.createInterface( - setupReadlineOptions(opt), - ) as InquirerReadline; - - /** - * Handle the ^C exit - */ - const onForceClose = () => { - this.close(); - process.kill(process.pid, 'SIGINT'); - console.log(''); - }; - - const onClose = () => { - process.removeListener('exit', onForceClose); - rl.removeListener('SIGINT', onForceClose); - rl.setPrompt(''); - rl.output.unmute(); - rl.output.write(ansiEscapes.cursorShow); - rl.output.end(); - rl.close(); - }; - - // Make sure new prompt start on a newline when closing - process.on('exit', onForceClose); - rl.on('SIGINT', onForceClose); - - const activePrompt = new prompt(q, rl, this.answers); - - const cleanup = () => { - onClose(); - cleanupSignal?.(); - }; - - const abort = () => { - reject(new AbortPromptError({ cause: signal.reason })); - cleanup(); - }; - signal.addEventListener('abort', abort); - cleanupSignal = () => { - signal.removeEventListener('abort', abort); - cleanupSignal = undefined; - }; - - activePrompt.run().then(resolve, reject).finally(cleanup); - }) - : prompt; - - let cleanupModuleSignal: (() => void) | undefined; - const { signal: moduleSignal } = this.opt; - if (moduleSignal?.aborted) { - this.abortController.abort(moduleSignal.reason); - } else if (moduleSignal) { - const abort = () => this.abortController?.abort(moduleSignal.reason); - moduleSignal.addEventListener('abort', abort); - cleanupModuleSignal = () => { - moduleSignal.removeEventListener('abort', abort); - }; - } - - const { filter = (value) => value } = question; - const { signal } = this.abortController; - return promptFn(question, { ...this.opt, signal }) - .then((answer: unknown) => ({ - name: question.name, - answer: filter(answer, this.answers), - })) .finally(() => { - cleanupSignal?.(); cleanupModuleSignal?.(); + this.close(); }); - }; + } /** * Close the interface and cleanup listeners @@ -406,18 +459,4 @@ export default class PromptsRunner { close = () => { this.abortController?.abort(); }; - - private shouldRun = async (question: AnyQuestion): Promise => { - if (question.askAnswered !== true && this.answers[question.name] !== undefined) { - return false; - } - - const { when } = question; - if (typeof when === 'function') { - const shouldRun = await runAsync(when)(this.answers); - return Boolean(shouldRun); - } - - return when !== false; - }; }