UNPKG

cli-kit

Version:

Everything you need to create awesome command line interfaces

357 lines (315 loc) 11.5 kB
import Command from './command.js'; import debug from '../lib/debug.js'; import E from '../lib/errors.js'; import helpCommand from '../commands/help.js'; import _path from 'path'; import { declareCLIKitClass, filename, findPackage, isExecutable, nodePath } from '../lib/util.js'; import { spawn } from 'child_process'; const { log, warn } = debug('cli-kit:extension'); const { highlight } = debug.styles; const nameRegExp = /^(?:(@\w+)\/)?(.*)$/; /** * Defines a namespace that wraps an external program or script. * * @extends {Command} */ export default class Extension { /** * Detects the extension defined in the specified path and initializes it. * * @param {String|Object} pathOrParams - The path to the extension or a params object. If the * path is a Node.js package with a `package.json` containing a `"cli-kit"` property, it will * merge the external cli-kit context tree into this namespace. * @param {Object} [params] - Various parameters when `extensionPath` is a `String`. * @param {Object} [params.exports] - A map of exported command names to descriptors containing * `aliases`, `desc`, `exe`, `main`, and `name` props. * @param {String} [params.name] - The extension name. If not set, it will load it from the * extension's `package.json` or the filename. * @param {String} [params.path] - The path to an executable, a JavaScript file, or Node.js * package. * @access public */ constructor(pathOrParams, params) { log({pathOrParams, params}); let path = pathOrParams; if (typeof path === 'string' && !params) { params = {}; } else if (pathOrParams && typeof pathOrParams === 'object') { ({ path } = params = pathOrParams); } if (!path || typeof path !== 'string') { throw E.INVALID_ARGUMENT('Expected an extension path or params object', { name: 'pathOrParams', scope: 'Extension.constructor', value: pathOrParams }); } if (typeof params !== 'object') { throw E.INVALID_ARGUMENT('Expected extension params to be an object or Context', { name: 'params', scope: 'Extension.constructor', value: params }); } this.exports = params.exports || {}; this.name = params.name; this.path = path; if (typeof this.exports !== 'object') { throw E.INVALID_ARGUMENT('Expected extension exports to be an object', { name: 'params.exports', scope: 'Extension.constructor', value: params.exports }); } // we need to determine if this extension is a binary or if it's a Node package try { const exe = isExecutable(path); if (!this.name) { this.name = filename(exe[0]); } this.registerExtension(this.name, { exe }, { async action({ __argv, cmd, terminal }) { if (!Array.isArray(exe)) { throw E.NO_EXECUTABLE(`Extension "${this.name}" has no executable!`); } const bin = exe[0]; const args = exe.slice(1); const p = __argv.findIndex(arg => arg && arg.type === 'extension' && arg.command === cmd); if (p !== -1) { for (let i = p + 1, len = __argv.length; i < len; i++) { args.push.apply(args, __argv[i].input); } } // spawn the process log(`Running: ${highlight(`${bin} ${args.join(' ')}`)}`); const child = spawn(bin, args, { windowsHide: true }); child.stdout.on('data', data => terminal.stdout.write(data.toString())); child.stderr.on('data', data => terminal.stderr.write(data.toString())); await new Promise(resolve => child.on('close', (code = 0) => resolve({ code }))); }, desc: params.desc }); } catch (e) { // maybe a Node package? try { let pkg; try { pkg = findPackage(path); if (!pkg.root) { throw new Error(); } } catch (e) { throw E.INVALID_EXTENSION(`Invalid extension: Unable to find executable, script, or package: ${typeof path === 'string' ? `"${path}"` : JSON.stringify(path)}`); } if (!this.name) { this.name = pkg.json.name; } if (!this.name) { this.name = filename(path); } const makeDefaultAction = main => { return async ({ __argv, cmd }) => { process.argv = [ nodePath(), main ]; const p = __argv.findIndex(arg => arg && arg.type === 'extension' && arg.command === cmd); if (p !== -1) { for (let i = p + 1, len = __argv.length; i < len; i++) { process.argv.push.apply(process.argv, __argv[i].input); } } log(`Importing ${highlight(main)}`); log(`Args: ${highlight(process.argv.join(' '))}`); await import(_path.isAbsolute(main) ? `file://${main}` : main); }; }; if (pkg.main && (!pkg.json.exports || !pkg.esm)) { log(`Found Node.js ${pkg.esm ? 'ESM' : 'CommonJS'} extension with main as export`); let { name } = this; const aliases = Array.isArray(pkg.json.aliases) ? pkg.json.aliases : []; // if the package name contains a scope, add the scoped package name as a hidden // alias and strip the scope from the name const m = name.match(nameRegExp); if (m) { aliases.push(`!${name}`); name = m[2]; } // if the name is different than the one in the package.json, add it to the aliases if (name && name !== pkg.json.name && !aliases.includes(name)) { aliases.push(name); } // if the package has a bin script that matches the package name, then add any other // bin name that aliases the package named bin if (pkg.json.bin && typeof pkg.json.bin === 'object') { const bins = Object.keys(pkg.json.bin); const primary = pkg.json.bin[pkg.json.name] || (bins && pkg.json.bin[bins[0]]); for (const [ name, bin ] of Object.entries(pkg.json.bin)) { if (bin !== primary && !aliases.includes(name)) { aliases.push(name); } } } this.registerExtension(name, { pkg }, { action: makeDefaultAction(pkg.main), aliases, desc: pkg.json.description }); } else if (typeof pkg.json.exports !== 'object') { throw E.INVALID_EXTENSION('Invalid extension: Expected exports to be an object', { name: 'pkg.json.exports', scope: 'Extension.constructor', value: pkg.json.exports }); } else { for (let [ name, params ] of Object.entries(pkg.json.exports)) { if (typeof params === 'string') { params = { main: params }; } if (params.main && !_path.isAbsolute(params.main)) { params.main = _path.resolve(pkg.root, params.main); } this.registerExtension(name, { pkg: { ...pkg, ...params } }, { action: makeDefaultAction(params.main), desc: pkg.json.description, ...params }); } } if (!Object.keys(this.exports).length) { throw E.INVALID_EXTENSION(`Invalid extension: Unable to find extension's main file: ${typeof path === 'string' ? `"${path}"` : JSON.stringify(path)}`); } } catch (e) { this.err = e; warn(e); warn('Found bad extension, creating error action'); this.registerExtension(this.name, {}, { action: ({ terminal }) => { const { stderr } = terminal; if (this.err) { let { stack } = e; const p = stack.indexOf('\n\n'); if (p !== -1) { stack = stack.substring(0, p).trim(); } for (const line of stack.split('\n')) { stderr.write(` ${line}\n`); } } else { stderr.write(`Invalid extension: ${this.name}\n`); } } }); } } declareCLIKitClass(this, 'Extension'); // mix in any other custom props for (const [ key, value ] of Object.entries(params)) { if (!Object.prototype.hasOwnProperty.call(this, key)) { this[key] = value; } } } /** * Initializes a command with the extension export info. * * @param {String} name - The command name. * @param {Object} meta - Metadata to mix into the command instance. * @param {Object} params - Command specific constructor parameters. * @access private */ registerExtension(name, meta, params) { log(`Registering extension command: ${highlight(`${this.name}:${name}`)}`); log(meta); log(params); const cmd = new Command(name, { parent: this, ...params }); this.exports[name] = Object.assign(cmd, meta); cmd.isExtension = true; cmd.isCLIKitExtension = !!meta?.pkg?.clikit; if (!cmd.isCLIKitExtension || !meta.pkg.json.dependencies?.['cli-kit']) { return; } // we only want to define `cmd.load()` if main exports a cli-kit object cmd.load = async function load() { log(`Importing cli-kit extension: ${highlight(this.name)} -> ${highlight(meta.pkg.main)}`); let ctx; try { ctx = await import(_path.isAbsolute(meta.pkg.main) ? `file://${meta.pkg.main}` : meta.pkg.main); if (!ctx || (typeof ctx !== 'object' && typeof ctx !== 'function')) { throw new Error('Extension must export an object or function'); } // if this is an ES6 module, grab the default export if (ctx.default) { ctx = ctx.default; } if (ctx.__esModule) { ctx = ctx.default; } // if the export was a function, call it now to get its CLI definition if (typeof ctx === 'function') { ctx = await ctx(this); } if (!ctx || typeof ctx !== 'object') { throw new Error('Extension does not resolve an object'); } } catch (err) { throw E.INVALID_EXTENSION(`Bad extension "${this.name}": ${err.message}`, { name: this.name, scope: 'Extension.load', value: err }); } this.aliases = ctx.aliases; this.camelCase = ctx.camelCase; this.defaultCommand = ctx.defaultCommand; this.help = ctx.help; this.remoteHelp = ctx.remoteHelp; this.treatUnknownOptionsAsArguments = ctx.treatUnknownOptionsAsArguments; this.version = ctx.version; this.init({ args: ctx.args, banner: ctx.banner, commands: ctx.commands, desc: ctx.desc || this.desc, extensions: ctx.extensions, name: this.name || ctx.name, options: ctx.options, parent: this.parent, title: ctx.title !== 'Global' && ctx.title || this.name }); // for each command, we need to load it and re-register it for (const cmd of this.commands.values()) { if (await cmd.load()) { this.register(cmd); } } const versionOption = this.version && this.lookup.long.version; if (versionOption && typeof versionOption.callback !== 'function') { versionOption.callback = async ({ exitCode, opts, next }) => { if (await next()) { let { version } = this; if (typeof version === 'function') { version = await version(opts); } (opts.terminal || this.get('terminal')).stdout.write(`${version}\n`); exitCode(0); return false; } }; } if (typeof ctx.action === 'function') { this.action = ctx.action; } else { this.action = async parser => { if (this.defaultCommand !== 'help' || !this.get('help')) { const defcmd = this.defaultCommand && this.commands[this.defaultCommand]; if (defcmd) { return await defcmd.action.call(defcmd, parser); } } return await helpCommand.action.call(helpCommand, parser); }; } }.bind(cmd); } /** * Returns the schema for this extension and all child contexts. * * @returns {Object} * @access public */ schema() { return { ...super.schema, path: this.path }; } }