chore(db): harden workspace env wrappers

This commit is contained in:
2026-03-31 22:47:07 +02:00
parent 5097ceab7e
commit 3e8b1702bc
6 changed files with 72 additions and 14 deletions
+6 -6
View File
@@ -9,10 +9,10 @@
}, },
"scripts": { "scripts": {
"db:doctor": "node ../../scripts/db-doctor.mjs capakraken", "db:doctor": "node ../../scripts/db-doctor.mjs capakraken",
"db:push": "node ../../scripts/with-env.mjs prisma db push --schema ./prisma/schema.prisma", "db:push": "node ../../scripts/prisma-with-env.mjs db push --schema ./prisma/schema.prisma",
"db:migrate": "node ../../scripts/with-env.mjs prisma migrate dev --schema ./prisma/schema.prisma", "db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma",
"db:migrate:deploy": "node ../../scripts/with-env.mjs prisma migrate deploy --schema ./prisma/schema.prisma", "db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma",
"db:validate": "node ../../scripts/with-env.mjs prisma validate --schema ./prisma/schema.prisma", "db:validate": "node ../../scripts/prisma-with-env.mjs validate --schema ./prisma/schema.prisma",
"db:seed": "node ../../scripts/with-env.mjs tsx src/seed.ts", "db:seed": "node ../../scripts/with-env.mjs tsx src/seed.ts",
"db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx src/seed-holiday-demo-resources.ts", "db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx src/seed-holiday-demo-resources.ts",
"db:seed:holidays": "node ../../scripts/with-env.mjs tsx src/seed-holiday-calendars.ts", "db:seed:holidays": "node ../../scripts/with-env.mjs tsx src/seed-holiday-calendars.ts",
@@ -21,8 +21,8 @@
"db:reset:dispo": "node ../../scripts/with-env.mjs tsx src/reset-dispo-import.ts", "db:reset:dispo": "node ../../scripts/with-env.mjs tsx src/reset-dispo-import.ts",
"db:import:dispo": "node ../../scripts/with-env.mjs tsx src/import-dispo-batch.ts", "db:import:dispo": "node ../../scripts/with-env.mjs tsx src/import-dispo-batch.ts",
"db:excel": "node ../../scripts/with-env.mjs tsx src/generate-excel.ts", "db:excel": "node ../../scripts/with-env.mjs tsx src/generate-excel.ts",
"db:studio": "node ../../scripts/with-env.mjs prisma studio --schema ./prisma/schema.prisma", "db:studio": "node ../../scripts/prisma-with-env.mjs studio --schema ./prisma/schema.prisma",
"db:generate": "node ../../scripts/with-env.mjs prisma generate --schema ./prisma/schema.prisma", "db:generate": "node ../../scripts/prisma-with-env.mjs generate --schema ./prisma/schema.prisma",
"test:unit": "tsx --test src/*.test.ts", "test:unit": "tsx --test src/*.test.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
+33 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js"; import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
import { assertSafeSeedTarget } from "./safe-destructive-env.js"; import { assertCapaKrakenDbTarget, assertSafeSeedTarget } from "./safe-destructive-env.js";
const ORIGINAL_ENV = { ...process.env }; const ORIGINAL_ENV = { ...process.env };
@@ -98,3 +98,35 @@ test("assertSafeSeedTarget rejects unexpected legacy disposable databases", () =
/not in the destructive-tool allowlist/u, /not in the destructive-tool allowlist/u,
); );
}); });
test("assertCapaKrakenDbTarget accepts non-destructive capakraken targets", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_dev",
});
const target = assertCapaKrakenDbTarget("db:seed:holidays");
assert.equal(target.databaseName, "capakraken_dev");
});
test("assertCapaKrakenDbTarget rejects legacy non-capakraken targets", () => {
setEnv({
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_capakraken",
});
assert.throws(
() => assertCapaKrakenDbTarget("db:seed:holidays"),
/not a valid CapaKraken target/u,
);
});
test("assertCapaKrakenDbTarget explains missing env loading clearly", () => {
setEnv({
DATABASE_URL: undefined,
});
assert.throws(
() => assertCapaKrakenDbTarget("db:update:blueprints"),
/Run the command through the CapaKraken env wrappers/u,
);
});
+2 -2
View File
@@ -8,7 +8,7 @@ interface DestructiveGuardOptions {
const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]); const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]);
function parseDatabaseUrl(rawUrl: string) { export function parseDatabaseUrl(rawUrl: string) {
const parsed = new URL(rawUrl); const parsed = new URL(rawUrl);
const databaseName = parsed.pathname.replace(/^\/+/, ""); const databaseName = parsed.pathname.replace(/^\/+/, "");
@@ -21,7 +21,7 @@ function parseDatabaseUrl(rawUrl: string) {
}; };
} }
function formatTarget(target: ReturnType<typeof parseDatabaseUrl>) { export function formatTarget(target: ReturnType<typeof parseDatabaseUrl>) {
const port = target.port ? `:${target.port}` : ""; const port = target.port ? `:${target.port}` : "";
return `${target.protocol}//${target.username}@${target.hostname}${port}/${target.databaseName}`; return `${target.protocol}//${target.username}@${target.hostname}${port}/${target.databaseName}`;
} }
+21 -1
View File
@@ -1,4 +1,4 @@
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js"; import { assertDestructiveDbAllowed, formatTarget, parseDatabaseUrl } from "./destructive-db-guard.js";
const TEST_DATABASE_NAMES = [ const TEST_DATABASE_NAMES = [
"capakraken_test", "capakraken_test",
@@ -12,3 +12,23 @@ export function assertSafeSeedTarget(commandName: string) {
allowedDatabaseNames: TEST_DATABASE_NAMES, allowedDatabaseNames: TEST_DATABASE_NAMES,
}); });
} }
export function assertCapaKrakenDbTarget(commandName: string) {
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) {
throw new Error(
`${commandName} aborted: DATABASE_URL is not configured. Run the command through the CapaKraken env wrappers so the workspace env files are loaded.`,
);
}
const target = parseDatabaseUrl(rawUrl);
if (!target.databaseName.startsWith("capakraken")) {
throw new Error(
`${commandName} aborted: database '${target.databaseName}' is not a valid CapaKraken target. Target=${formatTarget(target)}`,
);
}
return target;
}
+6 -2
View File
@@ -1,11 +1,15 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync, realpathSync } 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";
function resolveWorkspaceRoot() { export function resolveWorkspaceRoot() {
return resolve(dirname(fileURLToPath(import.meta.url)), ".."); return resolve(dirname(fileURLToPath(import.meta.url)), "..");
} }
export function resolveRealWorkspaceRoot() {
return realpathSync(resolveWorkspaceRoot());
}
export function resolveWorkspaceEnvPath(options = {}) { export function resolveWorkspaceEnvPath(options = {}) {
const { workspaceRoot = resolveWorkspaceRoot() } = options; const { workspaceRoot = resolveWorkspaceRoot() } = options;
return resolve(workspaceRoot, ".env"); return resolve(workspaceRoot, ".env");
+4 -2
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { loadWorkspaceEnv } from "./load-env.mjs"; import { loadWorkspaceEnv, resolveRealWorkspaceRoot } from "./load-env.mjs";
loadWorkspaceEnv(); loadWorkspaceEnv();
const workspaceRoot = resolveRealWorkspaceRoot();
process.chdir(workspaceRoot);
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -15,6 +17,7 @@ if (args.length === 0) {
const result = spawnSync(args[0], args.slice(1), { const result = spawnSync(args[0], args.slice(1), {
stdio: "inherit", stdio: "inherit",
env: process.env, env: process.env,
cwd: workspaceRoot,
}); });
if (result.error) { if (result.error) {
@@ -23,4 +26,3 @@ if (result.error) {
} }
process.exit(result.status ?? 1); process.exit(result.status ?? 1);