1'use strict' 2 3const { mkdir } = require('fs/promises') 4const Arborist = require('@npmcli/arborist') 5const ciInfo = require('ci-info') 6const crypto = require('crypto') 7const log = require('proc-log') 8const npa = require('npm-package-arg') 9const npmlog = require('npmlog') 10const pacote = require('pacote') 11const read = require('read') 12const semver = require('semver') 13 14const { fileExists, localFileExists } = require('./file-exists.js') 15const getBinFromManifest = require('./get-bin-from-manifest.js') 16const noTTY = require('./no-tty.js') 17const runScript = require('./run-script.js') 18const isWindows = require('./is-windows.js') 19 20const { dirname, resolve } = require('path') 21 22const binPaths = [] 23 24// when checking the local tree we look up manifests, cache those results by 25// spec.raw so we don't have to fetch again when we check npxCache 26const manifests = new Map() 27 28const getManifest = async (spec, flatOptions) => { 29 if (!manifests.has(spec.raw)) { 30 const manifest = await pacote.manifest(spec, { ...flatOptions, preferOnline: true }) 31 manifests.set(spec.raw, manifest) 32 } 33 return manifests.get(spec.raw) 34} 35 36// Returns the required manifest if the spec is missing from the tree 37// Returns the found node if it is in the tree 38const missingFromTree = async ({ spec, tree, flatOptions, isNpxTree }) => { 39 // If asking for a spec by name only (spec.raw === spec.name): 40 // - In local or global mode go with anything in the tree that matches 41 // - If looking in the npx cache check if a newer version is available 42 const npxByNameOnly = isNpxTree && spec.name === spec.raw 43 if (spec.registry && spec.type !== 'tag' && !npxByNameOnly) { 44 // registry spec that is not a specific tag. 45 const nodesBySpec = tree.inventory.query('packageName', spec.name) 46 for (const node of nodesBySpec) { 47 if (spec.rawSpec === '*') { 48 return { node } 49 } 50 // package requested by specific version 51 if (spec.type === 'version' && (node.pkgid === spec.raw)) { 52 return { node } 53 } 54 // package requested by version range, only remaining registry type 55 if (semver.satisfies(node.package.version, spec.rawSpec)) { 56 return { node } 57 } 58 } 59 const manifest = await getManifest(spec, flatOptions) 60 return { manifest } 61 } else { 62 // non-registry spec, or a specific tag, or name only in npx tree. Look up 63 // manifest and check resolved to see if it's in the tree. 64 const manifest = await getManifest(spec, flatOptions) 65 if (spec.type === 'directory') { 66 return { manifest } 67 } 68 const nodesByManifest = tree.inventory.query('packageName', manifest.name) 69 for (const node of nodesByManifest) { 70 if (node.package.resolved === manifest._resolved) { 71 // we have a package by the same name and the same resolved destination, nothing to add. 72 return { node } 73 } 74 } 75 return { manifest } 76 } 77} 78 79const exec = async (opts) => { 80 const { 81 args = [], 82 call = '', 83 localBin = resolve('./node_modules/.bin'), 84 locationMsg = undefined, 85 globalBin = '', 86 globalPath, 87 output, 88 // dereference values because we manipulate it later 89 packages: [...packages] = [], 90 path = '.', 91 runPath = '.', 92 scriptShell = isWindows ? process.env.ComSpec || 'cmd' : 'sh', 93 ...flatOptions 94 } = opts 95 96 let yes = opts.yes 97 const run = () => runScript({ 98 args, 99 call, 100 flatOptions, 101 locationMsg, 102 output, 103 path, 104 binPaths, 105 runPath, 106 scriptShell, 107 }) 108 109 // interactive mode 110 if (!call && !args.length && !packages.length) { 111 return run() 112 } 113 114 let needPackageCommandSwap = (args.length > 0) && (packages.length === 0) 115 // If they asked for a command w/o specifying a package, see if there is a 116 // bin that directly matches that name: 117 // - in the local package itself 118 // - in the local tree 119 // - globally 120 if (needPackageCommandSwap) { 121 let localManifest 122 try { 123 localManifest = await pacote.manifest(path, flatOptions) 124 } catch { 125 // no local package.json? no problem, move one. 126 } 127 if (localManifest?.bin?.[args[0]]) { 128 // we have to install the local package into the npx cache so that its 129 // bin links get set up 130 flatOptions.installLinks = false 131 // args[0] will exist when the package is installed 132 packages.push(path) 133 yes = true 134 needPackageCommandSwap = false 135 } else { 136 const dir = dirname(dirname(localBin)) 137 const localBinPath = await localFileExists(dir, args[0], '/') 138 if (localBinPath) { 139 binPaths.push(localBinPath) 140 return await run() 141 } else if (globalPath && await fileExists(`${globalBin}/${args[0]}`)) { 142 binPaths.push(globalBin) 143 return await run() 144 } 145 // We swap out args[0] with the bin from the manifest later 146 packages.push(args[0]) 147 } 148 } 149 150 // Resolve any directory specs so that the npx directory is unique to the 151 // resolved directory, not the potentially relative one (i.e. "npx .") 152 for (const i in packages) { 153 const pkg = packages[i] 154 const spec = npa(pkg) 155 if (spec.type === 'directory') { 156 packages[i] = spec.fetchSpec 157 } 158 } 159 160 const localArb = new Arborist({ ...flatOptions, path }) 161 const localTree = await localArb.loadActual() 162 163 // Find anything that isn't installed locally 164 const needInstall = [] 165 let commandManifest 166 await Promise.all(packages.map(async (pkg, i) => { 167 const spec = npa(pkg, path) 168 const { manifest, node } = await missingFromTree({ spec, tree: localTree, flatOptions }) 169 if (manifest) { 170 // Package does not exist in the local tree 171 needInstall.push({ spec, manifest }) 172 if (i === 0) { 173 commandManifest = manifest 174 } 175 } else if (i === 0) { 176 // The node.package has enough to look up the bin 177 commandManifest = node.package 178 } 179 })) 180 181 if (needPackageCommandSwap) { 182 const spec = npa(args[0]) 183 184 if (spec.type === 'directory') { 185 yes = true 186 } 187 188 args[0] = getBinFromManifest(commandManifest) 189 190 if (needInstall.length > 0 && globalPath) { 191 // See if the package is installed globally, and run the translated bin 192 const globalArb = new Arborist({ ...flatOptions, path: globalPath, global: true }) 193 const globalTree = await globalArb.loadActual() 194 const { manifest: globalManifest } = 195 await missingFromTree({ spec, tree: globalTree, flatOptions }) 196 if (!globalManifest && await fileExists(`${globalBin}/${args[0]}`)) { 197 binPaths.push(globalBin) 198 return await run() 199 } 200 } 201 } 202 203 const add = [] 204 if (needInstall.length > 0) { 205 // Install things to the npx cache, if needed 206 const { npxCache } = flatOptions 207 if (!npxCache) { 208 throw new Error('Must provide a valid npxCache path') 209 } 210 const hash = crypto.createHash('sha512') 211 .update(packages.map(p => { 212 // Keeps the npx directory unique to the resolved directory, not the 213 // potentially relative one (i.e. "npx .") 214 const spec = npa(p) 215 if (spec.type === 'directory') { 216 return spec.fetchSpec 217 } 218 return p 219 }).sort((a, b) => a.localeCompare(b, 'en')).join('\n')) 220 .digest('hex') 221 .slice(0, 16) 222 const installDir = resolve(npxCache, hash) 223 await mkdir(installDir, { recursive: true }) 224 const npxArb = new Arborist({ 225 ...flatOptions, 226 path: installDir, 227 }) 228 const npxTree = await npxArb.loadActual() 229 await Promise.all(needInstall.map(async ({ spec }) => { 230 const { manifest } = await missingFromTree({ 231 spec, 232 tree: npxTree, 233 flatOptions, 234 isNpxTree: true, 235 }) 236 if (manifest) { 237 // Manifest is not in npxCache, we need to install it there 238 if (!spec.registry) { 239 add.push(manifest._from) 240 } else { 241 add.push(manifest._id) 242 } 243 } 244 })) 245 246 if (add.length) { 247 if (!yes) { 248 const missingPackages = add.map(a => `${a.replace(/@$/, '')}`) 249 // set -n to always say no 250 if (yes === false) { 251 // Error message lists missing package(s) when process is canceled 252 /* eslint-disable-next-line max-len */ 253 throw new Error(`npx canceled due to missing packages and no YES option: ${JSON.stringify(missingPackages)}`) 254 } 255 256 if (noTTY() || ciInfo.isCI) { 257 log.warn('exec', `The following package${ 258 add.length === 1 ? ' was' : 's were' 259 } not found and will be installed: ${ 260 add.map((pkg) => pkg.replace(/@$/, '')).join(', ') 261 }`) 262 } else { 263 const addList = missingPackages.join('\n') + '\n' 264 const prompt = `Need to install the following packages:\n${ 265 addList 266 }Ok to proceed? ` 267 npmlog.clearProgress() 268 const confirm = await read({ prompt, default: 'y' }) 269 if (confirm.trim().toLowerCase().charAt(0) !== 'y') { 270 throw new Error('canceled') 271 } 272 } 273 } 274 await npxArb.reify({ 275 ...flatOptions, 276 add, 277 }) 278 } 279 binPaths.push(resolve(installDir, 'node_modules/.bin')) 280 } 281 282 return await run() 283} 284 285module.exports = exec 286