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.
@@ -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 Save to dependencies (--save=dev for devDependencies)',
46
- ' --save-dev Save to devDependencies',
47
- ' --only=prod|dev Install only prod or dev dependencies',
48
- ' -h, --help Show help',
49
- ' -v, --version Show version information',
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
  }
@@ -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
 
@@ -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(process.cwd(), 'package.json')))
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(process.cwd(), 'package.json'),
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
- const deps = Object.assign({}, optionalDependencies)
36
- allDependencies[only].forEach((key) => {
37
- if (!pkgJSON[key]) return
38
- Object.assign(deps, pkgJSON[key])
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
- if (save) saver(pkgs, save)
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
@@ -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
- const entry = packages[location]
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, location, name)
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
- const inProd = reachable(packages, Object.keys(pkgJSON.dependencies || {}))
91
- const inDev = reachable(packages, Object.keys(pkgJSON.devDependencies || {}))
92
- const inOptional = reachable(packages, Object.keys(pkgJSON.optionalDependencies || {}))
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
- const deps = Object.assign(
12
- {},
13
- pkgJSON.optionalDependencies || {},
14
- pkgJSON.devDependencies || {},
15
- pkgJSON.dependencies || {}
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(pkgJSON, global.dependenciesTree)
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 depth-first walk over the cached
13
- // metadata, so the output is identical on every run regardless of network
14
- // timing.
15
-
16
- const getter = (list, keys) => {
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 deterministically ---
93
- const place = (dep, list, base, path) => {
94
- const range = list[dep]
95
- if (tree[dep] && tree[dep].version && semver.satisfies(tree[dep].version, range)) {
96
- return
97
- }
98
- for (let i = 0; i < base.length; i += 2) {
99
- const target = getter(tree, base.slice(0, i))
100
- if (target && target[dep] && target[dep].version &&
101
- semver.satisfies(target[dep].version, range)) {
102
- return
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
- const meta = metas.get(metaKey(dep, range))
107
- if (!meta || !meta.version) return
108
- const id = `${dep}@${meta.version}`
109
- if (path.has(id)) return // guard against dependency cycles
110
-
111
- const item = Object.assign({}, meta)
112
- if (keepRequires) item.requires = meta.dependencies
113
- delete item.dependencies
114
- if (!tree[dep]) tree[dep] = item
115
- else setter(tree, dep, item, base)
116
-
117
- if (!meta.dependencies) return
118
- const newBase = base.length === 0
119
- ? [id]
120
- : [...base, 'dependencies', id]
121
- path.add(id)
122
- for (const child of Object.keys(meta.dependencies)) {
123
- place(child, meta.dependencies, newBase, path)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dep",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A little Node.js dependency installer",
5
5
  "type": "module",
6
6
  "main": "lib/install.js",
@@ -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
+ })