@techstream/quark-create-app 1.9.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 (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/index.js +376 -143
  4. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  5. package/templates/base-project/.github/copilot-instructions.md +55 -0
  6. package/templates/base-project/CLAUDE.md +273 -0
  7. package/templates/base-project/README.md +72 -30
  8. package/templates/base-project/apps/web/next.config.js +5 -1
  9. package/templates/base-project/apps/web/package.json +3 -3
  10. package/templates/base-project/apps/web/public/quark.svg +46 -0
  11. package/templates/base-project/apps/web/railway.json +2 -2
  12. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  13. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  14. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  15. package/templates/base-project/apps/web/src/app/api/health/route.js +28 -17
  16. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  17. package/templates/base-project/apps/web/src/app/global-error.js +53 -0
  18. package/templates/base-project/apps/web/src/app/globals.css +121 -15
  19. package/templates/base-project/apps/web/src/app/icon.svg +46 -0
  20. package/templates/base-project/apps/web/src/app/layout.js +1 -0
  21. package/templates/base-project/apps/web/src/app/not-found.js +35 -0
  22. package/templates/base-project/apps/web/src/app/page.js +38 -5
  23. package/templates/base-project/apps/web/src/lib/theme.js +23 -0
  24. package/templates/base-project/apps/web/src/proxy.js +10 -2
  25. package/templates/base-project/package.json +2 -0
  26. package/templates/base-project/packages/db/src/client.js +6 -1
  27. package/templates/base-project/packages/db/src/index.js +1 -0
  28. package/templates/base-project/packages/db/src/ping.js +66 -0
  29. package/templates/base-project/scripts/doctor.js +261 -0
  30. package/templates/base-project/turbo.json +2 -1
  31. package/templates/config/package.json +1 -0
  32. package/templates/jobs/package.json +2 -1
  33. package/templates/ui/README.md +67 -0
  34. package/templates/ui/package.json +1 -0
  35. package/templates/ui/src/badge.js +32 -0
  36. package/templates/ui/src/badge.test.js +42 -0
  37. package/templates/ui/src/button.js +64 -15
  38. package/templates/ui/src/button.test.js +34 -5
  39. package/templates/ui/src/card.js +58 -0
  40. package/templates/ui/src/card.test.js +59 -0
  41. package/templates/ui/src/checkbox.js +35 -0
  42. package/templates/ui/src/checkbox.test.js +35 -0
  43. package/templates/ui/src/dialog.js +139 -0
  44. package/templates/ui/src/dialog.test.js +15 -0
  45. package/templates/ui/src/index.js +16 -0
  46. package/templates/ui/src/input.js +15 -0
  47. package/templates/ui/src/input.test.js +27 -0
  48. package/templates/ui/src/label.js +14 -0
  49. package/templates/ui/src/label.test.js +22 -0
  50. package/templates/ui/src/select.js +42 -0
  51. package/templates/ui/src/select.test.js +27 -0
  52. package/templates/ui/src/skeleton.js +14 -0
  53. package/templates/ui/src/skeleton.test.js +22 -0
  54. package/templates/ui/src/table.js +75 -0
  55. package/templates/ui/src/table.test.js +69 -0
  56. package/templates/ui/src/textarea.js +15 -0
  57. package/templates/ui/src/textarea.test.js +27 -0
  58. package/templates/ui/src/theme-constants.js +24 -0
  59. package/templates/ui/src/theme.js +132 -0
  60. package/templates/ui/src/toast.js +229 -0
  61. package/templates/ui/src/toast.test.js +23 -0
  62. package/templates/{base-project/apps/worker → worker}/package.json +2 -2
  63. package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
  64. package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
  65. package/templates/base-project/apps/web/public/file.svg +0 -1
  66. package/templates/base-project/apps/web/public/globe.svg +0 -1
  67. package/templates/base-project/apps/web/public/next.svg +0 -1
  68. package/templates/base-project/apps/web/public/vercel.svg +0 -1
  69. package/templates/base-project/apps/web/public/window.svg +0 -1
  70. /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
  71. /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
  72. /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
  73. /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
  74. /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
package/src/index.js CHANGED
@@ -190,13 +190,42 @@ function replaceDepsScope(deps, scope, selectedPackages) {
190
190
  }
191
191
  }
