Skip to content

Commit 41099d3

Browse files
ruyadornoisaacs
authored andcommitted
feat(explain): add workspaces support
- Add highlight style for workspaces items in human output - Add ability to filter results by workspace using `-w` config - Added tests and docs Fixes: npm/statusboard#300 PR-URL: #3265 Credit: @ruyadorno Close: #3265 Reviewed-by: @isaacs
1 parent ec256a1 commit 41099d3

File tree

7 files changed

+259
-19
lines changed

7 files changed

+259
-19
lines changed

docs/content/commands/npm-explain.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ Whether or not to output JSON data, rather than the normal output.
6565

6666
Not supported by all npm commands.
6767

68+
#### `workspace`
69+
70+
* Default:
71+
* Type: String (can be set multiple times)
72+
73+
Enable running a command in the context of the configured workspaces of the
74+
current project while filtering by running only the workspaces defined by
75+
this configuration option.
76+
77+
Valid values for the `workspace` config are either:
78+
79+
* Workspace names
80+
* Path to a workspace directory
81+
* Path to a parent workspace directory (will result to selecting all of the
82+
nested workspaces)
83+
84+
When set for the `npm init` command, this may be set to the folder of a
85+
workspace which does not yet exist, to create the folder and set it up as a
86+
brand new workspace within the project.
87+
88+
This value is not exported to the environment for child processes.
89+
6890
<!-- AUTOGENERATED CONFIG DESCRIPTIONS END -->
6991

7092
### See Also

lib/explain.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ const npa = require('npm-package-arg')
55
const semver = require('semver')
66
const { relative, resolve } = require('path')
77
const validName = require('validate-npm-package-name')
8-
const BaseCommand = require('./base-command.js')
8+
const ArboristWorkspaceCmd = require('./workspaces/arborist-cmd.js')
99

