diff --git a/src/providers/sh/commands/bin/teams.js b/src/providers/sh/commands/bin/teams.js new file mode 100644 index 0000000..f9569e6 --- /dev/null +++ b/src/providers/sh/commands/bin/teams.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +// Packages +const chalk = require('chalk') +const minimist = require('minimist') + +// Utilities +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 { handleError } = require('../lib/error') +const list = require('./teams/list') +const add = require('./teams/add') +const change = require('./teams/switch') +const invite = require('./teams/invite') + +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 your team's url is 'zeit.co/teams/name', 'name' is the slug + ${chalk.gray('–')} If the slug 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 + `) +} + +let argv +let debug +let apiUrl +let subcommand + +const main = async ctx => { + argv = minimist(ctx.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't', + switch: 'change' + } + }) + + debug = argv.debug + apiUrl = argv.url || 'https://api.zeit.co' + + if (argv.config) { + cfg.setConfigFile(argv.config) + } + + const isSwitch = argv._[0] && argv._[0] === 'switch' + + argv._ = argv._.slice(1) + subcommand = argv._[0] + + if (isSwitch) { + subcommand = 'switch' + } + + if (argv.help || !subcommand) { + help() + exit(0) + } + + const config = await cfg.read({ token: argv.token }) + let token + + try { + 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) + } +} + +module.exports = async ctx => { + try { + await main(ctx) + } catch (err) { + handleError(err) + process.exit(1) + } +} + +async function run({ token, config: { currentTeam } }) { + const teams = new NowTeams({ apiUrl, token, debug, currentTeam }) + const args = argv._ + + switch (subcommand) { + case 'list': + case 'ls': { + await list({ + teams, + token + }) + break + } + case 'switch': + case 'change': { + await change({ + teams, + args, + token + }) + break + } + case 'add': + case 'create': { + await add({ teams, token }) + break + } + + case 'invite': { + await invite({ + teams, + args, + token + }) + 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/src/providers/sh/commands/bin/teams/add.js b/src/providers/sh/commands/bin/teams/add.js new file mode 100644 index 0000000..dcc3ba9 --- /dev/null +++ b/src/providers/sh/commands/bin/teams/add.js @@ -0,0 +1,135 @@ +// Packages +const chalk = require('chalk') + +// Utilities +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, token }) { + 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, + args: [], + token, + 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/src/providers/sh/commands/bin/teams/invite.js b/src/providers/sh/commands/bin/teams/invite.js new file mode 100644 index 0000000..06e8882 --- /dev/null +++ b/src/providers/sh/commands/bin/teams/invite.js @@ -0,0 +1,160 @@ +// Packages +const chalk = require('chalk') + +// Utilities +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, token, introMsg, noopMsg = 'No changes made' } = {} +) { + const { user, currentTeam } = await cfg.read({ token }) + + 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/src/providers/sh/commands/bin/teams/list.js b/src/providers/sh/commands/bin/teams/list.js new file mode 100644 index 0000000..a295e6c --- /dev/null +++ b/src/providers/sh/commands/bin/teams/list.js @@ -0,0 +1,64 @@ +// Packages +const chalk = require('chalk') + +// Utilities +const wait = require('../../lib/utils/output/wait') +const cfg = require('../../lib/cfg') +const info = require('../../lib/utils/output/info') +const error = require('../../lib/utils/output/error') +const { tick: tickChar } = require('../../lib/utils/output/chars') +const table = require('../../lib/utils/output/table') + +module.exports = async function({ teams, token }) { + const stopSpinner = wait('Fetching teams') + const list = (await teams.ls()).teams + let { user, currentTeam } = await cfg.read({ token }) + const accountIsCurrent = !currentTeam + stopSpinner() + + if (accountIsCurrent) { + currentTeam = { + slug: user.username || user.email + } + } + + const teamList = list.map(({ slug, name }) => { + return { + name, + value: slug, + current: slug === currentTeam.slug ? tickChar : '' + } + }) + + teamList.unshift({ + name: user.email, + value: user.username || user.email, + current: (accountIsCurrent && tickChar) || '' + }) + + // Let's bring the current team to the beginning of the list + if (!accountIsCurrent) { + const index = teamList.findIndex( + choice => choice.value === currentTeam.slug + ) + const choice = teamList.splice(index, 1)[0] + teamList.unshift(choice) + } + + // Printing + const count = teamList.length + if (!count) { + // Maybe should not happen + error(`No team found`) + return + } + + info(`${chalk.bold(count)} team${count > 1 ? 's' : ''} found`) + console.log() + + table( + ['', 'id', 'email / name'], + teamList.map(team => [team.current, team.value, team.name]), + [1, 5] + ) +} diff --git a/src/providers/sh/commands/bin/teams/switch.js b/src/providers/sh/commands/bin/teams/switch.js new file mode 100644 index 0000000..ddffe60 --- /dev/null +++ b/src/providers/sh/commands/bin/teams/switch.js @@ -0,0 +1,126 @@ +// Packages +const chalk = require('chalk') + +// Utilities +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, token }) { + let stopSpinner = wait('Fetching teams') + const list = (await teams.ls()).teams + let { user, currentTeam } = await cfg.read({ token }) + 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/src/providers/sh/commands/bin/upgrade.js b/src/providers/sh/commands/bin/upgrade.js new file mode 100644 index 0000000..8afef04 --- /dev/null +++ b/src/providers/sh/commands/bin/upgrade.js @@ -0,0 +1,267 @@ +#!/usr/bin/env node + +// Packages +const chalk = require('chalk') +const minimist = require('minimist') +const ms = require('ms') + +// Utilities +const login = require('../lib/login') +const cfg = require('../lib/cfg') +const NowPlans = require('../lib/plans') +const indent = require('../lib/indent') +const listInput = require('../lib/utils/input/list') +const code = require('../lib/utils/output/code') +const error = require('../lib/utils/output/error') +const success = require('../lib/utils/output/success') +const cmd = require('../lib/utils/output/cmd') +const logo = require('../lib/utils/output/logo') +const { handleError } = require('../lib/error') + +const { bold } = chalk + +const help = () => { + console.log(` + ${chalk.bold(`${logo} now upgrade`)} [plan] + + ${chalk.dim('Options:')} + + -h, --help Output usage information + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline( + 'FILE' + )} Config file + -d, --debug Debug mode [off] + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline( + 'TOKEN' + )} Login token + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} List available plans and pick one interactively + + ${chalk.cyan('$ now upgrade')} + + ${chalk.yellow('NOTE:')} ${chalk.gray( + 'Make sure you have a payment method, or add one:' + )} + + ${chalk.cyan(`$ now billing add`)} + + ${chalk.gray('–')} Pick a specific plan (premium): + + ${chalk.cyan(`$ now upgrade premium`)} + `) +} + +const exit = code => { + // We give stdout some time to flush out + // because there's a node bug where + // stdout writes are asynchronous + // https://github.com/nodejs/node/issues/6456 + setTimeout(() => process.exit(code || 0), 100) +} + +let argv +let debug +let apiUrl + +const main = async ctx => { + argv = minimist(ctx.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't' + } + }) + + argv._ = argv._.slice(1) + + debug = argv.debug + apiUrl = argv.url || 'https://api.zeit.co' + + if (argv.config) { + cfg.setConfigFile(argv.config) + } + + if (argv.help) { + help() + exit(0) + } + + const config = await cfg.read({ token: argv.token }) + + let token + + try { + 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) + } +} + +module.exports = async ctx => { + try { + await main(ctx) + } catch (err) { + handleError(err) + process.exit(1) + } +} + +function buildInquirerChoices(current, until) { + if (until) { + until = until.split(' ') + until = ' for ' + chalk.bold(until[0]) + ' more ' + until[1] + } else { + until = '' + } + + 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: ossName, + value: 'oss', + short: `OSS ${bold('FREE')}` + }, + { + name: premiumName, + value: 'premium', + short: `Premium ${bold('$15')}` + }, + { + name: proName, + value: 'pro', + short: `Pro ${bold('$50')}` + }, + { + name: advancedName, + value: 'advanced', + short: `Advanced ${bold('$200')}` + } + ] +} + +async function run({ token, config: { currentTeam, user } }) { + const args = argv._ + if (args.length > 1) { + error('Invalid number of arguments') + return exit(1) + } + + const start = new Date() + const plans = new NowPlans({ apiUrl, token, debug, currentTeam }) + + let planId = args[0] + + if (![undefined, 'oss', 'premium', 'pro', 'advanced'].includes(planId)) { + error(`Invalid plan name – should be ${code('oss')} or ${code('premium')}`) + return exit(1) + } + + const currentPlan = await plans.getCurrent() + + if (planId === undefined) { + const elapsed = ms(new Date() - start) + + 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: false, + abort: 'end' + }) + } + + if ( + planId === undefined || + (planId === currentPlan.id && currentPlan.until === undefined) + ) { + return console.log('No changes made') + } + + let newPlan + + try { + newPlan = await plans.set(planId) + } catch (err) { + 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 ${err.message}`) + } + plans.close() + return + } + + if (currentPlan.until && newPlan.id !== 'oss') { + success( + `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 ${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(`${newPlan.name} plan`)}`) + } + + plans.close() +} diff --git a/src/providers/sh/index.js b/src/providers/sh/index.js index 83b4fc5..23897bb 100644 --- a/src/providers/sh/index.js +++ b/src/providers/sh/index.js @@ -14,7 +14,10 @@ module.exports = { 'remove', 'whoami', 'secrets', - 'logs' + 'logs', + 'upgrade', + 'teams', + 'switch' ]), get deploy() { return require('./deploy') @@ -57,5 +60,14 @@ module.exports = { }, get logs() { return require('./commands/bin/logs') + }, + get upgrade() { + return require('./commands/bin/upgrade') + }, + get teams() { + return require('./commands/bin/teams') + }, + get switch() { + return require('./commands/bin/teams') } }