inquirer
Version:
A collection of common interactive command line user interfaces.
237 lines (236 loc) • 8.68 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
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 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 !== undefined ? res[key] : res), 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.
*/
function fetchAsyncQuestionProperty(question, prop, answers) {
if (prop in question) {
const propGetter = question[prop];
if (typeof propGetter === 'function') {
return from(runAsync(propGetter)(answers).then((value) => {
return Object.assign(question, { [prop]: value });
}));
}
}
return of(question);
}
class TTYError extends Error {
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 (prompt.prototype &&
'run' in prompt.prototype &&
typeof prompt.prototype.run === 'function');
}
/**
* Base interface class other can inherits from
*/
export default class PromptsRunner {
prompts;
answers = {};
process = EMPTY;
onClose;
opt;
rl;
constructor(prompts, opt) {
this.opt = opt;
this.prompts = prompts;
}
run(questions, answers) {
// 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) => this.processQuestion(question)));
const promise = lastValueFrom(this.process.pipe(reduce((answersObj, answer) => {
_.set(answersObj, answer.name, answer.answer);
return answersObj;
}, this.answers))).then(() => this.onCompletion(), (error) => this.onError(error));
return Object.assign(promise, { ui: this });
}
/**
* Once all prompt are over
*/
onCompletion() {
this.close();
return this.answers;
}
onError(error) {
this.close();
return Promise.reject(error);
}
processQuestion(question) {
question = { ...question };
return defer(() => {
const obs = of(question);
return obs.pipe(concatMap(this.setDefaultType), concatMap(this.filterIfRunnable), concatMap((question) => fetchAsyncQuestionProperty(question, 'message', this.answers)), concatMap((question) => fetchAsyncQuestionProperty(question, 'default', this.answers)), concatMap((question) => fetchAsyncQuestionProperty(question, 'choices', this.answers)), concatMap((question) => {
if ('choices' in question) {
// @ts-expect-error question type is too loose
question.choices = question.choices.map((choice) => {
if (typeof choice === 'string') {
return { name: choice, value: choice };
}
return choice;
});
}
return of(question);
}), concatMap((question) => this.fetchAnswer(question)));
});
}
fetchAnswer(question) {
const prompt = this.prompts[question.type];
if (prompt == null) {
throw new Error(`Prompt for type ${question.type} not found`);
}
return isPromptConstructor(prompt)
? defer(() => {
const rl = readline.createInterface(setupReadlineOptions(this.opt));
rl.resume();
const onClose = () => {
rl.removeListener('SIGINT', this.onForceClose);
rl.setPrompt('');
rl.output.unmute();
rl.output.write(ansiEscapes.cursorShow);
rl.output.end();
rl.close();
};
this.onClose = onClose;
this.rl = rl;
// Make sure new prompt start on a newline when closing
process.on('exit', this.onForceClose);
rl.on('SIGINT', this.onForceClose);
const activePrompt = new prompt(question, rl, this.answers);
return from(activePrompt.run().then((answer) => {
onClose();
this.onClose = undefined;
this.rl = undefined;
return { name: question.name, answer };
}));
})
: defer(() => from(prompt(question, this.opt).then((answer) => ({
name: question.name,
answer,
}))));
}
/**
* Handle the ^C exit
*/
onForceClose = () => {
this.close();
process.kill(process.pid, 'SIGINT');
console.log('');
};
/**
* Close the interface and cleanup listeners
*/
close = () => {
// Remove events listeners
process.removeListener('exit', this.onForceClose);
if (typeof this.onClose === 'function') {
this.onClose();
}
};
setDefaultType = (question) => {
// Default type to input
if (!this.prompts[question.type]) {
question = Object.assign({}, question, { type: 'input' });
}
return defer(() => of(question));
};
filterIfRunnable = (question) => {
if (question.askAnswered !== true &&
_.get(this.answers, question.name) !== undefined) {
return EMPTY;
}
const { when } = question;
if (when === false) {
return EMPTY;
}
if (typeof when !== 'function') {
return of(question);
}
return defer(() => from(runAsync(when)(this.answers).then((shouldRun) => {
if (shouldRun) {
return question;
}
return;
})).pipe(filter((val) => val != null)));
};
}