Skip to content

Commit

Permalink
feat(explain): add workspaces support
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
ruyadorno authored and isaacs committed May 20, 2021
1 parent ec256a1 commit 41099d3
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 19 deletions.
22 changes: 22 additions & 0 deletions docs/content/commands/npm-explain.md
Expand Up @@ -65,6 +65,28 @@ Whether or not to output JSON data, rather than the normal output.

Not supported by all npm commands.

#### `workspace`

* Default:
* Type: String (can be set multiple times)

Enable running a command in the context of the configured workspaces of the
current project while filtering by running only the workspaces defined by
this configuration option.

Valid values for the `workspace` config are either:

* Workspace names
* Path to a workspace directory
* Path to a parent workspace directory (will result to selecting all of the
nested workspaces)

When set for the `npm init` command, this may be set to the folder of a
workspace which does not yet exist, to create the folder and set it up as a
brand new workspace within the project.

This value is not exported to the environment for child processes.

<!-- AUTOGENERATED CONFIG DESCRIPTIONS END -->

### See Also
Expand Down
23 changes: 17 additions & 6 deletions lib/explain.js
Expand Up @@ -5,9 +5,9 @@ const npa = require('npm-package-arg')
const semver = require('semver')
const { relative, resolve } = require('path')
const validName = require('validate-npm-package-name')
const BaseCommand = require('./base-command.js')
const ArboristWorkspaceCmd = require('./workspaces/arborist-cmd.js')

