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/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
+ });