UNPKG

fauna-shell

Version:
399 lines (366 loc) 11.8 kB
/*eslint no-unused-expressions: [2, { allowTernary: true }]*/ const vm = require('vm') const os = require('os') const path = require('path') const fs = require('fs') const ini = require('ini') const {cli} = require('cli-ux') const faunadb = require('faunadb') const escodegen = require('escodegen') const Errors = require('@oclif/errors') var rp = require('request-promise') const FAUNA_CLOUD_DOMAIN = 'db.fauna.com' const ERROR_NO_DEFAULT_ENDPOINT = "You need to set a default endpoint. \nTry running 'fauna default-endpoint ENDPOINT_ALIAS'." const ERROR_WRONG_CLOUD_ENDPOINT = "You already have an endpoint 'cloud' defined and it doesn't point to 'db.fauna.com'.\nPlease fix your '~/.fauna-shell' file." const ERROR_SPECIFY_SECRET_KEY = 'You must specify a secret key to connect to FaunaDB' /** * Takes a parsed endpointURL, an endpoint alias, and the endpoint secret, * and saves it to the .ini config file. * * - If the endpoint already exists, it will be overwritten, after asking confirmation * from the user. * - If no other endpoint exists, then the endpoint will be set as the default one. */ function saveEndpointOrError(newEndpoint, alias, secret) { return loadEndpoints() .then(function (endpoints) { if (endpointExists(endpoints, alias)) { return confirmEndpointOverwrite(alias) .then(function (overwrite) { if (overwrite) { return saveEndpoint(endpoints, newEndpoint, alias, secret) } else { throw new Error('Try entering a different endpoint alias.') } }) } else { return saveEndpoint(endpoints, newEndpoint, alias, secret) } }) } function deleteEndpointOrError(alias) { return loadEndpoints() .then(function (endpoints) { if (endpointExists(endpoints, alias)) { return confirmEndpointDelete(alias) .then(function (del) { if (del) { return deleteEndpoint(endpoints, alias) } else { throw new Error("Couldn't override endpoint") } }) } else { throw new Error(`The endpoint '${alias}' doesn't exist`) } }) .catch(function (err) { errorOut(err, 1) }) } /** * Validates that the 'cloud' endpoint points to FAUNA_CLOUD_DOMAIN. */ function validCloudEndpoint() { return loadEndpoints().then(function (config) { return new Promise(function (resolve, reject) { if (config.cloud && config.cloud.domain !== FAUNA_CLOUD_DOMAIN) { reject(new Error(ERROR_WRONG_CLOUD_ENDPOINT)) } else { resolve(true) } }) }) } /** * Sets `endpoint` as the default endpoint. * If `endpoint` doesn't exist, returns an error. */ function setDefaultEndpoint(endpoint) { return loadEndpoints().then(function (endpoints) { return new Promise(function (resolve, reject) { if (endpoints[endpoint]) { endpoints.default = endpoint return saveConfig(endpoints) .then(function (_) { resolve(`Endpoint '${endpoint}' set as default endpoint.`) }) .catch(function (err) { reject(err) }) } else { reject(new Error(`Endpoint '${endpoint}' doesn't exist.`)) } }) }) } /** * Loads the endpoints from the ~/.fauna-shell file. * If the file doesn't exist, returns an empty object. */ function loadEndpoints() { return readFile(getConfigFile()) .then(function (configData) { return ini.parse(configData) }) .catch(function (err) { if (fileNotFound(err)) { return {} } throw err }) } function endpointExists(endpoints, endpointAlias) { return endpointAlias in endpoints } function confirmEndpointOverwrite(alias) { return cli.confirm(`The '${alias}' endpoint already exists. Overwrite? [y/n]`) } function confirmEndpointDelete(alias) { return cli.confirm(`Are you sure you want to delete the '${alias}' endpoint? [y/n]`) } function saveEndpoint(config, endpoint, alias, secret) { var port = endpoint.port ? `:${endpoint.port}` : '' var uri = `${endpoint.protocol}//${endpoint.host}${port}` var options = { method: 'HEAD', uri: uri, resolveWithFullResponse: true, } return rp(options) .then(function (res) { if ('x-faunadb-build' in res.headers) { return saveConfig(addEndpoint(config, endpoint, alias, secret)) } else { throw new Error(`'${alias}' is not a FaunaDB endopoint`) } }) .catch(function (err) { // Fauna returns a 401 which is an error for the request-promise library if (err.response !== undefined) { if ('x-faunadb-build' in err.response.headers) { return saveConfig(addEndpoint(config, endpoint, alias, secret)) } else { throw new Error(`'${alias}' is not a FaunaDB endopoint`) } } else { throw err } }) } function addEndpoint(config, endpoint, alias, secret) { if (shouldSetAsDefaultEndpoint(config)) { config.default = alias } config[alias] = buildEndpointObject(endpoint, secret) return config } function deleteEndpoint(endpoints, alias) { if (endpoints.default === alias) { delete endpoints.default console.log(`Endpoint '${alias}' deleted. '${alias}' was the default endpoint.`) console.log(ERROR_NO_DEFAULT_ENDPOINT) } delete endpoints[alias] return saveConfig(endpoints) } function shouldSetAsDefaultEndpoint(config) { return 'default' in config === false } function buildEndpointObject(endpoint, secret) { var domain = endpoint.hostname var port = endpoint.port var scheme = endpoint.protocol.slice(0, -1) //the scheme is parsed as 'http:' // if the value ends up being null, then Object.assign() will skip the property. domain = domain === null ? null : {domain} port = port === null ? null : {port} scheme = scheme === null ? null : {scheme} return Object.assign({}, domain, port, scheme, {secret}) } /** * Converts the `config` data provided to INI format, and then saves it to the * ~/.fauna-shell file. */ function saveConfig(config) { return writeFile(getConfigFile(), ini.stringify(config), 0o700) } /** * Returns the full path to the `.fauna-shell` config file */ function getConfigFile() { return path.join(os.homedir(), '.fauna-shell') } /** * Wraps `fs.readFile` into a Promise. */ function readFile(fileName) { return new Promise(function (resolve, reject) { fs.readFile(fileName, 'utf8', (err, data) => { err ? reject(err) : resolve(data) }) }) } /** * Wraps `fs.writeFile` into a Promise. */ function writeFile(fileName, data, mode) { return new Promise(function (resolve, reject) { fs.writeFile(fileName, data, {mode: mode}, err => { err ? reject(err) : resolve(data) }) }) } /** * Tests if an error is of the type "file not found". */ function fileNotFound(err) { return err.code === 'ENOENT' && err.syscall === 'open' } /** * Throws error with `msg` and exit code `code`. */ function errorOut(msg, code) { code = code || 1 console.error(`Error: ${msg}`) process.exit(code) //TODO: Using process.exit is not the optimal solution. //return Errors.error(msg, { exit: code }) } /** * Builds the options provided to the faunajs client. * Tries to load the ~/.fauna-shell file and read the default endpoint from there. * * Assumes that if the file exists, it would have been created by fauna-shell, * therefore it would have a defined endpoint. * * Flags like --host, --port, etc., provided by the CLI take precedence over what's * stored in ~/.fauna-shell. * * The --endpoint flag overries the default endpoint from fauna-shell. * * If ~/.fauna-shell doesn't exist, tries to build the connection options from the * flags passed to the script. * * It always expect a secret key to be set in ~/.fauna-shell or provided via CLI * arguments. * * @param {Object} cmdFlags - flags passed via the CLI. * @param {string} dbScope - A database name to scope the connection to. * @param {string} role - A user role: 'admin'|'server'|'server-readonly'|'client'. */ function buildConnectionOptions(cmdFlags, dbScope, role) { return new Promise(function (resolve, reject) { readFile(getConfigFile()) .then(function (configData) { var endpoint = {} const config = ini.parse(configData) // having a valid endpoint, assume there's a secret set if (hasValidEndpoint(config, cmdFlags.endpoint)) { endpoint = getEndpoint(config, cmdFlags.endpoint) } else if (!cmdFlags.hasOwnProperty('secret')) { reject(ERROR_NO_DEFAULT_ENDPOINT) } //TODO add a function endpointFromCmdFlags that builds an endpoint and clean up the code. const connectionOptions = Object.assign(endpoint, cmdFlags) //TODO refactor duplicated code if (connectionOptions.secret) { resolve(cleanUpConnectionOptions(maybeScopeKey(connectionOptions, dbScope, role))) } else { reject(ERROR_SPECIFY_SECRET_KEY) } }) .catch(function (err) { if (fileNotFound(err)) { if (cmdFlags.secret) { resolve(cleanUpConnectionOptions(maybeScopeKey(cmdFlags, dbScope, role))) } else { reject(ERROR_SPECIFY_SECRET_KEY) } } else { reject(err) } }) }) } function getEndpoint(config, cmdEndpoint) { const alias = cmdEndpoint ? cmdEndpoint : config.default return config[alias] } function hasValidEndpoint(config, cmdEndpoint) { if (cmdEndpoint) { return config.hasOwnProperty(cmdEndpoint) } else { return config.hasOwnProperty('default') && config.hasOwnProperty(config.default) } } /** * Makes sure the connectionOptions options passed to the js client * only contain valid properties. */ function cleanUpConnectionOptions(connectionOptions) { const accepted = ['domain', 'scheme', 'port', 'secret', 'timeout'] const res = {} accepted.forEach(function (key) { if (connectionOptions[key]) { res[key] = connectionOptions[key] } }) return res } /** * If `dbScope` and `role` aren't null, then the secret key is scoped to * the `dbScope` database for the provided user `role`. */ function maybeScopeKey(config, dbScope, role) { var scopedSecret = config.secret if (dbScope !== undefined && role !== undefined) { scopedSecret = config.secret + ':' + dbScope + ':' + role } return Object.assign(config, {secret: scopedSecret}) } // adapted from https://hackernoon.com/functional-javascript-resolving-promises-sequentially-7aac18c4431e function promiseSerial(fs) { return fs.reduce(function (promise, f) { return promise.then(function (result) { return f().then(Array.prototype.concat.bind(result)) }) }, Promise.resolve([])) } class QueryError extends Error { constructor(exp, faunaError, queryNumber, ...params) { super(params) if (Error.captureStackTrace) { Error.captureStackTrace(this, QueryError) } this.exp = exp this.faunaError = faunaError this.queryNumber = queryNumber } } function wrapQueries(expressions, client) { const q = faunadb.query vm.createContext(q) return expressions.map(function (exp, queryNumber) { return function () { return client.query(vm.runInContext(escodegen.generate(exp), q)) .catch(function (err) { throw new QueryError(escodegen.generate(exp), err, queryNumber + 1) }) } }) } function runQueries(expressions, client) { return promiseSerial(wrapQueries(expressions, client)) } module.exports = { saveEndpointOrError: saveEndpointOrError, deleteEndpointOrError: deleteEndpointOrError, setDefaultEndpoint: setDefaultEndpoint, validCloudEndpoint: validCloudEndpoint, loadEndpoints: loadEndpoints, buildConnectionOptions: buildConnectionOptions, errorOut: errorOut, readFile: readFile, writeFile: writeFile, runQueries: runQueries, }