From 00caff16ab5905608deb9bd40634ea36e739fd01 Mon Sep 17 00:00:00 2001 From: Marcelo Shima Date: Mon, 2 Sep 2024 18:45:54 -0300 Subject: [PATCH] Fix(inquirer): Rework type interface (#1531) Ref #1527 ------------ Co-authored-by: Simon Boudrias --- packages/inquirer/inquirer.test.mts | 78 ++++++++-------- packages/inquirer/src/index.mts | 58 +++++++----- packages/inquirer/src/types.mts | 139 +++++++++++++++------------- packages/inquirer/src/ui/prompt.mts | 74 +++++---------- 4 files changed, 175 insertions(+), 174 deletions(-) diff --git a/packages/inquirer/inquirer.test.mts b/packages/inquirer/inquirer.test.mts index ad26064b9..589c07285 100644 --- a/packages/inquirer/inquirer.test.mts +++ b/packages/inquirer/inquirer.test.mts @@ -8,22 +8,29 @@ import os from 'node:os'; import stream from 'node:stream'; import tty from 'node:tty'; import { vi, expect, beforeEach, afterEach, describe, it, expectTypeOf } from 'vitest'; -import { Observable } from 'rxjs'; +import { of } from 'rxjs'; import type { InquirerReadline } from '@inquirer/type'; import inquirer, { type QuestionMap } from './src/index.mjs'; -import type { Answers, Question } from './src/types.mjs'; +import type { Answers } from './src/types.mjs'; import { _ } from './src/ui/prompt.mjs'; declare module './src/index.mjs' { interface QuestionMap { - stub: { answer?: string | boolean; message: string }; + stub: { answer?: string | boolean; message: string; default?: string }; stub2: { answer?: string | boolean; message: string; default: string }; - stubSelect: { choices: { value: string }[] }; + stubSelect: { choices: string[] }; failing: { message: string }; } } -function throwFunc(step: string) { +type TestQuestions = { + stub: { answer?: string | boolean; message: string }; + stub2: { answer?: string | boolean; message: string; default: string }; + stubSelect: { choices: string[] }; + failing: { message: string }; +}; + +function throwFunc(step: any): any { throw new Error(`askAnswered Error ${step}`); } @@ -110,27 +117,24 @@ describe('inquirer.prompt(...)', () => { it('takes an Observable', async () => { const answers = await inquirer.prompt( - new Observable>((subscriber) => { - subscriber.next({ + of( + { type: 'stub', name: 'q1', message: 'message', answer: true, - }); - setTimeout(() => { - subscriber.next({ - type: 'stub', - name: 'q2', - message: 'message', - answer: false, - }); - subscriber.complete(); - }, 30); - }), + } as const, + { + type: 'stub', + name: 'q2', + message: 'message', + answer: false, + } as const, + ), ); expect(answers).toEqual({ q1: true, q2: false }); - expectTypeOf(answers).toEqualTypeOf<{ q1: boolean; q2: boolean }>(); + expectTypeOf(answers).toEqualTypeOf<{ q1: any; q2: any }>(); }); }); @@ -273,7 +277,6 @@ describe('inquirer.prompt(...)', () => { name: 'name2', answer: 'foo', message(answers) { - // @ts-expect-error TODO fix answer types passed in getters. expectTypeOf(answers).toEqualTypeOf>(); expect(answers).toEqual({ name1: 'bar' }); const goOn = this.async(); @@ -299,7 +302,7 @@ describe('inquirer.prompt(...)', () => { type: 'stub', name: 'name', message: 'message', - default(answers: { name1: string }) { + default(answers) { expect(answers.name1).toEqual('bar'); return 'foo'; }, @@ -337,7 +340,6 @@ describe('inquirer.prompt(...)', () => { message: 'message', default(answers) { goesInDefault = true; - // @ts-expect-error TODO fix answer types passed in getters. expectTypeOf(answers).toEqualTypeOf>(); expect(answers).toEqual({ name1: 'bar' }); const goOn = this.async(); @@ -413,7 +415,6 @@ describe('inquirer.prompt(...)', () => { name: 'name', message: 'message', choices(answers) { - // @ts-expect-error TODO fix answer types passed in getters. expectTypeOf(answers).toEqualTypeOf>(); expect(answers).toEqual({ name1: 'bar' }); return stubChoices; @@ -581,7 +582,6 @@ describe('inquirer.prompt(...)', () => { answer: 'answer from running', when(answers) { expect(answers).toEqual({ q1: 'bar' }); - // @ts-expect-error TODO fix answer types passed in getters. expectTypeOf(answers).toEqualTypeOf>(); goesInWhen = true; @@ -635,14 +635,13 @@ describe('inquirer.prompt(...)', () => { it('should not run prompt if answer exists for question', async () => { const answers = await inquirer.prompt( - // @ts-expect-error Passing wrong type on purpose. [ { type: 'input', name: 'prefilled', - when: throwFunc.bind(undefined, 'when'), - validate: throwFunc.bind(undefined, 'validate'), - transformer: throwFunc.bind(undefined, 'transformer'), + when: throwFunc, + validate: throwFunc, + transformer: throwFunc, message: 'message', default: 'newValue', }, @@ -655,14 +654,13 @@ describe('inquirer.prompt(...)', () => { it('should not run prompt if nested answer exists for question', async () => { const answers = await inquirer.prompt( - // @ts-expect-error Passing wrong type on purpose. [ { type: 'input', name: 'prefilled.nested', - when: throwFunc.bind(undefined, 'when'), - validate: throwFunc.bind(undefined, 'validate'), - transformer: throwFunc.bind(undefined, 'transformer'), + when: throwFunc, + validate: throwFunc, + transformer: throwFunc, message: 'message', default: 'newValue', }, @@ -773,7 +771,9 @@ describe('Non-TTY checks', () => { }); it('Throw an exception when run in non-tty', async () => { - const localPrompt = inquirer.createPromptModule({ skipTTYChecks: false }); + const localPrompt = inquirer.createPromptModule({ + skipTTYChecks: false, + }); localPrompt.registerPrompt('stub', StubPrompt); const promise = localPrompt([ @@ -787,7 +787,7 @@ describe('Non-TTY checks', () => { }); it("Don't throw an exception when run in non-tty by default ", async () => { - const localPrompt = inquirer.createPromptModule(); + const localPrompt = inquirer.createPromptModule(); localPrompt.registerPrompt('stub', StubPrompt); await localPrompt([ @@ -805,7 +805,9 @@ describe('Non-TTY checks', () => { }); it("Don't throw an exception when run in non-tty and skipTTYChecks is true ", async () => { - const localPrompt = inquirer.createPromptModule({ skipTTYChecks: true }); + const localPrompt = inquirer.createPromptModule({ + skipTTYChecks: true, + }); localPrompt.registerPrompt('stub', StubPrompt); await localPrompt([ @@ -823,7 +825,7 @@ describe('Non-TTY checks', () => { }); it("Don't throw an exception when run in non-tty and custom input is provided async ", async () => { - const localPrompt = inquirer.createPromptModule({ + const localPrompt = inquirer.createPromptModule({ input: new stream.Readable({ // We must have a default read implementation // for this to work, if not it will error out @@ -849,7 +851,7 @@ describe('Non-TTY checks', () => { }); it('Throw an exception when run in non-tty and custom input is provided with skipTTYChecks: false', async () => { - const localPrompt = inquirer.createPromptModule({ + const localPrompt = inquirer.createPromptModule({ input: new stream.Readable(), skipTTYChecks: false, }); @@ -871,7 +873,7 @@ describe('Non-TTY checks', () => { const input = new tty.ReadStream(fs.openSync('/dev/tty', 'r+')); // Uses manually opened tty as input instead of process.stdin - const localPrompt = inquirer.createPromptModule({ + const localPrompt = inquirer.createPromptModule({ input, skipTTYChecks: false, }); diff --git a/packages/inquirer/src/index.mts b/packages/inquirer/src/index.mts index 0de17eb7f..6c414dd1a 100644 --- a/packages/inquirer/src/index.mts +++ b/packages/inquirer/src/index.mts @@ -16,7 +16,7 @@ import { search, Separator, } from '@inquirer/prompts'; -import type { Prettify, UnionToIntersection } from '@inquirer/type'; +import type { Prettify } from '@inquirer/type'; import { default as PromptsRunner } from './ui/prompt.mjs'; import type { PromptCollection, @@ -25,12 +25,12 @@ import type { } from './ui/prompt.mjs'; import type { Answers, - Question, - QuestionAnswerMap, - QuestionArray, - QuestionObservable, + CustomQuestion, + BuiltInQuestion, StreamOptions, + QuestionMap, } from './types.mjs'; +import { Observable } from 'rxjs'; export type { QuestionMap } from './types.mjs'; @@ -56,42 +56,52 @@ type PromptReturnType = Promise> & { /** * Create a new self-contained prompt module. */ -export function createPromptModule(opt?: StreamOptions) { +export function createPromptModule< + Prompts extends Record> = never, +>(opt?: StreamOptions) { + type Question = BuiltInQuestion | CustomQuestion; + type NamedQuestion = Question & { + name: Extract; + }; function promptModule< - const AnswerList extends readonly Answers[], + const A extends Answers, PrefilledAnswers extends Answers = object, >( - questions: { [I in keyof AnswerList]: Question }, + questions: NamedQuestion>[], answers?: PrefilledAnswers, - ): PromptReturnType>; + ): PromptReturnType>; function promptModule< - const Map extends QuestionAnswerMap, - const A extends Answers>, + const A extends Answers, PrefilledAnswers extends Answers = object, - >(questions: Map, answers?: PrefilledAnswers): PromptReturnType; + >( + questions: { + [name in keyof A]: Question>; + }, + answers?: PrefilledAnswers, + ): PromptReturnType>>>; function promptModule< const A extends Answers, PrefilledAnswers extends Answers = object, >( - questions: QuestionObservable, + questions: Observable>>, answers?: PrefilledAnswers, - ): PromptReturnType; + ): PromptReturnType>; function promptModule< const A extends Answers, PrefilledAnswers extends Answers = object, >( - questions: Question, + questions: NamedQuestion, answers?: PrefilledAnswers, ): PromptReturnType; - function promptModule( + function promptModule( questions: - | QuestionArray - | QuestionAnswerMap - | QuestionObservable - | Question, - answers?: Partial, - ): PromptReturnType { - const runner = new PromptsRunner(promptModule.prompts, opt); + | NamedQuestion[] + | Record> + | Observable> + | NamedQuestion, + answers?: Partial, + ): PromptReturnType { + const runner = new PromptsRunner(promptModule.prompts, opt); const promptPromise = runner.run(questions, answers); return Object.assign(promptPromise, { ui: runner }); @@ -123,7 +133,7 @@ export function createPromptModule(opt?: StreamOptions) { /** * Public CLI helper interface */ -const prompt = createPromptModule(); +const prompt = createPromptModule>(); // Expose helper functions on the top level for easiest usage by common users function registerPrompt(name: string, newPrompt: LegacyPromptConstructor) { diff --git a/packages/inquirer/src/types.mts b/packages/inquirer/src/types.mts index f531c4973..1a3d49da9 100644 --- a/packages/inquirer/src/types.mts +++ b/packages/inquirer/src/types.mts @@ -1,21 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { - input, - select, - number, +import { + checkbox, confirm, - rawlist, + editor, expand, - checkbox, + input, + number, password, - editor, + rawlist, + search, + select, } from '@inquirer/prompts'; -import type { Prettify, KeyUnion, DistributiveMerge, Pick } from '@inquirer/type'; +import type { Context, DistributiveMerge, Prettify } from '@inquirer/type'; import { Observable } from 'rxjs'; -export type Answers = { - [key in Key]: any; -}; +export type Answers = Record; type AsyncCallbackFunction = ( ...args: [error: null | undefined, value: R] | [error: Error, value: undefined] @@ -23,66 +22,80 @@ type AsyncCallbackFunction = ( type AsyncGetterFunction = ( this: { async: () => AsyncCallbackFunction }, - answers: Partial, + answers: Prettify>, ) => void | R | Promise; +/** + * Allows to inject a custom question type into inquirer module. + * + * @example + * ```ts + * declare module 'inquirer' { + * interface QuestionMap { + * custom: { message: string }; + * } + * } + * ``` + * + * Globally defined question types are not correct. + */ export interface QuestionMap { - input: Parameters[0]; - select: Parameters[0]; - /** @deprecated `list` is now named `select` */ - list: Parameters[0]; - number: Parameters[0]; - confirm: Parameters[0]; - rawlist: Parameters[0]; - expand: Parameters[0]; - checkbox: Parameters[0]; - password: Parameters[0]; - editor: Parameters[0]; + // Dummy key to avoid empty object type + __dummy: { message: string }; } -type PromptConfigMap = { - [key in keyof QuestionMap]: Readonly< - DistributiveMerge< - QuestionMap[keyof QuestionMap], - { - type: keyof QuestionMap; - name: KeyUnion; - when?: AsyncGetterFunction> | boolean; - askAnswered?: boolean; - message: - | Pick - | AsyncGetterFunction< - Pick, - Prettify - >; - choices?: - | Pick - | string[] - | AsyncGetterFunction< - Pick | string[], - Prettify - >; - default?: - | Pick - | AsyncGetterFunction< - Pick | string[], - Prettify - >; - } - > - >; +type KeyValueOrAsyncGetterFunction = + T extends Record ? T[k] | AsyncGetterFunction : never; + +export type AnyQuestion = { + type: Type; + name: string; + askAnswered?: boolean; + when?: boolean | AsyncGetterFunction; }; -export type Question = PromptConfigMap[keyof PromptConfigMap]; +type QuestionWithGetters< + Type extends string, + Q extends Record, + A extends Answers, +> = DistributiveMerge< + Q, + { + type: Type; + askAnswered?: boolean; + when?: boolean | AsyncGetterFunction; + filter?(input: any, answers: A): any; + message: KeyValueOrAsyncGetterFunction; + default?: KeyValueOrAsyncGetterFunction; + choices?: KeyValueOrAsyncGetterFunction; + } +>; -export type QuestionAnswerMap = Readonly<{ - [name in KeyUnion]: Omit, 'name'>; -}>; +export type BuiltInQuestion = + | QuestionWithGetters<'checkbox', Parameters[0], A> + | QuestionWithGetters<'confirm', Parameters[0], A> + | QuestionWithGetters<'editor', Parameters[0], A> + | QuestionWithGetters<'expand', Parameters[0], A> + | QuestionWithGetters<'input', Parameters[0], A> + | QuestionWithGetters<'number', Parameters[0], A> + | QuestionWithGetters<'password', Parameters[0], A> + | QuestionWithGetters<'rawlist', Parameters[0], A> + | QuestionWithGetters<'search', Parameters[0], A> + // Alias list type to select; it's been renamed. + | QuestionWithGetters<'list', Parameters[0], A> + | QuestionWithGetters<'select', Parameters[0], A>; -export type QuestionArray = readonly Question[]; +export type CustomQuestion< + A extends Answers, + Q extends Record>, +> = { + [key in Extract]: Readonly>; +}[Extract]; -export type QuestionObservable = Observable>; +export type PromptSession> = + | Q[] + | Record> + | Observable + | Q; -export type StreamOptions = Prettify< - Parameters[1] & { skipTTYChecks?: boolean } ->; +export type StreamOptions = Prettify; diff --git a/packages/inquirer/src/ui/prompt.mts b/packages/inquirer/src/ui/prompt.mts index 79ad4c297..b0bf282db 100644 --- a/packages/inquirer/src/ui/prompt.mts +++ b/packages/inquirer/src/ui/prompt.mts @@ -16,14 +16,7 @@ import runAsync from 'run-async'; import MuteStream from 'mute-stream'; import type { InquirerReadline } from '@inquirer/type'; import ansiEscapes from 'ansi-escapes'; -import type { - Answers, - Question, - QuestionAnswerMap, - QuestionArray, - QuestionObservable, - StreamOptions, -} from '../types.mjs'; +import type { Answers, AnyQuestion, PromptSession, StreamOptions } from '../types.mjs'; export const _ = { set: (obj: Record, path: string = '', value: unknown): void => { @@ -64,11 +57,11 @@ export const _ = { * Resolve a question property value if it is passed as a function. * This method will overwrite the property on the question object with the received value. */ -function fetchAsyncQuestionProperty>( +function fetchAsyncQuestionProperty>( question: Q, prop: string, answers: A, -) { +): Observable> { if (prop in question) { const propGetter = question[prop as keyof Q]; if (typeof propGetter === 'function') { @@ -130,7 +123,7 @@ class TTYError extends Error { isTtyError = true; } -function setupReadlineOptions(opt: StreamOptions = {}) { +function setupReadlineOptions(opt: StreamOptions) { // Inquirer 8.x: // opt.skipTTYChecks = opt.skipTTYChecks === undefined ? opt.input !== undefined : opt.skipTTYChecks; opt.skipTTYChecks = opt.skipTTYChecks === undefined ? true : opt.skipTTYChecks; @@ -161,22 +154,14 @@ function setupReadlineOptions(opt: StreamOptions = {}) { } function isQuestionArray( - questions: - | QuestionArray - | QuestionAnswerMap - | QuestionObservable - | Question, -): questions is QuestionArray { + questions: PromptSession>, +): questions is AnyQuestion[] { return Array.isArray(questions); } function isQuestionMap( - questions: - | QuestionArray - | QuestionAnswerMap - | QuestionObservable - | Question, -): questions is QuestionAnswerMap { + questions: PromptSession>, +): questions is Record, 'name'>> { return Object.values(questions).every( (maybeQuestion) => typeof maybeQuestion === 'object' && @@ -204,38 +189,29 @@ export default class PromptsRunner { answers: Partial = {}; process: Observable = EMPTY; onClose?: () => void; - opt?: StreamOptions; + opt: StreamOptions; rl?: InquirerReadline; - constructor(prompts: PromptCollection, opt?: StreamOptions) { + constructor(prompts: PromptCollection, opt: StreamOptions = {}) { this.opt = opt; this.prompts = prompts; } - async run( - questions: - | QuestionArray - | QuestionAnswerMap - | QuestionObservable - | Question, - answers?: Partial, - ): Promise { + async run(questions: PromptSession>, answers?: Partial): Promise { // Keep global reference to the answers this.answers = typeof answers === 'object' ? { ...answers } : {}; - let obs: Observable>; + let obs: Observable>; if (isQuestionArray(questions)) { obs = from(questions); } else if (isObservable(questions)) { obs = questions; - } else if (isQuestionMap(questions)) { + } else if (isQuestionMap(questions)) { // Case: Called with a set of { name: question } obs = from( - Object.entries(questions).map( - ([name, question]: [string, Omit, 'name'>]): Question => { - return Object.assign({}, question, { name }) as Question; - }, - ), + Object.entries(questions).map(([name, question]): AnyQuestion => { + return Object.assign({}, question, { name }); + }), ); } else { // Case: Called with a single question config @@ -256,7 +232,7 @@ export default class PromptsRunner { .finally(() => this.close()); } - processQuestion(question: Question) { + processQuestion(question: AnyQuestion) { question = { ...question }; return defer(() => { const obs = of(question); @@ -274,10 +250,8 @@ export default class PromptsRunner { fetchAsyncQuestionProperty(question, 'choices', this.answers), ), concatMap((question) => { - const { choices } = question; - if (Array.isArray(choices)) { - // @ts-expect-error question type is too loose - question.choices = choices.map( + if ('choices' in question && Array.isArray(question.choices)) { + const choices = question.choices.map( (choice: string | number | { value?: string; name: string }) => { if (typeof choice === 'string' || typeof choice === 'number') { return { name: choice, value: choice }; @@ -287,6 +261,8 @@ export default class PromptsRunner { return choice; }, ); + + return of({ ...question, choices }); } return of(question); @@ -296,7 +272,7 @@ export default class PromptsRunner { }); } - fetchAnswer(question: Question) { + fetchAnswer(question: AnyQuestion) { const prompt = this.prompts[question.type]; if (prompt == null) { @@ -368,7 +344,7 @@ export default class PromptsRunner { } }; - setDefaultType = (question: Question): Observable> => { + setDefaultType = (question: AnyQuestion): Observable> => { // Default type to input if (!this.prompts[question.type]) { question = Object.assign({}, question, { type: 'input' }); @@ -377,7 +353,7 @@ export default class PromptsRunner { return defer(() => of(question)); }; - filterIfRunnable = (question: Question): Observable> => { + filterIfRunnable = (question: AnyQuestion): Observable> => { if ( question.askAnswered !== true && _.get(this.answers, question.name) !== undefined @@ -402,7 +378,7 @@ export default class PromptsRunner { } return; }), - ).pipe(filter((val): val is Question => val != null)), + ).pipe(filter((val) => val != null)), ); }; }