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