@techstream/quark-create-app 1.8.0 → 1.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techstream/quark-create-app",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "quark-create-app": "src/index.js",
@@ -16,7 +16,7 @@
16
16
  "chalk": "^5.6.2",
17
17
  "commander": "^14.0.3",
18
18
  "execa": "^9.6.1",
19
- "fs-extra": "^11.3.3",
19
+ "fs-extra": "^11.3.4",
20
20
  "prompts": "^2.4.2"
21
21
  },
22
22
  "publishConfig": {
@@ -30,7 +30,7 @@
30
30
  "scripts": {
31
31
  "sync-templates": "node scripts/sync-templates.js",
32
32
  "sync-templates:check": "node scripts/sync-templates.js --check",
33
- "test": "node test-cli.js",
33
+ "test": "node test-cli.js && node --test src/utils.test.js",
34
34
  "test:build": "node test-build.js",
35
35
  "test:e2e": "node test-e2e.js",
36
36
  "test:e2e:full": "node test-e2e-full.js",
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ import { Command } from "commander";
8
8
  import { execa } from "execa";
9
9
  import fs from "fs-extra";
10
10
  import prompts from "prompts";
11
+ import { formatProjectDisplayName } from "./utils.js";
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const templatesDir = path.join(__dirname, "../templates");
@@ -271,6 +272,8 @@ program
271
272
 
272
273
  const targetDir = validateProjectName(projectName);
273
274
  const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "");
275
+ const appDisplayName = formatProjectDisplayName(projectName);
276
+ const appDescription = `${appDisplayName} application`;
274
277
 
275
278
  // Clean up orphaned Docker volumes from a previous project with the same name.
276
279
  // Docker Compose names volumes as "<project>_postgres_data", "<project>_redis_data".
@@ -528,7 +531,11 @@ program
528
531
 
529
532
  // Step 8: Create .env.example file
530
533
  console.log(chalk.cyan("\n 📋 Creating environment configuration..."));
531
- const envExampleTemplate = `# --- Environment ---
534
+ const envExampleTemplate = `# ⚠️ IMPORTANT: Copy this file to .env and fill in the values for your environment.
535
+ # NEVER commit the .env file to version control — it contains secrets!
536
+ # $ cp .env.example .env
537
+
538
+ # --- Environment ---
532
539
  # Supported: development, test, staging, production (default: development)
533
540
  # NODE_ENV=development
534
541
 
@@ -544,6 +551,10 @@ POSTGRES_DB=${scope}_dev
544
551
  # Optional: Set DATABASE_URL to override the dynamic construction above
545
552
  # DATABASE_URL="postgresql://${scope}_user:CHANGE_ME_TO_STRONG_PASSWORD@localhost:5432/${scope}_dev?schema=public"
546
553
 
554
+ # --- Database Pool Configuration ---
555
+ # Connection pool settings are managed automatically. Customize if needed:
556
+ # For advanced tuning, see Prisma connection pool documentation.
557
+
547
558
  # --- Redis Configuration ---
548
559
  REDIS_HOST=localhost
549
560
  REDIS_PORT=6379
@@ -551,6 +562,9 @@ REDIS_PORT=6379
551
562
  # REDIS_URL="redis://localhost:6379"
552
563
 
553
564
  # --- Mail Configuration ---
565
+ # Email can be sent via SMTP (local or production), Resend, or Zeptomail.
566
+ # Choose one provider below based on your needs.
567
+
554
568
  # Development: Mailpit local SMTP (defaults below work with docker-compose)
555
569
  MAIL_HOST=localhost
556
570
  MAIL_SMTP_PORT=1025
@@ -563,12 +577,20 @@ MAIL_UI_PORT=8025
563
577
  # SMTP_USER=your_smtp_user
564
578
  # SMTP_PASSWORD=your_smtp_password
565
579
 
566
- # --- Email Provider ---
567
- # Provider: "smtp" (default) or "resend"
580
+ # --- Email Provider Selection ---
581
+ # Choose one: "smtp" (default), "resend", or "zeptomail"
568
582
  # EMAIL_PROVIDER=smtp
569
583
  # EMAIL_FROM=App Name <noreply@yourdomain.com>
570
584
 
571
- # Resend (only when EMAIL_PROVIDER=resend)
585
+ # Zeptomail (recommended for production)
586
+ # Get started at: https://www.zoho.com/zeptomail/
587
+ # Your token is shown in Zeptomail console and includes the "Zoho-enczapikey" prefix:
588
+ # e.g. ZEPTOMAIL_TOKEN=Zoho-enczapikey <your_key_here>
589
+ # ZEPTOMAIL_TOKEN=Zoho-enczapikey your_zeptomail_api_key
590
+ # ZEPTOMAIL_URL=https://api.zeptomail.com # Base URL; /v1.1/email is appended in code
591
+ # ZEPTOMAIL_BOUNCE_EMAIL=bounce@yourdomain.com # optional
592
+
593
+ # Resend (alternative provider)
572
594
  # Get your API key at: https://resend.com/api-keys
573
595
  # RESEND_API_KEY=re_xxxxxxxxxxxxx
574
596
 
@@ -579,14 +601,21 @@ MAIL_UI_PORT=8025
579
601
 
580
602
  # --- Application Identity ---
581
603
  # APP_NAME is used in metadata, emails, and page titles.
582
- APP_NAME=${projectName}
604
+ # ⚠️ APP_DESCRIPTION affects SEO snippets — update before production.
605
+ APP_NAME=${appDisplayName}
606
+ APP_DESCRIPTION=${appDescription}
583
607
 
584
608
  # --- NextAuth Configuration ---
585
609
  # ⚠️ CRITICAL: Generate a secure secret with: openssl rand -base64 32
586
610
  # This secret is used to encrypt JWT tokens and session data
587
611
  NEXTAUTH_SECRET=CHANGE_ME_TO_STRONG_SECRET
588
612
 
589
- # --- OAuth Providers (Optional) ---
613
+ # NextAuth callback URL (auto-derived from APP_URL in development)
614
+ # In production, explicitly set this to your domain:
615
+ # NEXTAUTH_URL=https://yourdomain.com/api/auth
616
+
617
+ # --- OAuth Providers (Not Yet Implemented) ---
618
+ # OAuth support is planned for a future release.
590
619
  # GitHub OAuth - Get credentials at: https://github.com/settings/developers
591
620
  # GITHUB_ID=your_github_client_id
592
621
  # GITHUB_SECRET=your_github_client_secret
@@ -687,7 +716,10 @@ MAIL_SMTP_PORT=${mailSmtpPort}
687
716
  MAIL_UI_PORT=${mailUiPort}
688
717
 
689
718
  # --- Application Identity ---
690
- APP_NAME=${projectName}
719
+ # APP_NAME is used in metadata, emails, and page titles.
720
+ # ⚠️ APP_DESCRIPTION affects SEO snippets — update before production.
721
+ APP_NAME=${appDisplayName}
722
+ APP_DESCRIPTION=${appDescription}
691
723
 
692
724
  # --- NextAuth Configuration ---
693
725
  NEXTAUTH_SECRET=${nextAuthSecret}
package/src/utils.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared utilities for @techstream/quark-create-app
3
+ */
4
+
5
+ /**
6
+ * Format a project slug into a human-friendly application name.
7
+ *
8
+ * - Hyphens, underscores, and dots are treated as word separators
9
+ * - Each word is title-cased
10
+ * - Degenerate inputs (all separators) fall back to "Quark App"
11
+ *
12
+ * @param {string} projectName - Raw project slug (e.g. "my-cool-app")
13
+ * @returns {string} Title-cased display name (e.g. "My Cool App")
14
+ *
15
+ * @example
16
+ * formatProjectDisplayName("my-cool-app") // "My Cool App"
17
+ * formatProjectDisplayName("my.app") // "My App"
18
+ * formatProjectDisplayName("my_app_v2") // "My App V2"
19
+ * formatProjectDisplayName("myapp") // "Myapp"
20
+ * formatProjectDisplayName("---") // "Quark App"
21
+ */
22
+ export function formatProjectDisplayName(projectName) {
23
+ const normalized = projectName
24
+ .replace(/[-_.]+/g, " ")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+
28
+ if (!normalized) {
29
+ return "Quark App";
30
+ }
31
+
32
+ return normalized
33
+ .split(" ")
34
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
35
+ .join(" ");
36
+ }
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { formatProjectDisplayName } from "./utils.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Happy path — common slug patterns
7
+ // ---------------------------------------------------------------------------
8
+
9
+ test("formatProjectDisplayName - hyphen-separated slug", () => {
10
+ assert.strictEqual(formatProjectDisplayName("my-cool-app"), "My Cool App");
11
+ });
12
+
13
+ test("formatProjectDisplayName - underscore-separated slug", () => {
14
+ assert.strictEqual(formatProjectDisplayName("my_app_v2"), "My App V2");
15
+ });
16
+
17
+ test("formatProjectDisplayName - dot-separated slug", () => {
18
+ assert.strictEqual(formatProjectDisplayName("my.app"), "My App");
19
+ });
20
+
21
+ test("formatProjectDisplayName - single word (no separators)", () => {
22
+ assert.strictEqual(formatProjectDisplayName("myapp"), "Myapp");
23
+ });
24
+
25
+ test("formatProjectDisplayName - already title-cased input", () => {
26
+ // slice(1) preserves remaining chars as-is — only the first char is forced uppercase
27
+ assert.strictEqual(formatProjectDisplayName("MyApp"), "MyApp");
28
+ });
29
+
30
+ test("formatProjectDisplayName - mixed separators", () => {
31
+ assert.strictEqual(formatProjectDisplayName("my-app_v2.0"), "My App V2 0");
32
+ });
33
+
34
+ test("formatProjectDisplayName - consecutive separators collapse to single space", () => {
35
+ assert.strictEqual(formatProjectDisplayName("my--app"), "My App");
36
+ assert.strictEqual(formatProjectDisplayName("my___app"), "My App");
37
+ assert.strictEqual(formatProjectDisplayName("my-._app"), "My App");
38
+ });
39
+
40
+ test("formatProjectDisplayName - numbers preserved in words", () => {
41
+ assert.strictEqual(formatProjectDisplayName("app-v2"), "App V2");
42
+ assert.strictEqual(formatProjectDisplayName("project-123"), "Project 123");
43
+ });
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Edge cases — degenerate / boundary inputs
47
+ // ---------------------------------------------------------------------------
48
+
49
+ test("formatProjectDisplayName - all separators returns fallback", () => {
50
+ assert.strictEqual(formatProjectDisplayName("---"), "Quark App");
51
+ assert.strictEqual(formatProjectDisplayName("___"), "Quark App");
52
+ assert.strictEqual(formatProjectDisplayName("..."), "Quark App");
53
+ assert.strictEqual(formatProjectDisplayName("-._-"), "Quark App");
54
+ });
55
+
56
+ test("formatProjectDisplayName - preserves casing of rest of word", () => {
57
+ // The function only forces the first char uppercase; the remainder is untouched
58
+ assert.strictEqual(formatProjectDisplayName("myApp"), "MyApp");
59
+ });
60
+
61
+ test("formatProjectDisplayName - short single-char name", () => {
62
+ assert.strictEqual(formatProjectDisplayName("a"), "A");
63
+ });
@@ -3,9 +3,13 @@ name: Release
3
3
  on:
4
4
  workflow_dispatch:
5
5
  inputs:
6
- notes:
7
- description: "Release notes (optional auto-generated if blank)"
6
+ prerelease:
7
+ description: "Tag as pre-release (staging-only, skips production deploy)"
8
8
  required: false
9
+ default: false
10
+ type: boolean
11
+
12
+ concurrency: ${{ github.workflow }}
9
13
 
10
14
  permissions:
11
15
  contents: write
@@ -17,22 +21,47 @@ jobs:
17
21
  steps:
18
22
  - uses: actions/checkout@v4
19
23
  with:
24
+ ref: main
20
25
  fetch-depth: 0
21
26
 
22
- - name: Generate date-based tag
27
+ - name: Configure git
28
+ run: |
29
+ git config user.name "github-actions[bot]"
30
+ git config user.email "github-actions[bot]@users.noreply.github.com"
31
+
32
+ - name: Push to production branch
33
+ if: ${{ !inputs.prerelease }}
34
+ run: |
35
+ git fetch origin
36
+ if git ls-remote --exit-code --heads origin production > /dev/null; then
37
+ git checkout production && git merge --ff-only origin/main
38
+ else
39
+ git checkout -b production origin/main
40
+ fi
41
+ git push origin production
42
+ git checkout main
43
+
44
+ - name: Generate tag
23
45
  id: tag
24
46
  run: |
25
47
  BASE="v$(date +%Y.%m.%d)"
26
- EXISTING=$(git tag -l "${BASE}*" | wc -l | tr -d ' ')
48
+ SUFFIX="${{ inputs.prerelease && '-rc' || '' }}"
49
+ # Count only exact-format matches to avoid rc tags polluting production counts
50
+ EXISTING=$(git tag -l | grep -cE "^${BASE}${SUFFIX}(\.[0-9]+)?$" || true)
27
51
  if [ "$EXISTING" -eq "0" ]; then
28
- echo "tag=${BASE}" >> "$GITHUB_OUTPUT"
52
+ echo "tag=${BASE}${SUFFIX}" >> "$GITHUB_OUTPUT"
29
53
  else
30
- echo "tag=${BASE}.${EXISTING}" >> "$GITHUB_OUTPUT"
54
+ echo "tag=${BASE}${SUFFIX}.${EXISTING}" >> "$GITHUB_OUTPUT"
31
55
  fi
32
56
 
57
+ - name: Tag release
58
+ run: |
59
+ git tag -a "${{ steps.tag.outputs.tag }}" -m "Release ${{ steps.tag.outputs.tag }}"
60
+ git push origin "${{ steps.tag.outputs.tag }}"
61
+
33
62
  - name: Create GitHub Release
34
63
  uses: softprops/action-gh-release@v2
35
64
  with:
36
65
  tag_name: ${{ steps.tag.outputs.tag }}
37
- generate_release_notes: ${{ github.event.inputs.notes == '' }}
38
- body: ${{ github.event.inputs.notes }}
66
+ generate_release_notes: true
67
+ prerelease: ${{ inputs.prerelease }}
@@ -12,22 +12,24 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@auth/prisma-adapter": "^2.11.1",
15
+ "@aws-sdk/client-s3": "^3.1002.0",
16
+ "@aws-sdk/s3-request-presigner": "^3.1002.0",
15
17
  "@techstream/quark-core": "^1.0.0",
16
18
  "@techstream/quark-db": "workspace:*",
17
19
  "@techstream/quark-jobs": "workspace:*",
18
20
  "@techstream/quark-ui": "workspace:*",
19
- "@prisma/client": "^7.4.0",
21
+ "@prisma/client": "^7.4.2",
20
22
  "next": "16.1.6",
21
23
  "next-auth": "5.0.0-beta.30",
22
- "pg": "^8.18.0",
24
+ "pg": "^8.20.0",
23
25
  "react": "19.2.4",
24
26
  "react-dom": "19.2.4",
25
27
  "zod": "^4.3.6"
26
28
  },
