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.
- package/README.md +89 -0
- package/bin/index.js +1024 -0
- package/package.json +43 -0
- package/scaffold-fragments/billing/files/app/api/billing/webhook/route.ts +5 -0
- package/scaffold-fragments/billing/module.json +31 -0
- package/scaffold-fragments/billing/runtime/config.ts +8 -0
- package/scaffold-fragments/billing/runtime/exports.ts +6 -0
- package/scaffold-fragments/billing/runtime/imports.ts +6 -0
- package/scaffold-fragments/billing/runtime/ports.ts +3 -0
- package/scaffold-fragments/cms/module.json +29 -0
- package/scaffold-fragments/cms/runtime/config.ts +4 -0
- package/scaffold-fragments/cms/runtime/exports.ts +1 -0
- package/scaffold-fragments/cms/runtime/imports.ts +1 -0
- package/scaffold-fragments/email/module.json +30 -0
- package/scaffold-fragments/email/runtime/config.ts +1 -0
- package/scaffold-fragments/email/runtime/imports.ts +1 -0
- package/scaffold-fragments/email/runtime/ports.ts +1 -0
- package/scaffold-fragments/feature-flags/files/app/lib/flags.ts +48 -0
- package/scaffold-fragments/feature-flags/module.json +66 -0
- package/scaffold-fragments/i18n/files/messages/en-US.json +15 -0
- package/scaffold-fragments/i18n/module.json +9 -0
- package/scaffold-fragments/i18n/runtime/config.ts +7 -0
- package/scaffold-fragments/i18n/runtime/exports.ts +1 -0
- package/scaffold-fragments/i18n/runtime/imports.ts +2 -0
- package/scaffold-fragments/i18n/runtime/ports.ts +1 -0
- package/scaffold-fragments/identity/module.json +30 -0
- package/scaffold-fragments/identity/vendors/better-auth/files/app/api/auth/[...all]/route.ts +15 -0
- package/scaffold-fragments/identity/vendors/better-auth/files/app/lib/auth.ts +20 -0
- package/scaffold-fragments/jobs/module.json +12 -0
- package/scaffold-fragments/jobs/runtime/config.ts +4 -0
- package/scaffold-fragments/jobs/runtime/imports.ts +1 -0
- package/scaffold-fragments/jobs/runtime/ports.ts +1 -0
- package/scaffold-fragments/notifications/module.json +12 -0
- package/scaffold-fragments/notifications/runtime/config.ts +8 -0
- package/scaffold-fragments/notifications/runtime/imports.ts +5 -0
- package/scaffold-fragments/notifications/runtime/ports.ts +1 -0
- package/scaffold-fragments/organizations/module.json +12 -0
- package/scaffold-fragments/telemetry/module.json +43 -0
- package/scaffold-fragments/telemetry/runtime/config.ts +7 -0
- package/scaffold-fragments/telemetry/runtime/imports.ts +7 -0
- package/scaffold-fragments/telemetry/runtime/ports.ts +1 -0
- package/skillex/private/contribute.md +193 -0
- package/skillex/public/consumer.md +157 -0
- package/templates/client/.storybook/main.ts +77 -0
- package/templates/client/.storybook/preview.ts +12 -0
- package/templates/client/CLAUDE.md +50 -0
- package/templates/client/_claude/settings.json +107 -0
- package/templates/client/_github/PULL_REQUEST_TEMPLATE.md +19 -0
- package/templates/client/_github/dependabot.yml +48 -0
- package/templates/client/_github/workflows/ci.yml +104 -0
- package/templates/client/_gitignore +15 -0
- package/templates/client/_npmrc +1 -0
- package/templates/client/app/(app)/layout.tsx +49 -0
- package/templates/client/app/(marketing)/layout.tsx +21 -0
- package/templates/client/app/(marketing)/page.tsx +34 -0
- package/templates/client/app/api/backend/[...path]/route.ts +37 -0
- package/templates/client/app/api/dev/login/route.ts +14 -0
- package/templates/client/app/api/dev/logout/route.ts +18 -0
- package/templates/client/app/globals.css +22 -0
- package/templates/client/app/home-auth-controls.tsx +28 -0
- package/templates/client/app/layout.tsx +38 -0
- package/templates/client/app/lib/backend-client.ts +55 -0
- package/templates/client/app/lib/runtime.test.ts +38 -0
- package/templates/client/app/lib/runtime.ts +54 -0
- package/templates/client/app/protected/page.tsx +31 -0
- package/templates/client/app/theme.override.css +9 -0
- package/templates/client/instrumentation.ts +10 -0
- package/templates/client/mocks/browser.tsx +28 -0
- package/templates/client/mocks/handlers.ts +20 -0
- package/templates/client/mocks/node.ts +7 -0
- package/templates/client/next.config.ts +8 -0
- package/templates/client/oxfmt.config.ts +8 -0
- package/templates/client/oxlint.config.ts +8 -0
- package/templates/client/package.json +61 -0
- package/templates/client/postcss.config.mjs +1 -0
- package/templates/client/public/mockServiceWorker.js +349 -0
- package/templates/client/scripts/build-theme.mjs +18 -0
- package/templates/client/site.spec.ts +58 -0
- package/templates/client/skillex.json +12 -0
- package/templates/client/skills/repo.md +56 -0
- package/templates/client/stylelint.config.mjs +4 -0
- package/templates/client/tsconfig.json +11 -0
- package/templates/client/vitest.config.ts +27 -0
- package/templates/client/vitest.setup.ts +18 -0
- package/templates/full-stack/.storybook/main.ts +77 -0
- package/templates/full-stack/.storybook/preview.ts +12 -0
- package/templates/full-stack/CLAUDE.md +50 -0
- package/templates/full-stack/_claude/settings.json +107 -0
- package/templates/full-stack/_github/PULL_REQUEST_TEMPLATE.md +19 -0
- package/templates/full-stack/_github/dependabot.yml +57 -0
- package/templates/full-stack/_github/workflows/ci.yml +109 -0
- package/templates/full-stack/_github/workflows/db-check.yml +36 -0
- package/templates/full-stack/_github/workflows/db-migrate.yml +39 -0
- package/templates/full-stack/_gitignore +15 -0
- package/templates/full-stack/_npmrc +1 -0
- package/templates/full-stack/app/(app)/layout.tsx +53 -0
- package/templates/full-stack/app/(marketing)/layout.tsx +21 -0
- package/templates/full-stack/app/(marketing)/page.tsx +38 -0
- package/templates/full-stack/app/api/dev/login/route.ts +14 -0
- package/templates/full-stack/app/api/dev/logout/route.ts +18 -0
- package/templates/full-stack/app/api/widgets/route.ts +10 -0
- package/templates/full-stack/app/demo/widgets/page.tsx +86 -0
- package/templates/full-stack/app/globals.css +22 -0
- package/templates/full-stack/app/home-auth-controls.tsx +28 -0
- package/templates/full-stack/app/layout.tsx +38 -0
- package/templates/full-stack/app/lib/runtime.test.ts +38 -0
- package/templates/full-stack/app/lib/runtime.ts +54 -0
- package/templates/full-stack/app/protected/page.tsx +31 -0
- package/templates/full-stack/app/theme.override.css +9 -0
- package/templates/full-stack/db/check.ts +46 -0
- package/templates/full-stack/db/index.ts +109 -0
- package/templates/full-stack/db/migrate.ts +66 -0
- package/templates/full-stack/db/migrations/.gitkeep +0 -0
- package/templates/full-stack/db/schema.ts +20 -0
- package/templates/full-stack/drizzle.config.ts +16 -0
- package/templates/full-stack/instrumentation.ts +10 -0
- package/templates/full-stack/mocks/browser.tsx +28 -0
- package/templates/full-stack/mocks/handlers.ts +34 -0
- package/templates/full-stack/mocks/node.ts +7 -0
- package/templates/full-stack/next.config.ts +8 -0
- package/templates/full-stack/oxfmt.config.ts +8 -0
- package/templates/full-stack/oxlint.config.ts +8 -0
- package/templates/full-stack/package.json +72 -0
- package/templates/full-stack/postcss.config.mjs +1 -0
- package/templates/full-stack/public/mockServiceWorker.js +349 -0
- package/templates/full-stack/scripts/build-theme.mjs +18 -0
- package/templates/full-stack/site.spec.ts +58 -0
- package/templates/full-stack/skillex.json +12 -0
- package/templates/full-stack/skills/repo.md +56 -0
- package/templates/full-stack/stylelint.config.mjs +4 -0
- package/templates/full-stack/tsconfig.json +11 -0
- package/templates/full-stack/vitest.config.ts +42 -0
- package/templates/full-stack/vitest.setup.ts +18 -0
- package/templates/static/.storybook/main.ts +77 -0
- package/templates/static/.storybook/preview.ts +12 -0
- package/templates/static/CLAUDE.md +50 -0
- package/templates/static/_claude/settings.json +107 -0
- package/templates/static/_github/PULL_REQUEST_TEMPLATE.md +18 -0
- package/templates/static/_github/dependabot.yml +43 -0
- package/templates/static/_github/workflows/ci.yml +91 -0
- package/templates/static/_gitignore +15 -0
- package/templates/static/_npmrc +1 -0
- package/templates/static/app/(marketing)/layout.tsx +21 -0
- package/templates/static/app/(marketing)/page.tsx +17 -0
- package/templates/static/app/globals.css +22 -0
- package/templates/static/app/layout.tsx +24 -0
- package/templates/static/app/theme.override.css +9 -0
- package/templates/static/next.config.ts +19 -0
- package/templates/static/oxfmt.config.ts +8 -0
- package/templates/static/oxlint.config.ts +8 -0
- package/templates/static/package.json +53 -0
- package/templates/static/postcss.config.mjs +1 -0
- package/templates/static/scripts/build-theme.mjs +18 -0
- package/templates/static/site.spec.ts +58 -0
- package/templates/static/skillex.json +12 -0
- package/templates/static/skills/repo.md +56 -0
- package/templates/static/stylelint.config.mjs +4 -0
- 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
|
+
});
|