codekingpro/portable-devtools
114k
1const { resolve } = require('node:path')
2const { stripVTControlCharacters } = require('node:util')
3const pacote = require('pacote')
4const table = require('text-table')
5const npa = require('npm-package-arg')
6const pickManifest = require('npm-pick-manifest')
7const { output } = require('proc-log')
8const localeCompare = require('@isaacs/string-locale-compare')('en')
9const ArboristWorkspaceCmd = require('../arborist-cmd.js')
10
11const safeNpa = (spec) => {
12 try {
13 return npa(spec)
14 } catch {
15 return null
16 }
17}
18
19// This string is load bearing and is shared with Arborist
20const MISSING = 'MISSING'
21
22class Outdated extends ArboristWorkspaceCmd {
23 static description = 'Check for outdated packages'
24 static name = 'outdated'
25 static usage = ['[<package-spec> ...]']
26 static params = [
27 'all',
28 'json',
29 'long',
30 'parseable',
31 'global',
32 'workspace',
33 'before',
34 ]
35
36 #tree
37 #list = []
38 #edges = new Set()
39 #filterSet
40
41 async exec (args) {
42 const Arborist = require('@npmcli/arborist')
43 const arb = new Arborist({
44 ...this.npm.flatOptions,
45 path: this.npm.global ? resolve(this.npm.globalDir, '..') : this.npm.prefix,
46 })
47 this.#tree = await arb.loadActual()
48
49 if (this.workspaceNames?.length) {
50 this.#filterSet = arb.workspaceDependencySet(
51 this.#tree,
52 this.workspaceNames,
53 this.npm.flatOptions.includeWorkspaceRoot
54 )
55 } else if (!this.npm.flatOptions.workspacesEnabled) {
56 this.#filterSet = arb.excludeWorkspacesDependencySet(this.#tree)
57 }
58
59 if (args.length) {
60 for (const arg of args) {
61 // specific deps
62 this.#getEdges(this.#tree.inventory.query('name', arg), 'edgesIn')
63 }
64 } else {
65 if (this.npm.config.get('all')) {
66 // all deps in tree
67 this.#getEdges(this.#tree.inventory.values(), 'edgesOut')
68 }
69 // top-level deps
70 this.#getEdges()
71 }
72
73 await Promise.all([...this.#edges].map((e) => this.#getOutdatedInfo(e)))
74
75 // sorts list alphabetically by name and then dependent
76 const outdated = this.#list
77 .sort((a, b) => localeCompare(a.name, b.name) || localeCompare(a.dependent, b.dependent))
78
79 if (outdated.length) {
80 process.exitCode = 1
81 }
82
83 if (this.npm.config.get('json')) {
84 output.buffer(this.#json(outdated))
85 return
86 }
87
88 const res = this.npm.config.get('parseable')
89 ? this.#parseable(outdated)
90 : this.#pretty(outdated)
91
92 if (res) {
93 output.standard(res)
94 }
95 }
96
97 #getEdges (nodes, type) {
98 // when no nodes are provided then it should only read direct deps from the root node and its workspaces direct dependencies
99 if (!nodes) {
100 this.#getEdgesOut(this.#tree)
101 this.#getWorkspacesEdges()
102 return
103 }
104
105 for (const node of nodes) {
106 if (type === 'edgesOut') {
107 this.#getEdgesOut(node)
108 } else {
109 this.#getEdgesIn(node)
110 }
111 }
112 }
113
114 #getEdgesIn (node) {
115 for (const edge of node.edgesIn) {
116 this.#trackEdge(edge)
117 }
118 }
119
120 #getEdgesOut (node) {
121 // TODO: normalize usage of edges and avoid looping through nodes here
122 const edges = this.npm.global ? node.children.values() : node.edgesOut.values()
123 for (const edge of edges) {
124 this.#trackEdge(edge)
125 }
126 }
127
128 #trackEdge (edge) {
129 if (edge.from && this.#filterSet?.size > 0 && !this.#filterSet.has(edge.from.target)) {
130 return
131 }
132 this.#edges.add(edge)
133 }
134
135 #getWorkspacesEdges () {
136 if (this.npm.global) {
137 return
138 }
139
140 for (const edge of this.#tree.edgesOut.values()) {
141 if (edge?.to?.target?.isWorkspace) {
142 this.#getEdgesOut(edge.to.target)
143 }
144 }
145 }
146
147 async #getPackument (spec) {
148 return pacote.packument(spec, {
149 ...this.npm.flatOptions,
150 fullMetadata: this.npm.config.get('long'),
151 preferOnline: true,
152 })
153 }
154
155 async #getOutdatedInfo (edge) {
156 const alias = safeNpa(edge.spec)?.subSpec
157 const spec = npa(alias ? alias.name : edge.name)
158 const node = edge.to || edge
159 const { path, location, package: { version: current } = {} } = node
160
161 const type = edge.optional ? 'optionalDependencies'
162 : edge.peer ? 'peerDependencies'
163 : edge.dev ? 'devDependencies'
164 : 'dependencies'
165
166 for (const omitType of this.npm.flatOptions.omit) {
167 if (node[omitType]) {
168 return
169 }
170 }
171
172 // deps different from prod not currently on disk are not included in the output
173 if (edge.error === MISSING && type !== 'dependencies') {
174 return
175 }
176
177 // if it's not a range, version, or tag, skip it
178 if (!safeNpa(`${edge.name}@${edge.spec}`)?.registry) {
179 return null
180 }
181
182 try {
183 const packument = await this.#getPackument(spec)
184 const expected = alias ? alias.fetchSpec : edge.spec
185 const wanted = pickManifest(packument, expected, this.npm.flatOptions)
186 const latest = pickManifest(packument, '*', this.npm.flatOptions)
187 if (!current || current !== wanted.version || wanted.version !== latest.version) {
188 this.#list.push({
189 name: alias ? edge.spec.replace('npm', edge.name) : edge.name,
190 path,
191 type,
192 current,
193 location,
194 wanted: wanted.version,
195 latest: latest.version,
196 workspaceDependent: edge.from?.isWorkspace ? edge.from.pkgid : null,
197 dependedByLocation: edge.from?.name
198 ? edge.from?.location
199 : 'global',
200 dependent: edge.from?.name ?? 'global',
201 homepage: packument.homepage,
202 })
203 }
204 } catch (err) {
205 // silently catch and ignore ETARGET, E403 & E404 errors
206 // deps are just skipped
207 if (!['ETARGET', 'E403', 'E404'].includes(err.code)) {
208 throw err
209 }
210 }
211 }
212
213 // formatting functions
214
215 #pretty (list) {
216 if (!list.length) {
217 return
218 }
219
220 const long = this.npm.config.get('long')
221 const { bold, yellow, red, cyan, blue } = this.npm.chalk
222
223 return table([
224 [
225 'Package',
226 'Current',
227 'Wanted',
228 'Latest',
229 'Location',
230 'Depended by',
231 ...long ? ['Package Type', 'Homepage', 'Depended By Location'] : [],
232 ].map(h => bold.underline(h)),
233 ...list.map((d) => [
234 d.current === d.wanted ? yellow(d.name) : red(d.name),
235 d.current ?? 'MISSING',
236 cyan(d.wanted),
237 blue(d.latest),
238 d.location ?? '-',
239 d.workspaceDependent ? blue(d.workspaceDependent) : d.dependent,
240 ...long ? [d.type, blue(d.homepage ?? ''), d.dependedByLocation] : [],
241 ]),
242 ], {
243 align: ['l', 'r', 'r', 'r', 'l'],
244 stringLength: s => stripVTControlCharacters(s).length,
245 })
246 }
247
248 // --parseable creates output like this:
249 // <fullpath>:<name@wanted>:<name@installed>:<name@latest>:<dependedby>
250 #parseable (list) {
251 return list.map(d => [
252 d.path,
253 `${d.name}@${d.wanted}`,
254 d.current ? `${d.name}@${d.current}` : 'MISSING',
255 `${d.name}@${d.latest}`,
256 d.dependent,
257 ...this.npm.config.get('long') ? [d.type, d.homepage, d.dependedByLocation] : [],
258 ].join(':')).join('\n')
259 }
260
261 #json (list) {
262 // TODO(BREAKING_CHANGE): this should just return an array.
263 // It's a list and turning it into an object with keys is lossy since multiple items in the list could have the same key. For now we hack that by only changing top level values into arrays if they have multiple outdated items
264 return list.reduce((acc, d) => {
265 const dep = {
266 current: d.current,
267 wanted: d.wanted,
268 latest: d.latest,
269 dependent: d.dependent,
270 location: d.path,
271 ...this.npm.config.get('long') ? {
272 type: d.type,
273 homepage: d.homepage,
274 dependedByLocation: d.dependedByLocation } : {},
275 }
276 acc[d.name] = acc[d.name]
277 // If this item already has an outdated dep then we turn it into an array
278 ? (Array.isArray(acc[d.name]) ? acc[d.name] : [acc[d.name]]).concat(dep)
279 : dep
280 return acc
281 }, {})
282 }
283}
284
285module.exports = Outdated
286 