27
29
  "devDependencies": {
28
- "@tailwindcss/postcss": "^4.2.0",
29
- "@types/node": "^25.2.3",
30
- "tailwindcss": "^4.2.0",
30
+ "@tailwindcss/postcss": "^4.2.1",
31
+ "@types/node": "^25.3.3",
32
+ "tailwindcss": "^4.2.1",
31
33
  "@techstream/quark-config": "workspace:*"
32
34
  }
33
35
  }
@@ -4,7 +4,7 @@
4
4
  * Times out after 5 seconds to prevent hanging.
5
5
  */
6
6
 
7
- import { createLogger, pingRedis } from "@techstream/quark-core";
7
+ import { createLogger, createStorage, pingRedis } from "@techstream/quark-core";
8
8
  import { prisma } from "@techstream/quark-db";
9
9
  import { NextResponse } from "next/server";
10
10
 
@@ -79,5 +79,33 @@ async function runHealthChecks() {
79
79
  health.status = "degraded";
80
80
  }
81
81
 
82
+ // Check storage connectivity
83
+ const storageResult = await checkStorage();
84
+ health.checks.storage = storageResult;
85
+ if (storageResult.status === "error") {
86
+ health.status = "degraded";
87
+ }
88
+
82
89
  return health;
83
90
  }
