create-reactor 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/LICENSE +21 -0
- package/README.md +123 -0
- package/create-app.mjs +712 -0
- package/lib/build.mjs +434 -0
- package/lib/pm.mjs +85 -0
- package/lib/presets.mjs +122 -0
- package/lib/templates/ai-docs.mjs +80 -0
- package/lib/templates/app.mjs +961 -0
- package/lib/templates/backend.mjs +715 -0
- package/lib/templates/base.mjs +671 -0
- package/lib/templates/biome.mjs +107 -0
- package/lib/templates/extras.mjs +360 -0
- package/lib/templates/features.mjs +463 -0
- package/lib/templates/quality.mjs +159 -0
- package/lib/templates/readme.mjs +351 -0
- package/lib/templates/security.mjs +70 -0
- package/lib/templates/server.mjs +141 -0
- package/lib/templates/state.mjs +192 -0
- package/package.json +52 -0
package/create-app.mjs
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-reactor — interactive generator for modern React projects.
|
|
3
|
+
//
|
|
4
|
+
// Interactive: node create-app.mjs
|
|
5
|
+
// Non-interactive: node create-app.mjs my-app --pm bun --backend convex --auth clerk \
|
|
6
|
+
// --router tanstack --ai anthropic --extras query,zustand --yes
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
import { PM, detectPackageManagers, run } from "./lib/pm.mjs";
|
|
12
|
+
import {
|
|
13
|
+
ALL_EXTRAS,
|
|
14
|
+
DB_PROVIDERS,
|
|
15
|
+
ESSENTIAL_COMPONENTS,
|
|
16
|
+
EXTRAS,
|
|
17
|
+
LINTER_OPTIONS,
|
|
18
|
+
OPTIONAL_COMPONENTS,
|
|
19
|
+
STATE_OPTIONS,
|
|
20
|
+
buildPlan,
|
|
21
|
+
enrichConfig,
|
|
22
|
+
validateConfig,
|
|
23
|
+
} from "./lib/build.mjs";
|
|
24
|
+
import { PRESETS, PRESET_NAMES } from "./lib/presets.mjs";
|
|
25
|
+
import { prismaFallbackSchema, prismaTaskModel } from "./lib/templates/backend.mjs";
|
|
26
|
+
import { nextSteps } from "./lib/templates/readme.mjs";
|
|
27
|
+
|
|
28
|
+
const VALID = {
|
|
29
|
+
pm: ["bun", "pnpm", "npm"],
|
|
30
|
+
preset: PRESET_NAMES,
|
|
31
|
+
backend: ["convex", "supabase", "hono", "none"],
|
|
32
|
+
orm: ["drizzle", "prisma", "none"],
|
|
33
|
+
db: ["neon", "docker", "turso", "supabase", "other"],
|
|
34
|
+
auth: ["clerk", "better-auth", "convex-auth", "supabase-auth", "none"],
|
|
35
|
+
router: ["tanstack", "react-router", "none"],
|
|
36
|
+
ai: ["anthropic", "openai", "google", "none"],
|
|
37
|
+
state: ["zustand", "jotai", "redux", "none"],
|
|
38
|
+
linter: ["eslint", "biome"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// CLI args
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const args = { _: [] };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
if (a === "--yes" || a === "-y") args.yes = true;
|
|
50
|
+
else if (a === "--no-git") args.git = false;
|
|
51
|
+
else if (a === "--no-install") args.install = false;
|
|
52
|
+
else if (a === "--no-verify") args.verify = false;
|
|
53
|
+
else if (a === "--no-secure") args.secure = false;
|
|
54
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
55
|
+
else if (a.startsWith("--")) {
|
|
56
|
+
const key = a.slice(2);
|
|
57
|
+
const val = argv[i + 1];
|
|
58
|
+
if (val !== undefined && !val.startsWith("--")) {
|
|
59
|
+
args[key] = val;
|
|
60
|
+
i++;
|
|
61
|
+
} else {
|
|
62
|
+
args[key] = true;
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
args._.push(a);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printHelp() {
|
|
72
|
+
console.log(`
|
|
73
|
+
${pc.bold("create-reactor")} — scaffold a modern React app in one command
|
|
74
|
+
|
|
75
|
+
${pc.bold("Usage:")}
|
|
76
|
+
node create-app.mjs [project-name] [options]
|
|
77
|
+
|
|
78
|
+
${pc.bold("Options:")}
|
|
79
|
+
--preset <minimal|saas|fullstack|ai|everything|custom>
|
|
80
|
+
Start from a preset (flags can override pieces)
|
|
81
|
+
--pm <bun|pnpm|npm> Package manager
|
|
82
|
+
--backend <convex|supabase|hono|none> Backend (hono = Hono + tRPC API server)
|
|
83
|
+
--state <zustand|jotai|redux|none> State management
|
|
84
|
+
--linter <eslint|biome> Linter/formatter (biome = Rust-based, single tool)
|
|
85
|
+
--orm <drizzle|prisma|none> ORM (non-Convex backends)
|
|
86
|
+
--db <neon|docker|turso|supabase|other> Database provider (when using an ORM)
|
|
87
|
+
--auth <clerk|better-auth|convex-auth|supabase-auth|none>
|
|
88
|
+
--router <tanstack|react-router|none> Routing
|
|
89
|
+
--ai <anthropic|openai|google|none> AI SDK provider
|
|
90
|
+
--components <a,b,c> Extra shadcn/ui components
|
|
91
|
+
--extras <a,b,c | all> Extra libraries, features & tooling:
|
|
92
|
+
query, table, forms, charts, motion, gsap, editor,
|
|
93
|
+
maps, dates, nuqs, redis, stripe, resend, posthog,
|
|
94
|
+
i18n, pwa, deploy, testing, e2e, msw, storybook,
|
|
95
|
+
prettier, husky, ci, sentry, fallow, knip
|
|
96
|
+
--no-secure Skip supply-chain protection (7-day package cooldown)
|
|
97
|
+
--no-git Skip git init
|
|
98
|
+
--no-install Skip dependency install
|
|
99
|
+
--no-verify Skip the verification build
|
|
100
|
+
-y, --yes Accept defaults, no prompts
|
|
101
|
+
|
|
102
|
+
${pc.bold("Examples:")}
|
|
103
|
+
node create-app.mjs ${pc.dim("# fully interactive")}
|
|
104
|
+
node create-app.mjs my-app --yes ${pc.dim("# defaults: bun + convex + clerk + tanstack")}
|
|
105
|
+
node create-app.mjs my-app --backend supabase --orm drizzle --auth supabase-auth --yes
|
|
106
|
+
`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Prompt helpers
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function unwrap(result) {
|
|
114
|
+
if (p.isCancel(result)) {
|
|
115
|
+
p.cancel("Cancelled — nothing was created.");
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolve an option: CLI flag wins, then --yes default, then interactive prompt. */
|
|
122
|
+
async function resolve(flagValue, validValues, defaultValue, isYes, promptFn) {
|
|
123
|
+
if (flagValue !== undefined) {
|
|
124
|
+
if (!validValues.includes(flagValue)) {
|
|
125
|
+
p.cancel(`Invalid value "${flagValue}". Expected one of: ${validValues.join(", ")}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
return flagValue;
|
|
129
|
+
}
|
|
130
|
+
if (isYes) return defaultValue;
|
|
131
|
+
return unwrap(await promptFn());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Step runner
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function runStep(spinner, label, cmd, args, opts, { fatal = true, retries = 0 } = {}) {
|
|
139
|
+
spinner.start(label);
|
|
140
|
+
let r = run(cmd, args, opts);
|
|
141
|
+
// Retry transient failures (e.g. registry/cache races during installs)
|
|
142
|
+
for (let attempt = 0; !r.ok && attempt < retries; attempt++) {
|
|
143
|
+
spinner.message(`${label} (retry ${attempt + 1}/${retries})`);
|
|
144
|
+
r = run(cmd, args, opts);
|
|
145
|
+
}
|
|
146
|
+
if (r.ok) {
|
|
147
|
+
spinner.stop(`${label} ${pc.green("✓")}`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
spinner.stop(`${label} ${fatal ? pc.red("✗") : pc.yellow("⚠ (skipped)")}`);
|
|
151
|
+
const output = (r.stderr || r.stdout || String(r.error ?? "unknown error"))
|
|
152
|
+
.split("\n")
|
|
153
|
+
.slice(-25)
|
|
154
|
+
.join("\n")
|
|
155
|
+
.trim();
|
|
156
|
+
if (fatal) {
|
|
157
|
+
p.log.error(`Command failed: ${cmd} ${args.join(" ")}\n\n${output}`);
|
|
158
|
+
p.cancel("Aborted. The partially-created project folder was left in place for inspection.");
|
|
159
|
+
process.exit(1);
|
|
160
|
+
} else {
|
|
161
|
+
p.log.warn(`Optional step failed (you can do it manually later):\n ${cmd} ${args.join(" ")}\n\n${output}`);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Main
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async function main() {
|
|
171
|
+
const argv = parseArgs(process.argv.slice(2));
|
|
172
|
+
if (argv.help) {
|
|
173
|
+
printHelp();
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
const isYes = Boolean(argv.yes);
|
|
177
|
+
|
|
178
|
+
console.log();
|
|
179
|
+
p.intro(pc.bgCyan(pc.black(" create-reactor ")));
|
|
180
|
+
|
|
181
|
+
// --- package managers available on this machine
|
|
182
|
+
const detected = detectPackageManagers();
|
|
183
|
+
if (detected.length === 0) {
|
|
184
|
+
p.cancel("No package manager found. Install bun (https://bun.sh), pnpm, or npm first.");
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- project name
|
|
189
|
+
let name = argv._[0];
|
|
190
|
+
if (!name && isYes) name = "my-app";
|
|
191
|
+
if (!name) {
|
|
192
|
+
name = unwrap(
|
|
193
|
+
await p.text({
|
|
194
|
+
message: "Project name",
|
|
195
|
+
placeholder: "my-app",
|
|
196
|
+
defaultValue: "my-app",
|
|
197
|
+
validate: (v) => {
|
|
198
|
+
if (v && !/^[a-z0-9][a-z0-9._-]*$/i.test(v)) {
|
|
199
|
+
return "Use letters, numbers, dots, dashes and underscores (no spaces or slashes)";
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) {
|
|
206
|
+
p.cancel(`Invalid project name "${name}".`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- target directory
|
|
211
|
+
const targetDir = path.resolve(process.cwd(), name);
|
|
212
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
213
|
+
if (isYes) {
|
|
214
|
+
p.cancel(`Directory ${name}/ already exists and is not empty.`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
const overwrite = unwrap(
|
|
218
|
+
await p.confirm({
|
|
219
|
+
message: `Directory ${pc.bold(name)}/ already exists and is not empty. Delete it and continue?`,
|
|
220
|
+
initialValue: false,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
if (!overwrite) {
|
|
224
|
+
p.cancel("Cancelled — nothing was created.");
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- preset
|
|
231
|
+
let preset = argv.preset;
|
|
232
|
+
if (preset !== undefined && !PRESET_NAMES.includes(preset)) {
|
|
233
|
+
p.cancel(`Invalid preset "${preset}". Valid: ${PRESET_NAMES.join(", ")}`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
if (!preset) {
|
|
237
|
+
const granularFlags = ["backend", "orm", "db", "auth", "router", "ai", "state", "components", "extras"];
|
|
238
|
+
const hasGranularFlags = granularFlags.some((f) => argv[f] !== undefined);
|
|
239
|
+
if (hasGranularFlags || isYes) {
|
|
240
|
+
preset = "custom";
|
|
241
|
+
} else {
|
|
242
|
+
preset = unwrap(
|
|
243
|
+
await p.select({
|
|
244
|
+
message: "Start from a preset?",
|
|
245
|
+
initialValue: "custom",
|
|
246
|
+
options: PRESET_NAMES.map((key) => ({
|
|
247
|
+
value: key,
|
|
248
|
+
label: PRESETS[key].label,
|
|
249
|
+
hint: PRESETS[key].hint,
|
|
250
|
+
})),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const presetConfig = PRESETS[preset]?.config ?? null;
|
|
256
|
+
|
|
257
|
+
// --- package manager (always asked, even with a preset)
|
|
258
|
+
const pmDefault = detected.includes("bun") ? "bun" : detected[0];
|
|
259
|
+
const pm = await resolve(argv.pm, VALID.pm, pmDefault, isYes, () =>
|
|
260
|
+
p.select({
|
|
261
|
+
message: "Package manager",
|
|
262
|
+
initialValue: pmDefault,
|
|
263
|
+
options: VALID.pm.map((m) => ({
|
|
264
|
+
value: m,
|
|
265
|
+
label: m,
|
|
266
|
+
hint: detected.includes(m) ? (m === "bun" ? "fastest" : undefined) : "not installed!",
|
|
267
|
+
})),
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
if (!detected.includes(pm)) {
|
|
271
|
+
p.cancel(`${pm} is not installed on this machine. Install it first or pick another package manager.`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
// Stack configuration: from the preset, or asked question by question
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
let backend, orm, dbProvider, auth, router, ai, stateLib, linter, components, extras;
|
|
279
|
+
|
|
280
|
+
if (presetConfig) {
|
|
281
|
+
// Preset values, individually overridable by CLI flags
|
|
282
|
+
const flagOr = (flagValue, validValues, presetValue) => {
|
|
283
|
+
if (flagValue === undefined) return presetValue;
|
|
284
|
+
if (!validValues.includes(flagValue)) {
|
|
285
|
+
p.cancel(`Invalid value "${flagValue}". Expected one of: ${validValues.join(", ")}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
return flagValue;
|
|
289
|
+
};
|
|
290
|
+
backend = flagOr(argv.backend, VALID.backend, presetConfig.backend);
|
|
291
|
+
orm = flagOr(argv.orm, VALID.orm, presetConfig.orm);
|
|
292
|
+
dbProvider = flagOr(argv.db, VALID.db.concat("none"), presetConfig.dbProvider);
|
|
293
|
+
auth = flagOr(argv.auth, VALID.auth, presetConfig.auth);
|
|
294
|
+
router = flagOr(argv.router, VALID.router, presetConfig.router);
|
|
295
|
+
ai = flagOr(argv.ai, VALID.ai, presetConfig.ai);
|
|
296
|
+
stateLib = flagOr(argv.state, VALID.state, presetConfig.state);
|
|
297
|
+
linter = flagOr(argv.linter, VALID.linter, presetConfig.linter ?? "eslint");
|
|
298
|
+
components = [...new Set([...ESSENTIAL_COMPONENTS, ...presetConfig.components])];
|
|
299
|
+
if (argv.components !== undefined) {
|
|
300
|
+
components = [
|
|
301
|
+
...new Set([
|
|
302
|
+
...ESSENTIAL_COMPONENTS,
|
|
303
|
+
...String(argv.components).split(",").map((s) => s.trim()).filter(Boolean),
|
|
304
|
+
]),
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
extras = presetConfig.extras === "all" ? [...ALL_EXTRAS] : [...presetConfig.extras];
|
|
308
|
+
if (argv.extras !== undefined) {
|
|
309
|
+
const parsed = String(argv.extras).split(",").map((s) => s.trim()).filter(Boolean);
|
|
310
|
+
extras = parsed.includes("all") ? [...ALL_EXTRAS] : parsed;
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
({ backend, orm, dbProvider, auth, router, ai, stateLib, linter, components, extras } =
|
|
314
|
+
await askStackQuestions(argv, isYes));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Legacy flag support: --extras zustand -> --state zustand
|
|
318
|
+
if (extras.includes("zustand")) {
|
|
319
|
+
extras = extras.filter((e) => e !== "zustand");
|
|
320
|
+
if (!stateLib || stateLib === "none") stateLib = "zustand";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The TanStack Table demo is built on the shadcn table component
|
|
324
|
+
if (extras.includes("table") && !components.includes("table")) {
|
|
325
|
+
components.push("table");
|
|
326
|
+
}
|
|
327
|
+
// MSW needs Vitest
|
|
328
|
+
if (extras.includes("msw") && !extras.includes("testing")) {
|
|
329
|
+
extras.push("testing");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- old inline questions moved into askStackQuestions() below
|
|
333
|
+
void 0;
|
|
334
|
+
async function askStackQuestions(argv, isYes) {
|
|
335
|
+
// --- backend
|
|
336
|
+
const backend = await resolve(argv.backend, VALID.backend, "convex", isYes, () =>
|
|
337
|
+
p.select({
|
|
338
|
+
message: "Backend",
|
|
339
|
+
initialValue: "convex",
|
|
340
|
+
options: [
|
|
341
|
+
{ value: "convex", label: "Convex", hint: "realtime DB + server functions, zero infra" },
|
|
342
|
+
{ value: "supabase", label: "Supabase", hint: "hosted Postgres + auth + storage" },
|
|
343
|
+
{ value: "hono", label: "Hono + tRPC", hint: "your own typed API server (Node)" },
|
|
344
|
+
{ value: "none", label: "None", hint: "frontend only, add a backend later" },
|
|
345
|
+
],
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// --- ORM (not applicable to Convex — it has its own database)
|
|
350
|
+
let orm = "none";
|
|
351
|
+
if (backend !== "convex") {
|
|
352
|
+
const ormDefault = backend === "none" ? "none" : "drizzle";
|
|
353
|
+
orm = await resolve(argv.orm, VALID.orm, ormDefault, isYes, () =>
|
|
354
|
+
p.select({
|
|
355
|
+
message: "ORM for your database",
|
|
356
|
+
initialValue: ormDefault,
|
|
357
|
+
options: [
|
|
358
|
+
{ value: "drizzle", label: "Drizzle ORM", hint: "lightweight, SQL-like, fast" },
|
|
359
|
+
{ value: "prisma", label: "Prisma", hint: "schema-first, rich tooling" },
|
|
360
|
+
{ value: "none", label: "None", hint: backend === "supabase" ? "use the Supabase JS client only" : "no database" },
|
|
361
|
+
],
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
} else if (argv.orm && argv.orm !== "none") {
|
|
365
|
+
p.log.warn("Ignoring --orm: Convex has its own database, Drizzle/Prisma don't apply.");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- database provider (only when an ORM was chosen)
|
|
369
|
+
let dbProvider = "none";
|
|
370
|
+
if (orm !== "none") {
|
|
371
|
+
if (backend === "supabase") {
|
|
372
|
+
// The Supabase project IS the Postgres database
|
|
373
|
+
dbProvider = "supabase";
|
|
374
|
+
} else {
|
|
375
|
+
const validProviders = DB_PROVIDERS[orm];
|
|
376
|
+
const providerOptions = [
|
|
377
|
+
{ value: "neon", label: "Neon", hint: "serverless Postgres, generous free tier" },
|
|
378
|
+
{ value: "docker", label: "Local Postgres (Docker)", hint: "docker-compose.yml included" },
|
|
379
|
+
...(orm === "drizzle"
|
|
380
|
+
? [{ value: "turso", label: "Turso", hint: "SQLite at the edge (libSQL)" }]
|
|
381
|
+
: []),
|
|
382
|
+
{ value: "other", label: "Other Postgres", hint: "any DATABASE_URL (Railway, RDS, ...)" },
|
|
383
|
+
];
|
|
384
|
+
dbProvider = await resolve(argv.db, validProviders, "neon", isYes, () =>
|
|
385
|
+
p.select({
|
|
386
|
+
message: "Database provider",
|
|
387
|
+
initialValue: "neon",
|
|
388
|
+
options: providerOptions,
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
} else if (argv.db) {
|
|
393
|
+
p.log.warn("Ignoring --db: a database provider only applies when an ORM is selected.");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- auth
|
|
397
|
+
const authOptions = {
|
|
398
|
+
convex: [
|
|
399
|
+
{ value: "clerk", label: "Clerk", hint: "hosted auth UI, integrates natively with Convex" },
|
|
400
|
+
{ value: "better-auth", label: "Better Auth", hint: "open-source, self-hosted, fastest-growing" },
|
|
401
|
+
{ value: "convex-auth", label: "Convex Auth", hint: "built into Convex, no third-party account" },
|
|
402
|
+
{ value: "none", label: "None", hint: "add auth later" },
|
|
403
|
+
],
|
|
404
|
+
supabase: [
|
|
405
|
+
{ value: "supabase-auth", label: "Supabase Auth", hint: "built into Supabase" },
|
|
406
|
+
{ value: "clerk", label: "Clerk", hint: "hosted auth UI" },
|
|
407
|
+
{ value: "none", label: "None", hint: "add auth later" },
|
|
408
|
+
],
|
|
409
|
+
hono: [
|
|
410
|
+
{ value: "clerk", label: "Clerk", hint: "hosted auth UI" },
|
|
411
|
+
{ value: "none", label: "None", hint: "add auth later" },
|
|
412
|
+
],
|
|
413
|
+
none: [
|
|
414
|
+
{ value: "clerk", label: "Clerk", hint: "hosted auth UI" },
|
|
415
|
+
{ value: "none", label: "None", hint: "add auth later" },
|
|
416
|
+
],
|
|
417
|
+
}[backend];
|
|
418
|
+
const authDefault = authOptions[0].value;
|
|
419
|
+
const auth = await resolve(argv.auth, VALID.auth, authDefault, isYes, () =>
|
|
420
|
+
p.select({
|
|
421
|
+
message: "Authentication",
|
|
422
|
+
initialValue: authDefault,
|
|
423
|
+
options: authOptions,
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// --- router
|
|
428
|
+
const router = await resolve(argv.router, VALID.router, "tanstack", isYes, () =>
|
|
429
|
+
p.select({
|
|
430
|
+
message: "Routing",
|
|
431
|
+
initialValue: "tanstack",
|
|
432
|
+
options: [
|
|
433
|
+
{ value: "tanstack", label: "TanStack Router", hint: "type-safe, file-based routes" },
|
|
434
|
+
{ value: "react-router", label: "React Router", hint: "classic declarative routing" },
|
|
435
|
+
{ value: "none", label: "None", hint: "single page, add routing later" },
|
|
436
|
+
],
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// --- state management
|
|
441
|
+
const stateLib = await resolve(argv.state, VALID.state, "none", isYes, () =>
|
|
442
|
+
p.select({
|
|
443
|
+
message: "State management",
|
|
444
|
+
initialValue: "zustand",
|
|
445
|
+
options: STATE_OPTIONS,
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// --- linter / formatter
|
|
450
|
+
const linter = await resolve(argv.linter, VALID.linter, "eslint", isYes, () =>
|
|
451
|
+
p.select({
|
|
452
|
+
message: "Linter & formatter",
|
|
453
|
+
initialValue: "eslint",
|
|
454
|
+
options: LINTER_OPTIONS,
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// --- shadcn components
|
|
459
|
+
let components;
|
|
460
|
+
if (argv.components !== undefined) {
|
|
461
|
+
components = String(argv.components).split(",").map((s) => s.trim()).filter(Boolean);
|
|
462
|
+
} else if (isYes) {
|
|
463
|
+
components = ["label", "dialog", "sonner"];
|
|
464
|
+
} else {
|
|
465
|
+
components = unwrap(
|
|
466
|
+
await p.multiselect({
|
|
467
|
+
message: `shadcn/ui components (${ESSENTIAL_COMPONENTS.join(", ")} are always included)`,
|
|
468
|
+
options: OPTIONAL_COMPONENTS.map((comp) => ({ value: comp, label: comp })),
|
|
469
|
+
initialValues: ["label", "dialog", "sonner"],
|
|
470
|
+
required: false,
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
components = [...new Set([...ESSENTIAL_COMPONENTS, ...components])];
|
|
475
|
+
|
|
476
|
+
// --- AI SDK
|
|
477
|
+
const ai = await resolve(argv.ai, VALID.ai, "none", isYes, () =>
|
|
478
|
+
p.select({
|
|
479
|
+
message: "AI SDK (Vercel) — LLM chat + text generation",
|
|
480
|
+
initialValue: "none",
|
|
481
|
+
options: [
|
|
482
|
+
{ value: "none", label: "Skip" },
|
|
483
|
+
{ value: "anthropic", label: "Anthropic (Claude)" },
|
|
484
|
+
{ value: "openai", label: "OpenAI (GPT)" },
|
|
485
|
+
{ value: "google", label: "Google (Gemini)" },
|
|
486
|
+
],
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// --- extras (app libraries + tooling), asked as two grouped multiselects
|
|
491
|
+
let extras;
|
|
492
|
+
if (argv.extras !== undefined) {
|
|
493
|
+
extras = String(argv.extras).split(",").map((s) => s.trim()).filter(Boolean);
|
|
494
|
+
if (extras.includes("all")) {
|
|
495
|
+
extras = [...ALL_EXTRAS];
|
|
496
|
+
} else {
|
|
497
|
+
const bad = extras.filter((e) => !ALL_EXTRAS.includes(e));
|
|
498
|
+
if (bad.length) {
|
|
499
|
+
p.cancel(`Invalid extras: ${bad.join(", ")}. Valid: all, ${ALL_EXTRAS.join(", ")}`);
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
} else if (isYes) {
|
|
504
|
+
extras = [];
|
|
505
|
+
} else {
|
|
506
|
+
const libraries = unwrap(
|
|
507
|
+
await p.multiselect({
|
|
508
|
+
message: "App libraries",
|
|
509
|
+
options: EXTRAS.libraries,
|
|
510
|
+
initialValues: ["query"],
|
|
511
|
+
required: false,
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
const tooling = unwrap(
|
|
515
|
+
await p.multiselect({
|
|
516
|
+
message: "Tooling & quality",
|
|
517
|
+
options: EXTRAS.tooling,
|
|
518
|
+
initialValues: ["testing", "prettier"],
|
|
519
|
+
required: false,
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
extras = [...libraries, ...tooling];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { backend, orm, dbProvider, auth, router, ai, stateLib, linter, components, extras };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- git / install / security
|
|
529
|
+
const git = argv.git !== false && (isYes || argv.git === true || unwrap(await p.confirm({ message: "Initialize a git repository?", initialValue: true })));
|
|
530
|
+
const install = argv.install !== false && (isYes || argv.install === true || unwrap(await p.confirm({ message: "Install dependencies now?", initialValue: true })));
|
|
531
|
+
const verify = argv.verify !== false;
|
|
532
|
+
const secure = argv.secure !== false;
|
|
533
|
+
|
|
534
|
+
// --- assemble + validate config
|
|
535
|
+
const config = enrichConfig({
|
|
536
|
+
name,
|
|
537
|
+
pm,
|
|
538
|
+
backend,
|
|
539
|
+
orm,
|
|
540
|
+
dbProvider,
|
|
541
|
+
auth,
|
|
542
|
+
router,
|
|
543
|
+
ai,
|
|
544
|
+
state: stateLib ?? "none",
|
|
545
|
+
linter: linter ?? "eslint",
|
|
546
|
+
components,
|
|
547
|
+
extras,
|
|
548
|
+
secure,
|
|
549
|
+
git,
|
|
550
|
+
install,
|
|
551
|
+
});
|
|
552
|
+
const err = validateConfig(config);
|
|
553
|
+
if (err) {
|
|
554
|
+
p.cancel(err);
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- summary
|
|
559
|
+
const dbProviderLabels = {
|
|
560
|
+
neon: "Neon (serverless Postgres)",
|
|
561
|
+
docker: "Local Postgres (Docker)",
|
|
562
|
+
turso: "Turso (SQLite)",
|
|
563
|
+
supabase: "Supabase Postgres",
|
|
564
|
+
other: "Other Postgres",
|
|
565
|
+
none: "None",
|
|
566
|
+
};
|
|
567
|
+
const summary = [
|
|
568
|
+
["Project", name],
|
|
569
|
+
["Folder", targetDir],
|
|
570
|
+
...(preset !== "custom" ? [["Preset", PRESETS[preset].label]] : []),
|
|
571
|
+
["Package manager", pm],
|
|
572
|
+
["Backend", { convex: "Convex", supabase: "Supabase", hono: "Hono + tRPC", none: "None" }[backend]],
|
|
573
|
+
...(backend !== "convex" ? [["ORM", { drizzle: "Drizzle ORM", prisma: "Prisma", none: "None" }[orm]]] : []),
|
|
574
|
+
...(orm !== "none" ? [["Database", dbProviderLabels[dbProvider]]] : []),
|
|
575
|
+
["Auth", { clerk: "Clerk", "better-auth": "Better Auth", "convex-auth": "Convex Auth", "supabase-auth": "Supabase Auth", none: "None" }[auth]],
|
|
576
|
+
["Routing", { tanstack: "TanStack Router", "react-router": "React Router", none: "None" }[router]],
|
|
577
|
+
["State", { zustand: "Zustand", jotai: "Jotai", redux: "Redux Toolkit", none: "None" }[stateLib ?? "none"]],
|
|
578
|
+
["Linting", { eslint: "ESLint" + (extras.includes("prettier") ? " + Prettier" : ""), biome: "Biome (Rust)" }[linter ?? "eslint"]],
|
|
579
|
+
["UI", `Tailwind v4 + shadcn/ui (${components.join(", ")})`],
|
|
580
|
+
["AI SDK", { anthropic: "Anthropic (Claude)", openai: "OpenAI (GPT)", google: "Google (Gemini)", none: "None" }[ai]],
|
|
581
|
+
["Extras", extras.length ? extras.join(", ") : "None"],
|
|
582
|
+
["Security", secure ? "7-day package cooldown (supply-chain protection)" : pc.yellow("disabled (--no-secure)")],
|
|
583
|
+
];
|
|
584
|
+
p.note(summary.map(([k, v]) => `${pc.dim(k.padEnd(17))}${v}`).join("\n"), "Your stack");
|
|
585
|
+
|
|
586
|
+
if (!isYes) {
|
|
587
|
+
const ok = unwrap(await p.confirm({ message: "Create the project?", initialValue: true }));
|
|
588
|
+
if (!ok) {
|
|
589
|
+
p.cancel("Cancelled — nothing was created.");
|
|
590
|
+
process.exit(0);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// -------------------------------------------------------------------------
|
|
595
|
+
// Execute
|
|
596
|
+
// -------------------------------------------------------------------------
|
|
597
|
+
const plan = buildPlan(config);
|
|
598
|
+
const pmc = PM[pm];
|
|
599
|
+
const spinner = p.spinner();
|
|
600
|
+
|
|
601
|
+
// 1. write files
|
|
602
|
+
spinner.start("Writing project files");
|
|
603
|
+
for (const [rel, content] of plan.files) {
|
|
604
|
+
const abs = path.join(targetDir, rel);
|
|
605
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
606
|
+
fs.writeFileSync(abs, content, "utf8");
|
|
607
|
+
}
|
|
608
|
+
spinner.stop(`Wrote ${plan.files.size} files ${pc.green("✓")}`);
|
|
609
|
+
|
|
610
|
+
// 2. git init early — Husky's prepare script needs an existing repo
|
|
611
|
+
let gitInitialized = false;
|
|
612
|
+
if (git) {
|
|
613
|
+
gitInitialized = run("git", ["init"], { cwd: targetDir }).ok;
|
|
614
|
+
if (!gitInitialized) {
|
|
615
|
+
p.log.warn("git init failed — continuing without a repository.");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (install) {
|
|
620
|
+
// 2. install dependencies (1 retry: registry hiccups and metadata-cache races are transient)
|
|
621
|
+
const [addCmd, addArgs] = pmc.add(plan.deps);
|
|
622
|
+
runStep(spinner, `Installing ${plan.deps.length} dependencies (${pm})`, addCmd, addArgs, { cwd: targetDir }, { retries: 1 });
|
|
623
|
+
|
|
624
|
+
const [devCmd, devArgs] = pmc.addDev(plan.devDeps);
|
|
625
|
+
runStep(spinner, `Installing ${plan.devDeps.length} dev dependencies (${pm})`, devCmd, devArgs, { cwd: targetDir }, { retries: 1 });
|
|
626
|
+
|
|
627
|
+
// 3. shadcn/ui components (CLI fetches from registry + installs radix deps)
|
|
628
|
+
const [shadCmd, shadArgs] = pmc.dlx("shadcn@latest", ["add", ...components, "--yes", "--overwrite"]);
|
|
629
|
+
runStep(spinner, `Adding ${components.length} shadcn/ui components`, shadCmd, shadArgs, { cwd: targetDir }, { fatal: false });
|
|
630
|
+
|
|
631
|
+
// 4. prisma init (matches whatever Prisma version was installed)
|
|
632
|
+
if (orm === "prisma") {
|
|
633
|
+
const [priCmd, priArgs] = pmc.dlx("prisma", ["init", "--datasource-provider", "postgresql"]);
|
|
634
|
+
runStep(spinner, "Initializing Prisma", priCmd, priArgs, { cwd: targetDir }, { fatal: false });
|
|
635
|
+
const schemaPath = path.join(targetDir, "prisma", "schema.prisma");
|
|
636
|
+
if (fs.existsSync(schemaPath)) {
|
|
637
|
+
fs.appendFileSync(schemaPath, prismaTaskModel(), "utf8");
|
|
638
|
+
} else {
|
|
639
|
+
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
|
640
|
+
fs.writeFileSync(schemaPath, prismaFallbackSchema(), "utf8");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// 4b. Storybook (its own CLI sets up .storybook/, stories and deps)
|
|
645
|
+
if (extras.includes("storybook")) {
|
|
646
|
+
const [sbCmd, sbArgs] = pmc.dlx("storybook@latest", ["init", "--yes", "--no-dev"]);
|
|
647
|
+
runStep(spinner, "Setting up Storybook (this one takes a while)", sbCmd, sbArgs, { cwd: targetDir }, { fatal: false });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 5. husky git hooks (needs the repo from step 2 + husky installed by step 3)
|
|
651
|
+
if (extras.includes("husky") && gitInitialized) {
|
|
652
|
+
// Add the prepare script now — putting it in package.json before install
|
|
653
|
+
// would make the install itself fail (husky doesn't exist yet at that point).
|
|
654
|
+
const pkgPath = path.join(targetDir, "package.json");
|
|
655
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
656
|
+
pkg.scripts = { ...pkg.scripts, prepare: "husky" };
|
|
657
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
658
|
+
|
|
659
|
+
const [prepCmd, prepArgs] = pmc.run("prepare");
|
|
660
|
+
runStep(spinner, "Setting up git hooks (husky)", prepCmd, prepArgs, { cwd: targetDir }, { fatal: false });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// 6. verification: build (+ tests when selected)
|
|
664
|
+
if (verify) {
|
|
665
|
+
const [buildCmd, buildArgs] = pmc.run("build");
|
|
666
|
+
const ok = runStep(spinner, "Verifying the project builds", buildCmd, buildArgs, { cwd: targetDir }, { fatal: false });
|
|
667
|
+
if (ok) {
|
|
668
|
+
// clean up build output, keep the project pristine
|
|
669
|
+
fs.rmSync(path.join(targetDir, "dist"), { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
if (extras.includes("testing")) {
|
|
672
|
+
const [testCmd, testArgs] = pmc.run("test");
|
|
673
|
+
runStep(spinner, "Running the sample tests", testCmd, testArgs, { cwd: targetDir }, { fatal: false });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
} else if (orm === "prisma") {
|
|
677
|
+
// no install -> write the fallback schema so the project is complete
|
|
678
|
+
const schemaPath = path.join(targetDir, "prisma", "schema.prisma");
|
|
679
|
+
fs.mkdirSync(path.dirname(schemaPath), { recursive: true });
|
|
680
|
+
fs.writeFileSync(schemaPath, prismaFallbackSchema(), "utf8");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 7. initial commit
|
|
684
|
+
if (gitInitialized) {
|
|
685
|
+
spinner.start("Creating initial commit");
|
|
686
|
+
run("git", ["add", "-A"], { cwd: targetDir });
|
|
687
|
+
// Fall back to a local identity so the initial commit works even when
|
|
688
|
+
// git has no global user.name/user.email configured.
|
|
689
|
+
const hasIdentity = run("git", ["config", "user.email"], { cwd: targetDir }).ok;
|
|
690
|
+
const idFlags = hasIdentity
|
|
691
|
+
? []
|
|
692
|
+
: ["-c", "user.name=create-reactor", "-c", "user.email=create-reactor@localhost"];
|
|
693
|
+
const gitOk = run("git", [...idFlags, "commit", "-m", "Initial commit from create-reactor"], { cwd: targetDir }).ok;
|
|
694
|
+
spinner.stop(gitOk ? `Created git repository + initial commit ${pc.green("✓")}` : `Initial commit ${pc.yellow("⚠")} skipped (repo is initialized — commit manually)`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// -------------------------------------------------------------------------
|
|
698
|
+
// Next steps
|
|
699
|
+
// -------------------------------------------------------------------------
|
|
700
|
+
const steps = nextSteps(config);
|
|
701
|
+
const stepsText = steps
|
|
702
|
+
.map((s, i) => `${pc.bold(`${i + 1}. ${s.title}`)}\n${s.details.map((d) => ` ${pc.cyan(d)}`).join("\n")}`)
|
|
703
|
+
.join("\n\n");
|
|
704
|
+
p.note(stepsText, "Next steps");
|
|
705
|
+
|
|
706
|
+
p.outro(`${pc.green("Done!")} Project created at ${pc.bold(targetDir)} — full checklist in its README.md`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
main().catch((err) => {
|
|
710
|
+
p.log.error(String(err?.stack ?? err));
|
|
711
|
+
process.exit(1);
|
|
712
|
+
});
|