diff --git a/package-lock.json b/package-lock.json index 07428d0a..6fbe5085 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "luxon": "^3.5.0", "open": "10.1.0", "shiki": "^1.15.2", + "table": "^6.9.0", "update-notifier": "^7.3.1", "yaml": "^2.6.1", "yargs": "^17.7.2" @@ -1036,6 +1037,15 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/atomically": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", @@ -2107,6 +2117,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", + "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -2943,6 +2969,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -3929,6 +3961,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4149,6 +4190,23 @@ "node": ">=0.3.1" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -4287,6 +4345,56 @@ "node": ">=8" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar-fs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", diff --git a/package.json b/package.json index a3f39d90..2884b259 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "luxon": "^3.5.0", "open": "10.1.0", "shiki": "^1.15.2", + "table": "^6.9.0", "update-notifier": "^7.3.1", "yaml": "^2.6.1", "yargs": "^17.7.2" diff --git a/src/cli.mjs b/src/cli.mjs index f770a075..e6fce769 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -184,12 +184,6 @@ function buildYargs(argvInput) { description: "Profile from the CLI config file to use.", group: "Config:", }, - json: { - type: "boolean", - description: "Output the results as JSON.", - default: false, - group: "Output:", - }, quiet: { type: "boolean", description: diff --git a/src/commands/database/create.mjs b/src/commands/database/create.mjs index 0f97c04e..6c9e1109 100644 --- a/src/commands/database/create.mjs +++ b/src/commands/database/create.mjs @@ -5,8 +5,25 @@ import { container } from "../../config/container.mjs"; import { CommandError } from "../../lib/errors.mjs"; import { faunaToCommandError } from "../../lib/fauna.mjs"; import { getSecret, retryInvalidCredsOnce } from "../../lib/fauna-client.mjs"; -import { colorize, Format } from "../../lib/formatting/colorize.mjs"; +import { createFormatter } from "../../lib/formatting/formatter.mjs"; import { validateDatabaseOrSecret } from "../../lib/middleware.mjs"; +import { FORMATTABLE_OPTIONS } from "../../lib/options.mjs"; + +const formatter = createFormatter({ + header: "fauna database create", + columns: [ + "global_id", + "name", + "coll", + "ts", + "protected", + "typechecked", + "priority", + ], + short: { + formatter: ({ global_id, name }) => `${name} (${global_id})`, // eslint-disable-line camelcase + }, +}); async function runCreateQuery(secret, argv) { const { fql } = container.resolve("fauna"); @@ -29,20 +46,18 @@ async function createDatabase(argv) { const logger = container.resolve("logger"); try { - await retryInvalidCredsOnce(secret, async (secret) => + const { data } = await retryInvalidCredsOnce(secret, async (secret) => runCreateQuery(secret, argv), ); + const dataWithAllFields = { + protected: undefined, + typechecked: undefined, + priority: undefined, + ...data, + }; - logger.stderr(`Database successfully created.`); - - const { color, json } = argv; - if (json) { - logger.stdout( - colorize({ name: argv.name }, { color, format: Format.JSON }), - ); - } else { - logger.stdout(argv.name); - } + const { color, format } = argv; + logger.stdout(formatter({ data: dataWithAllFields, color, format })); } catch (e) { faunaToCommandError({ err: e, @@ -76,6 +91,7 @@ async function createDatabase(argv) { function buildCreateCommand(yargs) { return yargs + .options(FORMATTABLE_OPTIONS) .options({ name: { type: "string", diff --git a/src/commands/database/list.mjs b/src/commands/database/list.mjs index e82a9113..0fdd7ba5 100644 --- a/src/commands/database/list.mjs +++ b/src/commands/database/list.mjs @@ -3,13 +3,22 @@ import chalk from "chalk"; import { container } from "../../config/container.mjs"; import { faunaToCommandError } from "../../lib/fauna.mjs"; -import { colorize, Format } from "../../lib/formatting/colorize.mjs"; +import { createFormatter } from "../../lib/formatting/formatter.mjs"; +import { FORMATTABLE_OPTIONS } from "../../lib/options.mjs"; + +const formatter = createFormatter({ + header: "fauna database list", + columns: ["name", "path"], + short: { + formatter: ({ path, name }) => `${path ?? name}`, + }, +}); async function listDatabasesWithAccountAPI(argv) { - const { pageSize, database } = argv; + const { maxSize, database } = argv; const { listDatabases } = container.resolve("accountAPI"); const response = await listDatabases({ - pageSize, + pageSize: maxSize, path: database, }); @@ -17,7 +26,7 @@ async function listDatabasesWithAccountAPI(argv) { } async function listDatabasesWithSecret(argv) { - const { url, secret, pageSize } = argv; + const { url, secret, maxSize, color } = argv; const { runQueryFromString } = container.resolve("faunaClientV10"); try { @@ -26,11 +35,11 @@ async function listDatabasesWithSecret(argv) { secret, // This gives us back an array of database names. If we want to // provide the after token at some point this query will need to be updated. - expression: `Database.all().paginate(${pageSize}).data { name }`, + expression: `Database.all().paginate(${maxSize}).data { name }`, }); return res.data; } catch (e) { - return faunaToCommandError({ err: e, color: argv.color }); + return faunaToCommandError({ err: e, color }); } } @@ -54,22 +63,19 @@ async function doListDatabases(argv) { ); } - if (argv.json) { - logger.stdout(colorize(res, { format: Format.JSON, color: argv.color })); - } else { - res.forEach(({ path, name }) => { - logger.stdout(path ?? name); - }); - } + const { format, color } = argv; + logger.stdout(formatter({ data: res, format, color })); } function buildListCommand(yargs) { return yargs + .options(FORMATTABLE_OPTIONS) .options({ - "page-size": { + "max-size": { + alias: "max", type: "number", description: "Maximum number of databases to return.", - default: 1000, + default: 10, }, }) .example([ @@ -83,12 +89,12 @@ function buildListCommand(yargs) { "List all child databases directly under a database scoped to a secret.", ], [ - "$0 database list --json", + "$0 database list -f json", "List all top-level databases and output as JSON.", ], [ - "$0 database list --page-size 10", - "List the first 10 top-level databases.", + "$0 database list --max-size 100", + "List the first 100 top-level databases.", ], ]); } diff --git a/src/commands/local.mjs b/src/commands/local.mjs index 4c1e331b..3013672d 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -5,7 +5,7 @@ import { pushSchema } from "../commands/schema/push.mjs"; import { container } from "../config/container.mjs"; import { ensureContainerRunning } from "../lib/docker-containers.mjs"; import { CommandError, ValidationError } from "../lib/errors.mjs"; -import { colorize, Format } from "../lib/formatting/colorize.mjs"; +import { colorize, Language } from "../lib/formatting/colorize.mjs"; /** * Starts the local Fauna container @@ -40,7 +40,7 @@ async function createDatabaseSchema(argv) { colorize( `[CreateDatabaseSchema] Creating schema for database '${argv.database}' from directory '${argv.directory}'...`, { - format: Format.LOG, + language: Language.LOG, color: argv.color, }, ), @@ -52,7 +52,7 @@ async function createDatabaseSchema(argv) { colorize( `[CreateDatabaseSchema] Schema for database '${argv.database}' created from directory '${argv.directory}'.`, { - format: Format.LOG, + language: Language.LOG, color: argv.color, }, ), @@ -66,7 +66,7 @@ async function createDatabase(argv) { const color = argv.color; logger.stderr( colorize(`[CreateDatabase] Creating database '${argv.database}'...`, { - format: Format.LOG, + language: Language.LOG, color, }), ); @@ -105,17 +105,17 @@ async function createDatabase(argv) { }); logger.stderr( colorize(`[CreateDatabase] Database '${argv.database}' created.`, { - format: Format.LOG, + language: Language.LOG, color, }), ); - logger.stderr(colorize(db.data, { format: Format.FQL, color })); + logger.stderr(colorize(db.data, { language: Language.FQL, color })); } catch (e) { if (e instanceof AbortError) { throw new CommandError( `${chalk.red(`[CreateDatabase] Database '${argv.database}' already exists but with differrent properties than requested:\n`)} ----------------- -${colorize(e.abort, { format: Format.FQL, color })} +${colorize(e.abort, { language: Language.FQL, color })} ----------------- ${chalk.red("Please use choose a different name using --name or align the --typechecked, --priority, and --protected with what is currently present.")}`, ); diff --git a/src/commands/query.mjs b/src/commands/query.mjs index ed74e28e..5b4cec03 100644 --- a/src/commands/query.mjs +++ b/src/commands/query.mjs @@ -21,7 +21,7 @@ import { DATABASE_PATH_OPTIONS, QUERY_OPTIONS, } from "../lib/options.mjs"; -import { isTTY, resolveFormat } from "../lib/utils.mjs"; +import { isTTY } from "../lib/utils.mjs"; function validate(argv) { const { existsSync, accessSync, constants } = container.resolve("fs"); @@ -96,14 +96,12 @@ async function queryCommand(argv) { performanceHints, color, include, + format, } = argv; // If we're writing to a file, don't colorize the output regardless of the user's preference const useColor = argv.output || !isTTY() ? false : color; - // Using --json takes precedence over --format - const outputFormat = resolveFormat(argv); - const results = await container.resolve("runQueryFromString")(expression, { apiVersion, secret, @@ -111,7 +109,7 @@ async function queryCommand(argv) { timeout, typecheck, performanceHints, - format: outputFormat, + format, color: useColor, }); @@ -130,7 +128,7 @@ async function queryCommand(argv) { const output = formatQueryResponse(results, { apiVersion, - format: outputFormat, + format, color: useColor, }); diff --git a/src/commands/shell.mjs b/src/commands/shell.mjs index d51e24a7..6a412fef 100644 --- a/src/commands/shell.mjs +++ b/src/commands/shell.mjs @@ -16,7 +16,6 @@ import { QUERY_INFO_CHOICES, QUERY_OPTIONS, } from "../lib/options.mjs"; -import { resolveFormat } from "../lib/utils.mjs"; async function shellCommand(argv) { const { query: v4Query } = container.resolve("faunadb"); @@ -159,13 +158,10 @@ async function buildCustomEval(argv) { if (cmd.trim() === "") return cb(); // These are options used for querying and formatting the response - const { apiVersion, color } = argv; + const { apiVersion, color, format } = argv; const include = getArgvOrCtx("include", argv, ctx); const performanceHints = getArgvOrCtx("performanceHints", argv, ctx); - // Using --json output takes precedence over --format - const outputFormat = resolveFormat({ ...argv }); - if (apiVersion === "4") { try { esprima.parseScript(cmd); @@ -177,7 +173,7 @@ async function buildCustomEval(argv) { let res; try { const secret = await getSecret(); - const { color, timeout, typecheck, url } = argv; + const { color, timeout, typecheck, url, format } = argv; res = await runQueryFromString(cmd, { apiVersion, @@ -186,7 +182,7 @@ async function buildCustomEval(argv) { timeout, typecheck, performanceHints, - format: outputFormat, + format, }); // If any query info should be displayed, print to stderr. @@ -209,7 +205,7 @@ async function buildCustomEval(argv) { const output = formatQueryResponse(res, { apiVersion, color, - format: outputFormat, + format, }); logger.stdout(output); diff --git a/src/lib/docker-containers.mjs b/src/lib/docker-containers.mjs index 7c2cb0e1..f55787fe 100644 --- a/src/lib/docker-containers.mjs +++ b/src/lib/docker-containers.mjs @@ -1,6 +1,6 @@ import { container } from "../config/container.mjs"; import { CommandError, SUPPORT_MESSAGE } from "./errors.mjs"; -import { colorize, Format } from "./formatting/colorize.mjs"; +import { colorize, Language } from "./formatting/colorize.mjs"; const IMAGE_NAME = "fauna/faunadb:latest"; let color = false; @@ -413,5 +413,5 @@ async function waitForHealthCheck({ */ function stderr(log) { const logger = container.resolve("logger"); - logger.stderr(colorize(log, { format: Format.LOG, color })); + logger.stderr(colorize(log, { language: Language.LOG, color })); } diff --git a/src/lib/fauna-client.mjs b/src/lib/fauna-client.mjs index b8d1b53e..ac96f6b0 100644 --- a/src/lib/fauna-client.mjs +++ b/src/lib/fauna-client.mjs @@ -6,7 +6,7 @@ import { container } from "../config/container.mjs"; import { isUnknownError } from "./errors.mjs"; import { faunaToCommandError } from "./fauna.mjs"; import { faunadbToCommandError } from "./faunadb.mjs"; -import { colorize, Format } from "./formatting/colorize.mjs"; +import { colorize, Language } from "./formatting/colorize.mjs"; /** * Regex to match the FQL diagnostic line. @@ -73,7 +73,7 @@ export const runQueryFromString = (expression, argv) => { } else { const { secret, url, timeout, format, performanceHints, ...rest } = argv; let apiFormat = "decorated"; - if (format === Format.JSON) { + if (format === Language.JSON) { apiFormat = "simple"; } @@ -177,7 +177,7 @@ export const formatQuerySummary = (summary) => { if (!line.match(FQL_DIAGNOSTIC_REGEX)) { return line; } - return colorize(line, { format: Format.FQL }); + return colorize(line, { language: Language.FQL }); }); return lines.join("\n"); } catch (err) { @@ -228,7 +228,7 @@ export const formatQueryInfo = (response, { apiVersion, color, include }) => { const metricsResponse = response; const colorized = colorize( { metrics: metricsResponse.metrics }, - { color, format: Format.YAML }, + { color, language: Language.YAML }, ); return `${colorized}\n`; @@ -242,14 +242,14 @@ export const formatQueryInfo = (response, { apiVersion, color, include }) => { // strip the ansi when we're checking if the line is a diagnostic line. const colorized = colorize(queryInfoToDisplay, { color, - format: Format.YAML, + language: Language.YAML, }) .split("\n") .map((line) => { if (!stripAnsi(line).match(FQL_DIAGNOSTIC_REGEX)) { return line; } - return colorize(line, { format: Format.FQL }); + return colorize(line, { language: Language.FQL }); }) .join("\n"); diff --git a/src/lib/fauna.mjs b/src/lib/fauna.mjs index 37b52ec3..cb8e5c6c 100644 --- a/src/lib/fauna.mjs +++ b/src/lib/fauna.mjs @@ -15,7 +15,7 @@ import { ValidationError, } from "./errors.mjs"; import { formatQuerySummary } from "./fauna-client.mjs"; -import { colorize, Format } from "./formatting/colorize.mjs"; +import { colorize, Language } from "./formatting/colorize.mjs"; /** * Interprets a string as a FQL expression and returns a query. @@ -157,10 +157,10 @@ export const formatError = (err, _opts = {}) => { * @returns {string} The formatted response */ export const formatQueryResponse = (res, opts = {}) => { - const { format = Format.JSON, color } = opts; + const { format = Language.JSON, color } = opts; const data = res.data; - return colorize(data, { format, color }); + return colorize(data, { language: format, color }); }; /** diff --git a/src/lib/faunadb.mjs b/src/lib/faunadb.mjs index 5837556f..f243988d 100644 --- a/src/lib/faunadb.mjs +++ b/src/lib/faunadb.mjs @@ -11,7 +11,7 @@ import { CommandError, NETWORK_ERROR_MESSAGE, } from "./errors.mjs"; -import { colorize, Format } from "./formatting/colorize.mjs"; +import { colorize, Language } from "./formatting/colorize.mjs"; /** * Creates a V4 Fauna client. @@ -190,14 +190,14 @@ export const formatQueryResponse = (res, opts = {}) => { let resolvedOutput; let resolvedFormat; - if (!format || format === Format.FQL) { + if (!format || format === Language.FQL) { resolvedOutput = util.inspect(data, { showHidden: false, depth: null }); - resolvedFormat = Format.FQL; + resolvedFormat = Language.FQL; } else { resolvedOutput = data; - resolvedFormat = Format.JSON; + resolvedFormat = Language.JSON; } - return colorize(resolvedOutput, { format: resolvedFormat, color }); + return colorize(resolvedOutput, { language: resolvedFormat, color }); }; /** diff --git a/src/lib/formatting/codeToAnsi.mjs b/src/lib/formatting/codeToAnsi.mjs index 78848300..b13f533b 100644 --- a/src/lib/formatting/codeToAnsi.mjs +++ b/src/lib/formatting/codeToAnsi.mjs @@ -3,6 +3,7 @@ import { createHighlighterCoreSync } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; import json from "shiki/langs/json.mjs"; import log from "shiki/langs/log.mjs"; +import tsv from "shiki/langs/tsv.mjs"; import yaml from "shiki/langs/yaml.mjs"; import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs"; @@ -14,7 +15,7 @@ const THEME = "github-dark-high-contrast"; export const createHighlighter = () => { const highlighter = createHighlighterCoreSync({ themes: [githubDarkHighContrast], - langs: [fql, log, json, yaml], + langs: [fql, log, json, yaml, tsv], engine: createJavaScriptRegexEngine(), }); @@ -66,7 +67,7 @@ const { codeToTokensBase, getTheme } = createHighlighter(); * Returns a string with ANSI codes applied to the code. This is a JS port of the * TypeScript codeToAnsi function from the Shiki library. * @param {*} code - The code to format. - * @param {"fql" | "log" | "json"} language - The language of the code. + * @param {"fql" | "log" | "tsv" | "json" | "yaml"} language - The language of the code. * @returns {string} - The formatted code with ANSI codes applied. */ export function codeToAnsi(code, language) { diff --git a/src/lib/formatting/colorize.mjs b/src/lib/formatting/colorize.mjs index 2df1f1cc..85191984 100644 --- a/src/lib/formatting/colorize.mjs +++ b/src/lib/formatting/colorize.mjs @@ -4,12 +4,13 @@ import YAML from "yaml"; import { container } from "../../config/container.mjs"; import { codeToAnsi } from "./codeToAnsi.mjs"; -export const Format = { +export const Language = { FQL: "fql", LOG: "log", JSON: "json", TEXT: "text", YAML: "yaml", + TSV: "tsv", }; const objToString = (obj) => JSON.stringify(obj, null, 2); @@ -66,6 +67,18 @@ const yamlToAnsi = (obj) => { return res.trim(); }; +const tsvToAnsi = (obj) => { + if (typeof obj !== "string") { + throw new Error("Unable to format TSV unless it is already a string."); + } + + const raw = stripAnsi(obj); + const codeToAnsi = container.resolve("codeToAnsi"); + const res = codeToAnsi(raw, "tsv"); + + return res.trim(); +}; + /** * Formats an object for display with ANSI color codes. * @param {any} obj - The object to format @@ -73,16 +86,18 @@ const yamlToAnsi = (obj) => { * @param {string} [opts.format] - The format to use * @returns {string} The formatted object */ -export const toAnsi = (obj, { format = Format.TEXT } = {}) => { +export const toAnsi = (obj, { format = Language.TEXT } = {}) => { switch (format) { - case Format.FQL: + case Language.FQL: return fqlToAnsi(obj); - case Format.JSON: + case Language.JSON: return jsonToAnsi(obj); - case Format.LOG: + case Language.LOG: return logToAnsi(obj); - case Format.YAML: + case Language.YAML: return yamlToAnsi(obj); + case Language.TSV: + return tsvToAnsi(obj); default: return textToAnsi(obj); } @@ -92,12 +107,15 @@ export const toAnsi = (obj, { format = Format.TEXT } = {}) => { * Formats an input for display based on its format and options. * @param {any} obj - The object to format * @param {object} opts - Options - * @param {string} [opts.format] - The format to use + * @param {string} [opts.language] - The language to use with the highlighter * @param {boolean} [opts.color] - Whether to colorize the object * @returns {string} The formatted object */ -export const colorize = (obj, { color = true, format = Format.TEXT } = {}) => { - const ansiString = toAnsi(obj, { format }); +export const colorize = ( + obj, + { color = true, language = Language.TEXT } = {}, +) => { + const ansiString = toAnsi(obj, { format: language }); if (color) { return ansiString; diff --git a/src/lib/formatting/formatter.mjs b/src/lib/formatting/formatter.mjs new file mode 100644 index 00000000..5d5f8338 --- /dev/null +++ b/src/lib/formatting/formatter.mjs @@ -0,0 +1,160 @@ +import { table } from "table"; + +import { colorize, Language } from "./colorize.mjs"; + +export const Format = { + TABLE: "table", + YAML: "yaml", + FQL: "fql", + TSV: "tsv", + SHORT: "short", + JSON: "json", +}; + +/** Returns an array of objects as a ascii table + * @param {object|Array} objectOrArray - The array of objects to format. + * @param {object} params The parameters for the table. + * @param {import("table").TableUserConfig} params.config - The configuration object. + * @param {Array} params.columns - The columns to display. + * @param {string} [params.header] - The header to display. + * @returns {string} - The formatted table. + */ +const toTable = (objectOrArray, { config, columns, header }) => { + const data = Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray]; + let rows = [columns]; + + if (Array.isArray(data)) { + data.forEach((row) => { + rows.push(columns.map((column) => row[column])); + }); + } else { + rows.push(columns.map((column) => [column, data[column]])); + } + + const spanningCells = []; + if (header) { + rows.unshift([header, ...Array(columns.length - 1).fill("")]); + spanningCells.push({ + col: 0, + row: 0, + colSpan: columns.length, + alignment: "center", + }); + } + + return table(rows, { + ...config, + ...(spanningCells.length ? { spanningCells } : {}), + }); +}; + +/** + * Returns an array of objects as a TSV string + * @param {object|Array} objectOrArray - The array of objects to format. + * @param {object} params The parameters for the table. + * @param {string} [params.color] - The color to use. + * @param {Array} params.columns - The columns to display. + * @returns {string} - The formatted table. + */ +const toTSV = (objectOrArray, { color, columns }) => { + const data = Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray]; + if (!data.length) { + return ""; + } + + const rows = data.map((row) => + columns.map((column) => row[column]).join("\t"), + ); + + return colorize(rows.join("\n"), { color, format: Language.TSV }); +}; + +/** + * Returns an array of objects as a short string + * @param {object|Array} objectOrArray - The array of objects to format. + * @param {object} params The parameters for the short formatter. + * @param {(Array) => string} params.formatter - The formatter function. + * @returns {string} - The formatted short string. + */ +const toShort = (objectOrArray, { formatter }) => { + const data = Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray]; + return data.map(formatter).join("\n"); +}; + +/** + * Returns an array of objects as a string in the requested format + * @param {object} params The parameters for the formatter. + * @param {object|Array} params.data - The array of objects to format. + * @param {import("./colorize.mjs").Format} params.format - The format to use. + * @param {boolean} [params.color] - Whether to colorize the output. + * @param {object} [params.config] - The configuration object. + * @returns {string} - The formatted string. + */ +const toFormat = ({ data, format, color, config }) => { + switch (format) { + case Format.TABLE: + return toTable(data, { ...config.table }); + case Format.YAML: + return colorize(data, { color, language: Language.YAML }); + case Format.FQL: + return colorize(data, { color, language: Language.FQL }); + case Format.TSV: + return toTSV(data, { color, ...config.tsv }); + case Format.SHORT: + return toShort(data, { color, ...config.short }); + case Format.JSON: + return colorize(data, { color, language: Language.JSON }); + default: + throw new Error(`Unknown output format requested: ${format}`); + } +}; + +/** + * Creates a formatter function that curries the config object for toFormat + * @param {object} options The configuration object. + * @param {string} [options.header] - The header to display. + * @param {Array} options.columns - The columns to display.\ + * @param {object} [options.config] - The configuration object. + * @param {string} [options.config.format] - The format to use. + * @param {object} [options.config.tsv] - The TSV formatter object. + * @param {object} [options.config.table] - The table formatter object. + * @param {object} [options.config.short] - The short formatter object. + * @param {function} options.config.short.formatter - The formatter function. + */ +export const createFormatter = (config) => { + const { header, columns, short } = config; + + if (typeof short.formatter !== "function") { + throw new Error("short formatter must be a function"); + } + + if (!Array.isArray(columns) || !columns.length) { + throw new Error("columns must be an array with at least one column"); + } + + if (typeof header !== "string") { + throw new Error("header must be a string"); + } + + if (!config.table) { + config.table = { header, columns }; + } else { + config.table.columns = config.table.columns ?? columns; + config.table.header = config.table.header ?? header; + } + + if (!config.tsv) { + config.tsv = { columns }; + } + + /** + * @param {object} params The parameters for the formatter. + * @param {object|Array} params.data - The array of objects to format. + * @param {import('./formatter.mjs').Format} params.format - The format to use. + * @param {boolean} [params.color] - Whether to colorize the output. + * @returns {string} - The formatted string. + */ + return ({ data, format, color }) => { + return toFormat({ data, format, color, config }); + }; +}; diff --git a/src/lib/options.mjs b/src/lib/options.mjs index 41cd089f..2acd0843 100644 --- a/src/lib/options.mjs +++ b/src/lib/options.mjs @@ -1,6 +1,6 @@ //@ts-check -import { Format } from "./formatting/colorize.mjs"; +import { Format } from "./formatting/formatter.mjs"; /** * Options required for any command making API requests to the Account API @@ -82,6 +82,17 @@ export const QUERY_INFO_CHOICES = [ "stats", ]; +export const FORMATTABLE_OPTIONS = { + format: { + alias: "f", + type: "string", + description: "Output format for the command.", + choices: [Format.TABLE, Format.YAML, Format.TSV, Format.SHORT, Format.JSON], + default: Format.TABLE, + group: "Output:", + }, +}; + /** * Options required for commands making FQL queries to the Core API */ @@ -98,8 +109,7 @@ export const QUERY_OPTIONS = { format: { type: "string", alias: "f", - description: - "Output format for the query. When present, --json takes precedence over --format. Only applies to v10 queries.", + description: "Output format for the query. Only applies to v10 queries.", choices: [Format.FQL, Format.JSON], default: Format.FQL, group: "API:", diff --git a/src/lib/utils.mjs b/src/lib/utils.mjs index 95afeb4f..ab14a47a 100644 --- a/src/lib/utils.mjs +++ b/src/lib/utils.mjs @@ -1,24 +1,7 @@ -import { container } from "../config/container.mjs"; -import { Format } from "./formatting/colorize.mjs"; - export function isTTY() { return process.stdout.isTTY; } -export const resolveFormat = (argv) => { - const logger = container.resolve("logger"); - - if (argv.json) { - logger.debug( - "--json has taken precedence over other formatting options, using JSON output", - "argv", - ); - return Format.JSON; - } - - return argv.format; -}; - /** * Standardizes the region of a database path. * diff --git a/test/commands/database/create.mjs b/test/commands/database/create.mjs index 06058ead..7e76f39e 100644 --- a/test/commands/database/create.mjs +++ b/test/commands/database/create.mjs @@ -135,6 +135,8 @@ describe("database create", () => { }, ].forEach(({ args, expected }) => { it(`calls fauna with the correct args: ${args}`, async () => { + runQuery.resolves({ data: { name: "testdb" } }); + await run(`database create ${args}`, container); expect(runQuery).to.have.been.calledOnceWith({ @@ -215,6 +217,7 @@ describe("database create", () => { // We will attempt to mint a new database key, mock the response // so we can verify that the new key is used. accountAPI.createKey.resolves({ secret: "new-secret" }); + runQuery.resolves({ data: { name: "testdb" } }); await run(`database create ${args}`, container); diff --git a/test/commands/database/list.mjs b/test/commands/database/list.mjs index b708c36f..0a9a7b6b 100644 --- a/test/commands/database/list.mjs +++ b/test/commands/database/list.mjs @@ -38,10 +38,10 @@ describe("database list", () => { expected: { secret: "taco", url: "http://0.0.0.0:8443" }, }, { - args: "--local --pageSize 10", + args: "--local --max-size 10", expected: { secret: "secret", - pageSize: 10, + maxSize: 10, url: "http://0.0.0.0:8443", }, }, @@ -52,12 +52,12 @@ describe("database list", () => { }; runQueryFromString.resolves(stubbedResponse); - await run(`database list ${args}`, container); + await run(`database list ${args} -f short`, container); expect(runQueryFromString).to.have.been.calledOnceWith({ url: expected.url, secret: expected.secret, - expression: `Database.all().paginate(${expected.pageSize ?? 1000}).data { name }`, + expression: `Database.all().paginate(${expected.maxSize ?? 10}).data { name }`, }); await stdout.waitForWritten(); @@ -75,20 +75,20 @@ describe("database list", () => { expected: { secret: "secret" }, }, { - args: "--secret 'secret' --pageSize 10", - expected: { secret: "secret", pageSize: 10 }, + args: "--secret 'secret' --max-size 10", + expected: { secret: "secret", maxSize: 10 }, }, ].forEach(({ args, expected }) => { it(`calls fauna with the correct args: ${args}`, async () => { const stubbedResponse = { data: [{ name: "testdb" }] }; runQueryFromString.resolves(stubbedResponse); - await run(`database list ${args}`, container); + await run(`database list ${args} -f short`, container); expect(runQueryFromString).to.have.been.calledOnceWith({ url: sinon.match.string, secret: expected.secret, - expression: `Database.all().paginate(${expected.pageSize ?? 1000}).data { name }`, + expression: `Database.all().paginate(${expected.maxSize ?? 10}).data { name }`, }); expect(stdout.getWritten()).to.equal("testdb\n"); @@ -108,7 +108,7 @@ describe("database list", () => { runQueryFromString.rejects(error); try { - await run(`database list --secret 'secret'`, container); + await run(`database list --secret 'secret' -f short`, container); } catch (e) {} expect(logger.stderr).to.have.been.calledWith( @@ -125,8 +125,8 @@ describe("database list", () => { expected: { regionGroup: "us-std" }, }, { - args: "--pageSize 10", - expected: { pageSize: 10, regionGroup: "us-std" }, + args: "--max-size 10", + expected: { maxSize: 10, regionGroup: "us-std" }, }, { args: "--database 'us-std/example'", @@ -150,10 +150,10 @@ describe("database list", () => { }; accountAPI.listDatabases.resolves(stubbedResponse); - await run(`database list ${args}`, container); + await run(`database list ${args} -f short`, container); expect(accountAPI.listDatabases).to.have.been.calledOnceWith({ - pageSize: expected.pageSize ?? 1000, + pageSize: expected.maxSize ?? 10, path: expected.database, }); @@ -169,7 +169,7 @@ describe("database list", () => { "--local", "--secret=test-secret", "--database=us/example", - "--pageSize 10", + "--maxSize 10", ].forEach((args) => { it(`outputs json when using ${args}`, async () => { mockAccessKeysFile({ fs }); @@ -190,11 +190,11 @@ describe("database list", () => { }); } - await run(`database list ${args} --json`, container); + await run(`database list ${args} -f json`, container); await stdout.waitForWritten(); expect(stdout.getWritten().trim()).to.equal( - `${colorize(data, { format: "json" })}`, + `${colorize(data, { language: "json" })}`, ); }); }); diff --git a/test/commands/query/common.mjs b/test/commands/query/common.mjs index df8f4765..e9a66950 100644 --- a/test/commands/query/common.mjs +++ b/test/commands/query/common.mjs @@ -154,11 +154,11 @@ describe("query common", function () { it("does not colorize output if --no-color is used", async function () { runQueryFromString.resolves({ data: [] }); await run( - `query "Database.all()" --secret=foo --no-color --json`, + `query "Database.all()" --secret=foo --no-color -f json`, container, ); expect(logger.stdout).to.have.been.calledWith( - colorize([], { format: "json", color: false }), + colorize([], { language: "json", color: false }), ); }); diff --git a/test/commands/query/v10.mjs b/test/commands/query/v10.mjs index 22c9c9f5..567d6e4d 100644 --- a/test/commands/query/v10.mjs +++ b/test/commands/query/v10.mjs @@ -46,7 +46,7 @@ describe("query v10", function () { }), ); expect(logger.stdout).to.have.been.calledWith( - colorize(testData, { format: "json", color: true }), + colorize(testData, { language: "json", color: true }), ); expect(logger.stderr).to.not.be.called; }); diff --git a/test/commands/query/v4.mjs b/test/commands/query/v4.mjs index 6f0f38b2..44c50bc2 100644 --- a/test/commands/query/v4.mjs +++ b/test/commands/query/v4.mjs @@ -48,7 +48,7 @@ describe("query v4", function () { depth: null, }); expect(logger.stdout).to.have.been.calledWith( - colorize(output, { format: "fql", color: true }), + colorize(output, { language: "fql", color: true }), ); expect(logger.stderr).to.not.be.called; }); @@ -56,13 +56,13 @@ describe("query v4", function () { it("can output the result of a query as JSON", async function () { runQueryFromString.resolves(testResponse); await run( - `query "Collection('test')" --apiVersion 4 --secret=foo --json`, + `query "Collection('test')" --apiVersion 4 --secret=foo -f json`, container, ); expect(runQueryFromString).to.have.been.calledWith(...runQueryExpectArgs); expect(logger.stdout).to.have.been.calledWith( - colorize(testResponseWireProtocol, { format: "json", color: true }), + colorize(testResponseWireProtocol, { language: "json", color: true }), ); expect(logger.stderr).to.not.be.called; }); @@ -72,7 +72,7 @@ describe("query v4", function () { runQueryFromString.resolves(createV4QuerySuccess(query)); await run(`query "null" --apiVersion 4 --secret=foo`, container); expect(logger.stdout).to.have.been.calledWith( - colorize(util.inspect(query), { format: "fql", color: true }), + colorize(util.inspect(query), { language: "fql", color: true }), ); expect(logger.stderr).to.not.be.called; }), diff --git a/test/commands/shell.mjs b/test/commands/shell.mjs index f82185bf..5dc3d63e 100644 --- a/test/commands/shell.mjs +++ b/test/commands/shell.mjs @@ -309,7 +309,7 @@ describe("shell", function () { // validate expect(stdout.getWritten()).to.equal( - `Type Ctrl+D or .exit to exit the shell${prompt}${query}\r\n${colorize(v10Object1.data, { format: "json", color: false })}${prompt}`, + `Type Ctrl+D or .exit to exit the shell${prompt}${query}\r\n${colorize(v10Object1.data, { language: "json", color: false })}${prompt}`, ); expect(logger.stderr).to.not.be.called; @@ -327,7 +327,7 @@ describe("shell", function () { // validate second object expect(stdout.getWritten()).to.equal( `${query}\r\n${colorize(v10Object2.data, { - format: "json", + language: "json", color: false, })}${prompt}`, ); diff --git a/test/config.mjs b/test/config.mjs index 2d2b4c8a..1539c0e5 100644 --- a/test/config.mjs +++ b/test/config.mjs @@ -363,7 +363,6 @@ describe("configuration file", function () { pathMatcher: path.join(__dirname, "../fauna.config.yaml"), argvMatcher: sinon.match({ color: true, - json: false, quiet: false, }), configToReturn: defaultConfig, diff --git a/test/credentials.mjs b/test/credentials.mjs index 9c82c044..8ee7b492 100644 --- a/test/credentials.mjs +++ b/test/credentials.mjs @@ -72,7 +72,7 @@ describe("credentials", function () { }; [ { - command: `query "Database.all()" -d us-std --no-color --json`, + command: `query "Database.all()" -d us-std --no-color -f json`, localCreds: defaultLocalCreds, expected: { accountKeys: { @@ -87,7 +87,7 @@ describe("credentials", function () { }, }, { - command: `query "Database.all()" --secret user-secret --no-color --json`, + command: `query "Database.all()" --secret user-secret --no-color -f json`, localCreds: defaultLocalCreds, expected: { accountKeys: { @@ -102,7 +102,7 @@ describe("credentials", function () { }, }, { - command: `query "Database.all()" -d us-std --accountKey user-account-key --no-color --json`, + command: `query "Database.all()" -d us-std --accountKey user-account-key --no-color -f json`, localCreds: defaultLocalCreds, expected: { accountKeys: { @@ -117,7 +117,7 @@ describe("credentials", function () { }, }, { - command: `query "Database.all()" -d us-std -r myrole --no-color --json`, + command: `query "Database.all()" -d us-std -r myrole --no-color -f json`, localCreds: defaultLocalCreds, expected: { accountKeys: { @@ -132,7 +132,7 @@ describe("credentials", function () { }, }, { - command: `query "Database.all()" -d us-std/test:badpath --no-color --json`, + command: `query "Database.all()" -d us-std/test:badpath --no-color -f json`, localCreds: defaultLocalCreds, expected: { databaseKeys: { @@ -166,7 +166,7 @@ describe("credentials", function () { try { setCredsFiles({}, {}); await run( - `query "Database.all()" -d us-std --no-color --json`, + `query "Database.all()" -d us-std --no-color -f json`, container, ); } catch (e) { @@ -182,7 +182,7 @@ describe("credentials", function () { try { await run( - `query "Database.all()" -d us-std --no-color --json`, + `query "Database.all()" -d us-std --no-color -f json`, container, ); } catch (e) { @@ -213,7 +213,7 @@ describe("credentials", function () { fetch.onCall(1).resolves(f({ secret: "new-secret" })); await run( - `query "Database.all()" -d us-std --no-color --json`, + `query "Database.all()" -d us-std --no-color -f json`, container, ); @@ -235,7 +235,7 @@ describe("credentials", function () { .resolves(f({}, 401)); try { await run( - `query "Database.all()" -d us-std --no-color --json`, + `query "Database.all()" -d us-std --no-color -f json`, container, ); } catch (e) { @@ -268,7 +268,7 @@ describe("credentials", function () { httpStatus: 401, }); try { - await run(`query "Database.all()" --no-color --json`, container); + await run(`query "Database.all()" --no-color -f json`, container); } catch (e) { expect(stderr.getWritten()).to.contain("Invalid credentials"); sinon.assert.calledWithMatch( @@ -297,7 +297,7 @@ describe("credentials", function () { }); await run( - `query "Database.all()" -d us-std --no-color --json`, + `query "Database.all()" -d us-std --no-color -f json`, container, );