@techstream/quark-create-app 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +10 -5
- package/templates/base-project/apps/web/next.config.js +4 -0
- package/templates/base-project/apps/web/package.json +6 -6
- package/templates/base-project/apps/web/railway.json +15 -0
- package/templates/base-project/apps/worker/package.json +3 -3
- package/templates/base-project/apps/worker/railway.json +13 -0
- package/templates/base-project/apps/worker/src/index.js +4 -0
- package/templates/base-project/packages/db/package.json +1 -1
- package/templates/base-project/packages/db/prisma.config.ts +5 -8
- package/templates/base-project/packages/db/src/client.js +1 -18
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/connection.test.js +119 -0
- package/templates/config/src/index.js +5 -2
- package/templates/config/src/load-config.js +1 -1
- package/templates/config/src/validate-env.js +63 -14
- package/templates/jobs/package.json +1 -1
- package/templates/ui/package.json +3 -3
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -484,11 +484,11 @@ program
|
|
|
484
484
|
# Generate strong passwords with: openssl rand -base64 32
|
|
485
485
|
POSTGRES_HOST=localhost
|
|
486
486
|
POSTGRES_PORT=5432
|
|
487
|
-
POSTGRES_USER
|
|
487
|
+
POSTGRES_USER=${scope}_user
|
|
488
488
|
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
489
489
|
POSTGRES_DB=${scope}_dev
|
|
490
490
|
# Optional: Set DATABASE_URL to override the dynamic construction above
|
|
491
|
-
# DATABASE_URL="postgresql
|
|
491
|
+
# DATABASE_URL="postgresql://${scope}_user:CHANGE_ME_TO_STRONG_PASSWORD@localhost:5432/${scope}_dev?schema=public"
|
|
492
492
|
|
|
493
493
|
# --- Redis Configuration ---
|
|
494
494
|
REDIS_HOST=localhost
|
|
@@ -501,8 +501,6 @@ REDIS_PORT=6379
|
|
|
501
501
|
MAIL_HOST=localhost
|
|
502
502
|
MAIL_SMTP_PORT=1025
|
|
503
503
|
MAIL_UI_PORT=8025
|
|
504
|
-
# Optional: Set MAIL_SMTP_URL to override the dynamic construction above
|
|
505
|
-
# MAIL_SMTP_URL="smtp://localhost:1025"
|
|
506
504
|
|
|
507
505
|
# Production SMTP: Set these instead of MAIL_* when using a real SMTP relay
|
|
508
506
|
# SMTP_HOST=smtp.example.com
|
|
@@ -525,6 +523,10 @@ MAIL_UI_PORT=8025
|
|
|
525
523
|
# In production, set this to your real domain:
|
|
526
524
|
# APP_URL=https://yourdomain.com
|
|
527
525
|
|
|
526
|
+
# --- Application Identity ---
|
|
527
|
+
# APP_NAME is used in metadata, emails, and page titles.
|
|
528
|
+
APP_NAME=${projectName}
|
|
529
|
+
|
|
528
530
|
# --- NextAuth Configuration ---
|
|
529
531
|
# ⚠️ CRITICAL: Generate a secure secret with: openssl rand -base64 32
|
|
530
532
|
# This secret is used to encrypt JWT tokens and session data
|
|
@@ -617,7 +619,7 @@ STORAGE_PROVIDER=local
|
|
|
617
619
|
const envContent = `# --- Database Configuration ---
|
|
618
620
|
POSTGRES_HOST=localhost
|
|
619
621
|
POSTGRES_PORT=${postgresPort}
|
|
620
|
-
POSTGRES_USER
|
|
622
|
+
POSTGRES_USER=${scope}_user
|
|
621
623
|
POSTGRES_PASSWORD=${dbPassword}
|
|
622
624
|
POSTGRES_DB=${scope}_dev
|
|
623
625
|
|
|
@@ -630,6 +632,9 @@ MAIL_HOST=localhost
|
|
|
630
632
|
MAIL_SMTP_PORT=${mailSmtpPort}
|
|
631
633
|
MAIL_UI_PORT=${mailUiPort}
|
|
632
634
|
|
|
635
|
+
# --- Application Identity ---
|
|
636
|
+
APP_NAME=${projectName}
|
|
637
|
+
|
|
633
638
|
# --- NextAuth Configuration ---
|
|
634
639
|
NEXTAUTH_SECRET=${nextAuthSecret}
|
|
635
640
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** @type {import('next').NextConfig} */
|
|
2
2
|
const nextConfig = {
|
|
3
|
+
// Required for Railway deployment — produces a self-contained build
|
|
4
|
+
// at .next/standalone that can run without node_modules.
|
|
5
|
+
output: "standalone",
|
|
6
|
+
|
|
3
7
|
// Support workspace package resolution (including @techstream/quark-db which uses
|
|
4
8
|
// the Prisma driver-adapter pattern — pure JS, no native engine binary)
|
|
5
9
|
transpilePackages: [
|
|
@@ -16,18 +16,18 @@
|
|
|
16
16
|
"@techstream/quark-db": "workspace:*",
|
|
17
17
|
"@techstream/quark-jobs": "workspace:*",
|
|
18
18
|
"@techstream/quark-ui": "workspace:*",
|
|
19
|
-
"@prisma/client": "^7.
|
|
19
|
+
"@prisma/client": "^7.4.0",
|
|
20
20
|
"next": "16.1.6",
|
|
21
21
|
"next-auth": "5.0.0-beta.30",
|
|
22
22
|
"pg": "^8.18.0",
|
|
23
|
-
"react": "19.2.
|
|
24
|
-
"react-dom": "19.2.
|
|
23
|
+
"react": "19.2.4",
|
|
24
|
+
"react-dom": "19.2.4",
|
|
25
25
|
"zod": "^4.3.6"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@tailwindcss/postcss": "^4.
|
|
29
|
-
"@types/node": "^
|
|
30
|
-
"tailwindcss": "^4.
|
|
28
|
+
"@tailwindcss/postcss": "^4.2.0",
|
|
29
|
+
"@types/node": "^25.2.3",
|
|
30
|
+
"tailwindcss": "^4.2.0",
|
|
31
31
|
"@techstream/quark-config": "workspace:*"
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://railway.com/railway.schema.json",
|
|
3
|
+
"build": {
|
|
4
|
+
"builder": "RAILPACK",
|
|
5
|
+
"buildCommand": "pnpm install --frozen-lockfile && pnpm db:generate && pnpm build",
|
|
6
|
+
"watchPatterns": ["apps/web/**", "packages/**"]
|
|
7
|
+
},
|
|
8
|
+
"deploy": {
|
|
9
|
+
"startCommand": "node apps/web/.next/standalone/server.js",
|
|
10
|
+
"healthcheckPath": "/api/health",
|
|
11
|
+
"healthcheckTimeout": 30,
|
|
12
|
+
"restartPolicyType": "ON_FAILURE",
|
|
13
|
+
"restartPolicyMaxRetries": 5
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
"license": "ISC",
|
|
16
16
|
"packageManager": "pnpm@10.12.1",
|
|
17
17
|
"dependencies": {
|
|
18
|
+
"@techstream/quark-config": "workspace:*",
|
|
18
19
|
"@techstream/quark-core": "^1.0.0",
|
|
19
20
|
"@techstream/quark-db": "workspace:*",
|
|
20
21
|
"@techstream/quark-jobs": "workspace:*",
|
|
21
|
-
"bullmq": "^5.
|
|
22
|
+
"bullmq": "^5.69.3"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
|
-
"@
|
|
25
|
-
"@types/node": "^24.10.12",
|
|
25
|
+
"@types/node": "^25.2.3",
|
|
26
26
|
"tsx": "^4.21.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://railway.com/railway.schema.json",
|
|
3
|
+
"build": {
|
|
4
|
+
"builder": "RAILPACK",
|
|
5
|
+
"buildCommand": "pnpm install --frozen-lockfile && pnpm db:generate",
|
|
6
|
+
"watchPatterns": ["apps/worker/**", "packages/**"]
|
|
7
|
+
},
|
|
8
|
+
"deploy": {
|
|
9
|
+
"startCommand": "node apps/worker/src/index.js",
|
|
10
|
+
"restartPolicyType": "ON_FAILURE",
|
|
11
|
+
"restartPolicyMaxRetries": 5
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Handles job execution, retries, and error tracking
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { loadEnv } from "@techstream/quark-config";
|
|
7
8
|
import {
|
|
8
9
|
createLogger,
|
|
9
10
|
createQueue,
|
|
@@ -13,6 +14,9 @@ import { prisma } from "@techstream/quark-db";
|
|
|
13
14
|
import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
|
|
14
15
|
import { jobHandlers } from "./handlers/index.js";
|
|
15
16
|
|
|
17
|
+
// Validate environment variables (worker-scoped — skips web-only checks)
|
|
18
|
+
loadEnv("worker");
|
|
19
|
+
|
|
16
20
|
const logger = createLogger("worker");
|
|
17
21
|
|
|
18
22
|
// Store workers for graceful shutdown
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { defineConfig } from "prisma/config";
|
|
3
|
+
import { getConnectionString } from "./src/connection.js";
|
|
3
4
|
|
|
4
5
|
// Load .env from monorepo root (needed for standalone commands like db:push, db:seed)
|
|
5
6
|
try {
|
|
6
7
|
process.loadEnvFile(resolve(__dirname, "../../.env"));
|
|
7
8
|
} catch {}
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const port = process.env.POSTGRES_PORT || "5432";
|
|
14
|
-
const db = process.env.POSTGRES_DB || "quark_dev";
|
|
15
|
-
|
|
16
|
-
const databaseUrl = `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
|
|
10
|
+
// Use shared connection builder — throwOnMissing=false so `prisma generate` works
|
|
11
|
+
// even without database credentials (e.g. in CI). Commands that need a real
|
|
12
|
+
// connection (migrate, push, studio) will fail at connect time.
|
|
13
|
+
const databaseUrl = getConnectionString({ throwOnMissing: false });
|
|
17
14
|
|
|
18
15
|
export default defineConfig({
|
|
19
16
|
schema: "prisma/schema.prisma",
|
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import { PrismaPg } from "@prisma/adapter-pg";
|
|
2
|
+
import { getConnectionString } from "./connection.js";
|
|
2
3
|
import { PrismaClient } from "./generated/prisma/client.ts";
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
* Builds a Postgres connection string from individual env vars (mirrors prisma.config.ts).
|
|
6
|
-
* Throws if any required variable is missing — but only when actually called,
|
|
7
|
-
* so the module can be safely imported at build time (e.g. during `next build`).
|
|
8
|
-
*/
|
|
9
|
-
function getConnectionString() {
|
|
10
|
-
const user = process.env.POSTGRES_USER;
|
|
11
|
-
if (!user) throw new Error("POSTGRES_USER environment variable is required");
|
|
12
|
-
const password = process.env.POSTGRES_PASSWORD;
|
|
13
|
-
if (!password)
|
|
14
|
-
throw new Error("POSTGRES_PASSWORD environment variable is required");
|
|
15
|
-
const host = process.env.POSTGRES_HOST || "localhost";
|
|
16
|
-
const port = process.env.POSTGRES_PORT || "5432";
|
|
17
|
-
const db = process.env.POSTGRES_DB;
|
|
18
|
-
if (!db) throw new Error("POSTGRES_DB environment variable is required");
|
|
19
|
-
return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
5
|
/**
|
|
23
6
|
* Returns the connection pool configuration for the `pg` driver.
|
|
24
7
|
*
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection string builder for PostgreSQL.
|
|
3
|
+
* Used by both the Prisma client (client.js) and Prisma CLI (prisma.config.ts).
|
|
4
|
+
*
|
|
5
|
+
* Priority: DATABASE_URL env var → assembled from POSTGRES_* env vars.
|
|
6
|
+
*
|
|
7
|
+
* @param {{ throwOnMissing?: boolean }} [options]
|
|
8
|
+
* - throwOnMissing=true (default) — runtime: throws if credentials are missing.
|
|
9
|
+
* - throwOnMissing=false — CLI/CI: returns a placeholder URL so `prisma generate` works.
|
|
10
|
+
* @returns {string} PostgreSQL connection string
|
|
11
|
+
*/
|
|
12
|
+
export function getConnectionString({ throwOnMissing = true } = {}) {
|
|
13
|
+
// 1. Prefer an explicit DATABASE_URL if set
|
|
14
|
+
if (process.env.DATABASE_URL) {
|
|
15
|
+
return process.env.DATABASE_URL;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Assemble from individual POSTGRES_* vars
|
|
19
|
+
const user = process.env.POSTGRES_USER;
|
|
20
|
+
const password = process.env.POSTGRES_PASSWORD;
|
|
21
|
+
const host = process.env.POSTGRES_HOST || "localhost";
|
|
22
|
+
const port = process.env.POSTGRES_PORT || "5432";
|
|
23
|
+
const db = process.env.POSTGRES_DB;
|
|
24
|
+
|
|
25
|
+
const hasCredentials = user && password && db;
|
|
26
|
+
|
|
27
|
+
if (!hasCredentials) {
|
|
28
|
+
if (throwOnMissing) {
|
|
29
|
+
const missing = [];
|
|
30
|
+
if (!user) missing.push("POSTGRES_USER");
|
|
31
|
+
if (!password) missing.push("POSTGRES_PASSWORD");
|
|
32
|
+
if (!db) missing.push("POSTGRES_DB");
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Missing required database environment variables: ${missing.join(", ")}. ` +
|
|
35
|
+
"Set DATABASE_URL or the individual POSTGRES_* variables.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Placeholder for prisma generate / CI — will fail at connect time, not at config time.
|
|
40
|
+
return "postgresql://placeholder:placeholder@localhost:5432/placeholder?schema=public";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=public`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
3
|
+
import { getConnectionString } from "./connection.js";
|
|
4
|
+
|
|
5
|
+
describe("getConnectionString", () => {
|
|
6
|
+
const originalEnv = { ...process.env };
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Clear all DB-related env vars before each test
|
|
10
|
+
delete process.env.DATABASE_URL;
|
|
11
|
+
delete process.env.POSTGRES_USER;
|
|
12
|
+
delete process.env.POSTGRES_PASSWORD;
|
|
13
|
+
delete process.env.POSTGRES_HOST;
|
|
14
|
+
delete process.env.POSTGRES_PORT;
|
|
15
|
+
delete process.env.POSTGRES_DB;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
// Restore original env
|
|
20
|
+
process.env = { ...originalEnv };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns DATABASE_URL when set", () => {
|
|
24
|
+
process.env.DATABASE_URL =
|
|
25
|
+
"postgresql://user:pass@remote:5433/mydb?schema=public";
|
|
26
|
+
const url = getConnectionString();
|
|
27
|
+
assert.strictEqual(url, process.env.DATABASE_URL);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("DATABASE_URL takes priority over individual POSTGRES_* vars", () => {
|
|
31
|
+
process.env.DATABASE_URL =
|
|
32
|
+
"postgresql://url_user:url_pass@remote:5433/url_db?schema=public";
|
|
33
|
+
process.env.POSTGRES_USER = "individual_user";
|
|
34
|
+
process.env.POSTGRES_PASSWORD = "individual_pass";
|
|
35
|
+
process.env.POSTGRES_DB = "individual_db";
|
|
36
|
+
|
|
37
|
+
const url = getConnectionString();
|
|
38
|
+
assert.strictEqual(url, process.env.DATABASE_URL);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("assembles from POSTGRES_* vars when DATABASE_URL is not set", () => {
|
|
42
|
+
process.env.POSTGRES_USER = "test_user";
|
|
43
|
+
process.env.POSTGRES_PASSWORD = "test_pass";
|
|
44
|
+
process.env.POSTGRES_DB = "test_db";
|
|
45
|
+
|
|
46
|
+
const url = getConnectionString();
|
|
47
|
+
assert.strictEqual(
|
|
48
|
+
url,
|
|
49
|
+
"postgresql://test_user:test_pass@localhost:5432/test_db?schema=public",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("uses custom host and port when provided", () => {
|
|
54
|
+
process.env.POSTGRES_USER = "user";
|
|
55
|
+
process.env.POSTGRES_PASSWORD = "pass";
|
|
56
|
+
process.env.POSTGRES_HOST = "db.example.com";
|
|
57
|
+
process.env.POSTGRES_PORT = "5433";
|
|
58
|
+
process.env.POSTGRES_DB = "mydb";
|
|
59
|
+
|
|
60
|
+
const url = getConnectionString();
|
|
61
|
+
assert.strictEqual(
|
|
62
|
+
url,
|
|
63
|
+
"postgresql://user:pass@db.example.com:5433/mydb?schema=public",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("throws when POSTGRES_USER is missing (throwOnMissing=true)", () => {
|
|
68
|
+
process.env.POSTGRES_PASSWORD = "pass";
|
|
69
|
+
process.env.POSTGRES_DB = "db";
|
|
70
|
+
|
|
71
|
+
assert.throws(() => getConnectionString(), {
|
|
72
|
+
message: /POSTGRES_USER/,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws when POSTGRES_PASSWORD is missing (throwOnMissing=true)", () => {
|
|
77
|
+
process.env.POSTGRES_USER = "user";
|
|
78
|
+
process.env.POSTGRES_DB = "db";
|
|
79
|
+
|
|
80
|
+
assert.throws(() => getConnectionString(), {
|
|
81
|
+
message: /POSTGRES_PASSWORD/,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("throws when POSTGRES_DB is missing (throwOnMissing=true)", () => {
|
|
86
|
+
process.env.POSTGRES_USER = "user";
|
|
87
|
+
process.env.POSTGRES_PASSWORD = "pass";
|
|
88
|
+
|
|
89
|
+
assert.throws(() => getConnectionString(), {
|
|
90
|
+
message: /POSTGRES_DB/,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("lists all missing vars in error message", () => {
|
|
95
|
+
assert.throws(() => getConnectionString(), {
|
|
96
|
+
message: /POSTGRES_USER.*POSTGRES_PASSWORD.*POSTGRES_DB/,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("returns placeholder when throwOnMissing=false and vars missing", () => {
|
|
101
|
+
const url = getConnectionString({ throwOnMissing: false });
|
|
102
|
+
assert.strictEqual(
|
|
103
|
+
url,
|
|
104
|
+
"postgresql://placeholder:placeholder@localhost:5432/placeholder?schema=public",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("still assembles correctly with throwOnMissing=false when vars present", () => {
|
|
109
|
+
process.env.POSTGRES_USER = "user";
|
|
110
|
+
process.env.POSTGRES_PASSWORD = "pass";
|
|
111
|
+
process.env.POSTGRES_DB = "db";
|
|
112
|
+
|
|
113
|
+
const url = getConnectionString({ throwOnMissing: false });
|
|
114
|
+
assert.strictEqual(
|
|
115
|
+
url,
|
|
116
|
+
"postgresql://user:pass@localhost:5432/db?schema=public",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const config = {
|
|
2
|
-
appName: "Quark",
|
|
3
|
-
appDescription:
|
|
2
|
+
appName: process.env.APP_NAME || "Quark",
|
|
3
|
+
appDescription:
|
|
4
|
+
process.env.APP_DESCRIPTION ||
|
|
5
|
+
"A modern monorepo with Next.js, React, and Prisma",
|
|
4
6
|
};
|
|
5
7
|
|
|
6
8
|
export { getAllowedOrigins, getAppUrl, syncNextAuthUrl } from "./app-url.js";
|
|
@@ -11,3 +13,4 @@ export {
|
|
|
11
13
|
resolveEnvironment,
|
|
12
14
|
} from "./environment.js";
|
|
13
15
|
export { getConfig, loadConfig, resetConfig } from "./load-config.js";
|
|
16
|
+
export { loadEnv, validateEnv } from "./validate-env.js";
|
|
@@ -36,7 +36,7 @@ export function loadConfig(overrides = {}, options = {}) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Step 1: Validate environment variables
|
|
39
|
-
const validated = validateEnv();
|
|
39
|
+
const { validated } = validateEnv();
|
|
40
40
|
|
|
41
41
|
// Step 2: Resolve environment and get defaults
|
|
42
42
|
const envConfig = getEnvironmentConfig();
|
|
@@ -23,7 +23,6 @@ const envSchema = {
|
|
|
23
23
|
REDIS_PORT: { required: false, description: "Redis port" },
|
|
24
24
|
|
|
25
25
|
// Mail (local SMTP — Mailpit in dev)
|
|
26
|
-
MAIL_SMTP_URL: { required: false, description: "Mail SMTP URL" },
|
|
27
26
|
MAIL_HOST: { required: false, description: "Mail host" },
|
|
28
27
|
MAIL_SMTP_PORT: { required: false, description: "Mail SMTP port" },
|
|
29
28
|
MAIL_UI_PORT: { required: false, description: "Mail UI port" },
|
|
@@ -55,6 +54,10 @@ const envSchema = {
|
|
|
55
54
|
},
|
|
56
55
|
|
|
57
56
|
// Application
|
|
57
|
+
APP_NAME: {
|
|
58
|
+
required: false,
|
|
59
|
+
description: "Application name — used in metadata, emails, and page titles",
|
|
60
|
+
},
|
|
58
61
|
APP_URL: {
|
|
59
62
|
required: false,
|
|
60
63
|
description:
|
|
@@ -84,18 +87,30 @@ const envSchema = {
|
|
|
84
87
|
};
|
|
85
88
|
|
|
86
89
|
/**
|
|
87
|
-
* Validates environment variables against schema
|
|
90
|
+
* Validates environment variables against schema.
|
|
91
|
+
*
|
|
92
|
+
* @param {"web" | "worker"} [service="web"] — The service being validated.
|
|
93
|
+
* Worker skips web-only checks (e.g. NEXTAUTH_SECRET).
|
|
88
94
|
* @throws {Error} If required environment variables are missing
|
|
89
|
-
* @returns {Object
|
|
95
|
+
* @returns {{ validated: Object, warnings: string[] }}
|
|
90
96
|
*/
|
|
91
|
-
export function validateEnv() {
|
|
97
|
+
export function validateEnv(service = "web") {
|
|
92
98
|
const errors = [];
|
|
99
|
+
const warnings = [];
|
|
93
100
|
const validated = {};
|
|
101
|
+
const isTest = process.env.NODE_ENV === "test";
|
|
102
|
+
|
|
103
|
+
// Web-only required fields that workers can skip
|
|
104
|
+
const webOnlyRequired = new Set(["NEXTAUTH_SECRET"]);
|
|
94
105
|
|
|
95
106
|
for (const [key, config] of Object.entries(envSchema)) {
|
|
96
107
|
const value = process.env[key];
|
|
97
108
|
|
|
98
|
-
|
|
109
|
+
// Skip web-only required checks for worker service
|
|
110
|
+
const isRequired =
|
|
111
|
+
config.required && !(service === "worker" && webOnlyRequired.has(key));
|
|
112
|
+
|
|
113
|
+
if (isRequired && !value) {
|
|
99
114
|
errors.push(
|
|
100
115
|
`Missing required environment variable: ${key} (${config.description})`,
|
|
101
116
|
);
|
|
@@ -112,6 +127,30 @@ export function validateEnv() {
|
|
|
112
127
|
}
|
|
113
128
|
}
|
|
114
129
|
|
|
130
|
+
// --- Cross-field validation ---
|
|
131
|
+
|
|
132
|
+
// Database: either DATABASE_URL or POSTGRES_USER must be set (skip in test)
|
|
133
|
+
if (!isTest) {
|
|
134
|
+
const hasDbUrl = !!process.env.DATABASE_URL;
|
|
135
|
+
const hasPostgresUser = !!process.env.POSTGRES_USER;
|
|
136
|
+
if (!hasDbUrl && !hasPostgresUser) {
|
|
137
|
+
errors.push(
|
|
138
|
+
"Database not configured: set DATABASE_URL or POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Redis: warn if not configured (defaults to localhost in dev, will fail in prod)
|
|
144
|
+
if (
|
|
145
|
+
!process.env.REDIS_URL &&
|
|
146
|
+
!process.env.REDIS_HOST &&
|
|
147
|
+
process.env.NODE_ENV === "production"
|
|
148
|
+
) {
|
|
149
|
+
warnings.push(
|
|
150
|
+
"Redis not configured: set REDIS_URL or REDIS_HOST (defaults to localhost)",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
// Conditional: S3 storage requires bucket + credentials
|
|
116
155
|
if (process.env.STORAGE_PROVIDER === "s3") {
|
|
117
156
|
for (const key of [
|
|
@@ -132,29 +171,39 @@ export function validateEnv() {
|
|
|
132
171
|
errors.push("Missing RESEND_API_KEY — required when EMAIL_PROVIDER=resend");
|
|
133
172
|
}
|
|
134
173
|
|
|
174
|
+
// Log warnings (non-fatal)
|
|
175
|
+
for (const warning of warnings) {
|
|
176
|
+
console.warn(`[env] ⚠️ ${warning}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
if (errors.length > 0) {
|
|
136
180
|
const errorMessage = `Environment Validation Failed:\n${errors.join("\n")}`;
|
|
137
181
|
throw new Error(errorMessage);
|
|
138
182
|
}
|
|
139
183
|
|
|
140
184
|
// Ensure NEXTAUTH_URL is derived from APP_URL when not explicitly set
|
|
141
|
-
|
|
185
|
+
if (service === "web") {
|
|
186
|
+
syncNextAuthUrl();
|
|
142
187
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
188
|
+
// Include the (possibly derived) NEXTAUTH_URL in the validated object
|
|
189
|
+
if (process.env.NEXTAUTH_URL && !validated.NEXTAUTH_URL) {
|
|
190
|
+
validated.NEXTAUTH_URL = process.env.NEXTAUTH_URL;
|
|
191
|
+
}
|
|
146
192
|
}
|
|
147
193
|
|
|
148
|
-
return validated;
|
|
194
|
+
return { validated, warnings };
|
|
149
195
|
}
|
|
150
196
|
|
|
151
197
|
/**
|
|
152
|
-
* Loads and validates environment variables
|
|
153
|
-
* Call this function at application startup
|
|
198
|
+
* Loads and validates environment variables.
|
|
199
|
+
* Call this function at application startup.
|
|
200
|
+
*
|
|
201
|
+
* @param {"web" | "worker"} [service="web"]
|
|
154
202
|
*/
|
|
155
|
-
export function loadEnv() {
|
|
203
|
+
export function loadEnv(service = "web") {
|
|
156
204
|
try {
|
|
157
|
-
|
|
205
|
+
const { validated } = validateEnv(service);
|
|
206
|
+
return validated;
|
|
158
207
|
} catch (error) {
|
|
159
208
|
console.error(error.message);
|
|
160
209
|
process.exit(1);
|