Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explain): add workspaces support #3265

Merged
merged 1 commit into from
May 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/content/commands/npm-explain.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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