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