create-raredays-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +89 -0
  2. package/bin/index.js +1024 -0
  3. package/package.json +43 -0
  4. package/scaffold-fragments/billing/files/app/api/billing/webhook/route.ts +5 -0
  5. package/scaffold-fragments/billing/module.json +31 -0
  6. package/scaffold-fragments/billing/runtime/config.ts +8 -0
  7. package/scaffold-fragments/billing/runtime/exports.ts +6 -0
  8. package/scaffold-fragments/billing/runtime/imports.ts +6 -0
  9. package/scaffold-fragments/billing/runtime/ports.ts +3 -0
  10. package/scaffold-fragments/cms/module.json +29 -0
  11. package/scaffold-fragments/cms/runtime/config.ts +4 -0
  12. package/scaffold-fragments/cms/runtime/exports.ts +1 -0
  13. package/scaffold-fragments/cms/runtime/imports.ts +1 -0
  14. package/scaffold-fragments/email/module.json +30 -0
  15. package/scaffold-fragments/email/runtime/config.ts +1 -0
  16. package/scaffold-fragments/email/runtime/imports.ts +1 -0
  17. package/scaffold-fragments/email/runtime/ports.ts +1 -0
  18. package/scaffold-fragments/feature-flags/files/app/lib/flags.ts +48 -0
  19. package/scaffold-fragments/feature-flags/module.json +66 -0
  20. package/scaffold-fragments/i18n/files/messages/en-US.json +15 -0
  21. package/scaffold-fragments/i18n/module.json +9 -0
  22. package/scaffold-fragments/i18n/runtime/config.ts +7 -0
  23. package/scaffold-fragments/i18n/runtime/exports.ts +1 -0
  24. package/scaffold-fragments/i18n/runtime/imports.ts +2 -0
  25. package/scaffold-fragments/i18n/runtime/ports.ts +1 -0
  26. package/scaffold-fragments/identity/module.json +30 -0
  27. package/scaffold-fragments/identity/vendors/better-auth/files/app/api/auth/[...all]/route.ts +15 -0
  28. package/scaffold-fragments/identity/vendors/better-auth/files/app/lib/auth.ts +20 -0
  29. package/scaffold-fragments/jobs/module.json +12 -0
  30. package/scaffold-fragments/jobs/runtime/config.ts +4 -0
  31. package/scaffold-fragments/jobs/runtime/imports.ts +1 -0
  32. package/scaffold-fragments/jobs/runtime/ports.ts +1 -0
  33. package/scaffold-fragments/notifications/module.json +12 -0
  34. package/scaffold-fragments/notifications/runtime/config.ts +8 -0
  35. package/scaffold-fragments/notifications/runtime/imports.ts +5 -0
  36. package/scaffold-fragments/notifications/runtime/ports.ts +1 -0
  37. package/scaffold-fragments/organizations/module.json +12 -0
  38. package/scaffold-fragments/telemetry/module.json +43 -0
  39. package/scaffold-fragments/telemetry/runtime/config.ts +7 -0
  40. package/scaffold-fragments/telemetry/runtime/imports.ts +7 -0
  41. package/scaffold-fragments/telemetry/runtime/ports.ts +1 -0
  42. package/skillex/private/contribute.md +193 -0
  43. package/skillex/public/consumer.md +157 -0
  44. package/templates/client/.storybook/main.ts +77 -0
  45. package/templates/client/.storybook/preview.ts +12 -0
  46. package/templates/client/CLAUDE.md +50 -0
  47. package/templates/client/_claude/settings.json +107 -0
  48. package/templates/client/_github/PULL_REQUEST_TEMPLATE.md +19 -0
  49. package/templates/client/_github/dependabot.yml +48 -0
  50. package/templates/client/_github/workflows/ci.yml +104 -0
  51. package/templates/client/_gitignore +15 -0
  52. package/templates/client/_npmrc +1 -0
  53. package/templates/client/app/(app)/layout.tsx +49 -0
  54. package/templates/client/app/(marketing)/layout.tsx +21 -0
  55. package/templates/client/app/(marketing)/page.tsx +34 -0
  56. package/templates/client/app/api/backend/[...path]/route.ts +37 -0
  57. package/templates/client/app/api/dev/login/route.ts +14 -0
  58. package/templates/client/app/api/dev/logout/route.ts +18 -0
  59. package/templates/client/app/globals.css +22 -0
  60. package/templates/client/app/home-auth-controls.tsx +28 -0
  61. package/templates/client/app/layout.tsx +38 -0
  62. package/templates/client/app/lib/backend-client.ts +55 -0
  63. package/templates/client/app/lib/runtime.test.ts +38 -0
  64. package/templates/client/app/lib/runtime.ts +54 -0
  65. package/templates/client/app/protected/page.tsx +31 -0
  66. package/templates/client/app/theme.override.css +9 -0
  67. package/templates/client/instrumentation.ts +10 -0
  68. package/templates/client/mocks/browser.tsx +28 -0
  69. package/templates/client/mocks/handlers.ts +20 -0
  70. package/templates/client/mocks/node.ts +7 -0
  71. package/templates/client/next.config.ts +8 -0
  72. package/templates/client/oxfmt.config.ts +8 -0
  73. package/templates/client/oxlint.config.ts +8 -0
  74. package/templates/client/package.json +61 -0
  75. package/templates/client/postcss.config.mjs +1 -0
  76. package/templates/client/public/mockServiceWorker.js +349 -0
  77. package/templates/client/scripts/build-theme.mjs +18 -0
  78. package/templates/client/site.spec.ts +58 -0
  79. package/templates/client/skillex.json +12 -0
  80. package/templates/client/skills/repo.md +56 -0
  81. package/templates/client/stylelint.config.mjs +4 -0
  82. package/templates/client/tsconfig.json +11 -0
  83. package/templates/client/vitest.config.ts +27 -0
  84. package/templates/client/vitest.setup.ts +18 -0
  85. package/templates/full-stack/.storybook/main.ts +77 -0
  86. package/templates/full-stack/.storybook/preview.ts +12 -0
  87. package/templates/full-stack/CLAUDE.md +50 -0
  88. package/templates/full-stack/_claude/settings.json +107 -0
  89. package/templates/full-stack/_github/PULL_REQUEST_TEMPLATE.md +19 -0
  90. package/templates/full-stack/_github/dependabot.yml +57 -0
  91. package/templates/full-stack/_github/workflows/ci.yml +109 -0
  92. package/templates/full-stack/_github/workflows/db-check.yml +36 -0
  93. package/templates/full-stack/_github/workflows/db-migrate.yml +39 -0
  94. package/templates/full-stack/_gitignore +15 -0
  95. package/templates/full-stack/_npmrc +1 -0
  96. package/templates/full-stack/app/(app)/layout.tsx +53 -0
  97. package/templates/full-stack/app/(marketing)/layout.tsx +21 -0
  98. package/templates/full-stack/app/(marketing)/page.tsx +38 -0
  99. package/templates/full-stack/app/api/dev/login/route.ts +14 -0
  100. package/templates/full-stack/app/api/dev/logout/route.ts +18 -0
  101. package/templates/full-stack/app/api/widgets/route.ts +10 -0
  102. package/templates/full-stack/app/demo/widgets/page.tsx +86 -0
  103. package/templates/full-stack/app/globals.css +22 -0
  104. package/templates/full-stack/app/home-auth-controls.tsx +28 -0
  105. package/templates/full-stack/app/layout.tsx +38 -0
  106. package/templates/full-stack/app/lib/runtime.test.ts +38 -0
  107. package/templates/full-stack/app/lib/runtime.ts +54 -0
  108. package/templates/full-stack/app/protected/page.tsx +31 -0
  109. package/templates/full-stack/app/theme.override.css +9 -0
  110. package/templates/full-stack/db/check.ts +46 -0
  111. package/templates/full-stack/db/index.ts +109 -0
  112. package/templates/full-stack/db/migrate.ts +66 -0
  113. package/templates/full-stack/db/migrations/.gitkeep +0 -0
  114. package/templates/full-stack/db/schema.ts +20 -0
  115. package/templates/full-stack/drizzle.config.ts +16 -0
  116. package/templates/full-stack/instrumentation.ts +10 -0
  117. package/templates/full-stack/mocks/browser.tsx +28 -0
  118. package/templates/full-stack/mocks/handlers.ts +34 -0
  119. package/templates/full-stack/mocks/node.ts +7 -0
  120. package/templates/full-stack/next.config.ts +8 -0
  121. package/templates/full-stack/oxfmt.config.ts +8 -0
  122. package/templates/full-stack/oxlint.config.ts +8 -0
  123. package/templates/full-stack/package.json +72 -0
  124. package/templates/full-stack/postcss.config.mjs +1 -0
  125. package/templates/full-stack/public/mockServiceWorker.js +349 -0
  126. package/templates/full-stack/scripts/build-theme.mjs +18 -0
  127. package/templates/full-stack/site.spec.ts +58 -0
  128. package/templates/full-stack/skillex.json +12 -0
  129. package/templates/full-stack/skills/repo.md +56 -0
  130. package/templates/full-stack/stylelint.config.mjs +4 -0
  131. package/templates/full-stack/tsconfig.json +11 -0
  132. package/templates/full-stack/vitest.config.ts +42 -0
  133. package/templates/full-stack/vitest.setup.ts +18 -0
  134. package/templates/static/.storybook/main.ts +77 -0
  135. package/templates/static/.storybook/preview.ts +12 -0
  136. package/templates/static/CLAUDE.md +50 -0
  137. package/templates/static/_claude/settings.json +107 -0
  138. package/templates/static/_github/PULL_REQUEST_TEMPLATE.md +18 -0
  139. package/templates/static/_github/dependabot.yml +43 -0
  140. package/templates/static/_github/workflows/ci.yml +91 -0
  141. package/templates/static/_gitignore +15 -0
  142. package/templates/static/_npmrc +1 -0
  143. package/templates/static/app/(marketing)/layout.tsx +21 -0
  144. package/templates/static/app/(marketing)/page.tsx +17 -0
  145. package/templates/static/app/globals.css +22 -0
  146. package/templates/static/app/layout.tsx +24 -0
  147. package/templates/static/app/theme.override.css +9 -0
  148. package/templates/static/next.config.ts +19 -0
  149. package/templates/static/oxfmt.config.ts +8 -0
  150. package/templates/static/oxlint.config.ts +8 -0
  151. package/templates/static/package.json +53 -0
  152. package/templates/static/postcss.config.mjs +1 -0
  153. package/templates/static/scripts/build-theme.mjs +18 -0
  154. package/templates/static/site.spec.ts +58 -0
  155. package/templates/static/skillex.json +12 -0
  156. package/templates/static/skills/repo.md +56 -0
  157. package/templates/static/stylelint.config.mjs +4 -0
  158. package/templates/static/tsconfig.json +11 -0
