@techstream/quark-create-app 1.7.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/README.md CHANGED
@@ -42,6 +42,67 @@ Aliases:
42
42
  - `create-quark-app`
43
43
  - `quark-update`
44
44
 
45
+ ## Usage with Flags
46
+
47
+ The CLI supports non-interactive mode with custom options for automation and CI/CD workflows.
48
+
49
+ ### Non-Interactive Mode
50
+
51
+ Skip all interactive prompts and use defaults:
52
+
53
+ ```bash
54
+ # Create project without prompts
55
+ npx @techstream/quark-create-app my-app --no-prompts
56
+ ```
57
+
58
+ ### Custom Features
59
+
60
+ Specify which optional packages to include (default: `ui,jobs`):
61
+
62
+ ```bash
63
+ # Only include UI package
64
+ npx @techstream/quark-create-app my-app --no-prompts --features ui
65
+
66
+ # Include both UI and Jobs
67
+ npx @techstream/quark-create-app my-app --no-prompts --features ui,jobs
68
+
69
+ # Minimal setup (no optional packages)
70
+ npx @techstream/quark-create-app my-app --no-prompts --features ""
71
+ ```
72
+
73
+ ### Skip Installation Steps
74
+
75
+ Create the project structure without running package installation:
76
+
77
+ ```bash
78
+ # Create project but skip pnpm install
79
+ npx @techstream/quark-create-app my-app --no-prompts --skip-install
80
+
81
+ # Useful for CI/CD where you'll install dependencies separately
82
+ ```
83
+
84
+ ### Docker Cleanup
85
+
86
+ Control whether to remove Docker volumes from previous cleanup:
87
+
88
+ ```bash
89
+ # Keep Docker working directories (useful in CI/CD)
90
+ npx @techstream/quark-create-app my-app --no-prompts --skip-docker
91
+ ```
92
+
93
+ ### Complete Example: Full Automation
94
+
95
+ ```bash
96
+ # Create, install, and setup everything automatically
97
+ npx @techstream/quark-create-app my-app \
98
+ --no-prompts \
99
+ --features ui,jobs \
100
+ && cd my-app \
101
+ && docker compose up -d \
102
+ && pnpm db:migrate \
103
+ && pnpm dev
104
+ ```
105
+
45
106
  ## Common Tasks
46
107
 
47
108
  - **Update Quark packages**: `quark-update` or `pnpm update @techstream/quark-*`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techstream/quark-create-app",
3
- "version": "1.7.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,10 +30,13 @@
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
+ "test:e2e:full": "node test-e2e-full.js",
36
37
  "test:integration": "node test-integration.js",
37
- "test:all": "node test-all.js"
38
+ "test:flags": "node --test test-flags.js",
39
+ "test:all": "node test-all.js",
40
+ "check:perf": "node scripts/check-e2e-perf.js"
38
41
  }
