@thinhnguyencth1204/nextcli 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +6 -2
  2. package/dist/cli.js +778 -101
  3. package/package.json +2 -1
  4. package/templates/next-base/PROJECT_STRUCTURE.md +88 -0
  5. package/templates/next-base/SETUP.md +86 -0
  6. package/templates/next-base/bun.lock +1443 -0
  7. package/templates/next-base/components.json +21 -0
  8. package/templates/next-base/messages/vi/auth.json +42 -0
  9. package/templates/next-base/messages/vi/common.json +34 -0
  10. package/templates/next-base/messages/vi/example.json +10 -0
  11. package/templates/next-base/next-env.d.ts +3 -1
  12. package/templates/next-base/next.config.ts +11 -1
  13. package/templates/next-base/nextcli.json +8 -0
  14. package/templates/next-base/package.json +21 -1
  15. package/templates/next-base/postcss.config.mjs +5 -0
  16. package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +104 -0
  17. package/templates/next-base/prisma/migrations/migration_lock.toml +3 -0
  18. package/templates/next-base/prisma/schema.prisma +23 -9
  19. package/templates/next-base/public/logo.svg +4 -0
  20. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  21. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  22. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  23. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +6 -3
  25. package/templates/next-base/src/app/(dashboard)/account/page.tsx +9 -5
  26. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  27. package/templates/next-base/src/app/(dashboard)/example/page.tsx +5 -2
  28. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  29. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  30. package/templates/next-base/src/app/api/v1/auth/login/route.ts +15 -5
  31. package/templates/next-base/src/app/api/v1/auth/me/route.ts +17 -19
  32. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  33. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  34. package/templates/next-base/src/app/globals.css +111 -0
  35. package/templates/next-base/src/app/layout.tsx +24 -10
  36. package/templates/next-base/src/app/page.tsx +2 -18
  37. package/templates/next-base/src/components/branding/logo.tsx +27 -0
  38. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  39. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  40. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  41. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  42. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  43. package/templates/next-base/src/components/providers/theme-provider.tsx +11 -0
  44. package/templates/next-base/src/components/ui/alert-dialog.tsx +11 -0
  45. package/templates/next-base/src/components/ui/avatar.tsx +45 -0
  46. package/templates/next-base/src/components/ui/badge.tsx +29 -0
  47. package/templates/next-base/src/components/ui/button.tsx +47 -7
  48. package/templates/next-base/src/components/ui/card.tsx +54 -0
  49. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  50. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  51. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  52. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  53. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  54. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  55. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  56. package/templates/next-base/src/components/ui/dialog.tsx +105 -0
  57. package/templates/next-base/src/components/ui/dropdown-menu.tsx +44 -0
  58. package/templates/next-base/src/components/ui/input.tsx +19 -0
  59. package/templates/next-base/src/components/ui/label.tsx +15 -0
  60. package/templates/next-base/src/components/ui/popover.tsx +30 -0
  61. package/templates/next-base/src/components/ui/scroll-area.tsx +47 -0
  62. package/templates/next-base/src/components/ui/select.tsx +76 -0
  63. package/templates/next-base/src/components/ui/separator.tsx +23 -0
  64. package/templates/next-base/src/components/ui/sheet.tsx +117 -0
  65. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  66. package/templates/next-base/src/components/ui/skeleton.tsx +10 -0
  67. package/templates/next-base/src/components/ui/sonner.tsx +3 -0
  68. package/templates/next-base/src/components/ui/table.tsx +54 -0
  69. package/templates/next-base/src/components/ui/tabs.tsx +52 -0
  70. package/templates/next-base/src/components/ui/textarea.tsx +17 -0
  71. package/templates/next-base/src/components/ui/tooltip.tsx +26 -0
  72. package/templates/next-base/src/config/branding.ts +14 -0
  73. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  74. package/templates/next-base/src/example/components/example-table.tsx +25 -40
  75. package/templates/next-base/src/features/auth/components/account-panel.tsx +32 -14
  76. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  77. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +53 -35
  78. package/templates/next-base/src/features/auth/validations.ts +7 -1
  79. package/templates/next-base/src/features/users/services.ts +132 -0
  80. package/templates/next-base/src/features/users/validations.ts +21 -0
  81. package/templates/next-base/src/hooks/index.ts +1 -1
  82. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  83. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  84. package/templates/next-base/src/i18n/config.ts +7 -0
  85. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  86. package/templates/next-base/src/i18n/request.ts +19 -2
  87. package/templates/next-base/src/instrumentation.ts +14 -0
  88. package/templates/next-base/src/lib/auth-client.ts +2 -2
  89. package/templates/next-base/src/lib/auth.ts +2 -2
  90. package/templates/next-base/src/lib/bootstrap.ts +96 -0
  91. package/templates/next-base/src/lib/constants.ts +7 -0
  92. package/templates/next-base/src/lib/prisma.ts +11 -1
  93. package/templates/next-base/src/lib/rbac.ts +62 -0
  94. package/templates/next-base/src/types/data-table.ts +4 -0
  95. package/templates/next-base/src/types/index.ts +2 -0
  96. package/templates/next-base/tsconfig.json +29 -7
  97. package/templates/next-base/middleware.ts +0 -10
  98. package/templates/next-base/src/app/styles.css +0 -12
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/commands/add.ts
4
- import path4 from "path";
4
+ import path7 from "path";
5
5
 
6
6
  // src/core/fs.ts