package/bin/index.js ADDED
@@ -0,0 +1,1024 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { cp, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseArgs } from "node:util";
8
+ import * as p from "@clack/prompts";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const packageRoot = resolve(__dirname, "..");
12
+ const templatesDir = resolve(packageRoot, "templates");
13
+ const fragmentsDir = resolve(packageRoot, "scaffold-fragments");
14
+
15
+ // ─── Presets ─────────────────────────────────────────────────────────────
16
+ // Pre-configured template + module combinations for common app types.
17
+ // These are offered in interactive mode and via --preset in non-interactive mode.
18
+
19
+ const PRESETS = {
20
+ courseware: {
21
+ label: "Courseware",
22
+ hint: "Full course platform — identity, billing, CMS, video, courseware modules pre-wired.",
23
+ template: "full-stack",
24
+ modules: ["billing"],
25
+ vendors: [],
26
+ brandPrimary: "#7c3aed",
27
+ navigation: {
28
+ primary: [
29
+ { label: "Home", href: "/" },
30
+ { label: "Courses", href: "/courses" },
31
+ { label: "Pricing", href: "/#pricing" },
32
+ ],
33
+ footer: [
34
+ { label: "Privacy", href: "/privacy" },
35
+ { label: "Terms", href: "/terms" },
36
+ { label: "Contact", href: "/contact" },
37
+ ],
38
+ },
39
+ },
40
+ "internal-tool": {
41
+ label: "Internal Tool",
42
+ hint: "Admin dashboard — identity + jobs. Stat cards, data tables, no public-facing pages.",
43
+ template: "full-stack",
44
+ modules: ["jobs"],
45
+ vendors: [],
46
+ brandPrimary: "#0f172a",
47
+ navigation: {
48
+ primary: [
49
+ { label: "Dashboard", href: "/" },
50
+ { label: "Users", href: "/users" },
51
+ { label: "Jobs", href: "/jobs" },
52
+ ],
53
+ footer: [],
54
+ },
55
+ },
56
+ demo: {
57
+ label: "Demo",
58
+ hint: "Lightweight demo — static template, core + components only. No identity or billing.",
59
+ template: "static",
60
+ modules: [],
61
+ vendors: [],
62
+ brandPrimary: "#10b981",
63
+ navigation: {
64
+ primary: [{ label: "Home", href: "/" }],
65
+ footer: [{ label: "Built with RareDays", href: "https://raredays.com" }],
66
+ },
67
+ },
68
+ };
69
+
70
+ function availableModuleKeys() {
71
+ if (!existsSync(fragmentsDir)) return [];
72
+ try {
73
+ return readdirSync(fragmentsDir, { withFileTypes: true })
74
+ .filter((e) => e.isDirectory())
75
+ .map((e) => e.name)
76
+ .filter((name) => existsSync(resolve(fragmentsDir, name, "module.json")))
77
+ .toSorted();
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function printHelp() {
84
+ const modules = availableModuleKeys();
85
+ const modulesLine =
86
+ modules.length > 0 ? modules.join(",") : "(none — scaffold-fragments directory missing)";
87
+ console.log(`Usage: create-raredays-app [<project-directory>] [options]
88
+
89
+ When invoked with no project directory, runs interactive mode (TUI).
90
+ When invoked with a project directory, runs non-interactively with the supplied flags.
91
+
92
+ Options:
93
+ --template <name> "full-stack" (default), "client", or "static"
94
+ --preset <name> Pre-configured app type: courseware, internal-tool, demo
95
+ Sets template, modules, and branding. Overrides --template/--modules.
96
+ --modules <list> Comma-separated. Discovered dynamically from scaffold-fragments:
97
+ ${modulesLine}
98
+ (full-stack + client templates)
99
+ --vendors <list> Comma-separated module:vendor pairs, e.g.
100
+ billing:stripe,cms:hygraph,email:resend,identity:better-auth
101
+ --remote-url <url> Set git remote origin to this URL after scaffolding
102
+ --no-git Skip git init
103
+ --no-install Skip pnpm install at the end
104
+ --dev Link @raredays/* deps as workspace:* (for in-repo dogfooding)
105
+ --harness <name> Wire @raredays/mcp into an MCP-aware IDE.
106
+ One of: claude-code, cursor, windsurf, none (default).
107
+ Adds @raredays/mcp to devDependencies and writes the IDE
108
+ config file pointing at the local install.
109
+ --deploy <target> Scaffold deploy config for a target host.
110
+ One of: vercel, none (default).
111
+ Writes vercel.json, .env.example, docs/DEPLOY.md, and
112
+ (full-stack only) .github/workflows/deploy-migrations.yml.
113
+ -h, --help Show this help
114
+
115
+ Presets:
116
+ courseware Full course platform (full-stack + billing)
117
+ internal-tool Admin dashboard (full-stack + jobs)
118
+ demo Lightweight demo (static, no modules)
119
+ `);
120
+ }
121
+
122
+ function fail(message) {
123
+ console.error(`✗ ${message}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ function cancelExit() {
128
+ p.cancel("Cancelled.");
129
+ process.exit(0);
130
+ }
131
+
132
+ function toSlug(name) {
133
+ const slug = name
134
+ .trim()
135
+ .toLowerCase()
136
+ .replace(/[^a-z0-9]+/g, "-")
137
+ .replace(/^-+|-+$/g, "");
138
+ if (slug.length === 0 || slug.length > 64) {
139
+ fail(
140
+ `Cannot derive a valid kebab-case slug from "${name}". Pick a project name with letters, digits, or hyphens.`,
141
+ );
142
+ }
143
+ return slug;
144
+ }
145
+
146
+ function isValidDomain(s) {
147
+ return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(s);
148
+ }
149
+
150
+ async function substituteSlug(filePath, slug) {
151
+ if (!existsSync(filePath)) return;
152
+ const original = await readFile(filePath, "utf8");
153
+ const replaced = original.replaceAll("__SLUG__", slug);
154
+ if (replaced !== original) {
155
+ await writeFile(filePath, replaced);
156
+ }
157
+ }
158
+
159
+ // ─── Fragment loading ────────────────────────────────────────────────────
160
+
161
+ async function loadFragment(key) {
162
+ const dir = resolve(fragmentsDir, key);
163
+ const manifestPath = resolve(dir, "module.json");
164
+ if (!existsSync(manifestPath)) return null;
165
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
166
+ const runtime = {};
167
+ for (const slot of ["imports", "config", "ports", "exports"]) {
168
+ const path = resolve(dir, "runtime", `${slot}.ts`);
169
+ if (existsSync(path)) runtime[slot] = (await readFile(path, "utf8")).trimEnd();
170
+ }
171
+ const filesDir = resolve(dir, "files");
172
+ // Per-vendor file dirs at vendors/<key>/files/ — copied when the vendor is selected.
173
+ const vendors = (manifest.vendors ?? []).map((v) => {
174
+ const vendorFilesDir = resolve(dir, "vendors", v.key, "files");
175
+ return { ...v, filesDir: existsSync(vendorFilesDir) ? vendorFilesDir : null };
176
+ });
177
+ return {
178
+ ...manifest,
179
+ vendors,
180
+ dir,
181
+ runtime,
182
+ filesDir: existsSync(filesDir) ? filesDir : null,
183
+ };
184
+ }
185
+
186
+ async function loadAllFragments() {
187
+ if (!existsSync(fragmentsDir)) return [];
188
+ const entries = await readdir(fragmentsDir, { withFileTypes: true });
189
+ const fragments = [];
190
+ for (const e of entries) {
191
+ if (!e.isDirectory()) continue;
192
+ const f = await loadFragment(e.name);
193
+ if (f) fragments.push(f);
194
+ }
195
+ return fragments;
196
+ }
197
+
198
+ // ─── Runtime.ts composition ──────────────────────────────────────────────
199
+
200
+ function composeRuntime(source, fragments) {
201
+ const slots = { imports: [], config: [], ports: [], exports: [] };
202
+ for (const f of fragments) {
203
+ for (const slot of Object.keys(slots)) {
204
+ if (f.runtime[slot]) slots[slot].push(f.runtime[slot]);
205
+ }
206
+ }
207
+ return source
208
+ .replace(
209
+ "// CRDA:imports",
210
+ slots.imports.length > 0 ? slots.imports.join("\n") : "// CRDA:imports",
211
+ )
212
+ .replace(
213
+ "// CRDA:config",
214
+ slots.config.length > 0
215
+ ? slots.config.join("\n\n")
216
+ : "// CRDA:config (no additional modules wired)",
217
+ )
218
+ .replace(
219
+ " // CRDA:ports",
220
+ slots.ports.length > 0 ? slots.ports.join("\n").trimEnd() : " // CRDA:ports",
221
+ )
222
+ .replace(
223
+ "// CRDA:exports",
224
+ slots.exports.length > 0 ? slots.exports.join("\n\n") : "// CRDA:exports",
225
+ );
226
+ }
227
+
228
+ // ─── Mock handlers composition ───────────────────────────────────────────
229
+
230
+ function composeHandlers(source, vendorMocks) {
231
+ const imports = vendorMocks.map(
232
+ (m) => `import { ${m.named} } from ${JSON.stringify(m.package)};`,
233
+ );
234
+ const spread = vendorMocks.map((m) => ` ${m.named},`);
235
+ return source
236
+ .replace(
237
+ "// CRDA:mocks-imports",
238
+ imports.length > 0 ? imports.join("\n") : "// CRDA:mocks-imports",
239
+ )
240
+ .replace(
241
+ " // CRDA:mocks-spread",
242
+ spread.length > 0 ? spread.join("\n") : " // CRDA:mocks-spread",
243
+ );
244
+ }
245
+
246
+ // ─── Site spec customisation ─────────────────────────────────────────────
247
+
248
+ async function customiseSiteSpec(filePath, opts) {
249
+ if (!existsSync(filePath)) return;
250
+ let content = await readFile(filePath, "utf8");
251
+ if (opts.displayName) {
252
+ content = content.replace(/name:\s*"[^"]*"/, `name: ${JSON.stringify(opts.displayName)}`);
253
+ }
254
+ if (opts.canonicalDomain) {
255
+ content = content.replace(
256
+ /canonicalDomain:\s*"[^"]*"/,
257
+ `canonicalDomain: ${JSON.stringify(opts.canonicalDomain)}`,
258
+ );
259
+ }
260
+ if (opts.brandPrimary) {
261
+ content = content.replace(
262
+ /primary:\s*"#[0-9a-fA-F]+"/,
263
+ `primary: ${JSON.stringify(opts.brandPrimary)}`,
264
+ );
265
+ }
266
+ await writeFile(filePath, content);
267
+ }
268
+
269
+ // ─── Env example ─────────────────────────────────────────────────────────
270
+
271
+ async function writeEnvExample(targetDir, envVars) {
272
+ if (envVars.length === 0) return;
273
+ const path = resolve(targetDir, ".env.example");
274
+ const hasDbUrl = envVars.includes("DATABASE_URL");
275
+ const lines = [
276
+ "# Environment variables required by the modules you selected.",
277
+ "# Copy to .env.local and fill in real values before running.",
278
+ "",
279
+ ];
280
+ if (!hasDbUrl) {
281
+ lines.push("# Database (uncomment when wiring Drizzle queries)", "# DATABASE_URL=", "");
282
+ }
283
+ lines.push(...envVars.map((v) => `${v}=`), "");
284
+ await writeFile(path, lines.join("\n"));
285
+ }
286
+
287
+ // ─── Module/vendor flag parsing ──────────────────────────────────────────
288
+
289
+ function parseCsv(value) {
290
+ if (!value) return [];
291
+ return value
292
+ .split(",")
293
+ .map((s) => s.trim())
294
+ .filter((s) => s.length > 0);
295
+ }
296
+
297
+ // ─── Deploy prompt (shared by interactive paths) ─────────────────────────
298
+
299
+ async function promptDeploy() {
300
+ const choice = await p.select({
301
+ message: "Where will you deploy?",
302
+ options: [
303
+ {
304
+ value: "vercel",
305
+ label: "Vercel",
306
+ hint: "Recommended for Next.js — generous free tier, GitHub-integrated previews",
307
+ },
308
+ { value: "none", label: "Other / decide later" },
309
+ ],
310
+ initialValue: "vercel",
311
+ });
312
+ if (p.isCancel(choice)) cancelExit();
313
+ return choice === "none" ? null : choice;
314
+ }
315
+
316
+ // ─── Interactive mode ────────────────────────────────────────────────────
317
+
318
+ async function runInteractive(allFragments) {
319
+ p.intro("create-raredays-app");
320
+
321
+ const projectName = await p.text({
322
+ message: "Project name (used as the directory + slug):",
323
+ placeholder: "my-app",
324
+ validate: (v) => {
325
+ if (!v) return "Required.";
326
+ if (existsSync(resolve(process.cwd(), v))) return `Directory ${v} already exists.`;
327
+ return undefined;
328
+ },
329
+ });
330
+ if (p.isCancel(projectName)) cancelExit();
331
+
332
+ const presetOrCustom = await p.select({
333
+ message: "What are you building?",
334
+ options: [
335
+ ...Object.entries(PRESETS).map(([key, preset]) => ({
336
+ value: key,
337
+ label: preset.label,
338
+ hint: preset.hint,
339
+ })),
340
+ { value: "custom", label: "Custom", hint: "Pick template and modules yourself" },
341
+ ],
342
+ });
343
+ if (p.isCancel(presetOrCustom)) cancelExit();
344
+
345
+ if (presetOrCustom !== "custom") {
346
+ const preset = PRESETS[presetOrCustom];
347
+ const displayName = await p.text({
348
+ message: "Display name (shown to users):",
349
+ placeholder: projectName,
350
+ defaultValue: projectName,
351
+ });
352
+ if (p.isCancel(displayName)) cancelExit();
353
+
354
+ const deploy = await promptDeploy();
355
+
356
+ const gitInit = await p.confirm({
357
+ message: "Initialize a git repo?",
358
+ initialValue: true,
359
+ });
360
+ if (p.isCancel(gitInit)) cancelExit();
361
+
362
+ const runInstall = await p.confirm({
363
+ message: "Run `pnpm install` after scaffolding?",
364
+ initialValue: true,
365
+ });
366
+ if (p.isCancel(runInstall)) cancelExit();
367
+
368
+ return {
369
+ projectName,
370
+ template: preset.template,
371
+ displayName: displayName || projectName,
372
+ canonicalDomain: undefined,
373
+ brandPrimary: preset.brandPrimary,
374
+ selectedModules: preset.modules,
375
+ selectedVendors: preset.vendors,
376
+ remoteUrl: undefined,
377
+ gitInit,
378
+ runInstall,
379
+ deploy,
380
+ };
381
+ }
382
+
383
+ const template = await p.select({
384
+ message: "Template:",
385
+ options: [
386
+ {
387
+ value: "static",
388
+ label: "static — truly static, deployable to CDN/edge (no SSR, no APIs)",
389
+ },
390
+ {
391
+ value: "client",
392
+ label: "client — Next.js BFF + SPA, proxies to an external backend",
393
+ },
394
+ {
395
+ value: "full-stack",
396
+ label: "full-stack — Next.js + Drizzle + framework modules",
397
+ },
398
+ ],
399
+ initialValue: "full-stack",
400
+ });
401
+ if (p.isCancel(template)) cancelExit();
402
+
403
+ const displayName = await p.text({
404
+ message: "Display name (shown to users):",
405
+ placeholder: projectName,
406
+ defaultValue: projectName,
407
+ });
408
+ if (p.isCancel(displayName)) cancelExit();
409
+
410
+ const canonicalDomain = await p.text({
411
+ message: "Canonical domain (optional, e.g. example.com):",
412
+ placeholder: `${projectName}.example.com`,
413
+ validate: (v) => (v && !isValidDomain(v) ? "Doesn't look like a domain." : undefined),
414
+ });
415
+ if (p.isCancel(canonicalDomain)) cancelExit();
416
+
417
+ const brandPrimary = await p.text({
418
+ message: "Brand primary color (hex):",
419
+ placeholder: "#10b981",
420
+ defaultValue: "#10b981",
421
+ validate: (v) => (v && !/^#[0-9a-fA-F]{3,8}$/.test(v) ? "Hex color, e.g. #10b981." : undefined),
422
+ });
423
+ if (p.isCancel(brandPrimary)) cancelExit();
424
+
425
+ let selectedModules = [];
426
+ let selectedVendors = [];
427
+ const applicable = allFragments.filter((f) => (f.templates ?? ["full-stack"]).includes(template));
428
+ if (template !== "static" && applicable.length > 0) {
429
+ const moduleChoice = await p.multiselect({
430
+ message:
431
+ "Modules to wire (space to toggle; identity is always wired with an in-memory dev provider — selecting it lets you pick a production vendor):",
432
+ options: applicable.map((f) => ({ value: f.key, label: f.label, hint: f.description })),
433
+ required: false,
434
+ });
435
+ if (p.isCancel(moduleChoice)) cancelExit();
436
+ selectedModules = moduleChoice;
437
+
438
+ for (const f of applicable) {
439
+ if (!selectedModules.includes(f.key)) continue;
440
+ if (!f.vendors || f.vendors.length === 0) continue;
441
+ const vendorChoice = await p.select({
442
+ message: `${f.label} — production provider:`,
443
+ options: [
444
+ { value: "none", label: "None", hint: "Use the in-memory dev adapter" },
445
+ ...f.vendors.map((v) => ({ value: `${f.key}:${v.key}`, label: v.label })),
446
+ ],
447
+ });
448
+ if (p.isCancel(vendorChoice)) cancelExit();
449
+ if (vendorChoice !== "none") selectedVendors.push(vendorChoice);
450
+ }
451
+ }
452
+
453
+ const deploy = await promptDeploy();
454
+
455
+ const remoteUrl = await p.text({
456
+ message: "Upstream git remote URL (optional):",
457
+ placeholder: "git@github.com:org/repo.git",
458
+ });
459
+ if (p.isCancel(remoteUrl)) cancelExit();
460
+
461
+ const gitInit = await p.confirm({
462
+ message: "Initialize a git repo?",
463
+ initialValue: true,
464
+ });
465
+ if (p.isCancel(gitInit)) cancelExit();
466
+
467
+ const runInstall = await p.confirm({
468
+ message: "Run `pnpm install` after scaffolding?",
469
+ initialValue: true,
470
+ });
471
+ if (p.isCancel(runInstall)) cancelExit();
472
+
473
+ return {
474
+ projectName,
475
+ template,
476
+ displayName: displayName || projectName,
477
+ canonicalDomain: canonicalDomain || undefined,
478
+ brandPrimary: brandPrimary || undefined,
479
+ selectedModules,
480
+ selectedVendors,
481
+ remoteUrl: remoteUrl || undefined,
482
+ gitInit,
483
+ runInstall,
484
+ deploy,
485
+ };
486
+ }
487
+
488
+ // ─── Scaffold ────────────────────────────────────────────────────────────
489
+
490
+ async function scaffold(opts) {
491
+ const targetDir = resolve(process.cwd(), opts.projectName);
492
+ if (existsSync(targetDir)) fail(`Directory already exists: ${targetDir}`);
493
+ const templateDir = resolve(templatesDir, opts.template);
494
+ if (!existsSync(templateDir)) fail(`Unknown template: ${opts.template}`);
495
+
496
+ const slug = toSlug(opts.projectName);
497
+
498
+ await cp(templateDir, targetDir, { recursive: true });
499
+
500
+ // Rename underscore-prefixed files/dirs (root .gitignore would
501
+ // otherwise filter dot-prefixed ones out of the published template).
502
+ for (const [from, to] of [
503
+ ["_gitignore", ".gitignore"],
504
+ ["_npmrc", ".npmrc"],
505
+ ["_claude", ".claude"],
506
+ ["_github", ".github"],
507
+ ]) {
508
+ const fromPath = resolve(targetDir, from);
509
+ if (existsSync(fromPath)) await rename(fromPath, resolve(targetDir, to));
510
+ }
511
+
512
+ // Substitute __SLUG__ in site.spec.ts, then apply brand/identity prompts.
513
+ await substituteSlug(resolve(targetDir, "site.spec.ts"), slug);
514
+ await customiseSiteSpec(resolve(targetDir, "site.spec.ts"), {
515
+ displayName: opts.displayName,
516
+ canonicalDomain: opts.canonicalDomain,
517
+ brandPrimary: opts.brandPrimary,
518
+ });
519
+
520
+ // Load + apply selected fragments. Static templates don't have a
521
+ // composition root, so skip module composition entirely there.
522
+ const collectedDeps = {};
523
+ const envVars = [];
524
+ const vendorMocks = [];
525
+ if (opts.template !== "static" && opts.selectedModules.length > 0) {
526
+ const fragments = await Promise.all(opts.selectedModules.map(loadFragment));
527
+ const valid = fragments.filter(Boolean);
528
+
529
+ for (const f of valid) {
530
+ Object.assign(collectedDeps, f.deps ?? {});
531
+ if (f.filesDir) await cp(f.filesDir, targetDir, { recursive: true });
532
+ }
533
+
534
+ for (const pair of opts.selectedVendors) {
535
+ const [moduleKey, vendorKey] = pair.split(":");
536
+ const f = valid.find((x) => x.key === moduleKey);
537
+ const vendor = f?.vendors?.find((v) => v.key === vendorKey);
538
+ if (!vendor) continue;
539
+ Object.assign(collectedDeps, vendor.deps ?? {});
540
+ for (const v of vendor.env ?? []) envVars.push(v);
541
+ if (vendor.mocks) {
542
+ vendorMocks.push(vendor.mocks);
543
+ Object.assign(collectedDeps, vendor.mocks.peerDeps ?? {});
544
+ }
545
+ if (vendor.filesDir) await cp(vendor.filesDir, targetDir, { recursive: true });
546
+ }
547
+
548
+ const runtimePath = resolve(targetDir, "app/lib/runtime.ts");
549
+ const source = await readFile(runtimePath, "utf8");
550
+ await writeFile(runtimePath, composeRuntime(source, valid));
551
+ }
552
+
553
+ // Compose mocks/handlers.ts for any template that ships it (full-stack + client).
554
+ if (opts.template !== "static") {
555
+ const handlersPath = resolve(targetDir, "mocks/handlers.ts");
556
+ if (existsSync(handlersPath)) {
557
+ const source = await readFile(handlersPath, "utf8");
558
+ await writeFile(handlersPath, composeHandlers(source, vendorMocks));
559
+ }
560
+ }
561
+
562
+ // Merge deps into package.json + rename + dev-link.
563
+ const pkgPath = resolve(targetDir, "package.json");
564
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
565
+ pkg.name = slug;
566
+ pkg.dependencies = { ...pkg.dependencies, ...collectedDeps };
567
+ if (opts.dev) {
568
+ for (const field of ["dependencies", "devDependencies"]) {
569
+ const deps = pkg[field];
570
+ if (!deps) continue;
571
+ for (const name of Object.keys(deps)) {
572
+ if (name.startsWith("@raredays/")) deps[name] = "workspace:*";
573
+ }
574
+ }
575
+ }
576
+ // Apply MCP harness wiring (devDep + IDE config) before writing pkg out.
577
+ await applyHarness(targetDir, pkg, opts.harness, opts.dev);
578
+
579
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
580
+
581
+ // Apply deploy-target scaffolding (vercel.json, DEPLOY.md, migrations workflow, etc.).
582
+ // Must run before .env.example so deploy can request additional vars (e.g. DATABASE_URL).
583
+ const deployEnvVars = await applyDeploy(targetDir, opts);
584
+ for (const v of deployEnvVars) if (!envVars.includes(v)) envVars.push(v);
585
+
586
+ if (envVars.length > 0) await writeEnvExample(targetDir, envVars);
587
+
588
+ // Optional git init + remote.
589
+ if (opts.gitInit) {
590
+ spawnSync("git", ["init", "--quiet", "--initial-branch=main"], { cwd: targetDir });
591
+ if (opts.remoteUrl) {
592
+ spawnSync("git", ["remote", "add", "origin", opts.remoteUrl], { cwd: targetDir });
593
+ }
594
+ }
595
+
596
+ // Optional install.
597
+ if (opts.runInstall) {
598
+ p.note("Running `pnpm install` — this can take a minute.", "Installing");
599
+ const result = spawnSync("pnpm", ["install"], { cwd: targetDir, stdio: "inherit" });
600
+ if (result.status !== 0) {
601
+ p.log.warn("pnpm install failed. Run it manually in the project directory.");
602
+ }
603
+
604
+ p.note("Initializing skillex skill registry.", "Skillex");
605
+ spawnSync("skillex", ["init", "--yes"], { cwd: targetDir });
606
+ }
607
+
608
+ return { targetDir, slug, envVars };
609
+ }
610
+
611
+ // ─── MCP harness wiring ──────────────────────────────────────────────────
612
+
613
+ const HARNESS_CONFIGS = {
614
+ "claude-code": ".claude/mcp.json",
615
+ cursor: ".cursor/mcp.json",
616
+ windsurf: ".windsurf/mcp.json",
617
+ };
618
+
619
+ const MCP_VERSION = "0.0.1";
620
+
621
+ async function applyHarness(targetDir, pkg, harness, dev) {
622
+ if (!harness || harness === "none") return;
623
+ if (!(harness in HARNESS_CONFIGS)) {
624
+ p.log.warn(
625
+ `Unknown --harness "${harness}". Use one of: ${Object.keys(HARNESS_CONFIGS).join(", ")}, none.`,
626
+ );
627
+ return;
628
+ }
629
+
630
+ // Add @raredays/mcp to devDependencies.
631
+ pkg.devDependencies ??= {};
632
+ pkg.devDependencies["@raredays/mcp"] = dev ? "workspace:*" : `^${MCP_VERSION}`;
633
+
634
+ // Write the IDE config file.
635
+ const relConfig = HARNESS_CONFIGS[harness];
636
+ const configPath = resolve(targetDir, relConfig);
637
+ const configDir = dirname(configPath);
638
+ if (!existsSync(configDir)) {
639
+ const { mkdir } = await import("node:fs/promises");
640
+ await mkdir(configDir, { recursive: true });
641
+ }
642
+
643
+ const config = {
644
+ mcpServers: {
645
+ raredays: {
646
+ command: "pnpm",
647
+ args: ["exec", "raredays-mcp"],
648
+ },
649
+ },
650
+ };
651
+ const { writeFile: writeF } = await import("node:fs/promises");
652
+ await writeF(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
653
+
654
+ p.note(
655
+ `MCP wired for ${harness}. Connect your IDE to see the @raredays/* tools.\nConfig: ${relConfig}`,
656
+ "MCP",
657
+ );
658
+ }
659
+
660
+ // ─── Deploy-target scaffolding ───────────────────────────────────────────
661
+
662
+ // Module-level deploy env-var manifest. These are vars a module needs in
663
+ // production regardless of the vendor selection (vendor-specific env vars
664
+ // are already collected via fragment.vendors[].env).
665
+ const DEPLOY_MODULE_ENV = {
666
+ identity: ["BETTER_AUTH_SECRET", "BETTER_AUTH_URL"],
667
+ };
668
+
669
+ const VERCEL_JSON = {
670
+ $schema: "https://openapi.vercel.sh/vercel.json",
671
+ framework: "nextjs",
672
+ buildCommand: "pnpm build",
673
+ installCommand: "pnpm install",
674
+ regions: ["iad1"],
675
+ functions: {
676
+ "app/api/**/*.ts": {
677
+ memory: 1024,
678
+ },
679
+ },
680
+ };
681
+
682
+ async function applyDeploy(targetDir, opts) {
683
+ if (!opts.deploy || opts.deploy === "none") return [];
684
+ if (opts.deploy !== "vercel") {
685
+ p.log?.warn?.(`Unknown --deploy "${opts.deploy}". Skipping deploy scaffolding.`);
686
+ return [];
687
+ }
688
+
689
+ const { mkdir } = await import("node:fs/promises");
690
+ const isFullStack = opts.template === "full-stack";
691
+ const hasApi = opts.template !== "static";
692
+
693
+ // 1. vercel.json — strip functions block for static templates (no API routes).
694
+ const vercelConfig = { ...VERCEL_JSON };
695
+ if (!hasApi) delete vercelConfig.functions;
696
+ await writeFile(resolve(targetDir, "vercel.json"), `${JSON.stringify(vercelConfig, null, 2)}\n`);
697
+
698
+ // 2. Append .vercel/ to .gitignore (so `vercel link` artefacts don't get committed).
699
+ const gitignorePath = resolve(targetDir, ".gitignore");
700
+ if (existsSync(gitignorePath)) {
701
+ const existing = await readFile(gitignorePath, "utf8");
702
+ if (!existing.includes(".vercel")) {
703
+ await writeFile(gitignorePath, `${existing.trimEnd()}\n\n# Vercel\n.vercel/\n`);
704
+ }
705
+ }
706
+
707
+ // 3. docs/DEPLOY.md — one-page runbook.
708
+ const docsDir = resolve(targetDir, "docs");
709
+ if (!existsSync(docsDir)) await mkdir(docsDir, { recursive: true });
710
+ await writeFile(resolve(docsDir, "DEPLOY.md"), buildDeployMd(opts));
711
+
712
+ // 4. Migrations workflow — only for full-stack (static/client don't have a DB).
713
+ if (isFullStack) {
714
+ const workflowsDir = resolve(targetDir, ".github", "workflows");
715
+ if (!existsSync(workflowsDir)) await mkdir(workflowsDir, { recursive: true });
716
+ await writeFile(resolve(workflowsDir, "deploy-migrations.yml"), buildMigrationsWorkflow());
717
+ }
718
+
719
+ // 5. scripts/setup-vercel.sh — interactive linker.
720
+ const scriptsDir = resolve(targetDir, "scripts");
721
+ if (!existsSync(scriptsDir)) await mkdir(scriptsDir, { recursive: true });
722
+ const scriptPath = resolve(scriptsDir, "setup-vercel.sh");
723
+ await writeFile(scriptPath, buildSetupVercelScript());
724
+ const { chmod } = await import("node:fs/promises");
725
+ await chmod(scriptPath, 0o755);
726
+
727
+ // 6. Return the extra env-vars deploy implies.
728
+ const extra = [];
729
+ if (isFullStack) extra.push("DATABASE_URL");
730
+ for (const m of opts.selectedModules ?? []) {
731
+ for (const v of DEPLOY_MODULE_ENV[m] ?? []) extra.push(v);
732
+ }
733
+ return extra;
734
+ }
735
+
736
+ function buildDeployMd(opts) {
737
+ const isFullStack = opts.template === "full-stack";
738
+ const dbSections = isFullStack
739
+ ? `
740
+ ## 3. Provision a Postgres database (Neon)
741
+
742
+ The full-stack template uses Postgres via the \`@raredays/postgres\` port. For
743
+ managed production Postgres with branching previews, [Neon](https://neon.tech)
744
+ pairs well with Vercel.
745
+
746
+ 1. Sign up at <https://console.neon.tech>.
747
+ 2. Create a project (free tier is fine to start).
748
+ 3. Copy the **Pooled connection** string — this is your \`DATABASE_URL\`.
749
+ 4. (Optional) Enable [Vercel-Neon integration](https://neon.tech/docs/guides/vercel)
750
+ for automatic preview-environment branching: each PR gets its own Postgres
751
+ branch with isolated data.
752
+
753
+ `
754
+ : "";
755
+
756
+ const migrationsSection = isFullStack
757
+ ? `
758
+ ## 6. Run migrations
759
+
760
+ The repo ships a workflow at \`.github/workflows/deploy-migrations.yml\` that
761
+ runs \`pnpm db:migrate\` on push to \`main\`, gated behind a manual approval.
762
+
763
+ To enable it:
764
+
765
+ 1. In your GitHub repo: **Settings → Environments → New environment** → name it \`production\`.
766
+ 2. Add a **required reviewer** (yourself, or your team).
767
+ 3. Add \`DATABASE_URL\` as an environment secret.
768
+ 4. On the next push to \`main\`, the workflow will pause for your approval before applying schema changes.
769
+
770
+ For the first deploy, you can run migrations locally:
771
+
772
+ \`\`\`sh
773
+ DATABASE_URL="<your-prod-url>" pnpm db:migrate
774
+ \`\`\`
775
+
776
+ `
777
+ : "";
778
+
779
+ const envBlock = isFullStack
780
+ ? "DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, and any module-specific keys"
781
+ : "any module-specific keys listed in `.env.example`";
782
+
783
+ return `# Deploying to Vercel
784
+
785
+ This app is configured for [Vercel](https://vercel.com), Next.js's reference
786
+ host. Free tier is generous; previews and production both deploy from git.
787
+
788
+ ## 1. One-time: install the Vercel CLI
789
+
790
+ \`\`\`sh
791
+ pnpm add -g vercel
792
+ vercel login
793
+ \`\`\`
794
+
795
+ Or use the convenience script:
796
+
797
+ \`\`\`sh
798
+ ./scripts/setup-vercel.sh
799
+ \`\`\`
800
+
801
+ ## 2. Link this repo to a Vercel project
802
+
803
+ From the project root:
804
+
805
+ \`\`\`sh
806
+ vercel link
807
+ \`\`\`
808
+
809
+ Pick "Create a new project" when prompted. This writes \`.vercel/project.json\`
810
+ locally (gitignored — each developer links to the same upstream project).
811
+ ${dbSections}
812
+ ## ${isFullStack ? "4" : "3"}. Set environment variables
813
+
814
+ Open your project in the [Vercel dashboard → Settings → Environment Variables](https://vercel.com/dashboard).
815
+ Add: ${envBlock}.
816
+
817
+ See \`.env.example\` in this repo for the canonical list. Set each var for
818
+ **Production**, **Preview**, and **Development** as appropriate.
819
+
820
+ ## ${isFullStack ? "5" : "4"}. First deploy
821
+
822
+ \`\`\`sh
823
+ vercel --prod
824
+ \`\`\`
825
+
826
+ This builds and deploys directly from your machine. Subsequent deploys can be
827
+ fully automatic: connect the repo via the Vercel dashboard's **Git** tab, and
828
+ every push to \`main\` ships to production, every PR gets a preview URL.
829
+ ${migrationsSection}
830
+ ## ${isFullStack ? "7" : "5"}. Custom domain
831
+
832
+ In the Vercel dashboard → **Settings → Domains**, add your domain and follow
833
+ the DNS instructions. Vercel handles SSL certificates automatically.
834
+
835
+ ## Notes & gotchas
836
+
837
+ - **Function memory**: \`vercel.json\` defaults API routes to 1024 MB. Bump in \`functions["app/api/**/*.ts"].memory\` if you see OOMs.
838
+ - **Regions**: defaults to \`iad1\` (US East). Co-locate with your database for lowest latency — change in \`vercel.json\` if your DB is elsewhere.
839
+ - **Build size limits**: Vercel's serverless functions cap at 250 MB unzipped. If you hit this, audit dependencies or split into separate functions.
840
+ - **Free tier limits**: 100 GB-hours/mo compute, 100 GB bandwidth. See <https://vercel.com/pricing>.
841
+
842
+ ## Alternative hosts
843
+
844
+ This app is plain Next.js — it'll also run on:
845
+
846
+ - **Railway** / **Fly.io** — \`pnpm build && pnpm start\`, set the same env vars
847
+ - **Self-hosted** — \`pnpm start\` behind a reverse proxy (nginx, Caddy)
848
+ - **Docker** — a \`Dockerfile\` target will land as a future CRDA \`--deploy docker\` option
849
+ `;
850
+ }
851
+
852
+ function buildMigrationsWorkflow() {
853
+ return `# Runs database migrations against the production database after a manual
854
+ # approval. Requires a GitHub Environment named "production" with DATABASE_URL
855
+ # as a secret and a required reviewer.
856
+ #
857
+ # See docs/DEPLOY.md for setup instructions.
858
+ name: Deploy migrations
859
+
860
+ on:
861
+ push:
862
+ branches: [main]
863
+ workflow_dispatch:
864
+
865
+ jobs:
866
+ migrate:
867
+ name: Apply pending migrations
868
+ runs-on: ubuntu-latest
869
+ environment: production
870
+ steps:
871
+ - uses: actions/checkout@v4
872
+
873
+ - uses: pnpm/action-setup@v4
874
+ with:
875
+ version: 9
876
+
877
+ - uses: actions/setup-node@v4
878
+ with:
879
+ node-version: 20
880
+ cache: pnpm
881
+
882
+ - run: pnpm install --frozen-lockfile
883
+
884
+ - name: Run migrations
885
+ env:
886
+ DATABASE_URL: \${{ secrets.DATABASE_URL }}
887
+ run: pnpm db:migrate
888
+ `;
889
+ }
890
+
891
+ function buildSetupVercelScript() {
892
+ return `#!/usr/bin/env bash
893
+ # Interactive helper: links this repo to a new Vercel project.
894
+ # See docs/DEPLOY.md for the manual equivalent.
895
+
896
+ set -euo pipefail
897
+
898
+ if ! command -v vercel >/dev/null 2>&1; then
899
+ echo "Vercel CLI not found."
900
+ echo "Install with: pnpm add -g vercel"
901
+ echo "Or via npm: npm i -g vercel"
902
+ exit 1
903
+ fi
904
+
905
+ if ! vercel whoami >/dev/null 2>&1; then
906
+ echo "Not logged into Vercel. Running 'vercel login'…"
907
+ vercel login
908
+ fi
909
+
910
+ echo
911
+ echo "Linking this repo to a Vercel project."
912
+ echo "Pick 'Create a new project' when prompted, then accept defaults."
913
+ echo
914
+ vercel link
915
+
916
+ echo
917
+ echo "Linked. Next:"
918
+ echo " 1. Set env vars: Vercel dashboard → Settings → Environment Variables"
919
+ echo " 2. First deploy: vercel --prod"
920
+ echo
921
+ echo "See docs/DEPLOY.md for the full runbook."
922
+ `;
923
+ }
924
+
925
+ // ─── Main ────────────────────────────────────────────────────────────────
926
+
927
+ async function main() {
928
+ const { values, positionals } = parseArgs({
929
+ args: process.argv.slice(2),
930
+ options: {
931
+ template: { type: "string", default: "full-stack" },
932
+ preset: { type: "string" },
933
+ modules: { type: "string" },
934
+ vendors: { type: "string" },
935
+ "remote-url": { type: "string" },
936
+ "no-git": { type: "boolean", default: false },
937
+ "no-install": { type: "boolean", default: false },
938
+ dev: { type: "boolean", default: false },
939
+ harness: { type: "string", default: "none" },
940
+ deploy: { type: "string" },
941
+ help: { type: "boolean", short: "h" },
942
+ },
943
+ allowPositionals: true,
944
+ });
945
+
946
+ if (values.help) {
947
+ printHelp();
948
+ return;
949
+ }
950
+
951
+ const allFragments = await loadAllFragments();
952
+ const target = positionals[0];
953
+
954
+ let opts;
955
+ if (target) {
956
+ // Non-interactive: positional given, use flags + defaults.
957
+ const preset = values.preset ? PRESETS[values.preset] : null;
958
+ if (values.preset && !preset) {
959
+ fail(
960
+ `Unknown preset "${values.preset}". Available presets: ${Object.keys(PRESETS).join(", ")}`,
961
+ );
962
+ }
963
+ const requestedModules = preset ? preset.modules : parseCsv(values.modules);
964
+ const availableModules = availableModuleKeys();
965
+ const known = [];
966
+ for (const m of requestedModules) {
967
+ if (availableModules.includes(m)) {
968
+ known.push(m);
969
+ } else {
970
+ console.warn(
971
+ `warning: --modules "${m}" has no scaffold-fragment under scaffold-fragments/${m}/, skipping. Available: ${availableModules.join(", ")}`,
972
+ );
973
+ }
974
+ }
975
+ opts = {
976
+ projectName: target,
977
+ template: preset ? preset.template : values.template,
978
+ displayName: undefined,
979
+ canonicalDomain: undefined,
980
+ brandPrimary: preset?.brandPrimary ?? undefined,
981
+ selectedModules: known,
982
+ selectedVendors: preset ? preset.vendors : parseCsv(values.vendors),
983
+ remoteUrl: values["remote-url"],
984
+ gitInit: !values["no-git"],
985
+ runInstall: !values["no-install"],
986
+ dev: values.dev,
987
+ harness: values.harness,
988
+ deploy: values.deploy && values.deploy !== "none" ? values.deploy : null,
989
+ };
990
+ if (values.deploy && values.deploy !== "none" && values.deploy !== "vercel") {
991
+ fail(`Unknown --deploy "${values.deploy}". Supported: vercel, none.`);
992
+ }
993
+ console.log(`→ Creating ${opts.projectName} at ${resolve(process.cwd(), opts.projectName)}`);
994
+ } else {
995
+ // Interactive.
996
+ opts = await runInteractive(allFragments);
997
+ opts.dev = values.dev;
998
+ opts.harness = values.harness;
999
+ }
1000
+
1001
+ const { targetDir, envVars } = await scaffold(opts);
1002
+
1003
+ const hasMocks = opts.template !== "static";
1004
+ const nextSteps = [
1005
+ `cd ${opts.projectName}`,
1006
+ opts.runInstall ? "" : "pnpm install",
1007
+ envVars.length > 0 ? "cp .env.example .env.local # fill in real values" : "",
1008
+ "pnpm dev",
1009
+ hasMocks
1010
+ ? "pnpm dev:mocks # alternative: dev with MSW intercepting fetch (server + browser)"
1011
+ : "",
1012
+ ].filter(Boolean);
1013
+
1014
+ if (target) {
1015
+ console.log(`\n✓ Created ${targetDir}\n`);
1016
+ console.log(`Next steps:\n ${nextSteps.join("\n ")}\n`);
1017
+ } else {
1018
+ p.outro(`Created ${targetDir}\n\nNext steps:\n ${nextSteps.join("\n ")}`);
1019
+ }
1020
+ }
1021
+
1022
+ main().catch((err) => {
1023
+ fail(err instanceof Error ? err.message : String(err));
1024
+ });