@npmcli/arborist 2.8.1 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,9 @@ const { resolve, dirname } = require('path')
9
9
  const { promisify } = require('util')
10
10
  const treeCheck = require('../tree-check.js')
11
11
  const readdir = promisify(require('readdir-scoped-modules'))
12
+ const fs = require('fs')
13
+ const lstat = promisify(fs.lstat)
14
+ const readlink = promisify(fs.readlink)
12
15
  const { depth } = require('treeverse')
13
16
 
14
17
  const {
@@ -407,7 +410,14 @@ module.exports = cls => class IdealTreeBuilder extends cls {
407
410
  if (this[_updateAll] || updateName) {
408
411
  if (updateName)
409
412
  globalExplicitUpdateNames.push(name)
410
- tree.package.dependencies[name] = '*'
413
+ const dir = resolve(nm, name)
414
+ const st = await lstat(dir).catch(/* istanbul ignore next */ er => null)
415
+ if (st && st.isSymbolicLink()) {
416
+ const target = await readlink(dir)
417
+ const real = resolve(dirname(dir), target)
418
+ tree.package.dependencies[name] = `file:${real}`
419
+ } else
420
+ tree.package.dependencies[name] = '*'
411
421
  }
412
422
  }
413
423
  }
@@ -1015,6 +1025,15 @@ This is a one-time fix-up, please be patient...
1015
1025
  // keep track of the thing that caused this node to be included.
1016
1026
  const src = parent.sourceReference
1017
1027
  this[_peerSetSource].set(node, src)
1028
+
1029
+ // do not load the peers along with the set if this is a global top pkg
1030
+ // otherwise we'll be tempted to put peers as other top-level installed
1031
+ // things, potentially clobbering what's there already, which is not
1032
+ // what we want. the missing edges will be picked up on the next pass.
1033
+ if (this[_global] && edge.from.isProjectRoot)
1034
+ return node
1035
+
1036
+ // otherwise, we have to make sure that our peers can go along with us.
1018
1037
  return this[_loadPeerSet](node, required)
1019
1038
  }
1020
1039
 
@@ -188,8 +188,10 @@ module.exports = cls => class ActualLoader extends cls {
188
188
  const tree = this[_actualTree]
189
189
  const actualRoot = tree.isLink ? tree.target : tree
190
190
  const { dependencies = {} } = actualRoot.package
191
- for (const name of actualRoot.children.keys())
192
- dependencies[name] = dependencies[name] || '*'
191
+ for (const [name, kid] of actualRoot.children.entries()) {
192
+ const def = kid.isLink ? `file:${kid.realpath}` : '*'
193
+ dependencies[name] = dependencies[name] || def
194
+ }
193
195
  actualRoot.package = { ...actualRoot.package, dependencies }
194
196
  }
195
197
  return this[_actualTree]
@@ -6,11 +6,13 @@ const AuditReport = require('../audit-report.js')
6
6
  const {subset, intersects} = require('semver')
7
7
  const npa = require('npm-package-arg')
8
8
  const debug = require('../debug.js')
9
+ const walkUp = require('walk-up-path')
9
10
 
10
11
  const {dirname, resolve, relative} = require('path')
11
12
  const {depth: dfwalk} = require('treeverse')
12
13
  const fs = require('fs')
13
14
  const {promisify} = require('util')
15
+ const lstat = promisify(fs.lstat)
14
16
  const symlink = promisify(fs.symlink)
15
17
  const mkdirp = require('mkdirp-infer-owner')
16
18
  const justMkdirp = require('mkdirp')
@@ -19,6 +21,7 @@ const rimraf = promisify(require('rimraf'))
19
21
  const PackageJson = require('@npmcli/package-json')
20
22
  const packageContents = require('@npmcli/installed-package-contents')
21
23
  const { checkEngine, checkPlatform } = require('npm-install-checks')
24
+ const _force = Symbol.for('force')
22
25
 
23
26
  const treeCheck = require('../tree-check.js')
24
27
  const relpath = require('../relpath.js')
@@ -76,8 +79,10 @@ const _copyIdealToActual = Symbol('copyIdealToActual')
76
79
  const _addOmitsToTrashList = Symbol('addOmitsToTrashList')
77
80
  const _packageLockOnly = Symbol('packageLockOnly')
78
81
  const _dryRun = Symbol('dryRun')
82
+ const _validateNodeModules = Symbol('validateNodeModules')
83
+ const _nmValidated = Symbol('nmValidated')
79
84
  const _validatePath = Symbol('validatePath')
80
- const _reifyPackages = Symbol('reifyPackages')
85
+ const _reifyPackages = Symbol.for('reifyPackages')
81
86
 
82
87
  const _omitDev = Symbol('omitDev')
