create-next-style-guide 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/addons/ci/.cursor/rules/ci.mdc +11 -0
  2. package/addons/ci/docs/style-guide/ops/ci.md +7 -0
  3. package/addons/docker/.cursor/rules/docker.mdc +12 -0
  4. package/addons/docker/.dockerignore +12 -0
  5. package/addons/docker/docker/production.Dockerfile +53 -0
  6. package/addons/docker/docs/style-guide/ops/docker.md +14 -0
  7. package/addons/docs/data/tanstack-query.md +7 -0
  8. package/addons/docs/examples/api-app-crud.md +94 -0
  9. package/addons/docs/ui/icons.md +5 -0
  10. package/addons/endpoints/.cursor/rules/endpoints.mdc +17 -0
  11. package/addons/endpoints/docs/style-guide/http/endpoints.md +21 -0
  12. package/addons/endpoints/scripts/endpoints.codegen.json +7 -0
  13. package/addons/endpoints/scripts/generate-endpoints.ts +1384 -0
  14. package/addons/endpoints/src/endpoints/Types.ts +105 -0
  15. package/addons/endpoints/src/endpoints/index.ts +2 -0
  16. package/addons/endpoints/src/utils/api-error.ts +181 -0
  17. package/addons/endpoints/src/utils/api-result.ts +13 -0
  18. package/addons/endpoints/src/utils/axios-instance.ts +23 -0
  19. package/addons/endpoints/src/utils/form-data.ts +37 -0
  20. package/addons/endpoints/src/utils/search-params.ts +21 -0
  21. package/addons/eslint/.cursor/rules/eslint.mdc +12 -0
  22. package/addons/eslint/docs/style-guide/tooling/eslint.md +13 -0
  23. package/addons/eslint/eslint.config.mjs +85 -0
  24. package/addons/husky/.husky/pre-commit +2 -0
  25. package/addons/husky/docs/style-guide/tooling/husky.md +10 -0
  26. package/addons/prettier/.cursor/rules/prettier.mdc +11 -0
  27. package/addons/prettier/.prettierrc +14 -0
  28. package/addons/prettier/docs/style-guide/tooling/prettier.md +9 -0
  29. package/dist/index.js +3848 -0
  30. package/dist/index.js.map +1 -0
  31. package/package.json +38 -4
  32. package/template/.cursor/rules/components.mdc +27 -0
  33. package/template/.cursor/rules/core.mdc +16 -0
  34. package/template/.cursor/rules/forms.mdc +16 -0
  35. package/template/.env.example +6 -0
  36. package/template/AGENTS.md +38 -0
  37. package/template/README.md +28 -0
  38. package/template/docs/style-guide/README.md +11 -0
  39. package/template/docs/style-guide/http/client.md +9 -0
  40. package/template/docs/style-guide/overview/architecture.md +21 -0
  41. package/template/docs/style-guide/overview/project-structure.md +36 -0
  42. package/template/eslint.config.mjs +13 -0
  43. package/template/gitignore +39 -0
  44. package/template/next-env.d.ts +4 -0
  45. package/template/next.config.ts +7 -0
  46. package/template/package.json +27 -0
  47. package/template/src/app/_components/welcome-card/index.tsx +24 -0
  48. package/template/src/app/globals.css +84 -0
  49. package/template/src/app/layout.tsx +22 -0
  50. package/template/src/app/page.tsx +25 -0
  51. package/template/src/components/.gitkeep +0 -0
  52. package/template/src/constants/.gitkeep +0 -0
  53. package/template/src/hooks/.gitkeep +0 -0
  54. package/template/src/lib/env.ts +17 -0
  55. package/template/src/lib/http.ts +28 -0
  56. package/template/src/lib/utils.ts +6 -0
  57. package/template/src/providers/.gitkeep +0 -0
  58. package/template/src/types/.gitkeep +0 -0
  59. package/template/src/utils/.gitkeep +0 -0
  60. package/template/tsconfig.json +23 -0
  61. package/README.md +0 -3
