dep 1.1.0 → 1.2.0
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/.claude/settings.local.json +2 -1
- package/bin/dep.js +9 -7
- package/lib/install/installer.js +11 -4
- package/lib/install/saver.js +3 -3
- package/lib/install.js +62 -7
- package/lib/lock/locker.js +39 -8
- package/lib/lock.js +46 -7
- package/lib/utils/pool.js +29 -0
- package/lib/utils/resolve-tree.js +46 -52
- package/lib/utils/workspaces.js +89 -0
- package/package.json +1 -1
- package/test/45-pool.js +45 -0
- package/test/50-workspace.js +167 -0
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"Bash(timeout 60 npm exec --offline -- node-gyp --version)",
|
|
60
60
|
"Bash(node -e 'const p=require\\(\"./package.json\"\\);console.log\\(\"dependencies:\",JSON.stringify\\(p.dependencies\\),\"| bundleDependencies:\",p.bundleDependencies||\"\\(removed\\)\"\\)')",
|
|
61
61
|
"Bash(grep -nE '`[^`]*`' lib/utils/cmd-shim.js)",
|
|
62
|
-
"Bash(grep -v '\\\\${')"
|
|
62
|
+
"Bash(grep -v '\\\\${')",
|
|
63
|
+
"Bash(DEP_CONCURRENCY=4 node --input-type=module -e ' *)"
|
|
63
64
|
]
|
|
64
65
|
}
|
|
65
66
|
}
|
package/bin/dep.js
CHANGED
|
@@ -42,11 +42,12 @@ const help = () => {
|
|
|
42
42
|
...rows,
|
|
43
43
|
'',
|
|
44
44
|
'Options:',
|
|
45
|
-
' --save
|
|
46
|
-
' --save-dev
|
|
47
|
-
' --only=prod|dev
|
|
48
|
-
' -
|
|
49
|
-
' -
|
|
45
|
+
' --save Save to dependencies (--save=dev for devDependencies)',
|
|
46
|
+
' --save-dev Save to devDependencies',
|
|
47
|
+
' --only=prod|dev Install only prod or dev dependencies',
|
|
48
|
+
' -w, --workspace <name> Add the package(s) to the named workspace(s)',
|
|
49
|
+
' -h, --help Show help',
|
|
50
|
+
' -v, --version Show version information',
|
|
50
51
|
''
|
|
51
52
|
].join('\n')
|
|
52
53
|
}
|
|
@@ -62,7 +63,8 @@ const { values, positionals } = parseArgs({
|
|
|
62
63
|
version: { type: 'boolean', short: 'v' },
|
|
63
64
|
only: { type: 'string' },
|
|
64
65
|
save: { type: 'string' },
|
|
65
|
-
'save-dev': { type: 'boolean' }
|
|
66
|
+
'save-dev': { type: 'boolean' },
|
|
67
|
+
workspace: { type: 'string', short: 'w', multiple: true }
|
|
66
68
|
}
|
|
67
69
|
})
|
|
68
70
|
|
|
@@ -79,7 +81,7 @@ if (values.version) {
|
|
|
79
81
|
} else if (values.help) {
|
|
80
82
|
process.stdout.write(help() + '\n')
|
|
81
83
|
} else if (command) {
|
|
82
|
-
command.handler({ _: positionals, only: values.only, save })
|
|
84
|
+
command.handler({ _: positionals, only: values.only, save, workspace: values.workspace })
|
|
83
85
|
} else {
|
|
84
86
|
process.stderr.write(help() + '\n')
|
|
85
87
|
}
|
package/lib/install/installer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
3
|
import nm from '../utils/nm.js'
|
|
4
|
+
import pool from '../utils/pool.js'
|
|
4
5
|
import bin from './installer/bin.js'
|
|
5
6
|
import git from './installer/git.js'
|
|
6
7
|
import local from './installer/local.js'
|
|
@@ -8,6 +9,11 @@ import remote from './installer/remote.js'
|
|
|
8
9
|
import registry from './installer/registry.js'
|
|
9
10
|
import runner from '../run/runner.js'
|
|
10
11
|
|
|
12
|
+
// Bound the number of tarball downloads / extractions / git clones running at
|
|
13
|
+
// once so deep trees don't open thousands of sockets and file handles.
|
|
14
|
+
const CONCURRENCY = Math.max(1, Number(process.env.DEP_CONCURRENCY) || 16)
|
|
15
|
+
const limit = pool(CONCURRENCY)
|
|
16
|
+
|
|
11
17
|
const installer = (dep, deps, base, resolve, reject) => {
|
|
12
18
|
fs.mkdirSync(nm, { recursive: true })
|
|
13
19
|
fs.mkdirSync(path.join(nm, '.bin'), { recursive: true })
|
|
@@ -20,18 +26,19 @@ const installer = (dep, deps, base, resolve, reject) => {
|
|
|
20
26
|
const pkg = deps[dep]
|
|
21
27
|
let fetch
|
|
22
28
|
|
|
29
|
+
// Defer the actual work into the pool (thunks) so only CONCURRENCY run at once.
|
|
23
30
|
switch (pkg.type) {
|
|
24
31
|
case 'git':
|
|
25
|
-
fetch = git(pkg, target)
|
|
32
|
+
fetch = limit(() => git(pkg, target))
|
|
26
33
|
break
|
|
27
34
|
case 'remote':
|
|
28
|
-
fetch = remote(pkg, target)
|
|
35
|
+
fetch = limit(() => remote(pkg, target))
|
|
29
36
|
break
|
|
30
37
|
case 'local':
|
|
31
|
-
fetch = local(pkg, target)
|
|
38
|
+
fetch = limit(() => local(pkg, target))
|
|
32
39
|
break
|
|
33
40
|
case 'registry':
|
|
34
|
-
fetch = registry(pkg, target)
|
|
41
|
+
fetch = limit(() => registry(pkg, target))
|
|
35
42
|
break
|
|
36
43
|
}
|
|
37
44
|
|
package/lib/install/saver.js
CHANGED
|
@@ -3,8 +3,8 @@ import path from 'path'
|
|
|
3
3
|
import npmrc from '../utils/npmrc.js'
|
|
4
4
|
import npa from '../utils/npa.js'
|
|
5
5
|
|
|
6
|
-
export default (pkgs, save) => {
|
|
7
|
-
const pkgJSON = JSON.parse(fs.readFileSync(path.join(
|
|
6
|
+
export default (pkgs, save, cwd = process.cwd()) => {
|
|
7
|
+
const pkgJSON = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json')))
|
|
8
8
|
const saveDeps = {}
|
|
9
9
|
pkgs.forEach((pkg) => {
|
|
10
10
|
// npa keeps scoped names (@scope/name) intact; the spec is whatever
|
|
@@ -29,7 +29,7 @@ export default (pkgs, save) => {
|
|
|
29
29
|
const newDeps = Object.assign({}, oldDeps, saveDeps)
|
|
30
30
|
pkgJSON[field] = newDeps
|
|
31
31
|
fs.writeFileSync(
|
|
32
|
-
path.join(
|
|
32
|
+
path.join(cwd, 'package.json'),
|
|
33
33
|
JSON.stringify(pkgJSON, null, 2)
|
|
34
34
|
)
|
|
35
35
|
}
|
package/lib/install.js
CHANGED
|
@@ -6,8 +6,23 @@ import saver from './install/saver.js'
|
|
|
6
6
|
import nodeGyp from './utils/node-gyp.js'
|
|
7
7
|
import nm from './utils/nm.js'
|
|
8
8
|
import npa from './utils/npa.js'
|
|
9
|
+
import bin from './install/installer/bin.js'
|
|
10
|
+
import { findWorkspaces, resolveWorkspace } from './utils/workspaces.js'
|
|
9
11
|
import dropPrivilege from './utils/drop-privilege.js'
|
|
10
12
|
|
|
13
|
+
const isWin = process.platform === 'win32'
|
|
14
|
+
|
|
15
|
+
// Symlink each workspace package into the root node_modules so cross-workspace
|
|
16
|
+
// imports resolve, and link its bins into node_modules/.bin.
|
|
17
|
+
const linkWorkspaces = (workspaces) => Promise.all(workspaces.map(async (ws) => {
|
|
18
|
+
const dest = path.join(nm, ws.name)
|
|
19
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
20
|
+
fs.rmSync(dest, { recursive: true, force: true })
|
|
21
|
+
const target = isWin ? ws.dir : path.relative(path.dirname(dest), ws.dir)
|
|
22
|
+
fs.symlinkSync(target, dest, isWin ? 'junction' : 'dir')
|
|
23
|
+
await bin(ws.name, ws.dir)
|
|
24
|
+
}))
|
|
25
|
+
|
|
11
26
|
global.dependenciesCount = 0
|
|
12
27
|
global.dependenciesTree = {}
|
|
13
28
|
global.nativeBuildQueue = []
|
|
@@ -19,7 +34,6 @@ const install = (argv) => {
|
|
|
19
34
|
const only = argv.only === 'dev' || argv.only === 'prod' ? argv.only : 'all'
|
|
20
35
|
const save = argv.save === 'dev' || argv.save === 'prod' ? argv.save : null
|
|
21
36
|
const pkgJSON = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')))
|
|
22
|
-
const optionalDependencies = pkgJSON.optionalDependencies || {}
|
|
23
37
|
const allDependencies = {
|
|
24
38
|
all: [
|
|
25
39
|
'dependencies',
|
|
@@ -32,11 +46,42 @@ const install = (argv) => {
|
|
|
32
46
|
'devDependencies'
|
|
33
47
|
]
|
|
34
48
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Object.assign(
|
|
49
|
+
// Collect a package.json's deps honouring the --only filter (optional deps
|
|
50
|
+
// are always included, like npm).
|
|
51
|
+
const collect = (json) => {
|
|
52
|
+
const out = Object.assign({}, json.optionalDependencies || {})
|
|
53
|
+
allDependencies[only].forEach((key) => {
|
|
54
|
+
if (json[key]) Object.assign(out, json[key])
|
|
55
|
+
})
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const deps = collect(pkgJSON)
|
|
60
|
+
|
|
61
|
+
// Workspaces: merge every workspace's deps into the root install and link the
|
|
62
|
+
// workspace packages themselves rather than fetching them from the registry.
|
|
63
|
+
const workspaces = pkgJSON.workspaces
|
|
64
|
+
? findWorkspaces(process.cwd(), pkgJSON.workspaces)
|
|
65
|
+
: []
|
|
66
|
+
workspaces.forEach((ws) => {
|
|
67
|
+
const wsDeps = collect(ws.pkg)
|
|
68
|
+
Object.keys(wsDeps).forEach((name) => {
|
|
69
|
+
if (!(name in deps)) deps[name] = wsDeps[name]
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
workspaces.forEach((ws) => { delete deps[ws.name] })
|
|
73
|
+
|
|
74
|
+
// `-w <workspace>`: the named package(s) belong to those workspaces. Resolve
|
|
75
|
+
// each target up front so a typo fails before we touch anything.
|
|
76
|
+
const wsTargets = (argv.workspace || []).map((target) => {
|
|
77
|
+
const ws = resolveWorkspace(workspaces, process.cwd(), target)
|
|
78
|
+
if (!ws) {
|
|
79
|
+
process.stderr.write(`No workspace found for "${target}"\n`)
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
return ws
|
|
39
83
|
})
|
|
84
|
+
|
|
40
85
|
pkgs.forEach((pkg) => {
|
|
41
86
|
// Use npa to split the name from the spec so scoped packages
|
|
42
87
|
// (@scope/name@range) aren't broken by the leading '@'.
|
|
@@ -50,8 +95,14 @@ const install = (argv) => {
|
|
|
50
95
|
dropPrivilege() // if root
|
|
51
96
|
const tasks = installer(global.dependenciesTree)
|
|
52
97
|
process.stdout.write('Installing dependencies\n')
|
|
53
|
-
Promise.all(tasks).then(() => {
|
|
54
|
-
|
|
98
|
+
Promise.all(tasks).then(async () => {
|
|
99
|
+
// `-w` implies saving into the target workspace(s); otherwise honour
|
|
100
|
+
// --save against the root package.json.
|
|
101
|
+
if (wsTargets.length && pkgs.length) {
|
|
102
|
+
wsTargets.forEach((ws) => saver(pkgs, save, ws.dir))
|
|
103
|
+
} else if (save) {
|
|
104
|
+
saver(pkgs, save)
|
|
105
|
+
}
|
|
55
106
|
global.nativeBuildQueue.forEach((cwd) => {
|
|
56
107
|
try {
|
|
57
108
|
process.stdout.write('Building dependencies\n')
|
|
@@ -61,6 +112,10 @@ const install = (argv) => {
|
|
|
61
112
|
fs.rmSync(cwd, { recursive: true, force: true })
|
|
62
113
|
}
|
|
63
114
|
})
|
|
115
|
+
if (workspaces.length) {
|
|
116
|
+
process.stdout.write('Linking workspaces\n')
|
|
117
|
+
await linkWorkspaces(workspaces)
|
|
118
|
+
}
|
|
64
119
|
const duration = process.hrtime(global.time)
|
|
65
120
|
const time = duration[0] + duration[1] / 1e9
|
|
66
121
|
const s = Math.round(time * 1000) / 1000
|
package/lib/lock/locker.js
CHANGED
|
@@ -53,7 +53,9 @@ const resolveFrom = (packages, fromPath, name) => {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Mark every location reachable from a set of root dependency names.
|
|
56
|
+
// Mark every location reachable from a set of root dependency names. A
|
|
57
|
+
// workspace link is followed to its source entry, so the workspace's own
|
|
58
|
+
// dependencies are traversed too.
|
|
57
59
|
const reachable = (packages, roots) => {
|
|
58
60
|
const seen = {}
|
|
59
61
|
const stack = roots
|
|
@@ -63,21 +65,28 @@ const reachable = (packages, roots) => {
|
|
|
63
65
|
const location = stack.pop()
|
|
64
66
|
if (seen[location]) continue
|
|
65
67
|
seen[location] = true
|
|
66
|
-
|
|
68
|
+
let entry = packages[location]
|
|
69
|
+
let base = location
|
|
70
|
+
if (entry && entry.link) {
|
|
71
|
+
base = entry.resolved
|
|
72
|
+
entry = packages[entry.resolved]
|
|
73
|
+
}
|
|
74
|
+
if (!entry) continue
|
|
67
75
|
const edges = Object.assign({}, entry.dependencies, entry.optionalDependencies)
|
|
68
76
|
Object.keys(edges).forEach((name) => {
|
|
69
|
-
const child = resolveFrom(packages,
|
|
77
|
+
const child = resolveFrom(packages, base, name)
|
|
70
78
|
if (child) stack.push(child)
|
|
71
79
|
})
|
|
72
80
|
}
|
|
73
81
|
return seen
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
const locker = (pkgJSON, tree) => {
|
|
84
|
+
const locker = (pkgJSON, tree, workspaces = []) => {
|
|
77
85
|
const packages = {}
|
|
78
86
|
|
|
79
87
|
const root = { name: pkgJSON.name, version: pkgJSON.version }
|
|
80
88
|
if (pkgJSON.license) root.license = pkgJSON.license
|
|
89
|
+
if (pkgJSON.workspaces) root.workspaces = pkgJSON.workspaces
|
|
81
90
|
if (notEmpty(pkgJSON.dependencies)) root.dependencies = pkgJSON.dependencies
|
|
82
91
|
if (notEmpty(pkgJSON.devDependencies)) root.devDependencies = pkgJSON.devDependencies
|
|
83
92
|
if (notEmpty(pkgJSON.optionalDependencies)) root.optionalDependencies = pkgJSON.optionalDependencies
|
|
@@ -85,11 +94,33 @@ const locker = (pkgJSON, tree) => {
|
|
|
85
94
|
|
|
86
95
|
flatten(tree, '', packages)
|
|
87
96
|
|
|
97
|
+
// Each workspace appears twice, npm-style: a source entry at its real
|
|
98
|
+
// location, and a `link: true` entry in node_modules pointing at it.
|
|
99
|
+
workspaces.forEach((ws) => {
|
|
100
|
+
const location = path.relative(process.cwd(), ws.dir).split(path.sep).join('/')
|
|
101
|
+
const src = { name: ws.pkg.name, version: ws.pkg.version }
|
|
102
|
+
if (ws.pkg.license) src.license = ws.pkg.license
|
|
103
|
+
if (notEmpty(ws.pkg.dependencies)) src.dependencies = ws.pkg.dependencies
|
|
104
|
+
if (notEmpty(ws.pkg.devDependencies)) src.devDependencies = ws.pkg.devDependencies
|
|
105
|
+
if (notEmpty(ws.pkg.optionalDependencies)) src.optionalDependencies = ws.pkg.optionalDependencies
|
|
106
|
+
if (notEmpty(ws.pkg.peerDependencies)) src.peerDependencies = ws.pkg.peerDependencies
|
|
107
|
+
if (ws.pkg.bin) src.bin = ws.pkg.bin
|
|
108
|
+
if (ws.pkg.engines) src.engines = ws.pkg.engines
|
|
109
|
+
packages[location] = src
|
|
110
|
+
packages['node_modules/' + ws.name] = { resolved: location, link: true }
|
|
111
|
+
})
|
|
112
|
+
|
|
88
113
|
// Classify each package as production, dev and/or optional so the lockfile
|
|
89
|
-
// mirrors npm's `dev`/`optional`/`devOptional` annotations.
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
114
|
+
// mirrors npm's `dev`/`optional`/`devOptional` annotations. Workspaces (and
|
|
115
|
+
// the deps reached through their links) count as production.
|
|
116
|
+
const wsDevNames = workspaces.flatMap((ws) => Object.keys(ws.pkg.devDependencies || {}))
|
|
117
|
+
const wsOptNames = workspaces.flatMap((ws) => Object.keys(ws.pkg.optionalDependencies || {}))
|
|
118
|
+
const inProd = reachable(packages, [
|
|
119
|
+
...Object.keys(pkgJSON.dependencies || {}),
|
|
120
|
+
...workspaces.map((ws) => ws.name)
|
|
121
|
+
])
|
|
122
|
+
const inDev = reachable(packages, [...Object.keys(pkgJSON.devDependencies || {}), ...wsDevNames])
|
|
123
|
+
const inOptional = reachable(packages, [...Object.keys(pkgJSON.optionalDependencies || {}), ...wsOptNames])
|
|
93
124
|
Object.keys(packages).forEach((location) => {
|
|
94
125
|
if (location === '') return
|
|
95
126
|
if (inProd[location]) return
|
package/lib/lock.js
CHANGED
|
@@ -2,23 +2,62 @@ import path from 'path'
|
|
|
2
2
|
import fs from 'fs'
|
|
3
3
|
import resolver from './lock/resolver.js'
|
|
4
4
|
import locker from './lock/locker.js'
|
|
5
|
+
import { findWorkspaces, resolveWorkspace } from './utils/workspaces.js'
|
|
5
6
|
|
|
6
7
|
global.dependenciesTree = {}
|
|
7
8
|
|
|
9
|
+
const allDeps = (json) => Object.assign(
|
|
10
|
+
{},
|
|
11
|
+
json.optionalDependencies || {},
|
|
12
|
+
json.devDependencies || {},
|
|
13
|
+
json.dependencies || {}
|
|
14
|
+
)
|
|
15
|
+
|
|
8
16
|
const lock = (argv) => {
|
|
9
17
|
argv._handled = true
|
|
10
18
|
const pkgJSON = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json')))
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
pkgJSON.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
19
|
+
|
|
20
|
+
const allWorkspaces = pkgJSON.workspaces
|
|
21
|
+
? findWorkspaces(process.cwd(), pkgJSON.workspaces)
|
|
22
|
+
: []
|
|
23
|
+
|
|
24
|
+
// `-w <workspace>`: narrow the lockfile to the named workspace(s). Without
|
|
25
|
+
// it, lock the root plus every workspace.
|
|
26
|
+
const targets = argv.workspace || []
|
|
27
|
+
const scoped = targets.length > 0
|
|
28
|
+
const workspaces = scoped
|
|
29
|
+
? targets.map((target) => {
|
|
30
|
+
const ws = resolveWorkspace(allWorkspaces, process.cwd(), target)
|
|
31
|
+
if (!ws) {
|
|
32
|
+
process.stderr.write(`No workspace found for "${target}"\n`)
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
return ws
|
|
36
|
+
})
|
|
37
|
+
: allWorkspaces
|
|
38
|
+
|
|
39
|
+
// The root's own deps are only locked when not scoping to specific workspaces.
|
|
40
|
+
const deps = scoped ? {} : allDeps(pkgJSON)
|
|
41
|
+
|
|
42
|
+
// Lock each in-scope workspace's deps too, and link the workspace packages
|
|
43
|
+
// themselves rather than fetching them from the registry.
|
|
44
|
+
workspaces.forEach((ws) => {
|
|
45
|
+
const wsDeps = allDeps(ws.pkg)
|
|
46
|
+
Object.keys(wsDeps).forEach((name) => {
|
|
47
|
+
if (!(name in deps)) deps[name] = wsDeps[name]
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
workspaces.forEach((ws) => { delete deps[ws.name] })
|
|
51
|
+
|
|
52
|
+
// When scoped, the root entry shouldn't declare deps we didn't resolve.
|
|
53
|
+
const rootJSON = scoped
|
|
54
|
+
? { name: pkgJSON.name, version: pkgJSON.version, license: pkgJSON.license, workspaces: pkgJSON.workspaces }
|
|
55
|
+
: pkgJSON
|
|
17
56
|
|
|
18
57
|
const list = resolver(deps)
|
|
19
58
|
process.stdout.write('Resolving dependencies\n')
|
|
20
59
|
Promise.all(list).then(() => {
|
|
21
|
-
locker(
|
|
60
|
+
locker(rootJSON, global.dependenciesTree, workspaces)
|
|
22
61
|
process.stdout.write(
|
|
23
62
|
'created package-lock.json\n'
|
|
24
63
|
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Bounded-concurrency runner. Returns a `run(thunk)` that schedules an async
|
|
2
|
+
// task (a function returning a promise) and resolves with its result, keeping
|
|
3
|
+
// at most `size` tasks in flight at once. Queued tasks start as slots free up.
|
|
4
|
+
//
|
|
5
|
+
// This caps simultaneous network connections, open file handles and tarball
|
|
6
|
+
// extractions so large dependency trees don't exhaust sockets/FDs or trip
|
|
7
|
+
// registry rate limits.
|
|
8
|
+
export default (size) => {
|
|
9
|
+
let active = 0
|
|
10
|
+
const queue = []
|
|
11
|
+
|
|
12
|
+
const next = () => {
|
|
13
|
+
if (active >= size || queue.length === 0) return
|
|
14
|
+
active++
|
|
15
|
+
const { thunk, resolve, reject } = queue.shift()
|
|
16
|
+
Promise.resolve()
|
|
17
|
+
.then(thunk)
|
|
18
|
+
.then(resolve, reject)
|
|
19
|
+
.finally(() => {
|
|
20
|
+
active--
|
|
21
|
+
next()
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (thunk) => new Promise((resolve, reject) => {
|
|
26
|
+
queue.push({ thunk, resolve, reject })
|
|
27
|
+
next()
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import semver from './semver.js'
|
|
2
|
+
import pool from './pool.js'
|
|
3
|
+
|
|
4
|
+
const CONCURRENCY = Math.max(1, Number(process.env.DEP_CONCURRENCY) || 16)
|
|
2
5
|
|
|
3
6
|
// Resolve a dependency graph into the hoisted tree shape consumed by the
|
|
4
7
|
// installer and locker (a top-level name->node map, with conflicting versions
|
|
@@ -9,22 +12,11 @@ import semver from './semver.js'
|
|
|
9
12
|
// `name@spec` (and, for the registry, by packument). This is where the
|
|
10
13
|
// time goes, so it runs fully concurrently and never fetches the same
|
|
11
14
|
// thing twice.
|
|
12
|
-
// 2. Build the tree with a deterministic
|
|
13
|
-
// metadata
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let ref = list
|
|
18
|
-
while (keys.length) {
|
|
19
|
-
const key = keys.shift()
|
|
20
|
-
if (key in ref) {
|
|
21
|
-
ref = ref[key]
|
|
22
|
-
} else {
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return ref
|
|
27
|
-
}
|
|
15
|
+
// 2. Build the tree with a deterministic breadth-first walk over the cached
|
|
16
|
+
// metadata. Processing shallower dependencies first lets direct deps claim
|
|
17
|
+
// the top-level (hoisted) slots, with conflicting deeper versions nested
|
|
18
|
+
// under their parent — and the output is identical on every run regardless
|
|
19
|
+
// of network timing.
|
|
28
20
|
|
|
29
21
|
const setter = (list, key, value, walk) => {
|
|
30
22
|
let i = 0
|
|
@@ -62,13 +54,14 @@ export default (deps, fetcher, keepRequires) => {
|
|
|
62
54
|
const cache = new Map() // name@spec -> in-flight promise
|
|
63
55
|
const metas = new Map() // name@spec -> resolved metadata
|
|
64
56
|
const pending = []
|
|
57
|
+
const limit = pool(CONCURRENCY)
|
|
65
58
|
let firstError = null
|
|
66
59
|
|
|
67
|
-
// --- phase 1: fetch all metadata, concurrently and de-duplicated ---
|
|
60
|
+
// --- phase 1: fetch all metadata, concurrently (bounded) and de-duplicated ---
|
|
68
61
|
const schedule = (name, spec) => {
|
|
69
62
|
const key = metaKey(name, spec)
|
|
70
63
|
if (cache.has(key)) return
|
|
71
|
-
const p = fetcher(name, spec)
|
|
64
|
+
const p = limit(() => fetcher(name, spec))
|
|
72
65
|
.then((meta) => {
|
|
73
66
|
metas.set(key, meta)
|
|
74
67
|
if (meta.dependencies) {
|
|
@@ -89,45 +82,46 @@ export default (deps, fetcher, keepRequires) => {
|
|
|
89
82
|
if (firstError) throw firstError
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
// --- phase 2: build the hoisted tree
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
85
|
+
// --- phase 2: build the hoisted tree breadth-first (deterministic) ---
|
|
86
|
+
const build = () => {
|
|
87
|
+
const queue = Object.keys(deps).map((name) => ({ dep: name, list: deps, base: [] }))
|
|
88
|
+
const visited = new Set()
|
|
89
|
+
|
|
90
|
+
while (queue.length) {
|
|
91
|
+
const { dep, list, base } = queue.shift()
|
|
92
|
+
const range = list[dep]
|
|
93
|
+
|
|
94
|
+
// Already satisfied by the hoisted top-level copy.
|
|
95
|
+
if (tree[dep] && tree[dep].version && semver.satisfies(tree[dep].version, range)) {
|
|
96
|
+
continue
|
|
103
97
|
}
|
|
104
|
-
}
|
|
105
98
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
99
|
+
const meta = metas.get(metaKey(dep, range))
|
|
100
|
+
if (!meta || !meta.version) continue
|
|
101
|
+
|
|
102
|
+
// Skip identical (path, dep, version) work — guards against cycles and
|
|
103
|
+
// redundant nesting.
|
|
104
|
+
const mark = `${base.join('|')}|${dep}@${meta.version}`
|
|
105
|
+
if (visited.has(mark)) continue
|
|
106
|
+
visited.add(mark)
|
|
107
|
+
|
|
108
|
+
const item = Object.assign({}, meta)
|
|
109
|
+
if (keepRequires) item.requires = meta.dependencies
|
|
110
|
+
delete item.dependencies
|
|
111
|
+
// First (shallowest) occurrence hoists to the top; conflicts nest.
|
|
112
|
+
if (!tree[dep]) tree[dep] = item
|
|
113
|
+
else setter(tree, dep, item, base)
|
|
114
|
+
|
|
115
|
+
if (!meta.dependencies) continue
|
|
116
|
+
const id = `${dep}@${meta.version}`
|
|
117
|
+
const childBase = base.length === 0 ? [id] : [...base, 'dependencies', id]
|
|
118
|
+
for (const child of Object.keys(meta.dependencies)) {
|
|
119
|
+
queue.push({ dep: child, list: meta.dependencies, base: childBase })
|
|
120
|
+
}
|
|
124
121
|
}
|
|
125
|
-
path.delete(id)
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
for (const name of Object.keys(deps)) schedule(name, deps[name])
|
|
129
125
|
|
|
130
|
-
return drain().then(
|
|
131
|
-
for (const name of Object.keys(deps)) place(name, deps, [], new Set())
|
|
132
|
-
})
|
|
126
|
+
return drain().then(build)
|
|
133
127
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
// Discover workspace packages declared in a root package.json `workspaces`
|
|
5
|
+
// field (npm/yarn style): an array of globs, or { packages: [...] }. Supports
|
|
6
|
+
// `*`, `**`, exact paths and `!`-prefixed negations.
|
|
7
|
+
|
|
8
|
+
const isDir = (p) => {
|
|
9
|
+
try { return fs.statSync(p).isDirectory() } catch (e) { return false }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const readPkg = (dir) => {
|
|
13
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'))) } catch (e) { return null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const subdirs = (dir) => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && e.name !== 'node_modules' && e.name[0] !== '.')
|
|
20
|
+
.map((e) => e.name)
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const allDirs = (dir, acc) => {
|
|
27
|
+
acc.push(dir)
|
|
28
|
+
for (const name of subdirs(dir)) allDirs(path.join(dir, name), acc)
|
|
29
|
+
return acc
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const segToRe = (seg) =>
|
|
33
|
+
new RegExp('^' + seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '$')
|
|
34
|
+
|
|
35
|
+
// Expand a single glob pattern to matching directories under `root`.
|
|
36
|
+
const expand = (root, pattern) => {
|
|
37
|
+
let dirs = [root]
|
|
38
|
+
for (const seg of pattern.split('/').filter(Boolean)) {
|
|
39
|
+
const next = []
|
|
40
|
+
for (const d of dirs) {
|
|
41
|
+
if (seg === '**') {
|
|
42
|
+
allDirs(d, next)
|
|
43
|
+
} else if (seg.includes('*')) {
|
|
44
|
+
const re = segToRe(seg)
|
|
45
|
+
for (const name of subdirs(d)) if (re.test(name)) next.push(path.join(d, name))
|
|
46
|
+
} else {
|
|
47
|
+
const p = path.join(d, seg)
|
|
48
|
+
if (isDir(p)) next.push(p)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
dirs = next
|
|
52
|
+
}
|
|
53
|
+
return dirs
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const getPatterns = (workspaces) =>
|
|
57
|
+
Array.isArray(workspaces) ? workspaces : (workspaces && workspaces.packages) || []
|
|
58
|
+
|
|
59
|
+
// Returns [{ dir, name, pkg }] for every workspace package with a name.
|
|
60
|
+
export const findWorkspaces = (root, workspaces) => {
|
|
61
|
+
const patterns = getPatterns(workspaces)
|
|
62
|
+
const includes = patterns.filter((p) => !p.startsWith('!'))
|
|
63
|
+
const excludes = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1))
|
|
64
|
+
const excluded = new Set()
|
|
65
|
+
for (const pattern of excludes) {
|
|
66
|
+
for (const dir of expand(root, pattern)) excluded.add(dir)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const seen = new Set()
|
|
70
|
+
const result = []
|
|
71
|
+
for (const pattern of includes) {
|
|
72
|
+
for (const dir of expand(root, pattern)) {
|
|
73
|
+
if (seen.has(dir) || excluded.has(dir) || dir === root) continue
|
|
74
|
+
seen.add(dir)
|
|
75
|
+
const pkg = readPkg(dir)
|
|
76
|
+
if (pkg && pkg.name) result.push({ dir, name: pkg.name, pkg })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Resolve a `-w` target (a workspace name, or a path relative to root) to one of
|
|
83
|
+
// the discovered workspaces. Returns the matching entry, or undefined.
|
|
84
|
+
export const resolveWorkspace = (workspaces, root, target) => {
|
|
85
|
+
const byName = workspaces.find((ws) => ws.name === target)
|
|
86
|
+
if (byName) return byName
|
|
87
|
+
const wanted = path.resolve(root, target)
|
|
88
|
+
return workspaces.find((ws) => ws.dir === wanted)
|
|
89
|
+
}
|
package/package.json
CHANGED
package/test/45-pool.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import tap from 'tap'
|
|
2
|
+
import pool from '../lib/utils/pool.js'
|
|
3
|
+
|
|
4
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
5
|
+
|
|
6
|
+
tap.test('runs at most `size` tasks concurrently', async (t) => {
|
|
7
|
+
const limit = pool(3)
|
|
8
|
+
let active = 0
|
|
9
|
+
let peak = 0
|
|
10
|
+
|
|
11
|
+
const results = await Promise.all(
|
|
12
|
+
Array.from({ length: 12 }, (_, i) =>
|
|
13
|
+
limit(async () => {
|
|
14
|
+
active++
|
|
15
|
+
peak = Math.max(peak, active)
|
|
16
|
+
await delay(10)
|
|
17
|
+
active--
|
|
18
|
+
return i * 2
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
t.ok(peak <= 3, `peak concurrency (${peak}) never exceeded the limit`)
|
|
24
|
+
t.ok(peak > 1, 'tasks actually ran in parallel up to the limit')
|
|
25
|
+
t.same(results, Array.from({ length: 12 }, (_, i) => i * 2), 'every task resolved with its own result')
|
|
26
|
+
t.end()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
tap.test('a failing task rejects only its own promise and frees its slot', async (t) => {
|
|
30
|
+
const limit = pool(2)
|
|
31
|
+
|
|
32
|
+
await t.rejects(limit(() => Promise.reject(new Error('boom'))), /boom/, 'rejection propagates')
|
|
33
|
+
|
|
34
|
+
// The pool keeps working after a failure.
|
|
35
|
+
const value = await limit(() => Promise.resolve('ok'))
|
|
36
|
+
t.equal(value, 'ok', 'subsequent tasks still run')
|
|
37
|
+
t.end()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
tap.test('a task that throws synchronously is caught', async (t) => {
|
|
41
|
+
const limit = pool(1)
|
|
42
|
+
await t.rejects(limit(() => { throw new Error('sync') }), /sync/, 'sync throw becomes a rejection')
|
|
43
|
+
t.equal(await limit(() => Promise.resolve(1)), 1, 'pool is not wedged')
|
|
44
|
+
t.end()
|
|
45
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import os from 'os'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { exec } from 'child_process'
|
|
5
|
+
import tap from 'tap'
|
|
6
|
+
import { findWorkspaces, resolveWorkspace } from '../lib/utils/workspaces.js'
|
|
7
|
+
|
|
8
|
+
const bin = path.join(import.meta.dirname, '..', 'bin', 'dep.js')
|
|
9
|
+
|
|
10
|
+
const mkMonorepo = () => {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dep-ws-'))
|
|
12
|
+
const write = (rel, obj) => {
|
|
13
|
+
const file = path.join(root, rel)
|
|
14
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
15
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2))
|
|
16
|
+
}
|
|
17
|
+
write('package.json', { name: 'root', version: '1.0.0', private: true, workspaces: ['packages/*'] })
|
|
18
|
+
write('packages/a/package.json', { name: '@scope/a', version: '1.0.0', dependencies: { 'is-odd': '^3.0.0' } })
|
|
19
|
+
write('packages/b/package.json', { name: 'pkg-b', version: '1.0.0', dependencies: { '@scope/a': '^1.0.0', 'is-number': '^7.0.0' } })
|
|
20
|
+
return root
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
tap.test('findWorkspaces discovers packages by glob, honouring excludes', (t) => {
|
|
24
|
+
const root = mkMonorepo()
|
|
25
|
+
fs.mkdirSync(path.join(root, 'packages', 'ignored'), { recursive: true })
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
path.join(root, 'packages', 'ignored', 'package.json'),
|
|
28
|
+
JSON.stringify({ name: 'ignored', version: '1.0.0' })
|
|
29
|
+
)
|
|
30
|
+
t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
31
|
+
|
|
32
|
+
const all = findWorkspaces(root, ['packages/*'])
|
|
33
|
+
t.same(all.map((w) => w.name).sort(), ['@scope/a', 'ignored', 'pkg-b'], 'globs every package dir')
|
|
34
|
+
|
|
35
|
+
const filtered = findWorkspaces(root, ['packages/*', '!packages/ignored'])
|
|
36
|
+
t.same(filtered.map((w) => w.name).sort(), ['@scope/a', 'pkg-b'], 'negation excludes a package')
|
|
37
|
+
t.end()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
tap.test('install links workspaces, hoists shared deps and nests conflicts', (t) => {
|
|
41
|
+
const root = mkMonorepo()
|
|
42
|
+
t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
43
|
+
|
|
44
|
+
exec(`node ${bin} install`, { cwd: root }, (err) => {
|
|
45
|
+
t.error(err, 'workspace install ran without error')
|
|
46
|
+
|
|
47
|
+
const nm = path.join(root, 'node_modules')
|
|
48
|
+
t.ok(fs.lstatSync(path.join(nm, 'pkg-b')).isSymbolicLink(), 'pkg-b is symlinked into node_modules')
|
|
49
|
+
t.ok(fs.lstatSync(path.join(nm, '@scope', 'a')).isSymbolicLink(), 'scoped workspace is symlinked')
|
|
50
|
+
|
|
51
|
+
// cross-workspace dependency resolves through the link
|
|
52
|
+
t.ok(fs.existsSync(path.join(nm, '@scope', 'a', 'package.json')), 'pkg-b can reach @scope/a')
|
|
53
|
+
|
|
54
|
+
// shared registry dep hoisted; conflicting transitive version nested
|
|
55
|
+
const top = JSON.parse(fs.readFileSync(path.join(nm, 'is-number', 'package.json')))
|
|
56
|
+
t.match(top.version, /^7\./, 'is-number@7 (direct) hoisted to the top')
|
|
57
|
+
const nested = JSON.parse(fs.readFileSync(path.join(nm, 'is-odd', 'node_modules', 'is-number', 'package.json')))
|
|
58
|
+
t.match(nested.version, /^6\./, "is-odd's is-number@6 nested underneath")
|
|
59
|
+
|
|
60
|
+
// workspace packages are linked, never fetched into a real folder
|
|
61
|
+
t.notOk(fs.existsSync(path.join(nm, 'pkg-b', 'node_modules')), 'workspace was not re-installed from the registry')
|
|
62
|
+
t.end()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
tap.test('lock records workspaces as npm-style link + source entries', (t) => {
|
|
67
|
+
const root = mkMonorepo()
|
|
68
|
+
t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
69
|
+
|
|
70
|
+
exec(`node ${bin} lock`, { cwd: root }, (err) => {
|
|
71
|
+
t.error(err, 'workspace lock ran without error')
|
|
72
|
+
const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8'))
|
|
73
|
+
const pkgs = lock.packages
|
|
74
|
+
|
|
75
|
+
t.same(pkgs[''].workspaces, ['packages/*'], 'root keeps the workspaces field')
|
|
76
|
+
|
|
77
|
+
// each workspace: a source entry + a link entry in node_modules
|
|
78
|
+
t.equal(pkgs['packages/a'].name, '@scope/a', 'workspace source entry at its real path')
|
|
79
|
+
t.same(pkgs['node_modules/@scope/a'], { resolved: 'packages/a', link: true }, 'scoped workspace linked to its source')
|
|
80
|
+
t.same(pkgs['node_modules/pkg-b'], { resolved: 'packages/b', link: true }, 'workspace linked to its source')
|
|
81
|
+
|
|
82
|
+
// shared dep hoisted, conflict nested — reached through the workspace links
|
|
83
|
+
t.match(pkgs['node_modules/is-number'].version, /^7\./, 'is-number@7 hoisted')
|
|
84
|
+
t.match(pkgs['node_modules/is-odd/node_modules/is-number'].version, /^6\./, 'is-number@6 nested')
|
|
85
|
+
|
|
86
|
+
// deps reached only via a workspace are production, not dev/optional
|
|
87
|
+
t.notOk(pkgs['node_modules/is-odd'].dev, 'transitive dep of a workspace is not marked dev')
|
|
88
|
+
t.end()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
tap.test('lock -w <workspace> narrows the lockfile to that workspace', (t) => {
|
|
93
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dep-ws-'))
|
|
94
|
+
const write = (rel, obj) => {
|
|
95
|
+
const file = path.join(root, rel)
|
|
96
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
97
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2))
|
|
98
|
+
}
|
|
99
|
+
write('package.json', { name: 'root', version: '1.0.0', private: true, workspaces: ['packages/*'], dependencies: { 'left-pad': '^1.3.0' } })
|
|
100
|
+
write('packages/a/package.json', { name: '@scope/a', version: '1.0.0', dependencies: { 'is-odd': '^3.0.0' } })
|
|
101
|
+
write('packages/b/package.json', { name: 'pkg-b', version: '1.0.0', dependencies: { 'is-number': '^7.0.0' } })
|
|
102
|
+
t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
103
|
+
|
|
104
|
+
exec(`node ${bin} lock -w @scope/a`, { cwd: root }, (err) => {
|
|
105
|
+
t.error(err, 'scoped lock ran without error')
|
|
106
|
+
const pkgs = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')).packages
|
|
107
|
+
|
|
108
|
+
t.ok(pkgs['node_modules/@scope/a'], 'targeted workspace is linked')
|
|
109
|
+
t.ok(pkgs['node_modules/is-odd'], "targeted workspace's dep is locked")
|
|
110
|
+
t.notOk(pkgs['node_modules/pkg-b'], 'other workspace is excluded')
|
|
111
|
+
t.notOk(pkgs['node_modules/left-pad'], 'root-only dep is excluded')
|
|
112
|
+
t.notOk(pkgs[''].dependencies, 'root entry declares no unresolved deps when scoped')
|
|
113
|
+
|
|
114
|
+
exec(`node ${bin} lock -w nope`, { cwd: root }, (err2) => {
|
|
115
|
+
t.ok(err2, 'unknown workspace exits non-zero')
|
|
116
|
+
t.end()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
tap.test('resolveWorkspace matches by name and by path', (t) => {
|
|
122
|
+
const root = mkMonorepo()
|
|
123
|
+
t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
124
|
+
const workspaces = findWorkspaces(root, ['packages/*'])
|
|
125
|
+
|
|
126
|
+
t.equal(resolveWorkspace(workspaces, root, '@scope/a').dir, path.join(root, 'packages', 'a'), 'resolves by package name')
|
|
127
|
+
t.equal(resolveWorkspace(workspaces, root, 'packages/b').name, 'pkg-b', 'resolves by relative path')
|
|
128
|
+
t.equal(resolveWorkspace(workspaces, root, 'nope'), undefined, 'unknown target is undefined')
|
|
129
|
+
t.end()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
tap.test('install <pkg> -w <workspace> adds dep to that workspace and hoists it', (t) => {
|
|
133
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dep-ws-'))
|
|
134
|
+
const write = (rel, obj) => {
|
|
135
|
+
const file = path.join(root, rel)
|
|
136
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
137
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2))
|
|
138
|
+
}
|
|
139
|
+
write('package.json', { name: 'root', version: '1.0.0', private: true, workspaces: ['packages/*'] })
|
|
140
|
+
write('packages/a/package.json', { name: '@scope/a', version: '1.0.0' })
|
|
141
|
+
write('packages/b/package.json', { name: 'pkg-b', version: '1.0.0' })
|
|
142
|
+
t.teardown(() => fs.rmSync(root, { recursive: true, force: true }))
|
|
143
|
+
|
|
144
|
+
// by name, default save target is dependencies
|
|
145
|
+
exec(`node ${bin} install is-odd@^3.0.0 -w @scope/a`, { cwd: root }, (err) => {
|
|
146
|
+
t.error(err, 'install -w (by name) ran without error')
|
|
147
|
+
const a = JSON.parse(fs.readFileSync(path.join(root, 'packages', 'a', 'package.json')))
|
|
148
|
+
t.equal(a.dependencies && a.dependencies['is-odd'], '^3.0.0', 'dep written to the target workspace')
|
|
149
|
+
t.ok(fs.existsSync(path.join(root, 'node_modules', 'is-odd', 'package.json')), 'dep hoisted into root node_modules')
|
|
150
|
+
t.notOk(fs.existsSync(path.join(root, 'packages', 'b', 'package.json')) &&
|
|
151
|
+
JSON.parse(fs.readFileSync(path.join(root, 'packages', 'b', 'package.json'))).dependencies,
|
|
152
|
+
'other workspace untouched')
|
|
153
|
+
|
|
154
|
+
// by path + --save-dev
|
|
155
|
+
exec(`node ${bin} install is-number@^7.0.0 -w packages/b --save-dev`, { cwd: root }, (err2) => {
|
|
156
|
+
t.error(err2, 'install -w (by path) ran without error')
|
|
157
|
+
const b = JSON.parse(fs.readFileSync(path.join(root, 'packages', 'b', 'package.json')))
|
|
158
|
+
t.equal(b.devDependencies && b.devDependencies['is-number'], '^7.0.0', '--save-dev writes to the workspace devDependencies')
|
|
159
|
+
|
|
160
|
+
// unknown workspace fails
|
|
161
|
+
exec(`node ${bin} install left-pad -w nope`, { cwd: root }, (err3) => {
|
|
162
|
+
t.ok(err3, 'unknown workspace exits non-zero')
|
|
163
|
+
t.end()
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
})
|