• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const { resolve, relative, sep } = require('path')
2const relativePrefix = `.${sep}`
3const { EOL } = require('os')
4
5const archy = require('archy')
6const { breadth } = require('treeverse')
7const npa = require('npm-package-arg')
8
9const _depth = Symbol('depth')
10const _dedupe = Symbol('dedupe')
11const _filteredBy = Symbol('filteredBy')
12const _include = Symbol('include')
13const _invalid = Symbol('invalid')
14const _name = Symbol('name')
15const _missing = Symbol('missing')
16const _parent = Symbol('parent')
17const _problems = Symbol('problems')
18const _required = Symbol('required')
19const _type = Symbol('type')
20const ArboristWorkspaceCmd = require('../arborist-cmd.js')
21const localeCompare = require('@isaacs/string-locale-compare')('en')
22
23class LS extends ArboristWorkspaceCmd {
24  static description = 'List installed packages'
25  static name = 'ls'
26  static usage = ['<package-spec>']
27  static params = [
28    'all',
29    'json',
30    'long',
31    'parseable',
32    'global',
33    'depth',
34    'omit',
35    'include',
36    'link',
37    'package-lock-only',
38    'unicode',
39    ...super.params,
40  ]
41
42  // TODO
43  /* istanbul ignore next */
44  static async completion (opts, npm) {
45    const completion = require('../utils/completion/installed-deep.js')
46    return completion(npm, opts)
47  }
48
49  async exec (args) {
50    const all = this.npm.config.get('all')
51    const chalk = this.npm.chalk
52    const depth = this.npm.config.get('depth')
53    const global = this.npm.global
54    const json = this.npm.config.get('json')
55    const link = this.npm.config.get('link')
56    const long = this.npm.config.get('long')
57    const omit = this.npm.flatOptions.omit
58    const parseable = this.npm.config.get('parseable')
59    const unicode = this.npm.config.get('unicode')
60    const packageLockOnly = this.npm.config.get('package-lock-only')
61    const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
62
63    const path = global ? resolve(this.npm.globalDir, '..') : this.npm.prefix
64
65    const Arborist = require('@npmcli/arborist')
66
67    const arb = new Arborist({
68      global,
69      ...this.npm.flatOptions,
70      legacyPeerDeps: false,
71      path,
72    })
73    const tree = await this.initTree({ arb, args, packageLockOnly })
74
75    // filters by workspaces nodes when using -w <workspace-name>
76    // We only have to filter the first layer of edges, so we don't
77    // explore anything that isn't part of the selected workspace set.
78    let wsNodes
79    if (this.workspaceNames && this.workspaceNames.length) {
80      wsNodes = arb.workspaceNodes(tree, this.workspaceNames)
81    }
82    const filterBySelectedWorkspaces = edge => {
83      if (!workspacesEnabled
84        && edge.from.isProjectRoot
85        && edge.to.isWorkspace
86      ) {
87        return false
88      }
89
90      if (!wsNodes || !wsNodes.length) {
91        return true
92      }
93
94      if (this.npm.flatOptions.includeWorkspaceRoot
95          && edge.to && !edge.to.isWorkspace) {
96        return true
97      }
98
99      if (edge.from.isProjectRoot) {
100        return (edge.to
101          && edge.to.isWorkspace
102          && wsNodes.includes(edge.to.target))
103      }
104
105      return true
106    }
107
108    const seenItems = new Set()
109    const seenNodes = new Map()
110    const problems = new Set()
111
112    // defines special handling of printed depth when filtering with args
113    const filterDefaultDepth = depth === null ? Infinity : depth
114    const depthToPrint = (all || args.length)
115      ? filterDefaultDepth
116      : (depth || 0)
117
118    // add root node of tree to list of seenNodes
119    seenNodes.set(tree.path, tree)
120
121    // tree traversal happens here, using treeverse.breadth
122    const result = await breadth({
123      tree,
124      // recursive method, `node` is going to be the current elem (starting from
125      // the `tree` obj) that was just visited in the `visit` method below
126      // `nodeResult` is going to be the returned `item` from `visit`
127      getChildren (node, nodeResult) {
128        const seenPaths = new Set()
129        const workspace = node.isWorkspace
130        const currentDepth = workspace ? 0 : node[_depth]
131        const shouldSkipChildren =
132          !(node instanceof Arborist.Node) || (currentDepth > depthToPrint)
133        return (shouldSkipChildren)
134          ? []
135          : [...(node.target).edgesOut.values()]
136            .filter(filterBySelectedWorkspaces)
137            .filter(currentDepth === 0 ? filterByEdgesTypes({
138              link,
139              omit,
140            }) : () => true)
141            .map(mapEdgesToNodes({ seenPaths }))
142            .concat(appendExtraneousChildren({ node, seenPaths }))
143            .sort(sortAlphabetically)
144            .map(augmentNodesWithMetadata({
145              args,
146              currentDepth,
147              nodeResult,
148              seenNodes,
149            }))
150      },
151      // visit each `node` of the `tree`, returning an `item` - these are
152      // the elements that will be used to build the final output
153      visit (node) {
154        node[_problems] = getProblems(node, { global })
155
156        const item = json
157          ? getJsonOutputItem(node, { global, long })
158          : parseable
159            ? null
160            : getHumanOutputItem(node, { args, chalk, global, long })
161
162        // loop through list of node problems to add them to global list
163        if (node[_include]) {
164          for (const problem of node[_problems]) {
165            problems.add(problem)
166          }
167        }
168
169        seenItems.add(item)
170
171        // return a promise so we don't blow the stack
172        return Promise.resolve(item)
173      },
174    })
175
176    // handle the special case of a broken package.json in the root folder
177    const [rootError] = tree.errors.filter(e =>
178      e.code === 'EJSONPARSE' && e.path === resolve(path, 'package.json'))
179
180    this.npm.outputBuffer(
181      json ? jsonOutput({ path, problems, result, rootError, seenItems }) :
182      parseable ? parseableOutput({ seenNodes, global, long }) :
183      humanOutput({ chalk, result, seenItems, unicode })
184    )
185
186    // if filtering items, should exit with error code on no results
187    if (result && !result[_include] && args.length) {
188      process.exitCode = 1
189    }
190
191    if (rootError) {
192      throw Object.assign(
193        new Error('Failed to parse root package.json'),
194        { code: 'EJSONPARSE' }
195      )
196    }
197
198    const shouldThrow = problems.size &&
199      ![...problems].every(problem => problem.startsWith('extraneous:'))
200
201    if (shouldThrow) {
202      throw Object.assign(
203        new Error([...problems].join(EOL)),
204        { code: 'ELSPROBLEMS' }
205      )
206    }
207  }
208
209  async initTree ({ arb, args, packageLockOnly }) {
210    const tree = await (
211      packageLockOnly
212        ? arb.loadVirtual()
213        : arb.loadActual()
214    )
215
216    tree[_include] = args.length === 0
217    tree[_depth] = 0
218
219    return tree
220  }
221}
222module.exports = LS
223
224const isGitNode = (node) => {
225  if (!node.resolved) {
226    return
227  }
228
229  try {
230    const { type } = npa(node.resolved)
231    return type === 'git' || type === 'hosted'
232  } catch (err) {
233    return false
234  }
235}
236
237const isOptional = (node) =>
238  node[_type] === 'optional' || node[_type] === 'peerOptional'
239
240const isExtraneous = (node, { global }) =>
241  node.extraneous && !global
242
243const getProblems = (node, { global }) => {
244  const problems = new Set()
245
246  if (node[_missing] && !isOptional(node)) {
247    problems.add(`missing: ${node.pkgid}, required by ${node[_missing]}`)
248  }
249
250  if (node[_invalid]) {
251    problems.add(`invalid: ${node.pkgid} ${node.path}`)
252  }
253
254  if (isExtraneous(node, { global })) {
255    problems.add(`extraneous: ${node.pkgid} ${node.path}`)
256  }
257
258  return problems
259}
260
261// annotates _parent and _include metadata into the resulting
262// item obj allowing for filtering out results during output
263const augmentItemWithIncludeMetadata = (node, item) => {
264  item[_parent] = node[_parent]
265  item[_include] = node[_include]
266
267  // append current item to its parent.nodes which is the
268  // structure expected by archy in order to print tree
269  if (node[_include]) {
270    // includes all ancestors of included node
271    let p = node[_parent]
272    while (p) {
273      p[_include] = true
274      p = p[_parent]
275    }
276  }
277
278  return item
279}
280
281const getHumanOutputItem = (node, { args, chalk, global, long }) => {
282  const { pkgid, path } = node
283  const workspacePkgId = chalk.green(pkgid)
284  let printable = node.isWorkspace ? workspacePkgId : pkgid
285
286  // special formatting for top-level package name
287  if (node.isRoot) {
288    const hasNoPackageJson = !Object.keys(node.package).length
289    if (hasNoPackageJson || global) {
290      printable = path
291    } else {
292      printable += `${long ? EOL : ' '}${path}`
293    }
294  }
295
296  const highlightDepName = args.length && node[_filteredBy]
297  const missingColor = isOptional(node)
298    ? chalk.yellow.bgBlack
299    : chalk.red.bgBlack
300  const missingMsg = `UNMET ${isOptional(node) ? 'OPTIONAL ' : ''}DEPENDENCY`
301  const targetLocation = node.root
302    ? relative(node.root.realpath, node.realpath)
303    : node.targetLocation
304  const invalid = node[_invalid]
305    ? `invalid: ${node[_invalid]}`
306    : ''
307  const label =
308    (
309      node[_missing]
310        ? missingColor(missingMsg) + ' '
311        : ''
312    ) +
313    `${highlightDepName ? chalk.yellow.bgBlack(printable) : printable}` +
314    (
315      node[_dedupe]
316        ? ' ' + chalk.gray('deduped')
317        : ''
318    ) +
319    (
320      invalid
321        ? ' ' + chalk.red.bgBlack(invalid)
322        : ''
323    ) +
324    (
325      isExtraneous(node, { global })
326        ? ' ' + chalk.green.bgBlack('extraneous')
327        : ''
328    ) +
329    (
330      node.overridden
331        ? ' ' + chalk.gray('overridden')
332        : ''
333    ) +
334    (isGitNode(node) ? ` (${node.resolved})` : '') +
335    (node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
336    (long ? `${EOL}${node.package.description || ''}` : '')
337
338  return augmentItemWithIncludeMetadata(node, { label, nodes: [] })
339}
340
341const getJsonOutputItem = (node, { global, long }) => {
342  const item = {}
343
344  if (node.version) {
345    item.version = node.version
346  }
347
348  if (node.resolved) {
349    item.resolved = node.resolved
350  }
351
352  // if the node is the project root, do not add the overridden flag. the project root can't be
353  // overridden anyway, and if we add the flag it causes undesirable behavior when `npm ls --json`
354  // is ran in an empty directory since we end up printing an object with only an overridden prop
355  if (!node.isProjectRoot) {
356    item.overridden = node.overridden
357  }
358
359  item[_name] = node.name
360
361  // special formatting for top-level package name
362  const hasPackageJson =
363    node && node.package && Object.keys(node.package).length
364  if (node.isRoot && hasPackageJson) {
365    item.name = node.package.name || node.name
366  }
367
368  if (long && !node[_missing]) {
369    item.name = item[_name]
370    const { dependencies, ...packageInfo } = node.package
371    Object.assign(item, packageInfo)
372    item.extraneous = false
373    item.path = node.path
374    item._dependencies = {
375      ...node.package.dependencies,
376      ...node.package.optionalDependencies,
377    }
378    item.devDependencies = node.package.devDependencies || {}
379    item.peerDependencies = node.package.peerDependencies || {}
380  }
381
382  // augment json output items with extra metadata
383  if (isExtraneous(node, { global })) {
384    item.extraneous = true
385  }
386
387  if (node[_invalid]) {
388    item.invalid = node[_invalid]
389  }
390
391  if (node[_missing] && !isOptional(node)) {
392    item.required = node[_required]
393    item.missing = true
394  }
395  if (node[_include] && node[_problems] && node[_problems].size) {
396    item.problems = [...node[_problems]]
397  }
398
399  return augmentItemWithIncludeMetadata(node, item)
400}
401
402const filterByEdgesTypes = ({ link, omit }) => (edge) => {
403  for (const omitType of omit) {
404    if (edge[omitType]) {
405      return false
406    }
407  }
408  return link ? edge.to && edge.to.isLink : true
409}
410
411const appendExtraneousChildren = ({ node, seenPaths }) =>
412  // extraneous children are not represented
413  // in edges out, so here we add them to the list:
414  [...node.children.values()]
415    .filter(i => !seenPaths.has(i.path) && i.extraneous)
416
417const mapEdgesToNodes = ({ seenPaths }) => (edge) => {
418  let node = edge.to
419
420  // if the edge is linking to a missing node, we go ahead
421  // and create a new obj that will represent the missing node
422  if (edge.missing || (edge.optional && !node)) {
423    const { name, spec } = edge
424    const pkgid = `${name}@${spec}`
425    node = { name, pkgid, [_missing]: edge.from.pkgid }
426  }
427
428  // keeps track of a set of seen paths to avoid the edge case in which a tree
429  // item would appear twice given that it's a children of an extraneous item,
430  // so it's marked extraneous but it will ALSO show up in edgesOuts of
431  // its parent so it ends up as two diff nodes if we don't track it
432  if (node.path) {
433    seenPaths.add(node.path)
434  }
435
436  node[_required] = edge.spec || '*'
437  node[_type] = edge.type
438
439  if (edge.invalid) {
440    const spec = JSON.stringify(node[_required])
441    const from = edge.from.location || 'the root project'
442    node[_invalid] = (node[_invalid] ? node[_invalid] + ', ' : '') +
443      (`${spec} from ${from}`)
444  }
445
446  return node
447}
448
449const filterByPositionalArgs = (args, { node }) =>
450  args.length > 0 ? args.some(
451    (spec) => (node.satisfies && node.satisfies(spec))
452  ) : true
453
454const augmentNodesWithMetadata = ({
455  args,
456  currentDepth,
457  nodeResult,
458  seenNodes,
459}) => (node) => {
460  // if the original edge was a deduped dep, treeverse will fail to
461  // revisit that node in tree traversal logic, so we make it so that
462  // we have a diff obj for deduped nodes:
463  if (seenNodes.has(node.path)) {
464    const { realpath, root } = node
465    const targetLocation = root ? relative(root.realpath, realpath)
466      : node.targetLocation
467    node = {
468      name: node.name,
469      version: node.version,
470      pkgid: node.pkgid,
471      package: node.package,
472      path: node.path,
473      isLink: node.isLink,
474      realpath: node.realpath,
475      targetLocation,
476      [_type]: node[_type],
477      [_invalid]: node[_invalid],
478      [_missing]: node[_missing],
479      // if it's missing, it's not deduped, it's just missing
480      [_dedupe]: !node[_missing],
481    }
482  } else {
483    // keeps track of already seen nodes in order to check for dedupes
484    seenNodes.set(node.path, node)
485  }
486
487  // _parent is going to be a ref to a treeverse-visited node (returned from
488  // getHumanOutputItem, getJsonOutputItem, etc) so that we have an easy
489  // shortcut to place new nodes in their right place during tree traversal
490  node[_parent] = nodeResult
491  // _include is the property that allow us to filter based on position args
492  // e.g: `npm ls foo`, `npm ls simple-output@2`
493  // _filteredBy is used to apply extra color info to the item that
494  // was used in args in order to filter
495  node[_filteredBy] = node[_include] =
496    filterByPositionalArgs(args, { node: seenNodes.get(node.path) })
497  // _depth keeps track of how many levels deep tree traversal currently is
498  // so that we can `npm ls --depth=1`
499  node[_depth] = currentDepth + 1
500
501  return node
502}
503
504const sortAlphabetically = ({ pkgid: a }, { pkgid: b }) => localeCompare(a, b)
505
506const humanOutput = ({ chalk, result, seenItems, unicode }) => {
507  // we need to traverse the entire tree in order to determine which items
508  // should be included (since a nested transitive included dep will make it
509  // so that all its ancestors should be displayed)
510  // here is where we put items in their expected place for archy output
511  for (const item of seenItems) {
512    if (item[_include] && item[_parent]) {
513      item[_parent].nodes.push(item)
514    }
515  }
516
517  if (!result.nodes.length) {
518    result.nodes = ['(empty)']
519  }
520
521  const archyOutput = archy(result, '', { unicode })
522  return chalk.reset(archyOutput)
523}
524
525const jsonOutput = ({ path, problems, result, rootError, seenItems }) => {
526  if (problems.size) {
527    result.problems = [...problems]
528  }
529
530  if (rootError) {
531    result.problems = [
532      ...(result.problems || []),
533      ...[`error in ${path}: Failed to parse root package.json`],
534    ]
535    result.invalid = true
536  }
537
538  // we need to traverse the entire tree in order to determine which items
539  // should be included (since a nested transitive included dep will make it
540  // so that all its ancestors should be displayed)
541  // here is where we put items in their expected place for json output
542  for (const item of seenItems) {
543    // append current item to its parent item.dependencies obj in order
544    // to provide a json object structure that represents the installed tree
545    if (item[_include] && item[_parent]) {
546      if (!item[_parent].dependencies) {
547        item[_parent].dependencies = {}
548      }
549
550      item[_parent].dependencies[item[_name]] = item
551    }
552  }
553
554  return JSON.stringify(result, null, 2)
555}
556
557const parseableOutput = ({ global, long, seenNodes }) => {
558  let out = ''
559  for (const node of seenNodes.values()) {
560    if (node.path && node[_include]) {
561      out += node.path
562      if (long) {
563        out += `:${node.pkgid}`
564        out += node.path !== node.realpath ? `:${node.realpath}` : ''
565        out += isExtraneous(node, { global }) ? ':EXTRANEOUS' : ''
566        out += node[_invalid] ? ':INVALID' : ''
567        out += node.overridden ? ':OVERRIDDEN' : ''
568      }
569      out += EOL
570    }
571  }
572  return out.trim()
573}
574