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