83
88
  const _omitOptional = Symbol('omitOptional')
@@ -119,6 +124,7 @@ module.exports = cls => class Reifier extends cls {
119
124
  this[_bundleUnpacked] = new Set()
120
125
  // child nodes we'd EXPECT to be included in a bundle, but aren't
121
126
  this[_bundleMissing] = new Set()
127
+ this[_nmValidated] = new Set()
122
128
  }
123
129
 
124
130
  // public method
@@ -159,6 +165,9 @@ module.exports = cls => class Reifier extends cls {
159
165
  // recursively, because it can have other side effects to do that
160
166
  // in a project directory. We just want to make it if it's missing.
161
167
  await justMkdirp(resolve(this.path))
168
+
169
+ // do not allow the top-level node_modules to be a symlink
170
+ await this[_validateNodeModules](resolve(this.path, 'node_modules'))
162
171
  }
163
172
 
164
173
  async [_reifyPackages] () {
@@ -328,12 +337,13 @@ module.exports = cls => class Reifier extends cls {
328
337
  ideal: this.idealTree,
329
338
  })
330
339
 
331
- for (const node of this.diff.removed) {
332
- // a node in a dep bundle will only be removed if its bundling dep
333
- // is removed as well. in which case, we don't have to delete it!
334
- if (!node.inDepBundle)
335
- this[_addNodeToTrashList](node)
336
- }
340
+ // we don't have to add 'removed' folders to the trashlist, because
341
+ // they'll be moved aside to a retirement folder, and then the retired
342
+ // folder will be deleted at the end. This is important when we have
343
+ // a folder like FOO being "removed" in favor of a folder like "foo",
344
+ // because if we remove node_modules/FOO on case-insensitive systems,
345
+ // it will remove the dep that we *want* at node_modules/foo.
346
+
337
347
  process.emit('timeEnd', 'reify:diffTrees')
338
348
  }
339
349
 
@@ -429,19 +439,40 @@ module.exports = cls => class Reifier extends cls {
429
439
  process.emit('time', 'reify:createSparse')
430
440
  // if we call this fn again, we look for the previous list
431
441
  // so that we can avoid making the same directory multiple times
432
- const dirs = this.diff.leaves
442
+ const leaves = this.diff.leaves
433
443
  .filter(diff => {
434
444
  return (diff.action === 'ADD' || diff.action === 'CHANGE') &&
435
445
  !this[_sparseTreeDirs].has(diff.ideal.path) &&
436
446
  !diff.ideal.isLink
437
447
  })
438
- .map(diff => diff.ideal.path)
439
-
440
- return promiseAllRejectLate(dirs.map(d => mkdirp(d)))
441
- .then(made => {
442
- made.forEach(made => this[_sparseTreeRoots].add(made))
443
- dirs.forEach(dir => this[_sparseTreeDirs].add(dir))
444
- })
448
+ .map(diff => diff.ideal)
449
+
450
+ // we check this in parallel, so guard against multiple attempts to
451
+ // retire the same path at the same time.
452
+ const dirsChecked = new Set()
453
+ return promiseAllRejectLate(leaves.map(async node => {
454
+ for (const d of walkUp(node.path)) {
455
+ if (d === node.top.path)
456
+ break
457
+ if (dirsChecked.has(d))
458
+ continue
459
+ dirsChecked.add(d)
460
+ const st = await lstat(d).catch(er => null)
461
+ // this can happen if we have a link to a package with a name
462
+ // that the filesystem treats as if it is the same thing.
463
+ // would be nice to have conditional istanbul ignores here...
464
+ /* istanbul ignore next - defense in depth */
465
+ if (st && !st.isDirectory()) {
466
+ const retired = retirePath(d)
467
+ this[_retiredPaths][d] = retired
468
+ this[_trashList].add(retired)
469
+ await this[_renamePath](d, retired)
470
+ }
471
+ }
472
+ const made = await mkdirp(node.path)
473
+ this[_sparseTreeDirs].add(node.path)
474
+ this[_sparseTreeRoots].add(made)
475
+ }))
445
476
  .then(() => process.emit('timeEnd', 'reify:createSparse'))
446
477
  }
447
478
 
@@ -536,7 +567,20 @@ module.exports = cls => class Reifier extends cls {
536
567
  })
537
568
  }
538
569
 
