diff options
author | Ruy Adorno <ruyadorno@hotmail.com> | 2020-05-17 08:30:11 +0300 |
---|---|---|
committer | nlf <quitlahok@gmail.com> | 2021-01-28 23:50:12 +0300 |
commit | d011266b733367aad283ccbfb9d2b19442c3405f (patch) | |
tree | 689c45b149cd77b4dab6d12b2c09bfa9c40d8aaa /lib | |
parent | bb7329def2631308a82144f050955de913a5c8e4 (diff) |
feat: add npm diffruyadorno/npm-diff
- As proposed in RFC: https://github.com/npm/rfcs/pull/144
PR-URL: https://github.com/npm/cli/pull/1319
Credit: @ruyadorno
Close: #1319
Reviewed-by: @isaacs
Diffstat (limited to 'lib')
-rw-r--r-- | lib/diff.js | 266 | ||||
-rw-r--r-- | lib/utils/cmd-list.js | 1 | ||||
-rw-r--r-- | lib/utils/config.js | 16 | ||||
-rw-r--r-- | lib/utils/flat-options.js | 9 |
4 files changed, 292 insertions, 0 deletions
diff --git a/lib/diff.js b/lib/diff.js new file mode 100644 index 000000000..af6760106 --- /dev/null +++ b/lib/diff.js @@ -0,0 +1,266 @@ +const { resolve } = require('path') + +const semver = require('semver') +const libdiff = require('libnpmdiff') +const npa = require('npm-package-arg') +const Arborist = require('@npmcli/arborist') +const npmlog = require('npmlog') +const pacote = require('pacote') +const pickManifest = require('npm-pick-manifest') + +const npm = require('./npm.js') +const usageUtil = require('./utils/usage.js') +const output = require('./utils/output.js') +const completion = require('./utils/completion/none.js') +const readLocalPkg = require('./utils/read-local-package.js') + +const usage = usageUtil( + 'diff', + 'npm diff [...<paths>]' + + '\nnpm diff --diff=<pkg-name> [...<paths>]' + + '\nnpm diff --diff=<version-a> [--diff=<version-b>] [...<paths>]' + + '\nnpm diff --diff=<spec-a> [--diff=<spec-b>] [...<paths>]' + + '\nnpm diff [--diff-ignore-all-space] [--diff-name-only] [...<paths>] [...<paths>]' +) + +const cmd = (args, cb) => diff(args).then(() => cb()).catch(cb) + +const where = () => { + const globalTop = resolve(npm.globalDir, '..') + const { global } = npm.flatOptions + return global ? globalTop : npm.prefix +} + +const diff = async (args) => { + const specs = npm.flatOptions.diff.filter(d => d) + if (specs.length > 2) { + throw new TypeError( + 'Can\'t use more than two --diff arguments.\n\n' + + `Usage:\n${usage}` + ) + } + + const [a, b] = await retrieveSpecs(specs) + npmlog.info('diff', { src: a, dst: b }) + + const res = await libdiff([a, b], { ...npm.flatOptions, diffFiles: args }) + return output(res) +} + +const retrieveSpecs = ([a, b]) => { + // no arguments, defaults to comparing cwd + // to its latest published registry version + if (!a) + return defaultSpec() + + // single argument, used to compare wanted versions of an + // installed dependency or to compare the cwd to a published version + if (!b) + return transformSingleSpec(a) + + return convertVersionsToSpecs([a, b]) + .then(findVersionsByPackageName) +} + +const defaultSpec = async () => { + let noPackageJson + let pkgName + try { + pkgName = await readLocalPkg() + } catch (e) { + npmlog.verbose('diff', 'could not read project dir package.json') + noPackageJson = true + } + + if (!pkgName || noPackageJson) { + throw new Error( + 'Needs multiple arguments to compare or run from a project dir.\n\n' + + `Usage:\n${usage}` + ) + } + + return [ + `${pkgName}@${npm.flatOptions.defaultTag}`, + `file:${npm.prefix}`, + ] +} + +const transformSingleSpec = async (a) => { + let noPackageJson + let pkgName + try { + pkgName = await readLocalPkg() + } catch (e) { + npmlog.verbose('diff', 'could not read project dir package.json') + noPackageJson = true + } + const missingPackageJson = new Error( + 'Needs multiple arguments to compare or run from a project dir.\n\n' + + `Usage:\n${usage}` + ) + + const specSelf = () => { + if (noPackageJson) + throw missingPackageJson + + return `file:${npm.prefix}` + } + + // 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 + if (semver.validRange(a)) { + if (!pkgName) + throw missingPackageJson + + return [ + `${pkgName}@${a}`, + specSelf(), + ] + } + + // 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 + const spec = npa(a) + if (spec.registry) { + let actualTree + let node + try { + const opts = { + ...npm.flatOptions, + path: where(), + } + const arb = new Arborist(opts) + actualTree = await arb.loadActual(opts) + node = actualTree && + actualTree.inventory.query('name', spec.name) + .values().next().value + } catch (e) { + npmlog.verbose('diff', 'failed to load actual install tree') + } + + if (!node || !node.name || !node.package || !node.package.version) { + return [ + `${spec.name}@${spec.fetchSpec}`, + specSelf(), + ] + } + + const tryRootNodeSpec = () => + (actualTree && actualTree.edgesOut.get(spec.name) || {}).spec + + const tryAnySpec = () => { + for (const edge of node.edgesIn) + return edge.spec + } + + const aSpec = `file:${node.realpath}` + + // finds what version of the package to compare against, if a exact + // version or tag was passed than it should use that, otherwise + // work from the top of the arborist tree to find the original semver + // range declared in the package that depends on the package. + let bSpec + if (spec.rawSpec) + bSpec = spec.rawSpec + else { + const bTargetVersion = + tryRootNodeSpec() + || tryAnySpec() + + // figure out what to compare against, + // follows same logic to npm outdated "Wanted" results + const packument = await pacote.packument(spec, { + ...npm.flatOptions, + preferOnline: true, + }) + bSpec = pickManifest( + packument, + bTargetVersion, + { ...npm.flatOptions } + ).version + } + + return [ + `${spec.name}@${aSpec}`, + `${spec.name}@${bSpec}`, + ] + } else if (spec.type === 'directory') { + return [ + `file:${spec.fetchSpec}`, + specSelf(), + ] + } else { + throw new Error( + 'Spec type not supported.\n\n' + + `Usage:\n${usage}` + ) + } +} + +const convertVersionsToSpecs = async ([a, b]) => { + const semverA = semver.validRange(a) + const semverB = semver.validRange(b) + + // both specs are semver versions, assume current project dir name + if (semverA && semverB) { + let pkgName + try { + pkgName = await readLocalPkg() + } catch (e) { + npmlog.verbose('diff', 'could not read project dir package.json') + } + + if (!pkgName) { + throw new Error( + 'Needs to be run from a project dir in order to diff two versions.\n\n' + + `Usage:\n${usage}` + ) + } + return [`${pkgName}@${a}`, `${pkgName}@${b}`] + } + + // otherwise uses the name from the other arg to + // figure out the spec.name of what to compare + if (!semverA && semverB) + return [a, `${npa(a).name}@${b}`] + + if (semverA && !semverB) + return [`${npa(b).name}@${a}`, b] + + // no valid semver ranges used + return [a, b] +} + +const findVersionsByPackageName = async (specs) => { + let actualTree + try { + const opts = { + ...npm.flatOptions, + path: where(), + } + const arb = new Arborist(opts) + actualTree = await arb.loadActual(opts) + } catch (e) { + npmlog.verbose('diff', 'failed to load actual install tree') + } + + return specs.map(i => { + const spec = npa(i) + if (spec.rawSpec) + return i + + const node = actualTree + && actualTree.inventory.query('name', spec.name) + .values().next().value + + const res = !node || !node.package || !node.package.version + ? spec.fetchSpec + : `file:${node.realpath}` + + return `${spec.name}@${res}` + }) +} + +module.exports = Object.assign(cmd, { completion, usage }) diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 8c092e719..4e088c12d 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -119,6 +119,7 @@ const cmdList = [ 'prefix', 'bin', 'whoami', + 'diff', 'dist-tag', 'ping', diff --git a/lib/utils/config.js b/lib/utils/config.js index 511215769..3ca976613 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -74,6 +74,14 @@ const defaults = { depth: null, description: true, dev: false, + diff: [], + 'diff-unified': null, + 'diff-ignore-all-space': false, + 'diff-name-only': false, + 'diff-no-prefix': false, + 'diff-src-prefix': '', + 'diff-dst-prefix': '', + 'diff-text': false, 'dry-run': false, editor, 'engine-strict': false, @@ -216,6 +224,14 @@ const types = { depth: [null, Number], description: Boolean, dev: Boolean, + diff: [String, Array], + 'diff-unified': [null, Number], + 'diff-ignore-all-space': Boolean, + 'diff-name-only': Boolean, + 'diff-no-prefix': Boolean, + 'diff-src-prefix': String, + 'diff-dst-prefix': String, + 'diff-text': Boolean, 'dry-run': Boolean, editor: String, 'engine-strict': Boolean, diff --git a/lib/utils/flat-options.js b/lib/utils/flat-options.js index a161ff2e6..c082e4137 100644 --- a/lib/utils/flat-options.js +++ b/lib/utils/flat-options.js @@ -102,6 +102,15 @@ const flatten = obj => ({ staleness: obj.searchstaleness, }, + diff: obj.diff, + diffUnified: obj['diff-unified'], + diffIgnoreAllSpace: obj['diff-ignore-all-space'], + diffNameOnly: obj['diff-name-only'], + diffNoPrefix: obj['diff-no-prefix'], + diffSrcPrefix: obj['diff-src-prefix'], + diffDstPrefix: obj['diff-dst-prefix'], + diffText: obj['diff-text'], + dryRun: obj['dry-run'], engineStrict: obj['engine-strict'], |