91
+
92
+ /**
93
+ * Verifies storage is reachable and writable by writing then deleting a
94
+ * small sentinel object. Uses whichever provider is configured via
95
+ * STORAGE_PROVIDER (defaults to "local" when unset).
96
+ * @returns {Promise<{ status: string, provider: string, message?: string }>}
97
+ */
98
+ async function checkStorage() {
99
+ const provider = process.env.STORAGE_PROVIDER || "local";
100
+ try {
101
+ const storage = createStorage();
102
+ const sentinelKey = ".health-check-sentinel";
103
+ await storage.put(sentinelKey, Buffer.from("ok"), {
104
+ contentType: "text/plain",
105
+ });
106
+ await storage.delete(sentinelKey);
107
+ return { status: "ok", provider };
108
+ } catch (error) {
109
+ return { status: "error", provider, message: error.message };
110
+ }
111
+ }
@@ -20,10 +20,10 @@
20
20
  "@techstream/quark-core": "^1.0.0",
21
21
  "@techstream/quark-db": "workspace:*",
22
22
  "@techstream/quark-jobs": "workspace:*",
23
- "bullmq": "^5.69.3"
23
+ "bullmq": "^5.70.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/node": "^25.2.3",
26
+ "@types/node": "^25.3.3",
27
27
  "tsx": "^4.21.0"