539
- [_extractOrLink] (node) {
570
+ // do not allow node_modules to be a symlink
571
+ async [_validateNodeModules] (nm) {
572
+ if (this[_force] || this[_nmValidated].has(nm))
573
+ return
574
+ const st = await lstat(nm).catch(() => null)
575
+ if (!st || st.isDirectory()) {
576
+ this[_nmValidated].add(nm)
577
+ return
578
+ }
579
+ this.log.warn('reify', 'Removing non-directory', nm)
580
+ await rimraf(nm)
581
+ }
582
+
583
+ async [_extractOrLink] (node) {
540
584
  // in normal cases, node.resolved should *always* be set by now.
541
585
  // however, it is possible when a lockfile is damaged, or very old,
542
586
  // or in some other race condition bugs in npm v6, that a previously
@@ -563,13 +607,29 @@ module.exports = cls => class Reifier extends cls {
563
607
  return
564
608
  }
565
609
 
566
- return node.isLink
567
- ? rimraf(node.path).then(() => this[_symlink](node))
568
- : pacote.extract(res, node.path, {
610
+ const nm = resolve(node.parent.path, 'node_modules')
611
+ await this[_validateNodeModules](nm)
612
+
613
+ if (node.isLink) {
614
+ await rimraf(node.path)
615
+ await this[_symlink](node)
616
+ } else {
617
+ await debug(async () => {
618
+ const st = await lstat(node.path).catch(e => null)
619
+ if (st && !st.isDirectory()) {
620
+ debug.log('unpacking into a non-directory', node)
621
+ throw Object.assign(new Error('ENOTDIR: not a directory'), {
622
+ code: 'ENOTDIR',
623
+ path: node.path,
624
+ })
625
+ }
626
+ })
627
+ await pacote.extract(res, node.path, {
569
628
  ...this.options,
570
629
  resolved: node.resolved,
571
630
  integrity: node.integrity,
572
631
  })
632
+ }
573
633
  }
574
634
 
575
635
  async [_symlink] (node) {
@@ -0,0 +1,48 @@
1
+ // package children are represented with a Map object, but many file systems
2
+ // are case-insensitive and unicode-normalizing, so we need to treat
3
+ // node.children.get('FOO') and node.children.get('foo') as the same thing.
4
+
5
+ const _keys = Symbol('keys')
6
+ const _normKey = Symbol('normKey')
7
+ const normalize = s => s.normalize('NFKD').toLowerCase()
8
+ const OGMap = Map
9
+ module.exports = class Map extends OGMap {
10
+ constructor (items = []) {
11
+ super()
12
+ this[_keys] = new OGMap()
13
+ for (const [key, val] of items)
14
+ this.set(key, val)
15
+ }
16
+
17
+ [_normKey] (key) {
18
+ return typeof key === 'string' ? normalize(key) : key
19
+ }
20
+
21
+ get (key) {
22
+ const normKey = this[_normKey](key)
23
+ return this[_keys].has(normKey) ? super.get(this[_keys].get(normKey))
24
+ : undefined
25
+ }
26
+
27
+ set (key, val) {
28
+ const normKey = this[_normKey](key)
29
+ if (this[_keys].has(normKey))
30
+ super.delete(this[_keys].get(normKey))
31
+ this[_keys].set(normKey, key)
32
+ return super.set(key, val)
33
+ }
34
+
35
+ delete (key) {
36
+ const normKey = this[_normKey](key)
37
+ if (this[_keys].has(normKey)) {
38
+ const prevKey = this[_keys].get(normKey)
39
+ this[_keys].delete(normKey)
40
+ return super.delete(prevKey)
41
+ }
42
+ }
43
+
44
+ has (key) {
45
+ const normKey = this[_normKey](key)
46
+ return this[_keys].has(normKey) && super.has(this[_keys].get(normKey))
47
+ }
48
+ }
@@ -5,7 +5,7 @@
5
5
  const deepestNestingTarget = (start, name) => {
6
6
  for (const target of start.ancestry()) {
7
7
  // note: this will skip past the first target if edge is peer
8
- if (target.isProjectRoot || !target.resolveParent)
8
+ if (target.isProjectRoot || !target.resolveParent || target.globalTop)
9
9
  return target
10
10
  const targetEdge = target.edgesOut.get(name)
11
11
  if (!targetEdge || !targetEdge.peer)
package/lib/edge.js CHANGED
@@ -77,7 +77,7 @@ class Edge {
77
77
  }
78
78
 
79
79
  satisfiedBy (node) {
80
- return depValid(node, this.spec, this.accept, this.from)
80
+ return node.name === this.name && depValid(node, this.spec, this.accept, this.from)
81
81
  }
82
82
 
83
83
  explain (seen = []) {
@@ -167,7 +167,7 @@ class Edge {
167
167
  [_loadError] () {
168
168
  return !this[_to] ? (this.optional ? null : 'MISSING')
169
169
  : this.peer && this.from === this.to.parent && !this.from.isTop ? 'PEER LOCAL'
170
- : !depValid(this.to, this.spec, this.accept, this.from) ? 'INVALID'
170
+ : !this.satisfiedBy(this.to) ? 'INVALID'
171
171
  : 'OK'
172
172
  }
173
173
 
package/lib/node.js CHANGED
@@ -66,6 +66,7 @@ const relpath = require('./relpath.js')
66
66
  const consistentResolve = require('./consistent-resolve.js')
67
67
 
68
68
  const printableTree = require('./printable.js')
69
+ const CaseInsensitiveMap = require('./case-insensitive-map.js')
69
70
 
70
71
  class Node {
71
72
  constructor (options) {
@@ -148,7 +149,7 @@ class Node {
148
149
  this.hasShrinkwrap = hasShrinkwrap || pkg._hasShrinkwrap || false
149
150
  this.legacyPeerDeps = legacyPeerDeps
150
151
 
151
- this.children = new Map()
152
+ this.children = new CaseInsensitiveMap()
152
153
  this.fsChildren = new Set()
153
154
  this.inventory = new Inventory({})
154
155
  this.tops = new Set()
@@ -181,7 +182,7 @@ class Node {
181
182
  }
182
183
 
183
184
  this.edgesIn = new Set()
184
- this.edgesOut = new Map()
185
+ this.edgesOut = new CaseInsensitiveMap()
185
186
 
186
187
  // have to set the internal package ref before assigning the parent,
187
188
  // because this.package is read when adding to inventory
@@ -248,7 +249,7 @@ class Node {
248
249
 
249
250
  // true for packages installed directly in the global node_modules folder
250
251
  get globalTop () {
251
- return this.global && this.parent.isProjectRoot
252
+ return this.global && this.parent && this.parent.isProjectRoot
252
253
  }
253
254
 
254
255
  get workspaces () {
@@ -478,6 +479,9 @@ class Node {
478
479
  }
479
480
 
480
481
  get isProjectRoot () {
482
+ // only treat as project root if it's the actual link that is the root,
483
+ // or the target of the root link, but NOT if it's another link to the
484
+ // same root that happens to be somewhere else.
481
485
  return this === this.root || this === this.root.target
482
486
  }
483
487
 
@@ -772,9 +776,15 @@ class Node {
772
776
  this[_loadDepType](this.package.dependencies, 'prod')
773
777
  this[_loadDepType](this.package.optionalDependencies, 'optional')
774
778
 
775
- const { isTop, path, sourceReference } = this
776
- const { isTop: srcTop, path: srcPath } = sourceReference || {}
777
- if (isTop && path && (!sourceReference || srcTop && srcPath))
779
+ const { globalTop, isTop, path, sourceReference } = this
780
+ const {
781
+ globalTop: srcGlobalTop,
782
+ isTop: srcTop,
783
+ path: srcPath,
784
+ } = sourceReference || {}
785
+ const thisDev = isTop && !globalTop && path
786
+ const srcDev = !sourceReference || srcTop && !srcGlobalTop && srcPath
787
+ if (thisDev && srcDev)
778
788
  this[_loadDepType](this.package.devDependencies, 'dev')
779
789
  }
780
790
 
@@ -1014,8 +1024,20 @@ class Node {
1014
1024
 
1015
1025
  replace (node) {
1016
1026
  this[_delistFromMeta]()
1017
- this.path = node.path
1018
- this.name = node.name
1027
+
1028
+ // if the name matches, but is not identical, we are intending to clobber
1029
+ // something case-insensitively, so merely setting name and path won't
1030
+ // have the desired effect. just set the path so it'll collide in the
1031
+ // parent's children map, and leave it at that.
1032
+ const nameMatch = node.parent &&
1033
+ node.parent.children.get(this.name) === node
1034
+ if (nameMatch)
1035
+ this.path = resolve(node.parent.path, 'node_modules', this.name)
1036
+ else {
1037
+ this.path = node.path
1038
+ this.name = node.name
1039
+ }
1040
+
1019
1041
  if (!this.isLink)
1020
1042
  this.realpath = this.path
1021
1043
  this[_refreshLocation]()
@@ -1210,7 +1232,7 @@ class Node {
1210
1232
  }
1211
1233
 
1212
1234
  get isTop () {
1213
- return !this.parent
1235
+ return !this.parent || this.globalTop
1214
1236
  }
1215
1237
 
1216
1238
  get top () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@npmcli/arborist",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
4
4
  "description": "Manage node_modules trees",
5
5
  "dependencies": {
6
6
  "@npmcli/installed-package-contents": "^1.0.7",
@@ -32,7 +32,6 @@
32
32
  "rimraf": "^3.0.2",
33
33
  "semver": "^7.3.5",
34
34
  "ssri": "^8.0.1",
35
- "tar": "^6.1.0",
36
35
  "treeverse": "^1.0.4",
37
36
  "walk-up-path": "^1.0.0"
38
37
  },