From 4b675f01c57416eef73f8c00fc58434d357713c3 Mon Sep 17 00:00:00 2001 From: Simon Gagnon Date: Tue, 4 Aug 2026 12:48:09 -0400 Subject: [PATCH] feat: add cedarpg run and Nx ensure-env wiring --- CHANGELOG.md | 9 ++++ README.md | 71 +++++++++++++++++++++++++-- package.json | 5 ++ scripts/smoke.mjs | 20 ++++++++ src/adapters/dev-env.ts | 10 ++++ src/adapters/ensure-task.ts | 39 +++++++++++++++ src/adapters/load-dev-env.ts | 8 ++++ src/adapters/load-mode-env.ts | 41 ++++++++++++++++ src/adapters/load-test-env.ts | 26 ++-------- src/adapters/nx.ts | 61 +++++++++++++++++------- src/adapters/tasks.ts | 38 +++++++++++++-- src/adapters/vite-plus.ts | 54 ++++++--------------- src/cli.ts | 85 ++++++++++++++++++++++++++------- src/core/lease.ts | 6 +++ src/index.ts | 12 ++++- tests/ensure-task.test.ts | 72 ++++++++++++++++++++++++++++ tests/load-dev-env.test.ts | 90 +++++++++++++++++++++++++++++++++++ tests/nx.test.ts | 37 ++++++++++++++ vite.config.ts | 1 + 19 files changed, 585 insertions(+), 100 deletions(-) create mode 100644 src/adapters/dev-env.ts create mode 100644 src/adapters/ensure-task.ts create mode 100644 src/adapters/load-dev-env.ts create mode 100644 src/adapters/load-mode-env.ts create mode 100644 tests/ensure-task.test.ts create mode 100644 tests/load-dev-env.test.ts create mode 100644 tests/nx.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e53c20..3200329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- CLI: `cedarpg run --mode=dev|test -- ` ensures then overwrites child `DATABASE_URL` (Nx/e2e/dev) +- CLI: `ensure --force` / `run --force` sets `CEDAR_PG_FORCE=1` (escape hatch only; `run` always injects child env) +- Shared `cedarPgLifecycleTargets` for Vite+ / Nx; Nx adds `cedarPgRunCommand` + `relativeEnvFile` +- `createEnsureTask({ afterEnsure })` on `@cedarjs/pg` (db:ready compose; not Nx-specific) +- `loadDevEnv({ overwrite })` + `@cedarjs/pg/dev-env`; `loadTestEnv` accepts `{ overwrite: true }` +- Public: `envFilePath(root, mode)` for stable `.cedarpg/.env` paths + ## 0.1.0-alpha.0 Initial alpha of **cedar-pg**, published on npm as `@cedarjs/pg` (CLI: `cedarpg`). diff --git a/README.md b/README.md index dc5cdfe..ddfea99 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,68 @@ yarn add @cedarjs/pg@file:../cedar-pg ```bash cedarpg ensure --mode=dev cedarpg ensure --mode=test --print-env +cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts +cedarpg run --mode=test -- vitest run cedarpg dispose --mode=test cedarpg print-url --mode=dev cedarpg gc # drop DBs whose worktree root is gone (uses ~/.cedarpg/registry) ``` +`cedarpg run` ensures (or attaches the lease), force-sets `DATABASE_URL` (and +`TEST_DATABASE_URL` in test mode) in the **child** process, then execs the command. +Use it for Nx / e2e / API wrappers — local `.env` URLs do not win inside the child. + +## Nx consumer adapter + +Nx `dependsOn` alone does not forward env from an ensure task into dependents +(Vite+ `env: [...]` does). **Canonical fix:** wrap the child with `cedarpg run`. +Secondary: point Nx `envFile` at `.cedarpg/.env` after ensure. + +```ts +import { cedarPgNxTargets, cedarPgRunCommand, relativeEnvFile } from "@cedarjs/pg/nx"; + +cedarPgNxTargets(); +// { "db:ensure": { command: "cedarpg ensure --mode=dev", cache: false }, … } + +cedarPgRunCommand("dev", "yarn tsx scripts/apiServer/dev.ts"); +// "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts" + +relativeEnvFile("dev"); // ".cedarpg/dev.env" +``` + +```json +{ + "targets": { + "dev": { + "command": "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts" + }, + "db:ensure": { "command": "cedarpg ensure --mode=dev" }, + "serve": { + "dependsOn": ["db:ensure"], + "command": "node dist/server.js", + "options": { "envFile": ".cedarpg/dev.env" } + } + } +} +``` + +For a db:ready-style migrate hook (same compose shape as Jest `createGlobalSetup`): + +```ts +// tools/db-ready.ts +import { createEnsureTask } from "@cedarjs/pg"; + +await createEnsureTask({ + mode: "dev", + afterEnsure: async ({ databaseUrl }) => { + // prisma migrate deploy / drizzle push / … + }, +})(); +``` + +Fallbacks when you cannot wrap with `run`: `loadDevEnv({ overwrite: true })` or +`import "@cedarjs/pg/dev-env"`. Absolute path helper: `envFilePath(root, mode)`. + ## Vite+ consumer adapter ```ts @@ -179,16 +236,24 @@ if (process.env.CEDAR_PG === "1" || process.env.CEDAR_PG === "true") { setupFiles: [require.resolve("@cedarjs/pg/test-env")], ``` -Use exported `STATE_DIRNAME` (`.cedarpg`) / `loadTestEnv(root?)` instead of hardcoding the lease dir. +Use exported `STATE_DIRNAME` (`.cedarpg`) / `loadTestEnv` / `loadDevEnv` / +`envFilePath(root, mode)` instead of hardcoding the lease dir. + +`loadTestEnv` / `loadDevEnv` only fill **undefined** keys by default. Pass +`{ overwrite: true }` (or import `@cedarjs/pg/dev-env`) when a local `.env` +`DATABASE_URL` / `TEST_DATABASE_URL` should lose to cedar-pg. That is not the +same as `CEDAR_PG_FORCE` / ensure `{ force }` (external-URL escape hatch). ## Programmatic API ```ts -import { ensure, dispose, loadTestEnv, STATE_DIRNAME } from "@cedarjs/pg"; +import { ensure, dispose, loadTestEnv, loadDevEnv, envFilePath, STATE_DIRNAME } from "@cedarjs/pg"; const { databaseUrl, adminUrl, databaseName, dispose: drop } = await ensure({ mode: "test" }); // … tests … await drop(); + +loadDevEnv({ overwrite: true }); // override .env DATABASE_URL from .cedarpg/dev.env ``` ### Host startup (CI ephemeral) @@ -322,7 +387,7 @@ Worker adapters call `cloneFromTemplateIfNeeded` (shared skip policy via `runIfN | `AUTOPG_PG_USER` / `_PASSWORD` | Autopg superuser for admin URL (default `postgres` / `postgres`) | | `CEDAR_PG=0` | Disable auto-ensure in adapters | | `TEST_DATABASE_URL` | Escape hatch: skip ensure for real external DBs (not `cpg_*` / `file:` / `{…}` / `<…>` template placeholders) | -| `CEDAR_PG_FORCE=1` | Ignore external-URL escape hatch (use for real external DBs / Jest when you still want ensure) | +| `CEDAR_PG_FORCE=1` | Ignore external-URL escape hatch (adapters + `cedarpg ensure --force` / `run --force`) | | `CEDAR_PG_EPHEMERAL_HOST` | `1` force / `0` disable ephemeral host (auto when `CI=true`) | | `CEDAR_PG_REGISTRY_DIR` | Override global lease registry (for `gc`) | | `CEDAR_PG_SKIP_POSTINSTALL=1` | Skip autopg install hook | diff --git a/package.json b/package.json index 2a69423..e9b60c2 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,11 @@ "import": "./dist/test-env.mjs", "require": "./dist/test-env.cjs" }, + "./dev-env": { + "types": "./dist/dev-env.d.mts", + "import": "./dist/dev-env.mjs", + "require": "./dist/dev-env.cjs" + }, "./jest/template": { "types": "./dist/jest-template.d.mts", "import": "./dist/jest-template.mjs", diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 57f8690..0b0572e 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -37,15 +37,20 @@ import { buildDatabaseName, cloneFromTemplate, cloneFromTemplateIfNeeded, + createEnsureTask, + envFilePath, + loadDevEnv, loadTestEnv, markTemplate, STATE_DIRNAME, } from '${PACKAGE_NAME}'; import { cedarPgTasks } from '${PACKAGE_NAME}/vite-plus'; +import { cedarPgNxTargets, cedarPgRunCommand, relativeEnvFile } from '${PACKAGE_NAME}/nx'; import vitestSetup from '${PACKAGE_NAME}/vitest'; import jestSetup from '${PACKAGE_NAME}/jest'; import jestTeardown from '${PACKAGE_NAME}/jest-teardown'; import '${PACKAGE_NAME}/test-env'; +import '${PACKAGE_NAME}/dev-env'; import { createGlobalSetup as createJestTemplateSetup, ensureWorkerDatabase, @@ -61,10 +66,22 @@ const name = buildDatabaseName( if (name !== 'cpg_cedar_feat_dev_abcd1234') throw new Error('bad name ' + name); const tasks = cedarPgTasks(); if (!tasks['db:ensure']) throw new Error('missing db:ensure'); +const nxTargets = cedarPgNxTargets(); +if (!nxTargets['db:ensure']) throw new Error('missing nx db:ensure'); +if (JSON.stringify(tasks) !== JSON.stringify(nxTargets)) { + throw new Error('vite-plus and nx lifecycle targets drifted'); +} +if (!cedarPgRunCommand('dev', 'echo ok').includes('run --mode=dev')) { + throw new Error('cedarPgRunCommand missing run'); +} +if (relativeEnvFile('dev') !== '.cedarpg/dev.env') throw new Error('bad relativeEnvFile'); +if (typeof createEnsureTask !== 'function') throw new Error('missing createEnsureTask'); if (typeof vitestSetup !== 'function') throw new Error('vitest setup export missing'); if (typeof jestSetup !== 'function') throw new Error('jest setup export missing'); if (typeof jestTeardown !== 'function') throw new Error('jest-teardown export missing'); if (typeof loadTestEnv !== 'function') throw new Error('loadTestEnv export missing'); +if (typeof loadDevEnv !== 'function') throw new Error('loadDevEnv export missing'); +if (typeof envFilePath !== 'function') throw new Error('envFilePath export missing'); if (STATE_DIRNAME !== '.cedarpg') throw new Error('bad STATE_DIRNAME ' + STATE_DIRNAME); if (typeof markTemplate !== 'function') throw new Error('missing markTemplate'); if (typeof cloneFromTemplate !== 'function') throw new Error('missing cloneFromTemplate'); @@ -87,6 +104,9 @@ const help = run("node", [join(tmp, "node_modules", PACKAGE_NAME, "dist/cli.mjs" if (!help.stdout?.includes(`${CLI_BIN} ensure`)) { throw new Error(`CLI help missing ${CLI_BIN} ensure`); } +if (!help.stdout?.includes(`${CLI_BIN} run`)) { + throw new Error(`CLI help missing ${CLI_BIN} run`); +} console.log("==> published files exclude smoke harness"); const packed = run("tar", ["-tzf", tarballPath], { silent: true }).stdout ?? ""; diff --git a/src/adapters/dev-env.ts b/src/adapters/dev-env.ts new file mode 100644 index 0000000..bcb6d7f --- /dev/null +++ b/src/adapters/dev-env.ts @@ -0,0 +1,10 @@ +import { loadDevEnv } from "./load-dev-env.ts"; + +/** + * `setupFiles`-style entry: load `.cedarpg/dev.env` with overwrite. + * + * ```ts + * import "@cedarjs/pg/dev-env"; + * ``` + */ +loadDevEnv({ overwrite: true }); diff --git a/src/adapters/ensure-task.ts b/src/adapters/ensure-task.ts new file mode 100644 index 0000000..72cabe2 --- /dev/null +++ b/src/adapters/ensure-task.ts @@ -0,0 +1,39 @@ +import { ensureIfNeeded, type EnsureResult } from "../core/lifecycle.ts"; +import type { DbMode } from "../core/naming.ts"; + +export type EnsureTaskContext = Pick< + EnsureResult, + "databaseUrl" | "adminUrl" | "databaseName" | "roleName" | "root" | "mode" | "port" +>; + +export type CreateEnsureTaskOptions = { + mode: DbMode; + root?: string; + /** Ignore external-URL escape hatch (`CEDAR_PG_FORCE=1` also works). */ + force?: boolean; + setEnv?: boolean; + /** App-owned migrate/seed after a successful ensure. */ + afterEnsure?: (ctx: EnsureTaskContext) => void | Promise; +}; + +/** ensure → optional afterEnsure (db:ready / migrate compose). */ +export function createEnsureTask(options: CreateEnsureTaskOptions): () => Promise { + return async () => { + const result = await ensureIfNeeded({ + root: options.root, + mode: options.mode, + setEnv: options.setEnv !== false, + force: options.force, + }); + if (result.status !== "ensured" || !options.afterEnsure) return; + await options.afterEnsure({ + databaseUrl: result.databaseUrl, + adminUrl: result.adminUrl, + databaseName: result.databaseName, + roleName: result.roleName, + root: result.root, + mode: result.mode, + port: result.port, + }); + }; +} diff --git a/src/adapters/load-dev-env.ts b/src/adapters/load-dev-env.ts new file mode 100644 index 0000000..478eaf0 --- /dev/null +++ b/src/adapters/load-dev-env.ts @@ -0,0 +1,8 @@ +import { loadModeEnv, normalizeLoadOptions, type LoadModeEnvOptions } from "./load-mode-env.ts"; + +export type LoadDevEnvOptions = LoadModeEnvOptions; + +/** Load `.cedarpg/dev.env` into `process.env`. Use `{ overwrite: true }` to beat `.env`. */ +export function loadDevEnv(rootOrOptions?: string | LoadDevEnvOptions): void { + loadModeEnv("dev", normalizeLoadOptions(rootOrOptions)); +} diff --git a/src/adapters/load-mode-env.ts b/src/adapters/load-mode-env.ts new file mode 100644 index 0000000..03ea7d5 --- /dev/null +++ b/src/adapters/load-mode-env.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from "node:fs"; +import { envPath, leasePath } from "../core/lease.ts"; +import type { DbMode } from "../core/naming.ts"; +import { resolveRoot } from "../core/worktree.ts"; + +export type LoadModeEnvOptions = { + root?: string; + /** Overwrite existing `process.env` keys (default: only fill undefined). */ + overwrite?: boolean; +}; + +export function normalizeLoadOptions( + rootOrOptions?: string | LoadModeEnvOptions, +): LoadModeEnvOptions { + if (typeof rootOrOptions === "string" || rootOrOptions === undefined) { + return { root: rootOrOptions }; + } + return rootOrOptions; +} + +/** Load `.cedarpg/.env` when a matching lease exists; no-op if stale/missing. */ +export function loadModeEnv(mode: DbMode, options: LoadModeEnvOptions = {}): void { + const resolved = resolveRoot(options.root); + if (!existsSync(leasePath(resolved, mode))) return; + + const file = envPath(resolved, mode); + if (!existsSync(file)) return; + + const overwrite = options.overwrite === true; + for (const line of readFileSync(file, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq); + const value = trimmed.slice(eq + 1); + if (overwrite || process.env[key] === undefined) { + process.env[key] = value; + } + } +} diff --git a/src/adapters/load-test-env.ts b/src/adapters/load-test-env.ts index 9edd2ec..1e7547e 100644 --- a/src/adapters/load-test-env.ts +++ b/src/adapters/load-test-env.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { envPath, leasePath } from "../core/lease.ts"; -import { resolveRoot } from "../core/worktree.ts"; +import { loadModeEnv, normalizeLoadOptions, type LoadModeEnvOptions } from "./load-mode-env.ts"; + +export type LoadTestEnvOptions = LoadModeEnvOptions; /** * Load `.cedarpg/test.env` into `process.env` (worker-side). @@ -11,22 +11,6 @@ import { resolveRoot } from "../core/worktree.ts"; * No-ops unless a matching `test.json` lease exists, so a leftover env after dispose * cannot inject a dropped DATABASE_URL. */ -export function loadTestEnv(root?: string): void { - const resolved = resolveRoot(root); - if (!existsSync(leasePath(resolved, "test"))) return; - - const file = envPath(resolved, "test"); - if (!existsSync(file)) return; - - for (const line of readFileSync(file, "utf8").split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq <= 0) continue; - const key = trimmed.slice(0, eq); - const value = trimmed.slice(eq + 1); - if (process.env[key] === undefined) { - process.env[key] = value; - } - } +export function loadTestEnv(rootOrOptions?: string | LoadTestEnvOptions): void { + loadModeEnv("test", normalizeLoadOptions(rootOrOptions)); } diff --git a/src/adapters/nx.ts b/src/adapters/nx.ts index 06719e9..acfcef6 100644 --- a/src/adapters/nx.ts +++ b/src/adapters/nx.ts @@ -1,31 +1,60 @@ /** - * Nx target command hints. Wire into project.json / package.json: + * Nx targets. `dependsOn` does not forward ensure env (unlike Vite+ `env: [...]`); + * wrap children with `cedarpg run`, or set `envFile` to `.cedarpg/.env`. * * ```json * { * "targets": { + * "dev": { + * "command": "cedarpg run --mode=dev -- yarn tsx scripts/apiServer/dev.ts" + * }, * "db:ensure": { "command": "cedarpg ensure --mode=dev" }, - * "test": { - * "dependsOn": ["db:ensure-test"], - * "command": "vitest run" + * "serve": { + * "dependsOn": ["db:ensure"], + * "command": "node dist/server.js", + * "options": { "envFile": ".cedarpg/dev.env" } * } * } * } * ``` - * - * Prefer the CLI (`cedarpg` from `@cedarjs/pg`) for ensure/dispose; - * there is no separate Nx runtime wrapper. */ -import { CLI_NAME } from "../core/constants.ts"; -import { cedarPgCommands } from "./tasks.ts"; +import { CLI_NAME, STATE_DIRNAME } from "../core/constants.ts"; +import { envFilePath } from "../core/lease.ts"; +import type { DbMode } from "../core/naming.ts"; +import { + cedarPgLifecycleTargets, + cedarPgRunCommand, + type CedarPgLifecycleTarget, + type CedarPgLifecycleTargetsOptions, + CEDAR_PG_TASK_DISPOSE_TEST, + CEDAR_PG_TASK_ENSURE_DEV, + CEDAR_PG_TASK_ENSURE_TEST, +} from "./tasks.ts"; + +export { + CEDAR_PG_TASK_ENSURE_DEV as CEDAR_PG_NX_ENSURE_DEV, + CEDAR_PG_TASK_ENSURE_TEST as CEDAR_PG_NX_ENSURE_TEST, + CEDAR_PG_TASK_DISPOSE_TEST as CEDAR_PG_NX_DISPOSE_TEST, + cedarPgLifecycleTargets as cedarPgNxTargets, + cedarPgRunCommand, + envFilePath, +}; + +/** Relative path for Nx `envFile` / dotenv. */ +export function relativeEnvFile(mode: DbMode): string { + return `${STATE_DIRNAME}/${mode}.env`; +} + +export type NxTargetHint = CedarPgLifecycleTarget; +export type CedarPgNxTargetsOptions = CedarPgLifecycleTargetsOptions; -/** Suggested target definitions for project.json / package.json nx targets. */ +/** @deprecated Prefer `cedarPgNxTargets()`. */ export function nxTargetHints(bin = CLI_NAME): Record { - const cmds = cedarPgCommands(bin); - return { - "db:ensure": { command: cmds.ensureDev }, - "db:ensure-test": { command: cmds.ensureTest }, - "db:dispose-test": { command: cmds.disposeTest }, - }; + return Object.fromEntries( + Object.entries(cedarPgLifecycleTargets({ bin })).map(([name, def]) => [ + name, + { command: def.command }, + ]), + ); } diff --git a/src/adapters/tasks.ts b/src/adapters/tasks.ts index cb994cd..a559c5a 100644 --- a/src/adapters/tasks.ts +++ b/src/adapters/tasks.ts @@ -1,8 +1,11 @@ -/** - * Shared CLI command strings for Vite+ / Nx task wiring. - */ +/** Shared CLI command strings and lifecycle target defs for Vite+ / Nx. */ import { CLI_NAME } from "../core/constants.ts"; +import type { DbMode } from "../core/naming.ts"; + +export const CEDAR_PG_TASK_ENSURE_DEV = "db:ensure"; +export const CEDAR_PG_TASK_ENSURE_TEST = "db:ensure-test"; +export const CEDAR_PG_TASK_DISPOSE_TEST = "db:dispose-test"; export type CedarPgCommands = { ensureDev: string; @@ -10,6 +13,17 @@ export type CedarPgCommands = { disposeTest: string; }; +export type CedarPgLifecycleTarget = { + command: string; + cache?: boolean; +}; + +export type CedarPgLifecycleTargetsOptions = { + bin?: string; + /** Include dispose-test target (default: true). */ + includeDisposeTest?: boolean; +}; + export function cedarPgCommands(bin = CLI_NAME): CedarPgCommands { return { ensureDev: `${bin} ensure --mode=dev`, @@ -17,3 +31,21 @@ export function cedarPgCommands(bin = CLI_NAME): CedarPgCommands { disposeTest: `${bin} dispose --mode=test`, }; } + +export function cedarPgLifecycleTargets( + options: CedarPgLifecycleTargetsOptions = {}, +): Record { + const cmds = cedarPgCommands(options.bin ?? CLI_NAME); + const targets: Record = { + [CEDAR_PG_TASK_ENSURE_DEV]: { command: cmds.ensureDev, cache: false }, + [CEDAR_PG_TASK_ENSURE_TEST]: { command: cmds.ensureTest, cache: false }, + }; + if (options.includeDisposeTest !== false) { + targets[CEDAR_PG_TASK_DISPOSE_TEST] = { command: cmds.disposeTest, cache: false }; + } + return targets; +} + +export function cedarPgRunCommand(mode: DbMode, command: string, bin = CLI_NAME): string { + return `${bin} run --mode=${mode} -- ${command}`; +} diff --git a/src/adapters/vite-plus.ts b/src/adapters/vite-plus.ts index 8396393..af1f1a5 100644 --- a/src/adapters/vite-plus.ts +++ b/src/adapters/vite-plus.ts @@ -29,45 +29,21 @@ * ``` */ -import { CLI_NAME } from "../core/constants.ts"; -import { cedarPgCommands } from "./tasks.ts"; +import { + cedarPgLifecycleTargets, + type CedarPgLifecycleTarget, + type CedarPgLifecycleTargetsOptions, + CEDAR_PG_TASK_DISPOSE_TEST, + CEDAR_PG_TASK_ENSURE_DEV, + CEDAR_PG_TASK_ENSURE_TEST, +} from "./tasks.ts"; -export const CEDAR_PG_TASK_ENSURE_DEV = "db:ensure"; -export const CEDAR_PG_TASK_ENSURE_TEST = "db:ensure-test"; -export const CEDAR_PG_TASK_DISPOSE_TEST = "db:dispose-test"; - -export type CedarPgTaskDef = { - command: string; - cache?: boolean; -}; - -export type CedarPgTasksOptions = { - /** Binary name / path (default: CLI_NAME / `cedarpg`). */ - bin?: string; - /** Include dispose-test task (default: true). */ - includeDisposeTest?: boolean; +export { + CEDAR_PG_TASK_ENSURE_DEV, + CEDAR_PG_TASK_ENSURE_TEST, + CEDAR_PG_TASK_DISPOSE_TEST, + cedarPgLifecycleTargets as cedarPgTasks, }; -/** - * Returns Vite+ `run.tasks` entries for cedar-pg lifecycle. - */ -export function cedarPgTasks(options: CedarPgTasksOptions = {}): Record { - const cmds = cedarPgCommands(options.bin ?? CLI_NAME); - const tasks: Record = { - [CEDAR_PG_TASK_ENSURE_DEV]: { - command: cmds.ensureDev, - cache: false, - }, - [CEDAR_PG_TASK_ENSURE_TEST]: { - command: cmds.ensureTest, - cache: false, - }, - }; - if (options.includeDisposeTest !== false) { - tasks[CEDAR_PG_TASK_DISPOSE_TEST] = { - command: cmds.disposeTest, - cache: false, - }; - } - return tasks; -} +export type CedarPgTaskDef = CedarPgLifecycleTarget; +export type CedarPgTasksOptions = CedarPgLifecycleTargetsOptions; diff --git a/src/cli.ts b/src/cli.ts index b9adfef..f60ecdb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { spawn } from "node:child_process"; import { CLI_NAME } from "./core/constants.ts"; import { ensure, dispose, gc, urlFromLease } from "./core/lifecycle.ts"; import { resolveWorktreeIdentity } from "./core/worktree.ts"; @@ -9,7 +10,8 @@ function printHelp(): void { process.stdout.write(`${CLI_NAME}: worktree-isolated local Postgres (via autopg) Usage: - ${CLI_NAME} ensure --mode=dev|test [--root ] [--json] [--print-env] + ${CLI_NAME} ensure --mode=dev|test [--root ] [--force] [--json] [--print-env] + ${CLI_NAME} run --mode=dev|test [--root ] [--force] -- ${CLI_NAME} dispose [--mode=dev|test] [--root ] ${CLI_NAME} gc [--json] ${CLI_NAME} print-url [--mode=dev|test] [--root ] @@ -19,9 +21,14 @@ Modes: dev Keep DB across restarts (default for ensure if omitted: dev) test Drop DB on dispose / test teardown +run: + Ensure, set DATABASE_URL (+ TEST_DATABASE_URL in test) on the child, exec . + --force sets CEDAR_PG_FORCE (escape hatch); child env overwrite is always on. + Env: - AUTOPG_BIN Path to autopg binary - CEDAR_PG=0 Disable adapters that auto-ensure + AUTOPG_BIN Path to autopg binary + CEDAR_PG=0 Disable adapters that auto-ensure + CEDAR_PG_FORCE=1 Ignore external-URL escape hatch (same as --force) `); } @@ -30,31 +37,60 @@ function parseMode(value: string | undefined): DbMode { throw new Error("--mode must be dev or test"); } -function parseArgs(argv: string[]) { - const out: { - cmd?: string; - mode?: DbMode; - root?: string; - json?: boolean; - printEnv?: boolean; - help?: boolean; - } = {}; +type ParsedArgs = { + cmd?: string; + mode?: DbMode; + root?: string; + json?: boolean; + printEnv?: boolean; + force?: boolean; + help?: boolean; + child?: string[]; +}; + +function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = {}; const rest = [...argv]; out.cmd = rest.shift(); - for (let i = 0; i < rest.length; i++) { - const a = rest[i]!; + + const dash = rest.indexOf("--"); + let flagArgs = rest; + if (dash >= 0) { + flagArgs = rest.slice(0, dash); + out.child = rest.slice(dash + 1); + } + + for (let i = 0; i < flagArgs.length; i++) { + const a = flagArgs[i]!; if (a === "--help" || a === "-h") out.help = true; else if (a === "--json") out.json = true; else if (a === "--print-env") out.printEnv = true; + else if (a === "--force") out.force = true; else if (a.startsWith("--mode=")) out.mode = parseMode(a.slice(7)); - else if (a === "--mode") out.mode = parseMode(rest[++i]); + else if (a === "--mode") out.mode = parseMode(flagArgs[++i]); else if (a.startsWith("--root=")) out.root = a.slice(7); - else if (a === "--root") out.root = rest[++i]; + else if (a === "--root") out.root = flagArgs[++i]; else throw new Error(`unknown argument: ${a}`); } return out; } +function runChild(command: string[], env: NodeJS.ProcessEnv): Promise { + const [file, ...args] = command; + if (!file) { + throw new Error( + `run requires a command after -- (e.g. ${CLI_NAME} run --mode=dev -- yarn dev)`, + ); + } + return new Promise((resolve, reject) => { + const child = spawn(file, args, { stdio: "inherit", env }); + child.on("error", reject); + child.on("exit", (code, signal) => { + resolve(signal ? 1 : (code ?? 1)); + }); + }); +} + async function main(): Promise { const argv = process.argv.slice(2); if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") { @@ -69,6 +105,7 @@ async function main(): Promise { try { if (args.cmd === "ensure") { + if (args.force) process.env.CEDAR_PG_FORCE = "1"; const mode = args.mode ?? "dev"; const result = await ensure({ root: args.root, @@ -109,6 +146,22 @@ async function main(): Promise { return 0; } + if (args.cmd === "run") { + if (args.force) process.env.CEDAR_PG_FORCE = "1"; + const mode = args.mode ?? "dev"; + const result = await ensure({ + root: args.root, + mode, + setEnv: true, + }); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: result.databaseUrl, + }; + if (mode === "test") childEnv.TEST_DATABASE_URL = result.databaseUrl; + return await runChild(args.child ?? [], childEnv); + } + if (args.cmd === "dispose") { const mode = args.mode ?? "test"; const result = await dispose({ root: args.root, mode }); diff --git a/src/core/lease.ts b/src/core/lease.ts index 63b064f..1ceb49d 100644 --- a/src/core/lease.ts +++ b/src/core/lease.ts @@ -11,6 +11,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { STATE_DIRNAME } from "./constants.ts"; import type { DbMode } from "./naming.ts"; +import { resolveRoot } from "./worktree.ts"; export type Lease = { schemaVersion: 1; @@ -76,6 +77,11 @@ export function envPath(root: string, mode: DbMode): string { return join(leaseDir(root), `${mode}.env`); } +/** Absolute `.cedarpg/.env` path (resolves root when omitted). */ +export function envFilePath(root: string | undefined, mode: DbMode): string { + return envPath(resolveRoot(root), mode); +} + /** Best-effort delete worktree lease JSON + mode env (missing is fine). */ function unlinkWorktreeArtifacts(root: string, mode: DbMode): void { for (const file of [leasePath(root, mode), envPath(root, mode)]) { diff --git a/src/index.ts b/src/index.ts index 3dd8688..68616c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,11 +26,19 @@ export type { DbMode } from "./core/naming.ts"; /** Worktree state dir name (`.cedarpg`). Prefer this over hardcoding in framework hosts. */ export { STATE_DIRNAME, CLI_NAME } from "./core/constants.ts"; -/** Worker-side loader for `.cedarpg/test.env` (also shipped as `@cedarjs/pg/test-env`). */ +/** Worker-side loader for `.cedarpg/test.env` (also `@cedarjs/pg/test-env`). */ export { loadTestEnv } from "./adapters/load-test-env.ts"; +export type { LoadTestEnvOptions } from "./adapters/load-test-env.ts"; + +/** Dev loader for `.cedarpg/dev.env` (also `@cedarjs/pg/dev-env`). */ +export { loadDevEnv } from "./adapters/load-dev-env.ts"; +export type { LoadDevEnvOptions } from "./adapters/load-dev-env.ts"; + +export { createEnsureTask } from "./adapters/ensure-task.ts"; +export type { CreateEnsureTaskOptions, EnsureTaskContext } from "./adapters/ensure-task.ts"; /** Read-only lease inspection (mutate/forget APIs are internal; drop-then-forget only). */ -export { parseLease, readLease, isOrphanLease } from "./core/lease.ts"; +export { parseLease, readLease, isOrphanLease, envFilePath } from "./core/lease.ts"; export type { Lease } from "./core/lease.ts"; export { diff --git a/tests/ensure-task.test.ts b/tests/ensure-task.test.ts new file mode 100644 index 0000000..8b92498 --- /dev/null +++ b/tests/ensure-task.test.ts @@ -0,0 +1,72 @@ +import { expect, test, vi } from "vite-plus/test"; +import type { EnsureIfNeededResult } from "../src/core/lifecycle.ts"; + +function ensuredLease( + overrides: Partial> = {}, +): Extract { + return { + status: "ensured", + databaseUrl: "postgresql://u:p@127.0.0.1:1/db", + adminUrl: "postgresql://postgres:postgres@127.0.0.1:1/postgres", + databaseName: "db", + roleName: "db_role", + repoSlug: "r", + worktreeSlug: "w", + pathHash: "abcd1234", + root: "/tmp/x", + mode: "dev", + port: 1, + dispose: async () => {}, + ...overrides, + }; +} + +async function withMockedEnsure( + ensureIfNeeded: ReturnType, + run: () => Promise, +): Promise { + vi.resetModules(); + vi.doMock("../src/core/lifecycle.ts", async () => { + const actual = await vi.importActual( + "../src/core/lifecycle.ts", + ); + return { ...actual, ensureIfNeeded }; + }); + try { + return await run(); + } finally { + vi.doUnmock("../src/core/lifecycle.ts"); + vi.resetModules(); + } +} + +test("createEnsureTask runs afterEnsure only when ensure succeeds", async () => { + const afterEnsure = vi.fn(); + const ensureIfNeeded = vi.fn(async () => ensuredLease()); + await withMockedEnsure(ensureIfNeeded, async () => { + const { createEnsureTask } = await import("../src/adapters/ensure-task.ts"); + await createEnsureTask({ mode: "dev", afterEnsure })(); + }); + expect(ensureIfNeeded).toHaveBeenCalledWith( + expect.objectContaining({ mode: "dev", setEnv: true }), + ); + expect(afterEnsure).toHaveBeenCalledWith( + expect.objectContaining({ + databaseUrl: "postgresql://u:p@127.0.0.1:1/db", + mode: "dev", + }), + ); +}); + +test("createEnsureTask skips afterEnsure when ensure is skipped", async () => { + const afterEnsure = vi.fn(); + const ensureIfNeeded = vi.fn(async () => ({ + status: "skipped" as const, + reason: "disabled" as const, + })); + await withMockedEnsure(ensureIfNeeded, async () => { + const { createEnsureTask } = await import("../src/adapters/ensure-task.ts"); + await createEnsureTask({ mode: "test", afterEnsure })(); + }); + expect(afterEnsure).not.toHaveBeenCalled(); +}); diff --git a/tests/load-dev-env.test.ts b/tests/load-dev-env.test.ts new file mode 100644 index 0000000..4cd6b69 --- /dev/null +++ b/tests/load-dev-env.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vite-plus/test"; +import { loadDevEnv } from "../src/adapters/load-dev-env.ts"; +import { loadTestEnv } from "../src/adapters/load-test-env.ts"; +import { STATE_DIRNAME } from "../src/core/constants.ts"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + delete process.env.DATABASE_URL; + delete process.env.TEST_DATABASE_URL; +}); + +function writeLiveArtifacts(root: string, mode: "dev" | "test", envBody: string): void { + const dir = join(root, STATE_DIRNAME); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync( + join(dir, `${mode}.json`), + JSON.stringify({ + schemaVersion: 1, + mode, + root, + repoSlug: "cedar", + worktreeSlug: "main", + pathHash: "abcd1234", + databaseName: `cpg_cedar_main_${mode}_abcd1234`, + roleName: `cpg_cedar_main_${mode}_abcd1234_role`, + port: 5432, + pid: 1, + createdAt: "2026-01-01T00:00:00.000Z", + }), + { mode: 0o600 }, + ); + writeFileSync(join(dir, `${mode}.env`), envBody, { mode: 0o600 }); +} + +test("loadDevEnv injects DATABASE_URL without overriding by default", () => { + const root = mkdtempSync(join(tmpdir(), "cedar-pg-dev-env-")); + dirs.push(root); + writeLiveArtifacts(root, "dev", "DATABASE_URL=postgresql://u:p@127.0.0.1:1/cpg_dev\n"); + + process.env.DATABASE_URL = "keep-me"; + loadDevEnv(root); + + expect(process.env.DATABASE_URL).toBe("keep-me"); +}); + +test("loadDevEnv({ overwrite: true }) overrides existing DATABASE_URL", () => { + const root = mkdtempSync(join(tmpdir(), "cedar-pg-dev-overwrite-")); + dirs.push(root); + writeLiveArtifacts(root, "dev", "DATABASE_URL=postgresql://u:p@127.0.0.1:1/cpg_dev\n"); + + process.env.DATABASE_URL = "from-dotenv"; + loadDevEnv({ root, overwrite: true }); + + expect(process.env.DATABASE_URL).toBe("postgresql://u:p@127.0.0.1:1/cpg_dev"); +}); + +test("loadTestEnv({ overwrite: true }) overrides existing TEST_DATABASE_URL", () => { + const root = mkdtempSync(join(tmpdir(), "cedar-pg-test-overwrite-")); + dirs.push(root); + writeLiveArtifacts( + root, + "test", + "DATABASE_URL=postgresql://u:p@127.0.0.1:1/cpg_x\nTEST_DATABASE_URL=postgresql://u:p@127.0.0.1:1/cpg_x\n", + ); + + process.env.DATABASE_URL = "keep"; + process.env.TEST_DATABASE_URL = "from-dotenv"; + loadTestEnv({ root, overwrite: true }); + + expect(process.env.DATABASE_URL).toBe("postgresql://u:p@127.0.0.1:1/cpg_x"); + expect(process.env.TEST_DATABASE_URL).toBe("postgresql://u:p@127.0.0.1:1/cpg_x"); +}); + +test("loadDevEnv no-ops without a matching lease", () => { + const root = mkdtempSync(join(tmpdir(), "cedar-pg-dev-stale-")); + dirs.push(root); + const dir = join(root, STATE_DIRNAME); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(join(dir, "dev.env"), "DATABASE_URL=postgresql://stale\n", { mode: 0o600 }); + + loadDevEnv({ root, overwrite: true }); + expect(process.env.DATABASE_URL).toBeUndefined(); +}); diff --git a/tests/nx.test.ts b/tests/nx.test.ts new file mode 100644 index 0000000..e68ac3a --- /dev/null +++ b/tests/nx.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vite-plus/test"; +import { + cedarPgNxTargets, + cedarPgRunCommand, + CEDAR_PG_NX_ENSURE_DEV, + CEDAR_PG_NX_ENSURE_TEST, + envFilePath, + nxTargetHints, + relativeEnvFile, +} from "../src/adapters/nx.ts"; +import { STATE_DIRNAME } from "../src/core/constants.ts"; + +test("cedarPgNxTargets exposes ensure/dispose commands", () => { + const targets = cedarPgNxTargets(); + expect(targets[CEDAR_PG_NX_ENSURE_DEV]?.command).toContain("ensure --mode=dev"); + expect(targets[CEDAR_PG_NX_ENSURE_TEST]?.command).toContain("ensure --mode=test"); + expect(targets[CEDAR_PG_NX_ENSURE_DEV]?.cache).toBe(false); +}); + +test("nxTargetHints stays compatible with cedarPgNxTargets commands", () => { + const hints = nxTargetHints("./bin/cedarpg"); + expect(hints["db:ensure"]?.command).toBe("./bin/cedarpg ensure --mode=dev"); +}); + +test("cedarPgRunCommand wraps child with cedarpg run", () => { + expect(cedarPgRunCommand("dev", "yarn tsx scripts/dev.ts")).toBe( + "cedarpg run --mode=dev -- yarn tsx scripts/dev.ts", + ); + expect(cedarPgRunCommand("test", "vitest run", "cedarpg")).toBe( + "cedarpg run --mode=test -- vitest run", + ); +}); + +test("relativeEnvFile and envFilePath point at .cedarpg/.env", () => { + expect(relativeEnvFile("dev")).toBe(`${STATE_DIRNAME}/dev.env`); + expect(envFilePath("/tmp/worktree", "test")).toBe(`/tmp/worktree/${STATE_DIRNAME}/test.env`); +}); diff --git a/vite.config.ts b/vite.config.ts index d4cd05d..f92fc7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ jest: "src/adapters/jest.ts", "jest-teardown": "src/adapters/jest-teardown.ts", "test-env": "src/adapters/test-env.ts", + "dev-env": "src/adapters/dev-env.ts", "jest-template": "src/adapters/jest-template.ts", },