@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 +61 -0
- package/package.json +7 -4
- package/src/index.js +130 -44
- 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/railway.json +1 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +29 -1
- package/templates/base-project/apps/worker/README.md +690 -0
- package/templates/base-project/apps/worker/package.json +3 -2
- package/templates/base-project/apps/worker/src/index.js +190 -5
- package/templates/base-project/apps/worker/src/index.test.js +278 -0
- package/templates/base-project/package.json +14 -1
- package/templates/base-project/packages/db/package.json +4 -7
- package/templates/base-project/packages/db/prisma/seed.js +119 -0
- package/templates/base-project/packages/db/prisma.config.ts +1 -0
- package/templates/config/src/index.js +2 -4
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +1 -1
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.
|
|
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,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:
|
|
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
|
-
.
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
460
|
+
features = response.features;
|
|
405
461
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 = `#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|