7
7
  import {
@@ -14,6 +14,21 @@ import {
14
14
  writeFile
15
15
  } from "fs/promises";
16
16
  import path from "path";
17
+ var TEMPLATE_COPY_SKIP_DIRS = /* @__PURE__ */ new Set([
18
+ "node_modules",
19
+ ".next",
20
+ ".git",
21
+ "dist",
22
+ "out",
23
+ ".turbo",
24
+ "coverage"
25
+ ]);
26
+ function shouldSkipTemplateDir(dirName) {
27
+ return TEMPLATE_COPY_SKIP_DIRS.has(dirName);
28
+ }
29
+ function shouldSkipTemplatePath(filePath) {
30
+ return filePath.split(path.sep).some((segment) => shouldSkipTemplateDir(segment));
31
+ }
17
32
  async function ensureDir(targetPath) {
18
33
  await mkdir(targetPath, { recursive: true });
19
34
  }
@@ -27,7 +42,10 @@ async function pathExists(targetPath) {
27
42
  }
28
43
  async function copyDirectory(source, destination) {
29
44
  await ensureDir(destination);
30
- await cp(source, destination, { recursive: true });
45
+ await cp(source, destination, {
46
+ recursive: true,
47
+ filter: (src) => !shouldSkipTemplatePath(src)
48
+ });
31
49
  }
32
50
  async function copyDirectorySafely(source, destination) {
33
51
  const report = {
@@ -41,6 +59,9 @@ async function copyDirectorySafely(source, destination) {
41
59
  const sourcePath = path.join(currentSource, entry.name);
42
60
  const destinationPath = path.join(currentDestination, entry.name);
43
61
  if (entry.isDirectory()) {
62
+ if (shouldSkipTemplateDir(entry.name)) {
63
+ continue;
64
+ }
44
65
  await walk(sourcePath, destinationPath);
45
66
  continue;
46
67
  }
@@ -48,7 +69,9 @@ async function copyDirectorySafely(source, destination) {
48
69
  continue;
49
70
  }
50
71
  if (await pathExists(destinationPath)) {
51
- report.skippedConflicts.push(path.relative(destination, destinationPath));
72
+ report.skippedConflicts.push(
73
+ path.relative(destination, destinationPath)
74
+ );
52
75
  continue;
53
76
  }
54
77
  await ensureDir(path.dirname(destinationPath));
@@ -90,7 +113,9 @@ async function replaceTokensInDirectory(directoryPath, replacements) {
90
113
  for (const entry of entries) {
91
114
  const fullPath = path.join(directoryPath, entry.name);
92
115
  if (entry.isDirectory()) {
93
- await replaceTokensInDirectory(fullPath, replacements);
116
+ if (!shouldSkipTemplateDir(entry.name)) {
117
+ await replaceTokensInDirectory(fullPath, replacements);
118
+ }
94
119
  continue;
95
120
  }
96
121
  if (!entry.isFile() || !isTextFile(fullPath)) {
@@ -152,12 +177,16 @@ async function addDependencies(packageJsonPath, dependencies) {
152
177
  ...current,
153
178
  ...dependencies
154
179
  };
155
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}
156
- `, "utf8");
180
+ await writeFile(
181
+ packageJsonPath,
182
+ `${JSON.stringify(packageJson, null, 2)}
183
+ `,
184
+ "utf8"
185
+ );
157
186
  }
158
187
 
159
188
  // src/commands/add.ts
160
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
189
+ import { readdir as readdir4, readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
161
190
 
162
191
  // src/core/templates.ts
163
192
  import path2 from "path";
@@ -182,7 +211,12 @@ var optionalModules = [
182
211
  templatePath: templatePaths.chat,
183
212
  env: {
184
213
  NEXT_PUBLIC_ENABLE_CHAT: "true"
185
- }
214
+ },
215
+ setupSection: `| Variable | Where to get |
216
+ | -------- | ------------ |
217
+ | \`NEXT_PUBLIC_ENABLE_CHAT\` | Set \`true\` when chat module is enabled (auto on add) |
218
+
219
+ Requires \`supabase-realtime\` (auto-added). Run \`db:migrate\` after add \u2014 chat Prisma models are appended.`
186
220
  },
187
221
  {
188
222
  id: "supabase",
@@ -196,7 +230,12 @@ var optionalModules = [
196
230
  },
197
231
  dependencies: {
198
232
  "@supabase/supabase-js": "^2.44.2"
199
- }
233
+ },
234
+ setupSection: `| Variable | Where to get |
235
+ | -------- | ------------ |
236
+ | \`NEXT_PUBLIC_SUPABASE_URL\` | Supabase Dashboard \u2192 Project Settings \u2192 API \u2192 Project URL |
237
+ | \`NEXT_PUBLIC_SUPABASE_ANON_KEY\` | Same page \u2192 Project API keys \u2192 \`anon\` \`public\` |
238
+ | \`NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET\` | Storage \u2192 create bucket \u2192 use bucket name (default scaffold: \`public\`) |`
200
239
  },
201
240
  {
202
241
  id: "supabase-realtime",
@@ -209,14 +248,16 @@ var optionalModules = [
209
248
  },
210
249
  dependencies: {
211
250
  "@supabase/supabase-js": "^2.44.2"
212
- }
251
+ },
252
+ setupSection: `Uses same Supabase URL/anon key as \`supabase\` module. Enable Realtime on tables in Supabase Dashboard \u2192 Database \u2192 Replication.`
213
253
  },
214
254
  {
215
255
  id: "seo",
216
256
  label: "SEO pack",
217
257
  description: "Adds robots/sitemap and JsonLd helper files",
218
258
  templatePath: templatePaths.seo,
219
- env: {}
259
+ env: {},
260
+ setupSection: `No extra env keys. Edit \`src/app/robots.ts\`, \`sitemap.ts\`, and JSON-LD helpers after add.`
220
261
  },
221
262
  {
222
263
  id: "resend",
@@ -231,7 +272,11 @@ var optionalModules = [
231
272
  resend: "^6.9.2",
232
273
  "@react-email/components": "^1.0.12",
233
274
  "react-email": "^4.0.0"
234
- }
275
+ },
276
+ setupSection: `| Variable | Where to get |
277
+ | -------- | ------------ |
278
+ | \`RESEND_API_KEY\` | resend.com \u2192 API Keys \u2192 Create |
279
+ | \`RESEND_FROM_EMAIL\` | resend.com \u2192 Domains \u2192 verify domain \u2192 use \`Name <you@domain.com>\` |`
235
280
  }
236
281
  ];
237
282
  function getModuleById(moduleId) {
@@ -501,6 +546,263 @@ async function ensureChatSchemaInProject(projectRoot) {
501
546
  return "added";
502
547
  }
503
548
 
549
+ // src/core/i18n.ts
550
+ import path5 from "path";
551
+ import { readdir as readdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
552
+
553
+ // src/core/manifest.ts
554
+ import path4 from "path";
555
+ import { readdir as readdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
556
+ var defaultManifest = {
557
+ cli: "0.4.0",
558
+ defaultLocale: "vi",
559
+ locales: ["vi"],
560
+ namespaces: ["common", "auth", "example"],
561
+ modules: [],
562
+ features: ["example"]
563
+ };
564
+ function getManifestPath(projectDir) {
565
+ return path4.join(projectDir, "nextcli.json");
566
+ }
567
+ async function readManifest(projectDir) {
568
+ const manifestPath = getManifestPath(projectDir);
569
+ if (!await pathExists(manifestPath)) {
570
+ return null;
571
+ }
572
+ const raw = await readFile3(manifestPath, "utf8");
573
+ return JSON.parse(raw);
574
+ }
575
+ async function writeManifest(projectDir, manifest) {
576
+ const manifestPath = getManifestPath(projectDir);
577
+ await writeFile3(
578
+ manifestPath,
579
+ `${JSON.stringify(manifest, null, 2)}
580
+ `,
581
+ "utf8"
582
+ );
583
+ }
584
+ function parseConstArray(content, marker) {
585
+ const regex = marker === "locales" ? /nextcli:locales:start[\s\S]*?=\s*\[(.*?)\]\s*as const[\s\S]*?nextcli:locales:end/m : /nextcli:namespaces:start[\s\S]*?=\s*\[(.*?)\]\s*as const[\s\S]*?nextcli:namespaces:end/m;
586
+ const match = content.match(regex);
587
+ if (!match) {
588
+ return [];
589
+ }
590
+ return match[1].split(",").map((item) => item.trim().replaceAll('"', "").replaceAll("'", "")).filter(Boolean);
591
+ }
592
+ async function detectLocalesFromDisk(projectDir) {
593
+ const messagesDir = path4.join(projectDir, "messages");
594
+ if (!await pathExists(messagesDir)) {
595
+ return ["vi"];
596
+ }
597
+ const entries = await readdir2(messagesDir, { withFileTypes: true });
598
+ const locales = entries.filter((item) => item.isDirectory()).map((item) => item.name);
599
+ return locales.length > 0 ? locales.sort() : ["vi"];
600
+ }
601
+ async function detectNamespacesFromDisk(projectDir) {
602
+ const namespaceFile = path4.join(projectDir, "src/i18n/namespaces.ts");
603
+ if (!await pathExists(namespaceFile)) {
604
+ return [...defaultManifest.namespaces];
605
+ }
606
+ const content = await readFile3(namespaceFile, "utf8");
607
+ const namespaces = parseConstArray(content, "namespaces");
608
+ return namespaces.length > 0 ? namespaces : [...defaultManifest.namespaces];
609
+ }
610
+ async function reconcileManifest(projectDir) {
611
+ const localesFromDisk = await detectLocalesFromDisk(projectDir);
612
+ const namespacesFromDisk = await detectNamespacesFromDisk(projectDir);
613
+ const existing = await readManifest(projectDir);
614
+ const merged = {
615
+ ...existing ?? defaultManifest,
616
+ locales: [.../* @__PURE__ */ new Set([...existing?.locales ?? [], ...localesFromDisk])],
617
+ namespaces: [
618
+ .../* @__PURE__ */ new Set([...existing?.namespaces ?? [], ...namespacesFromDisk])
619
+ ]
620
+ };
621
+ merged.defaultLocale = merged.locales.includes(merged.defaultLocale) ? merged.defaultLocale : merged.locales[0] ?? "vi";
622
+ merged.modules = [...new Set(merged.modules)];
623
+ merged.features = [...new Set(merged.features)];
624
+ await writeManifest(projectDir, merged);
625
+ return merged;
626
+ }
627
+
628
+ // src/core/i18n.ts
629
+ var localeStartMarker = "// nextcli:locales:start";
630
+ var localeEndMarker = "// nextcli:locales:end";
631
+ var namespaceStartMarker = "// nextcli:namespaces:start";
632
+ var namespaceEndMarker = "// nextcli:namespaces:end";
633
+ function formatArray(items) {
634
+ return items.map((item) => `"${item}"`).join(", ");
635
+ }
636
+ function patchBetweenMarkers(content, start, end, replacement) {
637
+ const regex = new RegExp(`(${start})([\\s\\S]*?)(${end})`, "m");
638
+ return content.replace(regex, `$1
639
+ ${replacement}
640
+ $3`);
641
+ }
642
+ function deepCloneValue(value) {
643
+ return JSON.parse(JSON.stringify(value));
644
+ }
645
+ async function detectProjectState(projectDir) {
646
+ return reconcileManifest(projectDir);
647
+ }
648
+ async function patchLocalesConfig(projectDir, locales) {
649
+ const configPath = path5.join(projectDir, "src/i18n/config.ts");
650
+ if (!await pathExists(configPath)) {
651
+ return;
652
+ }
653
+ const content = await readFile4(configPath, "utf8");
654
+ const next = patchBetweenMarkers(
655
+ content,
656
+ localeStartMarker,
657
+ localeEndMarker,
658
+ `export const locales = [${formatArray(locales)}] as const;`
659
+ );
660
+ await writeFile4(configPath, next, "utf8");
661
+ }
662
+ async function appendNamespace(projectDir, namespace) {
663
+ const namespacePath = path5.join(projectDir, "src/i18n/namespaces.ts");
664
+ if (!await pathExists(namespacePath)) {
665
+ return [];
666
+ }
667
+ const currentState = await detectProjectState(projectDir);
668
+ const namespaces = [.../* @__PURE__ */ new Set([...currentState.namespaces, namespace])];
669
+ const content = await readFile4(namespacePath, "utf8");
670
+ const next = patchBetweenMarkers(
671
+ content,
672
+ namespaceStartMarker,
673
+ namespaceEndMarker,
674
+ `export const namespaces = [${formatArray(namespaces)}] as const;`
675
+ );
676
+ await writeFile4(namespacePath, next, "utf8");
677
+ await writeManifest(projectDir, {
678
+ ...currentState,
679
+ namespaces
680
+ });
681
+ return namespaces;
682
+ }
683
+ async function cloneLocaleMessages(projectDir, fromLocale, toLocale) {
684
+ const sourceDir = path5.join(projectDir, "messages", fromLocale);
685
+ const targetDir = path5.join(projectDir, "messages", toLocale);
686
+ if (!await pathExists(sourceDir)) {
687
+ return;
688
+ }
689
+ const entries = await readdir3(sourceDir, { withFileTypes: true });
690
+ if (!await pathExists(targetDir)) {
691
+ await ensureDir(targetDir);
692
+ }
693
+ for (const entry of entries) {
694
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
695
+ continue;
696
+ }
697
+ const sourcePath = path5.join(sourceDir, entry.name);
698
+ const targetPath = path5.join(targetDir, entry.name);
699
+ const sourceContent = JSON.parse(
700
+ await readFile4(sourcePath, "utf8")
701
+ );
702
+ await writeFile4(
703
+ targetPath,
704
+ `${JSON.stringify(deepCloneValue(sourceContent), null, 2)}
705
+ `,
706
+ "utf8"
707
+ );
708
+ }
709
+ }
710
+ async function writeNamespaceMessages(projectDir, namespace, viTemplate) {
711
+ const state = await detectProjectState(projectDir);
712
+ for (const locale of state.locales) {
713
+ const localeDir = path5.join(projectDir, "messages", locale);
714
+ if (!await pathExists(localeDir)) {
715
+ continue;
716
+ }
717
+ const filePath = path5.join(localeDir, `${namespace}.json`);
718
+ await writeFile4(
719
+ filePath,
720
+ `${JSON.stringify(deepCloneValue(viTemplate), null, 2)}
721
+ `,
722
+ "utf8"
723
+ );
724
+ }
725
+ }
726
+
727
+ // src/core/setup-docs.ts
728
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
729
+ import path6 from "path";
730
+ var ENABLED_MODULES_START = "<!-- nextcli:enabled-modules:start -->";
731
+ var ENABLED_MODULES_END = "<!-- nextcli:enabled-modules:end -->";
732
+ var MODULE_ENV_START = "<!-- nextcli:module-env:start -->";
733
+ var MODULE_ENV_END = "<!-- nextcli:module-env:end -->";
734
+ function buildModuleSection(moduleId) {
735
+ const module = getModuleById(moduleId);
736
+ if (!module.setupSection) {
737
+ return null;
738
+ }
739
+ return `### Module: ${module.label} (\`${module.id}\`)
740
+
741
+ ${module.setupSection.trim()}
742
+ `;
743
+ }
744
+ function formatEnabledModulesLine(moduleIds) {
745
+ if (moduleIds.length === 0) {
746
+ return "**Enabled modules:** none";
747
+ }
748
+ const labels = moduleIds.map((id) => `\`${id}\``).join(", ");
749
+ return `**Enabled modules:** ${labels}`;
750
+ }
751
+ async function updateEnabledModulesLine(content, moduleIds) {
752
+ const startIndex = content.indexOf(ENABLED_MODULES_START);
753
+ const endIndex = content.indexOf(ENABLED_MODULES_END);
754
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
755
+ return content;
756
+ }
757
+ const before = content.slice(0, startIndex + ENABLED_MODULES_START.length);
758
+ const after = content.slice(endIndex);
759
+ const line = formatEnabledModulesLine(moduleIds);
760
+ return `${before}
761
+ ${line}
762
+ ${after}`;
763
+ }
764
+ async function mergeModuleSetupSections(projectDir, moduleIds, allProjectModules) {
765
+ const setupPath = path6.join(projectDir, "SETUP.md");
766
+ if (!await pathExists(setupPath)) {
767
+ return;
768
+ }
769
+ let content = await readFile5(setupPath, "utf8");
770
+ const enabledIds = allProjectModules ?? moduleIds;
771
+ content = await updateEnabledModulesLine(content, enabledIds);
772
+ const sections = moduleIds.map((moduleId) => buildModuleSection(moduleId)).filter((section) => Boolean(section));
773
+ if (sections.length === 0) {
774
+ await writeFile5(setupPath, content, "utf8");
775
+ return;
776
+ }
777
+ const startIndex = content.indexOf(MODULE_ENV_START);
778
+ const endIndex = content.indexOf(MODULE_ENV_END);
779
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
780
+ await writeFile5(setupPath, content, "utf8");
781
+ return;
782
+ }
783
+ const before = content.slice(0, startIndex + MODULE_ENV_START.length);
784
+ const after = content.slice(endIndex);
785
+ const existingBlock = content.slice(
786
+ startIndex + MODULE_ENV_START.length,
787
+ endIndex
788
+ );
789
+ let nextBlock = existingBlock.trim();
790
+ for (const section of sections) {
791
+ const header = section.split("\n")[0];
792
+ if (header && nextBlock.includes(header)) {
793
+ continue;
794
+ }
795
+ nextBlock = nextBlock ? `${nextBlock}
796
+
797
+ ${section}` : section;
798
+ }
799
+ content = `${before}
800
+ ${nextBlock ? `
801
+ ${nextBlock}
802
+ ` : "\n"}${after}`;
803
+ await writeFile5(setupPath, content, "utf8");
804
+ }
805
+
504
806
  // src/commands/add.ts
505
807
  function toKebabCase(input) {
506
808
  return input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -654,6 +956,172 @@ export function useDelete${modelPascal}() {
654
956
  }
655
957
  `;
656
958
  }
959
+ function buildFeatureTableContent(featureSlug, modelPascal) {
960
+ return `"use client";
961
+
962
+ import { useMemo } from "react";
963
+ import { useTranslations } from "next-intl";
964
+ import { createColumnHelper, getCoreRowModel, getPaginationRowModel, useReactTable } from "@tanstack/react-table";
965
+ import { DataTable } from "@/components/ui/data-table/data-table";
966
+ import { use${modelPascal}s } from "@/features/${featureSlug}/api/use-${featureSlug}";
967
+
968
+ type ${modelPascal}Item = {
969
+ id: string;
970
+ name: string;
971
+ description?: string | null;
972
+ createdAt: string;
973
+ updatedAt: string;
974
+ };
975
+
976
+ const columnHelper = createColumnHelper<${modelPascal}Item>();
977
+
978
+ export function ${modelPascal}Table() {
979
+ const t = useTranslations("${featureSlug}");
980
+ const { data, isLoading } = use${modelPascal}s();
981
+
982
+ const columns = useMemo(
983
+ () => [
984
+ columnHelper.accessor("name", {
985
+ header: t("table.name"),
986
+ }),
987
+ columnHelper.accessor("description", {
988
+ header: t("table.description"),
989
+ }),
990
+ ],
991
+ [t],
992
+ );
993
+
994
+ const table = useReactTable({
995
+ data: Array.isArray(data) ? data : [],
996
+ columns,
997
+ getCoreRowModel: getCoreRowModel(),
998
+ getPaginationRowModel: getPaginationRowModel(),
999
+ });
1000
+
1001
+ if (isLoading) {
1002
+ return <p>{t("table.loading")}</p>;
1003
+ }
1004
+
1005
+ return <DataTable table={table} />;
1006
+ }
1007
+ `;
1008
+ }
1009
+ function buildFeatureDialogContent(featureSlug, modelPascal) {
1010
+ return `"use client";
1011
+
1012
+ import { useState, type FormEvent } from "react";
1013
+ import { useTranslations } from "next-intl";
1014
+ import { Button } from "@/components/ui/button";
1015
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
1016
+ import { Input } from "@/components/ui/input";
1017
+ import { Label } from "@/components/ui/label";
1018
+
1019
+ export function Create${modelPascal}Dialog({
1020
+ onCreate,
1021
+ }: {
1022
+ onCreate: (payload: { name: string; description?: string }) => Promise<void>;
1023
+ }) {
1024
+ const t = useTranslations("${featureSlug}");
1025
+ const [open, setOpen] = useState(false);
1026
+ const [name, setName] = useState("");
1027
+ const [description, setDescription] = useState("");
1028
+ const [submitting, setSubmitting] = useState(false);
1029
+
1030
+ const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
1031
+ event.preventDefault();
1032
+ setSubmitting(true);
1033
+ try {
1034
+ await onCreate({ name, description: description || undefined });
1035
+ setName("");
1036
+ setDescription("");
1037
+ setOpen(false);
1038
+ } finally {
1039
+ setSubmitting(false);
1040
+ }
1041
+ };
1042
+
1043
+ return (
1044
+ <Dialog open={open} onOpenChange={setOpen}>
1045
+ <DialogTrigger asChild>
1046
+ <Button>{t("dialog.open")}</Button>
1047
+ </DialogTrigger>
1048
+ <DialogContent>
1049
+ <DialogHeader>
1050
+ <DialogTitle>{t("dialog.title")}</DialogTitle>
1051
+ </DialogHeader>
1052
+ <form className="grid gap-4" onSubmit={handleSubmit}>
1053
+ <div className="grid gap-2">
1054
+ <Label htmlFor="name">{t("dialog.name")}</Label>
1055
+ <Input id="name" value={name} onChange={(event) => setName(event.target.value)} required />
1056
+ </div>
1057
+ <div className="grid gap-2">
1058
+ <Label htmlFor="description">{t("dialog.description")}</Label>
1059
+ <Input
1060
+ id="description"
1061
+ value={description}
1062
+ onChange={(event) => setDescription(event.target.value)}
1063
+ />
1064
+ </div>
1065
+ <DialogFooter>
1066
+ <Button type="submit" disabled={submitting}>
1067
+ {submitting ? t("dialog.submitting") : t("dialog.submit")}
1068
+ </Button>
1069
+ </DialogFooter>
1070
+ </form>
1071
+ </DialogContent>
1072
+ </Dialog>
1073
+ );
1074
+ }
1075
+ `;
1076
+ }
1077
+ function buildFeaturePageContent(featureSlug, modelPascal) {
1078
+ return `"use client";
1079
+
1080
+ import { useTranslations } from "next-intl";
1081
+ import { useCreate${modelPascal} } from "@/features/${featureSlug}/api/use-${featureSlug}";
1082
+ import { Create${modelPascal}Dialog } from "@/features/${featureSlug}/components/create-${featureSlug}-dialog";
1083
+ import { ${modelPascal}Table } from "@/features/${featureSlug}/components/${featureSlug}-table";
1084
+
1085
+ export default function ${modelPascal}Page() {
1086
+ const t = useTranslations("${featureSlug}");
1087
+ const createMutation = useCreate${modelPascal}();
1088
+
1089
+ return (
1090
+ <main className="space-y-4">
1091
+ <div className="flex items-center justify-between">
1092
+ <h1 className="text-2xl font-semibold">{t("page.title")}</h1>
1093
+ <Create${modelPascal}Dialog
1094
+ onCreate={async (payload) => {
1095
+ await createMutation.mutateAsync(payload);
1096
+ }}
1097
+ />
1098
+ </div>
1099
+ <${modelPascal}Table />
1100
+ </main>
1101
+ );
1102
+ }
1103
+ `;
1104
+ }
1105
+ function buildFeatureMessages(featureName) {
1106
+ return {
1107
+ page: {
1108
+ title: featureName
1109
+ },
1110
+ table: {
1111
+ name: "T\xEAn",
1112
+ description: "M\xF4 t\u1EA3",
1113
+ loading: "\u0110ang t\u1EA3i d\u1EEF li\u1EC7u..."
1114
+ },
1115
+ dialog: {
1116
+ open: "T\u1EA1o m\u1EDBi",
1117
+ title: `T\u1EA1o ${featureName}`,
1118
+ name: "T\xEAn",
1119
+ description: "M\xF4 t\u1EA3",
1120
+ submit: "L\u01B0u",
1121
+ submitting: "\u0110ang l\u01B0u..."
1122
+ }
1123
+ };
1124
+ }
657
1125
  function buildCollectionRouteContent(featureSlug, modelPascal) {
658
1126
  return `import { fail, ok } from "@/lib/api-response";
659
1127
  import {
@@ -750,11 +1218,11 @@ export async function DELETE(
750
1218
  `;
751
1219
  }
752
1220
  async function appendFeatureModelToPrismaSchema(cwd, modelPascal) {
753
- const schemaPath = path4.join(cwd, "prisma", "schema.prisma");
1221
+ const schemaPath = path7.join(cwd, "prisma", "schema.prisma");
754
1222
  if (!await pathExists(schemaPath)) {
755
1223
  return "skipped";
756
1224
  }
757
- const schemaContent = await readFile3(schemaPath, "utf8");
1225
+ const schemaContent = await readFile6(schemaPath, "utf8");
758
1226
  const modelRegex = new RegExp(`\\bmodel\\s+${modelPascal}\\b`);
759
1227
  if (modelRegex.test(schemaContent)) {
760
1228
  return "exists";
@@ -771,8 +1239,12 @@ model ${modelPascal} {
771
1239
  updatedAt DateTime @updatedAt
772
1240
  }
773
1241
  `;
774
- await writeFile3(schemaPath, `${schemaContent.trimEnd()}${modelBlock}
775
- `, "utf8");
1242
+ await writeFile6(
1243
+ schemaPath,
1244
+ `${schemaContent.trimEnd()}${modelBlock}
1245
+ `,
1246
+ "utf8"
1247
+ );
776
1248
  return "added";
777
1249
  }
778
1250
  var authProviderStartMarker = "// AUTO_GENERATED_AUTH_PROVIDERS_START";
@@ -833,18 +1305,18 @@ async function upsertEnvValue(envFilePath, key, value) {
833
1305
  if (!await pathExists(envFilePath)) {
834
1306
  return;
835
1307
  }
836
- const content = await readFile3(envFilePath, "utf8");
1308
+ const content = await readFile6(envFilePath, "utf8");
837
1309
  const entry = `${key}=${value}`;
838
1310
  const pattern = new RegExp(`^${key}=.*$`, "m");
839
1311
  if (pattern.test(content)) {
840
1312
  const next = content.replace(pattern, entry);
841
1313
  if (next !== content) {
842
- await writeFile3(envFilePath, next, "utf8");
1314
+ await writeFile6(envFilePath, next, "utf8");
843
1315
  }
844
1316
  return;
845
1317
  }
846
1318
  const separator = content.endsWith("\n") || content.length === 0 ? "" : "\n";
847
- await writeFile3(envFilePath, `${content}${separator}${entry}
1319
+ await writeFile6(envFilePath, `${content}${separator}${entry}
848
1320
  `, "utf8");
849
1321
  }
850
1322
  function registerAddCommand(program2) {
@@ -857,54 +1329,118 @@ function registerAddCommand(program2) {
857
1329
  return;
858
1330
  }
859
1331
  const cwd = process.cwd();
860
- const srcPath = path4.join(cwd, "src");
1332
+ const srcPath = path7.join(cwd, "src");
861
1333
  if (!await pathExists(srcPath)) {
862
- log.error("Run this command from your generated Next.js project root (missing ./src).");
1334
+ log.error(
1335
+ "Run this command from your generated Next.js project root (missing ./src)."
1336
+ );
863
1337
  process.exitCode = 1;
864
1338
  return;
865
1339
  }
866
1340
  const featurePascal = toPascalCase(featureSlug);
867
1341
  const modelPascal = singularizeWord(featurePascal);
868
1342
  const modelDelegate = toCamelCase(modelPascal);
869
- const featureRoot = path4.join(cwd, "src/features", featureSlug);
1343
+ const featureRoot = path7.join(cwd, "src/features", featureSlug);
870
1344
  if (await pathExists(featureRoot)) {
871
1345
  log.error(`Feature already exists: ${featureRoot}`);
872
1346
  process.exitCode = 1;
873
1347
  return;
874
1348
  }
875
- await ensureDir(path4.join(featureRoot, "api"));
876
- await ensureDir(path4.join(featureRoot, "components"));
877
- await writeFile3(
878
- path4.join(featureRoot, "services.ts"),
1349
+ await ensureDir(path7.join(featureRoot, "api"));
1350
+ await ensureDir(path7.join(featureRoot, "components"));
1351
+ await writeFile6(
1352
+ path7.join(featureRoot, "services.ts"),
879
1353
  buildFeatureServicesContent(modelPascal, modelDelegate),
880
1354
  "utf8"
881
1355
  );
882
- await writeFile3(
883
- path4.join(featureRoot, "validations.ts"),
1356
+ await writeFile6(
1357
+ path7.join(featureRoot, "validations.ts"),
884
1358
  buildFeatureValidationContent(modelPascal),
885
1359
  "utf8"
886
1360
  );
887
- await writeFile3(
888
- path4.join(featureRoot, "api", `use-${featureSlug}.ts`),
1361
+ await writeFile6(
1362
+ path7.join(featureRoot, "api", `use-${featureSlug}.ts`),
889
1363
  buildFeatureHooksContent(featureSlug, modelPascal),
890
1364
  "utf8"
891
1365
  );
892
- const routeFilePath = path4.join(cwd, "src/app/api/v1", featureSlug, "route.ts");
893
- await ensureDir(path4.dirname(routeFilePath));
894
- await writeFile3(routeFilePath, buildCollectionRouteContent(featureSlug, modelPascal), "utf8");
895
- const idRoutePath = path4.join(cwd, "src/app/api/v1", featureSlug, "[id]", "route.ts");
896
- await ensureDir(path4.dirname(idRoutePath));
897
- await writeFile3(idRoutePath, buildItemRouteContent(featureSlug, modelPascal), "utf8");
898
- const schemaStatus = await appendFeatureModelToPrismaSchema(cwd, modelPascal);
1366
+ await writeFile6(
1367
+ path7.join(featureRoot, "components", `${featureSlug}-table.tsx`),
1368
+ buildFeatureTableContent(featureSlug, modelPascal),
1369
+ "utf8"
1370
+ );
1371
+ await writeFile6(
1372
+ path7.join(
1373
+ featureRoot,
1374
+ "components",
1375
+ `create-${featureSlug}-dialog.tsx`
1376
+ ),
1377
+ buildFeatureDialogContent(featureSlug, modelPascal),
1378
+ "utf8"
1379
+ );
1380
+ const routeFilePath = path7.join(
1381
+ cwd,
1382
+ "src/app/api/v1",
1383
+ featureSlug,
1384
+ "route.ts"
1385
+ );
1386
+ await ensureDir(path7.dirname(routeFilePath));
1387
+ await writeFile6(
1388
+ routeFilePath,
1389
+ buildCollectionRouteContent(featureSlug, modelPascal),
1390
+ "utf8"
1391
+ );
1392
+ const idRoutePath = path7.join(
1393
+ cwd,
1394
+ "src/app/api/v1",
1395
+ featureSlug,
1396
+ "[id]",
1397
+ "route.ts"
1398
+ );
1399
+ await ensureDir(path7.dirname(idRoutePath));
1400
+ await writeFile6(
1401
+ idRoutePath,
1402
+ buildItemRouteContent(featureSlug, modelPascal),
1403
+ "utf8"
1404
+ );
1405
+ const featurePagePath = path7.join(
1406
+ cwd,
1407
+ "src/app/(dashboard)",
1408
+ featureSlug,
1409
+ "page.tsx"
1410
+ );
1411
+ await ensureDir(path7.dirname(featurePagePath));
1412
+ await writeFile6(
1413
+ featurePagePath,
1414
+ buildFeaturePageContent(featureSlug, modelPascal),
1415
+ "utf8"
1416
+ );
1417
+ const manifestState = await detectProjectState(cwd);
1418
+ await writeNamespaceMessages(
1419
+ cwd,
1420
+ featureSlug,
1421
+ buildFeatureMessages(modelPascal)
1422
+ );
1423
+ const namespaces = await appendNamespace(cwd, featureSlug);
1424
+ await writeManifest(cwd, {
1425
+ ...manifestState,
1426
+ namespaces,
1427
+ features: [.../* @__PURE__ */ new Set([...manifestState.features, featureSlug])]
1428
+ });
1429
+ const schemaStatus = await appendFeatureModelToPrismaSchema(
1430
+ cwd,
1431
+ modelPascal
1432
+ );
899
1433
  const schemaMessage = schemaStatus === "added" ? `Model ${modelPascal} appended to prisma/schema.prisma` : schemaStatus === "exists" ? `Model ${modelPascal} already exists in prisma/schema.prisma` : "Skipped prisma/schema.prisma update (file not found)";
900
1434
  log.success(`Feature generated with CRUD: src/features/${featureSlug}`);
901
1435
  log.info(schemaMessage);
902
- log.warn("No migration was executed. Run your migration command manually when ready.");
1436
+ log.warn(
1437
+ "No migration was executed. Run your migration command manually when ready."
1438
+ );
903
1439
  });
904
1440
  add.command("module").description("Add optional modules using interactive multi-select").option("--module <module...>", "Preselect module ids").option("--yes", "Skip prompts").action(async (options) => {
905
1441
  const cwd = process.cwd();
906
- const hasSrc = await pathExists(path4.join(cwd, "src"));
907
- const hasPackageJson = await pathExists(path4.join(cwd, "package.json"));
1442
+ const hasSrc = await pathExists(path7.join(cwd, "src"));
1443
+ const hasPackageJson = await pathExists(path7.join(cwd, "package.json"));
908
1444
  if (!hasSrc || !hasPackageJson) {
909
1445
  log.error("Run this command from your generated Next.js project root.");
910
1446
  process.exitCode = 1;
@@ -951,46 +1487,89 @@ function registerAddCommand(program2) {
951
1487
  if (selectedModules.includes("chat")) {
952
1488
  chatSchemaStatus = await ensureChatSchemaInProject(cwd);
953
1489
  }
954
- const envEntries = selectedModules.reduce((acc, moduleId) => {
955
- const module = getModuleById(moduleId);
956
- return {
957
- ...acc,
958
- ...module.env
959
- };
960
- }, {});
1490
+ const envEntries = selectedModules.reduce(
1491
+ (acc, moduleId) => {
1492
+ const module = getModuleById(moduleId);
1493
+ return {
1494
+ ...acc,
1495
+ ...module.env
1496
+ };
1497
+ },
1498
+ {}
1499
+ );
961
1500
  if (Object.keys(envEntries).length > 0) {
962
- const envTargets = [
963
- ".env",
964
- ".env.example",
965
- ".env.development"
966
- ];
1501
+ const envTargets = [".env", ".env.example", ".env.development"];
967
1502
  for (const envFile of envTargets) {
968
- const envPath = path4.join(cwd, envFile);
1503
+ const envPath = path7.join(cwd, envFile);
969
1504
  if (await pathExists(envPath)) {
970
1505
  await mergeEnvFile(envPath, envEntries);
971
1506
  }
972
1507
  }
973
1508
  }
974
1509
  if (selectedModules.includes("chat")) {
975
- const envTargets = [
976
- ".env",
977
- ".env.example",
978
- ".env.development"
979
- ];
1510
+ const envTargets = [".env", ".env.example", ".env.development"];
980
1511
  for (const envFile of envTargets) {
981
- await upsertEnvValue(path4.join(cwd, envFile), "NEXT_PUBLIC_ENABLE_CHAT", "true");
1512
+ await upsertEnvValue(
1513
+ path7.join(cwd, envFile),
1514
+ "NEXT_PUBLIC_ENABLE_CHAT",
1515
+ "true"
1516
+ );
982
1517
  }
983
1518
  }
984
- const dependencyEntries = selectedModules.reduce((acc, moduleId) => {
985
- const module = getModuleById(moduleId);
986
- return {
987
- ...acc,
988
- ...module.dependencies ?? {}
989
- };
990
- }, {});
1519
+ const dependencyEntries = selectedModules.reduce(
1520
+ (acc, moduleId) => {
1521
+ const module = getModuleById(moduleId);
1522
+ return {
1523
+ ...acc,
1524
+ ...module.dependencies ?? {}
1525
+ };
1526
+ },
1527
+ {}
1528
+ );
991
1529
  if (Object.keys(dependencyEntries).length > 0) {
992
- await addDependencies(path4.join(cwd, "package.json"), dependencyEntries);
1530
+ await addDependencies(
1531
+ path7.join(cwd, "package.json"),
1532
+ dependencyEntries
1533
+ );
993
1534
  }
1535
+ const state = await detectProjectState(cwd);
1536
+ const namespaceSet = new Set(state.namespaces);
1537
+ for (const moduleId of selectedModules) {
1538
+ const moduleTemplateMessages = path7.join(
1539
+ getModuleById(moduleId).templatePath,
1540
+ "messages/vi"
1541
+ );
1542
+ if (!await pathExists(moduleTemplateMessages)) {
1543
+ continue;
1544
+ }
1545
+ const files = await readdir4(moduleTemplateMessages, {
1546
+ withFileTypes: true
1547
+ });
1548
+ for (const file of files) {
1549
+ if (!file.isFile() || !file.name.endsWith(".json")) {
1550
+ continue;
1551
+ }
1552
+ const namespace = file.name.replace(/\.json$/, "");
1553
+ namespaceSet.add(namespace);
1554
+ const templateData = JSON.parse(
1555
+ await readFile6(
1556
+ path7.join(moduleTemplateMessages, file.name),
1557
+ "utf8"
1558
+ )
1559
+ );
1560
+ await writeNamespaceMessages(cwd, namespace, templateData);
1561
+ await appendNamespace(cwd, namespace);
1562
+ }
1563
+ }
1564
+ await writeManifest(cwd, {
1565
+ ...state,
1566
+ namespaces: [...namespaceSet],
1567
+ modules: [.../* @__PURE__ */ new Set([...state.modules, ...selectedModules])]
1568
+ });
1569
+ const mergedModules = [
1570
+ .../* @__PURE__ */ new Set([...state.modules, ...selectedModules])
1571
+ ];
1572
+ await mergeModuleSetupSections(cwd, selectedModules, mergedModules);
994
1573
  finishPrompt(`Added modules: ${selectedModules.join(", ")}`);
995
1574
  log.detail("Copied files", String(copiedFileCount));
996
1575
  if (autoAddedModules.length > 0) {
@@ -1011,18 +1590,92 @@ function registerAddCommand(program2) {
1011
1590
  }
1012
1591
  }
1013
1592
  if (chatSchemaStatus === "added") {
1014
- log.info("Optional chat schema block was appended to prisma/schema.prisma.");
1593
+ log.info(
1594
+ "Optional chat schema block was appended to prisma/schema.prisma."
1595
+ );
1596
+ }
1597
+ log.step(
1598
+ "Next: run your package manager install to apply new dependencies."
1599
+ );
1600
+ });
1601
+ add.command("language").description("Add locales and clone message files from Vietnamese").option("--locale <locale...>", "Preselect locales: en,ja,ko").option("--yes", "Skip prompts").action(async (options) => {
1602
+ const cwd = process.cwd();
1603
+ const hasMessages = await pathExists(path7.join(cwd, "messages"));
1604
+ const hasConfig = await pathExists(path7.join(cwd, "src/i18n/config.ts"));
1605
+ if (!hasMessages || !hasConfig) {
1606
+ log.error(
1607
+ "Run this command from a generated Next.js project with i18n scaffold."
1608
+ );
1609
+ process.exitCode = 1;
1610
+ return;
1015
1611
  }
1016
- log.step("Next: run your package manager install to apply new dependencies.");
1612
+ const state = await detectProjectState(cwd);
1613
+ const supportedLocales = [
1614
+ { id: "en", label: "English" },
1615
+ { id: "ja", label: "Japanese" },
1616
+ { id: "ko", label: "Korean" }
1617
+ ];
1618
+ const requested = options.locale ? options.locale.flatMap((value) => value.split(",")).map((value) => value.trim().toLowerCase()).filter(Boolean) : [];
1619
+ const preselected = requested.filter(
1620
+ (item) => supportedLocales.some((supported) => supported.id === item)
1621
+ );
1622
+ const available = supportedLocales.filter(
1623
+ (locale) => !state.locales.includes(locale.id)
1624
+ );
1625
+ if (available.length === 0) {
1626
+ log.info("All supported locales already exist.");
1627
+ return;
1628
+ }
1629
+ startPrompt("NexTCLI i18n language setup");
1630
+ const selected = preselected.length > 0 ? preselected : options.yes ? ["en"] : await askMultiSelect(
1631
+ "Select locales to add:",
1632
+ available.map((locale) => ({
1633
+ value: locale.id,
1634
+ label: locale.label,
1635
+ hint: locale.id === "en" ? "Required baseline locale" : "Optional locale"
1636
+ })),
1637
+ available.some((locale) => locale.id === "en") ? ["en"] : []
1638
+ );
1639
+ const normalized = [
1640
+ ...new Set(
1641
+ selected.filter(
1642
+ (value) => available.some((item) => item.id === value)
1643
+ )
1644
+ )
1645
+ ];
1646
+ if (!normalized.includes("en")) {
1647
+ normalized.unshift("en");
1648
+ }
1649
+ if (normalized.length === 0) {
1650
+ finishPrompt("No locales selected.");
1651
+ return;
1652
+ }
1653
+ for (const locale of normalized) {
1654
+ await cloneLocaleMessages(cwd, "vi", locale);
1655
+ }
1656
+ const mergedLocales = [
1657
+ .../* @__PURE__ */ new Set([...state.locales, ...normalized])
1658
+ ].sort();
1659
+ await patchLocalesConfig(cwd, mergedLocales);
1660
+ await writeManifest(cwd, {
1661
+ ...state,
1662
+ locales: mergedLocales
1663
+ });
1664
+ finishPrompt(`Added locales: ${normalized.join(", ")}`);
1665
+ log.info(
1666
+ "Locale files were cloned from Vietnamese values. Translate them when ready."
1667
+ );
1017
1668
  });
1018
1669
  add.command("auth-provider").description("Add social auth providers to existing Better Auth setup").option("--provider <provider...>", "Preselect providers: google,facebook").option("--yes", "Skip prompts").action(async (options) => {
1019
1670
  const cwd = process.cwd();
1020
- const authFilePath = path4.join(cwd, "src/lib/auth.ts");
1021
- const hasSrc = await pathExists(path4.join(cwd, "src"));
1022
- const hasPackageJson = await pathExists(path4.join(cwd, "package.json"));
1671
+ const authFilePath = path7.join(cwd, "src/lib/auth.ts");
1672
+ const hasSrc = await pathExists(path7.join(cwd, "src"));
1673
+ const hasPackageJson = await pathExists(path7.join(cwd, "package.json"));
1023
1674
  const hasAuthFile = await pathExists(authFilePath);
1024
1675
  if (!hasSrc || !hasPackageJson || !hasAuthFile) {
1025
- log.error("Run this command from a generated Next.js project with src/lib/auth.ts.");
1676
+ log.error(
1677
+ "Run this command from a generated Next.js project with src/lib/auth.ts."
1678
+ );
1026
1679
  process.exitCode = 1;
1027
1680
  return;
1028
1681
  }
@@ -1039,8 +1692,16 @@ function registerAddCommand(program2) {
1039
1692
  const selectedProviders = requestedProviders.length > 0 ? [...new Set(requestedProviders)] : options.yes ? [] : await askMultiSelect(
1040
1693
  "Select social providers to enable:",
1041
1694
  [
1042
- { value: "google", label: "Google", hint: "Google OAuth login" },
1043
- { value: "facebook", label: "Facebook", hint: "Facebook OAuth login" }
1695
+ {
1696
+ value: "google",
1697
+ label: "Google",
1698
+ hint: "Google OAuth login"
1699
+ },
1700
+ {
1701
+ value: "facebook",
1702
+ label: "Facebook",
1703
+ hint: "Facebook OAuth login"
1704
+ }
1044
1705
  ],
1045
1706
  []
1046
1707
  );
@@ -1048,11 +1709,13 @@ function registerAddCommand(program2) {
1048
1709
  finishPrompt("No auth providers selected.");
1049
1710
  return;
1050
1711
  }
1051
- const authContent = await readFile3(authFilePath, "utf8");
1712
+ const authContent = await readFile6(authFilePath, "utf8");
1052
1713
  const existingProviders = readConfiguredProviders(authContent);
1053
- const mergedProviders = [.../* @__PURE__ */ new Set([...existingProviders, ...selectedProviders])];
1714
+ const mergedProviders = [
1715
+ .../* @__PURE__ */ new Set([...existingProviders, ...selectedProviders])
1716
+ ];
1054
1717
  const nextAuthContent = patchAuthProviders(authContent, mergedProviders);
1055
- await writeFile3(authFilePath, nextAuthContent, "utf8");
1718
+ await writeFile6(authFilePath, nextAuthContent, "utf8");
1056
1719
  const envEntries = {};
1057
1720
  if (mergedProviders.includes("google")) {
1058
1721
  envEntries.GOOGLE_CLIENT_ID = "";
@@ -1062,19 +1725,17 @@ function registerAddCommand(program2) {
1062
1725
  envEntries.FACEBOOK_CLIENT_ID = "";
1063
1726
  envEntries.FACEBOOK_CLIENT_SECRET = "";
1064
1727
  }
1065
- const envTargets = [
1066
- ".env",
1067
- ".env.example",
1068
- ".env.development"
1069
- ];
1728
+ const envTargets = [".env", ".env.example", ".env.development"];
1070
1729
  for (const envFile of envTargets) {
1071
- const envPath = path4.join(cwd, envFile);
1730
+ const envPath = path7.join(cwd, envFile);
1072
1731
  if (await pathExists(envPath)) {
1073
1732
  await mergeEnvFile(envPath, envEntries);
1074
1733
  }
1075
1734
  }
1076
1735
  finishPrompt(`Enabled providers: ${mergedProviders.join(", ")}`);
1077
- await ensureBetterAuthGenerate(cwd, { nonInteractive: Boolean(options.yes) });
1736
+ await ensureBetterAuthGenerate(cwd, {
1737
+ nonInteractive: Boolean(options.yes)
1738
+ });
1078
1739
  log.step("Next: set provider secrets in .env and restart dev server.");
1079
1740
  });
1080
1741
  }
@@ -1082,7 +1743,7 @@ function registerAddCommand(program2) {
1082
1743
  // src/commands/create.ts
1083
1744
  import { spawn as spawn2 } from "child_process";
1084
1745
  import { randomBytes } from "crypto";
1085
- import path5 from "path";
1746
+ import path8 from "path";
1086
1747
  async function runInstall(packageManager, cwd) {
1087
1748
  const installArgsMap = {
1088
1749
  npm: ["install"],
@@ -1138,7 +1799,7 @@ async function resolveProjectName() {
1138
1799
  }
1139
1800
  }
1140
1801
  });
1141
- const targetPath = path5.resolve(process.cwd(), projectName);
1802
+ const targetPath = path8.resolve(process.cwd(), projectName);
1142
1803
  if (await pathExists(targetPath)) {
1143
1804
  log.error(`Target directory already exists: ${targetPath}`);
1144
1805
  continue;
@@ -1150,8 +1811,8 @@ function registerCreateCommand(program2) {
1150
1811
  program2.command("create").description("Create a new outsource-ready Next.js app").action(async () => {
1151
1812
  startPrompt("NexTCLI project creation");
1152
1813
  const projectName = await resolveProjectName();
1153
- const targetPath = path5.resolve(process.cwd(), projectName);
1154
- const projectDirectoryName = path5.basename(targetPath);
1814
+ const targetPath = path8.resolve(process.cwd(), projectName);
1815
+ const projectDirectoryName = path8.basename(targetPath);
1155
1816
  const projectSlug = toProjectSlug(projectDirectoryName);
1156
1817
  const packageManager = await askSelect(
1157
1818
  "Which package manager do you want to use?",
@@ -1200,7 +1861,7 @@ function registerCreateCommand(program2) {
1200
1861
  if (Object.keys(moduleEnvEntries).length > 0) {
1201
1862
  const envTargets = [".env", ".env.example", ".env.development"];
1202
1863
  for (const envFile of envTargets) {
1203
- const envPath = path5.join(targetPath, envFile);
1864
+ const envPath = path8.join(targetPath, envFile);
1204
1865
  if (await pathExists(envPath)) {
1205
1866
  await mergeEnvFile(envPath, moduleEnvEntries);
1206
1867
  }
@@ -1218,7 +1879,7 @@ function registerCreateCommand(program2) {
1218
1879
  );
1219
1880
  if (Object.keys(dependencyEntries).length > 0) {
1220
1881
  await addDependencies(
1221
- path5.join(targetPath, "package.json"),
1882
+ path8.join(targetPath, "package.json"),
1222
1883
  dependencyEntries
1223
1884
  );
1224
1885
  }
@@ -1226,8 +1887,22 @@ function registerCreateCommand(program2) {
1226
1887
  await replaceTokensInDirectory(targetPath, {
1227
1888
  __PROJECT_NAME__: projectSlug,
1228
1889
  __ENABLE_CHAT__: selectedModules.includes("chat") ? "true" : "false",
1229
- __BETTER_AUTH_SECRET__: betterAuthSecret
1890
+ __BETTER_AUTH_SECRET__: betterAuthSecret,
1891
+ __NEXTCLI_VERSION__: "0.4.0"
1230
1892
  });
1893
+ await mergeModuleSetupSections(
1894
+ targetPath,
1895
+ selectedModules,
1896
+ selectedModules
1897
+ );
1898
+ const manifest = await readManifest(targetPath);
1899
+ if (manifest) {
1900
+ await writeManifest(targetPath, {
1901
+ ...manifest,
1902
+ cli: "0.4.0",
1903
+ modules: selectedModules
1904
+ });
1905
+ }
1231
1906
  if (shouldInstall) {
1232
1907
  log.step(`Installing dependencies with ${packageManager}...`);
1233
1908
  await runInstall(packageManager, targetPath);
@@ -1249,13 +1924,15 @@ function registerCreateCommand(program2) {
1249
1924
  "Optional chat schema block was appended to prisma/schema.prisma."
1250
1925
  );
1251
1926
  }
1252
- log.step(`Next: cd ${projectName} && ${packageManager} run dev`);
1927
+ log.step(
1928
+ `Next: cd ${projectName} && ${packageManager} run db:migrate && ${packageManager} run dev`
1929
+ );
1253
1930
  });
1254
1931
  }
1255
1932
 
1256
1933
  // src/commands/migrate.ts
1257
1934
  import { spawn as spawn3 } from "child_process";
1258
- import path6 from "path";
1935
+ import path9 from "path";
1259
1936
  function createDefaultMigrationName() {
1260
1937
  const now = /* @__PURE__ */ new Date();
1261
1938
  const y = now.getFullYear();
@@ -1267,16 +1944,16 @@ function createDefaultMigrationName() {
1267
1944
  return `auto_${y}${m}${d}${hh}${mm}${ss}`;
1268
1945
  }
1269
1946
  async function detectPackageManager(cwd) {
1270
- if (await pathExists(path6.join(cwd, "bun.lockb"))) {
1947
+ if (await pathExists(path9.join(cwd, "bun.lockb"))) {
1271
1948
  return "bun";
1272
1949
  }
1273
- if (await pathExists(path6.join(cwd, "bun.lock"))) {
1950
+ if (await pathExists(path9.join(cwd, "bun.lock"))) {
1274
1951
  return "bun";
1275
1952
  }
1276
- if (await pathExists(path6.join(cwd, "pnpm-lock.yaml"))) {
1953
+ if (await pathExists(path9.join(cwd, "pnpm-lock.yaml"))) {
1277
1954
  return "pnpm";
1278
1955
  }
1279
- if (await pathExists(path6.join(cwd, "yarn.lock"))) {
1956
+ if (await pathExists(path9.join(cwd, "yarn.lock"))) {
1280
1957
  return "yarn";
1281
1958
  }
1282
1959
  return "npm";
@@ -1311,8 +1988,8 @@ async function runCommand2(command, args, cwd) {
1311
1988
  function registerMigrateCommand(program2) {
1312
1989
  program2.command("migrate").description("Run Prisma migration script in current project").option("--name <migration-name>", "Migration name (defaults to auto timestamp)").option("--skip-generate", "Pass --skip-generate to prisma migrate dev").action(async (options) => {
1313
1990
  const cwd = process.cwd();
1314
- const hasPackageJson = await pathExists(path6.join(cwd, "package.json"));
1315
- const hasPrismaSchema = await pathExists(path6.join(cwd, "prisma", "schema.prisma"));
1991
+ const hasPackageJson = await pathExists(path9.join(cwd, "package.json"));
1992
+ const hasPrismaSchema = await pathExists(path9.join(cwd, "prisma", "schema.prisma"));
1316
1993
  if (!hasPackageJson || !hasPrismaSchema) {
1317
1994
  log.error(
1318
1995
  "Run this command from a generated project root (requires package.json + prisma/schema.prisma)."
@@ -1464,7 +2141,7 @@ var NexTCLICommand = class _NexTCLICommand extends Command {
1464
2141
 
1465
2142
  // src/cli.ts
1466
2143
  var program = new NexTCLICommand();
1467
- program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.2.1");
2144
+ program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.4.0");
1468
2145
  registerCreateCommand(program);
1469
2146
  registerAddCommand(program);
1470
2147
  registerMigrateCommand(program);