192
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
+
193
222
  /**
194
223
  * Replace @techstream/quark-* import paths in all .js source files
195
224
  * for workspace packages (db, jobs, ui, config) with @scope/* equivalents.
196
225
  * Registry packages (@techstream/quark-core) are left untouched.
197
226
  */
198
227
  async function replaceImportsInSourceFiles(dir, scope) {
199
- const workspacePackages = ["db", "jobs", "ui", "config"];
228
+ const workspacePackages = ["db", "jobs", "ui", "config", "admin"];
200
229
  const entries = await fs.readdir(dir, { withFileTypes: true });
201
230
 
202
231
  for (const entry of entries) {
@@ -261,8 +290,10 @@ program
261
290
  )
262
291
  .option(
263
292
  "--features <features>",
264
- "Comma-separated list of optional features to include (ui,jobs)",
293
+ "Comma-separated list of optional features to include (ui,jobs,admin)",
265
294
  )
295
+ .option("--skip-install", "Skip pnpm install and Prisma generate steps")
296
+ .option("--skip-docker", "Skip Docker orphan-volume cleanup")
266
297
  .action(async (projectName, options) => {
267
298
  console.log(
268
299
  chalk.blue.bold(
@@ -280,48 +311,49 @@ program
280
311
  // These persist even if the project directory is manually deleted, causing
281
312
  // authentication failures when the new project generates different credentials.
282
313
  // We also need to stop any running containers that reference these volumes.
283
- try {
284
- const volumePrefix = `${projectName}_`;
285
- const { stdout } = await execa("docker", [
286
- "volume",
287
- "ls",
288
- "--filter",
289
- `name=${volumePrefix}`,
290
- "--format",
291
- "{{.Name}}",
292
- ]);
293
- const orphanedVolumes = stdout
294
- .split("\n")
295
- .filter((v) => v.startsWith(volumePrefix));
296
- if (orphanedVolumes.length > 0) {
297
- // Stop and remove any containers using these volumes first
298
- const { stdout: containerOut } = await execa("docker", [
299
- "ps",
300
- "-a",
314
+ if (!options.skipDocker)
315
+ try {
316
+ const volumePrefix = `${projectName}_`;
317
+ const { stdout } = await execa("docker", [
318
+ "volume",
319
+ "ls",
301
320
  "--filter",
302
- `name=${projectName}`,
321
+ `name=${volumePrefix}`,
303
322
  "--format",
304
- "{{.ID}}",
323
+ "{{.Name}}",
305
324
  ]);
306
- const containers = containerOut.split("\n").filter(Boolean);
307
- if (containers.length > 0) {
308
- await execa("docker", ["rm", "-f", ...containers]);
309
- }
310
- // Remove the Docker network if it exists
311
- try {
312
- await execa("docker", ["network", "rm", `${projectName}_default`]);
313
- } catch {
314
- // Network may not exist — fine
315
- }
316
- // Now remove the orphaned volumes
317
- for (const vol of orphanedVolumes) {
318
- 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"));
319
353
  }
320
- console.log(chalk.green(" ✓ Cleaned up orphaned Docker volumes"));
354
+ } catch {
355
+ // Docker not available — fine
321
356
  }
322
- } catch {
323
- // Docker not available — fine
324
- }
325
357
 
326
358
  // Check if directory already exists
327
359
  if (await fs.pathExists(targetDir)) {
@@ -403,7 +435,7 @@ program
403
435
  if (!options.prompts && options.features) {
404
436
  // Parse features from CLI flag
405
437
  console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
406
- const validFeatures = ["ui", "jobs"];
438
+ const validFeatures = ["ui", "jobs", "admin"];
407
439
  features = options.features
408
440
  .split(",")
409
441
  .map((f) => f.trim())
@@ -419,6 +451,11 @@ program
419
451
  );
420
452
  }
421
453
 
454
+ // Enforce admin dependencies: admin requires ui
455
+ if (features.includes("admin") && !features.includes("ui")) {
456
+ features.push("ui");
457
+ }
458
+
422
459
  console.log(
423
460
  chalk.green(
424
461
  ` Selected features: ${features.join(", ") || "none"} (non-interactive mode)`,
@@ -427,7 +464,7 @@ program
427
464
  } else if (!options.prompts) {
428
465
  // Use defaults when --no-prompts is set without --features
429
466
  console.log(chalk.cyan("\n 🎯 Configuring optional features..."));
430
- features = ["ui", "jobs"]; // Default to both
467
+ features = ["ui", "jobs"]; // Default to ui + jobs (not admin)
431
468
  console.log(
432
469
  chalk.green(
433
470
  ` Using default features: ${features.join(", ")} (non-interactive mode)`,
@@ -449,10 +486,15 @@ program
449
486
  selected: true,
450
487
  },
451
488
  {
452
- title: "Job Definitions (packages/jobs)",
489
+ title: "Background Jobs (packages/jobs + apps/worker)",
453
490
  value: "jobs",
454
491
  selected: true,
455
492
  },
493
+ {
494
+ title: "Admin Dashboard (packages/admin) [requires: ui]",
495
+ value: "admin",
496
+ selected: false,
497
+ },
456
498
  ],
457
499
  },
458
500
  ]);
@@ -465,6 +507,14 @@ program
465
507
  await fs.remove(targetDir);
466
508
  process.exit(0);
467
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
+ }
468
518
  }
469
519
 
470
520
  // Step 6: Copy selected optional packages
@@ -472,6 +522,17 @@ program
472
522
  console.log(chalk.cyan("\n 📋 Setting up optional packages..."));
473
523
 
474
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
+
475
536
  const packageDir = path.join(targetDir, "packages", feature);
476
537
  await fs.ensureDir(packageDir);
477
538
  await copyTemplate(feature, packageDir);
@@ -482,6 +543,14 @@ program
482
543
 
483
544
  console.log(chalk.green(` ✓ ${feature}`));
484
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
+ }
485
554
  }
486
555
 
487
556
  // Step 7: Update all package.json dependencies to use correct scope
@@ -490,7 +559,10 @@ program
490
559
  // Collect all package.json files that need scope replacement (apps + packages)
491
560
  const allPkgPaths = [
492
561
  path.join(targetDir, "apps", "web", "package.json"),
493
- 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
+ : []),
494
566
  // Also update cross-dependencies in scaffolded packages (e.g. db → config)
495
567
  ...["db", ...features].map((pkg) =>
496
568
  path.join(targetDir, "packages", pkg, "package.json"),
@@ -529,6 +601,45 @@ program
529
601
  await replaceImportsInSourceFiles(targetDir, scope);
530
602
  console.log(chalk.green(` ✓ Import paths updated`));
531
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
+
532
643
  // Step 8: Create .env.example file
533
644
  console.log(chalk.cyan("\n 📋 Creating environment configuration..."));
534
645
  const envExampleTemplate = `# ⚠️ IMPORTANT: Copy this file to .env and fill in the values for your environment.
@@ -750,6 +861,8 @@ STORAGE_PROVIDER=local
750
861
  scaffoldedDate: new Date().toISOString(),
751
862
  requiredPackages: ["db", "config"],
752
863
  packages: features,
864
+ // Track that worker is paired with jobs (not independently selectable)
865
+ hasWorker: features.includes("jobs"),
753
866
  };
754
867
  await fs.writeFile(
755
868
  path.join(targetDir, ".quark-link.json"),
@@ -757,8 +870,24 @@ STORAGE_PROVIDER=local
757
870
  );
758
871
  console.log(chalk.green(` ✓ .quark-link.json`));
759
872
 
760
- // Step 10b: Generate project-context skill with actual values
761
- 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
762
891
  const skillPath = path.join(
763
892
  targetDir,
764
893
  ".github",
@@ -768,34 +897,38 @@ STORAGE_PROVIDER=local
768
897
  );
769
898
  if (await fs.pathExists(skillPath)) {
770
899
  let skillContent = await fs.readFile(skillPath, "utf-8");
771
-
772
- // Build optional packages section
773
- const optionalLines = features
774
- .map((f) => {
775
- const labels = {
776
- ui: "Shared UI components",
777
- jobs: "Job queue definitions",
778
- };
779
- return `│ ├── ${f}/ # ${labels[f] || f}`;
780
- })
781
- .join("\n");
782
- const optionalBlock = optionalLines ? `${optionalLines}\n` : "";
783
-
784
900
  skillContent = skillContent
785
901
  .replace(/__QUARK_SCOPE__/g, scope)
786
902
  .replace(/__QUARK_PROJECT_NAME__/g, projectName)
787
- .replace(
788
- /__QUARK_SCAFFOLD_DATE__/g,
789
- new Date().toISOString().split("T")[0],
790
- )
903
+ .replace(/__QUARK_SCAFFOLD_DATE__/g, scaffoldDate)
791
904
  .replace(/__QUARK_OPTIONAL_PACKAGES__/g, optionalBlock);
792
-
793
905
  await fs.writeFile(skillPath, skillContent);
794
906
  console.log(
795
907
  chalk.green(` ✓ .github/skills/project-context/SKILL.md`),
796
908
  );
797
909
  }
798
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
+
799
932
  // Step 11: Initialize git repository
800
933
  console.log(chalk.cyan("\n 📝 Initializing git repository..."));
801
934
  const gitInitialized = await initializeGit(targetDir);
@@ -804,41 +937,45 @@ STORAGE_PROVIDER=local
804
937
  }
