From 41099d3958d08f166313b7eb69b76458f8f9224c Mon Sep 17 00:00:00 2001 From: Ruy Adorno Date: Wed, 19 May 2021 17:57:39 -0400 Subject: [PATCH] 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: https://github.com/npm/statusboard/issues/300 PR-URL: https://github.com/npm/cli/pull/3265 Credit: @ruyadorno Close: #3265 Reviewed-by: @isaacs --- docs/content/commands/npm-explain.md | 22 +++ lib/explain.js | 23 +++- lib/utils/explain-dep.js | 38 +++++- .../test/lib/utils/explain-dep.js.test.cjs | 26 ++++ .../test/lib/utils/npm-usage.js.test.cjs | 2 +- test/lib/explain.js | 126 ++++++++++++++++++ test/lib/utils/explain-dep.js | 41 +++++- 7 files changed, 259 insertions(+), 19 deletions(-) diff --git a/docs/content/commands/npm-explain.md b/docs/content/commands/npm-explain.md index d4f828377a1ac..0e50d7ae43343 100644 --- a/docs/content/commands/npm-explain.md +++ b/docs/content/commands/npm-explain.md @@ -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. + ### See Also diff --git a/lib/explain.js b/lib/explain.js index a34c59a04ac09..de04c69857240 100644 --- a/lib/explain.js +++ b/lib/explain.js @@ -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' } @@ -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 */ @@ -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(', ')}` @@ -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(/\/+$/, '') diff --git a/lib/utils/explain-dep.js b/lib/utils/explain-dep.js index c01bc780bfb47..944b4be62bacf 100644 --- a/lib/utils/explain-dep.js +++ b/lib/utils/explain-dep.js @@ -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) } @@ -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))) @@ -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 '' @@ -88,10 +107,14 @@ 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) => { @@ -99,7 +122,8 @@ const explainFrom = (from, depth, color) => { 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 } diff --git a/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs b/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs index 7e77081f9d636..4d6f4686df8c4 100644 --- a/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs +++ b/tap-snapshots/test/lib/utils/explain-dep.js.test.cjs @@ -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 +` diff --git a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs index 661deec269177..585fc7e8b7ef4 100644 --- a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs +++ b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs @@ -430,7 +430,7 @@ All commands: npm explain Options: - [--json] + [--json] [-w|--workspace [-w|--workspace ...]] alias: why diff --git a/test/lib/explain.js b/test/lib/explain.js index 7e4ec8bd37bff..f690aeb2c7b02 100644 --- a/test/lib/explain.js +++ b/test/lib/explain.js @@ -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() + }) + }) +}) diff --git a/test/lib/utils/explain-dep.js b/test/lib/utils/explain-dep.js index cab4d7b59a3e6..000f5b8165a9b 100644 --- a/test/lib/utils/explain-dep.js +++ b/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: { @@ -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')