28
28
  }
29
29
  }
@@ -15,7 +15,18 @@
15
15
  "db:migrate": "turbo run db:migrate",
16
16
  "db:push": "turbo run db:push",
17
17
  "db:seed": "turbo run db:seed",
18
- "db:studio": "turbo run db:studio"
18
+ "db:studio": "turbo run db:studio",
19
+ "prepare": "simple-git-hooks"
20
+ },
21
+ "simple-git-hooks": {
22
+ "pre-commit": "pnpm nano-staged",
23
+ "pre-push": "pnpm test"
24
+ },
25
+ "nano-staged": {
26
+ "**/*.{js,mjs,ts,tsx,json,css}": [
27
+ "biome format --write",
28
+ "biome check --write"
29
+ ]
19
30
  },
20
31
  "keywords": [],
21
32
  "author": "",
@@ -39,6 +50,8 @@
39
50
  "@biomejs/biome": "^2.4.0",
40
51
  "@types/node": "^24.10.9",
41
52
  "dotenv-cli": "^11.0.0",
53
+ "nano-staged": "^0.9.0",
54
+ "simple-git-hooks": "^2.13.1",
42
55
  "tsx": "^4.21.0",
43
56
  "turbo": "^2.8.1"
44
57
  }
@@ -21,12 +21,12 @@
21
21
  "@faker-js/faker": "^10.3.0",
