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