From e59e7616c719b0194d729d489f57e1176bacef35 Mon Sep 17 00:00:00 2001 From: Matheus Fernandes Date: Mon, 1 May 2017 11:26:08 -0700 Subject: [PATCH] Add `logs`, `teams`, `switch`, `scale`, new `ls` and much more (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature/teams (#25) * Add the skeleton of `now teams` * Add support for "complex" command aliases This adds the ability to set, for example, `now switch` as an alias for `now teams switch`. Subsequent commands and arguments are taken into account: `now switch zeit --debug` will be parsed to `now teams switch zeit --debug`. * `now switch` => `now teams switch` * Extract `rightPad` * Extract `eraseLines` * Extract `✓` * Text input: add `valid` option * Text input: add `forceLowerCase` option * Add preliminary `now teams add` * Make the linter happy * Extract `> NOTE: ...` * Add missing labels * Fix typos * Add missing parameters * Change the section label after inviting all specified team mates * Call the API after each email submission * Show the elapsed time after each `inviteUser` api call * Handle user aborts * We don't need `args` for `now teams add` * Add missing `await` * Extract regex * `process.exit()` => `exit(1)` * `prompt-bool` is an `input` util, not an `output` one * Add the ability to delete a key from the config file * Add `fatal-error` * Add `now teams invite` * This shouldn't be black * Save the username in `~/.now.json` upon login * Save the token and userId instead of token and email * Fix typo * Save more info about the user to `~/.now.json` upon login * `~/.now.json`: Persist the current time when login in * Add `user` helper * `user.userId` => `user.id` * Tweak code organization * Add caching system to `.now.json` * Automatically switch to a team after its creation * Introduce the concept of `inactive` teams * Use bold for `payment method` * Remove duplicated code * Add line breaks * Auto complete with the first match * Remove placeholder stuff * Add the user's email to the list of suggestions * FIx bad merge * Add `now switch` * Make `now teams invite` more reliable and faster * Shut up XO * Improve autocompletion * Fix TypeError * Make stuff pretty * Not sure how this got overwritten * Feature/domains (#26) * Add the skeleton of `now teams` * Add support for "complex" command aliases This adds the ability to set, for example, `now switch` as an alias for `now teams switch`. Subsequent commands and arguments are taken into account: `now switch zeit --debug` will be parsed to `now teams switch zeit --debug`. * `now switch` => `now teams switch` * Extract `rightPad` * Extract `eraseLines` * Extract `✓` * Text input: add `valid` option * Text input: add `forceLowerCase` option * Add preliminary `now teams add` * Make the linter happy * Extract `> NOTE: ...` * Add missing labels * Fix typos * Add missing parameters * Change the section label after inviting all specified team mates * Call the API after each email submission * Show the elapsed time after each `inviteUser` api call * Handle user aborts * We don't need `args` for `now teams add` * Add missing `await` * Extract regex * `process.exit()` => `exit(1)` * `prompt-bool` is an `input` util, not an `output` one * Add the ability to delete a key from the config file * Add `fatal-error` * Add `now teams invite` * This shouldn't be black * Save the username in `~/.now.json` upon login * Save the token and userId instead of token and email * Fix typo * Save more info about the user to `~/.now.json` upon login * `~/.now.json`: Persist the current time when login in * Add `user` helper * `user.userId` => `user.id` * Tweak code organization * Add caching system to `.now.json` * Automatically switch to a team after its creation * Introduce the concept of `inactive` teams * Use bold for `payment method` * Remove duplicated code * Add line breaks * Auto complete with the first match * Remove placeholder stuff * Add the user's email to the list of suggestions * FIx bad merge * Add `now switch` * Make `now teams invite` more reliable and faster * Shut up XO * Improve autocompletion * Fix TypeError * Make stuff pretty * Not sure how this got overwritten * `prompt-bool` is an `input` util, not an `output` one * Make stuff pretty * Not sure how this got overwritten * Add domains.status, price and buy * Add `now domains buy` * Add the ability to buy a domain when running `now alias` * Logs (#27) * Added `logs` sub command * add missing dependencies * use utils/output/logo * logs: fix wrong reference * logs: fix buffer time * sort build logs (#19) * logs: use lib/logs * lib/logs: fix * logs: resolve url to id * logs: default to follow * logs: don't resolve URL to id * logs: revert to default unfollow * logs: add since option and until option * logs: fix default number of logs * fix logs auth * logs: listen ready event * logs: new endpoint * log: remove v query param * logs: default to not include access logs * fix styles of now-logs * logs: remove relative time * Fix bad merge conflict * Add `now scale` (#28) * Inital drafts fro `now-scale` * More final draft * sketch new `now ls` format * Add sketch for `now ls --all` * Placeholder for `now scale ls` * "Prettify" and improve scale command Signed-off-by: Jarmo Isotalo * Adopt to now-list api changes * Improve now-list --all colors Signed-off-by: Jarmo Isotalo * Add now scale ls Signed-off-by: Jarmo Isotalo * Prettify * Show auto correctly * Add partial match scale for now alias * Make alias to match scale before uptading alias and presumably a bunch of unrelated style changes * Replace spinners with help text * Make the list :nice: * Make now-list great again * Final touches * Allow --all only when app is defined * Add progress tracking to scale * Correctly use --all for > 0 deployments found [1s] and improve scale info ux * Show --all info if we are hiding stuff * Fixes * Refactor scale info and unfreeze * Fixes * Proper progress bar * Fix bad merge * Fix auth for now-scale * logs: fix reading config * Fix reference * Small ux tweaks * Improve now alias ux * Fix a ton of lint errors * Fix scaling up and alias ux * Fix lint errors + prettify * Make `bin/now-scale.js` available via `now scale` * Fix errornous syntax for domains list * And use correct header for domains list * Update now-scale help to match new spec * `await` for `cfg.read()` on `cfg.remove()` * Update scale command * Cleanu p * Fetch the teams from the api on teams.ls() Plus prettier shit * Run prettier hooks manually * Make `now switch` perfect * Rm unused variables * Lint * Ruin ux but lint * Consume `POST /teams` * Consume `PATCH /teams/:id` * Fix/teams support (#29) * Add teams support for lib/index.js * Consume `POST /teams/:id/members` * Make `now teams create` and `now teams invite` perfect * Add a way to not send `?teamId` if necessary * Add `?teamId=${currentTeam.id}` support to all comamnds and subcommands * Display the username only if it's available * Consume the preoduction endpoits for domain purchase * Fix typo * Fix grammar * Fix grammar * Remove useless require * Display the user name/team slug on creation/list commands * Remove use of old, now undefined variable * Show domains in bold on `now domains ls` * `user.userId` => `user.uid` * Remove console.log * Show a better messsage upon unexpected `domains.buy()` error * typo * Consume new `/plan` API and fix plan check * Update `now upgrade` – consume new APIs and expose new plans * Fix `now ugprade` info message * `now cc`: consume new APIs and fix error messages * Add team/user context to `now alias` when buying a domain * Fix wording on `now domains buy` * Add stamp to domain purchase * Improve scale ux * Remove ToS prompt upon login * Fix `prompt-bool` trailing issues * Remove useless `require` * Allow `now switch ` * Make `now help` better * This shouldn't be here * Make `now switch` incredible * Remove old stuff from ~/.now.json * Add comments * `now team` => `now teams` * 5.0.0 * Fix linter * Fix DNS * Parse subdomain * FIx lint * drop IDs for certs * Make now ls look nice also when noTTY * Make now list look nice when colors are not supported * Mane certs ls look nice when we have no colers * Now ls --all can also take uniq url as a parameter * Improve now ls --all * Now ls -all takes alias as an argument --- bin/domains/buy.js | 64 ++++++ bin/now-alias.js | 70 +++--- bin/now-billing-add.js | 49 +++-- bin/now-billing.js | 101 ++++++--- bin/now-certs.js | 117 +++++----- bin/now-deploy.js | 131 +++++++----- bin/now-dns.js | 49 +++-- bin/now-domains.js | 57 +++-- bin/now-list.js | 165 ++++++++++---- bin/now-logs.js | 270 +++++++++++++++++++++++ bin/now-open.js | 47 ++-- bin/now-remove.js | 78 +++---- bin/now-scale.js | 309 +++++++++++++++++++++++++++ bin/now-secrets.js | 47 ++-- bin/now-teams.js | 142 ++++++++++++ bin/now-upgrade.js | 147 +++++++------ bin/now.js | 20 +- bin/teams/add.js | 127 +++++++++++ bin/teams/invite.js | 160 ++++++++++++++ bin/teams/switch.js | 126 +++++++++++ lib/alias.js | 202 ++++++++++++++--- lib/cfg.js | 73 ++++++- lib/credit-cards.js | 18 +- lib/domains.js | 92 ++++++++ lib/index.js | 242 +++++++++++++++++++-- lib/login.js | 39 ++-- lib/logs.js | 2 +- lib/plans.js | 44 ++-- lib/re-alias.js | 6 +- lib/scale-info.js | 72 +++++++ lib/scale.js | 43 ++++ lib/teams.js | 141 ++++++++++++ lib/unfreeze.js | 0 lib/user.js | 54 +++++ lib/utils/domains/treat-buy-error.js | 25 +++ lib/utils/fatal-error.js | 7 + lib/utils/input/regexes.js | 3 + lib/utils/input/text.js | 50 +++-- lib/utils/output/chars.js | 3 + lib/utils/output/erase-lines.js | 3 + lib/utils/output/note.js | 6 + lib/utils/output/right-pad.js | 4 + lib/utils/url.js | 23 ++ package.json | 12 +- 44 files changed, 2921 insertions(+), 519 deletions(-) create mode 100644 bin/domains/buy.js create mode 100644 bin/now-logs.js create mode 100755 bin/now-scale.js create mode 100644 bin/now-teams.js create mode 100644 bin/teams/add.js create mode 100644 bin/teams/invite.js create mode 100644 bin/teams/switch.js create mode 100644 lib/scale-info.js create mode 100644 lib/scale.js create mode 100644 lib/teams.js create mode 100644 lib/unfreeze.js create mode 100644 lib/user.js create mode 100644 lib/utils/domains/treat-buy-error.js create mode 100644 lib/utils/fatal-error.js create mode 100644 lib/utils/input/regexes.js create mode 100644 lib/utils/output/chars.js create mode 100644 lib/utils/output/erase-lines.js create mode 100644 lib/utils/output/note.js create mode 100644 lib/utils/output/right-pad.js create mode 100644 lib/utils/url.js diff --git a/bin/domains/buy.js b/bin/domains/buy.js new file mode 100644 index 0000000..4daed1c --- /dev/null +++ b/bin/domains/buy.js @@ -0,0 +1,64 @@ +const { italic, bold } = require('chalk'); + +const error = require('../../lib/utils/output/error'); +const wait = require('../../lib/utils/output/wait'); +const cmd = require('../../lib/utils/output/cmd'); +const param = require('../../lib/utils/output/param'); +const info = require('../../lib/utils/output/info'); +const uid = require('../../lib/utils/output/uid'); +const success = require('../../lib/utils/output/success'); +const stamp = require('../../lib/utils/output/stamp'); +const promptBool = require('../../lib/utils/input/prompt-bool'); +const eraseLines = require('../../lib/utils/output/erase-lines'); +const treatBuyError = require('../../lib/utils/domains/treat-buy-error'); + +module.exports = async function({domains, args, currentTeam, user}) { + const name = args[0]; + let elapsed + + if (!name) { + return error(`Missing domain name. Run ${cmd('now domains help')}`); + } + + const nameParam = param(name); + elapsed = stamp() + let stopSpinner = wait(`Checking availability for ${nameParam}`); + + const price = await domains.price(name); + const available = await domains.status(name); + + stopSpinner(); + + if (! available) { + return error(`The domain ${nameParam} is ${italic('unavailable')}! ${elapsed()}`); + } + + info(`The domain ${nameParam} is ${italic('available')}! ${elapsed()}`); + const confirmation = await promptBool(`Buy now for ${bold(`$${price}`)} (${ + bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + })?`); + + eraseLines(1); + if (!confirmation) { + return info('Aborted'); + } + + stopSpinner = wait('Purchasing'); + elapsed = stamp() + let domain; + try { + domain = await domains.buy(name); + } catch (err) { + stopSpinner(); + return treatBuyError(err); + } + + stopSpinner(); + + success(`Domain purchased and created ${uid(domain.uid)} ${elapsed()}`); + info( + `You may now use your domain as an alias to your deployments. Run ${cmd('now alias help')}` + ); +}; diff --git a/bin/now-alias.js b/bin/now-alias.js index 6db01ba..6967b00 100755 --- a/bin/now-alias.js +++ b/bin/now-alias.js @@ -9,6 +9,7 @@ const ms = require('ms'); // Ours const strlen = require('../lib/strlen'); const NowAlias = require('../lib/alias'); +const NowDomains = require('../lib/domains'); const login = require('../lib/login'); const cfg = require('../lib/cfg'); const { error } = require('../lib/error'); @@ -104,29 +105,33 @@ if (argv.help) { help(); exit(0); } else { - const config = cfg.read(); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); + exit(1); + } - 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}\n${err.stack}`); - } - exit(1); + try { + await run({token, config}); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err}\n${err.stack}`); } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); exit(1); - }); + } + }); } -async function run(token) { - const alias = new NowAlias(apiUrl, token, { debug }); +async function run({token, config: {currentTeam, user}}) { + const alias = new NowAlias({apiUrl, token, debug, currentTeam }); + const domains = new NowDomains({apiUrl, token, debug, currentTeam }); const args = argv._.slice(1); switch (subcommand) { @@ -229,7 +234,11 @@ async function run(token) { const elapsed_ = ms(new Date() - start_); console.log( - `> ${aliases.length} alias${aliases.length === 1 ? '' : 'es'} found ${chalk.gray(`[${elapsed_}]`)}` + `> ${aliases.length} alias${aliases.length === 1 ? '' : 'es'} found ${chalk.gray(`[${elapsed_}]`)} under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }` ); if (text) { @@ -259,7 +268,11 @@ async function run(token) { if (!_alias) { const err = new Error( - `Alias not found by "${_target}". Run ${chalk.dim('`now alias ls`')} to see your aliases.` + `Alias not found by "${_target}" under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }. Run ${chalk.dim('`now alias ls`')} to see your aliases.` ); err.userError = true; throw err; @@ -288,7 +301,7 @@ async function run(token) { case 'add': case 'set': { if (argv.rules) { - await updatePathAlias(alias, argv._[0], argv.rules); + await updatePathAlias(alias, argv._[0], argv.rules, domains); break; } if (args.length !== 2) { @@ -297,7 +310,7 @@ async function run(token) { ); return exit(1); } - await alias.set(String(args[0]), String(args[1])); + await alias.set(String(args[0]), String(args[1]), currentTeam, user); break; } default: { @@ -307,9 +320,9 @@ async function run(token) { } if (argv.rules) { - await updatePathAlias(alias, argv._[0], argv.rules); + await updatePathAlias(alias, argv._[0], argv.rules, domains); } else if (argv._.length === 2) { - await alias.set(String(argv._[0]), String(argv._[1])); + await alias.set(String(argv._[0]), String(argv._[1]), domains, currentTeam, user); } else if (argv._.length >= 3) { error('Invalid number of arguments'); help(); @@ -322,6 +335,7 @@ async function run(token) { } } + domains.close() alias.close(); } @@ -343,7 +357,9 @@ async function confirmDeploymentRemoval(alias, _alias) { const msg = '> The following alias will be removed permanently\n' + ` ${tbl} \nAre you sure?`; - return promptBool(msg); + return promptBool(msg, { + trailing: '\n' + }); } function findAlias(alias, list) { @@ -382,9 +398,9 @@ function findAlias(alias, list) { return _alias; } -async function updatePathAlias(alias, aliasName, rules) { +async function updatePathAlias(alias, aliasName, rules, domains) { const start = new Date(); - const res = await alias.updatePathBasedroutes(String(aliasName), rules); + const res = await alias.updatePathBasedroutes(String(aliasName), rules, domains); const elapsed = ms(new Date() - start); if (res.error) { const err = new Error(res.error.message); diff --git a/bin/now-billing-add.js b/bin/now-billing-add.js index d14b54f..2c7f577 100644 --- a/bin/now-billing-add.js +++ b/bin/now-billing-add.js @@ -12,29 +12,30 @@ 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); -} +const { tick } = require('../lib/utils/output/chars'); +const rightPad = require('../lib/utils/output/right-pad'); function expDateMiddleware(data) { return data; } -module.exports = function(creditCards) { +module.exports = function({creditCards, currentTeam, user}) { const state = { error: undefined, - cardGroupLabel: `> ${chalk.bold('Enter your card details')}`, + cardGroupLabel: `> ${chalk.bold(`Enter your card details for ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }`)}`, name: { - label: rightPad('Full Name'), + label: rightPad('Full Name', 12), placeholder: 'John Appleseed', validateValue: data => data.trim().length > 0 }, cardNumber: { - label: rightPad('Number'), + label: rightPad('Number', 12), mask: 'cc', placeholder: '#### #### #### ####', validateKeypress: (data, value) => /\d/.test(data) && value.length < 19, @@ -49,7 +50,7 @@ module.exports = function(creditCards) { }, ccv: { - label: rightPad('CCV'), + label: rightPad('CCV', 12), mask: 'ccv', placeholder: '###', validateValue: data => { @@ -59,7 +60,7 @@ module.exports = function(creditCards) { }, expDate: { - label: rightPad('Exp. Date'), + label: rightPad('Exp. Date', 12), mask: 'expDate', placeholder: 'mm / yyyy', middleware: expDateMiddleware, @@ -69,7 +70,7 @@ module.exports = function(creditCards) { addressGroupLabel: `\n> ${chalk.bold('Enter your billing address')}`, country: { - label: rightPad('Country'), + label: rightPad('Country', 12), async autoComplete(value) { for (const country in countries) { if (!Object.hasOwnProperty.call(countries, country)) { @@ -85,23 +86,23 @@ module.exports = function(creditCards) { }, zipCode: { - label: rightPad('ZIP'), + label: rightPad('ZIP', 12), validadeKeypress: data => data.trim().length > 0, validateValue: data => data.trim().length > 0 }, state: { - label: rightPad('State'), + label: rightPad('State', 12), validateValue: data => data.trim().length > 0 }, city: { - label: rightPad('City'), + label: rightPad('City', 12), validateValue: data => data.trim().length > 0 }, address1: { - label: rightPad('Address'), + label: rightPad('Address', 12), validateValue: data => data.trim().length > 0 } }; @@ -141,16 +142,16 @@ module.exports = function(creditCards) { brand = chalk.cyan(`[${brand}]`); const masked = chalk.gray('#### '.repeat(3)) + result.split(' ')[3]; process.stdout.write( - `${chalk.cyan('✓')} ${piece.label}${masked} ${brand}\n` + `${chalk.cyan(tick)} ${piece.label}${masked} ${brand}\n` ); } else if (key === 'ccv') { process.stdout.write( - `${chalk.cyan('✓')} ${piece.label}${'*'.repeat(result.length)}\n` + `${chalk.cyan(tick)} ${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`); + process.stdout.write(`${chalk.cyan(tick)} ${piece.label}${text}\n`); } else if (key === 'zipCode') { const stopSpinner = wait(piece.label + result); const addressInfo = await geocode({ @@ -165,11 +166,11 @@ module.exports = function(creditCards) { } stopSpinner(); process.stdout.write( - `${chalk.cyan('✓')} ${piece.label}${result}\n` + `${chalk.cyan(tick)} ${piece.label}${result}\n` ); } else { process.stdout.write( - `${chalk.cyan('✓')} ${piece.label}${result}\n` + `${chalk.cyan(tick)} ${piece.label}${result}\n` ); } } catch (err) { @@ -198,7 +199,11 @@ module.exports = function(creditCards) { }); stopSpinner(); success( - `${state.cardNumber.brand} ending in ${res.last4} was added to your account` + `${state.cardNumber.brand} ending in ${res.last4} was added to ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }` ); } catch (err) { stopSpinner(); diff --git a/bin/now-billing.js b/bin/now-billing.js index 30721ad..bacdbe1 100644 --- a/bin/now-billing.js +++ b/bin/now-billing.js @@ -89,25 +89,27 @@ 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); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); + exit(1); + } + try { + await run({token, config}); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err.stack}`); } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); exit(1); - }); + } + }); } // Builds a `choices` object that can be passesd to inquirer.prompt() @@ -132,15 +134,21 @@ function buildInquirerChoices(cards) { }); } -async function run(token) { +async function run({token, config: {currentTeam, user}}) { const start = new Date(); - const creditCards = new NowCreditCards(apiUrl, token, { debug }); + const creditCards = new NowCreditCards({apiUrl, token, debug, currentTeam }); const args = argv._.slice(1); switch (subcommand) { case 'ls': case 'list': { - const cards = await creditCards.ls(); + let cards + try { + cards = await creditCards.ls(); + } catch (err) { + error(err.message) + return + } const text = cards.cards .map(card => { const _default = card.id === cards.defaultCardId @@ -177,7 +185,11 @@ async function run(token) { const elapsed = ms(new Date() - start); console.log( - `> ${cards.cards.length} card${cards.cards.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}` + `> ${cards.cards.length} card${cards.cards.length === 1 ? '' : 's'} found under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + } ${chalk.gray(`[${elapsed}]`)}` ); if (text) { console.log(`\n${text}\n`); @@ -193,7 +205,14 @@ async function run(token) { } const start = new Date(); - const cards = await creditCards.ls(); + + let cards + try { + cards = await creditCards.ls(); + } catch (err) { + error(err.message) + return + } if (cards.cards.length === 0) { error('You have no credit cards to choose from'); @@ -204,7 +223,11 @@ async function run(token) { 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 message = `Selecting a new default payment card for ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + } ${chalk.gray(`[${elapsed}]`)}`; const choices = buildInquirerChoices(cards); cardId = await listInput({ @@ -219,7 +242,9 @@ async function run(token) { // 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); + const confirmation = await promptBool(label, { + trailing: '\n' + }); if (!confirmation) { console.log('Aborted'); break; @@ -247,10 +272,20 @@ async function run(token) { } const start = new Date(); - const cards = await creditCards.ls(); + let cards + try { + cards = await creditCards.ls(); + } catch (err) { + error(err.message) + return + } if (cards.cards.length === 0) { - error('You have no credit cards to choose from to delete'); + error(`You have no credit cards to choose from to delete under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }`); return exit(0); } @@ -258,7 +293,11 @@ async function run(token) { 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 message = `Selecting a card to ${chalk.underline('remove')} under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + } ${chalk.gray(`[${elapsed}]`)}`; const choices = buildInquirerChoices(cards); cardId = await listInput({ @@ -298,7 +337,11 @@ async function run(token) { card => card.id === cards.defaultCardId ); - text += `\n${newDefaultCard.brand} ending in ${newDefaultCard.last4} in now default`; + text += `\n${newDefaultCard.brand} ending in ${newDefaultCard.last4} in now default for ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }`; } } @@ -313,7 +356,7 @@ async function run(token) { } case 'add': { - require(resolve(__dirname, 'now-billing-add.js'))(creditCards); + require(resolve(__dirname, 'now-billing-add.js'))({creditCards, currentTeam, user}); break; } diff --git a/bin/now-certs.js b/bin/now-certs.js index 939bd80..d616f6c 100755 --- a/bin/now-certs.js +++ b/bin/now-certs.js @@ -9,9 +9,11 @@ const table = require('text-table'); const minimist = require('minimist'); const fs = require('fs-promise'); const ms = require('ms'); +const printf = require('printf'); +require('epipebomb')(); +const supportsColor = require('supports-color'); // Ours -const strlen = require('../lib/strlen'); const cfg = require('../lib/cfg'); const { handleError, error } = require('../lib/error'); const NowCerts = require('../lib/certs'); @@ -22,12 +24,7 @@ const logo = require('../lib/utils/output/logo'); const argv = minimist(process.argv.slice(2), { string: ['config', 'token', 'crt', 'key', 'ca'], boolean: ['help', 'debug'], - alias: { - help: 'h', - config: 'c', - debug: 'd', - token: 't' - } + alias: { help: 'h', config: 'c', debug: 'd', token: 't' } }); const subcommand = argv._[0]; @@ -85,21 +82,24 @@ 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) { - handleError(err); - exit(1); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); exit(1); - }); + } + + try { + await run({ token, config }); + } catch (err) { + handleError(err); + exit(1); + } + }); } function formatExpirationDate(date) { @@ -109,8 +109,8 @@ function formatExpirationDate(date) { : chalk.gray('in ' + ms(diff)); } -async function run(token) { - const certs = new NowCerts(apiUrl, token, { debug }); +async function run({ token, config: { currentTeam, user } }) { + const certs = new NowCerts({ apiUrl, token, debug, currentTeam }); const args = argv._.slice(1); const start = Date.now(); @@ -126,7 +126,7 @@ async function run(token) { const elapsed = ms(new Date() - start); console.log( - `> ${list.length} certificate${list.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}` + `> ${list.length} certificate${list.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)} under ${chalk.bold((currentTeam && currentTeam.slug) || user.username || user.email)}` ); if (list.length > 0) { @@ -134,36 +134,37 @@ async function run(token) { list.sort((a, b) => { return a.cn.localeCompare(b.cn); }); - const header = [ - ['', 'id', 'cn', 'created', 'expiration', 'auto-renew'].map(s => - chalk.dim(s)) - ]; - const out = table( - header.concat( - list.map(cert => { - const cn = chalk.bold(cert.cn); - const time = chalk.gray(ms(cur - new Date(cert.created)) + ' ago'); - const expiration = formatExpirationDate(new Date(cert.expiration)); - return [ - '', - cert.uid ? cert.uid : 'unknown', - cn, - time, - expiration, - cert.autoRenew ? 'yes' : 'no' - ]; - }) - ), - { - align: ['l', 'r', 'l', 'l', 'l'], - hsep: ' '.repeat(2), - stringLength: strlen - } + + const maxCnLength = + list.reduce((acc, i) => { + return Math.max(acc, (i.cn && i.cn.length) || 0); + }, 0) + 1; + + console.log( + chalk.dim( + printf( + ` %-${maxCnLength}s %-8s %-10s %-10s`, + 'cn', + 'created', + 'expiration', + 'auto-renew' + ) + ) ); - if (out) { - console.log('\n' + out + '\n'); - } + list.forEach(cert => { + const cn = chalk.bold(cert.cn); + const time = chalk.gray(ms(cur - new Date(cert.created)) + ' ago'); + const expiration = formatExpirationDate(new Date(cert.expiration)); + const autoRenew = cert.autoRenew ? 'yes' : 'no'; + let spec; + if (supportsColor) { + spec = ` %-${maxCnLength + 9}s %-18s %-20s %-20s\n`; + } else { + spec = ` %-${maxCnLength}s %-8s %-10s %-10s\n`; + } + process.stdout.write(printf(spec, cn, time, expiration, autoRenew)); + }); } } else if (subcommand === 'create') { if (args.length !== 1) { @@ -209,7 +210,7 @@ async function run(token) { return exit(1); } - const cert = await getCertIdCn(certs, args[0]); + const cert = await getCertIdCn(certs, args[0], currentTeam, user); if (!cert) { return exit(1); } @@ -240,7 +241,7 @@ async function run(token) { const key = readX509File(argv.key); const ca = argv.ca ? readX509File(argv.ca) : ''; - const cert = await getCertIdCn(certs, args[0]); + const cert = await getCertIdCn(certs, args[0], currentTeam, user); if (!cert) { return exit(1); } @@ -266,7 +267,7 @@ async function run(token) { return exit(1); } - const cert = await getCertIdCn(certs, args[0]); + const cert = await getCertIdCn(certs, args[0], currentTeam, user); if (!cert) { return exit(1); } @@ -327,14 +328,16 @@ function readX509File(file) { return fs.readFileSync(path.resolve(file), 'utf8'); } -async function getCertIdCn(certs, idOrCn) { +async function getCertIdCn(certs, idOrCn, currentTeam, user) { const list = await certs.ls(); const thecert = list.filter(cert => { return cert.uid === idOrCn || cert.cn === idOrCn; })[0]; if (!thecert) { - error(`No certificate found by id or cn "${idOrCn}"`); + error( + `No certificate found by id or cn "${idOrCn}" under ${chalk.bold((currentTeam && currentTeam.slug) || user.username || user.email)}` + ); return null; } diff --git a/bin/now-deploy.js b/bin/now-deploy.js index 9a30d53..e716149 100755 --- a/bin/now-deploy.js +++ b/bin/now-deploy.js @@ -35,6 +35,7 @@ const info = require('../lib/utils/output/info'); const wait = require('../lib/utils/output/wait'); const NowPlans = require('../lib/plans'); const promptBool = require('../lib/utils/input/prompt-bool'); +const note = require('../lib/utils/output/note'); const argv = minimist(process.argv.slice(2), { string: ['config', 'token', 'name', 'alias'], @@ -80,21 +81,24 @@ const help = () => { ${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 - open Open the latest deployment for the project - help [cmd] Displays complete help for [cmd] + 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 + logs [url] Displays the logs for a deployment + scale [args] Scales the instance count of a deployment + 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 + billing | cc [cmd] Manages your credit cards and billing methods + upgrade | downgrade [plan] Upgrades or downgrades your plan + teams [team] Manages your teams + switch Switches between teams and your account ${chalk.dim('Options:')} @@ -197,44 +201,48 @@ if (deploymentName || wantsPublic) { forceNew = true; } -const config = cfg.read(); -const alwaysForwardNpm = config.forwardNpm; - -if (argv.h || argv.help) { - help(); - exit(0); -} else if (argv.v || argv.version) { - console.log(version); - process.exit(0); -} else if (!(argv.token || config.token) || shouldLogin) { - login(apiUrl) - .then(token => { - if (shouldLogin) { - console.log('> Logged in successfully. Token saved in ~/.now.json'); - process.exit(0); - } else { - sync(token).catch(err => { - error(`Unknown error: ${err}\n${err.stack}`); - process.exit(1); - }); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); +let alwaysForwardNpm; + +Promise.resolve().then(async () => { + const config = await cfg.read(); + alwaysForwardNpm = config.forwardNpm; + + if (argv.h || argv.help) { + help(); + exit(0); + } else if (argv.v || argv.version) { + console.log(version); + process.exit(0); + } else if (!(argv.token || config.token) || shouldLogin) { + let token; + try { + token = await login(apiUrl); + } catch (err) { + error(`Authentication error – ${err.message}`); + process.exit(1); + } + if (shouldLogin) { + console.log('> Logged in successfully. Token saved in ~/.now.json'); + process.exit(0); + } else { + sync({token, config}).catch(err => { + error(`Unknown error: ${err}\n${err.stack}`); + process.exit(1); + }); + } + } else { + sync({token: argv.token || config.token, config}).catch(err => { + error(`Unknown error: ${err}\n${err.stack}`); process.exit(1); }); -} else { - sync(argv.token || config.token).catch(err => { - error(`Unknown error: ${err}\n${err.stack}`); - process.exit(1); - }); -} + } +}); -async function sync(token) { +async function sync({token, config: {currentTeam, user}}) { const start = Date.now(); const rawPath = argv._[0]; - const planPromise = new NowPlans(apiUrl, token, { debug }).getCurrent(); + const planPromise = new NowPlans({apiUrl, token, debug, currentTeam }).getCurrent(); const stopDeployment = msg => { error(msg); @@ -301,11 +309,18 @@ async function sync(token) { if (gitRepo.main) { const gitRef = gitRepo.ref ? ` at "${chalk.bold(gitRepo.ref)}" ` : ''; console.log( - `> Deploying ${gitRepo.type} repository "${chalk.bold(gitRepo.main)}"` + - gitRef + `> Deploying ${gitRepo.type} repository "${chalk.bold(gitRepo.main)}" ${gitRef} under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }` ); } else { - console.log(`> Deploying ${chalk.bold(toHumanPath(path))}`); + console.log(`> Deploying ${chalk.bold(toHumanPath(path))} under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }`); } } @@ -420,7 +435,7 @@ async function sync(token) { quiet: true }); - const now = new Now(apiUrl, token, { debug }); + const now = new Now({apiUrl, token, debug, currentTeam }); let dotenvConfig; let dotenvOption; @@ -536,9 +551,7 @@ async function sync(token) { const env = {}; env_.filter(v => Boolean(v)).forEach(([key, val]) => { if (key in env) { - console.log( - `> ${chalk.yellow('NOTE:')} Overriding duplicate env key ${chalk.bold(`"${key}"`)}` - ); + note(`Overriding duplicate env key ${chalk.bold(`"${key}"`)}`); } env[key] = val; @@ -601,7 +614,7 @@ async function sync(token) { now.close(); // Show build logs - printLogs(now.host, token); + printLogs(now.host, token, currentTeam); }; const plan = await planPromise; @@ -609,13 +622,17 @@ async function sync(token) { if (plan.id === 'oss') { if (isTTY) { info( - `You are on the OSS plan. Your code will be made ${chalk.bold('public')}.` + `${ + chalk.bold( + (currentTeam && `${currentTeam.slug} is`) || `You (${user.username || user.email}) are` + ) + } on the OSS plan. Your code will be made ${chalk.bold('public')}.` ); let proceed; try { const label = 'Are you sure you want to proceed with the deployment?'; - proceed = await promptBool(label, { trailing: eraseLines(2) }); + proceed = await promptBool(label, { trailing: eraseLines(1) }); } catch (err) { if (err.message === 'USER_ABORT') { proceed = false; @@ -676,11 +693,11 @@ async function sync(token) { now.close(); // Show build logs - printLogs(now.host, token); + printLogs(now.host, token, currentTeam); } } -function printLogs(host, token) { +function printLogs(host, token, currentTeam) { // Log build const logger = new Logger(host, token, { debug, quiet }); @@ -714,7 +731,7 @@ function printLogs(host, token) { const assignments = []; for (const alias of aliasList) { - assignments.push(assignAlias(alias, token, host, apiUrl, debug)); + assignments.push(assignAlias(alias, token, host, apiUrl, debug, currentTeam)); } await Promise.all(assignments); diff --git a/bin/now-dns.js b/bin/now-dns.js index 728f8bc..b492f75 100755 --- a/bin/now-dns.js +++ b/bin/now-dns.js @@ -76,25 +76,28 @@ 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) { - handleError(err); - exit(1); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); exit(1); - }); + } + + try { + await run({token, config}); + } catch (err) { + handleError(err); + exit(1); + } + }); } -async function run(token) { - const domainRecords = new DomainRecords(apiUrl, token, { debug }); +async function run({token, config: {currentTeam, user}}) { + const domainRecords = new DomainRecords({apiUrl, token, debug, currentTeam }); const args = argv._.slice(1); const start = Date.now(); @@ -145,7 +148,11 @@ async function run(token) { } }); console.log( - `> ${count} record${count === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}` + `> ${count} record${count === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)} under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }` ); console.log(text.join('')); } else if (subcommand === 'add') { @@ -159,7 +166,11 @@ async function run(token) { const record = await domainRecords.create(param.domain, param.data); const elapsed = ms(new Date() - start); console.log( - `${chalk.cyan('> Success!')} A new DNS record for domain ${chalk.bold(param.domain)} ${chalk.gray(`(${record.uid})`)} created ${chalk.gray(`[${elapsed}]`)}` + `${chalk.cyan('> Success!')} A new DNS record for domain ${chalk.bold(param.domain)} ${chalk.gray(`(${record.uid})`)} created ${chalk.gray(`[${elapsed}]`)} (${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + })` ); } else if (subcommand === 'rm' || subcommand === 'remove') { if (args.length !== 1) { @@ -177,7 +188,7 @@ async function run(token) { const yes = await readConfirmation( record, - 'The following record will be removed permanently\n' + 'The following record will be removed permanently \n' ); if (!yes) { error('User abort'); diff --git a/bin/now-domains.js b/bin/now-domains.js index ae055bb..4758427 100755 --- a/bin/now-domains.js +++ b/bin/now-domains.js @@ -1,5 +1,8 @@ #!/usr/bin/env node +// Native +const {resolve} = require('path') + // Packages const chalk = require('chalk'); const minimist = require('minimist'); @@ -111,29 +114,31 @@ 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}\n${err.stack}`); - } - exit(1); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); + exit(1); + } + try { + await run({token, config}); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err}\n${err.stack}`); } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); exit(1); - }); + } + }); } -async function run(token) { - const domain = new NowDomains(apiUrl, token, { debug }); +async function run({token, config: {currentTeam, user}}) { + const domain = new NowDomains({apiUrl, token, debug, currentTeam }); const args = argv._.slice(1); switch (subcommand) { @@ -149,7 +154,7 @@ async function run(token) { domains.sort((a, b) => new Date(b.created) - new Date(a.created)); const current = new Date(); const header = [ - ['', 'id', 'dns', 'url', 'verified', 'created'].map(s => chalk.dim(s)) + ['', 'id', 'dns', 'domain', 'verified', 'created'].map(s => chalk.dim(s)) ]; const out = domains.length === 0 ? null @@ -157,7 +162,7 @@ async function run(token) { header.concat( domains.map(domain => { const ns = domain.isExternal ? 'external' : 'zeit.world'; - const url = chalk.underline(`https://${domain.name}`); + const url = chalk.bold(domain.name); const time = chalk.gray( ms(current - new Date(domain.created)) + ' ago' ); @@ -173,7 +178,11 @@ async function run(token) { const elapsed_ = ms(new Date() - start_); console.log( - `> ${domains.length} domain${domains.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed_}]`)}` + `> ${domains.length} domain${domains.length === 1 ? '' : 's'} found under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + } ${chalk.gray(`[${elapsed_}]`)}` ); if (out) { @@ -264,6 +273,10 @@ async function run(token) { } break; } + case 'buy': { + await require(resolve(__dirname, 'domains', 'buy.js'))({domains: domain, args, currentTeam, user}); + break; + } default: error('Please specify a valid subcommand: ls | add | rm'); help(); diff --git a/bin/now-list.js b/bin/now-list.js index ad72dc2..0813608 100755 --- a/bin/now-list.js +++ b/bin/now-list.js @@ -4,12 +4,12 @@ const fs = require('fs-promise'); const minimist = require('minimist'); const chalk = require('chalk'); -const table = require('text-table'); const ms = require('ms'); +const printf = require('printf'); +require('epipebomb')(); +const supportsColor = require('supports-color'); // Ours -const strlen = require('../lib/strlen'); -const indent = require('../lib/indent'); const Now = require('../lib'); const login = require('../lib/login'); const cfg = require('../lib/cfg'); @@ -18,7 +18,7 @@ const logo = require('../lib/utils/output/logo'); const argv = minimist(process.argv.slice(2), { string: ['config', 'token'], - boolean: ['help', 'debug'], + boolean: ['help', 'debug', 'all'], alias: { help: 'h', config: 'c', @@ -69,26 +69,33 @@ if (argv.config) { cfg.setConfigFile(argv.config); } -const config = cfg.read(); +Promise.resolve().then(async () => { + const config = await cfg.read(); -Promise.resolve(argv.token || config.token || login(apiUrl)) - .then(async token => { - try { - await list(token); - } catch (err) { - error(`Unknown error: ${err}\n${err.stack}`); - process.exit(1); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); process.exit(1); - }); + } + + try { + await list({ token, config }); + } catch (err) { + error(`Unknown error: ${err}\n${err.stack}`); + process.exit(1); + } +}); -async function list(token) { - const now = new Now(apiUrl, token, { debug }); +async function list({ token, config: { currentTeam, user } }) { + const now = new Now({ apiUrl, token, debug, currentTeam }); const start = new Date(); + if (argv.all && !app) { + console.log('> You must define an app when using `--all`'); + process.exit(1); + } let deployments; try { deployments = await now.list(app); @@ -97,40 +104,122 @@ async function list(token) { process.exit(1); } + if (!deployments || (Array.isArray(deployments) && deployments.length <= 0)) { + const match = await now.findDeployment(app); + if (match !== null && typeof match !== 'undefined') { + deployments = Array.of(match); + } + } + if (!deployments || (Array.isArray(deployments) && deployments.length <= 0)) { + const aliases = await now.listAliases(); + + const item = aliases.find(e => e.uid === app || e.alias === app); + const match = await now.findDeployment(item.deploymentId); + if (match !== null && typeof match !== 'undefined') { + deployments = Array.of(match); + } + } + now.close(); const apps = new Map(); + if (argv.all) { + await Promise.all( + deployments.map(async ({ uid }, i) => { + deployments[i].instances = await now.listInstances(uid); + }) + ); + } + for (const dep of deployments) { const deps = apps.get(dep.name) || []; apps.set(dep.name, deps.concat(dep)); } const sorted = await sort([...apps]); - const current = Date.now(); - const text = sorted - .map(([name, deps]) => { - const t = table( - deps.map(({ uid, url, created }) => { - const _url = url ? chalk.underline(`https://${url}`) : 'incomplete'; - const time = chalk.gray(ms(current - created) + ' ago'); - return [uid, _url, time]; - }), - { align: ['l', 'r', 'l'], hsep: ' '.repeat(6), stringLength: strlen } - ); - return chalk.bold(name) + '\n\n' + indent(t, 2); - }) - .join('\n\n'); - - const elapsed = ms(new Date() - start); + const urlLength = + deployments.reduce((acc, i) => { + return Math.max(acc, (i.url && i.url.length) || 0); + }, 0) + 5; + const timeNow = new Date(); console.log( - `> ${deployments.length} deployment${deployments.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}` + `> ${deployments.length} deployment${deployments.length === 1 ? '' : 's'} found under ${chalk.bold((currentTeam && currentTeam.slug) || user.username || user.email)} ${chalk.grey('[' + ms(timeNow - start) + ']')}` ); - if (text) { - console.log('\n' + text + '\n'); + let shouldShowAllInfo = false; + for (const app of apps) { + shouldShowAllInfo = + app[1].length > 5 || + app.find(depl => { + return depl.scale && depl.scale.current > 1; + }); + if (shouldShowAllInfo) { + break; + } } + if (!argv.all && shouldShowAllInfo) { + console.log( + `> To expand the list and see instances run ${chalk.cyan('`now ls --all [app]`')}` + ); + } + console.log(); + sorted.forEach(([name, deps]) => { + const listedDeployments = argv.all ? deps : deps.slice(0, 5); + console.log( + `${chalk.bold(name)} ${chalk.gray('(' + listedDeployments.length + ' of ' + deps.length + ' total)')}` + ); + const urlSpec = `%-${urlLength}s`; + console.log( + printf( + ` ${chalk.grey(urlSpec + ' %8s %-16s %8s')}`, + 'url', + 'inst #', + 'state', + 'age' + ) + ); + listedDeployments.forEach(dep => { + let state = dep.state; + let extraSpaceForState = 0; + if (state === null || typeof state === 'undefined') { + state = 'DEPLOYMENT_ERROR'; + } + if (/ERROR/.test(state)) { + state = chalk.red(state); + extraSpaceForState = 10; + } else if (state === 'FROZEN') { + state = chalk.grey(state); + extraSpaceForState = 10; + } + let spec; + if (supportsColor) { + spec = ` %-${urlLength + 10}s %8s %-${extraSpaceForState + 16}s %8s`; + } else { + spec = ` %-${urlLength + 1}s %8s %-${16}s %8s`; + } + + console.log( + printf( + spec, + chalk.underline(dep.url), + dep.scale.current, + state, + ms(timeNow - dep.created) + ) + ); + if (Array.isArray(dep.instances) && dep.instances.length > 0) { + dep.instances.forEach(i => { + console.log( + printf(` %-${urlLength + 10}s`, ` - ${chalk.underline(i.url)}`) + ); + }); + console.log(); + } + }); + console.log(); + }); } async function sort(apps) { diff --git a/bin/now-logs.js b/bin/now-logs.js new file mode 100644 index 0000000..e4f3c16 --- /dev/null +++ b/bin/now-logs.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +const qs = require('querystring'); +const minimist = require('minimist'); +const chalk = require('chalk'); +const dateformat = require('dateformat'); +const io = require('socket.io-client'); +const Now = require('../lib'); +const login = require('../lib/login'); +const cfg = require('../lib/cfg'); +const { handleError, error } = require('../lib/error'); +const logo = require('../lib/utils/output/logo'); +const { compare, deserialize } = require('../lib/logs'); +const { maybeURL, normalizeURL } = require('../lib/utils/url'); + +const argv = minimist(process.argv.slice(2), { + string: ['config', 'query', 'since', 'token', 'until'], + boolean: ['help', 'all', 'debug', 'f'], + alias: { + help: 'h', + all: 'a', + config: 'c', + debug: 'd', + token: 't', + query: 'q' + } +}); + +let deploymentIdOrURL = argv._[0]; + +const help = () => { + console.log( + ` + ${chalk.bold(`${logo} now logs`)} + + ${chalk.dim('Options:')} + + -h, --help output usage information + -a, --all include access logs + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline('FILE')} config file + -d, --debug debug mode [off] + -f wait for additional data [off] + -n ${chalk.bold.underline('NUMBER')} number of logs [1000] + -q ${chalk.bold.underline('QUERY')}, --query=${chalk.bold.underline('QUERY')} search query + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline('TOKEN')} login token + --since=${chalk.bold.underline('SINCE')} only return logs after date (ISO 8601) + --until=${chalk.bold.underline('UNTIL')} only return logs before date (ISO 8601), ignored if the f option is enbled. + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Print logs for the deployment ${chalk.dim('`deploymentId`')} + + ${chalk.cyan('$ now logs deploymentId')} +` + ); +}; + +if (argv.help || !deploymentIdOrURL) { + help(); + process.exit(0); +} + +// Options +const debug = argv.debug; +const apiUrl = argv.url || 'https://api.zeit.co'; +if (argv.config) { + cfg.setConfigFile(argv.config); +} +const limit = typeof argv.n === 'number' ? argv.n : 1000; +const query = argv.query || ''; +const follow = argv.f; +const types = argv.all ? [] : ['command', 'stdout', 'stderr', 'exit']; + +let since; +try { + since = argv.since ? toSerial(argv.since) : null; +} catch (err) { + error(`Invalid date string: ${argv.since}`); + process.exit(1); +} + +let until; +try { + until = argv.until ? toSerial(argv.until) : null; +} catch (err) { + error(`Invalid date string: ${argv.until}`); + process.exit(1); +} + +if (maybeURL(deploymentIdOrURL)) { + deploymentIdOrURL = normalizeURL(deploymentIdOrURL); +} + +Promise.resolve() + .then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || login(apiUrl); + } catch (err) { + error(`Authentication error – ${err.message}`); + process.exit(1); + } + + await printLogs({token, config}); + }) + .catch(err => { + error(`Unknown error: ${err.stack}`); + process.exit(1); + }); + +async function printLogs({token, config: {currentTeam}}) { + let buf = []; + let init = false; + let lastLog; + + if (!follow) { + onLogs(await fetchLogs({token, currentTeam, since, until })); + return; + } + + const isURL = deploymentIdOrURL.includes('.'); + const q = qs.stringify({ + deploymentId: isURL ? '' : deploymentIdOrURL, + host: isURL ? deploymentIdOrURL : '', + types: types.join(','), + query + }); + + const socket = io(`https://log-io.zeit.co?${q}`); + socket.on('connect', () => { + if (debug) { + console.log('> [debug] Socket connected'); + } + }); + + socket.on('auth', callback => { + if (debug) { + console.log('> [debug] Socket authenticate'); + } + callback(token); + }); + + socket.on('ready', () => { + if (debug) { + console.log('> [debug] Socket ready'); + } + + // For the case socket reconnected + const _since = lastLog ? lastLog.serial : since; + + fetchLogs({token, currentTeam, since: _since }).then(logs => { + init = true; + const m = {}; + logs.concat(buf.map(b => b.log)).forEach(l => { + m[l.id] = l; + }); + buf = []; + onLogs(Object.values(m)); + }); + }); + + socket.on('logs', l => { + const log = deserialize(l); + let timer; + if (init) { + // Wait for other logs for a while + // and sort them in the correct order + timer = setTimeout(() => { + buf.sort((a, b) => compare(a.log, b.log)); + const idx = buf.findIndex(b => b.log.id === log.id); + buf.slice(0, idx + 1).forEach(b => { + clearTimeout(b.timer); + onLog(b.log); + }); + buf = buf.slice(idx + 1); + }, 300); + } + buf.push({ log, timer }); + }); + + socket.on('disconnect', () => { + if (debug) { + console.log('> [debug] Socket disconnect'); + } + init = false; + }); + + socket.on('error', err => { + if (debug) { + console.log('> [debug] Socket error', err.stack); + } + }); + + function onLogs(logs) { + logs.sort(compare).forEach(onLog); + } + + function onLog(log) { + lastLog = log; + printLog(log); + } +} + +function printLog(log) { + let data; + const obj = log.object; + if (log.type === 'request') { + data = + `REQ "${obj.method} ${obj.uri} ${obj.protocol}"` + + ` ${obj.remoteAddr} - ${obj.remoteUser || ''}` + + ` "${obj.referer || ''}" "${obj.userAgent}"`; + } else if (log.type === 'response') { + data = + `RES "${obj.method} ${obj.uri} ${obj.protocol}"` + + ` ${obj.status} ${obj.bodyBytesSent}`; + } else { + data = obj + ? JSON.stringify(obj, null, 2) + : (log.text || '').replace(/\n$/, ''); + } + + const date = dateformat(log.date, 'mm/dd hh:MM TT'); + + data.split('\n').forEach((line, i) => { + if (i === 0) { + console.log(`${chalk.dim(date)} ${line}`); + } else { + console.log(`${repeat(' ', date.length)} ${line}`); + } + }); +} + +async function fetchLogs({token, currentTeam, since, until } = {}) { + const now = new Now({apiUrl, token, debug, currentTeam }); + + let logs; + try { + logs = await now.logs(deploymentIdOrURL, { + types, + limit, + query, + since, + until + }); + } catch (err) { + handleError(err); + process.exit(1); + } finally { + now.close(); + } + + return logs.map(deserialize); +} + +function repeat(s, n) { + return new Array(n + 1).join(s); +} + +function toSerial(datestr) { + const t = Date.parse(datestr); + if (isNaN(t)) { + throw new TypeError('Invalid date string'); + } + + const pidLen = 19; + const seqLen = 19; + return t + repeat('0', pidLen + seqLen); +} diff --git a/bin/now-open.js b/bin/now-open.js index 545ed72..5561fba 100644 --- a/bin/now-open.js +++ b/bin/now-open.js @@ -61,24 +61,27 @@ if (argv.config) { cfg.setConfigFile(argv.config); } -const config = cfg.read(); - -Promise.resolve(argv.token || config.token || login(apiUrl)) - .then(async token => { - try { - await open(token); - } catch (err) { - error(`Unknown error: ${err}\n${err.stack}`); - process.exit(1); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); +Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); process.exit(1); - }); + } + + try { + await open({token, config}); + } catch (err) { + error(`Unknown error: ${err}\n${err.stack}`); + process.exit(1); + } +}); -async function open(token) { - const now = new Now(apiUrl, token, { debug }); +async function open({token, config: {currentTeam, user}}) { + const now = new Now({apiUrl, token, debug, currentTeam }); let deployments; try { @@ -111,7 +114,11 @@ async function open(token) { ); if (typeof currentProjectDeployments === 'undefined') { - console.log(`no deployments found for ${chalk.bold(pkg.name)}`); + console.log(`No deployments found for ${chalk.bold(pkg.name)} under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }`); process.exit(0); } @@ -121,7 +128,11 @@ async function open(token) { try { const url = `https://${latestDeploy.url}`; - console.log(`Opening the latest deployment for ${chalk.bold(pkg.name)}...`); + console.log(`Opening the latest deployment for ${chalk.bold(pkg.name)}... under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }`); console.log(`Here's the URL: ${chalk.underline(url)}`); opn(url); diff --git a/bin/now-remove.js b/bin/now-remove.js index aa61bf1..88d8d71 100755 --- a/bin/now-remove.js +++ b/bin/now-remove.js @@ -1,18 +1,18 @@ #!/usr/bin/env node // Packages -const minimist = require('minimist'); -const chalk = require('chalk'); -const ms = require('ms'); -const table = require('text-table'); -const isURL = require('is-url'); +const minimist = require('minimist') +const chalk = require('chalk') +const ms = require('ms') +const table = require('text-table') // Ours -const Now = require('../lib'); -const login = require('../lib/login'); -const cfg = require('../lib/cfg'); -const { handleError, error } = require('../lib/error'); -const logo = require('../lib/utils/output/logo'); +const Now = require('../lib') +const login = require('../lib/login') +const cfg = require('../lib/cfg') +const {handleError, error} = require('../lib/error') +const logo = require('../lib/utils/output/logo') +const {normalizeURL} = require('../lib/utils/url') const argv = minimist(process.argv.slice(2), { string: ['config', 'token'], @@ -76,7 +76,24 @@ if (argv.config) { cfg.setConfigFile(argv.config); } -const config = cfg.read(); +Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = (await argv.token) || config.token || login(apiUrl); + } catch (err) { + error(`Authentication error – ${err.message}`); + process.exit(1); + } + + try { + await remove({token, config}); + } catch (err) { + error(`Unknown error: ${err}\n${err.stack}`); + process.exit(1); + } +}); function readConfirmation(matches) { return new Promise(resolve => { @@ -116,43 +133,16 @@ function readConfirmation(matches) { }); } -Promise.resolve(argv.token || config.token || login(apiUrl)) - .then(async token => { - try { - await remove(token); - } catch (err) { - error(`Unknown error: ${err}\n${err.stack}`); - process.exit(1); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); - process.exit(1); - }); - -async function remove(token) { - const now = new Now(apiUrl, token, { debug }); +async function remove({token, config: {currentTeam}}) { + const now = new Now({apiUrl, token, debug, currentTeam }); const deployments = await now.list(); const matches = deployments.filter(d => { - return ids.find(id => { - // Normalize URL by removing slash from the end - if (isURL(id) && id.slice(-1) === '/') { - id = id.slice(0, -1); - } - - // `url` should match the hostname of the deployment - let u = id.replace(/^https:\/\//i, ''); - - if (u.indexOf('.') === -1) { - // `.now.sh` domain is implied if just the subdomain is given - u += '.now.sh'; - } - - return d.uid === id || d.name === id || d.url === u; - }); - }); + return ids.some(id => { + return d.uid === id || d.name === id || d.url === normalizeURL(id) + }) + }) if (matches.length === 0) { error( diff --git a/bin/now-scale.js b/bin/now-scale.js new file mode 100755 index 0000000..78b63db --- /dev/null +++ b/bin/now-scale.js @@ -0,0 +1,309 @@ +#!/usr/bin/env node + +// Packages +const chalk = require('chalk'); +const isURL = require('is-url'); +const minimist = require('minimist'); +const ms = require('ms'); +const printf = require('printf'); +require('epipebomb')(); +const supportsColor = require('supports-color'); + +// Ours +const cfg = require('../lib/cfg'); +const { handleError, error } = require('../lib/error'); +const NowScale = require('../lib/scale'); +const login = require('../lib/login'); +const exit = require('../lib/utils/exit'); +const logo = require('../lib/utils/output/logo'); +const info = require('../lib/scale-info'); + +const argv = minimist(process.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { help: 'h', config: 'c', debug: 'd', token: 't' } +}); + +let id = argv._[0]; +const scaleArg = argv._[1]; +const optionalScaleArg = argv._[2]; + +// Options +const help = () => { + console.log( + ` + ${chalk.bold(`${logo} now scale`)} ls + ${chalk.bold(`${logo} now scale`)} + ${chalk.bold(`${logo} now scale`)} [max] + + ${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] + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Create an deployment with 3 instances, never sleeps: + + ${chalk.cyan('$ now scale my-deployment-ntahoeato.now.sh 3')} + + ${chalk.gray('–')} Create an automatically scaling deployment: + + ${chalk.cyan('$ now scale my-deployment-ntahoeato.now.sh 1 5')} + + ${chalk.gray('–')} Create an automatically scaling deployment without specifying max: + + ${chalk.cyan('$ now scale my-deployment-ntahoeato.now.sh 1 auto')} + + ${chalk.gray('–')} Create an automatically scaling deployment without specifying min or max: + + ${chalk.cyan('$ now scale my-deployment-ntahoeato.now.sh auto')} + + ${chalk.gray('–')} Create an deployment that is always active and never "sleeps": + + ${chalk.cyan('$ now scale my-deployment-ntahoeato.now.sh 1')} + ` + ); +}; + +// Options +const debug = argv.debug; +const apiUrl = argv.url || 'https://api.zeit.co'; + +if (argv.config) { + cfg.setConfigFile(argv.config); +} + +if (argv.help) { + help(); + exit(0); +} else { + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); + exit(1); + } + + try { + await run({ token, config }); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err}\n${err.stack}`); + } + exit(1); + } + }); +} + +function guessParams() { + if (Number.isInteger(scaleArg) && !optionalScaleArg) { + return { min: scaleArg, max: scaleArg }; + } else if (Number.isInteger(scaleArg) && Number.isInteger(optionalScaleArg)) { + return { min: scaleArg, max: optionalScaleArg }; + } else if (Number.isInteger(scaleArg) && optionalScaleArg === 'auto') { + return { min: scaleArg, max: 'auto' }; + } else if (!scaleArg && !optionalScaleArg) { + return { min: 1, max: 'auto' }; + } + help(); + process.exit(1); +} + +async function run({ token, config: { currentTeam } }) { + const scale = new NowScale({ apiUrl, token, debug, currentTeam }); + const start = Date.now(); + + if (id === 'ls') { + await list(scale); + process.exit(0); + } else if (id === 'info') { + await info(scale); + process.exit(0); + } else if (id) { + // Normalize URL by removing slash from the end + if (isURL(id) && id.slice(-1) === '/') { + id = id.slice(0, -1); + } + } else { + error('Please specify a deployment: now scale '); + help(); + exit(1); + } + + const deployments = await scale.list(); + + const match = deployments.find(d => { + // `url` should match the hostname of the deployment + let u = id.replace(/^https:\/\//i, ''); + + if (u.indexOf('.') === -1) { + // `.now.sh` domain is implied if just the subdomain is given + u += '.now.sh'; + } + + return d.uid === id || d.name === id || d.url === u; + }); + + if (!match) { + error(`Could not find any deployments matching ${id}`); + return process.exit(1); + } + + const { min, max } = guessParams(); + + if ( + !(Number.isInteger(min) || min === 'auto') && + !(Number.isInteger(max) || max === 'auto') + ) { + help(); + return exit(1); + } + + const { + max: currentMax, + min: currentMin, + current: currentCurrent + } = match.scale; + if ( + max === currentMax && + min === currentMin && + Number.isInteger(min) && + currentCurrent >= min && + Number.isInteger(max) && + currentCurrent <= max + ) { + console.log(`> Done`); + return; + } + + if ((match.state === 'FROZEN' || match.scale.current === 0) && min > 0) { + console.log( + `> Deployment is currently in 0 replicas, preparing deployment for scaling...` + ); + if (match.scale.max < 1) { + await scale.setScale(match.uid, { min: 0, max: 1 }); + } + await scale.unfreeze(match); + } + + const { min: newMin, max: newMax } = await scale.setScale(match.uid, { + min, + max + }); + + const elapsed = ms(new Date() - start); + + const currentReplicas = match.scale.current; + const log = console.log; + log(`> ${chalk.cyan('Success!')} Configured scaling rules [${elapsed}]`); + log(); + log( + `${chalk.bold(match.url)} (${chalk.gray(currentReplicas)} ${chalk.gray('current')})` + ); + log(printf('%6s %s', 'min', chalk.bold(newMin))); + log(printf('%6s %s', 'max', chalk.bold(newMax))); + log(printf('%6s %s', 'auto', chalk.bold(newMin === newMax ? '✖' : '✔'))); + log(); + await info(scale, match.url); + + scale.close(); +} + +async function list(scale) { + let deployments; + try { + const app = argv._[1]; + deployments = await scale.list(app); + } catch (err) { + handleError(err); + process.exit(1); + } + + scale.close(); + + const apps = new Map(); + + for (const dep of deployments) { + const deps = apps.get(dep.name) || []; + apps.set(dep.name, deps.concat(dep)); + } + + const timeNow = new Date(); + const urlLength = + deployments.reduce((acc, i) => { + return Math.max(acc, (i.url && i.url.length) || 0); + }, 0) + 5; + + for (const app of apps) { + const depls = argv.all ? app[1] : app[1].slice(0, 5); + console.log( + `${chalk.bold(app[0])} ${chalk.gray('(' + depls.length + ' of ' + app[1].length + ' total)')}` + ); + console.log(); + const urlSpec = `%-${urlLength}s`; + console.log( + printf( + ` ${chalk.grey(urlSpec + ' %8s %8s %8s %8s %8s')}`, + 'url', + 'cur', + 'min', + 'max', + 'auto', + 'age' + ) + ); + for (const instance of depls) { + if (instance.scale.current > 0) { + let spec; + if (supportsColor) { + spec = ` %-${urlLength + 10}s %8s %8s %8s %8s %8s`; + } else { + spec = ` %-${urlLength + 1}s %8s %8s %8s %8s %8s`; + } + console.log( + printf( + spec, + chalk.underline(instance.url), + instance.scale.current, + instance.scale.min, + instance.scale.max, + instance.scale.max === instance.scale.min ? '✖' : '✔', + ms(timeNow - instance.created) + ) + ); + } else { + let spec; + if (supportsColor) { + spec = ` %-${urlLength + 10}s ${chalk.gray('%8s %8s %8s %8s %8s')}`; + } else { + spec = ` %-${urlLength + 1}s ${chalk.gray('%8s %8s %8s %8s %8s')}`; + } + console.log( + printf( + spec, + chalk.underline(instance.url), + instance.scale.current, + instance.scale.min, + instance.scale.max, + instance.scale.max === instance.scale.min ? '✖' : '✔', + ms(timeNow - instance.created) + ) + ); + } + } + console.log(); + } +} + +process.on('uncaughtException', err => { + handleError(err); + exit(1); +}); diff --git a/bin/now-secrets.js b/bin/now-secrets.js index 3d47f4c..55d67e2 100755 --- a/bin/now-secrets.js +++ b/bin/now-secrets.js @@ -86,25 +86,28 @@ 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) { - handleError(err); - exit(1); - } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); exit(1); - }); + } + + try { + await run({token, config}); + } catch (err) { + handleError(err); + exit(1); + } + }); } -async function run(token) { - const secrets = new NowSecrets(apiUrl, token, { debug }); +async function run({token, config: {currentTeam, user}}) { + const secrets = new NowSecrets({apiUrl, token, debug, currentTeam }); const args = argv._.slice(1); const start = Date.now(); @@ -120,7 +123,11 @@ async function run(token) { const elapsed = ms(new Date() - start); console.log( - `> ${list.length} secret${list.length === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}` + `> ${list.length} secret${list.length === 1 ? '' : 's'} found under ${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + } ${chalk.gray(`[${elapsed}]`)}` ); if (list.length > 0) { @@ -226,7 +233,11 @@ async function run(token) { const elapsed = ms(new Date() - start); console.log( - `${chalk.cyan('> Success!')} Secret ${chalk.bold(name.toLowerCase())} ${chalk.gray(`(${secret.uid})`)} added ${chalk.gray(`[${elapsed}]`)}` + `${chalk.cyan('> Success!')} Secret ${chalk.bold(name.toLowerCase())} ${chalk.gray(`(${secret.uid})`)} added (${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + }) ${chalk.gray(`[${elapsed}]`)}` ); return secrets.close(); } diff --git a/bin/now-teams.js b/bin/now-teams.js new file mode 100644 index 0000000..c44c9b0 --- /dev/null +++ b/bin/now-teams.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +// Native +const { resolve } = require('path'); + +// Packages +const chalk = require('chalk'); +const minimist = require('minimist'); + +// Ours +const login = require('../lib/login'); +const cfg = require('../lib/cfg'); +const error = require('../lib/utils/output/error'); +const NowTeams = require('../lib/teams'); +const logo = require('../lib/utils/output/logo'); +const exit = require('../lib/utils/exit'); + +const argv = minimist(process.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't', + switch: 'change' + } +}); + +const subcommand = argv._[0]; + +const help = () => { + console.log( + ` + ${chalk.bold(`${logo} now teams`)} + + ${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('–')} Add a new team: + + ${chalk.cyan('$ now teams add')} + + ${chalk.gray('–')} Switch to a team: + + ${chalk.cyan(`$ now switch `)} + + ${chalk.gray('–')} If the id is omitted, you can choose interactively + + ${chalk.yellow('NOTE:')} When you switch, everything you add, list or remove will be scoped that team! + + ${chalk.gray('–')} Invite new members (interactively): + + ${chalk.cyan(`$ now teams invite`)} + + ${chalk.gray('–')} Invite a specific email: + + ${chalk.cyan(`$ now teams invite geist@zeit.co`)} + + ${chalk.gray('–')} Remove a team: + + ${chalk.cyan(`$ now teams rm `)} + + ${chalk.gray('–')} If the id is omitted, you can choose interactively + ` + ); +}; + +// Options +const debug = argv.debug; +const apiUrl = argv.url || 'https://api.zeit.co'; + +if (argv.config) { + cfg.setConfigFile(argv.config); +} + +if (argv.help || !subcommand) { + help(); + exit(0); +} else { + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); + exit(1); + } + + try { + await run({token, config}); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err.stack}`); + } + exit(1); + } + }); +} + +async function run({token, config: {currentTeam}}) { + const teams = new NowTeams({ apiUrl, token, debug, currentTeam }); + const args = argv._.slice(1); + + switch (subcommand) { + case 'switch': + case 'change': { + await require(resolve(__dirname, 'teams', 'switch.js'))(teams, args); + break; + } + case 'add': + case 'create': { + await require(resolve(__dirname, 'teams', 'add.js'))(teams); + break; + } + + case 'invite': { + await require(resolve(__dirname, 'teams', 'invite.js'))(teams, args); + break; + } + + default: { + let code = 0; + if (subcommand !== 'help') { + error('Please specify a valid subcommand: ls | add | rm | set-default'); + code = 1; + } + help(); + exit(code); + } + } +} diff --git a/bin/now-upgrade.js b/bin/now-upgrade.js index 517800b..2e06d59 100644 --- a/bin/now-upgrade.js +++ b/bin/now-upgrade.js @@ -4,7 +4,6 @@ const chalk = require('chalk'); const minimist = require('minimist'); const ms = require('ms'); -const stripAnsi = require('strip-ansi'); // Ours const login = require('../lib/login'); @@ -18,6 +17,8 @@ const success = require('../lib/utils/output/success'); const cmd = require('../lib/utils/output/cmd'); const logo = require('../lib/utils/output/logo'); +const {bold} = chalk + const argv = minimist(process.argv.slice(2), { string: ['config', 'token'], boolean: ['help', 'debug'], @@ -78,25 +79,28 @@ 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); + Promise.resolve().then(async () => { + const config = await cfg.read(); + + let token; + try { + token = argv.token || config.token || (await login(apiUrl)); + } catch (err) { + error(`Authentication error – ${err.message}`); + exit(1); + } + + try { + await run({token, config}); + } catch (err) { + if (err.userError) { + error(err.message); + } else { + error(`Unknown error: ${err.stack}`); } - }) - .catch(e => { - error(`Authentication error – ${e.message}`); exit(1); - }); + } + }); } function buildInquirerChoices(current, until) { @@ -106,37 +110,60 @@ function buildInquirerChoices(current, until) { } 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'; + + const currentText = bold('(current)') + let ossName = `OSS ${bold('FREE')}` + let premiumName = `Premium ${bold('$15')}` + let proName = `Pro ${bold('$50')}` + let advancedName = `Advanced ${bold('$200')}` + + switch (current) { + case 'oss': { + ossName += indent(currentText, 6) + break + } + case 'premium': { + premiumName += indent(currentText, 3) + break + } + case 'pro': { + proName += indent(currentText, 7) + break + } + case 'advanced': { + advancedName += indent(currentText, 1) + break + } + default: { + ossName += indent(currentText, 6) + } + } + 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'), + name: ossName, value: 'oss', - short: 'oss FREE' + short: `OSS ${bold('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'), + name: premiumName, value: 'premium', - short: 'premium $15/mo' - } + short: `Premium ${bold('$15')}` + }, + { + name: proName, + value: 'pro', + short: `Pro ${bold('$50')}` + }, + { + name: advancedName, + value: 'advanced', + short: `Advanced ${bold('$200')}` + }, ]; } -async function run(token) { +async function run({token, config: {currentTeam, user}}) { const args = argv._; if (args.length > 1) { error('Invalid number of arguments'); @@ -144,11 +171,11 @@ async function run(token) { } const start = new Date(); - const plans = new NowPlans(apiUrl, token, { debug }); + const plans = new NowPlans({ apiUrl, token, debug, currentTeam }); let planId = args[0]; - if (![undefined, 'oss', 'premium'].includes(planId)) { + if (![undefined, 'oss', 'premium', 'pro', 'advanced'].includes(planId)) { error(`Invalid plan name – should be ${code('oss')} or ${code('premium')}`); return exit(1); } @@ -158,14 +185,21 @@ async function run(token) { if (planId === undefined) { const elapsed = ms(new Date() - start); - let message = `To manage this from the web UI, head to https://zeit.co/account\n`; - message += `> Selecting a plan for your account ${chalk.gray(`[${elapsed}]`)}`; + let message = `For more info, please head to https://zeit.co`; + message = currentTeam ? + `${message}/${currentTeam.slug}/settings/plan` : + `${message}/account/plan` + message += `\n> Select a plan for ${ + bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + } ${chalk.gray(`[${elapsed}]`)}`; const choices = buildInquirerChoices(currentPlan.id, currentPlan.until); planId = await listInput({ message, choices, - separator: true, + separator: false, abort: 'end' }); } @@ -182,38 +216,27 @@ async function run(token) { 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') { + if (err.code === 'customer_not_found' || err.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}`); + error(`An unknow error occured. Please try again later ${err.message}`); } plans.close(); return; } - if (currentPlan.until && newPlan.id === 'premium') { + if (currentPlan.until && newPlan.id !== 'oss') { success( - `The cancelation has been undone. You're back on the ${chalk.bold('Premium plan')}` + `The cancelation has been undone. You're back on the ${chalk.bold(`${newPlan.name} 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` + `Your plan will be switched to ${chalk.bold(newPlan.name)} in ${chalk.bold(newPlan.until)}. Your card will not be charged again` ); } else { - success(`You're now on the ${chalk.bold('Premium plan')}`); + success(`You're now on the ${chalk.bold(`${newPlan.name} plan`)}`); } plans.close(); diff --git a/bin/now.js b/bin/now.js index 3d05c97..241613b 100755 --- a/bin/now.js +++ b/bin/now.js @@ -47,7 +47,13 @@ const commands = new Set([ 'billing', 'upgrade', 'downgrade', - 'open' + 'open', + 'team', + 'teams', + 'switch', + 'log', + 'logs', + 'scale' ]); const aliases = new Map([ @@ -59,11 +65,14 @@ const aliases = new Map([ ['cert', 'certs'], ['secret', 'secrets'], ['cc', 'billing'], - ['downgrade', 'upgrade'] + ['downgrade', 'upgrade'], + ['team', 'teams'], + ['switch', 'teams switch'], + ['log', 'logs'] ]); let cmd = defaultCommand; -const args = process.argv.slice(2); +let args = process.argv.slice(2); const index = args.findIndex(a => commands.has(a)); if (index > -1) { @@ -82,6 +91,11 @@ if (index > -1) { } cmd = aliases.get(cmd) || cmd; + if (cmd.includes(' ')) { + const parts = cmd.split(' '); + cmd = parts.shift(); + args = [].concat(parts, args); + } } // Don't throw a useless error message when running `now help help` diff --git a/bin/teams/add.js b/bin/teams/add.js new file mode 100644 index 0000000..f1ae90a --- /dev/null +++ b/bin/teams/add.js @@ -0,0 +1,127 @@ +// Packages +const chalk = require('chalk'); + +// Ours +const stamp = require('../../lib/utils/output/stamp'); +const info = require('../../lib/utils/output/info'); +const error = require('../../lib/utils/output/error'); +const wait = require('../../lib/utils/output/wait'); +const rightPad = require('../../lib/utils/output/right-pad'); +const eraseLines = require('../../lib/utils/output/erase-lines'); +const { tick } = require('../../lib/utils/output/chars'); +const success = require('../../lib/utils/output/success'); +const cmd = require('../../lib/utils/output/cmd'); +const note = require('../../lib/utils/output/note'); +const uid = require('../../lib/utils/output/uid'); +const textInput = require('../../lib/utils/input/text'); +const exit = require('../../lib/utils/exit'); +const cfg = require('../../lib/cfg'); + +function validateSlugKeypress(data, value) { + // TODO: the `value` here should contain the current value + the keypress + // should be fixed on utils/input/text.js + return /^[a-zA-Z]+[a-zA-Z0-9_-]*$/.test(value + data); +} + +function gracefulExit() { + console.log(); // Blank line + note( + `Your team is now active for all ${cmd('now')} commands!\n Run ${cmd('now switch')} to change it in the future.` + ); + return exit(); +} + +const teamUrlPrefix = rightPad('Team URL', 14) + chalk.gray('zeit.co/'); +const teamNamePrefix = rightPad('Team Name', 14); + +module.exports = async function(teams) { + let slug; + let team; + let elapsed; + let stopSpinner; + + info( + `Pick a team identifier for its url (e.g.: ${chalk.cyan('`zeit.co/acme`')})` + ); + do { + try { + // eslint-disable-next-line no-await-in-loop + slug = await textInput({ + label: `- ${teamUrlPrefix}`, + validateKeypress: validateSlugKeypress, + initialValue: slug, + valid: team, + forceLowerCase: true + }); + } catch (err) { + if (err.message === 'USER_ABORT') { + info('Aborted'); + return exit(); + } + throw err; + } + elapsed = stamp(); + stopSpinner = wait(teamUrlPrefix + slug); + + let res + try { + // eslint-disable-next-line no-await-in-loop + res = await teams.create({ slug }); + stopSpinner(); + team = res + } catch (err) { + stopSpinner(); + eraseLines(2); + error(err.message) + } + } while (!team); + + eraseLines(2); + success(`Team created ${uid(team.id)} ${elapsed()}`); + console.log(chalk.cyan(`${tick} `) + teamUrlPrefix + slug + '\n'); + + info('Pick a display name for your team'); + let name; + try { + name = await textInput({ + label: `- ${teamNamePrefix}`, + validateValue: value => value.trim().length > 0 + }); + } catch (err) { + if (err.message === 'USER_ABORT') { + info('No name specified'); + gracefulExit(); + } else { + throw err; + } + } + elapsed = stamp(); + stopSpinner = wait(teamNamePrefix + name); + const res = await teams.edit({ id: team.id, name }); + stopSpinner(); + + eraseLines(2); + if (res.error) { + error(res.error.message); + console.log(`${chalk.red(`✖ ${teamNamePrefix}`)}${name}`); + exit(1); + // TODO: maybe we want to ask the user to retry? not sure if + // there's a scenario where that would be wanted + } + + team = Object.assign(team, res); + + success(`Team name saved ${elapsed()}`); + console.log(chalk.cyan(`${tick} `) + teamNamePrefix + team.name + '\n'); + + stopSpinner = wait('Saving'); + await cfg.merge({ currentTeam: team }); + stopSpinner(); + + await require('./invite')(teams, [], { + introMsg: 'Invite your team mates! When done, press enter on an empty field', + noopMsg: `You can invite team mates later by running ${cmd('now teams invite')}` + }); + + gracefulExit(); +}; diff --git a/bin/teams/invite.js b/bin/teams/invite.js new file mode 100644 index 0000000..ed1ea29 --- /dev/null +++ b/bin/teams/invite.js @@ -0,0 +1,160 @@ +// Packages +const chalk = require('chalk'); + +// Ours +const regexes = require('../../lib/utils/input/regexes'); +const wait = require('../../lib/utils/output/wait'); +const cfg = require('../../lib/cfg'); +const fatalError = require('../../lib/utils/fatal-error'); +const cmd = require('../../lib/utils/output/cmd'); +const info = require('../../lib/utils/output/info'); +const stamp = require('../../lib/utils/output/stamp'); +const param = require('../../lib/utils/output/param'); +const { tick } = require('../../lib/utils/output/chars'); +const rightPad = require('../../lib/utils/output/right-pad'); +const textInput = require('../../lib/utils/input/text'); +const eraseLines = require('../../lib/utils/output/erase-lines'); +const success = require('../../lib/utils/output/success'); +const error = require('../../lib/utils/output/error'); + +function validateEmail(data) { + return regexes.email.test(data.trim()) || data.length === 0; +} + +const domains = Array.from( + new Set([ + 'aol.com', + 'gmail.com', + 'google.com', + 'yahoo.com', + 'ymail.com', + 'hotmail.com', + 'live.com', + 'outlook.com', + 'inbox.com', + 'mail.com', + 'gmx.com', + 'icloud.com' + ]) +); + +function emailAutoComplete(value, teamSlug) { + const parts = value.split('@'); + + if (parts.length === 2 && parts[1].length > 0) { + const [, host] = parts; + let suggestion = false; + + domains.unshift(teamSlug); + for (const domain of domains) { + if (domain.startsWith(host)) { + suggestion = domain.substr(host.length); + break; + } + } + + domains.shift(); + return suggestion; + } + + return false; +} + +module.exports = async function( + teams, + args, + { + introMsg, + noopMsg = 'No changes made' + } = {} +) { + const { user, currentTeam } = await cfg.read(); + + domains.push(user.email.split('@')[1]); + + if (!currentTeam) { + let err = `You can't run this command under ${param(user.username || user.email)}.\n`; + err += `${chalk.gray('>')} Run ${cmd('now switch')} to choose to a team.`; + return fatalError(err); + } + + info(introMsg || `Inviting team members to ${chalk.bold(currentTeam.name)}`); + + if (args.length > 0) { + for (const email of args) { + if (regexes.email.test(email)) { + const stopSpinner = wait(email); + const elapsed = stamp(); + // eslint-disable-next-line no-await-in-loop + await teams.inviteUser({ teamId: currentTeam.id, email }); + stopSpinner(); + console.log(`${chalk.cyan(tick)} ${email} ${elapsed()}`); + } else { + console.log(`${chalk.red(`✖ ${email}`)} ${chalk.gray('[invalid]')}`); + } + } + return; + } + + const inviteUserPrefix = rightPad('Invite User', 14); + const emails = []; + let hasError = false + let email; + do { + email = ''; + try { + // eslint-disable-next-line no-await-in-loop + email = await textInput({ + label: `- ${inviteUserPrefix}`, + validateValue: validateEmail, + autoComplete: value => emailAutoComplete(value, currentTeam.slug) + }); + } catch (err) { + if (err.message !== 'USER_ABORT') { + throw err; + } + } + let elapsed; + let stopSpinner; + if (email) { + elapsed = stamp(); + stopSpinner = wait(inviteUserPrefix + email); + try { + // eslint-disable-next-line no-await-in-loop + await teams.inviteUser({ teamId: currentTeam.id, email }); + stopSpinner(); + email = `${email} ${elapsed()}`; + emails.push(email); + console.log(`${chalk.cyan(tick)} ${inviteUserPrefix}${email}`); + if (hasError) { + hasError = false + eraseLines(emails.length + 2); + info(introMsg || `Inviting team members to ${chalk.bold(currentTeam.name)}`); + for (const email of emails) { + console.log(`${chalk.cyan(tick)} ${inviteUserPrefix}${email}`); + } + } + } catch (err) { + stopSpinner() + eraseLines(emails.length + 2); + error(err.message) + hasError = true + for (const email of emails) { + console.log(`${chalk.cyan(tick)} ${inviteUserPrefix}${email}`); + } + } + } + } while (email !== ''); + + eraseLines(emails.length + 2); + + const n = emails.length; + if (emails.length === 0) { + info(noopMsg); + } else { + success(`Invited ${n} team mate${n > 1 ? 's' : ''}`); + for (const email of emails) { + console.log(`${chalk.cyan(tick)} ${inviteUserPrefix}${email}`); + } + } +}; diff --git a/bin/teams/switch.js b/bin/teams/switch.js new file mode 100644 index 0000000..bd3c56b --- /dev/null +++ b/bin/teams/switch.js @@ -0,0 +1,126 @@ +const chalk = require('chalk'); + +const wait = require('../../lib/utils/output/wait'); +const listInput = require('../../lib/utils/input/list'); +const cfg = require('../../lib/cfg'); +const exit = require('../../lib/utils/exit'); +const success = require('../../lib/utils/output/success'); +const info = require('../../lib/utils/output/info'); +const error = require('../../lib/utils/output/error'); +const param = require('../../lib/utils/output/param'); + +async function updateCurrentTeam({ cfg, newTeam } = {}) { + delete newTeam.created; + delete newTeam.creator_id; + await cfg.merge({ currentTeam: newTeam }); +} + +module.exports = async function(teams, args) { + let stopSpinner = wait('Fetching teams'); + const list = (await teams.ls()).teams; + let { user, currentTeam } = await cfg.read(); + const accountIsCurrent = !currentTeam; + stopSpinner(); + + if (accountIsCurrent) { + currentTeam = { + slug: user.username || user.email + }; + } + + if (args.length !== 0) { + const desiredSlug = args[0]; + + const newTeam = list.find(team => team.slug === desiredSlug); + if (newTeam) { + await updateCurrentTeam({ cfg, newTeam }); + success(`The team ${chalk.bold(newTeam.name)} is now active!`); + return exit(); + } + if (desiredSlug === user.username) { + stopSpinner = wait('Saving'); + await cfg.remove('currentTeam'); + stopSpinner(); + return success(`Your account (${chalk.bold(desiredSlug)}) is now active!`); + } + error(`Could not find membership for team ${param(desiredSlug)}`); + return exit(1); + } + + const choices = list.map(({ slug, name }) => { + name = `${slug} (${name})`; + if (slug === currentTeam.slug) { + name += ` ${chalk.bold('(current)')}`; + } + + return { + name, + value: slug, + short: slug + }; + }); + + const suffix = accountIsCurrent ? ` ${chalk.bold('(current)')}` : ''; + + const userEntryName = user.username ? + `${user.username} (${user.email})${suffix}` : + user.email + + choices.unshift({ + name: userEntryName, + value: user.email, + short: user.username + }); + + // Let's bring the current team to the beginning of the list + if (!accountIsCurrent) { + const index = choices.findIndex( + choice => choice.value === currentTeam.slug + ); + const choice = choices.splice(index, 1)[0]; + choices.unshift(choice); + } + + let message; + + if (currentTeam) { + message = `Switch to:`; + } + + const choice = await listInput({ + message, + choices, + separator: false + }); + + // Abort + if (!choice) { + info('No changes made'); + return exit(); + } + + const newTeam = list.find(item => item.slug === choice); + + // Switch to account + if (!newTeam) { + if (currentTeam.slug === user.username || currentTeam.slug === user.email) { + info('No changes made') + return exit() + } + stopSpinner = wait('Saving'); + await cfg.remove('currentTeam'); + stopSpinner(); + return success(`Your account (${chalk.bold(choice)}) is now active!`); + } + + if (newTeam.slug === currentTeam.slug) { + info('No changes made') + return exit(); + } + + stopSpinner = wait('Saving'); + await updateCurrentTeam({ cfg, newTeam }); + stopSpinner(); + + success(`The team ${chalk.bold(newTeam.name)} is now active!`); +}; diff --git a/lib/alias.js b/lib/alias.js index ba74f1c..2e7f55d 100644 --- a/lib/alias.js +++ b/lib/alias.js @@ -2,16 +2,27 @@ const { readFileSync } = require('fs'); const publicSuffixList = require('psl'); const minimist = require('minimist'); +const ms = require('ms'); const chalk = require('chalk'); // Ours const promptBool = require('../lib/utils/input/prompt-bool'); +const info = require('../lib/utils/output/info'); +const param = require('../lib/utils/output/param'); +const wait = require('../lib/utils/output/wait'); +const success = require('../lib/utils/output/success'); +const uid = require('../lib/utils/output/uid'); +const eraseLines = require('../lib/utils/output/erase-lines'); +const stamp = require('../lib/utils/output/stamp'); +const error = require('../lib/utils/output/error'); +const treatBuyError = require('../lib/utils/domains/treat-buy-error'); +const scaleInfo = require('./scale-info'); +const { DOMAIN_VERIFICATION_ERROR } = require('./errors'); +const isZeitWorld = require('./is-zeit-world'); +const resolve4 = require('./dns'); +const toHost = require('./to-host'); const exit = require('./utils/exit'); const copy = require('./copy'); -const toHost = require('./to-host'); -const resolve4 = require('./dns'); -const isZeitWorld = require('./is-zeit-world'); -const { DOMAIN_VERIFICATION_ERROR } = require('./errors'); const Now = require('./'); const argv = minimist(process.argv.slice(2), { @@ -97,8 +108,8 @@ module.exports = class Alias extends Now { return depl; } - async updatePathBasedroutes(alias, rules) { - alias = await this.maybeSetUpDomain(alias); + async updatePathBasedroutes(alias, rules, domains) { + alias = await this.maybeSetUpDomain(alias, domains); return this.upsertPathAlias(alias, rules); } @@ -179,7 +190,6 @@ module.exports = class Alias extends Now { } // Try again, but now having provisioned the certificate - return this.upsertPathAlias(alias, rules); } @@ -216,7 +226,7 @@ module.exports = class Alias extends Now { } } - async set(deployment, alias) { + async set(deployment, alias, domains, currentTeam, user) { const depl = await this.findDeployment(deployment); if (!depl) { const err = new Error( @@ -230,10 +240,13 @@ module.exports = class Alias extends Now { if (aliasDepl && aliasDepl.rules) { if (isTTY) { try { - const msg = `> Path alias excists with ${aliasDepl.rules.length} rule${aliasDepl.rules.length > 1 ? 's' : ''}.\n` + + const msg = + `> Path alias exists with ${aliasDepl.rules.length} rule${aliasDepl.rules.length > 1 ? 's' : ''}.\n` + `> Are you sure you want to update ${alias} to be a normal alias?\n`; - const confirmation = await promptBool(msg); + const confirmation = await promptBool(msg, { + trailing: '\n' + }); if (!confirmation) { console.log('\n> Aborted'); @@ -249,8 +262,70 @@ module.exports = class Alias extends Now { } } - alias = await this.maybeSetUpDomain(alias); + let aliasedDeployment = null; + let shouldScaleDown = false; + + if (aliasDepl) { + aliasedDeployment = await this.findDeployment(aliasDepl.deploymentId); + if ( + aliasedDeployment && + aliasedDeployment.scale.current > depl.scale.current && + aliasedDeployment.scale.current >= aliasedDeployment.scale.min && + aliasedDeployment.scale.current <= aliasedDeployment.scale.max + ) { + shouldScaleDown = true; + console.log( + `> Alias ${alias} points to ${chalk.bold(aliasedDeployment.url)} (${chalk.bold(aliasedDeployment.scale.current + ' instances')})` + ); + console.log( + `> Scaling ${depl.url} to ${chalk.bold(aliasedDeployment.scale.current + ' instances')} atomically` + ); + + if (depl.scale.max < 1) { + if (this._debug) { + console.log( + 'Updating max scale to 1 so that deployment may be unfrozen.' + ); + } + await this.setScale(depl.uid, { + min: depl.scale.min, + max: Math.max(aliasedDeployment.scale.max, 1) + }); + } + if (depl.scale.current < 1) { + if (this._debug) { + console.log(`> Deployment ${depl.url} is frozen, unfreezing...`); + } + await this.unfreeze(depl); + if (this._debug) { + console.log( + `> Deployment is now unfrozen, scaling it to match current instance count` + ); + } + } + // Scale it to current limit + if (this._debug) { + console.log(`> Scaling deployment to match current scale.`); + } + await this.setScale(depl.uid, { + min: aliasedDeployment.scale.current, + max: aliasedDeployment.scale.current + }); + await scaleInfo(this, depl.url); + if (this._debug) { + console.log(`> Updating scaling rules for deployment.`); + } + await this.setScale(depl.uid, { + min: aliasedDeployment.scale.min, + max: aliasedDeployment.scale.max + }); + } + } + + alias = await this.maybeSetUpDomain(alias, domains, currentTeam, user); + + const aliasTime = Date.now(); const newAlias = await this.createAlias(depl, alias); if (!newAlias) { throw new Error( @@ -259,17 +334,13 @@ module.exports = class Alias extends Now { } const { created, uid } = newAlias; if (created) { - const pretty = `https://${alias}`; - const output = `${chalk.cyan('> Success!')} Alias created ${chalk.dim(`(${uid})`)}:\n${chalk.bold(chalk.underline(pretty))} now points to ${chalk.bold(`https://${depl.url}`)} ${chalk.dim(`(${depl.uid})`)}`; + const output = `${chalk.cyan('> Success!')} ${alias} now points to ${chalk.bold(depl.url)}! ${chalk.grey('[' + ms(Date.now() - aliasTime) + ']')}`; if (isTTY && clipboard) { - let append; try { - await copy(pretty); - append = '[copied to clipboard]'; + await copy(depl.url); } catch (err) { - append = ''; } finally { - console.log(`${output} ${append}`); + console.log(output); } } else { console.log(output); @@ -279,6 +350,13 @@ module.exports = class Alias extends Now { `${chalk.cyan('> Success!')} Alias already exists ${chalk.dim(`(${uid})`)}.` ); } + if (aliasedDeployment && shouldScaleDown) { + const scaleDown = Date.now(); + await this.setScale(aliasedDeployment.uid, { min: 0, max: 1 }); + console.log( + `> Scaled ${chalk.gray(aliasedDeployment.url)} down to 1 instance ${chalk.gray('[' + ms(Date.now() - scaleDown) + ']')}` + ); + } } createAlias(depl, alias) { @@ -402,11 +480,7 @@ module.exports = class Alias extends Now { const res = await this._fetch(`/domains/${domain}/records`, { method: 'POST', - body: { - type, - name: name === '' ? name : '*', - value: 'alias.zeit.co' - } + body: { type, name: name === '' ? name : '*', value: 'alias.zeit.co' } }); if (this._debug) { @@ -427,7 +501,13 @@ module.exports = class Alias extends Now { }); } - async maybeSetUpDomain(alias) { + async maybeSetUpDomain(alias, domains, currentTeam, user) { + const gracefulExit = () => { + this.close(); + domains.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(); + }; // Make alias lowercase alias = alias.toLowerCase(); @@ -455,6 +535,78 @@ module.exports = class Alias extends Now { console.log( `> ${chalk.bold(chalk.underline(alias))} is a custom domain.` ); + + let stopSpinner = wait('Fetching domain info'); + + let elapsed = stamp() + const parsed = publicSuffixList.parse(alias) + const pricePromise = domains.price(parsed.domain); + const canBePurchased = await domains.status(parsed.domain); + const aliasParam = param(parsed.domain); + + stopSpinner(); + + if (canBePurchased) { + const price = await pricePromise; + info( + `The domain ${aliasParam} is ${chalk.bold('available for purchase')}! ${elapsed()}` + ); + const confirmation = await promptBool( + `Buy now for ${chalk.bold(`$${price}`)} (${ + chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + ) + })?` + ) + eraseLines(1); + if (!confirmation) { + info('Aborted'); + gracefulExit(); + } + elapsed = stamp() + stopSpinner = wait('Purchasing'); + let domain; + try { + domain = await domains.buy(alias); + } catch (err) { + stopSpinner(); + treatBuyError(err); + gracefulExit(); + } + + stopSpinner(); + success(`Domain purchased and created ${uid(domain.uid)} ${elapsed()}`); + + stopSpinner = wait('Verifying nameservers'); + + let domainInfo; + + try { + domainInfo = await this.setupDomain(alias); + } catch (err) { + if (this._debug) { + console.log( + '> [debug] Error while trying to setup the domain', + err + ); + } + } + + stopSpinner(); + + if (!domainInfo.verified) { + let { tld } = publicSuffixList.parse(alias); + tld = param(`.${tld}`); + error( + 'The nameservers are pending propagation. Please try again shortly' + ); + info( + `The ${tld} servers might take some extra time to reflect changes` + ); + gracefulExit(); + } + } + console.log( `> Verifying the DNS settings for ${chalk.bold(chalk.underline(alias))} (see ${chalk.underline('https://zeit.world')} for help)` ); @@ -643,6 +795,6 @@ module.exports = class Alias extends Now { return bail(err); } } - }); + }, { retries: 5 }); } }; diff --git a/lib/cfg.js b/lib/cfg.js index ea606f4..79cf0b5 100644 --- a/lib/cfg.js +++ b/lib/cfg.js @@ -4,6 +4,13 @@ const path = require('path'); // Packages const fs = require('fs-promise'); +const ms = require('ms'); + +// Ours +const { get: getUser } = require('./user'); + +// `8h` is arbitrarily used based on the average sleep time +const TTL = ms('8h'); let file = process.env.NOW_JSON ? path.resolve(process.env.NOW_JSON) @@ -13,13 +20,62 @@ function setConfigFile(nowjson) { file = path.resolve(nowjson); } -function read() { +function save(data) { + fs.writeFileSync(file, JSON.stringify(data, null, 2)); +} + +/** + * Reads the config file + * + * Optionally, always queries the API to get the user info even if the + * config file is not present + * + * @param {Boolean} force [false] Queries the API even if the config + * file is not present. If `true`, `token` + * *must* be specified + * @param {String} token Will be used to autenticate in the API + * if `force` is `true` + * @param {String} apiUrl URL of the API to be used + * @return {Object} + */ +async function read({ force = false, token, apiUrl } = {}) { let existing = null; try { existing = fs.readFileSync(file, 'utf8'); existing = JSON.parse(existing); } catch (err) {} - return existing || {}; + + if (!existing && force && token) { + const user = await getUser({ token, apiUrl }); + if (user) { + return { + token, + user: { + uid: user.uid, + username: user.username, + email: user.email + } + }; + } + return {}; + } + + if (!existing) { + return {}; + } + + if (!existing.lastUpdate || Date.now() - existing.lastUpdate > TTL) { + // TODO: update `teams` info + const token = existing.token; + const user = await getUser({ token }); + + if (user) { + existing.user = user; + existing.lastUpdate = Date.now(); + save(existing); + } + } + return existing; } /** @@ -29,14 +85,21 @@ function read() { * (atomic) * @param {Object} data */ +async function merge(data) { + const cfg = Object.assign({}, await read(), data); + save(cfg); +} -function merge(data) { - const cfg = Object.assign({}, read(), data); +// Removes a key from the config and store the result +async function remove(key) { + const cfg = await read(); + delete cfg[key]; fs.writeFileSync(file, JSON.stringify(cfg, null, 2)); } module.exports = { setConfigFile, read, - merge + merge, + remove }; diff --git a/lib/credit-cards.js b/lib/credit-cards.js index d47af8a..08a13cc 100644 --- a/lib/credit-cards.js +++ b/lib/credit-cards.js @@ -4,14 +4,18 @@ const Now = require('../lib'); module.exports = class CreditCards extends Now { async ls() { - const res = await this._fetch('/www/user/cards'); + const res = await this._fetch('/cards'); const body = await res.json(); - + if (res.status !== 200) { + const e = new Error(body.error.message) + e.code = body.error.code + throw e + } return body; } async setDefault(cardId) { - await this._fetch('/www/user/cards/default', { + await this._fetch('/cards/default', { method: 'PUT', body: { cardId } }); @@ -19,7 +23,7 @@ module.exports = class CreditCards extends Now { } async rm(cardId) { - await this._fetch(`/www/user/cards/${encodeURIComponent(cardId)}`, { + await this._fetch(`/cards/${encodeURIComponent(cardId)}`, { method: 'DELETE' }); return true; @@ -45,16 +49,16 @@ module.exports = class CreditCards extends Now { try { const stripeToken = (await stripe.tokens.create({ card })).id; - const res = await this._fetch('/www/user/cards', { + const res = await this._fetch('/cards', { method: 'POST', body: { stripeToken } }); const body = await res.json(); - if (body.card && body.card.id) { + if (body && body.id) { resolve({ - last4: body.card.last4 + last4: body.last4 }); } else if (body.error && body.error.message) { reject(new Error(body.error.message)); diff --git a/lib/domains.js b/lib/domains.js index bde995f..5696f2b 100644 --- a/lib/domains.js +++ b/lib/domains.js @@ -1,8 +1,12 @@ +// Native +const { encode: encodeQuery } = require('querystring'); + // Packages const chalk = require('chalk'); // Ours const Now = require('../lib'); +const cfg = require('../lib/cfg'); const isZeitWorld = require('./is-zeit-world'); const { DNS_VERIFICATION_ERROR } = require('./errors'); @@ -78,4 +82,92 @@ module.exports = class Domains extends Now { err3.userError = true; throw err3; } + + async status(name) { + if (!name) { + throw new Error('`domain` is not defined'); + } + + const query = encodeQuery({ name }); + + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} GET /domains/status?${query}`); + } + + const res = await this._fetch(`/domains/status?${query}`); + const json = await res.json(); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} GET /domains/status?${query}`); + } + + return json.available; + }); + } + + async price(name) { + if (!name) { + throw new Error('`domain` is not defined'); + } + + const query = encodeQuery({ name }); + + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} GET /domains/price?${query}`); + } + + const res = await this._fetch(`/domains/price?${query}`); + const json = await res.json(); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} GET /domains/price?${query}`); + } + + return json.price; + }); + } + + async buy(name) { + const { token } = await cfg.read(); + if (!name) { + throw new Error('`name` is not defined'); + } + + const rawBody = { name }; + + if (name.startsWith('test')) { + rawBody.dev = true; + } + + const body = JSON.stringify(rawBody); + + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} GET /domains/buy`); + } + const res = await this._fetch(`/domains/buy`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}` + }, + body + }); + const json = await res.json(); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} GET /domains/buy`); + } + + if ([400, 403, 500, 503].includes(res.status)) { + const e = new Error(); + e.code = json.error.code; + e.message = json.error.message + return bail(e); + } + + return json; + }); + } }; diff --git a/lib/index.js b/lib/index.js index 87f8e64..9e0ef15 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,8 +2,11 @@ const { homedir } = require('os'); const { resolve: resolvePath, join: joinPaths } = require('path'); const EventEmitter = require('events'); +const qs = require('querystring'); +const {parse: parseUrl} = require('url') // Packages +const fetch = require('node-fetch'); const bytes = require('bytes'); const chalk = require('chalk'); const resumer = require('resumer'); @@ -18,6 +21,7 @@ const ua = require('./ua'); const hash = require('./hash'); const Agent = require('./agent'); const readMetaData = require('./read-metadata'); +const toHost = require('./to-host'); // How many concurrent HTTP/2 stream uploads const MAX_CONCURRENT = 10; @@ -27,13 +31,14 @@ const IS_WIN = process.platform.startsWith('win'); const SEP = IS_WIN ? '\\' : '/'; module.exports = class Now extends EventEmitter { - constructor(url, token, { forceNew = false, debug = false }) { + constructor({apiUrl, token, currentTeam, forceNew = false, debug = false }) { super(); this._token = token; this._debug = debug; this._forceNew = forceNew; - this._agent = new Agent(url, { debug }); + this._agent = new Agent(apiUrl, { debug }); this._onRetry = this._onRetry.bind(this); + this.currentTeam = currentTeam } async create( @@ -345,15 +350,16 @@ module.exports = class Now extends EventEmitter { ); } - return bail(responseError(res)); + return bail(await responseError(res)); } this.emit('upload', file); }, { retries: 3, randomize: true, onRetry: this._onRetry } - )) + ) + ) ) - .then(() => parts.length ? uploadChunk() : this.emit('complete')) + .then(() => (parts.length ? uploadChunk() : this.emit('complete'))) .catch(err => this.emit('error', err)); }; @@ -401,7 +407,7 @@ module.exports = class Now extends EventEmitter { } if (res.status !== 200) { - throw new Error('Fetching deployment list failed'); + throw new Error('Fetching deployment url failed'); } return res.json(); @@ -412,6 +418,129 @@ module.exports = class Now extends EventEmitter { return deployments; } + async listInstances(deploymentId) { + const { instances } = await this.retry( + async bail => { + if (this._debug) { + console.time(`> [debug] /deployments/${deploymentId}/instances`); + } + + const res = await this._fetch( + `/now/deployments/${deploymentId}/instances` + ); + + if (this._debug) { + console.timeEnd(`> [debug] /deployments/${deploymentId}/instances`); + } + + // No retry on 4xx + if (res.status >= 400 && res.status < 500) { + if (this._debug) { + console.log('> [debug] bailing on listing due to %s', res.status); + } + return bail(responseError(res)); + } + + if (res.status !== 200) { + throw new Error('Fetching instances list failed'); + } + + return res.json(); + }, + { retries: 3, minTimeout: 2500, onRetry: this._onRetry } + ); + + return instances; + } + + async findDeployment(deployment) { + const list = await this.list(); + + let key; + let val; + + if (/\./.test(deployment)) { + val = toHost(deployment); + key = 'url'; + } else { + val = deployment; + key = 'uid'; + } + + const depl = list.find(d => { + if (d[key] === val) { + if (this._debug) { + console.log(`> [debug] matched deployment ${d.uid} by ${key} ${val}`); + } + + return true; + } + + // Match prefix + if (`${val}.now.sh` === d.url) { + if (this._debug) { + console.log(`> [debug] matched deployment ${d.uid} by url ${d.url}`); + } + + return true; + } + + return false; + }); + + return depl; + } + + async logs(deploymentIdOrURL, { types, limit, query, since, until } = {}) { + const q = qs.stringify({ + types: types.join(','), + limit, + q: query, + since, + until + }); + + const { logs } = await this.retry( + async bail => { + if (this._debug) { + console.time('> [debug] /logs'); + } + + const url = `/now/deployments/${encodeURIComponent(deploymentIdOrURL)}/logs?${q}`; + const res = await this._fetch(url); + + if (this._debug) { + console.timeEnd('> [debug] /logs'); + } + + // No retry on 4xx + if (res.status >= 400 && res.status < 500) { + if (this._debug) { + console.log( + '> [debug] bailing on printing logs due to %s', + res.status + ); + } + + return bail(await responseError(res)); + } + + if (res.status !== 200) { + throw new Error('Fetching deployment logs failed'); + } + + return res.json(); + }, + { + retries: 3, + minTimeout: 2500, + onRetry: this._onRetry + } + ); + + return logs; + } + async listAliases(deploymentId) { return this.retry(async () => { const res = await this._fetch( @@ -577,13 +706,13 @@ module.exports = class Now extends EventEmitter { body.error && body.error.code === 'verification_failed' ) { - const err = new Error(body.error.message); - err.userError = true; - return bail(err); + throw new Error(body.error.message); } else if (res.status !== 200) { throw new Error(body.error.message); } + if (!body.verified) throw new Error('verification failed, retrying') + return body; }); } @@ -689,7 +818,7 @@ module.exports = class Now extends EventEmitter { if (this._debug) { console.log('> [debug] bailing on removal due to %s', res.status); } - return bail(responseError(res)); + return bail(await responseError(res)); } if (res.status !== 200) { @@ -740,11 +869,79 @@ module.exports = class Now extends EventEmitter { } _fetch(_url, opts = {}) { + if (!opts.useCurrentTeam && this.currentTeam) { + const parsedUrl = parseUrl(_url, true) + const query = parsedUrl.query + + query.teamId = this.currentTeam.id + _url = `${parsedUrl.pathname}?${qs.encode(query)}` + delete opts.useCurrentTeam + } + opts.headers = opts.headers || {}; opts.headers.authorization = `Bearer ${this._token}`; opts.headers['user-agent'] = ua; return this._agent.fetch(_url, opts); } + + setScale(nameOrId, scale) { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time( + `> [debug] #${attempt} POST /deployments/${nameOrId}/instances` + ); + } + + const res = await this._fetch(`/now/deployments/${nameOrId}/instances`, { + method: 'POST', + body: scale + }); + + if (this._debug) { + console.timeEnd( + `> [debug] #${attempt} POST /deployments/${nameOrId}/instances` + ); + } + + if (res.status === 403) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (res.status === 404 || res.status === 400) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } + + if (body.error && body.error.message) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } + throw new Error(`Error occurred while scaling. Please try again later`); + } + + return body; + }); + } + + async unfreeze(depl) { + return this.retry(async bail => { + const res = await fetch(`https://${depl.url}`); + + if ([500, 502, 503].includes(res.status)) { + const err = new Error('Unfreeze failed. Try again later.'); + bail(err); + } + }); + } + + async getPlanMax() { + return 10; + } }; function toRelative(path, base) { @@ -758,12 +955,33 @@ function toRelative(path, base) { return relative.replace(/\\/g, '/'); } -function responseError(res) { - const err = new Error('Response error'); +async function responseError(res) { + let message; + let userError; + + if (res.status >= 400 && res.status < 500) { + let body; + + try { + body = await res.json(); + } catch (err) { + body = {}; + } + + // Some APIs wrongly return `err` instead of `error` + message = (body.error || body.err || {}).message; + userError = true; + } else { + userError = false; + } + + const err = new Error(message || 'Response error'); err.status = res.status; + err.userError = userError; if (res.status === 429) { const retryAfter = res.headers.get('Retry-After'); + if (retryAfter) { err.retryAfter = parseInt(retryAfter, 10); } diff --git a/lib/login.js b/lib/login.js index 6e5d5fc..7b294bf 100644 --- a/lib/login.js +++ b/lib/login.js @@ -13,8 +13,6 @@ const ora = require('ora'); const pkg = require('./pkg'); const ua = require('./ua'); const cfg = require('./cfg'); -const info = require('./utils/output/info'); -const promptBool = require('./utils/input/prompt-bool'); async function getVerificationData(url, email) { const tokenName = `Now CLI ${os.platform()}-${os.arch()} ${pkg.version} (${os.hostname()})`; @@ -70,14 +68,6 @@ async function register(url, { retryEmail = false } = {}) { process.stdout.write('\n'); - info( - `By continuing, you declare that you agree with ${chalk.bold('https://zeit.co/terms')} and ${chalk.bold('https://zeit.co/privacy.')}` - ); - if (!await promptBool('Continue?')) { - info('Aborted.'); - process.exit(); // eslint-disable-line unicorn/no-process-exit - } - if (!validate(email)) { return register(url, { retryEmail: true }); } @@ -96,8 +86,7 @@ async function register(url, { retryEmail = false } = {}) { process.stdout.write('\n'); const spinner = ora({ - text: 'Waiting for confirmation...', - color: 'black' + text: 'Waiting for confirmation...' }).start(); let final; @@ -112,16 +101,38 @@ async function register(url, { retryEmail = false } = {}) { } while (!final); /* eslint-enable no-await-in-loop */ + let user; + try { + user = (await (await fetch(`${url}/www/user`, { + headers: { + Authorization: `Bearer ${final}` + } + })).json()).user; + } catch (err) { + spinner.stop(); + throw new Error(`Couldn't retrieve user details ${err.message}`); + } + spinner.text = 'Confirmed email address!'; spinner.stopAndPersist('✔'); process.stdout.write('\n'); - return { email, token: final }; + return { + token: final, + user: { + uid: user.uid, + username: user.username, + email: user.email + }, + lastUpdate: Date.now() + }; } module.exports = async function(url) { const loginData = await register(url); - cfg.merge(loginData); + await cfg.merge(loginData); + await cfg.remove('currentTeam') // Make sure to logout the team too + await cfg.remove('email') // Remove old schema from previus versions return loginData.token; }; diff --git a/lib/logs.js b/lib/logs.js index 27aaeec..d2ced4d 100644 --- a/lib/logs.js +++ b/lib/logs.js @@ -6,7 +6,7 @@ exports.compare = function(a, b) { exports.deserialize = function(log) { return Object.assign({}, log, { - data: new Date(log.date), + date: new Date(log.date), created: new Date(log.created) }); }; diff --git a/lib/plans.js b/lib/plans.js index 24bf591..36e479d 100644 --- a/lib/plans.js +++ b/lib/plans.js @@ -2,46 +2,56 @@ const ms = require('ms'); const Now = require('../lib'); -async function parsePlan(res) { +async function parsePlan(json) { + const { subscription } = json let id; let until; - - const { subscription } = await res.json(); + let name 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 } - ); + const planItems = subscription.items.data; + const mainPlan = planItems.find(d => d.plan.metadata.is_main_plan === '1'); + + if (mainPlan) { + id = mainPlan.plan.id + name = mainPlan.plan.name + if (subscription.cancel_at_period_end) { + until = ms( + new Date(subscription.current_period_end * 1000) - new Date(), + { long: true } + ); + } + } else { + id = 'oss' } } else { id = 'oss'; } - return { id, until }; + return { id, name, until }; } module.exports = class Plans extends Now { async getCurrent() { - const res = await this._fetch('/www/user/plan'); - return parsePlan(res); + const res = await this._fetch('/plan'); + const json = await res.json() + return parsePlan(json); } async set(plan) { - const res = await this._fetch('/www/user/plan', { + const res = await this._fetch('/plan', { method: 'PUT', body: { plan } }); + const json = await res.json() + if (res.ok) { - return parsePlan(res); + return parsePlan(json); } - const err = new Error(res.statusText); - err.res = res; + const err = new Error(json.error.message); + err.code = json.error.code throw err; } }; diff --git a/lib/re-alias.js b/lib/re-alias.js index 15b03fe..4f30687 100644 --- a/lib/re-alias.js +++ b/lib/re-alias.js @@ -10,8 +10,8 @@ const { error } = require('./error'); const readMetaData = require('./read-metadata'); const NowAlias = require('./alias'); -exports.assignAlias = async (autoAlias, token, deployment, apiUrl, debug) => { - const aliases = new NowAlias(apiUrl, token, { debug }); +exports.assignAlias = async (autoAlias, token, deployment, apiUrl, debug, currentTeam) => { + const aliases = new NowAlias({apiUrl, token, debug, currentTeam }); console.log( `> Assigning alias ${chalk.bold.underline(autoAlias)} to deployment...` ); @@ -83,7 +83,7 @@ exports.reAlias = async (token, host, help, exit, apiUrl, debug, alias) => { const assignments = []; for (const pointer of pointers) { - assignments.push(exports.assignAlias(pointer, token, host, apiUrl, debug)); + assignments.push(exports.assignAlias(pointer, token, host, apiUrl, debug, alias.currentTeam)); } await Promise.all(assignments); diff --git a/lib/scale-info.js b/lib/scale-info.js new file mode 100644 index 0000000..7fc7cab --- /dev/null +++ b/lib/scale-info.js @@ -0,0 +1,72 @@ +const linelog = require('single-line-log').stdout; +const range = require('lodash.range'); +const ms = require('ms'); +const chalk = require('chalk'); +const retry = require('async-retry'); + +function barify(cur, tot) { + return ( + '[' + + range(0, cur).map(() => '=').join('') + + range(cur, tot).map(() => '-').join('') + + ']' + ); +} + +module.exports = async function(now, url) { + const match = await now.findDeployment(url); + const { min, max, current } = match.scale; + + let targetReplicaCount = min; + if (current < min) { + targetReplicaCount = min; + } else if (current > max) { + targetReplicaCount = max; + } else { + console.log(`> Nothing to do, already scaled.`); + return; + } + + if (targetReplicaCount === 0) { + console.log(`> Scaled to 0 instances`); + return; + } + const startTime = Date.now(); + + let barcurr = current; + const end = Math.max(current, max); + linelog( + `> Scaling to ${chalk.bold(String(targetReplicaCount) + (targetReplicaCount === 1 ? ' instance' : ' instances'))}: ` + + barify(barcurr, end) + ); + + const instances = await retry( + async () => { + const res = await now.listInstances(match.uid); + if (barcurr !== res.length) { + barcurr = res.length; + linelog( + `> Scaling to ${chalk.bold(String(targetReplicaCount) + (targetReplicaCount === 1 ? ' instance' : ' instances'))}: ` + + barify(barcurr, end) + ); + + if (barcurr === targetReplicaCount) { + linelog.clear(); + linelog( + `> Scaled to ${chalk.bold(String(targetReplicaCount) + (targetReplicaCount === 1 ? ' instance' : ' instances'))}: ${chalk.gray('[' + ms(Date.now() - startTime) + ']')}\n` + ); + return res; + } + } + + throw new Error('Not ready yet'); + }, + { retries: 5000, minTimeout: 10, maxTimeout: 20 } + ); + + process.nextTick(() => { + instances.forEach(inst => { + console.log(` - ${chalk.underline(inst.url)}`); + }); + }); +}; diff --git a/lib/scale.js b/lib/scale.js new file mode 100644 index 0000000..05a1ac6 --- /dev/null +++ b/lib/scale.js @@ -0,0 +1,43 @@ +// Ours +const Now = require('../lib'); + +module.exports = class Scale extends Now { + + getInstances(id) { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time( + `> [debug] #${attempt} GET /deployments/${id}/instances` + ); + } + + const res = await this._fetch(`/now/deployments/${id}/instances`, { + method: 'GET' + }); + + if (this._debug) { + console.timeEnd( + `> [debug] #${attempt} GET /deployments/${id}/instances` + ); + } + + if (res.status === 403) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (res.status === 404 || res.status === 400) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } + + throw new Error(body.error.message); + } + + return body; + }); + } +}; diff --git a/lib/teams.js b/lib/teams.js new file mode 100644 index 0000000..e16d5e7 --- /dev/null +++ b/lib/teams.js @@ -0,0 +1,141 @@ +const Now = require('../lib'); + +module.exports = class Teams extends Now { + async create({ slug }) { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} POST /teams}`); + } + + const res = await this._fetch(`/teams`, { + method: 'POST', + body: { + slug + } + }); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} POST /teams`); + } + + if (res.status === 403) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + + if (res.status === 400) { + const e = new Error(body.error.message) + e.code = body.error.code + return bail(e) + } else if (res.status !== 200) { + const e = new Error(body.error.message) + e.code = body.error.code + throw e + } + + return body + }); + } + + async edit({ id, slug, name }) { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} PATCH /teams/${id}}`); + } + + const payload = {} + if (name) { + payload.name = name + } + if (slug) { + payload.slug = slug + } + + const res = await this._fetch(`/teams/${id}`, { + method: 'PATCH', + body: payload + }); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} PATCH /teams/${id}`); + } + + if (res.status === 403) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + + if (res.status === 400) { + const e = new Error(body.error.message) + e.code = body.error.code + return bail(e) + } else if (res.status !== 200) { + const e = new Error(body.error.message) + e.code = body.error.code + throw e + } + + return body + }); + } + + async inviteUser({teamId, email}) { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} POST /teams/${teamId}/members}`); + } + const res = await this._fetch(`/teams/${teamId}/members`, { + method: 'POST', + body: { + email + } + }); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} POST /teams/${teamId}/members}`); + } + + if (res.status === 403) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status === 400) { + const e = new Error(body.error.message) + e.code = body.error.code + return bail(e) + } else if (res.status !== 200) { + const e = new Error(body.error.message) + e.code = body.error.code + throw e + } + + return body + }); + } + + async ls() { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} GET /teams}`); + } + + const res = await this._fetch(`/teams`); + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} GET /teams`); + } + + if (res.status === 403) { + return bail(new Error('Unauthorized')); + } + + return res.json(); + }); + } +}; diff --git a/lib/unfreeze.js b/lib/unfreeze.js new file mode 100644 index 0000000..e69de29 diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 0000000..cc7916f --- /dev/null +++ b/lib/user.js @@ -0,0 +1,54 @@ +const _fetch = require('node-fetch'); + +function _filter(data) { + data = data.user; + + return { + userId: data.uid, + username: data.username, + email: data.email + }; +} + +/** + * Gets all the info we have about an user + * + * @param {Object} fetch Optionally, _our_ `fetch` can be passed here + * @param {String} token Only necessary if `fetch` is undefined + * @param {String} apiUrl Only necessary if `fetch` is undefined + * @param {Boolean} filter If `true`, the `filter` used will to the data + * before returning + * @return {Object} + */ +async function get( + { fetch, token, apiUrl = 'https://api.zeit.co', filter = true } = {} +) { + let headers = {}; + const endpoint = '/www/user'; + const url = fetch ? endpoint : apiUrl + endpoint; + + if (!fetch) { + headers = { + Authorization: `Bearer ${token}` + }; + fetch = _fetch; + } + + try { + const res = await fetch(url, { headers }); + + const json = await res.json(); + + if (filter) { + return _filter(json); + } + return json; + } catch (err) { + return {}; + } +} + +module.exports = { + get, + filter: _filter +}; diff --git a/lib/utils/domains/treat-buy-error.js b/lib/utils/domains/treat-buy-error.js new file mode 100644 index 0000000..c6824e2 --- /dev/null +++ b/lib/utils/domains/treat-buy-error.js @@ -0,0 +1,25 @@ +const error = require('../output/error'); + +module.exports = function (err) { + switch (err.code) { + case 'invalid_domain': { + error('Invalid domain') + break + } + case 'not_available': { + error('Domain can\'t be purchased at this time') + break + } + case 'service_unavailabe': { + error('Purchase failed – Service unavailable') + break + } + case 'unexpected_error': { + error('Purchase failed – Unexpected error') + break + } + default: { + error(err.message) + } + } +} diff --git a/lib/utils/fatal-error.js b/lib/utils/fatal-error.js new file mode 100644 index 0000000..22560ec --- /dev/null +++ b/lib/utils/fatal-error.js @@ -0,0 +1,7 @@ +const error = require('./output/error'); +const exit = require('./exit'); + +module.exports = (msg, code = 1) => { + error(msg); + exit(code); +}; diff --git a/lib/utils/input/regexes.js b/lib/utils/input/regexes.js new file mode 100644 index 0000000..c845905 --- /dev/null +++ b/lib/utils/input/regexes.js @@ -0,0 +1,3 @@ +module.exports = { + email: /.+@.+\..+$/ +}; diff --git a/lib/utils/input/text.js b/lib/utils/input/text.js index 4fb7a92..905429a 100644 --- a/lib/utils/input/text.js +++ b/lib/utils/input/text.js @@ -3,6 +3,8 @@ const ansiRegex = require('ansi-regex'); const chalk = require('chalk'); const stripAnsi = require('strip-ansi'); +const eraseLines = require('../output/erase-lines'); + const ESCAPES = { LEFT: '\u001B[D', RIGHT: '\u001B[C', @@ -16,34 +18,39 @@ module.exports = function( { label = '', initialValue = '', + // If false, the `- label` will be printed as `✖ label` in red + // Until the first keypress + valid = true, // Can be: // - `false`, which does nothing // - `cc`, for credit cards // - `date`, for dates in the mm / yyyy format mask = false, placeholder = '', - abortSequences = new Set(['\u0003']), + 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 + // 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 + // 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 + // 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 + // 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 - // tab - // right arrow - autoCompleteChars = new Set(['\t', '\u001B[C']) + // Tab + // Right arrow + autoCompleteChars = new Set(['\t', '\x1b[C']), + // If true, converts everything the user types to to lowercase + forceLowerCase = false } = {} ) { return new Promise((resolve, reject) => { @@ -54,9 +61,15 @@ module.exports = function( let regex; let suggestion = ''; - stdout.write(label); + if (valid) { + stdout.write(label); + } else { + const _label = label.replace('-', '✖'); + stdout.write(chalk.red(_label)); + } value = initialValue; stdout.write(initialValue); + if (mask) { if (!value) { value = chalk.gray(placeholder); @@ -96,7 +109,7 @@ module.exports = function( } async function onData(buffer) { - const data = buffer.toString(); + let data = buffer.toString(); value = stripAnsi(value); if (abortSequences.has(data)) { @@ -104,6 +117,10 @@ module.exports = function( return reject(new Error('USER_ABORT')); } + if (forceLowerCase) { + data = data.toLowerCase(); + } + if (suggestion !== '' && !caretOffset && autoCompleteChars.has(data)) { value += stripAnsi(suggestion); suggestion = ''; @@ -150,10 +167,10 @@ module.exports = function( } 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 - ); + eraseLines(1); + stdout.write(l + value + ansiEscapes.beep); if (caretOffset) { process.stdout.write( ansiEscapes.cursorBackward(Math.abs(caretOffset)) @@ -212,7 +229,8 @@ module.exports = function( value = value.replace(regex, chalk.gray('$1')); } - stdout.write(ansiEscapes.eraseLines(1) + label + value + suggestion); + eraseLines(1); + stdout.write(label + value + suggestion); if (caretOffset) { process.stdout.write(ansiEscapes.cursorBackward(Math.abs(caretOffset))); } diff --git a/lib/utils/output/chars.js b/lib/utils/output/chars.js new file mode 100644 index 0000000..d7dcc72 --- /dev/null +++ b/lib/utils/output/chars.js @@ -0,0 +1,3 @@ +module.exports = { + tick: '✓' +}; diff --git a/lib/utils/output/erase-lines.js b/lib/utils/output/erase-lines.js new file mode 100644 index 0000000..bcc2a0d --- /dev/null +++ b/lib/utils/output/erase-lines.js @@ -0,0 +1,3 @@ +const ansiEscapes = require('ansi-escapes'); + +module.exports = n => process.stdout.write(ansiEscapes.eraseLines(n)); diff --git a/lib/utils/output/note.js b/lib/utils/output/note.js new file mode 100644 index 0000000..1e7e069 --- /dev/null +++ b/lib/utils/output/note.js @@ -0,0 +1,6 @@ +const chalk = require('chalk'); + +// Prints a note +module.exports = msg => { + console.log(`${chalk.yellow('> NOTE:')} ${msg}`); +}; diff --git a/lib/utils/output/right-pad.js b/lib/utils/output/right-pad.js new file mode 100644 index 0000000..41ef71d --- /dev/null +++ b/lib/utils/output/right-pad.js @@ -0,0 +1,4 @@ +module.exports = (string, n = 0) => { + n -= string.length; + return string + ' '.repeat(n > -1 ? n : 0); +}; diff --git a/lib/utils/url.js b/lib/utils/url.js new file mode 100644 index 0000000..f137fcc --- /dev/null +++ b/lib/utils/url.js @@ -0,0 +1,23 @@ +const isURL = require('is-url') + +exports.maybeURL = id => { + // E.g, "appname-asdf" + return id.includes('-') +} + +exports.normalizeURL = u => { + // Normalize URL by removing slash from the end + if (isURL(u) && u.slice(-1) === '/') { + u = u.slice(0, -1) + } + + // `url` should match the hostname of the deployment + u = u.replace(/^https:\/\//i, '') + + if (!u.includes('.')) { + // `.now.sh` domain is implied if just the subdomain is given + u += '.now.sh' + } + + return u +} diff --git a/package.json b/package.json index cb7e214..84d215a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "now", - "version": "4.11.2", + "version": "5.0.0", "description": "The command line interface for Now", "repository": "zeit/now-cli", "license": "MIT", @@ -8,7 +8,7 @@ "build" ], "scripts": { - "precommit": "lint-staged", + "precommit": "xo --quiet && lint-staged", "lint": "xo", "test": "npm run build && npm run lint && ava", "prepublish": "npm run build", @@ -60,29 +60,35 @@ "copy-paste": "1.3.0", "credit-card": "3.0.1", "cross-spawn": "5.1.0", + "dateformat": "2.0.0", "docker-file-parser": "1.0.1", "dotenv": "4.0.0", "download": "5.0.3", "email-prompt": "0.2.0", "email-validator": "1.0.7", + "epipebomb": "1.0.0", "fs-promise": "2.0.2", "glob": "7.1.1", "ignore": "3.2.7", "ini": "1.3.4", "inquirer": "3.0.6", "is-url": "1.2.2", + "lodash.range": "3.2.0", "minimist": "1.2.0", "ms": "1.0.0", "node-fetch": "1.6.3", "opn": "4.0.2", "ora": "1.2.0", - "progress": "2.0.0", + "printf": "0.2.5", + "progress": "^2.0.0", "psl": "1.1.18", "resumer": "0.0.0", + "single-line-log": "1.1.2", "socket.io-client": "1.7.3", "split-array": "1.0.1", "strip-ansi": "3.0.1", "stripe": "4.17.1", + "supports-color": "3.2.3", "text-table": "0.2.0", "tmp-promise": "1.0.3", "update-notifier": "2.1.0"