805
938
 
806
939
  // Step 12: Run pnpm install
807
- console.log(chalk.cyan("\n 📦 Installing dependencies..."));
808
- try {
809
- await execa("pnpm", ["install"], {
810
- cwd: targetDir,
811
- stdio: "inherit",
812
- });
813
- console.log(chalk.green(`\n ✓ Dependencies installed`));
814
- } catch (installError) {
815
- console.warn(
816
- chalk.yellow(`\n ⚠️ pnpm install failed: ${installError.message}`),
817
- );
818
- console.warn(
819
- chalk.yellow(
820
- ` Run 'pnpm install' manually after resolving the issue.`,
821
- ),
822
- );
823
- }
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
+ }
824
960
 
825
- // Step 13: Generate Prisma client
826
- console.log(chalk.cyan("\n 🗄️ Generating Prisma client..."));
827
- try {
828
- await execa("pnpm", ["--filter", "db", "db:generate"], {
829
- cwd: targetDir,
830
- stdio: "inherit",
831
- });
832
- console.log(chalk.green(` ✓ Prisma client generated`));
833
- } catch (generateError) {
834
- console.warn(
835
- chalk.yellow(
836
- `\n ⚠️ Prisma generate failed: ${generateError.message}`,
837
- ),
838
- );
839
- console.warn(
840
- chalk.yellow(` Run 'pnpm --filter db db:generate' manually.`),
841
- );
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
+ }
842
979
  }
