// @ts-check const fs = require('fs'); const path = require('path'); const glob = require('glob'); const kleur = require('kleur'); const npx = require('libnpx'); const { get, forEach, partition } = require('lodash'); const inquirer = require('inquirer'); const which = require('which'); const { MrmUnknownTask, MrmInvalidTask, MrmUnknownAlias, MrmUndefinedOption, } = require('./errors'); /* eslint-disable no-console */ /** * Return all task and alias names and descriptions from all search directories. * * @param {string[]} directories * @param {Object} options * @return {Object} */ function getAllTasks(directories, options) { const allTasks = getAllAliases(options); for (const dir of directories) { const tasks = glob.sync(`${dir}/*/index.js`); tasks.forEach(filename => { const taskName = path.basename(path.dirname(filename)); if (!allTasks[taskName]) { const module = require(filename); allTasks[taskName] = module.description || ''; } }); } return allTasks; } /** * * @param {Object} options * @return {Object} */ function getAllAliases(options) { return get(options, 'aliases', {}); } /** * Runs an array of promises in series * * @method promiseSeries * * @param {Array} items * @param {Function} iterator * @return {Promise} */ function promiseSeries(items, iterator) { return items.reduce((iterable, name) => { return iterable.then(() => iterator(name)); }, Promise.resolve()); } /** * * @param {string|string[]} name * @param {string[]} directories * @param {Object} options * @param {Object} argv * @returns {Promise} */ function run(name, directories, options, argv) { if (Array.isArray(name)) { return new Promise((resolve, reject) => { promiseSeries(name, n => { return run(n, directories, options, argv); }) .then(() => resolve()) .catch(reject); }); } if (getAllAliases(options)[name]) { return runAlias(name, directories, options, argv); } return runTask(name, directories, options, argv); } /** * Run an alias. * * @param {string} aliasName * @param {string[]} directories * @param {Object} options * @param {Object} [argv] * @returns {Promise} */ function runAlias(aliasName, directories, options, argv) { return new Promise((resolve, reject) => { const tasks = getAllAliases(options)[aliasName]; if (!tasks) { reject(new MrmUnknownAlias(`Alias “${aliasName}” not found.`)); return; } console.log(kleur.yellow(`Running alias ${aliasName}...`)); promiseSeries(tasks, name => { const isAlias = getAllAliases(options)[name]; if (isAlias) { return runAlias(name, directories, options, argv); } else { return runTask(name, directories, options, argv); } }) .then(() => resolve()) .catch(reject); }); } /** * Returns the correct `mrm-` prefixed package name * * @param {"task" | "preset"} type * @param {string} packageName * @returns {string} */ function getPackageName(type, packageName) { const [scopeOrTask, scopedTaskName] = packageName.split('/'); return scopedTaskName ? `${scopeOrTask}/mrm-${type}-${scopedTaskName}` : `mrm-${type}-${scopeOrTask}`; } /** * Run a task. * * @param {string} taskName * @param {string[]} directories * @param {Object} options * @param {Object} [argv] * @returns {Promise} */ async function runTask(taskName, directories, options, argv) { const taskPackageName = getPackageName('task', taskName); let modulePath; try { modulePath = await promiseFirst([ () => tryFile(directories, `${taskName}/index.js`), () => require.resolve(taskPackageName), () => resolveUsingNpx(taskPackageName), () => require.resolve(taskName), () => resolveUsingNpx(taskName), ]); } catch { modulePath = null; } return new Promise((resolve, reject) => { if (!modulePath) { reject( new MrmUnknownTask(`Task “${taskName}” not found.`, { taskName, }) ); return; } const module = require(modulePath); if (typeof module !== 'function') { reject( new MrmInvalidTask(`Cannot call task “${taskName}”.`, { taskName }) ); return; } console.log(kleur.cyan(`Running ${taskName}...`)); Promise.resolve(getTaskOptions(module, argv.interactive, options)) .then(config => module(config, argv)) .then(resolve) .catch(reject); }); } /** * Get task specific options, either by running Inquirer.js in interactive mode, * or using defaults. * * @param {Function} task * @param {boolean} interactive? Whether or not interactive mode is enabled. * @param {Record} options? Default available options passed into the task. */ async function getTaskOptions(task, interactive = false, options = {}) { // If no parameters set, resolve to default options (from config file or command line). if (!task.parameters) { return options; } const parameters = Object.entries(task.parameters); const allOptions = await Promise.all( parameters.map(async ([name, param]) => ({ ...param, name, default: // Merge available default options with parameter initial values typeof options[name] !== 'undefined' ? options[name] : typeof param.default === 'function' ? await param.default(options) : param.default, })) ); // Split interactive and static options const [prompts, statics] = partition( allOptions, option => interactive && option.type !== 'config' ); // Validate static options const invalid = statics.filter(param => param.validate ? param.validate(param.default) !== true : false ); if (invalid.length > 0) { const names = invalid.map(({ name }) => name); throw new MrmUndefinedOption( `Missing required config options: ${names.join(', ')}.`, { unknown: names, } ); } // Run Inquirer.js with interactive options const answers = prompts.length > 0 ? await inquirer.prompt(prompts) : {}; // Merge answers with static defaults const values = { ...answers }; for (const param of statics) { values[param.name] = param.default; } return values; } /** * * @param {string[]} directories * @param {string} filename * @param {Object} argv * @return {Object} */ async function getConfig(directories, filename, argv) { const configFromFile = await getConfigFromFile(directories, filename); return { ...configFromFile, ...getConfigFromCommandLine(argv) }; } /** * Find and load config file. * * @param {string[]} directories * @param {string} filename * @return {Object} */ async function getConfigFromFile(directories, filename) { const filepath = await tryFile(directories, filename).catch(() => null); if (!filepath) { return {}; } return require(filepath); } /** * Get config options from command line, passed as --config:foo bar. * * @param {Object} argv * @return {Object} */ function getConfigFromCommandLine(argv) { const options = {}; forEach(argv, (value, key) => { if (key.startsWith('config:')) { options[key.replace(/^config:/, '')] = value; } }); return options; } /** * Try to load a file from a list of folders. * * @param {string[]} directories * @param {string} filename * @return {string|undefined} Absolute path or undefined */ function tryFile(directories, filename) { return promiseFirst( directories.map(dir => { const filepath = path.resolve(dir, filename); return () => fs.promises.access(filepath).then(() => filepath); }) ).catch(() => { throw new Error(`File “${filename}” not found.`); }); } /** * Resolve a module on-the-fly using npx under the hood * * @method resolveUsingNpx * * @param {String} packageName * @return {Promise} */ async function resolveUsingNpx(packageName) { const npm = which.sync('npm'); const { prefix } = await npx._ensurePackages(packageName, { npm, q: true }); return require.resolve(packageName, { paths: [ path.join(prefix, 'lib', 'node_modules'), path.join(prefix, 'lib64', 'node_modules'), ], }); } /** * Executes promise-returning thunks in series until one is resolved * * @method promiseFirst * * @param {Array} thunks * @return {Promise} */ async function promiseFirst(thunks, errors = []) { if (thunks.length === 0) { throw new Error(`None of the ${errors.length} thunks resolved. ${errors.join('\n')}`); } else { const [thunk, ...rest] = thunks; try { return await thunk(); } catch (error) { return promiseFirst(rest, [...errors, error]); } } } module.exports = { getAllAliases, getAllTasks, run, runTask, runAlias, getConfig, getConfigFromFile, getConfigFromCommandLine, getTaskOptions, tryFile, resolveUsingNpx, getPackageName, promiseFirst, };