From f7dfeef5548148d2282df99ea944eb48f14f3cf6 Mon Sep 17 00:00:00 2001 From: Marcelo Shima Date: Tue, 17 Sep 2024 12:46:45 -0300 Subject: [PATCH 1/3] implement createPromptSession --- packages/inquirer/inquirer.test.mts | 58 +++++++++++++++++ packages/inquirer/src/index.mts | 11 +++- packages/inquirer/src/ui/prompt.mts | 97 +++++++++++++++++------------ 3 files changed, 123 insertions(+), 43 deletions(-) diff --git a/packages/inquirer/inquirer.test.mts b/packages/inquirer/inquirer.test.mts index 23cacbb02..d54848861 100644 --- a/packages/inquirer/inquirer.test.mts +++ b/packages/inquirer/inquirer.test.mts @@ -786,6 +786,64 @@ describe('inquirer.prompt(...)', () => { }); }); +describe('createPromptSession', () => { + it('should expose a Reactive subject across a session', async () => { + const localPrompt = inquirer.createPromptModule(); + localPrompt.registerPrompt('stub', StubPrompt); + const session = localPrompt.createPromptSession(); + const spy = vi.fn(); + + await session.run([ + { + type: 'stub', + name: 'nonSubscribed', + message: 'nonSubscribedMessage', + answer: 'nonSubscribedAnswer', + }, + ]); + + session.process.subscribe(spy); + expect(spy).not.toHaveBeenCalled(); + + await session.run([ + { + type: 'stub', + name: 'name1', + message: 'message', + answer: 'bar', + }, + { + type: 'stub', + name: 'name', + message: 'message', + answer: 'doe', + }, + ]); + + expect(spy).toHaveBeenCalledWith({ name: 'name1', answer: 'bar' }); + expect(spy).toHaveBeenCalledWith({ name: 'name', answer: 'doe' }); + }); + + it('should return proxy object as prefilled answers', async () => { + const localPrompt = inquirer.createPromptModule(); + localPrompt.registerPrompt('stub', StubPrompt); + + const proxy = new Proxy({ prefilled: 'prefilled' }, {}); + const session = localPrompt.createPromptSession({ answers: proxy }); + + const answers = await session.run([ + { + type: 'stub', + name: 'nonSubscribed', + message: 'nonSubscribedMessage', + answer: 'nonSubscribedAnswer', + }, + ]); + + expect(answers).toBe(proxy); + }); +}); + describe('AbortSignal support', () => { it('throws on aborted signal', async () => { const localPrompt = inquirer.createPromptModule({ diff --git a/packages/inquirer/src/index.mts b/packages/inquirer/src/index.mts index 4da48ff1b..3bfb54976 100644 --- a/packages/inquirer/src/index.mts +++ b/packages/inquirer/src/index.mts @@ -98,9 +98,8 @@ export function createPromptModule< questions: PromptSession, answers?: Partial, ): PromptReturnType { - const runner = new PromptsRunner(promptModule.prompts, opt); - - const promptPromise = runner.run(questions, answers); + const runner = promptModule.createPromptSession({ answers }); + const promptPromise = runner.run(questions); return Object.assign(promptPromise, { ui: runner }); } @@ -124,6 +123,12 @@ export function createPromptModule< promptModule.prompts = { ...builtInPrompts }; }; + promptModule.createPromptSession = function ({ + answers, + }: { answers?: Partial } = {}) { + return new PromptsRunner(promptModule.prompts, { ...opt, answers }); + }; + return promptModule; } diff --git a/packages/inquirer/src/ui/prompt.mts b/packages/inquirer/src/ui/prompt.mts index 01059b8ef..4fbd36b8f 100644 --- a/packages/inquirer/src/ui/prompt.mts +++ b/packages/inquirer/src/ui/prompt.mts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */ import readline from 'node:readline'; +import { isProxy } from 'node:util/types'; import { defer, - EMPTY, from, of, concatMap, @@ -10,7 +10,9 @@ import { reduce, isObservable, Observable, + Subject, lastValueFrom, + tap, } from 'rxjs'; import runAsync from 'run-async'; import MuteStream from 'mute-stream'; @@ -40,11 +42,7 @@ export const _ = { pointer = pointer[key] as Record; }); }, - get: ( - obj: object, - path: string | number | symbol = '', - defaultValue?: unknown, - ): any => { + get: (obj: object, path: string = '', defaultValue?: unknown): any => { const travel = (regexp: RegExp) => String.prototype.split .call(path, regexp) @@ -193,22 +191,42 @@ function isPromptConstructor( */ export default class PromptsRunner { private prompts: PromptCollection; - answers: Partial = {}; - process: Observable = EMPTY; + answers: Partial; + process: Subject<{ name: string; answer: any }> = new Subject(); private abortController: AbortController = new AbortController(); private opt: StreamOptions; - constructor(prompts: PromptCollection, opt: StreamOptions = {}) { + constructor( + prompts: PromptCollection, + { answers = {}, ...opt }: StreamOptions & { answers?: Partial } = {}, + ) { this.opt = opt; this.prompts = prompts; + + this.answers = isProxy(answers) + ? answers + : new Proxy( + { ...answers }, + { + get: (target, prop) => { + if (typeof prop !== 'string') { + return; + } + return _.get(target, prop); + }, + set: (target, prop: string, value) => { + _.set(target, prop, value); + return true; + }, + }, + ); } - async run(questions: PromptSession, answers?: Partial): Promise { + async run = PromptSession>( + questions: Session, + ): Promise { this.abortController = new AbortController(); - // Keep global reference to the answers - this.answers = typeof answers === 'object' ? { ...answers } : {}; - let obs: Observable>; if (isQuestionArray(questions)) { obs = from(questions); @@ -223,34 +241,36 @@ export default class PromptsRunner { ); } else { // Case: Called with a single question config - obs = from([questions]); + obs = from([questions as AnyQuestion]); } - this.process = obs.pipe( - concatMap((question) => - of(question).pipe( + return lastValueFrom( + obs + .pipe( concatMap((question) => - from( - this.shouldRun(question).then((shouldRun: boolean | void) => { - if (shouldRun) { - return question; - } - return; - }), - ).pipe(filter((val) => val != null)), + of(question) + .pipe( + concatMap((question) => + from( + this.shouldRun(question).then((shouldRun: boolean | void) => { + if (shouldRun) { + return question; + } + return; + }), + ).pipe(filter((val) => val != null)), + ), + concatMap((question) => defer(() => from(this.fetchAnswer(question)))), + ) + .pipe(tap((answer) => this.process.next(answer))), ), - concatMap((question) => defer(() => from(this.fetchAnswer(question)))), + ) + .pipe( + reduce((answersObj: Record, answer: { name: string; answer: unknown }) => { + answersObj[answer.name] = answer.answer; + return answersObj; + }, this.answers), ), - ), - ); - - return lastValueFrom( - this.process.pipe( - reduce((answersObj, answer: { name: string; answer: unknown }) => { - _.set(answersObj, answer.name, answer.answer); - return answersObj; - }, this.answers), - ), ) .then(() => this.answers as A) .finally(() => this.close()); @@ -388,10 +408,7 @@ export default class PromptsRunner { }; private shouldRun = async (question: AnyQuestion): Promise => { - if ( - question.askAnswered !== true && - _.get(this.answers, question.name) !== undefined - ) { + if (question.askAnswered !== true && this.answers[question.name] !== undefined) { return false; } From f818c27fbdb3c78cbf85331053a2c115c297e1de Mon Sep 17 00:00:00 2001 From: Marcelo Shima Date: Tue, 17 Sep 2024 18:37:02 -0300 Subject: [PATCH 2/3] extract methods --- packages/inquirer/src/ui/prompt.mts | 335 ++++++++++++++++------------ 1 file changed, 187 insertions(+), 148 deletions(-) diff --git a/packages/inquirer/src/ui/prompt.mts b/packages/inquirer/src/ui/prompt.mts index 4fbd36b8f..5d8d29df9 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 | undefined, + 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; - }; } From 842a5ae05a65a4b81969a381dda36f6f47e8473e Mon Sep 17 00:00:00 2001 From: Marcelo Shima Date: Wed, 18 Sep 2024 14:19:46 -0300 Subject: [PATCH 3/3] make fields readonly --- packages/inquirer/src/ui/prompt.mts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/inquirer/src/ui/prompt.mts b/packages/inquirer/src/ui/prompt.mts index 5d8d29df9..ae093684d 100644 --- a/packages/inquirer/src/ui/prompt.mts +++ b/packages/inquirer/src/ui/prompt.mts @@ -331,11 +331,11 @@ async function fetchAnswer( * Base interface class other can inherits from */ export default class PromptsRunner { - private prompts: PromptCollection; - answers: Partial; - process: Subject<{ name: string; answer: any }> = new Subject(); + private readonly prompts: PromptCollection; + readonly answers: Partial; + readonly process: Subject<{ name: string; answer: any }> = new Subject(); private abortController: AbortController = new AbortController(); - private opt: StreamOptions; + private readonly opt: StreamOptions; constructor( prompts: PromptCollection,