843
980
 
844
981
  // Success message
@@ -849,7 +986,7 @@ STORAGE_PROVIDER=local
849
986
  );
850
987
 
851
988
  console.log(chalk.white(`📂 Project location: ${targetDir}\n`));
852
- console.log(chalk.cyan("Next steps:"));
989
+ console.log(chalk.cyan("Start here:"));
853
990
  console.log(chalk.white(` 1. cd ${projectName}`));
854
991
  console.log(chalk.white(` 2. docker compose up -d`));
855
992
  console.log(chalk.white(` 3. pnpm db:migrate`));
@@ -861,20 +998,20 @@ STORAGE_PROVIDER=local
861
998
  ),
862
999
  );
863
1000
 
864
- console.log(chalk.cyan("Important:"));
1001
+ console.log(chalk.cyan("Build with AI:"));
865
1002
  console.log(
866
- chalk.white(
867
- ` • Update Quark core with: pnpm update @techstream/quark-core`,
868
- ),
1003
+ chalk.white(` Open CLAUDE.md in your AI tool, then tell it:`),
869
1004
  );
870
1005
  console.log(
871
- 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
+ ),
872
1009
  );
873
1010
 
874
- console.log(chalk.cyan("Learn more:"));
875
- console.log(chalk.white(` 📖 Docs: https://github.com/Bobnoddle/quark`));
876
1011
  console.log(
877
- 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
+ ),
878
1015
  );
