UNPKG

inquirer

Version:

A collection of common interactive command line user interfaces.

264 lines (263 loc) 10.3 kB
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */ import readline from 'node:readline'; import { defer, EMPTY, from, of, concatMap, filter, reduce, isObservable, lastValueFrom, } from 'rxjs'; import runAsync from 'run-async'; import MuteStream from 'mute-stream'; import { AbortPromptError } from '@inquirer/core'; import ansiEscapes from 'ansi-escapes'; export const _ = { set: (obj, path = '', value) => { let pointer = obj; path.split('.').forEach((key, index, arr) => { if (key === '__proto__' || key === 'constructor') return; if (index === arr.length - 1) { pointer[key] = value; } else if (!(key in pointer) || typeof pointer[key] !== 'object') { pointer[key] = {}; } pointer = pointer[key]; }); }, get: (obj, path = '', defaultValue) => { const travel = (regexp) => String.prototype.split .call(path, regexp) .filter(Boolean) .reduce( // @ts-expect-error implicit any on res[key] (res, key) => (res == null ? res : res[key]), obj); const result = travel(/[,[\]]+?/) || travel(/[,.[\]]+?/); return result === undefined || result === obj ? defaultValue : result; }, }; /** * 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. */ async function fetchAsyncQuestionProperty(question, prop, answers) { const propGetter = question[prop]; if (typeof propGetter === 'function') { return runAsync(propGetter)(answers); } return propGetter; } class TTYError extends Error { name = 'TTYError'; isTtyError = true; } function setupReadlineOptions(opt) { // Inquirer 8.x: // opt.skipTTYChecks = opt.skipTTYChecks === undefined ? opt.input !== undefined : opt.skipTTYChecks; opt.skipTTYChecks = opt.skipTTYChecks === undefined ? true : opt.skipTTYChecks; // Default `input` to stdin const input = opt.input || process.stdin; // Check if prompt is being called in TTY environment // If it isn't return a failed promise // @ts-expect-error: ignore isTTY type error if (!opt.skipTTYChecks && !input.isTTY) { throw new TTYError('Prompts can not be meaningfully rendered in non-TTY environments'); } // Add mute capabilities to the output const ms = new MuteStream(); ms.pipe(opt.output || process.stdout); const output = ms; return { terminal: true, ...opt, input, output, }; } function isQuestionArray(questions) { return Array.isArray(questions); } function isQuestionMap(questions) { return Object.values(questions).every((maybeQuestion) => typeof maybeQuestion === 'object' && !Array.isArray(maybeQuestion) && maybeQuestion != null); } function isPromptConstructor(prompt) { return Boolean(prompt.prototype && 'run' in prompt.prototype && // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access typeof prompt.prototype.run === 'function'); } /** * Base interface class other can inherits from */ export default class PromptsRunner { prompts; answers = {}; process = EMPTY; abortController = new AbortController(); opt; constructor(prompts, opt = {}) { this.opt = opt; this.prompts = prompts; } async run(questions, answers) { this.abortController = new AbortController(); // Keep global reference to the answers this.answers = typeof answers === 'object' ? { ...answers } : {}; let obs; if (isQuestionArray(questions)) { obs = from(questions); } else if (isObservable(questions)) { obs = questions; } else if (isQuestionMap(questions)) { // Case: Called with a set of { name: question } obs = from(Object.entries(questions).map(([name, question]) => { return Object.assign({}, question, { name }); })); } else { // Case: Called with a single question config obs = from([questions]); } this.process = obs.pipe(concatMap((question) => of(question).pipe(concatMap((question) => from(this.shouldRun(question).then((shouldRun) => { if (shouldRun) { return question; } return; })).pipe(filter((val) => val != null))), concatMap((question) => defer(() => from(this.fetchAnswer(question))))))); return lastValueFrom(this.process.pipe(reduce((answersObj, answer) => { _.set(answersObj, answer.name, answer.answer); return answersObj; }, this.answers))) .then(() => this.answers) .finally(() => this.close()); } prepareQuestion = async (question) => { 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) => { const choiceObj = typeof choice !== 'object' || choice == null ? { name: choice, value: choice } : { ...choice, value: 'value' in choice ? choice.value : 'name' in choice ? choice.name : undefined, }; if ('value' in choiceObj && Array.isArray(defaultValue)) { // Add checked to question for backward compatibility. default was supported as alternative of per choice checked. return { checked: defaultValue.includes(choiceObj.value), ...choiceObj, }; } return choiceObj; }); } return Object.assign({}, question, { message, default: defaultValue, choices, type: question.type in this.prompts ? question.type : 'input', }); }; fetchAnswer = async (rawQuestion) => { 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; const 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)); /** * 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; 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) => ({ name: question.name, answer: filter(answer, this.answers), })) .finally(() => { cleanupSignal?.(); cleanupModuleSignal?.(); }); }; /** * Close the interface and cleanup listeners */ close = () => { this.abortController.abort(); }; shouldRun = async (question) => { if (question.askAnswered !== true && _.get(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; }; }