fix(tooling): harden database env loading
This commit is contained in:
@@ -31,6 +31,10 @@ npm test
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database commands
|
||||||
|
Use the repo-level `pnpm db:*` commands or `pnpm db:prisma -- <args>` 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
|
## Agent Coordination
|
||||||
|
|
||||||
### Swarm Configuration
|
### Swarm Configuration
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ fuser -k 3100/tcp 2>/dev/null
|
|||||||
PORT=3100 pnpm --filter @capakraken/web start &
|
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
|
### nginx configuration
|
||||||
|
|
||||||
The existing nginx reverse proxy should forward to port 3100:
|
The existing nginx reverse proxy should forward to port 3100:
|
||||||
|
|||||||
@@ -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 }), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -7,10 +7,34 @@ function resolveWorkspaceRoot() {
|
|||||||
return resolve(currentDir, "../../../");
|
return resolve(currentDir, "../../../");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadWorkspaceEnv() {
|
export function resolveWorkspaceEnvPaths(options: { workspaceRoot?: string; nodeEnv?: string | undefined } = {}) {
|
||||||
const envPath = resolve(resolveWorkspaceRoot(), ".env");
|
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");
|
const contents = readFileSync(envPath, "utf8");
|
||||||
|
|
||||||
for (const rawLine of contents.split(/\r?\n/u)) {
|
for (const rawLine of contents.split(/\r?\n/u)) {
|
||||||
@@ -31,13 +55,15 @@ export function loadWorkspaceEnv() {
|
|||||||
(rawValue.startsWith("'") && rawValue.endsWith("'"));
|
(rawValue.startsWith("'") && rawValue.endsWith("'"));
|
||||||
const value = quoted ? rawValue.slice(1, -1) : rawValue;
|
const value = quoted ? rawValue.slice(1, -1) : rawValue;
|
||||||
|
|
||||||
if (process.env[key] == null) {
|
if (!originalKeys.has(key)) {
|
||||||
process.env[key] = value;
|
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[]) {
|
export function resolveWorkspacePath(...segments: string[]) {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { URL } from "node:url";
|
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 expectedDatabase = process.argv[2] ?? "capakraken";
|
||||||
const rawUrl = process.env.DATABASE_URL;
|
const rawUrl = process.env.DATABASE_URL;
|
||||||
|
const expectedEnvSources = loadedEnvPaths.length > 0
|
||||||
|
? loadedEnvPaths.join(", ")
|
||||||
|
: resolveWorkspaceEnvPaths().join(", ");
|
||||||
|
|
||||||
if (!rawUrl) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-25
@@ -6,40 +6,64 @@ function resolveWorkspaceRoot() {
|
|||||||
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWorkspaceEnvPath() {
|
export function resolveWorkspaceEnvPath(options = {}) {
|
||||||
return resolve(resolveWorkspaceRoot(), ".env");
|
const { workspaceRoot = resolveWorkspaceRoot() } = options;
|
||||||
|
return resolve(workspaceRoot, ".env");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadWorkspaceEnv() {
|
export function resolveWorkspaceEnvPaths(options = {}) {
|
||||||
const envPath = resolveWorkspaceEnvPath();
|
const { workspaceRoot = resolveWorkspaceRoot(), nodeEnv = process.env.NODE_ENV } = options;
|
||||||
if (!existsSync(envPath)) {
|
const candidates = [".env"];
|
||||||
return envPath;
|
|
||||||
|
if (nodeEnv) {
|
||||||
|
candidates.push(`.env.${nodeEnv}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contents = readFileSync(envPath, "utf8");
|
candidates.push(".env.local");
|
||||||
for (const rawLine of contents.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
if (nodeEnv) {
|
||||||
if (!line || line.startsWith("#")) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const separatorIndex = line.indexOf("=");
|
const contents = readFileSync(envPath, "utf8");
|
||||||
if (separatorIndex <= 0) {
|
for (const rawLine of contents.split(/\r?\n/u)) {
|
||||||
continue;
|
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();
|
loadedPaths.push(envPath);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return envPath;
|
return loadedPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user