879
1016
  } catch (error) {
880
1017
  console.error(chalk.red(`\n✗ Error creating project: ${error.message}`));
@@ -889,13 +1026,55 @@ STORAGE_PROVIDER=local
889
1026
  }
890
1027
  });
891
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
+
892
1071
  /**
893
1072
  * quark-update command
894
1073
  * Updates Quark core infrastructure in a scaffolded project
895
1074
  */
896
1075
  program
897
1076
  .command("update")
898
- .description("Update Quark core infrastructure in the current project")
1077
+ .description("Update Quark packages in the current project")
899
1078
  .option("--check", "Check for updates without applying")
900
1079
  .option("--force", "Skip safety checks")
901
1080
  .action(async (options) => {
@@ -911,23 +1090,60 @@ program
911
1090
  }
912
1091
 
913
1092
  const quarkLink = await fs.readJSON(quarkLinkPath);
914
- console.log(chalk.cyan(`Current Quark version: ${quarkLink.quarkVersion}`));
915
- 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
+ );
916
1112
 
917
1113
  if (options.check) {
918
- console.log(chalk.yellow("Checking for updates..."));
919
- console.log(
920
- chalk.white("Run 'pnpm update @techstream/quark-*' to apply updates."),
921
- );
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
+ }
922
1138
  return;
923
1139
  }
924
1140
 
925
1141
  try {
926
- // Warn if git has uncommitted changes
1142
+ // Guard: check for both unstaged and staged changes
927
1143
  if (!options.force) {
928
- console.log(chalk.yellow("⚠️ Checking for uncommitted changes..."));
929
1144
  try {
930
- await execa("git", ["diff", "--exit-code"], {
1145
+ await execa("git", ["diff", "--exit-code"], { cwd: process.cwd() });
1146
+ await execa("git", ["diff", "--cached", "--exit-code"], {
931
1147
  cwd: process.cwd(),
932
1148
  });
933
1149
  } catch {
@@ -943,43 +1159,60 @@ program
943
1159
  }
944
1160
  }
945
1161
 
946
- // Run pnpm update
947
- console.log(chalk.cyan("\n📦 Updating Quark core infrastructure...\n"));
948
- 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], {
949
1165
  cwd: process.cwd(),
950
1166
  stdio: "inherit",
951
1167
  });
952
1168
 
953
- // Update .quark-link.json
954
- 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"));
955
1197
  try {
956
- const corePkg = await fs.readJSON(
957
- path.join(
958
- process.cwd(),
959
- "node_modules",
960
- "@techstream",
961
- "quark-core",
962
- "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",
963
1207
  ),
964
1208
  );
965
- updatedVersion = corePkg.version;
966
- } catch {}
967
- quarkLink.quarkVersion = updatedVersion;
968
- quarkLink.updatedDate = new Date().toISOString();
969
- await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, 2));
1209
+ }
970
1210
 
971
- console.log(
972
- chalk.green("\n✅ Quark core infrastructure updated successfully!\n"),
973
- );
974
- console.log(
975
- chalk.cyan("Note: This updates @techstream/quark-core only.\n"),
976
- );
1211
+ console.log(chalk.green("✅ Quark updated successfully!\n"));
977
1212
  console.log(chalk.cyan("Next steps:"));
978
- console.log(chalk.white(` 1. pnpm install (if prompted)`));
979
- console.log(chalk.white(` 2. pnpm lint`));
980
- console.log(chalk.white(` 3. pnpm test`));
1213
+ console.log(chalk.white(` 1. pnpm test`));
981
1214
  console.log(
982
- 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`),
983
1216
  );
984
1217
  } catch (error) {
985
1218
  console.error(chalk.red(`\n✗ Update failed: ${error.message}\n`));