Skip to content

Add 'npm explain' command #1776

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

Closed
wants to merge 2 commits into from
Closed
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
77 changes: 77 additions & 0 deletions docs/content/cli-commands/npm-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
section: cli-commands
title: npm-explain
description: Explain installed packages
---

# npm-explain(1)

## Explain installed packages

### Synopsis

```bash
npm explain <folder | specifier>
```

### Description

This command will print the chain of dependencies causing a given package
to be installed in the current project.

Positional arguments can be either folders within `node_modules`, or
`name@version-range` specifiers, which will select the dependency
relationships to explain.

For example, running `npm explain glob` within npm's source tree will show:

```bash
[email protected]
node_modules/glob
glob@"^7.1.4" from the root project

[email protected] dev
node_modules/tacks/node_modules/glob
glob@"^7.0.5" from [email protected]
node_modules/tacks/node_modules/rimraf
rimraf@"^2.6.2" from [email protected]
node_modules/tacks
dev tacks@"^1.3.0" from the root project
```

To explain just the package residing at a specific folder, pass that as the
argument to the command. This can be useful when trying to figure out
exactly why a given dependency is being duplicated to satisfy conflicting
version requirements within the project.

```bash
$ npm explain node_modules/nyc/node_modules/find-up
[email protected] dev
node_modules/nyc/node_modules/find-up
find-up@"^3.0.0" from [email protected]
node_modules/nyc
nyc@"^14.1.1" from [email protected]
node_modules/tap
dev tap@"^14.10.8" from the root project
```

### Configuration

#### json

* Default: false
* Type: Bolean

Show information in JSON format.

### See Also

* [npm config](/cli-commands/config)
* [npmrc](/configuring-npm/npmrc)
* [npm folders](/configuring-npm/folders)
* [npm ls](/cli-commands/ls)
* [npm install](/cli-commands/install)
* [npm link](/cli-commands/link)
* [npm prune](/cli-commands/prune)
* [npm outdated](/cli-commands/outdated)
* [npm update](/cli-commands/update)
3 changes: 2 additions & 1 deletion docs/content/cli-commands/npm-ls.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
section: cli-commands
section: cli-commands
title: npm-ls
description: List installed packages
---
Expand Down Expand Up @@ -122,6 +122,7 @@ Set it to false in order to use all-ansi output.
* [npm config](/cli-commands/config)
* [npmrc](/configuring-npm/npmrc)
* [npm folders](/configuring-npm/folders)
* [npm explain](/cli-commands/explain)
* [npm install](/cli-commands/install)
* [npm link](/cli-commands/link)
* [npm prune](/cli-commands/prune)
Expand Down
100 changes: 100 additions & 0 deletions lib/explain.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const usageUtil = require('./utils/usage.js')
const npm = require('./npm.js')
const { explainNode } = require('./utils/explain-dep.js')
const completion = require('./utils/completion/installed-deep.js')
const output = require('./utils/output.js')
const Arborist = require('@npmcli/arborist')
const npa = require('npm-package-arg')
const semver = require('semver')
const { relative, resolve } = require('path')
const validName = require('validate-npm-package-name')

const usage = usageUtil('explain', 'npm explain <folder | specifier>')

const cmd = (args, cb) => explain(args).then(() => cb()).catch(cb)

const explain = async (args) => {
if (!args.length) {
throw usage
}

const arb = new Arborist({ path: npm.prefix, ...npm.flatOptions })
const tree = await arb.loadActual()

const nodes = new Set()
for (const arg of args) {
for (const node of getNodes(tree, arg)) {
nodes.add(node)
}
}
if (nodes.size === 0) {
throw `No dependencies found matching ${args.join(', ')}`
}

const expls = []
for (const node of nodes) {
const { extraneous, dev, optional, devOptional, peer } = node
const expl = node.explain()
if (extraneous) {
expl.extraneous = true
} else {
expl.dev = dev
expl.optional = optional
expl.devOptional = devOptional
expl.peer = peer
}
expls.push(expl)
}

if (npm.flatOptions.json) {
output(JSON.stringify(expls, null, 2))
} else {
output(expls.map(expl => {
return explainNode(expl, Infinity, npm.color)
}).join('\n\n'))
}
}

