@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.
Files changed (80) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -3
  3. package/src/index.js +415 -150
  4. package/src/utils.js +36 -0
  5. package/src/utils.test.js +63 -0
  6. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  7. package/templates/base-project/.github/copilot-instructions.md +55 -0
  8. package/templates/base-project/.github/workflows/release.yml +37 -8
  9. package/templates/base-project/CLAUDE.md +273 -0
  10. package/templates/base-project/README.md +72 -30
  11. package/templates/base-project/apps/web/next.config.js +5 -1
  12. package/templates/base-project/apps/web/package.json +7 -5
  13. package/templates/base-project/apps/web/public/quark.svg +46 -0
  14. package/templates/base-project/apps/web/railway.json +2 -2
  15. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  16. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  17. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  18. package/templates/base-project/apps/web/src/app/api/health/route.js +56 -17
  19. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  20. package/templates/base-project/apps/web/src/app/global-error.js +53 -0
  21. package/templates/base-project/apps/web/src/app/globals.css +121 -15
  22. package/templates/base-project/apps/web/src/app/icon.svg +46 -0
  23. package/templates/base-project/apps/web/src/app/layout.js +1 -0
  24. package/templates/base-project/apps/web/src/app/not-found.js +35 -0
  25. package/templates/base-project/apps/web/src/app/page.js +38 -5
  26. package/templates/base-project/apps/web/src/lib/theme.js +23 -0
  27. package/templates/base-project/apps/web/src/proxy.js +10 -2
  28. package/templates/base-project/package.json +16 -1
  29. package/templates/base-project/packages/db/package.json +4 -4
  30. package/templates/base-project/packages/db/src/client.js +6 -1
  31. package/templates/base-project/packages/db/src/index.js +1 -0
  32. package/templates/base-project/packages/db/src/ping.js +66 -0
  33. package/templates/base-project/scripts/doctor.js +261 -0
  34. package/templates/base-project/turbo.json +2 -1
  35. package/templates/config/package.json +1 -0
  36. package/templates/config/src/index.js +1 -3
  37. package/templates/config/src/validate-env.js +79 -3
  38. package/templates/jobs/package.json +2 -1
  39. package/templates/ui/README.md +67 -0
  40. package/templates/ui/package.json +1 -0
  41. package/templates/ui/src/badge.js +32 -0
  42. package/templates/ui/src/badge.test.js +42 -0
  43. package/templates/ui/src/button.js +64 -15
  44. package/templates/ui/src/button.test.js +34 -5
  45. package/templates/ui/src/card.js +58 -0
  46. package/templates/ui/src/card.test.js +59 -0
  47. package/templates/ui/src/checkbox.js +35 -0
  48. package/templates/ui/src/checkbox.test.js +35 -0
  49. package/templates/ui/src/dialog.js +139 -0
  50. package/templates/ui/src/dialog.test.js +15 -0
  51. package/templates/ui/src/index.js +16 -0
  52. package/templates/ui/src/input.js +15 -0
  53. package/templates/ui/src/input.test.js +27 -0
  54. package/templates/ui/src/label.js +14 -0
  55. package/templates/ui/src/label.test.js +22 -0
  56. package/templates/ui/src/select.js +42 -0
  57. package/templates/ui/src/select.test.js +27 -0
  58. package/templates/ui/src/skeleton.js +14 -0
  59. package/templates/ui/src/skeleton.test.js +22 -0
  60. package/templates/ui/src/table.js +75 -0
  61. package/templates/ui/src/table.test.js +69 -0
  62. package/templates/ui/src/textarea.js +15 -0
  63. package/templates/ui/src/textarea.test.js +27 -0
  64. package/templates/ui/src/theme-constants.js +24 -0
  65. package/templates/ui/src/theme.js +132 -0
  66. package/templates/ui/src/toast.js +229 -0
  67. package/templates/ui/src/toast.test.js +23 -0
  68. package/templates/{base-project/apps/worker → worker}/package.json +2 -2
  69. package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
  70. package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
  71. package/templates/base-project/apps/web/public/file.svg +0 -1
  72. package/templates/base-project/apps/web/public/globe.svg +0 -1
  73. package/templates/base-project/apps/web/public/next.svg +0 -1
  74. package/templates/base-project/apps/web/public/vercel.svg +0 -1
  75. package/templates/base-project/apps/web/public/window.svg +0 -1
  76. /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
  77. /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
  78. /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
  79. /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
  80. /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
