diff --git a/bin/now b/bin/now index f160437..1de2cdf 100755 --- a/bin/now +++ b/bin/now @@ -19,8 +19,8 @@ const exit = (code) => { }; const defaultCommand = 'deploy'; -const commands = new Set([defaultCommand, 'list', 'ls', 'rm', 'remove', 'alias', 'aliases', 'ln', 'domain', 'domains']); -const aliases = new Map([['ls', 'list'], ['rm', 'remove'], ['ln', 'alias'], ['aliases', 'alias'], ['domain', 'domains']]); +const commands = new Set([defaultCommand, 'list', 'ls', 'rm', 'remove', 'alias', 'aliases', 'ln', 'domain', 'domains', 'secret', 'secrets']); +const aliases = new Map([['ls', 'list'], ['rm', 'remove'], ['ln', 'alias'], ['aliases', 'alias'], ['domain', 'domains'], ['secret', 'secrets']]); let cmd = argv._[0]; let args = []; @@ -41,7 +41,7 @@ if (commands.has(cmd)) { let bin = resolve(__dirname, 'now-' + cmd); if (process.enclose) { - args.unshift("--entrypoint", bin); + args.unshift('--entrypoint', bin); bin = process.execPath; } diff --git a/bin/now-deploy b/bin/now-deploy index 697ac59..ed03c06 100755 --- a/bin/now-deploy +++ b/bin/now-deploy @@ -42,6 +42,7 @@ const help = () => { rm | remove [id] remove a deployment ln | alias [id] [url] configures aliases for deployments domains [name] manages your domain names + secrets [name] manages your env secrets help [cmd] displays complete help for [cmd] ${chalk.dim('Options:')} diff --git a/bin/now-domains b/bin/now-domains index 5d275f8..a2fcaba 100755 --- a/bin/now-domains +++ b/bin/now-domains @@ -21,7 +21,7 @@ const subcommand = argv._[0]; // options const help = () => { console.log(` - ${chalk.bold('𝚫 now domains')} + ${chalk.bold('𝚫 now domains')} ${chalk.dim('Options:')} diff --git a/bin/now-secrets b/bin/now-secrets new file mode 100755 index 0000000..33924df --- /dev/null +++ b/bin/now-secrets @@ -0,0 +1,210 @@ +#!/usr/bin/env node +import chalk from 'chalk'; +import table from 'text-table'; +import minimist from 'minimist'; +import * as cfg from '../lib/cfg'; +import { handleError, error } from '../lib/error'; +import NowSecrets from '../lib/secrets'; +import ms from 'ms'; + +const argv = minimist(process.argv.slice(2), { + boolean: ['help', 'debug', 'base64'], + alias: { + help: 'h', + debug: 'd', + base64: 'b' + } +}); +const subcommand = argv._[0]; + +// options +const help = () => { + console.log(` + ${chalk.bold('𝚫 now secrets')} + + ${chalk.dim('Options:')} + + -h, --help output usage information + -b, --base64 treat value as base64-encoded + -d, --debug debug mode [off] + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Lists all your secrets: + + ${chalk.cyan('$ now secrets ls')} + + ${chalk.gray('–')} Adds a new secret: + + ${chalk.cyan('$ now secrets add my-secret "my value"')} + + ${chalk.gray('–')} Once added, a secret's value can't be retrieved in plaintext anymore + ${chalk.gray('–')} If the secret's value is more than one word, wrap it in quotes + ${chalk.gray('–')} Actually, when in doubt, wrap your value in quotes + + ${chalk.gray('–')} Exposes a secret as an env variable: + + ${chalk.cyan(`$ now -e MY_SECRET=${chalk.bold('@my-secret')}`)} + + Notice the ${chalk.cyan.bold('`@`')} symbol which makes the value a secret reference. + + ${chalk.gray('–')} Renames a secret: + + ${chalk.cyan(`$ now secrets rename my-secret my-renamed-secret`)} + + ${chalk.gray('–')} Removes a secret: + + ${chalk.cyan(`$ now secrets rm my-secret`)} +`); +}; + +// 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) { + handleError(err); + exit(1); + } + }) + .catch((e) => { + error(`Authentication error – ${e.message}`); + exit(1); + }); +} + +async function run (token) { + const secrets = new NowSecrets(apiUrl, token, { debug }); + const args = argv._.slice(1); + const start = Date.now(); + + if ('ls' === subcommand || 'list' === subcommand) { + if (0 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now secret ls`')}`); + return exit(1); + } + const list = await secrets.ls(); + const elapsed = ms(new Date() - start); + console.log(`> ${list.length} secrets found ${chalk.gray(`[${elapsed}]`)}`); + const cur = Date.now(); + const out = table(list.map((secret) => { + return [ + '', + secret.uid, + chalk.bold(secret.name), + chalk.gray(ms(cur - new Date(secret.created)) + ' ago') + ]; + }), { align: ['l', 'r', 'l'], hsep: ' '.repeat(3) }); + if (out) console.log('\n' + out + '\n'); + return secrets.close(); + } + + if ('rm' === subcommand || 'remove' === subcommand) { + if (1 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now secret rm `')}`); + return exit(1); + } + const list = await secrets.ls(); + const theSecret = list.filter((secret) => { + return secret.uid === args[0] + || secret.name === args[0]; + })[0]; + + if (theSecret) { + const yes = await readConfirmation(theSecret); + if (!yes) { + error('User abort'); + return exit(0); + } + } else { + error(`No secret found by id or name "${args[0]}"`); + return exit(1); + } + + const secret = await secrets.rm(args[0]); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Secret ${chalk.bold(secret.name)} ${chalk.gray(`(${secret.uid})`)} removed ${chalk.gray(`[${elapsed}]`)}`); + return secrets.close(); + } + + if ('rename' === subcommand) { + if (2 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now secret rename `')}`); + return exit(1); + } + const secret = await secrets.rename(args[0], args[1]); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Secret ${chalk.bold(secret.oldName)} ${chalk.gray(`(${secret.uid})`)} renamed to ${chalk.bold(args[1])} ${chalk.gray(`[${elapsed}]`)}`); + return secrets.close(); + } + + if ('add' === subcommand || 'set' === subcommand) { + if (2 !== args.length) { + error(`Invalid number of arguments. Usage: ${chalk.cyan('`now secret add `')}`); + if (args.length > 2) { + const [, ...rest] = args; + console.log('> If your secret has spaces, make sure to wrap it in quotes. Example: \n' + + ` ${chalk.cyan('$ now secret add ${args[0]} "${escaped}"')} `); + } + return exit(1); + } + const [name, value_] = args; + let value; + if (argv.base64) { + value = { base64: value_ }; + } else { + value = value_; + } + const secret = await secrets.add(name, value); + const elapsed = ms(new Date() - start); + console.log(`${chalk.cyan('> Success!')} Secret ${chalk.bold(name)} ${chalk.gray(`(${secret.uid})`)} added ${chalk.gray(`[${elapsed}]`)}`); + return secrets.close(); + } + + error('Please specify a valid subcommand: ls | add | rename | rm'); + help(); + exit(1); +} + +process.on('uncaughtException', (err) => { + handleError(err); + exit(1); +}); + +function readConfirmation (secret) { + return new Promise((resolve, reject) => { + const time = chalk.gray(ms(new Date() - new Date(secret.created)) + ' ago'); + const tbl = table( + [[secret.uid, chalk.bold(secret.name), time]], + { align: ['l', 'r', 'l'], hsep: ' '.repeat(6) } + ); + + process.stdout.write('> The following secret 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('y' === d.toString().trim().toLowerCase()); + }).resume(); + }); +} diff --git a/lib/secrets.js b/lib/secrets.js new file mode 100644 index 0000000..a196dfb --- /dev/null +++ b/lib/secrets.js @@ -0,0 +1,104 @@ +import Now from '../lib'; + +export default class Secrets extends Now { + + ls () { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} GET /secrets`); + const res = await this._fetch('/secrets'); + if (this._debug) console.timeEnd(`> [debug] #${attempt} GET /secrets`); + const body = await res.json(); + return body.secrets; + }); + } + + rm (nameOrId) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} DELETE /secrets/${nameOrId}`); + const res = await this._fetch(`/secrets/${nameOrId}`, { method: 'DELETE' }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} DELETE /secrets/${nameOrId}`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (404 === res.status || 400 === res.status) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } else { + throw new Error(body.error.message); + } + } + + return body; + }); + } + + add (name, value) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} POST /secrets`); + const res = await this._fetch('/secrets', { + method: 'POST', + body: { + name, + value + } + }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} POST /secrets`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (404 === res.status || 400 === res.status) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } else { + throw new Error(body.error.message); + } + } + + return body; + }); + } + + rename (nameOrId, newName) { + return this.retry(async (bail, attempt) => { + if (this._debug) console.time(`> [debug] #${attempt} PATCH /secrets/${nameOrId}`); + const res = await this._fetch(`/secrets/${nameOrId}`, { + method: 'PATCH', + body: { + name: newName + } + }); + if (this._debug) console.timeEnd(`> [debug] #${attempt} PATCH /secrets/${nameOrId}`); + + if (403 === res.status) { + return bail(new Error('Unauthorized')); + } + + const body = await res.json(); + + if (res.status !== 200) { + if (404 === res.status || 400 === res.status) { + const err = new Error(body.error.message); + err.userError = true; + return bail(err); + } else { + throw new Error(body.error.message); + } + } + + return body; + }); + } + +} diff --git a/package.json b/package.json index 0638774..ac23f8e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ ], "plugins": [ "transform-runtime", - "syntax-async-functions", "transform-async-to-generator" ] }, @@ -87,7 +86,6 @@ "alpha-sort": "1.0.2", "ava": "^0.16.0", "babel-eslint": "6.1.2", - "babel-plugin-syntax-async-functions": "6.13.0", "babel-plugin-transform-async-to-generator": "6.8.0", "babel-plugin-transform-runtime": "6.12.0", "babel-preset-es2015": "6.13.2",