From 594f7a537beada59e8d5c7ea6e7b94e27a82a4cb Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 16 Apr 2021 11:47:00 +0100 Subject: [PATCH] feat: add --config option for custom .grmc(.js) path (#113) Co-authored-by: ben-pr-p --- README.md | 41 +++++++++++++++-------- __tests__/settings.test.ts | 34 +++++++++++++++++++ src/cli.ts | 8 +++++ src/commands/_common.ts | 67 +++++++++++++++++++++++++++++++++----- src/commands/commit.ts | 15 ++++----- src/commands/compile.ts | 15 ++++----- src/commands/init.ts | 50 ++++++++++++++++++---------- src/commands/migrate.ts | 21 +++++++----- src/commands/reset.ts | 17 +++++----- src/commands/run.ts | 19 +++++------ src/commands/status.ts | 20 ++++++++---- src/commands/uncommit.ts | 6 ++-- src/commands/watch.ts | 21 +++++++----- 13 files changed, 233 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 7f60bbd..0672438 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,8 @@ Commands: graphile-migrate completion Generate shell completion script. Options: - --help Show help [boolean] + --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] You are running graphile-migrate v1.0.2. ``` @@ -238,8 +239,9 @@ Initializes a graphile-migrate project by creating a `.gmrc` file and `migrations` folder. Options: - --help Show help [boolean] - --folder Use a folder rather than a file for the current migration. + --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] + --folder Use a folder rather than a file for the current migration. [boolean] [default: false] ``` @@ -254,6 +256,7 @@ For use in production and development. Options: --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] --shadow Apply migrations to the shadow DB (for development). [boolean] [default: false] --forceActions Run beforeAllMigrations and afterAllMigrations actions even if @@ -270,9 +273,11 @@ Runs any un-executed committed migrations and then runs and watches the current migration, re-running it on any change. For development. Options: - --help Show help [boolean] - --once Runs the current migration and then exits.[boolean] [default: false] - --shadow Applies changes to shadow DB. [boolean] [default: false] + --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] + --once Runs the current migration and then exits. + [boolean] [default: false] + --shadow Applies changes to shadow DB. [boolean] [default: false] ``` @@ -286,6 +291,7 @@ current migration. Resets the shadow database. Options: --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] --message, -m Optional commit message to label migration, must not contain newlines. [string] ``` @@ -308,7 +314,8 @@ should result in the exact same hash. Development only, and liable to cause conflicts with other developers - be careful. Options: - --help Show help [boolean] + --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] ``` @@ -321,10 +328,11 @@ Drops and re-creates the database, re-running all committed migrations from the start. **HIGHLY DESTRUCTIVE**. Options: - --help Show help [boolean] - --shadow Applies migrations to shadow DB. [boolean] [default: false] - --erase This is your double opt-in to make it clear this DELETES EVERYTHING. - [boolean] [default: false] + --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] + --shadow Applies migrations to shadow DB. [boolean] [default: false] + --erase This is your double opt-in to make it clear this DELETES + EVERYTHING. [boolean] [default: false] ``` @@ -345,6 +353,7 @@ output. Options: --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] --skipDatabase Skip checks that require a database connection. [boolean] [default: false] ``` @@ -359,8 +368,9 @@ Compiles a SQL file, inserting all the placeholders and returning the result to STDOUT Options: - --help Show help [boolean] - --shadow Apply shadow DB placeholders (for development). + --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] + --shadow Apply shadow DB placeholders (for development). [boolean] [default: false] ``` @@ -377,6 +387,7 @@ run against the same database (via GM_DBURL envvar) unless --shadow or Options: --help Show help [boolean] + --config, -c Optional path to gmrc file [string] [default: .gmrc[.js]] --shadow Apply to the shadow database (for development). [boolean] [default: false] --root Run the file using the root user (but application database). @@ -490,6 +501,10 @@ opening brace `{` would be prepended with `module.exports =`: module.exports = { ``` +All commands accept an optional `--config` parameter with a custom path to a +`.gmrc(.js)` file. This is useful if, for example, you have a monorepo or other +project with multiple interacting databases. + ### Windows Since committed migrations utilize hashes to verify file integrity, the diff --git a/__tests__/settings.test.ts b/__tests__/settings.test.ts index bb44ccf..c4a67e1 100644 --- a/__tests__/settings.test.ts +++ b/__tests__/settings.test.ts @@ -3,6 +3,7 @@ import "./helpers"; // Side effects - must come first import * as mockFs from "mock-fs"; import * as path from "path"; +import { DEFAULT_GMRC_PATH, getSettings } from "../src/commands/_common"; import { makeRootDatabaseConnectionString, ParsedSettings, @@ -278,3 +279,36 @@ describe("actions", () => { `); }); }); + +describe("gmrc path", () => { + it("defaults to .gmrc", async () => { + mockFs.restore(); + mockFs({ + [DEFAULT_GMRC_PATH]: ` + { "connectionString": "postgres://appuser:apppassword@host:5432/defaultdb" } + `, + }); + const settings = await getSettings(); + expect(settings.connectionString).toEqual( + "postgres://appuser:apppassword@host:5432/defaultdb", + ); + mockFs.restore(); + }); + + it("accepts an override and follows it", async () => { + mockFs.restore(); + mockFs({ + [DEFAULT_GMRC_PATH]: ` + { "connectionString": "postgres://appuser:apppassword@host:5432/defaultdb" } + `, + ".other-gmrc": ` + { "connectionString": "postgres://appuser:apppassword@host:5432/otherdb" } + `, + }); + const settings = await getSettings({ configFile: ".other-gmrc" }); + expect(settings.connectionString).toEqual( + "postgres://appuser:apppassword@host:5432/otherdb", + ); + mockFs.restore(); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 21ad3b6..6d8f975 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,6 +78,14 @@ yargs .command(wrapHandler(compileCommand)) .command(wrapHandler(runCommand)) + // Make sure options added here are represented in CommonArgv + .option("config", { + alias: "c", + type: "string", + description: "Optional path to gmrc file", + defaultDescription: ".gmrc[.js]", + }) + .completion("completion", "Generate shell completion script.") .epilogue( process.env.GRAPHILE_SPONSOR diff --git a/src/commands/_common.ts b/src/commands/_common.ts index 578c418..971c2dd 100644 --- a/src/commands/_common.ts +++ b/src/commands/_common.ts @@ -1,11 +1,23 @@ import { constants, promises as fsp } from "fs"; import * as JSON5 from "json5"; +import { resolve } from "path"; import { parse } from "pg-connection-string"; import { Settings } from "../settings"; -export const GMRC_PATH = `${process.cwd()}/.gmrc`; -export const GMRCJS_PATH = `${GMRC_PATH}.js`; +export const DEFAULT_GMRC_PATH = `${process.cwd()}/.gmrc`; +export const DEFAULT_GMRCJS_PATH = `${DEFAULT_GMRC_PATH}.js`; + +/** + * Represents the option flags that are valid for all commands (see + * src/cli.ts). + */ +export interface CommonArgv { + /** + * Optional path to the gmrc file. + */ + config?: string; +} export async function exists(path: string): Promise { try { @@ -30,20 +42,59 @@ export async function getSettingsFromJSON(path: string): Promise { } } -export async function getSettings(): Promise { - if (await exists(GMRC_PATH)) { - return getSettingsFromJSON(GMRC_PATH); - } else if (await exists(GMRCJS_PATH)) { +/** + * Options passed to the getSettings function. + */ +interface Options { + /** + * Optional path to the gmrc config path to use; if not provided we'll fall + * back to `./.gmrc` and `./.gmrc.js`. + * + * This must be the full path, including extension. If the extension is `.js` + * then we'll use `require` to import it, otherwise we'll read it as JSON5. + */ + configFile?: string; +} + +/** + * Gets the raw settings from the relevant .gmrc file. Does *not* validate the + * settings - the result of this call should not be trusted. Pass the result of + * this function to `parseSettings` to get validated settings. + */ +export async function getSettings(options: Options = {}): Promise { + const { configFile } = options; + const tryRequire = (path: string): Settings => { + // If the file is e.g. `foo.js` then Node `require('foo.js')` would look in + // `node_modules`; we don't want this - instead force it to be a relative + // path. + const relativePath = resolve(process.cwd(), path); + try { - return require(GMRCJS_PATH); + return require(relativePath); } catch (e) { throw new Error( - `Failed to import '${GMRCJS_PATH}'; error:\n ${e.stack.replace( + `Failed to import '${relativePath}'; error:\n ${e.stack.replace( /\n/g, "\n ", )}`, ); } + }; + + if (configFile != null) { + if (!(await exists(configFile))) { + throw new Error(`Failed to import '${configFile}': file not found`); + } + + if (configFile.endsWith(".js")) { + return tryRequire(configFile); + } else { + return await getSettingsFromJSON(configFile); + } + } else if (await exists(DEFAULT_GMRC_PATH)) { + return await getSettingsFromJSON(DEFAULT_GMRC_PATH); + } else if (await exists(DEFAULT_GMRCJS_PATH)) { + return tryRequire(DEFAULT_GMRCJS_PATH); } else { throw new Error( "No .gmrc file found; please run `graphile-migrate init` first.", diff --git a/src/commands/commit.ts b/src/commands/commit.ts index e6d5d03..79ebea9 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -17,10 +17,14 @@ import { } from "../migration"; import { ParsedSettings, parseSettings, Settings } from "../settings"; import { sluggify } from "../sluggify"; -import { getSettings } from "./_common"; +import { CommonArgv, getSettings } from "./_common"; import { _migrate } from "./migrate"; import { _reset } from "./reset"; +interface CommitArgv extends CommonArgv { + message?: string; +} + function omit( obj: T, keys: K[], @@ -128,12 +132,7 @@ export async function commit( return _commit(parsedSettings, message); } -export const commitCommand: CommandModule< - never, - { - message?: string; - } -> = { +export const commitCommand: CommandModule = { command: "commit", aliases: [], describe: @@ -151,6 +150,6 @@ export const commitCommand: CommandModule< if (argv.message !== undefined && !argv.message) { throw new Error("Missing or empty commit message after --message flag"); } - await commit(await getSettings(), argv.message); + await commit(await getSettings({ configFile: argv.config }), argv.message); }, }; diff --git a/src/commands/compile.ts b/src/commands/compile.ts index 43ecc02..f6cf10e 100644 --- a/src/commands/compile.ts +++ b/src/commands/compile.ts @@ -3,7 +3,11 @@ import { CommandModule } from "yargs"; import { compilePlaceholders } from "../migration"; import { parseSettings, Settings } from "../settings"; -import { getSettings, readStdin } from "./_common"; +import { CommonArgv, getSettings, readStdin } from "./_common"; + +interface CompileArgv extends CommonArgv { + shadow?: boolean; +} export async function compile( settings: Settings, @@ -14,12 +18,7 @@ export async function compile( return compilePlaceholders(parsedSettings, content, shadow); } -export const compileCommand: CommandModule< - {}, - { - shadow?: boolean; - } -> = { +export const compileCommand: CommandModule<{}, CompileArgv> = { command: "compile [file]", aliases: [], describe: `\ @@ -32,7 +31,7 @@ Compiles a SQL file, inserting all the placeholders and returning the result to }, }, handler: async argv => { - const settings = await getSettings(); + const settings = await getSettings({ configFile: argv.config }); const content = typeof argv.file === "string" ? await fsp.readFile(argv.file, "utf8") diff --git a/src/commands/init.ts b/src/commands/init.ts index d87863f..91a537c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,19 +6,31 @@ import { CommandModule } from "yargs"; import { version } from "../../package.json"; import { getCurrentMigrationLocation, writeCurrentMigration } from "../current"; import { parseSettings } from "../settings"; -import { exists, getSettings, GMRC_PATH, GMRCJS_PATH } from "./_common"; - -interface InitOptions { +import { + CommonArgv, + DEFAULT_GMRC_PATH, + DEFAULT_GMRCJS_PATH, + exists, + getSettings, +} from "./_common"; + +interface InitArgv extends CommonArgv { folder?: boolean; } -export async function init(options: InitOptions = {}): Promise { - if (await exists(GMRC_PATH)) { - throw new Error(`.gmrc file already exists at ${GMRC_PATH}`); +export async function init(options: InitArgv = {}): Promise { + if (await exists(DEFAULT_GMRC_PATH)) { + throw new Error(`.gmrc file already exists at ${DEFAULT_GMRC_PATH}`); + } + if (await exists(DEFAULT_GMRCJS_PATH)) { + throw new Error(`.gmrc.js file already exists at ${DEFAULT_GMRCJS_PATH}`); } - if (await exists(GMRCJS_PATH)) { - throw new Error(`.gmrc.js file already exists at ${GMRCJS_PATH}`); + if (options.config && (await exists(options.config))) { + throw new Error(`.gmrc file already exists at ${options.config}`); } + + const gmrcPath = options.config || DEFAULT_GMRC_PATH; + const dbStrings = process.env.DATABASE_URL && process.env.SHADOW_DATABASE_URL && @@ -56,9 +68,7 @@ export async function init(options: InitOptions = {}): Promise { // "rootConnectionString": "postgres://adminuser:adminpassword@host:5432/postgres", `; - await fsp.writeFile( - GMRC_PATH, - `\ + const initialComment = `\ /* * Graphile Migrate configuration. * @@ -69,7 +79,9 @@ export async function init(options: InitOptions = {}): Promise { * This file is in JSON5 format, in VSCode you can use "JSON with comments" as * the file format. */ +`; + const jsonContent = `\ {${dbStrings} /* * pgSettings: key-value settings to be automatically loaded into PostgreSQL @@ -173,14 +185,18 @@ export async function init(options: InitOptions = {}): Promise { // migrationsFolder: "./migrations", "//generatedWith": "${version}" -} -`, - ); +}`; + + const fileContent = gmrcPath.endsWith(".js") + ? `${initialComment}module.exports = ${jsonContent};\n` + : `${initialComment}${jsonContent}\n`; + await fsp.writeFile(gmrcPath, fileContent); + // eslint-disable-next-line console.log( - `Template .gmrc file written to '${GMRC_PATH}'; please read and edit it to suit your needs.`, + `Template .gmrc file written to '${gmrcPath}'; please read and edit it to suit your needs.`, ); - const settings = await getSettings(); + const settings = await getSettings({ configFile: options.config }); const parsedSettings = await parseSettings({ connectionString: process.env.DATABASE_URL || "NOT_NEEDED", shadowConnectionString: process.env.SHADOW_DATABASE_URL || "NOT_NEEDED", @@ -207,7 +223,7 @@ export async function init(options: InitOptions = {}): Promise { ); } -export const initCommand: CommandModule<{}, InitOptions> = { +export const initCommand: CommandModule<{}, InitArgv> = { command: "init", aliases: [], describe: `\ diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 6c0d5c3..c546846 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -9,7 +9,12 @@ import { import { withClient } from "../pg"; import { withAdvisoryLock } from "../pgReal"; import { ParsedSettings, parseSettings, Settings } from "../settings"; -import { getSettings } from "./_common"; +import { CommonArgv, getSettings } from "./_common"; + +interface MigrateArgv extends CommonArgv { + shadow: boolean; + forceActions: boolean; +} export async function _migrate( parsedSettings: ParsedSettings, @@ -83,13 +88,7 @@ export async function migrate( return _migrate(parsedSettings, shadow, forceActions); } -export const migrateCommand: CommandModule< - never, - { - shadow: boolean; - forceActions: boolean; - } -> = { +export const migrateCommand: CommandModule = { command: "migrate", aliases: [], describe: @@ -108,6 +107,10 @@ export const migrateCommand: CommandModule< }, }, handler: async argv => { - await migrate(await getSettings(), argv.shadow, argv.forceActions); + await migrate( + await getSettings({ configFile: argv.config }), + argv.shadow, + argv.forceActions, + ); }, }; diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 74d463b..e3aab21 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -3,9 +3,14 @@ import { CommandModule } from "yargs"; import { executeActions } from "../actions"; import { escapeIdentifier, withClient } from "../pg"; import { ParsedSettings, parseSettings, Settings } from "../settings"; -import { getSettings } from "./_common"; +import { CommonArgv, getSettings } from "./_common"; import { _migrate } from "./migrate"; +interface ResetArgv extends CommonArgv { + shadow: boolean; + erase: boolean; +} + export async function _reset( parsedSettings: ParsedSettings, shadow: boolean, @@ -65,13 +70,7 @@ export async function reset(settings: Settings, shadow = false): Promise { return _reset(parsedSettings, shadow); } -export const resetCommand: CommandModule< - never, - { - shadow: boolean; - erase: boolean; - } -> = { +export const resetCommand: CommandModule = { command: "reset", aliases: [], describe: @@ -97,6 +96,6 @@ export const resetCommand: CommandModule< ); process.exit(2); } - await reset(await getSettings(), argv.shadow); + await reset(await getSettings({ configFile: argv.config }), argv.shadow); }, }; diff --git a/src/commands/run.ts b/src/commands/run.ts index 9f10204..65c1ddf 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -10,7 +10,13 @@ import { parseSettings, Settings, } from "../settings"; -import { getDatabaseName, getSettings, readStdin } from "./_common"; +import { CommonArgv, getDatabaseName, getSettings, readStdin } from "./_common"; + +interface RunArgv extends CommonArgv { + shadow?: boolean; + root?: boolean; + rootDatabase?: boolean; +} export async function run( settings: Settings, @@ -50,14 +56,7 @@ export async function run( ); } -export const runCommand: CommandModule< - {}, - { - shadow?: boolean; - root?: boolean; - rootDatabase?: boolean; - } -> = { +export const runCommand: CommandModule<{}, RunArgv> = { command: "run [file]", aliases: [], describe: `\ @@ -82,7 +81,7 @@ Compiles a SQL file, inserting all the placeholders, and then runs it against th }, }, handler: async argv => { - const defaultSettings = await getSettings(); + const defaultSettings = await getSettings({ configFile: argv.config }); // `run` might be called from an action; in this case `DATABASE_URL` will // be unavailable (overwritten with DO_NOT_USE_DATABASE_URL) to avoid diff --git a/src/commands/status.ts b/src/commands/status.ts index 32f37de..a672dbd 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -5,17 +5,19 @@ import { getCurrentMigrationLocation, readCurrentMigration } from "../current"; import { getLastMigration, getMigrationsAfter } from "../migration"; import { withClient } from "../pg"; import { ParsedSettings, parseSettings, Settings } from "../settings"; -import { getSettings } from "./_common"; +import { CommonArgv, getSettings } from "./_common"; + +interface StatusOptions { + skipDatabase?: boolean; +} + +interface StatusArgv extends StatusOptions, CommonArgv {} interface Status { remainingMigrations?: Array; hasCurrentMigration: boolean; } -interface StatusOptions { - skipDatabase?: boolean; -} - async function _status( parsedSettings: ParsedSettings, { skipDatabase }: StatusOptions, @@ -60,7 +62,7 @@ export async function status( return _status(parsedSettings, options); } -export const statusCommand: CommandModule = { +export const statusCommand: CommandModule = { command: "status", aliases: [], describe: `\ @@ -81,7 +83,11 @@ are true, exit status will be 0 (success). Additional messages may also be outpu handler: async argv => { /* eslint-disable no-console */ let exitCode = 0; - const details = await status(await getSettings(), argv); + const { config, ...options } = argv; + const details = await status( + await getSettings({ configFile: config }), + options, + ); if (details.remainingMigrations) { const remainingCount = details.remainingMigrations?.length; if (remainingCount > 0) { diff --git a/src/commands/uncommit.ts b/src/commands/uncommit.ts index 144474a..3d14a6c 100644 --- a/src/commands/uncommit.ts +++ b/src/commands/uncommit.ts @@ -14,7 +14,7 @@ import { undoMigration, } from "../migration"; import { ParsedSettings, parseSettings, Settings } from "../settings"; -import { getSettings } from "./_common"; +import { CommonArgv, getSettings } from "./_common"; import { _migrate } from "./migrate"; import { _reset } from "./reset"; @@ -64,7 +64,7 @@ export async function uncommit(settings: Settings): Promise { return _uncommit(parsedSettings); } -export const uncommitCommand: CommandModule = { +export const uncommitCommand: CommandModule = { command: "uncommit", aliases: [], describe: @@ -74,6 +74,6 @@ export const uncommitCommand: CommandModule = { if (argv.message !== undefined && !argv.message) { throw new Error("Missing or empty commit message after --message flag"); } - await uncommit(await getSettings()); + await uncommit(await getSettings({ configFile: argv.config })); }, }; diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 37663df..c2ac26d 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -14,7 +14,12 @@ import { readCurrentMigration, writeCurrentMigration, } from "../current"; -import { getSettings } from "./_common"; +import { CommonArgv, getSettings } from "./_common"; + +interface WatchArgv extends CommonArgv { + once: boolean; + shadow: boolean; +} export function _makeCurrentMigrationRunner( parsedSettings: ParsedSettings, @@ -248,13 +253,7 @@ export async function watch( return _watch(parsedSettings, once, shadow); } -export const watchCommand: CommandModule< - never, - { - once: boolean; - shadow: boolean; - } -> = { +export const watchCommand: CommandModule = { command: "watch", aliases: [], describe: @@ -272,6 +271,10 @@ export const watchCommand: CommandModule< }, }, handler: async argv => { - await watch(await getSettings(), argv.once, argv.shadow); + await watch( + await getSettings({ configFile: argv.config }), + argv.once, + argv.shadow, + ); }, };