class Explain extends BaseCommand {
class Explain extends ArboristWorkspaceCmd {
static get description () {
return 'Explain installed packages'
}
Expand All @@ -24,7 +24,10 @@ class Explain extends BaseCommand {

/* istanbul ignore next - see test/lib/load-all-commands.js */
static get params () {
return ['json']
return [
'json',
'workspace',
]
}

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

if (this.workspaces && this.workspaces.length)
this.filterSet = arb.workspaceDependencySet(tree, this.workspaces)

const nodes = new Set()
for (const arg of args) {
for (const node of this.getNodes(tree, arg))
nodes.add(node)
for (const node of this.getNodes(tree, arg)) {
const filteredOut = this.filterSet
&& this.filterSet.size > 0
&& !this.filterSet.has(node)
if (!filteredOut)
nodes.add(node)
}
}
if (nodes.size === 0)
throw `No dependencies found matching ${args.join(', ')}`
Expand Down Expand Up @@ -80,7 +91,7 @@ class Explain extends BaseCommand {
// if it's just a name, return packages by that name
const { validForOldPackages: valid } = validName(arg)
if (valid)
return tree.inventory.query('name', arg)
return tree.inventory.query('packageName', arg)

// if it's a location, get that node
const maybeLoc = arg.replace(/\\/g, '/').replace(/\/+$/, '')
Expand Down
38 changes: 31 additions & 7 deletions lib/utils/explain-dep.js
Expand Up @@ -7,19 +7,24 @@ const nocolor = {
cyan: s => s,
magenta: s => s,
blue: s => s,
green: s => s,
}

const { relative } = require('path')

const explainNode = (node, depth, color) =>
printNode(node, color) +
explainDependents(node, depth, color)
explainDependents(node, depth, color) +
explainLinksIn(node, depth, color)

const colorType = (type, color) => {
const { red, yellow, cyan, magenta, blue } = color ? chalk : nocolor
const { red, yellow, cyan, magenta, blue, green } = color ? chalk : nocolor
const style = type === 'extraneous' ? red
: type === 'dev' ? yellow
: type === 'optional' ? cyan
: type === 'peer' ? magenta
: type === 'bundled' ? blue
: type === 'workspace' ? green
: /* istanbul ignore next */ s => s
return style(type)
}
Expand All @@ -34,8 +39,9 @@ const printNode = (node, color) => {
optional,
peer,
bundled,
isWorkspace,
} = node
const { bold, dim } = color ? chalk : nocolor
const { bold, dim, green } = color ? chalk : nocolor
const extra = []
if (extraneous)
extra.push(' ' + bold(colorType('extraneous', color)))
Expand All @@ -52,10 +58,23 @@ const printNode = (node, color) => {
if (bundled)
extra.push(' ' + bold(colorType('bundled', color)))

return `${bold(name)}@${bold(version)}${extra.join('')}` +
const pkgid = isWorkspace
? green(`${name}@${version}`)
: `${bold(name)}@${bold(version)}`

return `${pkgid}${extra.join('')}` +
(location ? dim(`\n${location}`) : '')
}

const explainLinksIn = ({ linksIn }, depth, color) => {
if (!linksIn || !linksIn.length || depth <= 0)
return ''

const messages = linksIn.map(link => explainNode(link, depth - 1, color))
const str = '\n' + messages.join('\n')
return str.split('\n').join('\n ')
}

const explainDependents = ({ name, dependents }, depth, color) => {
if (!dependents || !dependents.length || depth <= 0)
return ''
Expand Down Expand Up @@ -88,18 +107,23 @@ const explainDependents = ({ name, dependents }, depth, color) => {

const explainEdge = ({ name, type, bundled, from, spec }, depth, color) => {
const { bold } = color ? chalk : nocolor
const dep = type === 'workspace'
? bold(relative(from.location, spec.slice('file:'.length)))
: `${bold(name)}@"${bold(spec)}"`
const fromMsg = ` from ${explainFrom(from, depth, color)}`

return (type === 'prod' ? '' : `${colorType(type, color)} `) +
(bundled ? `${colorType('bundled', color)} ` : '') +
`${bold(name)}@"${bold(spec)}" from ` +
explainFrom(from, depth, color)
`${dep}${fromMsg}`
}

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

return printNode(from, color) +
explainDependents(from, depth - 1, color)
explainDependents(from, depth - 1, color) +
explainLinksIn(from, depth - 1, color)
}

module.exports = { explainNode, printNode, explainEdge }
26 changes: 26 additions & 0 deletions tap-snapshots/test/lib/utils/explain-dep.js.test.cjs
Expand Up @@ -199,3 +199,29 @@ exports[`test/lib/utils/explain-dep.js TAP prodDep > print nocolor 1`] = `
prod-dep@1.2.3
node_modules/prod-dep
`

exports[`test/lib/utils/explain-dep.js TAP workspaces > explain color deep 1`] = `
a@1.0.0
a
a@1.0.0
node_modules/a
workspace a from the root project
`

exports[`test/lib/utils/explain-dep.js TAP workspaces > explain nocolor shallow 1`] = `
a@1.0.0
a
a@1.0.0
node_modules/a
workspace a from the root project
`

exports[`test/lib/utils/explain-dep.js TAP workspaces > print color 1`] = `
a@1.0.0
a
`

exports[`test/lib/utils/explain-dep.js TAP workspaces > print nocolor 1`] = `
a@1.0.0
a
`
2 changes: 1 addition & 1 deletion tap-snapshots/test/lib/utils/npm-usage.js.test.cjs
Expand Up @@ -430,7 +430,7 @@ All commands:
npm explain <folder | specifier>
Options:
[--json]
[--json] [-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
alias: why
Expand Down
126 changes: 126 additions & 0 deletions test/lib/explain.js
Expand Up @@ -175,3 +175,129 @@ t.test('explain some nodes', t => {
})
t.end()
})

t.test('workspaces', async t => {
npm.localPrefix = npm.prefix = t.testdir({
'package.json': JSON.stringify({
name: 'workspaces-project',
version: '1.0.0',
workspaces: ['packages/*'],
dependencies: {
abbrev: '^1.0.0',
},
}),
node_modules: {
a: t.fixture('symlink', '../packages/a'),
b: t.fixture('symlink', '../packages/b'),
c: t.fixture('symlink', '../packages/c'),
once: {
'package.json': JSON.stringify({
name: 'once',
version: '1.0.0',
dependencies: {
wrappy: '2.0.0',
},
}),
},
abbrev: {
'package.json': JSON.stringify({
name: 'abbrev',
version: '1.0.0',
}),
},
wrappy: {
'package.json': JSON.stringify({
name: 'wrappy',
version: '2.0.0',
}),
},
},
packages: {
a: {
'package.json': JSON.stringify({
name: 'a',
version: '1.0.0',
dependencies: {
once: '1.0.0',
},
}),
},
b: {
'package.json': JSON.stringify({
name: 'b',
version: '1.0.0',
dependencies: {
abbrev: '^1.0.0',
},
}),
},
c: {
'package.json': JSON.stringify({
name: 'c',
version: '1.0.0',
}),
},
},
})

await new Promise((res, rej) => {
explain.exec(['wrappy'], err => {
if (err)
rej(err)

t.strictSame(
OUTPUT,
[['wrappy@2.0.0 depth=Infinity color=true']],
'should explain workspaces deps'
)
OUTPUT.length = 0
res()
})
})

await new Promise((res, rej) => {
explain.execWorkspaces(['wrappy'], ['a'], err => {
if (err)
rej(err)

t.strictSame(
OUTPUT,
[
['wrappy@2.0.0 depth=Infinity color=true'],
],
'should explain deps when filtering to a single ws'
)
OUTPUT.length = 0
res()
})
})

await new Promise((res, rej) => {
explain.execWorkspaces(['abbrev'], [], err => {
if (err)
rej(err)

t.strictSame(
OUTPUT,
[
['abbrev@1.0.0 depth=Infinity color=true'],
],
'should explain deps of workspaces only'
)
OUTPUT.length = 0
res()
})
})

await new Promise((res, rej) => {
explain.execWorkspaces(['abbrev'], ['a'], err => {
t.equal(
err,
'No dependencies found matching abbrev',
'should throw usage if dep not found within filtered ws'
)

res()
})
})
})
41 changes: 36 additions & 5 deletions test/lib/utils/explain-dep.js
@@ -1,8 +1,16 @@
const { resolve } = require('path')
const t = require('tap')
const npm = {}
const { explainNode, printNode } = t.mock('../../../lib/utils/explain-dep.js', {
'../../../lib/npm.js': npm,
})
const { explainNode, printNode } = require('../../../lib/utils/explain-dep.js')
const testdir = t.testdirName

const redactCwd = (path) => {
const normalizePath = p => p
.replace(/\\+/g, '/')
.replace(/\r\n/g, '\n')
return normalizePath(path)
.replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}')
}
t.cleanSnapshot = (str) => redactCwd(str)

const cases = {
prodDep: {
Expand Down Expand Up @@ -204,9 +212,32 @@ cases.manyDeps = {
],
}

cases.workspaces = {
name: 'a',
version: '1.0.0',
location: 'a',
isWorkspace: true,
dependents: [],
linksIn: [
{
name: 'a',
version: '1.0.0',
location: 'node_modules/a',
isWorkspace: true,
dependents: [
{
type: 'workspace',
name: 'a',
spec: `file:${resolve(testdir, 'ws-project', 'a')}`,
from: { location: resolve(testdir, 'ws-project') },
},
],
},
],
}

for (const [name, expl] of Object.entries(cases)) {
t.test(name, t => {
npm.color = true
t.matchSnapshot(printNode(expl, true), 'print color')
t.matchSnapshot(printNode(expl, false), 'print nocolor')
t.matchSnapshot(explainNode(expl, Infinity, true), 'explain color deep')
Expand Down

0 comments on commit 41099d3

Please sign in to comment.