codekingpro/portable-devtools
115k
1const { resolve } = require('node:path')
2const semver = require('semver')
3const libnpmdiff = require('libnpmdiff')
4const npa = require('npm-package-arg')
5const pacote = require('pacote')
6const pickManifest = require('npm-pick-manifest')
7const { log, output } = require('proc-log')
8const pkgJson = require('@npmcli/package-json')
9const BaseCommand = require('../base-cmd.js')
10
11class Diff extends BaseCommand {
12 static description = 'The registry diff command'
13 static name = 'diff'
14 static usage = [
15 '[...<paths>]',
16 ]
17
18 static params = [
19 'diff',
20 'diff-name-only',
21 'diff-unified',
22 'diff-ignore-all-space',
23 'diff-no-prefix',
24 'diff-src-prefix',
25 'diff-dst-prefix',
26 'diff-text',
27 'global',
28 'tag',
29 'workspace',
30 'workspaces',
31 'include-workspace-root',
32 ]
33
34 static workspaces = true
35 static ignoreImplicitWorkspace = false
36
37 async exec (args) {
38 const specs = this.npm.config.get('diff').filter(d => d)
39 if (specs.length > 2) {
40 throw this.usageError(`Can't use more than two --diff arguments.`)
41 }
42
43 // execWorkspaces may have set this already
44 if (!this.prefix) {
45 this.prefix = this.npm.prefix
46 }
47
48 // this is the "top" directory, one up from node_modules in global mode we have to walk one up from globalDir because our node_modules is sometimes under ./lib, and in global mode we're only ever walking through node_modules (because we will have been given a package name already)
49 if (this.npm.global) {
50 this.top = resolve(this.npm.globalDir, '..')
51 } else {
52 this.top = this.prefix
53 }
54
55 const [a, b] = await this.retrieveSpecs(specs)
56 log.info('diff', { src: a, dst: b })
57
58 const res = await libnpmdiff([a, b], {
59 ...this.npm.flatOptions,
60 diffFiles: args,
61 where: this.top,
62 })
63 return output.standard(res)
64 }
65
66 async execWorkspaces (args) {
67 await this.setWorkspaces()
68 for (const workspacePath of this.workspacePaths) {
69 this.top = workspacePath
70 this.prefix = workspacePath
71 await this.exec(args)
72 }
73 }
74
75 // get the package name from the packument at `path`
76 // throws if no packument is present OR if it does not have `name` attribute
77 async packageName () {
78 let name
79 try {
80 const { content: pkg } = await pkgJson.normalize(this.prefix)
81 name = pkg.name
82 } catch {
83 log.verbose('diff', 'could not read project dir package.json')
84 }
85
86 if (!name) {
87 throw this.usageError('Needs multiple arguments to compare or run from a project dir.')
88 }
89
90 return name
91 }
92
93 async retrieveSpecs ([a, b]) {
94 if (a && b) {
95 const specs = await this.convertVersionsToSpecs([a, b])
96 return this.findVersionsByPackageName(specs)
97 }
98
99 // no arguments, defaults to comparing cwd to its latest published registry version
100 if (!a) {
101 const pkgName = await this.packageName()
102 return [
103 `${pkgName}@${this.npm.config.get('tag')}`,
104 `file:${this.prefix}`,
105 ]
106 }
107
108 // single argument, used to compare wanted versions of an installed dependency or to compare the cwd to a published version
109 let noPackageJson
110 let pkgName
111 try {
112 const { content: pkg } = await pkgJson.normalize(this.prefix)
113 pkgName = pkg.name
114 } catch {
115 log.verbose('diff', 'could not read project dir package.json')
116 noPackageJson = true
117 }
118
119 const missingPackageJson =
120 this.usageError('Needs multiple arguments to compare or run from a project dir.')
121
122 // using a valid semver range, that means it should just diff the cwd against a published version to the registry using the same project name and the provided semver range
123 if (semver.validRange(a)) {
124 if (!pkgName) {
125 throw missingPackageJson
126 }
127 return [
128 `${pkgName}@${a}`,
129 `file:${this.prefix}`,
130 ]
131 }
132
133 // when using a single package name as arg and it's part of the current install tree, then retrieve the current installed version and compare it against the same value `npm outdated` would suggest you to update to
134 const spec = npa(a)
135 if (spec.registry) {
136 let actualTree
137 let node
138 const Arborist = require('@npmcli/arborist')
139 try {
140 const opts = {
141 ...this.npm.flatOptions,
142 path: this.top,
143 }
144 const arb = new Arborist(opts)
145 actualTree = await arb.loadActual(opts)
146 node = actualTree &&
147 actualTree.inventory.query('name', spec.name)
148 .values().next().value
149 } catch {
150 log.verbose('diff', 'failed to load actual install tree')
151 }
152
153 if (!node || !node.name || !node.package || !node.package.version) {
154 if (noPackageJson) {
155 throw missingPackageJson
156 }
157 return [
158 `${spec.name}@${spec.fetchSpec}`,
159 `file:${this.prefix}`,
160 ]
161 }
162
163 const tryRootNodeSpec = () =>
164 (actualTree && actualTree.edgesOut.get(spec.name) || {}).spec
165
166 const tryAnySpec = () => {
167 for (const edge of node.edgesIn) {
168 return edge.spec
169 }
170 }
171
172 const aSpec = `file:${node.realpath}`
173
174 // finds what version of the package to compare against
175 // if an exact version or tag was passed than it should use that
176 // otherwise, work from the top of the arborist tree to find the original semver range declared in the package that depends on the package.
177 let bSpec
178 if (spec.rawSpec !== '*') {
179 bSpec = spec.rawSpec
180 } else {
181 const bTargetVersion =
182 tryRootNodeSpec()
183 || tryAnySpec()
184
185 // figure out what to compare against
186 // follows same logic to npm outdated "Wanted" results
187 const packument = await pacote.packument(spec, {
188 ...this.npm.flatOptions,
189 preferOnline: true,
190 _isRoot: true,
191 })
192 bSpec = pickManifest(
193 packument,
194 bTargetVersion,
195 { ...this.npm.flatOptions }
196 ).version
197 }
198
199 return [
200 `${spec.name}@${aSpec}`,
201 `${spec.name}@${bSpec}`,
202 ]
203 } else if (spec.type === 'directory') {
204 return [
205 `file:${spec.fetchSpec}`,
206 `file:${this.prefix}`,
207 ]
208 } else {
209 throw this.usageError(`Spec type ${spec.type} not supported.`)
210 }
211 }
212
213 async convertVersionsToSpecs ([a, b]) {
214 const semverA = semver.validRange(a)
215 const semverB = semver.validRange(b)
216
217 // both specs are semver versions, assume current project dir name
218 if (semverA && semverB) {
219 let pkgName
220 try {
221 const { content: pkg } = await pkgJson.normalize(this.prefix)
222 pkgName = pkg.name
223 } catch {
224 log.verbose('diff', 'could not read project dir package.json')
225 }
226
227 if (!pkgName) {
228 throw this.usageError('Needs to be run from a project dir in order to diff two versions.')
229 }
230
231 return [`${pkgName}@${a}`, `${pkgName}@${b}`]
232 }
233
234 // otherwise uses the name from the other arg to figure out the spec.name of what to compare
235 if (!semverA && semverB) {
236 return [a, `${npa(a).name}@${b}`]
237 }
238
239 if (semverA && !semverB) {
240 return [`${npa(b).name}@${a}`, b]
241 }
242
243 // no valid semver ranges used
244 return [a, b]
245 }
246
247 async findVersionsByPackageName (specs) {
248 let actualTree
249 const Arborist = require('@npmcli/arborist')
250 try {
251 const opts = {
252 ...this.npm.flatOptions,
253 path: this.top,
254 }
255 const arb = new Arborist(opts)
256 actualTree = await arb.loadActual(opts)
257 } catch {
258 log.verbose('diff', 'failed to load actual install tree')
259 }
260
261 return specs.map(i => {
262 const spec = npa(i)
263 if (spec.rawSpec !== '*') {
264 return i
265 }
266
267 const node = actualTree
268 && actualTree.inventory.query('name', spec.name)
269 .values().next().value
270
271 const res = !node || !node.package || !node.package.version
272 ? spec.fetchSpec
273 : `file:${node.realpath}`
274
275 return `${spec.name}@${res}`
276 })
277 }
278}
279
280module.exports = Diff
281 