Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
rebuild.js407 linesDownload Raw Back to arborist
1// Arborist.rebuild({path = this.path}) will do all the binlinks and
2// bundle building needed.  Called by reify, and by `npm rebuild`.
3
4const PackageJson = require('@npmcli/package-json')
5const binLinks = require('bin-links')
6const localeCompare = require('@isaacs/string-locale-compare')('en')
7const promiseAllRejectLate = require('promise-all-reject-late')
8const runScript = require('@npmcli/run-script')
9const { callLimit: promiseCallLimit } = require('promise-call-limit')
10const { depth: dfwalk } = require('treeverse')
11const { isNodeGypPackage, defaultGypInstallScript } = require('@npmcli/node-gyp')
12const { promiseRetry } = require('@gar/promise-retry')
13const { log, time } = require('proc-log')
14const { resolve } = require('node:path')
15
16const boolEnv = b => b ? '1' : ''
17const sortNodes = (a, b) => (a.depth - b.depth) || localeCompare(a.path, b.path)
18
19const _checkBins = Symbol.for('checkBins')
20
21// defined by reify mixin
22const _handleOptionalFailure = Symbol.for('handleOptionalFailure')
23const _trashList = Symbol.for('trashList')
24
25module.exports = cls => class Builder extends cls {
26  #doHandleOptionalFailure
27  #oldMeta = null
28  #queues = {
29    preinstall: [],
30    install: [],
31    postinstall: [],
32    prepare: [],
33    bin: [],
34  }
35
36  async rebuild ({ nodes, handleOptionalFailure = false } = {}) {
37    // nothing to do if we're not building anything!
38    if (this.options.ignoreScripts && !this.options.binLinks) {
39      return
40    }
41
42    // when building for the first time, as part of reify, we ignore
43    // failures in optional nodes, and just delete them.  however, when
44    // running JUST a rebuild, we treat optional failures as real fails
45    this.#doHandleOptionalFailure = handleOptionalFailure
46
47    if (!nodes) {
48      nodes = await this.#loadDefaultNodes()
49    }
50
51    // separates links nodes so that it can run
52    // prepare scripts and link bins in the expected order
53    const timeEnd = time.start('build')
54
55    const {
56      depNodes,
57      linkNodes,
58    } = this.#retrieveNodesByType(nodes)
59
60    // build regular deps
61    await this.#build(depNodes, {})
62
63    // build link deps
64    if (linkNodes.size) {
65      this.#queues = {
66        preinstall: [],
67        install: [],
68        postinstall: [],
69        prepare: [],
70        bin: [],
71      }
72      await this.#build(linkNodes, { type: 'links' })
73    }
74
75    timeEnd()
76  }
77
78  // if we don't have a set of nodes, then just rebuild
79  // the actual tree on disk.
80  async #loadDefaultNodes () {
81    let nodes
82    const tree = await this.loadActual()
83    let filterSet
84    if (!this.options.workspacesEnabled) {
85      filterSet = this.excludeWorkspacesDependencySet(tree)
86      nodes = tree.inventory.filter(node =>
87        filterSet.has(node) || node.isProjectRoot
88      )
89    } else if (this.options.workspaces.length) {
90      filterSet = this.workspaceDependencySet(
91        tree,
92        this.options.workspaces,
93        this.options.includeWorkspaceRoot
94      )
95      nodes = tree.inventory.filter(node => filterSet.has(node))
96    } else {
97      nodes = tree.inventory.values()
98    }
99    return nodes
100  }
101
102  #retrieveNodesByType (nodes) {
103    const depNodes = new Set()
104    const linkNodes = new Set()
105    const storeNodes = new Set()
106
107    for (const node of nodes) {
108      if (node.isStoreLink) {
109        storeNodes.add(node)
110      } else if (node.isLink) {
111        linkNodes.add(node)
112      } else {
113        depNodes.add(node)
114      }
115    }
116    // Make sure that store linked nodes are processed last.
117    // We can't process store links separately or else lifecycle scripts on
118    // standard nodes might not have bin links yet.
119    for (const node of storeNodes) {
120      depNodes.add(node)
121    }
122
123    // deduplicates link nodes and their targets, avoids
124    // calling lifecycle scripts twice when running `npm rebuild`
125    // ref: https://github.com/npm/cli/issues/2905
126    //
127    // we avoid doing so if global=true since `bin-links` relies
128    // on having the target nodes available in global mode.
129    if (!this.options.global) {
130      for (const node of linkNodes) {
131        depNodes.delete(node.target)
132      }
133    }
134
135    return {
136      depNodes,
137      linkNodes,
138    }
139  }
140
141  async #build (nodes, { type = 'deps' }) {
142    const timeEnd = time.start(`build:${type}`)
143
144    await this.#buildQueues(nodes)
145
146    if (!this.options.ignoreScripts) {
147      await this.#runScripts('preinstall')
148    }
149
150    // links should run prepare scripts and only link bins after that
151    if (type === 'links') {
152      if (!this.options.ignoreScripts) {
153        await this.#runScripts('prepare')
154      }
155    }
156    if (this.options.binLinks) {
157      await this.#linkAllBins()
158    }
159
160    if (!this.options.ignoreScripts) {
161      await this.#runScripts('install')
162      await this.#runScripts('postinstall')
163    }
164
165    timeEnd()
166  }
167
168  async #buildQueues (nodes) {
169    const timeEnd = time.start('build:queue')
170    const set = new Set()
171
172    const promises = []
173    for (const node of nodes) {
174      promises.push(this.#addToBuildSet(node, set))
175
176      // if it has bundle deps, add those too, if rebuildBundle
177      if (this.options.rebuildBundle !== false) {
178        const bd = node.package.bundleDependencies
179        if (bd && bd.length) {
180          dfwalk({
181            tree: node,
182            leave: node => promises.push(this.#addToBuildSet(node, set)),
183            getChildren: node => [...node.children.values()],
184            filter: node => node.inBundle,
185          })
186        }
187      }
188    }
189    await promiseAllRejectLate(promises)
190
191    // now sort into the queues for the 4 things we have to do
192    // run in the same predictable order that buildIdealTree uses
193    // there's no particular reason for doing it in this order rather
194    // than another, but sorting *somehow* makes it consistent.
195    const queue = [...set].sort(sortNodes)
196
197    for (const node of queue) {
198      const { package: { bin, scripts = {} } } = node.target
199      const { preinstall, install, postinstall, prepare } = scripts
200      const tests = { bin, preinstall, install, postinstall, prepare }
201      for (const [key, has] of Object.entries(tests)) {
202        if (has) {
203          this.#queues[key].push(node)
204        }
205      }
206    }
207    timeEnd()
208  }
209
210  async [_checkBins] (node) {
211    // if the node is a global top, and we're not in force mode, then
212    // any existing bins need to either be missing, or a symlink into
213    // the node path.  Otherwise a package can have a preinstall script
214    // that unlinks something, to allow them to silently overwrite system
215    // binaries, which is unsafe and insecure.
216    if (!node.globalTop || this.options.force) {
217      return
218    }
219    const { path, package: pkg } = node
220    await binLinks.checkBins({ pkg, path, top: true, global: true })
221  }
222
223  async #addToBuildSet (node, set, refreshed = false) {
224    if (set.has(node)) {
225      return
226    }
227
228    if (this.#oldMeta === null) {
229      const { root: { meta } } = node
230      this.#oldMeta = meta && meta.loadedFromDisk &&
231        !(meta.originalLockfileVersion >= 2)
232    }
233
234    const { package: pkg, hasInstallScript } = node.target
235    const { gypfile, bin, scripts = {} } = pkg
236
237    const { preinstall, install, postinstall, prepare } = scripts
238    const anyScript = preinstall || install || postinstall || prepare
239    if (!refreshed && !anyScript && (hasInstallScript || this.#oldMeta)) {
240      // we either have an old metadata (and thus might have scripts)
241      // or we have an indication that there's install scripts (but
242      // don't yet know what they are) so we have to load the package.json
243      // from disk to see what the deal is.  Failure here just means
244      // no scripts to add, probably borked package.json.
245      // add to the set then remove while we're reading the pj, so we
246      // don't accidentally hit it multiple times.
247      set.add(node)
248      const { content: pkg } = await PackageJson.normalize(node.path).catch(() => {
249        return { content: {} }
250      })
251      set.delete(node)
252
253      const { scripts = {} } = pkg
254      node.package.scripts = scripts
255      return this.#addToBuildSet(node, set, true)
256    }
257
258    // Rebuild node-gyp dependencies lacking an install or preinstall script
259    // note that 'scripts' might be missing entirely, and the package may
260    // set gypfile:false to avoid this automatic detection.
261    const isGyp = gypfile !== false &&
262      !install &&
263      !preinstall &&
264      await isNodeGypPackage(node.path)
265
266    if (bin || preinstall || install || postinstall || prepare || isGyp) {
267      if (bin) {
268        await this[_checkBins](node)
269      }
270      if (isGyp) {
271        scripts.install = defaultGypInstallScript
272        node.package.scripts = scripts
273      }
274      set.add(node)
275    }
276  }
277
278  async #runScripts (event) {
279    const queue = this.#queues[event]
280
281    if (!queue.length) {
282      return
283    }
284
285    const timeEnd = time.start(`build:run:${event}`)
286    const stdio = this.options.foregroundScripts ? 'inherit' : 'pipe'
287    const limit = this.options.foregroundScripts ? 1 : undefined
288    await promiseCallLimit(queue.map(node => async () => {
289      const {
290        path,
291        integrity,
292        resolved,
293        optional,
294        peer,
295        dev,
296        devOptional,
297        package: pkg,
298        location,
299      } = node.target
300
301      // skip any that we know we'll be deleting
302      // or links to store entries (their scripts run on the store
303      // entry itself, not through the link)
304      if (this[_trashList].has(path) || (node.isLink && node.target?.isInStore)) {
305        return
306      }
307
308      const timeEndLocation = time.start(`build:run:${event}:${location}`)
309      log.info('run', pkg._id, event, location, pkg.scripts[event])
310      const env = {
311        npm_package_resolved: resolved,
312        npm_package_integrity: integrity,
313        npm_package_json: resolve(path, 'package.json'),
314        npm_package_optional: boolEnv(optional),
315        npm_package_dev: boolEnv(dev),
316        npm_package_peer: boolEnv(peer),
317        npm_package_dev_optional:
318          boolEnv(devOptional && !dev && !optional),
319      }
320      const runOpts = {
321        event,
322        path,
323        pkg,
324        stdio,
325        env,
326        scriptShell: this.options.scriptShell,
327      }
328      const p = runScript(runOpts).catch(er => {
329        const { code, signal } = er
330        log.info('run', pkg._id, event, { code, signal })
331        throw er
332      }).then(({ args, code, signal, stdout, stderr }) => {
333        this.scriptsRun.add({
334          pkg,
335          path,
336          event,
337          // I do not know why this needs to be on THIS line but refactoring
338          // this function would be quite a process
339          // eslint-disable-next-line promise/always-return
340          cmd: args && args[args.length - 1],
341          env,
342          code,
343          signal,
344          stdout,
345          stderr,
346        })
347        log.info('run', pkg._id, event, { code, signal })
348      })
349
350      await (this.#doHandleOptionalFailure
351        ? this[_handleOptionalFailure](node, p)
352        : p)
353
354      timeEndLocation()
355    }), { limit })
356    timeEnd()
357  }
358
359  async #linkAllBins () {
360    const queue = this.#queues.bin
361    if (!queue.length) {
362      return
363    }
364
365    const timeEnd = time.start('build:link')
366    const promises = []
367    // sort the queue by node path, so that the module-local collision
368    // detector in bin-links will always resolve the same way.
369    for (const node of queue.sort(sortNodes)) {
370      // TODO these run before they're awaited
371      promises.push(this.#createBinLinks(node))
372    }
373
374    await promiseAllRejectLate(promises)
375    timeEnd()
376  }
377
378  async #createBinLinks (node) {
379    if (this[_trashList].has(node.path)) {
380      return
381    }
382
383    const timeEnd = time.start(`build:link:${node.location}`)
384
385    // On Windows, antivirus/indexer can transiently lock files, causing EPERM/EACCES/EBUSY on the rename inside write-file-atomic (used by bin-links/fix-bin.js), so, retry with backoff.
386    const p = promiseRetry((retry) => binLinks({
387      pkg: node.package,
388      path: node.path,
389      top: !!(node.isTop || node.globalTop),
390      force: this.options.force,
391      global: !!node.globalTop,
392    }).catch(/* istanbul ignore next - Windows-only transient antivirus locks */ err => {
393      if (process.platform === 'win32' &&
394          (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) {
395        return retry(err)
396      }
397      throw err
398    }), { retries: 5, minTimeout: 500 })
399
400    await (this.#doHandleOptionalFailure
401      ? this[_handleOptionalFailure](node, p)
402      : p)
403
404    timeEnd()
405  }
406}
407 
codekingpro/portable-devtools · Team Ai