Skip to content

Commit a1272ee

Browse files
committed
v2 implementation
1 parent d35c9b2 commit a1272ee

File tree

8 files changed

+283
-201
lines changed

8 files changed

+283
-201
lines changed

index.js

+40-201
Original file line numberDiff line numberDiff line change
@@ -1,206 +1,45 @@
1-
'use strict'
2-
3-
const path = require('path')
4-
const fs = require('graceful-fs')
5-
const { promisify } = require('util')
6-
const gentleFs = require('gentle-fs')
7-
const linkIfExists = promisify(gentleFs.linkIfExists)
8-
const gentleFsBinLink = promisify(gentleFs.binLink)
9-
const open = promisify(fs.open)
10-
const close = promisify(fs.close)
11-
const read = promisify(fs.read)
12-
const chmod = promisify(fs.chmod)
13-
const readFile = promisify(fs.readFile)
14-
const writeFileAtomic = promisify(require('write-file-atomic'))
15-
const normalize = require('npm-normalize-package-bin')
16-
17-
module.exports = binLinks
18-
19-
// don't blow up trying to log stuff if we weren't given a logger
20-
const log = {
21-
clearProgress () {},
22-
info () {},
23-
showProgress () {},
24-
silly () {},
25-
verbose () {}
26-
}
27-
28-
function binLinks (pkg, folder, global, opts) {
29-
pkg = normalize(pkg)
30-
folder = path.resolve(folder)
31-
32-
// if it's global, and folder is in {prefix}/node_modules,
33-
// then bins are in {prefix}/bin
34-
// otherwise, then bins are in folder/../.bin
35-
var parent = pkg.name && pkg.name[0] === '@' ? path.dirname(path.dirname(folder)) : path.dirname(folder)
36-
var gnm = global && opts.globalDir
37-
var gtop = parent === gnm
38-
39-
// use the no-op logger if one is not provided
40-
opts = {
41-
log,
42-
...opts
43-
}
44-
45-
opts.log.info('linkStuff', opts.pkgId)
46-
opts.log.silly('linkStuff', opts.pkgId, 'has', parent, 'as its parent node_modules')
47-
if (global) opts.log.silly('linkStuff', opts.pkgId, 'is part of a global install')
48-
if (gnm) opts.log.silly('linkStuff', opts.pkgId, 'is installed into a global node_modules')
49-
if (gtop) opts.log.silly('linkStuff', opts.pkgId, 'is installed into the top-level global node_modules')
1+
const {basename, dirname } = require('path')
2+
const isWindows = require('./lib/is-windows.js')
3+
4+
const linkBins = require('./lib/link-bins.js')
5+
const linkMans = require('./lib/link-mans.js')
6+
7+
const binLinks = opts => {
8+
const { path, pkg, force, global, top } = opts
9+
// global top pkgs on windows get bins installed in {prefix}, and no mans
10+
//
11+
// unix global top pkgs get their bins installed in {prefix}/bin,
12+
// and mans in {prefix}/share/man
13+
//
14+
// non-top pkgs get their bins installed in {prefix}/node_modules/.bin,
15+
// and do not install mans
16+
//
17+
// non-global top pkgs don't have any bins or mans linked.
18+
if (top && !global)
19+
return Promise.resolve()
20+
21+
// now we know it's global and/or not top, so the path has to be
22+
// {prefix}/node_modules/{name}. Can't rely on pkg.name, because
23+
// it might be installed as an alias.
24+
const scopeOrNm = dirname(path)
25+
const nm = basename(scopeOrNm) === 'node_modules' ? scopeOrNm
26+
: dirname(scopeOrNm)
27+
const prefix = dirname(nm)
28+
29+
const binTarget = !top ? nm + '/.bin'
30+
: isWindows ? prefix
31+
: dirname(prefix) + '/bin'
32+
33+
const manTarget = !top || isWindows ? null
34+
: dirname(prefix) + '/share/man'
5035

5136
return Promise.all([
52-
linkBins(pkg, folder, parent, gtop, opts),
53-
linkMans(pkg, folder, parent, gtop, opts)
37+
// allow clobbering within the local node_modules/.bin folder.
38+
// only global bins are protected in this way, or else it is
39+
// yet another vector for excessive dependency conflicts.
40+
linkBins({path, binTarget, pkg, force: force || !top}),
41+
linkMans({path, manTarget, pkg, force}),
5442
])
5543
}
5644

57-
function isHashbangFile (file) {
58-
/* istanbul ignore next */
59-
const FALSE = () => false
60-
return open(file, 'r').then(fileHandle => {
61-
const buf = Buffer.alloc(2)
62-
return read(fileHandle, buf, 0, 2, 0).then((...args) => {
63-
if (!hasHashbang(buf)) {
64-
return []
65-
}
66-
const line = Buffer.alloc(2048)
67-
return read(fileHandle, line, 0, 2048, 0)
68-
.then((bytes) => close(fileHandle).then(() => bytes && hasCR(line)))
69-
})
70-
// don't leak a fd if the read fails
71-
.catch(/* istanbul ignore next */ () => close(fileHandle).then(FALSE, FALSE))
72-
}).catch(FALSE)
73-
}
74-
75-
function hasHashbang (buf) {
76-
const str = buf.toString()
77-
return str.slice(0, 2) === '#!'
78-
}
79-
80-
function hasCR (buf) {
81-
return /^#![^\n]+\r\n/.test(buf)
82-
}
83-
84-
function dos2Unix (file) {
85-
return readFile(file, 'utf8').then(content => {
86-
return writeFileAtomic(file, content.replace(/^(#![^\n]+)\r\n/, '$1\n'))
87-
})
88-
}
89-
90-
function getLinkOpts (opts, gently) {
91-
return Object.assign({}, opts, { gently: gently })
92-
}
93-
94-
function linkBins (pkg, folder, parent, gtop, opts) {
95-
if (!pkg.bin || (!gtop && path.basename(parent) !== 'node_modules')) {
96-
return
97-
}
98-
var linkOpts = getLinkOpts(opts, gtop && folder)
99-
var execMode = parseInt('0777', 8) & (~opts.umask)
100-
var binRoot = gtop ? opts.globalBin
101-
: path.resolve(parent, '.bin')
102-
opts.log.verbose('linkBins', [pkg.bin, binRoot, gtop])
103-
104-
return Promise.all(Object.keys(pkg.bin).map(bin => {
105-
var dest = path.resolve(binRoot, bin)
106-
var src = path.resolve(folder, pkg.bin[bin])
107-
108-
/* istanbul ignore if - that unpossible */
109-
if (src.indexOf(folder) !== 0) {
110-
return Promise.reject(new Error('invalid bin entry for package ' +
111-
pkg._id + '. key=' + bin + ', value=' + pkg.bin[bin]))
112-
}
113-
114-
return linkBin(src, dest, linkOpts).then(() => {
115-
// bins should always be executable.
116-
// XXX skip chmod on windows?
117-
return chmod(src, execMode)
118-
}).then(() => {
119-
return isHashbangFile(src)
120-
}).then(isHashbang => {
121-
if (!isHashbang) return
122-
opts.log.silly('linkBins', 'Converting line endings of hashbang file:', src)
123-
return dos2Unix(src)
124-
}).then(() => {
125-
if (!gtop) return
126-
var dest = path.resolve(binRoot, bin)
127-
var out = opts.parseable
128-
? dest + '::' + src + ':BINFILE'
129-
: dest + ' -> ' + src
130-
131-
if (!opts.json && !opts.parseable) {
132-
opts.log.clearProgress()
133-
console.log(out)
134-
opts.log.showProgress()
135-
}
136-
}).catch(err => {
137-
/* istanbul ignore next */
138-
if (err.code === 'ENOENT' && opts.ignoreScripts) return
139-
throw err
140-
})
141-
}))
142-
}
143-
144-
function linkBin (from, to, opts) {
145-
// do not clobber global bins
146-
if (opts.globalBin && to.indexOf(opts.globalBin) === 0) {
147-
opts.clobberLinkGently = true
148-
}
149-
return gentleFsBinLink(from, to, opts)
150-
}
151-
152-
function linkMans (pkg, folder, parent, gtop, opts) {
153-
if (!pkg.man || !gtop || process.platform === 'win32') return
154-
155-
var manRoot = path.resolve(opts.prefix, 'share', 'man')
156-
opts.log.verbose('linkMans', 'man files are', pkg.man, 'in', manRoot)
157-
158-
// make sure that the mans are unique.
159-
// otherwise, if there are dupes, it'll fail with EEXIST
160-
var set = pkg.man.reduce(function (acc, man) {
161-
if (typeof man !== 'string') {
162-
return acc
163-
}
164-
const cleanMan = path.join('/', man).replace(/\\|:/g, '/').substr(1)
165-
acc[path.basename(man)] = cleanMan
166-
return acc
167-
}, {})
168-
var manpages = pkg.man.filter(function (man) {
169-
if (typeof man !== 'string') {
170-
return false
171-
}
172-
const cleanMan = path.join('/', man).replace(/\\|:/g, '/').substr(1)
173-
return set[path.basename(man)] === cleanMan
174-
})
175-
176-
return Promise.all(manpages.map(man => {
177-
opts.log.silly('linkMans', 'preparing to link', man)
178-
var parseMan = man.match(/(.*\.([0-9]+)(\.gz)?)$/)
179-
if (!parseMan) {
180-
return Promise.reject(new Error(
181-
man + ' is not a valid name for a man file. ' +
182-
'Man files must end with a number, ' +
183-
'and optionally a .gz suffix if they are compressed.'
184-
))
185-
}
186-
187-
var stem = parseMan[1]
188-
var sxn = parseMan[2]
189-
var bn = path.basename(stem)
190-
var manSrc = path.resolve(folder, man)
191-
/* istanbul ignore if - that unpossible */
192-
if (manSrc.indexOf(folder) !== 0) {
193-
return Promise.reject(new Error('invalid man entry for package ' +
194-
pkg._id + '. man=' + manSrc))
195-
}
196-
197-
var manDest = path.join(manRoot, 'man' + sxn, bn)
198-
199-
// man pages should always be clobbering gently, because they are
200-
// only installed for top-level global packages, so never destroy
201-
// a link if it doesn't point into the folder we're linking
202-
opts.clobberLinkGently = true
203-
204-
return linkIfExists(manSrc, manDest, getLinkOpts(opts, gtop && folder))
205-
}))
206-
}
45+
module.exports = binLinks

lib/fix-bin.js

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// make sure that bins are executable, and that they don't have
2+
// windows line-endings on the hashbang line.
3+
const fs = require('fs')
4+
const { promisify } = require('util')
5+
6+
const execMode = 0o777 & (~process.umask())
7+
8+
const writeFileAtomic = promisify(require('write-file-atomic'))
9+
const open = promisify(fs.open)
10+
const close = promisify(fs.close)
11+
const read = promisify(fs.read)
12+
const chmod = promisify(fs.chmod)
13+
const readFile = promisify(fs.readFile)
14+
15+
const isWindowsHashBang = buf =>
16+
buf[0] === '#'.charCodeAt(0) &&
17+
buf[1] === '!'.charCodeAt(0) &&
18+
/^#![^\n]+\r\n/.test(buf.toString())
19+
20+
const isWindowsHashbangFile = file => {
21+
const FALSE = () => false
22+
return open(file, 'r').then(fd => {
23+
const buf = Buffer.alloc(2048)
24+
return read(fd, buf, 0, 2048, 0)
25+
.then(
26+
() => {
27+
const isWHB = isWindowsHashBang(buf)
28+
return close(fd).then(() => isWHB, () => isWHB)
29+
},
30+
// don't leak FD if read() fails
31+
() => close(fd).then(FALSE, FALSE)
32+
)
33+
}, FALSE)
34+
}
35+
36+
const dos2Unix = file =>
37+
readFile(file, 'utf8').then(content =>
38+
writeFileAtomic(file, content.replace(/^(#![^\n]+)\r\n/, '$1\n')))
39+
40+
const fixBin = file => chmod(file, execMode)
41+
.then(() => isWindowsHashbangFile(file))
42+
.then(isWHB => isWHB ? dos2Unix(file) : null)
43+
44+
module.exports = fixBin

lib/is-windows.js

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
const platform = process.env.__TESTING_BIN_LINKS_PLATFORM__ || process.platform
2+
module.exports = platform === 'win32'

lib/link-bin.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const linkGently = require('./link-gently.js')
2+
const fixBin = require('./fix-bin.js')
3+
4+
// linking bins is simple. just symlink, and if we linked it, fix the bin up
5+
const linkBin = ({path, to, from, absFrom, force}) =>
6+
linkGently({path, to, from, absFrom, force})
7+
.then(linked => linked && fixBin(absFrom))
8+
9+
module.exports = linkBin

lib/link-bins.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const isWindows = require('./is-windows.js')
2+
const { dirname, resolve, relative } = require('path')
3+
const linkBin = isWindows ? require('./shim-bin.js') : require('./link-bin.js')
4+
const normalize = require('npm-normalize-package-bin')
5+
6+
const linkBins = ({path, binTarget, pkg, force}) => {
7+
pkg = normalize(pkg)
8+
if (!pkg.bin)
9+
return Promise.resolve([])
10+
const promises = []
11+
for (const [key, val] of Object.entries(pkg.bin)) {
12+
const to = resolve(binTarget, key)
13+
const absFrom = resolve(path, val)
14+
const from = relative(dirname(to), absFrom)
15+
promises.push(linkBin({path, from, to, absFrom, force}))
16+
}
17+
return Promise.all(promises)
18+
}
19+
20+
module.exports = linkBins

lib/link-gently.js

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// if the thing isn't there, skip it
2+
// if there's a non-symlink there already, eexist
3+
// if there's a symlink already, pointing somewhere else, eexist
4+
// if there's a symlink already, pointing into our pkg, remove it first
5+
// then create the symlink
6+
7+
const { promisify } = require('util')
8+
const { resolve, dirname } = require('path')
9+
const mkdirp = require('mkdirp-infer-owner')
10+
const fs = require('fs')
11+
const symlink = promisify(fs.symlink)
12+
const readlink = promisify(fs.readlink)
13+
const lstat = promisify(fs.lstat)
14+
const throwNonEnoent = er => { if (er.code !== 'ENOENT') throw er }
15+
16+
// disable glob in our rimraf calls
17+
const rimraf = promisify(require('rimraf'))
18+
const rm = path => rimraf(path, { glob: false })
19+
20+
const SKIP = Symbol('skip - missing or already installed')
21+
22+
const linkGently = ({path, to, from, absFrom, force}) => {
23+
// if the script or manpage isn't there, just ignore it.
24+
// this arguably *should* be an install error of some sort,
25+
// or at least a warning, but npm has always behaved this
26+
// way in the past, so it'd be a breaking change
27+
return Promise.all([
28+
lstat(absFrom).catch(throwNonEnoent),
29+
lstat(to).catch(throwNonEnoent),
30+
]).then(([stFrom, stTo]) => {
31+
// not present in package, skip it
32+
if (!stFrom)
33+
return SKIP
34+
35+
// exists! maybe clobber if we can
36+
if (stTo) {
37+
if (!stTo.isSymbolicLink())
38+
return force ? rm(to) : Promise.resolve()
39+
40+
return readlink(to).then(target => {
41+
if (target === from)
42+
return SKIP // skip it, already set up like we want it.
43+
44+
target = resolve(dirname(to), target)
45+
if (target.indexOf(path) === 0 || force)
46+
return rm(to)
47+
})
48+
} else {
49+
// doesn't exist, dir might not either
50+
return mkdirp(dirname(to))
51+
}
52+
})
53+
// this will fail if we didn't remove it
54+
.then(skip => skip !== SKIP && symlink(from, to, 'file').then(() => true))
55+
}
56+
57+
module.exports = linkGently

0 commit comments

Comments
 (0)