10-
class Explain extends BaseCommand {
10+
class Explain extends ArboristWorkspaceCmd {
1111
static get description () {
1212
return 'Explain installed packages'
1313
}
@@ -24,7 +24,10 @@ class Explain extends BaseCommand {
2424

2525
/* istanbul ignore next - see test/lib/load-all-commands.js */
2626
static get params () {
27-
return ['json']
27+
return [
28+
'json',
29+
'workspace',
30+
]
2831
}
2932

3033
/* istanbul ignore next - see test/lib/load-all-commands.js */
@@ -43,10 +46,18 @@ class Explain extends BaseCommand {
4346
const arb = new Arborist({ path: this.npm.prefix, ...this.npm.flatOptions })
4447
const tree = await arb.loadActual()
4548

49+
if (this.workspaces && this.workspaces.length)
50+
this.filterSet = arb.workspaceDependencySet(tree, this.workspaces)
51+
4652
const nodes = new Set()
4753
for (const arg of args) {
48-
for (const node of this.getNodes(tree, arg))
49-
nodes.add(node)
54+
for (const node of this.getNodes(tree, arg)) {
55+
const filteredOut = this.filterSet
56+
&& this.filterSet.size > 0
57+
&& !this.filterSet.has(node)
58+
if (!filteredOut)
59+
nodes.add(node)
60+
}
5061
}
5162
if (nodes.size === 0)
5263
throw `No dependencies found matching ${args.join(', ')}`
@@ -80,7 +91,7 @@ class Explain extends BaseCommand {
8091
// if it's just a name, return packages by that name
8192
const { validForOldPackages: valid } = validName(arg)
8293
if (valid)
83-
return tree.inventory.query('name', arg)
94+
return tree.inventory.query('packageName', arg)
8495

8596
// if it's a location, get that node
8697
const maybeLoc = arg.replace(/\\/g, '/').replace(/\/+$/, '')

lib/utils/explain-dep.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,24 @@ const nocolor = {
77
cyan: s => s,
88
magenta: s => s,
99
blue: s => s,
10+
green: s => s,
1011
}
1112

13+
const { relative } = require('path')
14+
1215
const explainNode = (node, depth, color) =>
1316
printNode(node, color) +
14-
explainDependents(node, depth, color)
17+
explainDependents(node, depth, color) +
18+
explainLinksIn(node, depth, color)
1519

1620
const colorType = (type, color) => {
17-
const { red, yellow, cyan, magenta, blue } = color ? chalk : nocolor
21+
const { red, yellow, cyan, magenta, blue, green } = color ? chalk : nocolor
1822
const style = type === 'extraneous' ? red
1923
: type === 'dev' ? yellow
2024
: type === 'optional' ? cyan
2125
: type === 'peer' ? magenta
2226
: type === 'bundled' ? blue
27+
: type === 'workspace' ? green
2328
: /* istanbul ignore next */ s => s
2429
return style(type)
2530
}
@@ -34,8 +39,9 @@ const printNode = (node, color) => {
3439
optional,
3540
peer,
3641
bundled,
42+
isWorkspace,
3743
} = node
38-
const { bold, dim } = color ? chalk : nocolor
44+
const { bold, dim, green } = color ? chalk : nocolor
3945
const extra = []
4046
if (extraneous)
4147
extra.push(' ' + bold(colorType('extraneous', color)))
@@ -52,10 +58,23 @@ const printNode = (node, color) => {
5258
if (bundled)
5359
extra.push(' ' + bold(colorType('bundled', color)))
5460

55-
return `${bold(name)}@${bold(version)}${extra.join('')}` +
61+
const pkgid = isWorkspace
62+
? green(`${name}@${version}`)
63+
: `${bold(name)}@${bold(version)}`
64+
65+
return `${pkgid}${extra.join('')}` +
5666
(location ? dim(`\n${location}`) : '')
5767
}
5868

69+
const explainLinksIn = ({ linksIn }, depth, color) => {
70+
if (!linksIn || !linksIn.length || depth <= 0)
71+
return ''
72+
73+
const messages = linksIn.map(link => explainNode(link, depth - 1, color))
74+
const str = '\n' + messages.join('\n')
75+
return str.split('\n').join('\n ')
76+
}
77+
5978
const explainDependents = ({ name, dependents }, depth, color) => {
6079
if (!dependents || !dependents.length || depth <= 0)
6180
return ''
@@ -88,18 +107,23 @@ const explainDependents = ({ name, dependents }, depth, color) => {
88107

89108
const explainEdge = ({ name, type, bundled, from, spec }, depth, color) => {
90109
const { bold } = color ? chalk : nocolor
110+
const dep = type === 'workspace'
111+
? bold(relative(from.location, spec.slice('file:'.length)))
112+
: `${bold(name)}@"${bold(spec)}"`
113+
const fromMsg = ` from ${explainFrom(from, depth, color)}`
114+
91115
return (type === 'prod' ? '' : `${colorType(type, color)} `) +
92116
(bundled ? `${colorType('bundled', color)} ` : '') +
93-
`${bold(name)}@"${bold(spec)}" from ` +
94-
explainFrom(from, depth, color)
117+
`${dep}${fromMsg}`
95118
}
96119

97120
const explainFrom = (from, depth, color) => {
98121
if (!from.name && !from.version)
99122
return 'the root project'
100123

101124
return printNode(from, color) +
102-
explainDependents(from, depth - 1, color)
125+
explainDependents(from, depth - 1, color) +
126+
explainLinksIn(from, depth - 1, color)
103127
}
104128

105129
module.exports = { explainNode, printNode, explainEdge }

tap-snapshots/test/lib/utils/explain-dep.js.test.cjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,29 @@ exports[`test/lib/utils/explain-dep.js TAP prodDep > print nocolor 1`] = `
199199
200200
node_modules/prod-dep
201201
`
202+
203+
exports[`test/lib/utils/explain-dep.js TAP workspaces > explain color deep 1`] = `
204+
[[email protected]
205+
a
206+
[[email protected]
207+
node_modules/a
208+
workspace a from the root project
209+
`
210+
211+
exports[`test/lib/utils/explain-dep.js TAP workspaces > explain nocolor shallow 1`] = `
212+
213+
a
214+
215+
node_modules/a
216+
workspace a from the root project
217+
`
218+
219+
exports[`test/lib/utils/explain-dep.js TAP workspaces > print color 1`] = `
220+
[[email protected]
221+
a
222+
`
223+
224+
exports[`test/lib/utils/explain-dep.js TAP workspaces > print nocolor 1`] = `
225+
226+
a
227+
`

tap-snapshots/test/lib/utils/npm-usage.js.test.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ All commands:
430430
npm explain <folder | specifier>
431431
432432
Options:
433-
[--json]
433+
[--json] [-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
434434
435435
alias: why
436436

test/lib/explain.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,129 @@ t.test('explain some nodes', t => {
175175
})
176176
t.end()
177177
})
178+
179+
t.test('workspaces', async t => {
180+
npm.localPrefix = npm.prefix = t.testdir({
181+
'package.json': JSON.stringify({
182+
name: 'workspaces-project',
183+
version: '1.0.0',
184+
workspaces: ['packages/*'],
185+
dependencies: {
186+
abbrev: '^1.0.0',
187+
},
188+
}),
189+
node_modules: {
190+
a: t.fixture('symlink', '../packages/a'),
191+
b: t.fixture('symlink', '../packages/b'),
192+
c: t.fixture('symlink', '../packages/c'),
193+
once: {
194+
'package.json': JSON.stringify({
195+
name: 'once',
196+
version: '1.0.0',
197+
dependencies: {
198+
wrappy: '2.0.0',
199+
},
200+
}),
201+
},
202+
abbrev: {
203+
'package.json': JSON.stringify({
204+
name: 'abbrev',
205+
version: '1.0.0',
206+
}),
207+
},
208+
wrappy: {
209+
'package.json': JSON.stringify({
210+
name: 'wrappy',
211+
version: '2.0.0',
212+
}),
213+
},
214+
},
215+
packages: {
216+
a: {
217+
'package.json': JSON.stringify({
218+
name: 'a',
219+
version: '1.0.0',
220+
dependencies: {
221+
once: '1.0.0',
222+
},
223+
}),
224+
},
225+
b: {
226+
'package.json': JSON.stringify({
227+
name: 'b',
228+
version: '1.0.0',
229+
dependencies: {
230+
abbrev: '^1.0.0',
231+
},
232+
}),
233+
},
234+
c: {
235+
'package.json': JSON.stringify({
236+
name: 'c',
237+
version: '1.0.0',
238+
}),
239+
},
240+
},
241+
})
242+
243+
await new Promise((res, rej) => {
244+
explain.exec(['wrappy'], err => {
245+
if (err)
246+
rej(err)
247+
248+
t.strictSame(
249+
OUTPUT,
250+
[['[email protected] depth=Infinity color=true']],
251+
'should explain workspaces deps'
252+
)
253+
OUTPUT.length = 0
254+
res()
255+
})
256+
})
257+
258+
await new Promise((res, rej) => {
259+
explain.execWorkspaces(['wrappy'], ['a'], err => {
260+
if (err)
261+
rej(err)
262+
263+
t.strictSame(
264+
OUTPUT,
265+
[
266+
['[email protected] depth=Infinity color=true'],
267+
],
268+
'should explain deps when filtering to a single ws'
269+
)
270+
OUTPUT.length = 0
271+
res()
272+
})
273+
})
274+
275+
await new Promise((res, rej) => {
276+
explain.execWorkspaces(['abbrev'], [], err => {
277+
if (err)
278+
rej(err)
279+
280+
t.strictSame(
281+
OUTPUT,
282+
[
283+
['[email protected] depth=Infinity color=true'],
284+
],
285+
'should explain deps of workspaces only'
286+
)
287+
OUTPUT.length = 0
288+
res()
289+
})
290+
})
291+
292+
await new Promise((res, rej) => {
293+
explain.execWorkspaces(['abbrev'], ['a'], err => {
294+
t.equal(
295+
err,
296+
'No dependencies found matching abbrev',
297+
'should throw usage if dep not found within filtered ws'
298+
)
299+
300+
res()
301+
})
302+
})
303+
})

test/lib/utils/explain-dep.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
const { resolve } = require('path')
12
const t = require('tap')
2-
const npm = {}
3-
const { explainNode, printNode } = t.mock('../../../lib/utils/explain-dep.js', {
4-
'../../../lib/npm.js': npm,
5-
})
3+
const { explainNode, printNode } = require('../../../lib/utils/explain-dep.js')
4+
const testdir = t.testdirName
5+
6+
const redactCwd = (path) => {
7+
const normalizePath = p => p
8+
.replace(/\\+/g, '/')
9+
.replace(/\r\n/g, '\n')
10+
return normalizePath(path)
11+
.replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}')
12+
}
13+
t.cleanSnapshot = (str) => redactCwd(str)
614

715
const cases = {
816
prodDep: {
@@ -204,9 +212,32 @@ cases.manyDeps = {
204212
],
205213
}
206214

215+
cases.workspaces = {
216+
name: 'a',
217+
version: '1.0.0',
218+
location: 'a',
219+
isWorkspace: true,
220+
dependents: [],
221+
linksIn: [
222+
{
223+
name: 'a',
224+
version: '1.0.0',
225+
location: 'node_modules/a',
226+
isWorkspace: true,
227+
dependents: [
228+
{
229+
type: 'workspace',
230+
name: 'a',
231+
spec: `file:${resolve(testdir, 'ws-project', 'a')}`,
232+
from: { location: resolve(testdir, 'ws-project') },
233+
},
234+
],
235+
},
236+
],
237+
}
238+
207239
for (const [name, expl] of Object.entries(cases)) {
208240
t.test(name, t => {
209-
npm.color = true
210241
t.matchSnapshot(printNode(expl, true), 'print color')
211242
t.matchSnapshot(printNode(expl, false), 'print nocolor')
212243
t.matchSnapshot(explainNode(expl, Infinity, true), 'explain color deep')

0 commit comments

Comments
 (0)