Skip to content

Commit 320ac9a

Browse files
committed
Do not remove global bin/man links inappropriately
Prevent a global install from overwriting bins and manpages if they are not links/shims that npm controls, or if then are links/shims to packages other than the one being installed. Changes error message output on EEXIST errors to be more helpful. Related: - npm/bin-links#12 - npm/gentle-fs#7 Note: this does NOT prevent packages from overwriting one another's bins in non-global package installs, because doing so would introduce a [dependency hell](https://en.wikipedia.org/wiki/Dependency_hell) that npm 6 is not capable of avoiding without significant refactoring. The collision detection in npm v7's tree building will enable us to explore such an option, by never placing dependencies in the same place if they would write the same bin script. (It's fundamentally similar to peerDependency resolution, but much simpler.) Since users have not complained about this potential foot-gun in the last 5 years, its unlikely that it is a significant issue, and introducing additional dependency nesting (or worse, failing installs for unresolveable trees) is likely an even worse hazard. If we do prevent non-global-top installs from overwriting one another's bins, it ought to be done only as best-effort (ie, allow the collision if both deps need to be placed in the same node_modules folder) and perhaps opt-in with a config flag.
1 parent d06f5c0 commit 320ac9a

File tree

2 files changed

+110
-2
lines changed

2 files changed

+110
-2
lines changed

lib/utils/error-message.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,9 @@ function errorMessage (er) {
280280

281281
case 'EEXIST':
282282
short.push(['', er.message])
283-
short.push(['', 'File exists: ' + er.path])
284-
detail.push(['', 'Move it away, and try again.'])
283+
short.push(['', 'File exists: ' + (er.dest || er.path)])
284+
detail.push(['', 'Remove the existing file and try again, or run npm'])
285+
detail.push(['', 'with --force to overwrite files recklessly.'])
285286
break
286287

287288
case 'ENEEDAUTH':

test/tap/bin-overwriting.js

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const t = require('tap')
2+
const common = require('../common-tap.js')
3+
const pkg = common.pkg
4+
5+
const { writeFileSync, readFileSync, readlink } = require('fs')
6+
const readCmdShim = require('read-cmd-shim')
7+
const path = require('path')
8+
const readBinCb = process.platform === 'win32' ? readCmdShim : readlink
9+
const readBin = bin => new Promise((resolve, reject) => {
10+
readBinCb(bin, (er, target) => {
11+
if (er)
12+
reject(er)
13+
else
14+
resolve(path.resolve(pkg + '/global/bin', target))
15+
})
16+
})
17+
18+
// verify that we can't overwrite bins that we shouldn't be able to
19+
20+
const mkdirp = require('mkdirp').sync
21+
22+
process.env.npm_config_prefix = pkg + '/global'
23+
process.env.npm_config_global = true
24+
25+
const globalBin = process.platform === 'win32'
26+
? path.resolve(pkg, 'global')
27+
: path.resolve(pkg, 'global/bin')
28+
29+
const globalDir = process.platform === 'win32'
30+
? path.resolve(pkg, 'global/node_modules')
31+
: path.resolve(pkg, 'global/lib/node_modules')
32+
33+
const beep = path.resolve(globalBin, 'beep')
34+
const firstBin = path.resolve(globalDir, 'first/first.js')
35+
const secondBin = path.resolve(globalDir, 'second/second.js')
36+
37+
t.test('setup', { bail: true }, t => {
38+
// set up non-link bin in place
39+
mkdirp(globalBin)
40+
writeFileSync(beep, 'beep boop')
41+
42+
// create first package
43+
mkdirp(pkg + '/first')
44+
writeFileSync(pkg + '/first/package.json', JSON.stringify({
45+
name: 'first',
46+
version: '1.0.0',
47+
bin: { beep: 'first.js' }
48+
}))
49+
writeFileSync(pkg + '/first/first.js', `#!/usr/bin/env node
50+
console.log('first')`)
51+
52+
// create second package
53+
mkdirp(pkg + '/second')
54+
writeFileSync(pkg + '/second/package.json', JSON.stringify({
55+
name: 'second',
56+
version: '1.0.0',
57+
bin: { beep: 'second.js' }
58+
}))
59+
writeFileSync(pkg + '/second/second.js', `#!/usr/bin/env node
60+
console.log('second')`)
61+
62+
// pack both to install globally
63+
return common.npm(['pack'], { cwd: pkg + '/first' })
64+
.then(() => common.npm(['pack'], { cwd: pkg + '/second' }))
65+
})
66+
67+
t.test('installing first fails, because pre-existing bin in place', t => {
68+
return common.npm([
69+
'install',
70+
pkg + '/first/first-1.0.0.tgz'
71+
]).then(([code, stdout, stderr]) => {
72+
t.notEqual(code, 0)
73+
t.match(stderr, 'EEXIST')
74+
t.equal(readFileSync(beep, 'utf8'), 'beep boop')
75+
})
76+
})
77+
78+
t.test('installing first with --force succeeds', t => {
79+
return common.npm([
80+
'install',
81+
pkg + '/first/first-1.0.0.tgz',
82+
'--force'
83+
]).then(() => {
84+
return t.resolveMatch(readBin(beep), firstBin, 'bin written to first.js')
85+
})
86+
})
87+
88+
t.test('installing second fails, because bin links to other package', t => {
89+
return common.npm([
90+
'install',
91+
pkg + '/second/second-1.0.0.tgz'
92+
]).then(([code, stdout, stderr]) => {
93+
t.notEqual(code, 0)
94+
t.match(stderr, 'EEXIST')
95+
return t.resolveMatch(readBin(beep), firstBin, 'bin still linked to first')
96+
})
97+
})
98+
99+
t.test('installing second with --force succeeds', t => {
100+
return common.npm([
101+
'install',
102+
pkg + '/second/second-1.0.0.tgz',
103+
'--force'
104+
]).then(() => {
105+
return t.resolveMatch(readBin(beep), secondBin, 'bin written to second.js')
106+
})
107+
})

0 commit comments

Comments
 (0)