Guillermo Rauch
9 years ago
17 changed files with 767 additions and 153 deletions
@ -0,0 +1,280 @@ |
|||
#!/usr/bin/env node |
|||
import chalk from 'chalk'; |
|||
import minimist from 'minimist'; |
|||
import table from 'text-table'; |
|||
import ms from 'ms'; |
|||
import NowAlias from '../lib/alias'; |
|||
import login from '../lib/login'; |
|||
import * as cfg from '../lib/cfg'; |
|||
import { error } from '../lib/error'; |
|||
import toHost from '../lib/to-host'; |
|||
|
|||
const argv = minimist(process.argv.slice(2), { |
|||
boolean: ['help', 'debug'], |
|||
alias: { |
|||
help: 'h', |
|||
debug: 'd' |
|||
} |
|||
}); |
|||
const subcommand = argv._[0]; |
|||
|
|||
// options |
|||
const help = () => { |
|||
console.log(` |
|||
${chalk.bold('𝚫 now alias')} <ls | set | rm> <deployment> <alias> |
|||
|
|||
${chalk.dim('Options:')} |
|||
|
|||
-h, --help output usage information |
|||
-d, --debug debug mode [off] |
|||
|
|||
${chalk.dim('Examples:')} |
|||
|
|||
${chalk.gray('–')} Lists all your aliases: |
|||
|
|||
${chalk.cyan('$ now alias ls')} |
|||
|
|||
${chalk.gray('–')} Adds a new alias to ${chalk.underline('my-api.now.sh')}: |
|||
|
|||
${chalk.cyan(`$ now alias set ${chalk.underline('api-ownv3nc9f8.now.sh')} ${chalk.underline('my-api.now.sh')}`)} |
|||
|
|||
The ${chalk.dim('`.now.sh`')} suffix can be ommited: |
|||
|
|||
${chalk.cyan('$ now alias set api-ownv3nc9f8 my-api')} |
|||
|
|||
The deployment id can be used as the source: |
|||
|
|||
${chalk.cyan('$ now alias set deploymentId my-alias')} |
|||
|
|||
Custom domains work as alias targets: |
|||
|
|||
${chalk.cyan(`$ now alias set ${chalk.underline('api-ownv3nc9f8.now.sh')} ${chalk.underline('my-api.com')}`)} |
|||
|
|||
${chalk.dim('–')} The subcommand ${chalk.dim('`set`')} is the default and can be skipped. |
|||
${chalk.dim('–')} ${chalk.dim('`http(s)://`')} in the URLs is unneeded / ignored. |
|||
|
|||
${chalk.gray('–')} Removing an alias: |
|||
|
|||
${chalk.cyan('$ now alias rm aliasId')} |
|||
|
|||
To get the list of alias ids, use ${chalk.dim('`now alias ls`')}. |
|||
|
|||
${chalk.dim('Alias:')} ln |
|||
`); |
|||
}; |
|||
|
|||
// options |
|||
const debug = argv.debug; |
|||
const apiUrl = argv.url || 'https://api.zeit.co'; |
|||
|
|||
const exit = (code) => { |
|||
// we give stdout some time to flush out |
|||
// because there's a node bug where |
|||
// stdout writes are asynchronous |
|||
// https://github.com/nodejs/node/issues/6456 |
|||
setTimeout(() => process.exit(code || 0), 100); |
|||
}; |
|||
|
|||
if (argv.help || !subcommand) { |
|||
help(); |
|||
exit(0); |
|||
} else { |
|||
const config = cfg.read(); |
|||
|
|||
Promise.resolve(config.token || login(apiUrl)) |
|||
.then(async (token) => { |
|||
try { |
|||
await run(token); |
|||
} catch (err) { |
|||
if (err.userError) { |
|||
error(err.message); |
|||
} else { |
|||
error(`Unknown error: ${err.stack}`); |
|||
} |
|||
exit(1); |
|||
} |
|||
}) |
|||
.catch((e) => { |
|||
error(`Authentication error – ${e.message}`); |
|||
exit(1); |
|||
}); |
|||
} |
|||
|
|||
async function run (token) { |
|||
const alias = new NowAlias(apiUrl, token, { debug }); |
|||
const args = argv._.slice(1); |
|||
|
|||
switch (subcommand) { |
|||
case 'list': |
|||
case 'ls': |
|||
const list = await alias.list(); |
|||
const urls = new Map(list.map(l => [l.uid, l.url])); |
|||
|
|||
const target = null != args[0] ? String(args[0]) : null; |
|||
const aliases = await alias.ls(target); |
|||
const byTarget = new Map(); |
|||
if (target) { |
|||
byTarget.set(target, aliases); |
|||
} else { |
|||
aliases.forEach((_alias) => { |
|||
const _aliases = byTarget.get(_alias.deploymentId) || []; |
|||
byTarget.set(_alias.deploymentId, _aliases.concat(_alias)); |
|||
}); |
|||
} |
|||
|
|||
const sorted = await sort([...byTarget]); |
|||
|
|||
const current = new Date(); |
|||
const text = sorted.map(([target, _aliases]) => { |
|||
return table(_aliases.map((_alias) => { |
|||
const _url = chalk.underline(`https://${_alias.alias}`); |
|||
const _sourceUrl = chalk.underline(`https://${urls.get(target)}`); |
|||
const time = chalk.gray(ms(current - new Date(_alias.created)) + ' ago'); |
|||
return [_alias.uid, _sourceUrl, _url, time]; |
|||
}), { align: ['l', 'r', 'l'], hsep: ' '.repeat(3) }); |
|||
}).join('\n\n'); |
|||
|
|||
if (text) console.log('\n' + text + '\n'); |
|||
break; |
|||
|
|||
case 'remove': |
|||
case 'rm': |
|||
const _target = String(args[0]); |
|||
if (!_target) { |
|||
const err = new Error('No alias id specified'); |
|||
err.userError = true; |
|||
throw err; |
|||
} |
|||
|
|||
const _aliases = await alias.ls(); |
|||
const _alias = findAlias(_target, _aliases); |
|||
|
|||
if (!_alias) { |
|||
const err = new Error(`Alias not found by "${_target}". Run ${chalk.dim('`now alias ls`')} to see your aliases.`); |
|||
err.userError = true; |
|||
throw err; |
|||
} |
|||
|
|||
try { |
|||
const confirmation = (await readConfirmation(alias, _alias)).toLowerCase(); |
|||
if ('y' !== confirmation && 'yes' !== confirmation) { |
|||
console.log('\n> Aborted'); |
|||
process.exit(0); |
|||
} |
|||
|
|||
const start = new Date(); |
|||
await alias.rm(_alias); |
|||
const elapsed = ms(new Date() - start); |
|||
console.log(`${chalk.cyan('> Success!')} Alias ${chalk.bold(_alias.uid)} removed [${elapsed}]`); |
|||
} catch (err) { |
|||
error(err); |
|||
exit(1); |
|||
} |
|||
|
|||
break; |
|||
|
|||
case 'add': |
|||
case 'set': |
|||
if (2 !== args.length) { |
|||
error('Invalid number of arguments'); |
|||
return; |
|||
} |
|||
await alias.set(String(args[0]), String(args[1])); |
|||
break; |
|||
|
|||
default: |
|||
if (2 === argv._.length) { |
|||
await alias.set(String(argv._[0]), String(argv._[1])); |
|||
} else if (argv._.length >= 3) { |
|||
error('Invalid number of arguments'); |
|||
help(); |
|||
exit(1); |
|||
} else { |
|||
error('Please specify a valid subcommand: ls | set | rm'); |
|||
help(); |
|||
exit(1); |
|||
} |
|||
} |
|||
|
|||
alias.close(); |
|||
} |
|||
|
|||
async function sort (aliases) { |
|||
let pkg; |
|||
try { |
|||
const json = await fs.readFile('package.json'); |
|||
pkg = JSON.parse(json); |
|||
} catch (err) { |
|||
pkg = {}; |
|||
} |
|||
|
|||
return aliases |
|||
.map(([target, _aliases]) => { |
|||
_aliases = _aliases.slice().sort((a, b) => { |
|||
return b.created - a.created; |
|||
}); |
|||
return [target, _aliases]; |
|||
}) |
|||
.sort(([targetA, aliasesA], [targetB, aliasesB]) => { |
|||
if (pkg.name === targetA) return -1; |
|||
if (pkg.name === targetB) return 1; |
|||
return aliasesB[0].created - aliasesA[0].created; |
|||
}); |
|||
} |
|||
|
|||
function indent (text, n) { |
|||
return text.split('\n').map((l) => ' '.repeat(n) + l).join('\n'); |
|||
} |
|||
|
|||
async function readConfirmation (alias, _alias) { |
|||
const list = await alias.list(); |
|||
const urls = new Map(list.map(l => [l.uid, l.url])); |
|||
|
|||
return new Promise((resolve, reject) => { |
|||
const time = chalk.gray(ms(new Date() - new Date(_alias.created)) + ' ago'); |
|||
const _sourceUrl = chalk.underline(`https://${urls.get(_alias.deploymentId)}`); |
|||
const tbl = table( |
|||
[[_alias.uid, _sourceUrl, chalk.underline(`https://${_alias.alias}`), time]], |
|||
{ align: ['l', 'r', 'l'], hsep: ' '.repeat(6) } |
|||
); |
|||
|
|||
process.stdout.write('> The following deployment will be removed permanently\n'); |
|||
process.stdout.write(' ' + tbl + '\n'); |
|||
process.stdout.write(` ${chalk.bold.red('> Are you sure?')} ${chalk.gray('[yN] ')}`); |
|||
|
|||
process.stdin.on('data', (d) => { |
|||
process.stdin.pause(); |
|||
resolve(d.toString().trim()); |
|||
}).resume(); |
|||
}); |
|||
} |
|||
|
|||
function findAlias (alias, list) { |
|||
let key, val; |
|||
if (/\./.test(alias)) { |
|||
val = toHost(alias); |
|||
key = 'alias'; |
|||
} else { |
|||
val = alias; |
|||
key = 'uid'; |
|||
} |
|||
|
|||
const _alias = list.find((d) => { |
|||
if (d[key] === val) { |
|||
if (debug) console.log(`> [debug] matched alias ${d.uid} by ${key} ${val}`); |
|||
return true; |
|||
} |
|||
|
|||
// match prefix |
|||
if (`${val}.now.sh` === d.alias) { |
|||
if (debug) console.log(`> [debug] matched alias ${d.uid} by url ${d.host}`); |
|||
return true; |
|||
} |
|||
|
|||
return false; |
|||
}); |
|||
|
|||
return _alias; |
|||
} |
|||
|
|||
|
@ -0,0 +1,246 @@ |
|||
import retry from 'async-retry'; |
|||
import dns from 'dns'; |
|||
import Now from '../lib'; |
|||
import toHost from './to-host'; |
|||
import chalk from 'chalk'; |
|||
|
|||
export default class Alias extends Now { |
|||
|
|||
async ls (deployment) { |
|||
if (deployment) { |
|||
const target = await this.findDeployment(deployment); |
|||
if (!target) { |
|||
const err = new Error(`Aliases not found by "${deployment}". Run ${chalk.dim('`now alias ls`')} to see your aliases.`); |
|||
err.userError = true; |
|||
throw err; |
|||
} |
|||
|
|||
return this.retry(async (bail, attempt) => { |
|||
const res = await this._fetch(`/now/deployments/${target.uid}/aliases`); |
|||
const body = await res.json(); |
|||
return body.aliases; |
|||
}); |
|||
} |
|||
|
|||
return this.retry(async (bail, attempt) => { |
|||
const res = await this._fetch('/now/aliases'); |
|||
const body = await res.json(); |
|||
return body.aliases; |
|||
}); |
|||
} |
|||
|
|||
async rm (_alias) { |
|||
return this.retry(async (bail, attempt) => { |
|||
const res = await this._fetch(`/now/aliases/${_alias.uid}`, { |
|||
method: 'DELETE' |
|||
}); |
|||
|
|||
if (403 === res.status) { |
|||
return bail(new Error('Unauthorized')); |
|||
} |
|||
|
|||
if (res.status !== 200) { |
|||
const err = new Error('Deletion failed. Try again later.'); |
|||
throw err; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
async findDeployment (deployment) { |
|||
const list = await this.list(); |
|||
let key, 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 set (deployment, alias) { |
|||
alias = alias.toLowerCase(); |
|||
|
|||
const depl = await this.findDeployment(deployment); |
|||
if (!depl) { |
|||
const err = new Error(`Deployment not found by "${deployment}". Run ${chalk.dim('`now ls`')} to see your deployments.`); |
|||
err.userError = true; |
|||
throw err; |
|||
} |
|||
|
|||
// evaluate the alias
|
|||
if (!/\./.test(alias)) { |
|||
if (this._debug) console.log(`> [debug] suffixing \`.now.sh\` to alias ${alias}`); |
|||
alias = `${alias}.now.sh`; |
|||
} else { |
|||
alias = toHost(alias); |
|||
} |
|||
|
|||
if (!/\.now\.sh$/.test(alias)) { |
|||
console.log(`> ${chalk.bold(chalk.underline(alias))} is a custom domain.`); |
|||
console.log(`> Verifying that ${chalk.bold(chalk.underline(alias))} has a ${chalk.cyan('`CNAME`')} or ${chalk.cyan('`ALIAS`')} record pointing to ${chalk.bold(chalk.underline('alias.zeit.co'))}.`); |
|||
await this.verifyOwnership(alias); |
|||
console.log(`> Verification ${chalk.bold('OK')}!`); |
|||
} |
|||
|
|||
const { created, uid } = await this.createAlias(depl, alias); |
|||
if (created) { |
|||
console.log(`${chalk.cyan('> Success!')} Alias created ${chalk.dim(`(${uid})`)}: ${chalk.bold(`https://${depl.url}`)} ${chalk.dim(`(${depl.uid})`)} now points to ${chalk.bold(chalk.underline(`https://${alias}`))}`); |
|||
} else { |
|||
console.log(`${chalk.cyan('> Success!')} Alias already exists ${chalk.dim(`(${uid})`)}.`); |
|||
} |
|||
} |
|||
|
|||
createAlias (depl, alias) { |
|||
return this.retry(async (bail, attempt) => { |
|||
if (this._debug) console.time(`> [debug] /now/deployments/${depl.uid}/aliases #${attempt}`); |
|||
const res = await this._fetch(`/now/deployments/${depl.uid}/aliases`, { |
|||
method: 'POST', |
|||
body: { alias } |
|||
}); |
|||
|
|||
const body = await res.json(); |
|||
if (this._debug) console.timeEnd(`> [debug] /now/deployments/${depl.uid}/aliases #${attempt}`); |
|||
|
|||
// 409 conflict is returned if it already exists
|
|||
if (409 === res.status) return { uid: body.error.uid }; |
|||
|
|||
// no retry on authorization problems
|
|||
if (403 === res.status) { |
|||
const code = body.error.code; |
|||
|
|||
if ('custom_domain_needs_upgrade' === code) { |
|||
const err = new Error(`Custom domains are only enabled for premium accounts. Please upgrade at ${chalk.underline('https://zeit.co/account')}.`); |
|||
err.userError = true; |
|||
return bail(err); |
|||
} |
|||
|
|||
if ('alias_in_use' === code) { |
|||
const err = new Error(`The alias you are trying to configure (${chalk.underline(chalk.bold(alias))}) is already in use by a different account.`); |
|||
err.userError = true; |
|||
return bail(err); |
|||
} |
|||
|
|||
return bail(new Error('Authorization error')); |
|||
} |
|||
|
|||
// all other errors
|
|||
if (body.error) { |
|||
const code = body.error.code; |
|||
|
|||
if ('deployment_not_found' === code) { |
|||
return bail(new Error('Deployment not found')); |
|||
} |
|||
|
|||
if ('cert_missing' === code) { |
|||
console.log(`> Provisioning certificate for ${chalk.underline(chalk.bold(alias))}`); |
|||
await this.createCert(alias); |
|||
|
|||
// try again, but now having provisioned the certificate
|
|||
return this.createAlias(depl, alias); |
|||
} |
|||
|
|||
return bail(new Error(body.error.message)); |
|||
} |
|||
|
|||
// the two expected succesful cods are 200 and 304
|
|||
if (200 !== res.status && 304 !== res.status) { |
|||
throw new Error('Unhandled error'); |
|||
} |
|||
|
|||
return body; |
|||
}); |
|||
} |
|||
|
|||
verifyOwnership (domain) { |
|||
return this.retry(async (bail, attempt) => { |
|||
const targets = await resolve4('alias.zeit.co'); |
|||
|
|||
if (!targets.length) { |
|||
return bail(new Error('Unable to resolve alias.zeit.co')); |
|||
} |
|||
|
|||
const ips = await resolve4(domain); |
|||
if (!ips.length) { |
|||
const err = new Error('The domain ${domain} A record in the DNS configuration is not returning any IPs.'); |
|||
err.userError = true; |
|||
return bail(err); |
|||
} |
|||
|
|||
for (const ip of ips) { |
|||
if (!~targets.indexOf(ip)) { |
|||
const err = new Error(`The domain ${domain} has an A record ${chalk.bold(ip)} that doesn\'t resolve to ${chalk.bold(chalk.underline('alias.zeit.co'))}. Please check your DNS settings.`); |
|||
err.userError = true; |
|||
return bail(err); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
createCert (domain) { |
|||
return this.retry(async (bail, attempt) => { |
|||
if (this._debug) console.time(`> [debug] /now/certs #${attempt}`); |
|||
const res = await this._fetch('/now/certs', { |
|||
method: 'POST', |
|||
body: { |
|||
domains: [domain] |
|||
} |
|||
}); |
|||
|
|||
if (304 === res.status) { |
|||
console.log('> Certificate already issued.'); |
|||
return; |
|||
} |
|||
|
|||
const body = await res.json(); |
|||
if (this._debug) console.timeEnd(`> [debug] /now/certs #${attempt}`); |
|||
|
|||
if (body.error) { |
|||
const { code } = body.error; |
|||
|
|||
if ('verification_failed' === code) { |
|||
const err = new Error(`We couldn't verify ownership of the domain ${domain}. Make sure the appropriate \`ALIAS\` or \`CNAME\` records are configured and pointing to ${chalk.bold('alias.zeit.co')}.`); |
|||
err.userError = true; |
|||
return bail(err); |
|||
} |
|||
|
|||
throw new Error(body.message); |
|||
} |
|||
|
|||
if (200 !== res.status && 304 !== res.status) { |
|||
throw new Error('Unhandled error'); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
retry (fn) { |
|||
return retry(fn, { retries: 5, randomize: true, onRetry: this._onRetry }); |
|||
} |
|||
|
|||
} |
|||
|
|||
function resolve4 (host) { |
|||
return new Promise((resolve, reject) => { |
|||
return dns.resolve4(host, (err, answer) => { |
|||
if (err) return reject(err); |
|||
resolve(answer); |
|||
}); |
|||
}); |
|||
} |
@ -1,29 +0,0 @@ |
|||
import retrier from 'retry'; |
|||
|
|||
export default function retry (fn, opts) { |
|||
return new Promise((resolve, reject) => { |
|||
const op = retrier.operation(opts); |
|||
const { onRetry } = opts; |
|||
|
|||
// we allow the user to abort retrying
|
|||
// this makes sense in the cases where
|
|||
// knowledge is obtained that retrying
|
|||
// would be futile (e.g.: auth errors)
|
|||
const bail = (err) => reject(err); |
|||
|
|||
op.attempt((num) => { |
|||
if (num > 1 && onRetry) { |
|||
const errs = op.errors(); |
|||
onRetry(errs[errs.length - 1]); |
|||
} |
|||
|
|||
fn(bail) |
|||
.then((val) => resolve(val)) |
|||
.catch(err => { |
|||
if (!op.retry(err)) { |
|||
reject(op.mainError()); |
|||
} |
|||
}); |
|||
}); |
|||
}); |
|||
} |
@ -0,0 +1,17 @@ |
|||
import { parse } from 'url'; |
|||
|
|||
/** |
|||
* Converts a valid deployment lookup parameter to a hostname. |
|||
* `http://google.com` => google.com |
|||
* google.com => google.com |
|||
*/ |
|||
|
|||
export default function toHost (url) { |
|||
if (/^https?:\/\//.test(url)) { |
|||
return parse(url).host; |
|||
} else { |
|||
// remove any path if present
|
|||
// `a.b.c/` => `a.b.c`
|
|||
return url.replace(/(\/\/)?([^\/]+)(.*)/, '$2'); |
|||
} |
|||
} |
@ -0,0 +1,4 @@ |
|||
import os from 'os'; |
|||
import { version } from '../../package'; |
|||
|
|||
export default `now ${version} node-${process.version} ${os.platform()} (${os.arch()})`; |
After Width: | Height: | Size: 167 KiB |
@ -0,0 +1,26 @@ |
|||
import test from 'ava'; |
|||
import toHost from '../lib/to-host'; |
|||
|
|||
test('simple', async t => { |
|||
t.is(toHost('zeit.co'), 'zeit.co'); |
|||
}); |
|||
|
|||
test('leading //', async t => { |
|||
t.is(toHost('//zeit-logos-rnemgaicnc.now.sh'), 'zeit-logos-rnemgaicnc.now.sh'); |
|||
}); |
|||
|
|||
test('leading http://', async t => { |
|||
t.is(toHost('http://zeit-logos-rnemgaicnc.now.sh'), 'zeit-logos-rnemgaicnc.now.sh'); |
|||
}); |
|||
|
|||
test('leading https://', async t => { |
|||
t.is(toHost('https://zeit-logos-rnemgaicnc.now.sh'), 'zeit-logos-rnemgaicnc.now.sh'); |
|||
}); |
|||
|
|||
test('leading https:// and path', async t => { |
|||
t.is(toHost('https://zeit-logos-rnemgaicnc.now.sh/path'), 'zeit-logos-rnemgaicnc.now.sh'); |
|||
}); |
|||
|
|||
test('simple and path', async t => { |
|||
t.is(toHost('zeit.co/test'), 'zeit.co'); |
|||
}); |
Loading…
Reference in new issue