Skip to content

Commit 5a50762

Browse files
authored
fix(arborist): link deps lifecycle scripts (#4875)
- Fixes running proper lifecycle scripts for linked deps and workspaces. - Added test to validate lifecycle scripts don't run twice for linked deps - Tweaked "reify workspaces bin files" test to also validate proper lifecycle scripts ran before check for linked bins. - Tweaked reify test running lifecycle scripts of unchanged link nodes to also validate that the install lifecycle scripts are also called. Fixes: #4277 Fixes: #4552 Fixes: npm/statusboard#439 Relates to: #2905
1 parent f985dbb commit 5a50762

File tree

10 files changed

+136
-46
lines changed

10 files changed

+136
-46
lines changed

workspaces/arborist/lib/arborist/rebuild.js

Lines changed: 75 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const sortNodes = (a, b) =>
2121

2222
const _workspaces = Symbol.for('workspaces')
2323
const _build = Symbol('build')
24+
const _loadDefaultNodes = Symbol('loadDefaultNodes')
25+
const _retrieveNodesByType = Symbol('retrieveNodesByType')
2426
const _resetQueues = Symbol('resetQueues')
2527
const _rebuildBundle = Symbol('rebuildBundle')
2628
const _ignoreScripts = Symbol('ignoreScripts')
@@ -39,6 +41,7 @@ const _includeWorkspaceRoot = Symbol.for('includeWorkspaceRoot')
3941
const _workspacesEnabled = Symbol.for('workspacesEnabled')
4042

4143
const _force = Symbol.for('force')
44+
const _global = Symbol.for('global')
4245

4346
// defined by reify mixin
4447
const _handleOptionalFailure = Symbol.for('handleOptionalFailure')
@@ -75,51 +78,83 @@ module.exports = cls => class Builder extends cls {
7578
// running JUST a rebuild, we treat optional failures as real fails
7679
this[_doHandleOptionalFailure] = handleOptionalFailure
7780

78-
// if we don't have a set of nodes, then just rebuild
79-
// the actual tree on disk.
8081
if (!nodes) {
81-
const tree = await this.loadActual()
82-
let filterSet
83-
if (!this[_workspacesEnabled]) {
84-
filterSet = this.excludeWorkspacesDependencySet(tree)
85-
nodes = tree.inventory.filter(node =>
86-
filterSet.has(node) || node.isProjectRoot
87-
)
88-
} else if (this[_workspaces] && this[_workspaces].length) {
89-
filterSet = this.workspaceDependencySet(
90-
tree,
91-
this[_workspaces],
92-
this[_includeWorkspaceRoot]
93-
)
94-
nodes = tree.inventory.filter(node => filterSet.has(node))
95-
} else {
96-
nodes = tree.inventory.values()
97-
}
82+
nodes = await this[_loadDefaultNodes]()
9883
}
9984

10085
// separates links nodes so that it can run
10186
// prepare scripts and link bins in the expected order
10287
process.emit('time', 'build')
88+
89+
const {
90+
depNodes,
91+
linkNodes,
92+
} = this[_retrieveNodesByType](nodes)
93+
94+
// build regular deps
95+
await this[_build](depNodes, {})
96+
97+
// build link deps
98+
if (linkNodes.size) {
99+
this[_resetQueues]()
100+
await this[_build](linkNodes, { type: 'links' })
101+
}
102+
103+
process.emit('timeEnd', 'build')
104+
}
105+
106+
// if we don't have a set of nodes, then just rebuild
107+
// the actual tree on disk.
108+
async [_loadDefaultNodes] () {
109+
let nodes
110+
const tree = await this.loadActual()
111+
let filterSet
112+
if (!this[_workspacesEnabled]) {
113+
filterSet = this.excludeWorkspacesDependencySet(tree)
114+
nodes = tree.inventory.filter(node =>
115+
filterSet.has(node) || node.isProjectRoot
116+
)
117+
} else if (this[_workspaces] && this[_workspaces].length) {
118+
filterSet = this.workspaceDependencySet(
119+
tree,
120+
this[_workspaces],
121+
this[_includeWorkspaceRoot]
122+
)
123+
nodes = tree.inventory.filter(node => filterSet.has(node))
124+
} else {
125+
nodes = tree.inventory.values()
126+
}
127+
return nodes
128+
}
129+
130+
[_retrieveNodesByType] (nodes) {
103131
const depNodes = new Set()
104132
const linkNodes = new Set()
133+
105134
for (const node of nodes) {
106-
// we skip the target nodes to that workspace in order to make sure
107-
// we only run lifecycle scripts / place bin links once per workspace
108135
if (node.isLink) {
109136
linkNodes.add(node)
110137
} else {
111138
depNodes.add(node)
112139
}
113140
}
114141

115-
await this[_build](depNodes, {})
116-
117-
if (linkNodes.size) {
118-
this[_resetQueues]()
119-
await this[_build](linkNodes, { type: 'links' })
142+
// deduplicates link nodes and their targets, avoids
143+
// calling lifecycle scripts twice when running `npm rebuild`
144+
// ref: https://github.com/npm/cli/issues/2905
145+
//
146+
// we avoid doing so if global=true since `bin-links` relies
147+
// on having the target nodes available in global mode.
148+
if (!this[_global]) {
149+
for (const node of linkNodes) {
150+
depNodes.delete(node.target)
151+
}
120152
}
121153

122-
process.emit('timeEnd', 'build')
154+
return {
155+
depNodes,
156+
linkNodes,
157+
}
123158
}
124159

125160
[_resetQueues] () {
@@ -136,24 +171,22 @@ module.exports = cls => class Builder extends cls {
136171
process.emit('time', `build:${type}`)
137172

138173
await this[_buildQueues](nodes)
174+
175+
if (!this[_ignoreScripts]) {
176+
await this[_runScripts]('preinstall')
177+
}
178+
139179
// links should run prepare scripts and only link bins after that
140-
if (type !== 'links') {
141-
if (!this[_ignoreScripts]) {
142-
await this[_runScripts]('preinstall')
143-
}
144-
if (this[_binLinks]) {
145-
await this[_linkAllBins]()
146-
}
147-
if (!this[_ignoreScripts]) {
148-
await this[_runScripts]('install')
149-
await this[_runScripts]('postinstall')
150-
}
151-
} else {
180+
if (type === 'links') {
152181
await this[_runScripts]('prepare')
182+
}
183+
if (this[_binLinks]) {
184+
await this[_linkAllBins]()
185+
}
153186

154-
if (this[_binLinks]) {
155-
await this[_linkAllBins]()
156-
}
187+
if (!this[_ignoreScripts]) {
188+
await this[_runScripts]('install')
189+
await this[_runScripts]('postinstall')
157190
}
158191

159192
process.emit('timeEnd', `build:${type}`)

workspaces/arborist/lib/arborist/reify.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1105,7 +1105,8 @@ module.exports = cls => class Reifier extends cls {
11051105
// skip links that only live within node_modules as they are most
11061106
// likely managed by packages we installed, we only want to rebuild
11071107
// unchanged links we directly manage
1108-
if (node.isLink && node.target.fsTop === tree) {
1108+
const linkedFromRoot = node.parent === tree || node.target.fsTop === tree
1109+
if (node.isLink && linkedFromRoot) {
11091110
nodes.push(node)
11101111
}
11111112
}

workspaces/arborist/test/arborist/rebuild.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,51 @@ t.test('do not rebuild node-gyp dependencies with gypfile:false', async t => {
459459
await arb.rebuild()
460460
})
461461

462+
// ref: https://github.com/npm/cli/issues/2905
463+
t.test('do not run lifecycle scripts of linked deps twice', async t => {
464+
const testdir = t.testdir({
465+
project: {
466+
'package.json': JSON.stringify({
467+
name: 'my-project',
468+
version: '1.0.0',
469+
dependencies: {
470+
foo: 'file:../foo',
471+
},
472+
}),
473+
node_modules: {
474+
foo: t.fixture('symlink', '../../foo'),
475+
},
476+
},
477+
foo: {
478+
'package.json': JSON.stringify({
479+
name: 'foo',
480+
version: '1.0.0',
481+
scripts: {
482+
postinstall: 'echo "ok"',
483+
},
484+
}),
485+
},
486+
})
487+
488+
const path = resolve(testdir, 'project')
489+
const RUNS = []
490+
const Arborist = t.mock('../../lib/arborist/index.js', {
491+
'@npmcli/run-script': opts => {
492+
RUNS.push(opts)
493+
return require('@npmcli/run-script')(opts)
494+
},
495+
})
496+
const arb = new Arborist({ path, registry })
497+
await arb.rebuild()
498+
t.equal(RUNS.length, 1, 'should run postinstall script only once')
499+
t.match(RUNS, [
500+
{
501+
event: 'postinstall',
502+
pkg: { name: 'foo' },
503+
},
504+
])
505+
})
506+
462507
t.test('workspaces', async t => {
463508
const path = t.testdir({
464509
'package.json': JSON.stringify({

workspaces/arborist/test/arborist/reify.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,6 +1770,8 @@ t.test('running lifecycle scripts of unchanged link nodes on reify', async t =>
17701770

17711771
t.ok(fs.lstatSync(resolve(path, 'a/a-prepare')).isFile(),
17721772
'should run prepare lifecycle scripts for links directly linked to the tree')
1773+
t.ok(fs.lstatSync(resolve(path, 'a/a-post-install')).isFile(),
1774+
'should run postinstall lifecycle scripts for links directly linked to the tree')
17731775
})
17741776

17751777
t.test('save-prod, with optional', async t => {

workspaces/arborist/test/fixtures/link-dep-lifecycle-scripts/a/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "a",
33
"version": "1.0.0",
44
"scripts": {
5-
"prepare": "node -e \"require('fs').writeFileSync(require('path').resolve('a-prepare'), '')\""
5+
"prepare": "node -e \"require('fs').writeFileSync('a-prepare', '')\"",
6+
"postinstall": "node -e \"require('fs').writeFileSync('a-post-install', '')\""
67
}
78
}

workspaces/arborist/test/fixtures/reify-cases/link-dep-lifecycle-scripts.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ module.exports = t => {
66
"name": "a",
77
"version": "1.0.0",
88
"scripts": {
9-
"prepare": "node -e \"require('fs').writeFileSync(require('path').resolve('a-prepare'), '')\""
9+
"prepare": "node -e \"require('fs').writeFileSync('a-prepare', '')\"",
10+
"postinstall": "node -e \"require('fs').writeFileSync('a-post-install', '')\""
1011
}
1112
})
1213
},

workspaces/arborist/test/fixtures/reify-cases/workspaces-link-bin.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,16 @@ module.exports = t => {
2525
})
2626
},
2727
"b": {
28-
"file.js": "",
28+
"create-file.js": "require('fs').writeFileSync('file.js', '')\n",
2929
"package.json": JSON.stringify({
3030
"name": "b",
3131
"version": "1.0.0",
3232
"files": [
3333
"file.js"
3434
],
35+
"scripts": {
36+
"preinstall": "node create-file.js"
37+
},
3538
"bin": "file.js"
3639
})
3740
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require('fs').writeFileSync('file.js', '')

workspaces/arborist/test/fixtures/workspaces-link-bin/packages/b/file.js

Whitespace-only changes.

workspaces/arborist/test/fixtures/workspaces-link-bin/packages/b/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,8 @@
22
"name": "b",
33
"version": "1.0.0",
44
"files": ["file.js"],
5+
"scripts": {
6+
"preinstall": "node create-file.js"
7+
},
58
"bin": "file.js"
69
}

0 commit comments

Comments
 (0)