diff --git a/src/commands/local.mjs b/src/commands/local.mjs index dc5e2a51..a143ae03 100644 --- a/src/commands/local.mjs +++ b/src/commands/local.mjs @@ -2,6 +2,7 @@ import chalk from "chalk"; import { AbortError } from "fauna"; import { container } from "../cli.mjs"; +import { pushSchema } from "../commands/schema/push.mjs"; import { ensureContainerRunning } from "../lib/docker-containers.mjs"; import { CommandError, ValidationError } from "../lib/errors.mjs"; import { colorize, Format } from "../lib/formatting/colorize.mjs"; @@ -28,6 +29,34 @@ async function startLocal(argv) { if (argv.database) { await createDatabase(argv); } + if (argv.directory) { + await createDatabaseSchema(argv); + } +} + +async function createDatabaseSchema(argv) { + const logger = container.resolve("logger"); + logger.stderr( + colorize( + `[CreateDatabaseSchema] Creating schema for database '${argv.database}' from directory '${argv.directory}'...`, + { + format: Format.LOG, + color: argv.color, + }, + ), + ); + // hack to let us push schema to the local database + argv.secret = `secret:${argv.database}:admin`; + await pushSchema(argv); + logger.stderr( + colorize( + `[CreateDatabaseSchema] Schema for database '${argv.database}' created from directory '${argv.directory}'.`, + { + format: Format.LOG, + color: argv.color, + }, + ), + ); } async function createDatabase(argv) { @@ -152,6 +181,24 @@ function buildLocalCommand(yargs) { description: "User-defined priority for the database. Valid only if --database is set.", }, + "project-directory": { + type: "string", + alias: ["dir", "directory"], + description: + "Path to a local directory containing `.fsl` files for the database. Valid only if --database is set.", + }, + active: { + description: + "Immediately applies the local schema to the database's active schema, skipping staging the schema. To disable this, use `--no-active` or `--active=false`.", + type: "boolean", + default: true, + }, + input: { + description: + "Prompt for schema input, such as confirmation. To disable prompts, use `--no-input` or `--input=false`. Disabled prompts are useful for scripts, CI/CD, and automation workflows.", + default: true, + type: "boolean", + }, }) .check((argv) => { if (argv.maxAttempts < 1) { @@ -177,6 +224,11 @@ function buildLocalCommand(yargs) { "--priority can only be set if --database is set.", ); } + if (argv.directory && !argv.database) { + throw new ValidationError( + "--directory,--dir can only be set if --database is set.", + ); + } return true; }); } diff --git a/src/commands/schema/push.mjs b/src/commands/schema/push.mjs index 72d6d95d..e87ec3bf 100644 --- a/src/commands/schema/push.mjs +++ b/src/commands/schema/push.mjs @@ -9,7 +9,11 @@ import { getSecret } from "../../lib/fauna-client.mjs"; import { reformatFSL } from "../../lib/schema.mjs"; import { localSchemaOptions } from "./schema.mjs"; -async function doPush(argv) { +/** + * Pushes a schema (FSL) based on argv. + * @param {import("yargs").Argv & {dir: string, active: boolean, input: boolean}} argv + */ +export async function pushSchema(argv) { const logger = container.resolve("logger"); const makeFaunaRequest = container.resolve("makeFaunaRequest"); const gatherFSL = container.resolve("gatherFSL"); @@ -66,6 +70,7 @@ async function doPush(argv) { ? "Stage the file contents anyway?" : "Push the file contents anyway?"; } + const confirm = container.resolve("confirm"); const confirmed = await confirm({ message, @@ -133,5 +138,5 @@ export default { command: "push", description: "Push local .fsl schema files to Fauna.", builder: buildPushCommand, - handler: doPush, + handler: pushSchema, }; diff --git a/src/lib/auth/credentials.mjs b/src/lib/auth/credentials.mjs index ca86e878..852d223e 100644 --- a/src/lib/auth/credentials.mjs +++ b/src/lib/auth/credentials.mjs @@ -3,18 +3,19 @@ import { asValue, Lifetime } from "awilix"; import { container } from "../../cli.mjs"; import { ValidationError } from "../errors.mjs"; import { FaunaAccountClient } from "../fauna-account-client.mjs"; +import { isLocal } from "../middleware.mjs"; import { AccountKeys } from "./accountKeys.mjs"; import { DatabaseKeys } from "./databaseKeys.mjs"; const validateCredentialArgs = (argv) => { const logger = container.resolve("logger"); const illegalArgCombos = [ - ["accountKey", "secret", "local"], - ["secret", "database", "local"], - ["secret", "role", "local"], + ["accountKey", "secret", isLocal], + ["secret", "database", isLocal], + ["secret", "role", isLocal], ]; for (const [first, second, conditional] of illegalArgCombos) { - if (argv[first] && argv[second] && !argv[conditional]) { + if (argv[first] && argv[second] && !conditional(argv)) { throw new ValidationError( `Cannot use both the '--${first}' and '--${second}' options together. Please specify only one.`, ); diff --git a/src/lib/middleware.mjs b/src/lib/middleware.mjs index d23d441d..3bdcc6dc 100644 --- a/src/lib/middleware.mjs +++ b/src/lib/middleware.mjs @@ -9,7 +9,7 @@ import { container } from "../cli.mjs"; import { fixPath } from "../lib/file-util.mjs"; import { redactedStringify } from "./formatting/redact.mjs"; -const LOCAL_URL = "http://localhost:8443"; +const LOCAL_URL = "http://0.0.0.0:8443"; const LOCAL_SECRET = "secret"; const DEFAULT_URL = "https://db.fauna.com"; @@ -79,6 +79,15 @@ export function applyLocalArg(argv) { applyLocalToSecret(argv); } +/** + * @param {import('yargs').Arguments} argv + * @returns {boolean} true if this command acts on a local + * container, false otherwise. + */ +export function isLocal(argv) { + return argv.local || argv._[0] === "local"; +} + /** * Mutates argv.url appropriately for local Fauna usage * (i.e. local container usage). If --local is provided @@ -89,7 +98,7 @@ export function applyLocalArg(argv) { function applyLocalToUrl(argv) { const logger = container.resolve("logger"); if (!argv.url) { - if (argv.local) { + if (isLocal(argv)) { argv.url = LOCAL_URL; logger.debug( `Set url to '${LOCAL_URL}' as --local was given and --url was not`, @@ -120,7 +129,7 @@ function applyLocalToUrl(argv) { */ function applyLocalToSecret(argv) { const logger = container.resolve("logger"); - if (!argv.secret && argv.local) { + if (!argv.secret && isLocal(argv)) { if (argv.role && argv.database) { argv.secret = `${LOCAL_SECRET}:${argv.database}:${argv.role}`; } else if (argv.role) { diff --git a/test/config.mjs b/test/config.mjs index 73c98832..2d2b4c8a 100644 --- a/test/config.mjs +++ b/test/config.mjs @@ -177,13 +177,13 @@ describe("configuration file", function () { }); }); - it("--local arg sets the argv.url to http://localhost:8443 if no --url is given", async function () { + it("--local arg sets the argv.url to http://0.0.0.0:8443 if no --url is given", async function () { fs.readdirSync.withArgs(process.cwd()).returns([]); await runArgvTest({ cmd: `argv --secret "no-config" --local`, argvMatcher: sinon.match({ secret: "no-config", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), }); }); @@ -205,7 +205,7 @@ describe("configuration file", function () { cmd: `argv --local`, argvMatcher: sinon.match({ secret: "secret", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), }); }); @@ -216,7 +216,7 @@ describe("configuration file", function () { cmd: `argv --local --secret "sauce"`, argvMatcher: sinon.match({ secret: "sauce", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), }); }); diff --git a/test/database/list.mjs b/test/database/list.mjs index 28e2b16d..bd951254 100644 --- a/test/database/list.mjs +++ b/test/database/list.mjs @@ -27,7 +27,7 @@ describe("database list", () => { [ { args: "--local", - expected: { secret: "secret", url: "http://localhost:8443" }, + expected: { secret: "secret", url: "http://0.0.0.0:8443" }, }, { args: "--local --url http://yo_dog:8443", @@ -35,14 +35,14 @@ describe("database list", () => { }, { args: "--local --secret taco", - expected: { secret: "taco", url: "http://localhost:8443" }, + expected: { secret: "taco", url: "http://0.0.0.0:8443" }, }, { args: "--local --pageSize 10", expected: { secret: "secret", pageSize: 10, - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }, }, ].forEach(({ args, expected }) => { diff --git a/test/lib/middleware.mjs b/test/lib/middleware.mjs index 14d005bb..83b9990f 100644 --- a/test/lib/middleware.mjs +++ b/test/lib/middleware.mjs @@ -14,10 +14,10 @@ describe("middlewares", function () { setupTestContainer(); }); - it("should set url to localhost:8443 when --local is true and no url provided", function () { + it("should set url to 0.0.0.0:8443 when --local is true and no url provided", function () { const argv = { ...baseArgv, local: true }; applyLocalArg(argv); - expect(argv.url).to.equal("http://localhost:8443"); + expect(argv.url).to.equal("http://0.0.0.0:8443"); expect(argv.secret).to.equal("secret"); }); @@ -38,7 +38,7 @@ describe("middlewares", function () { it("should not modify secret if already provided", function () { const argv = { ...baseArgv, local: true, secret: "custom-secret" }; applyLocalArg(argv); - expect(argv.url).to.equal("http://localhost:8443"); + expect(argv.url).to.equal("http://0.0.0.0:8443"); expect(argv.secret).to.equal("custom-secret"); }); diff --git a/test/local.mjs b/test/local.mjs index da4fd9d0..e70e8145 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -1,4 +1,5 @@ //@ts-check +/* eslint-disable max-lines */ import { expect } from "chai"; import { AbortError } from "fauna"; @@ -6,9 +7,10 @@ import sinon, { stub } from "sinon"; import { run } from "../src/cli.mjs"; import { setupTestContainer } from "../src/config/setup-test-container.mjs"; +import { reformatFSL } from "../src/lib/schema.mjs"; import { f } from "./helpers.mjs"; -describe("ensureContainerRunning", () => { +describe("local command", () => { let container, fetch, logger, @@ -18,7 +20,20 @@ describe("ensureContainerRunning", () => { serverMock, simulateError, startStub, - unpauseStub; + unpauseStub, + gatherFSL, + confirm; + + const diffString = + "\u001b[1;34m* Modifying collection `Customer`\u001b[0m at collections.fsl:2:1:\n * Defined fields:\n\u001b[31m - drop field `.age`\u001b[0m\n\n"; + + const fsl = [ + { + name: "coll.fsl", + content: + "collection MyColl {\\n name: String\\n index byName {\\n terms [.name]\\n }\\n}\\n", + }, + ]; beforeEach(async () => { simulateError = false; @@ -30,6 +45,11 @@ describe("ensureContainerRunning", () => { logsStub = stub(); startStub = stub(); unpauseStub = stub(); + confirm = container.resolve("confirm"); + + gatherFSL = container.resolve("gatherFSL"); + gatherFSL.resolves(fsl); + // Requested port is free serverMock = { close: sinon.stub(), @@ -145,6 +165,121 @@ Please pass a --host-port other than '8443'.", }); }); + [ + "--database Foo --dir ./bar ", + "--database Foo --directory ./bar ", + "--database Foo --project-directory ./bar", + ].forEach((args) => { + it("Creates a schema if requested", async () => { + const baseUrl = "http://0.0.0.0:8443/schema/1"; + const { runQuery } = container.resolve("faunaClientV10"); + + confirm.resolves(true); + setupCreateContainerMocks(); + runQuery.resolves({ + data: JSON.stringify({ name: "Foo" }, null, 2), + }); + + // The first call pings the container, then the second creates the diff... + fetch + .onCall(1) + .resolves( + f({ + version: 1728675598430000, + diff: diffString, + }), + ) + .onCall(2) + .resolves( + f({ + version: 1728675598430000, + }), + ); + + await run(`local --no-color ${args}`, container); + + expect(gatherFSL).to.have.been.calledWith("bar"); + expect(fetch).to.have.been.calledWith(`${baseUrl}/diff?staged=false`, { + method: "POST", + headers: { AUTHORIZATION: "Bearer secret:Foo:admin" }, + body: reformatFSL(fsl), + }); + expect(fetch).to.have.been.calledWith( + `${baseUrl}/update?staged=false&version=1728675598430000`, + { + method: "POST", + headers: { AUTHORIZATION: "Bearer secret:Foo:admin" }, + body: reformatFSL(fsl), + }, + ); + const written = stderrStream.getWritten(); + expect(written).to.contain( + "[CreateDatabaseSchema] Schema for database 'Foo' created from directory './bar'.", + ); + }); + }); + + [ + "--database Foo --dir ./bar --no-active", + "--database Foo --directory ./bar --no-active", + "--database Foo --project-directory ./bar --no-active", + ].forEach((args) => { + it(`Creates a staged schema without forcing if requested using ${args}`, async () => { + const baseUrl = "http://0.0.0.0:8443/schema/1"; + const { runQuery } = container.resolve("faunaClientV10"); + + confirm.resolves(true); + setupCreateContainerMocks(); + runQuery.resolves({ + data: JSON.stringify({ name: "Foo" }, null, 2), + }); + + // The first call pings the container, then the second creates the diff... + fetch + .onCall(1) + .resolves( + f({ + version: 1728675598430000, + diff: diffString, + }), + ) + .onCall(2) + .resolves( + f({ + version: 1728675598430000, + }), + ); + + await run(`local --no-color ${args}`, container); + + expect(gatherFSL).to.have.been.calledWith("bar"); + expect(fetch).to.have.been.calledWith(`${baseUrl}/diff?staged=true`, { + method: "POST", + headers: { AUTHORIZATION: "Bearer secret:Foo:admin" }, + body: reformatFSL(fsl), + }); + expect(fetch).to.have.been.calledWith( + `${baseUrl}/update?staged=true&version=1728675598430000`, + { + method: "POST", + headers: { AUTHORIZATION: "Bearer secret:Foo:admin" }, + body: reformatFSL(fsl), + }, + ); + expect(logger.stdout).to.have.been.calledWith("Proposed diff:\n"); + const written = stderrStream.getWritten(); + expect(written).to.contain( + "[CreateDatabaseSchema] Schema for database 'Foo' created from directory './bar'.", + ); + expect(confirm).to.have.been.calledWith( + sinon.match.has("message", "Stage the above changes?"), + ); + expect(logger.stdout).to.have.been.calledWith(diffString); + }); + }); + + it.skip("Shows errors on schema problems", () => {}); + it("Exits with an expected error if the create db query aborts", async () => { setupCreateContainerMocks(); const { runQuery } = container.resolve("faunaClientV10"); @@ -164,20 +299,22 @@ Please pass a --host-port other than '8443'.", expect(written).not.to.contain("An unexpected"); }); - ["--typechecked", "--protected", "--priority 1"].forEach((args) => { - it("Rejects invalid create database args", async () => { - setupCreateContainerMocks(); - const { runQuery } = container.resolve("faunaClientV10"); - try { - await run(`local --no-color ${args}`, container); - } catch (_) {} - expect(runQuery).not.to.have.been.called; - const written = stderrStream.getWritten(); - expect(written).to.contain("fauna local"); - expect(written).not.to.contain("An unexpected"); - expect(written).to.contain("can only be set if"); - }); - }); + ["--typechecked", "--protected", "--priority 1", "--directory foo"].forEach( + (args) => { + it("Rejects invalid create database args", async () => { + setupCreateContainerMocks(); + const { runQuery } = container.resolve("faunaClientV10"); + try { + await run(`local --no-color ${args}`, container); + } catch (_) {} + expect(runQuery).not.to.have.been.called; + const written = stderrStream.getWritten(); + expect(written).to.contain("fauna local"); + expect(written).not.to.contain("An unexpected"); + expect(written).to.contain("can only be set if"); + }); + }, + ); it("Does not create a database when not requested to do so", async () => { setupCreateContainerMocks(); diff --git a/test/query.mjs b/test/query.mjs index eaa04eef..1c9ca24f 100644 --- a/test/query.mjs +++ b/test/query.mjs @@ -199,7 +199,7 @@ describe("query", function () { sinon.match({ apiVersion: "10", secret: "secret", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), ); }); @@ -216,7 +216,7 @@ describe("query", function () { sinon.match({ apiVersion: "10", secret: "secret:Taco:admin", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), ); }); @@ -233,7 +233,7 @@ describe("query", function () { sinon.match({ apiVersion: "10", secret: "secret:MyRole", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), ); }); @@ -253,7 +253,7 @@ describe("query", function () { sinon.match({ apiVersion: "10", secret: "secret:Db:MyRole", - url: "http://localhost:8443", + url: "http://0.0.0.0:8443", }), ); });