From 4a5dd3a5a200b3f4f7b47168497d8e03dca3a2ca Mon Sep 17 00:00:00 2001 From: Gar Date: Wed, 24 Feb 2021 15:54:50 -0800 Subject: [PATCH] fix(npm) pass npm context everywhere Instead of files randomly requiring the npm singleton, we pass it where it needs to go so that tests don't need to do so much require mocking everywhere PR-URL: https://github.com/npm/cli/pull/2772 Credit: @wraithgar Close: #2772 Reviewed-by: @ruyadorno --- lib/access.js | 285 ++++---- lib/adduser.js | 105 +-- lib/audit.js | 90 +-- lib/auth/legacy.js | 8 +- lib/auth/oauth.js | 5 +- lib/auth/saml.js | 5 +- lib/auth/sso.js | 5 +- lib/bin.js | 33 +- lib/birthday.js | 27 +- lib/bugs.js | 69 +- lib/cache.js | 184 ++--- lib/ci.js | 108 +-- lib/completion.js | 201 +++--- lib/config.js | 372 +++++----- lib/dedupe.js | 46 +- lib/deprecate.js | 115 +-- lib/diff.js | 416 +++++------ lib/dist-tag.js | 181 +++-- lib/docs.js | 74 +- lib/doctor.js | 487 ++++++------- lib/edit.js | 70 +- lib/exec.js | 451 ++++++------ lib/explain.js | 150 ++-- lib/explore.js | 119 ++-- lib/find-dupes.js | 19 +- lib/fund.js | 344 ++++----- lib/get.js | 30 +- lib/help-search.js | 312 ++++---- lib/help.js | 357 +++++----- lib/hook.js | 110 +-- lib/init.js | 157 ++-- lib/install-ci-test.js | 32 +- lib/install-test.js | 34 +- lib/install.js | 233 +++--- lib/link.js | 257 +++---- lib/ll.js | 22 +- lib/logout.js | 76 +- lib/ls.js | 301 ++++---- lib/npm.js | 60 +- lib/org.js | 249 +++---- lib/outdated.js | 369 +++++----- lib/owner.js | 212 +++--- lib/pack.js | 63 +- lib/ping.js | 49 +- lib/prefix.js | 25 +- lib/profile.js | 604 ++++++++-------- lib/prune.js | 40 +- lib/publish.js | 210 +++--- lib/rebuild.js | 98 +-- lib/repo.js | 80 ++- lib/restart.js | 11 +- lib/root.js | 25 +- lib/run-script.js | 235 +++--- lib/search.js | 129 ++-- lib/set-script.js | 92 +-- lib/set.js | 32 +- lib/shrinkwrap.js | 92 +-- lib/star.js | 118 +-- lib/stars.js | 61 +- lib/start.js | 11 +- lib/stop.js | 11 +- lib/team.js | 248 +++---- lib/test.js | 29 +- lib/token.js | 314 ++++---- lib/uninstall.js | 87 ++- lib/unpublish.js | 175 ++--- lib/unstar.js | 14 +- lib/update.js | 62 +- lib/utils/audit-error.js | 3 +- lib/utils/completion/installed-deep.js | 3 +- lib/utils/completion/installed-shallow.js | 6 +- lib/utils/get-identity.js | 3 +- lib/utils/lifecycle-cmd.js | 21 +- lib/utils/npm-usage.js | 10 +- lib/utils/open-url.js | 41 +- lib/utils/read-local-package.js | 4 +- lib/utils/reify-finish.js | 9 +- lib/utils/reify-output.js | 13 +- lib/utils/usage.js | 4 +- lib/version.js | 127 ++-- lib/view.js | 636 +++++++++-------- lib/whoami.js | 31 +- .../test-lib-utils-npm-usage.js-TAP.test.js | 28 +- test/lib/access.js | 157 ++-- test/lib/adduser.js | 67 +- test/lib/audit.js | 68 +- test/lib/auth/legacy.js | 40 +- test/lib/auth/oauth.js | 20 +- test/lib/auth/saml.js | 20 +- test/lib/auth/sso.js | 23 +- test/lib/bin.js | 20 +- test/lib/birthday.js | 8 +- test/lib/bugs.js | 11 +- test/lib/cache.js | 25 +- test/lib/ci.js | 111 +-- test/lib/completion.js | 44 +- test/lib/config.js | 60 +- test/lib/dedupe.js | 56 +- test/lib/deprecate.js | 24 +- test/lib/diff.js | 109 +-- test/lib/dist-tag.js | 49 +- test/lib/docs.js | 11 +- test/lib/doctor.js | 40 +- test/lib/edit.js | 38 +- test/lib/exec.js | 673 +++++++++--------- test/lib/explain.js | 140 ++-- test/lib/explore.js | 97 +-- test/lib/find-dupes.js | 27 +- test/lib/fund.js | 77 +- test/lib/get.js | 17 +- test/lib/help-search.js | 22 +- test/lib/help.js | 41 +- test/lib/hook.js | 48 +- test/lib/init.js | 34 +- test/lib/install-ci-test.js | 57 ++ test/lib/install-test.js | 57 ++ test/lib/install.js | 94 +-- test/lib/link.js | 23 +- test/lib/ll.js | 51 +- test/lib/load-all-commands.js | 6 +- test/lib/logout.js | 20 +- test/lib/ls.js | 244 +++---- test/lib/npm.js | 10 +- test/lib/org.js | 50 +- test/lib/outdated.js | 53 +- test/lib/owner.js | 56 +- test/lib/pack.js | 76 +- test/lib/ping.js | 18 +- test/lib/prefix.js | 6 +- test/lib/profile.js | 174 +++-- test/lib/prune.js | 16 +- test/lib/publish.js | 302 ++++---- test/lib/rebuild.js | 16 +- test/lib/repo.js | 11 +- test/lib/restart.js | 17 +- test/lib/root.js | 6 +- test/lib/run-script.js | 444 ++++++------ test/lib/search.js | 26 +- test/lib/set-script.js | 56 +- test/lib/set.js | 17 +- test/lib/shrinkwrap.js | 42 +- test/lib/star.js | 19 +- test/lib/stars.js | 14 +- test/lib/start.js | 17 +- test/lib/stop.js | 17 +- test/lib/team.js | 80 ++- test/lib/test.js | 19 +- test/lib/token.js | 70 +- test/lib/uninstall.js | 19 +- test/lib/unpublish.js | 92 ++- test/lib/unstar.js | 34 +- test/lib/update.js | 25 +- test/lib/utils/audit-error.js | 9 +- test/lib/utils/completion/installed-deep.js | 35 +- .../lib/utils/completion/installed-shallow.js | 10 +- test/lib/utils/get-identity.js | 57 +- test/lib/utils/lifecycle-cmd.js | 14 +- test/lib/utils/npm-usage.js | 206 +++--- test/lib/utils/open-url.js | 129 ++-- test/lib/utils/read-local-package.js | 15 +- test/lib/utils/reify-finish.js | 9 +- test/lib/utils/reify-output.js | 23 +- test/lib/version.js | 21 +- test/lib/view.js | 209 +++--- test/lib/whoami.js | 12 +- 165 files changed, 8445 insertions(+), 7469 deletions(-) create mode 100644 test/lib/install-ci-test.js create mode 100644 test/lib/install-test.js diff --git a/lib/access.js b/lib/access.js index 3c4ed590683c0..e11934af43ebc 100644 --- a/lib/access.js +++ b/lib/access.js @@ -3,25 +3,11 @@ const path = require('path') const libaccess = require('libnpmaccess') const readPackageJson = require('read-package-json-fast') -const npm = require('./npm.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') const usageUtil = require('./utils/usage.js') const getIdentity = require('./utils/get-identity.js') -const usage = usageUtil( - 'access', - 'npm access public []\n' + - 'npm access restricted []\n' + - 'npm access grant []\n' + - 'npm access revoke []\n' + - 'npm access 2fa-required []\n' + - 'npm access 2fa-not-required []\n' + - 'npm access ls-packages [||]\n' + - 'npm access ls-collaborators [ []]\n' + - 'npm access edit []' -) - const subcommands = [ 'public', 'restricted', @@ -34,152 +20,195 @@ const subcommands = [ '2fa-not-required', ] -const UsageError = (msg) => - Object.assign(new Error(`\nUsage: ${msg}\n\n` + usage), { - code: 'EUSAGE', - }) - -const cmd = (args, cb) => - access(args) - .then(x => cb(null, x)) - .catch(err => err.code === 'EUSAGE' - ? cb(err.message) - : cb(err) +class Access { + constructor (npm) { + this.npm = npm + } + + get usage () { + return usageUtil( + 'access', + 'npm access public []\n' + + 'npm access restricted []\n' + + 'npm access grant []\n' + + 'npm access revoke []\n' + + 'npm access 2fa-required []\n' + + 'npm access 2fa-not-required []\n' + + 'npm access ls-packages [||]\n' + + 'npm access ls-collaborators [ []]\n' + + 'npm access edit []' ) + } -const access = async ([cmd, ...args], cb) => { - const fn = subcommands.includes(cmd) && access[cmd] + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length === 2) + return subcommands + + switch (argv[2]) { + case 'grant': + if (argv.length === 3) + return ['read-only', 'read-write'] + else + return [] + + case 'public': + case 'restricted': + case 'ls-packages': + case 'ls-collaborators': + case 'edit': + case '2fa-required': + case '2fa-not-required': + case 'revoke': + return [] + default: + throw new Error(argv[2] + ' not recognized') + } + } - if (!cmd) - throw UsageError('Subcommand is required.') + exec (args, cb) { + this.access(args) + .then(x => cb(null, x)) + .catch(err => err.code === 'EUSAGE' + ? cb(err.message) + : cb(err) + ) + } - if (!fn) - throw UsageError(`${cmd} is not a recognized subcommand.`) + async access ([cmd, ...args]) { + if (!cmd) + throw this.usageError('Subcommand is required.') - return fn(args, { ...npm.flatOptions }) -} + if (!subcommands.includes(cmd) || !this[cmd]) + throw this.usageError(`${cmd} is not a recognized subcommand.`) -const completion = async (opts) => { - const argv = opts.conf.argv.remain - if (argv.length === 2) - return subcommands + return this[cmd](args, { ...this.npm.flatOptions }) + } - switch (argv[2]) { - case 'grant': - if (argv.length === 3) - return ['read-only', 'read-write'] - else - return [] + public ([pkg], opts) { + return this.modifyPackage(pkg, opts, libaccess.public) + } - case 'public': - case 'restricted': - case 'ls-packages': - case 'ls-collaborators': - case 'edit': - case '2fa-required': - case '2fa-not-required': - case 'revoke': - return [] - default: - throw new Error(argv[2] + ' not recognized') + restricted ([pkg], opts) { + return this.modifyPackage(pkg, opts, libaccess.restricted) } -} -access.public = ([pkg], opts) => - modifyPackage(pkg, opts, libaccess.public) + async grant ([perms, scopeteam, pkg], opts) { + if (!perms || (perms !== 'read-only' && perms !== 'read-write')) + throw this.usageError('First argument must be either `read-only` or `read-write`.') -access.restricted = ([pkg], opts) => - modifyPackage(pkg, opts, libaccess.restricted) + if (!scopeteam) + throw this.usageError('`` argument is required.') -access.grant = async ([perms, scopeteam, pkg], opts) => { - if (!perms || (perms !== 'read-only' && perms !== 'read-write')) - throw UsageError('First argument must be either `read-only` or `read-write`.') + const [, scope, team] = scopeteam.match(/^@?([^:]+):(.*)$/) || [] - if (!scopeteam) - throw UsageError('`` argument is required.') + if (!scope && !team) { + throw this.usageError( + 'Second argument used incorrect format.\n' + + 'Example: @example:developers' + ) + } - const [, scope, team] = scopeteam.match(/^@?([^:]+):(.*)$/) || [] + return this.modifyPackage(pkg, opts, (pkgName, opts) => + libaccess.grant(pkgName, scopeteam, perms, opts), false) + } - if (!scope && !team) { - throw UsageError( - 'Second argument used incorrect format.\n' + - 'Example: @example:developers' - ) + async revoke ([scopeteam, pkg], opts) { + if (!scopeteam) + throw this.usageError('`` argument is required.') + + const [, scope, team] = scopeteam.match(/^@?([^:]+):(.*)$/) || [] + + if (!scope || !team) { + throw this.usageError( + 'First argument used incorrect format.\n' + + 'Example: @example:developers' + ) + } + + return this.modifyPackage(pkg, opts, (pkgName, opts) => + libaccess.revoke(pkgName, scopeteam, opts)) } - return modifyPackage(pkg, opts, (pkgName, opts) => - libaccess.grant(pkgName, scopeteam, perms, opts), false) -} + get ['2fa-required'] () { + return this.tfaRequired + } -access.revoke = async ([scopeteam, pkg], opts) => { - if (!scopeteam) - throw UsageError('`` argument is required.') + tfaRequired ([pkg], opts) { + return this.modifyPackage(pkg, opts, libaccess.tfaRequired, false) + } - const [, scope, team] = scopeteam.match(/^@?([^:]+):(.*)$/) || [] + get ['2fa-not-required'] () { + return this.tfaNotRequired + } - if (!scope || !team) { - throw UsageError( - 'First argument used incorrect format.\n' + - 'Example: @example:developers' - ) + tfaNotRequired ([pkg], opts) { + return this.modifyPackage(pkg, opts, libaccess.tfaNotRequired, false) } - return modifyPackage(pkg, opts, (pkgName, opts) => - libaccess.revoke(pkgName, scopeteam, opts)) -} + get ['ls-packages'] () { + return this.lsPackages + } -access['2fa-required'] = access.tfaRequired = ([pkg], opts) => - modifyPackage(pkg, opts, libaccess.tfaRequired, false) + async lsPackages ([owner], opts) { + if (!owner) + owner = await getIdentity(this.npm, opts) -access['2fa-not-required'] = access.tfaNotRequired = ([pkg], opts) => - modifyPackage(pkg, opts, libaccess.tfaNotRequired, false) + const pkgs = await libaccess.lsPackages(owner, opts) -access['ls-packages'] = access.lsPackages = async ([owner], opts) => { - if (!owner) - owner = await getIdentity(opts) + // TODO - print these out nicely (breaking change) + output(JSON.stringify(pkgs, null, 2)) + } - const pkgs = await libaccess.lsPackages(owner, opts) + get ['ls-collaborators'] () { + return this.lsCollaborators + } - // TODO - print these out nicely (breaking change) - output(JSON.stringify(pkgs, null, 2)) -} + async lsCollaborators ([pkg, usr], opts) { + const pkgName = await this.getPackage(pkg, false) + const collabs = await libaccess.lsCollaborators(pkgName, usr, opts) -access['ls-collaborators'] = access.lsCollaborators = async ([pkg, usr], opts) => { - const pkgName = await getPackage(pkg, false) - const collabs = await libaccess.lsCollaborators(pkgName, usr, opts) + // TODO - print these out nicely (breaking change) + output(JSON.stringify(collabs, null, 2)) + } - // TODO - print these out nicely (breaking change) - output(JSON.stringify(collabs, null, 2)) -} + async edit () { + throw new Error('edit subcommand is not implemented yet') + } -access.edit = () => - Promise.reject(new Error('edit subcommand is not implemented yet')) - -const modifyPackage = (pkg, opts, fn, requireScope = true) => - getPackage(pkg, requireScope) - .then(pkgName => otplease(opts, opts => fn(pkgName, opts))) - -const getPackage = async (name, requireScope) => { - if (name && name.trim()) - return name.trim() - else { - try { - const pkg = await readPackageJson(path.resolve(npm.prefix, 'package.json')) - name = pkg.name - } catch (err) { - if (err.code === 'ENOENT') { - throw new Error( - 'no package name passed to command and no package.json found' - ) - } else - throw err + modifyPackage (pkg, opts, fn, requireScope = true) { + return this.getPackage(pkg, requireScope) + .then(pkgName => otplease(opts, opts => fn(pkgName, opts))) + } + + async getPackage (name, requireScope) { + if (name && name.trim()) + return name.trim() + else { + try { + const pkg = await readPackageJson(path.resolve(this.npm.prefix, 'package.json')) + name = pkg.name + } catch (err) { + if (err.code === 'ENOENT') { + throw new Error( + 'no package name passed to command and no package.json found' + ) + } else + throw err + } + + if (requireScope && !name.match(/^@[^/]+\/.*$/)) + throw this.usageError('This command is only available for scoped packages.') + else + return name } + } - if (requireScope && !name.match(/^@[^/]+\/.*$/)) - throw UsageError('This command is only available for scoped packages.') - else - return name + usageError (msg) { + return Object.assign(new Error(`\nUsage: ${msg}\n\n` + this.usage), { + code: 'EUSAGE', + }) } } -module.exports = Object.assign(cmd, { usage, completion, subcommands }) +module.exports = Access diff --git a/lib/adduser.js b/lib/adduser.js index c68c2b80f8790..dac0f5a46840d 100644 --- a/lib/adduser.js +++ b/lib/adduser.js @@ -1,5 +1,4 @@ const log = require('npmlog') -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') const replaceInfo = require('./utils/replace-info.js') @@ -10,66 +9,76 @@ const authTypes = { sso: require('./auth/sso.js'), } -const usage = usageUtil( - 'adduser', - 'npm adduser [--registry=url] [--scope=@orgname] [--always-auth]' -) +class AddUser { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => adduser(args).then(() => cb()).catch(cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'adduser', + 'npm adduser [--registry=url] [--scope=@orgname] [--always-auth]' + ) + } -const getRegistry = ({ scope, registry }) => { - if (scope) { - const scopedRegistry = npm.config.get(`${scope}:registry`) - const cliRegistry = npm.config.get('registry', 'cli') - if (scopedRegistry && !cliRegistry) - return scopedRegistry + exec (args, cb) { + this.adduser(args).then(() => cb()).catch(cb) } - return registry -} -const getAuthType = ({ authType }) => { - const type = authTypes[authType] + async adduser (args) { + const { scope } = this.npm.flatOptions + const registry = this.getRegistry(this.npm.flatOptions) + const auth = this.getAuthType(this.npm.flatOptions) + const creds = this.npm.config.getCredentialsByURI(registry) - if (!type) - throw new Error('no such auth module') + log.disableProgress() - return type -} + log.notice('', `Log in on ${replaceInfo(registry)}`) -const updateConfig = async ({ newCreds, registry, scope }) => { - npm.config.delete('_token', 'user') // prevent legacy pollution + const { message, newCreds } = await auth(this.npm, { + ...this.npm.flatOptions, + creds, + registry, + scope, + }) - if (scope) - npm.config.set(scope + ':registry', registry, 'user') + await this.updateConfig({ + newCreds, + registry, + scope, + }) - npm.config.setCredentialsByURI(registry, newCreds) - await npm.config.save('user') -} + output(message) + } -const adduser = async (args) => { - const { scope } = npm.flatOptions - const registry = getRegistry(npm.flatOptions) - const auth = getAuthType(npm.flatOptions) - const creds = npm.config.getCredentialsByURI(registry) + getRegistry ({ scope, registry }) { + if (scope) { + const scopedRegistry = this.npm.config.get(`${scope}:registry`) + const cliRegistry = this.npm.config.get('registry', 'cli') + if (scopedRegistry && !cliRegistry) + return scopedRegistry + } + return registry + } - log.disableProgress() + getAuthType ({ authType }) { + const type = authTypes[authType] - log.notice('', `Log in on ${replaceInfo(registry)}`) + if (!type) + throw new Error('no such auth module') - const { message, newCreds } = await auth({ - ...npm.flatOptions, - creds, - registry, - scope, - }) + return type + } - await updateConfig({ - newCreds, - registry, - scope, - }) + async updateConfig ({ newCreds, registry, scope }) { + this.npm.config.delete('_token', 'user') // prevent legacy pollution - output(message) -} + if (scope) + this.npm.config.set(scope + ':registry', registry, 'user') -module.exports = Object.assign(cmd, { usage }) + this.npm.config.setCredentialsByURI(registry, newCreds) + await this.npm.config.save('user') + } +} +module.exports = AddUser diff --git a/lib/audit.js b/lib/audit.js index 1b31401b1a6b0..dfa01cb2709fa 100644 --- a/lib/audit.js +++ b/lib/audit.js @@ -1,55 +1,65 @@ const Arborist = require('@npmcli/arborist') const auditReport = require('npm-audit-report') -const npm = require('./npm.js') const output = require('./utils/output.js') const reifyFinish = require('./utils/reify-finish.js') const auditError = require('./utils/audit-error.js') +const usageUtil = require('./utils/usage.js') -const audit = async args => { - const arb = new Arborist({ - ...npm.flatOptions, - audit: true, - path: npm.prefix, - }) - const fix = args[0] === 'fix' - await arb.audit({ fix }) - if (fix) - await reifyFinish(arb) - else { - // will throw if there's an error, because this is an audit command - auditError(arb.auditReport) - const reporter = npm.flatOptions.json ? 'json' : 'detail' - const result = auditReport(arb.auditReport, { - ...npm.flatOptions, - reporter, - }) - process.exitCode = process.exitCode || result.exitCode - output(result.report) +class Audit { + constructor (npm) { + this.npm = npm } -} -const cmd = (args, cb) => audit(args).then(() => cb()).catch(cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'audit', + 'npm audit [--json] [--production]' + + '\nnpm audit fix ' + + '[--force|--package-lock-only|--dry-run|--production|--only=(dev|prod)]' + ) + } -const usageUtil = require('./utils/usage') -const usage = usageUtil( - 'audit', - 'npm audit [--json] [--production]' + - '\nnpm audit fix ' + - '[--force|--package-lock-only|--dry-run|--production|--only=(dev|prod)]' -) + async completion (opts) { + const argv = opts.conf.argv.remain -const completion = async (opts) => { - const argv = opts.conf.argv.remain + if (argv.length === 2) + return ['fix'] - if (argv.length === 2) - return ['fix'] + switch (argv[2]) { + case 'fix': + return [] + default: + throw new Error(argv[2] + ' not recognized') + } + } - switch (argv[2]) { - case 'fix': - return [] - default: - throw new Error(argv[2] + ' not recognized') + exec (args, cb) { + this.audit(args).then(() => cb()).catch(cb) + } + + async audit (args) { + const arb = new Arborist({ + ...this.npm.flatOptions, + audit: true, + path: this.npm.prefix, + }) + const fix = args[0] === 'fix' + await arb.audit({ fix }) + if (fix) + await reifyFinish(this.npm, arb) + else { + // will throw if there's an error, because this is an audit command + auditError(this.npm, arb.auditReport) + const reporter = this.npm.flatOptions.json ? 'json' : 'detail' + const result = auditReport(arb.auditReport, { + ...this.npm.flatOptions, + reporter, + }) + process.exitCode = process.exitCode || result.exitCode + output(result.report) + } } } -module.exports = Object.assign(cmd, { usage, completion }) +module.exports = Audit diff --git a/lib/auth/legacy.js b/lib/auth/legacy.js index f291ca794e7f1..8659446dc4c02 100644 --- a/lib/auth/legacy.js +++ b/lib/auth/legacy.js @@ -4,11 +4,6 @@ const profile = require('npm-profile') const openUrl = require('../utils/open-url.js') const read = require('../utils/read-user-info.js') -// TODO: refactor lib/utils/open-url and its usages -const openerPromise = (url) => new Promise((resolve, reject) => { - openUrl(url, 'to complete your login please visit', (er) => er ? reject(er) : resolve()) -}) - const loginPrompter = async (creds) => { const opts = { log: log } @@ -19,7 +14,7 @@ const loginPrompter = async (creds) => { return creds } -const login = async (opts) => { +const login = async (npm, opts) => { let res const requestOTP = async () => { @@ -54,6 +49,7 @@ const login = async (opts) => { return newUser } + const openerPromise = (url) => openUrl(npm, url, 'to complete your login please visit') try { res = await profile.login(openerPromise, loginPrompter, opts) } catch (err) { diff --git a/lib/auth/oauth.js b/lib/auth/oauth.js index ee45317113421..99c2ca0ca04b7 100644 --- a/lib/auth/oauth.js +++ b/lib/auth/oauth.js @@ -1,9 +1,8 @@ const sso = require('./sso.js') -const npm = require('../npm.js') -const login = (opts) => { +const login = (npm, opts) => { npm.config.set('sso-type', 'oauth') - return sso(opts) + return sso(npm, opts) } module.exports = login diff --git a/lib/auth/saml.js b/lib/auth/saml.js index f30d82849dbf9..3dd31ca013f52 100644 --- a/lib/auth/saml.js +++ b/lib/auth/saml.js @@ -1,9 +1,8 @@ const sso = require('./sso.js') -const npm = require('../npm.js') -const login = (opts) => { +const login = (npm, opts) => { npm.config.set('sso-type', 'saml') - return sso(opts) + return sso(npm, opts) } module.exports = login diff --git a/lib/auth/sso.js b/lib/auth/sso.js index 378295f5f606f..ca8c501684c29 100644 --- a/lib/auth/sso.js +++ b/lib/auth/sso.js @@ -13,7 +13,6 @@ const log = require('npmlog') const profile = require('npm-profile') const npmFetch = require('npm-registry-fetch') -const npm = require('../npm.js') const openUrl = promisify(require('../utils/open-url.js')) const otplease = require('../utils/otplease.js') @@ -38,7 +37,7 @@ function sleep (time) { return new Promise((resolve) => setTimeout(resolve, time)) } -const login = async ({ creds, registry, scope }) => { +const login = async (npm, { creds, registry, scope }) => { log.warn('deprecated', 'SSO --auth-type is deprecated') const opts = { ...npm.flatOptions, creds, registry, scope } @@ -65,7 +64,7 @@ const login = async ({ creds, registry, scope }) => { if (!sso) throw new Error('no SSO URL returned by services') - await openUrl(sso, 'to complete your login please visit') + await openUrl(npm, sso, 'to complete your login please visit') const username = await pollForSession({ registry, token, opts }) diff --git a/lib/bin.js b/lib/bin.js index e627ce22f13a6..11490c41cbcc5 100644 --- a/lib/bin.js +++ b/lib/bin.js @@ -1,13 +1,26 @@ -const npm = require('./npm.js') const output = require('./utils/output.js') +const envPath = require('./utils/path.js') const usageUtil = require('./utils/usage.js') -const PATH = require('./utils/path.js') -const cmd = (args, cb) => bin(args).then(() => cb()).catch(cb) -const usage = usageUtil('bin', 'npm bin [-g]') -const bin = async (args, cb) => { - const b = npm.bin - output(b) - if (npm.flatOptions.global && !PATH.includes(b)) - console.error('(not in PATH env variable)') + +class Bin { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('bin', 'npm bin [-g]') + } + + exec (args, cb) { + this.bin(args).then(() => cb()).catch(cb) + } + + async bin (args) { + const b = this.npm.bin + output(b) + if (this.npm.flatOptions.global && !envPath.includes(b)) + console.error('(not in PATH env variable)') + } } -module.exports = Object.assign(cmd, { usage }) +module.exports = Bin diff --git a/lib/birthday.js b/lib/birthday.js index 6c71a9e715668..5ea855512f9f6 100644 --- a/lib/birthday.js +++ b/lib/birthday.js @@ -1,11 +1,18 @@ -const npm = require('./npm.js') -module.exports = (_, cb) => { - Object.defineProperty(npm, 'flatOptions', { - value: { - ...npm.flatOptions, - package: ['@npmcli/npm-birthday'], - yes: true, - }, - }) - return npm.commands.exec(['npm-birthday'], cb) +class Birthday { + constructor (npm) { + this.npm = npm + Object.defineProperty(this.npm, 'flatOptions', { + value: { + ...npm.flatOptions, + package: ['@npmcli/npm-birthday'], + yes: true, + }, + }) + } + + exec (args, cb) { + return this.npm.commands.exec(['npm-birthday'], cb) + } } + +module.exports = Birthday diff --git a/lib/bugs.js b/lib/bugs.js index 09856313ce883..fb0d7c92770c7 100644 --- a/lib/bugs.js +++ b/lib/bugs.js @@ -1,46 +1,55 @@ const log = require('npmlog') const pacote = require('pacote') -const { promisify } = require('util') -const openUrl = promisify(require('./utils/open-url.js')) +const openUrl = require('./utils/open-url.js') const usageUtil = require('./utils/usage.js') -const npm = require('./npm.js') const hostedFromMani = require('./utils/hosted-git-info-from-manifest.js') -const usage = usageUtil('bugs', 'npm bugs []') +class Bugs { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => bugs(args).then(() => cb()).catch(cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('bugs', 'npm bugs []') + } -const bugs = async args => { - if (!args || !args.length) - args = ['.'] + exec (args, cb) { + this.bugs(args).then(() => cb()).catch(cb) + } - await Promise.all(args.map(pkg => getBugs(pkg))) -} + async bugs (args) { + if (!args || !args.length) + args = ['.'] -const getBugsUrl = mani => { - if (mani.bugs) { - if (typeof mani.bugs === 'string') - return mani.bugs + await Promise.all(args.map(pkg => this.getBugs(pkg))) + } - if (typeof mani.bugs === 'object' && mani.bugs.url) - return mani.bugs.url + async getBugs (pkg) { + const opts = { ...this.npm.flatOptions, fullMetadata: true } + const mani = await pacote.manifest(pkg, opts) + const url = this.getBugsUrl(mani) + log.silly('bugs', 'url', url) + await openUrl(this.npm, url, `${mani.name} bug list available at the following URL`) } - // try to get it from the repo, if possible - const info = hostedFromMani(mani) - if (info) - return info.bugs() + getBugsUrl (mani) { + if (mani.bugs) { + if (typeof mani.bugs === 'string') + return mani.bugs - // just send them to the website, hopefully that has some info! - return `https://www.npmjs.com/package/${mani.name}` -} + if (typeof mani.bugs === 'object' && mani.bugs.url) + return mani.bugs.url + } -const getBugs = async pkg => { - const opts = { ...npm.flatOptions, fullMetadata: true } - const mani = await pacote.manifest(pkg, opts) - const url = getBugsUrl(mani) - log.silly('bugs', 'url', url) - await openUrl(url, `${mani.name} bug list available at the following URL`) + // try to get it from the repo, if possible + const info = hostedFromMani(mani) + if (info) + return info.bugs() + + // just send them to the website, hopefully that has some info! + return `https://www.npmjs.com/package/${mani.name}` + } } -module.exports = Object.assign(cmd, { usage }) +module.exports = Bugs diff --git a/lib/cache.js b/lib/cache.js index 7b84353b4a19b..8469559764fb3 100644 --- a/lib/cache.js +++ b/lib/cache.js @@ -1,62 +1,69 @@ const cacache = require('cacache') const { promisify } = require('util') const log = require('npmlog') -const npm = require('./npm.js') const output = require('./utils/output.js') const pacote = require('pacote') const path = require('path') const rimraf = promisify(require('rimraf')) const usageUtil = require('./utils/usage.js') +class Cache { + constructor (npm) { + this.npm = npm + } -const usage = usageUtil('cache', - 'npm cache add ' + - '\nnpm cache add ' + - '\nnpm cache add ' + - '\nnpm cache add ' + - '\nnpm cache add @' + - '\nnpm cache clean' + - '\nnpm cache verify' -) - -const completion = async (opts) => { - const argv = opts.conf.argv.remain - if (argv.length === 2) - return ['add', 'clean', 'verify'] - - // TODO - eventually... - switch (argv[2]) { - case 'verify': - case 'clean': - case 'add': - return [] + get usage () { + return usageUtil('cache', + 'npm cache add ' + + '\nnpm cache add ' + + '\nnpm cache add ' + + '\nnpm cache add ' + + '\nnpm cache add @' + + '\nnpm cache clean' + + '\nnpm cache verify' + ) } -} -const cmd = (args, cb) => cache(args).then(() => cb()).catch(cb) - -const cache = async (args) => { - const cmd = args.shift() - switch (cmd) { - case 'rm': case 'clear': case 'clean': - return await clean(args) - case 'add': - return await add(args) - case 'verify': case 'check': - return await verify() - default: - throw Object.assign(new Error(usage), { code: 'EUSAGE' }) + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length === 2) + return ['add', 'clean', 'verify'] + + // TODO - eventually... + switch (argv[2]) { + case 'verify': + case 'clean': + case 'add': + return [] + } + } + + exec (args, cb) { + this.cache(args).then(() => cb()).catch(cb) + } + + async cache (args) { + const cmd = args.shift() + switch (cmd) { + case 'rm': case 'clear': case 'clean': + return await this.clean(args) + case 'add': + return await this.add(args) + case 'verify': case 'check': + return await this.verify() + default: + throw Object.assign(new Error(this.usage), { code: 'EUSAGE' }) + } } -} -// npm cache clean [pkg]* -const clean = async (args) => { - if (args.length) - throw new Error('npm cache clear does not accept arguments') + // npm cache clean [pkg]* + async clean (args) { + if (args.length) + throw new Error('npm cache clear does not accept arguments') - const cachePath = path.join(npm.cache, '_cacache') - if (!npm.flatOptions.force) { - throw new Error(`As of npm@5, the npm cache self-heals from corruption issues + const cachePath = path.join(this.npm.cache, '_cacache') + if (!this.npm.flatOptions.force) { + throw new Error(`As of npm@5, the npm cache self-heals from corruption issues by treating integrity mismatches as cache misses. As a result, data extracted from the cache is guaranteed to be valid. If you want to make sure everything is consistent, use \`npm cache verify\` @@ -70,52 +77,53 @@ temporary cache instead of nuking the actual one. If you're sure you want to delete the entire cache, rerun this command with --force.`) + } + return rimraf(cachePath) } - return rimraf(cachePath) -} -// npm cache add -// npm cache add -// npm cache add -// npm cache add -const add = async (args) => { - const usage = 'Usage:\n' + - ' npm cache add \n' + - ' npm cache add @\n' + - ' npm cache add \n' + - ' npm cache add \n' - log.silly('cache add', 'args', args) - const spec = args[0] && args[0] + - (args[1] === undefined || args[1] === null ? '' : `@${args[1]}`) - - if (!spec) - throw Object.assign(new Error(usage), { code: 'EUSAGE' }) - - log.silly('cache add', 'spec', spec) - const opts = { ...npm.flatOptions } - - // we ask pacote for the thing, and then just throw the data - // away so that it tee-pipes it into the cache like it does - // for a normal request. - await pacote.tarball.stream(spec, stream => { - stream.resume() - return stream.promise() - }, opts) -} + // npm cache add + // npm cache add + // npm cache add + // npm cache add + async add (args) { + const usage = 'Usage:\n' + + ' npm cache add \n' + + ' npm cache add @\n' + + ' npm cache add \n' + + ' npm cache add \n' + log.silly('cache add', 'args', args) + const spec = args[0] && args[0] + + (args[1] === undefined || args[1] === null ? '' : `@${args[1]}`) + + if (!spec) + throw Object.assign(new Error(usage), { code: 'EUSAGE' }) + + log.silly('cache add', 'spec', spec) + const opts = { ...this.npm.flatOptions } -const verify = async () => { - const cache = path.join(npm.cache, '_cacache') - const prefix = cache.indexOf(process.env.HOME) === 0 - ? `~${cache.substr(process.env.HOME.length)}` - : cache - const stats = await cacache.verify(cache) - output(`Cache verified and compressed (${prefix})`) - output(`Content verified: ${stats.verifiedContent} (${stats.keptSize} bytes)`) - stats.badContentCount && output(`Corrupted content removed: ${stats.badContentCount}`) - stats.reclaimedCount && output(`Content garbage-collected: ${stats.reclaimedCount} (${stats.reclaimedSize} bytes)`) - stats.missingContent && output(`Missing content: ${stats.missingContent}`) - output(`Index entries: ${stats.totalEntries}`) - output(`Finished in ${stats.runTime.total / 1000}s`) + // we ask pacote for the thing, and then just throw the data + // away so that it tee-pipes it into the cache like it does + // for a normal request. + await pacote.tarball.stream(spec, stream => { + stream.resume() + return stream.promise() + }, opts) + } + + async verify () { + const cache = path.join(this.npm.cache, '_cacache') + const prefix = cache.indexOf(process.env.HOME) === 0 + ? `~${cache.substr(process.env.HOME.length)}` + : cache + const stats = await cacache.verify(cache) + output(`Cache verified and compressed (${prefix})`) + output(`Content verified: ${stats.verifiedContent} (${stats.keptSize} bytes)`) + stats.badContentCount && output(`Corrupted content removed: ${stats.badContentCount}`) + stats.reclaimedCount && output(`Content garbage-collected: ${stats.reclaimedCount} (${stats.reclaimedSize} bytes)`) + stats.missingContent && output(`Missing content: ${stats.missingContent}`) + output(`Index entries: ${stats.totalEntries}`) + output(`Finished in ${stats.runTime.total / 1000}s`) + } } -module.exports = Object.assign(cmd, { completion, usage }) +module.exports = Cache diff --git a/lib/ci.js b/lib/ci.js index 51c165accef7a..03a91a60463f2 100644 --- a/lib/ci.js +++ b/lib/ci.js @@ -7,13 +7,8 @@ const fs = require('fs') const readdir = util.promisify(fs.readdir) const log = require('npmlog') -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil('ci', 'npm ci') - -const cmd = (args, cb) => ci().then(() => cb()).catch(cb) - const removeNodeModules = async where => { const rimrafOpts = { glob: false } process.emit('time', 'npm-ci:rm') @@ -24,55 +19,70 @@ const removeNodeModules = async where => { process.emit('timeEnd', 'npm-ci:rm') } -const ci = async () => { - if (npm.flatOptions.global) { - const err = new Error('`npm ci` does not work for global packages') - err.code = 'ECIGLOBAL' - throw err +class CI { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('ci', 'npm ci') } - const where = npm.prefix - const { scriptShell, ignoreScripts } = npm.flatOptions - const arb = new Arborist({ ...npm.flatOptions, path: where }) + exec (args, cb) { + this.ci().then(() => cb()).catch(cb) + } + + async ci () { + if (this.npm.flatOptions.global) { + const err = new Error('`npm ci` does not work for global packages') + err.code = 'ECIGLOBAL' + throw err + } + + const where = this.npm.prefix + const { scriptShell, ignoreScripts } = this.npm.flatOptions + const arb = new Arborist({ ...this.npm.flatOptions, path: where }) - await Promise.all([ - arb.loadVirtual().catch(er => { - log.verbose('loadVirtual', er.stack) - const msg = - 'The `npm ci` command can only install with an existing package-lock.json or\n' + - 'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' + - 'later to generate a package-lock.json file, then try again.' - throw new Error(msg) - }), - removeNodeModules(where), - ]) - // npm ci should never modify the lockfile or package.json - await arb.reify({ ...npm.flatOptions, save: false }) + await Promise.all([ + arb.loadVirtual().catch(er => { + log.verbose('loadVirtual', er.stack) + const msg = + 'The `npm ci` command can only install with an existing package-lock.json or\n' + + 'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' + + 'later to generate a package-lock.json file, then try again.' + throw new Error(msg) + }), + removeNodeModules(where), + ]) + // npm ci should never modify the lockfile or package.json + await arb.reify({ ...this.npm.flatOptions, save: false }) - // run the same set of scripts that `npm install` runs. - if (!ignoreScripts) { - const scripts = [ - 'preinstall', - 'install', - 'postinstall', - 'prepublish', // XXX should we remove this finally?? - 'preprepare', - 'prepare', - 'postprepare', - ] - for (const event of scripts) { - await runScript({ - path: where, - args: [], - scriptShell, - stdio: 'inherit', - stdioString: true, - banner: log.level !== 'silent', - event, - }) + // run the same set of scripts that `npm install` runs. + if (!ignoreScripts) { + const scripts = [ + 'preinstall', + 'install', + 'postinstall', + 'prepublish', // XXX should we remove this finally?? + 'preprepare', + 'prepare', + 'postprepare', + ] + for (const event of scripts) { + await runScript({ + path: where, + args: [], + scriptShell, + stdio: 'inherit', + stdioString: true, + banner: log.level !== 'silent', + event, + }) + } } + await reifyFinish(this.npm, arb) } - await reifyFinish(arb) } -module.exports = Object.assign(cmd, {usage}) +module.exports = CI diff --git a/lib/completion.js b/lib/completion.js index b31867d988a69..4c37e6ef354ef 100644 --- a/lib/completion.js +++ b/lib/completion.js @@ -29,7 +29,6 @@ // as an array. // -const npm = require('./npm.js') const { types, shorthands } = require('./utils/config.js') const deref = require('./utils/deref-command.js') const { aliases, cmdList, plumbing } = require('./utils/cmd-list.js') @@ -44,115 +43,127 @@ const output = require('./utils/output.js') const fileExists = require('./utils/file-exists.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil('completion', 'source <(npm completion)') const { promisify } = require('util') -const cmd = (args, cb) => compl(args).then(() => cb()).catch(cb) +class Completion { + constructor (npm) { + this.npm = npm + } -// completion for the completion command -const completion = async (opts) => { - if (opts.w > 2) - return + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('completion', 'source <(npm completion)') + } - const { resolve } = require('path') - const [bashExists, zshExists] = await Promise.all([ - fileExists(resolve(process.env.HOME, '.bashrc')), - fileExists(resolve(process.env.HOME, '.zshrc')), - ]) - const out = [] - if (zshExists) - out.push(['>>', '~/.zshrc']) - - if (bashExists) - out.push(['>>', '~/.bashrc']) - - return out -} + // completion for the completion command + async completion (opts) { + if (opts.w > 2) + return -const compl = async args => { - if (isWindowsShell) { - const msg = 'npm completion supported only in MINGW / Git bash on Windows' - throw Object.assign(new Error(msg), { - code: 'ENOTSUP', - }) + const { resolve } = require('path') + const [bashExists, zshExists] = await Promise.all([ + fileExists(resolve(process.env.HOME, '.bashrc')), + fileExists(resolve(process.env.HOME, '.zshrc')), + ]) + const out = [] + if (zshExists) + out.push(['>>', '~/.zshrc']) + + if (bashExists) + out.push(['>>', '~/.bashrc']) + + return out } - const { COMP_CWORD, COMP_LINE, COMP_POINT } = process.env + exec (args, cb) { + this.compl(args).then(() => cb()).catch(cb) + } - // if the COMP_* isn't in the env, then just dump the script. - if (COMP_CWORD === undefined || + async compl (args) { + if (isWindowsShell) { + const msg = 'npm completion supported only in MINGW / Git bash on Windows' + throw Object.assign(new Error(msg), { + code: 'ENOTSUP', + }) + } + + const { COMP_CWORD, COMP_LINE, COMP_POINT } = process.env + + // if the COMP_* isn't in the env, then just dump the script. + if (COMP_CWORD === undefined || COMP_LINE === undefined || COMP_POINT === undefined) - return dumpScript() - - // ok we're actually looking at the envs and outputting the suggestions - // get the partial line and partial word, - // if the point isn't at the end. - // ie, tabbing at: npm foo b|ar - const w = +COMP_CWORD - const words = args.map(unescape) - const word = words[w] - const line = COMP_LINE - const point = +COMP_POINT - const partialLine = line.substr(0, point) - const partialWords = words.slice(0, w) - - // figure out where in that last word the point is. - const partialWordRaw = args[w] - let i = partialWordRaw.length - while (partialWordRaw.substr(0, i) !== partialLine.substr(-1 * i) && i > 0) - i-- - - const partialWord = unescape(partialWordRaw.substr(0, i)) - partialWords.push(partialWord) - - const opts = { - words, - w, - word, - line, - lineLength: line.length, - point, - partialLine, - partialWords, - partialWord, - raw: args, - } + return dumpScript() + + // ok we're actually looking at the envs and outputting the suggestions + // get the partial line and partial word, + // if the point isn't at the end. + // ie, tabbing at: npm foo b|ar + const w = +COMP_CWORD + const words = args.map(unescape) + const word = words[w] + const line = COMP_LINE + const point = +COMP_POINT + const partialLine = line.substr(0, point) + const partialWords = words.slice(0, w) + + // figure out where in that last word the point is. + const partialWordRaw = args[w] + let i = partialWordRaw.length + while (partialWordRaw.substr(0, i) !== partialLine.substr(-1 * i) && i > 0) + i-- + + const partialWord = unescape(partialWordRaw.substr(0, i)) + partialWords.push(partialWord) + + const opts = { + words, + w, + word, + line, + lineLength: line.length, + point, + partialLine, + partialWords, + partialWord, + raw: args, + } - if (partialWords.slice(0, -1).indexOf('--') === -1) { - if (word.charAt(0) === '-') - return wrap(opts, configCompl(opts)) + if (partialWords.slice(0, -1).indexOf('--') === -1) { + if (word.charAt(0) === '-') + return wrap(opts, configCompl(opts)) - if (words[w - 1] && + if (words[w - 1] && words[w - 1].charAt(0) === '-' && !isFlag(words[w - 1])) { - // awaiting a value for a non-bool config. - // don't even try to do this for now - return wrap(opts, configValueCompl(opts)) + // awaiting a value for a non-bool config. + // don't even try to do this for now + return wrap(opts, configValueCompl(opts)) + } } - } - // try to find the npm command. - // it's the first thing after all the configs. - // take a little shortcut and use npm's arg parsing logic. - // don't have to worry about the last arg being implicitly - // boolean'ed, since the last block will catch that. - const parsed = opts.conf = - nopt(types, shorthands, partialWords.slice(0, -1), 0) - // check if there's a command already. - const cmd = parsed.argv.remain[1] - if (!cmd) - return wrap(opts, cmdCompl(opts)) - - Object.keys(parsed).forEach(k => npm.config.set(k, parsed[k])) - - // at this point, if words[1] is some kind of npm command, - // then complete on it. - // otherwise, do nothing - const impl = npm.commands[cmd] - if (impl && impl.completion) { - const comps = await impl.completion(opts) - return wrap(opts, comps) + // try to find the npm command. + // it's the first thing after all the configs. + // take a little shortcut and use npm's arg parsing logic. + // don't have to worry about the last arg being implicitly + // boolean'ed, since the last block will catch that. + const parsed = opts.conf = + nopt(types, shorthands, partialWords.slice(0, -1), 0) + // check if there's a command already. + const cmd = parsed.argv.remain[1] + if (!cmd) + return wrap(opts, cmdCompl(opts)) + + Object.keys(parsed).forEach(k => this.npm.config.set(k, parsed[k])) + + // at this point, if words[1] is some kind of npm command, + // then complete on it. + // otherwise, do nothing + const impl = this.npm.commands[cmd] + if (impl && impl.completion) { + const comps = await impl.completion(opts) + return wrap(opts, comps) + } } } @@ -266,4 +277,4 @@ const cmdCompl = opts => { return fullList } -module.exports = Object.assign(cmd, { completion, usage }) +module.exports = Completion diff --git a/lib/config.js b/lib/config.js index e4da296de8f88..2805db9b80ec7 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,4 +1,3 @@ -const npm = require('./npm.js') const { defaults, types } = require('./utils/config.js') const usageUtil = require('./utils/usage.js') const output = require('./utils/output.js') @@ -13,165 +12,173 @@ const { spawn } = require('child_process') const { EOL } = require('os') const ini = require('ini') -const usage = usageUtil( - 'config', - 'npm config set = [= ...]' + - '\nnpm config get [ [ ...]]' + - '\nnpm config delete [ ...]' + - '\nnpm config list [--json]' + - '\nnpm config edit' + - '\nnpm set = [= ...]' + - '\nnpm get [ [ ...]]' -) - -const cmd = (args, cb) => config(args).then(() => cb()).catch(cb) - -const completion = async (opts) => { - const argv = opts.conf.argv.remain - if (argv[1] !== 'config') - argv.unshift('config') - - if (argv.length === 2) { - const cmds = ['get', 'set', 'delete', 'ls', 'rm', 'edit'] - if (opts.partialWord !== 'l') - cmds.push('list') - - return cmds +// take an array of `[key, value, k2=v2, k3, v3, ...]` and turn into +// { key: value, k2: v2, k3: v3 } +const keyValues = args => { + const kv = {} + for (let i = 0; i < args.length; i++) { + const arg = args[i].split('=') + const key = arg.shift() + const val = arg.length ? arg.join('=') + : i < args.length - 1 ? args[++i] + : '' + kv[key.trim()] = val.trim() } + return kv +} - const action = argv[2] - switch (action) { - case 'set': - // todo: complete with valid values, if possible. - if (argv.length > 3) - return [] +const publicVar = k => !/^(\/\/[^:]+:)?_/.test(k) - // fallthrough - /* eslint no-fallthrough:0 */ - case 'get': - case 'delete': - case 'rm': - return Object.keys(types) - case 'edit': - case 'list': - case 'ls': - default: - return [] +class Config { + constructor (npm) { + this.npm = npm } -} -const UsageError = () => - Object.assign(new Error(usage), { code: 'EUSAGE' }) + get usage () { + return usageUtil( + 'config', + 'npm config set = [= ...]' + + '\nnpm config get [ [ ...]]' + + '\nnpm config delete [ ...]' + + '\nnpm config list [--json]' + + '\nnpm config edit' + + '\nnpm set = [= ...]' + + '\nnpm get [ [ ...]]' + ) + } + + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv[1] !== 'config') + argv.unshift('config') + + if (argv.length === 2) { + const cmds = ['get', 'set', 'delete', 'ls', 'rm', 'edit'] + if (opts.partialWord !== 'l') + cmds.push('list') -const config = async ([action, ...args]) => { - npm.log.disableProgress() - try { + return cmds + } + + const action = argv[2] switch (action) { case 'set': - await set(args) - break + // todo: complete with valid values, if possible. + if (argv.length > 3) + return [] + + // fallthrough + /* eslint no-fallthrough:0 */ case 'get': - await get(args) - break case 'delete': case 'rm': - case 'del': - await del(args) - break + return Object.keys(types) + case 'edit': case 'list': case 'ls': - await (npm.flatOptions.json ? listJson() : list()) - break - case 'edit': - await edit() - break default: - throw UsageError() + return [] } - } finally { - npm.log.enableProgress() } -} -// take an array of `[key, value, k2=v2, k3, v3, ...]` and turn into -// { key: value, k2: v2, k3: v3 } -const keyValues = args => { - const kv = {} - for (let i = 0; i < args.length; i++) { - const arg = args[i].split('=') - const key = arg.shift() - const val = arg.length ? arg.join('=') - : i < args.length - 1 ? args[++i] - : '' - kv[key.trim()] = val.trim() + exec (args, cb) { + this.config(args).then(() => cb()).catch(cb) } - return kv -} -const set = async (args) => { - if (!args.length) - throw UsageError() - - const where = npm.flatOptions.global ? 'global' : 'user' - for (const [key, val] of Object.entries(keyValues(args))) { - npm.log.info('config', 'set %j %j', key, val) - npm.config.set(key, val || '', where) - if (!npm.config.validate(where)) - npm.log.warn('config', 'omitting invalid config values') + async config ([action, ...args]) { + this.npm.log.disableProgress() + try { + switch (action) { + case 'set': + await this.set(args) + break + case 'get': + await this.get(args) + break + case 'delete': + case 'rm': + case 'del': + await this.del(args) + break + case 'list': + case 'ls': + await (this.npm.flatOptions.json ? this.listJson() : this.list()) + break + case 'edit': + await this.edit() + break + default: + throw this.usageError() + } + } finally { + this.npm.log.enableProgress() + } } - await npm.config.save(where) -} + async set (args) { + if (!args.length) + throw this.usageError() + + const where = this.npm.flatOptions.global ? 'global' : 'user' + for (const [key, val] of Object.entries(keyValues(args))) { + this.npm.log.info('config', 'set %j %j', key, val) + this.npm.config.set(key, val || '', where) + if (!this.npm.config.validate(where)) + this.npm.log.warn('config', 'omitting invalid config values') + } + + await this.npm.config.save(where) + } -const get = async keys => { - if (!keys.length) - return list() + async get (keys) { + if (!keys.length) + return this.list() - const out = [] - for (const key of keys) { - if (!publicVar(key)) - throw `The ${key} option is protected, and cannot be retrieved in this way` + const out = [] + for (const key of keys) { + if (!publicVar(key)) + throw `The ${key} option is protected, and cannot be retrieved in this way` - const pref = keys.length > 1 ? `${key}=` : '' - out.push(pref + npm.config.get(key)) + const pref = keys.length > 1 ? `${key}=` : '' + out.push(pref + this.npm.config.get(key)) + } + output(out.join('\n')) } - output(out.join('\n')) -} -const del = async keys => { - if (!keys.length) - throw UsageError() + async del (keys) { + if (!keys.length) + throw this.usageError() - const where = npm.flatOptions.global ? 'global' : 'user' - for (const key of keys) - npm.config.delete(key, where) - await npm.config.save(where) -} + const where = this.npm.flatOptions.global ? 'global' : 'user' + for (const key of keys) + this.npm.config.delete(key, where) + await this.npm.config.save(where) + } -const edit = async () => { - const { editor: e, global } = npm.flatOptions - const where = global ? 'global' : 'user' - const file = npm.config.data.get(where).source - - // save first, just to make sure it's synced up - // this also removes all the comments from the last time we edited it. - await npm.config.save(where) - - const data = ( - await readFile(file, 'utf8').catch(() => '') - ).replace(/\r\n/g, '\n') - const defData = Object.entries(defaults).reduce((str, [key, val]) => { - const obj = { [key]: val } - const i = ini.stringify(obj) - .replace(/\r\n/g, '\n') // normalizes output from ini.stringify - .replace(/\n$/m, '') - .replace(/^/g, '; ') - .replace(/\n/g, '\n; ') - .split('\n') - return str + '\n' + i - }, '') - - const tmpData = `;;;; + async edit () { + const { editor: e, global } = this.npm.flatOptions + const where = global ? 'global' : 'user' + const file = this.npm.config.data.get(where).source + + // save first, just to make sure it's synced up + // this also removes all the comments from the last time we edited it. + await this.npm.config.save(where) + + const data = ( + await readFile(file, 'utf8').catch(() => '') + ).replace(/\r\n/g, '\n') + const defData = Object.entries(defaults).reduce((str, [key, val]) => { + const obj = { [key]: val } + const i = ini.stringify(obj) + .replace(/\r\n/g, '\n') // normalizes output from ini.stringify + .replace(/\n$/m, '') + .replace(/^/g, '; ') + .replace(/\n/g, '\n; ') + .split('\n') + return str + '\n' + i + }, '') + + const tmpData = `;;;; ; npm ${where}config file: ${file} ; this is a simple ini-formatted file ; lines that start with semi-colons are comments @@ -190,64 +197,67 @@ ${data.split('\n').sort((a, b) => a.localeCompare(b)).join('\n').trim()} ${defData} `.split('\n').join(EOL) - await mkdirp(dirname(file)) - await writeFile(file, tmpData, 'utf8') - await new Promise((resolve, reject) => { - const [bin, ...args] = e.split(/\s+/) - const editor = spawn(bin, [...args, file], { stdio: 'inherit' }) - editor.on('exit', (code) => { - if (code) - return reject(new Error(`editor process exited with code: ${code}`)) - return resolve() + await mkdirp(dirname(file)) + await writeFile(file, tmpData, 'utf8') + await new Promise((resolve, reject) => { + const [bin, ...args] = e.split(/\s+/) + const editor = spawn(bin, [...args, file], { stdio: 'inherit' }) + editor.on('exit', (code) => { + if (code) + return reject(new Error(`editor process exited with code: ${code}`)) + return resolve() + }) }) - }) -} - -const publicVar = k => !/^(\/\/[^:]+:)?_/.test(k) + } -const list = async () => { - const msg = [] - const { long } = npm.flatOptions - for (const [where, { data, source }] of npm.config.data.entries()) { - if (where === 'default' && !long) - continue + async list () { + const msg = [] + const { long } = this.npm.flatOptions + for (const [where, { data, source }] of this.npm.config.data.entries()) { + if (where === 'default' && !long) + continue + + const keys = Object.keys(data).sort((a, b) => a.localeCompare(b)) + if (!keys.length) + continue + + msg.push(`; "${where}" config from ${source}`, '') + for (const k of keys) { + const v = publicVar(k) ? JSON.stringify(data[k]) : '(protected)' + const src = this.npm.config.find(k) + const overridden = src !== where + msg.push((overridden ? '; ' : '') + + `${k} = ${v} ${overridden ? `; overridden by ${src}` : ''}`) + } + msg.push('') + } - const keys = Object.keys(data).sort((a, b) => a.localeCompare(b)) - if (!keys.length) - continue - - msg.push(`; "${where}" config from ${source}`, '') - for (const k of keys) { - const v = publicVar(k) ? JSON.stringify(data[k]) : '(protected)' - const src = npm.config.find(k) - const overridden = src !== where - msg.push((overridden ? '; ' : '') + - `${k} = ${v} ${overridden ? `; overridden by ${src}` : ''}`) + if (!long) { + msg.push( + `; node bin location = ${process.execPath}`, + `; cwd = ${process.cwd()}`, + `; HOME = ${process.env.HOME}`, + '; Run `npm config ls -l` to show all defaults.' + ) } - msg.push('') - } - if (!long) { - msg.push( - `; node bin location = ${process.execPath}`, - `; cwd = ${process.cwd()}`, - `; HOME = ${process.env.HOME}`, - '; Run `npm config ls -l` to show all defaults.' - ) + output(msg.join('\n').trim()) } - output(msg.join('\n').trim()) -} + async listJson () { + const publicConf = {} + for (const key in this.npm.config.list[0]) { + if (!publicVar(key)) + continue -const listJson = async () => { - const publicConf = {} - for (const key in npm.config.list[0]) { - if (!publicVar(key)) - continue + publicConf[key] = this.npm.config.get(key) + } + output(JSON.stringify(publicConf, null, 2)) + } - publicConf[key] = npm.config.get(key) + usageError () { + return Object.assign(new Error(this.usage), { code: 'EUSAGE' }) } - output(JSON.stringify(publicConf, null, 2)) } -module.exports = Object.assign(cmd, { usage, completion }) +module.exports = Config diff --git a/lib/dedupe.js b/lib/dedupe.js index 2211fcac8b481..59978895effb2 100644 --- a/lib/dedupe.js +++ b/lib/dedupe.js @@ -1,29 +1,39 @@ // dedupe duplicated packages, or find them in the tree -const npm = require('./npm.js') const Arborist = require('@npmcli/arborist') const usageUtil = require('./utils/usage.js') const reifyFinish = require('./utils/reify-finish.js') -const usage = usageUtil('dedupe', 'npm dedupe') +class Dedupe { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => dedupe(args).then(() => cb()).catch(cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('dedupe', 'npm dedupe') + } -const dedupe = async (args) => { - if (npm.flatOptions.global) { - const er = new Error('`npm dedupe` does not work in global mode.') - er.code = 'EDEDUPEGLOBAL' - throw er + exec (args, cb) { + this.dedupe(args).then(() => cb()).catch(cb) } - const dryRun = (args && args.dryRun) || npm.flatOptions.dryRun - const where = npm.prefix - const arb = new Arborist({ - ...npm.flatOptions, - path: where, - dryRun, - }) - await arb.dedupe(npm.flatOptions) - await reifyFinish(arb) + async dedupe (args) { + if (this.npm.config.get('global')) { + const er = new Error('`npm dedupe` does not work in global mode.') + er.code = 'EDEDUPEGLOBAL' + throw er + } + + const dryRun = this.npm.config.get('dry-run') + const where = this.npm.prefix + const arb = new Arborist({ + ...this.npm.flatOptions, + path: where, + dryRun, + }) + await arb.dedupe(this.npm.flatOptions) + await reifyFinish(this.npm, arb) + } } -module.exports = Object.assign(cmd, { usage }) +module.exports = Dedupe diff --git a/lib/deprecate.js b/lib/deprecate.js index 42d099b544e31..48f27ab6c35e8 100644 --- a/lib/deprecate.js +++ b/lib/deprecate.js @@ -1,4 +1,3 @@ -const npm = require('./npm.js') const fetch = require('npm-registry-fetch') const otplease = require('./utils/otplease.js') const npa = require('npm-package-arg') @@ -7,67 +6,77 @@ const getIdentity = require('./utils/get-identity.js') const libaccess = require('libnpmaccess') const usageUtil = require('./utils/usage.js') -const UsageError = () => - Object.assign(new Error(`\nUsage: ${usage}`), { - code: 'EUSAGE', - }) +class Deprecate { + constructor (npm) { + this.npm = npm + } -const usage = usageUtil( - 'deprecate', - 'npm deprecate [@] ' -) + get usage () { + return usageUtil( + 'deprecate', + 'npm deprecate [@] ' + ) + } -const completion = async (opts) => { - if (opts.conf.argv.remain.length > 1) - return [] + async completion (opts) { + if (opts.conf.argv.remain.length > 1) + return [] - const username = await getIdentity(npm.flatOptions) - const packages = await libaccess.lsPackages(username, npm.flatOptions) - return Object.keys(packages) - .filter((name) => - packages[name] === 'write' && - (opts.conf.argv.remain.length === 0 || - name.startsWith(opts.conf.argv.remain[0]))) -} - -const cmd = (args, cb) => - deprecate(args) - .then(() => cb()) - .catch(err => cb(err.code === 'EUSAGE' ? err.message : err)) + const username = await getIdentity(this.npm, this.npm.flatOptions) + const packages = await libaccess.lsPackages(username, this.npm.flatOptions) + return Object.keys(packages) + .filter((name) => + packages[name] === 'write' && + (opts.conf.argv.remain.length === 0 || + name.startsWith(opts.conf.argv.remain[0]))) + } -const deprecate = async ([pkg, msg]) => { - if (!pkg || !msg) - throw UsageError() + exec (args, cb) { + this.deprecate(args) + .then(() => cb()) + .catch(err => cb(err.code === 'EUSAGE' ? err.message : err)) + } - // fetch the data and make sure it exists. - const p = npa(pkg) - // npa makes the default spec "latest", but for deprecation - // "*" is the appropriate default. - const spec = p.rawSpec === '' ? '*' : p.fetchSpec + async deprecate ([pkg, msg]) { + if (!pkg || !msg) + throw this.usageError() - if (semver.validRange(spec, true) === null) - throw new Error(`invalid version range: ${spec}`) + // fetch the data and make sure it exists. + const p = npa(pkg) + // npa makes the default spec "latest", but for deprecation + // "*" is the appropriate default. + const spec = p.rawSpec === '' ? '*' : p.fetchSpec - const uri = '/' + p.escapedName - const packument = await fetch.json(uri, { - ...npm.flatOptions, - spec: p, - query: { write: true }, - }) + if (semver.validRange(spec, true) === null) + throw new Error(`invalid version range: ${spec}`) - Object.keys(packument.versions) - .filter(v => semver.satisfies(v, spec, { includePrerelease: true })) - .forEach(v => { - packument.versions[v].deprecated = msg + const uri = '/' + p.escapedName + const packument = await fetch.json(uri, { + ...this.npm.flatOptions, + spec: p, + query: { write: true }, }) - return otplease(npm.flatOptions, opts => fetch(uri, { - ...opts, - spec: p, - method: 'PUT', - body: packument, - ignoreBody: true, - })) + Object.keys(packument.versions) + .filter(v => semver.satisfies(v, spec, { includePrerelease: true })) + .forEach(v => { + packument.versions[v].deprecated = msg + }) + + return otplease(this.npm.flatOptions, opts => fetch(uri, { + ...opts, + spec: p, + method: 'PUT', + body: packument, + ignoreBody: true, + })) + } + + usageError () { + return Object.assign(new Error(`\nUsage: ${this.usage}`), { + code: 'EUSAGE', + }) + } } -module.exports = Object.assign(cmd, { completion, usage }) +module.exports = Deprecate diff --git a/lib/diff.js b/lib/diff.js index 9ef5a78a20ce9..ea0340a4909d2 100644 --- a/lib/diff.js +++ b/lib/diff.js @@ -8,258 +8,270 @@ 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 readLocalPkg = require('./utils/read-local-package.js') -const usage = usageUtil( - 'diff', - 'npm diff [...]' + - '\nnpm diff --diff= [...]' + - '\nnpm diff --diff= [--diff=] [...]' + - '\nnpm diff --diff= [--diff=] [...]' + - '\nnpm diff [--diff-ignore-all-space] [--diff-name-only] [...] [...]' -) - -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 -} +class Diff { + constructor (npm) { + this.npm = npm + } -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}` + get usage () { + return usageUtil( + 'diff', + 'npm diff [...]' + + '\nnpm diff --diff= [...]' + + '\nnpm diff --diff= [--diff=] [...]' + + '\nnpm diff --diff= [--diff=] [...]' + + '\nnpm diff [--diff-ignore-all-space] [--diff-name-only] [...] [...]' ) } - 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() + get where () { + const globalTop = resolve(this.npm.globalDir, '..') + const { global } = this.npm.flatOptions + return global ? globalTop : this.npm.prefix + } - // 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) + exec (args, cb) { + this.diff(args).then(() => cb()).catch(cb) + } - return convertVersionsToSpecs([a, b]) - .then(findVersionsByPackageName) -} + async diff (args) { + const specs = this.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${this.usage}` + ) + } -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 - } + const [a, b] = await this.retrieveSpecs(specs) + npmlog.info('diff', { src: a, dst: b }) - if (!pkgName || noPackageJson) { - throw new Error( - 'Needs multiple arguments to compare or run from a project dir.\n\n' + - `Usage:\n${usage}` + const res = await libdiff( + [a, b], + { ...this.npm.flatOptions, diffFiles: args } ) + return output(res) } - return [ - `${pkgName}@${npm.flatOptions.defaultTag}`, - `file:${npm.prefix}`, - ] -} + async retrieveSpecs ([a, b]) { + // no arguments, defaults to comparing cwd + // to its latest published registry version + if (!a) + return this.defaultSpec() -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}` - ) + // single argument, used to compare wanted versions of an + // installed dependency or to compare the cwd to a published version + if (!b) + return this.transformSingleSpec(a) - const specSelf = () => { - if (noPackageJson) - throw missingPackageJson - - return `file:${npm.prefix}` + const specs = await this.convertVersionsToSpecs([a, b]) + return this.findVersionsByPackageName(specs) } - // 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 + async defaultSpec () { + let noPackageJson + let pkgName + try { + pkgName = await readLocalPkg(this.npm) + } 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${this.usage}` + ) + } return [ - `${pkgName}@${a}`, - specSelf(), + `${pkgName}@${this.npm.flatOptions.defaultTag}`, + `file:${this.npm.prefix}`, ] } - // 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 + async transformSingleSpec (a) { + let noPackageJson + let pkgName 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 + pkgName = await readLocalPkg(this.npm) } catch (e) { - npmlog.verbose('diff', 'failed to load actual install tree') + 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${this.usage}` + ) + + const specSelf = () => { + if (noPackageJson) + throw missingPackageJson + + return `file:${this.npm.prefix}` } - if (!node || !node.name || !node.package || !node.package.version) { + // 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 [ - `${spec.name}@${spec.fetchSpec}`, + `${pkgName}@${a}`, specSelf(), ] } - const tryRootNodeSpec = () => - (actualTree && actualTree.edgesOut.get(spec.name) || {}).spec - - const tryAnySpec = () => { - for (const edge of node.edgesIn) - return edge.spec - } + // 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 = { + ...this.npm.flatOptions, + path: this.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') + } - 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 - } + if (!node || !node.name || !node.package || !node.package.version) { + return [ + `${spec.name}@${spec.fetchSpec}`, + specSelf(), + ] + } - 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 tryRootNodeSpec = () => + (actualTree && actualTree.edgesOut.get(spec.name) || {}).spec -const convertVersionsToSpecs = async ([a, b]) => { - const semverA = semver.validRange(a) - const semverB = semver.validRange(b) + const tryAnySpec = () => { + for (const edge of node.edgesIn) + return edge.spec + } - // 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') - } + 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, { + ...this.npm.flatOptions, + preferOnline: true, + }) + bSpec = pickManifest( + packument, + bTargetVersion, + { ...this.npm.flatOptions } + ).version + } - if (!pkgName) { + return [ + `${spec.name}@${aSpec}`, + `${spec.name}@${bSpec}`, + ] + } else if (spec.type === 'directory') { + return [ + `file:${spec.fetchSpec}`, + specSelf(), + ] + } else { throw new Error( - 'Needs to be run from a project dir in order to diff two versions.\n\n' + - `Usage:\n${usage}` + 'Spec type not supported.\n\n' + + `Usage:\n${this.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}`] + async convertVersionsToSpecs ([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(this.npm) + } 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${this.usage}` + ) + } + return [`${pkgName}@${a}`, `${pkgName}@${b}`] + } - if (semverA && !semverB) - return [`${npa(b).name}@${a}`, 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}`] - // no valid semver ranges used - return [a, b] -} + if (semverA && !semverB) + return [`${npa(b).name}@${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') + // no valid semver ranges used + return [a, b] } - return specs.map(i => { - const spec = npa(i) - if (spec.rawSpec) - return i + async findVersionsByPackageName (specs) { + let actualTree + try { + const opts = { + ...this.npm.flatOptions, + path: this.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 node = actualTree + && actualTree.inventory.query('name', spec.name) + .values().next().value - const res = !node || !node.package || !node.package.version - ? spec.fetchSpec - : `file:${node.realpath}` + const res = !node || !node.package || !node.package.version + ? spec.fetchSpec + : `file:${node.realpath}` - return `${spec.name}@${res}` - }) + return `${spec.name}@${res}` + }) + } } -module.exports = Object.assign(cmd, { usage }) +module.exports = Diff diff --git a/lib/dist-tag.js b/lib/dist-tag.js index e958bb7544222..171a88c527e5d 100644 --- a/lib/dist-tag.js +++ b/lib/dist-tag.js @@ -3,69 +3,77 @@ const npa = require('npm-package-arg') const regFetch = require('npm-registry-fetch') const semver = require('semver') -const npm = require('./npm.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') const readLocalPkgName = require('./utils/read-local-package.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'dist-tag', - 'npm dist-tag add @ []' + - '\nnpm dist-tag rm ' + - '\nnpm dist-tag ls []' -) - -const completion = async (opts) => { - const argv = opts.conf.argv.remain - if (argv.length === 2) - return ['add', 'rm', 'ls'] - - switch (argv[2]) { - default: - return [] +class DistTag { + constructor (npm) { + this.npm = npm } -} -const cmd = (args, cb) => distTag(args).then(() => cb()).catch(cb) + get usage () { + return usageUtil( + 'dist-tag', + 'npm dist-tag add @ []' + + '\nnpm dist-tag rm ' + + '\nnpm dist-tag ls []' + ) + } -const distTag = async ([cmdName, pkg, tag]) => { - const opts = npm.flatOptions - const has = (items) => new Set(items).has(cmdName) + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length === 2) + return ['add', 'rm', 'ls'] - if (has(['add', 'a', 'set', 's'])) - return add(pkg, tag, opts) + switch (argv[2]) { + default: + return [] + } + } - if (has(['rm', 'r', 'del', 'd', 'remove'])) - return remove(pkg, tag, opts) + exec (args, cb) { + this.distTag(args).then(() => cb()).catch(cb) + } - if (has(['ls', 'l', 'sl', 'list'])) - return list(pkg, opts) + async distTag ([cmdName, pkg, tag]) { + const opts = this.npm.flatOptions + const has = (items) => new Set(items).has(cmdName) - if (!pkg) { - // when only using the pkg name the default behavior - // should be listing the existing tags - return list(cmdName, opts) - } else - throw usage -} + if (has(['add', 'a', 'set', 's'])) + return this.add(pkg, tag, opts) -function add (spec, tag, opts) { - spec = npa(spec || '') - const version = spec.rawSpec - const defaultTag = tag || opts.defaultTag + if (has(['rm', 'r', 'del', 'd', 'remove'])) + return this.remove(pkg, tag, opts) - log.verbose('dist-tag add', defaultTag, 'to', spec.name + '@' + version) + if (has(['ls', 'l', 'sl', 'list'])) + return this.list(pkg, opts) - if (!spec.name || !version || !defaultTag) - throw usage + if (!pkg) { + // when only using the pkg name the default behavior + // should be listing the existing tags + return this.list(cmdName, opts) + } else + throw this.usage + } + + async add (spec, tag, opts) { + spec = npa(spec || '') + const version = spec.rawSpec + const defaultTag = tag || opts.defaultTag + + log.verbose('dist-tag add', defaultTag, 'to', spec.name + '@' + version) - const t = defaultTag.trim() + if (!spec.name || !version || !defaultTag) + throw this.usage - if (semver.validRange(t)) - throw new Error('Tag name must not be a valid SemVer range: ' + t) + const t = defaultTag.trim() - return fetchTags(spec, opts).then(tags => { + if (semver.validRange(t)) + throw new Error('Tag name must not be a valid SemVer range: ' + t) + + const tags = await this.fetchTags(spec, opts) if (tags[t] === version) { log.warn('dist-tag add', t, 'is already set to version', version) return @@ -82,20 +90,18 @@ function add (spec, tag, opts) { }, spec, } - return otplease(reqOpts, reqOpts => regFetch(url, reqOpts)).then(() => { - output(`+${t}: ${spec.name}@${version}`) - }) - }) -} + await otplease(reqOpts, reqOpts => regFetch(url, reqOpts)) + output(`+${t}: ${spec.name}@${version}`) + } -function remove (spec, tag, opts) { - spec = npa(spec || '') - log.verbose('dist-tag del', tag, 'from', spec.name) + async remove (spec, tag, opts) { + spec = npa(spec || '') + log.verbose('dist-tag del', tag, 'from', spec.name) - if (!spec.name) - throw usage + if (!spec.name) + throw this.usage - return fetchTags(spec, opts).then(tags => { + const tags = await this.fetchTags(spec, opts) if (!tags[tag]) { log.info('dist-tag del', tag, 'is not a dist-tag on', spec.name) throw new Error(tag + ' is not a dist-tag on ' + spec.name) @@ -109,50 +115,43 @@ function remove (spec, tag, opts) { method: 'DELETE', spec, } - return otplease(reqOpts, reqOpts => regFetch(url, reqOpts)).then(() => { - output(`-${tag}: ${spec.name}@${version}`) - }) - }) -} + await otplease(reqOpts, reqOpts => regFetch(url, reqOpts)) + output(`-${tag}: ${spec.name}@${version}`) + } -function list (spec, opts) { - if (!spec) { - return readLocalPkgName().then(pkg => { + async list (spec, opts) { + if (!spec) { + const pkg = await readLocalPkgName(this.npm) if (!pkg) - throw usage + throw this.usage - return list(pkg, opts) - }) + return this.list(pkg, opts) + } + spec = npa(spec) + + try { + const tags = await this.fetchTags(spec, opts) + const msg = + Object.keys(tags).map(k => `${k}: ${tags[k]}`).sort().join('\n') + output(msg) + return tags + } catch (err) { + log.error('dist-tag ls', "Couldn't get dist-tag data for", spec) + throw err + } } - spec = npa(spec) - - return fetchTags(spec, opts).then(tags => { - const msg = - Object.keys(tags).map(k => `${k}: ${tags[k]}`).sort().join('\n') - output(msg) - return tags - }, err => { - log.error('dist-tag ls', "Couldn't get dist-tag data for", spec) - throw err - }) -} -function fetchTags (spec, opts) { - return regFetch.json( - `/-/package/${spec.escapedName}/dist-tags`, - { - ...opts, - 'prefer-online': true, - spec, - } - ).then(data => { + async fetchTags (spec, opts) { + const data = await regFetch.json( + `/-/package/${spec.escapedName}/dist-tags`, + { ...opts, 'prefer-online': true, spec } + ) if (data && typeof data === 'object') delete data._etag if (!data || !Object.keys(data).length) throw new Error('No dist-tags found for ' + spec.name) return data - }) + } } - -module.exports = Object.assign(cmd, { usage, completion }) +module.exports = DistTag diff --git a/lib/docs.js b/lib/docs.js index fa0adb3d37309..2dad7a26db4e7 100644 --- a/lib/docs.js +++ b/lib/docs.js @@ -1,39 +1,47 @@ const log = require('npmlog') const pacote = require('pacote') -const { promisify } = require('util') -const openUrl = promisify(require('./utils/open-url.js')) +const openUrl = require('./utils/open-url.js') const usageUtil = require('./utils/usage.js') -const npm = require('./npm.js') const hostedFromMani = require('./utils/hosted-git-info-from-manifest.js') -const usage = usageUtil('docs', 'npm docs [ [ ...]]') - -const cmd = (args, cb) => docs(args).then(() => cb()).catch(cb) - -const docs = async args => { - if (!args || !args.length) - args = ['.'] - - await Promise.all(args.map(pkg => getDocs(pkg))) -} - -const getDocsUrl = mani => { - if (mani.homepage) - return mani.homepage - - const info = hostedFromMani(mani) - if (info) - return info.docs() - - return 'https://www.npmjs.com/package/' + mani.name +class Docs { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('docs', 'npm docs [ [ ...]]') + } + + exec (args, cb) { + this.docs(args).then(() => cb()).catch(cb) + } + + async docs (args) { + if (!args || !args.length) + args = ['.'] + + await Promise.all(args.map(pkg => this.getDocs(pkg))) + } + + async getDocs (pkg) { + const opts = { ...this.npm.flatOptions, fullMetadata: true } + const mani = await pacote.manifest(pkg, opts) + const url = this.getDocsUrl(mani) + log.silly('docs', 'url', url) + await openUrl(this.npm, url, `${mani.name} docs available at the following URL`) + } + + getDocsUrl (mani) { + if (mani.homepage) + return mani.homepage + + const info = hostedFromMani(mani) + if (info) + return info.docs() + + return 'https://www.npmjs.com/package/' + mani.name + } } - -const getDocs = async pkg => { - const opts = { ...npm.flatOptions, fullMetadata: true } - const mani = await pacote.manifest(pkg, opts) - const url = getDocsUrl(mani) - log.silly('docs', 'url', url) - await openUrl(url, `${mani.name} docs available at the following URL`) -} - -module.exports = Object.assign(cmd, { usage }) +module.exports = Docs diff --git a/lib/doctor.js b/lib/doctor.js index e149aec1286d5..81860004e344e 100644 --- a/lib/doctor.js +++ b/lib/doctor.js @@ -1,79 +1,22 @@ -const npm = require('./npm.js') - +const cacache = require('cacache') const chalk = require('chalk') -const ansiTrim = require('./utils/ansi-trim.js') +const fs = require('fs') +const fetch = require('make-fetch-happen') const table = require('text-table') -const output = require('./utils/output.js') -const usageUtil = require('./utils/usage.js') -const usage = usageUtil('doctor', 'npm doctor') -const { resolve } = require('path') - -const ping = require('./utils/ping.js') -const checkPing = async () => { - const tracker = npm.log.newItem('checkPing', 1) - tracker.info('checkPing', 'Pinging registry') - try { - await ping(npm.flatOptions) - return '' - } catch (er) { - if (/^E\d{3}$/.test(er.code || '')) - throw er.code.substr(1) + ' ' + er.message - else - throw er.message - } finally { - tracker.finish() - } -} - +const which = require('which') const pacote = require('pacote') -const getLatestNpmVersion = async () => { - const tracker = npm.log.newItem('getLatestNpmVersion', 1) - tracker.info('getLatestNpmVersion', 'Getting npm package information') - try { - const latest = (await pacote.manifest('npm@latest', npm.flatOptions)).version - if (semver.gte(npm.version, latest)) - return `current: v${npm.version}, latest: v${latest}` - else - throw `Use npm v${latest}` - } finally { - tracker.finish() - } -} - +const { resolve } = require('path') const semver = require('semver') -const fetch = require('make-fetch-happen') -const getLatestNodejsVersion = async () => { - // XXX get the latest in the current major as well - const current = process.version - const currentRange = `^${current}` - const url = 'https://nodejs.org/dist/index.json' - const tracker = npm.log.newItem('getLatestNodejsVersion', 1) - tracker.info('getLatestNodejsVersion', 'Getting Node.js release information') - try { - const res = await fetch(url, { method: 'GET', ...npm.flatOptions }) - const data = await res.json() - let maxCurrent = '0.0.0' - let maxLTS = '0.0.0' - for (const { lts, version } of data) { - if (lts && semver.gt(version, maxLTS)) - maxLTS = version - - if (semver.satisfies(version, currentRange) && - semver.gt(version, maxCurrent)) - maxCurrent = version - } - const recommended = semver.gt(maxCurrent, maxLTS) ? maxCurrent : maxLTS - if (semver.gte(process.version, recommended)) - return `current: ${current}, recommended: ${recommended}` - else - throw `Use node ${recommended} (current: ${current})` - } finally { - tracker.finish() - } -} - const { promisify } = require('util') -const fs = require('fs') +const ansiTrim = require('./utils/ansi-trim.js') +const isWindows = require('./utils/is-windows.js') +const output = require('./utils/output.js') +const ping = require('./utils/ping.js') +const usageUtil = require('./utils/usage.js') +const { defaults: { registry: defaultRegistry } } = require('./utils/config.js') +const lstat = promisify(fs.lstat) +const readdir = promisify(fs.readdir) +const access = promisify(fs.access) const { R_OK, W_OK, X_OK } = fs.constants const maskLabel = mask => { const label = [] @@ -88,200 +31,268 @@ const maskLabel = mask => { return label.join(', ') } -const lstat = promisify(fs.lstat) -const readdir = promisify(fs.readdir) -const access = promisify(fs.access) -const isWindows = require('./utils/is-windows.js') -const checkFilesPermission = async (root, shouldOwn, mask = null) => { - if (mask === null) - mask = shouldOwn ? R_OK | W_OK : R_OK - - let ok = true - - const tracker = npm.log.newItem(root, 1) - - try { - const uid = process.getuid() - const gid = process.getgid() - const files = new Set([root]) - for (const f of files) { - tracker.silly('checkFilesPermission', f.substr(root.length + 1)) - const st = await lstat(f) - .catch(er => { - ok = false - tracker.warn('checkFilesPermission', 'error getting info for ' + f) - }) - tracker.completeWork(1) - - if (!st) - continue +class Doctor { + constructor (npm) { + this.npm = npm + } - if (shouldOwn && (uid !== st.uid || gid !== st.gid)) { - tracker.warn('checkFilesPermission', 'should be owner of ' + f) - ok = false - } + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('doctor', 'npm doctor') + } - if (!st.isDirectory() && !st.isFile()) - continue + exec (args, cb) { + this.doctor(args).then(() => cb()).catch(cb) + } + async doctor (args) { + this.npm.log.info('Running checkup') + + // each message is [title, ok, message] + const messages = [] + + const actions = [ + ['npm ping', 'checkPing', []], + ['npm -v', 'getLatestNpmVersion', []], + ['node -v', 'getLatestNodejsVersion', []], + ['npm config get registry', 'checkNpmRegistry', []], + ['which git', 'getGitPath', []], + ...(isWindows ? [] : [ + ['Perms check on cached files', 'checkFilesPermission', [this.npm.cache, true, R_OK]], + ['Perms check on local node_modules', 'checkFilesPermission', [this.npm.localDir, true]], + ['Perms check on global node_modules', 'checkFilesPermission', [this.npm.globalDir, false]], + ['Perms check on local bin folder', 'checkFilesPermission', [this.npm.localBin, false, R_OK | W_OK | X_OK]], + ['Perms check on global bin folder', 'checkFilesPermission', [this.npm.globalBin, false, X_OK]], + ]), + ['Verify cache contents', 'verifyCachedFiles', [this.npm.flatOptions.cache]], + // TODO: + // - ensure arborist.loadActual() runs without errors and no invalid edges + // - ensure package-lock.json matches loadActual() + // - verify loadActual without hidden lock file matches hidden lockfile + // - verify all local packages have bins linked + ] + + // Do the actual work + for (const [msg, fn, args] of actions) { + const line = [msg] try { - await access(f, mask) + line.push(true, await this[fn](...args)) } catch (er) { - ok = false - const msg = `Missing permissions on ${f} (expect: ${maskLabel(mask)})` - tracker.error('checkFilesPermission', msg) - continue + line.push(false, er) } + messages.push(line) + } - if (st.isDirectory()) { - const entries = await readdir(f) - .catch(er => { - ok = false - tracker.warn('checkFilesPermission', 'error reading directory ' + f) - return [] - }) - for (const entry of entries) - files.add(resolve(f, entry)) + const outHead = ['Check', 'Value', 'Recommendation/Notes'] + .map(!this.npm.color ? h => h : h => chalk.underline(h)) + let allOk = true + const outBody = messages.map(!this.npm.color + ? item => { + allOk = allOk && item[1] + item[1] = item[1] ? 'ok' : 'not ok' + item[2] = String(item[2]) + return item } + : item => { + allOk = allOk && item[1] + if (!item[1]) { + item[0] = chalk.red(item[0]) + item[2] = chalk.magenta(String(item[2])) + } + item[1] = item[1] ? chalk.green('ok') : chalk.red('not ok') + return item + }) + const outTable = [outHead, ...outBody] + const tableOpts = { + stringLength: s => ansiTrim(s).length, } - } finally { - tracker.finish() - if (!ok) { - throw `Check the permissions of files in ${root}` + - (shouldOwn ? ' (should be owned by current user)' : '') - } else + + const silent = this.npm.log.levels[this.npm.log.level] > + this.npm.log.levels.error + if (!silent) { + output(table(outTable, tableOpts)) + if (!allOk) + console.error('') + } + if (!allOk) + throw 'Some problems found. See above for recommendations.' + } + + async checkPing () { + const tracker = this.npm.log.newItem('checkPing', 1) + tracker.info('checkPing', 'Pinging registry') + try { + await ping(this.npm.flatOptions) return '' + } catch (er) { + if (/^E\d{3}$/.test(er.code || '')) + throw er.code.substr(1) + ' ' + er.message + else + throw er.message + } finally { + tracker.finish() + } } -} -const which = require('which') -const getGitPath = async () => { - const tracker = npm.log.newItem('getGitPath', 1) - tracker.info('getGitPath', 'Finding git in your PATH') - try { - return await which('git').catch(er => { - tracker.warn(er) - throw "Install git and ensure it's in your PATH." - }) - } finally { - tracker.finish() + async getLatestNpmVersion () { + const tracker = this.npm.log.newItem('getLatestNpmVersion', 1) + tracker.info('getLatestNpmVersion', 'Getting npm package information') + try { + const latest = (await pacote.manifest('npm@latest', this.npm.flatOptions)).version + if (semver.gte(this.npm.version, latest)) + return `current: v${this.npm.version}, latest: v${latest}` + else + throw `Use npm v${latest}` + } finally { + tracker.finish() + } } -} -const cacache = require('cacache') -const verifyCachedFiles = async () => { - const tracker = npm.log.newItem('verifyCachedFiles', 1) - tracker.info('verifyCachedFiles', 'Verifying the npm cache') - try { - const stats = await cacache.verify(npm.flatOptions.cache) - const { - badContentCount, - reclaimedCount, - missingContent, - reclaimedSize, - } = stats - if (badContentCount || reclaimedCount || missingContent) { - if (badContentCount) - tracker.warn('verifyCachedFiles', `Corrupted content removed: ${badContentCount}`) - - if (reclaimedCount) - tracker.warn('verifyCachedFiles', `Content garbage-collected: ${reclaimedCount} (${reclaimedSize} bytes)`) - - if (missingContent) - tracker.warn('verifyCachedFiles', `Missing content: ${missingContent}`) - - tracker.warn('verifyCachedFiles', 'Cache issues have been fixed') + async getLatestNodejsVersion () { + // XXX get the latest in the current major as well + const current = process.version + const currentRange = `^${current}` + const url = 'https://nodejs.org/dist/index.json' + const tracker = this.npm.log.newItem('getLatestNodejsVersion', 1) + tracker.info('getLatestNodejsVersion', 'Getting Node.js release information') + try { + const res = await fetch(url, { method: 'GET', ...this.npm.flatOptions }) + const data = await res.json() + let maxCurrent = '0.0.0' + let maxLTS = '0.0.0' + for (const { lts, version } of data) { + if (lts && semver.gt(version, maxLTS)) + maxLTS = version + + if (semver.satisfies(version, currentRange) && + semver.gt(version, maxCurrent)) + maxCurrent = version + } + const recommended = semver.gt(maxCurrent, maxLTS) ? maxCurrent : maxLTS + if (semver.gte(process.version, recommended)) + return `current: ${current}, recommended: ${recommended}` + else + throw `Use node ${recommended} (current: ${current})` + } finally { + tracker.finish() } - tracker.info('verifyCachedFiles', `Verification complete. Stats: ${ - JSON.stringify(stats, null, 2) - }`) - return `verified ${stats.verifiedContent} tarballs` - } finally { - tracker.finish() } -} -const { defaults: { registry: defaultRegistry } } = require('./utils/config.js') -const checkNpmRegistry = async () => { - if (npm.flatOptions.registry !== defaultRegistry) - throw `Try \`npm config set registry=${defaultRegistry}\`` - else - return `using default registry (${defaultRegistry})` -} + async checkFilesPermission (root, shouldOwn, mask = null) { + if (mask === null) + mask = shouldOwn ? R_OK | W_OK : R_OK + + let ok = true + + const tracker = this.npm.log.newItem(root, 1) -const cmd = (args, cb) => doctor(args).then(() => cb()).catch(cb) - -const doctor = async args => { - npm.log.info('Running checkup') - - // each message is [title, ok, message] - const messages = [] - - const actions = [ - ['npm ping', checkPing, []], - ['npm -v', getLatestNpmVersion, []], - ['node -v', getLatestNodejsVersion, []], - ['npm config get registry', checkNpmRegistry, []], - ['which git', getGitPath, []], - ...(isWindows ? [] : [ - ['Perms check on cached files', checkFilesPermission, [npm.cache, true, R_OK]], - ['Perms check on local node_modules', checkFilesPermission, [npm.localDir, true]], - ['Perms check on global node_modules', checkFilesPermission, [npm.globalDir, false]], - ['Perms check on local bin folder', checkFilesPermission, [npm.localBin, false, R_OK | W_OK | X_OK]], - ['Perms check on global bin folder', checkFilesPermission, [npm.globalBin, false, X_OK]], - ]), - ['Verify cache contents', verifyCachedFiles, [npm.flatOptions.cache]], - // TODO: - // - ensure arborist.loadActual() runs without errors and no invalid edges - // - ensure package-lock.json matches loadActual() - // - verify loadActual without hidden lock file matches hidden lockfile - // - verify all local packages have bins linked - ] - - for (const [msg, fn, args] of actions) { - const line = [msg] try { - line.push(true, await fn(...args)) - } catch (er) { - line.push(false, er) + const uid = process.getuid() + const gid = process.getgid() + const files = new Set([root]) + for (const f of files) { + tracker.silly('checkFilesPermission', f.substr(root.length + 1)) + const st = await lstat(f) + .catch(er => { + ok = false + tracker.warn('checkFilesPermission', 'error getting info for ' + f) + }) + + tracker.completeWork(1) + + if (!st) + continue + + if (shouldOwn && (uid !== st.uid || gid !== st.gid)) { + tracker.warn('checkFilesPermission', 'should be owner of ' + f) + ok = false + } + + if (!st.isDirectory() && !st.isFile()) + continue + + try { + await access(f, mask) + } catch (er) { + ok = false + const msg = `Missing permissions on ${f} (expect: ${maskLabel(mask)})` + tracker.error('checkFilesPermission', msg) + continue + } + + if (st.isDirectory()) { + const entries = await readdir(f) + .catch(er => { + ok = false + tracker.warn('checkFilesPermission', 'error reading directory ' + f) + return [] + }) + for (const entry of entries) + files.add(resolve(f, entry)) + } + } + } finally { + tracker.finish() + if (!ok) { + throw `Check the permissions of files in ${root}` + + (shouldOwn ? ' (should be owned by current user)' : '') + } else + return '' } - messages.push(line) } - const silent = npm.log.levels[npm.log.level] > npm.log.levels.error - - const outHead = ['Check', 'Value', 'Recommendation/Notes'] - .map(!npm.color ? h => h : h => chalk.underline(h)) - let allOk = true - const outBody = messages.map(!npm.color - ? item => { - allOk = allOk && item[1] - item[1] = item[1] ? 'ok' : 'not ok' - item[2] = String(item[2]) - return item + async getGitPath () { + const tracker = this.npm.log.newItem('getGitPath', 1) + tracker.info('getGitPath', 'Finding git in your PATH') + try { + return await which('git').catch(er => { + tracker.warn(er) + throw "Install git and ensure it's in your PATH." + }) + } finally { + tracker.finish() } - : item => { - allOk = allOk && item[1] - if (!item[1]) { - item[0] = chalk.red(item[0]) - item[2] = chalk.magenta(String(item[2])) + } + + async verifyCachedFiles () { + const tracker = this.npm.log.newItem('verifyCachedFiles', 1) + tracker.info('verifyCachedFiles', 'Verifying the npm cache') + try { + const stats = await cacache.verify(this.npm.flatOptions.cache) + const { + badContentCount, + reclaimedCount, + missingContent, + reclaimedSize, + } = stats + if (badContentCount || reclaimedCount || missingContent) { + if (badContentCount) + tracker.warn('verifyCachedFiles', `Corrupted content removed: ${badContentCount}`) + + if (reclaimedCount) + tracker.warn('verifyCachedFiles', `Content garbage-collected: ${reclaimedCount} (${reclaimedSize} bytes)`) + + if (missingContent) + tracker.warn('verifyCachedFiles', `Missing content: ${missingContent}`) + + tracker.warn('verifyCachedFiles', 'Cache issues have been fixed') } - item[1] = item[1] ? chalk.green('ok') : chalk.red('not ok') - return item - }) - const outTable = [outHead, ...outBody] - const tableOpts = { - stringLength: s => ansiTrim(s).length, + tracker.info('verifyCachedFiles', `Verification complete. Stats: ${ + JSON.stringify(stats, null, 2) + }`) + return `verified ${stats.verifiedContent} tarballs` + } finally { + tracker.finish() + } } - if (!silent) { - output(table(outTable, tableOpts)) - if (!allOk) - console.error('') + async checkNpmRegistry () { + if (this.npm.flatOptions.registry !== defaultRegistry) + throw `Try \`npm config set registry=${defaultRegistry}\`` + else + return `using default registry (${defaultRegistry})` } - if (!allOk) - throw 'Some problems found. See above for recommendations.' } -module.exports = Object.assign(cmd, { usage }) +module.exports = Doctor diff --git a/lib/edit.js b/lib/edit.js index 9ae6349262c2d..a7dbb38205b02 100644 --- a/lib/edit.js +++ b/lib/edit.js @@ -4,33 +4,55 @@ const { resolve } = require('path') const fs = require('graceful-fs') const { spawn } = require('child_process') -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const splitPackageNames = require('./utils/split-package-names.js') - -const usage = usageUtil('edit', 'npm edit [/...]') const completion = require('./utils/completion/installed-shallow.js') -function edit (args, cb) { - if (args.length !== 1) - return cb(usage) - - const path = splitPackageNames(args[0]) - const dir = resolve(npm.dir, path) - - fs.lstat(dir, (err) => { - if (err) - return cb(err) - - const [bin, ...args] = npm.config.get('editor').split(/\s+/) - const editor = spawn(bin, [...args, dir], { stdio: 'inherit' }) - editor.on('exit', (code) => { - if (code) - return cb(new Error(`editor process exited with code: ${code}`)) - - npm.commands.rebuild([dir], cb) +class Edit { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('edit', 'npm edit [/...]') + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return completion(this.npm, opts) + } + + exec (args, cb) { + this.edit(args).then(() => cb()).catch(cb) + } + + async edit (args) { + if (args.length !== 1) + throw new Error(this.usage) + + const path = splitPackageNames(args[0]) + const dir = resolve(this.npm.dir, path) + + // graceful-fs does not promisify + await new Promise((resolve, reject) => { + fs.lstat(dir, (err) => { + if (err) + return reject(err) + const [bin, ...args] = this.npm.config.get('editor').split(/\s+/) + const editor = spawn(bin, [...args, dir], { stdio: 'inherit' }) + editor.on('exit', (code) => { + if (code) + return reject(new Error(`editor process exited with code: ${code}`)) + this.npm.commands.rebuild([dir], (err) => { + if (err) + return reject(err) + + resolve() + }) + }) + }) }) - }) + } } - -module.exports = Object.assign(edit, { completion, usage }) +module.exports = Edit diff --git a/lib/exec.js b/lib/exec.js index dab65c23a37b2..d1db49128587e 100644 --- a/lib/exec.js +++ b/lib/exec.js @@ -1,28 +1,18 @@ -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil('exec', - 'Run a command from a local or remote npm package.\n\n' + - - 'npm exec -- [@] [args...]\n' + - 'npm exec --package=[@] -- [args...]\n' + - 'npm exec -c \' [args...]\'\n' + - 'npm exec --package=foo -c \' [args...]\'\n' + - '\n' + - 'npx [@] [args...]\n' + - 'npx -p [@] [args...]\n' + - 'npx -c \' [args...]\'\n' + - 'npx -p [@] -c \' [args...]\'' + - '\n' + - 'Run without --call or positional args to open interactive subshell\n', - - '\n--package= (may be specified multiple times)\n' + - '-p is a shorthand for --package only when using npx executable\n' + - '-c --call= (may not be mixed with positional arguments)' -) - const { promisify } = require('util') const read = promisify(require('read')) +const mkdirp = require('mkdirp-infer-owner') +const readPackageJson = require('read-package-json-fast') +const Arborist = require('@npmcli/arborist') +const runScript = require('@npmcli/run-script') +const { resolve, delimiter } = require('path') +const ciDetect = require('@npmcli/ci-detect') +const crypto = require('crypto') +const pacote = require('pacote') +const npa = require('npm-package-arg') +const fileExists = require('./utils/file-exists.js') +const PATH = require('./utils/path.js') // it's like this: // @@ -49,237 +39,258 @@ const read = promisify(require('read')) // runScript({ pkg, event: 'npx', ... }) // process.env.npm_lifecycle_event = 'npx' -const mkdirp = require('mkdirp-infer-owner') -const readPackageJson = require('read-package-json-fast') -const Arborist = require('@npmcli/arborist') -const runScript = require('@npmcli/run-script') -const { resolve, delimiter } = require('path') -const ciDetect = require('@npmcli/ci-detect') -const crypto = require('crypto') -const pacote = require('pacote') -const npa = require('npm-package-arg') -const fileExists = require('./utils/file-exists.js') -const PATH = require('./utils/path.js') - -const cmd = (args, cb) => exec(args).then(() => cb()).catch(cb) - -const run = async ({ args, call, pathArr, shell }) => { - // turn list of args into command string - const script = call || args.shift() || shell - - // do the fakey runScript dance - // still should work if no package.json in cwd - const realPkg = await readPackageJson(`${npm.localPrefix}/package.json`) - .catch(() => ({})) - const pkg = { - ...realPkg, - scripts: { - ...(realPkg.scripts || {}), - npx: script, - }, +class Exec { + constructor (npm) { + this.npm = npm } - npm.log.disableProgress() - try { - if (script === shell) { - if (process.stdin.isTTY) { - if (ciDetect()) - return npm.log.warn('exec', 'Interactive mode disabled in CI environment') - output(`\nEntering npm script environment\nType 'exit' or ^D when finished\n`) - } - } - return await runScript({ - ...npm.flatOptions, - pkg, - banner: false, - // we always run in cwd, not --prefix - path: process.cwd(), - stdioString: true, - event: 'npx', - args, - env: { - PATH: pathArr.join(delimiter), - }, - stdio: 'inherit', - }) - } finally { - npm.log.enableProgress() + get usage () { + return usageUtil('exec', + 'Run a command from a local or remote npm package.\n\n' + + + 'npm exec -- [@] [args...]\n' + + 'npm exec --package=[@] -- [args...]\n' + + 'npm exec -c \' [args...]\'\n' + + 'npm exec --package=foo -c \' [args...]\'\n' + + '\n' + + 'npx [@] [args...]\n' + + 'npx -p [@] [args...]\n' + + 'npx -c \' [args...]\'\n' + + 'npx -p [@] -c \' [args...]\'' + + '\n' + + 'Run without --call or positional args to open interactive subshell\n', + + '\n--package= (may be specified multiple times)\n' + + '-p is a shorthand for --package only when using npx executable\n' + + '-c --call= (may not be mixed with positional arguments)' + ) } -} - -const exec = async args => { - const { package: packages, call, shell } = npm.flatOptions - if (call && args.length) - throw usage + exec (args, cb) { + this._exec(args).then(() => cb()).catch(cb) + } - const pathArr = [...PATH] + // When commands go async and we can dump the boilerplate exec methods this + // can be named correctly + async _exec (args) { + const { package: packages, call, shell } = this.npm.flatOptions - // nothing to maybe install, skip the arborist dance - if (!call && !args.length && !packages.length) { - return await run({ - args, - call, - shell, - pathArr, - }) - } + if (call && args.length) + throw this.usage - const needPackageCommandSwap = args.length && !packages.length - // if there's an argument and no package has been explicitly asked for - // check the local and global bin paths for a binary named the same as - // the argument and run it if it exists, otherwise fall through to - // the behavior of treating the single argument as a package name - if (needPackageCommandSwap) { - let binExists = false - if (await fileExists(`${npm.localBin}/${args[0]}`)) { - pathArr.unshift(npm.localBin) - binExists = true - } else if (await fileExists(`${npm.globalBin}/${args[0]}`)) { - pathArr.unshift(npm.globalBin) - binExists = true - } + const pathArr = [...PATH] - if (binExists) { - return await run({ + // nothing to maybe install, skip the arborist dance + if (!call && !args.length && !packages.length) { + return await this.run({ args, call, - pathArr, shell, + pathArr, }) } - packages.push(args[0]) - } + const needPackageCommandSwap = args.length && !packages.length + // if there's an argument and no package has been explicitly asked for + // check the local and global bin paths for a binary named the same as + // the argument and run it if it exists, otherwise fall through to + // the behavior of treating the single argument as a package name + if (needPackageCommandSwap) { + let binExists = false + if (await fileExists(`${this.npm.localBin}/${args[0]}`)) { + pathArr.unshift(this.npm.localBin) + binExists = true + } else if (await fileExists(`${this.npm.globalBin}/${args[0]}`)) { + pathArr.unshift(this.npm.globalBin) + binExists = true + } - // If we do `npm exec foo`, and have a `foo` locally, then we'll - // always use that, so we don't really need to fetch the manifest. - // So: run npa on each packages entry, and if it is a name with a - // rawSpec==='', then try to readPackageJson at - // node_modules/${name}/package.json, and only pacote fetch if - // that fails. - const manis = await Promise.all(packages.map(async p => { - const spec = npa(p, npm.localPrefix) - if (spec.type === 'tag' && spec.rawSpec === '') { - // fall through to the pacote.manifest() approach - try { - const pj = resolve(npm.localPrefix, 'node_modules', spec.name) - return await readPackageJson(pj) - } catch (er) {} + if (binExists) { + return await this.run({ + args, + call, + pathArr, + shell, + }) + } + + packages.push(args[0]) } - // Force preferOnline to true so we are making sure to pull in the latest - // This is especially useful if the user didn't give us a version, and - // they expect to be running @latest - return await pacote.manifest(p, { - ...npm.flatOptions, - preferOnline: true, - }) - })) - - if (needPackageCommandSwap) - args[0] = getBinFromManifest(manis[0]) - - // figure out whether we need to install stuff, or if local is fine - const localArb = new Arborist({ - ...npm.flatOptions, - path: npm.localPrefix, - }) - const tree = await localArb.loadActual() - - // do we have all the packages in manifest list? - const needInstall = manis.some(mani => manifestMissing(tree, mani)) - - if (needInstall) { - const installDir = cacheInstallDir(packages) - await mkdirp(installDir) - const arb = new Arborist({ ...npm.flatOptions, path: installDir }) - const tree = await arb.loadActual() - - // at this point, we have to ensure that we get the exact same - // version, because it's something that has only ever been installed - // by npm exec in the cache install directory - const add = manis.filter(mani => manifestMissing(tree, { - ...mani, - _from: `${mani.name}@${mani.version}`, + + // If we do `npm exec foo`, and have a `foo` locally, then we'll + // always use that, so we don't really need to fetch the manifest. + // So: run npa on each packages entry, and if it is a name with a + // rawSpec==='', then try to readPackageJson at + // node_modules/${name}/package.json, and only pacote fetch if + // that fails. + const manis = await Promise.all(packages.map(async p => { + const spec = npa(p, this.npm.localPrefix) + if (spec.type === 'tag' && spec.rawSpec === '') { + // fall through to the pacote.manifest() approach + try { + const pj = resolve(this.npm.localPrefix, 'node_modules', spec.name) + return await readPackageJson(pj) + } catch (er) {} + } + // Force preferOnline to true so we are making sure to pull in the latest + // This is especially useful if the user didn't give us a version, and + // they expect to be running @latest + return await pacote.manifest(p, { + ...this.npm.flatOptions, + preferOnline: true, + }) })) - .map(mani => mani._from) - .sort((a, b) => a.localeCompare(b)) - - // no need to install if already present - if (add.length) { - if (!npm.flatOptions.yes) { - // set -n to always say no - if (npm.flatOptions.yes === false) - throw 'canceled' - - if (!process.stdin.isTTY || ciDetect()) { - npm.log.warn('exec', `The following package${ + + if (needPackageCommandSwap) + args[0] = this.getBinFromManifest(manis[0]) + + // figure out whether we need to install stuff, or if local is fine + const localArb = new Arborist({ + ...this.npm.flatOptions, + path: this.npm.localPrefix, + }) + const tree = await localArb.loadActual() + + // do we have all the packages in manifest list? + const needInstall = manis.some(mani => this.manifestMissing(tree, mani)) + + if (needInstall) { + const installDir = this.cacheInstallDir(packages) + await mkdirp(installDir) + const arb = new Arborist({ ...this.npm.flatOptions, path: installDir }) + const tree = await arb.loadActual() + + // at this point, we have to ensure that we get the exact same + // version, because it's something that has only ever been installed + // by npm exec in the cache install directory + const add = manis.filter(mani => this.manifestMissing(tree, { + ...mani, + _from: `${mani.name}@${mani.version}`, + })) + .map(mani => mani._from) + .sort((a, b) => a.localeCompare(b)) + + // no need to install if already present + if (add.length) { + if (!this.npm.flatOptions.yes) { + // set -n to always say no + if (this.npm.flatOptions.yes === false) + throw 'canceled' + + if (!process.stdin.isTTY || ciDetect()) { + this.npm.log.warn('exec', `The following package${ add.length === 1 ? ' was' : 's were' } not found and will be installed: ${ add.map((pkg) => pkg.replace(/@$/, '')).join(', ') }`) - } else { - const addList = add.map(a => ` ${a.replace(/@$/, '')}`) - .join('\n') + '\n' - const prompt = `Need to install the following packages:\n${ + } else { + const addList = add.map(a => ` ${a.replace(/@$/, '')}`) + .join('\n') + '\n' + const prompt = `Need to install the following packages:\n${ addList }Ok to proceed? ` - const confirm = await read({ prompt, default: 'y' }) - if (confirm.trim().toLowerCase().charAt(0) !== 'y') - throw 'canceled' + const confirm = await read({ prompt, default: 'y' }) + if (confirm.trim().toLowerCase().charAt(0) !== 'y') + throw 'canceled' + } } + await arb.reify({ ...this.npm.flatOptions, add }) } - await arb.reify({ ...npm.flatOptions, add }) + pathArr.unshift(resolve(installDir, 'node_modules/.bin')) } - pathArr.unshift(resolve(installDir, 'node_modules/.bin')) + + return await this.run({ args, call, pathArr, shell }) } - return await run({ args, call, pathArr, shell }) -} + async run ({ args, call, pathArr, shell }) { + // turn list of args into command string + const script = call || args.shift() || shell + + // do the fakey runScript dance + // still should work if no package.json in cwd + const realPkg = await readPackageJson(`${this.npm.localPrefix}/package.json`) + .catch(() => ({})) + const pkg = { + ...realPkg, + scripts: { + ...(realPkg.scripts || {}), + npx: script, + }, + } -const manifestMissing = (tree, mani) => { - // if the tree doesn't have a child by that name/version, return true - // true means we need to install it - const child = tree.children.get(mani.name) - // if no child, we have to load it - if (!child) - return true + this.npm.log.disableProgress() + try { + if (script === shell) { + if (process.stdin.isTTY) { + if (ciDetect()) + return this.npm.log.warn('exec', 'Interactive mode disabled in CI environment') + output(`\nEntering npm script environment\nType 'exit' or ^D when finished\n`) + } + } + return await runScript({ + ...this.npm.flatOptions, + pkg, + banner: false, + // we always run in cwd, not --prefix + path: process.cwd(), + stdioString: true, + event: 'npx', + args, + env: { + PATH: pathArr.join(delimiter), + }, + stdio: 'inherit', + }) + } finally { + this.npm.log.enableProgress() + } + } - // if no version/tag specified, allow whatever's there - if (mani._from === `${mani.name}@`) - return false + manifestMissing (tree, mani) { + // if the tree doesn't have a child by that name/version, return true + // true means we need to install it + const child = tree.children.get(mani.name) + // if no child, we have to load it + if (!child) + return true - // otherwise the version has to match what we WOULD get - return child.version !== mani.version -} + // if no version/tag specified, allow whatever's there + if (mani._from === `${mani.name}@`) + return false -const getBinFromManifest = mani => { - // if we have a bin matching (unscoped portion of) packagename, use that - // otherwise if there's 1 bin or all bin value is the same (alias), use that, - // otherwise fail - const bin = mani.bin || {} - if (new Set(Object.values(bin)).size === 1) - return Object.keys(bin)[0] - - // XXX probably a util to parse this better? - const name = mani.name.replace(/^@[^/]+\//, '') - if (bin[name]) - return name - - // XXX need better error message - throw Object.assign(new Error('could not determine executable to run'), { - pkgid: mani._id, - }) -} + // otherwise the version has to match what we WOULD get + return child.version !== mani.version + } -// only packages not found in ${prefix}/node_modules -const cacheInstallDir = packages => - resolve(npm.config.get('cache'), '_npx', getHash(packages)) + getBinFromManifest (mani) { + // if we have a bin matching (unscoped portion of) packagename, use that + // otherwise if there's 1 bin or all bin value is the same (alias), use + // that, otherwise fail + const bin = mani.bin || {} + if (new Set(Object.values(bin)).size === 1) + return Object.keys(bin)[0] + + // XXX probably a util to parse this better? + const name = mani.name.replace(/^@[^/]+\//, '') + if (bin[name]) + return name + + // XXX need better error message + throw Object.assign(new Error('could not determine executable to run'), { + pkgid: mani._id, + }) + } -const getHash = packages => - crypto.createHash('sha512') - .update(packages.sort((a, b) => a.localeCompare(b)).join('\n')) - .digest('hex') - .slice(0, 16) + cacheInstallDir (packages) { + // only packages not found in ${prefix}/node_modules + return resolve(this.npm.config.get('cache'), '_npx', this.getHash(packages)) + } -module.exports = Object.assign(cmd, { usage }) + getHash (packages) { + return crypto.createHash('sha512') + .update(packages.sort((a, b) => a.localeCompare(b)).join('\n')) + .digest('hex') + .slice(0, 16) + } +} +module.exports = Exec diff --git a/lib/explain.js b/lib/explain.js index a0a4427bccf2c..01541040ef649 100644 --- a/lib/explain.js +++ b/lib/explain.js @@ -1,5 +1,4 @@ const usageUtil = require('./utils/usage.js') -const npm = require('./npm.js') const { explainNode } = require('./utils/explain-dep.js') const completion = require('./utils/completion/installed-deep.js') const output = require('./utils/output.js') @@ -9,86 +8,101 @@ const semver = require('semver') const { relative, resolve } = require('path') const validName = require('validate-npm-package-name') -const usage = usageUtil('explain', 'npm explain ') - -const cmd = (args, cb) => explain(args).then(() => cb()).catch(cb) +class Explain { + constructor (npm) { + this.npm = npm + } -const explain = async (args) => { - if (!args.length) - throw usage + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('explain', 'npm explain ') + } - const arb = new Arborist({ path: npm.prefix, ...npm.flatOptions }) - const tree = await arb.loadActual() + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return completion(this.npm, opts) + } - const nodes = new Set() - for (const arg of args) { - for (const node of getNodes(tree, arg)) - nodes.add(node) + exec (args, cb) { + this.explain(args).then(() => cb()).catch(cb) } - if (nodes.size === 0) - throw `No dependencies found matching ${args.join(', ')}` - const expls = [] - for (const node of nodes) { - const { extraneous, dev, optional, devOptional, peer, inBundle } = node - const expl = node.explain() - if (extraneous) - expl.extraneous = true - else { - expl.dev = dev - expl.optional = optional - expl.devOptional = devOptional - expl.peer = peer - expl.bundled = inBundle + async explain (args) { + if (!args.length) + throw this.usage + + const arb = new Arborist({ path: this.npm.prefix, ...this.npm.flatOptions }) + const tree = await arb.loadActual() + + const nodes = new Set() + for (const arg of args) { + for (const node of this.getNodes(tree, arg)) + nodes.add(node) } - expls.push(expl) - } + if (nodes.size === 0) + throw `No dependencies found matching ${args.join(', ')}` - if (npm.flatOptions.json) - output(JSON.stringify(expls, null, 2)) - else { - output(expls.map(expl => { - return explainNode(expl, Infinity, npm.color) - }).join('\n\n')) + const expls = [] + for (const node of nodes) { + const { extraneous, dev, optional, devOptional, peer, inBundle } = node + const expl = node.explain() + if (extraneous) + expl.extraneous = true + else { + expl.dev = dev + expl.optional = optional + expl.devOptional = devOptional + expl.peer = peer + expl.bundled = inBundle + } + expls.push(expl) + } + + if (this.npm.flatOptions.json) + output(JSON.stringify(expls, null, 2)) + else { + output(expls.map(expl => { + return explainNode(expl, Infinity, this.npm.color) + }).join('\n\n')) + } } -} -const getNodes = (tree, arg) => { - // if it's just a name, return packages by that name - const { validForOldPackages: valid } = validName(arg) - if (valid) - return tree.inventory.query('name', arg) + getNodes (tree, arg) { + // if it's just a name, return packages by that name + const { validForOldPackages: valid } = validName(arg) + if (valid) + return tree.inventory.query('name', arg) - // if it's a location, get that node - const maybeLoc = arg.replace(/\\/g, '/').replace(/\/+$/, '') - const nodeByLoc = tree.inventory.get(maybeLoc) - if (nodeByLoc) - return [nodeByLoc] + // if it's a location, get that node + const maybeLoc = arg.replace(/\\/g, '/').replace(/\/+$/, '') + const nodeByLoc = tree.inventory.get(maybeLoc) + if (nodeByLoc) + return [nodeByLoc] - // maybe a path to a node_modules folder - const maybePath = relative(npm.prefix, resolve(maybeLoc)) - .replace(/\\/g, '/').replace(/\/+$/, '') - const nodeByPath = tree.inventory.get(maybePath) - if (nodeByPath) - return [nodeByPath] + // maybe a path to a node_modules folder + const maybePath = relative(this.npm.prefix, resolve(maybeLoc)) + .replace(/\\/g, '/').replace(/\/+$/, '') + const nodeByPath = tree.inventory.get(maybePath) + if (nodeByPath) + return [nodeByPath] - // otherwise, try to select all matching nodes - try { - return getNodesByVersion(tree, arg) - } catch (er) { - return [] + // otherwise, try to select all matching nodes + try { + return this.getNodesByVersion(tree, arg) + } catch (er) { + return [] + } } -} -const getNodesByVersion = (tree, arg) => { - const spec = npa(arg, npm.prefix) - if (spec.type !== 'version' && spec.type !== 'range') - return [] + getNodesByVersion (tree, arg) { + const spec = npa(arg, this.npm.prefix) + if (spec.type !== 'version' && spec.type !== 'range') + return [] - return tree.inventory.filter(node => { - return node.package.name === spec.name && - semver.satisfies(node.package.version, spec.rawSpec) - }) + return tree.inventory.filter(node => { + return node.package.name === spec.name && + semver.satisfies(node.package.version, spec.rawSpec) + }) + } } - -module.exports = Object.assign(cmd, { usage, completion }) +module.exports = Explain diff --git a/lib/explore.js b/lib/explore.js index e9b09707ec63b..fdfe6e1bcf7c8 100644 --- a/lib/explore.js +++ b/lib/explore.js @@ -1,69 +1,82 @@ // npm explore [@] // open a subshell to the package folder. -const usageUtil = require('./utils/usage.js') -const completion = require('./utils/completion/installed-shallow.js') -const usage = usageUtil('explore', 'npm explore [ -- ]') const rpj = require('read-package-json-fast') - -const cmd = (args, cb) => explore(args).then(() => cb()).catch(cb) - -const output = require('./utils/output.js') -const npm = require('./npm.js') - const runScript = require('@npmcli/run-script') const { join, resolve, relative } = require('path') +const completion = require('./utils/completion/installed-shallow.js') +const output = require('./utils/output.js') +const usageUtil = require('./utils/usage.js') -const explore = async args => { - if (args.length < 1 || !args[0]) - throw usage +class Explore { + constructor (npm) { + this.npm = npm + } - const pkgname = args.shift() + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('explore', 'npm explore [ -- ]') + } - // detect and prevent any .. shenanigans - const path = join(npm.dir, join('/', pkgname)) - if (relative(path, npm.dir) === '') - throw usage + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return completion(this.npm, opts) + } - // run as if running a script named '_explore', which we set to either - // the set of arguments, or the shell config, and let @npmcli/run-script - // handle all the escaping and PATH setup stuff. + exec (args, cb) { + this.explore(args).then(() => cb()).catch(cb) + } - const pkg = await rpj(resolve(path, 'package.json')).catch(er => { - npm.log.error('explore', `It doesn't look like ${pkgname} is installed.`) - throw er - }) + async explore (args) { + if (args.length < 1 || !args[0]) + throw this.usage - const { shell } = npm.flatOptions - pkg.scripts = { - ...(pkg.scripts || {}), - _explore: args.join(' ').trim() || shell, - } + const pkgname = args.shift() + + // detect and prevent any .. shenanigans + const path = join(this.npm.dir, join('/', pkgname)) + if (relative(path, this.npm.dir) === '') + throw this.usage - if (!args.length) - output(`\nExploring ${path}\nType 'exit' or ^D when finished\n`) - npm.log.disableProgress() - try { - return await runScript({ - ...npm.flatOptions, - pkg, - banner: false, - path, - stdioString: true, - event: '_explore', - stdio: 'inherit', - }).catch(er => { - process.exitCode = typeof er.code === 'number' && er.code !== 0 ? er.code - : 1 - // if it's not an exit error, or non-interactive, throw it - const isProcExit = er.message === 'command failed' && - (typeof er.code === 'number' || /^SIG/.test(er.signal || '')) - if (args.length || !isProcExit) - throw er + // run as if running a script named '_explore', which we set to either + // the set of arguments, or the shell config, and let @npmcli/run-script + // handle all the escaping and PATH setup stuff. + + const pkg = await rpj(resolve(path, 'package.json')).catch(er => { + this.npm.log.error('explore', `It doesn't look like ${pkgname} is installed.`) + throw er }) - } finally { - npm.log.enableProgress() + + const { shell } = this.npm.flatOptions + pkg.scripts = { + ...(pkg.scripts || {}), + _explore: args.join(' ').trim() || shell, + } + + if (!args.length) + output(`\nExploring ${path}\nType 'exit' or ^D when finished\n`) + this.npm.log.disableProgress() + try { + return await runScript({ + ...this.npm.flatOptions, + pkg, + banner: false, + path, + stdioString: true, + event: '_explore', + stdio: 'inherit', + }).catch(er => { + process.exitCode = typeof er.code === 'number' && er.code !== 0 ? er.code + : 1 + // if it's not an exit error, or non-interactive, throw it + const isProcExit = er.message === 'command failed' && + (typeof er.code === 'number' || /^SIG/.test(er.signal || '')) + if (args.length || !isProcExit) + throw er + }) + } finally { + this.npm.log.enableProgress() + } } } - -module.exports = Object.assign(cmd, { completion, usage }) +module.exports = Explore diff --git a/lib/find-dupes.js b/lib/find-dupes.js index 19e7ea6a7c8cc..5061be9cc381a 100644 --- a/lib/find-dupes.js +++ b/lib/find-dupes.js @@ -1,8 +1,19 @@ // dedupe duplicated packages, or find them in the tree -const dedupe = require('./dedupe.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil('find-dupes', 'npm find-dupes') -const cmd = (args, cb) => dedupe({ dryRun: true }, cb) +class FindDupes { + constructor (npm) { + this.npm = npm + } -module.exports = Object.assign(cmd, { usage }) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('find-dupes', 'npm find-dupes') + } + + exec (args, cb) { + this.npm.config.set('dry-run', true) + this.npm.commands.dedupe([], cb) + } +} +module.exports = FindDupes diff --git a/lib/fund.js b/lib/fund.js index 41dd48c465342..1e9724266401f 100644 --- a/lib/fund.js +++ b/lib/fund.js @@ -11,200 +11,210 @@ const { isValidFunding, } = require('libnpmfund') -const npm = require('./npm.js') const completion = require('./utils/completion/installed-deep.js') const output = require('./utils/output.js') const openUrl = require('./utils/open-url.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'fund', - 'npm fund', - 'npm fund [--json] [--browser] [--unicode] [[<@scope>/] [--which=]' -) - -const cmd = (args, cb) => fund(args).then(() => cb()).catch(cb) - -function printJSON (fundingInfo) { - return JSON.stringify(fundingInfo, null, 2) -} - const getPrintableName = ({ name, version }) => { const printableVersion = version ? `@${version}` : '' return `${name}${printableVersion}` } -function printHuman (fundingInfo, { color, unicode }) { - const seenUrls = new Map() - - const tree = obj => - archy(obj, '', { unicode }) - - const result = depth({ - tree: fundingInfo, - - // composes human readable package name - // and creates a new archy item for readable output - visit: ({ name, version, funding }) => { - const [fundingSource] = [] - .concat(normalizeFunding(funding)) - .filter(isValidFunding) - const { url } = fundingSource || {} - const pkgRef = getPrintableName({ name, version }) - let item = { - label: pkgRef, - } - - if (url) { - item.label = tree({ - label: color ? chalk.bgBlack.white(url) : url, - nodes: [pkgRef], - }).trim() - - // stacks all packages together under the same item - if (seenUrls.has(url)) { - item = seenUrls.get(url) - item.label += `, ${pkgRef}` - return null - } else - seenUrls.set(url, item) - } - - return item - }, - - // puts child nodes back into returned archy - // output while also filtering out missing items - leave: (item, children) => { - if (item) - item.nodes = children.filter(Boolean) - - return item - }, - - // turns tree-like object return by libnpmfund - // into children to be properly read by treeverse - getChildren: (node) => - Object.keys(node.dependencies || {}) - .map(key => ({ - name: key, - ...node.dependencies[key], - })), - }) - - const res = tree(result) - return color ? chalk.reset(res) : res -} +class Fund { + constructor (npm) { + this.npm = npm + } -async function openFundingUrl ({ path, tree, spec, fundingSourceNumber }) { - const arg = npa(spec, path) - const retrievePackageMetadata = () => { - if (arg.type === 'directory') { - if (tree.path === arg.fetchSpec) { - // matches cwd, e.g: npm fund . - return tree.package - } else { - // matches any file path within current arborist inventory - for (const item of tree.inventory.values()) { - if (item.path === arg.fetchSpec) - return item.package - } - } - } else { - // tries to retrieve a package from arborist inventory - // by matching resulted package name from the provided spec - const [item] = [...tree.inventory.query('name', arg.name)] - .filter(i => semver.valid(i.package.version)) - .sort((a, b) => semver.rcompare(a.package.version, b.package.version)) - - if (item) - return item.package - } + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'fund', + 'npm fund', + 'npm fund [--json] [--browser] [--unicode] [[<@scope>/] [--which=]' + ) } - const { funding } = retrievePackageMetadata() || - await pacote.manifest(arg, npm.flatOptions).catch(() => ({})) - - const validSources = [] - .concat(normalizeFunding(funding)) - .filter(isValidFunding) - - const matchesValidSource = - validSources.length === 1 || - (fundingSourceNumber > 0 && fundingSourceNumber <= validSources.length) - - if (matchesValidSource) { - const index = fundingSourceNumber ? fundingSourceNumber - 1 : 0 - const { type, url } = validSources[index] - const typePrefix = type ? `${type} funding` : 'Funding' - const msg = `${typePrefix} available at the following URL` - return new Promise((resolve, reject) => - openUrl(url, msg, err => err - ? reject(err) - : resolve() - )) - } else if (validSources.length && !(fundingSourceNumber >= 1)) { - validSources.forEach(({ type, url }, i) => { - const typePrefix = type ? `${type} funding` : 'Funding' - const msg = `${typePrefix} available at the following URL` - output(`${i + 1}: ${msg}: ${url}`) - }) - output('Run `npm fund [<@scope>/] --which=1`, for example, to open the first funding URL listed in that package') - } else { - const noFundingError = new Error(`No valid funding method available for: ${spec}`) - noFundingError.code = 'ENOFUND' + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return completion(this.npm, opts) + } - throw noFundingError + exec (args, cb) { + this.fund(args).then(() => cb()).catch(cb) } -} -const fund = async (args) => { - const opts = npm.flatOptions - const spec = args[0] - const numberArg = opts.which + async fund (args) { + const opts = this.npm.flatOptions + const spec = args[0] + const numberArg = opts.which - const fundingSourceNumber = numberArg && parseInt(numberArg, 10) + const fundingSourceNumber = numberArg && parseInt(numberArg, 10) - const badFundingSourceNumber = - numberArg !== undefined && + const badFundingSourceNumber = + numberArg !== undefined && (String(fundingSourceNumber) !== numberArg || fundingSourceNumber < 1) - if (badFundingSourceNumber) { - const err = new Error('`npm fund [<@scope>/] [--which=fundingSourceNumber]` must be given a positive integer') - err.code = 'EFUNDNUMBER' - throw err + if (badFundingSourceNumber) { + const err = new Error('`npm fund [<@scope>/] [--which=fundingSourceNumber]` must be given a positive integer') + err.code = 'EFUNDNUMBER' + throw err + } + + if (opts.global) { + const err = new Error('`npm fund` does not support global packages') + err.code = 'EFUNDGLOBAL' + throw err + } + + const where = this.npm.prefix + const arb = new Arborist({ ...opts, path: where }) + const tree = await arb.loadActual() + + if (spec) { + await this.openFundingUrl({ + path: where, + tree, + spec, + fundingSourceNumber, + }) + return + } + + const print = opts.json + ? this.printJSON + : this.printHuman + + output( + print( + getFundingInfo(tree), + opts + ) + ) } - if (opts.global) { - const err = new Error('`npm fund` does not support global packages') - err.code = 'EFUNDGLOBAL' - throw err + printJSON (fundingInfo) { + return JSON.stringify(fundingInfo, null, 2) } - const where = npm.prefix - const arb = new Arborist({ ...opts, path: where }) - const tree = await arb.loadActual() + printHuman (fundingInfo, { color, unicode }) { + const seenUrls = new Map() + + const tree = obj => + archy(obj, '', { unicode }) + + const result = depth({ + tree: fundingInfo, + + // composes human readable package name + // and creates a new archy item for readable output + visit: ({ name, version, funding }) => { + const [fundingSource] = [] + .concat(normalizeFunding(funding)) + .filter(isValidFunding) + const { url } = fundingSource || {} + const pkgRef = getPrintableName({ name, version }) + let item = { + label: pkgRef, + } + + if (url) { + item.label = tree({ + label: color ? chalk.bgBlack.white(url) : url, + nodes: [pkgRef], + }).trim() + + // stacks all packages together under the same item + if (seenUrls.has(url)) { + item = seenUrls.get(url) + item.label += `, ${pkgRef}` + return null + } else + seenUrls.set(url, item) + } - if (spec) { - await openFundingUrl({ - path: where, - tree, - spec, - fundingSourceNumber, + return item + }, + + // puts child nodes back into returned archy + // output while also filtering out missing items + leave: (item, children) => { + if (item) + item.nodes = children.filter(Boolean) + + return item + }, + + // turns tree-like object return by libnpmfund + // into children to be properly read by treeverse + getChildren: (node) => + Object.keys(node.dependencies || {}) + .map(key => ({ + name: key, + ...node.dependencies[key], + })), }) - return + + const res = tree(result) + return color ? chalk.reset(res) : res } - const print = opts.json - ? printJSON - : printHuman + async openFundingUrl ({ path, tree, spec, fundingSourceNumber }) { + const arg = npa(spec, path) + const retrievePackageMetadata = () => { + if (arg.type === 'directory') { + if (tree.path === arg.fetchSpec) { + // matches cwd, e.g: npm fund . + return tree.package + } else { + // matches any file path within current arborist inventory + for (const item of tree.inventory.values()) { + if (item.path === arg.fetchSpec) + return item.package + } + } + } else { + // tries to retrieve a package from arborist inventory + // by matching resulted package name from the provided spec + const [item] = [...tree.inventory.query('name', arg.name)] + .filter(i => semver.valid(i.package.version)) + .sort((a, b) => semver.rcompare(a.package.version, b.package.version)) + + if (item) + return item.package + } + } + + const { funding } = retrievePackageMetadata() || + await pacote.manifest(arg, this.npm.flatOptions).catch(() => ({})) - output( - print( - getFundingInfo(tree), - opts - ) - ) -} + const validSources = [] + .concat(normalizeFunding(funding)) + .filter(isValidFunding) + + const matchesValidSource = + validSources.length === 1 || + (fundingSourceNumber > 0 && fundingSourceNumber <= validSources.length) + + if (matchesValidSource) { + const index = fundingSourceNumber ? fundingSourceNumber - 1 : 0 + const { type, url } = validSources[index] + const typePrefix = type ? `${type} funding` : 'Funding' + const msg = `${typePrefix} available at the following URL` + return openUrl(this.npm, url, msg) + } else if (validSources.length && !(fundingSourceNumber >= 1)) { + validSources.forEach(({ type, url }, i) => { + const typePrefix = type ? `${type} funding` : 'Funding' + const msg = `${typePrefix} available at the following URL` + output(`${i + 1}: ${msg}: ${url}`) + }) + output('Run `npm fund [<@scope>/] --which=1`, for example, to open the first funding URL listed in that package') + } else { + const noFundingError = new Error(`No valid funding method available for: ${spec}`) + noFundingError.code = 'ENOFUND' -module.exports = Object.assign(cmd, { usage, completion }) + throw noFundingError + } + } +} +module.exports = Fund diff --git a/lib/get.js b/lib/get.js index 8a416027d7fba..a5b2f5514473d 100644 --- a/lib/get.js +++ b/lib/get.js @@ -1,15 +1,25 @@ -const npm = require('./npm.js') -const config = require('./config.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'get', - 'npm get [ ...] (See `npm config`)' -) +class Get { + constructor (npm) { + this.npm = npm + } -const completion = config.completion + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'get', + 'npm get [ ...] (See `npm config`)' + ) + } -const cmd = (args, cb) => - npm.commands.config(['get'].concat(args), cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return this.npm.commands.config.completion(opts) + } -module.exports = Object.assign(cmd, { usage, completion }) + exec (args, cb) { + this.npm.commands.config(['get'].concat(args), cb) + } +} +module.exports = Get diff --git a/lib/help-search.js b/lib/help-search.js index b184735048043..ed2bc23b9109d 100644 --- a/lib/help-search.js +++ b/lib/help-search.js @@ -1,203 +1,211 @@ const fs = require('fs') const path = require('path') -const npm = require('./npm.js') const color = require('ansicolors') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') +const npmUsage = require('./utils/npm-usage.js') const { promisify } = require('util') const glob = promisify(require('glob')) const readFile = promisify(fs.readFile) const didYouMean = require('./utils/did-you-mean.js') const { cmdList } = require('./utils/cmd-list.js') -const usage = usageUtil('help-search', 'npm help-search ') - -const npmUsage = require('./utils/npm-usage.js') +class HelpSearch { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => helpSearch(args).then(() => cb()).catch(cb) + get usage () { + return usageUtil('help-search', 'npm help-search ') + } -const helpSearch = async args => { - if (!args.length) - throw usage + exec (args, cb) { + this.helpSearch(args).then(() => cb()).catch(cb) + } - const docPath = path.resolve(__dirname, '..', 'docs/content') + async helpSearch (args) { + if (!args.length) + throw this.usage + + const docPath = path.resolve(__dirname, '..', 'docs/content') + + const files = await glob(`${docPath}/*/*.md`) + const data = await this.readFiles(files) + const results = await this.searchFiles(args, data, files) + // if only one result, then just show that help section. + if (results.length === 1) { + return this.npm.commands.help([path.basename(results[0].file, '.md')], er => { + if (er) + throw er + }) + } - const files = await glob(`${docPath}/*/*.md`) - const data = await readFiles(files) - const results = await searchFiles(args, data, files) - // if only one result, then just show that help section. - if (results.length === 1) { - return npm.commands.help([path.basename(results[0].file, '.md')], er => { - if (er) - throw er - }) + const formatted = this.formatResults(args, results) + if (!formatted.trim()) + npmUsage(this.npm, false) + else { + output(formatted) + output(didYouMean(args[0], cmdList)) + } } - const formatted = formatResults(args, results) - if (!formatted.trim()) - npmUsage(false) - else { - output(formatted) - output(didYouMean(args[0], cmdList)) + async readFiles (files) { + const res = {} + await Promise.all(files.map(async file => { + res[file] = (await readFile(file, 'utf8')) + .replace(/^---\n(.*\n)*?---\n/, '').trim() + })) + return res } -} -const readFiles = async files => { - const res = {} - await Promise.all(files.map(async file => { - res[file] = (await readFile(file, 'utf8')) - .replace(/^---\n(.*\n)*?---\n/, '').trim() - })) - return res -} + async searchFiles (args, data, files) { + const results = [] + for (const [file, content] of Object.entries(data)) { + const lowerCase = content.toLowerCase() + // skip if no matches at all + if (!args.some(a => lowerCase.includes(a.toLowerCase()))) + continue + + const lines = content.split(/\n+/) + + // if a line has a search term, then skip it and the next line. + // if the next line has a search term, then skip all 3 + // otherwise, set the line to null. then remove the nulls. + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const nextLine = lines[i + 1] + let match = false + if (nextLine) { + match = args.some(a => + nextLine.toLowerCase().includes(a.toLowerCase())) + if (match) { + // skip over the next line, and the line after it. + i += 2 + continue + } + } + + match = args.some(a => line.toLowerCase().includes(a.toLowerCase())) -const searchFiles = async (args, data, files) => { - const results = [] - for (const [file, content] of Object.entries(data)) { - const lowerCase = content.toLowerCase() - // skip if no matches at all - if (!args.some(a => lowerCase.includes(a.toLowerCase()))) - continue - - const lines = content.split(/\n+/) - - // if a line has a search term, then skip it and the next line. - // if the next line has a search term, then skip all 3 - // otherwise, set the line to null. then remove the nulls. - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - const nextLine = lines[i + 1] - let match = false - if (nextLine) { - match = args.some(a => nextLine.toLowerCase().includes(a.toLowerCase())) if (match) { - // skip over the next line, and the line after it. - i += 2 + // skip over the next line + i++ continue } - } - match = args.some(a => line.toLowerCase().includes(a.toLowerCase())) - - if (match) { - // skip over the next line - i++ - continue + lines[i] = null } - lines[i] = null - } - - // now squish any string of nulls into a single null - const pruned = lines.reduce((l, r) => { - if (!(r === null && l[l.length - 1] === null)) - l.push(r) + // now squish any string of nulls into a single null + const pruned = lines.reduce((l, r) => { + if (!(r === null && l[l.length - 1] === null)) + l.push(r) - return l - }, []) + return l + }, []) - if (pruned[pruned.length - 1] === null) - pruned.pop() + if (pruned[pruned.length - 1] === null) + pruned.pop() - if (pruned[0] === null) - pruned.shift() + if (pruned[0] === null) + pruned.shift() - // now count how many args were found - const found = {} - let totalHits = 0 - for (const line of pruned) { - for (const arg of args) { - const hit = (line || '').toLowerCase() - .split(arg.toLowerCase()).length - 1 + // now count how many args were found + const found = {} + let totalHits = 0 + for (const line of pruned) { + for (const arg of args) { + const hit = (line || '').toLowerCase() + .split(arg.toLowerCase()).length - 1 - if (hit > 0) { - found[arg] = (found[arg] || 0) + hit - totalHits += hit + if (hit > 0) { + found[arg] = (found[arg] || 0) + hit + totalHits += hit + } } } + + const cmd = 'npm help ' + + path.basename(file, '.md').replace(/^npm-/, '') + results.push({ + file, + cmd, + lines: pruned, + found: Object.keys(found), + hits: found, + totalHits, + }) } - const cmd = 'npm help ' + - path.basename(file, '.md').replace(/^npm-/, '') - results.push({ - file, - cmd, - lines: pruned, - found: Object.keys(found), - hits: found, - totalHits, - }) + // sort results by number of results found, then by number of hits + // then by number of matching lines + + // coverage is ignored here because the contents of results are + // nondeterministic due to either glob or readFiles or Object.entries + return results.sort(/* istanbul ignore next */ (a, b) => + a.found.length > b.found.length ? -1 + : a.found.length < b.found.length ? 1 + : a.totalHits > b.totalHits ? -1 + : a.totalHits < b.totalHits ? 1 + : a.lines.length > b.lines.length ? -1 + : a.lines.length < b.lines.length ? 1 + : 0).slice(0, 10) } - // sort results by number of results found, then by number of hits - // then by number of matching lines - - // coverage is ignored here because the contents of results are - // nondeterministic due to either glob or readFiles or Object.entries - return results.sort(/* istanbul ignore next */ (a, b) => - a.found.length > b.found.length ? -1 - : a.found.length < b.found.length ? 1 - : a.totalHits > b.totalHits ? -1 - : a.totalHits < b.totalHits ? 1 - : a.lines.length > b.lines.length ? -1 - : a.lines.length < b.lines.length ? 1 - : 0).slice(0, 10) -} - -const formatResults = (args, results) => { - const cols = Math.min(process.stdout.columns || Infinity, 80) + 1 + formatResults (args, results) { + const cols = Math.min(process.stdout.columns || Infinity, 80) + 1 - const out = results.map(res => { - const out = [res.cmd] - const r = Object.keys(res.hits) - .map(k => `${k}:${res.hits[k]}`) - .sort((a, b) => a > b ? 1 : -1) - .join(' ') + const out = results.map(res => { + const out = [res.cmd] + const r = Object.keys(res.hits) + .map(k => `${k}:${res.hits[k]}`) + .sort((a, b) => a > b ? 1 : -1) + .join(' ') - out.push(' '.repeat((Math.max(1, cols - out.join(' ').length - r.length - 1)))) - out.push(r) + out.push(' '.repeat((Math.max(1, cols - out.join(' ').length - r.length - 1)))) + out.push(r) - if (!npm.flatOptions.long) - return out.join('') + if (!this.npm.flatOptions.long) + return out.join('') - out.unshift('\n\n') - out.push('\n') - out.push('-'.repeat(cols - 1) + '\n') - res.lines.forEach((line, i) => { - if (line === null || i > 3) - return + out.unshift('\n\n') + out.push('\n') + out.push('-'.repeat(cols - 1) + '\n') + res.lines.forEach((line, i) => { + if (line === null || i > 3) + return - if (!npm.color) { - out.push(line + '\n') - return - } - const hilitLine = [] - for (const arg of args) { - const finder = line.toLowerCase().split(arg.toLowerCase()) - let p = 0 - for (const f of finder) { - hilitLine.push(line.substr(p, f.length)) - const word = line.substr(p + f.length, arg.length) - const hilit = color.bgBlack(color.red(word)) - hilitLine.push(hilit) - p += f.length + arg.length + if (!this.npm.color) { + out.push(line + '\n') + return } - } - out.push(hilitLine.join('') + '\n') - }) + const hilitLine = [] + for (const arg of args) { + const finder = line.toLowerCase().split(arg.toLowerCase()) + let p = 0 + for (const f of finder) { + hilitLine.push(line.substr(p, f.length)) + const word = line.substr(p + f.length, arg.length) + const hilit = color.bgBlack(color.red(word)) + hilitLine.push(hilit) + p += f.length + arg.length + } + } + out.push(hilitLine.join('') + '\n') + }) - return out.join('') - }).join('\n') + return out.join('') + }).join('\n') - const finalOut = results.length && !npm.flatOptions.long - ? 'Top hits for ' + (args.map(JSON.stringify).join(' ')) + '\n' + + const finalOut = results.length && !this.npm.flatOptions.long + ? 'Top hits for ' + (args.map(JSON.stringify).join(' ')) + '\n' + '—'.repeat(cols - 1) + '\n' + out + '\n' + '—'.repeat(cols - 1) + '\n' + '(run with -l or --long to see more context)' - : out + : out - return finalOut.trim() + return finalOut.trim() + } } - -module.exports = Object.assign(cmd, { usage }) +module.exports = HelpSearch diff --git a/lib/help.js b/lib/help.js index 6f215c76c1ead..d7897326f3118 100644 --- a/lib/help.js +++ b/lib/help.js @@ -1,191 +1,224 @@ - -module.exports = help - -help.completion = async (opts) => { - if (opts.conf.argv.remain.length > 2) - return [] - const g = path.resolve(__dirname, '../man/man[0-9]/*.[0-9]') - const files = await new Promise((resolve, reject) => { - glob(g, function (er, files) { - if (er) - return reject(er) - resolve(files) - }) - }) - - return Object.keys(files.reduce(function (acc, file) { - file = path.basename(file).replace(/\.[0-9]+$/, '') - file = file.replace(/^npm-/, '') - acc[file] = true - return acc - }, { help: true })) -} - const npmUsage = require('./utils/npm-usage.js') const { spawn } = require('child_process') const path = require('path') -const npm = require('./npm.js') const log = require('npmlog') -const openUrl = require('./utils/open-url') +const openUrl = require('./utils/open-url.js') const glob = require('glob') const output = require('./utils/output.js') const usage = require('./utils/usage.js') -help.usage = usage('help', 'npm help []') - -function help (args, cb) { - const argv = npm.config.parsedArgv.cooked - - let argnum = 0 - if (args.length === 2 && ~~args[0]) - argnum = ~~args.shift() - - // npm help foo bar baz: search topics - if (args.length > 1 && args[0]) - return npm.commands['help-search'](args, cb) +class Help { + constructor (npm) { + this.npm = npm + } - const affordances = { - 'find-dupes': 'dedupe', + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usage('help', 'npm help []') } - let section = affordances[args[0]] || npm.deref(args[0]) || args[0] - // npm help : show basic usage - if (!section) { - npmUsage(argv[0] === 'help') - return cb() + async completion (opts) { + if (opts.conf.argv.remain.length > 2) + return [] + const g = path.resolve(__dirname, '../man/man[0-9]/*.[0-9]') + const files = await new Promise((resolve, reject) => { + glob(g, function (er, files) { + if (er) + return reject(er) + resolve(files) + }) + }) + + return Object.keys(files.reduce(function (acc, file) { + file = path.basename(file).replace(/\.[0-9]+$/, '') + file = file.replace(/^npm-/, '') + acc[file] = true + return acc + }, { help: true })) } - // npm -h: show command usage - if (npm.config.get('usage') && - npm.commands[section] && - npm.commands[section].usage) { - npm.config.set('loglevel', 'silent') - log.level = 'silent' - output(npm.commands[section].usage) - return cb() + exec (args, cb) { + this.help(args).then(() => cb()).catch(cb) } - let pref = [1, 5, 7] - if (argnum) - pref = [argnum].concat(pref.filter(n => n !== argnum)) - - // npm help
: Try to find the path - const manroot = path.resolve(__dirname, '..', 'man') - - // legacy - if (section === 'global') - section = 'folders' - else if (section.match(/.*json/)) - section = section.replace('.json', '-json') - - // find either /section.n or /npm-section.n - // The glob is used in the glob. The regexp is used much - // further down. Globs and regexps are different - const compextglob = '.+(gz|bz2|lzma|[FYzZ]|xz)' - const compextre = '\\.(gz|bz2|lzma|[FYzZ]|xz)$' - const f = '+(npm-' + section + '|' + section + ').[0-9]?(' + compextglob + ')' - return glob(manroot + '/*/' + f, (er, mans) => { - if (er) - return cb(er) - - if (!mans.length) - return npm.commands['help-search'](args, cb) - - mans = mans.map((man) => { - const ext = path.extname(man) - if (man.match(new RegExp(compextre))) - man = path.basename(man, ext) - - return man + async help (args) { + const argv = this.npm.config.parsedArgv.cooked + + let argnum = 0 + if (args.length === 2 && ~~args[0]) + argnum = ~~args.shift() + + // npm help foo bar baz: search topics + if (args.length > 1 && args[0]) + return this.helpSearch(args) + + const affordances = { + 'find-dupes': 'dedupe', + } + let section = affordances[args[0]] || this.npm.deref(args[0]) || args[0] + + // npm help : show basic usage + if (!section) { + npmUsage(this.npm, argv[0] === 'help') + return + } + + // npm -h: show command usage + if (this.npm.config.get('usage') && + this.npm.commands[section] && + this.npm.commands[section].usage) { + this.npm.config.set('loglevel', 'silent') + log.level = 'silent' + output(this.npm.commands[section].usage) + return + } + + let pref = [1, 5, 7] + if (argnum) + pref = [argnum].concat(pref.filter(n => n !== argnum)) + + // npm help
: Try to find the path + const manroot = path.resolve(__dirname, '..', 'man') + + // legacy + if (section === 'global') + section = 'folders' + else if (section.match(/.*json/)) + section = section.replace('.json', '-json') + + // find either /section.n or /npm-section.n + // The glob is used in the glob. The regexp is used much + // further down. Globs and regexps are different + const compextglob = '.+(gz|bz2|lzma|[FYzZ]|xz)' + const compextre = '\\.(gz|bz2|lzma|[FYzZ]|xz)$' + const f = '+(npm-' + section + '|' + section + ').[0-9]?(' + compextglob + ')' + return new Promise((resolve, reject) => { + glob(manroot + '/*/' + f, async (er, mans) => { + if (er) + return reject(er) + + if (!mans.length) { + this.helpSearch(args).then(resolve).catch(reject) + return + } + + mans = mans.map((man) => { + const ext = path.extname(man) + if (man.match(new RegExp(compextre))) + man = path.basename(man, ext) + + return man + }) + + this.viewMan(this.pickMan(mans, pref), (err) => { + if (err) + return reject(err) + return resolve() + }) + }) }) + } - viewMan(pickMan(mans, pref), cb) - }) -} - -function pickMan (mans, pref_) { - const nre = /([0-9]+)$/ - const pref = {} - pref_.forEach((sect, i) => pref[sect] = i) - mans = mans.sort((a, b) => { - const an = a.match(nre)[1] - const bn = b.match(nre)[1] - return an === bn ? (a > b ? -1 : 1) - : pref[an] < pref[bn] ? -1 - : 1 - }) - return mans[0] -} + helpSearch (args) { + return new Promise((resolve, reject) => { + this.npm.commands['help-search'](args, (err) => { + // This would only error if args was empty, which it never is + /* istanbul ignore next */ + if (err) + return reject(err) -function viewMan (man, cb) { - const nre = /([0-9]+)$/ - const num = man.match(nre)[1] - const section = path.basename(man, '.' + num) - - // at this point, we know that the specified man page exists - const manpath = path.join(__dirname, '..', 'man') - const env = {} - Object.keys(process.env).forEach(function (i) { - env[i] = process.env[i] - }) - env.MANPATH = manpath - const viewer = npm.config.get('viewer') - - const opts = { - env, - stdio: 'inherit', + resolve() + }) + }) } - let bin = 'man' - const args = [] - switch (viewer) { - case 'woman': - bin = 'emacsclient' - args.push('-e', `(woman-find-file '${man}')`) - break - - case 'browser': - bin = false - try { - const url = htmlMan(man) - openUrl(url, 'help available at the following URL', cb) - } catch (err) { - return cb(err) - } - break - - default: - args.push(num, section) - break + pickMan (mans, pref_) { + const nre = /([0-9]+)$/ + const pref = {} + pref_.forEach((sect, i) => pref[sect] = i) + mans = mans.sort((a, b) => { + const an = a.match(nre)[1] + const bn = b.match(nre)[1] + return an === bn ? (a > b ? -1 : 1) + : pref[an] < pref[bn] ? -1 + : 1 + }) + return mans[0] } - if (bin) { - const proc = spawn(bin, args, opts) - proc.on('exit', (code) => { - if (code) - return cb(new Error(`help process exited with code: ${code}`)) + viewMan (man, cb) { + const nre = /([0-9]+)$/ + const num = man.match(nre)[1] + const section = path.basename(man, '.' + num) - return cb() + // at this point, we know that the specified man page exists + const manpath = path.join(__dirname, '..', 'man') + const env = {} + Object.keys(process.env).forEach(function (i) { + env[i] = process.env[i] }) + env.MANPATH = manpath + const viewer = this.npm.config.get('viewer') + + const opts = { + env, + stdio: 'inherit', + } + + let bin = 'man' + const args = [] + switch (viewer) { + case 'woman': + bin = 'emacsclient' + args.push('-e', `(woman-find-file '${man}')`) + break + + case 'browser': + bin = false + try { + const url = this.htmlMan(man) + openUrl(this.npm, url, 'help available at the following URL').then( + () => cb() + ).catch(cb) + } catch (err) { + cb(err) + } + break + + default: + args.push(num, section) + break + } + + if (bin) { + const proc = spawn(bin, args, opts) + proc.on('exit', (code) => { + if (code) + return cb(new Error(`help process exited with code: ${code}`)) + + return cb() + }) + } } -} -function htmlMan (man) { - let sect = +man.match(/([0-9]+)$/)[1] - const f = path.basename(man).replace(/[.]([0-9]+)$/, '') - switch (sect) { - case 1: - sect = 'commands' - break - case 5: - sect = 'configuring-npm' - break - case 7: - sect = 'using-npm' - break - default: - throw new Error('invalid man section: ' + sect) + htmlMan (man) { + let sect = +man.match(/([0-9]+)$/)[1] + const f = path.basename(man).replace(/[.]([0-9]+)$/, '') + switch (sect) { + case 1: + sect = 'commands' + break + case 5: + sect = 'configuring-npm' + break + case 7: + sect = 'using-npm' + break + default: + throw new Error('invalid man section: ' + sect) + } + return 'file://' + path.resolve(__dirname, '..', 'docs', 'output', sect, f + '.html') } - return 'file://' + path.resolve(__dirname, '..', 'docs', 'output', sect, f + '.html') } +module.exports = Help diff --git a/lib/hook.js b/lib/hook.js index 7d69ccbf2aa4c..312f542d7cff6 100644 --- a/lib/hook.js +++ b/lib/hook.js @@ -1,53 +1,62 @@ const hookApi = require('libnpmhook') -const npm = require('./npm.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') const relativeDate = require('tiny-relative-date') const Table = require('cli-table3') - const usageUtil = require('./utils/usage.js') -const usage = usageUtil('hook', [ - 'npm hook add [--type=]', - 'npm hook ls [pkg]', - 'npm hook rm ', - 'npm hook update ', -].join('\n')) -const cmd = (args, cb) => hook(args).then(() => cb()).catch(cb) +class Hook { + constructor (npm) { + this.npm = npm + } + + get usage () { + return usageUtil('hook', [ + 'npm hook add [--type=]', + 'npm hook ls [pkg]', + 'npm hook rm ', + 'npm hook update ', + ].join('\n')) + } + + exec (args, cb) { + this.hook(args).then(() => cb()).catch(cb) + } -const hook = async (args) => otplease(npm.flatOptions, opts => { - switch (args[0]) { - case 'add': - return add(args[1], args[2], args[3], opts) - case 'ls': - return ls(args[1], opts) - case 'rm': - return rm(args[1], opts) - case 'update': - case 'up': - return update(args[1], args[2], args[3], opts) - default: - throw usage + async hook (args) { + return otplease(this.npm.flatOptions, (opts) => { + switch (args[0]) { + case 'add': + return this.add(args[1], args[2], args[3], opts) + case 'ls': + return this.ls(args[1], opts) + case 'rm': + return this.rm(args[1], opts) + case 'update': + case 'up': + return this.update(args[1], args[2], args[3], opts) + default: + throw this.usage + } + }) } -}) -const add = (pkg, uri, secret, opts) => { - hookApi.add(pkg, uri, secret, opts).then(hook => { + async add (pkg, uri, secret, opts) { + const hook = await hookApi.add(pkg, uri, secret, opts) if (opts.json) output(JSON.stringify(hook, null, 2)) else if (opts.parseable) { output(Object.keys(hook).join('\t')) output(Object.keys(hook).map(k => hook[k]).join('\t')) } else if (!opts.silent && opts.loglevel !== 'silent') { - output(`+ ${hookName(hook)} ${ + output(`+ ${this.hookName(hook)} ${ opts.unicode ? ' ➜ ' : ' -> ' } ${hook.endpoint}`) } - }) -} + } -const ls = (pkg, opts) => { - return hookApi.ls({ ...opts, package: pkg }).then(hooks => { + async ls (pkg, opts) { + const hooks = await hookApi.ls({ ...opts, package: pkg }) if (opts.json) output(JSON.stringify(hooks, null, 2)) else if (opts.parseable) { @@ -67,7 +76,7 @@ const ls = (pkg, opts) => { hooks.forEach((hook) => { table.push([ { rowSpan: 2, content: hook.id }, - hookName(hook), + this.hookName(hook), hook.endpoint, ]) if (hook.last_delivery) { @@ -83,46 +92,43 @@ const ls = (pkg, opts) => { }) output(table.toString()) } - }) -} + } -const rm = (id, opts) => { - return hookApi.rm(id, opts).then(hook => { + async rm (id, opts) { + const hook = await hookApi.rm(id, opts) if (opts.json) output(JSON.stringify(hook, null, 2)) else if (opts.parseable) { output(Object.keys(hook).join('\t')) output(Object.keys(hook).map(k => hook[k]).join('\t')) } else if (!opts.silent && opts.loglevel !== 'silent') { - output(`- ${hookName(hook)} ${ + output(`- ${this.hookName(hook)} ${ opts.unicode ? ' ✘ ' : ' X ' } ${hook.endpoint}`) } - }) -} + } -const update = (id, uri, secret, opts) => { - return hookApi.update(id, uri, secret, opts).then(hook => { + async update (id, uri, secret, opts) { + const hook = await hookApi.update(id, uri, secret, opts) if (opts.json) output(JSON.stringify(hook, null, 2)) else if (opts.parseable) { output(Object.keys(hook).join('\t')) output(Object.keys(hook).map(k => hook[k]).join('\t')) } else if (!opts.silent && opts.loglevel !== 'silent') { - output(`+ ${hookName(hook)} ${ + output(`+ ${this.hookName(hook)} ${ opts.unicode ? ' ➜ ' : ' -> ' } ${hook.endpoint}`) } - }) -} + } -const hookName = (hook) => { - let target = hook.name - if (hook.type === 'scope') - target = '@' + target - if (hook.type === 'owner') - target = '~' + target - return target + hookName (hook) { + let target = hook.name + if (hook.type === 'scope') + target = '@' + target + if (hook.type === 'owner') + target = '~' + target + return target + } } - -module.exports = Object.assign(cmd, { usage }) +module.exports = Hook diff --git a/lib/init.js b/lib/init.js index a029779f89638..af97a9614e368 100644 --- a/lib/init.js +++ b/lib/init.js @@ -1,88 +1,97 @@ const initJson = require('init-package-json') const npa = require('npm-package-arg') -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const output = require('./utils/output.js') -const usage = usageUtil( - 'init', - '\nnpm init [--force|-f|--yes|-y|--scope]' + - '\nnpm init <@scope> (same as `npx <@scope>/create`)' + - '\nnpm init [<@scope>/] (same as `npx [<@scope>/]create-`)' -) - -const cmd = (args, cb) => init(args).then(() => cb()).catch(cb) +class Init { + constructor (npm) { + this.npm = npm + } -const init = async args => { - // the new npx style way - if (args.length) { - const initerName = args[0] - let packageName = initerName - if (/^@[^/]+$/.test(initerName)) - packageName = initerName + '/create' - else { - const req = npa(initerName) - if (req.type === 'git' && req.hosted) { - const { user, project } = req.hosted - packageName = initerName - .replace(user + '/' + project, user + '/create-' + project) - } else if (req.registry) { - packageName = req.name.replace(/^(@[^/]+\/)?/, '$1create-') - if (req.rawSpec) - packageName += '@' + req.rawSpec - } else { - throw Object.assign(new Error( - 'Unrecognized initializer: ' + initerName + - '\nFor more package binary executing power check out `npx`:' + - '\nhttps://www.npmjs.com/package/npx' - ), { code: 'EUNSUPPORTED' }) - } - } - npm.config.set('package', []) - const newArgs = [packageName, ...args.slice(1)] - return new Promise((res, rej) => { - npm.commands.exec(newArgs, er => er ? rej(er) : res()) - }) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'init', + '\nnpm init [--force|-f|--yes|-y|--scope]' + + '\nnpm init <@scope> (same as `npx <@scope>/create`)' + + '\nnpm init [<@scope>/] (same as `npx [<@scope>/]create-`)' + ) } - // the old way - const dir = process.cwd() - npm.log.pause() - npm.log.disableProgress() - const initFile = npm.config.get('init-module') - if (!npm.flatOptions.yes && !npm.flatOptions.force) { - output([ - 'This utility will walk you through creating a package.json file.', - 'It only covers the most common items, and tries to guess sensible defaults.', - '', - 'See `npm help init` for definitive documentation on these fields', - 'and exactly what they do.', - '', - 'Use `npm install ` afterwards to install a package and', - 'save it as a dependency in the package.json file.', - '', - 'Press ^C at any time to quit.', - ].join('\n')) + exec (args, cb) { + this.init(args).then(() => cb()).catch(cb) } - // XXX promisify init-package-json - await new Promise((res, rej) => { - initJson(dir, initFile, npm.config, (er, data) => { - npm.log.resume() - npm.log.enableProgress() - npm.log.silly('package data', data) - if (er && er.message === 'canceled') { - npm.log.warn('init', 'canceled') - return res() - } - if (er) - rej(er) + + async init (args) { + // the new npx style way + if (args.length) { + const initerName = args[0] + let packageName = initerName + if (/^@[^/]+$/.test(initerName)) + packageName = initerName + '/create' else { - npm.log.info('init', 'written successfully') - res(data) + const req = npa(initerName) + if (req.type === 'git' && req.hosted) { + const { user, project } = req.hosted + packageName = initerName + .replace(user + '/' + project, user + '/create-' + project) + } else if (req.registry) { + packageName = req.name.replace(/^(@[^/]+\/)?/, '$1create-') + if (req.rawSpec) + packageName += '@' + req.rawSpec + } else { + throw Object.assign(new Error( + 'Unrecognized initializer: ' + initerName + + '\nFor more package binary executing power check out `npx`:' + + '\nhttps://www.npmjs.com/package/npx' + ), { code: 'EUNSUPPORTED' }) + } } + this.npm.config.set('package', []) + const newArgs = [packageName, ...args.slice(1)] + return new Promise((res, rej) => { + this.npm.commands.exec(newArgs, er => er ? rej(er) : res()) + }) + } + + // the old way + const dir = process.cwd() + this.npm.log.pause() + this.npm.log.disableProgress() + const initFile = this.npm.config.get('init-module') + if (!this.npm.flatOptions.yes && !this.npm.flatOptions.force) { + output([ + 'This utility will walk you through creating a package.json file.', + 'It only covers the most common items, and tries to guess sensible defaults.', + '', + 'See `npm help init` for definitive documentation on these fields', + 'and exactly what they do.', + '', + 'Use `npm install ` afterwards to install a package and', + 'save it as a dependency in the package.json file.', + '', + 'Press ^C at any time to quit.', + ].join('\n')) + } + // XXX promisify init-package-json + await new Promise((res, rej) => { + initJson(dir, initFile, this.npm.config, (er, data) => { + this.npm.log.resume() + this.npm.log.enableProgress() + this.npm.log.silly('package data', data) + if (er && er.message === 'canceled') { + this.npm.log.warn('init', 'canceled') + return res() + } + if (er) + rej(er) + else { + this.npm.log.info('init', 'written successfully') + res(data) + } + }) }) - }) + } } - -module.exports = Object.assign(cmd, { usage }) +module.exports = Init diff --git a/lib/install-ci-test.js b/lib/install-ci-test.js index 52c41c413a64c..d1740999d4b67 100644 --- a/lib/install-ci-test.js +++ b/lib/install-ci-test.js @@ -1,19 +1,27 @@ // npm install-ci-test // Runs `npm ci` and then runs `npm test` -const ci = require('./ci.js') -const test = require('./test.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'install-ci-test', - 'npm install-ci-test [args]' + - '\nSame args as `npm ci`' -) +class InstallCITest { + constructor (npm) { + this.npm = npm + } -const completion = ci.completion + get usage () { + return usageUtil( + 'install-ci-test', + 'npm install-ci-test [args]' + + '\nSame args as `npm ci`' + ) + } -const ciTest = (args, cb) => - ci(args, er => er ? cb(er) : test([], cb)) - -module.exports = Object.assign(ciTest, { usage, completion }) + exec (args, cb) { + this.npm.commands.ci(args, (er) => { + if (er) + return cb(er) + this.npm.commands.test([], cb) + }) + } +} +module.exports = InstallCITest diff --git a/lib/install-test.js b/lib/install-test.js index 9593361e320b8..487f8da00b6d3 100644 --- a/lib/install-test.js +++ b/lib/install-test.js @@ -1,19 +1,31 @@ // npm install-test // Runs `npm install` and then runs `npm test` -const install = require('./install.js') -const test = require('./test.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'install-test', - 'npm install-test [args]' + - '\nSame args as `npm install`' -) +class InstallTest { + constructor (npm) { + this.npm = npm + } -const completion = install.completion + get usage () { + return usageUtil( + 'install-test', + 'npm install-test [args]' + + '\nSame args as `npm install`' + ) + } -const installTest = (args, cb) => - install(args, er => er ? cb(er) : test([], cb)) + async completion (opts) { + return this.npm.commands.install.completion(opts) + } -module.exports = Object.assign(installTest, { usage, completion }) + exec (args, cb) { + this.npm.commands.install(args, (er) => { + if (er) + return cb(er) + this.npm.commands.test([], cb) + }) + } +} +module.exports = InstallTest diff --git a/lib/install.js b/lib/install.js index 5f0137db1ceac..d7fd384d5bd6f 100644 --- a/lib/install.js +++ b/lib/install.js @@ -3,7 +3,6 @@ const fs = require('fs') const util = require('util') const readdir = util.promisify(fs.readdir) -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const reifyFinish = require('./utils/reify-finish.js') const log = require('npmlog') @@ -11,133 +10,143 @@ const { resolve, join } = require('path') const Arborist = require('@npmcli/arborist') const runScript = require('@npmcli/run-script') -const cmd = async (args, cb) => install(args).then(() => cb()).catch(cb) - -const install = async args => { - // the /path/to/node_modules/.. - const globalTop = resolve(npm.globalDir, '..') - const { ignoreScripts, global: isGlobalInstall } = npm.flatOptions - const where = isGlobalInstall ? globalTop : npm.prefix - - // don't try to install the prefix into itself - args = args.filter(a => resolve(a) !== npm.prefix) - - // `npm i -g` => "install this package globally" - if (where === globalTop && !args.length) - args = ['.'] - - // TODO: Add warnings for other deprecated flags? or remove this one? - if (npm.config.get('dev')) - log.warn('install', 'Usage of the `--dev` option is deprecated. Use `--include=dev` instead.') - - const arb = new Arborist({ - ...npm.flatOptions, - path: where, - }) +class Install { + constructor (npm) { + this.npm = npm + } - await arb.reify({ - ...npm.flatOptions, - add: args, - }) - if (!args.length && !isGlobalInstall && !ignoreScripts) { - const { scriptShell } = npm.flatOptions - const scripts = [ - 'preinstall', + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( 'install', - 'postinstall', - 'prepublish', // XXX should we remove this finally?? - 'preprepare', - 'prepare', - 'postprepare', - ] - for (const event of scripts) { - await runScript({ - path: where, - args: [], - scriptShell, - stdio: 'inherit', - stdioString: true, - banner: log.level !== 'silent', - event, - }) - } + 'npm install (with no args, in package dir)' + + '\nnpm install [<@scope>/]' + + '\nnpm install [<@scope>/]@' + + '\nnpm install [<@scope>/]@' + + '\nnpm install [<@scope>/]@' + + '\nnpm install @npm:' + + '\nnpm install ' + + '\nnpm install ' + + '\nnpm install ' + + '\nnpm install ' + + '\nnpm install /', + '[--save-prod|--save-dev|--save-optional|--save-peer] [--save-exact] [--no-save]' + ) } - await reifyFinish(arb) -} -const usage = usageUtil( - 'install', - 'npm install (with no args, in package dir)' + - '\nnpm install [<@scope>/]' + - '\nnpm install [<@scope>/]@' + - '\nnpm install [<@scope>/]@' + - '\nnpm install [<@scope>/]@' + - '\nnpm install @npm:' + - '\nnpm install ' + - '\nnpm install ' + - '\nnpm install ' + - '\nnpm install ' + - '\nnpm install /', - '[--save-prod|--save-dev|--save-optional|--save-peer] [--save-exact] [--no-save]' -) + async completion (opts) { + const { partialWord } = opts + // install can complete to a folder with a package.json, or any package. + // if it has a slash, then it's gotta be a folder + // if it starts with https?://, then just give up, because it's a url + if (/^https?:\/\//.test(partialWord)) { + // do not complete to URLs + return [] + } -const completion = async (opts) => { - const { partialWord } = opts - // install can complete to a folder with a package.json, or any package. - // if it has a slash, then it's gotta be a folder - // if it starts with https?://, then just give up, because it's a url - if (/^https?:\/\//.test(partialWord)) { - // do not complete to URLs - return [] - } + if (/\//.test(partialWord)) { + // Complete fully to folder if there is exactly one match and it + // is a folder containing a package.json file. If that is not the + // case we return 0 matches, which will trigger the default bash + // complete. + const lastSlashIdx = partialWord.lastIndexOf('/') + const partialName = partialWord.slice(lastSlashIdx + 1) + const partialPath = partialWord.slice(0, lastSlashIdx) || '/' - if (/\//.test(partialWord)) { - // Complete fully to folder if there is exactly one match and it - // is a folder containing a package.json file. If that is not the - // case we return 0 matches, which will trigger the default bash - // complete. - const lastSlashIdx = partialWord.lastIndexOf('/') - const partialName = partialWord.slice(lastSlashIdx + 1) - const partialPath = partialWord.slice(0, lastSlashIdx) || '/' + const annotatePackageDirMatch = async (sibling) => { + const fullPath = join(partialPath, sibling) + if (sibling.slice(0, partialName.length) !== partialName) + return null // not name match - const annotatePackageDirMatch = async (sibling) => { - const fullPath = join(partialPath, sibling) - if (sibling.slice(0, partialName.length) !== partialName) - return null // not name match + try { + const contents = await readdir(fullPath) + return { + fullPath, + isPackage: contents.indexOf('package.json') !== -1, + } + } catch (er) { + return { isPackage: false } + } + } try { - const contents = await readdir(fullPath) - return { - fullPath, - isPackage: contents.indexOf('package.json') !== -1, + const siblings = await readdir(partialPath) + const matches = await Promise.all( + siblings.map(async sibling => { + return await annotatePackageDirMatch(sibling) + }) + ) + const match = matches.filter(el => !el || el.isPackage).pop() + if (match) { + // Success - only one match and it is a package dir + return [match.fullPath] + } else { + // no matches + return [] } } catch (er) { - return { isPackage: false } + return [] // invalid dir: no matching } } + // Note: there used to be registry completion here, + // but it stopped making sense somewhere around + // 50,000 packages on the registry + } + + exec (args, cb) { + this.install(args).then(() => cb()).catch(cb) + } + + async install (args) { + // the /path/to/node_modules/.. + const globalTop = resolve(this.npm.globalDir, '..') + const { ignoreScripts, global: isGlobalInstall } = this.npm.flatOptions + const where = isGlobalInstall ? globalTop : this.npm.prefix - try { - const siblings = await readdir(partialPath) - const matches = await Promise.all( - siblings.map(async sibling => { - return await annotatePackageDirMatch(sibling) + // don't try to install the prefix into itself + args = args.filter(a => resolve(a) !== this.npm.prefix) + + // `npm i -g` => "install this package globally" + if (where === globalTop && !args.length) + args = ['.'] + + // TODO: Add warnings for other deprecated flags? or remove this one? + if (this.npm.config.get('dev')) + log.warn('install', 'Usage of the `--dev` option is deprecated. Use `--include=dev` instead.') + + const arb = new Arborist({ + ...this.npm.flatOptions, + path: where, + }) + + await arb.reify({ + ...this.npm.flatOptions, + add: args, + }) + if (!args.length && !isGlobalInstall && !ignoreScripts) { + const { scriptShell } = this.npm.flatOptions + const scripts = [ + 'preinstall', + 'install', + 'postinstall', + 'prepublish', // XXX should we remove this finally?? + 'preprepare', + 'prepare', + 'postprepare', + ] + for (const event of scripts) { + await runScript({ + path: where, + args: [], + scriptShell, + stdio: 'inherit', + stdioString: true, + banner: log.level !== 'silent', + event, }) - ) - const match = matches.filter(el => !el || el.isPackage).pop() - if (match) { - // Success - only one match and it is a package dir - return [match.fullPath] - } else { - // no matches - return [] } - } catch (er) { - return [] // invalid dir: no matching } + await reifyFinish(this.npm, arb) } - // Note: there used to be registry completion here, - // but it stopped making sense somewhere around - // 50,000 packages on the registry } - -module.exports = Object.assign(cmd, { usage, completion }) +module.exports = Install diff --git a/lib/link.js b/lib/link.js index 0bb3d87b5e7d4..6d5e207105825 100644 --- a/lib/link.js +++ b/lib/link.js @@ -8,145 +8,154 @@ const npa = require('npm-package-arg') const rpj = require('read-package-json-fast') const semver = require('semver') -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const reifyFinish = require('./utils/reify-finish.js') -const completion = async (opts) => { - const dir = npm.globalDir - const files = await readdir(dir) - return files.filter(f => !/^[._-]/.test(f)) -} +class Link { + constructor (npm) { + this.npm = npm + } -const usage = usageUtil( - 'link', - 'npm link (in package dir)' + - '\nnpm link [<@scope>/][@]' -) - -const cmd = (args, cb) => link(args).then(() => cb()).catch(cb) - -const link = async args => { - if (npm.config.get('global')) { - throw Object.assign( - new Error( - 'link should never be --global.\n' + - 'Please re-run this command with --local' - ), - { code: 'ELINKGLOBAL' } + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'link', + 'npm link (in package dir)' + + '\nnpm link [<@scope>/][@]' ) } - // link with no args: symlink the folder to the global location - // link with package arg: symlink the global to the local - args = args.filter(a => resolve(a) !== npm.prefix) - return args.length - ? linkInstall(args) - : linkPkg() -} + async completion (opts) { + const dir = this.npm.globalDir + const files = await readdir(dir) + return files.filter(f => !/^[._-]/.test(f)) + } -// Returns a list of items that can't be fulfilled by -// things found in the current arborist inventory -const missingArgsFromTree = (tree, args) => { - if (tree.isLink) - return missingArgsFromTree(tree.target, args) - - const foundNodes = [] - const missing = args.filter(a => { - const arg = npa(a) - const nodes = tree.children.values() - const argFound = [...nodes].every(node => { - // TODO: write tests for unmatching version specs, this is hard to test - // atm but should be simple once we have a mocked registry again - if (arg.name !== node.name /* istanbul ignore next */ || ( - arg.version && - !semver.satisfies(node.version, arg.version) - )) { - foundNodes.push(node) - return true - } - }) - return argFound - }) + exec (args, cb) { + this.link(args).then(() => cb()).catch(cb) + } - // remote nodes from the loaded tree in order - // to avoid dropping them later when reifying - for (const node of foundNodes) - node.parent = null + async link (args) { + if (this.npm.config.get('global')) { + throw Object.assign( + new Error( + 'link should never be --global.\n' + + 'Please re-run this command with --local' + ), + { code: 'ELINKGLOBAL' } + ) + } + + // link with no args: symlink the folder to the global location + // link with package arg: symlink the global to the local + args = args.filter(a => resolve(a) !== this.npm.prefix) + return args.length + ? this.linkInstall(args) + : this.linkPkg() + } - return missing -} + async linkInstall (args) { + // load current packages from the global space, + // and then add symlinks installs locally + const globalTop = resolve(this.npm.globalDir, '..') + const globalOpts = { + ...this.npm.flatOptions, + path: globalTop, + global: true, + prune: false, + } + const globalArb = new Arborist(globalOpts) + + // get only current top-level packages from the global space + const globals = await globalArb.loadActual({ + filter: (node, kid) => + !node.isRoot || args.some(a => npa(a).name === kid), + }) -const linkInstall = async args => { - // load current packages from the global space, - // and then add symlinks installs locally - const globalTop = resolve(npm.globalDir, '..') - const globalOpts = { - ...npm.flatOptions, - path: globalTop, - global: true, - prune: false, - } - const globalArb = new Arborist(globalOpts) - - // get only current top-level packages from the global space - const globals = await globalArb.loadActual({ - filter: (node, kid) => - !node.isRoot || args.some(a => npa(a).name === kid), - }) - - // any extra arg that is missing from the current - // global space should be reified there first - const missing = missingArgsFromTree(globals, args) - if (missing.length) { - await globalArb.reify({ - ...globalOpts, - add: missing, + // any extra arg that is missing from the current + // global space should be reified there first + const missing = this.missingArgsFromTree(globals, args) + if (missing.length) { + await globalArb.reify({ + ...globalOpts, + add: missing, + }) + } + + // get a list of module names that should be linked in the local prefix + const names = [] + for (const a of args) { + const arg = npa(a) + names.push( + arg.type === 'directory' + ? (await rpj(resolve(arg.fetchSpec, 'package.json'))).name + : arg.name + ) + } + + // npm link should not save=true by default unless you're + // using any of --save-dev or other types + const save = + Boolean(this.npm.config.find('save') !== 'default' || this.npm.flatOptions.saveType) + + // create a new arborist instance for the local prefix and + // reify all the pending names as symlinks there + const localArb = new Arborist({ + ...this.npm.flatOptions, + path: this.npm.prefix, + save, }) + await localArb.reify({ + ...this.npm.flatOptions, + path: this.npm.prefix, + add: names.map(l => `file:${resolve(globalTop, 'node_modules', l)}`), + save, + }) + + await reifyFinish(this.npm, localArb) } - // get a list of module names that should be linked in the local prefix - const names = [] - for (const a of args) { - const arg = npa(a) - names.push( - arg.type === 'directory' - ? (await rpj(resolve(arg.fetchSpec, 'package.json'))).name - : arg.name - ) + async linkPkg () { + const globalTop = resolve(this.npm.globalDir, '..') + const arb = new Arborist({ + ...this.npm.flatOptions, + path: globalTop, + global: true, + }) + await arb.reify({ add: [`file:${this.npm.prefix}`] }) + await reifyFinish(this.npm, arb) } - // npm link should not save=true by default unless you're - // using any of --save-dev or other types - const save = - Boolean(npm.config.find('save') !== 'default' || npm.flatOptions.saveType) - - // create a new arborist instance for the local prefix and - // reify all the pending names as symlinks there - const localArb = new Arborist({ - ...npm.flatOptions, - path: npm.prefix, - save, - }) - await localArb.reify({ - ...npm.flatOptions, - path: npm.prefix, - add: names.map(l => `file:${resolve(globalTop, 'node_modules', l)}`), - save, - }) - - await reifyFinish(localArb) -} + // Returns a list of items that can't be fulfilled by + // things found in the current arborist inventory + missingArgsFromTree (tree, args) { + if (tree.isLink) + return this.missingArgsFromTree(tree.target, args) + + const foundNodes = [] + const missing = args.filter(a => { + const arg = npa(a) + const nodes = tree.children.values() + const argFound = [...nodes].every(node => { + // TODO: write tests for unmatching version specs, this is hard to test + // atm but should be simple once we have a mocked registry again + if (arg.name !== node.name /* istanbul ignore next */ || ( + arg.version && + !semver.satisfies(node.version, arg.version) + )) { + foundNodes.push(node) + return true + } + }) + return argFound + }) -const linkPkg = async () => { - const globalTop = resolve(npm.globalDir, '..') - const arb = new Arborist({ - ...npm.flatOptions, - path: globalTop, - global: true, - }) - await arb.reify({ add: [`file:${npm.prefix}`] }) - await reifyFinish(arb) -} + // remote nodes from the loaded tree in order + // to avoid dropping them later when reifying + for (const node of foundNodes) + node.parent = null -module.exports = Object.assign(cmd, { completion, usage }) + return missing + } +} +module.exports = Link diff --git a/lib/ll.js b/lib/ll.js index 1d5a6217da9c7..7915f5d27c011 100644 --- a/lib/ll.js +++ b/lib/ll.js @@ -1,9 +1,19 @@ -const { usage, completion } = require('./ls.js') -const npm = require('./npm.js') +const LS = require('./ls.js') +const usageUtil = require('./utils/usage.js') -const cmd = (args, cb) => { - npm.config.set('long', true) - return npm.commands.ls(args, cb) +class LL extends LS { + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'll', + 'npm ll [[<@scope>/] ...]' + ) + } + + exec (args, cb) { + this.npm.config.set('long', true) + super.exec(args, cb) + } } -module.exports = Object.assign(cmd, { usage, completion }) +module.exports = LL diff --git a/lib/logout.js b/lib/logout.js index d2762c1ba3e5f..9fb1eab21a152 100644 --- a/lib/logout.js +++ b/lib/logout.js @@ -1,44 +1,52 @@ -const eu = encodeURIComponent const log = require('npmlog') const getAuth = require('npm-registry-fetch/auth.js') const npmFetch = require('npm-registry-fetch') -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'logout', - 'npm logout [--registry=] [--scope=<@scope>]' -) - -const cmd = (args, cb) => logout(args).then(() => cb()).catch(cb) - -const logout = async (args) => { - const { registry, scope } = npm.flatOptions - const regRef = scope ? `${scope}:registry` : 'registry' - const reg = npm.flatOptions[regRef] || registry - - const auth = getAuth(reg, npm.flatOptions) - - if (auth.token) { - log.verbose('logout', `clearing token for ${reg}`) - await npmFetch(`/-/user/token/${eu(auth.token)}`, { - ...npm.flatOptions, - method: 'DELETE', - ignoreBody: true, - }) - } else if (auth.username || auth.password) - log.verbose('logout', `clearing user credentials for ${reg}`) - else { - const msg = `not logged in to ${reg}, so can't log out!` - throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' }) +class Logout { + constructor (npm) { + this.npm = npm } - if (scope) - npm.config.delete(regRef, 'user') + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'logout', + 'npm logout [--registry=] [--scope=<@scope>]' + ) + } - npm.config.clearCredentialsByURI(reg) + exec (args, cb) { + this.logout(args).then(() => cb()).catch(cb) + } - await npm.config.save('user') + async logout (args) { + const { registry, scope } = this.npm.flatOptions + const regRef = scope ? `${scope}:registry` : 'registry' + const reg = this.npm.flatOptions[regRef] || registry + + const auth = getAuth(reg, this.npm.flatOptions) + + if (auth.token) { + log.verbose('logout', `clearing token for ${reg}`) + await npmFetch(`/-/user/token/${encodeURIComponent(auth.token)}`, { + ...this.npm.flatOptions, + method: 'DELETE', + ignoreBody: true, + }) + } else if (auth.username || auth.password) + log.verbose('logout', `clearing user credentials for ${reg}`) + else { + const msg = `not logged in to ${reg}, so can't log out!` + throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' }) + } + + if (scope) + this.npm.config.delete(regRef, 'user') + + this.npm.config.clearCredentialsByURI(reg) + + await this.npm.config.save('user') + } } - -module.exports = Object.assign(cmd, { usage }) +module.exports = Logout diff --git a/lib/ls.js b/lib/ls.js index d9c06de875df7..359fe21e6f8cc 100644 --- a/lib/ls.js +++ b/lib/ls.js @@ -7,7 +7,6 @@ const Arborist = require('@npmcli/arborist') const { breadth } = require('treeverse') const npa = require('npm-package-arg') -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const completion = require('./utils/completion/installed-deep.js') const output = require('./utils/output.js') @@ -24,20 +23,166 @@ const _problems = Symbol('problems') const _required = Symbol('required') const _type = Symbol('type') -const usage = usageUtil( - 'ls', - 'npm ls [[<@scope>/] ...]' -) +class LS { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'ls', + 'npm ls [[<@scope>/] ...]' + ) + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return completion(this.npm, opts) + } + + exec (args, cb) { + this.ls(args).then(() => cb()).catch(cb) + } -const cmd = (args, cb) => ls(args).then(() => cb()).catch(cb) + async ls (args) { + const { + all, + color, + depth, + json, + long, + global, + parseable, + prefix, + unicode, + } = this.npm.flatOptions + const path = global ? resolve(this.npm.globalDir, '..') : prefix + const dev = this.npm.config.get('dev') + const development = this.npm.config.get('development') + const link = this.npm.config.get('link') + const only = this.npm.config.get('only') + const prod = this.npm.config.get('prod') + const production = this.npm.config.get('production') + + const arb = new Arborist({ + global, + ...this.npm.flatOptions, + legacyPeerDeps: false, + path, + }) + const tree = await this.initTree({arb, args }) + + const seenItems = new Set() + const seenNodes = new Map() + const problems = new Set() + + // defines special handling of printed depth when filtering with args + const filterDefaultDepth = depth === null ? Infinity : depth + const depthToPrint = (all || args.length) + ? filterDefaultDepth + : (depth || 0) + + // add root node of tree to list of seenNodes + seenNodes.set(tree.path, tree) + + // tree traversal happens here, using treeverse.breadth + const result = await breadth({ + tree, + // recursive method, `node` is going to be the current elem (starting from + // the `tree` obj) that was just visited in the `visit` method below + // `nodeResult` is going to be the returned `item` from `visit` + getChildren (node, nodeResult) { + const seenPaths = new Set() + const shouldSkipChildren = + !(node instanceof Arborist.Node) || (node[_depth] > depthToPrint) + return (shouldSkipChildren) + ? [] + : [...(node.target || node).edgesOut.values()] + .filter(filterByEdgesTypes({ + dev, + development, + link, + node, + prod, + production, + only, + tree, + })) + .map(mapEdgesToNodes({ seenPaths })) + .concat(appendExtraneousChildren({ node, seenPaths })) + .sort(sortAlphabetically) + .map(augmentNodesWithMetadata({ + args, + currentDepth: node[_depth], + nodeResult, + seenNodes, + })) + }, + // visit each `node` of the `tree`, returning an `item` - these are + // the elements that will be used to build the final output + visit (node) { + node[_problems] = getProblems(node, { global }) + + const item = json + ? getJsonOutputItem(node, { global, long }) + : parseable + ? null + : getHumanOutputItem(node, { args, color, global, long }) + + // loop through list of node problems to add them to global list + if (node[_include]) { + for (const problem of node[_problems]) + problems.add(problem) + } + + seenItems.add(item) + + // return a promise so we don't blow the stack + return Promise.resolve(item) + }, + }) + + // handle the special case of a broken package.json in the root folder + const [rootError] = tree.errors.filter(e => + e.code === 'EJSONPARSE' && e.path === resolve(path, 'package.json')) + + output( + json + ? jsonOutput({ path, problems, result, rootError, seenItems }) + : parseable + ? parseableOutput({ seenNodes, global, long }) + : humanOutput({ color, result, seenItems, unicode }) + ) -const initTree = async ({ arb, args }) => { - const tree = await arb.loadActual() - tree[_include] = args.length === 0 - tree[_depth] = 0 + // if filtering items, should exit with error code on no results + if (result && !result[_include] && args.length) + process.exitCode = 1 - return tree + if (rootError) { + throw Object.assign( + new Error('Failed to parse root package.json'), + { code: 'EJSONPARSE' } + ) + } + + if (problems.size) { + throw Object.assign( + new Error([...problems].join(EOL)), + { code: 'ELSPROBLEMS' } + ) + } + } + + async initTree ({ arb, args }) { + const tree = await arb.loadActual() + tree[_include] = args.length === 0 + tree[_depth] = 0 + + return tree + } } +module.exports = LS const isGitNode = (node) => { if (!node.resolved) @@ -358,137 +503,3 @@ const parseableOutput = ({ global, long, seenNodes }) => { } return out.trim() } - -const ls = async (args) => { - const { - all, - color, - depth, - json, - long, - global, - parseable, - prefix, - unicode, - } = npm.flatOptions - const path = global ? resolve(npm.globalDir, '..') : prefix - const dev = npm.config.get('dev') - const development = npm.config.get('development') - const link = npm.config.get('link') - const only = npm.config.get('only') - const prod = npm.config.get('prod') - const production = npm.config.get('production') - - const arb = new Arborist({ - global, - ...npm.flatOptions, - legacyPeerDeps: false, - path, - }) - const tree = await initTree({ - arb, - args, - }) - - const seenItems = new Set() - const seenNodes = new Map() - const problems = new Set() - - // defines special handling of printed depth when filtering with args - const filterDefaultDepth = depth === null ? Infinity : depth - const depthToPrint = (all || args.length) - ? filterDefaultDepth - : (depth || 0) - - // add root node of tree to list of seenNodes - seenNodes.set(tree.path, tree) - - // tree traversal happens here, using treeverse.breadth - const result = await breadth({ - tree, - // recursive method, `node` is going to be the current elem (starting from - // the `tree` obj) that was just visited in the `visit` method below - // `nodeResult` is going to be the returned `item` from `visit` - getChildren (node, nodeResult) { - const seenPaths = new Set() - const shouldSkipChildren = - !(node instanceof Arborist.Node) || (node[_depth] > depthToPrint) - return (shouldSkipChildren) - ? [] - : [...(node.target || node).edgesOut.values()] - .filter(filterByEdgesTypes({ - dev, - development, - link, - node, - prod, - production, - only, - tree, - })) - .map(mapEdgesToNodes({ seenPaths })) - .concat(appendExtraneousChildren({ node, seenPaths })) - .sort(sortAlphabetically) - .map(augmentNodesWithMetadata({ - args, - currentDepth: node[_depth], - nodeResult, - seenNodes, - })) - }, - // visit each `node` of the `tree`, returning an `item` - these are - // the elements that will be used to build the final output - visit (node) { - node[_problems] = getProblems(node, { global }) - - const item = json - ? getJsonOutputItem(node, { global, long }) - : parseable - ? null - : getHumanOutputItem(node, { args, color, global, long }) - - // loop through list of node problems to add them to global list - if (node[_include]) { - for (const problem of node[_problems]) - problems.add(problem) - } - - seenItems.add(item) - - // return a promise so we don't blow the stack - return Promise.resolve(item) - }, - }) - - // handle the special case of a broken package.json in the root folder - const [rootError] = tree.errors.filter(e => - e.code === 'EJSONPARSE' && e.path === resolve(path, 'package.json')) - - output( - json - ? jsonOutput({ path, problems, result, rootError, seenItems }) - : parseable - ? parseableOutput({ seenNodes, global, long }) - : humanOutput({ color, result, seenItems, unicode }) - ) - - // if filtering items, should exit with error code on no results - if (result && !result[_include] && args.length) - process.exitCode = 1 - - if (rootError) { - throw Object.assign( - new Error('Failed to parse root package.json'), - { code: 'EJSONPARSE' } - ) - } - - if (problems.size) { - throw Object.assign( - new Error([...problems].join(EOL)), - { code: 'ELSPROBLEMS' } - ) - } -} - -module.exports = Object.assign(cmd, { usage, completion }) diff --git a/lib/npm.js b/lib/npm.js index 949e6309e7bba..1f8c785e755c4 100644 --- a/lib/npm.js +++ b/lib/npm.js @@ -13,40 +13,28 @@ require('graceful-fs').gracefulify(require('fs')) const procLogListener = require('./utils/proc-log-listener.js') -const hasOwnProperty = (obj, key) => - Object.prototype.hasOwnProperty.call(obj, key) - -// the first time `npm.commands.xyz` is loaded, it gets added -// to the cmds object, so we don't have to load it again. -const proxyCmds = (npm) => { - const cmds = {} - return new Proxy(cmds, { - get: (prop, cmd) => { - if (hasOwnProperty(cmds, cmd)) - return cmds[cmd] - - const actual = deref(cmd) - if (!actual) { - cmds[cmd] = undefined - return cmds[cmd] - } - if (cmds[actual]) { - cmds[cmd] = cmds[actual] - return cmds[cmd] - } - cmds[actual] = makeCmd(actual) - cmds[cmd] = cmds[actual] - return cmds[cmd] - }, - }) -} - -const makeCmd = cmd => { - const impl = require(`./${cmd}.js`) - const fn = (args, cb) => npm[_runCmd](cmd, impl, args, cb) - Object.assign(fn, impl) - return fn -} +const proxyCmds = new Proxy({}, { + get: (target, cmd) => { + const actual = deref(cmd) + if (actual && !Reflect.has(target, actual)) { + const Impl = require(`./${actual}.js`) + const impl = new Impl(npm) + // Our existing npm.commands[x] act like a function with attributes, but + // our modules have non-inumerable attributes so we can't just assign + // them to an anonymous function like we used to. This acts like that + // old way of doing things, until we can make breaking changes to the + // npm.commands[x] api + target[actual] = new Proxy( + (args, cb) => npm[_runCmd](cmd, impl, args, cb), + { + get: (target, attr, receiver) => { + return Reflect.get(impl, attr, receiver) + }, + }) + } + return target[actual] + }, +}) const { types, defaults, shorthands } = require('./utils/config.js') const { shellouts } = require('./utils/cmd-list.js') @@ -68,7 +56,7 @@ const npm = module.exports = new class extends EventEmitter { } this.started = Date.now() this.command = null - this.commands = proxyCmds(this) + this.commands = proxyCmds procLogListener() process.emit('time', 'npm') this.version = require('../package.json').version @@ -121,7 +109,7 @@ const npm = module.exports = new class extends EventEmitter { console.log(impl.usage) cb() } else { - impl(args, er => { + impl.exec(args, er => { process.emit('timeEnd', `command:${cmd}`) cb(er) }) diff --git a/lib/org.js b/lib/org.js index aa9c97d497bbf..054e1833dba4b 100644 --- a/lib/org.js +++ b/lib/org.js @@ -1,139 +1,148 @@ const liborg = require('libnpmorg') -const npm = require('./npm.js') +const usageUtil = require('./utils/usage.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') const Table = require('cli-table3') -module.exports = org - -org.subcommands = ['set', 'rm', 'ls'] - -org.usage = - 'npm org set orgname username [developer | admin | owner]\n' + - 'npm org rm orgname username\n' + - 'npm org ls orgname []' - -org.completion = async (opts) => { - var argv = opts.conf.argv.remain - if (argv.length === 2) - return org.subcommands +class Org { + constructor (npm) { + this.npm = npm + } - switch (argv[2]) { - case 'ls': - case 'add': - case 'rm': - case 'set': - return [] - default: - throw new Error(argv[2] + ' not recognized') + get usage () { + return usageUtil( + 'org', + 'npm org set orgname username [developer | admin | owner]\n' + + 'npm org rm orgname username\n' + + 'npm org ls orgname []' + ) } -} -function UsageError () { - throw Object.assign(new Error(org.usage), { code: 'EUSAGE' }) -} + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length === 2) + return ['set', 'rm', 'ls'] -function org ([cmd, orgname, username, role], cb) { - return otplease(npm.flatOptions, opts => { - switch (cmd) { + switch (argv[2]) { + case 'ls': case 'add': - case 'set': - return orgSet(orgname, username, role, opts) case 'rm': - return orgRm(orgname, username, opts) - case 'ls': - return orgList(orgname, username, opts) + case 'set': + return [] default: - UsageError() + throw new Error(argv[2] + ' not recognized') } - }).then( - x => cb(null, x), - err => cb(err.code === 'EUSAGE' ? err.message : err) - ) -} + } -function orgSet (org, user, role, opts) { - role = role || 'developer' - if (!org) - throw new Error('First argument `orgname` is required.') - - if (!user) - throw new Error('Second argument `username` is required.') - - if (!['owner', 'admin', 'developer'].find(x => x === role)) - throw new Error('Third argument `role` must be one of `owner`, `admin`, or `developer`, with `developer` being the default value if omitted.') - - return liborg.set(org, user, role, opts).then(memDeets => { - if (opts.json) - output(JSON.stringify(memDeets, null, 2)) - else if (opts.parseable) { - output(['org', 'orgsize', 'user', 'role'].join('\t')) - output([ - memDeets.org.name, - memDeets.org.size, - memDeets.user, - memDeets.role, - ].join('\t')) - } else if (!opts.silent && opts.loglevel !== 'silent') - output(`Added ${memDeets.user} as ${memDeets.role} to ${memDeets.org.name}. You now have ${memDeets.org.size} member${memDeets.org.size === 1 ? '' : 's'} in this org.`) - - return memDeets - }) -} + exec (args, cb) { + this.org(args) + .then(x => cb(null, x)) + .catch(err => err.code === 'EUSAGE' + ? cb(err.message) + : cb(err) + ) + } -function orgRm (org, user, opts) { - if (!org) - throw new Error('First argument `orgname` is required.') - - if (!user) - throw new Error('Second argument `username` is required.') - - return liborg.rm(org, user, opts).then(() => { - return liborg.ls(org, opts) - }).then(roster => { - user = user.replace(/^[~@]?/, '') - org = org.replace(/^[~@]?/, '') - const userCount = Object.keys(roster).length - if (opts.json) { - output(JSON.stringify({ - user, - org, - userCount, - deleted: true, - })) - } else if (opts.parseable) { - output(['user', 'org', 'userCount', 'deleted'].join('\t')) - output([user, org, userCount, true].join('\t')) - } else if (!opts.silent && opts.loglevel !== 'silent') - output(`Successfully removed ${user} from ${org}. You now have ${userCount} member${userCount === 1 ? '' : 's'} in this org.`) - }) -} + async org ([cmd, orgname, username, role], cb) { + return otplease(this.npm.flatOptions, opts => { + switch (cmd) { + case 'add': + case 'set': + return this.set(orgname, username, role, opts) + case 'rm': + return this.rm(orgname, username, opts) + case 'ls': + return this.ls(orgname, username, opts) + default: + throw Object.assign(new Error(this.usage), { code: 'EUSAGE' }) + } + }) + } -function orgList (org, user, opts) { - if (!org) - throw new Error('First argument `orgname` is required.') + set (org, user, role, opts) { + role = role || 'developer' + if (!org) + throw new Error('First argument `orgname` is required.') + + if (!user) + throw new Error('Second argument `username` is required.') + + if (!['owner', 'admin', 'developer'].find(x => x === role)) + throw new Error('Third argument `role` must be one of `owner`, `admin`, or `developer`, with `developer` being the default value if omitted.') + + return liborg.set(org, user, role, opts).then(memDeets => { + if (opts.json) + output(JSON.stringify(memDeets, null, 2)) + else if (opts.parseable) { + output(['org', 'orgsize', 'user', 'role'].join('\t')) + output([ + memDeets.org.name, + memDeets.org.size, + memDeets.user, + memDeets.role, + ].join('\t')) + } else if (!opts.silent && opts.loglevel !== 'silent') + output(`Added ${memDeets.user} as ${memDeets.role} to ${memDeets.org.name}. You now have ${memDeets.org.size} member${memDeets.org.size === 1 ? '' : 's'} in this org.`) + + return memDeets + }) + } - return liborg.ls(org, opts).then(roster => { - if (user) { - const newRoster = {} - if (roster[user]) - newRoster[user] = roster[user] + rm (org, user, opts) { + if (!org) + throw new Error('First argument `orgname` is required.') + + if (!user) + throw new Error('Second argument `username` is required.') + + return liborg.rm(org, user, opts).then(() => { + return liborg.ls(org, opts) + }).then(roster => { + user = user.replace(/^[~@]?/, '') + org = org.replace(/^[~@]?/, '') + const userCount = Object.keys(roster).length + if (opts.json) { + output(JSON.stringify({ + user, + org, + userCount, + deleted: true, + })) + } else if (opts.parseable) { + output(['user', 'org', 'userCount', 'deleted'].join('\t')) + output([user, org, userCount, true].join('\t')) + } else if (!opts.silent && opts.loglevel !== 'silent') + output(`Successfully removed ${user} from ${org}. You now have ${userCount} member${userCount === 1 ? '' : 's'} in this org.`) + }) + } - roster = newRoster - } - if (opts.json) - output(JSON.stringify(roster, null, 2)) - else if (opts.parseable) { - output(['user', 'role'].join('\t')) - Object.keys(roster).forEach(user => { - output([user, roster[user]].join('\t')) - }) - } else if (!opts.silent && opts.loglevel !== 'silent') { - const table = new Table({ head: ['user', 'role'] }) - Object.keys(roster).sort().forEach(user => { - table.push([user, roster[user]]) - }) - output(table.toString()) - } - }) + ls (org, user, opts) { + if (!org) + throw new Error('First argument `orgname` is required.') + + return liborg.ls(org, opts).then(roster => { + if (user) { + const newRoster = {} + if (roster[user]) + newRoster[user] = roster[user] + + roster = newRoster + } + if (opts.json) + output(JSON.stringify(roster, null, 2)) + else if (opts.parseable) { + output(['user', 'role'].join('\t')) + Object.keys(roster).forEach(user => { + output([user, roster[user]].join('\t')) + }) + } else if (!opts.silent && opts.loglevel !== 'silent') { + const table = new Table({ head: ['user', 'role'] }) + Object.keys(roster).sort().forEach(user => { + table.push([user, roster[user]]) + }) + output(table.toString()) + } + }) + } } +module.exports = Org diff --git a/lib/outdated.js b/lib/outdated.js index c10f63a12e3a2..fc6967faf60fe 100644 --- a/lib/outdated.js +++ b/lib/outdated.js @@ -9,112 +9,135 @@ const pickManifest = require('npm-pick-manifest') const Arborist = require('@npmcli/arborist') -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') const ansiTrim = require('./utils/ansi-trim.js') -const usage = usageUtil('outdated', - 'npm outdated [[<@scope>/] ...]' -) +class Outdated { + constructor (npm) { + this.npm = npm + } -function cmd (args, cb) { - outdated(args) - .then(() => cb()) - .catch(cb) -} + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('outdated', + 'npm outdated [[<@scope>/] ...]' + ) + } -async function outdated (args) { - const opts = npm.flatOptions - const global = path.resolve(npm.globalDir, '..') - const where = opts.global - ? global - : npm.prefix - - const arb = new Arborist({ - ...opts, - path: where, - }) - - const tree = await arb.loadActual() - const list = await outdated_(tree, args, opts) - - // sorts list alphabetically - const outdated = list.sort((a, b) => a.name.localeCompare(b.name)) - - // return if no outdated packages - if (outdated.length === 0 && !opts.json) - return - - // display results - if (opts.json) - output(makeJSON(outdated, opts)) - else if (opts.parseable) - output(makeParseable(outdated, opts)) - else { - const outList = outdated.map(x => makePretty(x, opts)) - const outHead = ['Package', - 'Current', - 'Wanted', - 'Latest', - 'Location', - 'Depended by', - ] - - if (opts.long) - outHead.push('Package Type', 'Homepage') - const outTable = [outHead].concat(outList) - - if (opts.color) - outTable[0] = outTable[0].map(heading => styles.underline(heading)) - - const tableOpts = { - align: ['l', 'r', 'r', 'r', 'l'], - stringLength: s => ansiTrim(s).length, - } - output(table(outTable, tableOpts)) + exec (args, cb) { + this.outdated(args).then(() => cb()).catch(cb) } -} -async function outdated_ (tree, deps, opts) { - const list = [] + async outdated (args) { + this.opts = this.npm.flatOptions + + const global = path.resolve(this.npm.globalDir, '..') + const where = this.opts.global + ? global + : this.npm.prefix + + const arb = new Arborist({ + ...this.opts, + path: where, + }) - const edges = new Set() - function getEdges (nodes, type) { - const getEdgesIn = (node) => { - for (const edge of node.edgesIn) - edges.add(edge) + this.edges = new Set() + this.list = [] + this.tree = await arb.loadActual() + + if (args.length !== 0) { + // specific deps + for (let i = 0; i < args.length; i++) { + const nodes = this.tree.inventory.query('name', args[i]) + this.getEdges(nodes, 'edgesIn') + } + } else { + if (this.opts.all) { + // all deps in tree + const nodes = this.tree.inventory.values() + this.getEdges(nodes, 'edgesOut') + } + // top-level deps + this.getEdges() } - const getEdgesOut = (node) => { - if (opts.global) { - for (const child of node.children.values()) - edges.add(child) - } else { - for (const edge of node.edgesOut.values()) - edges.add(edge) + await Promise.all(Array.from(this.edges).map((edge) => { + return this.getOutdatedInfo(edge) + })) + + // sorts list alphabetically + const outdated = this.list.sort((a, b) => a.name.localeCompare(b.name)) + + // return if no outdated packages + if (outdated.length === 0 && !this.opts.json) + return + + // display results + if (this.opts.json) + output(this.makeJSON(outdated)) + else if (this.opts.parseable) + output(this.makeParseable(outdated)) + else { + const outList = outdated.map(x => this.makePretty(x)) + const outHead = ['Package', + 'Current', + 'Wanted', + 'Latest', + 'Location', + 'Depended by', + ] + + if (this.opts.long) + outHead.push('Package Type', 'Homepage') + const outTable = [outHead].concat(outList) + + if (this.opts.color) + outTable[0] = outTable[0].map(heading => styles.underline(heading)) + + const tableOpts = { + align: ['l', 'r', 'r', 'r', 'l'], + stringLength: s => ansiTrim(s).length, } + output(table(outTable, tableOpts)) } + } + getEdges (nodes, type) { if (!nodes) - return getEdgesOut(tree) + return this.getEdgesOut(this.tree) for (const node of nodes) { type === 'edgesOut' - ? getEdgesOut(node) - : getEdgesIn(node) + ? this.getEdgesOut(node) + : this.getEdgesIn(node) + } + } + + getEdgesIn (node) { + for (const edge of node.edgesIn) + this.edges.add(edge) + } + + getEdgesOut (node) { + if (this.opts.global) { + for (const child of node.children.values()) + this.edges.add(child) + } else { + for (const edge of node.edgesOut.values()) + this.edges.add(edge) } } - async function getPackument (spec) { + async getPackument (spec) { const packument = await pacote.packument(spec, { - ...npm.flatOptions, - fullMetadata: npm.flatOptions.long, + ...this.npm.flatOptions, + fullMetadata: this.npm.flatOptions.long, preferOnline: true, }) return packument } - async function getOutdatedInfo (edge) { + async getOutdatedInfo (edge) { const spec = npa(edge.name) const node = edge.to || edge const { path, location } = node @@ -125,7 +148,7 @@ async function outdated_ (tree, deps, opts) { : edge.dev ? 'devDependencies' : 'dependencies' - for (const omitType of opts.omit || []) { + for (const omitType of this.opts.omit || []) { if (node[omitType]) return } @@ -136,7 +159,7 @@ async function outdated_ (tree, deps, opts) { return try { - const packument = await getPackument(spec) + const packument = await this.getPackument(spec) const expected = edge.spec // if it's not a range, version, or tag, skip it try { @@ -145,15 +168,15 @@ async function outdated_ (tree, deps, opts) { } catch (err) { return null } - const wanted = pickManifest(packument, expected, npm.flatOptions) - const latest = pickManifest(packument, '*', npm.flatOptions) + const wanted = pickManifest(packument, expected, this.npm.flatOptions) + const latest = pickManifest(packument, '*', this.npm.flatOptions) if ( !current || current !== wanted.version || wanted.version !== latest.version ) { - list.push({ + this.list.push({ name: edge.name, path, type, @@ -167,7 +190,7 @@ async function outdated_ (tree, deps, opts) { } } catch (err) { // silently catch and ignore ETARGET, E403 & - // E404 errors, deps are just skipped { + // E404 errors, deps are just skipped if (!( err.code === 'ETARGET' || err.code === 'E403' || @@ -177,113 +200,89 @@ async function outdated_ (tree, deps, opts) { } } - const p = [] - if (deps.length !== 0) { - // specific deps - for (let i = 0; i < deps.length; i++) { - const nodes = tree.inventory.query('name', deps[i]) - getEdges(nodes, 'edgesIn') - } - } else { - if (opts.all) { - // all deps in tree - const nodes = tree.inventory.values() - getEdges(nodes, 'edgesOut') - } - // top-level deps - getEdges() - } - - for (const edge of edges) - p.push(getOutdatedInfo(edge)) - - await Promise.all(p) - return list -} - -// formatting functions -function makePretty (dep, opts) { - const { - current = 'MISSING', - location = '-', - homepage = '', - name, - wanted, - latest, - type, - dependent, - } = dep - - const columns = [name, current, wanted, latest, location, dependent] - - if (opts.long) { - columns[6] = type - columns[7] = homepage - } - - if (opts.color) { - columns[0] = color[current === wanted ? 'yellow' : 'red'](columns[0]) // current - columns[2] = color.green(columns[2]) // wanted - columns[3] = color.magenta(columns[3]) // latest - } - - return columns -} - -// --parseable creates output like this: -// :::: -function makeParseable (list, opts) { - return list.map(dep => { + // formatting functions + makePretty (dep) { const { + current = 'MISSING', + location = '-', + homepage = '', name, - current, wanted, latest, - path, - dependent, type, - homepage, - } = dep - const out = [ - path, - name + '@' + wanted, - current ? (name + '@' + current) : 'MISSING', - name + '@' + latest, dependent, - ] - if (opts.long) - out.push(type, homepage) + } = dep - return out.join(':') - }).join(os.EOL) -} + const columns = [name, current, wanted, latest, location, dependent] -function makeJSON (list, opts) { - const out = {} - list.forEach(dep => { - const { - name, - current, - wanted, - latest, - path, - type, - dependent, - homepage, - } = dep - out[name] = { - current, - wanted, - latest, - dependent, - location: path, + if (this.opts.long) { + columns[6] = type + columns[7] = homepage } - if (opts.long) { - out[name].type = type - out[name].homepage = homepage + + if (this.opts.color) { + columns[0] = color[current === wanted ? 'yellow' : 'red'](columns[0]) // current + columns[2] = color.green(columns[2]) // wanted + columns[3] = color.magenta(columns[3]) // latest } - }) - return JSON.stringify(out, null, 2) -} -module.exports = Object.assign(cmd, { usage }) + return columns + } + + // --parseable creates output like this: + // :::: + makeParseable (list) { + return list.map(dep => { + const { + name, + current, + wanted, + latest, + path, + dependent, + type, + homepage, + } = dep + const out = [ + path, + name + '@' + wanted, + current ? (name + '@' + current) : 'MISSING', + name + '@' + latest, + dependent, + ] + if (this.opts.long) + out.push(type, homepage) + + return out.join(':') + }).join(os.EOL) + } + + makeJSON (list) { + const out = {} + list.forEach(dep => { + const { + name, + current, + wanted, + latest, + path, + type, + dependent, + homepage, + } = dep + out[name] = { + current, + wanted, + latest, + dependent, + location: path, + } + if (this.opts.long) { + out[name].type = type + out[name].homepage = homepage + } + }) + return JSON.stringify(out, null, 2) + } +} +module.exports = Outdated diff --git a/lib/owner.js b/lib/owner.js index 6dce3ec70f396..6cb9904880dc2 100644 --- a/lib/owner.js +++ b/lib/owner.js @@ -3,94 +3,138 @@ const npa = require('npm-package-arg') const npmFetch = require('npm-registry-fetch') const pacote = require('pacote') -const npm = require('./npm.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') const readLocalPkg = require('./utils/read-local-package.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'owner', - 'npm owner add [<@scope>/]' + - '\nnpm owner rm [<@scope>/]' + - '\nnpm owner ls [<@scope>/]' -) +class Owner { + constructor (npm) { + this.npm = npm + } -const completion = async (opts) => { - const argv = opts.conf.argv.remain - if (argv.length > 3) - return [] + get usage () { + return usageUtil( + 'owner', + 'npm owner add [<@scope>/]' + + '\nnpm owner rm [<@scope>/]' + + '\nnpm owner ls [<@scope>/]' + ) + } - if (argv[1] !== 'owner') - argv.unshift('owner') + get usageError () { + return Object.assign(new Error(this.usage), { code: 'EUSAGE' }) + } - if (argv.length === 2) - return ['add', 'rm', 'ls'] + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length > 3) + return [] - // reaches registry in order to autocomplete rm - if (argv[2] === 'rm') { - const opts = { - ...npm.flatOptions, - fullMetadata: true, + if (argv[1] !== 'owner') + argv.unshift('owner') + + if (argv.length === 2) + return ['add', 'rm', 'ls'] + + // reaches registry in order to autocomplete rm + if (argv[2] === 'rm') { + const pkgName = await readLocalPkg(this.npm) + if (!pkgName) + return [] + + const spec = npa(pkgName) + const data = await pacote.packument(spec, { + ...this.npm.flatOptions, + fullMetadata: true, + }) + if (data && data.maintainers && data.maintainers.length) + return data.maintainers.map(m => m.name) } - const pkgName = await readLocalPkg() - if (!pkgName) - return [] + return [] + } - const spec = npa(pkgName) - const data = await pacote.packument(spec, opts) - if (data && data.maintainers && data.maintainers.length) - return data.maintainers.map(m => m.name) + exec (args, cb) { + this.owner(args).then(() => cb()).catch(cb) } - return [] -} -const UsageError = () => - Object.assign(new Error(usage), { code: 'EUSAGE' }) - -const cmd = (args, cb) => owner(args).then(() => cb()).catch(cb) - -const owner = async ([action, ...args]) => { - const opts = npm.flatOptions - switch (action) { - case 'ls': - case 'list': - return ls(args[0], opts) - case 'add': - return add(args[0], args[1], opts) - case 'rm': - case 'remove': - return rm(args[0], args[1], opts) - default: - throw UsageError() + async owner ([action, ...args]) { + const opts = this.npm.flatOptions + switch (action) { + case 'ls': + case 'list': + return this.ls(args[0], opts) + case 'add': + return this.add(args[0], args[1], opts) + case 'rm': + case 'remove': + return this.rm(args[0], args[1], opts) + default: + throw this.usageError + } } -} -const ls = async (pkg, opts) => { - if (!pkg) { - const pkgName = await readLocalPkg() - if (!pkgName) - throw UsageError() + async ls (pkg, opts) { + if (!pkg) { + const pkgName = await readLocalPkg(this.npm) + if (!pkgName) + throw this.usageError + + pkg = pkgName + } + + const spec = npa(pkg) + + try { + const packumentOpts = { ...opts, fullMetadata: true } + const { maintainers } = await pacote.packument(spec, packumentOpts) + if (!maintainers || !maintainers.length) + output('no admin found') + else + output(maintainers.map(o => `${o.name} <${o.email}>`).join('\n')) - pkg = pkgName + return maintainers + } catch (err) { + log.error('owner ls', "Couldn't get owner data", pkg) + throw err + } } - const spec = npa(pkg) + async add (user, pkg, opts) { + if (!user) + throw this.usageError - try { - const packumentOpts = { ...opts, fullMetadata: true } - const { maintainers } = await pacote.packument(spec, packumentOpts) - if (!maintainers || !maintainers.length) - output('no admin found') - else - output(maintainers.map(o => `${o.name} <${o.email}>`).join('\n')) + if (!pkg) { + const pkgName = await readLocalPkg(this.npm) + if (!pkgName) + throw this.usageError - return maintainers - } catch (err) { - log.error('owner ls', "Couldn't get owner data", pkg) - throw err + pkg = pkgName + } + log.verbose('owner add', '%s to %s', user, pkg) + + const spec = npa(pkg) + return putOwners(spec, user, opts, validateAddOwner) + } + + async rm (user, pkg, opts) { + if (!user) + throw this.usageError + + if (!pkg) { + const pkgName = await readLocalPkg(this.npm) + if (!pkgName) + throw this.usageError + + pkg = pkgName + } + log.verbose('owner rm', '%s from %s', user, pkg) + + const spec = npa(pkg) + return putOwners(spec, user, opts, validateRmOwner) } } +module.exports = Owner const validateAddOwner = (newOwner, owners) => { owners = owners || [] @@ -109,23 +153,6 @@ const validateAddOwner = (newOwner, owners) => { ] } -const add = async (user, pkg, opts) => { - if (!user) - throw UsageError() - - if (!pkg) { - const pkgName = await readLocalPkg() - if (!pkgName) - throw UsageError() - - pkg = pkgName - } - log.verbose('owner add', '%s to %s', user, pkg) - - const spec = npa(pkg) - return putOwners(spec, user, opts, validateAddOwner) -} - const validateRmOwner = (rmOwner, owners) => { let found = false const m = owners.filter(function (o) { @@ -151,23 +178,6 @@ const validateRmOwner = (rmOwner, owners) => { return m } -const rm = async (user, pkg, opts) => { - if (!user) - throw UsageError() - - if (!pkg) { - const pkgName = await readLocalPkg() - if (!pkgName) - throw UsageError() - - pkg = pkgName - } - log.verbose('owner rm', '%s from %s', user, pkg) - - const spec = npa(pkg) - return putOwners(spec, user, opts, validateRmOwner) -} - const putOwners = async (spec, user, opts, validation) => { const uri = `/-/user/org.couchdb.user:${encodeURIComponent(user)}` let u = '' @@ -227,5 +237,3 @@ const putOwners = async (spec, user, opts, validation) => { } return res } - -module.exports = Object.assign(cmd, { usage, completion }) diff --git a/lib/pack.js b/lib/pack.js index ff906cc2bd5a1..cf1e77f48ee69 100644 --- a/lib/pack.js +++ b/lib/pack.js @@ -4,46 +4,53 @@ const pacote = require('pacote') const libpack = require('libnpmpack') const npa = require('npm-package-arg') -const npm = require('./npm.js') const { getContents, logTar } = require('./utils/tar.js') const writeFile = util.promisify(require('fs').writeFile) const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil('pack', 'npm pack [[<@scope>/]...] [--dry-run]') -const cmd = (args, cb) => pack(args).then(() => cb()).catch(cb) +class Pack { + constructor (npm) { + this.npm = npm + } -const pack = async (args) => { - if (args.length === 0) - args = ['.'] + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('pack', 'npm pack [[<@scope>/]...] [--dry-run]') + } - const { unicode } = npm.flatOptions + exec (args, cb) { + this.pack(args).then(() => cb()).catch(cb) + } - // clone the opts because pacote mutates it with resolved/integrity - const tarballs = await Promise.all(args.map((arg) => - pack_(arg, { ...npm.flatOptions }))) + async pack (args) { + if (args.length === 0) + args = ['.'] - for (const tar of tarballs) { - logTar(tar, { log, unicode }) - output(tar.filename.replace(/^@/, '').replace(/\//, '-')) - } -} + const { unicode } = this.npm.flatOptions -const pack_ = async (arg, opts) => { - const spec = npa(arg) - const { dryRun } = opts - const manifest = await pacote.manifest(spec, opts) - const filename = `${manifest.name}-${manifest.version}.tgz` - .replace(/^@/, '').replace(/\//, '-') - const tarballData = await libpack(arg, opts) - const pkgContents = await getContents(manifest, tarballData) + // clone the opts because pacote mutates it with resolved/integrity + const tarballs = await Promise.all(args.map(async (arg) => { + const spec = npa(arg) + const { dryRun } = this.npm.flatOptions + const manifest = await pacote.manifest(spec, this.npm.flatOptions) + const filename = `${manifest.name}-${manifest.version}.tgz` + .replace(/^@/, '').replace(/\//, '-') + const tarballData = await libpack(arg, this.npm.flatOptions) + const pkgContents = await getContents(manifest, tarballData) - if (!dryRun) - await writeFile(filename, tarballData) + if (!dryRun) + await writeFile(filename, tarballData) - return pkgContents -} + return pkgContents + })) -module.exports = Object.assign(cmd, { usage }) + for (const tar of tarballs) { + logTar(tar, { log, unicode }) + output(tar.filename.replace(/^@/, '').replace(/\//, '-')) + } + } +} +module.exports = Pack diff --git a/lib/ping.js b/lib/ping.js index efa22631033c9..e43f0640f212b 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -1,27 +1,36 @@ const log = require('npmlog') -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') +const pingUtil = require('./utils/ping.js') -const usage = usageUtil('ping', 'npm ping\nping registry') +class Ping { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => ping(args).then(() => cb()).catch(cb) -const pingUtil = require('./utils/ping.js') + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('ping', 'npm ping\nping registry') + } -const ping = async args => { - log.notice('PING', npm.flatOptions.registry) - const start = Date.now() - const details = await pingUtil(npm.flatOptions) - const time = Date.now() - start - log.notice('PONG', `${time / 1000}ms`) - if (npm.flatOptions.json) { - output(JSON.stringify({ - registry: npm.flatOptions.registry, - time, - details, - }, null, 2)) - } else if (Object.keys(details).length) - log.notice('PONG', `${JSON.stringify(details, null, 2)}`) -} + exec (args, cb) { + this.ping(args).then(() => cb()).catch(cb) + } -module.exports = Object.assign(cmd, { usage }) + async ping (args) { + log.notice('PING', this.npm.flatOptions.registry) + const start = Date.now() + const details = await pingUtil(this.npm.flatOptions) + const time = Date.now() - start + log.notice('PONG', `${time / 1000}ms`) + if (this.npm.flatOptions.json) { + output(JSON.stringify({ + registry: this.npm.flatOptions.registry, + time, + details, + }, null, 2)) + } else if (Object.keys(details).length) + log.notice('PONG', `${JSON.stringify(details, null, 2)}`) + } +} +module.exports = Ping diff --git a/lib/prefix.js b/lib/prefix.js index d108b9d423afd..e46f9c4cdd94a 100644 --- a/lib/prefix.js +++ b/lib/prefix.js @@ -1,7 +1,22 @@ -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') -const cmd = (args, cb) => prefix(args).then(() => cb()).catch(cb) -const usage = usageUtil('prefix', 'npm prefix [-g]') -const prefix = async (args, cb) => output(npm.prefix) -module.exports = Object.assign(cmd, { usage }) + +class Prefix { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('prefix', 'npm prefix [-g]') + } + + exec (args, cb) { + this.prefix(args).then(() => cb()).catch(cb) + } + + async prefix (args) { + return output(this.npm.prefix) + } +} +module.exports = Prefix diff --git a/lib/profile.js b/lib/profile.js index f5db75eb4cce6..dab99092b0a0f 100644 --- a/lib/profile.js +++ b/lib/profile.js @@ -6,72 +6,14 @@ const npmProfile = require('npm-profile') const qrcodeTerminal = require('qrcode-terminal') const Table = require('cli-table3') -const npm = require('./npm.js') const otplease = require('./utils/otplease.js') const output = require('./utils/output.js') const pulseTillDone = require('./utils/pulse-till-done.js') const readUserInfo = require('./utils/read-user-info.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'profile', - 'npm profile enable-2fa [auth-only|auth-and-writes]\n' + - 'npm profile disable-2fa\n' + - 'npm profile get []\n' + - 'npm profile set ' -) - -const completion = async (opts) => { - var argv = opts.conf.argv.remain - const subcommands = ['enable-2fa', 'disable-2fa', 'get', 'set'] - - if (!argv[2]) - return subcommands - - switch (argv[2]) { - case 'enable-2fa': - case 'enable-tfa': - return ['auth-and-writes', 'auth-only'] - - case 'disable-2fa': - case 'disable-tfa': - case 'get': - case 'set': - return [] - default: - throw new Error(argv[2] + ' not recognized') - } -} - -const cmd = (args, cb) => profile(args).then(() => cb()).catch(cb) - -const profile = async (args) => { - if (args.length === 0) - throw new Error(usage) - - log.gauge.show('profile') - - const [subcmd, ...opts] = args - - switch (subcmd) { - case 'enable-2fa': - case 'enable-tfa': - case 'enable2fa': - case 'enabletfa': - return enable2fa(opts) - case 'disable-2fa': - case 'disable-tfa': - case 'disable2fa': - case 'disabletfa': - return disable2fa() - case 'get': - return get(opts) - case 'set': - return set(opts) - default: - throw new Error('Unknown profile command: ' + subcmd) - } -} +const qrcode = url => + new Promise((resolve) => qrcodeTerminal.generate(url, resolve)) const knownProfileKeys = [ 'name', @@ -86,64 +28,6 @@ const knownProfileKeys = [ 'updated', ] -const get = async args => { - const tfa = 'two-factor auth' - const conf = { ...npm.flatOptions } - - const info = await pulseTillDone.withPromise(npmProfile.get(conf)) - - if (!info.cidr_whitelist) - delete info.cidr_whitelist - - if (conf.json) { - output(JSON.stringify(info, null, 2)) - return - } - - // clean up and format key/values for output - const cleaned = {} - for (const key of knownProfileKeys) - cleaned[key] = info[key] || '' - - const unknownProfileKeys = Object.keys(info).filter((k) => !(k in cleaned)) - for (const key of unknownProfileKeys) - cleaned[key] = info[key] || '' - - delete cleaned.tfa - delete cleaned.email_verified - cleaned.email += info.email_verified ? ' (verified)' : '(unverified)' - - if (info.tfa && !info.tfa.pending) - cleaned[tfa] = info.tfa.mode - else - cleaned[tfa] = 'disabled' - - if (args.length) { - const values = args // comma or space separated - .join(',') - .split(/,/) - .filter((arg) => arg.trim() !== '') - .map((arg) => cleaned[arg]) - .join('\t') - output(values) - } else { - if (conf.parseable) { - for (const key of Object.keys(info)) { - if (key === 'tfa') - output(`${key}\t${cleaned[tfa]}`) - else - output(`${key}\t${info[key]}`) - } - } else { - const table = new Table() - for (const key of Object.keys(cleaned)) - table.push({ [ansistyles.bright(key)]: cleaned[key] }) - - output(table.toString()) - } - } -} - const writableProfileKeys = [ 'email', 'password', @@ -154,242 +38,364 @@ const writableProfileKeys = [ 'github', ] -const set = async (args) => { - const conf = { ...npm.flatOptions } - const prop = (args[0] || '').toLowerCase().trim() +class Profile { + constructor (npm) { + this.npm = npm + } - let value = args.length > 1 ? args.slice(1).join(' ') : null + get usage () { + return usageUtil( + 'profile', + 'npm profile enable-2fa [auth-only|auth-and-writes]\n', + 'npm profile disable-2fa\n', + 'npm profile get []\n', + 'npm profile set ' + ) + } - const readPasswords = async () => { - const newpassword = await readUserInfo.password('New password: ') - const confirmedpassword = await readUserInfo.password(' Again: ') + async completion (opts) { + var argv = opts.conf.argv.remain - if (newpassword !== confirmedpassword) { - log.warn('profile', 'Passwords do not match, please try again.') - return readPasswords() - } + if (!argv[2]) + return ['enable-2fa', 'disable-2fa', 'get', 'set'] - return newpassword - } + switch (argv[2]) { + case 'enable-2fa': + case 'enable-tfa': + return ['auth-and-writes', 'auth-only'] - if (prop !== 'password' && value === null) - throw new Error('npm profile set ') + case 'disable-2fa': + case 'disable-tfa': + case 'get': + case 'set': + return [] + default: + throw new Error(argv[2] + ' not recognized') + } + } - if (prop === 'password' && value !== null) { - throw new Error( - 'npm profile set password\n' + - 'Do not include your current or new passwords on the command line.') + exec (args, cb) { + this.profile(args).then(() => cb()).catch(cb) } - if (writableProfileKeys.indexOf(prop) === -1) { - throw new Error(`"${prop}" is not a property we can set. ` + - `Valid properties are: ` + writableProfileKeys.join(', ')) + async profile (args) { + if (args.length === 0) + throw new Error(this.usage) + + log.gauge.show('profile') + + const [subcmd, ...opts] = args + + switch (subcmd) { + case 'enable-2fa': + case 'enable-tfa': + case 'enable2fa': + case 'enabletfa': + return this.enable2fa(opts) + case 'disable-2fa': + case 'disable-tfa': + case 'disable2fa': + case 'disabletfa': + return this.disable2fa() + case 'get': + return this.get(opts) + case 'set': + return this.set(opts) + default: + throw new Error('Unknown profile command: ' + subcmd) + } } - if (prop === 'password') { - const current = await readUserInfo.password('Current password: ') - const newpassword = await readPasswords() + async get (args) { + const tfa = 'two-factor auth' + const conf = { ...this.npm.flatOptions } + + const info = await pulseTillDone.withPromise(npmProfile.get(conf)) + + if (!info.cidr_whitelist) + delete info.cidr_whitelist + + if (conf.json) { + output(JSON.stringify(info, null, 2)) + return + } - value = { old: current, new: newpassword } + // clean up and format key/values for output + const cleaned = {} + for (const key of knownProfileKeys) + cleaned[key] = info[key] || '' + + const unknownProfileKeys = Object.keys(info).filter((k) => !(k in cleaned)) + for (const key of unknownProfileKeys) + cleaned[key] = info[key] || '' + + delete cleaned.tfa + delete cleaned.email_verified + cleaned.email += info.email_verified ? ' (verified)' : '(unverified)' + + if (info.tfa && !info.tfa.pending) + cleaned[tfa] = info.tfa.mode + else + cleaned[tfa] = 'disabled' + + if (args.length) { + const values = args // comma or space separated + .join(',') + .split(/,/) + .filter((arg) => arg.trim() !== '') + .map((arg) => cleaned[arg]) + .join('\t') + output(values) + } else { + if (conf.parseable) { + for (const key of Object.keys(info)) { + if (key === 'tfa') + output(`${key}\t${cleaned[tfa]}`) + else + output(`${key}\t${info[key]}`) + } + } else { + const table = new Table() + for (const key of Object.keys(cleaned)) + table.push({ [ansistyles.bright(key)]: cleaned[key] }) + + output(table.toString()) + } + } } - // FIXME: Work around to not clear everything other than what we're setting - const user = await pulseTillDone.withPromise(npmProfile.get(conf)) - const newUser = {} + async set (args) { + const conf = { ...this.npm.flatOptions } + const prop = (args[0] || '').toLowerCase().trim() - for (const key of writableProfileKeys) - newUser[key] = user[key] + let value = args.length > 1 ? args.slice(1).join(' ') : null - newUser[prop] = value + const readPasswords = async () => { + const newpassword = await readUserInfo.password('New password: ') + const confirmedpassword = await readUserInfo.password(' Again: ') - const result = await otplease(conf, conf => npmProfile.set(newUser, conf)) + if (newpassword !== confirmedpassword) { + log.warn('profile', 'Passwords do not match, please try again.') + return readPasswords() + } - if (conf.json) - output(JSON.stringify({ [prop]: result[prop] }, null, 2)) - else if (conf.parseable) - output(prop + '\t' + result[prop]) - else if (result[prop] != null) - output('Set', prop, 'to', result[prop]) - else - output('Set', prop) -} + return newpassword + } -const enable2fa = async (args) => { - if (args.length > 1) - throw new Error('npm profile enable-2fa [auth-and-writes|auth-only]') - - const mode = args[0] || 'auth-and-writes' - if (mode !== 'auth-only' && mode !== 'auth-and-writes') { - throw new Error( - `Invalid two-factor authentication mode "${mode}".\n` + - 'Valid modes are:\n' + - ' auth-only - Require two-factor authentication only when logging in\n' + - ' auth-and-writes - Require two-factor authentication when logging in ' + - 'AND when publishing' - ) - } + if (prop !== 'password' && value === null) + throw new Error('npm profile set ') - const conf = { ...npm.flatOptions } - if (conf.json || conf.parseable) { - throw new Error( - 'Enabling two-factor authentication is an interactive operation and ' + - (conf.json ? 'JSON' : 'parseable') + ' output mode is not available' - ) - } + if (prop === 'password' && value !== null) { + throw new Error( + 'npm profile set password\n' + + 'Do not include your current or new passwords on the command line.') + } - const info = { - tfa: { - mode: mode, - }, - } + if (writableProfileKeys.indexOf(prop) === -1) { + throw new Error(`"${prop}" is not a property we can set. ` + + `Valid properties are: ` + writableProfileKeys.join(', ')) + } - // if they're using legacy auth currently then we have to - // update them to a bearer token before continuing. - const auth = getAuth(conf) + if (prop === 'password') { + const current = await readUserInfo.password('Current password: ') + const newpassword = await readPasswords() - if (!auth.basic && !auth.token) { - throw new Error( - 'You need to be logged in to registry ' + - `${conf.registry} in order to enable 2fa` - ) + value = { old: current, new: newpassword } + } + + // FIXME: Work around to not clear everything other than what we're setting + const user = await pulseTillDone.withPromise(npmProfile.get(conf)) + const newUser = {} + + for (const key of writableProfileKeys) + newUser[key] = user[key] + + newUser[prop] = value + + const result = await otplease(conf, conf => npmProfile.set(newUser, conf)) + + if (conf.json) + output(JSON.stringify({ [prop]: result[prop] }, null, 2)) + else if (conf.parseable) + output(prop + '\t' + result[prop]) + else if (result[prop] != null) + output('Set', prop, 'to', result[prop]) + else + output('Set', prop) } - if (auth.basic) { - log.info('profile', 'Updating authentication to bearer token') - const result = await npmProfile.createToken( - auth.basic.password, false, [], conf - ) + async enable2fa (args) { + if (args.length > 1) + throw new Error('npm profile enable-2fa [auth-and-writes|auth-only]') - if (!result.token) { + const mode = args[0] || 'auth-and-writes' + if (mode !== 'auth-only' && mode !== 'auth-and-writes') { throw new Error( - `Your registry ${conf.registry} does not seem to ` + - 'support bearer tokens. Bearer tokens are required for ' + - 'two-factor authentication' + `Invalid two-factor authentication mode "${mode}".\n` + + 'Valid modes are:\n' + + ' auth-only - Require two-factor authentication only when logging in\n' + + ' auth-and-writes - Require two-factor authentication when logging in ' + + 'AND when publishing' ) } - npm.config.setCredentialsByURI(conf.registry, { token: result.token }) - await npm.config.save('user') - } + const conf = { ...this.npm.flatOptions } + if (conf.json || conf.parseable) { + throw new Error( + 'Enabling two-factor authentication is an interactive operation and ' + + (conf.json ? 'JSON' : 'parseable') + ' output mode is not available' + ) + } - log.notice('profile', 'Enabling two factor authentication for ' + mode) - const password = await readUserInfo.password() - info.tfa.password = password + const info = { + tfa: { + mode: mode, + }, + } - log.info('profile', 'Determine if tfa is pending') - const userInfo = await pulseTillDone.withPromise(npmProfile.get(conf)) + // if they're using legacy auth currently then we have to + // update them to a bearer token before continuing. + const creds = this.npm.config.getCredentialsByURI(conf.registry) + const auth = {} + + if (creds.token) + auth.token = creds.token + else if (creds.username) + auth.basic = { username: creds.username, password: creds.password } + else if (creds.auth) { + const basic = Buffer.from(creds.auth, 'base64').toString().split(':', 2) + auth.basic = { username: basic[0], password: basic[1] } + } - if (userInfo && userInfo.tfa && userInfo.tfa.pending) { - log.info('profile', 'Resetting two-factor authentication') - await pulseTillDone.withPromise( - npmProfile.set({ tfa: { password, mode: 'disable' } }, conf) - ) - } else if (userInfo && userInfo.tfa) { if (conf.otp) - conf.otp = conf.otp - else { - const otp = await readUserInfo.otp( - 'Enter one-time password from your authenticator app: ' + auth.otp = conf.otp + + if (!auth.basic && !auth.token) { + throw new Error( + 'You need to be logged in to registry ' + + `${conf.registry} in order to enable 2fa` ) - conf.otp = otp } - } - log.info('profile', 'Setting two-factor authentication to ' + mode) - const challenge = await pulseTillDone.withPromise(npmProfile.set(info, conf)) + if (auth.basic) { + log.info('profile', 'Updating authentication to bearer token') + const result = await npmProfile.createToken( + auth.basic.password, false, [], conf + ) - if (challenge.tfa === null) { - output('Two factor authentication mode changed to: ' + mode) - return - } + if (!result.token) { + throw new Error( + `Your registry ${conf.registry} does not seem to ` + + 'support bearer tokens. Bearer tokens are required for ' + + 'two-factor authentication' + ) + } - const badResponse = typeof challenge.tfa !== 'string' - || !/^otpauth:[/][/]/.test(challenge.tfa) - if (badResponse) { - throw new Error( - 'Unknown error enabling two-factor authentication. Expected otpauth URL' + - ', got: ' + inspect(challenge.tfa) - ) - } + this.npm.config.setCredentialsByURI( + conf.registry, + { token: result.token } + ) + await this.npm.config.save('user') + } - const otpauth = new URL(challenge.tfa) - const secret = otpauth.searchParams.get('secret') - const code = await qrcode(challenge.tfa) + log.notice('profile', 'Enabling two factor authentication for ' + mode) + const password = await readUserInfo.password() + info.tfa.password = password - output( - 'Scan into your authenticator app:\n' + code + '\n Or enter code:', secret - ) + log.info('profile', 'Determine if tfa is pending') + const userInfo = await pulseTillDone.withPromise(npmProfile.get(conf)) - const interactiveOTP = - await readUserInfo.otp('And an OTP code from your authenticator: ') + if (userInfo && userInfo.tfa && userInfo.tfa.pending) { + log.info('profile', 'Resetting two-factor authentication') + await pulseTillDone.withPromise( + npmProfile.set({ tfa: { password, mode: 'disable' } }, conf) + ) + } else if (userInfo && userInfo.tfa) { + if (conf.otp) + conf.otp = conf.otp + else { + const otp = await readUserInfo.otp( + 'Enter one-time password from your authenticator app: ' + ) + conf.otp = otp + } + } - log.info('profile', 'Finalizing two-factor authentication') + log.info('profile', 'Setting two-factor authentication to ' + mode) + const challenge = await pulseTillDone.withPromise( + npmProfile.set(info, conf) + ) - const result = await npmProfile.set({ tfa: [interactiveOTP] }, conf) + if (challenge.tfa === null) { + output('Two factor authentication mode changed to: ' + mode) + return + } - output( - '2FA successfully enabled. Below are your recovery codes, ' + - 'please print these out.' - ) - output( - 'You will need these to recover access to your account ' + - 'if you lose your authentication device.' - ) + const badResponse = typeof challenge.tfa !== 'string' + || !/^otpauth:[/][/]/.test(challenge.tfa) + if (badResponse) { + throw new Error( + 'Unknown error enabling two-factor authentication. Expected otpauth URL' + + ', got: ' + inspect(challenge.tfa) + ) + } - for (const tfaCode of result.tfa) - output('\t' + tfaCode) -} + const otpauth = new URL(challenge.tfa) + const secret = otpauth.searchParams.get('secret') + const code = await qrcode(challenge.tfa) -const getAuth = conf => { - const creds = npm.config.getCredentialsByURI(conf.registry) - const auth = {} - - if (creds.token) - auth.token = creds.token - else if (creds.username) - auth.basic = { username: creds.username, password: creds.password } - else if (creds.auth) { - const basic = Buffer.from(creds.auth, 'base64').toString().split(':', 2) - auth.basic = { username: basic[0], password: basic[1] } - } + output( + 'Scan into your authenticator app:\n' + code + '\n Or enter code:', secret + ) - if (conf.otp) - auth.otp = conf.otp + const interactiveOTP = + await readUserInfo.otp('And an OTP code from your authenticator: ') - return auth -} + log.info('profile', 'Finalizing two-factor authentication') -const disable2fa = async args => { - const conf = { ...npm.flatOptions } - const info = await pulseTillDone.withPromise(npmProfile.get(conf)) + const result = await npmProfile.set({ tfa: [interactiveOTP] }, conf) - if (!info.tfa || info.tfa.pending) { - output('Two factor authentication not enabled.') - return + output( + '2FA successfully enabled. Below are your recovery codes, ' + + 'please print these out.' + ) + output( + 'You will need these to recover access to your account ' + + 'if you lose your authentication device.' + ) + + for (const tfaCode of result.tfa) + output('\t' + tfaCode) } - const password = await readUserInfo.password() + async disable2fa (args) { + const conf = { ...this.npm.flatOptions } + const info = await pulseTillDone.withPromise(npmProfile.get(conf)) - if (!conf.otp) { - const msg = 'Enter one-time password from your authenticator app: ' - conf.otp = await readUserInfo.otp(msg) - } + if (!info.tfa || info.tfa.pending) { + output('Two factor authentication not enabled.') + return + } - log.info('profile', 'disabling tfa') + const password = await readUserInfo.password() - await pulseTillDone.withPromise(npmProfile.set({ - tfa: { password: password, mode: 'disable' }, - }, conf)) + if (!conf.otp) { + const msg = 'Enter one-time password from your authenticator app: ' + conf.otp = await readUserInfo.otp(msg) + } - if (conf.json) - output(JSON.stringify({ tfa: false }, null, 2)) - else if (conf.parseable) - output('tfa\tfalse') - else - output('Two factor authentication disabled.') -} + log.info('profile', 'disabling tfa') -const qrcode = url => - new Promise((resolve) => qrcodeTerminal.generate(url, resolve)) + await pulseTillDone.withPromise(npmProfile.set({ + tfa: { password: password, mode: 'disable' }, + }, conf)) -module.exports = Object.assign(cmd, { usage, completion }) + if (conf.json) + output(JSON.stringify({ tfa: false }, null, 2)) + else if (conf.parseable) + output('tfa\tfalse') + else + output('Two factor authentication disabled.') + } +} +module.exports = Profile diff --git a/lib/prune.js b/lib/prune.js index 228fd3eebb178..b839301d5194c 100644 --- a/lib/prune.js +++ b/lib/prune.js @@ -1,24 +1,32 @@ // prune extraneous packages -const npm = require('./npm.js') const Arborist = require('@npmcli/arborist') const usageUtil = require('./utils/usage.js') - const reifyFinish = require('./utils/reify-finish.js') -const usage = usageUtil('prune', - 'npm prune [[<@scope>/]...] [--production]' -) +class Prune { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => prune().then(() => cb()).catch(cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('prune', + 'npm prune [[<@scope>/]...] [--production]' + ) + } -const prune = async () => { - const where = npm.prefix - const arb = new Arborist({ - ...npm.flatOptions, - path: where, - }) - await arb.prune(npm.flatOptions) - await reifyFinish(arb) -} + exec (args, cb) { + this.prune().then(() => cb()).catch(cb) + } -module.exports = Object.assign(cmd, { usage }) + async prune () { + const where = this.npm.prefix + const arb = new Arborist({ + ...this.npm.flatOptions, + path: where, + }) + await arb.prune(this.npm.flatOptions) + await reifyFinish(this.npm, arb) + } +} +module.exports = Prune diff --git a/lib/publish.js b/lib/publish.js index 5ec66d42fa9a7..c8e82c44c5a3c 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -8,9 +8,10 @@ const pacote = require('pacote') const npa = require('npm-package-arg') const npmFetch = require('npm-registry-fetch') -const npm = require('./npm.js') +const { flatten } = require('./utils/flat-options.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') +const usageUtil = require('./utils/usage.js') const { getContents, logTar } = require('./utils/tar.js') // this is the only case in the CLI where we use the old full slow @@ -18,122 +19,125 @@ const { getContents, logTar } = require('./utils/tar.js') // defaults and metadata, like git sha's and default scripts and all that. const readJson = util.promisify(require('read-package-json')) -const usageUtil = require('./utils/usage.js') -const usage = usageUtil('publish', - 'npm publish [] [--tag ] [--access ] [--dry-run]' + - '\n\nPublishes \'.\' if no argument supplied' + - '\nSets tag `latest` if no --tag specified') +class Publish { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => publish(args).then(() => cb()).catch(cb) + get usage () { + return usageUtil('publish', + 'npm publish [] [--tag ] [--access ] [--dry-run]' + + '\n\nPublishes \'.\' if no argument supplied' + + '\nSets tag `latest` if no --tag specified') + } -const publish = async args => { - if (args.length === 0) - args = ['.'] - if (args.length !== 1) - throw usage + exec (args, cb) { + this.publish(args).then(() => cb()).catch(cb) + } - log.verbose('publish', args) + async publish (args) { + if (args.length === 0) + args = ['.'] + if (args.length !== 1) + throw this.usage - const opts = { ...npm.flatOptions } - const { json, defaultTag } = opts + log.verbose('publish', args) - if (semver.validRange(defaultTag)) - throw new Error('Tag name must not be a valid SemVer range: ' + defaultTag.trim()) + const opts = { ...this.npm.flatOptions } + const { unicode, dryRun, json, defaultTag } = opts - const tarball = await publish_(args[0], opts) - const silent = log.level === 'silent' - if (!silent && json) - output(JSON.stringify(tarball, null, 2)) - else if (!silent) - output(`+ ${tarball.id}`) + if (semver.validRange(defaultTag)) + throw new Error('Tag name must not be a valid SemVer range: ' + defaultTag.trim()) - return tarball -} + // you can publish name@version, ./foo.tgz, etc. + // even though the default is the 'file:.' cwd. + const spec = npa(args[0]) + let manifest = await this.getManifest(spec, opts) -// if it's a directory, read it from the file system -// otherwise, get the full metadata from whatever it is -const getManifest = (spec, opts) => - spec.type === 'directory' ? readJson(`${spec.fetchSpec}/package.json`) - : pacote.manifest(spec, { ...opts, fullMetadata: true }) + if (manifest.publishConfig) + Object.assign(opts, this.publishConfigToOpts(manifest.publishConfig)) -// for historical reasons, publishConfig in package.json can contain -// ANY config keys that npm supports in .npmrc files and elsewhere. -// We *may* want to revisit this at some point, and have a minimal set -// that's a SemVer-major change that ought to get a RFC written on it. -const { flatten } = require('./utils/flat-options.js') -const publishConfigToOpts = publishConfig => - // create a new object that inherits from the config stack - // then squash the css-case into camelCase opts, like we do - flatten(Object.assign(Object.create(npm.config.list[0]), publishConfig)) - -const publish_ = async (arg, opts) => { - const { unicode, dryRun, json } = opts - // you can publish name@version, ./foo.tgz, etc. - // even though the default is the 'file:.' cwd. - const spec = npa(arg) - - let manifest = await getManifest(spec, opts) - - if (manifest.publishConfig) - Object.assign(opts, publishConfigToOpts(manifest.publishConfig)) - - // only run scripts for directory type publishes - if (spec.type === 'directory') { - await runScript({ - event: 'prepublishOnly', - path: spec.fetchSpec, - stdio: 'inherit', - pkg: manifest, - banner: log.level !== 'silent', - }) - } + // only run scripts for directory type publishes + if (spec.type === 'directory') { + await runScript({ + event: 'prepublishOnly', + path: spec.fetchSpec, + stdio: 'inherit', + pkg: manifest, + banner: log.level !== 'silent', + }) + } + + const tarballData = await pack(spec, opts) + const pkgContents = await getContents(manifest, tarballData) + + // The purpose of re-reading the manifest is in case it changed, + // so that we send the latest and greatest thing to the registry + // note that publishConfig might have changed as well! + manifest = await this.getManifest(spec, opts) + if (manifest.publishConfig) + Object.assign(opts, this.publishConfigToOpts(manifest.publishConfig)) + + // note that logTar calls npmlog.notice(), so if we ARE in silent mode, + // this will do nothing, but we still want it in the debuglog if it fails. + if (!json) + logTar(pkgContents, { log, unicode }) + + if (!dryRun) { + const resolved = npa.resolve(manifest.name, manifest.version) + const registry = npmFetch.pickRegistry(resolved, opts) + const creds = this.npm.config.getCredentialsByURI(registry) + if (!creds.token && !creds.username) { + throw Object.assign(new Error('This command requires you to be logged in.'), { + code: 'ENEEDAUTH', + }) + } + await otplease(opts, opts => libpub(manifest, tarballData, opts)) + } + + if (spec.type === 'directory') { + await runScript({ + event: 'publish', + path: spec.fetchSpec, + stdio: 'inherit', + pkg: manifest, + banner: log.level !== 'silent', + }) - const tarballData = await pack(spec, opts) - const pkgContents = await getContents(manifest, tarballData) - - // The purpose of re-reading the manifest is in case it changed, - // so that we send the latest and greatest thing to the registry - // note that publishConfig might have changed as well! - manifest = await getManifest(spec, opts) - if (manifest.publishConfig) - Object.assign(opts, publishConfigToOpts(manifest.publishConfig)) - - // note that logTar calls npmlog.notice(), so if we ARE in silent mode, - // this will do nothing, but we still want it in the debuglog if it fails. - if (!json) - logTar(pkgContents, { log, unicode }) - - if (!dryRun) { - const resolved = npa.resolve(manifest.name, manifest.version) - const registry = npmFetch.pickRegistry(resolved, opts) - const creds = npm.config.getCredentialsByURI(registry) - if (!creds.token && !creds.username) { - throw Object.assign(new Error('This command requires you to be logged in.'), { - code: 'ENEEDAUTH', + await runScript({ + event: 'postpublish', + path: spec.fetchSpec, + stdio: 'inherit', + pkg: manifest, + banner: log.level !== 'silent', }) } - await otplease(opts, opts => libpub(manifest, tarballData, opts)) + + const silent = log.level === 'silent' + if (!silent && json) + output(JSON.stringify(pkgContents, null, 2)) + else if (!silent) + output(`+ ${pkgContents.id}`) + + return pkgContents } - if (spec.type === 'directory') { - await runScript({ - event: 'publish', - path: spec.fetchSpec, - stdio: 'inherit', - pkg: manifest, - banner: log.level !== 'silent', - }) - - await runScript({ - event: 'postpublish', - path: spec.fetchSpec, - stdio: 'inherit', - pkg: manifest, - banner: log.level !== 'silent', - }) + // if it's a directory, read it from the file system + // otherwise, get the full metadata from whatever it is + getManifest (spec, opts) { + if (spec.type === 'directory') + return readJson(`${spec.fetchSpec}/package.json`) + return pacote.manifest(spec, { ...opts, fullMetadata: true }) } - return pkgContents + // for historical reasons, publishConfig in package.json can contain + // ANY config keys that npm supports in .npmrc files and elsewhere. + // We *may* want to revisit this at some point, and have a minimal set + // that's a SemVer-major change that ought to get a RFC written on it. + publishConfigToOpts (publishConfig) { + // create a new object that inherits from the config stack + // then squash the css-case into camelCase opts, like we do + return flatten({...this.npm.config.list[0], ...publishConfig}) + } } - -module.exports = Object.assign(cmd, { usage }) +module.exports = Publish diff --git a/lib/rebuild.js b/lib/rebuild.js index ab34b7f3dfb51..1091b01589389 100644 --- a/lib/rebuild.js +++ b/lib/rebuild.js @@ -2,64 +2,74 @@ const { resolve } = require('path') const Arborist = require('@npmcli/arborist') const npa = require('npm-package-arg') const semver = require('semver') - -const npm = require('./npm.js') const usageUtil = require('./utils/usage.js') const output = require('./utils/output.js') +const completion = require('./utils/completion/installed-deep.js') -const cmd = (args, cb) => rebuild(args).then(() => cb()).catch(cb) +class Rebuild { + constructor (npm) { + this.npm = npm + } -const usage = usageUtil('rebuild', 'npm rebuild [[<@scope>/][@] ...]') + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('rebuild', 'npm rebuild [[<@scope>/][@] ...]') + } -const completion = require('./utils/completion/installed-deep.js') + /* istanbul ignore next - see test/lib/load-all-commands.js */ + async completion (opts) { + return completion(this.npm, opts) + } -const rebuild = async args => { - const globalTop = resolve(npm.globalDir, '..') - const where = npm.flatOptions.global ? globalTop : npm.prefix - const arb = new Arborist({ - ...npm.flatOptions, - path: where, - }) + exec (args, cb) { + this.rebuild(args).then(() => cb()).catch(cb) + } - if (args.length) { - // get the set of nodes matching the name that we want rebuilt - const tree = await arb.loadActual() - const filter = getFilterFn(args) - await arb.rebuild({ - nodes: tree.inventory.filter(filter), + async rebuild (args) { + const globalTop = resolve(this.npm.globalDir, '..') + const where = this.npm.flatOptions.global ? globalTop : this.npm.prefix + const arb = new Arborist({ + ...this.npm.flatOptions, + path: where, }) - } else - await arb.rebuild() - output('rebuilt dependencies successfully') -} + if (args.length) { + // get the set of nodes matching the name that we want rebuilt + const tree = await arb.loadActual() + const specs = args.map(arg => { + const spec = npa(arg) + if (spec.type === 'tag' && spec.rawSpec === '') + return spec -const getFilterFn = args => { - const specs = args.map(arg => { - const spec = npa(arg) - if (spec.type === 'tag' && spec.rawSpec === '') - return spec + if (spec.type !== 'range' && spec.type !== 'version' && spec.type !== 'directory') + throw new Error('`npm rebuild` only supports SemVer version/range specifiers') - if (spec.type !== 'range' && spec.type !== 'version' && spec.type !== 'directory') - throw new Error('`npm rebuild` only supports SemVer version/range specifiers') + return spec + }) + const nodes = tree.inventory.filter(node => this.isNode(specs, node)) - return spec - }) + await arb.rebuild({ nodes }) + } else + await arb.rebuild() - return node => specs.some(spec => { - if (spec.type === 'directory') - return node.path === spec.fetchSpec + output('rebuilt dependencies successfully') + } - if (spec.name !== node.name) - return false + isNode (specs, node) { + return specs.some(spec => { + if (spec.type === 'directory') + return node.path === spec.fetchSpec - if (spec.rawSpec === '' || spec.rawSpec === '*') - return true + if (spec.name !== node.name) + return false - const { version } = node.package - // TODO: add tests for a package with missing version - return semver.satisfies(version, spec.fetchSpec) - }) -} + if (spec.rawSpec === '' || spec.rawSpec === '*') + return true -module.exports = Object.assign(cmd, { usage, completion }) + const { version } = node.package + // TODO: add tests for a package with missing version + return semver.satisfies(version, spec.fetchSpec) + }) + } +} +module.exports = Rebuild diff --git a/lib/repo.js b/lib/repo.js index e9074dca68d7c..60fe6dbaf90b9 100644 --- a/lib/repo.js +++ b/lib/repo.js @@ -1,52 +1,64 @@ const log = require('npmlog') const pacote = require('pacote') +const { URL } = require('url') const { promisify } = require('util') + +const hostedFromMani = require('./utils/hosted-git-info-from-manifest.js') const openUrl = promisify(require('./utils/open-url.js')) const usageUtil = require('./utils/usage.js') -const npm = require('./npm.js') -const hostedFromMani = require('./utils/hosted-git-info-from-manifest.js') -const { URL } = require('url') -const usage = usageUtil('repo', 'npm repo [ [ ...]]') +class Repo { + constructor (npm) { + this.npm = npm + } -const cmd = (args, cb) => repo(args).then(() => cb()).catch(cb) + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('repo', 'npm repo [ [ ...]]') + } -const repo = async args => { - if (!args || !args.length) - args = ['.'] + exec (args, cb) { + this.repo(args).then(() => cb()).catch(cb) + } - await Promise.all(args.map(pkg => getRepo(pkg))) -} + async repo (args) { + if (!args || !args.length) + args = ['.'] -const getRepo = async pkg => { - const opts = { ...npm.flatOptions, fullMetadata: true } - const mani = await pacote.manifest(pkg, opts) + await Promise.all(args.map(pkg => this.get(pkg))) + } - const r = mani.repository - const rurl = !r ? null - : typeof r === 'string' ? r - : typeof r === 'object' && typeof r.url === 'string' ? r.url - : null + async get (pkg) { + const opts = { ...this.npm.flatOptions, fullMetadata: true } + const mani = await pacote.manifest(pkg, opts) - if (!rurl) { - throw Object.assign(new Error('no repository'), { - pkgid: pkg, - }) - } + const r = mani.repository + const rurl = !r ? null + : typeof r === 'string' ? r + : typeof r === 'object' && typeof r.url === 'string' ? r.url + : null - const info = hostedFromMani(mani) - const url = info ? - info.browse(mani.repository.directory) : unknownHostedUrl(rurl) + if (!rurl) { + throw Object.assign(new Error('no repository'), { + pkgid: pkg, + }) + } - if (!url) { - throw Object.assign(new Error('no repository: could not get url'), { - pkgid: pkg, - }) - } + const info = hostedFromMani(mani) + const url = info ? + info.browse(mani.repository.directory) : unknownHostedUrl(rurl) - log.silly('docs', 'url', url) - await openUrl(url, `${mani.name} repo available at the following URL`) + if (!url) { + throw Object.assign(new Error('no repository: could not get url'), { + pkgid: pkg, + }) + } + + log.silly('docs', 'url', url) + await openUrl(this.npm, url, `${mani.name} repo available at the following URL`) + } } +module.exports = Repo const unknownHostedUrl = url => { try { @@ -67,5 +79,3 @@ const unknownHostedUrl = url => { return null } } - -module.exports = Object.assign(cmd, { usage }) diff --git a/lib/restart.js b/lib/restart.js index 1462cf6051d0f..d5a7789ca92c0 100644 --- a/lib/restart.js +++ b/lib/restart.js @@ -1,2 +1,9 @@ -const npm = require('./npm.js') -module.exports = require('./utils/lifecycle-cmd.js')(npm, 'restart') +const LifecycleCmd = require('./utils/lifecycle-cmd.js') + +// This ends up calling run-script(['restart', ...args]) +class Restart extends LifecycleCmd { + constructor (npm) { + super(npm, 'restart') + } +} +module.exports = Restart diff --git a/lib/root.js b/lib/root.js index 631aef83867d1..8e5ac63d7b9b8 100644 --- a/lib/root.js +++ b/lib/root.js @@ -1,7 +1,22 @@ -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') -const cmd = (args, cb) => root(args).then(() => cb()).catch(cb) -const usage = usageUtil('root', 'npm root [-g]') -const root = async (args, cb) => output(npm.dir) -module.exports = Object.assign(cmd, { usage }) + +class Root { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil('root', 'npm root [-g]') + } + + exec (args, cb) { + this.root(args).then(() => cb()).catch(cb) + } + + async root () { + output(this.npm.dir) + } +} +module.exports = Root diff --git a/lib/run-script.js b/lib/run-script.js index 4dfb854cad9fa..cdfd88f10f7b8 100644 --- a/lib/run-script.js +++ b/lib/run-script.js @@ -1,144 +1,157 @@ const runScript = require('@npmcli/run-script') const { isServerPackage } = runScript -const npm = require('./npm.js') const readJson = require('read-package-json-fast') const { resolve } = require('path') const output = require('./utils/output.js') const log = require('npmlog') -const usageUtil = require('./utils/usage') -const didYouMean = require('./utils/did-you-mean') +const usageUtil = require('./utils/usage.js') +const didYouMean = require('./utils/did-you-mean.js') const isWindowsShell = require('./utils/is-windows-shell.js') -const usage = usageUtil( - 'run-script', - 'npm run-script [-- ]' -) - -const completion = async (opts) => { - const argv = opts.conf.argv.remain - if (argv.length === 2) { - // find the script name - const json = resolve(npm.localPrefix, 'package.json') - const { scripts = {} } = await readJson(json).catch(er => ({})) - return Object.keys(scripts) +const cmdList = [ + 'publish', + 'install', + 'uninstall', + 'test', + 'stop', + 'start', + 'restart', + 'version', +].reduce((l, p) => l.concat(['pre' + p, p, 'post' + p]), []) + +class RunScript { + constructor (npm) { + this.npm = npm } -} -const cmd = (args, cb) => { - const fn = args.length ? doRun : list - return fn(args).then(() => cb()).catch(cb) -} + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'run-script', + 'npm run-script [-- ]' + ) + } -const doRun = async (args) => { - const path = npm.localPrefix - const event = args.shift() - const { scriptShell } = npm.flatOptions + async completion (opts) { + const argv = opts.conf.argv.remain + if (argv.length === 2) { + // find the script name + const json = resolve(this.npm.localPrefix, 'package.json') + const { scripts = {} } = await readJson(json).catch(er => ({})) + return Object.keys(scripts) + } + } - const pkg = await readJson(`${path}/package.json`) - const { scripts = {} } = pkg + exec (args, cb) { + if (args.length) + this.run(args).then(() => cb()).catch(cb) + else + this.list(args).then(() => cb()).catch(cb) + } - if (event === 'restart' && !scripts.restart) - scripts.restart = 'npm stop --if-present && npm start' - else if (event === 'env' && !scripts.env) - scripts.env = isWindowsShell ? 'SET' : 'env' + async run (args) { + const path = this.npm.localPrefix + const event = args.shift() + const { scriptShell } = this.npm.flatOptions - pkg.scripts = scripts + const pkg = await readJson(`${path}/package.json`) + const { scripts = {} } = pkg - if (!Object.prototype.hasOwnProperty.call(scripts, event) && !(event === 'start' && await isServerPackage(path))) { - if (npm.config.get('if-present')) - return + if (event === 'restart' && !scripts.restart) + scripts.restart = 'npm stop --if-present && npm start' + else if (event === 'env' && !scripts.env) + scripts.env = isWindowsShell ? 'SET' : 'env' - const suggestions = didYouMean(event, Object.keys(scripts)) - throw new Error(`missing script: ${event}${ - suggestions ? `\n${suggestions}` : ''}`) - } + pkg.scripts = scripts - // positional args only added to the main event, not pre/post - const events = [[event, args]] - if (!npm.flatOptions.ignoreScripts) { - if (scripts[`pre${event}`]) - events.unshift([`pre${event}`, []]) + if ( + !Object.prototype.hasOwnProperty.call(scripts, event) && + !(event === 'start' && await isServerPackage(path)) + ) { + if (this.npm.config.get('if-present')) + return - if (scripts[`post${event}`]) - events.push([`post${event}`, []]) - } + const suggestions = didYouMean(event, Object.keys(scripts)) + throw new Error(`missing script: ${event}${ + suggestions ? `\n${suggestions}` : ''}`) + } - const opts = { - path, - args, - scriptShell, - stdio: 'inherit', - stdioString: true, - pkg, - banner: log.level !== 'silent', - } + // positional args only added to the main event, not pre/post + const events = [[event, args]] + if (!this.npm.flatOptions.ignoreScripts) { + if (scripts[`pre${event}`]) + events.unshift([`pre${event}`, []]) + + if (scripts[`post${event}`]) + events.push([`post${event}`, []]) + } - for (const [event, args] of events) { - await runScript({ - ...opts, - event, + const opts = { + path, args, - }) + scriptShell, + stdio: 'inherit', + stdioString: true, + pkg, + banner: log.level !== 'silent', + } + + for (const [event, args] of events) { + await runScript({ + ...opts, + event, + args, + }) + } } -} -const list = async () => { - const path = npm.localPrefix - const { scripts, name } = await readJson(`${path}/package.json`) - const cmdList = [ - 'publish', - 'install', - 'uninstall', - 'test', - 'stop', - 'start', - 'restart', - 'version', - ].reduce((l, p) => l.concat(['pre' + p, p, 'post' + p]), []) - - if (!scripts) - return [] - - const allScripts = Object.keys(scripts) - if (log.level === 'silent') - return allScripts + async list () { + const path = this.npm.localPrefix + const { scripts, name } = await readJson(`${path}/package.json`) - if (npm.flatOptions.json) { - output(JSON.stringify(scripts, null, 2)) - return allScripts - } + if (!scripts) + return [] - if (npm.flatOptions.parseable) { - for (const [script, cmd] of Object.entries(scripts)) - output(`${script}:${cmd}`) + const allScripts = Object.keys(scripts) + if (log.level === 'silent') + return allScripts - return allScripts - } + if (this.npm.flatOptions.json) { + output(JSON.stringify(scripts, null, 2)) + return allScripts + } - const indent = '\n ' - const prefix = ' ' - const cmds = [] - const runScripts = [] - for (const script of allScripts) { - const list = cmdList.includes(script) ? cmds : runScripts - list.push(script) - } + if (this.npm.flatOptions.parseable) { + for (const [script, cmd] of Object.entries(scripts)) + output(`${script}:${cmd}`) - if (cmds.length) - output(`Lifecycle scripts included in ${name}:`) + return allScripts + } - for (const script of cmds) - output(prefix + script + indent + scripts[script]) + const indent = '\n ' + const prefix = ' ' + const cmds = [] + const runScripts = [] + for (const script of allScripts) { + const list = cmdList.includes(script) ? cmds : runScripts + list.push(script) + } - if (!cmds.length && runScripts.length) - output(`Scripts available in ${name} via \`npm run-script\`:`) - else if (runScripts.length) - output('\navailable via `npm run-script`:') + if (cmds.length) + output(`Lifecycle scripts included in ${name}:`) - for (const script of runScripts) - output(prefix + script + indent + scripts[script]) + for (const script of cmds) + output(prefix + script + indent + scripts[script]) - return allScripts -} + if (!cmds.length && runScripts.length) + output(`Scripts available in ${name} via \`npm run-script\`:`) + else if (runScripts.length) + output('\navailable via `npm run-script`:') + + for (const script of runScripts) + output(prefix + script + indent + scripts[script]) -module.exports = Object.assign(cmd, { completion, usage }) + return allScripts + } +} +module.exports = RunScript diff --git a/lib/search.js b/lib/search.js index 3f8fd99fb8ad8..ba6c28163bd97 100644 --- a/lib/search.js +++ b/lib/search.js @@ -5,68 +5,9 @@ const log = require('npmlog') const formatPackageStream = require('./search/format-package-stream.js') const packageFilter = require('./search/package-filter.js') -const npm = require('./npm.js') const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') -const usage = usageUtil( - 'search', - 'npm search [-l|--long] [--json] [--parseable] [--no-description] [search terms ...]' -) - -const cmd = (args, cb) => search(args).then(() => cb()).catch(cb) - -const search = async (args) => { - const opts = { - ...npm.flatOptions, - ...npm.flatOptions.search, - include: prepareIncludes(args, npm.flatOptions.search.opts), - exclude: prepareExcludes(npm.flatOptions.search.exclude), - } - - if (opts.include.length === 0) - throw new Error('search must be called with arguments') - - // Used later to figure out whether we had any packages go out - let anyOutput = false - - class FilterStream extends Minipass { - write (pkg) { - if (packageFilter(pkg, opts.include, opts.exclude)) - super.write(pkg) - } - } - - const filterStream = new FilterStream() - - // Grab a configured output stream that will spit out packages in the - // desired format. - const outputStream = formatPackageStream({ - args, // --searchinclude options are not highlighted - ...opts, - }) - - log.silly('search', 'searching packages') - const p = new Pipeline( - libSearch.stream(opts.include, opts), - filterStream, - outputStream - ) - - p.on('data', chunk => { - if (!anyOutput) - anyOutput = true - output(chunk.toString('utf8')) - }) - - await p.promise() - if (!anyOutput && !opts.json && !opts.parseable) - output('No matches found for ' + (args.map(JSON.stringify).join(' '))) - - log.silly('search', 'search completed') - log.clearProgress() -} - function prepareIncludes (args, searchopts) { return args .map(s => s.toLowerCase()) @@ -85,4 +26,72 @@ function prepareExcludes (searchexclude) { .filter(s => s) } -module.exports = Object.assign(cmd, { usage }) +class Search { + constructor (npm) { + this.npm = npm + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + get usage () { + return usageUtil( + 'search', + 'npm search [-l|--long] [--json] [--parseable] [--no-description] [search terms ...]' + ) + } + + exec (args, cb) { + this.search(args).then(() => cb()).catch(cb) + } + + async search (args) { + const opts = { + ...this.npm.flatOptions, + ...this.npm.flatOptions.search, + include: prepareIncludes(args, this.npm.flatOptions.search.opts), + exclude: prepareExcludes(this.npm.flatOptions.search.exclude), + } + + if (opts.include.length === 0) + throw new Error('search must be called with arguments') + + // Used later to figure out whether we had any packages go out + let anyOutput = false + + class FilterStream extends Minipass { + write (pkg) { + if (packageFilter(pkg, opts.include, opts.exclude)) + super.write(pkg) + } + } + + const filterStream = new FilterStream() + + // Grab a configured output stream that will spit out packages in the + // desired format. + const outputStream = formatPackageStream({ + args, // --searchinclude options are not highlighted + ...opts, + }) + + log.silly('search', 'searching packages') + const p = new Pipeline( + libSearch.stream(opts.include, opts), + filterStream, + outputStream + ) + + p.on('data', chunk => { + if (!anyOutput) + anyOutput = true + output(chunk.toString('utf8')) + }) + + await p.promise() + if (!anyOutput && !opts.json && !opts.parseable) + output('No matches found for ' + (args.map(JSON.stringify).join(' '))) + + log.silly('search', 'search completed') + log.clearProgress() + } +} +module.exports = Search diff --git a/lib/set-script.js b/lib/set-script.js index 7bac6eca50604..25545898e1640 100644 --- a/lib/set-script.js +++ b/lib/set-script.js @@ -1,52 +1,62 @@ const log = require('npmlog') const usageUtil = require('./utils/usage.js') -const { localPrefix } = require('./npm.js') const fs = require('fs') -const usage = usageUtil('set-script', 'npm set-script [