diff --git a/AGENTS.md b/AGENTS.md index 252bd18..e90302f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,10 @@ npm test npm run dev ``` +### Database commands +Use the repo-level `pnpm db:*` commands or `pnpm db:prisma -- ` for Prisma work. +These wrappers auto-load `.env`, `.env.local`, `.env.$NODE_ENV`, and `.env.$NODE_ENV.local`, so direct raw Prisma commands should be avoided unless `DATABASE_URL` is already exported in the shell. + ## Agent Coordination ### Swarm Configuration diff --git a/docs/ci-cd-manual.md b/docs/ci-cd-manual.md index 2665891..8cd410e 100644 --- a/docs/ci-cd-manual.md +++ b/docs/ci-cd-manual.md @@ -201,6 +201,8 @@ fuser -k 3100/tcp 2>/dev/null PORT=3100 pnpm --filter @capakraken/web start & ``` +Use the repo-level `pnpm db:*` commands for Prisma/database operations. They load `.env`, `.env.local`, `.env.$NODE_ENV`, and `.env.$NODE_ENV.local` automatically before invoking Prisma. + ### nginx configuration The existing nginx reverse proxy should forward to port 3100: diff --git a/packages/db/src/load-workspace-env.test.ts b/packages/db/src/load-workspace-env.test.ts new file mode 100644 index 0000000..63d1615 --- /dev/null +++ b/packages/db/src/load-workspace-env.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadWorkspaceEnv, resolveWorkspaceEnvPaths } from "./load-workspace-env.js"; + +const envKeys = [ + "DATABASE_URL", + "SHARED_VALUE", + "LOCAL_ONLY", + "MODE_ONLY", + "MODE_LOCAL_ONLY", +]; + +function clearEnv() { + for (const key of envKeys) { + delete process.env[key]; + } +} + +function withTempWorkspace(run: (workspaceRoot: string) => void) { + const workspaceRoot = mkdtempSync(join(tmpdir(), "capakraken-env-")); + + try { + run(workspaceRoot); + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }); + } +} + +afterEach(() => { + clearEnv(); + delete process.env.NODE_ENV; +}); + +describe("loadWorkspaceEnv", () => { + it("loads standard workspace env files in precedence order", () => { + withTempWorkspace((workspaceRoot) => { + writeFileSync(join(workspaceRoot, ".env"), "DATABASE_URL=postgres://from-env\nSHARED_VALUE=base\n"); + writeFileSync(join(workspaceRoot, ".env.development"), "SHARED_VALUE=mode\nMODE_ONLY=development\n"); + writeFileSync(join(workspaceRoot, ".env.local"), "SHARED_VALUE=local\nLOCAL_ONLY=1\n"); + writeFileSync(join(workspaceRoot, ".env.development.local"), "SHARED_VALUE=mode-local\nMODE_LOCAL_ONLY=1\n"); + process.env.NODE_ENV = "development"; + + const loadedPaths = loadWorkspaceEnv({ workspaceRoot }); + + assert.deepEqual( + loadedPaths.map((entry) => entry.slice(workspaceRoot.length + 1)), + [".env", ".env.development", ".env.local", ".env.development.local"], + ); + assert.equal(process.env.DATABASE_URL, "postgres://from-env"); + assert.equal(process.env.SHARED_VALUE, "mode-local"); + assert.equal(process.env.LOCAL_ONLY, "1"); + assert.equal(process.env.MODE_ONLY, "development"); + assert.equal(process.env.MODE_LOCAL_ONLY, "1"); + }); + }); + + it("does not override variables that already exist in the shell environment", () => { + withTempWorkspace((workspaceRoot) => { + writeFileSync(join(workspaceRoot, ".env"), "DATABASE_URL=postgres://from-env\n"); + writeFileSync(join(workspaceRoot, ".env.local"), "DATABASE_URL=postgres://from-local\n"); + process.env.DATABASE_URL = "postgres://from-shell"; + + loadWorkspaceEnv({ workspaceRoot }); + + assert.equal(process.env.DATABASE_URL, "postgres://from-shell"); + }); + }); + + it("returns the candidate env paths even when files are missing", () => { + withTempWorkspace((workspaceRoot) => { + process.env.NODE_ENV = "test"; + + const envPaths = resolveWorkspaceEnvPaths({ workspaceRoot }); + + assert.deepEqual( + envPaths.map((entry) => entry.slice(workspaceRoot.length + 1)), + [".env", ".env.test", ".env.local", ".env.test.local"], + ); + assert.deepEqual(loadWorkspaceEnv({ workspaceRoot }), []); + }); + }); +}); diff --git a/packages/db/src/load-workspace-env.ts b/packages/db/src/load-workspace-env.ts index 1a26c1e..bcfc549 100644 --- a/packages/db/src/load-workspace-env.ts +++ b/packages/db/src/load-workspace-env.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,10 +7,34 @@ function resolveWorkspaceRoot() { return resolve(currentDir, "../../../"); } -export function loadWorkspaceEnv() { - const envPath = resolve(resolveWorkspaceRoot(), ".env"); +export function resolveWorkspaceEnvPaths(options: { workspaceRoot?: string; nodeEnv?: string | undefined } = {}) { + const workspaceRoot = options.workspaceRoot ?? resolveWorkspaceRoot(); + const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV; + const candidates = [".env"]; + + if (nodeEnv) { + candidates.push(`.env.${nodeEnv}`); + } + + candidates.push(".env.local"); + + if (nodeEnv) { + candidates.push(`.env.${nodeEnv}.local`); + } + + return [...new Set(candidates.map((candidate) => resolve(workspaceRoot, candidate)))]; +} + +export function loadWorkspaceEnv(options: { workspaceRoot?: string; nodeEnv?: string | undefined } = {}) { + const envPaths = resolveWorkspaceEnvPaths(options); + const originalKeys = new Set(Object.keys(process.env)); + const loadedPaths: string[] = []; + + for (const envPath of envPaths) { + if (!existsSync(envPath)) { + continue; + } - try { const contents = readFileSync(envPath, "utf8"); for (const rawLine of contents.split(/\r?\n/u)) { @@ -31,13 +55,15 @@ export function loadWorkspaceEnv() { (rawValue.startsWith("'") && rawValue.endsWith("'")); const value = quoted ? rawValue.slice(1, -1) : rawValue; - if (process.env[key] == null) { + if (!originalKeys.has(key)) { process.env[key] = value; } } - } catch { - // Leave environment untouched when .env is not present in the workspace. + + loadedPaths.push(envPath); } + + return loadedPaths; } export function resolveWorkspacePath(...segments: string[]) { diff --git a/scripts/db-doctor.mjs b/scripts/db-doctor.mjs index d6febc3..625edbb 100644 --- a/scripts/db-doctor.mjs +++ b/scripts/db-doctor.mjs @@ -1,14 +1,17 @@ #!/usr/bin/env node import { URL } from "node:url"; -import { loadWorkspaceEnv, resolveWorkspaceEnvPath } from "./load-env.mjs"; +import { loadWorkspaceEnv, resolveWorkspaceEnvPaths } from "./load-env.mjs"; -const envPath = loadWorkspaceEnv(); +const loadedEnvPaths = loadWorkspaceEnv(); const expectedDatabase = process.argv[2] ?? "capakraken"; const rawUrl = process.env.DATABASE_URL; +const expectedEnvSources = loadedEnvPaths.length > 0 + ? loadedEnvPaths.join(", ") + : resolveWorkspaceEnvPaths().join(", "); if (!rawUrl) { - console.error(`DATABASE_URL is not configured. Expected it from ${envPath ?? resolveWorkspaceEnvPath()}.`); + console.error(`DATABASE_URL is not configured. Expected it from one of: ${expectedEnvSources}.`); process.exit(1); } diff --git a/scripts/load-env.mjs b/scripts/load-env.mjs index 49cf052..eff4693 100644 --- a/scripts/load-env.mjs +++ b/scripts/load-env.mjs @@ -6,40 +6,64 @@ function resolveWorkspaceRoot() { return resolve(dirname(fileURLToPath(import.meta.url)), ".."); } -export function resolveWorkspaceEnvPath() { - return resolve(resolveWorkspaceRoot(), ".env"); +export function resolveWorkspaceEnvPath(options = {}) { + const { workspaceRoot = resolveWorkspaceRoot() } = options; + return resolve(workspaceRoot, ".env"); } -export function loadWorkspaceEnv() { - const envPath = resolveWorkspaceEnvPath(); - if (!existsSync(envPath)) { - return envPath; +export function resolveWorkspaceEnvPaths(options = {}) { + const { workspaceRoot = resolveWorkspaceRoot(), nodeEnv = process.env.NODE_ENV } = options; + const candidates = [".env"]; + + if (nodeEnv) { + candidates.push(`.env.${nodeEnv}`); } - const contents = readFileSync(envPath, "utf8"); - for (const rawLine of contents.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { + candidates.push(".env.local"); + + if (nodeEnv) { + candidates.push(`.env.${nodeEnv}.local`); + } + + return [...new Set(candidates.map((candidate) => resolve(workspaceRoot, candidate)))]; +} + +export function loadWorkspaceEnv(options = {}) { + const envPaths = resolveWorkspaceEnvPaths(options); + const originalKeys = new Set(Object.keys(process.env)); + const loadedPaths = []; + + for (const envPath of envPaths) { + if (!existsSync(envPath)) { continue; } - const separatorIndex = line.indexOf("="); - if (separatorIndex <= 0) { - continue; + const contents = readFileSync(envPath, "utf8"); + for (const rawLine of contents.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 1).trim(); + const quoted = + (rawValue.startsWith("\"") && rawValue.endsWith("\"")) + || (rawValue.startsWith("'") && rawValue.endsWith("'")); + const value = quoted ? rawValue.slice(1, -1) : rawValue; + + if (!originalKeys.has(key)) { + process.env[key] = value; + } } - const key = line.slice(0, separatorIndex).trim(); - const rawValue = line.slice(separatorIndex + 1).trim(); - const quoted = - (rawValue.startsWith("\"") && rawValue.endsWith("\"")) - || (rawValue.startsWith("'") && rawValue.endsWith("'")); - const value = quoted ? rawValue.slice(1, -1) : rawValue; - - if (process.env[key] == null) { - process.env[key] = value; - } + loadedPaths.push(envPath); } - return envPath; + return loadedPaths; } -