UNPKG

npm

Version:

a package manager for JavaScript

368 lines (327 loc) 11.4 kB
const { promisify } = require('util') const read = promisify(require('read')) const chalk = require('chalk') const mkdirp = require('mkdirp-infer-owner') const readPackageJson = require('read-package-json-fast') const Arborist = require('@npmcli/arborist') const runScript = require('@npmcli/run-script') const { resolve, delimiter } = require('path') const ciDetect = require('@npmcli/ci-detect') const crypto = require('crypto') const pacote = require('pacote') const npa = require('npm-package-arg') const fileExists = require('./utils/file-exists.js') const PATH = require('./utils/path.js') const BaseCommand = require('./base-command.js') const getWorkspaces = require('./workspaces/get-workspaces.js') // it's like this: // // npm x pkg@version <-- runs the bin named "pkg" or the only bin if only 1 // // { name: 'pkg', bin: { pkg: 'pkg.js', foo: 'foo.js' }} <-- run pkg // { name: 'pkg', bin: { foo: 'foo.js' }} <-- run foo? // // npm x -p pkg@version -- foo // // npm x -p pkg@version -- foo --registry=/dev/null // // const pkg = npm.config.get('package') || getPackageFrom(args[0]) // const cmd = getCommand(pkg, args[0]) // --> npm x -c 'cmd ...args.slice(1)' // // we've resolved cmd and args, and escaped them properly, and installed the // relevant packages. // // Add the ${npx install prefix}/node_modules/.bin to PATH // // pkg = readPackageJson('./package.json') // pkg.scripts.___npx = ${the -c arg} // runScript({ pkg, event: 'npx', ... }) // process.env.npm_lifecycle_event = 'npx' const nocolor = { reset: s => s, bold: s => s, dim: s => s, green: s => s, } class Exec extends BaseCommand { /* istanbul ignore next - see test/lib/load-all-commands.js */ static get description () { return 'Run a command from a local or remote npm package' } /* istanbul ignore next - see test/lib/load-all-commands.js */ static get params () { return ['workspace', 'workspaces'] } /* istanbul ignore next - see test/lib/load-all-commands.js */ static get name () { return 'exec' } /* istanbul ignore next - see test/lib/load-all-commands.js */ static get usage () { return [ '-- <pkg>[@<version>] [args...]', '--package=<pkg>[@<version>] -- <cmd> [args...]', '-c \'<cmd> [args...]\'', '--package=foo -c \'<cmd> [args...]\'', ] } exec (args, cb) { const path = this.npm.localPrefix const runPath = process.cwd() this._exec(args, { path, runPath }).then(() => cb()).catch(cb) } execWorkspaces (args, filters, cb) { this._execWorkspaces(args, filters).then(() => cb()).catch(cb) } // When commands go async and we can dump the boilerplate exec methods this // can be named correctly async _exec (_args, { locationMsg, path, runPath }) { const call = this.npm.config.get('call') const shell = this.npm.config.get('shell') // dereferenced because we manipulate it later const packages = [...this.npm.config.get('package')] if (call && _args.length) throw this.usage const args = [..._args] const pathArr = [...PATH] // nothing to maybe install, skip the arborist dance if (!call && !args.length && !packages.length) { return await this.run({ args, call, locationMsg, shell, path, pathArr, runPath, }) } const needPackageCommandSwap = args.length && !packages.length // if there's an argument and no package has been explicitly asked for // check the local and global bin paths for a binary named the same as // the argument and run it if it exists, otherwise fall through to // the behavior of treating the single argument as a package name if (needPackageCommandSwap) { let binExists = false if (await fileExists(`${this.npm.localBin}/${args[0]}`)) { pathArr.unshift(this.npm.localBin) binExists = true } else if (await fileExists(`${this.npm.globalBin}/${args[0]}`)) { pathArr.unshift(this.npm.globalBin) binExists = true } if (binExists) { return await this.run({ args, call, locationMsg, path, pathArr, runPath, shell, }) } packages.push(args[0]) } // If we do `npm exec foo`, and have a `foo` locally, then we'll // always use that, so we don't really need to fetch the manifest. // So: run npa on each packages entry, and if it is a name with a // rawSpec==='', then try to readPackageJson at // node_modules/${name}/package.json, and only pacote fetch if // that fails. const manis = await Promise.all(packages.map(async p => { const spec = npa(p, path) if (spec.type === 'tag' && spec.rawSpec === '') { // fall through to the pacote.manifest() approach try { const pj = resolve(path, 'node_modules', spec.name) return await readPackageJson(pj) } catch (er) {} } // Force preferOnline to true so we are making sure to pull in the latest // This is especially useful if the user didn't give us a version, and // they expect to be running @latest return await pacote.manifest(p, { ...this.npm.flatOptions, preferOnline: true, }) })) if (needPackageCommandSwap) args[0] = this.getBinFromManifest(manis[0]) // figure out whether we need to install stuff, or if local is fine const localArb = new Arborist({ ...this.npm.flatOptions, path, }) const tree = await localArb.loadActual() // do we have all the packages in manifest list? const needInstall = manis.some(mani => this.manifestMissing(tree, mani)) if (needInstall) { const installDir = this.cacheInstallDir(packages) await mkdirp(installDir) const arb = new Arborist({ ...this.npm.flatOptions, log: this.npm.log, path: installDir, }) const tree = await arb.loadActual() // at this point, we have to ensure that we get the exact same // version, because it's something that has only ever been installed // by npm exec in the cache install directory const add = manis.filter(mani => this.manifestMissing(tree, { ...mani, _from: `${mani.name}@${mani.version}`, })) .map(mani => mani._from) .sort((a, b) => a.localeCompare(b)) // no need to install if already present if (add.length) { if (!this.npm.config.get('yes')) { // set -n to always say no if (this.npm.config.get('yes') === false) throw new Error('canceled') if (!process.stdin.isTTY || ciDetect()) { this.npm.log.warn('exec', `The following package${ add.length === 1 ? ' was' : 's were' } not found and will be installed: ${ add.map((pkg) => pkg.replace(/@$/, '')).join(', ') }`) } else { const addList = add.map(a => ` ${a.replace(/@$/, '')}`) .join('\n') + '\n' const prompt = `Need to install the following packages:\n${ addList }Ok to proceed? ` const confirm = await read({ prompt, default: 'y' }) if (confirm.trim().toLowerCase().charAt(0) !== 'y') throw new Error('canceled') } } await arb.reify({ ...this.npm.flatOptions, log: this.npm.log, add, }) } pathArr.unshift(resolve(installDir, 'node_modules/.bin')) } return await this.run({ args, call, locationMsg, path, pathArr, runPath, shell, }) } async run ({ args, call, locationMsg, path, pathArr, runPath, shell }) { // turn list of args into command string const script = call || args.shift() || shell // do the fakey runScript dance // still should work if no package.json in cwd const realPkg = await readPackageJson(`${path}/package.json`) .catch(() => ({})) const pkg = { ...realPkg, scripts: { ...(realPkg.scripts || {}), npx: script, }, } this.npm.log.disableProgress() try { if (script === shell) { if (process.stdin.isTTY) { if (ciDetect()) return this.npm.log.warn('exec', 'Interactive mode disabled in CI environment') const color = this.npm.config.get('color') const colorize = color ? chalk : nocolor locationMsg = locationMsg || ` at location:\n${colorize.dim(runPath)}` this.npm.output(`${ colorize.reset('\nEntering npm script environment') }${ colorize.reset(locationMsg) }${ colorize.bold('\nType \'exit\' or ^D when finished\n') }`) } } return await runScript({ ...this.npm.flatOptions, pkg, banner: false, // we always run in cwd, not --prefix path: runPath, stdioString: true, event: 'npx', args, env: { PATH: pathArr.join(delimiter), }, stdio: 'inherit', }) } finally { this.npm.log.enableProgress() } } manifestMissing (tree, mani) { // if the tree doesn't have a child by that name/version, return true // true means we need to install it const child = tree.children.get(mani.name) // if no child, we have to load it if (!child) return true // if no version/tag specified, allow whatever's there if (mani._from === `${mani.name}@`) return false // otherwise the version has to match what we WOULD get return child.version !== mani.version } getBinFromManifest (mani) { // if we have a bin matching (unscoped portion of) packagename, use that // otherwise if there's 1 bin or all bin value is the same (alias), use // that, otherwise fail const bin = mani.bin || {} if (new Set(Object.values(bin)).size === 1) return Object.keys(bin)[0] // XXX probably a util to parse this better? const name = mani.name.replace(/^@[^/]+\//, '') if (bin[name]) return name // XXX need better error message throw Object.assign(new Error('could not determine executable to run'), { pkgid: mani._id, }) } cacheInstallDir (packages) { // only packages not found in ${prefix}/node_modules return resolve(this.npm.config.get('cache'), '_npx', this.getHash(packages)) } getHash (packages) { return crypto.createHash('sha512') .update(packages.sort((a, b) => a.localeCompare(b)).join('\n')) .digest('hex') .slice(0, 16) } async _execWorkspaces (args, filters) { const workspaces = await getWorkspaces(filters, { path: this.npm.localPrefix }) const getLocationMsg = async path => { const color = this.npm.config.get('color') const colorize = color ? chalk : nocolor const { _id } = await readPackageJson(`${path}/package.json`) return ` in workspace ${colorize.green(_id)} at location:\n${colorize.dim(path)}` } for (const workspacePath of workspaces.values()) { const locationMsg = await getLocationMsg(workspacePath) await this._exec(args, { locationMsg, path: workspacePath, runPath: workspacePath, }) } } } module.exports = Exec