From 47a5c69c26b6670c10bc0787f5a42804fc9268bb Mon Sep 17 00:00:00 2001 From: Matheus Fernandes Date: Tue, 14 Feb 2017 10:35:28 -0300 Subject: [PATCH] Add `now billing` and `now upgrade` (#309) * Add the skeleton of `now cc` * Add the `ls` command * Add `inquirer` dependency * Add the `set-default` command * Fix typo * Show the real number of cards when selecting the default one * Add the `ls` command * Fix: Do not throw if there's no cards in the account * Add `blessed` dependency * Add the first sketch of `now cc add` * Add instructions * Add labels * Save every element in the `elements` array instead of variables * Tweaks * Fix: update the element attribute if it's not a special case * Add the `name` input; Add moving between inputs; Make the state more reliable * Auto "detect" if the input is losing focus * Remove useless stuff * Add the ability to move between the fields with tab/shift+tab * Add CCV field * Make the cycling between the fields "infinite" * Add expiration date field and allow only numbers in the CCV field * The form shouldn't have a fixed height * Add the address box and label * Add the address fields * Remove blessed stuff * Add preliminary input field * output utils * add prompt for booleans * fix @matheuss linting problems * remove example * lint * error and info helpers * helper for embedded commands * Remove useless stuff * Add `trailing` option * Add `resolveChars` option * Add `validate` option * Add `strip-ansi` dependency * Add `credit-card` dependency * Add credit card masking * Add support for expiration date mask * Make things simpler * Add auto completion support * Always show the `card_` id prefix * Add `@google/maps` dependency * Always print the initial value if it's available * Add `stripe` dependency * Add `add()` method * Add billing related utils * Add `now cc add` * Rename `cc` to `billing` * Fix: log only one blank line * Refactor * Add list input component * This shouldn't be here * Add `code` output util * Add `now upgrade | downgrade` * add build step * make it more future-proof * more reliable build * remove lock for now * Hide the CCV * Print the new line before `Saving card` * Use the new `success` component * Add confirmation steps for `cc rm` and `cc set-default` * Temporarily monket patch Inquirer * Build before testing * Run the tests using the built files * Fix the `prepublish` script and run the `build` one before packaging * Improve `now help` --- .gitignore | 1 + bin/now-billing-add.js | 203 ++++++++++++++++++ bin/now-billing.js | 315 ++++++++++++++++++++++++++++ bin/now-deploy.js | 29 ++- bin/now-upgrade.js | 207 ++++++++++++++++++ bin/now.js | 18 +- build.sh | 10 + lib/credit-cards.js | 70 +++++++ lib/plans.js | 48 +++++ lib/utils/billing/card-brands.json | 8 + lib/utils/billing/country-list.json | 251 ++++++++++++++++++++++ lib/utils/billing/geocode.js | 27 +++ lib/utils/input/list.js | 86 ++++++++ lib/utils/input/text.js | 207 ++++++++++++++++++ lib/utils/output/cmd.js | 8 + lib/utils/output/code.js | 8 + lib/utils/output/error.js | 7 + lib/utils/output/info.js | 7 + lib/utils/output/param.js | 8 + lib/utils/output/prompt-bool.js | 54 +++++ lib/utils/output/stamp.js | 12 ++ lib/utils/output/success.js | 6 + lib/utils/output/uid.js | 7 + lib/utils/output/wait.js | 15 ++ package.json | 18 +- test/args-parsing.js | 2 +- test/index.js | 6 +- test/to-host.js | 2 +- 28 files changed, 1609 insertions(+), 31 deletions(-) create mode 100644 bin/now-billing-add.js create mode 100644 bin/now-billing.js create mode 100644 bin/now-upgrade.js create mode 100755 build.sh create mode 100644 lib/credit-cards.js create mode 100644 lib/plans.js create mode 100644 lib/utils/billing/card-brands.json create mode 100644 lib/utils/billing/country-list.json create mode 100644 lib/utils/billing/geocode.js create mode 100644 lib/utils/input/list.js create mode 100644 lib/utils/input/text.js create mode 100644 lib/utils/output/cmd.js create mode 100644 lib/utils/output/code.js create mode 100644 lib/utils/output/error.js create mode 100644 lib/utils/output/info.js create mode 100644 lib/utils/output/param.js create mode 100644 lib/utils/output/prompt-bool.js create mode 100644 lib/utils/output/stamp.js create mode 100644 lib/utils/output/success.js create mode 100644 lib/utils/output/uid.js create mode 100644 lib/utils/output/wait.js diff --git a/.gitignore b/.gitignore index e950a29..aa4b643 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ node_modules # logs npm-debug.log +build diff --git a/bin/now-billing-add.js b/bin/now-billing-add.js new file mode 100644 index 0000000..ac8508e --- /dev/null +++ b/bin/now-billing-add.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +const ansiEscapes = require('ansi-escapes') +const chalk = require('chalk') +const ccValidator = require('credit-card') + +const textInput = require('../lib/utils/input/text') +const countries = require('../lib/utils/billing/country-list') +const cardBrands = require('../lib/utils/billing/card-brands') +const geocode = require('../lib/utils/billing/geocode') +const success = require('../lib/utils/output/success') +const wait = require('../lib/utils/output/wait') + +function rightPad(string, n = 12) { + n -= string.length + return string + ' '.repeat(n > -1 ? n : 0) +} + +function expDateMiddleware(data) { + return data +} + +module.exports = function (creditCards) { + const state = { + error: undefined, + cardGroupLabel: `> ${chalk.bold('Enter your card details')}`, + + name: { + label: rightPad('Name'), + placeholder: 'John Appleseed', + validateValue: data => data.trim().length > 0 + }, + + cardNumber: { + label: rightPad('Number'), + mask: 'cc', + placeholder: '#### #### #### ####', + validateKeypress: (data, value) => ( + /\d/.test(data) && value.length < 19 + ), + validateValue: data => { + data = data.replace(/ /g, '') + const type = ccValidator.determineCardType(data) + if (!type) { + return false + } + return ccValidator.isValidCardNumber(data, type) + } + }, + + ccv: { + label: rightPad('CCV'), + mask: 'ccv', + placeholder: '###', + validateValue: data => { + const brand = state.cardNumber.brand.toLowerCase() + return ccValidator.doesCvvMatchType(data, brand) + } + }, + + expDate: { + label: rightPad('Exp. Date'), + mask: 'expDate', + placeholder: 'mm / yyyy', + middleware: expDateMiddleware, + validateValue: data => !ccValidator.isExpired(...data.split(' / ')) + }, + + addressGroupLabel: `\n> ${chalk.bold('Enter your billing address')}`, + + country: { + label: rightPad('Country'), + async autoComplete(value) { + for (const country in countries) { + if (!Object.hasOwnProperty.call(countries, country)) { + continue + } + if (country.startsWith(value)) { + return country.substr(value.length) + } + } + return false + }, + validateValue: value => countries[value] !== undefined + }, + + zipCode: { + label: rightPad('ZIP'), + validadeKeypress: data => data.trim().length > 0, + validateValue: data => data.trim().length > 0 + }, + + state: { + label: rightPad('State'), + validateValue: data => data.trim().length > 0 + }, + + city: { + label: rightPad('City'), + validateValue: data => data.trim().length > 0 + }, + + address1: { + label: rightPad('Address'), + validateValue: data => data.trim().length > 0 + } + } + + async function render() { + for (const key in state) { + if (!Object.hasOwnProperty.call(state, key)) { + continue + } + const piece = state[key] + if (typeof piece === 'string') { + console.log(piece) + } else if (typeof piece === 'object') { + let result + try { + result = await textInput({ + label: '- ' + piece.label, + initialValue: piece.initialValue || piece.value, + placeholder: piece.placeholder, + mask: piece.mask, + validateKeypress: piece.validateKeypress, + validateValue: piece.validateValue, + autoComplete: piece.autoComplete + }) + piece.value = result + if (key === 'cardNumber') { + let brand = cardBrands[ccValidator.determineCardType(result)] + piece.brand = brand + if (brand === 'American Express') { + state.ccv.placeholder = '#'.repeat(4) + } else { + state.ccv.placeholder = '#'.repeat(3) + } + brand = chalk.cyan(`[${brand}]`) + process.stdout.write( + `${chalk.cyan('✓')} ${piece.label}${result} ${brand}\n` + ) + } else if (key === 'ccv') { + process.stdout.write( + `${chalk.cyan('✓')} ${piece.label}${'*'.repeat(result.length)}\n` + ) + } else if (key === 'expDate') { + let text = result.split(' / ') + text = text[0] + chalk.gray(' / ') + text[1] + process.stdout.write(`${chalk.cyan('✓')} ${piece.label}${text}\n`) + } else if (key === 'zipCode') { + const stopSpinner = wait(piece.label + result) + const addressInfo = await geocode({ + country: state.country.value, + zipCode: result + }) + if (addressInfo.state) { + state.state.initialValue = addressInfo.state + } + if (addressInfo.city) { + state.city.initialValue = addressInfo.city + } + stopSpinner() + process.stdout.write(`${chalk.cyan('✓')} ${piece.label}${result}\n`) + } else { + process.stdout.write(`${chalk.cyan('✓')} ${piece.label}${result}\n`) + } + } catch (err) { + if (err.message === 'USER_ABORT') { + process.exit(1) + } else { + console.error(err) + } + } + } + } + console.log('') // new line + const stopSpinner = wait('Saving card') + + try { + const res = await creditCards.add({ + name: state.name.value, + cardNumber: state.cardNumber.value, + ccv: state.ccv.value, + expDate: state.expDate.value, + country: state.country.value, + zipCode: state.zipCode.value, + state: state.state.value, + city: state.city.value, + address1: state.address1.value + }) + stopSpinner() + success(`${state.cardNumber.brand} ending in ${res.last4} was added to your account`) + } catch (err) { + stopSpinner() + const linesToClear = state.error ? 13 : 12 + process.stdout.write(ansiEscapes.eraseLines(linesToClear)) + state.error = `${chalk.red('> Error!')} ${err.message} Please make sure the info is correct` + await render() + } + } + + render().catch(console.error) +} diff --git a/bin/now-billing.js b/bin/now-billing.js new file mode 100644 index 0000000..d0bc056 --- /dev/null +++ b/bin/now-billing.js @@ -0,0 +1,315 @@ +#!/usr/bin/env node + +// Native +const {resolve} = require('path') + +// Packages +const chalk = require('chalk') +const minimist = require('minimist') +const ms = require('ms') + +// Ours +const login = require('../lib/login') +const cfg = require('../lib/cfg') +const {error} = require('../lib/error') +const NowCreditCards = require('../lib/credit-cards') +const indent = require('../lib/indent') +const listInput = require('../lib/utils/input/list') +const success = require('../lib/utils/output/success') +const promptBool = require('../lib/utils/output/prompt-bool') + +const argv = minimist(process.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't' + } +}) + +const subcommand = argv._[0] + +const help = () => { + console.log(` + ${chalk.bold('𝚫 now billing')} + + ${chalk.dim('Options:')} + + -h, --help Output usage information + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline('FILE')} Config file + -d, --debug Debug mode [off] + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline('TOKEN')} Login token + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Lists all your credit cards: + + ${chalk.cyan('$ now billing ls')} + + ${chalk.gray('–')} Adds a credit card (interactively): + + ${chalk.cyan(`$ now billing add`)} + + ${chalk.gray('–')} Removes a credit card: + + ${chalk.cyan(`$ now billing rm `)} + + ${chalk.gray('–')} If the id is ommitted, you can choose interactively + + ${chalk.gray('–')} Selects your default credit card: + + ${chalk.cyan(`$ now billing set-default `)} + + ${chalk.gray('–')} If the id is ommitted, you can choose interactively + `) +} + +// options +const debug = argv.debug +const apiUrl = argv.url || 'https://api.zeit.co' + +if (argv.config) { + cfg.setConfigFile(argv.config) +} + +const exit = code => { + // we give stdout some time to flush out + // because there's a node bug where + // stdout writes are asynchronous + // https://github.com/nodejs/node/issues/6456 + setTimeout(() => process.exit(code || 0), 100) +} + +if (argv.help || !subcommand) { + help() + exit(0) +} else { + const config = cfg.read() + + Promise.resolve(argv.token || config.token || login(apiUrl)) + .then(async token => { + try { + await run(token) + } catch (err) { + if (err.userError) { + error(err.message) + } else { + error(`Unknown error: ${err.stack}`) + } + exit(1) + } + }) + .catch(e => { + error(`Authentication error – ${e.message}`) + exit(1) + }) +} + +// Builds a `choices` object that can be passesd to inquirer.prompt() +function buildInquirerChoices(cards) { + return cards.cards.map(card => { + const _default = card.id === cards.defaultCardId ? ' ' + chalk.bold('(default)') : '' + const id = `${chalk.cyan(`ID: ${card.id}`)}${_default}` + const number = `${chalk.gray('#### ').repeat(3)}${card.last4}` + const str = [ + id, + indent(card.name, 2), + indent(`${card.brand} ${number}`, 2) + ].join('\n') + + return { + name: str, // Will be displayed by Inquirer + value: card.id, // Will be used to identify the answer + short: card.id // Will be displayed after the users answers + } + }) +} + +async function run(token) { + const start = new Date() + const creditCards = new NowCreditCards(apiUrl, token, {debug}) + const args = argv._.slice(1) + + switch (subcommand) { + case 'ls': + case 'list': { + const cards = await creditCards.ls() + const text = cards.cards.map(card => { + const _default = card.id === cards.defaultCardId ? ' ' + chalk.bold('(default)') : '' + const id = `${chalk.gray('-')} ${chalk.cyan(`ID: ${card.id}`)}${_default}` + const number = `${chalk.gray('#### ').repeat(3)}${card.last4}` + let address = card.address_line1 + + if (card.address_line2) { + address += `, ${card.address_line2}.` + } else { + address += '.' + } + + address += `\n${card.address_city}, ` + + if (card.address_state) { + address += `${card.address_state}, ` + } + + // TODO: Stripe is returning a two digit code for the country, + // but we want the full country name + address += `${card.address_zip}. ${card.address_country}` + + return [ + id, + indent(card.name, 2), + indent(`${card.brand} ${number}`, 2), + indent(address, 2) + ].join('\n') + }).join('\n\n') + + const elapsed = ms(new Date() - start) + console.log(`> ${cards.cards.length} card${cards.cards.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}`) + if (text) { + console.log(`\n${text}\n`) + } + + break + } + + case 'set-default': { + if (args.length > 1) { + error('Invalid number of arguments') + return exit(1) + } + + const start = new Date() + const cards = await creditCards.ls() + + if (cards.cards.length === 0) { + error('You have no credit cards to choose from') + return exit(0) + } + + let cardId = args[0] + + if (cardId === undefined) { + const elapsed = ms(new Date() - start) + const message = `Selecting a new default payment card from ${cards.cards.length} total ${chalk.gray(`[${elapsed}]`)}` + const choices = buildInquirerChoices(cards) + + cardId = await listInput({ + message, + choices, + separator: true, + abort: 'end' + }) + } + + // TODO: check if the provided cardId (in case the user + // typed `now billing set-default `) is valid + if (cardId) { + const label = `Are you sure that you to set this card as the default?` + const confirmation = await promptBool(label) + console.log('') // new line + if (!confirmation) { + console.log('Aborted') + break + } + const start = new Date() + await creditCards.setDefault(cardId) + + const card = cards.cards.find(card => card.id === cardId) + const elapsed = ms(new Date() - start) + success(`${card.brand} ending in ${card.last4} is now the default ${chalk.gray(`[${elapsed}]`)}`) + } else { + console.log('No changes made') + } + + break + } + + case 'rm': + case 'remove': { + if (args.length > 1) { + error('Invalid number of arguments') + return exit(1) + } + + const start = new Date() + const cards = await creditCards.ls() + + if (cards.cards.length === 0) { + error('You have no credit cards to choose from to delete') + return exit(0) + } + + let cardId = args[0] + + if (cardId === undefined) { + const elapsed = ms(new Date() - start) + const message = `Selecting a card to ${chalk.underline('remove')} from ${cards.cards.length} total ${chalk.gray(`[${elapsed}]`)}` + const choices = buildInquirerChoices(cards) + + cardId = await listInput({ + message, + choices, + separator: true, + abort: 'start' + }) + } + + // TODO: check if the provided cardId (in case the user + // typed `now billing rm `) is valid + if (cardId) { + const label = `Are you sure that you want to remove this card?` + const confirmation = await promptBool(label) + console.log('') // new line + if (!confirmation) { + console.log('Aborted') + break + } + const start = new Date() + await creditCards.rm(cardId) + + const deletedCard = cards.cards.find(card => card.id === cardId) + const remainingCards = cards.cards.filter(card => card.id !== cardId) + + let text = `${deletedCard.brand} ending in ${deletedCard.last4} was deleted` + // ${chalk.gray(`[${elapsed}]`)} + + if (cardId === cards.defaultCardId) { + if (remainingCards.length === 0) { + // The user deleted the last card in their account + text += `\n${chalk.yellow('Warning!')} You have no default card` + } else { + // We can't guess the current default card – let's ask the API + const cards = await creditCards.ls() + const newDefaultCard = cards.cards.find(card => card.id === cards.defaultCardId) + + text += `\n${newDefaultCard.brand} ending in ${newDefaultCard.last4} in now default` + } + } + + const elapsed = ms(new Date() - start) + text += ` ${chalk.gray(`[${elapsed}]`)}` + success(text) + } else { + console.log('No changes made') + } + + break + } + + case 'add': { + require(resolve(__dirname, 'now-billing-add.js'))(creditCards) + + break + } + + default: + error('Please specify a valid subcommand: ls | add | rm | set-default') + help() + exit(1) + } + + creditCards.close() +} diff --git a/bin/now-deploy.js b/bin/now-deploy.js index 2fc6f60..c789330 100755 --- a/bin/now-deploy.js +++ b/bin/now-deploy.js @@ -73,17 +73,24 @@ const help = () => { console.log(` ${chalk.bold('𝚫 now')} [options] - ${chalk.dim('Commands:')} - - deploy [path] Performs a deployment ${chalk.bold('(default)')} - ls | list [app] List deployments - rm | remove [id] Remove a deployment - ln | alias [id] [url] Configures aliases for deployments - domains [name] Manages your domain names - certs [cmd] Manages your SSL certificates - secrets [name] Manages your secret environment variables - dns [name] Manages your DNS records - help [cmd] Displays complete help for [cmd] + ${chalk.dim('Commands')} + + ${chalk.dim('Cloud')} + + deploy [path] Performs a deployment ${chalk.bold('(default)')} + ls | list [app] List deployments + rm | remove [id] Remove a deployment + ln | alias [id] [url] Configures aliases for deployments + domains [name] Manages your domain names + certs [cmd] Manages your SSL certificates + secrets [name] Manages your secret environment variables + dns [name] Manages your DNS records + help [cmd] Displays complete help for [cmd] + + ${chalk.dim('Administrative')} + + billing | cc [cmd] Manages your credit cards and billing methods + upgrade | downgrade [plan] Upgrades or downgrades your plan ${chalk.dim('Options:')} diff --git a/bin/now-upgrade.js b/bin/now-upgrade.js new file mode 100644 index 0000000..2169330 --- /dev/null +++ b/bin/now-upgrade.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +// Packages +const chalk = require('chalk') +const minimist = require('minimist') +const ms = require('ms') +const stripAnsi = require('strip-ansi') + +// Ours +const login = require('../lib/login') +const cfg = require('../lib/cfg') +const NowPlans = require('../lib/plans') +const indent = require('../lib/indent') +const listInput = require('../lib/utils/input/list') +const code = require('../lib/utils/output/code') +const error = require('../lib/utils/output/error') +const success = require('../lib/utils/output/success') +const cmd = require('../lib/utils/output/cmd') + +const argv = minimist(process.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't' + } +}) + +const help = () => { + console.log(` + ${chalk.bold('𝚫 now upgrade')} [plan] + + ${chalk.dim('Options:')} + + -h, --help Output usage information + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline('FILE')} Config file + -d, --debug Debug mode [off] + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline('TOKEN')} Login token + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} List available plans and pick one interactively + + ${chalk.cyan('$ now upgrade')} + + ${chalk.yellow('NOTE:')} ${chalk.gray('Make sure you have a payment method, or add one:')} + + ${chalk.cyan(`$ now billing add`)} + + ${chalk.gray('–')} Pick a specific plan (premium): + + ${chalk.cyan(`$ now upgrade premium`)} + `) +} + +// options +const debug = argv.debug +const apiUrl = argv.url || 'https://api.zeit.co' + +if (argv.config) { + cfg.setConfigFile(argv.config) +} + +const exit = code => { + // we give stdout some time to flush out + // because there's a node bug where + // stdout writes are asynchronous + // https://github.com/nodejs/node/issues/6456 + setTimeout(() => process.exit(code || 0), 100) +} + +if (argv.help) { + help() + exit(0) +} else { + const config = cfg.read() + + Promise.resolve(argv.token || config.token || login(apiUrl)) + .then(async token => { + try { + await run(token) + } catch (err) { + if (err.userError) { + error(err.message) + } else { + error(`Unknown error: ${err.stack}`) + } + exit(1) + } + }) + .catch(e => { + error(`Authentication error – ${e.message}`) + exit(1) + }) +} + +function buildInquirerChoices(current, until) { + if (until) { + until = until.split(' ') + until = ' for ' + chalk.bold(until[0]) + ' more ' + until[1] + } else { + until = '' + } + const ossTitle = current === 'oss' ? + `oss FREE ${' '.repeat(28)} (current)` : + 'oss FREE' + const premiumTitle = current === 'premium' ? + `premium $15/mo ${' '.repeat(24 - stripAnsi(until).length)} (current${until})` : + 'premium $15/mo' + return [ + { + name: [ + ossTitle, + indent('✓ All code is public and open-source', 2), + indent('✓ 20 deploys per month | 1GB monthly bandwidth', 2), + indent('✓ 1GB FREE storage | 1MB size limit per file', 2) + ].join('\n'), + value: 'oss', + short: 'oss FREE' + }, + { + name: [ + premiumTitle, + indent('✓ All code is private and secure', 2), + indent('✓ 1000 deploys per month | 50GB monthly bandwidth', 2), + indent('✓ 100GB storage | No filesize limit', 2) + ].join('\n'), + value: 'premium', + short: 'premium $15/mo' + } + ] +} + +async function run(token) { + const args = argv._ + if (args.length > 1) { + error('Invalid number of arguments') + return exit(1) + } + + const start = new Date() + const plans = new NowPlans(apiUrl, token, {debug}) + + let planId = args[0] + + if (![undefined, 'oss', 'premium'].includes(planId)) { + error(`Invalid plan name – should be ${code('oss')} or ${code('premium')}`) + return exit(1) + } + + const currentPlan = await plans.getCurrent() + + if (planId === undefined) { + const elapsed = ms(new Date() - start) + + const message = `Selecting a plan for your account ${chalk.gray(`[${elapsed}]`)}` + const choices = buildInquirerChoices(currentPlan.id, currentPlan.until) + + planId = await listInput({ + message, + choices, + separator: true, + abort: 'end' + }) + } + + if (planId === undefined || (planId === currentPlan.id && currentPlan.until === undefined)) { + return console.log('No changes made') + } + + let newPlan + + try { + newPlan = await plans.set(planId) + } catch (err) { + let errorBody + if (err.res && err.res.status === 400) { + errorBody = err.res.json() + } else { + const message = 'A network error has occurred. Please retry.' + errorBody = {message} + } + + const _err = (await errorBody).error + const {code, message} = _err + + if (code === 'customer_not_found' || code === 'source_not_found') { + error(`You have no payment methods available. Run ${cmd('now billing add')} to add one`) + } else { + error(`An unknow error occured. Please try again later ${message}`) + } + plans.close() + return + } + + if (currentPlan.until && newPlan.id === 'premium') { + success(`The cancelation has been undone. You're back on the ${chalk.bold('Premium plan')}`) + } else if (newPlan.until) { + success(`Your plan will be switched to OSS in ${chalk.bold(newPlan.until)}. Your card will not be charged again`) + } else { + success(`You're now on the ${chalk.bold('Premium plan')}`) + } + + plans.close() +} diff --git a/bin/now.js b/bin/now.js index 48b818f..69dd736 100755 --- a/bin/now.js +++ b/bin/now.js @@ -12,14 +12,6 @@ const chalk = require('chalk') const {error} = require('../lib/error') const pkg = require('../package') -const pathSep = process.platform === 'win32' ? '\\\\' : '/' -// Support for keywords "async" and "await" -require('async-to-gen/register')({ - includes: new RegExp(`.*now(-cli)?${pathSep}(lib|bin).*`), - excludes: null, - sourceMaps: false -}) - // Throw an error if node version is too low if (nodeVersion.major < 6) { error('Now requires at least version 6 of Node. Please upgrade!') @@ -59,7 +51,11 @@ const commands = new Set([ 'cert', 'certs', 'secret', - 'secrets' + 'secrets', + 'cc', + 'billing', + 'upgrade', + 'downgrade' ]) const aliases = new Map([ @@ -69,7 +65,9 @@ const aliases = new Map([ ['aliases', 'alias'], ['domain', 'domains'], ['cert', 'certs'], - ['secret', 'secrets'] + ['secret', 'secrets'], + ['cc', 'billing'], + ['downgrade', 'upgrade'] ]) let cmd = defaultCommand diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..57c6889 --- /dev/null +++ b/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +rm -rf build +mkdir -p build/{lib,bin} +find lib/** -type d -exec mkdir -p build/{} \; +find bin/** -type d -exec mkdir -p build/{} \; +find lib/** -type f -exec node_modules/.bin/async-to-gen --out-file build/{} {} \; +find bin/** -type f -exec node_modules/.bin/async-to-gen --out-file build/{} {} \; +chmod +x build/bin/now.js +cp lib/utils/billing/*.json build/lib/utils/billing/ +cp package.json build/ diff --git a/lib/credit-cards.js b/lib/credit-cards.js new file mode 100644 index 0000000..cb1ce0e --- /dev/null +++ b/lib/credit-cards.js @@ -0,0 +1,70 @@ +const stripe = require('stripe')('pk_live_alyEi3lN0kSwbdevK0nrGwTw') + +const Now = require('../lib') + +module.exports = class CreditCards extends Now { + + async ls() { + const res = await this._fetch('/www/user/cards') + const body = await res.json() + + return body + } + + async setDefault(cardId) { + await this._fetch('/www/user/cards/default', { + method: 'PUT', + body: {cardId} + }) + return true + } + + async rm(cardId) { + await this._fetch(`/www/user/cards/${encodeURIComponent(cardId)}`, {method: 'DELEtE'}) + return true + } + + /* eslint-disable camelcase */ + add(card) { + return new Promise(async (resolve, reject) => { + const expDateParts = card.expDate.split(' / ') + card = { + name: card.name, + number: card.cardNumber, + cvc: card.ccv, + address_country: card.country, + address_zip: card.zipCode, + address_state: card.state, + address_city: card.city, + address_line1: card.address1 + } + + card.exp_month = expDateParts[0] + card.exp_year = expDateParts[1] + + try { + const stripeToken = (await stripe.tokens.create({card})).id + const res = await this._fetch('/www/user/cards', { + method: 'POST', + body: {stripeToken} + }) + + const body = await res.json() + + if (body.card && body.card.id) { + resolve({ + last4: body.card.last4 + }) + } else if (body.error && body.error.message) { + reject({message: body.error.message}) + } else { + reject('Unknown error') + } + } catch (err) { + reject({ + message: err.message || 'Unknown error' + }) + } + }) + } +} diff --git a/lib/plans.js b/lib/plans.js new file mode 100644 index 0000000..c884c7e --- /dev/null +++ b/lib/plans.js @@ -0,0 +1,48 @@ +const ms = require('ms') + +const Now = require('../lib') + +async function parsePlan(res) { + let id + let until + + const {subscription} = await res.json() + + if (subscription) { + id = subscription.plan.id + + if (subscription.cancel_at_period_end) { + until = ms( + new Date(subscription.current_period_end * 1000) - new Date(), + {long: true} + ) + } + } else { + id = 'oss' + } + + return {id, until} +} + +module.exports = class Plans extends Now { + + async getCurrent() { + const res = await this._fetch('/www/user/plan') + + return await parsePlan(res) + } + + async set(plan) { + const res = await this._fetch('/www/user/plan', { + method: 'PUT', + body: {plan} + }) + + if (res.ok) { + return await parsePlan(res) + } + const err = new Error(res.statusText) + err.res = res + throw err + } +} diff --git a/lib/utils/billing/card-brands.json b/lib/utils/billing/card-brands.json new file mode 100644 index 0000000..8bf68a3 --- /dev/null +++ b/lib/utils/billing/card-brands.json @@ -0,0 +1,8 @@ +{ + "VISA": "Visa", + "MASTERCARD": "MasterCard", + "AMERICANEXPRESS": "American Express", + "DINERSCLUB": "Diners Club", + "DISCOVER": "Discover", + "JCB": "JCB" +} diff --git a/lib/utils/billing/country-list.json b/lib/utils/billing/country-list.json new file mode 100644 index 0000000..605f6f3 --- /dev/null +++ b/lib/utils/billing/country-list.json @@ -0,0 +1,251 @@ +{ + "United States": "US", + "Afghanistan": "AF", + "Åland Islands": "AX", + "Albania": "AL", + "Algeria": "DZ", + "American Samoa": "AS", + "Andorra": "AD", + "Angola": "AO", + "Anguilla": "AI", + "Antarctica": "AQ", + "Antigua and Barbuda": "AG", + "Argentina": "AR", + "Armenia": "AM", + "Aruba": "AW", + "Australia": "AU", + "Austria": "AT", + "Azerbaijan": "AZ", + "Bahamas": "BS", + "Bahrain": "BH", + "Bangladesh": "BD", + "Barbados": "BB", + "Belarus": "BY", + "Belgium": "BE", + "Belize": "BZ", + "Benin": "BJ", + "Bermuda": "BM", + "Bhutan": "BT", + "Bolivia, Plurinational State of": "BO", + "Bonaire, Sint Eustatius and Saba": "BQ", + "Bosnia and Herzegovina": "BA", + "Botswana": "BW", + "Bouvet Island": "BV", + "Brazil": "BR", + "British Indian Ocean Territory": "IO", + "Brunei Darussalam": "BN", + "Bulgaria": "BG", + "Burkina Faso": "BF", + "Burundi": "BI", + "Cambodia": "KH", + "Cameroon": "CM", + "Canada": "CA", + "Cape Verde": "CV", + "Cayman Islands": "KY", + "Central African Republic": "CF", + "Chad": "TD", + "Chile": "CL", + "China": "CN", + "Christmas Island": "CX", + "Cocos (Keeling) Islands": "CC", + "Colombia": "CO", + "Comoros": "KM", + "Congo": "CG", + "CD": "Congo, the Democratic Republic of the", + "Cook Islands": "CK", + "Costa Rica": "CR", + "Côte d'Ivoire": "CI", + "Croatia": "HR", + "Cuba": "CU", + "Curaçao": "CW", + "Cyprus": "CY", + "Czech Republic": "CZ", + "Denmark": "DK", + "Djibouti": "DJ", + "Dominica": "DM", + "Dominican Republic": "DO", + "Ecuador": "EC", + "Egypt": "EG", + "El Salvador": "SV", + "Equatorial Guinea": "GQ", + "Eritrea": "ER", + "Estonia": "EE", + "Ethiopia": "ET", + "Falkland Islands (Malvinas)": "FK", + "Faroe Islands": "FO", + "Fiji": "FJ", + "Finland": "FI", + "France": "FR", + "French Guiana": "GF", + "French Polynesia": "PF", + "French Southern Territories": "TF", + "Gabon": "GA", + "Gambia": "GM", + "Georgia": "GE", + "Germany": "DE", + "Ghana": "GH", + "Gibraltar": "GI", + "Greece": "GR", + "Greenland": "GL", + "Grenada": "GD", + "Guadeloupe": "GP", + "Guam": "GU", + "Guatemala": "GT", + "Guernsey": "GG", + "Guinea": "GN", + "Guinea-Bissau": "GW", + "Guyana": "GY", + "Haiti": "HT", + "Heard Island and McDonald Islands": "HM", + "Holy See (Vatican City State)": "VA", + "Honduras": "HN", + "Hong Kong": "HK", + "Hungary": "HU", + "Iceland": "IS", + "India": "IN", + "Indonesia": "ID", + "Iran, Islamic Republic of": "IR", + "Iraq": "IQ", + "Ireland": "IE", + "Isle of Man": "IM", + "Israel": "IL", + "Italy": "IT", + "Jamaica": "JM", + "Japan": "JP", + "Jersey": "JE", + "Jordan": "JO", + "Kazakhstan": "KZ", + "Kenya": "KE", + "Kiribati": "KI", + "KP": "Korea, Democratic People's Republic of", + "Korea, Republic of": "KR", + "Kuwait": "KW", + "Kyrgyzstan": "KG", + "Lao People's Democratic Republic": "LA", + "Latvia": "LV", + "Lebanon": "LB", + "Lesotho": "LS", + "Liberia": "LR", + "Libya": "LY", + "Liechtenstein": "LI", + "Lithuania": "LT", + "Luxembourg": "LU", + "Macao": "MO", + "MK": "Macedonia, the former Yugoslav Republic of", + "Madagascar": "MG", + "Malawi": "MW", + "Malaysia": "MY", + "Maldives": "MV", + "Mali": "ML", + "Malta": "MT", + "Marshall Islands": "MH", + "Martinique": "MQ", + "Mauritania": "MR", + "Mauritius": "MU", + "Mayotte": "YT", + "Mexico": "MX", + "Micronesia, Federated States of": "FM", + "Moldova, Republic of": "MD", + "Monaco": "MC", + "Mongolia": "MN", + "Montenegro": "ME", + "Montserrat": "MS", + "Morocco": "MA", + "Mozambique": "MZ", + "Myanmar": "MM", + "Namibia": "NA", + "Nauru": "NR", + "Nepal": "NP", + "Netherlands": "NL", + "New Caledonia": "NC", + "New Zealand": "NZ", + "Nicaragua": "NI", + "Niger": "NE", + "Nigeria": "NG", + "Niue": "NU", + "Norfolk Island": "NF", + "Northern Mariana Islands": "MP", + "Norway": "NO", + "Oman": "OM", + "Pakistan": "PK", + "Palau": "PW", + "Palestinian Territory, Occupied": "PS", + "Panama": "PA", + "Papua New Guinea": "PG", + "Paraguay": "PY", + "Peru": "PE", + "Philippines": "PH", + "Pitcairn": "PN", + "Poland": "PL", + "Portugal": "PT", + "Puerto Rico": "PR", + "Qatar": "QA", + "Réunion": "RE", + "Romania": "RO", + "Russian Federation": "RU", + "Rwanda": "RW", + "Saint Barthélemy": "BL", + "SH": "Saint Helena, Ascension and Tristan da Cunha", + "Saint Kitts and Nevis": "KN", + "Saint Lucia": "LC", + "Saint Martin (French part)": "MF", + "Saint Pierre and Miquelon": "PM", + "Saint Vincent and the Grenadines": "VC", + "Samoa": "WS", + "San Marino": "SM", + "Sao Tome and Principe": "ST", + "Saudi Arabia": "SA", + "Senegal": "SN", + "Serbia": "RS", + "Seychelles": "SC", + "Sierra Leone": "SL", + "Singapore": "SG", + "Sint Maarten (Dutch part)": "SX", + "Slovakia": "SK", + "Slovenia": "SI", + "Solomon Islands": "SB", + "Somalia": "SO", + "South Africa": "ZA", + "GS": "South Georgia and the South Sandwich Islands", + "South Sudan": "SS", + "Spain": "ES", + "Sri Lanka": "LK", + "Sudan": "SD", + "Suriname": "SR", + "Svalbard and Jan Mayen": "SJ", + "Swaziland": "SZ", + "Sweden": "SE", + "Switzerland": "CH", + "Syrian Arab Republic": "SY", + "Taiwan, Province of China": "TW", + "Tajikistan": "TJ", + "Tanzania, United Republic of": "TZ", + "Thailand": "TH", + "Timor-Leste": "TL", + "Togo": "TG", + "Tokelau": "TK", + "Tonga": "TO", + "Trinidad and Tobago": "TT", + "Tunisia": "TN", + "Turkey": "TR", + "Turkmenistan": "TM", + "Turks and Caicos Islands": "TC", + "Tuvalu": "TV", + "Uganda": "UG", + "Ukraine": "UA", + "United Arab Emirates": "AE", + "United Kingdom": "GB", + "UM": "United States Minor Outlying Islands", + "Uruguay": "UY", + "Uzbekistan": "UZ", + "Vanuatu": "VU", + "Venezuela, Bolivarian Republic of": "VE", + "Viet Nam": "VN", + "Virgin Islands, British": "VG", + "Virgin Islands, U.S.": "VI", + "Wallis and Futuna": "WF", + "Western Sahara": "EH", + "Yemen": "YE", + "Zambia": "ZM", +"Zimbabwe": "ZW" +} diff --git a/lib/utils/billing/geocode.js b/lib/utils/billing/geocode.js new file mode 100644 index 0000000..4c6d5fe --- /dev/null +++ b/lib/utils/billing/geocode.js @@ -0,0 +1,27 @@ +// Packages +const gMaps = require('@google/maps') + +const MAPS_API_KEY = 'AIzaSyALfKTQ6AiIoJ8WGDXR3E7IBOwlHoTPfYY' + +// eslint-disable-next-line camelcase +module.exports = function ({country, zipCode: postal_code}) { + return new Promise(resolve => { + const maps = gMaps.createClient({key: MAPS_API_KEY}) + maps.geocode({ + address: `${postal_code} ${country}` // eslint-disable-line camelcase + }, (err, res) => { + if (err || res.json.results.length === 0) { + resolve() + } + + const data = res.json.results[0] + const components = {} + data.address_components.forEach(c => { + components[c.types[0]] = c + }) + const state = components.administrative_area_level_1 + const city = components.locality + resolve({state: state && state.long_name, city: city && city.long_name}) + }) + }) +} diff --git a/lib/utils/input/list.js b/lib/utils/input/list.js new file mode 100644 index 0000000..47dc582 --- /dev/null +++ b/lib/utils/input/list.js @@ -0,0 +1,86 @@ +const chalk = require('chalk') +const inquirer = require('inquirer') +const stripAnsi = require('strip-ansi') + +/* eslint-disable no-multiple-empty-lines, no-var, no-undef, no-eq-null, eqeqeq, semi */ +inquirer.prompt.prompts.list.prototype.getQuestion = function () { + var message = chalk.bold('> ' + this.opt.message) + ' ' + + // Append the default if available, and if question isn't answered + if (this.opt.default != null && this.status !== 'answered') { + message += chalk.dim('(' + this.opt.default + ') ') + } + + return message +}; +/* eslint-enable */ + +function getLength(string) { + let biggestLength = 0 + string.split('\n').map(str => { + str = stripAnsi(str) + if (str.length > biggestLength) { + biggestLength = str.length + } + return undefined + }) + return biggestLength +} + +module.exports = async function ({ + message = 'the question', + choices = [{ // eslint-disable-line no-unused-vars + name: 'something\ndescription\ndetails\netc', + value: 'something unique', + short: 'generally the first line of `name`' + }], + pageSize = 15, // Show 15 lines without scrolling (~4 credit cards) + separator = true, // puts a blank separator between each choice + abort = 'end' // wether the `abort` option will be at the `start` or the `end` +}) { + let biggestLength = 0 + + choices = choices.map(choice => { + if (choice.name) { + const length = getLength(choice.name) + if (length > biggestLength) { + biggestLength = length + } + return choice + } + throw new Error('Invalid choice') + }) + + if (separator === true) { + choices = choices.reduce((prev, curr) => ( + prev.concat(new inquirer.Separator(' '), curr) + ), []) + } + + const abortSeparator = new inquirer.Separator('─'.repeat(biggestLength)) + const _abort = { + name: 'Abort', + value: undefined + } + + if (abort === 'start') { + const blankSep = choices.shift() + choices.unshift(abortSeparator) + choices.unshift(_abort) + choices.unshift(blankSep) + } else { + choices.push(abortSeparator) + choices.push(_abort) + } + + const nonce = Date.now() + const answer = await inquirer.prompt({ + name: nonce, + type: 'list', + message, + choices, + pageSize + }) + + return answer[nonce] +} diff --git a/lib/utils/input/text.js b/lib/utils/input/text.js new file mode 100644 index 0000000..9cfd4a1 --- /dev/null +++ b/lib/utils/input/text.js @@ -0,0 +1,207 @@ +const ansiEscapes = require('ansi-escapes') +const ansiRegex = require('ansi-regex') +const chalk = require('chalk') +const stripAnsi = require('strip-ansi') + +const ESCAPES = { + LEFT: '\x1b[D', + RIGHT: '\x1b[C', + CTRL_C: '\x03', + BACKSPACE: '\x08', + CTRL_H: '\x7f', + CARRIAGE: '\r' +} + +module.exports = function ({ + label = '', + initialValue = '', + // can be: + // - `false`, which does nothing + // - `cc`, for credit cards + // - `date`, for dates in the mm / yyyy format + mask = false, + placeholder = '', + abortSequences = new Set(['\x03']), + eraseSequences = new Set([ESCAPES.BACKSPACE, ESCAPES.CTRL_H]), + resolveChars = new Set([ESCAPES.CARRIAGE]), + stdin = process.stdin, + stdout = process.stdout, + // char to print before resolving/rejecting the promise + // if `false`, nothing will be printed + trailing = ansiEscapes.eraseLines(1), + // gets called on each keypress; + // `data` contains the current keypress; + // `futureValue` contains the current value + the + // keypress in the correct place + validateKeypress = (data, futureValue) => true, // eslint-disable-line no-unused-vars + // get's called before the promise is resolved + // returning `false` here will prevent the user from submiting the value + validateValue = data => true, // eslint-disable-line no-unused-vars + // receives the value of the input and should return a string + // or false if no autocomplion is available + autoComplete = value => false, // eslint-disable-line no-unused-vars + autoCompleteChars = new Set([ + '\t', // tab + '\x1b[C' // right arrow + ]) +} = {}) { + return new Promise((resolve, reject) => { + const isRaw = process.stdin.isRaw + + let value + let caretOffset = 0 + let regex + let suggestion = '' + + stdout.write(label) + value = initialValue + stdout.write(initialValue) + if (mask) { + if (!value) { + value = chalk.gray(placeholder) + caretOffset = 0 - stripAnsi(value).length + stdout.write(value) + stdout.write(ansiEscapes.cursorBackward(Math.abs(caretOffset))) + } + + regex = placeholder.split('').reduce((prev, curr) => { + if (curr !== ' ' && !prev.includes(curr)) { + if (curr === '/') { + prev.push(' / ') + } else { + prev.push(curr) + } + } + return prev + }, []).join('|') + regex = new RegExp(`(${regex})`, 'g') + } + stdin.setRawMode(true) + stdin.resume() + + function restore() { + stdin.setRawMode(isRaw) + stdin.pause() + stdin.removeListener('data', onData) + if (trailing) { + stdout.write(trailing) + } + } + + async function onData(buffer) { + const data = buffer.toString() + value = stripAnsi(value) + + if (abortSequences.has(data)) { + restore() + return reject(new Error('USER_ABORT')) + } + + if (suggestion !== '' && !caretOffset && autoCompleteChars.has(data)) { + value += stripAnsi(suggestion) + suggestion = '' + } else if (data === ESCAPES.LEFT) { + if (value.length > Math.abs(caretOffset)) { + caretOffset-- + } + } else if (data === ESCAPES.RIGHT) { + if (caretOffset < 0) { + caretOffset++ + } + } else if (eraseSequences.has(data)) { + let char + if ((mask) && value.length > Math.abs(caretOffset)) { + if (value[value.length + caretOffset - 1] === ' ') { + if (value[value.length + caretOffset - 2] === '/') { + caretOffset -= 1 + } + char = placeholder[value.length + caretOffset] + value = value.substr(0, value.length + caretOffset - 2) + char + + value.substr(value.length + caretOffset - 1) + caretOffset-- + } else { + char = placeholder[value.length + caretOffset - 1] + value = value.substr(0, value.length + caretOffset - 1) + char + + value.substr(value.length + caretOffset) + } + caretOffset-- + } else { + value = value.substr(0, value.length + caretOffset - 1) + + value.substr(value.length + caretOffset) + } + suggestion = '' + } else if (resolveChars.has(data)) { + if (validateValue(value)) { + restore() + resolve(value) + } else { + if (mask === 'cc' || mask === 'ccv') { + value = value.replace(/\s/g, '').replace(/(.{4})/g, '$1 ').trim() + value = value.replace(regex, chalk.gray('$1')) + } else if (mask === 'expDate') { + value = value.replace(regex, chalk.gray('$1')) + } + const l = chalk.red(label.replace('-', '✖')) + stdout.write(ansiEscapes.eraseLines(1) + l + value + ansiEscapes.beep) + if (caretOffset) { + process.stdout.write(ansiEscapes.cursorBackward(Math.abs(caretOffset))) + } + } + return + } else if (!ansiRegex().test(data)) { + let tmp = value.substr(0, value.length + caretOffset) + data + + value.substr(value.length + caretOffset) + + if (mask) { + if (/\d/.test(data) && caretOffset !== 0) { + if (value[value.length + caretOffset + 1] === ' ') { + tmp = value.substr(0, value.length + caretOffset) + data + + value.substr(value.length + caretOffset + 1) + caretOffset += 2 + if (value[value.length + caretOffset] === '/') { + caretOffset += 2 + } + } else { + tmp = value.substr(0, value.length + caretOffset) + data + + value.substr(value.length + caretOffset + 1) + caretOffset++ + } + } else if (/\s/.test(data) && caretOffset < 0) { + caretOffset++ + tmp = value + } else { + return stdout.write(ansiEscapes.beep) + } + value = tmp + } else if (validateKeypress(data, value)) { + value = tmp + if (caretOffset === 0) { + const completion = await autoComplete(value) + if (completion) { + suggestion = chalk.gray(completion) + suggestion += ansiEscapes.cursorBackward(completion.length) + } else { + suggestion = '' + } + } + } else { + return stdout.write(ansiEscapes.beep) + } + } + + if (mask === 'cc' || mask === 'ccv') { + value = value.replace(/\s/g, '').replace(/(.{4})/g, '$1 ').trim() + value = value.replace(regex, chalk.gray('$1')) + } else if (mask === 'expDate') { + value = value.replace(regex, chalk.gray('$1')) + } + + stdout.write(ansiEscapes.eraseLines(1) + label + value + suggestion) + if (caretOffset) { + process.stdout.write(ansiEscapes.cursorBackward(Math.abs(caretOffset))) + } + } + + stdin.on('data', onData) + }) +} diff --git a/lib/utils/output/cmd.js b/lib/utils/output/cmd.js new file mode 100644 index 0000000..1553f4e --- /dev/null +++ b/lib/utils/output/cmd.js @@ -0,0 +1,8 @@ +const chalk = require('chalk') + +// the equivalent of , for embedding a cmd +// eg: Please run ${cmd(woot)} + +module.exports = cmd => ( + `${chalk.gray('`')}${chalk.cyan(cmd)}${chalk.gray('`')}` +) diff --git a/lib/utils/output/code.js b/lib/utils/output/code.js new file mode 100644 index 0000000..6f26948 --- /dev/null +++ b/lib/utils/output/code.js @@ -0,0 +1,8 @@ +const chalk = require('chalk') + +// the equivalent of , for embedding anything +// you may want to take a look at ./cmd.js + +module.exports = cmd => ( + `${chalk.gray('`')}${chalk.bold(cmd)}${chalk.gray('`')}` +) diff --git a/lib/utils/output/error.js b/lib/utils/output/error.js new file mode 100644 index 0000000..01a7c01 --- /dev/null +++ b/lib/utils/output/error.js @@ -0,0 +1,7 @@ +const chalk = require('chalk') + +// prints an error message +module.exports = msg => { + console.error(`${chalk.red('> Error!')} ${msg}`) +} + diff --git a/lib/utils/output/info.js b/lib/utils/output/info.js new file mode 100644 index 0000000..9041204 --- /dev/null +++ b/lib/utils/output/info.js @@ -0,0 +1,7 @@ +const chalk = require('chalk') + +// prints an informational message +module.exports = msg => { + console.log(`${chalk.gray('>')} ${msg}`) +} + diff --git a/lib/utils/output/param.js b/lib/utils/output/param.js new file mode 100644 index 0000000..7c1445f --- /dev/null +++ b/lib/utils/output/param.js @@ -0,0 +1,8 @@ +const chalk = require('chalk') + +// returns a user param in a nice formatting +// e.g.: google.com -> "google.com" (in bold) + +module.exports = param => ( + chalk.bold(`${chalk.gray('"')}${chalk.bold(param)}${chalk.gray('"')}`) +) diff --git a/lib/utils/output/prompt-bool.js b/lib/utils/output/prompt-bool.js new file mode 100644 index 0000000..812109f --- /dev/null +++ b/lib/utils/output/prompt-bool.js @@ -0,0 +1,54 @@ +const chalk = require('chalk') + +module.exports = (label, { + defaultValue = false, + abortSequences = new Set(['\x03']), + resolveChars = new Set(['\r']), + yesChar = 'y', + noChar = 'n', + stdin = process.stdin, + stdout = process.stdout +} = {}) => { + return new Promise((resolve, reject) => { + const isRaw = process.stdin.isRaw + + stdin.setRawMode(true) + stdin.resume() + + function restore() { + stdin.setRawMode(isRaw) + stdin.pause() + stdin.removeListener('data', onData) + } + + function onData(buffer) { + const data = buffer.toString() + + if (abortSequences.has(data)) { + restore() + return reject(new Error('USER_ABORT')) + } + + if (resolveChars.has(data[0])) { + restore() + resolve(defaultValue) + } else if (data[0].toLowerCase() === yesChar) { + restore() + resolve(true) + } else if (data[0].toLowerCase() === noChar) { + restore() + resolve(false) + } else { + // ignore extraneous input + } + } + + const defaultText = defaultValue === null ? + `[${yesChar}|${noChar}]` : + defaultValue ? + `[${chalk.bold(yesChar.toUpperCase())}|${noChar}]` : + `[${yesChar}|${chalk.bold(noChar.toUpperCase())}]` + stdout.write(`${chalk.gray('-')} ${label} ${chalk.gray(defaultText)} `) + stdin.on('data', onData) + }) +} diff --git a/lib/utils/output/stamp.js b/lib/utils/output/stamp.js new file mode 100644 index 0000000..ace9951 --- /dev/null +++ b/lib/utils/output/stamp.js @@ -0,0 +1,12 @@ +const ms = require('ms') +const chalk = require('chalk') + +// returns a time delta with the right color +// example: `[103ms]` + +module.exports = () => { + const start = new Date() + return () => ( + chalk.gray(`[${ms(new Date() - start)}]`) + ) +} diff --git a/lib/utils/output/success.js b/lib/utils/output/success.js new file mode 100644 index 0000000..dbb7db5 --- /dev/null +++ b/lib/utils/output/success.js @@ -0,0 +1,6 @@ +const chalk = require('chalk') + +// prints a success message +module.exports = msg => { + console.log(`${chalk.cyan('> Success!')} ${msg}`) +} diff --git a/lib/utils/output/uid.js b/lib/utils/output/uid.js new file mode 100644 index 0000000..d36b947 --- /dev/null +++ b/lib/utils/output/uid.js @@ -0,0 +1,7 @@ +const chalk = require('chalk') + +// used for including uids in the output +// example: `(dom_ji13dj2fih4fi2hf)` +module.exports = id => ( + chalk.gray(`(${id})`) +) diff --git a/lib/utils/output/wait.js b/lib/utils/output/wait.js new file mode 100644 index 0000000..4b5c37c --- /dev/null +++ b/lib/utils/output/wait.js @@ -0,0 +1,15 @@ +const ora = require('ora') +const chalk = require('chalk') +const {eraseLine} = require('ansi-escapes') + +// prints a spinner followed by the given text +module.exports = msg => { + const spinner = ora(chalk.gray(msg)) + spinner.color = 'gray' + spinner.start() + + return () => { + spinner.stop() + process.stdout.write(eraseLine) + } +} diff --git a/package.json b/package.json index d15a34c..b611124 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,19 @@ "scripts": { "precommit": "npm run lint", "lint": "xo", - "test": "npm run lint && ava", - "pack": "pkg . --out-dir packed -t node7-alpine-x64,node7-linux-x64,node7-macos-x64,node7-win-x64" + "test": "npm run build && npm run lint && ava", + "prepublish": "npm run build", + "build": "./build.sh", + "pack": "npm run build && pkg . --out-dir packed -t node7-alpine-x64,node7-linux-x64,node7-macos-x64,node7-win-x64" }, "pkg": { "scripts": [ - "./bin/*", - "./lib/**/*" + "./build/bin/*", + "./build/lib/**/*" ] }, "bin": { - "now": "./bin/now.js" + "now": "./build/bin/now.js" }, "ava": { "failFast": true, @@ -55,7 +57,9 @@ "node": ">=6.9.0" }, "dependencies": { + "@google/maps": "0.3.1", "ansi-escapes": "1.4.0", + "ansi-regex": "^2.1.1", "arr-flatten": "1.0.1", "array-unique": "0.3.2", "async-retry": "0.2.1", @@ -63,6 +67,7 @@ "bytes": "2.4.0", "chalk": "1.1.3", "copy-paste": "1.3.0", + "credit-card": "^3.0.1", "cross-spawn": "5.0.1", "docker-file-parser": "0.1.0", "dotenv": "4.0.0", @@ -73,6 +78,7 @@ "glob": "7.1.1", "ignore": "3.2.2", "ini": "1.3.4", + "inquirer": "^2.0.0", "is-url": "1.2.2", "minimist": "1.2.0", "ms": "0.7.2", @@ -84,6 +90,8 @@ "resumer": "0.0.0", "socket.io-client": "1.7.2", "split-array": "1.0.1", + "strip-ansi": "3.0.1", + "stripe": "4.15.0", "text-table": "0.2.0", "tmp-promise": "1.0.3", "update-notifier": "2.0.0" diff --git a/test/args-parsing.js b/test/args-parsing.js index d9de669..e7bb2be 100644 --- a/test/args-parsing.js +++ b/test/args-parsing.js @@ -66,7 +66,7 @@ test('"now alias --help" is the same as "now --help alias"', async t => { */ function now(...args) { return new Promise((resolve, reject) => { - const command = path.resolve(__dirname, '../bin/now.js') + const command = path.resolve(__dirname, '../build/bin/now.js') const now = spawn(command, args) let stdout = '' diff --git a/test/index.js b/test/index.js index 11b1f99..0722f9d 100644 --- a/test/index.js +++ b/test/index.js @@ -6,9 +6,9 @@ const test = require('ava') const {asc: alpha} = require('alpha-sort') // Ours -const hash = require('../lib/hash') -const readMetadata = require('../lib/read-metadata') -const {npm: getNpmFiles_, docker: getDockerFiles} = require('../lib/get-files') +const hash = require('../build/lib/hash') +const readMetadata = require('../build/lib/read-metadata') +const {npm: getNpmFiles_, docker: getDockerFiles} = require('../build/lib/get-files') const prefix = join(__dirname, '_fixtures') + '/' const base = path => path.replace(prefix, '') diff --git a/test/to-host.js b/test/to-host.js index 449dff0..52c3b22 100644 --- a/test/to-host.js +++ b/test/to-host.js @@ -1,5 +1,5 @@ const test = require('ava') -const toHost = require('../lib/to-host') +const toHost = require('../build/lib/to-host') test('simple', async t => { t.is(toHost('zeit.co'), 'zeit.co')