• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const os = require('node:os')
2const { resolve } = require('node:path')
3const { stripVTControlCharacters } = require('node:util')
4const pacote = require('pacote')
5const table = require('text-table')
6const npa = require('npm-package-arg')
7const pickManifest = require('npm-pick-manifest')
8const localeCompare = require('@isaacs/string-locale-compare')('en')
9
10const ArboristWorkspaceCmd = require('../arborist-cmd.js')
11
12class Outdated extends ArboristWorkspaceCmd {
13  static description = 'Check for outdated packages'
14  static name = 'outdated'
15  static usage = ['[<package-spec> ...]']
16  static params = [
17    'all',
18    'json',
19    'long',
20    'parseable',
21    'global',
22    'workspace',
23  ]
24
25  async exec (args) {
26    const global = resolve(this.npm.globalDir, '..')
27    const where = this.npm.global
28      ? global
29      : this.npm.prefix
30
31    const Arborist = require('@npmcli/arborist')
32    const arb = new Arborist({
33      ...this.npm.flatOptions,
34      path: where,
35    })
36
37    this.edges = new Set()
38    this.list = []
39    this.tree = await arb.loadActual()
40
41    if (this.workspaceNames && this.workspaceNames.length) {
42      this.filterSet =
43        arb.workspaceDependencySet(
44          this.tree,
45          this.workspaceNames,
46          this.npm.flatOptions.includeWorkspaceRoot
47        )
48    } else if (!this.npm.flatOptions.workspacesEnabled) {
49      this.filterSet =
50        arb.excludeWorkspacesDependencySet(this.tree)
51    }
52
53    if (args.length !== 0) {
54      // specific deps
55      for (let i = 0; i < args.length; i++) {
56        const nodes = this.tree.inventory.query('name', args[i])
57        this.getEdges(nodes, 'edgesIn')
58      }
59    } else {
60      if (this.npm.config.get('all')) {
61        // all deps in tree
62        const nodes = this.tree.inventory.values()
63        this.getEdges(nodes, 'edgesOut')
64      }
65      // top-level deps
66      this.getEdges()
67    }
68
69    await Promise.all(Array.from(this.edges).map((edge) => {
70      return this.getOutdatedInfo(edge)
71    }))
72
73    // sorts list alphabetically
74    const outdated = this.list.sort((a, b) => localeCompare(a.name, b.name))
75
76    if (outdated.length > 0) {
77      process.exitCode = 1
78    }
79
80    // return if no outdated packages
81    if (outdated.length === 0 && !this.npm.config.get('json')) {
82      return
83    }
84
85    // display results
86    if (this.npm.config.get('json')) {
87      this.npm.output(this.makeJSON(outdated))
88    } else if (this.npm.config.get('parseable')) {
89      this.npm.output(this.makeParseable(outdated))
90    } else {
91      const outList = outdated.map(x => this.makePretty(x))
92      const outHead = ['Package',
93        'Current',
94        'Wanted',
95        'Latest',
96        'Location',
97        'Depended by',
98      ]
99
100      if (this.npm.config.get('long')) {
101        outHead.push('Package Type', 'Homepage')
102      }
103      const outTable = [outHead].concat(outList)
104
105      outTable[0] = outTable[0].map(heading => this.npm.chalk.underline(heading))
106
107      const tableOpts = {
108        align: ['l', 'r', 'r', 'r', 'l'],
109        stringLength: s => stripVTControlCharacters(s).length,
110      }
111      this.npm.output(table(outTable, tableOpts))
112    }
113  }
114
115  getEdges (nodes, type) {
116    // when no nodes are provided then it should only read direct deps
117    // from the root node and its workspaces direct dependencies
118    if (!nodes) {
119      this.getEdgesOut(this.tree)
120      this.getWorkspacesEdges()
121      return
122    }
123
124    for (const node of nodes) {
125      type === 'edgesOut'
126        ? this.getEdgesOut(node)
127        : this.getEdgesIn(node)
128    }
129  }
130
131  getEdgesIn (node) {
132    for (const edge of node.edgesIn) {
133      this.trackEdge(edge)
134    }
135  }
136
137  getEdgesOut (node) {
138    // TODO: normalize usage of edges and avoid looping through nodes here
139    if (this.npm.global) {
140      for (const child of node.children.values()) {
141        this.trackEdge(child)
142      }
143    } else {
144      for (const edge of node.edgesOut.values()) {
145        this.trackEdge(edge)
146      }
147    }
148  }
149
150  trackEdge (edge) {
151    const filteredOut =
152      edge.from
153        && this.filterSet
154        && this.filterSet.size > 0
155        && !this.filterSet.has(edge.from.target)
156
157    if (filteredOut) {
158      return
159    }
160
161    this.edges.add(edge)
162  }
163
164  getWorkspacesEdges (node) {
165    if (this.npm.global) {
166      return
167    }
168
169    for (const edge of this.tree.edgesOut.values()) {
170      const workspace = edge
171        && edge.to
172        && edge.to.target
173        && edge.to.target.isWorkspace
174
175      if (workspace) {
176        this.getEdgesOut(edge.to.target)
177      }
178    }
179  }
180
181  async getPackument (spec) {
182    const packument = await pacote.packument(spec, {
183      ...this.npm.flatOptions,
184      fullMetadata: this.npm.config.get('long'),
185      preferOnline: true,
186    })
187    return packument
188  }
189
190  async getOutdatedInfo (edge) {
191    let alias = false
192    try {
193      alias = npa(edge.spec).subSpec
194    } catch (err) {
195      // ignore errors, no alias
196    }
197    const spec = npa(alias ? alias.name : edge.name)
198    const node = edge.to || edge
199    const { path, location } = node
200    const { version: current } = node.package || {}
201
202    const type = edge.optional ? 'optionalDependencies'
203      : edge.peer ? 'peerDependencies'
204      : edge.dev ? 'devDependencies'
205      : 'dependencies'
206
207    for (const omitType of this.npm.flatOptions.omit) {
208      if (node[omitType]) {
209        return
210      }
211    }
212
213    // deps different from prod not currently
214    // on disk are not included in the output
215    if (edge.error === 'MISSING' && type !== 'dependencies') {
216      return
217    }
218
219    try {
220      const packument = await this.getPackument(spec)
221      const expected = alias ? alias.fetchSpec : edge.spec
222      // if it's not a range, version, or tag, skip it
223      try {
224        if (!npa(`${edge.name}@${edge.spec}`).registry) {
225          return null
226        }
227      } catch (err) {
228        return null
229      }
230      const wanted = pickManifest(packument, expected, this.npm.flatOptions)
231      const latest = pickManifest(packument, '*', this.npm.flatOptions)
232
233      if (
234        !current ||
235        current !== wanted.version ||
236        wanted.version !== latest.version
237      ) {
238        const dependent = edge.from ?
239          this.maybeWorkspaceName(edge.from)
240          : 'global'
241
242        this.list.push({
243          name: alias ? edge.spec.replace('npm', edge.name) : edge.name,
244          path,
245          type,
246          current,
247          location,
248          wanted: wanted.version,
249          latest: latest.version,
250          dependent,
251          homepage: packument.homepage,
252        })
253      }
254    } catch (err) {
255      // silently catch and ignore ETARGET, E403 &
256      // E404 errors, deps are just skipped
257      if (!(
258        err.code === 'ETARGET' ||
259        err.code === 'E403' ||
260        err.code === 'E404')
261      ) {
262        throw err
263      }
264    }
265  }
266
267  maybeWorkspaceName (node) {
268    if (!node.isWorkspace) {
269      return node.name
270    }
271
272    const humanOutput =
273      !this.npm.config.get('json') && !this.npm.config.get('parseable')
274
275    const workspaceName =
276      humanOutput
277        ? node.pkgid
278        : node.name
279
280    return humanOutput
281      ? this.npm.chalk.green(workspaceName)
282      : workspaceName
283  }
284
285  // formatting functions
286  makePretty (dep) {
287    const {
288      current = 'MISSING',
289      location = '-',
290      homepage = '',
291      name,
292      wanted,
293      latest,
294      type,
295      dependent,
296    } = dep
297
298    const columns = [name, current, wanted, latest, location, dependent]
299
300    if (this.npm.config.get('long')) {
301      columns[6] = type
302      columns[7] = homepage
303    }
304
305    columns[0] = this.npm.chalk[current === wanted ? 'yellow' : 'red'](columns[0]) // current
306    columns[2] = this.npm.chalk.green(columns[2]) // wanted
307    columns[3] = this.npm.chalk.magenta(columns[3]) // latest
308
309    return columns
310  }
311
312  // --parseable creates output like this:
313  // <fullpath>:<name@wanted>:<name@installed>:<name@latest>:<dependedby>
314  makeParseable (list) {
315    return list.map(dep => {
316      const {
317        name,
318        current,
319        wanted,
320        latest,
321        path,
322        dependent,
323        type,
324        homepage,
325      } = dep
326      const out = [
327        path,
328        name + '@' + wanted,
329        current ? (name + '@' + current) : 'MISSING',
330        name + '@' + latest,
331        dependent,
332      ]
333      if (this.npm.config.get('long')) {
334        out.push(type, homepage)
335      }
336
337      return out.join(':')
338    }).join(os.EOL)
339  }
340
341  makeJSON (list) {
342    const out = {}
343    list.forEach(dep => {
344      const {
345        name,
346        current,
347        wanted,
348        latest,
349        path,
350        type,
351        dependent,
352        homepage,
353      } = dep
354      out[name] = {
355        current,
356        wanted,
357        latest,
358        dependent,
359        location: path,
360      }
361      if (this.npm.config.get('long')) {
362        out[name].type = type
363        out[name].homepage = homepage
364      }
365    })
366    return JSON.stringify(out, null, 2)
367  }
368}
369module.exports = Outdated
370