39
42
  }
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");
@@ -254,7 +255,15 @@ function validateProjectName(name) {
254
255
 
255
256
  program
256
257
  .argument("<project-name>", "Name of the project to create")
257
- .action(async (projectName) => {
258
+ .option(
259
+ "--no-prompts",
260
+ "Skip interactive prompts and use default/provided values",
261
+ )
262
+ .option(
263
+ "--features <features>",
264
+ "Comma-separated list of optional features to include (ui,jobs)",
265
+ )
266
+ .action(async (projectName, options) => {
258
267
  console.log(
259
268
  chalk.blue.bold(
260
269
  `\n\uD83D\uDE80 Creating your new Quark project: ${projectName}\n`,
@@ -263,6 +272,8 @@ program
263
272
 
264
273
  const targetDir = validateProjectName(projectName);
265
274
  const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "");
275
+ const appDisplayName = formatProjectDisplayName(projectName);
276
+ const appDescription = `${appDisplayName} application`;
266
277
 
267
278
  // Clean up orphaned Docker volumes from a previous project with the same name.
268
279
  // Docker Compose names volumes as "<project>_postgres_data", "<project>_redis_data".
@@ -314,16 +325,25 @@ program
314
325
 
315
326
  // Check if directory already exists
316
327
  if (await fs.pathExists(targetDir)) {
317
- const { overwrite } = await prompts({
318
- type: "confirm",
319
- name: "overwrite",
320
- message: `Directory "${projectName}" already exists. Remove it and recreate?`,
321
- initial: false,
322
- });
328
+ if (!options.prompts) {
329
+ // In non-interactive mode, automatically remove existing directory
330
+ console.log(
331
+ chalk.yellow(
332
+ ` Directory "${projectName}" already exists. Removing... (non-interactive mode)`,
333
+ ),
334
+ );
335
+ } else {
336
+ const { overwrite } = await prompts({
337
+ type: "confirm",
338
+ name: "overwrite",
339
+ message: `Directory "${projectName}" already exists. Remove it and recreate?`,
340
+ initial: false,
341
+ });
323
342
 
324
- if (!overwrite) {
325
- console.log(chalk.yellow("Aborted."));
326
- process.exit(1);
343
+ if (!overwrite) {
344
+ console.log(chalk.yellow("Aborted."));
345
+ process.exit(1);
346
+ }
327
347
  }
328
348
 
329
349
  // Stop any running Docker containers for this project
@@ -379,35 +399,72 @@ program
379
399
  }
380
400
 
381
401
  // Step 5: Ask which optional features to eject
382
- console.log(chalk.cyan("\n 🎯 Configuring optional features...\n"));
383
- const response = await prompts([
384
- {
385
- type: "multiselect",
386
- name: "features",
387
- message: "Which optional packages would you like to include?",
388
- instructions: false,
389
- choices: [
390
- {
391
- title: "UI Components (packages/ui)",
392
- value: "ui",
393
- selected: true,
394
- },
395
- {
396
- title: "Job Definitions (packages/jobs)",
397
- value: "jobs",
398
- selected: true,
399
- },
400
- ],
401
- },
402
- ]);
402
+ let features;
403
+ if (!options.prompts && options.features) {
404
+ // Parse features from CLI flag
405
+ console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
406
+ const validFeatures = ["ui", "jobs"];
407
+ features = options.features
408
+ .split(",")
409
+ .map((f) => f.trim())
410
+ .filter((f) => f.length > 0);
411
+
412
+ // Validate features
413
+ const invalidFeatures = features.filter(
414
+ (f) => !validFeatures.includes(f),
415
+ );
416
+ if (invalidFeatures.length > 0) {
417
+ throw new Error(
418
+ `Invalid features: ${invalidFeatures.join(", ")}. Valid options are: ${validFeatures.join(", ")}`,
419
+ );
420
+ }
421
+
422
+ console.log(
423
+ chalk.green(
424
+ ` Selected features: ${features.join(", ") || "none"} (non-interactive mode)`,
425
+ ),
426
+ );
427
+ } else if (!options.prompts) {
428
+ // Use defaults when --no-prompts is set without --features
429
+ console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
430
+ features = ["ui", "jobs"]; // Default to both
431
+ console.log(
432
+ chalk.green(
433
+ ` Using default features: ${features.join(", ")} (non-interactive mode)`,
434
+ ),
435
+ );
436
+ } else {
437
+ // Interactive prompt
438
+ console.log(chalk.cyan("\n 🎯 Configuring optional features...\n"));
439
+ const response = await prompts([
440
+ {
441
+ type: "multiselect",
442
+ name: "features",
443
+ message: "Which optional packages would you like to include?",
444
+ instructions: false,
445
+ choices: [
446
+ {
447
+ title: "UI Components (packages/ui)",
448
+ value: "ui",
449
+ selected: true,
450
+ },
451
+ {
452
+ title: "Job Definitions (packages/jobs)",
453
+ value: "jobs",
454
+ selected: true,
455
+ },
456
+ ],
457
+ },
458
+ ]);
403
459
 
404
- const { features } = response;
460
+ features = response.features;
405
461
 
406
- // Handle prompt cancellation (Ctrl+C)
407
- if (!features) {
408
- console.log(chalk.yellow("\n\u26A0\uFE0F Setup cancelled."));
409
- await fs.remove(targetDir);
410
- process.exit(0);
462
+ // Handle prompt cancellation (Ctrl+C)
463
+ if (!features) {
464
+ console.log(chalk.yellow("\n\u26A0\uFE0F Setup cancelled."));
465
+ await fs.remove(targetDir);
466
+ process.exit(0);
467
+ }
411
468
  }
412
469
 
413
470
  // Step 6: Copy selected optional packages
@@ -474,7 +531,11 @@ program
474
531
 
475
532
  // Step 8: Create .env.example file
476
533
  console.log(chalk.cyan("\n 📋 Creating environment configuration..."));
477
- 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 ---
478
539
  # Supported: development, test, staging, production (default: development)
479
540
  # NODE_ENV=development
480
541
 
@@ -490,6 +551,10 @@ POSTGRES_DB=${scope}_dev
490
551
  # Optional: Set DATABASE_URL to override the dynamic construction above
491
552
  # DATABASE_URL="postgresql://${scope}_user:CHANGE_ME_TO_STRONG_PASSWORD@localhost:5432/${scope}_dev?schema=public"
492
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
+
493
558
  # --- Redis Configuration ---
494
559
  REDIS_HOST=localhost
495
560
  REDIS_PORT=6379
@@ -497,6 +562,9 @@ REDIS_PORT=6379
497
562
  # REDIS_URL="redis://localhost:6379"
498
563
 
499
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
+
500
568
  # Development: Mailpit local SMTP (defaults below work with docker-compose)
501
569
  MAIL_HOST=localhost
502
570
  MAIL_SMTP_PORT=1025
@@ -509,12 +577,20 @@ MAIL_UI_PORT=8025
509
577
  # SMTP_USER=your_smtp_user
510
578
  # SMTP_PASSWORD=your_smtp_password
511
579
 
512
- # --- Email Provider ---
513
- # Provider: "smtp" (default) or "resend"
580
+ # --- Email Provider Selection ---
581
+ # Choose one: "smtp" (default), "resend", or "zeptomail"
514
582
  # EMAIL_PROVIDER=smtp
515
583
  # EMAIL_FROM=App Name <noreply@yourdomain.com>
516
584
 
517
- # 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)
518
594
  # Get your API key at: https://resend.com/api-keys
519
595
  # RESEND_API_KEY=re_xxxxxxxxxxxxx
520
596
 
@@ -525,14 +601,21 @@ MAIL_UI_PORT=8025
525
601
 
526
602
  # --- Application Identity ---
527
603
  # APP_NAME is used in metadata, emails, and page titles.
528
- APP_NAME=${projectName}
604
+ # ⚠️ APP_DESCRIPTION affects SEO snippets — update before production.
605
+ APP_NAME=${appDisplayName}
606
+ APP_DESCRIPTION=${appDescription}
529
607
 
530
608
  # --- NextAuth Configuration ---
531
609
  # ⚠️ CRITICAL: Generate a secure secret with: openssl rand -base64 32
532
610
  # This secret is used to encrypt JWT tokens and session data
533
611
  NEXTAUTH_SECRET=CHANGE_ME_TO_STRONG_SECRET
534
612
 
535
- # --- 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.
536
619
  # GitHub OAuth - Get credentials at: https://github.com/settings/developers
537
620
  # GITHUB_ID=your_github_client_id
538
621
  # GITHUB_SECRET=your_github_client_secret
@@ -633,7 +716,10 @@ MAIL_SMTP_PORT=${mailSmtpPort}
633
716
  MAIL_UI_PORT=${mailUiPort}
634
717
 
635
718
  # --- Application Identity ---
636
- 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}
637
723
 
638
724
  # --- NextAuth Configuration ---
639
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
  }
@@ -6,6 +6,7 @@
6
6
  "watchPatterns": ["apps/web/**", "packages/**"]
7
7
  },
8
8
  "deploy": {
9
+ "releaseCommand": "pnpm --filter @techstream/quark-db exec prisma migrate deploy",
9
10
  "startCommand": "node apps/web/.next/standalone/server.js",
10
11
  "healthcheckPath": "/api/health",
11
12
  "healthcheckTimeout": 30,
@@ -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
+ }