From f6ebf5e8bd6a212c7661e248c62c423f2b54d978 Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Thu, 28 Sep 2017 11:30:00 -0700 Subject: profile,token: Add new profile commands --- doc/cli/npm-profile.md | 74 +++++++++++ doc/cli/npm-token.md | 59 +++++++++ doc/misc/npm-config.md | 22 ++++ lib/config/cmd-list.js | 2 + lib/config/defaults.js | 8 ++ lib/profile.js | 296 +++++++++++++++++++++++++++++++++++++++++++ lib/token.js | 211 ++++++++++++++++++++++++++++++ lib/utils/pulse-till-done.js | 40 ++++-- lib/utils/read-user-info.js | 66 ++++++++++ 9 files changed, 766 insertions(+), 12 deletions(-) create mode 100644 doc/cli/npm-profile.md create mode 100644 doc/cli/npm-token.md create mode 100644 lib/profile.js create mode 100644 lib/token.js create mode 100644 lib/utils/read-user-info.js diff --git a/doc/cli/npm-profile.md b/doc/cli/npm-profile.md new file mode 100644 index 000000000..16b5e11b6 --- /dev/null +++ b/doc/cli/npm-profile.md @@ -0,0 +1,74 @@ +npm-profile(1) -- Change settings on your registry profile +========================================================== + +## SYNOPSIS + + npm profile get [--json|--parseable] [] + npm profile set [--json|--parseable] + npm profile set password + npm profile enable-2fa [auth-and-writes|auth-only] + npm profile disable-2fa + +## DESCRIPTION + +Change your profile information on the registry. This not be available if +you're using a non-npmjs registry. + +* `npm profile get []`: + Display all of the properties of your profile, or one or more specific + properties. It looks like: + +``` ++-----------------+---------------------------+ +| name | example | ++-----------------+---------------------------+ +| email | me@example.com (verified) | ++-----------------+---------------------------+ +| two factor auth | auth-and-writes | ++-----------------+---------------------------+ +| fullname | Example User | ++-----------------+---------------------------+ +| homepage | | ++-----------------+---------------------------+ +| freenode | | ++-----------------+---------------------------+ +| twitter | | ++-----------------+---------------------------+ +| github | | ++-----------------+---------------------------+ +| created | 2015-02-26T01:38:35.892Z | ++-----------------+---------------------------+ +| updated | 2017-10-02T21:29:45.922Z | ++-----------------+---------------------------+ +``` + +* `npm profile set `: + Set the value of a profile property. You can set the following properties this way: + email, fullname, homepage, freenode, twitter, github + +* `npm profile set password`: + Change your password. This is interactive, you'll be prompted for your + current password and a new password. You'll also be prompted for an OTP + if you have two-factor authentication enabled. + +* `npm profile enable-2fa [auth-and-writes|auth-only]`: + Enables two-factor authentication. Defaults to `auth-and-writes` mode. Modes are: + * `auth-only`: Require an OTP when logging in or making changes to your + account's authentication. The OTP will be required on both the website + and the command line. + * `auth-and-writes`: Requires an OTP at all the times `auth-only` does, and also requires one when + publishing a module, setting the `latest` dist-tag, or changing access + via `npm access` and `npm owner`. + +* `npm profile disable-2fa`: + Disables two-factor authentication. + +## DETAILS + +All of the `npm profile` subcommands accept `--json` and `--parseable` and +will tailor their output based on those. Some of these commands may not be +available on non npmjs.com registries. + +## SEE ALSO + +* npm-config(7) diff --git a/doc/cli/npm-token.md b/doc/cli/npm-token.md new file mode 100644 index 000000000..dbc7ea9ee --- /dev/null +++ b/doc/cli/npm-token.md @@ -0,0 +1,59 @@ +npm-token(1) -- Manage your authentication tokens +================================================= + +## SYNOPSIS + + npm token list [--json|--parseable] + npm token create [--read-only] [--cidr=1.1.1.1/24,2.2.2.2/16] + npm token delete + +## DESCRIPTION + +This list you list, create and delete authentication tokens. + +* `npm token list`: + Shows a table of all active authentication tokens. You can request this as + JSON with `--json` or tab-separated values with `--parseable`. +``` ++--------+---------+------------+----------+----------------+ +| id | token | created | read-only | CIDR whitelist | ++--------+---------+------------+----------+----------------+ +| 7f3134 | 1fa9ba… | 2017-10-02 | yes | | ++--------+---------+------------+----------+----------------+ +| c03241 | af7aef… | 2017-10-02 | no | 192.168.0.1/24 | ++--------+---------+------------+----------+----------------+ +| e0cf92 | 3a436a… | 2017-10-02 | no | | ++--------+---------+------------+----------+----------------+ +| 63eb9d | 74ef35… | 2017-09-28 | no | | ++--------+---------+------------+----------+----------------+ +| 2daaa8 | cbad5f… | 2017-09-26 | no | | ++--------+---------+------------+----------+----------------+ +| 68c2fe | 127e51… | 2017-09-23 | no | | ++--------+---------+------------+----------+----------------+ +| 6334e1 | 1dadd1… | 2017-09-23 | no | | ++--------+---------+------------+----------+----------------+ +``` + +* `npm token create [--read-only] [--cidr=]`: + Create a new authentication token. It can be `--read-only` or accept a list of + [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) ranges to + limit use of this token to. This will prompt you for your password, and, if you have + two-factor authentication enabled, an otp. + +``` ++----------------+--------------------------------------+ +| token | a73c9572-f1b9-8983-983d-ba3ac3cc913d | ++----------------+--------------------------------------+ +| cidr_whitelist | | ++----------------+--------------------------------------+ +| readonly | false | ++----------------+--------------------------------------+ +| created | 2017-10-02T07:52:24.838Z | ++----------------+--------------------------------------+ +``` + +* `npm token delete `: + This removes an authentication token, making it immediately unusable. This can accept + both complete tokens (as you get back from `npm token create` and will + find in your `.npmrc`) and ids as seen in the `npm token list` output. + This will NOT accept the truncated token found in `npm token list` output. diff --git a/doc/misc/npm-config.md b/doc/misc/npm-config.md index 30ea578f0..3c9ec3881 100644 --- a/doc/misc/npm-config.md +++ b/doc/misc/npm-config.md @@ -269,6 +269,13 @@ PEM format (Windows calls it "Base-64 encoded X.509 (.CER)") with newlines repla It is _not_ the path to a certificate file (and there is no "certfile" option). +### cidr + +* Default: `null` +* Type: String, Array, null + +This is a list of CIDR address to be used when configuring limited access tokens with the `npm token create` command. + ### color * Default: true @@ -699,6 +706,14 @@ Attempt to install packages in the `optionalDependencies` object. Note that if these packages fail to install, the overall installation process is not aborted. +### otp + +* Default: null +* Type: Number + +This is a one-time password from a two-factor authenticator. It's needed +when publishing or changing package permissions with `npm access`. + ### package-lock * Default: true @@ -773,6 +788,13 @@ A proxy to use for outgoing http requests. If the `HTTP_PROXY` or `http_proxy` environment variables are set, proxy settings will be honored by the underlying `request` library. +### read-only + +* Default: false +* Type: Boolean + +This is used to mark a token as unable to publish when configuring limited access tokens with the `npm token create` command. + ### rebuild-bundle * Default: true diff --git a/lib/config/cmd-list.js b/lib/config/cmd-list.js index f2d5fab17..49c445a4f 100644 --- a/lib/config/cmd-list.js +++ b/lib/config/cmd-list.js @@ -74,6 +74,8 @@ var cmdList = [ 'team', 'deprecate', 'shrinkwrap', + 'token', + 'profile', 'help', 'help-search', diff --git a/lib/config/defaults.js b/lib/config/defaults.js index 3a566ee0f..35617fd63 100644 --- a/lib/config/defaults.js +++ b/lib/config/defaults.js @@ -128,6 +128,8 @@ Object.defineProperty(exports, 'defaults', {get: function () { cert: null, + cidr: null, + color: true, depth: Infinity, description: true, @@ -179,6 +181,7 @@ Object.defineProperty(exports, 'defaults', {get: function () { 'onload-script': false, only: null, optional: true, + otp: null, 'package-lock': true, parseable: false, 'prefer-offline': false, @@ -192,6 +195,7 @@ Object.defineProperty(exports, 'defaults', {get: function () { 'node/{node-version} ' + '{platform} ' + '{arch}', + 'read-only': false, 'rebuild-bundle': true, registry: 'https://registry.npmjs.org/', rollback: true, @@ -257,6 +261,7 @@ exports.types = { 'cache-max': Number, 'cache-min': Number, cert: [null, String], + cidr: [null, String, Array], color: ['always', Boolean], depth: Number, description: Boolean, @@ -309,6 +314,7 @@ exports.types = { only: [null, 'dev', 'development', 'prod', 'production'], optional: Boolean, 'package-lock': Boolean, + otp: Number, parseable: Boolean, 'prefer-offline': Boolean, 'prefer-online': Boolean, @@ -316,6 +322,7 @@ exports.types = { production: Boolean, progress: Boolean, proxy: [null, false, url], // allow proxy to be disabled explicitly + 'read-only': Boolean, 'rebuild-bundle': Boolean, registry: [null, url], rollback: Boolean, @@ -405,6 +412,7 @@ exports.shorthands = { m: ['--message'], p: ['--parseable'], porcelain: ['--parseable'], + readonly: ['--read-only'], g: ['--global'], S: ['--save'], D: ['--save-dev'], diff --git a/lib/profile.js b/lib/profile.js new file mode 100644 index 000000000..4238e1427 --- /dev/null +++ b/lib/profile.js @@ -0,0 +1,296 @@ +'use strict' +const profile = require('npm-profile') +const npm = require('./npm.js') +const log = require('npmlog') +const output = require('./utils/output.js') +const qw = require('qw') +const Table = require('cli-table2') +const ansistyles = require('ansistyles') +const Bluebird = require('bluebird') +const readUserInfo = require('./utils/read-user-info.js') +const qrcodeTerminal = require('qrcode-terminal') +const url = require('url') +const queryString = require('query-string') +const pulseTillDone = require('./utils/pulse-till-done.js') + +module.exports = profileCmd + +profileCmd.usage = + 'npm profile enable-2fa [auth-only|auth-and-writes]\n' + + 'npm profile disable-2fa\n' + + 'npm profile get []\n' + + 'npm profile set ' + +profileCmd.subcommands = qw`enable-2fa disable-2fa get set` + +profileCmd.completion = function (opts, cb) { + var argv = opts.conf.argv.remain + switch (argv[2]) { + case 'enable-2fa': + case 'enable-tfa': + if (argv.length === 3) { + return cb(null, qw`auth-and-writes auth-only`) + } else { + return cb(null, []) + } + case 'disable-2fa': + case 'disable-tfa': + case 'get': + case 'set': + return cb(null, []) + default: + return cb(new Error(argv[2] + ' not recognized')) + } +} + +function withCb (prom, cb) { + prom.then((value) => cb(null, value), cb) +} + +function profileCmd (args, cb) { + if (args.length === 0) return cb(new Error(profileCmd.usage)) + log.gauge.show('profile') + switch (args[0]) { + case 'enable-2fa': + case 'enable-tfa': + case 'enable2fa': + case 'enabletfa': + withCb(enable2fa(args.slice(1)), cb) + break + case 'disable-2fa': + case 'disable-tfa': + case 'disable2fa': + case 'disabletfa': + withCb(disable2fa(), cb) + break + case 'get': + withCb(get(args.slice(1)), cb) + break + case 'set': + withCb(set(args.slice(1)), cb) + break + default: + cb(new Error('Unknown profile command: ' + args[0])) + } +} + +function config () { + const conf = { + json: npm.config.get('json'), + parseable: npm.config.get('parseable'), + registry: npm.config.get('registry'), + otp: npm.config.get('otp') + } + conf.auth = npm.config.getCredentialsByURI(conf.registry) + if (conf.otp) conf.auth.otp = conf.otp + return conf +} + +const knownProfileKeys = qw` + name email ${'two factor auth'} fullname homepage + freenode twitter github created updated` + +function get (args) { + const tfa = 'two factor auth' + const conf = config() + return pulseTillDone.withPromise(profile.get(conf)).then((info) => { + if (!info.cidr_whitelist) delete info.cidr_whitelist + if (conf.json) { + output(JSON.stringify(info, null, 2)) + return + } + const cleaned = {} + knownProfileKeys.forEach((k) => { cleaned[k] = info[k] || '' }) + Object.keys(info).filter((k) => !(k in cleaned)).forEach((k) => { cleaned[k] = info[k] || '' }) + 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(/,/).map((arg) => arg.trim()).filter((arg) => arg !== '') + .map((arg) => cleaned[arg]) + .join('\t') + output(values) + } else { + if (conf.parseable) { + Object.keys(info).forEach((key) => { + if (key === 'tfa') { + output(`${key}\t${cleaned[tfa]}`) + } else { + output(`${key}\t${info[key]}`) + } + }) + return + } else { + const table = new Table() + Object.keys(cleaned).forEach((k) => table.push({[ansistyles.bright(k)]: cleaned[k]})) + output(table.toString()) + } + } + }) +} + +const writableProfileKeys = qw` + email password fullname homepage freenode twitter github` + +function set (args) { + const conf = config() + const prop = (args[0] || '').toLowerCase().trim() + let value = args.length > 1 ? args.slice(1).join(' ') : null + if (prop !== 'password' && value === null) { + return Promise.reject(Error('npm profile set ')) + } + if (prop === 'password' && value !== null) { + return Promise.reject(Error( + 'npm profile set password\n' + + 'Do not include your current or new passwords on the command line.')) + } + if (writableProfileKeys.indexOf(prop) === -1) { + return Promise.reject(Error(`"${prop}" is not a property we can set. Valid properties are: ` + writableProfileKeys.join(', '))) + } + return Bluebird.try(() => { + if (prop !== 'password') return + return readUserInfo.password('Current password: ').then((current) => { + return readPasswords().then((newpassword) => { + value = {old: current, new: newpassword} + }) + }) + function readPasswords () { + return readUserInfo.password('New password: ').then((password1) => { + return readUserInfo.password(' Again: ').then((password2) => { + if (password1 !== password2) { + log.warn('profile', 'Passwords do not match, please try again.') + return readPasswords() + } + return password1 + }) + }) + } + }).then(() => { + // FIXME: Work around to not clear everything other than what we're setting + return pulseTillDone.withPromise(profile.get(conf).then((user) => { + const newUser = {} + writableProfileKeys.forEach((k) => { newUser[k] = user[k] }) + newUser[prop] = value + return profile.set(newUser, conf).catch((err) => { + if (err.code !== 'EOTP') throw err + return readUserInfo.otp('Enter OTP: ').then((otp) => { + conf.auth.otp = otp + return profile.set(newUser, conf) + }) + }).then((result) => { + if (conf.json) { + output(JSON.stringify({[prop]: result[prop]}, null, 2)) + } else if (conf.parseable) { + output(prop + '\t' + result[prop]) + } else { + output('Set', prop, 'to', result[prop]) + } + }) + })) + }) +} + +function enable2fa (args) { + if (args.length > 1) { + return Promise.reject(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') { + return Promise.reject(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')) + } + const conf = config() + if (conf.json || conf.parseable) { + return Promise.reject(new Error( + 'Enabling two-factor authentication is an interactive opperation and ' + + (conf.json ? 'JSON' : 'parseable') + 'output mode is not available')) + } + log.notice('profile', 'Enabling two factor authentication for ' + mode) + const info = { + tfa: { + mode: mode + } + } + return readUserInfo.password().then((password) => { + info.tfa.password = password + log.info('profile', 'Determine if tfa is pending') + return pulseTillDone.withPromise(profile.get(conf)).then((info) => { + if (!info.tfa) return + if (info.tfa.pending) { + log.info('profile', 'Resetting two-factor authentication') + return pulseTillDone.withPromise(profile.set({tfa: {password, mode: 'disable'}}, conf)) + } else { + if (conf.auth.otp) return + return readUserInfo.otp('Enter OTP: ').then((otp) => { + conf.auth.otp = otp + }) + } + }) + }).then(() => { + log.info('profile', 'Setting two factor authentication to ' + mode) + return pulseTillDone.withPromise(profile.set(info, conf)) + }).then((challenge) => { + if (challenge.tfa === null) { + output('Two factor authentication mode changed to: ' + mode) + return + } + if (typeof challenge.tfa !== 'string' || !/^otpauth:[/][/]/.test(challenge.tfa)) { + throw new Error('Unknown error enabling two-factor authentication. Expected otpauth URL, got: ' + challenge.tfa) + } + const otpauth = url.parse(challenge.tfa) + const opts = queryString.parse(otpauth.query) + return qrcode(challenge.tfa).then((code) => { + output('Scan into your authenticator app:\n' + code + '\n Or enter code:', opts.secret) + }).then((code) => { + return readUserInfo.otp('And an OTP code from your authenticator: ') + }).then((otp1) => { + log.info('profile', 'Finalizing two factor authentication') + return profile.set({tfa: [otp1]}, conf) + }).then((result) => { + output('TFA 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.') + result.tfa.forEach((c) => output('\t' + c)) + }) + }) +} + +function disable2fa (args) { + const conf = config() + return pulseTillDone.withPromise(profile.get(conf)).then((info) => { + if (!info.tfa || info.tfa.pending) { + output('Two factor authentication not enabled.') + return + } + return readUserInfo.password().then((password) => { + return Bluebird.try(() => { + if (conf.auth.otp) return + return readUserInfo.otp('Enter one-time password from your authenticator: ').then((otp) => { + conf.auth.otp = otp + }) + }).then(() => { + log.info('profile', 'disabling tfa') + return pulseTillDone.withPromise(profile.set({tfa: {password: password, mode: 'disable'}}, conf)).then(() => { + if (conf.json) { + output(JSON.stringify({tfa: false}, null, 2)) + } else if (conf.parseable) { + output('tfa\tfalse') + } else { + output('Two factor authentication disabled.') + } + }) + }) + }) + }) +} + +function qrcode (url) { + return new Promise((resolve) => qrcodeTerminal.generate(url, resolve)) +} diff --git a/lib/token.js b/lib/token.js new file mode 100644 index 000000000..a182b633d --- /dev/null +++ b/lib/token.js @@ -0,0 +1,211 @@ +'use strict' +const profile = require('npm-profile') +const npm = require('./npm.js') +const output = require('./utils/output.js') +const Table = require('cli-table2') +const Bluebird = require('bluebird') +const isCidrV4 = require('is-cidr').isCidrV4 +const isCidrV6 = require('is-cidr').isCidrV6 +const readUserInfo = require('./utils/read-user-info.js') +const ansistyles = require('ansistyles') +const log = require('npmlog') +const pulseTillDone = require('./utils/pulse-till-done.js') + +module.exports = token + +token.usage = + 'npm token list\n' + + 'npm token delete \n' + + 'npm token create [--read-only] [--cidr=list]\n' + +token.subcommands = ['list', 'delete', 'create'] + +token.completion = function (opts, cb) { + var argv = opts.conf.argv.remain + + switch (argv[2]) { + case 'list': + case 'delete': + case 'create': + return cb(null, []) + default: + return cb(new Error(argv[2] + ' not recognized')) + } +} + +function withCb (prom, cb) { + prom.then((value) => cb(null, value), cb) +} + +function token (args, cb) { + log.gauge.show('token') + if (args.length === 0) return withCb(list([]), cb) + switch (args[0]) { + case 'list': + case 'ls': + withCb(list(), cb) + break + case 'delete': + case 'rel': + case 'remove': + case 'rm': + withCb(rm(args.slice(1)), cb) + break + case 'create': + withCb(create(args.slice(1)), cb) + break + default: + cb(new Error('Unknown profile command: ' + args[0])) + } +} + +function generateTokenIds (tokens, minLength) { + const byId = {} + tokens.forEach((token) => { + token.id = token.key + for (let ii = minLength; ii < token.key.length; ++ii) { + if (!tokens.some((ot) => ot !== token && ot.key.slice(0, ii) === token.key.slice(0, ii))) { + token.id = token.key.slice(0, ii) + break + } + } + byId[token.id] = token + }) + return byId +} + +function config () { + const conf = { + json: npm.config.get('json'), + parseable: npm.config.get('parseable'), + registry: npm.config.get('registry'), + otp: npm.config.get('otp') + } + conf.auth = npm.config.getCredentialsByURI(conf.registry) + if (conf.otp) conf.auth.otp = conf.otp + return conf +} + +function list (args) { + const conf = config() + log.info('token', 'getting list') + return pulseTillDone.withPromise(profile.listTokens(conf)).then((tokens) => { + if (conf.json) { + output(JSON.stringify(tokens, null, 2)) + return + } else if (conf.parseable) { + output(['key', 'token', 'created', 'readonly', 'CIDR whitelist'].join('\t')) + tokens.forEach((token) => { + output([ + token.key, + token.token, + token.created, + token.readonly ? 'true' : 'false', + token.cidr_whitelist ? token.cidr_whitelist.join(',') : '' + ].join('\t')) + }) + return + } + generateTokenIds(tokens, 6) + const idWidth = tokens.reduce((acc, token) => Math.max(acc, token.id.length), 0) + const table = new Table({ + head: ['id', 'token', 'created', 'readonly', 'CIDR whitelist'], + colWidths: [Math.max(idWidth, 2) + 2, 9, 12, 10] + }) + tokens.forEach((token) => { + table.push([ + token.id, + token.token + '…', + String(token.created).slice(0, 10), + token.readonly ? 'yes' : 'no', + token.cidr_whitelist ? token.cidr_whitelist.join(', ') : '' + ]) + }) + output(table.toString()) + }) +} + +function rm (args) { + if (args.length === 0) { + throw new Error('npm token delete ') + } + const conf = config() + const toRemove = [] + const progress = log.newItem('removing tokens', toRemove.length) + progress.info('token', 'getting existing list') + return pulseTillDone.withPromise(profile.listTokens(conf).then((tokens) => { + args.forEach((id) => { + const matches = tokens.filter((token) => token.key.indexOf(id) === 0) + if (matches.length === 1) { + toRemove.push(matches[0].key) + } else if (matches.length > 1) { + throw new Error(`Token ID "${id}" was ambiguous, a new token may have been created since you last ran \`npm-profile token list\`.`) + } else { + const tokenMatches = tokens.filter((token) => id.indexOf(token.token) === 0) + if (tokenMatches === 0) { + throw new Error(`Unknown token id or value "${id}".`) + } + toRemove.push(id) + } + }) + return Bluebird.map(toRemove, (key) => { + progress.info('token', 'removing', key) + profile.removeToken(key, conf).then(() => profile.completeWork(1)) + }) + })).then(() => { + if (conf.json) { + output(JSON.stringify(toRemove)) + } else if (conf.parseable) { + output(toRemove.join('\t')) + } else { + output('Removed ' + toRemove.length + ' token' + (toRemove.length !== 1 ? 's' : '')) + } + }) +} + +function create (args) { + const conf = config() + const cidr = npm.config.get('cidr') + const readonly = npm.config.get('read-only') + + const validCIDR = validateCIDRList(cidr) + return readUserInfo.password().then((password) => { + log.info('token', 'creating') + return profile.createToken(password, readonly, validCIDR, conf).catch((ex) => { + if (ex.code !== 'EOTP') throw ex + log.info('token', 'failed because it requires OTP') + return readUserInfo.otp('Authenticator provided OTP:').then((otp) => { + conf.auth.otp = otp + log.info('token', 'creating with OTP') + return pulseTillDone.withPromise(profile.createToken(password, readonly, validCIDR, conf)) + }) + }) + }).then((result) => { + delete result.key + delete result.updated + if (conf.json) { + output(JSON.stringify(result)) + } else if (conf.parseable) { + Object.keys(result).forEach((k) => output(k + '\t' + result[k])) + } else { + const table = new Table() + Object.keys(result).forEach((k) => table.push({[ansistyles.bright(k)]: String(result[k])})) + output(table.toString()) + } + }) +} + +function validateCIDR (cidr) { + if (isCidrV6(cidr)) { + throw new Error('CIDR whitelist can only contain IPv4 addresses, ' + cidr + ' is IPv6') + } + if (!isCidrV4(cidr)) { + throw new Error('CIDR whitelist contains invalid CIDR entry: ' + cidr) + } +} + +function validateCIDRList (cidrs) { + const list = Array.isArray(cidrs) ? cidrs : cidrs ? cidrs.split(/,\s*/) : [] + list.forEach(validateCIDR) + return list +} diff --git a/lib/utils/pulse-till-done.js b/lib/utils/pulse-till-done.js index 266924130..b292c2fa5 100644 --- a/lib/utils/pulse-till-done.js +++ b/lib/utils/pulse-till-done.js @@ -1,22 +1,38 @@ 'use strict' -var validate = require('aproba') -var log = require('npmlog') +const validate = require('aproba') +const log = require('npmlog') +const Bluebird = require('bluebird') -var pulsers = 0 -var pulse +let pulsers = 0 +let pulse + +function pulseStart (prefix) { + if (++pulsers > 1) return + pulse = setInterval(function () { + log.gauge.pulse(prefix) + }, 150) +} +function pulseStop () { + if (--pulsers > 0) return + clearInterval(pulse) +} module.exports = function (prefix, cb) { validate('SF', [prefix, cb]) if (!prefix) prefix = 'network' - if (!pulsers++) { - pulse = setInterval(function () { - log.gauge.pulse(prefix) - }, 250) - } + pulseStart(prefix) return function () { - if (!--pulsers) { - clearInterval(pulse) - } + pulseStop() cb.apply(null, arguments) } } +module.exports.withPromise = pulseWhile + +function pulseWhile (prefix, promise) { + if (!promise) { + promise = prefix + prefix = '' + } + pulseStart(prefix) + return Bluebird.resolve(promise).finally(() => pulseStop()) +} diff --git a/lib/utils/read-user-info.js b/lib/utils/read-user-info.js new file mode 100644 index 000000000..359432cf7 --- /dev/null +++ b/lib/utils/read-user-info.js @@ -0,0 +1,66 @@ +'use strict' +const Bluebird = require('bluebird') +const readAsync = Bluebird.promisify(require('read')) +const userValidate = require('npm-user-validate') +const log = require('npmlog') + +exports.otp = readOTP +exports.password = readPassword +exports.username = readUsername +exports.email = readEmail + +function read (opts) { + return Bluebird.try(() => { + log.clearProgress() + return readAsync(opts) + }).finally(() => { + log.showProgress() + }) +} + +function readOTP (msg, otp, isRetry) { + if (!msg) msg = 'Enter OTP: ' + if (isRetry && otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) return otp.replace(/\s+/g, '') + + return read({prompt: msg, default: otp || ''}) + .then((otp) => readOTP(msg, otp, true)) +} + +function readPassword (msg, password, isRetry) { + if (!msg) msg = 'npm password: ' + if (isRetry && password) return password + + return read({prompt: msg, silent: true, default: password || ''}) + .then((password) => readPassword(msg, password, true)) +} + +function readUsername (msg, username, opts, isRetry) { + if (!msg) msg = 'npm username: ' + if (isRetry && username) { + const error = userValidate.username(username) + if (error) { + opts.log && opts.log.warn(error.message) + } else { + return Promise.resolve(username.trim()) + } + } + + return read({prompt: msg, default: username || ''}) + .then((username) => readUsername(msg, username, opts, true)) +} + +function readEmail (msg, email, opts, isRetry) { + if (!msg) msg = 'email (this IS public): ' + if (isRetry && email) { + const error = userValidate.email(email) + if (error) { + opts.log && opts.log.warn(error.message) + } else { + return email.trim() + } + } + + return read({prompt: msg, default: email || ''}) + .then((username) => readEmail(msg, username, opts, true)) +} + -- cgit v1.2.3