diff --git a/bin/now-dns.js b/bin/now-dns.js new file mode 100755 index 0000000..9bb26a9 --- /dev/null +++ b/bin/now-dns.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +// Packages +import chalk from 'chalk' +import minimist from 'minimist' +import ms from 'ms' +import table from 'text-table' + +// Ours +import * as cfg from '../lib/cfg' +import DomainRecords from '../lib/domain-records' +import indent from '../lib/indent' +import login from '../lib/login' +import strlen from '../lib/strlen' +import {handleError, error} from '../lib/error' + +const argv = minimist(process.argv.slice(2), { + string: ['config'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't' + } +}) +const subcommand = argv._[0] + +// options +const help = () => { + console.log(` + ${chalk.bold('𝚫 now dns ls')} [domain] + ${chalk.bold('𝚫 now dns add')} [mx_priority] + ${chalk.bold('𝚫 now dns rm')} + + ${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('–')} Listing all your DNS records: + + ${chalk.cyan('$ now dns ls')} + + ${chalk.gray('–')} Adding an A record for a subdomain: + + ${chalk.cyan('$ now dns add zeit.rocks subdomain A 198.51.100.100')} + + ${chalk.gray('–')} Adding an MX record (@ as a name refers to the domain): + + ${chalk.cyan('$ now dns add zeit.rocks @ MX mail.zeit.rocks 10')} +`) +} + +// options +const debug = argv.debug +const apiUrl = argv.url || 'https://api.zeit.co' +if (argv.config) { + cfg.setConfigFile(argv.config) +} + +const exit = code => { + // we give stdout some time to flush out + // because there's a node bug where + // stdout writes are asynchronous + // https://github.com/nodejs/node/issues/6456 + setTimeout(() => process.exit(code || 0), 100) +} + +if (argv.help || !subcommand) { + help() + exit(0) +} else { + const config = cfg.read() + + Promise.resolve(argv.token || config.token || login(apiUrl)) + .then(async token => { + try { + await run(token) + } catch (err) { + handleError(err) + exit(1) + } + }) + .catch(e => { + error(`Authentication error – ${e.message}`) + exit(1) + }) +} + +async function run(token) { + const domainRecords = new DomainRecords(apiUrl, token, {debug}) + const args = argv._.slice(1) + const start = Date.now() + + if (subcommand === 'ls' || subcommand === 'list') { + if (args.length > 1) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now dns ls [domain]`')}`) + return exit(1) + } + + const elapsed = ms(new Date() - start) + const res = await domainRecords.ls(args[0]) + const text = [] + let count = 0 + res.forEach((records, domain) => { + count += records.length + if (records.length > 0) { + const cur = Date.now() + const header = [['', 'id', 'name', 'type', 'value', 'aux', 'created'].map(s => chalk.dim(s))] + const out = table(header.concat(records.map(record => { + const time = chalk.gray(ms(cur - new Date(Number(record.created))) + ' ago') + return [ + '', + record.id, + record.name, + record.type, + record.value, + record.mxPriority ? record.mxPriority : '', + time + ] + })), {align: ['l', 'r', 'l', 'l', 'l', 'l'], hsep: ' '.repeat(2), stringLength: strlen}) + text.push(`\n\n${chalk.bold(domain)}\n${indent(out, 2)}`) + } + }) + console.log(`> ${count} record${count === 1 ? '' : 's'} found ${chalk.gray(`[${elapsed}]`)}`) + console.log(text.join('')) + } else if (subcommand === 'add') { + const domain = args[0] + const name = args[1] === '@' ? '' : args[1] + const type = args[2] + const value = args[3] + const mxPriority = args[4] + + if (!(args.length >= 4 && args.length <= 5) || + (type === 'MX' ? !mxPriority : mxPriority)) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now dns add [mx_priority]`')}`) + return exit(1) + } + + const record = await domainRecords.create(domain, {name, type, value, mxPriority}) + const elapsed = ms(new Date() - start) + console.log(`${chalk.cyan('> Success!')} A new DNS record for domain ${chalk.bold(domain)} ${chalk.gray(`(${record.uid})`)} created ${chalk.gray(`[${elapsed}]`)}`) + } else if (subcommand === 'rm' || subcommand === 'remove') { + if (args.length !== 1) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now dns rm `')}`) + return exit(1) + } + + const record = await domainRecords.getRecord(args[0]) + if (!record) { + error('DNS record not found') + return exit(1) + } + + const yes = await readConfirmation(record, 'The following record will be removed permanently\n') + if (!yes) { + error('User abort') + return exit(0) + } + + await domainRecords.delete(record.domain, record.id) + const elapsed = ms(new Date() - start) + console.log(`${chalk.cyan('> Success!')} Record ${chalk.gray(`${record.id}`)} removed ${chalk.gray(`[${elapsed}]`)}`) + } else { + error('Please specify a valid subcommand: ls | add | rm') + help() + exit(1) + } + return domainRecords.close() +} + +process.on('uncaughtException', err => { + handleError(err) + exit(1) +}) + +function readConfirmation(record, msg) { + return new Promise(resolve => { + const time = chalk.gray(ms(new Date() - new Date(Number(record.created))) + ' ago') + const tbl = table( + [[record.id, + chalk.bold(`${record.name.length > 0 ? record.name + '.' : ''}${record.domain} ${record.type} ${record.value} ${record.mxPriority ? record.mxPriority : ''}`), + time]], + {align: ['l', 'r', 'l'], hsep: ' '.repeat(6)} + ) + + process.stdout.write(`> ${msg}`) + 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().toLowerCase() === 'y') + }).resume() + }) +} diff --git a/bin/now-list.js b/bin/now-list.js index 266010f..61dc539 100755 --- a/bin/now-list.js +++ b/bin/now-list.js @@ -9,6 +9,7 @@ import ms from 'ms' // Ours import strlen from '../lib/strlen' +import indent from '../lib/indent' import Now from '../lib' import login from '../lib/login' import * as cfg from '../lib/cfg' @@ -150,7 +151,3 @@ async function sort(apps) { return depsB[0].created - depsA[0].created }) } - -function indent(text, n) { - return text.split('\n').map(l => ' '.repeat(n) + l).join('\n') -} diff --git a/bin/now.js b/bin/now.js index 1b3b045..ba5050f 100755 --- a/bin/now.js +++ b/bin/now.js @@ -38,6 +38,7 @@ const commands = new Set([ 'ln', 'domain', 'domains', + 'dns', 'cert', 'certs', 'secret', diff --git a/lib/domain-records.js b/lib/domain-records.js new file mode 100644 index 0000000..4fce2fe --- /dev/null +++ b/lib/domain-records.js @@ -0,0 +1,130 @@ +// Ours +import Now from '../lib' + +export default class DomainRecords extends Now { + + async getRecord(id) { + const all = (await this.ls()).entries() + for (const [domain, records] of all) { + for (const record of records) { + if (record.id === id) { + record.domain = domain + return record + } + } + } + return null + } + + async ls(dom) { + let domains + + if (dom) { + domains = [dom] + } else { + const ret = await this.listDomains() + domains = ret.filter(x => !x.isExternal).map(x => x.name).sort((a, b) => a.localeCompare(b)) + } + + const records = new Map() + for (const domain of domains) { + const body = await this.retry(async (bail, attempt) => { + const url = `/domains/${domain}/records` + if (this._debug) { + console.time(`> [debug] #${attempt} GET ${url}`) + } + const res = await this._fetch(url) + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} GET ${url}`) + } + const body = await res.json() + + if (res.status === 404 && body.code === 'not_found') { + return bail(new Error(body.message)) + } else if (res.status !== 200) { + throw new Error(`Failed to get DNS records for domain "${domain}"`) + } + + return body + }) + records.set(domain, body.records.sort((a, b) => a.slug.localeCompare(b.slug))) + } + + return records + } + + create(domain, {name, type, value, mxPriority} = {}) { + const url = `/domains/${domain}/records` + + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} POST ${url}`) + } + const res = await this._fetch(url, { + method: 'POST', + body: {name, value, type, mxPriority} + }) + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} POST ${url}`) + } + + const body = await res.json() + if (res.status === 400) { + return bail(new Error(body.error ? body.error.message : 'Unknown error')) + } else if (res.status === 403) { + const err = new Error(`Not authorized to access domain ${name}`) + err.userError = true + return bail(err) + } else if (res.status === 404) { + let err + + if (body.error.code === 'not_found') { + err = new Error(`The domain "${domain}" was not found`) + err.userError = true + return bail(err) + } + } + + if (res.status !== 200) { + throw new Error(body.error ? body.error.message : 'Unknown error') + } + + return body + }) + } + + delete(domain, recordId) { + const url = `/domains/${domain}/records/${recordId}` + + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} DELETE ${url}`) + } + const res = await this._fetch(url, {method: 'DELETE'}) + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} DELETE ${url}`) + } + + const body = await res.json() + if (res.status === 403) { + const err = new Error(`Not authorized to access domain ${domain}`) + err.userError = true + return bail(err) + } else if (res.status === 404) { + let err + + if (body.error.code === 'not_found') { + err = new Error(body.error.message) + err.userError = true + return bail(err) + } + } + + if (res.status !== 200) { + throw new Error(body.error ? body.error.message : 'Unkown error') + } + + return body + }) + } +} diff --git a/lib/domains.js b/lib/domains.js index 750e8ae..74d18df 100644 --- a/lib/domains.js +++ b/lib/domains.js @@ -11,20 +11,7 @@ const domainRegex = /^((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a- export default class Domains extends Now { async ls() { - return this.retry(async (bail, attempt) => { - if (this._debug) { - console.time(`> [debug] #${attempt} GET /domains`) - } - - const res = await this._fetch('/domains') - - if (this._debug) { - console.timeEnd(`> [debug] #${attempt} GET /domains`) - } - - const body = await res.json() - return body.domains - }) + return await this.listDomains() } async rm(name) { diff --git a/lib/indent.js b/lib/indent.js new file mode 100644 index 0000000..a1fa3be --- /dev/null +++ b/lib/indent.js @@ -0,0 +1,3 @@ +export default function indent(text, n) { + return text.split('\n').map(l => ' '.repeat(n) + l).join('\n') +} diff --git a/lib/index.js b/lib/index.js index 1961635..d97609b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -368,6 +368,23 @@ export default class Now extends EventEmitter { return last } + async listDomains() { + return this.retry(async (bail, attempt) => { + if (this._debug) { + console.time(`> [debug] #${attempt} GET /domains`) + } + + const res = await this._fetch('/domains') + + if (this._debug) { + console.timeEnd(`> [debug] #${attempt} GET /domains`) + } + + const body = await res.json() + return body.domains + }) + } + getNameservers(domain) { return new Promise(resolve => { let fallback = false