MINI MINI MANI MO
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 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 workspaces (filters) {
return getWorkspaces(filters, { path: this.npm.localPrefix })
}
async _execWorkspaces (args, filters) {
const workspaces = await this.workspaces(filters)
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
OHA YOOOO