22
22
  "@techstream/quark-config": "workspace:*",
23
23
  "bcryptjs": "^3.0.3",
24
- "prisma": "^7.4.0"
24
+ "prisma": "^7.4.2"
25
25
  },
26
26
  "dependencies": {
27
- "@prisma/adapter-pg": "^7.4.0",
28
- "@prisma/client": "^7.4.0",
29
- "pg": "^8.18.0",
27
+ "@prisma/adapter-pg": "^7.4.2",
28
+ "@prisma/client": "^7.4.2",
29
+ "pg": "^8.20.0",
30
30
  "zod": "^4.3.6"
31
31
  }
32
32
  }
@@ -1,8 +1,6 @@
1
1
  export const config = {
2
2
  appName: process.env.APP_NAME || "Quark",
3
- appDescription:
4
- process.env.APP_DESCRIPTION ||
5
- "A modern monorepo with Next.js, React, and Prisma",
3
+ appDescription: process.env.APP_DESCRIPTION || "A Quark-powered application",
6
4
  };
7
5
 
8
6
  export { getAllowedOrigins, getAppUrl, syncNextAuthUrl } from "./app-url.js";
@@ -37,10 +37,16 @@ const envSchema = {
37
37
  // Email provider
38
38
  EMAIL_PROVIDER: {
39
39
  required: false,
40
- description: 'Email provider — "smtp" (default) or "resend"',
40
+ description: 'Email provider — "smtp" (default), "resend", or "zeptomail"',
41
41
  },
42
42
  EMAIL_FROM: { required: false, description: "Sender email address" },
43
43
  RESEND_API_KEY: { required: false, description: "Resend API key" },
44
+ ZEPTOMAIL_TOKEN: { required: false, description: "Zeptomail API token" },
45
+ ZEPTOMAIL_URL: { required: false, description: "Zeptomail API base URL" },
46
+ ZEPTOMAIL_BOUNCE_EMAIL: {
47
+ required: false,
48
+ description: "Zeptomail bounce email address",
49
+ },
44
50
 
45
51
  // NextAuth
46
52
  NEXTAUTH_SECRET: {
@@ -58,6 +64,11 @@ const envSchema = {
58
64
  required: false,
59
65
  description: "Application name — used in metadata, emails, and page titles",
60
66
  },
67
+ APP_DESCRIPTION: {
68
+ required: false,
69
+ description:
70
+ "Application description — used for SEO metadata and social previews",
71
+ },
61
72
  APP_URL: {
62
73
  required: false,
63
74
  description:
@@ -69,6 +80,12 @@ const envSchema = {
69
80
  },
70
81
  PORT: { required: false, description: "Web server port" },
71
82
 
83
+ // Worker
84
+ WORKER_CONCURRENCY: {
85
+ required: false,
86
+ description: "Number of concurrent jobs per queue (default: 5)",
87
+ },
88
+
72
89
  // Storage
73
90
  STORAGE_PROVIDER: {
74
91
  required: false,
@@ -80,10 +97,19 @@ const envSchema = {
80
97
  },
81
98
  S3_BUCKET: { required: false, description: "S3 bucket name" },
82
99
  S3_REGION: { required: false, description: "S3 region" },
83
- S3_ENDPOINT: { required: false, description: "S3-compatible endpoint URL" },
100
+ S3_ENDPOINT: {
101
+ required: false,
102
+ description:
103
+ "S3-compatible endpoint URL (required for non-AWS providers: R2, MinIO, etc.)",
104
+ },
84
105
  S3_ACCESS_KEY_ID: { required: false, description: "S3 access key" },
85
106
  S3_SECRET_ACCESS_KEY: { required: false, description: "S3 secret key" },
86
107
  S3_PUBLIC_URL: { required: false, description: "S3 public URL prefix" },
108
+ ASSET_CDN_URL: {
109
+ required: false,
110
+ description:
111
+ "Public CDN base URL for asset delivery — provider-agnostic (CloudFront, Cloudflare, Bunny, etc.). Falls back to /api/files when unset.",
112
+ },
87
113
  };
88
114
 
89
115
  /**
@@ -129,6 +155,25 @@ export function validateEnv(service = "web") {
129
155
 
130
156
  // --- Cross-field validation ---
131
157
 
158
+ // Placeholder value security check
159
+ const placeholderPattern = /^CHANGE_ME_/i;
160
+ const criticalKeys = [
161
+ "NEXTAUTH_SECRET",
162
+ "POSTGRES_PASSWORD",
163
+ "RESEND_API_KEY",
164
+ "ZEPTOMAIL_TOKEN",
165
+ "S3_SECRET_ACCESS_KEY",
166
+ "SMTP_PASSWORD",
167
+ ];
168
+ for (const key of criticalKeys) {
169
+ const value = process.env[key];
170
+ if (value && placeholderPattern.test(value)) {
171
+ errors.push(
172
+ `${key} contains a placeholder value — replace with a real secret (${envSchema[key]?.description || ""})`,
173
+ );
174
+ }
175
+ }
176
+
132
177
  // Database: either DATABASE_URL or POSTGRES_USER must be set (skip in test)
133
178
  if (!isTest) {
134
179
  const hasDbUrl = !!process.env.DATABASE_URL;
@@ -141,16 +186,33 @@ export function validateEnv(service = "web") {
141
186
  }
142
187
 
143
188
  // Redis: warn if not configured (defaults to localhost in dev, will fail in prod)
189
+ const currentEnv = (process.env.NODE_ENV || "").toLowerCase();
144
190
  if (
145
191
  !process.env.REDIS_URL &&
146
192
  !process.env.REDIS_HOST &&
147
- process.env.NODE_ENV === "production"
193
+ (currentEnv === "production" || currentEnv === "staging")
148
194
  ) {
149
195
  warnings.push(
150
196
  "Redis not configured: set REDIS_URL or REDIS_HOST (defaults to localhost)",
151
197
  );
152
198
  }
153
199
 
200
+ // SEO metadata: APP_DESCRIPTION should be explicitly set before production
201
+ const isProductionLike =
202
+ currentEnv === "production" || currentEnv === "staging";
203
+ if (!isTest && isProductionLike) {
204
+ const appDescription = process.env.APP_DESCRIPTION;
205
+ if (!appDescription) {
206
+ warnings.push(
207
+ "APP_DESCRIPTION not set: metadata description will fall back to a generic value. Set APP_DESCRIPTION before production.",
208
+ );
209
+ } else if (/^CHANGE_ME_|^TODO_/i.test(appDescription)) {
210
+ warnings.push(
211
+ "APP_DESCRIPTION appears to be a placeholder value. Update it before production.",
212
+ );
213
+ }
214
+ }
215
+
154
216
  // Conditional: S3 storage requires bucket + credentials
155
217
  if (process.env.STORAGE_PROVIDER === "s3") {
156
218
  for (const key of [
@@ -171,6 +233,20 @@ export function validateEnv(service = "web") {
171
233
  errors.push("Missing RESEND_API_KEY — required when EMAIL_PROVIDER=resend");
172
234
  }
173
235
 
236
+ // Conditional: Zeptomail provider requires token and URL
237
+ if (process.env.EMAIL_PROVIDER === "zeptomail") {
238
+ if (!process.env.ZEPTOMAIL_TOKEN) {
239
+ errors.push(
240
+ "Missing ZEPTOMAIL_TOKEN — required when EMAIL_PROVIDER=zeptomail",
241
+ );
242
+ }
243
+ if (!process.env.ZEPTOMAIL_URL) {
244
+ errors.push(
245
+ "Missing ZEPTOMAIL_URL — required when EMAIL_PROVIDER=zeptomail",
246
+ );
247
+ }
248
+ }
249
+
174
250
  // Log warnings (non-fatal)
175
251
  for (const warning of warnings) {
176
252
  console.warn(`[env] ⚠️ ${warning}`);
@@ -3,6 +3,6 @@
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "bullmq": "^5.69.3"
6
+ "bullmq": "^5.70.2"
7
7
  }
8
8
  }