@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 +3 -3
- package/src/index.js +39 -7
- package/src/utils.js +36 -0
- package/src/utils.test.js +63 -0
- package/templates/base-project/.github/workflows/release.yml +37 -8
- package/templates/base-project/apps/web/package.json +7 -5
- package/templates/base-project/apps/web/src/app/api/health/route.js +29 -1
- package/templates/base-project/apps/worker/package.json +2 -2
- package/templates/base-project/package.json +14 -1
- package/templates/base-project/packages/db/package.json +4 -4
- package/templates/config/src/index.js +1 -3
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techstream/quark-create-app",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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 = `#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
7
|
-
description: "
|
|
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:
|
|
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
|
-
|
|
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:
|
|
38
|
-
|
|
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.
|
|
21
|
+
"@prisma/client": "^7.4.2",
|
|
20
22
|
"next": "16.1.6",
|
|
21
23
|
"next-auth": "5.0.0-beta.30",
|
|
22
|
-
"pg": "^8.
|
|
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.
|
|
29
|
-
"@types/node": "^25.
|
|
30
|
-
"tailwindcss": "^4.2.
|
|
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.
|
|
23
|
+
"bullmq": "^5.70.2"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@types/node": "^25.
|
|
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.
|
|
24
|
+
"prisma": "^7.4.2"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@prisma/adapter-pg": "^7.4.
|
|
28
|
-
"@prisma/client": "^7.4.
|
|
29
|
-
"pg": "^8.
|
|
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 "
|
|
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: {
|
|
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
|
-
|
|
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}`);
|