libnpmexec 10.1.6 → 10.1.8
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.
- package/README.md +1 -1
- package/lib/get-bin-from-manifest.js +1 -1
- package/lib/index.js +6 -4
- package/lib/run-script.js +5 -2
- package/lib/with-lock.js +175 -0
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ await libexec({
|
|
|
40
40
|
- `runPath`: Location to where to execute the script **String**, defaults to `.`
|
|
41
41
|
- `scriptShell`: Default shell to be used **String**, defaults to `sh` on POSIX systems, `process.env.ComSpec` OR `cmd` on Windows
|
|
42
42
|
- `yes`: Should skip download confirmation prompt when fetching missing packages from the registry? **Boolean**
|
|
43
|
-
- `registry`, `cache`, and more options that are forwarded to [@npmcli/arborist](https://github.com/npm/arborist/) and [pacote](https://github.com/npm/pacote/#options) **Object**
|
|
43
|
+
- `registry`, `cache`, and more options that are forwarded to [@npmcli/arborist](https://github.com/npm/cli/blob/latest/workspaces/arborist/README.md) and [pacote](https://github.com/npm/pacote/#options) **Object**
|
|
44
44
|
|
|
45
45
|
## LICENSE
|
|
46
46
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const getBinFromManifest = (mani) => {
|
|
2
2
|
// if we have a bin matching (unscoped portion of) packagename, use that
|
|
3
3
|
// otherwise if there's 1 bin or all bin value is the same (alias), use
|
|
4
|
-
// that
|
|
4
|
+
// that; otherwise, fail
|
|
5
5
|
const bin = mani.bin || {}
|
|
6
6
|
if (new Set(Object.values(bin)).size === 1) {
|
|
7
7
|
return Object.keys(bin)[0]
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { dirname, resolve } = require('node:path')
|
|
3
|
+
const { dirname, join, resolve } = require('node:path')
|
|
4
4
|
const crypto = require('node:crypto')
|
|
5
5
|
const { mkdir } = require('node:fs/promises')
|
|
6
6
|
const Arborist = require('@npmcli/arborist')
|
|
@@ -16,6 +16,7 @@ const getBinFromManifest = require('./get-bin-from-manifest.js')
|
|
|
16
16
|
const noTTY = require('./no-tty.js')
|
|
17
17
|
const runScript = require('./run-script.js')
|
|
18
18
|
const isWindows = require('./is-windows.js')
|
|
19
|
+
const withLock = require('./with-lock.js')
|
|
19
20
|
|
|
20
21
|
const binPaths = []
|
|
21
22
|
|
|
@@ -247,7 +248,8 @@ const exec = async (opts) => {
|
|
|
247
248
|
...flatOptions,
|
|
248
249
|
path: installDir,
|
|
249
250
|
})
|
|
250
|
-
const
|
|
251
|
+
const lockPath = join(installDir, 'concurrency.lock')
|
|
252
|
+
const npxTree = await withLock(lockPath, () => npxArb.loadActual())
|
|
251
253
|
await Promise.all(needInstall.map(async ({ spec }) => {
|
|
252
254
|
const { manifest } = await missingFromTree({
|
|
253
255
|
spec,
|
|
@@ -290,11 +292,11 @@ const exec = async (opts) => {
|
|
|
290
292
|
}
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
|
-
await npxArb.reify({
|
|
295
|
+
await withLock(lockPath, () => npxArb.reify({
|
|
294
296
|
...flatOptions,
|
|
295
297
|
save: true,
|
|
296
298
|
add,
|
|
297
|
-
})
|
|
299
|
+
}))
|
|
298
300
|
}
|
|
299
301
|
binPaths.push(resolve(installDir, 'node_modules/.bin'))
|
|
300
302
|
const pkgJson = await PackageJson.load(installDir)
|
package/lib/run-script.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const ciInfo = require('ci-info')
|
|
2
2
|
const runScript = require('@npmcli/run-script')
|
|
3
|
-
const
|
|
3
|
+
const pkgJson = require('@npmcli/package-json')
|
|
4
4
|
const { log, output } = require('proc-log')
|
|
5
5
|
const noTTY = require('./no-tty.js')
|
|
6
6
|
const isWindowsShell = require('./is-windows.js')
|
|
@@ -28,7 +28,10 @@ const run = async ({
|
|
|
28
28
|
|
|
29
29
|
// do the fakey runScript dance
|
|
30
30
|
// still should work if no package.json in cwd
|
|
31
|
-
const realPkg = await
|
|
31
|
+
const { content: realPkg } = await pkgJson.normalize(path, { steps: [
|
|
32
|
+
'binDir',
|
|
33
|
+
...pkgJson.normalizeSteps,
|
|
34
|
+
] }).catch(() => ({ content: {} }))
|
|
32
35
|
const pkg = {
|
|
33
36
|
...realPkg,
|
|
34
37
|
scripts: {
|
package/lib/with-lock.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const fs = require('node:fs/promises')
|
|
2
|
+
const { rmdirSync } = require('node:fs')
|
|
3
|
+
const promiseRetry = require('promise-retry')
|
|
4
|
+
const { onExit } = require('signal-exit')
|
|
5
|
+
|
|
6
|
+
// a lockfile implementation inspired by the unmaintained proper-lockfile library
|
|
7
|
+
//
|
|
8
|
+
// similarities:
|
|
9
|
+
// - based on mkdir's atomicity
|
|
10
|
+
// - works across processes and even machines (via NFS)
|
|
11
|
+
// - cleans up after itself
|
|
12
|
+
// - detects compromised locks
|
|
13
|
+
//
|
|
14
|
+
// differences:
|
|
15
|
+
// - higher-level API (just a withLock function)
|
|
16
|
+
// - written in async/await style
|
|
17
|
+
// - uses mtime + inode for more reliable compromised lock detection
|
|
18
|
+
// - more ergonomic compromised lock handling (i.e. withLock will reject, and callbacks have access to an AbortSignal)
|
|
19
|
+
// - uses a more recent version of signal-exit
|
|
20
|
+
|
|
21
|
+
const touchInterval = 1_000
|
|
22
|
+
// mtime precision is platform dependent, so use a reasonably large threshold
|
|
23
|
+
const staleThreshold = 5_000
|
|
24
|
+
|
|
25
|
+
// track current locks and their cleanup functions
|
|
26
|
+
const currentLocks = new Map()
|
|
27
|
+
|
|
28
|
+
function cleanupLocks () {
|
|
29
|
+
for (const [, cleanup] of currentLocks) {
|
|
30
|
+
try {
|
|
31
|
+
cleanup()
|
|
32
|
+
} catch (err) {
|
|
33
|
+
//
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// clean up any locks that were not released normally
|
|
39
|
+
onExit(cleanupLocks)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Acquire an advisory lock for the given path and hold it for the duration of the callback.
|
|
43
|
+
*
|
|
44
|
+
* The lock will be released automatically when the callback resolves or rejects.
|
|
45
|
+
* Concurrent calls to withLock() for the same path will wait until the lock is released.
|
|
46
|
+
*/
|
|
47
|
+
async function withLock (lockPath, cb) {
|
|
48
|
+
try {
|
|
49
|
+
const signal = await acquireLock(lockPath)
|
|
50
|
+
return await new Promise((resolve, reject) => {
|
|
51
|
+
signal.addEventListener('abort', () => {
|
|
52
|
+
reject(Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' }))
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
(async () => {
|
|
56
|
+
try {
|
|
57
|
+
resolve(await cb(signal))
|
|
58
|
+
} catch (err) {
|
|
59
|
+
reject(err)
|
|
60
|
+
}
|
|
61
|
+
})()
|
|
62
|
+
})
|
|
63
|
+
} finally {
|
|
64
|
+
releaseLock(lockPath)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function acquireLock (lockPath) {
|
|
69
|
+
return promiseRetry({
|
|
70
|
+
minTimeout: 100,
|
|
71
|
+
maxTimeout: 5_000,
|
|
72
|
+
// if another process legitimately holds the lock, wait for it to release; if it dies abnormally and the lock becomes stale, we'll acquire it automatically
|
|
73
|
+
forever: true,
|
|
74
|
+
}, async (retry) => {
|
|
75
|
+
try {
|
|
76
|
+
await fs.mkdir(lockPath)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.code !== 'EEXIST' && err.code !== 'EBUSY' && err.code !== 'EPERM') {
|
|
79
|
+
throw err
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const status = await getLockStatus(lockPath)
|
|
83
|
+
|
|
84
|
+
if (status === 'locked') {
|
|
85
|
+
// let's see if we can acquire it on the next attempt 🤞
|
|
86
|
+
return retry(err)
|
|
87
|
+
}
|
|
88
|
+
if (status === 'stale') {
|
|
89
|
+
try {
|
|
90
|
+
// there is a very tiny window where another process could also release the stale lock and acquire it before we release it here; the lock compromise checker should detect this and throw an error
|
|
91
|
+
deleteLock(lockPath)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// on windows, EBUSY/EPERM can happen if another process is (re)creating the lock; maybe we can acquire it on a subsequent attempt 🤞
|
|
94
|
+
if (e.code === 'EBUSY' || e.code === 'EPERM') {
|
|
95
|
+
return retry(e)
|
|
96
|
+
}
|
|
97
|
+
throw e
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// immediately attempt to acquire the lock (no backoff)
|
|
101
|
+
return await acquireLock(lockPath)
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const signal = await maintainLock(lockPath)
|
|
105
|
+
return signal
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' })
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function deleteLock (lockPath) {
|
|
113
|
+
try {
|
|
114
|
+
// synchronous, so we can call in an exit handler
|
|
115
|
+
rmdirSync(lockPath)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err.code !== 'ENOENT') {
|
|
118
|
+
throw err
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function releaseLock (lockPath) {
|
|
124
|
+
currentLocks.get(lockPath)?.()
|
|
125
|
+
currentLocks.delete(lockPath)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function getLockStatus (lockPath) {
|
|
129
|
+
try {
|
|
130
|
+
const stat = await fs.stat(lockPath)
|
|
131
|
+
return (Date.now() - stat.mtimeMs > staleThreshold) ? 'stale' : 'locked'
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err.code === 'ENOENT') {
|
|
134
|
+
return 'unlocked'
|
|
135
|
+
}
|
|
136
|
+
throw err
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function maintainLock (lockPath) {
|
|
141
|
+
const controller = new AbortController()
|
|
142
|
+
const stats = await fs.stat(lockPath)
|
|
143
|
+
// fs.utimes operates on floating points seconds (directly, or via strings/Date objects), which may not match the underlying filesystem's mtime precision, meaning that we might read a slightly different mtime than we write. always round to the nearest second, since all filesystems support at least second precision
|
|
144
|
+
let mtime = Math.round(stats.mtimeMs / 1000)
|
|
145
|
+
const signal = controller.signal
|
|
146
|
+
|
|
147
|
+
async function touchLock () {
|
|
148
|
+
try {
|
|
149
|
+
const currentStats = (await fs.stat(lockPath))
|
|
150
|
+
const currentMtime = Math.round(currentStats.mtimeMs / 1000)
|
|
151
|
+
if (currentStats.ino !== stats.ino || currentMtime !== mtime) {
|
|
152
|
+
throw new Error('Lock compromised')
|
|
153
|
+
}
|
|
154
|
+
mtime = Math.round(Date.now() / 1000)
|
|
155
|
+
// touch the lock, unless we just released it during this iteration
|
|
156
|
+
if (currentLocks.has(lockPath)) {
|
|
157
|
+
await fs.utimes(lockPath, mtime, mtime)
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// stats mismatch or other fs error means the lock was compromised
|
|
161
|
+
controller.abort()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const timeout = setInterval(touchLock, touchInterval)
|
|
166
|
+
timeout.unref()
|
|
167
|
+
function cleanup () {
|
|
168
|
+
clearInterval(timeout)
|
|
169
|
+
deleteLock(lockPath)
|
|
170
|
+
}
|
|
171
|
+
currentLocks.set(lockPath, cleanup)
|
|
172
|
+
return signal
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = withLock
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "libnpmexec",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.8",
|
|
4
4
|
"files": [
|
|
5
5
|
"bin/",
|
|
6
6
|
"lib/"
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@npmcli/eslint-config": "^5.0.1",
|
|
54
54
|
"@npmcli/mock-registry": "^1.0.0",
|
|
55
|
-
"@npmcli/template-oss": "4.
|
|
55
|
+
"@npmcli/template-oss": "4.25.1",
|
|
56
56
|
"bin-links": "^5.0.0",
|
|
57
57
|
"chalk": "^5.2.0",
|
|
58
58
|
"just-extend": "^6.2.0",
|
|
@@ -60,21 +60,22 @@
|
|
|
60
60
|
"tap": "^16.3.8"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@npmcli/arborist": "^9.1.
|
|
64
|
-
"@npmcli/package-json": "^
|
|
65
|
-
"@npmcli/run-script": "^
|
|
63
|
+
"@npmcli/arborist": "^9.1.6",
|
|
64
|
+
"@npmcli/package-json": "^7.0.0",
|
|
65
|
+
"@npmcli/run-script": "^10.0.0",
|
|
66
66
|
"ci-info": "^4.0.0",
|
|
67
|
-
"npm-package-arg": "^
|
|
68
|
-
"pacote": "^21.0.
|
|
67
|
+
"npm-package-arg": "^13.0.0",
|
|
68
|
+
"pacote": "^21.0.2",
|
|
69
69
|
"proc-log": "^5.0.0",
|
|
70
|
+
"promise-retry": "^2.0.1",
|
|
70
71
|
"read": "^4.0.0",
|
|
71
|
-
"read-package-json-fast": "^4.0.0",
|
|
72
72
|
"semver": "^7.3.7",
|
|
73
|
+
"signal-exit": "^4.1.0",
|
|
73
74
|
"walk-up-path": "^4.0.0"
|
|
74
75
|
},
|
|
75
76
|
"templateOSS": {
|
|
76
77
|
"//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
|
|
77
|
-
"version": "4.
|
|
78
|
+
"version": "4.25.1",
|
|
78
79
|
"content": "../../scripts/template-oss/index.js"
|
|
79
80
|
}
|
|
80
81
|
}
|