diff --git a/bin/now b/bin/now index fb4fb4e..ed777ff 100755 --- a/bin/now +++ b/bin/now @@ -1,53 +1,15 @@ #!/usr/bin/env node -import Progress from 'progress'; -import copy from '../lib/copy'; +import minimist from 'minimist'; import { resolve } from 'path'; -import login from '../lib/login'; -import * as cfg from '../lib/cfg'; -import { version } from '../../package'; +import { spawn } from 'child_process'; import checkUpdate from '../lib/check-update'; -import Logger from '../lib/build-logger'; -import bytes from 'bytes'; -import chalk from 'chalk'; -import minimist from 'minimist'; -import Now from '../lib'; -import ms from 'ms'; const argv = minimist(process.argv.slice(2)); -const help = () => { - console.log(` - 𝚫 now [options] - - Options: - - -h, --help output usage information - -v, --version output the version number - -d, --debug Debug mode [off] - -f, --force Force a new deployment even if nothing has changed - -L, --login Configure login - -C, --no-clipboard Do not attempt to copy URL to clipboard -`); -}; - -let path = argv._[0]; - -if (path) { - if ('/' !== path[0]) { - path = resolve(process.cwd(), path); - } -} else { - path = process.cwd(); -} // options const debug = argv.debug || argv.d; -const clipboard = !(argv.noClipboard || argv.C); -const force = argv.f || argv.force; -const forceSync = argv.F || argv.forceSync; -const shouldLogin = argv.L || argv.login; // auto-update checking -const config = cfg.read(); const update = checkUpdate({ debug }); const exit = (code) => { update.then(() => process.exit(code)); @@ -56,146 +18,21 @@ const exit = (code) => { setTimeout(() => process.exit(code), 1000); }; -if (argv.h || argv.help) { - help(); - exit(0); -} else if (argv.v || argv.version) { - console.log(chalk.bold('𝚫 now'), version); - exit(0); -} else if (!config.token || shouldLogin) { - login() - .then((token) => { - if (shouldLogin) { - console.log('> Logged in successfully. Token saved in ~/.now.json'); - exit(0); - } else { - sync(token).catch((err) => { - error(`Unknown error: ${err.stack}`); - exit(1); - }); - } - }) - .catch((e) => { - error(`Authentication error – ${e.message}`); - exit(1); - }); -} else { - sync(config.token).catch((err) => { - error(`Unknown error: ${err.stack}`); - exit(1); - }); -} - -async function sync (token) { - const start = Date.now(); - - console.log(`> Deploying "${path}"`); - - const now = new Now(token, { debug }); - - try { - await now.create(path, { forceNew: force, forceSync: forceSync }); - } catch (err) { - handleError(err); - return; - } - - const { url } = now; - const elapsed = ms(new Date() - start); +const commands = new Set(['deploy', 'list', 'ls']); +const aliases = new Map([['ls', 'list']]); - if (clipboard) { - try { - await copy(url); - console.log(`${chalk.cyan('> Ready!')} ${chalk.bold(url)} (copied to clipboard) [${elapsed}]`); - } catch (err) { - console.log(`${chalk.cyan('> Ready!')} ${chalk.bold(url)} [${elapsed}]`); - } - } else { - console.log(`> ${url} [${elapsed}]`); - } +let cmd = argv._[0]; +let args; - const start_u = new Date(); - const complete = () => { - const elapsed_u = ms(new Date() - start_u); - console.log(`> Sync complete (${bytes(now.syncAmount)}) [${elapsed_u}] `); - - // close http2 agent - now.close(); - - // show build logs - printLogs(now.host); - }; - - if (now.syncAmount) { - const bar = new Progress('> Upload [:bar] :percent :etas', { - width: 20, - complete: '=', - incomplete: '', - total: now.syncAmount - }); - - now.upload(); - - now.on('upload', ({ name, data }) => { - const amount = data.length; - if (debug) { - console.log(`> [debug] Uploaded: ${name} (${bytes(data.length)})`); - } - bar.tick(amount); - }); - - now.on('complete', complete); - - now.on('error', (err) => { - error('Upload failed'); - handleError(err); - exit(1); - }); - } else { - console.log('> Sync complete (cached)'); - - // close http2 agent - now.close(); - - // show build logs - printLogs(now.host); - } -} - -function printLogs (host) { - // log build - const logger = new Logger(host); - logger.on('error', () => { - console.log('> Connection error.'); - exit(1); - }); - logger.on('close', () => { - console.log(`${chalk.cyan('> Deployment complete!')}`); - exit(0); - }); -} - -function handleError (err) { - if (403 === err.status) { - error('Authentication error. Run `now -L` or `now --login` to log-in again.'); - } else if (429 === err.status) { - if (null != err.retryAfter) { - error('Rate limit exceeded error. Try again in ' + - ms(err.retryAfter * 1000, { long: true }) + - ', or upgrade your account: https://zeit.co/now#pricing'); - } else { - error('Rate limit exceeded error. Please try later.'); - } - } else if (err.userError) { - error(err.message); - } else if (500 === err.status) { - error('Unexpected server error. Please retry.'); - } else { - error(`Unexpected error. Please try later. (${err.message})`); - } - exit(1); +if (commands.has(cmd)) { + cmd = aliases.get(cmd) || cmd; + args = process.argv.slice(3); +} else { + cmd = 'deploy'; + args = process.argv.slice(2); } -function error (err) { - console.error(`> \u001b[31mError!\u001b[39m ${err}`); -} +const bin = resolve(__dirname, 'now-' + cmd); +const proc = spawn(bin, args, { stdio: 'inherit', customFds: [0, 1, 2] }); +proc.on('close', (code) => exit(code)); +proc.on('error', () => exit(1)); diff --git a/bin/now-deploy b/bin/now-deploy new file mode 100755 index 0000000..e96743b --- /dev/null +++ b/bin/now-deploy @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import Progress from 'progress'; +import copy from '../lib/copy'; +import { resolve } from 'path'; +import login from '../lib/login'; +import * as cfg from '../lib/cfg'; +import { version } from '../../package'; +import Logger from '../lib/build-logger'; +import bytes from 'bytes'; +import chalk from 'chalk'; +import minimist from 'minimist'; +import Now from '../lib'; +import ms from 'ms'; +import { handleError, error } from '../lib/error'; + +const argv = minimist(process.argv.slice(2)); +const help = () => { + console.log(` + 𝚫 now [options] + + Commands: + + list output list of instances + ls alias of list + + Options: + + -h, --help output usage information + -v, --version output the version number + -d, --debug Debug mode [off] + -f, --force Force a new deployment even if nothing has changed + -L, --login Configure login + -C, --no-clipboard Do not attempt to copy URL to clipboard +`); +}; + +let path = argv._[0]; + +if (path) { + if ('/' !== path[0]) { + path = resolve(process.cwd(), path); + } +} else { + path = process.cwd(); +} + +// options +const debug = argv.debug || argv.d; +const clipboard = !(argv.noClipboard || argv.C); +const force = argv.f || argv.force; +const forceSync = argv.F || argv.forceSync; +const shouldLogin = argv.L || argv.login; +const apiUrl = argv.url || 'https://api.now.sh'; + +const config = cfg.read(); + +if (argv.h || argv.help) { + help(); + process.exit(0); +} else if (argv.v || argv.version) { + console.log(chalk.bold('𝚫 now'), version); + process.exit(0); +} else if (!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.stack}`); + process.exit(1); + }); + } + }) + .catch((e) => { + error(`Authentication error – ${e.message}`); + process.exit(1); + }); +} else { + sync(config.token).catch((err) => { + error(`Unknown error: ${err.stack}`); + process.exit(1); + }); +} + +async function sync (token) { + const start = Date.now(); + + console.log(`> Deploying "${path}"`); + + const now = new Now(apiUrl, token, { debug }); + + try { + await now.create(path, { forceNew: force, forceSync: forceSync }); + } catch (err) { + handleError(err); + process.exit(1); + } + + const { url } = now; + const elapsed = ms(new Date() - start); + + if (clipboard) { + try { + await copy(url); + console.log(`${chalk.cyan('> Ready!')} ${chalk.bold(url)} (copied to clipboard) [${elapsed}]`); + } catch (err) { + console.log(`${chalk.cyan('> Ready!')} ${chalk.bold(url)} [${elapsed}]`); + } + } else { + console.log(`> ${url} [${elapsed}]`); + } + + const start_u = new Date(); + const complete = () => { + const elapsed_u = ms(new Date() - start_u); + console.log(`> Sync complete (${bytes(now.syncAmount)}) [${elapsed_u}] `); + + // close http2 agent + now.close(); + + // show build logs + printLogs(now.host); + }; + + if (now.syncAmount) { + const bar = new Progress('> Upload [:bar] :percent :etas', { + width: 20, + complete: '=', + incomplete: '', + total: now.syncAmount + }); + + now.upload(); + + now.on('upload', ({ name, data }) => { + const amount = data.length; + if (debug) { + console.log(`> [debug] Uploaded: ${name} (${bytes(data.length)})`); + } + bar.tick(amount); + }); + + now.on('complete', complete); + + now.on('error', (err) => { + error('Upload failed'); + handleError(err); + process.exit(1); + }); + } else { + console.log('> Sync complete (cached)'); + + // close http2 agent + now.close(); + + // show build logs + printLogs(now.host); + } +} + +function printLogs (host) { + // log build + const logger = new Logger(host); + logger.on('error', () => { + console.log('> Connection error.'); + process.exit(1); + }); + logger.on('close', () => { + console.log(`${chalk.cyan('> Deployment complete!')}`); + process.exit(0); + }); +} diff --git a/bin/now-list b/bin/now-list new file mode 100755 index 0000000..667e760 --- /dev/null +++ b/bin/now-list @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +import minimist from 'minimist'; +import chalk from 'chalk'; +import table from 'text-table'; +import ms from 'ms'; +import Now from '../lib'; +import login from '../lib/login'; +import * as cfg from '../lib/cfg'; +import { handleError, error } from '../lib/error'; + +const argv = minimist(process.argv.slice(2)); +const app = argv._[0]; + +// options +const debug = argv.debug || argv.d; +const apiUrl = argv.url || 'https://api.now.sh'; + +const config = cfg.read(); + +Promise.resolve(config.token || login(apiUrl)) +.then(async (token) => { + try { + await list(token); + } catch (err) { + error(`Unknown error: ${err.stack}`); + process.exit(1); + } +}) +.catch((e) => { + error(`Authentication error – ${e.message}`); + process.exit(1); +}); + +async function list (token) { + const now = new Now(apiUrl, token, { debug }); + + let deployments; + try { + deployments = await now.list(app); + } catch (err) { + handleError(err); + process.exit(1); + } + + now.close(); + + const apps = new Map(); + for (const dep of deployments) { + const deps = apps.get(dep.name) || []; + apps.set(dep.name, deps.concat(dep)); + } + + const current = Date.now(); + const text = [...apps].map(([name, deps]) => { + const t = table(deps.map(({ uid, url, created }) => { + const time = ms(current - created, { long: true }) + ' ago'; + return [ uid, time, `https://${url}` ]; + }), { align: ['l', 'r', 'l'] }); + return chalk.bold(name) + '\n\n' + indent(t, 2).split('\n').join('\n\n'); + }).join('\n\n'); + + if (text) console.log('\n' + text + '\n'); +} + +function indent (text, n) { + return text.split('\n').map((l) => ' '.repeat(n) + l).join('\n'); +} diff --git a/lib/agent.js b/lib/agent.js index 2b55266..84debbc 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -12,8 +12,8 @@ import fetch from 'node-fetch'; */ export default class Agent { - constructor (host, { debug } = {}) { - this._host = host; + constructor (url, { debug } = {}) { + this._url = url; this._debug = debug; this._initAgent(); } @@ -54,7 +54,7 @@ export default class Agent { opts.headers['Content-Length'] = Buffer.byteLength(opts.body); } - return fetch(`https://${this._host}${path}`, opts); + return fetch(this._url + path, opts); } close () { diff --git a/lib/error.js b/lib/error.js new file mode 100644 index 0000000..acaab62 --- /dev/null +++ b/lib/error.js @@ -0,0 +1,25 @@ +import ms from 'ms'; + +export function handleError (err) { + if (403 === err.status) { + error('Authentication error. Run `now -L` or `now --login` to log-in again.'); + } else if (429 === err.status) { + if (null != err.retryAfter) { + error('Rate limit exceeded error. Try again in ' + + ms(err.retryAfter * 1000, { long: true }) + + ', or upgrade your account: https://zeit.co/now#pricing'); + } else { + error('Rate limit exceeded error. Please try later.'); + } + } else if (err.userError) { + error(err.message); + } else if (500 === err.status) { + error('Unexpected server error. Please retry.'); + } else { + error(`Unexpected error. Please try later. (${err.message})`); + } +} + +export function error (err) { + console.error(`> \u001b[31mError!\u001b[39m ${err}`); +} diff --git a/lib/index.js b/lib/index.js index a34fbe2..773dd97 100644 --- a/lib/index.js +++ b/lib/index.js @@ -20,12 +20,12 @@ const IS_WIN = /^win/.test(process.platform); const SEP = IS_WIN ? '\\' : '/'; export default class Now extends EventEmitter { - constructor (token, { forceNew = false, debug = false }) { + constructor (url, token, { forceNew = false, debug = false }) { super(); this._token = token; this._debug = debug; this._forceNew = forceNew; - this._agent = new Agent('api.now.sh', { debug }); + this._agent = new Agent(url, { debug }); this._onRetry = this._onRetry.bind(this); } @@ -159,6 +159,32 @@ export default class Now extends EventEmitter { uploadChunk(); } + async list (app) { + const query = app ? `?app=${encodeURIComponent(app)}` : ''; + + const { deployments } = await retry(async (bail) => { + if (this._debug) console.time('> [debug] /list'); + const res = await this._fetch('/list' + query); + if (this._debug) console.timeEnd('> [debug] /list'); + + // no retry on 4xx + if (400 <= res.status && 500 > res.status) { + if (this._debug) { + console.log('> [debug] bailing on listing due to %s', res.status); + } + return bail(responseError(res)); + } + + if (200 !== res.status) { + throw new Error('Fetching deployment list failed'); + } + + return res.json(); + }, { retries: 3, minTimeout: 2500, onRetry: this._onRetry }); + + return deployments; + } + _onRetry (err) { if (this._debug) { console.log(`> [debug] Retrying: ${err.stack}`); @@ -190,7 +216,7 @@ export default class Now extends EventEmitter { return this._syncAmount; } - async _fetch (_url, opts) { + async _fetch (_url, opts = {}) { opts.headers = opts.headers || {}; opts.headers.authorization = `Bearer ${this._token}`; return await this._agent.fetch(_url, opts); diff --git a/lib/login.js b/lib/login.js index 1024aa3..337661a 100644 --- a/lib/login.js +++ b/lib/login.js @@ -3,23 +3,19 @@ import fetch from 'node-fetch'; import * as cfg from './cfg'; import { stringify as stringifyQuery } from 'querystring'; -const URL = 'https://api.now.sh/registration'; - -const stdin = process.openStdin(); - function readEmail () { return new Promise((resolve, reject) => { process.stdout.write('> Enter your email address: '); - stdin.on('data', (d) => { - stdin.destroy(); + process.stdin.on('data', (d) => { + process.stdin.pause(); resolve(d.toString().trim()); - }); + }).resume(); }); } -async function getVerificationToken (email) { +async function getVerificationToken (url, email) { const data = JSON.stringify({ email }); - const res = await fetch(URL, { + const res = await fetch(url + '/registration', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -36,13 +32,13 @@ async function getVerificationToken (email) { return body.token; } -async function verify (email, verificationToken) { +async function verify (url, email, verificationToken) { const query = { email, token: verificationToken }; - const res = await fetch(`${URL}/verify?${stringifyQuery(query)}`); + const res = await fetch(`${url}/registration/verify?${stringifyQuery(query)}`); const body = await res.json(); return body.token; } @@ -53,9 +49,9 @@ function sleep (ms) { }); } -async function register () { +async function register (url) { const email = await readEmail(); - const verificationToken = await getVerificationToken(email); + const verificationToken = await getVerificationToken(url, email); console.log(`> Please follow the link sent to ${chalk.bold(email)} to log in.`); process.stdout.write('> Waiting for confirmation..'); @@ -64,7 +60,7 @@ async function register () { do { await sleep(2500); try { - final = await verify(email, verificationToken); + final = await verify(url, email, verificationToken); } catch (e) {} process.stdout.write('.'); } while (!final); @@ -74,8 +70,8 @@ async function register () { return { email, token: final }; } -export default async function () { - const loginData = await register(); +export default async function (url) { + const loginData = await register(url); cfg.merge(loginData); return loginData.token; } diff --git a/package.json b/package.json index 2461c5b..8b408b3 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "now": "./build/bin/now" }, "dependencies": { + "ansi-escapes": "1.3.0", "arr-flatten": "1.0.1", "array-unique": "0.2.1", "babel-runtime": "6.6.1", @@ -20,15 +21,15 @@ "fs-promise": "0.4.1", "graceful-fs": "4.1.3", "minimatch": "3.0.0", + "minimist": "1.2.0", "ms": "0.7.1", "node-fetch": "1.3.3", "progress": "1.1.8", "resumer": "0.0.0", "retry": "0.9.0", + "socket.io-client": "1.4.5", "split-array": "1.0.1", - "minimist": "1.2.0", - "ansi-escapes": "1.3.0", - "socket.io-client": "1.4.5" + "text-table": "0.2.0" }, "devDependencies": { "alpha-sort": "1.0.2",