package/dist/index.js ADDED
@@ -0,0 +1,3848 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import * as p4 from "@clack/prompts";
5
+ import color4 from "picocolors";
6
+ import path30 from "path";
7
+
8
+ // src/constants.ts
9
+ import path from "path";
10
+ import { fileURLToPath } from "url";
11
+ var here = path.dirname(fileURLToPath(import.meta.url));
12
+ var TEMPLATE_DIR = path.resolve(here, "../template");
13
+ var ADDONS_DIR = path.resolve(here, "../addons");
14
+ var TITLE = "Next Style Guide";
15
+ var PKG_NAME = "create-next-style-guide";
16
+
17
+ // src/helpers/package-manager.ts
18
+ function joinArgs(args) {
19
+ return args?.length ? ` ${args.join(" ")}` : "";
20
+ }
21
+ var PACKAGE_MANAGERS = {
22
+ pnpm: {
23
+ id: "pnpm",
24
+ packageManagerField: "pnpm@9.15.0",
25
+ lockfile: "pnpm-lock.yaml",
26
+ installArgs: ["install", "--ignore-workspace"],
27
+ runScript: (script, args) => `pnpm ${script}${joinArgs(args)}`,
28
+ exec: (bin, args) => `pnpm exec ${bin}${joinArgs(args)}`
29
+ },
30
+ npm: {
31
+ id: "npm",
32
+ packageManagerField: null,
33
+ lockfile: "package-lock.json",
34
+ // --install-strategy=hoisted keeps the app isolated if nested under another workspace tree
35
+ installArgs: ["install", "--install-strategy=hoisted"],
36
+ runScript: (script, args) => `npm run ${script}${joinArgs(args)}`,
37
+ exec: (bin, args) => `npx ${bin}${joinArgs(args)}`
38
+ },
39
+ yarn: {
40
+ id: "yarn",
41
+ packageManagerField: "yarn@1.22.22",
42
+ lockfile: "yarn.lock",
43
+ installArgs: ["install"],
44
+ runScript: (script, args) => `yarn ${script}${joinArgs(args)}`,
45
+ exec: (bin, args) => `yarn ${bin}${joinArgs(args)}`
46
+ }
47
+ };
48
+ function getPackageManager(id) {
49
+ return PACKAGE_MANAGERS[id];
50
+ }
51
+ function ciInstallCommand(id) {
52
+ switch (id) {
53
+ case "pnpm":
54
+ return "pnpm install --frozen-lockfile";
55
+ case "npm":
56
+ return "npm ci";
57
+ case "yarn":
58
+ return "yarn install --frozen-lockfile";
59
+ }
60
+ }
61
+
62
+ // src/helpers/slugify.ts
63
+ function slugify(name) {
64
+ const slug = name.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_|_$/g, "");
65
+ if (!slug) {
66
+ throw new Error(`Could not derive a package slug from "${name}".`);
67
+ }
68
+ if (/^[0-9]/.test(slug)) {
69
+ return `app_${slug}`;
70
+ }
71
+ return slug;
72
+ }
73
+
74
+ // src/presets.ts
75
+ var PRESET_SEEDS = {
76
+ minimal: {
77
+ packageManager: "pnpm",
78
+ auth: "none",
79
+ styling: "none",
80
+ ui: "none",
81
+ data: "rsc",
82
+ store: "none",
83
+ icons: "none",
84
+ validation: "zod",
85
+ i18n: false,
86
+ darkMode: false,
87
+ apiEnvelope: false,
88
+ endpoints: false,
89
+ seo: false,
90
+ eslint: false,
91
+ prettier: false,
92
+ husky: false,
93
+ docker: false,
94
+ ci: false,
95
+ testing: false,
96
+ playwright: false
97
+ },
98
+ "api-app": {
99
+ packageManager: "pnpm",
100
+ auth: "jwt",
101
+ styling: "tailwind",
102
+ ui: "shadcn",
103
+ data: "tanstack-query",
104
+ store: "zustand",
105
+ icons: "lucide",
106
+ validation: "zod",
107
+ i18n: false,
108
+ darkMode: true,
109
+ apiEnvelope: true,
110
+ endpoints: true,
111
+ seo: false,
112
+ eslint: true,
113
+ prettier: true,
114
+ husky: true,
115
+ docker: false,
116
+ ci: true,
117
+ testing: true,
118
+ playwright: false
119
+ },
120
+ marketing: {
121
+ packageManager: "pnpm",
122
+ auth: "none",
123
+ styling: "tailwind",
124
+ ui: "shadcn",
125
+ data: "rsc",
126
+ store: "none",
127
+ icons: "lucide",
128
+ validation: "zod",
129
+ i18n: true,
130
+ darkMode: true,
131
+ apiEnvelope: false,
132
+ endpoints: false,
133
+ seo: true,
134
+ eslint: true,
135
+ prettier: true,
136
+ husky: true,
137
+ docker: false,
138
+ ci: true,
139
+ testing: true,
140
+ playwright: false
141
+ },
142
+ dashboard: {
143
+ packageManager: "pnpm",
144
+ auth: "none",
145
+ styling: "tailwind",
146
+ ui: "shadcn",
147
+ data: "tanstack-query",
148
+ store: "zustand",
149
+ icons: "lucide",
150
+ validation: "zod",
151
+ i18n: false,
152
+ darkMode: true,
153
+ apiEnvelope: false,
154
+ endpoints: false,
155
+ seo: false,
156
+ eslint: true,
157
+ prettier: true,
158
+ husky: true,
159
+ docker: false,
160
+ ci: true,
161
+ testing: true,
162
+ playwright: false
163
+ }
164
+ };
165
+ function describePreset(id) {
166
+ const seed = PRESET_SEEDS[id];
167
+ return [
168
+ `styling=${seed.styling}`,
169
+ seed.ui === "shadcn" ? "shadcn" : null,
170
+ `auth=${seed.auth}`,
171
+ seed.store !== "none" ? `store=${seed.store}` : null,
172
+ `validation=${seed.validation}`,
173
+ seed.endpoints ? "endpoints" : null,
174
+ seed.data === "tanstack-query" ? "query" : null,
175
+ seed.darkMode ? "dark" : null,
176
+ seed.seo ? "seo" : null,
177
+ seed.i18n ? "i18n" : null,
178
+ seed.testing ? "vitest" : null
179
+ ].filter(Boolean).join(" \xB7 ");
180
+ }
181
+ function collectOptionWarnings(options) {
182
+ const warnings = [];
183
+ if (options.auth === "jwt" && !options.endpoints) {
184
+ warnings.push(
185
+ "JWT without endpoints: only token-storage is added (no axios refresh wiring)."
186
+ );
187
+ }
188
+ if (options.ui === "shadcn" && options.styling !== "tailwind") {
189
+ warnings.push("shadcn requires Tailwind \u2014 ui forced to none.");
190
+ }
191
+ if (options.darkMode && options.styling !== "tailwind") {
192
+ warnings.push("Dark mode requires Tailwind \u2014 disabled.");
193
+ }
194
+ if (options.apiEnvelope && !options.endpoints) {
195
+ warnings.push(
196
+ "API envelope enabled without endpoints: unwrap applies to src/lib/http.ts apiFetch."
197
+ );
198
+ }
199
+ if (options.playwright && !options.testing) {
200
+ warnings.push("Playwright was requested without Vitest \u2014 enabling testing.");
201
+ }
202
+ if (options.store !== "none" && options.data === "tanstack-query") {
203
+ warnings.push(
204
+ "Client store + TanStack Query: use Query for server cache; store for UI/session client state only."
205
+ );
206
+ }
207
+ return warnings;
208
+ }
209
+
210
+ // src/prompts.ts
211
+ import * as p from "@clack/prompts";
212
+ import color from "picocolors";
213
+
214
+ // src/types.ts
215
+ var DEFAULT_OPTIONS = {
216
+ packageManager: "pnpm",
217
+ auth: "none",
218
+ styling: "none",
219
+ ui: "none",
220
+ data: "rsc",
221
+ store: "none",
222
+ icons: "none",
223
+ validation: "zod",
224
+ i18n: false,
225
+ darkMode: false,
226
+ /** Neutral base — enable via preset / flags when building an API client app */
227
+ apiEnvelope: false,
228
+ endpoints: false,
229
+ seo: false,
230
+ eslint: true,
231
+ prettier: true,
232
+ husky: true,
233
+ docker: false,
234
+ ci: true,
235
+ testing: true,
236
+ playwright: false,
237
+ noInstall: false
238
+ };
239
+ var ICON_PACKAGES = {
240
+ lucide: { name: "lucide-react", version: "^0.469.0" },
241
+ tabler: { name: "@tabler/icons-react", version: "^3.46.0" }
242
+ };
243
+ function normalizeOptions(options) {
244
+ let next = { ...options };
245
+ if (next.styling !== "tailwind" && next.ui === "shadcn") {
246
+ next = { ...next, ui: "none" };
247
+ }
248
+ if (next.styling !== "tailwind" && next.darkMode) {
249
+ next = { ...next, darkMode: false };
250
+ }
251
+ if (next.playwright && !next.testing) {
252
+ next = { ...next, testing: true };
253
+ }
254
+ return next;
255
+ }
256
+
257
+ // src/prompts.ts
258
+ function assertNotCancel(value) {
259
+ if (p.isCancel(value)) {
260
+ p.cancel("Scaffold cancelled.");
261
+ process.exit(0);
262
+ }
263
+ return value;
264
+ }
265
+ async function promptCustomOptions(base) {
266
+ const styling = assertNotCancel(
267
+ await p.select({
268
+ message: "Styling system?",
269
+ options: [
270
+ { value: "none", label: "None", hint: "plain CSS" },
271
+ {
272
+ value: "tailwind",
273
+ label: "Tailwind CSS",
274
+ hint: "enables optional shadcn"
275
+ },
276
+ {
277
+ value: "mui",
278
+ label: "MUI",
279
+ hint: "no shadcn"
280
+ }
281
+ ],
282
+ initialValue: base.styling
283
+ })
284
+ );
285
+ let ui = "none";
286
+ let darkMode = false;
287
+ if (styling === "tailwind") {
288
+ const useShadcn = assertNotCancel(
289
+ await p.confirm({
290
+ message: "Add shadcn/ui?",
291
+ initialValue: base.ui === "shadcn"
292
+ })
293
+ );
294
+ ui = useShadcn ? "shadcn" : "none";
295
+ darkMode = assertNotCancel(
296
+ await p.confirm({
297
+ message: "Add dark mode (next-themes)?",
298
+ initialValue: base.darkMode
299
+ })
300
+ );
301
+ }
302
+ const icons = assertNotCancel(
303
+ await p.select({
304
+ message: "Icon library?",
305
+ options: [
306
+ { value: "none", label: "None" },
307
+ { value: "lucide", label: "Lucide" },
308
+ { value: "tabler", label: "Tabler" }
309
+ ],
310
+ initialValue: base.icons
311
+ })
312
+ );
313
+ const validation = assertNotCancel(
314
+ await p.select({
315
+ message: "Validation library?",
316
+ options: [
317
+ { value: "zod", label: "Zod" },
318
+ { value: "yup", label: "Yup" }
319
+ ],
320
+ initialValue: base.validation
321
+ })
322
+ );
323
+ const useTanstackQuery = assertNotCancel(
324
+ await p.confirm({
325
+ message: "Add TanStack Query?",
326
+ initialValue: base.data === "tanstack-query"
327
+ })
328
+ );
329
+ const needsStore = assertNotCancel(
330
+ await p.confirm({
331
+ message: "Add a client store (global UI state)?",
332
+ initialValue: base.store !== "none"
333
+ })
334
+ );
335
+ let store = "none";
336
+ if (needsStore) {
337
+ store = assertNotCancel(
338
+ await p.select({
339
+ message: "Which store library?",
340
+ options: [
341
+ {
342
+ value: "zustand",
343
+ label: "Zustand",
344
+ hint: "lightweight"
345
+ },
346
+ {
347
+ value: "redux",
348
+ label: "Redux Toolkit",
349
+ hint: "structured slices"
350
+ }
351
+ ],
352
+ initialValue: base.store === "redux" ? "redux" : "zustand"
353
+ })
354
+ );
355
+ }
356
+ const endpoints = assertNotCancel(
357
+ await p.confirm({
358
+ message: "Add OpenAPI endpoints codegen?",
359
+ initialValue: base.endpoints
360
+ })
361
+ );
362
+ let apiEnvelope = base.apiEnvelope;
363
+ if (endpoints) {
364
+ apiEnvelope = assertNotCancel(
365
+ await p.confirm({
366
+ message: "Unwrap API envelopes in http/axios helpers?",
367
+ initialValue: true
368
+ })
369
+ );
370
+ } else {
371
+ apiEnvelope = assertNotCancel(
372
+ await p.confirm({
373
+ message: "Unwrap API envelopes in apiFetch (lib/http.ts)?",
374
+ initialValue: false
375
+ })
376
+ );
377
+ }
378
+ const auth = assertNotCancel(
379
+ await p.select({
380
+ message: "Auth?",
381
+ options: [
382
+ { value: "none", label: "None" },
383
+ {
384
+ value: "jwt",
385
+ label: "JWT",
386
+ hint: "Bearer + refresh"
387
+ },
388
+ {
389
+ value: "authjs",
390
+ label: "Auth.js",
391
+ hint: "Credentials demo"
392
+ }
393
+ ],
394
+ initialValue: base.auth
395
+ })
396
+ );
397
+ const seo = assertNotCancel(
398
+ await p.confirm({
399
+ message: "Add SEO helpers (sitemap/robots)?",
400
+ initialValue: base.seo
401
+ })
402
+ );
403
+ const i18n = assertNotCancel(
404
+ await p.confirm({
405
+ message: "Add i18n (next-intl)?",
406
+ initialValue: base.i18n
407
+ })
408
+ );
409
+ const testing = assertNotCancel(
410
+ await p.confirm({
411
+ message: "Add Vitest?",
412
+ initialValue: base.testing
413
+ })
414
+ );
415
+ let playwright = false;
416
+ if (testing) {
417
+ playwright = assertNotCancel(
418
+ await p.confirm({
419
+ message: "Also add Playwright e2e?",
420
+ initialValue: base.playwright
421
+ })
422
+ );
423
+ }
424
+ const eslint = assertNotCancel(
425
+ await p.confirm({
426
+ message: "Enhanced ESLint?",
427
+ initialValue: base.eslint
428
+ })
429
+ );
430
+ const prettier = assertNotCancel(
431
+ await p.confirm({
432
+ message: "Add Prettier?",
433
+ initialValue: base.prettier
434
+ })
435
+ );
436
+ const husky = assertNotCancel(
437
+ await p.confirm({
438
+ message: "Add Husky + lint-staged?",
439
+ initialValue: base.husky
440
+ })
441
+ );
442
+ const docker = assertNotCancel(
443
+ await p.confirm({
444
+ message: "Add Docker production image?",
445
+ initialValue: base.docker
446
+ })
447
+ );
448
+ const ci = assertNotCancel(
449
+ await p.confirm({
450
+ message: "Add GitHub Actions CI?",
451
+ initialValue: base.ci
452
+ })
453
+ );
454
+ return {
455
+ ...base,
456
+ styling,
457
+ ui,
458
+ darkMode,
459
+ icons,
460
+ validation,
461
+ data: useTanstackQuery ? "tanstack-query" : "rsc",
462
+ store,
463
+ endpoints,
464
+ apiEnvelope,
465
+ auth,
466
+ seo,
467
+ i18n,
468
+ testing,
469
+ playwright,
470
+ eslint,
471
+ prettier,
472
+ husky,
473
+ docker,
474
+ ci
475
+ };
476
+ }
477
+ async function runPrompts(initialName) {
478
+ p.intro(color.bgCyan(color.black(` ${TITLE} `)));
479
+ const name = assertNotCancel(
480
+ await p.text({
481
+ message: "Project name?",
482
+ placeholder: "my-app",
483
+ initialValue: initialName ?? "my-app",
484
+ validate(value) {
485
+ if (!value.trim()) return "Name is required.";
486
+ try {
487
+ slugify(value);
488
+ } catch (error) {
489
+ return error instanceof Error ? error.message : "Invalid name.";
490
+ }
491
+ }
492
+ })
493
+ );
494
+ const slug = slugify(name);
495
+ const packageManager = assertNotCancel(
496
+ await p.select({
497
+ message: "Package manager?",
498
+ options: [
499
+ { value: "pnpm", label: "pnpm", hint: "default" },
500
+ { value: "npm", label: "npm" },
501
+ { value: "yarn", label: "yarn", hint: "classic" }
502
+ ],
503
+ initialValue: "pnpm"
504
+ })
505
+ );
506
+ const preset = assertNotCancel(
507
+ await p.select({
508
+ message: "Preset?",
509
+ options: [
510
+ {
511
+ value: "minimal",
512
+ label: "Minimal",
513
+ hint: describePreset("minimal")
514
+ },
515
+ {
516
+ value: "api-app",
517
+ label: "API app",
518
+ hint: describePreset("api-app")
519
+ },
520
+ {
521
+ value: "marketing",
522
+ label: "Marketing / site",
523
+ hint: describePreset("marketing")
524
+ },
525
+ {
526
+ value: "dashboard",
527
+ label: "Dashboard",
528
+ hint: describePreset("dashboard")
529
+ },
530
+ {
531
+ value: "custom",
532
+ label: "Custom",
533
+ hint: "answer every question"
534
+ }
535
+ ],
536
+ initialValue: "custom"
537
+ })
538
+ );
539
+ let optionSeed = preset === "custom" ? { ...DEFAULT_OPTIONS, packageManager } : { ...PRESET_SEEDS[preset], packageManager };
540
+ if (preset === "custom") {
541
+ optionSeed = await promptCustomOptions(optionSeed);
542
+ } else {
543
+ p.note(describePreset(preset), `Preset: ${preset}`);
544
+ const tweak = assertNotCancel(
545
+ await p.confirm({
546
+ message: "Customize this preset further?",
547
+ initialValue: false
548
+ })
549
+ );
550
+ if (tweak) {
551
+ optionSeed = await promptCustomOptions(optionSeed);
552
+ }
553
+ }
554
+ const draft = normalizeOptions({
555
+ name: name.trim(),
556
+ slug,
557
+ ...optionSeed,
558
+ packageManager,
559
+ noInstall: false
560
+ });
561
+ const warnings = collectOptionWarnings(draft);
562
+ if (warnings.length) {
563
+ p.log.warn(warnings.join("\n"));
564
+ }
565
+ const confirmed = assertNotCancel(
566
+ await p.confirm({
567
+ message: `Create ${color.cyan(name)} (${color.dim(slug)})?`,
568
+ initialValue: true
569
+ })
570
+ );
571
+ if (!confirmed) {
572
+ p.cancel("Scaffold cancelled.");
573
+ process.exit(0);
574
+ }
575
+ return draft;
576
+ }
577
+
578
+ // src/scaffold.ts
579
+ import fs27 from "fs-extra";
580
+ import path28 from "path";
581
+ import * as p2 from "@clack/prompts";
582
+ import { execSync } from "child_process";
583
+ import color2 from "picocolors";
584
+
585
+ // src/helpers/replace-tokens.ts
586
+ import fs from "fs-extra";
587
+ import path2 from "path";
588
+ var TEXT_EXT = /* @__PURE__ */ new Set([
589
+ ".ts",
590
+ ".tsx",
591
+ ".js",
592
+ ".jsx",
593
+ ".mjs",
594
+ ".cjs",
595
+ ".json",
596
+ ".md",
597
+ ".mdc",
598
+ ".css",
599
+ ".scss",
600
+ ".html",
601
+ ".svg",
602
+ ".yml",
603
+ ".yaml",
604
+ ".toml",
605
+ ".env",
606
+ ".example",
607
+ ".gitignore",
608
+ ".npmrc"
609
+ ]);
610
+ function shouldRewrite(filePath) {
611
+ const base = path2.basename(filePath);
612
+ if (base === ".env.example" || base === "LICENSE") return true;
613
+ const ext = path2.extname(filePath);
614
+ return TEXT_EXT.has(ext) || base.startsWith(".");
615
+ }
616
+ function applyTokens(contents, tokens) {
617
+ let out = contents;
618
+ for (const [key, value] of Object.entries(tokens)) {
619
+ out = out.split(key).join(value);
620
+ }
621
+ return out;
622
+ }
623
+ async function replaceTokensInTree(root, tokens) {
624
+ const entries = await fs.readdir(root, { withFileTypes: true });
625
+ for (const entry of entries) {
626
+ const full = path2.join(root, entry.name);
627
+ if (entry.isDirectory()) {
628
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
629
+ await replaceTokensInTree(full, tokens);
630
+ continue;
631
+ }
632
+ if (!shouldRewrite(full)) continue;
633
+ const raw = await fs.readFile(full, "utf8");
634
+ const next = applyTokens(raw, tokens);
635
+ if (next !== raw) {
636
+ await fs.writeFile(full, next, "utf8");
637
+ }
638
+ }
639
+ }
640
+
641
+ // src/installers/api-envelope.ts
642
+ import fs2 from "fs-extra";
643
+ import path3 from "path";
644
+ var HTTP_ENVELOPE = `import { env } from "@/lib/env";
645
+
646
+ type ApiEnvelope<T> = {
647
+ result?: T;
648
+ status?: number;
649
+ success?: boolean;
650
+ messages?: unknown;
651
+ };
652
+
653
+ function unwrapEnvelope<T>(data: unknown): T {
654
+ if (data && typeof data === "object" && "result" in data) {
655
+ const result = (data as ApiEnvelope<T>).result;
656
+ if (result !== undefined && result !== null) {
657
+ return result;
658
+ }
659
+ }
660
+ return data as T;
661
+ }
662
+
663
+ /**
664
+ * JSON fetch helper that unwraps \`{ result, status, success, messages }\` envelopes.
665
+ */
666
+ export async function apiFetch<T>(
667
+ path: string,
668
+ init?: RequestInit,
669
+ ): Promise<T> {
670
+ const url = path.startsWith("http")
671
+ ? path
672
+ : \`\${env.NEXT_PUBLIC_API_URL.replace(/\\/$/, "")}/\${path.replace(/^\\//, "")}\`;
673
+
674
+ const response = await fetch(url, {
675
+ ...init,
676
+ headers: {
677
+ Accept: "application/json",
678
+ ...(init?.headers ?? {}),
679
+ },
680
+ });
681
+
682
+ if (!response.ok) {
683
+ throw new Error(\`API \${response.status}: \${response.statusText}\`);
684
+ }
685
+
686
+ const json: unknown = await response.json();
687
+ return unwrapEnvelope<T>(json);
688
+ }
689
+ `;
690
+ var RULE = `---
691
+ description: API envelope unwrap for apiFetch
692
+ globs: src/lib/http.ts
693
+ alwaysApply: false
694
+ ---
695
+
696
+ # API envelope
697
+
698
+ \`apiFetch\` unwraps \`{ result, status, success, messages }\` when present.
699
+ Prefer generated \`src/endpoints/*\` + axios when endpoints codegen is enabled.
700
+ `;
701
+ var apiEnvelopeInstaller = {
702
+ id: "api-envelope",
703
+ active: (o) => o.apiEnvelope,
704
+ async run({ projectDir, options }) {
705
+ await fs2.writeFile(
706
+ path3.join(projectDir, "src", "lib", "http.ts"),
707
+ HTTP_ENVELOPE,
708
+ "utf8"
709
+ );
710
+ await fs2.writeFile(
711
+ path3.join(projectDir, ".cursor", "rules", "api-envelope.mdc"),
712
+ RULE,
713
+ "utf8"
714
+ );
715
+ await fs2.outputFile(
716
+ path3.join(projectDir, "docs", "style-guide", "http", "envelope.md"),
717
+ `# API envelope
718
+
719
+ \`src/lib/http.ts\` \u2192 \`apiFetch\` unwraps \`result\` from standard envelopes.
720
+ ${options.endpoints ? "Endpoints/axios: use `unwrapApiResult` from `@/utils/api-result`.\n" : ""}
721
+ `
722
+ );
723
+ }
724
+ };
725
+
726
+ // src/helpers/app-providers.ts
727
+ import fs3 from "fs-extra";
728
+ import path4 from "path";
729
+ function needsAppProviders(options) {
730
+ return options.data === "tanstack-query" || options.store === "redux" || options.styling === "mui" || options.i18n || options.darkMode;
731
+ }
732
+ function generateAppProvidersSource(options) {
733
+ const imports = [
734
+ `"use client";`,
735
+ ``,
736
+ `import type { ReactNode } from "react";`
737
+ ];
738
+ const props = [`children: ReactNode`];
739
+ if (options.i18n) {
740
+ imports.push(`import { NextIntlClientProvider } from "next-intl";`);
741
+ props.push(`messages: Record<string, unknown>`);
742
+ }
743
+ if (options.darkMode) {
744
+ imports.push(`import { ThemeProvider } from "@/providers/theme-provider";`);
745
+ }
746
+ if (options.styling === "mui") {
747
+ imports.push(`import { MuiProvider } from "@/providers/mui-provider";`);
748
+ }
749
+ if (options.store === "redux") {
750
+ imports.push(`import { StoreProvider } from "@/providers/store-provider";`);
751
+ }
752
+ if (options.data === "tanstack-query") {
753
+ imports.push(`import { QueryProvider } from "@/providers/query-provider";`);
754
+ }
755
+ const openers = [];
756
+ const closers = [];
757
+ if (options.i18n) {
758
+ openers.push(` <NextIntlClientProvider messages={messages}>`);
759
+ closers.unshift(` </NextIntlClientProvider>`);
760
+ }
761
+ if (options.darkMode) {
762
+ openers.push(` <ThemeProvider>`);
763
+ closers.unshift(` </ThemeProvider>`);
764
+ }
765
+ if (options.styling === "mui") {
766
+ openers.push(` <MuiProvider>`);
767
+ closers.unshift(` </MuiProvider>`);
768
+ }
769
+ if (options.store === "redux") {
770
+ openers.push(` <StoreProvider>`);
771
+ closers.unshift(` </StoreProvider>`);
772
+ }
773
+ if (options.data === "tanstack-query") {
774
+ openers.push(` <QueryProvider>`);
775
+ closers.unshift(` </QueryProvider>`);
776
+ }
777
+ return `${imports.join("\n")}
778
+
779
+ type AppProvidersProps = {
780
+ ${props.join(";\n ")};
781
+ };
782
+
783
+ /**
784
+ * Single composition root for client providers.
785
+ * Feature installers add leaf providers; this file wraps them in a stable order.
786
+ */
787
+ export function AppProviders({ children${options.i18n ? ", messages" : ""} }: AppProvidersProps) {
788
+ return (
789
+ ${openers.join("\n")}
790
+ {children}
791
+ ${closers.join("\n")}
792
+ );
793
+ }
794
+ `;
795
+ }
796
+ async function writeAppProviders(projectDir, options) {
797
+ if (!needsAppProviders(options)) return;
798
+ const providersDir = path4.join(projectDir, "src", "providers");
799
+ await fs3.ensureDir(providersDir);
800
+ await fs3.remove(path4.join(providersDir, ".gitkeep"));
801
+ await fs3.writeFile(
802
+ path4.join(providersDir, "app-providers.tsx"),
803
+ generateAppProvidersSource(options),
804
+ "utf8"
805
+ );
806
+ }
807
+ async function wireRootLayout(projectDir, options) {
808
+ const layoutPath = path4.join(projectDir, "src", "app", "layout.tsx");
809
+ const useProviders = needsAppProviders(options);
810
+ const dark = options.darkMode;
811
+ const i18n = options.i18n;
812
+ const htmlLang = i18n ? "{locale}" : `"en"`;
813
+ const htmlExtra = dark ? " suppressHydrationWarning" : "";
814
+ const imports = [
815
+ `import type { Metadata } from "next";`,
816
+ ``,
817
+ `import "./globals.css";`
818
+ ];
819
+ if (useProviders) {
820
+ imports.push(
821
+ ``,
822
+ `import { AppProviders } from "@/providers/app-providers";`
823
+ );
824
+ }
825
+ if (i18n) {
826
+ imports.push(
827
+ `import { getLocale, getMessages } from "next-intl/server";`
828
+ );
829
+ }
830
+ const bodyInner = useProviders ? i18n ? ` <AppProviders messages={messages}>
831
+ <main>{children}</main>
832
+ </AppProviders>` : ` <AppProviders>
833
+ <main>{children}</main>
834
+ </AppProviders>` : ` <main>{children}</main>`;
835
+ const fnBody = i18n ? ` const locale = await getLocale();
836
+ const messages = await getMessages();
837
+
838
+ return (
839
+ <html lang=${htmlLang}${htmlExtra}>
840
+ <body>
841
+ ${bodyInner}
842
+ </body>
843
+ </html>
844
+ );` : ` return (
845
+ <html lang=${htmlLang}${htmlExtra}>
846
+ <body>
847
+ ${bodyInner}
848
+ </body>
849
+ </html>
850
+ );`;
851
+ const source = `${imports.join("\n")}
852
+
853
+ export const metadata: Metadata = {
854
+ title: "__PROJECT_NAME__",
855
+ description: "Generated with next-style-guide",
856
+ };
857
+
858
+ export default ${i18n ? "async " : ""}function RootLayout({
859
+ children,
860
+ }: Readonly<{
861
+ children: React.ReactNode;
862
+ }>) {
863
+ ${fnBody}
864
+ }
865
+ `;
866
+ let final = source;
867
+ if (await fs3.pathExists(layoutPath)) {
868
+ const existing = await fs3.readFile(layoutPath, "utf8");
869
+ const titleMatch = existing.match(/title:\s*"([^"]+)"/);
870
+ if (titleMatch?.[1] && titleMatch[1] !== "__PROJECT_NAME__") {
871
+ final = final.replace(
872
+ 'title: "__PROJECT_NAME__"',
873
+ `title: "${titleMatch[1]}"`
874
+ );
875
+ }
876
+ }
877
+ await fs3.writeFile(layoutPath, final, "utf8");
878
+ }
879
+
880
+ // src/installers/app-providers.ts
881
+ var appProvidersInstaller = {
882
+ id: "app-providers",
883
+ active: (o) => needsAppProviders(o) || o.i18n || o.darkMode,
884
+ async run({ projectDir, options }) {
885
+ await writeAppProviders(projectDir, options);
886
+ await wireRootLayout(projectDir, options);
887
+ }
888
+ };
889
+
890
+ // src/installers/auth-authjs.ts
891
+ import fs5 from "fs-extra";
892
+ import path6 from "path";
893
+
894
+ // src/helpers/package-json.ts
895
+ import fs4 from "fs-extra";
896
+ import path5 from "path";
897
+ async function readPkg(projectDir) {
898
+ const pkgPath = path5.join(projectDir, "package.json");
899
+ const pkg = await fs4.readJson(pkgPath);
900
+ return { pkgPath, pkg };
901
+ }
902
+ function sortRecord(record) {
903
+ return Object.fromEntries(
904
+ Object.entries(record).sort(([a], [b]) => a.localeCompare(b))
905
+ );
906
+ }
907
+ async function addDependencies(projectDir, deps, kind = "dependencies") {
908
+ const { pkgPath, pkg } = await readPkg(projectDir);
909
+ pkg[kind] = sortRecord({
910
+ ...pkg[kind] ?? {},
911
+ ...deps
912
+ });
913
+ await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
914
+ }
915
+ async function addScripts(projectDir, scripts) {
916
+ const { pkgPath, pkg } = await readPkg(projectDir);
917
+ pkg.scripts = {
918
+ ...pkg.scripts ?? {},
919
+ ...scripts
920
+ };
921
+ await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
922
+ }
923
+ async function mergePackageField(projectDir, field, value) {
924
+ const { pkgPath, pkg } = await readPkg(projectDir);
925
+ pkg[field] = value;
926
+ await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
927
+ }
928
+ async function removeDependencies(projectDir, names, kind = "dependencies") {
929
+ const { pkgPath, pkg } = await readPkg(projectDir);
930
+ if (!pkg[kind]) return;
931
+ for (const name of names) {
932
+ delete pkg[kind][name];
933
+ }
934
+ pkg[kind] = sortRecord(pkg[kind]);
935
+ await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
936
+ }
937
+
938
+ // src/installers/auth-authjs.ts
939
+ var AUTH_TS = `import NextAuth from "next-auth";
940
+ import Credentials from "next-auth/providers/credentials";
941
+
942
+ /**
943
+ * Auth.js \u2014 Credentials demo provider.
944
+ * Replace \`authorize\` with your API/DB lookup. Set \`AUTH_SECRET\` in env.
945
+ */
946
+ export const { handlers, auth, signIn, signOut } = NextAuth({
947
+ providers: [
948
+ Credentials({
949
+ name: "Credentials",
950
+ credentials: {
951
+ email: { label: "Email", type: "email" },
952
+ password: { label: "Password", type: "password" },
953
+ },
954
+ async authorize(credentials) {
955
+ if (!credentials?.email || !credentials?.password) {
956
+ return null;
957
+ }
958
+ // Demo only \u2014 wire to your backend.
959
+ return {
960
+ id: "1",
961
+ email: String(credentials.email),
962
+ name: "Demo User",
963
+ };
964
+ },
965
+ }),
966
+ ],
967
+ pages: {
968
+ signIn: "/login",
969
+ },
970
+ session: { strategy: "jwt" },
971
+ trustHost: true,
972
+ });
973
+ `;
974
+ var ROUTE = `import { handlers } from "@/lib/auth";
975
+
976
+ export const { GET, POST } = handlers;
977
+ `;
978
+ var MIDDLEWARE = `import { auth } from "@/lib/auth";
979
+ import { NextResponse } from "next/server";
980
+
981
+ const publicPaths = ["/login", "/api/auth"];
982
+
983
+ export default auth((req) => {
984
+ const { pathname } = req.nextUrl;
985
+ const isPublic = publicPaths.some(
986
+ (p) => pathname === p || pathname.startsWith(\`\${p}/\`),
987
+ );
988
+
989
+ if (!req.auth && !isPublic) {
990
+ const url = new URL("/login", req.nextUrl.origin);
991
+ url.searchParams.set("callbackUrl", pathname);
992
+ return NextResponse.redirect(url);
993
+ }
994
+
995
+ if (req.auth && pathname === "/login") {
996
+ return NextResponse.redirect(new URL("/", req.nextUrl.origin));
997
+ }
998
+
999
+ return NextResponse.next();
1000
+ });
1001
+
1002
+ export const config = {
1003
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
1004
+ };
1005
+ `;
1006
+ var LOGIN_PAGE = `import { Suspense } from "react";
1007
+
1008
+ import { LoginForm } from "@/app/login/_components/login-form";
1009
+
1010
+ export default function LoginPage() {
1011
+ return (
1012
+ <section className="hero">
1013
+ <h1>Sign in</h1>
1014
+ <p>Auth.js Credentials demo \u2014 replace authorize with your API.</p>
1015
+ <Suspense fallback={<p>Loading\u2026</p>}>
1016
+ <LoginForm />
1017
+ </Suspense>
1018
+ </section>
1019
+ );
1020
+ }
1021
+ `;
1022
+ var LOGIN_FORM = `"use client";
1023
+
1024
+ import { useState, type FormEvent } from "react";
1025
+ import { signIn } from "next-auth/react";
1026
+ import { useRouter, useSearchParams } from "next/navigation";
1027
+
1028
+ export function LoginForm() {
1029
+ const router = useRouter();
1030
+ const searchParams = useSearchParams();
1031
+ const callbackUrl = searchParams.get("callbackUrl") ?? "/";
1032
+ const [error, setError] = useState<string | null>(null);
1033
+ const [pending, setPending] = useState(false);
1034
+
1035
+ async function onSubmit(event: FormEvent<HTMLFormElement>) {
1036
+ event.preventDefault();
1037
+ setPending(true);
1038
+ setError(null);
1039
+ const form = new FormData(event.currentTarget);
1040
+ const result = await signIn("credentials", {
1041
+ email: String(form.get("email") ?? ""),
1042
+ password: String(form.get("password") ?? ""),
1043
+ redirect: false,
1044
+ });
1045
+ setPending(false);
1046
+ if (result?.error) {
1047
+ setError("Invalid credentials");
1048
+ return;
1049
+ }
1050
+ router.push(callbackUrl);
1051
+ router.refresh();
1052
+ }
1053
+
1054
+ return (
1055
+ <form onSubmit={onSubmit} className="card" style={{ marginTop: "1.5rem" }}>
1056
+ <label style={{ display: "grid", gap: "0.35rem", marginBottom: "0.75rem" }}>
1057
+ Email
1058
+ <input name="email" type="email" required autoComplete="email" />
1059
+ </label>
1060
+ <label style={{ display: "grid", gap: "0.35rem", marginBottom: "0.75rem" }}>
1061
+ Password
1062
+ <input
1063
+ name="password"
1064
+ type="password"
1065
+ required
1066
+ autoComplete="current-password"
1067
+ />
1068
+ </label>
1069
+ {error ? <p style={{ color: "crimson" }}>{error}</p> : null}
1070
+ <button type="submit" disabled={pending}>
1071
+ {pending ? "Signing in\u2026" : "Sign in"}
1072
+ </button>
1073
+ </form>
1074
+ );
1075
+ }
1076
+ `;
1077
+ var RULE2 = `---
1078
+ description: Auth.js (NextAuth) conventions
1079
+ globs: src/lib/auth.ts,src/app/api/auth/**/*,src/middleware.ts,src/app/login/**
1080
+ alwaysApply: false
1081
+ ---
1082
+
1083
+ # Auth.js
1084
+
1085
+ - Config: \`src/lib/auth.ts\` (Credentials demo \u2014 replace \`authorize\`).
1086
+ - Route handlers: \`src/app/api/auth/[...nextauth]/route.ts\`.
1087
+ - Middleware: \`src/middleware.ts\` protects non-public routes; public: \`/login\`, \`/api/auth\`.
1088
+ - Login UI: \`src/app/login/\`.
1089
+ - Env: \`AUTH_SECRET\` (required in production).
1090
+ - Prefer \`auth()\` in Server Components; \`signIn\` / \`signOut\` on the client.
1091
+ `;
1092
+ var authAuthjsInstaller = {
1093
+ id: "auth-authjs",
1094
+ active: (o) => o.auth === "authjs",
1095
+ async run({ projectDir }) {
1096
+ await fs5.writeFile(
1097
+ path6.join(projectDir, "src", "lib", "auth.ts"),
1098
+ AUTH_TS,
1099
+ "utf8"
1100
+ );
1101
+ await fs5.outputFile(
1102
+ path6.join(
1103
+ projectDir,
1104
+ "src",
1105
+ "app",
1106
+ "api",
1107
+ "auth",
1108
+ "[...nextauth]",
1109
+ "route.ts"
1110
+ ),
1111
+ ROUTE
1112
+ );
1113
+ await fs5.writeFile(
1114
+ path6.join(projectDir, "src", "middleware.ts"),
1115
+ MIDDLEWARE,
1116
+ "utf8"
1117
+ );
1118
+ await fs5.outputFile(
1119
+ path6.join(projectDir, "src", "app", "login", "page.tsx"),
1120
+ LOGIN_PAGE
1121
+ );
1122
+ await fs5.outputFile(
1123
+ path6.join(
1124
+ projectDir,
1125
+ "src",
1126
+ "app",
1127
+ "login",
1128
+ "_components",
1129
+ "login-form",
1130
+ "index.tsx"
1131
+ ),
1132
+ LOGIN_FORM
1133
+ );
1134
+ await fs5.writeFile(
1135
+ path6.join(projectDir, ".cursor", "rules", "auth.mdc"),
1136
+ RULE2,
1137
+ "utf8"
1138
+ );
1139
+ await fs5.outputFile(
1140
+ path6.join(projectDir, "docs", "style-guide", "auth", "authjs.md"),
1141
+ `# Auth.js
1142
+
1143
+ - \`src/lib/auth.ts\` \u2014 Credentials demo (replace \`authorize\`)
1144
+ - \`src/app/api/auth/[...nextauth]/route.ts\`
1145
+ - \`src/middleware.ts\` \u2014 protect routes; public \`/login\` + \`/api/auth\`
1146
+ - \`src/app/login/\` \u2014 sample login page
1147
+ - Env: \`AUTH_SECRET\`
1148
+ `
1149
+ );
1150
+ const envPath = path6.join(projectDir, ".env.example");
1151
+ if (await fs5.pathExists(envPath)) {
1152
+ let env = await fs5.readFile(envPath, "utf8");
1153
+ if (!env.includes("AUTH_SECRET")) {
1154
+ env += `
1155
+ # Auth.js \u2014 generate with: openssl rand -base64 32
1156
+ AUTH_SECRET=
1157
+ `;
1158
+ await fs5.writeFile(envPath, env, "utf8");
1159
+ }
1160
+ }
1161
+ await addDependencies(projectDir, {
1162
+ "next-auth": "^5.0.0-beta.25"
1163
+ });
1164
+ }
1165
+ };
1166
+
1167
+ // src/installers/auth-jwt.ts
1168
+ import fs6 from "fs-extra";
1169
+ import path7 from "path";
1170
+ function tokenStorage(slug) {
1171
+ const accessKey = `${slug}.accessToken`;
1172
+ const refreshKey = `${slug}.refreshToken`;
1173
+ return `const ACCESS_TOKEN_KEY = "${accessKey}";
1174
+ const REFRESH_TOKEN_KEY = "${refreshKey}";
1175
+
1176
+ export type AuthTokens = {
1177
+ access: string;
1178
+ refresh: string;
1179
+ };
1180
+
1181
+ export function getAccessToken(): string | null {
1182
+ if (typeof window === "undefined") {
1183
+ return null;
1184
+ }
1185
+ return window.localStorage.getItem(ACCESS_TOKEN_KEY);
1186
+ }
1187
+
1188
+ export function getRefreshToken(): string | null {
1189
+ if (typeof window === "undefined") {
1190
+ return null;
1191
+ }
1192
+ return window.localStorage.getItem(REFRESH_TOKEN_KEY);
1193
+ }
1194
+
1195
+ export function setTokens({ access, refresh }: AuthTokens) {
1196
+ window.localStorage.setItem(ACCESS_TOKEN_KEY, access);
1197
+ window.localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
1198
+ }
1199
+
1200
+ export function clearTokens() {
1201
+ if (typeof window === "undefined") {
1202
+ return;
1203
+ }
1204
+ window.localStorage.removeItem(ACCESS_TOKEN_KEY);
1205
+ window.localStorage.removeItem(REFRESH_TOKEN_KEY);
1206
+ }
1207
+ `;
1208
+ }
1209
+ var AXIOS_JWT = `import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
1210
+
1211
+ import { unwrapApiResult } from "@/utils/api-result";
1212
+ import {
1213
+ clearTokens,
1214
+ getAccessToken,
1215
+ getRefreshToken,
1216
+ setTokens,
1217
+ } from "@/utils/token-storage";
1218
+
1219
+ type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
1220
+
1221
+ const baseURL = process.env.NEXT_PUBLIC_API_URL ?? "";
1222
+
1223
+ /** Shared Axios client for \`src/endpoints/*\`. Do not call from UI directly. */
1224
+ export const api = axios.create({
1225
+ baseURL,
1226
+ headers: {
1227
+ Accept: "application/json",
1228
+ "Content-Type": "application/json",
1229
+ },
1230
+ });
1231
+
1232
+ api.interceptors.request.use((config) => {
1233
+ const accessToken = getAccessToken();
1234
+ if (accessToken) {
1235
+ config.headers.setAuthorization(\`Bearer \${accessToken}\`);
1236
+ }
1237
+ if (typeof FormData !== "undefined" && config.data instanceof FormData) {
1238
+ config.headers.delete("Content-Type");
1239
+ }
1240
+ return config;
1241
+ });
1242
+
1243
+ let refreshPromise: Promise<string | null> | null = null;
1244
+
1245
+ async function refreshAccessToken(): Promise<string | null> {
1246
+ const refresh = getRefreshToken();
1247
+ if (!refresh || !baseURL) {
1248
+ return null;
1249
+ }
1250
+
1251
+ try {
1252
+ const { data } = await axios.post(\`\${baseURL}/auth/refresh/\`, { refresh });
1253
+ const payload = unwrapApiResult<{ access?: string; refresh?: string }>(data);
1254
+ const access = payload.access;
1255
+ if (!access) {
1256
+ clearTokens();
1257
+ return null;
1258
+ }
1259
+ setTokens({ access, refresh: payload.refresh ?? refresh });
1260
+ return access;
1261
+ } catch {
1262
+ clearTokens();
1263
+ return null;
1264
+ }
1265
+ }
1266
+
1267
+ function shouldSkipRefresh(url: string | undefined) {
1268
+ if (!url) return false;
1269
+ return (
1270
+ url.includes("/auth/login") ||
1271
+ url.includes("/auth/refresh") ||
1272
+ url.includes("/auth/logout")
1273
+ );
1274
+ }
1275
+
1276
+ api.interceptors.response.use(
1277
+ (response) => response,
1278
+ async (error: AxiosError) => {
1279
+ const original = error.config as RetryConfig | undefined;
1280
+
1281
+ if (
1282
+ !original ||
1283
+ error.response?.status !== 401 ||
1284
+ original._retry ||
1285
+ shouldSkipRefresh(original.url)
1286
+ ) {
1287
+ return Promise.reject(error);
1288
+ }
1289
+
1290
+ original._retry = true;
1291
+ refreshPromise ??= refreshAccessToken().finally(() => {
1292
+ refreshPromise = null;
1293
+ });
1294
+
1295
+ const access = await refreshPromise;
1296
+ if (!access) {
1297
+ return Promise.reject(error);
1298
+ }
1299
+
1300
+ original.headers.setAuthorization(\`Bearer \${access}\`);
1301
+ return api(original);
1302
+ },
1303
+ );
1304
+ `;
1305
+ var RULE3 = `---
1306
+ description: JWT auth (Bearer + refresh) helpers
1307
+ globs: src/utils/token-storage.ts,src/utils/axios-instance.ts,src/**/*auth*.{ts,tsx}
1308
+ alwaysApply: false
1309
+ ---
1310
+
1311
+ # Auth (JWT)
1312
+
1313
+ - Tokens: \`src/utils/token-storage.ts\` (access + refresh in \`localStorage\`).
1314
+ - Axios: Bearer on requests; 401 \u2192 \`/auth/refresh/\` once, then retry.
1315
+ - Do not store tokens in cookies unless you intentionally change the strategy.
1316
+ - Login/logout UI should call \`setTokens\` / \`clearTokens\` \u2014 never invent a second storage key.
1317
+ `;
1318
+ var authJwtInstaller = {
1319
+ id: "auth-jwt",
1320
+ active: (o) => o.auth === "jwt",
1321
+ async run({ projectDir, options }) {
1322
+ await fs6.ensureDir(path7.join(projectDir, "src", "utils"));
1323
+ await fs6.remove(path7.join(projectDir, "src", "utils", ".gitkeep"));
1324
+ await fs6.writeFile(
1325
+ path7.join(projectDir, "src", "utils", "token-storage.ts"),
1326
+ tokenStorage(options.slug),
1327
+ "utf8"
1328
+ );
1329
+ if (options.endpoints) {
1330
+ await fs6.writeFile(
1331
+ path7.join(projectDir, "src", "utils", "axios-instance.ts"),
1332
+ AXIOS_JWT,
1333
+ "utf8"
1334
+ );
1335
+ }
1336
+ await fs6.writeFile(
1337
+ path7.join(projectDir, ".cursor", "rules", "auth.mdc"),
1338
+ RULE3,
1339
+ "utf8"
1340
+ );
1341
+ await fs6.outputFile(
1342
+ path7.join(projectDir, "docs", "style-guide", "auth", "jwt.md"),
1343
+ `# JWT auth
1344
+
1345
+ - \`src/utils/token-storage.ts\` \u2014 access/refresh in localStorage
1346
+ - When endpoints are enabled, \`axios-instance\` attaches Bearer and refreshes on 401 via \`POST /auth/refresh/\`
1347
+ `
1348
+ );
1349
+ }
1350
+ };
1351
+
1352
+ // src/installers/ci.ts
1353
+ import fs7 from "fs-extra";
1354
+ import path8 from "path";
1355
+ function workflowYaml(options) {
1356
+ const pm = getPackageManager(options.packageManager);
1357
+ const run = (script) => pm.runScript(script);
1358
+ const setupSteps = [
1359
+ ` - name: Checkout
1360
+ uses: actions/checkout@v4
1361
+ `
1362
+ ];
1363
+ if (options.packageManager === "pnpm") {
1364
+ setupSteps.push(` - name: Setup pnpm
1365
+ uses: pnpm/action-setup@v4
1366
+ `);
1367
+ }
1368
+ if (options.packageManager === "yarn") {
1369
+ setupSteps.push(` - name: Enable Corepack
1370
+ run: corepack enable
1371
+ `);
1372
+ }
1373
+ setupSteps.push(` - name: Setup Node
1374
+ uses: actions/setup-node@v4
1375
+ with:
1376
+ node-version: 22
1377
+ cache: ${options.packageManager}
1378
+ `);
1379
+ const steps = [
1380
+ ...setupSteps,
1381
+ ` - name: Install
1382
+ run: ${ciInstallCommand(options.packageManager)}
1383
+
1384
+ - name: Typecheck
1385
+ run: ${run("typecheck")}`
1386
+ ];
1387
+ if (options.eslint) {
1388
+ steps.push(`
1389
+ - name: Lint
1390
+ run: ${run("lint")}`);
1391
+ }
1392
+ if (options.prettier) {
1393
+ steps.push(`
1394
+ - name: Format check
1395
+ run: ${run("format:check")}`);
1396
+ }
1397
+ if (options.testing) {
1398
+ steps.push(`
1399
+ - name: Test
1400
+ run: ${run("test")}`);
1401
+ }
1402
+ steps.push(`
1403
+ - name: Build
1404
+ run: ${run("build")}
1405
+ env:
1406
+ NEXT_PUBLIC_APP_URL: http://localhost:3000
1407
+ NEXT_PUBLIC_API_URL: http://localhost:8000`);
1408
+ return `name: CI
1409
+
1410
+ on:
1411
+ push:
1412
+ branches: [main, master]
1413
+ pull_request:
1414
+
1415
+ concurrency:
1416
+ group: ci-\${{ github.workflow }}-\${{ github.ref }}
1417
+ cancel-in-progress: true
1418
+
1419
+ jobs:
1420
+ checks:
1421
+ name: Checks
1422
+ runs-on: ubuntu-latest
1423
+ timeout-minutes: 20
1424
+ steps:
1425
+ ${steps.join("\n")}
1426
+ `;
1427
+ }
1428
+ var ciInstaller = {
1429
+ id: "ci",
1430
+ active: (o) => o.ci,
1431
+ async run({ projectDir, options }) {
1432
+ const addon = path8.join(ADDONS_DIR, "ci");
1433
+ await fs7.outputFile(
1434
+ path8.join(projectDir, ".github", "workflows", "ci.yml"),
1435
+ workflowYaml(options)
1436
+ );
1437
+ await fs7.copy(
1438
+ path8.join(addon, ".cursor", "rules", "ci.mdc"),
1439
+ path8.join(projectDir, ".cursor", "rules", "ci.mdc")
1440
+ );
1441
+ await fs7.copy(
1442
+ path8.join(addon, "docs", "style-guide", "ops", "ci.md"),
1443
+ path8.join(projectDir, "docs", "style-guide", "ops", "ci.md")
1444
+ );
1445
+ }
1446
+ };
1447
+
1448
+ // src/installers/dark-mode.ts
1449
+ import fs8 from "fs-extra";
1450
+ import path9 from "path";
1451
+ var THEME_PROVIDER = `"use client";
1452
+
1453
+ import { ThemeProvider as NextThemesProvider } from "next-themes";
1454
+ import type { ReactNode } from "react";
1455
+
1456
+ export function ThemeProvider({ children }: { children: ReactNode }) {
1457
+ return (
1458
+ <NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
1459
+ {children}
1460
+ </NextThemesProvider>
1461
+ );
1462
+ }
1463
+ `;
1464
+ var RULE4 = `---
1465
+ description: Dark mode (next-themes) with Tailwind
1466
+ globs: src/providers/theme-provider.tsx,src/app/globals.css
1467
+ alwaysApply: false
1468
+ ---
1469
+
1470
+ # Dark mode
1471
+
1472
+ - \`ThemeProvider\` in \`src/providers/theme-provider.tsx\` (composed via \`AppProviders\`).
1473
+ - Tailwind: use \`dark:\` variants; \`html\` gets \`class="dark"\` from next-themes.
1474
+ - Prefer \`defaultTheme="system"\`.
1475
+ `;
1476
+ var darkModeInstaller = {
1477
+ id: "dark-mode",
1478
+ active: (o) => o.darkMode && o.styling === "tailwind",
1479
+ async run({ projectDir }) {
1480
+ const providersDir = path9.join(projectDir, "src", "providers");
1481
+ await fs8.ensureDir(providersDir);
1482
+ await fs8.remove(path9.join(providersDir, ".gitkeep"));
1483
+ await fs8.writeFile(
1484
+ path9.join(providersDir, "theme-provider.tsx"),
1485
+ THEME_PROVIDER,
1486
+ "utf8"
1487
+ );
1488
+ await fs8.writeFile(
1489
+ path9.join(projectDir, ".cursor", "rules", "dark-mode.mdc"),
1490
+ RULE4,
1491
+ "utf8"
1492
+ );
1493
+ await fs8.outputFile(
1494
+ path9.join(projectDir, "docs", "style-guide", "ui", "dark-mode.md"),
1495
+ `# Dark mode
1496
+
1497
+ next-themes + Tailwind \`dark:\` variants.
1498
+ Provider: \`src/providers/theme-provider.tsx\` (via \`AppProviders\`).
1499
+ `
1500
+ );
1501
+ const globalsPath = path9.join(projectDir, "src", "app", "globals.css");
1502
+ if (await fs8.pathExists(globalsPath)) {
1503
+ let css = await fs8.readFile(globalsPath, "utf8");
1504
+ if (!css.includes("@custom-variant dark")) {
1505
+ css = css.replace(
1506
+ `@import "tailwindcss";
1507
+ `,
1508
+ `@import "tailwindcss";
1509
+
1510
+ @custom-variant dark (&:where(.dark, .dark *));
1511
+ `
1512
+ );
1513
+ await fs8.writeFile(globalsPath, css, "utf8");
1514
+ }
1515
+ }
1516
+ await addDependencies(projectDir, {
1517
+ "next-themes": "^0.4.4"
1518
+ });
1519
+ }
1520
+ };
1521
+
1522
+ // src/installers/docker-compose.ts
1523
+ import fs9 from "fs-extra";
1524
+ import path10 from "path";
1525
+ var COMPOSE = `services:
1526
+ api:
1527
+ image: nginx:alpine
1528
+ ports:
1529
+ - "8000:80"
1530
+ volumes:
1531
+ - ./docker/api-mock:/usr/share/nginx/html:ro
1532
+ # Replace this stub with your real API image when ready.
1533
+ # JWT apps expect NEXT_PUBLIC_API_URL=http://localhost:8000
1534
+
1535
+ # Example (uncomment when you have a backend image):
1536
+ # api:
1537
+ # image: your-org/api:latest
1538
+ # ports:
1539
+ # - "8000:8000"
1540
+ # environment:
1541
+ # DATABASE_URL: postgres://...
1542
+ `;
1543
+ var MOCK_README = `# Local API stub
1544
+
1545
+ Placeholder for Docker Compose. Point \`NEXT_PUBLIC_API_URL\` at \`http://localhost:8000\`.
1546
+
1547
+ Replace the nginx stub in \`docker-compose.yml\` with your real API service.
1548
+ `;
1549
+ var RULE5 = `---
1550
+ description: Local API via Docker Compose (JWT + endpoints)
1551
+ globs: docker-compose.yml,docker/api-mock/**
1552
+ alwaysApply: false
1553
+ ---
1554
+
1555
+ # Docker Compose (local API)
1556
+
1557
+ - \`docker-compose.yml\` \u2014 stub API on port 8000 for JWT + endpoints apps.
1558
+ - \`docker compose up\` then set \`NEXT_PUBLIC_API_URL=http://localhost:8000\`.
1559
+ - Swap the stub service for your real backend image before production work.
1560
+ `;
1561
+ var dockerComposeInstaller = {
1562
+ id: "docker-compose",
1563
+ active: (o) => o.auth === "jwt" && o.endpoints,
1564
+ async run({ projectDir }) {
1565
+ await fs9.writeFile(
1566
+ path10.join(projectDir, "docker-compose.yml"),
1567
+ COMPOSE,
1568
+ "utf8"
1569
+ );
1570
+ await fs9.outputFile(
1571
+ path10.join(projectDir, "docker", "api-mock", "README.md"),
1572
+ MOCK_README
1573
+ );
1574
+ await fs9.writeFile(
1575
+ path10.join(projectDir, ".cursor", "rules", "docker-compose.mdc"),
1576
+ RULE5,
1577
+ "utf8"
1578
+ );
1579
+ await fs9.outputFile(
1580
+ path10.join(projectDir, "docs", "style-guide", "ops", "docker-compose.md"),
1581
+ `# Docker Compose (local API)
1582
+
1583
+ \`\`\`bash
1584
+ docker compose up
1585
+ \`\`\`
1586
+
1587
+ Stub listens on \`http://localhost:8000\`. Align with \`NEXT_PUBLIC_API_URL\`.
1588
+ `
1589
+ );
1590
+ }
1591
+ };
1592
+
1593
+ // src/installers/docker.ts
1594
+ import fs10 from "fs-extra";
1595
+ import path11 from "path";
1596
+ var STANDALONE_CONFIG = `import type { NextConfig } from "next";
1597
+
1598
+ const nextConfig: NextConfig = {
1599
+ reactStrictMode: true,
1600
+ output: "standalone",
1601
+ };
1602
+
1603
+ export default nextConfig;
1604
+ `;
1605
+ function dockerfileFor(pm) {
1606
+ if (pm === "npm") {
1607
+ return `# ---------------------------------------------------------------------------- #
1608
+ # Stage 1: Base & Dependencies #
1609
+ # ---------------------------------------------------------------------------- #
1610
+ FROM node:22-alpine AS builder
1611
+
1612
+ RUN apk add --no-cache libc6-compat
1613
+
1614
+ WORKDIR /app
1615
+
1616
+ ENV HUSKY=0 \\
1617
+ NEXT_TELEMETRY_DISABLED=1
1618
+
1619
+ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
1620
+ ARG NEXT_PUBLIC_API_URL=http://localhost:8000
1621
+
1622
+ ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \\
1623
+ NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
1624
+
1625
+ COPY package.json package-lock.json .npmrc* ./
1626
+ RUN --mount=type=cache,id=npm,target=/root/.npm \\
1627
+ npm ci
1628
+
1629
+ COPY . /app/
1630
+
1631
+ RUN npm run build
1632
+
1633
+ # ---------------------------------------------------------------------------- #
1634
+ # Stage 2: Production Runner #
1635
+ # ---------------------------------------------------------------------------- #
1636
+ FROM node:22-alpine AS runner
1637
+
1638
+ RUN apk add --no-cache curl
1639
+
1640
+ WORKDIR /app
1641
+
1642
+ ENV NODE_ENV=production \\
1643
+ NEXT_TELEMETRY_DISABLED=1 \\
1644
+ HOSTNAME=0.0.0.0 \\
1645
+ PORT=3000
1646
+
1647
+ COPY --from=builder /app/public ./public
1648
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
1649
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
1650
+
1651
+ USER node
1652
+
1653
+ EXPOSE 3000
1654
+
1655
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \\
1656
+ CMD curl -fsS http://127.0.0.1:3000/ || exit 1
1657
+
1658
+ CMD ["node", "server.js"]
1659
+ `;
1660
+ }
1661
+ if (pm === "yarn") {
1662
+ return `# ---------------------------------------------------------------------------- #
1663
+ # Stage 1: Base & Dependencies #
1664
+ # ---------------------------------------------------------------------------- #
1665
+ FROM node:22-alpine AS builder
1666
+
1667
+ RUN apk add --no-cache libc6-compat
1668
+ RUN corepack enable && corepack prepare yarn@1.22.22 --activate
1669
+
1670
+ WORKDIR /app
1671
+
1672
+ ENV HUSKY=0 \\
1673
+ NEXT_TELEMETRY_DISABLED=1
1674
+
1675
+ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
1676
+ ARG NEXT_PUBLIC_API_URL=http://localhost:8000
1677
+
1678
+ ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \\
1679
+ NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
1680
+
1681
+ COPY package.json yarn.lock .npmrc* ./
1682
+ RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \\
1683
+ yarn install --frozen-lockfile
1684
+
1685
+ COPY . /app/
1686
+
1687
+ RUN yarn build
1688
+
1689
+ # ---------------------------------------------------------------------------- #
1690
+ # Stage 2: Production Runner #
1691
+ # ---------------------------------------------------------------------------- #
1692
+ FROM node:22-alpine AS runner
1693
+
1694
+ RUN apk add --no-cache curl
1695
+
1696
+ WORKDIR /app
1697
+
1698
+ ENV NODE_ENV=production \\
1699
+ NEXT_TELEMETRY_DISABLED=1 \\
1700
+ HOSTNAME=0.0.0.0 \\
1701
+ PORT=3000
1702
+
1703
+ COPY --from=builder /app/public ./public
1704
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
1705
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
1706
+
1707
+ USER node
1708
+
1709
+ EXPOSE 3000
1710
+
1711
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \\
1712
+ CMD curl -fsS http://127.0.0.1:3000/ || exit 1
1713
+
1714
+ CMD ["node", "server.js"]
1715
+ `;
1716
+ }
1717
+ return `# ---------------------------------------------------------------------------- #
1718
+ # Stage 1: Base & Dependencies #
1719
+ # ---------------------------------------------------------------------------- #
1720
+ FROM node:22-alpine AS builder
1721
+
1722
+ RUN apk add --no-cache libc6-compat
1723
+ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
1724
+
1725
+ WORKDIR /app
1726
+
1727
+ ENV HUSKY=0 \\
1728
+ NEXT_TELEMETRY_DISABLED=1
1729
+
1730
+ ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
1731
+ ARG NEXT_PUBLIC_API_URL=http://localhost:8000
1732
+
1733
+ ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \\
1734
+ NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
1735
+
1736
+ COPY package.json pnpm-lock.yaml .npmrc* ./
1737
+ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \\
1738
+ pnpm install --frozen-lockfile
1739
+
1740
+ COPY . /app/
1741
+
1742
+ RUN pnpm build
1743
+
1744
+ # ---------------------------------------------------------------------------- #
1745
+ # Stage 2: Production Runner #
1746
+ # ---------------------------------------------------------------------------- #
1747
+ FROM node:22-alpine AS runner
1748
+
1749
+ RUN apk add --no-cache curl
1750
+
1751
+ WORKDIR /app
1752
+
1753
+ ENV NODE_ENV=production \\
1754
+ NEXT_TELEMETRY_DISABLED=1 \\
1755
+ HOSTNAME=0.0.0.0 \\
1756
+ PORT=3000
1757
+
1758
+ COPY --from=builder /app/public ./public
1759
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
1760
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
1761
+
1762
+ USER node
1763
+
1764
+ EXPOSE 3000
1765
+
1766
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \\
1767
+ CMD curl -fsS http://127.0.0.1:3000/ || exit 1
1768
+
1769
+ CMD ["node", "server.js"]
1770
+ `;
1771
+ }
1772
+ var dockerInstaller = {
1773
+ id: "docker",
1774
+ active: (o) => o.docker,
1775
+ async run({ projectDir, options }) {
1776
+ const addon = path11.join(ADDONS_DIR, "docker");
1777
+ const pm = getPackageManager(options.packageManager);
1778
+ await fs10.ensureDir(path11.join(projectDir, "docker"));
1779
+ await fs10.writeFile(
1780
+ path11.join(projectDir, "docker", "production.Dockerfile"),
1781
+ dockerfileFor(options.packageManager),
1782
+ "utf8"
1783
+ );
1784
+ await fs10.copy(
1785
+ path11.join(addon, ".dockerignore"),
1786
+ path11.join(projectDir, ".dockerignore")
1787
+ );
1788
+ await fs10.copy(
1789
+ path11.join(addon, ".cursor", "rules", "docker.mdc"),
1790
+ path11.join(projectDir, ".cursor", "rules", "docker.mdc")
1791
+ );
1792
+ let doc = await fs10.readFile(
1793
+ path11.join(addon, "docs", "style-guide", "ops", "docker.md"),
1794
+ "utf8"
1795
+ );
1796
+ doc = doc.replaceAll("__PROJECT_SLUG__", options.slug).replaceAll("__PACKAGE_MANAGER__", pm.id);
1797
+ await fs10.outputFile(
1798
+ path11.join(projectDir, "docs", "style-guide", "ops", "docker.md"),
1799
+ doc
1800
+ );
1801
+ await fs10.writeFile(
1802
+ path11.join(projectDir, "next.config.ts"),
1803
+ STANDALONE_CONFIG,
1804
+ "utf8"
1805
+ );
1806
+ }
1807
+ };
1808
+
1809
+ // src/installers/endpoints.ts
1810
+ import fs11 from "fs-extra";
1811
+ import path12 from "path";
1812
+ var endpointsInstaller = {
1813
+ id: "endpoints",
1814
+ active: (o) => o.endpoints,
1815
+ async run({ projectDir }) {
1816
+ const addon = path12.join(ADDONS_DIR, "endpoints");
1817
+ await fs11.copy(
1818
+ path12.join(addon, "scripts"),
1819
+ path12.join(projectDir, "scripts")
1820
+ );
1821
+ await fs11.copy(
1822
+ path12.join(addon, "src", "endpoints"),
1823
+ path12.join(projectDir, "src", "endpoints")
1824
+ );
1825
+ for (const file of [
1826
+ "axios-instance.ts",
1827
+ "api-result.ts",
1828
+ "api-error.ts",
1829
+ "search-params.ts",
1830
+ "form-data.ts"
1831
+ ]) {
1832
+ await fs11.copy(
1833
+ path12.join(addon, "src", "utils", file),
1834
+ path12.join(projectDir, "src", "utils", file)
1835
+ );
1836
+ }
1837
+ await fs11.remove(path12.join(projectDir, "src", "utils", ".gitkeep"));
1838
+ await fs11.copy(
1839
+ path12.join(addon, ".cursor", "rules", "endpoints.mdc"),
1840
+ path12.join(projectDir, ".cursor", "rules", "endpoints.mdc")
1841
+ );
1842
+ await fs11.copy(
1843
+ path12.join(addon, "docs", "style-guide", "http", "endpoints.md"),
1844
+ path12.join(projectDir, "docs", "style-guide", "http", "endpoints.md")
1845
+ );
1846
+ await addDependencies(projectDir, { axios: "^1.7.9" });
1847
+ await addDependencies(
1848
+ projectDir,
1849
+ { tsx: "^4.19.2" },
1850
+ "devDependencies"
1851
+ );
1852
+ await addScripts(projectDir, {
1853
+ "endpoints:generate": "tsx scripts/generate-endpoints.ts"
1854
+ });
1855
+ }
1856
+ };
1857
+
1858
+ // src/installers/eslint.ts
1859
+ import fs12 from "fs-extra";
1860
+ import path13 from "path";
1861
+ var eslintInstaller = {
1862
+ id: "eslint",
1863
+ active: (o) => o.eslint,
1864
+ async run({ projectDir }) {
1865
+ const addon = path13.join(ADDONS_DIR, "eslint");
1866
+ await fs12.copy(
1867
+ path13.join(addon, "eslint.config.mjs"),
1868
+ path13.join(projectDir, "eslint.config.mjs")
1869
+ );
1870
+ await fs12.copy(
1871
+ path13.join(addon, ".cursor", "rules", "eslint.mdc"),
1872
+ path13.join(projectDir, ".cursor", "rules", "eslint.mdc")
1873
+ );
1874
+ await fs12.copy(
1875
+ path13.join(addon, "docs", "style-guide", "tooling", "eslint.md"),
1876
+ path13.join(projectDir, "docs", "style-guide", "tooling", "eslint.md")
1877
+ );
1878
+ await addDependencies(
1879
+ projectDir,
1880
+ {
1881
+ "eslint-config-prettier": "^10.1.8",
1882
+ "eslint-plugin-import": "^2.32.0",
1883
+ "eslint-plugin-simple-import-sort": "^12.1.1"
1884
+ },
1885
+ "devDependencies"
1886
+ );
1887
+ await addScripts(projectDir, {
1888
+ lint: "eslint .",
1889
+ "lint:fix": "eslint . --fix"
1890
+ });
1891
+ }
1892
+ };
1893
+
1894
+ // src/installers/husky.ts
1895
+ import fs13 from "fs-extra";
1896
+ import path14 from "path";
1897
+ var huskyInstaller = {
1898
+ id: "husky",
1899
+ active: (o) => o.husky,
1900
+ async run({ projectDir, options }) {
1901
+ const addon = path14.join(ADDONS_DIR, "husky");
1902
+ await fs13.copy(path14.join(addon, ".husky"), path14.join(projectDir, ".husky"));
1903
+ await fs13.copy(
1904
+ path14.join(addon, "docs", "style-guide", "tooling", "husky.md"),
1905
+ path14.join(projectDir, "docs", "style-guide", "tooling", "husky.md")
1906
+ );
1907
+ try {
1908
+ await fs13.chmod(path14.join(projectDir, ".husky", "pre-commit"), 493);
1909
+ } catch {
1910
+ }
1911
+ await addDependencies(
1912
+ projectDir,
1913
+ {
1914
+ husky: "^9.1.7",
1915
+ "lint-staged": "^15.2.11"
1916
+ },
1917
+ "devDependencies"
1918
+ );
1919
+ await addScripts(projectDir, { prepare: "husky" });
1920
+ const tsHooks = [];
1921
+ if (options.prettier) tsHooks.push("prettier --write");
1922
+ if (options.eslint) tsHooks.push("eslint --fix");
1923
+ if (tsHooks.length === 0) tsHooks.push("echo 'husky: no lint/format tools'");
1924
+ const lintStaged = {
1925
+ "*.{js,jsx,ts,tsx}": tsHooks
1926
+ };
1927
+ if (options.prettier) {
1928
+ lintStaged["*.{json,css,scss,md,yaml,yml}"] = ["prettier --write"];
1929
+ }
1930
+ await mergePackageField(projectDir, "lint-staged", lintStaged);
1931
+ }
1932
+ };
1933
+
1934
+ // src/installers/i18n.ts
1935
+ import fs14 from "fs-extra";
1936
+ import path15 from "path";
1937
+ var I18N_REQUEST = `import { getRequestConfig } from "next-intl/server";
1938
+
1939
+ export default getRequestConfig(async () => {
1940
+ const locale = "en";
1941
+ return {
1942
+ locale,
1943
+ messages: (await import(\`../../messages/\${locale}.json\`)).default,
1944
+ };
1945
+ });
1946
+ `;
1947
+ var MESSAGES_EN = `{
1948
+ "Home": {
1949
+ "title": "Welcome",
1950
+ "body": "Scaffolded with next-style-guide."
1951
+ }
1952
+ }
1953
+ `;
1954
+ var RULE6 = `---
1955
+ description: next-intl i18n conventions
1956
+ globs: src/i18n/**,messages/**,src/providers/app-providers.tsx
1957
+ alwaysApply: false
1958
+ ---
1959
+
1960
+ # i18n (next-intl)
1961
+
1962
+ - Request config: \`src/i18n/request.ts\`.
1963
+ - Messages: \`messages/<locale>.json\`.
1964
+ - Prefer \`useTranslations\` / \`getTranslations\` \u2014 avoid hardcoded copy when a key exists.
1965
+ - \`NextIntlClientProvider\` is composed in \`AppProviders\`; root layout loads locale/messages.
1966
+ `;
1967
+ var i18nInstaller = {
1968
+ id: "i18n",
1969
+ active: (o) => o.i18n,
1970
+ async run({ projectDir }) {
1971
+ await fs14.outputFile(
1972
+ path15.join(projectDir, "src", "i18n", "request.ts"),
1973
+ I18N_REQUEST
1974
+ );
1975
+ await fs14.outputFile(
1976
+ path15.join(projectDir, "messages", "en.json"),
1977
+ MESSAGES_EN
1978
+ );
1979
+ await fs14.writeFile(
1980
+ path15.join(projectDir, ".cursor", "rules", "i18n.mdc"),
1981
+ RULE6,
1982
+ "utf8"
1983
+ );
1984
+ await fs14.outputFile(
1985
+ path15.join(projectDir, "docs", "style-guide", "i18n", "overview.md"),
1986
+ `# i18n
1987
+
1988
+ next-intl: \`src/i18n/request.ts\` + \`messages/en.json\`
1989
+ Plugin wired in \`next.config.ts\`. Provider via \`AppProviders\`.
1990
+ `
1991
+ );
1992
+ const nextConfigPath = path15.join(projectDir, "next.config.ts");
1993
+ if (await fs14.pathExists(nextConfigPath)) {
1994
+ let nextConfig = await fs14.readFile(nextConfigPath, "utf8");
1995
+ if (!nextConfig.includes("next-intl")) {
1996
+ if (!nextConfig.includes("createNextIntlPlugin")) {
1997
+ nextConfig = `import createNextIntlPlugin from "next-intl/plugin";
1998
+
1999
+ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
2000
+
2001
+ ${nextConfig}`;
2002
+ }
2003
+ nextConfig = nextConfig.replace(
2004
+ "export default nextConfig;",
2005
+ "export default withNextIntl(nextConfig);"
2006
+ );
2007
+ await fs14.writeFile(nextConfigPath, nextConfig, "utf8");
2008
+ }
2009
+ }
2010
+ await addDependencies(projectDir, {
2011
+ "next-intl": "^3.26.3"
2012
+ });
2013
+ }
2014
+ };
2015
+
2016
+ // src/installers/icons.ts
2017
+ import fs15 from "fs-extra";
2018
+ import path16 from "path";
2019
+ var RULE7 = `---
2020
+ description: Icon library conventions
2021
+ globs: "src/**/*.{ts,tsx}"
2022
+ alwaysApply: false
2023
+ ---
2024
+
2025
+ # Icons
2026
+
2027
+ __ICONS_BODY__
2028
+ `;
2029
+ var BODIES = {
2030
+ lucide: `- Use \`lucide-react\` icons (\`import { IconName } from "lucide-react"\`).
2031
+ - Prefer Lucide when adding shadcn primitives later.`,
2032
+ tabler: `- Use \`@tabler/icons-react\` with the \`Icon*\` prefix (\`import { IconHome } from "@tabler/icons-react"\`).
2033
+ - Do not mix Lucide and Tabler in the same app.`
2034
+ };
2035
+ var iconsInstaller = {
2036
+ id: "icons",
2037
+ active: (o) => o.icons !== "none",
2038
+ async run({ projectDir, options }) {
2039
+ if (options.icons === "none") return;
2040
+ const pkg = ICON_PACKAGES[options.icons];
2041
+ await addDependencies(projectDir, { [pkg.name]: pkg.version });
2042
+ const ruleDir = path16.join(projectDir, ".cursor", "rules");
2043
+ await fs15.ensureDir(ruleDir);
2044
+ await fs15.writeFile(
2045
+ path16.join(ruleDir, "icons.mdc"),
2046
+ RULE7.replace("__ICONS_BODY__", BODIES[options.icons]),
2047
+ "utf8"
2048
+ );
2049
+ }
2050
+ };
2051
+
2052
+ // src/installers/mui.ts
2053
+ import fs16 from "fs-extra";
2054
+ import path17 from "path";
2055
+ var THEME_PROVIDER2 = `"use client";
2056
+
2057
+ import { type ReactNode } from "react";
2058
+
2059
+ import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
2060
+ import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
2061
+
2062
+ const theme = createTheme({
2063
+ cssVariables: true,
2064
+ palette: {
2065
+ mode: "light",
2066
+ primary: { main: "#0b5fff" },
2067
+ },
2068
+ });
2069
+
2070
+ export function MuiProvider({ children }: { children: ReactNode }) {
2071
+ return (
2072
+ <AppRouterCacheProvider>
2073
+ <ThemeProvider theme={theme}>
2074
+ <CssBaseline />
2075
+ {children}
2076
+ </ThemeProvider>
2077
+ </AppRouterCacheProvider>
2078
+ );
2079
+ }
2080
+ `;
2081
+ var RULE8 = `---
2082
+ description: MUI (Material UI) conventions
2083
+ globs: src/providers/mui-provider.tsx,src/**/*.{ts,tsx}
2084
+ alwaysApply: false
2085
+ ---
2086
+
2087
+ # MUI
2088
+
2089
+ - Theme via \`MuiProvider\` (\`src/providers/mui-provider.tsx\`), composed in \`AppProviders\`.
2090
+ - Prefer MUI components (\`Button\`, \`TextField\`, \`Stack\`, \u2026) over ad-hoc CSS.
2091
+ - Do not add shadcn \u2014 incompatible with this styling choice.
2092
+ - Emotion cache: \`@mui/material-nextjs\` App Router provider (already wired).
2093
+ `;
2094
+ var muiInstaller = {
2095
+ id: "mui",
2096
+ active: (o) => o.styling === "mui",
2097
+ async run({ projectDir }) {
2098
+ const providersDir = path17.join(projectDir, "src", "providers");
2099
+ await fs16.ensureDir(providersDir);
2100
+ await fs16.remove(path17.join(providersDir, ".gitkeep"));
2101
+ await fs16.writeFile(
2102
+ path17.join(providersDir, "mui-provider.tsx"),
2103
+ THEME_PROVIDER2,
2104
+ "utf8"
2105
+ );
2106
+ await fs16.writeFile(
2107
+ path17.join(projectDir, ".cursor", "rules", "mui.mdc"),
2108
+ RULE8,
2109
+ "utf8"
2110
+ );
2111
+ await addDependencies(projectDir, {
2112
+ "@mui/material": "^6.4.0",
2113
+ "@mui/material-nextjs": "^6.4.0",
2114
+ "@emotion/react": "^11.14.0",
2115
+ "@emotion/styled": "^11.14.0",
2116
+ "@emotion/cache": "^11.14.0"
2117
+ });
2118
+ }
2119
+ };
2120
+
2121
+ // src/installers/package-manager.ts
2122
+ import fs17 from "fs-extra";
2123
+ import path18 from "path";
2124
+ var packageManagerInstaller = {
2125
+ id: "package-manager",
2126
+ active: () => true,
2127
+ async run({ projectDir, options }) {
2128
+ const meta = getPackageManager(options.packageManager);
2129
+ const pkgPath = path18.join(projectDir, "package.json");
2130
+ const pkg = await fs17.readJson(pkgPath);
2131
+ if (meta.packageManagerField) {
2132
+ pkg.packageManager = meta.packageManagerField;
2133
+ } else {
2134
+ delete pkg.packageManager;
2135
+ }
2136
+ await fs17.writeJson(pkgPath, pkg, { spaces: 2 });
2137
+ }
2138
+ };
2139
+
2140
+ // src/installers/prettier.ts
2141
+ import fs18 from "fs-extra";
2142
+ import path19 from "path";
2143
+ var prettierInstaller = {
2144
+ id: "prettier",
2145
+ active: (o) => o.prettier,
2146
+ async run({ projectDir }) {
2147
+ const addon = path19.join(ADDONS_DIR, "prettier");
2148
+ await fs18.copy(
2149
+ path19.join(addon, ".prettierrc"),
2150
+ path19.join(projectDir, ".prettierrc")
2151
+ );
2152
+ await fs18.copy(
2153
+ path19.join(addon, ".cursor", "rules", "prettier.mdc"),
2154
+ path19.join(projectDir, ".cursor", "rules", "prettier.mdc")
2155
+ );
2156
+ await fs18.copy(
2157
+ path19.join(addon, "docs", "style-guide", "tooling", "prettier.md"),
2158
+ path19.join(projectDir, "docs", "style-guide", "tooling", "prettier.md")
2159
+ );
2160
+ await addDependencies(
2161
+ projectDir,
2162
+ { prettier: "^3.6.2" },
2163
+ "devDependencies"
2164
+ );
2165
+ await addScripts(projectDir, {
2166
+ format: "prettier --write .",
2167
+ "format:check": "prettier --check ."
2168
+ });
2169
+ }
2170
+ };
2171
+
2172
+ // src/installers/seo.ts
2173
+ import fs19 from "fs-extra";
2174
+ import path20 from "path";
2175
+ var SEO_TS = `import type { Metadata } from "next";
2176
+
2177
+ import { env } from "@/lib/env";
2178
+
2179
+ export const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME?.trim() || "__PROJECT_NAME__";
2180
+ export const DEFAULT_DESCRIPTION =
2181
+ process.env.NEXT_PUBLIC_SITE_DESCRIPTION?.trim() ||
2182
+ "Generated with next-style-guide";
2183
+
2184
+ /** Absolute public origin (no trailing slash). */
2185
+ export function getAppUrl(): string {
2186
+ try {
2187
+ return new URL(env.NEXT_PUBLIC_APP_URL).origin;
2188
+ } catch {
2189
+ return "http://localhost:3000";
2190
+ }
2191
+ }
2192
+
2193
+ export function absoluteUrl(path: string): string {
2194
+ const base = getAppUrl();
2195
+ if (!path || path === "/") return base;
2196
+ return \`\${base}\${path.startsWith("/") ? path : \`/\${path}\`}\`;
2197
+ }
2198
+
2199
+ export type SitemapChangeFrequency =
2200
+ | "always"
2201
+ | "hourly"
2202
+ | "daily"
2203
+ | "weekly"
2204
+ | "monthly"
2205
+ | "yearly"
2206
+ | "never";
2207
+
2208
+ export function buildPublicMetadata(input: {
2209
+ title: string;
2210
+ description: string;
2211
+ path: string;
2212
+ absoluteTitle?: string;
2213
+ noIndex?: boolean;
2214
+ }): Metadata {
2215
+ const url = absoluteUrl(input.path);
2216
+ return {
2217
+ title: input.absoluteTitle ?? input.title,
2218
+ description: input.description,
2219
+ alternates: { canonical: url },
2220
+ openGraph: {
2221
+ title: input.absoluteTitle ?? input.title,
2222
+ description: input.description,
2223
+ url,
2224
+ siteName: SITE_NAME,
2225
+ type: "website",
2226
+ },
2227
+ robots: input.noIndex
2228
+ ? { index: false, follow: false }
2229
+ : { index: true, follow: true },
2230
+ };
2231
+ }
2232
+ `;
2233
+ var PUBLIC_ROUTES = `import {
2234
+ buildPublicMetadata,
2235
+ DEFAULT_DESCRIPTION,
2236
+ SITE_NAME,
2237
+ type SitemapChangeFrequency,
2238
+ } from "@/lib/seo";
2239
+
2240
+ /**
2241
+ * Indexable URLs \u2014 single source of truth for sitemap + page metadata.
2242
+ * Add a route under \`src/app/\`, then register it here.
2243
+ */
2244
+ export type PublicRoute = {
2245
+ path: string;
2246
+ title: string;
2247
+ description: string;
2248
+ absoluteTitle?: string;
2249
+ changeFrequency: SitemapChangeFrequency;
2250
+ priority: number;
2251
+ lastModified?: string;
2252
+ noIndex?: boolean;
2253
+ };
2254
+
2255
+ export const STATIC_PUBLIC_ROUTES: PublicRoute[] = [
2256
+ {
2257
+ path: "/",
2258
+ title: SITE_NAME,
2259
+ description: DEFAULT_DESCRIPTION,
2260
+ changeFrequency: "weekly",
2261
+ priority: 1,
2262
+ lastModified: "2026-01-01",
2263
+ },
2264
+ ];
2265
+
2266
+ export function publicPageMetadata(path: string) {
2267
+ const route = STATIC_PUBLIC_ROUTES.find((item) => item.path === path);
2268
+ if (!route) {
2269
+ throw new Error(\`Missing public route registration for \${path}\`);
2270
+ }
2271
+ return buildPublicMetadata(route);
2272
+ }
2273
+ `;
2274
+ var SITEMAP = `import type { MetadataRoute } from "next";
2275
+
2276
+ import { absoluteUrl } from "@/lib/seo";
2277
+ import { STATIC_PUBLIC_ROUTES } from "@/lib/public-routes";
2278
+
2279
+ export default function sitemap(): MetadataRoute.Sitemap {
2280
+ return STATIC_PUBLIC_ROUTES.filter((route) => !route.noIndex).map((route) => ({
2281
+ url: absoluteUrl(route.path),
2282
+ lastModified: route.lastModified,
2283
+ changeFrequency: route.changeFrequency,
2284
+ priority: route.priority,
2285
+ }));
2286
+ }
2287
+ `;
2288
+ var ROBOTS = `import type { MetadataRoute } from "next";
2289
+
2290
+ import { getAppUrl } from "@/lib/seo";
2291
+
2292
+ export default function robots(): MetadataRoute.Robots {
2293
+ const origin = getAppUrl();
2294
+ return {
2295
+ rules: {
2296
+ userAgent: "*",
2297
+ allow: "/",
2298
+ },
2299
+ sitemap: \`\${origin}/sitemap.xml\`,
2300
+ };
2301
+ }
2302
+ `;
2303
+ var RULE9 = `---
2304
+ description: SEO + public routes
2305
+ globs: src/lib/public-routes.ts,src/lib/seo.ts,src/app/sitemap.ts,src/app/robots.ts,src/app/**/page.tsx
2306
+ alwaysApply: false
2307
+ ---
2308
+
2309
+ # SEO
2310
+
2311
+ - Register every indexable path in \`STATIC_PUBLIC_ROUTES\` (\`src/lib/public-routes.ts\`).
2312
+ - Page metadata: \`publicPageMetadata("/path")\` \u2014 keep canonical/title/description in sync.
2313
+ - \`sitemap.ts\` / \`robots.ts\` read from that registry \u2014 don't hardcode URLs twice.
2314
+ - Single \`<main>\` in root layout only.
2315
+ - Placeholders / thin pages: \`noIndex: true\` and keep them out of the sitemap.
2316
+ - Prefer static \`lastModified\` dates over \`new Date()\` for every route.
2317
+ `;
2318
+ var seoInstaller = {
2319
+ id: "seo",
2320
+ active: (o) => o.seo,
2321
+ async run({ projectDir, options }) {
2322
+ let seo = SEO_TS.replaceAll("__PROJECT_NAME__", options.name);
2323
+ await fs19.writeFile(path20.join(projectDir, "src", "lib", "seo.ts"), seo, "utf8");
2324
+ await fs19.writeFile(
2325
+ path20.join(projectDir, "src", "lib", "public-routes.ts"),
2326
+ PUBLIC_ROUTES,
2327
+ "utf8"
2328
+ );
2329
+ await fs19.writeFile(
2330
+ path20.join(projectDir, "src", "app", "sitemap.ts"),
2331
+ SITEMAP,
2332
+ "utf8"
2333
+ );
2334
+ await fs19.writeFile(
2335
+ path20.join(projectDir, "src", "app", "robots.ts"),
2336
+ ROBOTS,
2337
+ "utf8"
2338
+ );
2339
+ await fs19.writeFile(
2340
+ path20.join(projectDir, ".cursor", "rules", "seo.mdc"),
2341
+ RULE9,
2342
+ "utf8"
2343
+ );
2344
+ await fs19.outputFile(
2345
+ path20.join(projectDir, "docs", "style-guide", "seo", "overview.md"),
2346
+ `# SEO
2347
+
2348
+ - Registry: \`src/lib/public-routes.ts\`
2349
+ - Helpers: \`src/lib/seo.ts\`
2350
+ - \`sitemap.ts\` / \`robots.ts\` under \`src/app/\`
2351
+
2352
+ Add a page \u2192 register the path \u2192 metadata/sitemap update automatically.
2353
+ `
2354
+ );
2355
+ }
2356
+ };
2357
+
2358
+ // src/installers/shadcn.ts
2359
+ import fs20 from "fs-extra";
2360
+ import path21 from "path";
2361
+ function componentsJson(iconLibrary) {
2362
+ return `${JSON.stringify(
2363
+ {
2364
+ $schema: "https://ui.shadcn.com/schema.json",
2365
+ style: "new-york",
2366
+ rsc: true,
2367
+ tsx: true,
2368
+ tailwind: {
2369
+ config: "",
2370
+ css: "src/app/globals.css",
2371
+ baseColor: "neutral",
2372
+ cssVariables: true,
2373
+ prefix: ""
2374
+ },
2375
+ iconLibrary,
2376
+ aliases: {
2377
+ components: "@/components",
2378
+ utils: "@/lib/utils",
2379
+ ui: "@/components/ui",
2380
+ lib: "@/lib",
2381
+ hooks: "@/hooks"
2382
+ }
2383
+ },
2384
+ null,
2385
+ 2
2386
+ )}
2387
+ `;
2388
+ }
2389
+ var BUTTON = `import { cva, type VariantProps } from "class-variance-authority";
2390
+ import type { ComponentProps } from "react";
2391
+
2392
+ import { cn } from "@/lib/utils";
2393
+
2394
+ const buttonVariants = cva(
2395
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
2396
+ {
2397
+ variants: {
2398
+ variant: {
2399
+ default: "bg-neutral-900 text-white hover:bg-neutral-800",
2400
+ outline: "border border-neutral-300 bg-transparent hover:bg-neutral-100",
2401
+ ghost: "hover:bg-neutral-100",
2402
+ },
2403
+ size: {
2404
+ default: "h-9 px-4 py-2",
2405
+ sm: "h-8 rounded-md px-3 text-xs",
2406
+ lg: "h-10 rounded-md px-6",
2407
+ },
2408
+ },
2409
+ defaultVariants: {
2410
+ variant: "default",
2411
+ size: "default",
2412
+ },
2413
+ },
2414
+ );
2415
+
2416
+ export function Button({
2417
+ className,
2418
+ variant,
2419
+ size,
2420
+ ...props
2421
+ }: ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
2422
+ return (
2423
+ <button className={cn(buttonVariants({ variant, size, className }))} {...props} />
2424
+ );
2425
+ }
2426
+ `;
2427
+ var RULE10 = `---
2428
+ description: shadcn/ui conventions
2429
+ globs: components.json,src/components/ui/**,src/lib/utils.ts
2430
+ alwaysApply: false
2431
+ ---
2432
+
2433
+ # shadcn
2434
+
2435
+ - Primitives live flat in \`src/components/ui/*\` (CLI-compatible).
2436
+ - Starter \`Button\` is included \u2014 add more with \`npx shadcn@latest add \u2026\`.
2437
+ - \`cn()\` from \`@/lib/utils\` for class merging.
2438
+ - Config: \`components.json\`. Requires Tailwind.
2439
+ `;
2440
+ var shadcnInstaller = {
2441
+ id: "shadcn",
2442
+ active: (o) => o.ui === "shadcn" && o.styling === "tailwind",
2443
+ async run({ projectDir, options }) {
2444
+ const iconLibrary = options.icons === "tabler" ? "tabler" : "lucide";
2445
+ await fs20.writeFile(
2446
+ path21.join(projectDir, "components.json"),
2447
+ componentsJson(iconLibrary),
2448
+ "utf8"
2449
+ );
2450
+ await fs20.ensureDir(path21.join(projectDir, "src", "components", "ui"));
2451
+ await fs20.writeFile(
2452
+ path21.join(projectDir, "src", "components", "ui", "button.tsx"),
2453
+ BUTTON,
2454
+ "utf8"
2455
+ );
2456
+ await fs20.remove(path21.join(projectDir, "src", "components", "ui", ".gitkeep"));
2457
+ await fs20.remove(path21.join(projectDir, "src", "components", ".gitkeep"));
2458
+ await fs20.writeFile(
2459
+ path21.join(projectDir, ".cursor", "rules", "shadcn.mdc"),
2460
+ RULE10,
2461
+ "utf8"
2462
+ );
2463
+ if (options.icons === "none") {
2464
+ await addDependencies(projectDir, {
2465
+ "lucide-react": "^0.469.0"
2466
+ });
2467
+ }
2468
+ await addDependencies(projectDir, {
2469
+ "class-variance-authority": "^0.7.1"
2470
+ });
2471
+ }
2472
+ };
2473
+
2474
+ // src/installers/store.ts
2475
+ import fs21 from "fs-extra";
2476
+ import path22 from "path";
2477
+ var ZUSTAND_STORE = `import { create } from "zustand";
2478
+
2479
+ type AppState = {
2480
+ hydrated: boolean;
2481
+ setHydrated: (value: boolean) => void;
2482
+ };
2483
+
2484
+ /** App-wide client store \u2014 extend slices as the product grows. */
2485
+ export const useAppStore = create<AppState>((set) => ({
2486
+ hydrated: false,
2487
+ setHydrated: (value) => set({ hydrated: value }),
2488
+ }));
2489
+ `;
2490
+ var ZUSTAND_RULE = `---
2491
+ description: Zustand client store conventions
2492
+ globs: src/stores/**
2493
+ alwaysApply: false
2494
+ ---
2495
+
2496
+ # Zustand
2497
+
2498
+ - Stores live under \`src/stores/\` (one file per domain slice when possible).
2499
+ - Prefer small selectors: \`useAppStore((s) => s.hydrated)\`.
2500
+ - Do not put server secrets or tokens in the client store.
2501
+ - Server Components stay free of store hooks \u2014 pass data down or use a client leaf.
2502
+ `;
2503
+ var REDUX_STORE = `import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
2504
+
2505
+ type AppSliceState = {
2506
+ hydrated: boolean;
2507
+ };
2508
+
2509
+ const initialState: AppSliceState = {
2510
+ hydrated: false,
2511
+ };
2512
+
2513
+ const appSlice = createSlice({
2514
+ name: "app",
2515
+ initialState,
2516
+ reducers: {
2517
+ setHydrated(state, action: PayloadAction<boolean>) {
2518
+ state.hydrated = action.payload;
2519
+ },
2520
+ },
2521
+ });
2522
+
2523
+ export const { setHydrated } = appSlice.actions;
2524
+
2525
+ export const makeStore = () =>
2526
+ configureStore({
2527
+ reducer: {
2528
+ app: appSlice.reducer,
2529
+ },
2530
+ });
2531
+
2532
+ export type AppStore = ReturnType<typeof makeStore>;
2533
+ export type RootState = ReturnType<AppStore["getState"]>;
2534
+ export type AppDispatch = AppStore["dispatch"];
2535
+ `;
2536
+ var REDUX_HOOKS = `import { useDispatch, useSelector, useStore } from "react-redux";
2537
+
2538
+ import type { AppDispatch, AppStore, RootState } from "@/stores/store";
2539
+
2540
+ export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
2541
+ export const useAppSelector = useSelector.withTypes<RootState>();
2542
+ export const useAppStore = useStore.withTypes<AppStore>();
2543
+ `;
2544
+ var REDUX_PROVIDER = `"use client";
2545
+
2546
+ import { type ReactNode, useRef } from "react";
2547
+ import { Provider } from "react-redux";
2548
+
2549
+ import { makeStore, type AppStore } from "@/stores/store";
2550
+
2551
+ export function StoreProvider({ children }: { children: ReactNode }) {
2552
+ const storeRef = useRef<AppStore | null>(null);
2553
+ if (!storeRef.current) {
2554
+ storeRef.current = makeStore();
2555
+ }
2556
+
2557
+ return <Provider store={storeRef.current}>{children}</Provider>;
2558
+ }
2559
+ `;
2560
+ var REDUX_RULE = `---
2561
+ description: Redux Toolkit client store conventions
2562
+ globs: src/stores/**,src/providers/store-provider.tsx
2563
+ alwaysApply: false
2564
+ ---
2565
+
2566
+ # Redux Toolkit
2567
+
2568
+ - Store factory: \`src/stores/store.ts\` (\`makeStore\`).
2569
+ - Typed hooks: \`src/stores/hooks.ts\` (\`useAppDispatch\` / \`useAppSelector\`).
2570
+ - Provider: \`src/providers/store-provider.tsx\` (composed via \`AppProviders\`).
2571
+ - Prefer RTK slices; keep server secrets out of the client store.
2572
+ `;
2573
+ var storeInstaller = {
2574
+ id: "store",
2575
+ active: (o) => o.store !== "none",
2576
+ async run({ projectDir, options }) {
2577
+ const storesDir = path22.join(projectDir, "src", "stores");
2578
+ await fs21.ensureDir(storesDir);
2579
+ if (options.store === "zustand") {
2580
+ await fs21.writeFile(
2581
+ path22.join(storesDir, "app-store.ts"),
2582
+ ZUSTAND_STORE,
2583
+ "utf8"
2584
+ );
2585
+ await fs21.writeFile(
2586
+ path22.join(projectDir, ".cursor", "rules", "store.mdc"),
2587
+ ZUSTAND_RULE,
2588
+ "utf8"
2589
+ );
2590
+ await fs21.outputFile(
2591
+ path22.join(projectDir, "docs", "style-guide", "state", "zustand.md"),
2592
+ `# Zustand
2593
+
2594
+ - Store: \`src/stores/app-store.ts\`
2595
+ - Extend with domain slices under \`src/stores/\`
2596
+ `
2597
+ );
2598
+ await addDependencies(projectDir, { zustand: "^5.0.3" });
2599
+ return;
2600
+ }
2601
+ await fs21.writeFile(path22.join(storesDir, "store.ts"), REDUX_STORE, "utf8");
2602
+ await fs21.writeFile(path22.join(storesDir, "hooks.ts"), REDUX_HOOKS, "utf8");
2603
+ const providersDir = path22.join(projectDir, "src", "providers");
2604
+ await fs21.ensureDir(providersDir);
2605
+ await fs21.remove(path22.join(providersDir, ".gitkeep"));
2606
+ await fs21.writeFile(
2607
+ path22.join(providersDir, "store-provider.tsx"),
2608
+ REDUX_PROVIDER,
2609
+ "utf8"
2610
+ );
2611
+ await fs21.writeFile(
2612
+ path22.join(projectDir, ".cursor", "rules", "store.mdc"),
2613
+ REDUX_RULE,
2614
+ "utf8"
2615
+ );
2616
+ await fs21.outputFile(
2617
+ path22.join(projectDir, "docs", "style-guide", "state", "redux.md"),
2618
+ `# Redux Toolkit
2619
+
2620
+ - Store: \`src/stores/store.ts\`
2621
+ - Hooks: \`src/stores/hooks.ts\`
2622
+ - Provider: \`src/providers/store-provider.tsx\` (via \`AppProviders\`)
2623
+ `
2624
+ );
2625
+ await addDependencies(projectDir, {
2626
+ "@reduxjs/toolkit": "^2.5.1",
2627
+ "react-redux": "^9.2.0"
2628
+ });
2629
+ }
2630
+ };
2631
+
2632
+ // src/installers/surface.ts
2633
+ import fs22 from "fs-extra";
2634
+ import path23 from "path";
2635
+ function stackLines(options) {
2636
+ const stylingLabel = options.styling === "tailwind" ? options.ui === "shadcn" ? "Tailwind + shadcn/ui" : "Tailwind CSS" : options.styling === "mui" ? "MUI" : "Plain CSS";
2637
+ return [
2638
+ `Package manager: ${options.packageManager}`,
2639
+ `Styling: ${stylingLabel}`,
2640
+ `Validation: ${options.validation}`,
2641
+ `Auth: ${options.auth}`,
2642
+ options.store === "none" ? "No client store" : `Client store: ${options.store}`,
2643
+ options.darkMode ? "Dark mode (next-themes)" : "No dark mode",
2644
+ options.seo ? "SEO helpers (sitemap/robots)" : "No SEO helpers",
2645
+ options.data === "tanstack-query" ? "TanStack Query enabled" : "RSC / fetch (no TanStack Query)",
2646
+ options.icons === "none" ? "No icon library" : `Icons: ${options.icons}`,
2647
+ options.endpoints ? `OpenAPI endpoints codegen (\`${options.packageManager} endpoints:generate\`)` : "No endpoints codegen",
2648
+ options.apiEnvelope ? "API envelope unwrap" : "No envelope unwrap",
2649
+ options.i18n ? "i18n (next-intl)" : "No i18n",
2650
+ options.testing ? options.playwright ? "Vitest + Playwright" : "Vitest" : "No tests",
2651
+ options.eslint ? "Enhanced ESLint" : "Minimal ESLint",
2652
+ options.prettier ? "Prettier" : "No Prettier",
2653
+ options.husky ? "Husky + lint-staged" : "No git hooks",
2654
+ options.docker ? "Docker standalone image" : "No Docker",
2655
+ options.ci ? "GitHub Actions CI" : "No CI workflow"
2656
+ ];
2657
+ }
2658
+ function welcomeCardSource(options) {
2659
+ const items = stackLines(options).map((line) => ` <li>${escapeJsx(line)}</li>`).join("\n");
2660
+ return `/** Homepage sample \u2014 reflects scaffold choices; replace with real UI. */
2661
+ export function WelcomeCard() {
2662
+ return (
2663
+ <div className="card">
2664
+ <h2>Your scaffold</h2>
2665
+ <ul>
2666
+ ${items}
2667
+ </ul>
2668
+ <p className="meta" style={{ marginTop: "1rem" }}>
2669
+ Route UI \u2192 <code>src/app/&lt;route&gt;/_components/</code> \xB7 shared \u2192{" "}
2670
+ <code>src/components/</code>
2671
+ </p>
2672
+ </div>
2673
+ );
2674
+ }
2675
+ `;
2676
+ }
2677
+ function escapeJsx(text2) {
2678
+ return text2.replaceAll("`", "\\`");
2679
+ }
2680
+ function homePageSource(options) {
2681
+ const uiBit = options.ui === "shadcn" ? " \xB7 ui=<code>shadcn</code>" : "";
2682
+ return `import { WelcomeCard } from "@/app/_components/welcome-card";
2683
+
2684
+ export default function HomePage() {
2685
+ return (
2686
+ <>
2687
+ <section className="hero">
2688
+ <h1>${options.name}</h1>
2689
+ <p>
2690
+ Scaffolded with <strong>next-style-guide</strong> (Next.js 16).
2691
+ styling=<code>${options.styling}</code>${uiBit} \xB7 validation=
2692
+ <code>${options.validation}</code> \xB7 icons=<code>${options.icons}</code> \xB7
2693
+ data=<code>${options.data}</code>.
2694
+ </p>
2695
+ </section>
2696
+
2697
+ <WelcomeCard />
2698
+
2699
+ <p className="meta">
2700
+ Read <code>AGENTS.md</code> and <code>docs/style-guide/</code> before
2701
+ adding routes. Feature rules in <code>.cursor/rules/</code> load by glob.
2702
+ </p>
2703
+ </>
2704
+ );
2705
+ }
2706
+ `;
2707
+ }
2708
+ function docsIndex(options) {
2709
+ const rows = [
2710
+ [
2711
+ "[Architecture](overview/architecture.md)",
2712
+ "Layers: app / components / lib / utils"
2713
+ ],
2714
+ ["[Project structure](overview/project-structure.md)", "Folder map"],
2715
+ ["[HTTP client](http/client.md)", "`apiFetch` helper"],
2716
+ [
2717
+ `[Validation](validation/${options.validation}.md)`,
2718
+ options.validation.toUpperCase()
2719
+ ]
2720
+ ];
2721
+ if (options.auth === "jwt") {
2722
+ rows.push(["[JWT auth](auth/jwt.md)", "token-storage + Bearer refresh"]);
2723
+ }
2724
+ if (options.auth === "authjs") {
2725
+ rows.push(["[Auth.js](auth/authjs.md)", "NextAuth stub"]);
2726
+ }
2727
+ if (options.store === "zustand") {
2728
+ rows.push(["[Zustand](state/zustand.md)", "client store"]);
2729
+ }
2730
+ if (options.store === "redux") {
2731
+ rows.push(["[Redux Toolkit](state/redux.md)", "client store"]);
2732
+ }
2733
+ if (options.darkMode) {
2734
+ rows.push(["[Dark mode](ui/dark-mode.md)", "next-themes"]);
2735
+ }
2736
+ if (options.auth === "jwt" && options.endpoints) {
2737
+ rows.push([
2738
+ "[Docker Compose](ops/docker-compose.md)",
2739
+ "local API stub"
2740
+ ]);
2741
+ }
2742
+ if (options.seo) {
2743
+ rows.push(["[SEO](seo/overview.md)", "public-routes \xB7 sitemap \xB7 robots"]);
2744
+ }
2745
+ if (options.i18n) {
2746
+ rows.push(["[i18n](i18n/overview.md)", "next-intl"]);
2747
+ }
2748
+ if (options.apiEnvelope) {
2749
+ rows.push(["[API envelope](http/envelope.md)", "unwrap result"]);
2750
+ }
2751
+ if (options.testing) {
2752
+ rows.push(["[Testing](tooling/testing.md)", "Vitest"]);
2753
+ }
2754
+ if (options.styling === "tailwind") {
2755
+ rows.push(["Tailwind", "See `.cursor/rules/tailwind.mdc`"]);
2756
+ }
2757
+ if (options.ui === "shadcn") {
2758
+ rows.push(["shadcn/ui", "See `.cursor/rules/shadcn.mdc` + `components.json`"]);
2759
+ }
2760
+ if (options.styling === "mui") {
2761
+ rows.push(["MUI", "See `.cursor/rules/mui.mdc`"]);
2762
+ }
2763
+ if (options.endpoints) {
2764
+ rows.push([
2765
+ "[OpenAPI endpoints](http/endpoints.md)",
2766
+ `\`${options.packageManager} endpoints:generate\``
2767
+ ]);
2768
+ }
2769
+ if (options.data === "tanstack-query") {
2770
+ rows.push([
2771
+ "[TanStack Query](data/tanstack-query.md)",
2772
+ "Client cache + QueryProvider"
2773
+ ]);
2774
+ }
2775
+ if (options.icons !== "none") {
2776
+ rows.push(["[Icons](ui/icons.md)", `Icon library (${options.icons})`]);
2777
+ }
2778
+ if (options.eslint) {
2779
+ rows.push(["[ESLint](tooling/eslint.md)", "Import sort + style rules"]);
2780
+ }
2781
+ if (options.prettier) {
2782
+ rows.push(["[Prettier](tooling/prettier.md)", "Formatting"]);
2783
+ }
2784
+ if (options.husky) {
2785
+ rows.push(["[Husky](tooling/husky.md)", "Pre-commit hooks"]);
2786
+ }
2787
+ if (options.docker) {
2788
+ rows.push(["[Docker](ops/docker.md)", "Standalone production image"]);
2789
+ }
2790
+ if (options.ci) {
2791
+ rows.push(["[CI](ops/ci.md)", "GitHub Actions"]);
2792
+ }
2793
+ const table = [
2794
+ "| Page | Topic |",
2795
+ "|------|--------|",
2796
+ ...rows.map(([page, topic]) => `| ${page} | ${topic} |`)
2797
+ ].join("\n");
2798
+ return `# Style guide \u2014 ${options.name}
2799
+
2800
+ Living conventions for this app. Open one page at a time.
2801
+
2802
+ ${table}
2803
+
2804
+ Coding agents: start at [\`AGENTS.md\`](../../AGENTS.md) \u2192 [\`.cursor/rules/\`](../../.cursor/rules/) (glob-scoped).
2805
+ `;
2806
+ }
2807
+ var surfaceInstaller = {
2808
+ id: "surface",
2809
+ active: () => true,
2810
+ async run({ projectDir, options }) {
2811
+ await fs22.writeFile(
2812
+ path23.join(
2813
+ projectDir,
2814
+ "src",
2815
+ "app",
2816
+ "_components",
2817
+ "welcome-card",
2818
+ "index.tsx"
2819
+ ),
2820
+ welcomeCardSource(options),
2821
+ "utf8"
2822
+ );
2823
+ await fs22.writeFile(
2824
+ path23.join(projectDir, "src", "app", "page.tsx"),
2825
+ homePageSource(options),
2826
+ "utf8"
2827
+ );
2828
+ if (options.data === "tanstack-query") {
2829
+ await fs22.copy(
2830
+ path23.join(ADDONS_DIR, "docs", "data", "tanstack-query.md"),
2831
+ path23.join(
2832
+ projectDir,
2833
+ "docs",
2834
+ "style-guide",
2835
+ "data",
2836
+ "tanstack-query.md"
2837
+ )
2838
+ );
2839
+ }
2840
+ if (options.icons !== "none") {
2841
+ await fs22.copy(
2842
+ path23.join(ADDONS_DIR, "docs", "ui", "icons.md"),
2843
+ path23.join(projectDir, "docs", "style-guide", "ui", "icons.md")
2844
+ );
2845
+ }
2846
+ if (options.auth === "jwt" && options.endpoints) {
2847
+ const exampleSrc = path23.join(
2848
+ ADDONS_DIR,
2849
+ "docs",
2850
+ "examples",
2851
+ "api-app-crud.md"
2852
+ );
2853
+ if (await fs22.pathExists(exampleSrc)) {
2854
+ await fs22.copy(
2855
+ exampleSrc,
2856
+ path23.join(projectDir, "docs", "examples", "api-app-crud.md")
2857
+ );
2858
+ }
2859
+ }
2860
+ await fs22.writeFile(
2861
+ path23.join(projectDir, "docs", "style-guide", "README.md"),
2862
+ docsIndex(options),
2863
+ "utf8"
2864
+ );
2865
+ }
2866
+ };
2867
+
2868
+ // src/installers/tailwind.ts
2869
+ import fs23 from "fs-extra";
2870
+ import path24 from "path";
2871
+ var POSTCSS = `const config = {
2872
+ plugins: {
2873
+ "@tailwindcss/postcss": {},
2874
+ },
2875
+ };
2876
+
2877
+ export default config;
2878
+ `;
2879
+ var GLOBALS = `@import "tailwindcss";
2880
+
2881
+ :root {
2882
+ color-scheme: light;
2883
+ --background: #f6f4ef;
2884
+ --foreground: #14201b;
2885
+ --muted: #5b6b63;
2886
+ --accent: #0b5fff;
2887
+ --card: #ffffff;
2888
+ --border: #d9e0db;
2889
+ }
2890
+
2891
+ @theme inline {
2892
+ --color-background: var(--background);
2893
+ --color-foreground: var(--foreground);
2894
+ --color-muted: var(--muted);
2895
+ --color-accent: var(--accent);
2896
+ --color-card: var(--card);
2897
+ --color-border: var(--border);
2898
+ }
2899
+
2900
+ * {
2901
+ box-sizing: border-box;
2902
+ }
2903
+
2904
+ html,
2905
+ body {
2906
+ margin: 0;
2907
+ min-height: 100%;
2908
+ background: radial-gradient(circle at top left, #eef3ff, transparent 40%),
2909
+ var(--background);
2910
+ color: var(--foreground);
2911
+ font-family: ui-sans-serif, system-ui, sans-serif;
2912
+ }
2913
+
2914
+ a {
2915
+ color: var(--accent);
2916
+ text-decoration: none;
2917
+ }
2918
+
2919
+ a:hover {
2920
+ text-decoration: underline;
2921
+ }
2922
+
2923
+ main {
2924
+ width: min(720px, calc(100% - 2rem));
2925
+ margin: 0 auto;
2926
+ padding: 4rem 0 3rem;
2927
+ }
2928
+
2929
+ .hero {
2930
+ display: grid;
2931
+ gap: 1rem;
2932
+ }
2933
+
2934
+ .hero h1 {
2935
+ margin: 0;
2936
+ font-size: clamp(2rem, 5vw, 3rem);
2937
+ letter-spacing: -0.03em;
2938
+ }
2939
+
2940
+ .hero p {
2941
+ margin: 0;
2942
+ color: var(--muted);
2943
+ line-height: 1.6;
2944
+ max-width: 40rem;
2945
+ }
2946
+
2947
+ .card {
2948
+ margin-top: 2rem;
2949
+ padding: 1.25rem 1.5rem;
2950
+ background: var(--card);
2951
+ border: 1px solid var(--border);
2952
+ border-radius: 12px;
2953
+ }
2954
+
2955
+ .card h2 {
2956
+ margin: 0 0 0.5rem;
2957
+ font-size: 1rem;
2958
+ }
2959
+
2960
+ .card ul {
2961
+ margin: 0;
2962
+ padding-left: 1.1rem;
2963
+ color: var(--muted);
2964
+ line-height: 1.7;
2965
+ }
2966
+
2967
+ .meta {
2968
+ margin-top: 2rem;
2969
+ font-size: 0.875rem;
2970
+ color: var(--muted);
2971
+ }
2972
+ `;
2973
+ var UTILS = `import { type ClassValue, clsx } from "clsx";
2974
+ import { twMerge } from "tailwind-merge";
2975
+
2976
+ export function cn(...inputs: ClassValue[]) {
2977
+ return twMerge(clsx(inputs));
2978
+ }
2979
+ `;
2980
+ var RULE11 = `---
2981
+ description: Tailwind CSS v4 conventions
2982
+ globs: src/app/globals.css,src/**/*.{tsx,css},postcss.config.mjs
2983
+ alwaysApply: false
2984
+ ---
2985
+
2986
+ # Tailwind
2987
+
2988
+ - Tailwind v4 via \`@import "tailwindcss"\` in \`globals.css\` + \`@tailwindcss/postcss\`.
2989
+ - Prefer utility classes; keep semantic CSS variables in \`:root\` / \`@theme\`.
2990
+ - Class merging: \`cn()\` from \`@/lib/utils\` (clsx + tailwind-merge).
2991
+ `;
2992
+ var tailwindInstaller = {
2993
+ id: "tailwind",
2994
+ active: (o) => o.styling === "tailwind",
2995
+ async run({ projectDir }) {
2996
+ await fs23.writeFile(
2997
+ path24.join(projectDir, "postcss.config.mjs"),
2998
+ POSTCSS,
2999
+ "utf8"
3000
+ );
3001
+ await fs23.writeFile(
3002
+ path24.join(projectDir, "src", "app", "globals.css"),
3003
+ GLOBALS,
3004
+ "utf8"
3005
+ );
3006
+ await fs23.writeFile(
3007
+ path24.join(projectDir, "src", "lib", "utils.ts"),
3008
+ UTILS,
3009
+ "utf8"
3010
+ );
3011
+ await fs23.writeFile(
3012
+ path24.join(projectDir, ".cursor", "rules", "tailwind.mdc"),
3013
+ RULE11,
3014
+ "utf8"
3015
+ );
3016
+ await addDependencies(projectDir, {
3017
+ clsx: "^2.1.1",
3018
+ "tailwind-merge": "^3.6.0"
3019
+ });
3020
+ await addDependencies(
3021
+ projectDir,
3022
+ {
3023
+ tailwindcss: "^4",
3024
+ "@tailwindcss/postcss": "^4"
3025
+ },
3026
+ "devDependencies"
3027
+ );
3028
+ }
3029
+ };
3030
+
3031
+ // src/installers/tanstack-query.ts
3032
+ import fs24 from "fs-extra";
3033
+ import path25 from "path";
3034
+ var PROVIDER = `"use client";
3035
+
3036
+ import { type ReactNode, useState } from "react";
3037
+
3038
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3039
+
3040
+ function createQueryClient() {
3041
+ return new QueryClient({
3042
+ defaultOptions: {
3043
+ queries: {
3044
+ staleTime: 60_000,
3045
+ refetchOnWindowFocus: false,
3046
+ retry: 1,
3047
+ },
3048
+ mutations: {
3049
+ retry: false,
3050
+ },
3051
+ },
3052
+ });
3053
+ }
3054
+
3055
+ export function QueryProvider({ children }: { children: ReactNode }) {
3056
+ const [client] = useState(createQueryClient);
3057
+
3058
+ return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
3059
+ }
3060
+ `;
3061
+ var RULE12 = `---
3062
+ description: TanStack Query conventions
3063
+ globs: src/providers/**,src/hooks/**,src/endpoints/**,src/**/*query*.{ts,tsx},src/**/use-*.{ts,tsx}
3064
+ alwaysApply: false
3065
+ ---
3066
+
3067
+ # TanStack Query
3068
+
3069
+ - \`QueryProvider\` (\`src/providers/query-provider.tsx\`) is composed via \`AppProviders\`.
3070
+ - Client data: \`useQuery\` / \`useMutation\`; keep RSC for server-friendly fetches.
3071
+ - Query keys: use generated \`get<EndpointName>QueryKey(...)\` when endpoints codegen is on \u2014 never ad-hoc \`["resource"]\` strings.
3072
+ - Default hook \`params\` to the matching \`*DefaultParams\` when present.
3073
+ - Errors: \`getApiErrorMessage\` / field helpers from \`@/utils/api-error\`.
3074
+ `;
3075
+ var tanstackQueryInstaller = {
3076
+ id: "tanstack-query",
3077
+ active: (o) => o.data === "tanstack-query",
3078
+ async run({ projectDir }) {
3079
+ await addDependencies(projectDir, {
3080
+ "@tanstack/react-query": "^5.62.8"
3081
+ });
3082
+ const providersDir = path25.join(projectDir, "src", "providers");
3083
+ await fs24.ensureDir(providersDir);
3084
+ await fs24.remove(path25.join(providersDir, ".gitkeep"));
3085
+ await fs24.writeFile(
3086
+ path25.join(providersDir, "query-provider.tsx"),
3087
+ PROVIDER,
3088
+ "utf8"
3089
+ );
3090
+ const ruleDir = path25.join(projectDir, ".cursor", "rules");
3091
+ await fs24.ensureDir(ruleDir);
3092
+ await fs24.writeFile(
3093
+ path25.join(ruleDir, "tanstack-query.mdc"),
3094
+ RULE12,
3095
+ "utf8"
3096
+ );
3097
+ }
3098
+ };
3099
+
3100
+ // src/installers/testing.ts
3101
+ import fs25 from "fs-extra";
3102
+ import path26 from "path";
3103
+ var VITEST_CONFIG = `import react from "@vitejs/plugin-react";
3104
+ import { resolve } from "node:path";
3105
+ import { defineConfig } from "vitest/config";
3106
+
3107
+ export default defineConfig({
3108
+ plugins: [react()],
3109
+ test: {
3110
+ environment: "jsdom",
3111
+ globals: true,
3112
+ include: ["tests/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
3113
+ },
3114
+ resolve: {
3115
+ alias: {
3116
+ "@": resolve(import.meta.dirname, "./src"),
3117
+ },
3118
+ },
3119
+ });
3120
+ `;
3121
+ var SAMPLE_TEST = `import { renderToStaticMarkup } from "react-dom/server";
3122
+ import { describe, expect, it } from "vitest";
3123
+
3124
+ import { WelcomeCard } from "@/app/_components/welcome-card";
3125
+
3126
+ describe("WelcomeCard", () => {
3127
+ it("renders scaffold heading", () => {
3128
+ const html = renderToStaticMarkup(<WelcomeCard />);
3129
+ expect(html).toContain("Your scaffold");
3130
+ });
3131
+ });
3132
+ `;
3133
+ function playwrightConfig(devCommand) {
3134
+ return `import { defineConfig, devices } from "@playwright/test";
3135
+
3136
+ export default defineConfig({
3137
+ testDir: "./e2e",
3138
+ fullyParallel: true,
3139
+ forbidOnly: !!process.env.CI,
3140
+ retries: process.env.CI ? 1 : 0,
3141
+ use: {
3142
+ baseURL: "http://127.0.0.1:3000",
3143
+ trace: "on-first-retry",
3144
+ },
3145
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
3146
+ webServer: {
3147
+ command: "${devCommand}",
3148
+ url: "http://127.0.0.1:3000",
3149
+ reuseExistingServer: !process.env.CI,
3150
+ },
3151
+ });
3152
+ `;
3153
+ }
3154
+ var E2E_SMOKE = `import { expect, test } from "@playwright/test";
3155
+
3156
+ test("home loads", async ({ page }) => {
3157
+ await page.goto("/");
3158
+ await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
3159
+ });
3160
+ `;
3161
+ var testingInstaller = {
3162
+ id: "testing",
3163
+ active: (o) => o.testing,
3164
+ async run({ projectDir, options }) {
3165
+ const pm = getPackageManager(options.packageManager);
3166
+ const testCmd = pm.runScript("test");
3167
+ const e2eCmd = pm.runScript("test:e2e");
3168
+ const rule = `---
3169
+ description: Vitest (and optional Playwright) conventions
3170
+ globs: "**/*.test.{ts,tsx}",vitest.config.*,e2e/**,playwright.config.*
3171
+ alwaysApply: false
3172
+ ---
3173
+
3174
+ # Testing
3175
+
3176
+ - Vitest: colocated \`<name>.test.tsx\` next to components; also \`tests/**\`.
3177
+ - Prefer \`renderToStaticMarkup\` for simple server component smoke.
3178
+ - Run \`${testCmd}\`. Playwright (if enabled): \`${e2eCmd}\`.
3179
+ `;
3180
+ await fs25.writeFile(
3181
+ path26.join(projectDir, "vitest.config.mts"),
3182
+ VITEST_CONFIG,
3183
+ "utf8"
3184
+ );
3185
+ await fs25.outputFile(
3186
+ path26.join(
3187
+ projectDir,
3188
+ "src",
3189
+ "app",
3190
+ "_components",
3191
+ "welcome-card",
3192
+ "welcome-card.test.tsx"
3193
+ ),
3194
+ SAMPLE_TEST
3195
+ );
3196
+ await fs25.writeFile(
3197
+ path26.join(projectDir, ".cursor", "rules", "testing.mdc"),
3198
+ rule,
3199
+ "utf8"
3200
+ );
3201
+ await fs25.outputFile(
3202
+ path26.join(projectDir, "docs", "style-guide", "tooling", "testing.md"),
3203
+ `# Testing
3204
+
3205
+ Vitest: \`${testCmd}\`
3206
+ Config: \`vitest.config.mts\`
3207
+ Colocate \`*.test.tsx\` with components under \`src/\`.
3208
+ ${options.playwright ? `
3209
+ Playwright: \`${e2eCmd}\` \xB7 config \`playwright.config.ts\` \xB7 specs in \`e2e/\`.
3210
+ ` : ""}
3211
+ `
3212
+ );
3213
+ await addDependencies(
3214
+ projectDir,
3215
+ {
3216
+ vitest: "^3.0.5",
3217
+ "@vitejs/plugin-react": "^4.3.4",
3218
+ jsdom: "^26.0.0"
3219
+ },
3220
+ "devDependencies"
3221
+ );
3222
+ await addScripts(projectDir, {
3223
+ test: "vitest run",
3224
+ "test:watch": "vitest"
3225
+ });
3226
+ if (options.playwright) {
3227
+ await fs25.writeFile(
3228
+ path26.join(projectDir, "playwright.config.ts"),
3229
+ playwrightConfig(pm.runScript("dev")),
3230
+ "utf8"
3231
+ );
3232
+ await fs25.outputFile(path26.join(projectDir, "e2e", "home.spec.ts"), E2E_SMOKE);
3233
+ await addDependencies(
3234
+ projectDir,
3235
+ { "@playwright/test": "^1.50.1" },
3236
+ "devDependencies"
3237
+ );
3238
+ await addScripts(projectDir, {
3239
+ "test:e2e": "playwright test"
3240
+ });
3241
+ }
3242
+ }
3243
+ };
3244
+
3245
+ // src/installers/validation.ts
3246
+ import fs26 from "fs-extra";
3247
+ import path27 from "path";
3248
+ var ENV_ZOD = `import { z } from "zod";
3249
+
3250
+ /**
3251
+ * Validated public env. Import from Client Components only for NEXT_PUBLIC_*.
3252
+ * Server-only secrets stay out of this schema until needed.
3253
+ */
3254
+ const publicEnvSchema = z.object({
3255
+ NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
3256
+ NEXT_PUBLIC_API_URL: z.string().url().default("http://localhost:8000"),
3257
+ });
3258
+
3259
+ export type PublicEnv = z.infer<typeof publicEnvSchema>;
3260
+
3261
+ export const env: PublicEnv = publicEnvSchema.parse({
3262
+ NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
3263
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
3264
+ });
3265
+ `;
3266
+ var ENV_YUP = `import * as yup from "yup";
3267
+
3268
+ /**
3269
+ * Validated public env. Import from Client Components only for NEXT_PUBLIC_*.
3270
+ * Server-only secrets stay out of this schema until needed.
3271
+ */
3272
+ const publicEnvSchema = yup.object({
3273
+ NEXT_PUBLIC_APP_URL: yup
3274
+ .string()
3275
+ .url()
3276
+ .default("http://localhost:3000"),
3277
+ NEXT_PUBLIC_API_URL: yup
3278
+ .string()
3279
+ .url()
3280
+ .default("http://localhost:8000"),
3281
+ });
3282
+
3283
+ export type PublicEnv = yup.InferType<typeof publicEnvSchema>;
3284
+
3285
+ export const env: PublicEnv = publicEnvSchema.validateSync({
3286
+ NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
3287
+ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
3288
+ });
3289
+ `;
3290
+ function formsRule(validation) {
3291
+ const schemaLine = validation === "zod" ? "- RHF + `Controller` + Zod `zodResolver`. Schema `satisfies z.ZodType<*Body>` when endpoints codegen exists; field names = API keys." : "- RHF + `Controller` + Yup `yupResolver`. Schemas in `src/lib/validations/`; field names = API keys when endpoints codegen exists.";
3292
+ return `---
3293
+ description: RHF + ${validation === "zod" ? "Zod" : "Yup"} forms aligned to API body types
3294
+ globs: src/**/*form*/**,src/**/*-form*/**,src/**/*.form.tsx,src/components/forms/**,src/lib/**/*form*.ts,src/lib/validations/**
3295
+ alwaysApply: false
3296
+ ---
3297
+
3298
+ # Forms
3299
+
3300
+ - \`"use client"\` + named exports. \`page.tsx\` stays server when metadata-only.
3301
+ - Placement: shared \u2192 \`src/components/forms/<name>/\`; one route \u2192 \`src/app/<route>/_components/forms/<name>/\`.
3302
+ ${schemaLine}
3303
+ - Reuse shared/UI controls \u2014 no ad-hoc inputs.
3304
+ - Required: derive from schema / \`*Body\`; use \`required\` on Field/Label (no handmade \`*\` text).
3305
+ - Invalid: \`data-invalid\`, \`aria-invalid\`, \`FieldError\`.
3306
+ - Server 400 envelope: \`getApiErrorMessage\` / \`extractApiFieldErrors\` (\`@/utils/api-error\`); map to \`setError\` when using RHF.
3307
+ - Never duplicate the same form state/submit logic across pages.
3308
+ `;
3309
+ }
3310
+ function validationRule(validation) {
3311
+ return `---
3312
+ description: ${validation === "zod" ? "Zod" : "Yup"} validation conventions
3313
+ globs: src/lib/env.ts,src/lib/validations/**,src/**/*schema*.{ts,tsx}
3314
+ alwaysApply: false
3315
+ ---
3316
+
3317
+ # Validation (${validation})
3318
+
3319
+ - Public env: \`src/lib/env.ts\` (${validation} schema).
3320
+ - Domain schemas: \`src/lib/validations/<name>.ts\`.
3321
+ - Forms: use \`${validation === "zod" ? "zodResolver" : "yupResolver"}\` from \`@hookform/resolvers\`.
3322
+ - Prefer one library \u2014 do not mix Zod and Yup in the same app.
3323
+ `;
3324
+ }
3325
+ var validationInstaller = {
3326
+ id: "validation",
3327
+ active: () => true,
3328
+ async run({ projectDir, options }) {
3329
+ const validationsDir = path27.join(projectDir, "src", "lib", "validations");
3330
+ await fs26.ensureDir(validationsDir);
3331
+ await fs26.writeFile(path27.join(validationsDir, ".gitkeep"), "", "utf8");
3332
+ if (options.validation === "yup") {
3333
+ await fs26.writeFile(
3334
+ path27.join(projectDir, "src", "lib", "env.ts"),
3335
+ ENV_YUP,
3336
+ "utf8"
3337
+ );
3338
+ await removeDependencies(projectDir, ["zod"]);
3339
+ await addDependencies(projectDir, { yup: "^1.6.1" });
3340
+ } else {
3341
+ await fs26.writeFile(
3342
+ path27.join(projectDir, "src", "lib", "env.ts"),
3343
+ ENV_ZOD,
3344
+ "utf8"
3345
+ );
3346
+ await addDependencies(projectDir, { zod: "^3.24.1" });
3347
+ }
3348
+ await fs26.writeFile(
3349
+ path27.join(projectDir, ".cursor", "rules", "forms.mdc"),
3350
+ formsRule(options.validation),
3351
+ "utf8"
3352
+ );
3353
+ await fs26.writeFile(
3354
+ path27.join(projectDir, ".cursor", "rules", "validation.mdc"),
3355
+ validationRule(options.validation),
3356
+ "utf8"
3357
+ );
3358
+ await fs26.outputFile(
3359
+ path27.join(
3360
+ projectDir,
3361
+ "docs",
3362
+ "style-guide",
3363
+ "validation",
3364
+ `${options.validation}.md`
3365
+ ),
3366
+ `# Validation \u2014 ${options.validation}
3367
+
3368
+ Env: \`src/lib/env.ts\`
3369
+ Schemas: \`src/lib/validations/\`
3370
+ Forms: \`@hookform/resolvers/${options.validation}\` when using RHF.
3371
+ `
3372
+ );
3373
+ }
3374
+ };
3375
+
3376
+ // src/installers/index.ts
3377
+ var installers = [
3378
+ packageManagerInstaller,
3379
+ iconsInstaller,
3380
+ storeInstaller,
3381
+ tanstackQueryInstaller,
3382
+ endpointsInstaller,
3383
+ apiEnvelopeInstaller,
3384
+ authJwtInstaller,
3385
+ authAuthjsInstaller,
3386
+ tailwindInstaller,
3387
+ darkModeInstaller,
3388
+ muiInstaller,
3389
+ shadcnInstaller,
3390
+ validationInstaller,
3391
+ seoInstaller,
3392
+ i18nInstaller,
3393
+ dockerComposeInstaller,
3394
+ testingInstaller,
3395
+ eslintInstaller,
3396
+ prettierInstaller,
3397
+ huskyInstaller,
3398
+ dockerInstaller,
3399
+ ciInstaller,
3400
+ appProvidersInstaller,
3401
+ surfaceInstaller
3402
+ ];
3403
+ async function runInstallers(ctx) {
3404
+ const ran = [];
3405
+ for (const installer of installers) {
3406
+ if (!installer.active(ctx.options)) continue;
3407
+ await installer.run(ctx);
3408
+ ran.push(installer.id);
3409
+ }
3410
+ return ran;
3411
+ }
3412
+
3413
+ // src/scaffold.ts
3414
+ async function scaffold(options) {
3415
+ const projectDir = path28.resolve(process.cwd(), options.name);
3416
+ const pm = getPackageManager(options.packageManager);
3417
+ if (await fs27.pathExists(projectDir)) {
3418
+ const entries = await fs27.readdir(projectDir);
3419
+ if (entries.length > 0) {
3420
+ throw new Error(
3421
+ `Target directory already exists and is not empty: ${projectDir}`
3422
+ );
3423
+ }
3424
+ }
3425
+ const spin = p2.spinner();
3426
+ spin.start("Copying template");
3427
+ if (!await fs27.pathExists(TEMPLATE_DIR)) {
3428
+ spin.stop("Template missing");
3429
+ throw new Error(`Template directory not found at ${TEMPLATE_DIR}`);
3430
+ }
3431
+ await fs27.copy(TEMPLATE_DIR, projectDir, {
3432
+ filter: (src) => {
3433
+ const base = path28.basename(src);
3434
+ return base !== "node_modules" && base !== ".next" && base !== "dist";
3435
+ }
3436
+ });
3437
+ const gitignoreSrc = path28.join(projectDir, "gitignore");
3438
+ const gitignoreDest = path28.join(projectDir, ".gitignore");
3439
+ if (await fs27.pathExists(gitignoreSrc)) {
3440
+ await fs27.move(gitignoreSrc, gitignoreDest, { overwrite: true });
3441
+ }
3442
+ spin.message("Applying project tokens");
3443
+ await replaceTokensInTree(projectDir, {
3444
+ __PROJECT_NAME__: options.name,
3445
+ __PROJECT_SLUG__: options.slug,
3446
+ __PACKAGE_MANAGER__: pm.id
3447
+ });
3448
+ spin.message("Running installers");
3449
+ const ran = await runInstallers({ projectDir, options });
3450
+ if (!options.noInstall) {
3451
+ spin.message(`Installing dependencies (${pm.id})`);
3452
+ try {
3453
+ execSync(`${pm.id} ${pm.installArgs.join(" ")}`, {
3454
+ cwd: projectDir,
3455
+ stdio: "ignore"
3456
+ });
3457
+ } catch {
3458
+ if (pm.id !== "npm") {
3459
+ spin.message(`${pm.id} failed \u2014 trying npm`);
3460
+ execSync("npm install", { cwd: projectDir, stdio: "ignore" });
3461
+ } else {
3462
+ throw new Error("npm install failed");
3463
+ }
3464
+ }
3465
+ }
3466
+ spin.stop("Project ready");
3467
+ if (ran.length) {
3468
+ p2.log.info(`Installers: ${ran.join(", ")}`);
3469
+ } else {
3470
+ p2.log.step(color2.dim("No optional installers (base template only)."));
3471
+ }
3472
+ return projectDir;
3473
+ }
3474
+
3475
+ // src/upgrade.ts
3476
+ import fs28 from "fs-extra";
3477
+ import path29 from "path";
3478
+ import * as p3 from "@clack/prompts";
3479
+ import color3 from "picocolors";
3480
+ var TRACKED = [
3481
+ "AGENTS.md",
3482
+ ".cursor/rules/core.mdc",
3483
+ ".cursor/rules/components.mdc",
3484
+ ".cursor/rules/forms.mdc"
3485
+ ];
3486
+ function rel(projectDir, file) {
3487
+ return path29.join(projectDir, file);
3488
+ }
3489
+ function templatePath(file) {
3490
+ return path29.join(TEMPLATE_DIR, file);
3491
+ }
3492
+ async function runUpgrade(options) {
3493
+ const { projectDir, apply } = options;
3494
+ if (!await fs28.pathExists(path29.join(projectDir, "package.json"))) {
3495
+ throw new Error(`Not an app directory (missing package.json): ${projectDir}`);
3496
+ }
3497
+ if (!await fs28.pathExists(TEMPLATE_DIR)) {
3498
+ throw new Error(`Template missing at ${TEMPLATE_DIR}`);
3499
+ }
3500
+ p3.intro(color3.bgCyan(color3.black(" next-style-guide upgrade ")));
3501
+ p3.log.info(
3502
+ apply ? `Applying template agent files \u2192 ${projectDir}` : `Diff only (pass --apply to write). Target: ${projectDir}`
3503
+ );
3504
+ let changed = 0;
3505
+ let missing = 0;
3506
+ let same = 0;
3507
+ for (const file of TRACKED) {
3508
+ const from = templatePath(file);
3509
+ const to = rel(projectDir, file);
3510
+ if (!await fs28.pathExists(from)) {
3511
+ p3.log.warn(`Template missing ${file}`);
3512
+ continue;
3513
+ }
3514
+ const next = await fs28.readFile(from, "utf8");
3515
+ let outgoing = next;
3516
+ if (file === "AGENTS.md" && await fs28.pathExists(to)) {
3517
+ const current2 = await fs28.readFile(to, "utf8");
3518
+ const nameMatch = current2.match(/^# Agents \((.+)\)$/m);
3519
+ if (nameMatch?.[1]) {
3520
+ outgoing = outgoing.replace("__PROJECT_NAME__", nameMatch[1]);
3521
+ }
3522
+ const pmMatch = current2.match(/· ([a-z]+)(\.|$)/m);
3523
+ if (current2.includes("pnpm") || current2.includes("npm") || current2.includes("yarn")) {
3524
+ const stackPm = current2.match(/· (pnpm|npm|yarn)\./);
3525
+ if (stackPm?.[1]) {
3526
+ outgoing = outgoing.replace("__PACKAGE_MANAGER__", stackPm[1]);
3527
+ }
3528
+ }
3529
+ }
3530
+ outgoing = outgoing.replaceAll("__PROJECT_NAME__", path29.basename(projectDir)).replaceAll("__PACKAGE_MANAGER__", "pnpm").replaceAll("__PROJECT_SLUG__", path29.basename(projectDir));
3531
+ if (!await fs28.pathExists(to)) {
3532
+ missing += 1;
3533
+ p3.log.warn(`missing ${file}`);
3534
+ if (apply) {
3535
+ await fs28.ensureDir(path29.dirname(to));
3536
+ await fs28.writeFile(to, outgoing, "utf8");
3537
+ p3.log.success(`wrote ${file}`);
3538
+ }
3539
+ continue;
3540
+ }
3541
+ const current = await fs28.readFile(to, "utf8");
3542
+ if (current === outgoing) {
3543
+ same += 1;
3544
+ p3.log.step(color3.dim(`same ${file}`));
3545
+ continue;
3546
+ }
3547
+ changed += 1;
3548
+ p3.log.warn(`differs ${file}`);
3549
+ if (apply) {
3550
+ await fs28.writeFile(to, outgoing, "utf8");
3551
+ p3.log.success(`updated ${file}`);
3552
+ }
3553
+ }
3554
+ p3.note(
3555
+ [
3556
+ `same=${same} differs=${changed} missing=${missing}`,
3557
+ apply ? "Applied template agent surface." : "Re-run with --apply to write differs/missing files.",
3558
+ "Feature rules (.cursor/rules/* beyond core/components/forms) are not overwritten \u2014 re-scaffold options or copy manually."
3559
+ ].join("\n"),
3560
+ "Summary"
3561
+ );
3562
+ p3.outro(color3.green(apply ? "Upgrade applied." : "Diff complete."));
3563
+ }
3564
+
3565
+ // src/index.ts
3566
+ var ICON_VALUES = /* @__PURE__ */ new Set(["none", "lucide", "tabler"]);
3567
+ var STYLING_VALUES = /* @__PURE__ */ new Set(["none", "tailwind", "mui"]);
3568
+ var VALIDATION_VALUES = /* @__PURE__ */ new Set(["zod", "yup"]);
3569
+ var AUTH_VALUES = /* @__PURE__ */ new Set(["none", "jwt", "authjs"]);
3570
+ var STORE_VALUES = /* @__PURE__ */ new Set(["none", "zustand", "redux"]);
3571
+ var PM_VALUES = /* @__PURE__ */ new Set(["pnpm", "npm", "yarn"]);
3572
+ var PRESET_VALUES = /* @__PURE__ */ new Set([
3573
+ "custom",
3574
+ "minimal",
3575
+ "api-app",
3576
+ "marketing",
3577
+ "dashboard"
3578
+ ]);
3579
+ function parseBoolFlag(arg, positive, negative) {
3580
+ if (arg === positive) return true;
3581
+ if (arg === negative) return false;
3582
+ return void 0;
3583
+ }
3584
+ function readChoice(arg, flag, values, args, i) {
3585
+ if (arg.startsWith(`${flag}=`)) {
3586
+ const value = arg.slice(flag.length + 1);
3587
+ if (!values.has(value)) {
3588
+ throw new Error(`Invalid ${flag}=${value}.`);
3589
+ }
3590
+ return { value, nextIndex: i };
3591
+ }
3592
+ if (arg === flag) {
3593
+ const value = args[i + 1];
3594
+ if (!value || !values.has(value)) {
3595
+ throw new Error(`Invalid ${flag}.`);
3596
+ }
3597
+ return { value, nextIndex: i + 1 };
3598
+ }
3599
+ return null;
3600
+ }
3601
+ function parseArgs(argv) {
3602
+ const args = argv.slice(2);
3603
+ const out = {
3604
+ noInstall: false,
3605
+ yes: false,
3606
+ upgrade: false,
3607
+ apply: false
3608
+ };
3609
+ for (let i = 0; i < args.length; i += 1) {
3610
+ const arg = args[i];
3611
+ if (arg === "upgrade") {
3612
+ out.upgrade = true;
3613
+ continue;
3614
+ }
3615
+ if (arg === "--apply") {
3616
+ out.apply = true;
3617
+ continue;
3618
+ }
3619
+ if (arg === "--diff") {
3620
+ out.upgrade = true;
3621
+ out.apply = false;
3622
+ continue;
3623
+ }
3624
+ if (arg === "--no-install") {
3625
+ out.noInstall = true;
3626
+ continue;
3627
+ }
3628
+ if (arg === "--yes" || arg === "-y") {
3629
+ out.yes = true;
3630
+ continue;
3631
+ }
3632
+ if (arg === "--help" || arg === "-h") {
3633
+ printHelp();
3634
+ process.exit(0);
3635
+ }
3636
+ const boolKeys = [
3637
+ ["tanstackQuery", "--tanstack-query", "--no-tanstack-query"],
3638
+ ["endpoints", "--endpoints", "--no-endpoints"],
3639
+ ["apiEnvelope", "--api-envelope", "--no-api-envelope"],
3640
+ ["seo", "--seo", "--no-seo"],
3641
+ ["i18n", "--i18n", "--no-i18n"],
3642
+ ["darkMode", "--dark-mode", "--no-dark-mode"],
3643
+ ["testing", "--testing", "--no-testing"],
3644
+ ["playwright", "--playwright", "--no-playwright"],
3645
+ ["eslint", "--eslint", "--no-eslint"],
3646
+ ["prettier", "--prettier", "--no-prettier"],
3647
+ ["husky", "--husky", "--no-husky"],
3648
+ ["docker", "--docker", "--no-docker"],
3649
+ ["ci", "--ci", "--no-ci"],
3650
+ ["shadcn", "--shadcn", "--no-shadcn"]
3651
+ ];
3652
+ let matched = false;
3653
+ for (const [key, pos, neg] of boolKeys) {
3654
+ const v = parseBoolFlag(arg, pos, neg);
3655
+ if (v !== void 0) {
3656
+ out[key] = v;
3657
+ matched = true;
3658
+ break;
3659
+ }
3660
+ }
3661
+ if (matched) continue;
3662
+ const preset = readChoice(arg, "--preset", PRESET_VALUES, args, i);
3663
+ if (preset) {
3664
+ out.preset = preset.value;
3665
+ i = preset.nextIndex;
3666
+ continue;
3667
+ }
3668
+ const pm = readChoice(arg, "--pm", PM_VALUES, args, i);
3669
+ if (pm) {
3670
+ out.packageManager = pm.value;
3671
+ i = pm.nextIndex;
3672
+ continue;
3673
+ }
3674
+ const pmLong = readChoice(arg, "--package-manager", PM_VALUES, args, i);
3675
+ if (pmLong) {
3676
+ out.packageManager = pmLong.value;
3677
+ i = pmLong.nextIndex;
3678
+ continue;
3679
+ }
3680
+ const store = readChoice(arg, "--store", STORE_VALUES, args, i);
3681
+ if (store) {
3682
+ out.store = store.value;
3683
+ i = store.nextIndex;
3684
+ continue;
3685
+ }
3686
+ const styling = readChoice(arg, "--styling", STYLING_VALUES, args, i);
3687
+ if (styling) {
3688
+ out.styling = styling.value;
3689
+ i = styling.nextIndex;
3690
+ continue;
3691
+ }
3692
+ const validation = readChoice(
3693
+ arg,
3694
+ "--validation",
3695
+ VALIDATION_VALUES,
3696
+ args,
3697
+ i
3698
+ );
3699
+ if (validation) {
3700
+ out.validation = validation.value;
3701
+ i = validation.nextIndex;
3702
+ continue;
3703
+ }
3704
+ const auth = readChoice(arg, "--auth", AUTH_VALUES, args, i);
3705
+ if (auth) {
3706
+ out.auth = auth.value;
3707
+ i = auth.nextIndex;
3708
+ continue;
3709
+ }
3710
+ const icons = readChoice(arg, "--icons", ICON_VALUES, args, i);
3711
+ if (icons) {
3712
+ out.icons = icons.value;
3713
+ i = icons.nextIndex;
3714
+ continue;
3715
+ }
3716
+ if (!arg.startsWith("-") && !out.name) {
3717
+ out.name = arg;
3718
+ }
3719
+ }
3720
+ return out;
3721
+ }
3722
+ function printHelp() {
3723
+ console.log(`
3724
+ ${TITLE} \u2014 ${PKG_NAME}
3725
+
3726
+ Usage:
3727
+ npx create-next-style-guide [name] [options]
3728
+ npx create-next-style-guide upgrade [--apply] [dir]
3729
+ npx create-next-style-guide --diff [dir]
3730
+
3731
+ Options:
3732
+ --yes, -y
3733
+ --no-install
3734
+ --preset minimal|api-app|marketing|dashboard|custom
3735
+ --pm, --package-manager pnpm|npm|yarn
3736
+ --store none|zustand|redux
3737
+ --styling none|tailwind|mui
3738
+ --shadcn | --no-shadcn
3739
+ --dark-mode | --no-dark-mode
3740
+ --validation zod|yup
3741
+ --auth none|jwt|authjs
3742
+ --icons none|lucide|tabler
3743
+ --tanstack-query | --no-tanstack-query
3744
+ --endpoints | --no-endpoints
3745
+ --api-envelope | --no-api-envelope
3746
+ --seo | --no-seo
3747
+ --i18n | --no-i18n
3748
+ --testing | --no-testing
3749
+ --playwright | --no-playwright
3750
+ --eslint | --no-eslint
3751
+ --prettier | --no-prettier
3752
+ --husky | --no-husky
3753
+ --docker | --no-docker
3754
+ --ci | --no-ci
3755
+ --help, -h
3756
+
3757
+ Upgrade:
3758
+ upgrade [--apply] Diff (default) or apply AGENTS.md + core .cursor/rules
3759
+ --diff Alias for upgrade without --apply
3760
+ `);
3761
+ }
3762
+ function applyFlagOverrides(options, flags) {
3763
+ let next = { ...options };
3764
+ if (flags.packageManager !== void 0) {
3765
+ next.packageManager = flags.packageManager;
3766
+ }
3767
+ if (flags.icons !== void 0) next.icons = flags.icons;
3768
+ if (flags.styling !== void 0) next.styling = flags.styling;
3769
+ if (flags.shadcn !== void 0) next.ui = flags.shadcn ? "shadcn" : "none";
3770
+ if (flags.validation !== void 0) next.validation = flags.validation;
3771
+ if (flags.auth !== void 0) next.auth = flags.auth;
3772
+ if (flags.store !== void 0) next.store = flags.store;
3773
+ if (flags.tanstackQuery !== void 0) {
3774
+ next.data = flags.tanstackQuery ? "tanstack-query" : "rsc";
3775
+ }
3776
+ if (flags.endpoints !== void 0) next.endpoints = flags.endpoints;
3777
+ if (flags.apiEnvelope !== void 0) next.apiEnvelope = flags.apiEnvelope;
3778
+ if (flags.seo !== void 0) next.seo = flags.seo;
3779
+ if (flags.i18n !== void 0) next.i18n = flags.i18n;
3780
+ if (flags.darkMode !== void 0) next.darkMode = flags.darkMode;
3781
+ if (flags.testing !== void 0) next.testing = flags.testing;
3782
+ if (flags.playwright !== void 0) next.playwright = flags.playwright;
3783
+ if (flags.eslint !== void 0) next.eslint = flags.eslint;
3784
+ if (flags.prettier !== void 0) next.prettier = flags.prettier;
3785
+ if (flags.husky !== void 0) next.husky = flags.husky;
3786
+ if (flags.docker !== void 0) next.docker = flags.docker;
3787
+ if (flags.ci !== void 0) next.ci = flags.ci;
3788
+ return normalizeOptions(next);
3789
+ }
3790
+ async function main() {
3791
+ const flags = parseArgs(process.argv);
3792
+ if (flags.upgrade) {
3793
+ const projectDir2 = path30.resolve(process.cwd(), flags.name ?? ".");
3794
+ await runUpgrade({ projectDir: projectDir2, apply: flags.apply });
3795
+ return;
3796
+ }
3797
+ let options;
3798
+ if (flags.yes) {
3799
+ const name = (flags.name ?? "my-app").trim();
3800
+ const presetSeed = flags.preset && flags.preset !== "custom" ? PRESET_SEEDS[flags.preset] : DEFAULT_OPTIONS;
3801
+ options = applyFlagOverrides(
3802
+ {
3803
+ name,
3804
+ slug: slugify(name),
3805
+ ...presetSeed,
3806
+ noInstall: flags.noInstall
3807
+ },
3808
+ flags
3809
+ );
3810
+ p4.intro(color4.bgCyan(color4.black(` ${TITLE} `)));
3811
+ p4.log.info(
3812
+ [
3813
+ `Non-interactive: ${options.name} (${options.slug})`,
3814
+ flags.preset ? `preset=${flags.preset}` : null,
3815
+ `pm=${options.packageManager}`,
3816
+ `styling=${options.styling} ui=${options.ui} auth=${options.auth} store=${options.store}`,
3817
+ `endpoints=${options.endpoints} seo=${options.seo} i18n=${options.i18n} testing=${options.testing}`
3818
+ ].filter(Boolean).join(" \xB7 ")
3819
+ );
3820
+ } else {
3821
+ options = await runPrompts(flags.name);
3822
+ options.noInstall = flags.noInstall;
3823
+ options = applyFlagOverrides(options, flags);
3824
+ }
3825
+ const warnings = collectOptionWarnings(options);
3826
+ for (const warning of warnings) {
3827
+ p4.log.warn(warning);
3828
+ }
3829
+ const projectDir = await scaffold(options);
3830
+ const pm = getPackageManager(options.packageManager);
3831
+ const nextSteps = [
3832
+ `cd ${path30.relative(process.cwd(), projectDir) || "."}`,
3833
+ options.noInstall ? `${pm.id} ${pm.installArgs.filter((a) => a !== "--ignore-workspace").join(" ")}`.trim() : null,
3834
+ options.ui === "shadcn" ? "npx shadcn@latest add input # optional" : null,
3835
+ options.endpoints ? `${pm.runScript("endpoints:generate")} # when API schema is up` : null,
3836
+ options.testing ? pm.runScript("test") : null,
3837
+ pm.runScript("dev")
3838
+ ].filter(Boolean);
3839
+ p4.note(nextSteps.join("\n"), "Next steps");
3840
+ p4.outro(color4.green("Done. Happy shipping."));
3841
+ }
3842
+ main().catch((error) => {
3843
+ const message = error instanceof Error ? error.message : String(error);
3844
+ p4.log.error(message);
3845
+ p4.cancel("Scaffold failed.");
3846
+ process.exit(1);
3847
+ });
3848
+ //# sourceMappingURL=index.js.map