- try {
281
- const volumePrefix = `${projectName}_`;
282
- const { stdout } = await execa("docker", [
283
- "volume",
284
- "ls",
285
- "--filter",
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=${projectName}`,
321
+ `name=${volumePrefix}`,
300
322
  "--format",
301
- "{{.ID}}",
323
+ "{{.Name}}",
302
324
  ]);
303
- const containers = containerOut.split("\n").filter(Boolean);
304
- if (containers.length > 0) {
305
- await execa("docker", ["rm", "-f", ...containers]);
306
- }
307
- // Remove the Docker network if it exists
308
- try {
309
- await execa("docker", ["network", "rm", `${projectName}_default`]);
310
- } catch {
311
- // Network may not exist — fine
312
- }
313
- // Now remove the orphaned volumes
314
- for (const vol of orphanedVolumes) {
315
- await execa("docker", ["volume", "rm", "-f", vol]);
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
- console.log(chalk.green(" ✓ Cleaned up orphaned Docker volumes"));
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 both
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: "Job Definitions (packages/jobs)",
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
- path.join(targetDir, "apps", "worker", "package.json"),
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 = `# --- Environment ---
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
- # Provider: "smtp" (default) or "resend"
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
- # Resend (only when EMAIL_PROVIDER=resend)
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
- APP_NAME=${projectName}
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
- # --- OAuth Providers (Optional) ---
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=${projectName}
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 project-context skill with actual values
729
- console.log(chalk.cyan("\n 🤖 Generating project-context skill..."));
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
- console.log(chalk.cyan("\n 📦 Installing dependencies..."));
776
- try {
777
- await execa("pnpm", ["install"], {
778
- cwd: targetDir,
779
- stdio: "inherit",
780
- });
781
- console.log(chalk.green(`\n ✓ Dependencies installed`));
782
- } catch (installError) {
783
- console.warn(
784
- chalk.yellow(`\n ⚠️ pnpm install failed: ${installError.message}`),
785
- );
786
- console.warn(
787
- chalk.yellow(
788
- ` Run 'pnpm install' manually after resolving the issue.`,
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
- // Step 13: Generate Prisma client
794
- console.log(chalk.cyan("\n 🗄️ Generating Prisma client..."));
795
- try {
796
- await execa("pnpm", ["--filter", "db", "db:generate"], {
797
- cwd: targetDir,
798
- stdio: "inherit",
799
- });
800
- console.log(chalk.green(` ✓ Prisma client generated`));
801
- } catch (generateError) {
802
- console.warn(
803
- chalk.yellow(
804
- `\n ⚠️ Prisma generate failed: ${generateError.message}`,
805
- ),
806
- );
807
- console.warn(
808
- chalk.yellow(` Run 'pnpm --filter db db:generate' manually.`),
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("Next steps:"));
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("Important:"));
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(` • Or run: npx @techstream/quark-create-app update\n`),
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.white(` 💬 Issues: https://github.com/Bobnoddle/quark/issues\n`),
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 core infrastructure in the current project")
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
- console.log(chalk.cyan(`Current Quark version: ${quarkLink.quarkVersion}`));
883
- console.log(chalk.cyan(`Scaffolded: ${quarkLink.scaffoldedDate}\n`));
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
- console.log(chalk.yellow("Checking for updates..."));
887
- console.log(
888
- chalk.white("Run 'pnpm update @techstream/quark-*' to apply updates."),
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
- // Warn if git has uncommitted changes
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("\n📦 Updating Quark core infrastructure...\n"));
916
- await execa("pnpm", ["update", "@techstream/quark-core"], {
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
- // Update .quark-link.json
922
- let updatedVersion = "updated";
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
- const corePkg = await fs.readJSON(
925
- path.join(
926
- process.cwd(),
927
- "node_modules",
928
- "@techstream",
929
- "quark-core",
930
- "package.json",
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
- updatedVersion = corePkg.version;
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 install (if prompted)`));
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(` 4. git add . && git commit -m "chore: update Quark"\n`),
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`));