const os = require('os') const path = require('path') const pacote = require('pacote') const table = require('text-table') const color = require('chalk') const styles = require('ansistyles') const npa = require('npm-package-arg') const pickManifest = require('npm-pick-manifest') const localeCompare = require('@isaacs/string-locale-compare')('en') const Arborist = require('@npmcli/arborist') const ansiTrim = require('./utils/ansi-trim.js') const ArboristWorkspaceCmd = require('./workspaces/arborist-cmd.js') class Outdated extends ArboristWorkspaceCmd { /* istanbul ignore next - see test/lib/load-all-commands.js */ static get description () { return 'Check for outdated packages' } /* istanbul ignore next - see test/lib/load-all-commands.js */ static get name () { return 'outdated' } /* istanbul ignore next - see test/lib/load-all-commands.js */ static get usage () { return ['[[<@scope>/] ...]'] } /* istanbul ignore next - see test/lib/load-all-commands.js */ static get params () { return [ 'all', 'json', 'long', 'parseable', 'global', 'workspace', ] } exec (args, cb) { this.outdated(args).then(() => cb()).catch(cb) } async outdated (args) { const global = path.resolve(this.npm.globalDir, '..') const where = this.npm.config.get('global') ? global : this.npm.prefix const arb = new Arborist({ ...this.npm.flatOptions, path: where, }) this.edges = new Set() this.list = [] this.tree = await arb.loadActual() if (this.workspaceNames && this.workspaceNames.length) { this.filterSet = arb.workspaceDependencySet(this.tree, this.workspaceNames) } if (args.length !== 0) { // specific deps for (let i = 0; i < args.length; i++) { const nodes = this.tree.inventory.query('name', args[i]) this.getEdges(nodes, 'edgesIn') } } else { if (this.npm.config.get('all')) { // all deps in tree const nodes = this.tree.inventory.values() this.getEdges(nodes, 'edgesOut') } // top-level deps this.getEdges() } await Promise.all(Array.from(this.edges).map((edge) => { return this.getOutdatedInfo(edge) })) // sorts list alphabetically const outdated = this.list.sort((a, b) => localeCompare(a.name, b.name)) if (outdated.length > 0) process.exitCode = 1 // return if no outdated packages if (outdated.length === 0 && !this.npm.config.get('json')) return // display results if (this.npm.config.get('json')) this.npm.output(this.makeJSON(outdated)) else if (this.npm.config.get('parseable')) this.npm.output(this.makeParseable(outdated)) else { const outList = outdated.map(x => this.makePretty(x)) const outHead = ['Package', 'Current', 'Wanted', 'Latest', 'Location', 'Depended by', ] if (this.npm.config.get('long')) outHead.push('Package Type', 'Homepage') const outTable = [outHead].concat(outList) if (this.npm.color) outTable[0] = outTable[0].map(heading => styles.underline(heading)) const tableOpts = { align: ['l', 'r', 'r', 'r', 'l'], stringLength: s => ansiTrim(s).length, } this.npm.output(table(outTable, tableOpts)) } } getEdges (nodes, type) { // when no nodes are provided then it should only read direct deps // from the root node and its workspaces direct dependencies if (!nodes) { this.getEdgesOut(this.tree) this.getWorkspacesEdges() return } for (const node of nodes) { type === 'edgesOut' ? this.getEdgesOut(node) : this.getEdgesIn(node) } } getEdgesIn (node) { for (const edge of node.edgesIn) this.trackEdge(edge) } getEdgesOut (node) { // TODO: normalize usage of edges and avoid looping through nodes here if (this.npm.config.get('global')) { for (const child of node.children.values()) this.trackEdge(child) } else { for (const edge of node.edgesOut.values()) this.trackEdge(edge) } } trackEdge (edge) { const filteredOut = edge.from && this.filterSet && this.filterSet.size > 0 && !this.filterSet.has(edge.from.target) if (filteredOut) return this.edges.add(edge) } getWorkspacesEdges (node) { if (this.npm.config.get('global')) return for (const edge of this.tree.edgesOut.values()) { const workspace = edge && edge.to && edge.to.target && edge.to.target.isWorkspace if (workspace) this.getEdgesOut(edge.to.target) } } async getPackument (spec) { const packument = await pacote.packument(spec, { ...this.npm.flatOptions, fullMetadata: this.npm.config.get('long'), preferOnline: true, }) return packument } async getOutdatedInfo (edge) { const spec = npa(edge.name) const node = edge.to || edge const { path, location } = node const { version: current } = node.package || {} const type = edge.optional ? 'optionalDependencies' : edge.peer ? 'peerDependencies' : edge.dev ? 'devDependencies' : 'dependencies' for (const omitType of this.npm.config.get('omit')) { if (node[omitType]) return } // deps different from prod not currently // on disk are not included in the output if (edge.error === 'MISSING' && type !== 'dependencies') return try { const packument = await this.getPackument(spec) const expected = edge.spec // if it's not a range, version, or tag, skip it try { if (!npa(`${edge.name}@${edge.spec}`).registry) return null } catch (err) { return null } const wanted = pickManifest(packument, expected, this.npm.flatOptions) const latest = pickManifest(packument, '*', this.npm.flatOptions) if ( !current || current !== wanted.version || wanted.version !== latest.version ) { const dependent = edge.from ? this.maybeWorkspaceName(edge.from) : 'global' this.list.push({ name: edge.name, path, type, current, location, wanted: wanted.version, latest: latest.version, dependent, homepage: packument.homepage, }) } } catch (err) { // silently catch and ignore ETARGET, E403 & // E404 errors, deps are just skipped if (!( err.code === 'ETARGET' || err.code === 'E403' || err.code === 'E404') ) throw err } } maybeWorkspaceName (node) { if (!node.isWorkspace) return node.name const humanOutput = !this.npm.config.get('json') && !this.npm.config.get('parseable') const workspaceName = humanOutput ? node.pkgid : node.name return this.npm.color && humanOutput ? color.green(workspaceName) : workspaceName } // formatting functions makePretty (dep) { const { current = 'MISSING', location = '-', homepage = '', name, wanted, latest, type, dependent, } = dep const columns = [name, current, wanted, latest, location, dependent] if (this.npm.config.get('long')) { columns[6] = type columns[7] = homepage } if (this.npm.color) { columns[0] = color[current === wanted ? 'yellow' : 'red'](columns[0]) // current columns[2] = color.green(columns[2]) // wanted columns[3] = color.magenta(columns[3]) // latest } return columns } // --parseable creates output like this: // :::: makeParseable (list) { return list.map(dep => { const { name, current, wanted, latest, path, dependent, type, homepage, } = dep const out = [ path, name + '@' + wanted, current ? (name + '@' + current) : 'MISSING', name + '@' + latest, dependent, ] if (this.npm.config.get('long')) out.push(type, homepage) return out.join(':') }).join(os.EOL) } makeJSON (list) { const out = {} list.forEach(dep => { const { name, current, wanted, latest, path, type, dependent, homepage, } = dep out[name] = { current, wanted, latest, dependent, location: path, } if (this.npm.config.get('long')) { out[name].type = type out[name].homepage = homepage } }) return JSON.stringify(out, null, 2) } } module.exports = Outdated