const getNodes = (tree, arg) => {
// if it's just a name, return packages by that name
const { validForOldPackages: valid } = validName(arg)
if (valid) {
return tree.inventory.query('name', arg)
}

// if it's a location, get that node
const maybeLoc = arg.replace(/\\/g, '/').replace(/\/+$/, '')
const nodeByLoc = tree.inventory.get(maybeLoc)
if (nodeByLoc) {
return [nodeByLoc]
}

// maybe a path to a node_modules folder
const maybePath = relative(npm.prefix, resolve(maybeLoc))
.replace(/\\/g, '/').replace(/\/+$/, '')
const nodeByPath = tree.inventory.get(maybePath)
if (nodeByPath) {
return [nodeByPath]
}

// otherwise, try to select all matching nodes
try {
return getNodesByVersion(tree, arg)
} catch (er) {
return []
}
}

const getNodesByVersion = (tree, arg) => {
const spec = npa(arg, npm.prefix)
if (spec.type !== 'version' && spec.type !== 'range') {
return []
}

return tree.inventory.filter(node => {
return node.package.name === spec.name &&
semver.satisfies(node.package.version, spec.rawSpec)
})
}

module.exports = Object.assign(cmd, { usage, completion })
6 changes: 4 additions & 2 deletions lib/utils/cmd-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const shorthands = {
run: 'run-script',
'clean-install': 'ci',
'clean-install-test': 'cit',
x: 'exec'
x: 'exec',
why: 'explain'
}

const affordances = {
Expand Down Expand Up @@ -128,7 +129,8 @@ const cmdList = [
'run-script',
'completion',
'doctor',
'exec'
'exec',
'explain'
]

const plumbing = ['birthday', 'help-search']
Expand Down
101 changes: 101 additions & 0 deletions lib/utils/explain-dep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const chalk = require('chalk')
const nocolor = {
bold: s => s,
dim: s => s,
red: s => s,
yellow: s => s,
cyan: s => s,
magenta: s => s
}

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

const colorType = (type, color) => {
const { red, yellow, cyan, magenta } = color ? chalk : nocolor
const style = type === 'extraneous' ? red
: type === 'dev' ? yellow
: type === 'optional' ? cyan
: type === 'peer' ? magenta
: /* istanbul ignore next */ s => s
return style(type)
}

const printNode = (node, color) => {
const {
name,
version,
location,
extraneous,
dev,
optional,
peer
} = node
const { bold, dim } = color ? chalk : nocolor
const extra = []
if (extraneous) {
extra.push(' ' + bold(colorType('extraneous', color)))
}
if (dev) {
extra.push(' ' + bold(colorType('dev', color)))
}
if (optional) {
extra.push(' ' + bold(colorType('optional', color)))
}
if (peer) {
extra.push(' ' + bold(colorType('peer', color)))
}

return `${bold(name)}@${bold(version)}${extra.join('')}` +
(location ? dim(`\n${location}`) : '')
}

const explainDependents = ({ name, dependents }, depth, color) => {
if (!dependents || !dependents.length || depth <= 0) {
return ''
}

const max = Math.ceil(depth / 2)
const messages = dependents.slice(0, max)
.map(dep => explainDependency(name, dep, depth, color))

// show just the names of the first 5 deps that overflowed the list
if (dependents.length > max) {
let len = 0
const maxLen = 50
const showNames = []
for (let i = max; i < dependents.length; i++) {
const { from: { name } } = dependents[i]
len += name.length
if (len >= maxLen && i < dependents.length - 1) {
showNames.push('...')
break
}
showNames.push(name)
}
const show = `(${showNames.join(', ')})`
messages.push(`${dependents.length - max} more ${show}`)
}

const str = '\n' + messages.join('\n')
return str.split('\n').join('\n ')
}

const explainDependency = (name, { type, from, spec }, depth, color) => {
const { bold } = color ? chalk : nocolor
return (type === 'prod' ? '' : `${colorType(type, color)} `) +
`${bold(name)}@"${bold(spec)}" from ` +
explainFrom(from, depth, color)
}

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

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

module.exports = { explainNode, printNode }
Loading