@techstream/quark-create-app 1.8.0 → 1.10.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 +2 -2
- package/package.json +3 -3
- package/src/index.js +415 -150
- package/src/utils.js +36 -0
- package/src/utils.test.js +63 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.github/copilot-instructions.md +55 -0
- package/templates/base-project/.github/workflows/release.yml +37 -8
- package/templates/base-project/CLAUDE.md +273 -0
- package/templates/base-project/README.md +72 -30
- package/templates/base-project/apps/web/next.config.js +5 -1
- package/templates/base-project/apps/web/package.json +7 -5
- package/templates/base-project/apps/web/public/quark.svg +46 -0
- package/templates/base-project/apps/web/railway.json +2 -2
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +56 -17
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +53 -0
- package/templates/base-project/apps/web/src/app/globals.css +121 -15
- package/templates/base-project/apps/web/src/app/icon.svg +46 -0
- package/templates/base-project/apps/web/src/app/layout.js +1 -0
- package/templates/base-project/apps/web/src/app/not-found.js +35 -0
- package/templates/base-project/apps/web/src/app/page.js +38 -5
- package/templates/base-project/apps/web/src/lib/theme.js +23 -0
- package/templates/base-project/apps/web/src/proxy.js +10 -2
- package/templates/base-project/package.json +16 -1
- package/templates/base-project/packages/db/package.json +4 -4
- package/templates/base-project/packages/db/src/client.js +6 -1
- package/templates/base-project/packages/db/src/index.js +1 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/scripts/doctor.js +261 -0
- package/templates/base-project/turbo.json +2 -1
- package/templates/config/package.json +1 -0
- package/templates/config/src/index.js +1 -3
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +2 -1
- package/templates/ui/README.md +67 -0
- package/templates/ui/package.json +1 -0
- package/templates/ui/src/badge.js +32 -0
- package/templates/ui/src/badge.test.js +42 -0
- package/templates/ui/src/button.js +64 -15
- package/templates/ui/src/button.test.js +34 -5
- package/templates/ui/src/card.js +58 -0
- package/templates/ui/src/card.test.js +59 -0
- package/templates/ui/src/checkbox.js +35 -0
- package/templates/ui/src/checkbox.test.js +35 -0
- package/templates/ui/src/dialog.js +139 -0
- package/templates/ui/src/dialog.test.js +15 -0
- package/templates/ui/src/index.js +16 -0
- package/templates/ui/src/input.js +15 -0
- package/templates/ui/src/input.test.js +27 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/label.test.js +22 -0
- package/templates/ui/src/select.js +42 -0
- package/templates/ui/src/select.test.js +27 -0
- package/templates/ui/src/skeleton.js +14 -0
- package/templates/ui/src/skeleton.test.js +22 -0
- package/templates/ui/src/table.js +75 -0
- package/templates/ui/src/table.test.js +69 -0
- package/templates/ui/src/textarea.js +15 -0
- package/templates/ui/src/textarea.test.js +27 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +132 -0
- package/templates/ui/src/toast.js +229 -0
- package/templates/ui/src/toast.test.js +23 -0
- package/templates/{base-project/apps/worker → worker}/package.json +2 -2
- package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
- package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
- package/templates/base-project/apps/web/public/file.svg +0 -1
- package/templates/base-project/apps/web/public/globe.svg +0 -1
- package/templates/base-project/apps/web/public/next.svg +0 -1
- package/templates/base-project/apps/web/public/vercel.svg +0 -1
- package/templates/base-project/apps/web/public/window.svg +0 -1
- /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
- /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
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");
|
|
@@ -189,13 +190,42 @@ function replaceDepsScope(deps, scope, selectedPackages) {
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Remove unselected optional feature entries from next.config.js transpilePackages.
|
|
195
|
+
* Must run AFTER replaceImportsInSourceFiles so package names are already scoped.
|
|
196
|
+
*/
|
|
197
|
+
async function patchNextConfig(webDir, scope, selectedFeatures) {
|
|
198
|
+
const configPath = path.join(webDir, "next.config.js");
|
|
199
|
+
if (!(await fs.pathExists(configPath))) return;
|
|
200
|
+
|
|
201
|
+
let content = await fs.readFile(configPath, "utf-8");
|
|
202
|
+
|
|
203
|
+
const optionalEntries = {
|
|
204
|
+
ui: `@${scope}/ui`,
|
|
205
|
+
jobs: `@${scope}/jobs`,
|
|
206
|
+
admin: `@${scope}/admin`,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const [feature, pkg] of Object.entries(optionalEntries)) {
|
|
210
|
+
if (!selectedFeatures.includes(feature)) {
|
|
211
|
+
// Remove the line containing this package from transpilePackages
|
|
212
|
+
content = content.replace(
|
|
213
|
+
new RegExp(`[ \\t]*"${pkg.replace("/", "\\/")}",?\\n`, "g"),
|
|
214
|
+
"",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await fs.writeFile(configPath, content);
|
|
220
|
+
}
|
|
221
|
+
|
|
192
222
|
/**
|
|
193
223
|
* Replace @techstream/quark-* import paths in all .js source files
|
|
194
224
|
* for workspace packages (db, jobs, ui, config) with @scope/* equivalents.
|
|
195
225
|
* Registry packages (@techstream/quark-core) are left untouched.
|
|
196
226
|
*/
|
|
197
227
|
async function replaceImportsInSourceFiles(dir, scope) {
|
|
198
|
-
const workspacePackages = ["db", "jobs", "ui", "config"];
|
|
228
|
+
const workspacePackages = ["db", "jobs", "ui", "config", "admin"];
|
|
199
229
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
200
230
|
|
|
201
231
|
for (const entry of entries) {
|
|
@@ -260,8 +290,10 @@ program
|
|
|
260
290
|
)
|
|
261
291
|
.option(
|
|
262
292
|
"--features <features>",
|
|
263
|
-
"Comma-separated list of optional features to include (ui,jobs)",
|
|
293
|
+
"Comma-separated list of optional features to include (ui,jobs,admin)",
|
|
264
294
|
)
|
|
295
|
+
.option("--skip-install", "Skip pnpm install and Prisma generate steps")
|
|
296
|
+
.option("--skip-docker", "Skip Docker orphan-volume cleanup")
|
|
265
297
|
.action(async (projectName, options) => {
|
|
266
298
|
console.log(
|
|
267
299
|
chalk.blue.bold(
|
|
@@ -271,54 +303,57 @@ program
|
|
|
271
303
|
|
|
272
304
|
const targetDir = validateProjectName(projectName);
|
|
273
305
|
const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
306
|
+
const appDisplayName = formatProjectDisplayName(projectName);
|
|
307
|
+
const appDescription = `${appDisplayName} application`;
|
|
274
308
|
|
|
275
309
|
// Clean up orphaned Docker volumes from a previous project with the same name.
|
|
276
310
|
// Docker Compose names volumes as "<project>_postgres_data", "<project>_redis_data".
|
|
277
311
|
// These persist even if the project directory is manually deleted, causing
|
|
278
312
|
// authentication failures when the new project generates different credentials.
|
|
279
313
|
// We also need to stop any running containers that reference these volumes.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
"
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
`name=${volumePrefix}`,
|
|
287
|
-
"--format",
|
|
288
|
-
"{{.Name}}",
|
|
289
|
-
]);
|
|
290
|
-
const orphanedVolumes = stdout
|
|
291
|
-
.split("\n")
|
|
292
|
-
.filter((v) => v.startsWith(volumePrefix));
|
|
293
|
-
if (orphanedVolumes.length > 0) {
|
|
294
|
-
// Stop and remove any containers using these volumes first
|
|
295
|
-
const { stdout: containerOut } = await execa("docker", [
|
|
296
|
-
"ps",
|
|
297
|
-
"-a",
|
|
314
|
+
if (!options.skipDocker)
|
|
315
|
+
try {
|
|
316
|
+
const volumePrefix = `${projectName}_`;
|
|
317
|
+
const { stdout } = await execa("docker", [
|
|
318
|
+
"volume",
|
|
319
|
+
"ls",
|
|
298
320
|
"--filter",
|
|
299
|
-
`name=${
|
|
321
|
+
`name=${volumePrefix}`,
|
|
300
322
|
"--format",
|
|
301
|
-
"{{.
|
|
323
|
+
"{{.Name}}",
|
|
302
324
|
]);
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
325
|
+
const orphanedVolumes = stdout
|
|
326
|
+
.split("\n")
|
|
327
|
+
.filter((v) => v.startsWith(volumePrefix));
|
|
328
|
+
if (orphanedVolumes.length > 0) {
|
|
329
|
+
// Stop and remove any containers using these volumes first
|
|
330
|
+
const { stdout: containerOut } = await execa("docker", [
|
|
331
|
+
"ps",
|
|
332
|
+
"-a",
|
|
333
|
+
"--filter",
|
|
334
|
+
`name=${projectName}`,
|
|
335
|
+
"--format",
|
|
336
|
+
"{{.ID}}",
|
|
337
|
+
]);
|
|
338
|
+
const containers = containerOut.split("\n").filter(Boolean);
|
|
339
|
+
if (containers.length > 0) {
|
|
340
|
+
await execa("docker", ["rm", "-f", ...containers]);
|
|
341
|
+
}
|
|
342
|
+
// Remove the Docker network if it exists
|
|
343
|
+
try {
|
|
344
|
+
await execa("docker", ["network", "rm", `${projectName}_default`]);
|
|
345
|
+
} catch {
|
|
346
|
+
// Network may not exist — fine
|
|
347
|
+
}
|
|
348
|
+
// Now remove the orphaned volumes
|
|
349
|
+
for (const vol of orphanedVolumes) {
|
|
350
|
+
await execa("docker", ["volume", "rm", "-f", vol]);
|
|
351
|
+
}
|
|
352
|
+
console.log(chalk.green(" ✓ Cleaned up orphaned Docker volumes"));
|
|
316
353
|
}
|
|
317
|
-
|
|
354
|
+
} catch {
|
|
355
|
+
// Docker not available — fine
|
|
318
356
|
}
|
|
319
|
-
} catch {
|
|
320
|
-
// Docker not available — fine
|
|
321
|
-
}
|
|
322
357
|
|
|
323
358
|
// Check if directory already exists
|
|
324
359
|
if (await fs.pathExists(targetDir)) {
|
|
@@ -400,7 +435,7 @@ program
|
|
|
400
435
|
if (!options.prompts && options.features) {
|
|
401
436
|
// Parse features from CLI flag
|
|
402
437
|
console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
|
|
403
|
-
const validFeatures = ["ui", "jobs"];
|
|
438
|
+
const validFeatures = ["ui", "jobs", "admin"];
|
|
404
439
|
features = options.features
|
|
405
440
|
.split(",")
|
|
406
441
|
.map((f) => f.trim())
|
|
@@ -416,6 +451,11 @@ program
|
|
|
416
451
|
);
|
|
417
452
|
}
|
|
418
453
|
|
|
454
|
+
// Enforce admin dependencies: admin requires ui
|
|
455
|
+
if (features.includes("admin") && !features.includes("ui")) {
|
|
456
|
+
features.push("ui");
|
|
457
|
+
}
|
|
458
|
+
|
|
419
459
|
console.log(
|
|
420
460
|
chalk.green(
|
|
421
461
|
` Selected features: ${features.join(", ") || "none"} (non-interactive mode)`,
|
|
@@ -424,7 +464,7 @@ program
|
|
|
424
464
|
} else if (!options.prompts) {
|
|
425
465
|
// Use defaults when --no-prompts is set without --features
|
|
426
466
|
console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
|
|
427
|
-
features = ["ui", "jobs"]; // Default to
|
|
467
|
+
features = ["ui", "jobs"]; // Default to ui + jobs (not admin)
|
|
428
468
|
console.log(
|
|
429
469
|
chalk.green(
|
|
430
470
|
` Using default features: ${features.join(", ")} (non-interactive mode)`,
|
|
@@ -446,10 +486,15 @@ program
|
|
|
446
486
|
selected: true,
|
|
447
487
|
},
|
|
448
488
|
{
|
|
449
|
-
title: "
|
|
489
|
+
title: "Background Jobs (packages/jobs + apps/worker)",
|
|
450
490
|
value: "jobs",
|
|
451
491
|
selected: true,
|
|
452
492
|
},
|
|
493
|
+
{
|
|
494
|
+
title: "Admin Dashboard (packages/admin) [requires: ui]",
|
|
495
|
+
value: "admin",
|
|
496
|
+
selected: false,
|
|
497
|
+
},
|
|
453
498
|
],
|
|
454
499
|
},
|
|
455
500
|
]);
|
|
@@ -462,6 +507,14 @@ program
|
|
|
462
507
|
await fs.remove(targetDir);
|
|
463
508
|
process.exit(0);
|
|
464
509
|
}
|
|
510
|
+
|
|
511
|
+
// Enforce admin dependencies: admin requires ui
|
|
512
|
+
if (features.includes("admin") && !features.includes("ui")) {
|
|
513
|
+
features.push("ui");
|
|
514
|
+
console.log(
|
|
515
|
+
chalk.yellow(" ℹ Admin requires UI — automatically included."),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
465
518
|
}
|
|
466
519
|
|
|
467
520
|
// Step 6: Copy selected optional packages
|
|
@@ -469,6 +522,17 @@ program
|
|
|
469
522
|
console.log(chalk.cyan("\n 📋 Setting up optional packages..."));
|
|
470
523
|
|
|
471
524
|
for (const feature of features) {
|
|
525
|
+
// admin is a package template — skip if no template exists yet
|
|
526
|
+
const templatePath = path.join(templatesDir, feature);
|
|
527
|
+
if (!(await fs.pathExists(templatePath))) {
|
|
528
|
+
console.log(
|
|
529
|
+
chalk.yellow(
|
|
530
|
+
` ⚠ ${feature} (template not yet available — skipped)`,
|
|
531
|
+
),
|
|
532
|
+
);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
472
536
|
const packageDir = path.join(targetDir, "packages", feature);
|
|
473
537
|
await fs.ensureDir(packageDir);
|
|
474
538
|
await copyTemplate(feature, packageDir);
|
|
@@ -479,6 +543,14 @@ program
|
|
|
479
543
|
|
|
480
544
|
console.log(chalk.green(` ✓ ${feature}`));
|
|
481
545
|
}
|
|
546
|
+
|
|
547
|
+
// If jobs selected, also scaffold apps/worker from its template
|
|
548
|
+
if (features.includes("jobs")) {
|
|
549
|
+
const workerDir = path.join(targetDir, "apps", "worker");
|
|
550
|
+
await fs.ensureDir(workerDir);
|
|
551
|
+
await copyTemplate("worker", workerDir);
|
|
552
|
+
console.log(chalk.green(` ✓ worker (paired with jobs)`));
|
|
553
|
+
}
|
|
482
554
|
}
|
|
483
555
|
|
|
484
556
|
// Step 7: Update all package.json dependencies to use correct scope
|
|
@@ -487,7 +559,10 @@ program
|
|
|
487
559
|
// Collect all package.json files that need scope replacement (apps + packages)
|
|
488
560
|
const allPkgPaths = [
|
|
489
561
|
path.join(targetDir, "apps", "web", "package.json"),
|
|
490
|
-
|
|
562
|
+
// Worker is only present when jobs is selected
|
|
563
|
+
...(features.includes("jobs")
|
|
564
|
+
? [path.join(targetDir, "apps", "worker", "package.json")]
|
|
565
|
+
: []),
|
|
491
566
|
// Also update cross-dependencies in scaffolded packages (e.g. db → config)
|
|
492
567
|
...["db", ...features].map((pkg) =>
|
|
493
568
|
path.join(targetDir, "packages", pkg, "package.json"),
|
|
@@ -526,9 +601,52 @@ program
|
|
|
526
601
|
await replaceImportsInSourceFiles(targetDir, scope);
|
|
527
602
|
console.log(chalk.green(` ✓ Import paths updated`));
|
|
528
603
|
|
|
604
|
+
// Step 7c: Patch next.config.js to remove unselected feature transpilePackages entries
|
|
605
|
+
await patchNextConfig(
|
|
606
|
+
path.join(targetDir, "apps", "web"),
|
|
607
|
+
scope,
|
|
608
|
+
features,
|
|
609
|
+
);
|
|
610
|
+
console.log(chalk.green(` ✓ next.config.js patched`));
|
|
611
|
+
|
|
612
|
+
// Step 7d: Strip jobs-related code from register route when jobs not selected
|
|
613
|
+
if (!features.includes("jobs")) {
|
|
614
|
+
const registerPath = path.join(
|
|
615
|
+
targetDir,
|
|
616
|
+
"apps",
|
|
617
|
+
"web",
|
|
618
|
+
"src",
|
|
619
|
+
"app",
|
|
620
|
+
"api",
|
|
621
|
+
"auth",
|
|
622
|
+
"register",
|
|
623
|
+
"route.js",
|
|
624
|
+
);
|
|
625
|
+
if (await fs.pathExists(registerPath)) {
|
|
626
|
+
let content = await fs.readFile(registerPath, "utf-8");
|
|
627
|
+
// Remove the jobs import line
|
|
628
|
+
content = content.replace(
|
|
629
|
+
/import\s*\{[^}]*\}\s*from\s*["']@[^/]+\/jobs["'];?\n/,
|
|
630
|
+
"",
|
|
631
|
+
);
|
|
632
|
+
// Remove createQueue from the core import if present
|
|
633
|
+
content = content.replace(/\tcreateQueue,\n/, "");
|
|
634
|
+
// Remove the @quark:start:jobs ... @quark:end:jobs block (inclusive)
|
|
635
|
+
content = content.replace(
|
|
636
|
+
/[ \t]*\/\/ @quark:start:jobs[\s\S]*?\/\/ @quark:end:jobs\n?/,
|
|
637
|
+
"",
|
|
638
|
+
);
|
|
639
|
+
await fs.writeFile(registerPath, content);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
529
643
|
// Step 8: Create .env.example file
|
|
530
644
|
console.log(chalk.cyan("\n 📋 Creating environment configuration..."));
|
|
531
|
-
const envExampleTemplate = `#
|
|
645
|
+
const envExampleTemplate = `# ⚠️ IMPORTANT: Copy this file to .env and fill in the values for your environment.
|
|
646
|
+
# NEVER commit the .env file to version control — it contains secrets!
|
|
647
|
+
# $ cp .env.example .env
|
|
648
|
+
|
|
649
|
+
# --- Environment ---
|
|
532
650
|
# Supported: development, test, staging, production (default: development)
|
|
533
651
|
# NODE_ENV=development
|
|
534
652
|
|
|
@@ -544,6 +662,10 @@ POSTGRES_DB=${scope}_dev
|
|
|
544
662
|
# Optional: Set DATABASE_URL to override the dynamic construction above
|
|
545
663
|
# DATABASE_URL="postgresql://${scope}_user:CHANGE_ME_TO_STRONG_PASSWORD@localhost:5432/${scope}_dev?schema=public"
|
|
546
664
|
|
|
665
|
+
# --- Database Pool Configuration ---
|
|
666
|
+
# Connection pool settings are managed automatically. Customize if needed:
|
|
667
|
+
# For advanced tuning, see Prisma connection pool documentation.
|
|
668
|
+
|
|
547
669
|
# --- Redis Configuration ---
|
|
548
670
|
REDIS_HOST=localhost
|
|
549
671
|
REDIS_PORT=6379
|
|
@@ -551,6 +673,9 @@ REDIS_PORT=6379
|
|
|
551
673
|
# REDIS_URL="redis://localhost:6379"
|
|
552
674
|
|
|
553
675
|
# --- Mail Configuration ---
|
|
676
|
+
# Email can be sent via SMTP (local or production), Resend, or Zeptomail.
|
|
677
|
+
# Choose one provider below based on your needs.
|
|
678
|
+
|
|
554
679
|
# Development: Mailpit local SMTP (defaults below work with docker-compose)
|
|
555
680
|
MAIL_HOST=localhost
|
|
556
681
|
MAIL_SMTP_PORT=1025
|
|
@@ -563,12 +688,20 @@ MAIL_UI_PORT=8025
|
|
|
563
688
|
# SMTP_USER=your_smtp_user
|
|
564
689
|
# SMTP_PASSWORD=your_smtp_password
|
|
565
690
|
|
|
566
|
-
# --- Email Provider ---
|
|
567
|
-
#
|
|
691
|
+
# --- Email Provider Selection ---
|
|
692
|
+
# Choose one: "smtp" (default), "resend", or "zeptomail"
|
|
568
693
|
# EMAIL_PROVIDER=smtp
|
|
569
694
|
# EMAIL_FROM=App Name <noreply@yourdomain.com>
|
|
570
695
|
|
|
571
|
-
#
|
|
696
|
+
# Zeptomail (recommended for production)
|
|
697
|
+
# Get started at: https://www.zoho.com/zeptomail/
|
|
698
|
+
# Your token is shown in Zeptomail console and includes the "Zoho-enczapikey" prefix:
|
|
699
|
+
# e.g. ZEPTOMAIL_TOKEN=Zoho-enczapikey <your_key_here>
|
|
700
|
+
# ZEPTOMAIL_TOKEN=Zoho-enczapikey your_zeptomail_api_key
|
|
701
|
+
# ZEPTOMAIL_URL=https://api.zeptomail.com # Base URL; /v1.1/email is appended in code
|
|
702
|
+
# ZEPTOMAIL_BOUNCE_EMAIL=bounce@yourdomain.com # optional
|
|
703
|
+
|
|
704
|
+
# Resend (alternative provider)
|
|
572
705
|
# Get your API key at: https://resend.com/api-keys
|
|
573
706
|
# RESEND_API_KEY=re_xxxxxxxxxxxxx
|
|
574
707
|
|
|
@@ -579,14 +712,21 @@ MAIL_UI_PORT=8025
|
|
|
579
712
|
|
|
580
713
|
# --- Application Identity ---
|
|
581
714
|
# APP_NAME is used in metadata, emails, and page titles.
|
|
582
|
-
|
|
715
|
+
# ⚠️ APP_DESCRIPTION affects SEO snippets — update before production.
|
|
716
|
+
APP_NAME=${appDisplayName}
|
|
717
|
+
APP_DESCRIPTION=${appDescription}
|
|
583
718
|
|
|
584
719
|
# --- NextAuth Configuration ---
|
|
585
720
|
# ⚠️ CRITICAL: Generate a secure secret with: openssl rand -base64 32
|
|
586
721
|
# This secret is used to encrypt JWT tokens and session data
|
|
587
722
|
NEXTAUTH_SECRET=CHANGE_ME_TO_STRONG_SECRET
|
|
588
723
|
|
|
589
|
-
#
|
|
724
|
+
# NextAuth callback URL (auto-derived from APP_URL in development)
|
|
725
|
+
# In production, explicitly set this to your domain:
|
|
726
|
+
# NEXTAUTH_URL=https://yourdomain.com/api/auth
|
|
727
|
+
|
|
728
|
+
# --- OAuth Providers (Not Yet Implemented) ---
|
|
729
|
+
# OAuth support is planned for a future release.
|
|
590
730
|
# GitHub OAuth - Get credentials at: https://github.com/settings/developers
|
|
591
731
|
# GITHUB_ID=your_github_client_id
|
|
592
732
|
# GITHUB_SECRET=your_github_client_secret
|
|
@@ -687,7 +827,10 @@ MAIL_SMTP_PORT=${mailSmtpPort}
|
|
|
687
827
|
MAIL_UI_PORT=${mailUiPort}
|
|
688
828
|
|
|
689
829
|
# --- Application Identity ---
|
|
690
|
-
APP_NAME
|
|
830
|
+
# APP_NAME is used in metadata, emails, and page titles.
|
|
831
|
+
# ⚠️ APP_DESCRIPTION affects SEO snippets — update before production.
|
|
832
|
+
APP_NAME=${appDisplayName}
|
|
833
|
+
APP_DESCRIPTION=${appDescription}
|
|
691
834
|
|
|
692
835
|
# --- NextAuth Configuration ---
|
|
693
836
|
NEXTAUTH_SECRET=${nextAuthSecret}
|
|
@@ -718,6 +861,8 @@ STORAGE_PROVIDER=local
|
|
|
718
861
|
scaffoldedDate: new Date().toISOString(),
|
|
719
862
|
requiredPackages: ["db", "config"],
|
|
720
863
|
packages: features,
|
|
864
|
+
// Track that worker is paired with jobs (not independently selectable)
|
|
865
|
+
hasWorker: features.includes("jobs"),
|
|
721
866
|
};
|
|
722
867
|
await fs.writeFile(
|
|
723
868
|
path.join(targetDir, ".quark-link.json"),
|
|
@@ -725,8 +870,24 @@ STORAGE_PROVIDER=local
|
|
|
725
870
|
);
|
|
726
871
|
console.log(chalk.green(` ✓ .quark-link.json`));
|
|
727
872
|
|
|
728
|
-
// Step 10b: Generate
|
|
729
|
-
console.log(chalk.cyan("\n 🤖 Generating
|
|
873
|
+
// Step 10b + 10c: Generate all AI coding tool context files
|
|
874
|
+
console.log(chalk.cyan("\n 🤖 Generating AI context files..."));
|
|
875
|
+
|
|
876
|
+
// Build shared variable map (used by SKILL.md, CLAUDE.md, .cursor/rules, copilot-instructions)
|
|
877
|
+
const scaffoldDate = new Date().toISOString().split("T")[0];
|
|
878
|
+
const optionalLines = features
|
|
879
|
+
.map((f) => {
|
|
880
|
+
const labels = {
|
|
881
|
+
ui: "Shared UI components",
|
|
882
|
+
jobs: "Job queue definitions",
|
|
883
|
+
admin: "Auto-generated admin dashboard",
|
|
884
|
+
};
|
|
885
|
+
return `│ ├── ${f}/ # ${labels[f] || f}`;
|
|
886
|
+
})
|
|
887
|
+
.join("\n");
|
|
888
|
+
const optionalBlock = optionalLines ? `${optionalLines}\n` : "";
|
|
889
|
+
|
|
890
|
+
// Step 10b: Substitute variables in project-context SKILL.md
|
|
730
891
|
const skillPath = path.join(
|
|
731
892
|
targetDir,
|
|
732
893
|
".github",
|
|
@@ -736,34 +897,38 @@ STORAGE_PROVIDER=local
|
|
|
736
897
|
);
|
|
737
898
|
if (await fs.pathExists(skillPath)) {
|
|
738
899
|
let skillContent = await fs.readFile(skillPath, "utf-8");
|
|
739
|
-
|
|
740
|
-
// Build optional packages section
|
|
741
|
-
const optionalLines = features
|
|
742
|
-
.map((f) => {
|
|
743
|
-
const labels = {
|
|
744
|
-
ui: "Shared UI components",
|
|
745
|
-
jobs: "Job queue definitions",
|
|
746
|
-
};
|
|
747
|
-
return `│ ├── ${f}/ # ${labels[f] || f}`;
|
|
748
|
-
})
|
|
749
|
-
.join("\n");
|
|
750
|
-
const optionalBlock = optionalLines ? `${optionalLines}\n` : "";
|
|
751
|
-
|
|
752
900
|
skillContent = skillContent
|
|
753
901
|
.replace(/__QUARK_SCOPE__/g, scope)
|
|
754
902
|
.replace(/__QUARK_PROJECT_NAME__/g, projectName)
|
|
755
|
-
.replace(
|
|
756
|
-
/__QUARK_SCAFFOLD_DATE__/g,
|
|
757
|
-
new Date().toISOString().split("T")[0],
|
|
758
|
-
)
|
|
903
|
+
.replace(/__QUARK_SCAFFOLD_DATE__/g, scaffoldDate)
|
|
759
904
|
.replace(/__QUARK_OPTIONAL_PACKAGES__/g, optionalBlock);
|
|
760
|
-
|
|
761
905
|
await fs.writeFile(skillPath, skillContent);
|
|
762
906
|
console.log(
|
|
763
907
|
chalk.green(` ✓ .github/skills/project-context/SKILL.md`),
|
|
764
908
|
);
|
|
765
909
|
}
|
|
766
910
|
|
|
911
|
+
// Step 10c: Substitute variables in all AI coding tool context files
|
|
912
|
+
const aiContextFiles = [
|
|
913
|
+
"README.md",
|
|
914
|
+
"CLAUDE.md",
|
|
915
|
+
".cursor/rules/quark.mdc",
|
|
916
|
+
".github/copilot-instructions.md",
|
|
917
|
+
];
|
|
918
|
+
for (const relPath of aiContextFiles) {
|
|
919
|
+
const filePath = path.join(targetDir, relPath);
|
|
920
|
+
if (await fs.pathExists(filePath)) {
|
|
921
|
+
let content = await fs.readFile(filePath, "utf-8");
|
|
922
|
+
content = content
|
|
923
|
+
.replace(/__QUARK_SCOPE__/g, scope)
|
|
924
|
+
.replace(/__QUARK_PROJECT_NAME__/g, projectName)
|
|
925
|
+
.replace(/__QUARK_SCAFFOLD_DATE__/g, scaffoldDate)
|
|
926
|
+
.replace(/__QUARK_OPTIONAL_PACKAGES__/g, optionalBlock);
|
|
927
|
+
await fs.writeFile(filePath, content);
|
|
928
|
+
console.log(chalk.green(` ✓ ${relPath}`));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
767
932
|
// Step 11: Initialize git repository
|
|
768
933
|
console.log(chalk.cyan("\n 📝 Initializing git repository..."));
|
|
769
934
|
const gitInitialized = await initializeGit(targetDir);
|
|
@@ -772,41 +937,45 @@ STORAGE_PROVIDER=local
|
|
|
772
937
|
}
|
|
773
938
|
|
|
774
939
|
// Step 12: Run pnpm install
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
940
|
+
if (!options.skipInstall) {
|
|
941
|
+
console.log(chalk.cyan("\n 📦 Installing dependencies..."));
|
|
942
|
+
try {
|
|
943
|
+
await execa("pnpm", ["install"], {
|
|
944
|
+
cwd: targetDir,
|
|
945
|
+
stdio: "inherit",
|
|
946
|
+
});
|
|
947
|
+
console.log(chalk.green(`\n ✓ Dependencies installed`));
|
|
948
|
+
} catch (installError) {
|
|
949
|
+
console.warn(
|
|
950
|
+
chalk.yellow(
|
|
951
|
+
`\n ⚠️ pnpm install failed: ${installError.message}`,
|
|
952
|
+
),
|
|
953
|
+
);
|
|
954
|
+
console.warn(
|
|
955
|
+
chalk.yellow(
|
|
956
|
+
` Run 'pnpm install' manually after resolving the issue.`,
|
|
957
|
+
),
|
|
958
|
+
);
|
|
959
|
+
}
|
|
792
960
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
961
|
+
// Step 13: Generate Prisma client
|
|
962
|
+
console.log(chalk.cyan("\n 🗄️ Generating Prisma client..."));
|
|
963
|
+
try {
|
|
964
|
+
await execa("pnpm", ["--filter", "db", "db:generate"], {
|
|
965
|
+
cwd: targetDir,
|
|
966
|
+
stdio: "inherit",
|
|
967
|
+
});
|
|
968
|
+
console.log(chalk.green(` ✓ Prisma client generated`));
|
|
969
|
+
} catch (generateError) {
|
|
970
|
+
console.warn(
|
|
971
|
+
chalk.yellow(
|
|
972
|
+
`\n ⚠️ Prisma generate failed: ${generateError.message}`,
|
|
973
|
+
),
|
|
974
|
+
);
|
|
975
|
+
console.warn(
|
|
976
|
+
chalk.yellow(` Run 'pnpm --filter db db:generate' manually.`),
|
|
977
|
+
);
|
|
978
|
+
}
|
|
810
979
|
}
|
|
811
980
|
|
|
812
981
|
// Success message
|
|
@@ -817,7 +986,7 @@ STORAGE_PROVIDER=local
|
|
|
817
986
|
);
|
|
818
987
|
|
|
819
988
|
console.log(chalk.white(`📂 Project location: ${targetDir}\n`));
|
|
820
|
-
console.log(chalk.cyan("
|
|
989
|
+
console.log(chalk.cyan("Start here:"));
|
|
821
990
|
console.log(chalk.white(` 1. cd ${projectName}`));
|
|
822
991
|
console.log(chalk.white(` 2. docker compose up -d`));
|
|
823
992
|
console.log(chalk.white(` 3. pnpm db:migrate`));
|
|
@@ -829,20 +998,20 @@ STORAGE_PROVIDER=local
|
|
|
829
998
|
),
|
|
830
999
|
);
|
|
831
1000
|
|
|
832
|
-
console.log(chalk.cyan("
|
|
1001
|
+
console.log(chalk.cyan("Build with AI:"));
|
|
833
1002
|
console.log(
|
|
834
|
-
chalk.white(
|
|
835
|
-
` • Update Quark core with: pnpm update @techstream/quark-core`,
|
|
836
|
-
),
|
|
1003
|
+
chalk.white(` Open CLAUDE.md in your AI tool, then tell it:`),
|
|
837
1004
|
);
|
|
838
1005
|
console.log(
|
|
839
|
-
chalk.white(
|
|
1006
|
+
chalk.white(
|
|
1007
|
+
` "I'm building ${projectName} — [what it does]. Review CLAUDE.md and let's start."\n`,
|
|
1008
|
+
),
|
|
840
1009
|
);
|
|
841
1010
|
|
|
842
|
-
console.log(chalk.cyan("Learn more:"));
|
|
843
|
-
console.log(chalk.white(` 📖 Docs: https://github.com/Bobnoddle/quark`));
|
|
844
1011
|
console.log(
|
|
845
|
-
chalk.
|
|
1012
|
+
chalk.dim(
|
|
1013
|
+
` 📖 github.com/Bobnoddle/quark • Updates: npx @techstream/quark-create-app update\n`,
|
|
1014
|
+
),
|
|
846
1015
|
);
|
|
847
1016
|
} catch (error) {
|
|
848
1017
|
console.error(chalk.red(`\n✗ Error creating project: ${error.message}`));
|
|
@@ -857,13 +1026,55 @@ STORAGE_PROVIDER=local
|
|
|
857
1026
|
}
|
|
858
1027
|
});
|
|
859
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* Read the installed version of a package from node_modules.
|
|
1031
|
+
* Returns null if the package is not installed.
|
|
1032
|
+
* @param {string} cwd - Project root
|
|
1033
|
+
* @param {string} packageName - e.g. "@techstream/quark-core"
|
|
1034
|
+
* @returns {Promise<string|null>}
|
|
1035
|
+
*/
|
|
1036
|
+
async function getInstalledVersion(cwd, packageName) {
|
|
1037
|
+
try {
|
|
1038
|
+
const pkgPath = path.join(
|
|
1039
|
+
cwd,
|
|
1040
|
+
"node_modules",
|
|
1041
|
+
...packageName.split("/"),
|
|
1042
|
+
"package.json",
|
|
1043
|
+
);
|
|
1044
|
+
const { version } = await fs.readJSON(pkgPath);
|
|
1045
|
+
return version ?? null;
|
|
1046
|
+
} catch {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Fetch the latest published version of a package from the npm registry.
|
|
1053
|
+
* Returns null on network failure so the caller can degrade gracefully.
|
|
1054
|
+
* @param {string} packageName
|
|
1055
|
+
* @returns {Promise<string|null>}
|
|
1056
|
+
*/
|
|
1057
|
+
async function getLatestNpmVersion(packageName) {
|
|
1058
|
+
try {
|
|
1059
|
+
const { stdout } = await execa("npm", [
|
|
1060
|
+
"view",
|
|
1061
|
+
packageName,
|
|
1062
|
+
"version",
|
|
1063
|
+
"--json",
|
|
1064
|
+
]);
|
|
1065
|
+
return JSON.parse(stdout);
|
|
1066
|
+
} catch {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
860
1071
|
/**
|
|
861
1072
|
* quark-update command
|
|
862
1073
|
* Updates Quark core infrastructure in a scaffolded project
|
|
863
1074
|
*/
|
|
864
1075
|
program
|
|
865
1076
|
.command("update")
|
|
866
|
-
.description("Update Quark
|
|
1077
|
+
.description("Update Quark packages in the current project")
|
|
867
1078
|
.option("--check", "Check for updates without applying")
|
|
868
1079
|
.option("--force", "Skip safety checks")
|
|
869
1080
|
.action(async (options) => {
|
|
@@ -879,23 +1090,60 @@ program
|
|
|
879
1090
|
}
|
|
880
1091
|
|
|
881
1092
|
const quarkLink = await fs.readJSON(quarkLinkPath);
|
|
882
|
-
|
|
883
|
-
|
|
1093
|
+
|
|
1094
|
+
// The packages this command manages
|
|
1095
|
+
const MANAGED_PACKAGES = [
|
|
1096
|
+
"@techstream/quark-core",
|
|
1097
|
+
"@techstream/quark-create-app",
|
|
1098
|
+
];
|
|
1099
|
+
|
|
1100
|
+
// Snapshot installed versions before any update
|
|
1101
|
+
const before = {};
|
|
1102
|
+
for (const name of MANAGED_PACKAGES) {
|
|
1103
|
+
before[name] = await getInstalledVersion(process.cwd(), name);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
console.log(chalk.cyan(`Scaffolded: ${quarkLink.scaffoldedDate}`));
|
|
1107
|
+
console.log(
|
|
1108
|
+
chalk.cyan(
|
|
1109
|
+
`Installed core: ${before["@techstream/quark-core"] ?? quarkLink.quarkVersion ?? "unknown"}\n`,
|
|
1110
|
+
),
|
|
1111
|
+
);
|
|
884
1112
|
|
|
885
1113
|
if (options.check) {
|
|
886
|
-
|
|
887
|
-
console.log(
|
|
888
|
-
|
|
889
|
-
)
|
|
1114
|
+
// Query npm for latest versions and report the delta
|
|
1115
|
+
console.log(chalk.yellow("Checking npm registry for updates...\n"));
|
|
1116
|
+
let anyUpdates = false;
|
|
1117
|
+
for (const name of MANAGED_PACKAGES) {
|
|
1118
|
+
const installed = before[name];
|
|
1119
|
+
const latest = await getLatestNpmVersion(name);
|
|
1120
|
+
if (!latest) {
|
|
1121
|
+
console.log(chalk.dim(` ${name}: registry unreachable`));
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (!installed || installed === latest) {
|
|
1125
|
+
console.log(chalk.green(` ✓ ${name} ${latest} — up to date`));
|
|
1126
|
+
} else {
|
|
1127
|
+
console.log(
|
|
1128
|
+
chalk.yellow(` ↑ ${name}: ${installed} → ${chalk.bold(latest)}`),
|
|
1129
|
+
);
|
|
1130
|
+
anyUpdates = true;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (anyUpdates) {
|
|
1134
|
+
console.log(chalk.white("\n Run without --check to apply updates.\n"));
|
|
1135
|
+
} else {
|
|
1136
|
+
console.log(chalk.dim("\n Nothing to update.\n"));
|
|
1137
|
+
}
|
|
890
1138
|
return;
|
|
891
1139
|
}
|
|
892
1140
|
|
|
893
1141
|
try {
|
|
894
|
-
//
|
|
1142
|
+
// Guard: check for both unstaged and staged changes
|
|
895
1143
|
if (!options.force) {
|
|
896
|
-
console.log(chalk.yellow("⚠️ Checking for uncommitted changes..."));
|
|
897
1144
|
try {
|
|
898
|
-
await execa("git", ["diff", "--exit-code"], {
|
|
1145
|
+
await execa("git", ["diff", "--exit-code"], { cwd: process.cwd() });
|
|
1146
|
+
await execa("git", ["diff", "--cached", "--exit-code"], {
|
|
899
1147
|
cwd: process.cwd(),
|
|
900
1148
|
});
|
|
901
1149
|
} catch {
|
|
@@ -911,43 +1159,60 @@ program
|
|
|
911
1159
|
}
|
|
912
1160
|
}
|
|
913
1161
|
|
|
914
|
-
// Run pnpm update
|
|
915
|
-
console.log(chalk.cyan("
|
|
916
|
-
await execa("pnpm", ["update",
|
|
1162
|
+
// Run pnpm update for all managed packages
|
|
1163
|
+
console.log(chalk.cyan("📦 Updating Quark packages...\n"));
|
|
1164
|
+
await execa("pnpm", ["update", ...MANAGED_PACKAGES], {
|
|
917
1165
|
cwd: process.cwd(),
|
|
918
1166
|
stdio: "inherit",
|
|
919
1167
|
});
|
|
920
1168
|
|
|
921
|
-
//
|
|
922
|
-
|
|
1169
|
+
// Snapshot installed versions after update
|
|
1170
|
+
const after = {};
|
|
1171
|
+
for (const name of MANAGED_PACKAGES) {
|
|
1172
|
+
after[name] = await getInstalledVersion(process.cwd(), name);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Report the delta
|
|
1176
|
+
console.log(chalk.cyan("\n📋 Update summary:\n"));
|
|
1177
|
+
for (const name of MANAGED_PACKAGES) {
|
|
1178
|
+
const was = before[name];
|
|
1179
|
+
const now = after[name];
|
|
1180
|
+
if (!now) continue;
|
|
1181
|
+
if (was && was !== now) {
|
|
1182
|
+
console.log(chalk.green(` ✓ ${name}: ${was} → ${chalk.bold(now)}`));
|
|
1183
|
+
} else {
|
|
1184
|
+
console.log(chalk.dim(` · ${name}: ${now} (already current)`));
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Persist the new core version — keep previous on failure, never write garbage
|
|
1189
|
+
const newCoreVersion =
|
|
1190
|
+
after["@techstream/quark-core"] ?? quarkLink.quarkVersion;
|
|
1191
|
+
quarkLink.quarkVersion = newCoreVersion;
|
|
1192
|
+
quarkLink.updatedDate = new Date().toISOString();
|
|
1193
|
+
await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, 2));
|
|
1194
|
+
|
|
1195
|
+
// Run lint to surface any API breakage from the update
|
|
1196
|
+
console.log(chalk.cyan("\n🔍 Running lint to check for breakage...\n"));
|
|
923
1197
|
try {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1198
|
+
await execa("pnpm", ["lint"], {
|
|
1199
|
+
cwd: process.cwd(),
|
|
1200
|
+
stdio: "inherit",
|
|
1201
|
+
});
|
|
1202
|
+
console.log(chalk.green("\n ✓ Lint passed\n"));
|
|
1203
|
+
} catch {
|
|
1204
|
+
console.log(
|
|
1205
|
+
chalk.yellow(
|
|
1206
|
+
"\n ⚠️ Lint reported issues — review before committing.\n",
|
|
931
1207
|
),
|
|
932
1208
|
);
|
|
933
|
-
|
|
934
|
-
} catch {}
|
|
935
|
-
quarkLink.quarkVersion = updatedVersion;
|
|
936
|
-
quarkLink.updatedDate = new Date().toISOString();
|
|
937
|
-
await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, 2));
|
|
1209
|
+
}
|
|
938
1210
|
|
|
939
|
-
console.log(
|
|
940
|
-
chalk.green("\n✅ Quark core infrastructure updated successfully!\n"),
|
|
941
|
-
);
|
|
942
|
-
console.log(
|
|
943
|
-
chalk.cyan("Note: This updates @techstream/quark-core only.\n"),
|
|
944
|
-
);
|
|
1211
|
+
console.log(chalk.green("✅ Quark updated successfully!\n"));
|
|
945
1212
|
console.log(chalk.cyan("Next steps:"));
|
|
946
|
-
console.log(chalk.white(` 1. pnpm
|
|
947
|
-
console.log(chalk.white(` 2. pnpm lint`));
|
|
948
|
-
console.log(chalk.white(` 3. pnpm test`));
|
|
1213
|
+
console.log(chalk.white(` 1. pnpm test`));
|
|
949
1214
|
console.log(
|
|
950
|
-
chalk.white(`
|
|
1215
|
+
chalk.white(` 2. git add . && git commit -m "chore: update Quark"\n`),
|
|
951
1216
|
);
|
|
952
1217
|
} catch (error) {
|
|
953
1218
|
console.error(chalk.red(`\n✗ Update failed: ${error.message}\n`));
|