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