@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
package/src/index.js ADDED
@@ -0,0 +1,2920 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import net from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import chalk from "chalk";
8
+ import { Command } from "commander";
9
+ import { execa } from "execa";
10
+ import fs from "fs-extra";
11
+ import prompts from "prompts";
12
+ import { findEnclosingGitRepo, formatProjectDisplayName } from "./utils.js";
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const templatesDir = path.join(__dirname, "../templates");
16
+ const pkg = await fs.readJSON(path.join(__dirname, "../package.json"));
17
+ // db, config, and ui are always scaffolded: the base web app (layout, auth)
18
+ // imports the ui package, so a scaffold without it is broken.
19
+ const REQUIRED_PACKAGES = ["db", "config", "ui"];
20
+ const SCAFFOLD_CHECK_IGNORED_FILES = new Set([".env", ".quark-link.json"]);
21
+
22
+ // AI harness โ†’ directory where embedded skills are placed for auto-loading.
23
+ const HARNESS_SKILL_DIRS = {
24
+ opencode: ".opencode/skills",
25
+ claude: ".claude/skills",
26
+ copilot: ".github/skills",
27
+ };
28
+ const DEFAULT_HARNESS = "opencode";
29
+
30
+ const program = new Command();
31
+
32
+ program
33
+ .name("quark")
34
+ .description("Quark CLI - scaffold, update, and deploy Quark projects")
35
+ .version(pkg.version);
36
+
37
+ /**
38
+ * Generate a cryptographically secure random string
39
+ * @param {number} length - Length of the random string (default: 32)
40
+ * @returns {string} Base64 encoded random string
41
+ */
42
+ function generateSecureSecret(length = 32) {
43
+ return crypto
44
+ .randomBytes(length)
45
+ .toString("base64")
46
+ .replace(/[/+=]/g, "")
47
+ .substring(0, length);
48
+ }
49
+
50
+ /**
51
+ * Generate a secure random password
52
+ * @param {number} length - Length of the password (default: 24)
53
+ * @returns {string} Alphanumeric password
54
+ */
55
+ function generateSecurePassword(length = 24) {
56
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
57
+ const bytes = crypto.randomBytes(length);
58
+ let result = "";
59
+ for (let i = 0; i < length; i++) {
60
+ result += chars[bytes[i] % chars.length];
61
+ }
62
+ return result;
63
+ }
64
+
65
+ /**
66
+ * Check if a TCP port is available on localhost.
67
+ * Uses a connect test (not bind) to reliably detect Docker-bound ports on macOS.
68
+ * @param {number} port
69
+ * @returns {Promise<boolean>}
70
+ */
71
+ function isPortAvailable(port) {
72
+ return new Promise((resolve) => {
73
+ const socket = new net.Socket();
74
+ socket.setTimeout(500);
75
+ socket.once("connect", () => {
76
+ socket.destroy();
77
+ resolve(false); // something is listening - port is in use
78
+ });
79
+ socket.once("error", () => {
80
+ socket.destroy();
81
+ resolve(true); // ECONNREFUSED - port is free
82
+ });
83
+ socket.once("timeout", () => {
84
+ socket.destroy();
85
+ resolve(true); // no response - port is free
86
+ });
87
+ socket.connect(port, "127.0.0.1");
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Find the next available port starting from a given port
93
+ * @param {number} startPort
94
+ * @param {number} [maxAttempts=20]
95
+ * @returns {Promise<number>}
96
+ */
97
+ async function findAvailablePort(startPort, maxAttempts = 20) {
98
+ for (let i = 0; i < maxAttempts; i++) {
99
+ const port = startPort + i;
100
+ if (await isPortAvailable(port)) {
101
+ return port;
102
+ }
103
+ }
104
+ return startPort; // fallback to default if all checked ports are busy
105
+ }
106
+
107
+ /**
108
+ * Copy a template directory to the target location, with variable substitution
109
+ */
110
+ async function copyTemplate(templateName, targetDir, variables = {}) {
111
+ const templatePath = path.join(templatesDir, templateName);
112
+
113
+ if (!(await fs.pathExists(templatePath))) {
114
+ throw new Error(`Template not found: ${templateName}`);
115
+ }
116
+
117
+ // Copy the template
118
+ await fs.copy(templatePath, targetDir);
119
+
120
+ // Replace variables in package.json files
121
+ if (Object.keys(variables).length > 0) {
122
+ const packageJsonPath = path.join(targetDir, "package.json");
123
+ if (await fs.pathExists(packageJsonPath)) {
124
+ let content = await fs.readFile(packageJsonPath, "utf-8");
125
+
126
+ for (const [key, value] of Object.entries(variables)) {
127
+ const pattern = new RegExp(
128
+ key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
129
+ "g",
130
+ );
131
+ content = content.replace(pattern, value);
132
+ }
133
+
134
+ await fs.writeFile(packageJsonPath, content);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Resolve symlinks so paths reported by git and paths reported by the caller
141
+ * can be compared reliably (macOS tmpdirs are symlinked).
142
+ */
143
+ function canonicalPath(dir) {
144
+ try {
145
+ return fs.realpathSync(dir);
146
+ } catch {
147
+ return path.resolve(dir);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Find the root of the git repository that contains a directory, if any.
153
+ * Returns null when the directory is not inside a git work tree.
154
+ */
155
+ async function findEnclosingGitRoot(projectDir) {
156
+ try {
157
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], {
158
+ cwd: projectDir,
159
+ });
160
+ const root = stdout.trim();
161
+ return root ? canonicalPath(root) : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Initialize a git repository and create an initial commit.
169
+ *
170
+ * When the project directory is already inside a git work tree, no nested
171
+ * repository is created: the enclosing repository tracks the project files.
172
+ * Set QUARK_FORCE_GIT_INIT=true to create a separate nested repository instead.
173
+ *
174
+ * @returns {Promise<{ initialized: boolean, existingRoot: string | null }>}
175
+ */
176
+ async function initializeGit(projectDir) {
177
+ if (process.env.QUARK_FORCE_GIT_INIT !== "true") {
178
+ const existingRoot = await findEnclosingGitRoot(projectDir);
179
+
180
+ if (existingRoot) {
181
+ console.log(
182
+ chalk.dim(` ยท Already inside a git repository: ${existingRoot}`),
183
+ );
184
+ if (existingRoot === canonicalPath(projectDir)) {
185
+ console.log(
186
+ chalk.dim(` Leaving the existing repository untouched.`),
187
+ );
188
+ } else {
189
+ console.log(
190
+ chalk.dim(
191
+ ` Skipping nested git init - this project will be tracked by that repository.`,
192
+ ),
193
+ );
194
+ }
195
+ console.log(
196
+ chalk.dim(
197
+ ` Set QUARK_FORCE_GIT_INIT=true to create a separate nested repository.`,
198
+ ),
199
+ );
200
+ return { initialized: false, existingRoot };
201
+ }
202
+ }
203
+
204
+ try {
205
+ // Initialize git repo
206
+ await execa("git", ["init"], { cwd: projectDir });
207
+
208
+ // Add all files
209
+ await execa("git", ["add", "."], { cwd: projectDir });
210
+
211
+ // Create initial commit.
212
+ // Supply a local identity via -c to avoid failures on machines that
213
+ // have no global user.email / user.name configured (common on Windows).
214
+ await execa(
215
+ "git",
216
+ [
217
+ "-c",
218
+ "user.email=scaffold@quark.local",
219
+ "-c",
220
+ "user.name=Quark Scaffold",
221
+ "commit",
222
+ "-m",
223
+ "Initial commit: Quark project scaffold",
224
+ ],
225
+ {
226
+ cwd: projectDir,
227
+ },
228
+ );
229
+
230
+ return { initialized: true, existingRoot: null };
231
+ } catch (error) {
232
+ console.warn(
233
+ chalk.yellow(`โš ๏ธ Git initialization failed: ${error.message}`),
234
+ );
235
+ return { initialized: false, existingRoot: null };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Substitute __QUARK_* placeholders in a single scaffold file.
241
+ * Writes only when the content actually changed.
242
+ */
243
+ async function substitutePlaceholders(filePath, values) {
244
+ let content = await fs.readFile(filePath, "utf-8");
245
+ const before = content;
246
+
247
+ for (const [token, value] of Object.entries(values)) {
248
+ // Replacer function so `$` sequences in values are treated literally.
249
+ content = content.replaceAll(token, () => value);
250
+ }
251
+
252
+ if (content !== before) {
253
+ await fs.writeFile(filePath, content);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Recursively substitute __QUARK_* placeholders in every Markdown file.
259
+ */
260
+ async function substitutePlaceholdersInMarkdownFiles(dir, values) {
261
+ const entries = await fs.readdir(dir, { withFileTypes: true });
262
+
263
+ for (const entry of entries) {
264
+ const fullPath = path.join(dir, entry.name);
265
+ if (entry.isDirectory()) {
266
+ await substitutePlaceholdersInMarkdownFiles(fullPath, values);
267
+ } else if (/\.md$/i.test(entry.name)) {
268
+ await substitutePlaceholders(fullPath, values);
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Check whether a project directory has uncommitted (unstaged or staged) changes.
275
+ *
276
+ * The diff is scoped to the project directory so that a Quark project nested in
277
+ * a larger repository is not blocked by dirty sibling files elsewhere in that
278
+ * repository.
279
+ */
280
+ async function hasUncommittedChanges(projectDir) {
281
+ try {
282
+ await execa("git", ["diff", "--exit-code", "--", "."], {
283
+ cwd: projectDir,
284
+ });
285
+ await execa("git", ["diff", "--cached", "--exit-code", "--", "."], {
286
+ cwd: projectDir,
287
+ });
288
+ return false;
289
+ } catch {
290
+ return true;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Update package.json name with scope
296
+ */
297
+ async function updatePackageJsonName(filePath, scope) {
298
+ const content = await fs.readFile(filePath, "utf-8");
299
+ const packageJson = JSON.parse(content);
300
+
301
+ // Extract package name (e.g., "@myquark/ui" -> "ui")
302
+ const parts = packageJson.name.split("/");
303
+ const packageName = parts[parts.length - 1];
304
+
305
+ packageJson.name = `@${scope}/${packageName}`;
306
+
307
+ await fs.writeFile(filePath, `${JSON.stringify(packageJson, null, " ")}\n`);
308
+ }
309
+
310
+ function getWorkspacePackagesForFeatures(features) {
311
+ const packages = new Set();
312
+
313
+ for (const feature of features) {
314
+ const meta = FEATURE_META[feature];
315
+ if (meta?.skill) continue; // skill-based verticals have no workspace package
316
+ const packageNames = meta?.packages ?? [feature];
317
+
318
+ for (const packageName of packageNames) {
319
+ packages.add(packageName);
320
+ }
321
+ }
322
+
323
+ return [...packages];
324
+ }
325
+
326
+ function getPairedTemplatesForFeatures(features) {
327
+ const pairs = [];
328
+
329
+ for (const feature of features) {
330
+ const meta = FEATURE_META[feature];
331
+ if (!meta || meta.skill) continue; // skill-based verticals have no paired routes
332
+
333
+ for (const pair of meta.pairs) {
334
+ if (!pairs.includes(pair)) {
335
+ pairs.push(pair);
336
+ }
337
+ }
338
+ }
339
+
340
+ return pairs;
341
+ }
342
+
343
+ function buildFeatureRows(features) {
344
+ const rows = {
345
+ ui: "| UI package | Included | `packages/ui/README.md` |",
346
+ jobs: "| Jobs + worker | Included | `packages/jobs/README.md`, `apps/worker/src/handlers/` |",
347
+ pwa: "| PWA support | Included | `apps/web/public/sw.js`, `apps/web/src/app/manifest.js` |",
348
+ };
349
+
350
+ const selectedRows = features
351
+ .filter((feature) => rows[feature])
352
+ .map((feature) => rows[feature]);
353
+
354
+ if (selectedRows.length === 0) {
355
+ return "| Optional scaffolded features | None selected yet | Add one later with `npx @techstream/quark-create-app add <feature>` |";
356
+ }
357
+
358
+ return selectedRows.join("\n");
359
+ }
360
+
361
+ function buildOptionalAppLines(features) {
362
+ const lines = [];
363
+ if (features.includes("jobs")) {
364
+ lines.push("โ”‚ โ””โ”€โ”€ worker/ # BullMQ background worker\n");
365
+ }
366
+ return lines.join("");
367
+ }
368
+
369
+ function buildFirstEditLines(features) {
370
+ const lines = {
371
+ ui: "- `packages/ui/src/` - adjust primitives or add app-specific UI components",
372
+ jobs: "- `packages/jobs/src/definitions.js` and `apps/worker/src/handlers/` - define and process background jobs",
373
+ pwa: "- `apps/web/public/sw.js` - service worker caching config, `src/app/manifest.js` - app manifest (replaces the base manifest)",
374
+ };
375
+
376
+ const selectedLines = features
377
+ .filter((feature) => lines[feature])
378
+ .map((feature) => lines[feature]);
379
+
380
+ if (selectedLines.length === 0) {
381
+ return "- No optional scaffolded packages were selected yet. Add one later when the product needs it.";
382
+ }
383
+
384
+ return selectedLines.join("\n");
385
+ }
386
+
387
+ function buildFeatureGuideLines(features) {
388
+ const lines = {
389
+ ui: "- `packages/ui/README.md` - component catalog, import rules, and extension notes",
390
+ jobs: "- `packages/jobs/README.md` - queue names, worker pairing, and the job-extension workflow",
391
+ pwa: "- `apps/web/public/sw.js` - service worker caching strategies and offline behavior",
392
+ };
393
+
394
+ const selectedLines = features
395
+ .filter((feature) => lines[feature])
396
+ .map((feature) => lines[feature]);
397
+
398
+ if (selectedLines.length === 0) {
399
+ return "- No optional package guides are present yet. Start with `packages/db` and `apps/web`.";
400
+ }
401
+
402
+ return selectedLines.join("\n");
403
+ }
404
+
405
+ function normalizeScaffoldDate(scaffoldedDate) {
406
+ return (scaffoldedDate || new Date().toISOString()).split("T")[0];
407
+ }
408
+
409
+ function formatFeatureSummary(features) {
410
+ return features.length > 0 ? features.join(", ") : "none";
411
+ }
412
+
413
+ /**
414
+ * Registry pin for @techstream/quark-core in scaffolded apps.
415
+ * Mirrors sync-templates.js CORE_VERSION_PIN (major.0.0 from baked templates).
416
+ */
417
+ function getCoreVersionPin() {
418
+ try {
419
+ const webPkg = fs.readJSONSync(
420
+ path.join(templatesDir, "base-project/apps/web/package.json"),
421
+ );
422
+ const pin = webPkg?.dependencies?.["@techstream/quark-core"];
423
+ if (typeof pin === "string" && pin !== "workspace:*") {
424
+ return pin;
425
+ }
426
+ } catch {
427
+ // Templates may be unavailable in some test contexts.
428
+ }
429
+ return "^2.0.0";
430
+ }
431
+
432
+ /**
433
+ * Replace @techstream/quark-* workspace deps with @scope/* for local packages.
434
+ * Also removes deps for packages that were not selected.
435
+ * @techstream/quark-core is a published registry package โ€” rewrite workspace:*
436
+ * to the registry pin instead of deleting or rescoping it.
437
+ */
438
+ function replaceDepsScope(deps, scope, selectedPackages) {
439
+ if (!deps) return;
440
+ for (const [key, value] of Object.entries(deps)) {
441
+ if (key.startsWith("@techstream/quark-") && value === "workspace:*") {
442
+ // Published registry package โ€” keep name, pin to registry version.
443
+ if (key === "@techstream/quark-core") {
444
+ deps[key] = getCoreVersionPin();
445
+ continue;
446
+ }
447
+ const packageName = key.replace("@techstream/quark-", "");
448
+ delete deps[key];
449
+ // Only keep the dep if the package was selected (or is always required)
450
+ if (
451
+ packageName === "db" ||
452
+ packageName === "config" ||
453
+ packageName === "ui" ||
454
+ selectedPackages.includes(packageName)
455
+ ) {
456
+ deps[`@${scope}/${packageName}`] = value;
457
+ }
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Remove unselected workspace package entries from next.config.js transpilePackages.
464
+ * Must run AFTER replaceImportsInSourceFiles so package names are already scoped.
465
+ */
466
+ async function patchNextConfig(webDir, scope, selectedPackages) {
467
+ const configPath = path.join(webDir, "next.config.js");
468
+ if (!(await fs.pathExists(configPath))) return;
469
+
470
+ let content = await fs.readFile(configPath, "utf-8");
471
+
472
+ const optionalEntries = {
473
+ ui: `@${scope}/ui`,
474
+ jobs: `@${scope}/jobs`,
475
+ };
476
+
477
+ for (const [feature, pkg] of Object.entries(optionalEntries)) {
478
+ if (!selectedPackages.includes(feature)) {
479
+ // Remove the line containing this package from transpilePackages.
480
+ // Use \r?\n so the regex matches both LF and CRLF line endings
481
+ // (CRLF can appear on Windows when git autocrlf=true is set).
482
+ content = content.replace(
483
+ new RegExp(`[ \\t]*"${pkg.replace("/", "\\/")}",?\\r?\\n`, "g"),
484
+ "",
485
+ );
486
+ }
487
+ }
488
+
489
+ await fs.writeFile(configPath, content);
490
+ }
491
+
492
+ /**
493
+ * Replace @techstream/quark-* import paths in all .js source files
494
+ * for workspace packages (db, jobs, ui, config) with @scope/* equivalents.
495
+ * Registry packages (@techstream/quark-core) are left untouched.
496
+ */
497
+ async function replaceImportsInSourceFiles(dir, scope) {
498
+ const workspacePackages = ["db", "jobs", "ui", "config"];
499
+ const entries = await fs.readdir(dir, { withFileTypes: true });
500
+
501
+ for (const entry of entries) {
502
+ const fullPath = path.join(dir, entry.name);
503
+
504
+ if (
505
+ entry.isDirectory() &&
506
+ entry.name !== "node_modules" &&
507
+ entry.name !== ".next"
508
+ ) {
509
+ await replaceImportsInSourceFiles(fullPath, scope);
510
+ } else if (entry.isFile() && /\.(js|ts|jsx|tsx|mjs)$/.test(entry.name)) {
511
+ let content = await fs.readFile(fullPath, "utf-8");
512
+ let changed = false;
513
+
514
+ for (const pkg of workspacePackages) {
515
+ const pattern = new RegExp(`@techstream/quark-${pkg}`, "g");
516
+ if (pattern.test(content)) {
517
+ content = content.replace(pattern, `@${scope}/${pkg}`);
518
+ changed = true;
519
+ }
520
+ }
521
+
522
+ if (changed) {
523
+ await fs.writeFile(fullPath, content);
524
+ }
525
+ }
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Validate project name to prevent path traversal and ensure safe directory creation
531
+ */
532
+ function validateProjectName(name) {
533
+ if (!name || typeof name !== "string") {
534
+ throw new Error("Project name is required");
535
+ }
536
+ // Only allow safe characters: alphanumeric, hyphens, underscores, dots
537
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
538
+ throw new Error(
539
+ "Project name may only contain letters, numbers, hyphens, underscores, and dots",
540
+ );
541
+ }
542
+ // Block path traversal patterns
543
+ if (name.startsWith(".") || name.includes("..")) {
544
+ throw new Error("Project name must not start with '.' or contain '..'");
545
+ }
546
+
547
+ const resolved = path.resolve(process.cwd(), name);
548
+ if (!resolved.startsWith(process.cwd())) {
549
+ throw new Error("Project name must not escape the current directory");
550
+ }
551
+
552
+ return resolved;
553
+ }
554
+
555
+ function resolveSignupPreference(optionValue) {
556
+ if (optionValue === undefined) {
557
+ return undefined;
558
+ }
559
+
560
+ const normalized = optionValue.trim().toLowerCase();
561
+
562
+ if (["enabled", "enable", "true", "yes", "on"].includes(normalized)) {
563
+ return true;
564
+ }
565
+
566
+ if (["disabled", "disable", "false", "no", "off"].includes(normalized)) {
567
+ return false;
568
+ }
569
+
570
+ throw new Error(
571
+ `Invalid value for --signup: ${optionValue}. Use "enabled" or "disabled".`,
572
+ );
573
+ }
574
+
575
+ program
576
+ .argument("[project-name]", "Name of the project to create")
577
+ .option(
578
+ "--no-prompts",
579
+ "Skip interactive prompts and use default/provided values",
580
+ )
581
+ .option(
582
+ "--signup <mode>",
583
+ 'Public self-service signup mode for the scaffolded project ("enabled" or "disabled")',
584
+ )
585
+ .option(
586
+ "--packages <packages>",
587
+ "Comma-separated list of optional packages to include (ui,jobs,pwa)",
588
+ )
589
+ .option(
590
+ "--prompt <prompt>",
591
+ "Product brief used to populate MAIN.md (AI View)",
592
+ )
593
+ .option(
594
+ "--harness <harness>",
595
+ "AI harness for embedded skills (opencode, claude, copilot)",
596
+ )
597
+ .option("--skip-install", "Skip pnpm install and Prisma generate steps")
598
+ .option("--skip-docker", "Skip Docker orphan-volume cleanup")
599
+ .action(async (projectName, options) => {
600
+ console.log(
601
+ chalk.blue.bold(
602
+ `\n\uD83D\uDE80 Creating your new Quark project: ${projectName}\n`,
603
+ ),
604
+ );
605
+
606
+ // Verify pnpm is available before doing any work
607
+ if (!options.skipInstall) {
608
+ try {
609
+ await execa("pnpm", ["--version"]);
610
+ } catch {
611
+ console.error(chalk.red("\n โœ– pnpm is required but was not found.\n"));
612
+ console.error(chalk.yellow(" Install it with one of:"));
613
+ console.error(
614
+ chalk.white(
615
+ " corepack enable (recommended, built into Node)",
616
+ ),
617
+ );
618
+ console.error(chalk.white(" npm install -g pnpm"));
619
+ console.error(chalk.white(" brew install pnpm (macOS)\n"));
620
+ console.error(chalk.gray(" Then re-run this command.\n"));
621
+ process.exit(1);
622
+ }
623
+ }
624
+
625
+ // If no project name provided, prompt for it (interactive only)
626
+ if (!projectName) {
627
+ if (options.prompts === false) {
628
+ console.error(
629
+ chalk.red(
630
+ "\n โœ– Project name is required in non-interactive mode.\n",
631
+ ),
632
+ );
633
+ process.exit(1);
634
+ }
635
+ const nameResponse = await prompts({
636
+ type: "text",
637
+ name: "value",
638
+ message: "Project name:",
639
+ validate: (v) => {
640
+ if (!v || !/^[a-zA-Z0-9._-]+$/.test(v)) {
641
+ return "Only letters, numbers, hyphens, underscores, and dots allowed";
642
+ }
643
+ if (v.startsWith(".") || v.includes("..")) {
644
+ return "Must not start with '.' or contain '..'";
645
+ }
646
+ return true;
647
+ },
648
+ });
649
+ if (!nameResponse.value) {
650
+ console.log(chalk.yellow("\nโš ๏ธ Setup cancelled."));
651
+ process.exit(0);
652
+ }
653
+ projectName = nameResponse.value;
654
+ }
655
+
656
+ const targetDir = validateProjectName(projectName);
657
+ const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "");
658
+ const appDisplayName = formatProjectDisplayName(projectName);
659
+ const appDescription = `${appDisplayName} application`;
660
+
661
+ // Creating a project inside an existing git work tree nests the project
662
+ // folder (and, if we ran `git init`, a second repository) below the repo
663
+ // root. pnpm workspaces, turbo and CI workflows all expect package.json
664
+ // at the repository root, so warn loudly and never create a nested
665
+ // repository.
666
+ const enclosingRepo = await findEnclosingGitRepo(process.cwd());
667
+ if (enclosingRepo) {
668
+ console.log(
669
+ chalk.yellow(
670
+ `\n โš ๏ธ This project will be created inside an existing git repository:\n` +
671
+ ` repository root : ${enclosingRepo}\n` +
672
+ ` project folder : ${path.relative(enclosingRepo, targetDir) || projectName}\n\n` +
673
+ ` pnpm, turbo and GitHub Actions expect package.json at the\n` +
674
+ ` repository root, so a nested project folder breaks installs\n` +
675
+ ` and CI. Prefer one of:\n` +
676
+ ` โ€ข scaffold outside this repository, then push the project\n` +
677
+ ` as its own repository, or\n` +
678
+ ` โ€ข scaffold into a temporary folder and move the files to\n` +
679
+ ` the repository root before the first commit.\n\n` +
680
+ ` Git initialization will be skipped to avoid creating a\n` +
681
+ ` nested repository.\n\n` +
682
+ ` GitHub only runs workflows stored at the repository root, so\n` +
683
+ ` <project>/.github/workflows/*.yml will be ignored by GitHub\n` +
684
+ ` Actions.\n`,
685
+ ),
686
+ );
687
+ }
688
+
689
+ // Auto-detect interactive mode: if any config options are provided,
690
+ // skip prompts automatically. Only show prompts when no options given.
691
+ const hasConfigOptions =
692
+ options.packages !== undefined ||
693
+ options.signup !== undefined ||
694
+ options.prompt !== undefined ||
695
+ options.harness;
696
+ if (hasConfigOptions && options.prompts !== false) {
697
+ console.log(
698
+ chalk.dim(
699
+ " โ„น Configuration options provided โ€” skipping interactive prompts",
700
+ ),
701
+ );
702
+ options.prompts = false;
703
+ }
704
+
705
+ // Deprecation warning for --no-prompts (now implicit when options are provided)
706
+ if (options.prompts === false && !hasConfigOptions) {
707
+ console.log(
708
+ chalk.yellow(
709
+ " โš  --no-prompts is deprecated. Options now auto-skip prompts.",
710
+ ),
711
+ );
712
+ }
713
+
714
+ const requestedSignupPreference = resolveSignupPreference(options.signup);
715
+
716
+ // Clean up orphaned Docker volumes from a previous project with the same name.
717
+ // Docker Compose names volumes as "<project>_postgres_data", "<project>_redis_data".
718
+ // These persist even if the project directory is manually deleted, causing
719
+ // authentication failures when the new project generates different credentials.
720
+ // We also need to stop any running containers that reference these volumes.
721
+ if (!options.skipDocker)
722
+ try {
723
+ const volumePrefix = `${projectName}_`;
724
+ const { stdout } = await execa("docker", [
725
+ "volume",
726
+ "ls",
727
+ "--filter",
728
+ `name=${volumePrefix}`,
729
+ "--format",
730
+ "{{.Name}}",
731
+ ]);
732
+ const orphanedVolumes = stdout
733
+ .split("\n")
734
+ .filter((v) => v.startsWith(volumePrefix));
735
+ if (orphanedVolumes.length > 0) {
736
+ // Stop and remove any containers using these volumes first
737
+ const { stdout: containerOut } = await execa("docker", [
738
+ "ps",
739
+ "-a",
740
+ "--filter",
741
+ `name=${projectName}`,
742
+ "--format",
743
+ "{{.ID}}",
744
+ ]);
745
+ const containers = containerOut.split("\n").filter(Boolean);
746
+ if (containers.length > 0) {
747
+ await execa("docker", ["rm", "-f", ...containers]);
748
+ }
749
+ // Remove the Docker network if it exists
750
+ try {
751
+ await execa("docker", ["network", "rm", `${projectName}_default`]);
752
+ } catch {
753
+ // Network may not exist - fine
754
+ }
755
+ // Now remove the orphaned volumes
756
+ for (const vol of orphanedVolumes) {
757
+ await execa("docker", ["volume", "rm", "-f", vol]);
758
+ }
759
+ console.log(chalk.green(" โœ“ Cleaned up orphaned Docker volumes"));
760
+ }
761
+ } catch {
762
+ // Docker not available - fine
763
+ }
764
+
765
+ // Check if directory already exists
766
+ if (await fs.pathExists(targetDir)) {
767
+ if (!options.prompts) {
768
+ // In non-interactive mode, automatically remove existing directory
769
+ console.log(
770
+ chalk.yellow(
771
+ ` Directory "${projectName}" already exists. Removing... (non-interactive mode)`,
772
+ ),
773
+ );
774
+ } else {
775
+ const { overwrite } = await prompts({
776
+ type: "confirm",
777
+ name: "overwrite",
778
+ message: `Directory "${projectName}" already exists. Remove it and recreate?`,
779
+ initial: false,
780
+ });
781
+
782
+ if (!overwrite) {
783
+ console.log(chalk.yellow("Aborted."));
784
+ process.exit(1);
785
+ }
786
+ }
787
+
788
+ // Stop any running Docker containers for this project
789
+ try {
790
+ await execa("docker", ["compose", "down"], {
791
+ cwd: targetDir,
792
+ stdio: "ignore",
793
+ });
794
+ console.log(chalk.green(" โœ“ Stopped existing Docker containers"));
795
+ } catch {
796
+ // No docker-compose file or Docker not running - fine
797
+ }
798
+
799
+ await fs.remove(targetDir);
800
+ }
801
+
802
+ try {
803
+ // Create the base directory
804
+ await fs.ensureDir(targetDir);
805
+
806
+ // Step 1: Copy base project template
807
+ console.log(chalk.cyan(" ๐Ÿ“ฆ Scaffolding base project structure..."));
808
+ await copyTemplate("base-project", targetDir, {
809
+ "@myquark": `@${scope}`,
810
+ });
811
+
812
+ // Step 1b: Place embedded skills in the selected AI harness directory.
813
+ const harness = options.harness || DEFAULT_HARNESS;
814
+ const harnessSkillDir = HARNESS_SKILL_DIRS[harness];
815
+ if (harnessSkillDir) {
816
+ const neutralSkills = path.join(targetDir, "skills");
817
+ if (await fs.pathExists(neutralSkills)) {
818
+ const destSkills = path.join(targetDir, harnessSkillDir);
819
+ await fs.ensureDir(destSkills);
820
+ await fs.copy(neutralSkills, destSkills);
821
+ await fs.remove(neutralSkills);
822
+ console.log(
823
+ chalk.green(
824
+ ` โœ“ Embedded skills โ†’ ${harnessSkillDir} (${harness})`,
825
+ ),
826
+ );
827
+ }
828
+ // Point the scaffolded docs at the harness skill directory.
829
+ for (const doc of ["MAIN.md", "CLAUDE.md", "README.md"]) {
830
+ const docPath = path.join(targetDir, doc);
831
+ if (await fs.pathExists(docPath)) {
832
+ let content = await fs.readFile(docPath, "utf-8");
833
+ content = content.replace(/\.opencode\/skills/g, harnessSkillDir);
834
+ await fs.writeFile(docPath, content);
835
+ }
836
+ }
837
+ } else {
838
+ console.log(
839
+ chalk.yellow(
840
+ ` โš  Unknown harness "${harness}". Skills left in skills/ (no auto-load).`,
841
+ ),
842
+ );
843
+ }
844
+
845
+ // Step 2: Create apps directory
846
+ await fs.ensureDir(path.join(targetDir, "apps"));
847
+
848
+ // Step 3: Create packages directory
849
+ await fs.ensureDir(path.join(targetDir, "packages"));
850
+
851
+ // Step 4: Copy required packages (always included)
852
+ console.log(chalk.cyan("\n ๐Ÿ“ฆ Setting up required packages..."));
853
+
854
+ // Database and config packages are always required
855
+ for (const reqPkg of REQUIRED_PACKAGES) {
856
+ const pkgDir = path.join(targetDir, "packages", reqPkg);
857
+ // db is already copied from base-project; config needs to be copied from its template
858
+ if (!(await fs.pathExists(pkgDir))) {
859
+ await fs.ensureDir(pkgDir);
860
+ await copyTemplate(reqPkg, pkgDir);
861
+ }
862
+ const pkgJsonPath = path.join(pkgDir, "package.json");
863
+ // Verify copy succeeded - guards against Windows fs race conditions where a
864
+ // directory handle is returned before child writes are fully committed.
865
+ if (!(await fs.pathExists(pkgJsonPath))) {
866
+ throw new Error(
867
+ `Required package '${reqPkg}' is missing package.json after copy.\n` +
868
+ ` This can occur on Windows due to filesystem timing. Please re-run the scaffolder.\n` +
869
+ ` Expected: ${pkgJsonPath}`,
870
+ );
871
+ }
872
+ const pkgJson = await fs.readJSON(pkgJsonPath);
873
+ pkgJson.name = `@${scope}/${reqPkg}`;
874
+ await fs.writeFile(
875
+ pkgJsonPath,
876
+ `${JSON.stringify(pkgJson, null, " ")}\n`,
877
+ );
878
+ console.log(chalk.green(` โœ“ ${reqPkg} (required)`));
879
+ }
880
+
881
+ // Step 5: Determine which optional packages to scaffold
882
+ let features;
883
+ let brief = options.prompt?.trim() || `${appDisplayName} application`;
884
+ if (!options.prompts && options.packages !== undefined) {
885
+ // Parse packages from CLI flag
886
+ console.log(chalk.cyan("\n ๐ŸŽฏ Configuring optional packages..."));
887
+ const validFeatures = Object.keys(FEATURE_META);
888
+ features = options.packages
889
+ .split(",")
890
+ .map((f) => f.trim())
891
+ .filter((f) => f.length > 0);
892
+
893
+ // Validate features
894
+ const invalidFeatures = features.filter(
895
+ (f) => !validFeatures.includes(f),
896
+ );
897
+ if (invalidFeatures.length > 0) {
898
+ throw new Error(
899
+ `Invalid packages: ${invalidFeatures.join(", ")}. Valid options are: ${validFeatures.join(", ")}`,
900
+ );
901
+ }
902
+
903
+ // Mobile is added post-creation via `quark add mobile`
904
+ if (features.includes("mobile")) {
905
+ throw new Error(
906
+ 'Mobile app cannot be included during project creation. Run "quark add mobile" after scaffolding.',
907
+ );
908
+ }
909
+
910
+ const requestedFeatures = [...features];
911
+ features = resolveFeatureSelection(features);
912
+ const autoIncluded = features.filter(
913
+ (feature) => !requestedFeatures.includes(feature),
914
+ );
915
+ if (autoIncluded.length > 0) {
916
+ console.log(
917
+ chalk.yellow(
918
+ ` โ„น Automatically included required packages: ${autoIncluded.join(", ")}`,
919
+ ),
920
+ );
921
+ }
922
+
923
+ console.log(
924
+ chalk.green(
925
+ ` Selected packages: ${features.join(", ") || "none"} (non-interactive mode)`,
926
+ ),
927
+ );
928
+ } else if (!options.prompts) {
929
+ // Use defaults when --no-prompts is set without --packages
930
+ console.log(chalk.cyan("\n ๐ŸŽฏ Configuring optional packages..."));
931
+ features = ["ui", "jobs"];
932
+ console.log(
933
+ chalk.green(
934
+ ` Using default packages: ${features.join(", ")} (non-interactive mode)`,
935
+ ),
936
+ );
937
+ } else {
938
+ // Interactive: simple Y/n prompts
939
+ console.log(chalk.cyan("\n ๐Ÿ’ก Let's set up your project...\n"));
940
+ const briefResponse = await prompts({
941
+ type: "text",
942
+ name: "brief",
943
+ message: "Describe your app in a sentence or two (seeds MAIN.md):",
944
+ initial: `${appDisplayName} application`,
945
+ });
946
+ brief = briefResponse.brief;
947
+ if (!brief) {
948
+ console.log(chalk.yellow("\nโš ๏ธ Setup cancelled."));
949
+ await fs.remove(targetDir);
950
+ process.exit(0);
951
+ }
952
+ brief = brief.trim();
953
+
954
+ console.log("");
955
+ const jobsResponse = await prompts({
956
+ type: "confirm",
957
+ name: "value",
958
+ message:
959
+ "Include background jobs (emails, webhooks, scheduled tasks)?",
960
+ initial: true,
961
+ });
962
+ if (typeof jobsResponse.value !== "boolean") {
963
+ console.log(chalk.yellow("\nโš ๏ธ Setup cancelled."));
964
+ await fs.remove(targetDir);
965
+ process.exit(0);
966
+ }
967
+
968
+ const selectedFeatures = ["ui"];
969
+ if (jobsResponse.value) {
970
+ selectedFeatures.push("jobs");
971
+ }
972
+
973
+ const pwaResponse = await prompts({
974
+ type: "confirm",
975
+ name: "enabled",
976
+ message: "Enable PWA support (offline, installable)?",
977
+ initial: false,
978
+ });
979
+ if (typeof pwaResponse.enabled !== "boolean") {
980
+ console.log(chalk.yellow("\nโš ๏ธ Setup cancelled."));
981
+ await fs.remove(targetDir);
982
+ process.exit(0);
983
+ }
984
+ if (pwaResponse.enabled) {
985
+ selectedFeatures.push("pwa");
986
+ }
987
+
988
+ features = resolveFeatureSelection(selectedFeatures);
989
+
990
+ const requestedFeatures = [...selectedFeatures];
991
+ const autoIncluded = features.filter(
992
+ (feature) => !requestedFeatures.includes(feature),
993
+ );
994
+ if (autoIncluded.length > 0) {
995
+ console.log(
996
+ chalk.yellow(
997
+ ` โ„น Automatically included required packages: ${autoIncluded.join(", ")}.`,
998
+ ),
999
+ );
1000
+ }
1001
+ }
1002
+
1003
+ const scaffoldPackages = getWorkspacePackagesForFeatures(features);
1004
+ const pairedTemplates = getPairedTemplatesForFeatures(features);
1005
+ let allowSignup;
1006
+
1007
+ if (requestedSignupPreference !== undefined) {
1008
+ allowSignup = requestedSignupPreference;
1009
+ console.log(
1010
+ chalk.cyan(
1011
+ `\n ๐Ÿ” Public signup: ${allowSignup ? "enabled" : "disabled"} (from flag)`,
1012
+ ),
1013
+ );
1014
+ } else if (!options.prompts) {
1015
+ allowSignup = true;
1016
+ console.log(
1017
+ chalk.cyan(
1018
+ "\n ๐Ÿ” Public signup: enabled (default, non-interactive mode)",
1019
+ ),
1020
+ );
1021
+ } else {
1022
+ console.log(chalk.cyan("\n ๐Ÿ” Configuring authentication...\n"));
1023
+ const authResponse = await prompts({
1024
+ type: "confirm",
1025
+ name: "allowSignup",
1026
+ message: "Allow public self-service signup?",
1027
+ initial: true,
1028
+ });
1029
+
1030
+ if (typeof authResponse.allowSignup !== "boolean") {
1031
+ console.log(chalk.yellow("\nโš ๏ธ Setup cancelled."));
1032
+ await fs.remove(targetDir);
1033
+ process.exit(0);
1034
+ }
1035
+
1036
+ allowSignup = authResponse.allowSignup;
1037
+ }
1038
+
1039
+ const signupEnvValue = allowSignup ? "true" : "false";
1040
+
1041
+ // Step 6: Copy selected optional packages
1042
+ if (scaffoldPackages.length > 0) {
1043
+ console.log(chalk.cyan("\n ๐Ÿ“‹ Setting up optional packages..."));
1044
+
1045
+ for (const packageName of scaffoldPackages) {
1046
+ const templatePath = path.join(templatesDir, packageName);
1047
+ if (!(await fs.pathExists(templatePath))) {
1048
+ console.log(
1049
+ chalk.yellow(
1050
+ ` โš  ${packageName} (template not yet available - skipped)`,
1051
+ ),
1052
+ );
1053
+ continue;
1054
+ }
1055
+
1056
+ const packageDir = path.join(targetDir, "packages", packageName);
1057
+ await fs.ensureDir(packageDir);
1058
+ await copyTemplate(packageName, packageDir);
1059
+
1060
+ // Update package.json with proper scope
1061
+ const packageJsonPath = path.join(packageDir, "package.json");
1062
+ await updatePackageJsonName(packageJsonPath, scope);
1063
+
1064
+ console.log(chalk.green(` โœ“ ${packageName}`));
1065
+ }
1066
+
1067
+ // If jobs selected, also scaffold apps/worker from its template
1068
+ if (pairedTemplates.includes("worker")) {
1069
+ const workerDir = path.join(targetDir, "apps", "worker");
1070
+ await fs.ensureDir(workerDir);
1071
+ await copyTemplate("worker", workerDir);
1072
+ console.log(chalk.green(` โœ“ worker (paired with jobs)`));
1073
+ }
1074
+ }
1075
+
1076
+ // Step 6b: Copy PWA files to web app if selected
1077
+ if (features.includes("pwa")) {
1078
+ console.log(chalk.cyan("\n ๐Ÿ“‹ Setting up PWA support..."));
1079
+ const pwaTemplateDir = path.join(templatesDir, "pwa");
1080
+ const webDir = path.join(targetDir, "apps/web");
1081
+
1082
+ // Replace the base manifest.js. Next.js serves app/manifest.js at
1083
+ // /manifest.webmanifest; adding a sibling app/manifest.json made the
1084
+ // build fail with "Cannot find module for page: /manifest.webmanifest".
1085
+ await fs.copy(
1086
+ path.join(pwaTemplateDir, "app/manifest.js"),
1087
+ path.join(webDir, "src/app/manifest.js"),
1088
+ );
1089
+
1090
+ // Point the PWA manifest at this project's scope.
1091
+ await substitutePlaceholders(path.join(webDir, "src/app/manifest.js"), {
1092
+ __QUARK_SCOPE__: scope,
1093
+ });
1094
+
1095
+ // Copy sw.js to public/
1096
+ await fs.copy(
1097
+ path.join(pwaTemplateDir, "public/sw.js"),
1098
+ path.join(webDir, "public/sw.js"),
1099
+ );
1100
+
1101
+ // Copy PWARegister client component
1102
+ await fs.copy(
1103
+ path.join(pwaTemplateDir, "app/_components/PWARegister.js"),
1104
+ path.join(webDir, "src/app/_components/PWARegister.js"),
1105
+ );
1106
+
1107
+ // Inject PWARegister into layout.js
1108
+ const layoutPath = path.join(webDir, "src/app/layout.js");
1109
+ let layout = await fs.readFile(layoutPath, "utf-8");
1110
+
1111
+ // Add import (after the last existing import)
1112
+ layout = layout.replace(
1113
+ /import UmamiWebVitals from/,
1114
+ 'import PWARegister from "./_components/PWARegister.js";\nimport UmamiWebVitals from',
1115
+ );
1116
+
1117
+ // Add viewport export (before generateMetadata)
1118
+ layout = layout.replace(
1119
+ /export function generateMetadata/,
1120
+ 'export const viewport = {\n\tthemeColor: "#000000",\n};\n\nexport function generateMetadata',
1121
+ );
1122
+
1123
+ // Add <PWARegister /> to body (before {children})
1124
+ layout = layout.replace(/<body>/, "<body>\n\t\t\t<PWARegister />");
1125
+
1126
+ await fs.writeFile(layoutPath, layout);
1127
+
1128
+ console.log(
1129
+ chalk.green(" โœ“ PWA support enabled (manifest + service worker)"),
1130
+ );
1131
+ }
1132
+
1133
+ // Step 7: Update all package.json dependencies to use correct scope
1134
+ console.log(chalk.cyan("\n ๐Ÿ”ง Updating app dependencies..."));
1135
+
1136
+ // Collect all package.json files that need scope replacement (apps + packages)
1137
+ const allPkgPaths = [
1138
+ path.join(targetDir, "apps", "web", "package.json"),
1139
+ // Worker is only present when jobs is selected
1140
+ ...(features.includes("jobs")
1141
+ ? [path.join(targetDir, "apps", "worker", "package.json")]
1142
+ : []),
1143
+
1144
+ // Also update cross-dependencies in scaffolded packages (e.g. db โ†’ config)
1145
+ ...[...REQUIRED_PACKAGES, ...scaffoldPackages].map((pkg) =>
1146
+ path.join(targetDir, "packages", pkg, "package.json"),
1147
+ ),
1148
+ ];
1149
+
1150
+ for (const pkgPath of allPkgPaths) {
1151
+ if (await fs.pathExists(pkgPath)) {
1152
+ const pkg = await fs.readJSON(pkgPath);
1153
+ // Rename package name if it uses @myquark/ placeholder scope
1154
+ if (pkg.name?.startsWith("@myquark/")) {
1155
+ const shortName = pkg.name.replace("@myquark/", "");
1156
+ pkg.name = `@${scope}/${shortName}`;
1157
+ }
1158
+ replaceDepsScope(pkg.dependencies, scope, scaffoldPackages);
1159
+ replaceDepsScope(pkg.devDependencies, scope, scaffoldPackages);
1160
+ await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
1161
+ }
1162
+ }
1163
+
1164
+ // Also rename root package.json
1165
+ const rootPkgPath = path.join(targetDir, "package.json");
1166
+ if (await fs.pathExists(rootPkgPath)) {
1167
+ const rootPkg = await fs.readJSON(rootPkgPath);
1168
+ rootPkg.name = `@${scope}/root`;
1169
+ await fs.writeFile(
1170
+ rootPkgPath,
1171
+ `${JSON.stringify(rootPkg, null, " ")}\n`,
1172
+ );
1173
+ }
1174
+
1175
+ console.log(chalk.green(` โœ“ App dependencies updated`));
1176
+
1177
+ // Step 7b: Replace workspace package imports in source files
1178
+ console.log(chalk.cyan("\n ๐Ÿ”„ Updating import paths..."));
1179
+ await replaceImportsInSourceFiles(targetDir, scope);
1180
+ console.log(chalk.green(` โœ“ Import paths updated`));
1181
+
1182
+ // Step 7c: Patch next.config.js to remove unselected feature transpilePackages entries
1183
+ await patchNextConfig(
1184
+ path.join(targetDir, "apps", "web"),
1185
+ scope,
1186
+ scaffoldPackages,
1187
+ );
1188
+ console.log(chalk.green(` โœ“ next.config.js patched`));
1189
+
1190
+ // Step 7d: Strip jobs-related code from register route when jobs not selected
1191
+ if (!features.includes("jobs")) {
1192
+ const registerPath = path.join(
1193
+ targetDir,
1194
+ "apps",
1195
+ "web",
1196
+ "src",
1197
+ "app",
1198
+ "api",
1199
+ "auth",
1200
+ "register",
1201
+ "route.js",
1202
+ );
1203
+ if (await fs.pathExists(registerPath)) {
1204
+ let content = await fs.readFile(registerPath, "utf-8");
1205
+ // Remove the jobs import line
1206
+ content = content.replace(
1207
+ /import\s*\{[^}]*\}\s*from\s*["']@[^/]+\/jobs["'];?\n/,
1208
+ "",
1209
+ );
1210
+ // Remove createQueue from the core import if present
1211
+ content = content.replace(/\tcreateQueue,\n/, "");
1212
+ // Remove the @quark:start:jobs ... @quark:end:jobs block (inclusive)
1213
+ content = content.replace(
1214
+ /[ \t]*\/\/ @quark:start:jobs[\s\S]*?\/\/ @quark:end:jobs\n?/,
1215
+ "",
1216
+ );
1217
+ await fs.writeFile(registerPath, content);
1218
+ }
1219
+ }
1220
+
1221
+ // Step 8: Create .env.example file
1222
+ console.log(chalk.cyan("\n ๐Ÿ“‹ Creating environment configuration..."));
1223
+ const envExampleTemplate = `# โš ๏ธ IMPORTANT: Copy this file to .env and fill in the values for your environment.
1224
+ # NEVER commit the .env file to version control - it contains secrets!
1225
+ # $ cp .env.example .env
1226
+
1227
+ # --- Environment ---
1228
+ # Supported: development, test, staging, production (default: development)
1229
+ # NODE_ENV=development
1230
+
1231
+ # --- Database Configuration ---
1232
+ # These map to the service names in docker-compose.yml
1233
+ # โš ๏ธ SECURITY WARNING: Change these default passwords in production!
1234
+ # Generate strong passwords with: openssl rand -base64 32
1235
+ POSTGRES_HOST=localhost
1236
+ POSTGRES_PORT=5432
1237
+ POSTGRES_USER=${scope}_user
1238
+ POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
1239
+ POSTGRES_DB=${scope}_dev
1240
+ # Optional: Set DATABASE_URL to override the dynamic construction above
1241
+ # DATABASE_URL="postgresql://${scope}_user:CHANGE_ME_TO_STRONG_PASSWORD@localhost:5432/${scope}_dev?schema=public"
1242
+
1243
+ # --- Database Pool Configuration ---
1244
+ # Connection pool settings are managed automatically. Customize if needed:
1245
+ # For advanced tuning, see Prisma connection pool documentation.
1246
+
1247
+ # --- Redis Configuration ---
1248
+ REDIS_HOST=localhost
1249
+ REDIS_PORT=6379
1250
+ # Optional: Set REDIS_URL to override the dynamic construction above
1251
+ # REDIS_URL="redis://localhost:6379"
1252
+
1253
+ # --- Mail Configuration ---
1254
+ # Email can be sent via SMTP (local or production), Resend, or Zeptomail.
1255
+ # Choose one provider below based on your needs.
1256
+
1257
+ # Development: Mailpit local SMTP (defaults below work with docker-compose)
1258
+ MAIL_HOST=localhost
1259
+ MAIL_SMTP_PORT=1025
1260
+ MAIL_UI_PORT=8025
1261
+
1262
+ # Production SMTP: Set these instead of MAIL_* when using a real SMTP relay
1263
+ # SMTP_HOST=smtp.example.com
1264
+ # SMTP_PORT=587
1265
+ # SMTP_SECURE=true
1266
+ # SMTP_USER=your_smtp_user
1267
+ # SMTP_PASSWORD=your_smtp_password
1268
+
1269
+ # --- Email Provider Selection ---
1270
+ # Choose one: "smtp" (default), "resend", or "zeptomail"
1271
+ # EMAIL_PROVIDER=smtp
1272
+ # EMAIL_FROM=App Name <noreply@yourdomain.com>
1273
+
1274
+ # Zeptomail (recommended for production)
1275
+ # Get started at: https://www.zoho.com/zeptomail/
1276
+ # Your token is shown in Zeptomail console and includes the "Zoho-enczapikey" prefix:
1277
+ # e.g. ZEPTOMAIL_TOKEN=Zoho-enczapikey <your_key_here>
1278
+ # ZEPTOMAIL_TOKEN=Zoho-enczapikey your_zeptomail_api_key
1279
+ # ZEPTOMAIL_URL=https://api.zeptomail.com # Base URL; /v1.1/email is appended in code
1280
+ # ZEPTOMAIL_BOUNCE_EMAIL=bounce@yourdomain.com # optional
1281
+
1282
+ # Resend (alternative provider)
1283
+ # Get your API key at: https://resend.com/api-keys
1284
+ # RESEND_API_KEY=re_xxxxxxxxxxxxx
1285
+
1286
+ # --- Application URL ---
1287
+ # Canonical origin for the app. Auth.js pins every sign-in redirect and the
1288
+ # client-side Auth.js base URL to this, so it must match the origin you browse
1289
+ # on exactly (scheme + host + port).
1290
+ # In development it falls back to http://localhost:PORT - set it explicitly
1291
+ # whenever you use something else (127.0.0.1, a LAN IP, a tunnel, a different
1292
+ # port), otherwise the session cookie is issued for one host and the
1293
+ # post-sign-in redirect points at another, and you land back signed out.
1294
+ # In production, set this to your real domain:
1295
+ # APP_URL=https://yourdomain.com
1296
+
1297
+ # --- Application Identity ---
1298
+ # APP_NAME is used in metadata, emails, and page titles.
1299
+ # โš ๏ธ APP_DESCRIPTION affects SEO snippets - update before production.
1300
+ APP_NAME=${appDisplayName}
1301
+ APP_DESCRIPTION=${appDescription}
1302
+
1303
+ # --- NextAuth Configuration ---
1304
+ # โš ๏ธ CRITICAL: Generate a secure secret with: openssl rand -base64 32
1305
+ # This secret is used to encrypt JWT tokens and session data.
1306
+ # Must be at least 32 characters or startup validation fails.
1307
+ NEXTAUTH_SECRET=CHANGE_ME_TO_A_32_CHARACTERS_OR_LONGER_SECRET
1308
+
1309
+ # NextAuth callback URL. Normally leave this unset: it is derived from APP_URL.
1310
+ # Only set it if the auth endpoints live on a different origin, and never give
1311
+ # it a path - it must be a bare origin (scheme + host + port).
1312
+ # NEXTAUTH_URL=https://yourdomain.com
1313
+
1314
+ # --- Authentication ---
1315
+ # Toggle during development without re-scaffolding.
1316
+ AUTH_ALLOW_SIGNUP=${signupEnvValue}
1317
+
1318
+ # --- OAuth Providers (Not Yet Implemented) ---
1319
+ # OAuth support is planned for a future release.
1320
+ # GitHub OAuth - Get credentials at: https://github.com/settings/developers
1321
+ # GITHUB_ID=your_github_client_id
1322
+ # GITHUB_SECRET=your_github_client_secret
1323
+
1324
+ # Google OAuth - Get credentials at: https://console.cloud.google.com/apis/credentials
1325
+ # GOOGLE_CLIENT_ID=your_google_client_id
1326
+ # GOOGLE_CLIENT_SECRET=your_google_client_secret
1327
+
1328
+ # --- Web App Configuration ---
1329
+ PORT=3000
1330
+
1331
+ # Development-only: allow extra hosts that can load Next.js dev endpoints
1332
+ # from LAN/VPN devices. Comma-separate additional hosts or IPs as needed.
1333
+ # NEXT_DEV_ALLOWED_ORIGINS=192.168.1.9,my-macbook.local
1334
+ # Legacy alias still recognized: ALLOWED_DEV_ORIGINS=192.168.1.9,my-macbook.local
1335
+
1336
+ # --- Worker Configuration ---
1337
+ WORKER_CONCURRENCY=5
1338
+
1339
+ # --- File Storage ---
1340
+ # Provider: "local" (default) or "s3" (S3-compatible: AWS S3, Cloudflare R2, MinIO)
1341
+ STORAGE_PROVIDER=local
1342
+ # Local storage directory (only when STORAGE_PROVIDER=local)
1343
+ # STORAGE_LOCAL_DIR=./uploads
1344
+
1345
+ # S3 / Cloudflare R2 (only when STORAGE_PROVIDER=s3)
1346
+ # S3_BUCKET=your-bucket-name
1347
+ # S3_REGION=auto # Use "auto" for Cloudflare R2
1348
+ # S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
1349
+ # S3_ACCESS_KEY_ID=your-access-key
1350
+ # S3_SECRET_ACCESS_KEY=your-secret-key
1351
+ # S3_PUBLIC_URL=https://your-public-bucket-domain.com
1352
+
1353
+ # --- Upload Limits ---
1354
+ # UPLOAD_MAX_SIZE=10485760 # Max file size in bytes (default: 10MB)
1355
+ # UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif,image/svg+xml,application/pdf
1356
+
1357
+ # --- Rate Limiting & Security ---
1358
+ # RATE_LIMIT_MAX=100 # Max requests per window (default: 1000 dev, 100 prod)
1359
+ # RATE_LIMIT_WINDOW_MS=900000 # Window in ms (default: 15 minutes)
1360
+ # API_BODY_SIZE_LIMIT=2097152 # 2MB (default)
1361
+ # UPLOAD_SIZE_LIMIT=10485760 # 10MB proxy-level limit (default)
1362
+
1363
+ # --- Logging & Cache ---
1364
+ # LOG_LEVEL=debug # debug, info, warn, error (default: debug dev, info prod)
1365
+ # CACHE_TTL=60 # Default cache TTL in seconds (default: 60 dev, 600 prod)
1366
+
1367
+ # --- Database Seeding ---
1368
+ # SEED_PROFILE=dev # Options: dev (default, includes audit logs + sample job), minimal (users only)
1369
+ `;
1370
+ await fs.writeFile(
1371
+ path.join(targetDir, ".env.example"),
1372
+ envExampleTemplate,
1373
+ );
1374
+ console.log(chalk.green(` โœ“ .env.example`));
1375
+
1376
+ // Step 9: Find available ports and generate .env
1377
+ console.log(chalk.cyan("\n ๐Ÿ”Œ Checking port availability..."));
1378
+ const postgresPort = await findAvailablePort(5432);
1379
+ const redisPort = await findAvailablePort(6379);
1380
+ const mailSmtpPort = await findAvailablePort(1025);
1381
+ const mailUiPort = await findAvailablePort(8025);
1382
+ const webPort = await findAvailablePort(3000);
1383
+
1384
+ const portChanges = [];
1385
+ if (postgresPort !== 5432)
1386
+ portChanges.push(`PostgreSQL: ${postgresPort}`);
1387
+ if (redisPort !== 6379) portChanges.push(`Redis: ${redisPort}`);
1388
+ if (mailSmtpPort !== 1025) portChanges.push(`Mail SMTP: ${mailSmtpPort}`);
1389
+ if (mailUiPort !== 8025) portChanges.push(`Mail UI: ${mailUiPort}`);
1390
+ if (webPort !== 3000) portChanges.push(`Web: ${webPort}`);
1391
+
1392
+ if (portChanges.length > 0) {
1393
+ console.log(chalk.yellow(` โšก Ports adjusted to avoid conflicts:`));
1394
+ for (const change of portChanges) {
1395
+ console.log(chalk.yellow(` โ€ข ${change}`));
1396
+ }
1397
+ } else {
1398
+ console.log(chalk.green(` โœ“ All default ports available`));
1399
+ }
1400
+
1401
+ console.log(chalk.cyan("\n ๐Ÿ”‘ Generating secure environment file..."));
1402
+
1403
+ // Generate secure random values
1404
+ const dbPassword = generateSecurePassword(24);
1405
+ const nextAuthSecret = generateSecureSecret(32);
1406
+
1407
+ // Create .env with auto-generated secure values
1408
+ const envContent = `# --- Database Configuration ---
1409
+ POSTGRES_HOST=localhost
1410
+ POSTGRES_PORT=${postgresPort}
1411
+ POSTGRES_USER=${scope}_user
1412
+ POSTGRES_PASSWORD=${dbPassword}
1413
+ POSTGRES_DB=${scope}_dev
1414
+
1415
+ # --- Redis Configuration ---
1416
+ REDIS_HOST=localhost
1417
+ REDIS_PORT=${redisPort}
1418
+
1419
+ # --- Mail Configuration ---
1420
+ MAIL_HOST=localhost
1421
+ MAIL_SMTP_PORT=${mailSmtpPort}
1422
+ MAIL_UI_PORT=${mailUiPort}
1423
+
1424
+ # --- Application Identity ---
1425
+ # APP_NAME is used in metadata, emails, and page titles.
1426
+ # โš ๏ธ APP_DESCRIPTION affects SEO snippets - update before production.
1427
+ APP_NAME=${appDisplayName}
1428
+ APP_DESCRIPTION=${appDescription}
1429
+
1430
+ # --- NextAuth Configuration ---
1431
+ NEXTAUTH_SECRET=${nextAuthSecret}
1432
+
1433
+ # --- Authentication ---
1434
+ # Toggle during development without re-scaffolding.
1435
+ AUTH_ALLOW_SIGNUP=${signupEnvValue}
1436
+
1437
+ # --- Web App Configuration ---
1438
+ # APP_URL is derived from PORT automatically in development.
1439
+ # In production, set APP_URL explicitly in your environment.
1440
+ PORT=${webPort}
1441
+
1442
+ # --- Worker Configuration ---
1443
+ WORKER_CONCURRENCY=5
1444
+
1445
+ # --- File Storage ---
1446
+ STORAGE_PROVIDER=local
1447
+
1448
+ # --- Database Seeding ---
1449
+ # SEED_PROFILE=dev # Options: dev (default), minimal (users only - use for production initial seed)
1450
+ #`;
1451
+ await fs.writeFile(path.join(targetDir, ".env"), envContent);
1452
+ console.log(
1453
+ chalk.green(` โœ“ .env (with auto-generated secure secrets)`),
1454
+ );
1455
+
1456
+ // Step 11: Create .quark-link.json to track Quark version
1457
+ const scaffoldedAt =
1458
+ process.env.QUARK_SCAFFOLD_DATE || new Date().toISOString();
1459
+ const quarkLinkJson = {
1460
+ quarkVersion: process.env.QUARK_VERSION || "latest",
1461
+ quarkSourcePath: process.env.QUARK_SOURCE_PATH || "../../quark",
1462
+ scaffoldedDate: scaffoldedAt,
1463
+ projectName,
1464
+ requiredPackages: REQUIRED_PACKAGES,
1465
+ packages: [...new Set([...REQUIRED_PACKAGES, ...features])],
1466
+ featureVersions: {},
1467
+ authAllowSignup: allowSignup,
1468
+ // Track that worker is paired with jobs (not independently selectable)
1469
+ hasWorker: features.includes("jobs"),
1470
+ brief,
1471
+ };
1472
+ await fs.writeFile(
1473
+ path.join(targetDir, ".quark-link.json"),
1474
+ JSON.stringify(quarkLinkJson, null, " "),
1475
+ );
1476
+ console.log(chalk.green(` โœ“ .quark-link.json`));
1477
+
1478
+ // Step 10b + 10c: Generate all AI coding tool context files
1479
+ console.log(chalk.cyan("\n ๐Ÿค– Generating AI context files..."));
1480
+
1481
+ // Build shared variable map (used by SKILL.md, CLAUDE.md, .cursor/rules, copilot-instructions)
1482
+ const scaffoldDate = normalizeScaffoldDate(scaffoldedAt);
1483
+ const optionalLines = scaffoldPackages
1484
+ .map((f) => {
1485
+ const labels = {
1486
+ ui: "Shared UI components",
1487
+ jobs: "Job queue definitions",
1488
+ pwa: "PWA support",
1489
+ };
1490
+ return `โ”‚ โ”œโ”€โ”€ ${f}/ # ${labels[f] || f}`;
1491
+ })
1492
+ .join("\n");
1493
+ const optionalAppLines = buildOptionalAppLines(features);
1494
+ const optionalBlock = optionalLines ? `${optionalLines}\n` : "";
1495
+ const featureRows = buildFeatureRows(features);
1496
+ const firstEditLines = buildFirstEditLines(features);
1497
+ const featureGuideLines = buildFeatureGuideLines(features);
1498
+
1499
+ // Every __QUARK_* placeholder used by scaffolded files. Keep this map as
1500
+ // the single source of truth so template files cannot ship with
1501
+ // un-substituted placeholders (guarded by scaffold-output.test.js).
1502
+ const placeholderValues = {
1503
+ __QUARK_SCOPE__: scope,
1504
+ __QUARK_PROJECT_NAME__: projectName,
1505
+ __QUARK_SCAFFOLD_DATE__: scaffoldDate,
1506
+ __QUARK_OPTIONAL_APPS__: optionalAppLines,
1507
+ __QUARK_OPTIONAL_PACKAGES__: optionalBlock,
1508
+ __QUARK_FEATURE_ROWS__: featureRows,
1509
+ __QUARK_FIRST_EDITS__: firstEditLines,
1510
+ __QUARK_FEATURE_GUIDES__: featureGuideLines,
1511
+ __QUARK_PROJECT_BRIEF__: brief,
1512
+ };
1513
+
1514
+ // Step 10b: Substitute variables in project-context SKILL.md
1515
+ const skillPath = path.join(
1516
+ targetDir,
1517
+ ".github",
1518
+ "skills",
1519
+ "project-context",
1520
+ "SKILL.md",
1521
+ );
1522
+ if (await fs.pathExists(skillPath)) {
1523
+ await substitutePlaceholders(skillPath, placeholderValues);
1524
+ console.log(
1525
+ chalk.green(` โœ“ .github/skills/project-context/SKILL.md`),
1526
+ );
1527
+ }
1528
+
1529
+ // Step 10c: Substitute variables in all AI coding tool context files
1530
+ const aiContextFiles = [
1531
+ "README.md",
1532
+ "CLAUDE.md",
1533
+ ".cursor/rules/quark.mdc",
1534
+ ".github/copilot-instructions.md",
1535
+ "docs/adr/README.md",
1536
+ ];
1537
+ for (const relPath of aiContextFiles) {
1538
+ const filePath = path.join(targetDir, relPath);
1539
+ if (await fs.pathExists(filePath)) {
1540
+ await substitutePlaceholders(filePath, placeholderValues);
1541
+ console.log(chalk.green(` โœ“ ${relPath}`));
1542
+ }
1543
+ }
1544
+
1545
+ // Step 10d: Substitute variables in scaffolded docs, Railway config,
1546
+ // and embedded skills (outside the AI-context file list above).
1547
+ const scaffoldAssetFiles = [
1548
+ "MAIN.md",
1549
+ ".env.railway.example",
1550
+ ".railway/railway.ts",
1551
+ ];
1552
+ for (const relPath of scaffoldAssetFiles) {
1553
+ const filePath = path.join(targetDir, relPath);
1554
+ if (await fs.pathExists(filePath)) {
1555
+ await substitutePlaceholders(filePath, placeholderValues);
1556
+ }
1557
+ }
1558
+
1559
+ const skillsPlaceholderDir = harnessSkillDir
1560
+ ? path.join(targetDir, harnessSkillDir)
1561
+ : path.join(targetDir, "skills");
1562
+ if (await fs.pathExists(skillsPlaceholderDir)) {
1563
+ await substitutePlaceholdersInMarkdownFiles(
1564
+ skillsPlaceholderDir,
1565
+ placeholderValues,
1566
+ );
1567
+ console.log(
1568
+ chalk.green(` โœ“ Embedded skill placeholders substituted`),
1569
+ );
1570
+ }
1571
+
1572
+ // Step 11: Initialize git repository
1573
+ if (process.env.QUARK_SKIP_GIT_INIT === "true") {
1574
+ console.log(chalk.dim("\n ยท Skipping git initialization"));
1575
+ } else if (enclosingRepo) {
1576
+ console.log(
1577
+ chalk.dim(
1578
+ "\n ยท Skipping git initialization (inside an existing repository)",
1579
+ ),
1580
+ );
1581
+ } else {
1582
+ console.log(chalk.cyan("\n ๐Ÿ“ Setting up git..."));
1583
+ const gitResult = await initializeGit(targetDir);
1584
+ if (gitResult.initialized) {
1585
+ console.log(chalk.green(` โœ“ Git initialized with initial commit`));
1586
+ } else if (gitResult.existingRoot) {
1587
+ console.log(
1588
+ chalk.green(
1589
+ ` โœ“ Using existing git repository: ${gitResult.existingRoot}`,
1590
+ ),
1591
+ );
1592
+
1593
+ // GitHub Actions only reads workflow files from the repository
1594
+ // root, so warn when the scaffold's own workflows will be ignored.
1595
+ if (gitResult.existingRoot !== canonicalPath(targetDir)) {
1596
+ const relativeProjectPath = path.relative(
1597
+ gitResult.existingRoot,
1598
+ targetDir,
1599
+ );
1600
+ console.log(
1601
+ chalk.yellow(
1602
+ `\n โš ๏ธ Nested project: GitHub only runs workflows stored at the repository root.`,
1603
+ ),
1604
+ );
1605
+ console.log(
1606
+ chalk.white(
1607
+ ` ${relativeProjectPath}/.github/workflows/*.yml will be ignored by GitHub Actions.`,
1608
+ ),
1609
+ );
1610
+ console.log(
1611
+ chalk.white(
1612
+ ` To enable CI/deploys, copy them into ${gitResult.existingRoot}/.github/workflows/`,
1613
+ ),
1614
+ );
1615
+ console.log(
1616
+ chalk.white(
1617
+ ` and set working-directory: ${relativeProjectPath} on each job's steps.\n`,
1618
+ ),
1619
+ );
1620
+ }
1621
+ }
1622
+ }
1623
+
1624
+ // Step 12: Run pnpm install
1625
+ if (!options.skipInstall) {
1626
+ console.log(chalk.cyan("\n ๐Ÿ“ฆ Installing dependencies..."));
1627
+ try {
1628
+ await execa("pnpm", ["install"], {
1629
+ cwd: targetDir,
1630
+ stdio: "inherit",
1631
+ });
1632
+ console.log(chalk.green(`\n โœ“ Dependencies installed`));
1633
+ } catch (installError) {
1634
+ console.warn(
1635
+ chalk.yellow(
1636
+ `\n โš ๏ธ pnpm install failed: ${installError.message}`,
1637
+ ),
1638
+ );
1639
+ console.warn(
1640
+ chalk.yellow(
1641
+ ` Run 'pnpm install' manually after resolving the issue.`,
1642
+ ),
1643
+ );
1644
+ }
1645
+
1646
+ // Step 13: Generate Prisma client
1647
+ console.log(chalk.cyan("\n ๐Ÿ—„๏ธ Generating Prisma client..."));
1648
+ try {
1649
+ await execa("pnpm", ["--filter", "db", "db:generate"], {
1650
+ cwd: targetDir,
1651
+ stdio: "inherit",
1652
+ });
1653
+ console.log(chalk.green(` โœ“ Prisma client generated`));
1654
+ } catch (generateError) {
1655
+ console.warn(
1656
+ chalk.yellow(
1657
+ `\n โš ๏ธ Prisma generate failed: ${generateError.message}`,
1658
+ ),
1659
+ );
1660
+ console.warn(
1661
+ chalk.yellow(` Run 'pnpm --filter db db:generate' manually.`),
1662
+ );
1663
+ }
1664
+ }
1665
+
1666
+ // Success message
1667
+ console.log(
1668
+ chalk.green.bold(
1669
+ `\nโœ… Project "${projectName}" created successfully!\n`,
1670
+ ),
1671
+ );
1672
+
1673
+ console.log(chalk.white(`๐Ÿ“‚ Project location: ${targetDir}\n`));
1674
+ console.log(chalk.cyan("Start here:"));
1675
+ console.log(chalk.white(` 1. cd ${projectName}`));
1676
+ console.log(chalk.white(` 2. docker compose up -d`));
1677
+ console.log(chalk.white(` 3. pnpm db:migrate`));
1678
+ console.log(chalk.white(` 4. pnpm db:seed`));
1679
+ console.log(chalk.white(` 5. pnpm dev\n`));
1680
+ console.log(
1681
+ chalk.dim(
1682
+ ` Toggle signup any time in development by editing AUTH_ALLOW_SIGNUP in .env\n`,
1683
+ ),
1684
+ );
1685
+ console.log(chalk.cyan("Build with AI:"));
1686
+ console.log(
1687
+ chalk.white(` Open CLAUDE.md in your AI tool, then tell it:`),
1688
+ );
1689
+ console.log(
1690
+ chalk.white(
1691
+ ` "I'm building ${projectName} - [what it does]. Review CLAUDE.md and let's start."\n`,
1692
+ ),
1693
+ );
1694
+
1695
+ console.log(
1696
+ chalk.dim(
1697
+ ` ๐Ÿ“– github.com/usequark/quark โ€ข Updates: npx @techstream/quark-create-app update\n`,
1698
+ ),
1699
+ );
1700
+ } catch (error) {
1701
+ console.error(chalk.red(`\nโœ— Error creating project: ${error.message}`));
1702
+ console.error(chalk.dim(error.stack));
1703
+
1704
+ // Clean up on error
1705
+ if (await fs.pathExists(targetDir)) {
1706
+ await fs.remove(targetDir);
1707
+ }
1708
+
1709
+ process.exit(1);
1710
+ }
1711
+ });
1712
+
1713
+ // ---------------------------------------------------------------------------
1714
+ // quark add <feature> - Add optional packages to an existing Quark project
1715
+ // ---------------------------------------------------------------------------
1716
+
1717
+ /**
1718
+ * Feature metadata: dependencies, packages, paired apps/routes, and starter.
1719
+ *
1720
+ * Two kinds of feature:
1721
+ * - `package` (default): scaffolds a workspace package (and optional paired
1722
+ * apps/routes). e.g. ui, jobs.
1723
+ * - `starter`: drops in a minified domain starter (generic Prisma model + CRUD
1724
+ * endpoint + skill) from `templates/starters/<starter>/`. No workspace
1725
+ * package, no paired routes.
1726
+ */
1727
+ const FEATURE_META = {
1728
+ ui: { requires: [], packages: ["ui"], pairs: [] },
1729
+ jobs: { requires: [], packages: ["jobs"], pairs: ["worker"] },
1730
+ pwa: { requires: [], packages: [], pairs: [] },
1731
+ mobile: { requires: [], packages: [], pairs: [] },
1732
+ // Admin dashboard and domain verticals (bookings, crm, cms, ai) are not
1733
+ // scaffolded features. All embedded skills ship with every build and the
1734
+ // AI builds them on demand - see the skill index in <harness>/skills/.
1735
+ };
1736
+
1737
+ function resolveFeatureSelection(features) {
1738
+ const resolved = [];
1739
+ const resolvedSet = new Set();
1740
+ const visiting = new Set();
1741
+
1742
+ function visit(feature) {
1743
+ if (resolvedSet.has(feature)) return;
1744
+ if (visiting.has(feature)) {
1745
+ throw new Error(`Circular feature dependency detected for: ${feature}`);
1746
+ }
1747
+
1748
+ const meta = FEATURE_META[feature];
1749
+ if (!meta) return;
1750
+
1751
+ visiting.add(feature);
1752
+ for (const dependency of meta.requires) {
1753
+ visit(dependency);
1754
+ }
1755
+ visiting.delete(feature);
1756
+
1757
+ resolved.push(feature);
1758
+ resolvedSet.add(feature);
1759
+ }
1760
+
1761
+ for (const feature of features) {
1762
+ visit(feature);
1763
+ }
1764
+
1765
+ return resolved;
1766
+ }
1767
+
1768
+ /**
1769
+ * Detect the project scope from existing package.json files.
1770
+ * Reads root package.json and extracts the scope from the name field.
1771
+ * @param {string} projectDir
1772
+ * @returns {Promise<string|null>}
1773
+ */
1774
+ async function detectProjectScope(projectDir) {
1775
+ const rootPkgPath = path.join(projectDir, "package.json");
1776
+ if (!(await fs.pathExists(rootPkgPath))) return null;
1777
+ const rootPkg = await fs.readJSON(rootPkgPath);
1778
+ const match = rootPkg.name?.match(/^@([^/]+)\//);
1779
+ return match ? match[1] : null;
1780
+ }
1781
+
1782
+ /**
1783
+ * Detect which optional features are already installed in a project.
1784
+ * @param {string} projectDir
1785
+ * @returns {Promise<string[]>}
1786
+ */
1787
+ async function detectInstalledFeatures(projectDir) {
1788
+ const installed = [];
1789
+ // Skill-based verticals are tracked in .quark-link.json (no code is scaffolded).
1790
+ let quarkLinkPackages = [];
1791
+ try {
1792
+ const quarkLink = await fs.readJSON(
1793
+ path.join(projectDir, ".quark-link.json"),
1794
+ );
1795
+ quarkLinkPackages = quarkLink.packages ?? [];
1796
+ } catch {
1797
+ // No .quark-link.json โ€” fall through to directory detection.
1798
+ }
1799
+ for (const feature of Object.keys(FEATURE_META)) {
1800
+ const meta = FEATURE_META[feature];
1801
+ if (meta.skill) {
1802
+ if (quarkLinkPackages.includes(feature)) {
1803
+ installed.push(feature);
1804
+ }
1805
+ continue;
1806
+ }
1807
+ // PWA has no workspace package โ€” detect by PWARegister component in web app
1808
+ if (feature === "pwa") {
1809
+ if (
1810
+ await fs.pathExists(
1811
+ path.join(
1812
+ projectDir,
1813
+ "apps",
1814
+ "web",
1815
+ "src",
1816
+ "app",
1817
+ "_components",
1818
+ "PWARegister.js",
1819
+ ),
1820
+ )
1821
+ ) {
1822
+ installed.push(feature);
1823
+ }
1824
+ continue;
1825
+ }
1826
+ const featureDir = path.join(projectDir, "packages", feature);
1827
+ if (await fs.pathExists(featureDir)) {
1828
+ installed.push(feature);
1829
+ }
1830
+ }
1831
+ return installed;
1832
+ }
1833
+
1834
+ async function collectRelativeFilePaths(
1835
+ rootDir,
1836
+ currentDir = rootDir,
1837
+ results = [],
1838
+ ) {
1839
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
1840
+
1841
+ for (const entry of entries) {
1842
+ if (entry.name === ".git" || entry.name === "node_modules") {
1843
+ continue;
1844
+ }
1845
+
1846
+ const fullPath = path.join(currentDir, entry.name);
1847
+ const relativePath = path.relative(rootDir, fullPath);
1848
+
1849
+ if (entry.isDirectory()) {
1850
+ await collectRelativeFilePaths(rootDir, fullPath, results);
1851
+ continue;
1852
+ }
1853
+
1854
+ if (entry.isFile() && !SCAFFOLD_CHECK_IGNORED_FILES.has(relativePath)) {
1855
+ results.push(relativePath);
1856
+ }
1857
+ }
1858
+
1859
+ return results.sort();
1860
+ }
1861
+
1862
+ async function compareScaffoldManagedFiles(expectedDir, projectDir) {
1863
+ const expectedFiles = await collectRelativeFilePaths(expectedDir);
1864
+ const changed = [];
1865
+ const missing = [];
1866
+
1867
+ for (const relativePath of expectedFiles) {
1868
+ const expectedPath = path.join(expectedDir, relativePath);
1869
+ const projectPath = path.join(projectDir, relativePath);
1870
+
1871
+ if (!(await fs.pathExists(projectPath))) {
1872
+ missing.push(relativePath);
1873
+ continue;
1874
+ }
1875
+
1876
+ const projectStat = await fs.stat(projectPath);
1877
+ if (!projectStat.isFile()) {
1878
+ changed.push(relativePath);
1879
+ continue;
1880
+ }
1881
+
1882
+ const [expectedContent, projectContent] = await Promise.all([
1883
+ fs.readFile(expectedPath),
1884
+ fs.readFile(projectPath),
1885
+ ]);
1886
+
1887
+ if (!expectedContent.equals(projectContent)) {
1888
+ changed.push(relativePath);
1889
+ }
1890
+ }
1891
+
1892
+ return {
1893
+ total: expectedFiles.length,
1894
+ changed,
1895
+ missing,
1896
+ };
1897
+ }
1898
+
1899
+ async function createReferenceScaffold(projectDir, quarkLink) {
1900
+ const projectName = quarkLink.projectName || path.basename(projectDir);
1901
+ const linkedFeatures = resolveFeatureSelection(
1902
+ Array.isArray(quarkLink.packages) ? quarkLink.packages : [],
1903
+ );
1904
+ const detectedFeatures = resolveFeatureSelection(
1905
+ await detectInstalledFeatures(projectDir),
1906
+ );
1907
+ const comparisonFeatures = resolveFeatureSelection([
1908
+ ...linkedFeatures,
1909
+ ...detectedFeatures,
1910
+ ]);
1911
+ const allowSignup =
1912
+ typeof quarkLink.authAllowSignup === "boolean"
1913
+ ? quarkLink.authAllowSignup
1914
+ : true;
1915
+ const tempRoot = await fs.mkdtemp(
1916
+ path.join(os.tmpdir(), "quark-scaffold-check-"),
1917
+ );
1918
+ const args = [
1919
+ path.join(__dirname, "index.js"),
1920
+ projectName,
1921
+ "--no-prompts",
1922
+ "--skip-install",
1923
+ "--skip-docker",
1924
+ "--packages",
1925
+ comparisonFeatures.join(","),
1926
+ "--signup",
1927
+ allowSignup ? "enabled" : "disabled",
1928
+ ];
1929
+
1930
+ try {
1931
+ await execa(process.execPath, args, {
1932
+ cwd: tempRoot,
1933
+ env: {
1934
+ ...process.env,
1935
+ QUARK_SCAFFOLD_DATE:
1936
+ quarkLink.scaffoldedDate || new Date().toISOString(),
1937
+ QUARK_SKIP_GIT_INIT: "true",
1938
+ },
1939
+ stdio: "pipe",
1940
+ });
1941
+ } catch (error) {
1942
+ const details = error.stderr || error.stdout || error.shortMessage;
1943
+ await fs.remove(tempRoot);
1944
+ throw new Error(
1945
+ details
1946
+ ? `Could not generate the reference scaffold.\n${details}`
1947
+ : "Could not generate the reference scaffold.",
1948
+ );
1949
+ }
1950
+
1951
+ return {
1952
+ tempRoot,
1953
+ expectedDir: path.join(tempRoot, projectName),
1954
+ linkedFeatures,
1955
+ detectedFeatures,
1956
+ comparisonFeatures,
1957
+ };
1958
+ }
1959
+
1960
+ async function reportScaffoldDrift(projectDir, quarkLink) {
1961
+ console.log(chalk.yellow("Checking scaffold-managed files...\n"));
1962
+
1963
+ const {
1964
+ tempRoot,
1965
+ expectedDir,
1966
+ linkedFeatures,
1967
+ detectedFeatures,
1968
+ comparisonFeatures,
1969
+ } = await createReferenceScaffold(projectDir, quarkLink);
1970
+
1971
+ try {
1972
+ const { total, changed, missing } = await compareScaffoldManagedFiles(
1973
+ expectedDir,
1974
+ projectDir,
1975
+ );
1976
+ const linkedOnly = linkedFeatures.filter(
1977
+ (feature) => !detectedFeatures.includes(feature),
1978
+ );
1979
+ const detectedOnly = detectedFeatures.filter(
1980
+ (feature) => !linkedFeatures.includes(feature),
1981
+ );
1982
+
1983
+ console.log(
1984
+ chalk.cyan(`Recorded features: ${formatFeatureSummary(linkedFeatures)}`),
1985
+ );
1986
+ console.log(
1987
+ chalk.cyan(
1988
+ `Detected features: ${formatFeatureSummary(detectedFeatures)}`,
1989
+ ),
1990
+ );
1991
+ console.log(
1992
+ chalk.cyan(
1993
+ `Compared against: ${formatFeatureSummary(comparisonFeatures)}`,
1994
+ ),
1995
+ );
1996
+ console.log(chalk.cyan(`Managed files: ${total}`));
1997
+
1998
+ if (linkedOnly.length > 0 || detectedOnly.length > 0) {
1999
+ console.log(
2000
+ chalk.yellow(
2001
+ "\n โ„น .quark-link.json and installed feature directories differ; this report used the union.",
2002
+ ),
2003
+ );
2004
+ }
2005
+
2006
+ if (changed.length === 0 && missing.length === 0) {
2007
+ console.log(
2008
+ chalk.green(
2009
+ "\n โœ“ No scaffold drift detected. This report is read-only.\n",
2010
+ ),
2011
+ );
2012
+ return { total, changed, missing };
2013
+ }
2014
+
2015
+ console.log(
2016
+ chalk.yellow(
2017
+ `\nโš ๏ธ Scaffold drift detected in ${changed.length + missing.length} file(s).\n`,
2018
+ ),
2019
+ );
2020
+
2021
+ if (changed.length > 0) {
2022
+ console.log(chalk.yellow(`Changed files (${changed.length}):`));
2023
+ for (const relativePath of changed) {
2024
+ console.log(chalk.white(` - ${relativePath}`));
2025
+ }
2026
+ }
2027
+
2028
+ if (missing.length > 0) {
2029
+ if (changed.length > 0) {
2030
+ console.log("");
2031
+ }
2032
+ console.log(chalk.yellow(`Missing scaffold files (${missing.length}):`));
2033
+ for (const relativePath of missing) {
2034
+ console.log(chalk.white(` - ${relativePath}`));
2035
+ }
2036
+ }
2037
+
2038
+ console.log(
2039
+ chalk.dim(
2040
+ "\n This command only reports scaffold drift. It does not overwrite project files.\n",
2041
+ ),
2042
+ );
2043
+
2044
+ return { total, changed, missing };
2045
+ } finally {
2046
+ await fs.remove(tempRoot);
2047
+ }
2048
+ }
2049
+
2050
+ /**
2051
+ * Add a transpilePackages entry to next.config.js if not already present.
2052
+ * @param {string} webDir - Path to apps/web
2053
+ * @param {string} scopedPkg - e.g. "@myapp/admin"
2054
+ */
2055
+ async function addTranspilePackage(webDir, scopedPkg) {
2056
+ const configPath = path.join(webDir, "next.config.js");
2057
+ if (!(await fs.pathExists(configPath))) return;
2058
+
2059
+ let content = await fs.readFile(configPath, "utf-8");
2060
+ if (content.includes(`"${scopedPkg}"`)) return; // already present
2061
+
2062
+ // Insert after the last entry in transpilePackages array
2063
+ const transpileMatch = content.match(/transpilePackages:\s*\[([^\]]*)\]/s);
2064
+ if (!transpileMatch) return;
2065
+
2066
+ const existingBlock = transpileMatch[1];
2067
+ const lastEntry = existingBlock.trimEnd();
2068
+ // Add a trailing comma to the last entry if needed, then add new entry
2069
+ const updatedBlock = lastEntry.endsWith(",")
2070
+ ? `${lastEntry}\n\t\t"${scopedPkg}",`
2071
+ : `${lastEntry},\n\t\t"${scopedPkg}",`;
2072
+
2073
+ content = content.replace(transpileMatch[1], `${updatedBlock}\n\t`);
2074
+ await fs.writeFile(configPath, content);
2075
+ }
2076
+
2077
+ /**
2078
+ * Add a workspace dependency to a package.json file.
2079
+ * @param {string} pkgJsonPath - Path to the package.json
2080
+ * @param {string} depName - e.g. "@myapp/admin"
2081
+ */
2082
+ async function addWorkspaceDep(pkgJsonPath, depName) {
2083
+ if (!(await fs.pathExists(pkgJsonPath))) return;
2084
+ const pkg = await fs.readJSON(pkgJsonPath);
2085
+ pkg.dependencies = pkg.dependencies || {};
2086
+ if (pkg.dependencies[depName]) return; // already present
2087
+ pkg.dependencies[depName] = "workspace:*";
2088
+ await fs.writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, " ")}\n`);
2089
+ }
2090
+
2091
+ async function rewriteWorkspaceDeps(pkgJsonPath, scope, workspacePackages) {
2092
+ if (!(await fs.pathExists(pkgJsonPath))) return;
2093
+ const pkg = await fs.readJSON(pkgJsonPath);
2094
+ replaceDepsScope(pkg.dependencies, scope, workspacePackages);
2095
+ replaceDepsScope(pkg.devDependencies, scope, workspacePackages);
2096
+ await fs.writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, " ")}\n`);
2097
+ }
2098
+
2099
+ program
2100
+ .command("add")
2101
+ .argument("<feature>", "Package to add (ui, jobs, pwa)")
2102
+ .description("Add an optional package to an existing Quark project")
2103
+ .option("--force", "Skip safety checks (uncommitted changes)")
2104
+ .option("--skip-install", "Skip pnpm install after adding")
2105
+ .action(async (feature, options) => {
2106
+ console.log(chalk.blue.bold(`\n๐Ÿ“ฆ Quark Add: ${feature}\n`));
2107
+
2108
+ // The global `--skip-install` (on the create program) shadows the add
2109
+ // subcommand's option in commander, so also check process.argv directly.
2110
+ const skipInstall =
2111
+ options.skipInstall || process.argv.includes("--skip-install");
2112
+
2113
+ const projectDir = process.cwd();
2114
+
2115
+ // --- Validate: is this a Quark project? ---
2116
+ const quarkLinkPath = path.join(projectDir, ".quark-link.json");
2117
+ if (!(await fs.pathExists(quarkLinkPath))) {
2118
+ console.error(
2119
+ chalk.red(
2120
+ "โœ— .quark-link.json not found. Are you in a Quark project root?",
2121
+ ),
2122
+ );
2123
+ process.exit(1);
2124
+ }
2125
+
2126
+ // --- Validate: is the feature valid? ---
2127
+ const validFeatures = Object.keys(FEATURE_META);
2128
+ if (!validFeatures.includes(feature)) {
2129
+ console.error(
2130
+ chalk.red(
2131
+ `โœ— Unknown feature: "${feature}". Valid options: ${validFeatures.join(", ")}`,
2132
+ ),
2133
+ );
2134
+ process.exit(1);
2135
+ }
2136
+
2137
+ // --- Detect project state ---
2138
+ const scope = await detectProjectScope(projectDir);
2139
+ if (!scope) {
2140
+ console.error(
2141
+ chalk.red(
2142
+ "โœ— Could not detect project scope from package.json. Expected @scope/root format.",
2143
+ ),
2144
+ );
2145
+ process.exit(1);
2146
+ }
2147
+
2148
+ const quarkLink = await fs.readJSON(quarkLinkPath);
2149
+ const installedFeatures = await detectInstalledFeatures(projectDir);
2150
+
2151
+ // --- Check if already installed ---
2152
+ if (installedFeatures.includes(feature)) {
2153
+ console.log(
2154
+ chalk.yellow(`โš ๏ธ "${feature}" is already installed in this project.`),
2155
+ );
2156
+ process.exit(0);
2157
+ }
2158
+
2159
+ // --- Safety: check for uncommitted changes (project-scoped) ---
2160
+ if (!options.force && (await hasUncommittedChanges(projectDir))) {
2161
+ console.log(
2162
+ chalk.yellow(
2163
+ "โš ๏ธ You have uncommitted changes. Commit or stash them first.",
2164
+ ),
2165
+ );
2166
+ console.log(
2167
+ chalk.white("Use --force to skip this check (not recommended).\n"),
2168
+ );
2169
+ process.exit(1);
2170
+ }
2171
+
2172
+ // --- Resolve dependency chain ---
2173
+ const resolvedFeatures = resolveFeatureSelection([
2174
+ ...installedFeatures,
2175
+ feature,
2176
+ ]);
2177
+ const toAdd = resolvedFeatures.filter(
2178
+ (installedFeature) => !installedFeatures.includes(installedFeature),
2179
+ );
2180
+ const missingRequirements = toAdd.filter(
2181
+ (installedFeature) => installedFeature !== feature,
2182
+ );
2183
+ if (missingRequirements.length > 0) {
2184
+ console.log(
2185
+ chalk.yellow(
2186
+ ` โ„น "${feature}" requires ${missingRequirements.join(", ")} - adding automatically.`,
2187
+ ),
2188
+ );
2189
+ }
2190
+ const finalFeatures = resolvedFeatures;
2191
+ const finalWorkspacePackages =
2192
+ getWorkspacePackagesForFeatures(finalFeatures);
2193
+
2194
+ try {
2195
+ for (const feat of toAdd) {
2196
+ const featMeta = FEATURE_META[feat];
2197
+ const packageNames = featMeta.packages ?? [feat];
2198
+ console.log(chalk.cyan(`\n ๐Ÿ“‹ Adding ${feat}...`));
2199
+
2200
+ // 0. Skill-based vertical โ€” the embedded skill is already scaffolded;
2201
+ // no starter code is added.
2202
+ if (featMeta.skill) {
2203
+ console.log(
2204
+ chalk.green(
2205
+ ` โœ“ ${feat} (skill โ€” build it with the embedded skill)`,
2206
+ ),
2207
+ );
2208
+ continue;
2209
+ }
2210
+
2211
+ // PWA modifies the web app, not a workspace package
2212
+ if (feat === "pwa") {
2213
+ const pwaTemplateDir = path.join(templatesDir, "pwa");
2214
+ const webDir = path.join(projectDir, "apps/web");
2215
+
2216
+ if (
2217
+ await fs.pathExists(
2218
+ path.join(webDir, "src/app/_components/PWARegister.js"),
2219
+ )
2220
+ ) {
2221
+ console.log(chalk.dim(" ยท PWA already configured - skipping"));
2222
+ } else {
2223
+ // Replace the base manifest.js - see the scaffold-time copy for
2224
+ // why a sibling manifest.json breaks the Next.js build.
2225
+ await fs.copy(
2226
+ path.join(pwaTemplateDir, "app/manifest.js"),
2227
+ path.join(webDir, "src/app/manifest.js"),
2228
+ );
2229
+
2230
+ // Point the PWA manifest at this project's scope.
2231
+ await substitutePlaceholders(
2232
+ path.join(webDir, "src/app/manifest.js"),
2233
+ { __QUARK_SCOPE__: scope },
2234
+ );
2235
+
2236
+ // Copy sw.js to public/
2237
+ await fs.copy(
2238
+ path.join(pwaTemplateDir, "public/sw.js"),
2239
+ path.join(webDir, "public/sw.js"),
2240
+ );
2241
+
2242
+ // Copy PWARegister client component
2243
+ await fs.copy(
2244
+ path.join(pwaTemplateDir, "app/_components/PWARegister.js"),
2245
+ path.join(webDir, "src/app/_components/PWARegister.js"),
2246
+ );
2247
+
2248
+ // Inject PWARegister into layout.js
2249
+ const layoutPath = path.join(webDir, "src/app/layout.js");
2250
+ let layout = await fs.readFile(layoutPath, "utf-8");
2251
+
2252
+ layout = layout.replace(
2253
+ /import UmamiWebVitals from/,
2254
+ 'import PWARegister from "./_components/PWARegister.js";\nimport UmamiWebVitals from',
2255
+ );
2256
+
2257
+ layout = layout.replace(
2258
+ /export function generateMetadata/,
2259
+ 'export const viewport = {\n\tthemeColor: "#000000",\n};\n\nexport function generateMetadata',
2260
+ );
2261
+
2262
+ layout = layout.replace(/<body>/, "<body>\n\t\t\t<PWARegister />");
2263
+
2264
+ await fs.writeFile(layoutPath, layout);
2265
+
2266
+ console.log(
2267
+ chalk.green(
2268
+ " โœ“ PWA support enabled (manifest + service worker)",
2269
+ ),
2270
+ );
2271
+ }
2272
+ continue;
2273
+ }
2274
+
2275
+ // Mobile app scaffolds apps/mobile from the template
2276
+ if (feat === "mobile") {
2277
+ const mobileTemplateDir = path.join(templatesDir, "mobile");
2278
+ const mobileDir = path.join(projectDir, "apps/mobile");
2279
+
2280
+ if (await fs.pathExists(mobileDir)) {
2281
+ console.log(
2282
+ chalk.dim(" ยท apps/mobile already exists - skipping copy"),
2283
+ );
2284
+ } else {
2285
+ await fs.ensureDir(mobileDir);
2286
+ await fs.copy(mobileTemplateDir, mobileDir);
2287
+
2288
+ // Replace placeholder variables in template files
2289
+ const mobileAppName = formatProjectDisplayName(
2290
+ quarkLink.projectName,
2291
+ );
2292
+ const mobileFiles = ["app.json", "package.json", "README.md"];
2293
+
2294
+ for (const relPath of mobileFiles) {
2295
+ const filePath = path.join(mobileDir, relPath);
2296
+ if (await fs.pathExists(filePath)) {
2297
+ let content = await fs.readFile(filePath, "utf-8");
2298
+ content = content.replace(/__QUARK_APP_NAME__/g, mobileAppName);
2299
+ content = content.replace(
2300
+ /__QUARK_PROJECT_NAME__/g,
2301
+ quarkLink.projectName,
2302
+ );
2303
+ // Replace EAS project ID with a placeholder that users
2304
+ // fill in after running `eas init`
2305
+ content = content.replace(
2306
+ /__QUARK_EAS_PROJECT_ID__/g,
2307
+ "YOUR_EAS_PROJECT_ID",
2308
+ );
2309
+ await fs.writeFile(filePath, content);
2310
+ }
2311
+ }
2312
+
2313
+ await updatePackageJsonName(
2314
+ path.join(mobileDir, "package.json"),
2315
+ scope,
2316
+ );
2317
+
2318
+ // Add EXPO_PUBLIC_API_URL to .env.example
2319
+ const envExamplePath = path.join(projectDir, ".env.example");
2320
+ if (await fs.pathExists(envExamplePath)) {
2321
+ let envContent = await fs.readFile(envExamplePath, "utf-8");
2322
+ if (!envContent.includes("EXPO_PUBLIC_API_URL")) {
2323
+ envContent +=
2324
+ "\n# --- Mobile App ---\nEXPO_PUBLIC_API_URL=http://localhost:3000\n";
2325
+ await fs.writeFile(envExamplePath, envContent);
2326
+ }
2327
+ }
2328
+
2329
+ // Add mobile scripts to root package.json
2330
+ const rootPkgPath = path.join(projectDir, "package.json");
2331
+ if (await fs.pathExists(rootPkgPath)) {
2332
+ const rootPkg = await fs.readJSON(rootPkgPath);
2333
+ rootPkg.scripts = rootPkg.scripts || {};
2334
+ if (!rootPkg.scripts["dev:mobile"]) {
2335
+ rootPkg.scripts["dev:mobile"] =
2336
+ `pnpm --filter @${scope}/mobile start`;
2337
+ }
2338
+ if (!rootPkg.scripts["build:mobile"]) {
2339
+ rootPkg.scripts["build:mobile"] =
2340
+ `pnpm --filter @${scope}/mobile export`;
2341
+ }
2342
+ await fs.writeFile(
2343
+ rootPkgPath,
2344
+ `${JSON.stringify(rootPkg, null, "\t")}\n`,
2345
+ );
2346
+ }
2347
+
2348
+ // Add DEVICE model to Prisma schema
2349
+ const schemaPath = path.join(
2350
+ projectDir,
2351
+ "packages/db/prisma/schema.prisma",
2352
+ );
2353
+ if (await fs.pathExists(schemaPath)) {
2354
+ let schema = await fs.readFile(schemaPath, "utf-8");
2355
+ if (!schema.includes("model Device")) {
2356
+ // Insert Device model after AuditLog
2357
+ const deviceModel = `
2358
+
2359
+ // โ”€โ”€โ”€ Device Model (Mobile Push Notifications) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2360
+
2361
+ model Device {
2362
+ id String @id @default(cuid())
2363
+ userId String
2364
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
2365
+ platform String // "ios" | "android"
2366
+ pushToken String
2367
+ deviceId String
2368
+ createdAt DateTime @default(now())
2369
+ updatedAt DateTime @updatedAt
2370
+
2371
+ @@unique([userId, deviceId])
2372
+ @@index([userId])
2373
+ @@index([pushToken])
2374
+ @@index([createdAt])
2375
+ }
2376
+ `;
2377
+ schema = schema.replace(
2378
+ /(\/\/ Audit Log Model[\s\S]*?^}\s)/m,
2379
+ `$1${deviceModel}`,
2380
+ );
2381
+ // Add devices relation to User model
2382
+ schema = schema.replace(
2383
+ /(files\s+File\[\])/,
2384
+ "$1\n devices Device[]",
2385
+ );
2386
+ await fs.writeFile(schemaPath, schema);
2387
+ }
2388
+ }
2389
+
2390
+ console.log(
2391
+ chalk.green(" โœ“ Mobile app scaffolded (apps/mobile)"),
2392
+ );
2393
+ console.log(
2394
+ chalk.green(" โœ“ DEVICE model added to Prisma schema"),
2395
+ );
2396
+ console.log(
2397
+ chalk.green(" โœ“ EXPO_PUBLIC_API_URL added to .env.example"),
2398
+ );
2399
+ console.log(
2400
+ chalk.dim(
2401
+ " ยท API routes (token, refresh, device/register) already in base template",
2402
+ ),
2403
+ );
2404
+ }
2405
+ continue;
2406
+ }
2407
+
2408
+ // 1. Copy package template
2409
+ for (const packageName of packageNames) {
2410
+ const packageDir = path.join(projectDir, "packages", packageName);
2411
+ if (await fs.pathExists(packageDir)) {
2412
+ console.log(
2413
+ chalk.dim(
2414
+ ` ยท packages/${packageName} already exists - skipping copy`,
2415
+ ),
2416
+ );
2417
+ } else {
2418
+ await copyTemplate(packageName, packageDir);
2419
+ await updatePackageJsonName(
2420
+ path.join(packageDir, "package.json"),
2421
+ scope,
2422
+ );
2423
+ await rewriteWorkspaceDeps(
2424
+ path.join(packageDir, "package.json"),
2425
+ scope,
2426
+ finalWorkspacePackages,
2427
+ );
2428
+ await replaceImportsInSourceFiles(packageDir, scope);
2429
+ console.log(chalk.green(` โœ“ packages/${packageName}`));
2430
+ }
2431
+ }
2432
+
2433
+ // 2. Handle paired apps/routes
2434
+ for (const pair of featMeta.pairs) {
2435
+ if (pair === "worker") {
2436
+ const workerDir = path.join(projectDir, "apps", "worker");
2437
+ if (await fs.pathExists(workerDir)) {
2438
+ console.log(
2439
+ chalk.dim(` ยท apps/worker already exists - skipping copy`),
2440
+ );
2441
+ } else {
2442
+ await copyTemplate("worker", workerDir);
2443
+ await updatePackageJsonName(
2444
+ path.join(workerDir, "package.json"),
2445
+ scope,
2446
+ );
2447
+ await rewriteWorkspaceDeps(
2448
+ path.join(workerDir, "package.json"),
2449
+ scope,
2450
+ finalWorkspacePackages,
2451
+ );
2452
+ await replaceImportsInSourceFiles(workerDir, scope);
2453
+ console.log(chalk.green(` โœ“ apps/worker (paired with jobs)`));
2454
+ }
2455
+ }
2456
+ }
2457
+
2458
+ // 3. Add transpilePackages entries and workspace deps to apps/web/package.json
2459
+ for (const packageName of packageNames) {
2460
+ const scopedPkg = `@${scope}/${packageName}`;
2461
+ await addTranspilePackage(
2462
+ path.join(projectDir, "apps", "web"),
2463
+ scopedPkg,
2464
+ );
2465
+ await addWorkspaceDep(
2466
+ path.join(projectDir, "apps", "web", "package.json"),
2467
+ scopedPkg,
2468
+ );
2469
+ }
2470
+
2471
+ // 5. If adding jobs, also add dep to worker
2472
+ if (feat === "jobs") {
2473
+ await addWorkspaceDep(
2474
+ path.join(projectDir, "apps", "worker", "package.json"),
2475
+ `@${scope}/jobs`,
2476
+ );
2477
+ }
2478
+ }
2479
+
2480
+ // --- Update .quark-link.json ---
2481
+ const allFeatures = [...new Set([...quarkLink.packages, ...toAdd])];
2482
+ quarkLink.packages = allFeatures;
2483
+ quarkLink.hasWorker = allFeatures.includes("jobs");
2484
+ quarkLink.lastAddedFeature = feature;
2485
+ quarkLink.lastModifiedDate = new Date().toISOString();
2486
+ // Track per-feature versions for migration detection
2487
+ if (!quarkLink.featureVersions) quarkLink.featureVersions = {};
2488
+ for (const addedFeature of toAdd) {
2489
+ const featurePkgPath = path.join(
2490
+ projectDir,
2491
+ addedFeature === "mobile" ? "apps" : "packages",
2492
+ addedFeature,
2493
+ "package.json",
2494
+ );
2495
+ if (await fs.pathExists(featurePkgPath)) {
2496
+ const featurePkg = JSON.parse(
2497
+ await fs.readFile(featurePkgPath, "utf-8"),
2498
+ );
2499
+ quarkLink.featureVersions[addedFeature] = featurePkg.version;
2500
+ }
2501
+ }
2502
+ await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, " "));
2503
+ console.log(chalk.green(`\n โœ“ .quark-link.json updated`));
2504
+
2505
+ // --- Run pnpm install ---
2506
+ if (!skipInstall) {
2507
+ console.log(chalk.cyan("\n ๐Ÿ“ฆ Installing dependencies..."));
2508
+ try {
2509
+ await execa("pnpm", ["install"], {
2510
+ cwd: projectDir,
2511
+ stdio: "inherit",
2512
+ });
2513
+ console.log(chalk.green(`\n โœ“ Dependencies installed`));
2514
+ } catch {
2515
+ console.warn(
2516
+ chalk.yellow(
2517
+ `\n โš ๏ธ pnpm install failed. Run it manually to resolve.`,
2518
+ ),
2519
+ );
2520
+ }
2521
+ } else {
2522
+ console.log(chalk.dim("\n ยท Skipping pnpm install (--skip-install)"));
2523
+ }
2524
+
2525
+ // --- Success ---
2526
+ console.log(chalk.green.bold(`\nโœ… Added "${feature}" successfully!\n`));
2527
+
2528
+ // Feature-specific next steps
2529
+ if (feature === "jobs") {
2530
+ console.log(chalk.cyan("Next steps:"));
2531
+ console.log(chalk.white(" 1. Define jobs in packages/jobs/src/"));
2532
+ console.log(
2533
+ chalk.white(" 2. Add handlers in apps/worker/src/handlers/"),
2534
+ );
2535
+ console.log(chalk.white(" 3. pnpm dev (starts web + worker)\n"));
2536
+ } else if (feature === "ui") {
2537
+ console.log(chalk.cyan("Next steps:"));
2538
+ console.log(
2539
+ chalk.white(
2540
+ ` 1. Import components: import { Button } from "@${scope}/ui"`,
2541
+ ),
2542
+ );
2543
+ console.log(chalk.white(" 2. pnpm dev\n"));
2544
+ } else if (feature === "pwa") {
2545
+ console.log(chalk.cyan("Next steps:"));
2546
+ console.log(
2547
+ chalk.white(
2548
+ " 1. Edit src/app/manifest.json to set your app name and theme color",
2549
+ ),
2550
+ );
2551
+ console.log(
2552
+ chalk.white(" 2. Replace public/quark.svg with your app icon"),
2553
+ );
2554
+ console.log(
2555
+ chalk.white(
2556
+ " 3. Customize public/sw.js caching strategies if needed",
2557
+ ),
2558
+ );
2559
+ console.log(
2560
+ chalk.white(" 4. pnpm build (test SW registration in production)\n"),
2561
+ );
2562
+ } else if (feature === "mobile") {
2563
+ console.log(chalk.cyan("Next steps:"));
2564
+ console.log(chalk.white(" 1. pnpm install"));
2565
+ console.log(
2566
+ chalk.white(" 2. Set EXPO_PUBLIC_API_URL in apps/mobile/.env"),
2567
+ );
2568
+ console.log(chalk.white(" 3. pnpm dev (starts web + worker)"));
2569
+ console.log(
2570
+ chalk.white(
2571
+ " 4. pnpm dev:mobile (starts Expo in a second terminal)",
2572
+ ),
2573
+ );
2574
+ console.log(
2575
+ chalk.white(
2576
+ " 5. Configure APNs/FCM env vars for push notifications\n",
2577
+ ),
2578
+ );
2579
+ }
2580
+
2581
+ console.log(
2582
+ chalk.dim(" Run 'pnpm doctor' to check your project configuration.\n"),
2583
+ );
2584
+ } catch (error) {
2585
+ console.error(
2586
+ chalk.red(`\nโœ— Failed to add "${feature}": ${error.message}`),
2587
+ );
2588
+ console.error(chalk.dim(error.stack));
2589
+ process.exit(1);
2590
+ }
2591
+ });
2592
+
2593
+ // ---------------------------------------------------------------------------
2594
+ // quark skill <feature> - Print an embedded skill for a feature
2595
+ // ---------------------------------------------------------------------------
2596
+
2597
+ program
2598
+ .command("skill")
2599
+ .argument(
2600
+ "<feature>",
2601
+ "Feature to print a skill for (model, endpoint, dashboard)",
2602
+ )
2603
+ .description("Print an embedded skill for a feature")
2604
+ .action(async (feature) => {
2605
+ const skillName =
2606
+ feature === "model"
2607
+ ? "add-model"
2608
+ : feature === "endpoint"
2609
+ ? "add-endpoint"
2610
+ : feature === "dashboard"
2611
+ ? "add-dashboard"
2612
+ : feature;
2613
+ const skillPath = path.join(
2614
+ templatesDir,
2615
+ "base-project",
2616
+ "skills",
2617
+ skillName,
2618
+ "SKILL.md",
2619
+ );
2620
+
2621
+ if (!(await fs.pathExists(skillPath))) {
2622
+ console.error(
2623
+ chalk.red(
2624
+ `โœ— No skill found for "${feature}". Available: model, endpoint, dashboard`,
2625
+ ),
2626
+ );
2627
+ process.exit(1);
2628
+ }
2629
+
2630
+ const skill = await fs.readFile(skillPath, "utf-8");
2631
+ console.log(skill);
2632
+ });
2633
+
2634
+ /**
2635
+ * Read the installed version of a package from node_modules.
2636
+ * Returns null if the package is not installed.
2637
+ * @param {string} cwd - Project root
2638
+ * @param {string} packageName - e.g. "@techstream/quark-core"
2639
+ * @returns {Promise<string|null>}
2640
+ */
2641
+ async function getInstalledVersion(cwd, packageName) {
2642
+ try {
2643
+ const pkgPath = path.join(
2644
+ cwd,
2645
+ "node_modules",
2646
+ ...packageName.split("/"),
2647
+ "package.json",
2648
+ );
2649
+ const { version } = await fs.readJSON(pkgPath);
2650
+ return version ?? null;
2651
+ } catch {
2652
+ return null;
2653
+ }
2654
+ }
2655
+
2656
+ /**
2657
+ * Fetch the latest published version of a package from the npm registry.
2658
+ * Returns null on network failure so the caller can degrade gracefully.
2659
+ * @param {string} packageName
2660
+ * @returns {Promise<string|null>}
2661
+ */
2662
+ async function getLatestNpmVersion(packageName) {
2663
+ try {
2664
+ const { stdout } = await execa("npm", [
2665
+ "view",
2666
+ packageName,
2667
+ "version",
2668
+ "--json",
2669
+ ]);
2670
+ return JSON.parse(stdout);
2671
+ } catch {
2672
+ return null;
2673
+ }
2674
+ }
2675
+
2676
+ /**
2677
+ * quark-update command
2678
+ * Updates Quark core infrastructure in a scaffolded project
2679
+ */
2680
+ program
2681
+ .command("update")
2682
+ .description("Update Quark packages in the current project")
2683
+ .option("--check", "Check for updates without applying")
2684
+ .option(
2685
+ "--scaffold-check",
2686
+ "Report drift in scaffold-managed files without applying changes",
2687
+ )
2688
+ .option(
2689
+ "--fail-on-drift",
2690
+ "Exit with status 1 when scaffold drift is detected (use with --scaffold-check)",
2691
+ )
2692
+ .option("--force", "Skip safety checks")
2693
+ .action(async (options) => {
2694
+ console.log(chalk.blue.bold(`\n๐Ÿ”„ Quark Package Update\n`));
2695
+
2696
+ if (options.failOnDrift && !options.scaffoldCheck) {
2697
+ console.error(
2698
+ chalk.red("โœ— --fail-on-drift requires --scaffold-check.\n"),
2699
+ );
2700
+ process.exit(1);
2701
+ }
2702
+
2703
+ // Check if .quark-link.json exists
2704
+ const quarkLinkPath = path.join(process.cwd(), ".quark-link.json");
2705
+ if (!(await fs.pathExists(quarkLinkPath))) {
2706
+ console.error(
2707
+ chalk.red("โœ— .quark-link.json not found. Are you in a Quark project?"),
2708
+ );
2709
+ process.exit(1);
2710
+ }
2711
+
2712
+ const quarkLink = await fs.readJSON(quarkLinkPath);
2713
+
2714
+ // The packages this command manages
2715
+ const MANAGED_PACKAGES = [
2716
+ "@techstream/quark-core",
2717
+ "@techstream/quark-create-app",
2718
+ ];
2719
+
2720
+ // Snapshot installed versions before any update
2721
+ const before = {};
2722
+ for (const name of MANAGED_PACKAGES) {
2723
+ before[name] = await getInstalledVersion(process.cwd(), name);
2724
+ }
2725
+
2726
+ console.log(chalk.cyan(`Scaffolded: ${quarkLink.scaffoldedDate}`));
2727
+ console.log(
2728
+ chalk.cyan(
2729
+ `Installed core: ${before["@techstream/quark-core"] ?? quarkLink.quarkVersion ?? "unknown"}\n`,
2730
+ ),
2731
+ );
2732
+
2733
+ if (options.check) {
2734
+ // Query npm for latest versions and report the delta
2735
+ console.log(chalk.yellow("Checking npm registry for updates...\n"));
2736
+ let anyUpdates = false;
2737
+ for (const name of MANAGED_PACKAGES) {
2738
+ const installed = before[name];
2739
+ const latest = await getLatestNpmVersion(name);
2740
+ if (!latest) {
2741
+ console.log(chalk.dim(` ${name}: registry unreachable`));
2742
+ continue;
2743
+ }
2744
+ if (!installed || installed === latest) {
2745
+ console.log(chalk.green(` โœ“ ${name} ${latest} - up to date`));
2746
+ } else {
2747
+ console.log(
2748
+ chalk.yellow(` โ†‘ ${name}: ${installed} โ†’ ${chalk.bold(latest)}`),
2749
+ );
2750
+ anyUpdates = true;
2751
+ }
2752
+ }
2753
+ if (anyUpdates) {
2754
+ console.log(chalk.white("\n Run without --check to apply updates.\n"));
2755
+ } else {
2756
+ console.log(chalk.dim("\n Nothing to update.\n"));
2757
+ }
2758
+ }
2759
+
2760
+ if (options.scaffoldCheck) {
2761
+ const scaffoldReport = await reportScaffoldDrift(
2762
+ process.cwd(),
2763
+ quarkLink,
2764
+ );
2765
+ const hasDrift =
2766
+ scaffoldReport.changed.length > 0 || scaffoldReport.missing.length > 0;
2767
+ if (options.failOnDrift && hasDrift) {
2768
+ console.error(
2769
+ chalk.red("โœ— Drift detected and --fail-on-drift was set.\n"),
2770
+ );
2771
+ process.exitCode = 1;
2772
+ }
2773
+ }
2774
+
2775
+ if (options.check || options.scaffoldCheck) {
2776
+ return;
2777
+ }
2778
+
2779
+ try {
2780
+ // Guard: check for both unstaged and staged changes (project-scoped)
2781
+ if (!options.force && (await hasUncommittedChanges(process.cwd()))) {
2782
+ console.log(
2783
+ chalk.yellow(
2784
+ "โš ๏ธ You have uncommitted changes. Commit or stash them first.",
2785
+ ),
2786
+ );
2787
+ console.log(
2788
+ chalk.white("Use --force to skip this check (not recommended).\n"),
2789
+ );
2790
+ process.exit(1);
2791
+ }
2792
+
2793
+ // Run pnpm update for all managed packages
2794
+ console.log(chalk.cyan("๐Ÿ“ฆ Updating Quark packages...\n"));
2795
+ await execa("pnpm", ["update", ...MANAGED_PACKAGES], {
2796
+ cwd: process.cwd(),
2797
+ stdio: "inherit",
2798
+ });
2799
+
2800
+ // Snapshot installed versions after update
2801
+ const after = {};
2802
+ for (const name of MANAGED_PACKAGES) {
2803
+ after[name] = await getInstalledVersion(process.cwd(), name);
2804
+ }
2805
+
2806
+ // Report the delta
2807
+ console.log(chalk.cyan("\n๐Ÿ“‹ Update summary:\n"));
2808
+ for (const name of MANAGED_PACKAGES) {
2809
+ const was = before[name];
2810
+ const now = after[name];
2811
+ if (!now) continue;
2812
+ if (was && was !== now) {
2813
+ console.log(chalk.green(` โœ“ ${name}: ${was} โ†’ ${chalk.bold(now)}`));
2814
+ } else {
2815
+ console.log(chalk.dim(` ยท ${name}: ${now} (already current)`));
2816
+ }
2817
+ }
2818
+
2819
+ // Persist the new core version - keep previous on failure, never write garbage
2820
+ const newCoreVersion =
2821
+ after["@techstream/quark-core"] ?? quarkLink.quarkVersion;
2822
+ quarkLink.quarkVersion = newCoreVersion;
2823
+ quarkLink.updatedDate = new Date().toISOString();
2824
+ await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, " "));
2825
+
2826
+ // Run lint to surface any API breakage from the update
2827
+ console.log(chalk.cyan("\n๐Ÿ” Running lint to check for breakage...\n"));
2828
+ try {
2829
+ await execa("pnpm", ["lint"], {
2830
+ cwd: process.cwd(),
2831
+ stdio: "inherit",
2832
+ });
2833
+ console.log(chalk.green("\n โœ“ Lint passed\n"));
2834
+ } catch {
2835
+ console.log(
2836
+ chalk.yellow(
2837
+ "\n โš ๏ธ Lint reported issues - review before committing.\n",
2838
+ ),
2839
+ );
2840
+ }
2841
+
2842
+ console.log(chalk.green("โœ… Quark updated successfully!\n"));
2843
+ console.log(chalk.cyan("Next steps:"));
2844
+ console.log(chalk.white(` 1. pnpm test`));
2845
+ console.log(
2846
+ chalk.white(` 2. git add . && git commit -m "chore: update Quark"\n`),
2847
+ );
2848
+ } catch (error) {
2849
+ console.error(chalk.red(`\nโœ— Update failed: ${error.message}\n`));
2850
+ process.exit(1);
2851
+ }
2852
+ });
2853
+
2854
+ // ---------------------------------------------------------------------------
2855
+ // quark deploy - Deploy and inspect
2856
+ // ---------------------------------------------------------------------------
2857
+
2858
+ const deployCmd = program
2859
+ .command("deploy")
2860
+ .description("Deploy a Quark project to Railway");
2861
+
2862
+ deployCmd
2863
+ .command("railway")
2864
+ .description("Deploy to Railway (web + worker + Postgres + Redis)")
2865
+ .option(
2866
+ "--project-name <name>",
2867
+ "Railway project name (creates a new project)",
2868
+ )
2869
+ .option("--project-id <id>", "Existing Railway project ID to link to")
2870
+ .option("--environment <env>", "Railway environment (default: production)")
2871
+ .option("--no-provision", "Skip provisioning PostgreSQL and Redis plugins")
2872
+ .option("--dry-run", "Validate configuration without deploying")
2873
+ .action(async (options) => {
2874
+ const { deployToRailway } = await import("./deploy/deploy.js");
2875
+
2876
+ if (options.dryRun) {
2877
+ const { inspectProject } = await import("./deploy/inspect.js");
2878
+ console.log(
2879
+ chalk.yellow("\nโš  Dry run - validating deployment configuration\n"),
2880
+ );
2881
+ await inspectProject({ cwd: process.cwd() });
2882
+ console.log(
2883
+ chalk.green(
2884
+ "โœ” Configuration valid. Run without --dry-run to deploy.\n",
2885
+ ),
2886
+ );
2887
+ return;
2888
+ }
2889
+
2890
+ try {
2891
+ await deployToRailway({
2892
+ cwd: process.cwd(),
2893
+ projectName: options.projectName,
2894
+ projectId: options.projectId,
2895
+ environment: options.environment,
2896
+ provision: options.provision,
2897
+ });
2898
+ } catch (error) {
2899
+ console.error(chalk.red(`\nโœ– Deploy failed: ${error.message}\n`));
2900
+ process.exit(1);
2901
+ }
2902
+ });
2903
+
2904
+ deployCmd
2905
+ .command("inspect")
2906
+ .description("Inspect project deployment readiness")
2907
+ .action(async () => {
2908
+ const { inspectProject } = await import("./deploy/inspect.js");
2909
+ await inspectProject({ cwd: process.cwd() });
2910
+ });
2911
+
2912
+ deployCmd
2913
+ .command("status")
2914
+ .description("Check deployed service status")
2915
+ .action(async () => {
2916
+ const { inspectProject } = await import("./deploy/inspect.js");
2917
+ await inspectProject({ cwd: process.cwd() });
2918
+ });
2919
+
2920
+ program.parse();