'use strict' const { mkdir } = require('fs/promises') const Arborist = require('@npmcli/arborist') const ciInfo = require('ci-info') const crypto = require('crypto') const { log, input } = require('proc-log') const npa = require('npm-package-arg') const pacote = require('pacote') const { read } = require('read') const semver = require('semver') const { fileExists, localFileExists } = require('./file-exists.js') const getBinFromManifest = require('./get-bin-from-manifest.js') const noTTY = require('./no-tty.js') const runScript = require('./run-script.js') const isWindows = require('./is-windows.js') const { dirname, resolve } = require('path') const binPaths = [] // when checking the local tree we look up manifests, cache those results by // spec.raw so we don't have to fetch again when we check npxCache const manifests = new Map() const getManifest = async (spec, flatOptions) => { if (!manifests.has(spec.raw)) { const manifest = await pacote.manifest(spec, { ...flatOptions, preferOnline: true }) manifests.set(spec.raw, manifest) } return manifests.get(spec.raw) } // Returns the required manifest if the spec is missing from the tree // Returns the found node if it is in the tree const missingFromTree = async ({ spec, tree, flatOptions, isNpxTree }) => { // If asking for a spec by name only (spec.raw === spec.name): // - In local or global mode go with anything in the tree that matches // - If looking in the npx cache check if a newer version is available const npxByNameOnly = isNpxTree && spec.name === spec.raw if (spec.registry && spec.type !== 'tag' && !npxByNameOnly) { // registry spec that is not a specific tag. const nodesBySpec = tree.inventory.query('packageName', spec.name) for (const node of nodesBySpec) { if (spec.rawSpec === '*') { return { node } } // package requested by specific version if (spec.type === 'version' && (node.pkgid === spec.raw)) { return { node } } // package requested by version range, only remaining registry type if (semver.satisfies(node.package.version, spec.rawSpec)) { return { node } } } const manifest = await getManifest(spec, flatOptions) return { manifest } } else { // non-registry spec, or a specific tag, or name only in npx tree. Look up // manifest and check resolved to see if it's in the tree. const manifest = await getManifest(spec, flatOptions) if (spec.type === 'directory') { return { manifest } } const nodesByManifest = tree.inventory.query('packageName', manifest.name) for (const node of nodesByManifest) { if (node.package.resolved === manifest._resolved) { // we have a package by the same name and the same resolved destination, nothing to add. return { node } } } return { manifest } } } const exec = async (opts) => { const { args = [], call = '', localBin = resolve('./node_modules/.bin'), locationMsg = undefined, globalBin = '', globalPath, // dereference values because we manipulate it later packages: [...packages] = [], path = '.', runPath = '.', scriptShell = isWindows ? process.env.ComSpec || 'cmd' : 'sh', ...flatOptions } = opts let yes = opts.yes const run = () => runScript({ args, call, flatOptions, locationMsg, path, binPaths, runPath, scriptShell, }) // interactive mode if (!call && !args.length && !packages.length) { return run() } let needPackageCommandSwap = (args.length > 0) && (packages.length === 0) // If they asked for a command w/o specifying a package, see if there is a // bin that directly matches that name: // - in the local package itself // - in the local tree // - globally if (needPackageCommandSwap) { let localManifest try { localManifest = await pacote.manifest(path, flatOptions) } catch { // no local package.json? no problem, move one. } if (localManifest?.bin?.[args[0]]) { // we have to install the local package into the npx cache so that its // bin links get set up flatOptions.installLinks = false // args[0] will exist when the package is installed packages.push(path) yes = true needPackageCommandSwap = false } else { const dir = dirname(dirname(localBin)) const localBinPath = await localFileExists(dir, args[0], '/') if (localBinPath) { binPaths.push(localBinPath) return await run() } else if (globalPath && await fileExists(`${globalBin}/${args[0]}`)) { binPaths.push(globalBin) return await run() } // We swap out args[0] with the bin from the manifest later packages.push(args[0]) } } // Resolve any directory specs so that the npx directory is unique to the // resolved directory, not the potentially relative one (i.e. "npx .") for (const i in packages) { const pkg = packages[i] const spec = npa(pkg) if (spec.type === 'directory') { packages[i] = spec.fetchSpec } } const localArb = new Arborist({ ...flatOptions, path }) const localTree = await localArb.loadActual() // Find anything that isn't installed locally const needInstall = [] let commandManifest await Promise.all(packages.map(async (pkg, i) => { const spec = npa(pkg, path) const { manifest, node } = await missingFromTree({ spec, tree: localTree, flatOptions }) if (manifest) { // Package does not exist in the local tree needInstall.push({ spec, manifest }) if (i === 0) { commandManifest = manifest } } else if (i === 0) { // The node.package has enough to look up the bin commandManifest = node.package } })) if (needPackageCommandSwap) { const spec = npa(args[0]) if (spec.type === 'directory') { yes = true } args[0] = getBinFromManifest(commandManifest) if (needInstall.length > 0 && globalPath) { // See if the package is installed globally, and run the translated bin const globalArb = new Arborist({ ...flatOptions, path: globalPath, global: true }) const globalTree = await globalArb.loadActual() const { manifest: globalManifest } = await missingFromTree({ spec, tree: globalTree, flatOptions }) if (!globalManifest && await fileExists(`${globalBin}/${args[0]}`)) { binPaths.push(globalBin) return await run() } } } const add = [] if (needInstall.length > 0) { // Install things to the npx cache, if needed const { npxCache } = flatOptions if (!npxCache) { throw new Error('Must provide a valid npxCache path') } const hash = crypto.createHash('sha512') .update(packages.map(p => { // Keeps the npx directory unique to the resolved directory, not the // potentially relative one (i.e. "npx .") const spec = npa(p) if (spec.type === 'directory') { return spec.fetchSpec } return p }).sort((a, b) => a.localeCompare(b, 'en')).join('\n')) .digest('hex') .slice(0, 16) const installDir = resolve(npxCache, hash) await mkdir(installDir, { recursive: true }) const npxArb = new Arborist({ ...flatOptions, path: installDir, }) const npxTree = await npxArb.loadActual() await Promise.all(needInstall.map(async ({ spec }) => { const { manifest } = await missingFromTree({ spec, tree: npxTree, flatOptions, isNpxTree: true, }) if (manifest) { // Manifest is not in npxCache, we need to install it there if (!spec.registry) { add.push(manifest._from) } else { add.push(manifest._id) } } })) if (add.length) { if (!yes) { const addList = add.map(a => `${a.replace(/@$/, '')}`) // set -n to always say no if (yes === false) { // Error message lists missing package(s) when process is canceled /* eslint-disable-next-line max-len */ throw new Error(`npx canceled due to missing packages and no YES option: ${JSON.stringify(addList)}`) } if (noTTY() || ciInfo.isCI) { /* eslint-disable-next-line max-len */ log.warn('exec', `The following package${add.length === 1 ? ' was' : 's were'} not found and will be installed: ${addList.join(', ')}`) } else { const confirm = await input.read(() => read({ /* eslint-disable-next-line max-len */ prompt: `Need to install the following packages:\n${addList.join('\n')}\nOk to proceed? `, default: 'y', })) if (confirm.trim().toLowerCase().charAt(0) !== 'y') { throw new Error('canceled') } } } await npxArb.reify({ ...flatOptions, add, }) } binPaths.push(resolve(installDir, 'node_modules/.bin')) } return await run() } module.exports = exec