@thinhnguyencth1204/nextcli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1439 -0
- package/package.json +43 -0
- package/templates/features/chat/src/app/api/v1/chat/route.ts +40 -0
- package/templates/features/chat/src/features/chat/api/use-chat-history.ts +18 -0
- package/templates/features/chat/src/features/chat/api/use-realtime-sync.ts +15 -0
- package/templates/features/chat/src/features/chat/api/use-send-message.ts +35 -0
- package/templates/features/chat/src/features/chat/components/ChatWidget.tsx +40 -0
- package/templates/features/chat/src/features/chat/services.ts +27 -0
- package/templates/features/seo/public/robots.txt +3 -0
- package/templates/features/seo/public/sitemap.xml +6 -0
- package/templates/features/seo/src/app/robots.ts +13 -0
- package/templates/features/seo/src/app/sitemap.ts +21 -0
- package/templates/features/seo/src/components/seo/json-ld.tsx +14 -0
- package/templates/features/supabase/src/lib/supabase/client.ts +9 -0
- package/templates/features/supabase/src/lib/supabase/storage-config.ts +69 -0
- package/templates/features/supabase/src/lib/supabase/storage.ts +167 -0
- package/templates/features/supabase-realtime/src/features/supabase-realtime/client.ts +9 -0
- package/templates/features/supabase-realtime/src/features/supabase-realtime/use-supabase-channel.ts +19 -0
- package/templates/next-base/.env +11 -0
- package/templates/next-base/.env.development +11 -0
- package/templates/next-base/.env.example +11 -0
- package/templates/next-base/eslint.config.mjs +20 -0
- package/templates/next-base/middleware.ts +10 -0
- package/templates/next-base/next-env.d.ts +4 -0
- package/templates/next-base/next.config.ts +7 -0
- package/templates/next-base/package.json +45 -0
- package/templates/next-base/prisma/migrations/.gitkeep +1 -0
- package/templates/next-base/prisma/schema.prisma +72 -0
- package/templates/next-base/prisma.config.ts +16 -0
- package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
- package/templates/next-base/src/app/(auth)/sign-in/page.tsx +11 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +14 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +10 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +60 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +26 -0
- package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- package/templates/next-base/src/app/layout.tsx +28 -0
- package/templates/next-base/src/app/page.tsx +21 -0
- package/templates/next-base/src/app/styles.css +12 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
- package/templates/next-base/src/components/ui/button.tsx +16 -0
- package/templates/next-base/src/example/api/use-example.ts +21 -0
- package/templates/next-base/src/example/api/use-mutations.ts +20 -0
- package/templates/next-base/src/example/components/example-table.tsx +66 -0
- package/templates/next-base/src/example/services.ts +9 -0
- package/templates/next-base/src/example/validations.ts +8 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +62 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +77 -0
- package/templates/next-base/src/features/auth/validations.ts +8 -0
- package/templates/next-base/src/hooks/index.ts +1 -0
- package/templates/next-base/src/i18n/request.ts +8 -0
- package/templates/next-base/src/lib/api-response.ts +49 -0
- package/templates/next-base/src/lib/auth-client.ts +7 -0
- package/templates/next-base/src/lib/auth-cookies.ts +15 -0
- package/templates/next-base/src/lib/auth.ts +20 -0
- package/templates/next-base/src/lib/axios-instance.ts +140 -0
- package/templates/next-base/src/lib/prisma.ts +13 -0
- package/templates/next-base/src/lib/token-store.ts +13 -0
- package/templates/next-base/src/types/index.ts +40 -0
- package/templates/next-base/src/utils/cn.ts +6 -0
- package/templates/next-base/tsconfig.json +24 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1439 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/add.ts
|
|
4
|
+
import path4 from "path";
|
|
5
|
+
|
|
6
|
+
// src/core/fs.ts
|
|
7
|
+
import {
|
|
8
|
+
copyFile,
|
|
9
|
+
cp,
|
|
10
|
+
mkdir,
|
|
11
|
+
readdir,
|
|
12
|
+
readFile,
|
|
13
|
+
stat,
|
|
14
|
+
writeFile
|
|
15
|
+
} from "fs/promises";
|
|
16
|
+
import path from "path";
|
|
17
|
+
async function ensureDir(targetPath) {
|
|
18
|
+
await mkdir(targetPath, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
async function pathExists(targetPath) {
|
|
21
|
+
try {
|
|
22
|
+
await stat(targetPath);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function copyDirectory(source, destination) {
|
|
29
|
+
await ensureDir(destination);
|
|
30
|
+
await cp(source, destination, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
async function copyDirectorySafely(source, destination) {
|
|
33
|
+
const report = {
|
|
34
|
+
copiedCount: 0,
|
|
35
|
+
skippedConflicts: []
|
|
36
|
+
};
|
|
37
|
+
async function walk(currentSource, currentDestination) {
|
|
38
|
+
await ensureDir(currentDestination);
|
|
39
|
+
const entries = await readdir(currentSource, { withFileTypes: true });
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const sourcePath = path.join(currentSource, entry.name);
|
|
42
|
+
const destinationPath = path.join(currentDestination, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
await walk(sourcePath, destinationPath);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!entry.isFile()) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (await pathExists(destinationPath)) {
|
|
51
|
+
report.skippedConflicts.push(path.relative(destination, destinationPath));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
await ensureDir(path.dirname(destinationPath));
|
|
55
|
+
await copyFile(sourcePath, destinationPath);
|
|
56
|
+
report.copiedCount += 1;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await walk(source, destination);
|
|
60
|
+
return report;
|
|
61
|
+
}
|
|
62
|
+
var textExtensions = /* @__PURE__ */ new Set([
|
|
63
|
+
".env",
|
|
64
|
+
".example",
|
|
65
|
+
".development",
|
|
66
|
+
".json",
|
|
67
|
+
".ts",
|
|
68
|
+
".tsx",
|
|
69
|
+
".js",
|
|
70
|
+
".jsx",
|
|
71
|
+
".mjs",
|
|
72
|
+
".cjs",
|
|
73
|
+
".md",
|
|
74
|
+
".txt",
|
|
75
|
+
".css",
|
|
76
|
+
".yml",
|
|
77
|
+
".yaml",
|
|
78
|
+
".prisma"
|
|
79
|
+
]);
|
|
80
|
+
function isTextFile(filePath) {
|
|
81
|
+
const base = path.basename(filePath);
|
|
82
|
+
if (base === ".env" || base === ".env.example" || base === ".env.development") {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const ext = path.extname(filePath);
|
|
86
|
+
return textExtensions.has(ext);
|
|
87
|
+
}
|
|
88
|
+
async function replaceTokensInDirectory(directoryPath, replacements) {
|
|
89
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const fullPath = path.join(directoryPath, entry.name);
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
await replaceTokensInDirectory(fullPath, replacements);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!entry.isFile() || !isTextFile(fullPath)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const content = await readFile(fullPath, "utf8");
|
|
100
|
+
let nextContent = content;
|
|
101
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
102
|
+
nextContent = nextContent.replaceAll(token, value);
|
|
103
|
+
}
|
|
104
|
+
if (nextContent !== content) {
|
|
105
|
+
await writeFile(fullPath, nextContent, "utf8");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function mergeEnvFile(envFilePath, entries) {
|
|
110
|
+
const envExists = await pathExists(envFilePath);
|
|
111
|
+
const currentContent = envExists ? await readFile(envFilePath, "utf8") : "";
|
|
112
|
+
const lines = currentContent ? currentContent.split("\n") : [];
|
|
113
|
+
const existingKeys = /* @__PURE__ */ new Set();
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const [key] = trimmed.split("=");
|
|
120
|
+
if (key) {
|
|
121
|
+
existingKeys.add(key.trim());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const additions = [];
|
|
125
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
126
|
+
if (!existingKeys.has(key)) {
|
|
127
|
+
additions.push(`${key}=${formatEnvValue(value)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (additions.length === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const separator = currentContent.endsWith("\n") || currentContent.length === 0 ? "" : "\n";
|
|
134
|
+
const nextContent = `${currentContent}${separator}${additions.join("\n")}
|
|
135
|
+
`;
|
|
136
|
+
await writeFile(envFilePath, nextContent, "utf8");
|
|
137
|
+
}
|
|
138
|
+
function formatEnvValue(value) {
|
|
139
|
+
if (value === "") {
|
|
140
|
+
return '""';
|
|
141
|
+
}
|
|
142
|
+
if (/^[a-zA-Z0-9._:/-]+$/.test(value)) {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
return JSON.stringify(value);
|
|
146
|
+
}
|
|
147
|
+
async function addDependencies(packageJsonPath, dependencies) {
|
|
148
|
+
const packageRaw = await readFile(packageJsonPath, "utf8");
|
|
149
|
+
const packageJson = JSON.parse(packageRaw);
|
|
150
|
+
const current = packageJson.dependencies ?? {};
|
|
151
|
+
packageJson.dependencies = {
|
|
152
|
+
...current,
|
|
153
|
+
...dependencies
|
|
154
|
+
};
|
|
155
|
+
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}
|
|
156
|
+
`, "utf8");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/commands/add.ts
|
|
160
|
+
import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
161
|
+
|
|
162
|
+
// src/core/templates.ts
|
|
163
|
+
import path2 from "path";
|
|
164
|
+
import { fileURLToPath } from "url";
|
|
165
|
+
var currentDir = path2.dirname(fileURLToPath(import.meta.url));
|
|
166
|
+
var rootDir = path2.resolve(currentDir, "../");
|
|
167
|
+
var templatePaths = {
|
|
168
|
+
base: path2.join(rootDir, "templates/next-base"),
|
|
169
|
+
chat: path2.join(rootDir, "templates/features/chat"),
|
|
170
|
+
supabase: path2.join(rootDir, "templates/features/supabase"),
|
|
171
|
+
supabaseRealtime: path2.join(rootDir, "templates/features/supabase-realtime"),
|
|
172
|
+
seo: path2.join(rootDir, "templates/features/seo")
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/core/modules.ts
|
|
176
|
+
var optionalModules = [
|
|
177
|
+
{
|
|
178
|
+
id: "chat",
|
|
179
|
+
label: "Chat module",
|
|
180
|
+
description: "Adds chat API route, hooks/components, and chat Prisma models",
|
|
181
|
+
templatePath: templatePaths.chat,
|
|
182
|
+
env: {
|
|
183
|
+
NEXT_PUBLIC_ENABLE_CHAT: "true"
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: "supabase",
|
|
188
|
+
label: "Supabase",
|
|
189
|
+
description: "Adds Supabase client and storage upload helpers",
|
|
190
|
+
templatePath: templatePaths.supabase,
|
|
191
|
+
env: {
|
|
192
|
+
NEXT_PUBLIC_SUPABASE_URL: "",
|
|
193
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: "",
|
|
194
|
+
NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET: "public"
|
|
195
|
+
},
|
|
196
|
+
dependencies: {
|
|
197
|
+
"@supabase/supabase-js": "^2.44.2"
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "supabase-realtime",
|
|
202
|
+
label: "Supabase Realtime",
|
|
203
|
+
description: "Adds Realtime channel helper and hooks",
|
|
204
|
+
templatePath: templatePaths.supabaseRealtime,
|
|
205
|
+
env: {
|
|
206
|
+
NEXT_PUBLIC_SUPABASE_URL: "",
|
|
207
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ""
|
|
208
|
+
},
|
|
209
|
+
dependencies: {
|
|
210
|
+
"@supabase/supabase-js": "^2.44.2"
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: "seo",
|
|
215
|
+
label: "SEO pack",
|
|
216
|
+
description: "Adds robots/sitemap and JsonLd helper files",
|
|
217
|
+
templatePath: templatePaths.seo,
|
|
218
|
+
env: {}
|
|
219
|
+
}
|
|
220
|
+
];
|
|
221
|
+
function getModuleById(moduleId) {
|
|
222
|
+
const module = optionalModules.find((item) => item.id === moduleId);
|
|
223
|
+
if (!module) {
|
|
224
|
+
throw new Error(`Unknown optional module: ${moduleId}`);
|
|
225
|
+
}
|
|
226
|
+
return module;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/core/prompts.ts
|
|
230
|
+
import {
|
|
231
|
+
cancel,
|
|
232
|
+
confirm,
|
|
233
|
+
intro,
|
|
234
|
+
isCancel,
|
|
235
|
+
multiselect,
|
|
236
|
+
outro,
|
|
237
|
+
select
|
|
238
|
+
} from "@clack/prompts";
|
|
239
|
+
|
|
240
|
+
// src/core/theme.ts
|
|
241
|
+
var reset = "\x1B[0m";
|
|
242
|
+
function paint(code, text) {
|
|
243
|
+
return `${code}${text}${reset}`;
|
|
244
|
+
}
|
|
245
|
+
var theme = {
|
|
246
|
+
cyan: (text) => paint("\x1B[36m", text),
|
|
247
|
+
green: (text) => paint("\x1B[32m", text),
|
|
248
|
+
yellow: (text) => paint("\x1B[33m", text),
|
|
249
|
+
red: (text) => paint("\x1B[31m", text),
|
|
250
|
+
blue: (text) => paint("\x1B[34m", text),
|
|
251
|
+
magenta: (text) => paint("\x1B[35m", text),
|
|
252
|
+
dim: (text) => paint("\x1B[2m", text),
|
|
253
|
+
bold: (text) => paint("\x1B[1m", text)
|
|
254
|
+
};
|
|
255
|
+
var bannerGradient = [
|
|
256
|
+
"\x1B[38;5;51m",
|
|
257
|
+
"\x1B[38;5;45m",
|
|
258
|
+
"\x1B[38;5;39m",
|
|
259
|
+
"\x1B[38;5;33m",
|
|
260
|
+
"\x1B[38;5;27m"
|
|
261
|
+
];
|
|
262
|
+
var log = {
|
|
263
|
+
info(message) {
|
|
264
|
+
console.log(`${theme.cyan("\u2139")} ${message}`);
|
|
265
|
+
},
|
|
266
|
+
success(message) {
|
|
267
|
+
console.log(`${theme.green("\u2714")} ${message}`);
|
|
268
|
+
},
|
|
269
|
+
warn(message) {
|
|
270
|
+
console.log(`${theme.yellow("\u26A0")} ${message}`);
|
|
271
|
+
},
|
|
272
|
+
error(message) {
|
|
273
|
+
console.error(`${theme.red("\u2716")} ${message}`);
|
|
274
|
+
},
|
|
275
|
+
step(message) {
|
|
276
|
+
console.log(`${theme.blue("\u2192")} ${message}`);
|
|
277
|
+
},
|
|
278
|
+
detail(label, value) {
|
|
279
|
+
console.log(`${theme.dim(`${label}:`)} ${theme.cyan(value)}`);
|
|
280
|
+
},
|
|
281
|
+
command(command) {
|
|
282
|
+
console.log(`${theme.dim(" ")}${theme.magenta(command)}`);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// src/core/prompts.ts
|
|
287
|
+
function handleCancelled(value) {
|
|
288
|
+
if (!isCancel(value)) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
cancel(theme.red("Operation cancelled."));
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
function startPrompt(title) {
|
|
295
|
+
intro(theme.cyan(theme.bold(title)));
|
|
296
|
+
}
|
|
297
|
+
function finishPrompt(message) {
|
|
298
|
+
outro(theme.green(message));
|
|
299
|
+
}
|
|
300
|
+
async function askSelect(message, options, initialValue) {
|
|
301
|
+
const value = await select({
|
|
302
|
+
message,
|
|
303
|
+
options,
|
|
304
|
+
initialValue
|
|
305
|
+
});
|
|
306
|
+
handleCancelled(value);
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
async function askMultiSelect(message, options, initialValues = []) {
|
|
310
|
+
const values = await multiselect({
|
|
311
|
+
message,
|
|
312
|
+
options,
|
|
313
|
+
initialValues,
|
|
314
|
+
required: false
|
|
315
|
+
});
|
|
316
|
+
handleCancelled(values);
|
|
317
|
+
return values;
|
|
318
|
+
}
|
|
319
|
+
async function askConfirm(message, initialValue = false) {
|
|
320
|
+
const value = await confirm({
|
|
321
|
+
message,
|
|
322
|
+
initialValue
|
|
323
|
+
});
|
|
324
|
+
handleCancelled(value);
|
|
325
|
+
return Boolean(value);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/core/auth-bootstrap.ts
|
|
329
|
+
import { spawn } from "child_process";
|
|
330
|
+
async function runCommand(command, args, cwd, silent = true) {
|
|
331
|
+
return new Promise((resolve) => {
|
|
332
|
+
const chunks = [];
|
|
333
|
+
const child = spawn(command, args, {
|
|
334
|
+
cwd,
|
|
335
|
+
shell: process.platform === "win32",
|
|
336
|
+
stdio: silent ? "pipe" : "inherit"
|
|
337
|
+
});
|
|
338
|
+
if (silent) {
|
|
339
|
+
child.stdout?.on("data", (chunk) => chunks.push(String(chunk)));
|
|
340
|
+
child.stderr?.on("data", (chunk) => chunks.push(String(chunk)));
|
|
341
|
+
}
|
|
342
|
+
child.on("error", () => {
|
|
343
|
+
resolve({ ok: false, output: chunks.join("") });
|
|
344
|
+
});
|
|
345
|
+
child.on("close", (code) => {
|
|
346
|
+
resolve({ ok: code === 0, output: chunks.join("") });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function ensureBetterAuthGenerate(cwd, options = {}) {
|
|
351
|
+
const localCheck = await runCommand("npx", ["--no-install", "auth", "--help"], cwd);
|
|
352
|
+
const hasCli = localCheck.ok;
|
|
353
|
+
let canInstall = hasCli;
|
|
354
|
+
if (!hasCli) {
|
|
355
|
+
if (options.nonInteractive) {
|
|
356
|
+
log.warn("Better Auth CLI not detected. Skipping schema generation in non-interactive mode.");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const approved = await askConfirm(
|
|
360
|
+
"Better Auth CLI is not installed. Install/use npx auth@latest and run schema generation now?",
|
|
361
|
+
true
|
|
362
|
+
);
|
|
363
|
+
if (!approved) {
|
|
364
|
+
log.warn("Skipped Better Auth schema generation.");
|
|
365
|
+
log.command("npx auth@latest generate --config src/lib/auth.ts");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
canInstall = true;
|
|
369
|
+
}
|
|
370
|
+
if (!canInstall) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const generateArgs = hasCli ? ["auth", "generate", "--config", "src/lib/auth.ts"] : ["auth@latest", "generate", "--config", "src/lib/auth.ts"];
|
|
374
|
+
const result = await runCommand("npx", generateArgs, cwd, false);
|
|
375
|
+
if (!result.ok) {
|
|
376
|
+
log.error("Better Auth generate command failed. Run manually:");
|
|
377
|
+
log.command("npx auth@latest generate --config src/lib/auth.ts");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/core/chat-schema.ts
|
|
382
|
+
import path3 from "path";
|
|
383
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
384
|
+
var chatSchemaBlock = `
|
|
385
|
+
|
|
386
|
+
// ------------------------------------------------------------
|
|
387
|
+
// Optional Chatbox Models (Provider-Agnostic)
|
|
388
|
+
// Review carefully before running migrations on production.
|
|
389
|
+
// ------------------------------------------------------------
|
|
390
|
+
model ChatConversation {
|
|
391
|
+
id String @id @default(cuid())
|
|
392
|
+
title String?
|
|
393
|
+
createdAt DateTime @default(now())
|
|
394
|
+
updatedAt DateTime @updatedAt
|
|
395
|
+
participants ChatParticipant[]
|
|
396
|
+
messages ChatMessage[]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
model ChatParticipant {
|
|
400
|
+
id String @id @default(cuid())
|
|
401
|
+
conversationId String
|
|
402
|
+
userId String
|
|
403
|
+
role ChatParticipantRole @default(MEMBER)
|
|
404
|
+
createdAt DateTime @default(now())
|
|
405
|
+
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
406
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
407
|
+
|
|
408
|
+
@@unique([conversationId, userId])
|
|
409
|
+
@@index([userId])
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
model ChatMessage {
|
|
413
|
+
id String @id @default(cuid())
|
|
414
|
+
conversationId String
|
|
415
|
+
senderId String
|
|
416
|
+
content String
|
|
417
|
+
kind ChatMessageKind @default(TEXT)
|
|
418
|
+
createdAt DateTime @default(now())
|
|
419
|
+
updatedAt DateTime @updatedAt
|
|
420
|
+
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
421
|
+
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
|
422
|
+
|
|
423
|
+
@@index([conversationId, createdAt])
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
enum ChatParticipantRole {
|
|
427
|
+
OWNER
|
|
428
|
+
MEMBER
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
enum ChatMessageKind {
|
|
432
|
+
TEXT
|
|
433
|
+
SYSTEM
|
|
434
|
+
}
|
|
435
|
+
`;
|
|
436
|
+
function ensureUserRelations(schema) {
|
|
437
|
+
if (schema.includes("chatParticipants") && schema.includes("chatMessages")) {
|
|
438
|
+
return schema;
|
|
439
|
+
}
|
|
440
|
+
const userBlockRegex = /model User \{[\s\S]*?\n\}/m;
|
|
441
|
+
const userBlock = schema.match(userBlockRegex)?.[0];
|
|
442
|
+
if (!userBlock) {
|
|
443
|
+
return schema;
|
|
444
|
+
}
|
|
445
|
+
const relationLines = [];
|
|
446
|
+
if (!userBlock.includes("chatParticipants")) {
|
|
447
|
+
relationLines.push(" chatParticipants ChatParticipant[]");
|
|
448
|
+
}
|
|
449
|
+
if (!userBlock.includes("chatMessages")) {
|
|
450
|
+
relationLines.push(" chatMessages ChatMessage[]");
|
|
451
|
+
}
|
|
452
|
+
if (relationLines.length === 0) {
|
|
453
|
+
return schema;
|
|
454
|
+
}
|
|
455
|
+
const nextUserBlock = userBlock.replace(/\n\}$/, `
|
|
456
|
+
${relationLines.join("\n")}
|
|
457
|
+
}`);
|
|
458
|
+
return schema.replace(userBlock, nextUserBlock);
|
|
459
|
+
}
|
|
460
|
+
async function ensureChatSchemaInProject(projectRoot) {
|
|
461
|
+
const schemaPath = path3.join(projectRoot, "prisma", "schema.prisma");
|
|
462
|
+
if (!await pathExists(schemaPath)) {
|
|
463
|
+
return "skipped";
|
|
464
|
+
}
|
|
465
|
+
let schema = await readFile2(schemaPath, "utf8");
|
|
466
|
+
schema = ensureUserRelations(schema);
|
|
467
|
+
if (schema.includes("model ChatConversation")) {
|
|
468
|
+
await writeFile2(schemaPath, schema, "utf8");
|
|
469
|
+
return "exists";
|
|
470
|
+
}
|
|
471
|
+
const nextSchema = `${schema.trimEnd()}${chatSchemaBlock}
|
|
472
|
+
`;
|
|
473
|
+
await writeFile2(schemaPath, nextSchema, "utf8");
|
|
474
|
+
return "added";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/commands/add.ts
|
|
478
|
+
function toKebabCase(input) {
|
|
479
|
+
return input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
480
|
+
}
|
|
481
|
+
function toPascalCase(slug) {
|
|
482
|
+
return slug.split("-").filter(Boolean).map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join("");
|
|
483
|
+
}
|
|
484
|
+
function toCamelCase(input) {
|
|
485
|
+
return `${input.charAt(0).toLowerCase()}${input.slice(1)}`;
|
|
486
|
+
}
|
|
487
|
+
function singularizeWord(word) {
|
|
488
|
+
if (word.endsWith("ies") && word.length > 3) {
|
|
489
|
+
return `${word.slice(0, -3)}y`;
|
|
490
|
+
}
|
|
491
|
+
if (/(xes|ches|shes|sses|zes)$/.test(word)) {
|
|
492
|
+
return word.slice(0, -2);
|
|
493
|
+
}
|
|
494
|
+
if (word.endsWith("s") && !/(ss|us|is)$/.test(word)) {
|
|
495
|
+
return word.slice(0, -1);
|
|
496
|
+
}
|
|
497
|
+
return word;
|
|
498
|
+
}
|
|
499
|
+
function buildFeatureServicesContent(modelPascal, modelDelegate) {
|
|
500
|
+
return `import prisma from "@/lib/prisma";
|
|
501
|
+
|
|
502
|
+
export async function list${modelPascal}s() {
|
|
503
|
+
return prisma.${modelDelegate}.findMany({
|
|
504
|
+
orderBy: {
|
|
505
|
+
createdAt: "desc",
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export async function get${modelPascal}ById(id: string) {
|
|
511
|
+
return prisma.${modelDelegate}.findUnique({
|
|
512
|
+
where: { id },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export async function create${modelPascal}(data: {
|
|
517
|
+
name: string;
|
|
518
|
+
description?: string;
|
|
519
|
+
}) {
|
|
520
|
+
return prisma.${modelDelegate}.create({
|
|
521
|
+
data,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export async function update${modelPascal}(
|
|
526
|
+
id: string,
|
|
527
|
+
data: {
|
|
528
|
+
name?: string;
|
|
529
|
+
description?: string;
|
|
530
|
+
},
|
|
531
|
+
) {
|
|
532
|
+
return prisma.${modelDelegate}.update({
|
|
533
|
+
where: { id },
|
|
534
|
+
data,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function delete${modelPascal}(id: string) {
|
|
539
|
+
return prisma.${modelDelegate}.delete({
|
|
540
|
+
where: { id },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
`;
|
|
544
|
+
}
|
|
545
|
+
function buildFeatureValidationContent(modelPascal) {
|
|
546
|
+
return `import { z } from "zod";
|
|
547
|
+
|
|
548
|
+
export const create${modelPascal}Schema = z.object({
|
|
549
|
+
name: z.string().min(2),
|
|
550
|
+
description: z.string().max(500).optional(),
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
export const update${modelPascal}Schema = create${modelPascal}Schema.partial();
|
|
554
|
+
|
|
555
|
+
export const ${toCamelCase(modelPascal)}IdSchema = z.object({
|
|
556
|
+
id: z.string().min(1),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
export type Create${modelPascal}Input = z.infer<typeof create${modelPascal}Schema>;
|
|
560
|
+
export type Update${modelPascal}Input = z.infer<typeof update${modelPascal}Schema>;
|
|
561
|
+
`;
|
|
562
|
+
}
|
|
563
|
+
function buildFeatureHooksContent(featureSlug, modelPascal) {
|
|
564
|
+
return `import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
565
|
+
import { api } from "@/lib/axios-instance";
|
|
566
|
+
import type { ApiSuccess } from "@/types";
|
|
567
|
+
import type { Create${modelPascal}Input, Update${modelPascal}Input } from "../validations";
|
|
568
|
+
|
|
569
|
+
type ${modelPascal}Item = {
|
|
570
|
+
id: string;
|
|
571
|
+
name: string;
|
|
572
|
+
description?: string | null;
|
|
573
|
+
createdAt: string;
|
|
574
|
+
updatedAt: string;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
export function use${modelPascal}s() {
|
|
578
|
+
return useQuery({
|
|
579
|
+
queryKey: ["${featureSlug}", "items"],
|
|
580
|
+
queryFn: async () => {
|
|
581
|
+
const { data } = await api.get("/api/v1/${featureSlug}");
|
|
582
|
+
return (data as ApiSuccess<{ items: ${modelPascal}Item[] }>).data.items;
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function useCreate${modelPascal}() {
|
|
588
|
+
const queryClient = useQueryClient();
|
|
589
|
+
|
|
590
|
+
return useMutation({
|
|
591
|
+
mutationFn: async (payload: Create${modelPascal}Input) => {
|
|
592
|
+
const { data } = await api.post("/api/v1/${featureSlug}", payload);
|
|
593
|
+
return (data as ApiSuccess<${modelPascal}Item>).data;
|
|
594
|
+
},
|
|
595
|
+
onSuccess: async () => {
|
|
596
|
+
await queryClient.invalidateQueries({ queryKey: ["${featureSlug}", "items"] });
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function useUpdate${modelPascal}() {
|
|
602
|
+
const queryClient = useQueryClient();
|
|
603
|
+
|
|
604
|
+
return useMutation({
|
|
605
|
+
mutationFn: async (payload: { id: string; data: Update${modelPascal}Input }) => {
|
|
606
|
+
const { data } = await api.put("/api/v1/${featureSlug}/" + payload.id, payload.data);
|
|
607
|
+
return (data as ApiSuccess<${modelPascal}Item>).data;
|
|
608
|
+
},
|
|
609
|
+
onSuccess: async () => {
|
|
610
|
+
await queryClient.invalidateQueries({ queryKey: ["${featureSlug}", "items"] });
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function useDelete${modelPascal}() {
|
|
616
|
+
const queryClient = useQueryClient();
|
|
617
|
+
|
|
618
|
+
return useMutation({
|
|
619
|
+
mutationFn: async (id: string) => {
|
|
620
|
+
const { data } = await api.delete("/api/v1/${featureSlug}/" + id);
|
|
621
|
+
return (data as ApiSuccess<{ deleted: boolean }>).data;
|
|
622
|
+
},
|
|
623
|
+
onSuccess: async () => {
|
|
624
|
+
await queryClient.invalidateQueries({ queryKey: ["${featureSlug}", "items"] });
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
`;
|
|
629
|
+
}
|
|
630
|
+
function buildCollectionRouteContent(featureSlug, modelPascal) {
|
|
631
|
+
return `import { fail, ok } from "@/lib/api-response";
|
|
632
|
+
import {
|
|
633
|
+
create${modelPascal},
|
|
634
|
+
list${modelPascal}s,
|
|
635
|
+
} from "@/features/${featureSlug}/services";
|
|
636
|
+
import { create${modelPascal}Schema } from "@/features/${featureSlug}/validations";
|
|
637
|
+
|
|
638
|
+
export async function GET() {
|
|
639
|
+
try {
|
|
640
|
+
const items = await list${modelPascal}s();
|
|
641
|
+
return ok({ items });
|
|
642
|
+
} catch {
|
|
643
|
+
return fail("INTERNAL_ERROR", "Failed to load ${featureSlug}.", { status: 500 });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export async function POST(request: Request) {
|
|
648
|
+
const payload = await request.json().catch(() => null);
|
|
649
|
+
const parsed = create${modelPascal}Schema.safeParse(payload);
|
|
650
|
+
if (!parsed.success) {
|
|
651
|
+
return fail("VALIDATION_ERROR", "Invalid payload.", {
|
|
652
|
+
status: 400,
|
|
653
|
+
details: parsed.error.flatten(),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
const created = await create${modelPascal}(parsed.data);
|
|
659
|
+
return ok(created, { status: 201 });
|
|
660
|
+
} catch {
|
|
661
|
+
return fail("INTERNAL_ERROR", "Failed to create ${featureSlug}.", { status: 500 });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
`;
|
|
665
|
+
}
|
|
666
|
+
function buildItemRouteContent(featureSlug, modelPascal) {
|
|
667
|
+
return `import { fail, ok } from "@/lib/api-response";
|
|
668
|
+
import {
|
|
669
|
+
delete${modelPascal},
|
|
670
|
+
get${modelPascal}ById,
|
|
671
|
+
update${modelPascal},
|
|
672
|
+
} from "@/features/${featureSlug}/services";
|
|
673
|
+
import { update${modelPascal}Schema } from "@/features/${featureSlug}/validations";
|
|
674
|
+
|
|
675
|
+
export async function GET(
|
|
676
|
+
_request: Request,
|
|
677
|
+
context: { params: Promise<{ id: string }> },
|
|
678
|
+
) {
|
|
679
|
+
const { id } = await context.params;
|
|
680
|
+
const item = await get${modelPascal}ById(id);
|
|
681
|
+
|
|
682
|
+
if (!item) {
|
|
683
|
+
return fail("NOT_FOUND", "${modelPascal} not found.", { status: 404 });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return ok(item);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export async function PUT(
|
|
690
|
+
request: Request,
|
|
691
|
+
context: { params: Promise<{ id: string }> },
|
|
692
|
+
) {
|
|
693
|
+
const { id } = await context.params;
|
|
694
|
+
const payload = await request.json().catch(() => null);
|
|
695
|
+
const parsed = update${modelPascal}Schema.safeParse(payload);
|
|
696
|
+
if (!parsed.success) {
|
|
697
|
+
return fail("VALIDATION_ERROR", "Invalid payload.", {
|
|
698
|
+
status: 400,
|
|
699
|
+
details: parsed.error.flatten(),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
const updated = await update${modelPascal}(id, parsed.data);
|
|
705
|
+
return ok(updated);
|
|
706
|
+
} catch {
|
|
707
|
+
return fail("INTERNAL_ERROR", "Failed to update ${featureSlug}.", { status: 500 });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export async function DELETE(
|
|
712
|
+
_request: Request,
|
|
713
|
+
context: { params: Promise<{ id: string }> },
|
|
714
|
+
) {
|
|
715
|
+
const { id } = await context.params;
|
|
716
|
+
try {
|
|
717
|
+
await delete${modelPascal}(id);
|
|
718
|
+
return ok({ deleted: true });
|
|
719
|
+
} catch {
|
|
720
|
+
return fail("INTERNAL_ERROR", "Failed to delete ${featureSlug}.", { status: 500 });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
`;
|
|
724
|
+
}
|
|
725
|
+
async function appendFeatureModelToPrismaSchema(cwd, modelPascal) {
|
|
726
|
+
const schemaPath = path4.join(cwd, "prisma", "schema.prisma");
|
|
727
|
+
if (!await pathExists(schemaPath)) {
|
|
728
|
+
return "skipped";
|
|
729
|
+
}
|
|
730
|
+
const schemaContent = await readFile3(schemaPath, "utf8");
|
|
731
|
+
const modelRegex = new RegExp(`\\bmodel\\s+${modelPascal}\\b`);
|
|
732
|
+
if (modelRegex.test(schemaContent)) {
|
|
733
|
+
return "exists";
|
|
734
|
+
}
|
|
735
|
+
const modelBlock = `
|
|
736
|
+
|
|
737
|
+
// Example feature model generated by NexTCLI.
|
|
738
|
+
// Review fields/indexes before running migration on production.
|
|
739
|
+
model ${modelPascal} {
|
|
740
|
+
id String @id @default(cuid())
|
|
741
|
+
name String
|
|
742
|
+
description String?
|
|
743
|
+
createdAt DateTime @default(now())
|
|
744
|
+
updatedAt DateTime @updatedAt
|
|
745
|
+
}
|
|
746
|
+
`;
|
|
747
|
+
await writeFile3(schemaPath, `${schemaContent.trimEnd()}${modelBlock}
|
|
748
|
+
`, "utf8");
|
|
749
|
+
return "added";
|
|
750
|
+
}
|
|
751
|
+
var authProviderStartMarker = "// AUTO_GENERATED_AUTH_PROVIDERS_START";
|
|
752
|
+
var authProviderEndMarker = "// AUTO_GENERATED_AUTH_PROVIDERS_END";
|
|
753
|
+
function buildProviderSnippet(providers) {
|
|
754
|
+
const lines = [];
|
|
755
|
+
if (providers.includes("google")) {
|
|
756
|
+
lines.push(
|
|
757
|
+
" google: {",
|
|
758
|
+
' clientId: process.env.GOOGLE_CLIENT_ID ?? "",',
|
|
759
|
+
' clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",',
|
|
760
|
+
" },"
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (providers.includes("facebook")) {
|
|
764
|
+
lines.push(
|
|
765
|
+
" facebook: {",
|
|
766
|
+
' clientId: process.env.FACEBOOK_CLIENT_ID ?? "",',
|
|
767
|
+
' clientSecret: process.env.FACEBOOK_CLIENT_SECRET ?? "",',
|
|
768
|
+
" },"
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
return lines.join("\n");
|
|
772
|
+
}
|
|
773
|
+
function readConfiguredProviders(content) {
|
|
774
|
+
const providers = [];
|
|
775
|
+
if (/\bgoogle\s*:/.test(content)) {
|
|
776
|
+
providers.push("google");
|
|
777
|
+
}
|
|
778
|
+
if (/\bfacebook\s*:/.test(content)) {
|
|
779
|
+
providers.push("facebook");
|
|
780
|
+
}
|
|
781
|
+
return providers;
|
|
782
|
+
}
|
|
783
|
+
function patchAuthProviders(content, providers) {
|
|
784
|
+
const snippet = buildProviderSnippet(providers);
|
|
785
|
+
const regex = new RegExp(
|
|
786
|
+
`(${authProviderStartMarker})([\\s\\S]*?)(${authProviderEndMarker})`,
|
|
787
|
+
"m"
|
|
788
|
+
);
|
|
789
|
+
return content.replace(regex, `$1
|
|
790
|
+
${snippet ? `${snippet}
|
|
791
|
+
` : ""} $3`);
|
|
792
|
+
}
|
|
793
|
+
function normalizeModuleSelection(moduleIds) {
|
|
794
|
+
const selected = [...new Set(moduleIds)];
|
|
795
|
+
const autoAdded = [];
|
|
796
|
+
if (selected.includes("chat") && !selected.includes("supabase-realtime")) {
|
|
797
|
+
selected.unshift("supabase-realtime");
|
|
798
|
+
autoAdded.push("supabase-realtime");
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
selectedModules: [...new Set(selected)],
|
|
802
|
+
autoAddedModules: autoAdded
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
async function upsertEnvValue(envFilePath, key, value) {
|
|
806
|
+
if (!await pathExists(envFilePath)) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const content = await readFile3(envFilePath, "utf8");
|
|
810
|
+
const entry = `${key}=${value}`;
|
|
811
|
+
const pattern = new RegExp(`^${key}=.*$`, "m");
|
|
812
|
+
if (pattern.test(content)) {
|
|
813
|
+
const next = content.replace(pattern, entry);
|
|
814
|
+
if (next !== content) {
|
|
815
|
+
await writeFile3(envFilePath, next, "utf8");
|
|
816
|
+
}
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const separator = content.endsWith("\n") || content.length === 0 ? "" : "\n";
|
|
820
|
+
await writeFile3(envFilePath, `${content}${separator}${entry}
|
|
821
|
+
`, "utf8");
|
|
822
|
+
}
|
|
823
|
+
function registerAddCommand(program2) {
|
|
824
|
+
const add = program2.command("add").description("Add modules to an existing app");
|
|
825
|
+
add.command("feature").description("Scaffold a feature folder under src/features").argument("<feature-name>", "Feature name").action(async (featureName) => {
|
|
826
|
+
const featureSlug = toKebabCase(featureName);
|
|
827
|
+
if (!featureSlug) {
|
|
828
|
+
log.error("Invalid feature name.");
|
|
829
|
+
process.exitCode = 1;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const cwd = process.cwd();
|
|
833
|
+
const srcPath = path4.join(cwd, "src");
|
|
834
|
+
if (!await pathExists(srcPath)) {
|
|
835
|
+
log.error("Run this command from your generated Next.js project root (missing ./src).");
|
|
836
|
+
process.exitCode = 1;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const featurePascal = toPascalCase(featureSlug);
|
|
840
|
+
const modelPascal = singularizeWord(featurePascal);
|
|
841
|
+
const modelDelegate = toCamelCase(modelPascal);
|
|
842
|
+
const featureRoot = path4.join(cwd, "src/features", featureSlug);
|
|
843
|
+
if (await pathExists(featureRoot)) {
|
|
844
|
+
log.error(`Feature already exists: ${featureRoot}`);
|
|
845
|
+
process.exitCode = 1;
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
await ensureDir(path4.join(featureRoot, "api"));
|
|
849
|
+
await ensureDir(path4.join(featureRoot, "components"));
|
|
850
|
+
await writeFile3(
|
|
851
|
+
path4.join(featureRoot, "services.ts"),
|
|
852
|
+
buildFeatureServicesContent(modelPascal, modelDelegate),
|
|
853
|
+
"utf8"
|
|
854
|
+
);
|
|
855
|
+
await writeFile3(
|
|
856
|
+
path4.join(featureRoot, "validations.ts"),
|
|
857
|
+
buildFeatureValidationContent(modelPascal),
|
|
858
|
+
"utf8"
|
|
859
|
+
);
|
|
860
|
+
await writeFile3(
|
|
861
|
+
path4.join(featureRoot, "api", `use-${featureSlug}.ts`),
|
|
862
|
+
buildFeatureHooksContent(featureSlug, modelPascal),
|
|
863
|
+
"utf8"
|
|
864
|
+
);
|
|
865
|
+
const routeFilePath = path4.join(cwd, "src/app/api/v1", featureSlug, "route.ts");
|
|
866
|
+
await ensureDir(path4.dirname(routeFilePath));
|
|
867
|
+
await writeFile3(routeFilePath, buildCollectionRouteContent(featureSlug, modelPascal), "utf8");
|
|
868
|
+
const idRoutePath = path4.join(cwd, "src/app/api/v1", featureSlug, "[id]", "route.ts");
|
|
869
|
+
await ensureDir(path4.dirname(idRoutePath));
|
|
870
|
+
await writeFile3(idRoutePath, buildItemRouteContent(featureSlug, modelPascal), "utf8");
|
|
871
|
+
const schemaStatus = await appendFeatureModelToPrismaSchema(cwd, modelPascal);
|
|
872
|
+
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)";
|
|
873
|
+
log.success(`Feature generated with CRUD: src/features/${featureSlug}`);
|
|
874
|
+
log.info(schemaMessage);
|
|
875
|
+
log.warn("No migration was executed. Run your migration command manually when ready.");
|
|
876
|
+
});
|
|
877
|
+
add.command("module").description("Add optional modules using interactive multi-select").option("--module <module...>", "Preselect module ids").option("--yes", "Skip prompts").action(async (options) => {
|
|
878
|
+
const cwd = process.cwd();
|
|
879
|
+
const hasSrc = await pathExists(path4.join(cwd, "src"));
|
|
880
|
+
const hasPackageJson = await pathExists(path4.join(cwd, "package.json"));
|
|
881
|
+
if (!hasSrc || !hasPackageJson) {
|
|
882
|
+
log.error("Run this command from your generated Next.js project root.");
|
|
883
|
+
process.exitCode = 1;
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const validIds = new Set(optionalModules.map((module) => module.id));
|
|
887
|
+
const requestedIds = options.module ? options.module.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean) : [];
|
|
888
|
+
for (const moduleId of requestedIds) {
|
|
889
|
+
if (!validIds.has(moduleId)) {
|
|
890
|
+
log.error(`Unknown module: ${moduleId}`);
|
|
891
|
+
process.exitCode = 1;
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
startPrompt("NexTCLI optional modules");
|
|
896
|
+
const rawModules = requestedIds.length > 0 ? [...new Set(requestedIds)] : options.yes ? [] : await askMultiSelect(
|
|
897
|
+
"Select modules to add:",
|
|
898
|
+
optionalModules.map((module) => ({
|
|
899
|
+
value: module.id,
|
|
900
|
+
label: module.label,
|
|
901
|
+
hint: module.description
|
|
902
|
+
})),
|
|
903
|
+
[]
|
|
904
|
+
);
|
|
905
|
+
const { selectedModules, autoAddedModules } = normalizeModuleSelection(rawModules);
|
|
906
|
+
if (selectedModules.length === 0) {
|
|
907
|
+
finishPrompt("No modules selected.");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const skippedConflictsByModule = [];
|
|
911
|
+
let copiedFileCount = 0;
|
|
912
|
+
for (const moduleId of selectedModules) {
|
|
913
|
+
const module = getModuleById(moduleId);
|
|
914
|
+
const copyReport = await copyDirectorySafely(module.templatePath, cwd);
|
|
915
|
+
copiedFileCount += copyReport.copiedCount;
|
|
916
|
+
if (copyReport.skippedConflicts.length > 0) {
|
|
917
|
+
skippedConflictsByModule.push({
|
|
918
|
+
moduleId,
|
|
919
|
+
paths: copyReport.skippedConflicts
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
let chatSchemaStatus;
|
|
924
|
+
if (selectedModules.includes("chat")) {
|
|
925
|
+
chatSchemaStatus = await ensureChatSchemaInProject(cwd);
|
|
926
|
+
}
|
|
927
|
+
const envEntries = selectedModules.reduce((acc, moduleId) => {
|
|
928
|
+
const module = getModuleById(moduleId);
|
|
929
|
+
return {
|
|
930
|
+
...acc,
|
|
931
|
+
...module.env
|
|
932
|
+
};
|
|
933
|
+
}, {});
|
|
934
|
+
if (Object.keys(envEntries).length > 0) {
|
|
935
|
+
const envTargets = [
|
|
936
|
+
".env",
|
|
937
|
+
".env.example",
|
|
938
|
+
".env.development"
|
|
939
|
+
];
|
|
940
|
+
for (const envFile of envTargets) {
|
|
941
|
+
const envPath = path4.join(cwd, envFile);
|
|
942
|
+
if (await pathExists(envPath)) {
|
|
943
|
+
await mergeEnvFile(envPath, envEntries);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (selectedModules.includes("chat")) {
|
|
948
|
+
const envTargets = [
|
|
949
|
+
".env",
|
|
950
|
+
".env.example",
|
|
951
|
+
".env.development"
|
|
952
|
+
];
|
|
953
|
+
for (const envFile of envTargets) {
|
|
954
|
+
await upsertEnvValue(path4.join(cwd, envFile), "NEXT_PUBLIC_ENABLE_CHAT", "true");
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const dependencyEntries = selectedModules.reduce((acc, moduleId) => {
|
|
958
|
+
const module = getModuleById(moduleId);
|
|
959
|
+
return {
|
|
960
|
+
...acc,
|
|
961
|
+
...module.dependencies ?? {}
|
|
962
|
+
};
|
|
963
|
+
}, {});
|
|
964
|
+
if (Object.keys(dependencyEntries).length > 0) {
|
|
965
|
+
await addDependencies(path4.join(cwd, "package.json"), dependencyEntries);
|
|
966
|
+
}
|
|
967
|
+
finishPrompt(`Added modules: ${selectedModules.join(", ")}`);
|
|
968
|
+
log.detail("Copied files", String(copiedFileCount));
|
|
969
|
+
if (autoAddedModules.length > 0) {
|
|
970
|
+
log.detail("Auto-added", autoAddedModules.join(", "));
|
|
971
|
+
}
|
|
972
|
+
if (skippedConflictsByModule.length > 0) {
|
|
973
|
+
const totalSkipped = skippedConflictsByModule.reduce(
|
|
974
|
+
(total, item) => total + item.paths.length,
|
|
975
|
+
0
|
|
976
|
+
);
|
|
977
|
+
log.warn(
|
|
978
|
+
`Skipped ${totalSkipped} existing file(s). Existing project files were kept unchanged.`
|
|
979
|
+
);
|
|
980
|
+
for (const conflict of skippedConflictsByModule) {
|
|
981
|
+
const preview = conflict.paths.slice(0, 3).join(", ");
|
|
982
|
+
const suffix = conflict.paths.length > 3 ? ", ..." : "";
|
|
983
|
+
log.detail(`Skipped (${conflict.moduleId})`, `${preview}${suffix}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (chatSchemaStatus === "added") {
|
|
987
|
+
log.info("Optional chat schema block was appended to prisma/schema.prisma.");
|
|
988
|
+
}
|
|
989
|
+
log.step("Next: run your package manager install to apply new dependencies.");
|
|
990
|
+
});
|
|
991
|
+
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) => {
|
|
992
|
+
const cwd = process.cwd();
|
|
993
|
+
const authFilePath = path4.join(cwd, "src/lib/auth.ts");
|
|
994
|
+
const hasSrc = await pathExists(path4.join(cwd, "src"));
|
|
995
|
+
const hasPackageJson = await pathExists(path4.join(cwd, "package.json"));
|
|
996
|
+
const hasAuthFile = await pathExists(authFilePath);
|
|
997
|
+
if (!hasSrc || !hasPackageJson || !hasAuthFile) {
|
|
998
|
+
log.error("Run this command from a generated Next.js project with src/lib/auth.ts.");
|
|
999
|
+
process.exitCode = 1;
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const requestedProviders = options.provider ? options.provider.flatMap((value) => value.split(",")).map((value) => value.trim().toLowerCase()).filter(Boolean) : [];
|
|
1003
|
+
const allowed = /* @__PURE__ */ new Set(["google", "facebook"]);
|
|
1004
|
+
for (const provider of requestedProviders) {
|
|
1005
|
+
if (!allowed.has(provider)) {
|
|
1006
|
+
log.error(`Unknown provider: ${provider}`);
|
|
1007
|
+
process.exitCode = 1;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
startPrompt("NexTCLI auth provider setup");
|
|
1012
|
+
const selectedProviders = requestedProviders.length > 0 ? [...new Set(requestedProviders)] : options.yes ? [] : await askMultiSelect(
|
|
1013
|
+
"Select social providers to enable:",
|
|
1014
|
+
[
|
|
1015
|
+
{ value: "google", label: "Google", hint: "Google OAuth login" },
|
|
1016
|
+
{ value: "facebook", label: "Facebook", hint: "Facebook OAuth login" }
|
|
1017
|
+
],
|
|
1018
|
+
[]
|
|
1019
|
+
);
|
|
1020
|
+
if (selectedProviders.length === 0) {
|
|
1021
|
+
finishPrompt("No auth providers selected.");
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const authContent = await readFile3(authFilePath, "utf8");
|
|
1025
|
+
const existingProviders = readConfiguredProviders(authContent);
|
|
1026
|
+
const mergedProviders = [.../* @__PURE__ */ new Set([...existingProviders, ...selectedProviders])];
|
|
1027
|
+
const nextAuthContent = patchAuthProviders(authContent, mergedProviders);
|
|
1028
|
+
await writeFile3(authFilePath, nextAuthContent, "utf8");
|
|
1029
|
+
const envEntries = {};
|
|
1030
|
+
if (mergedProviders.includes("google")) {
|
|
1031
|
+
envEntries.GOOGLE_CLIENT_ID = "";
|
|
1032
|
+
envEntries.GOOGLE_CLIENT_SECRET = "";
|
|
1033
|
+
}
|
|
1034
|
+
if (mergedProviders.includes("facebook")) {
|
|
1035
|
+
envEntries.FACEBOOK_CLIENT_ID = "";
|
|
1036
|
+
envEntries.FACEBOOK_CLIENT_SECRET = "";
|
|
1037
|
+
}
|
|
1038
|
+
const envTargets = [
|
|
1039
|
+
".env",
|
|
1040
|
+
".env.example",
|
|
1041
|
+
".env.development"
|
|
1042
|
+
];
|
|
1043
|
+
for (const envFile of envTargets) {
|
|
1044
|
+
const envPath = path4.join(cwd, envFile);
|
|
1045
|
+
if (await pathExists(envPath)) {
|
|
1046
|
+
await mergeEnvFile(envPath, envEntries);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
finishPrompt(`Enabled providers: ${mergedProviders.join(", ")}`);
|
|
1050
|
+
await ensureBetterAuthGenerate(cwd, { nonInteractive: Boolean(options.yes) });
|
|
1051
|
+
log.step("Next: set provider secrets in .env and restart dev server.");
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/commands/create.ts
|
|
1056
|
+
import { spawn as spawn2 } from "child_process";
|
|
1057
|
+
import { randomBytes } from "crypto";
|
|
1058
|
+
import path5 from "path";
|
|
1059
|
+
async function runInstall(packageManager, cwd) {
|
|
1060
|
+
const installArgsMap = {
|
|
1061
|
+
npm: ["install"],
|
|
1062
|
+
pnpm: ["install"],
|
|
1063
|
+
yarn: ["install"],
|
|
1064
|
+
bun: ["install"]
|
|
1065
|
+
};
|
|
1066
|
+
await new Promise((resolve, reject) => {
|
|
1067
|
+
const child = spawn2(packageManager, installArgsMap[packageManager], {
|
|
1068
|
+
cwd,
|
|
1069
|
+
stdio: "inherit",
|
|
1070
|
+
shell: process.platform === "win32"
|
|
1071
|
+
});
|
|
1072
|
+
child.on("error", reject);
|
|
1073
|
+
child.on("close", (code) => {
|
|
1074
|
+
if (code === 0) {
|
|
1075
|
+
resolve();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
reject(new Error(`Dependency installation failed with code ${code ?? "unknown"}`));
|
|
1079
|
+
});
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
function parseModuleIds(moduleValues) {
|
|
1083
|
+
if (!moduleValues || moduleValues.length === 0) {
|
|
1084
|
+
return [];
|
|
1085
|
+
}
|
|
1086
|
+
const flattened = moduleValues.flatMap((value) => value.split(","));
|
|
1087
|
+
const normalized = flattened.map((value) => value.trim()).filter(Boolean);
|
|
1088
|
+
const validIds = new Set(optionalModules.map((module) => module.id));
|
|
1089
|
+
for (const moduleId of normalized) {
|
|
1090
|
+
if (!validIds.has(moduleId)) {
|
|
1091
|
+
throw new Error(`Unknown module: ${moduleId}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return [...new Set(normalized)];
|
|
1095
|
+
}
|
|
1096
|
+
function toProjectSlug(input) {
|
|
1097
|
+
return input.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1098
|
+
}
|
|
1099
|
+
function normalizeModuleSelection2(moduleIds) {
|
|
1100
|
+
const selected = [...new Set(moduleIds)];
|
|
1101
|
+
const autoAdded = [];
|
|
1102
|
+
if (selected.includes("chat") && !selected.includes("supabase-realtime")) {
|
|
1103
|
+
selected.unshift("supabase-realtime");
|
|
1104
|
+
autoAdded.push("supabase-realtime");
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
selectedModules: [...new Set(selected)],
|
|
1108
|
+
autoAddedModules: autoAdded
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function registerCreateCommand(program2) {
|
|
1112
|
+
program2.command("create").description("Create a new outsource-ready Next.js app").argument("<project-name>", "Target project name").option("--package-manager <manager>", "Package manager: npm|pnpm|yarn|bun").option("--module <module...>", "Preselect modules (chat,supabase,supabase-realtime,seo)").option("--install", "Install dependencies after generation").option("--no-install", "Skip dependency installation").option("--yes", "Skip prompts and use defaults").action(async (projectName, options) => {
|
|
1113
|
+
startPrompt("NexTCLI project creation");
|
|
1114
|
+
const packageManager = options.packageManager ?? (options.yes ? "npm" : await askSelect(
|
|
1115
|
+
"Which package manager do you want to use?",
|
|
1116
|
+
[
|
|
1117
|
+
{ value: "npm", label: "npm", hint: "Default and stable" },
|
|
1118
|
+
{ value: "pnpm", label: "pnpm", hint: "Fast and disk-efficient" },
|
|
1119
|
+
{ value: "yarn", label: "yarn", hint: "Classic workspace choice" },
|
|
1120
|
+
{ value: "bun", label: "bun", hint: "Fast runtime and package manager" }
|
|
1121
|
+
],
|
|
1122
|
+
"npm"
|
|
1123
|
+
));
|
|
1124
|
+
const allowedManagers = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
1125
|
+
if (!allowedManagers.has(packageManager)) {
|
|
1126
|
+
log.error("Invalid --package-manager value. Use npm, pnpm, yarn, or bun.");
|
|
1127
|
+
process.exitCode = 1;
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const targetPath = path5.resolve(process.cwd(), projectName);
|
|
1131
|
+
if (await pathExists(targetPath)) {
|
|
1132
|
+
log.error(`Target directory already exists: ${targetPath}`);
|
|
1133
|
+
process.exitCode = 1;
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const projectDirectoryName = path5.basename(targetPath);
|
|
1137
|
+
const projectSlug = toProjectSlug(projectDirectoryName);
|
|
1138
|
+
if (!projectSlug) {
|
|
1139
|
+
log.error("Invalid project name. Use letters, numbers, dots, underscores, or dashes.");
|
|
1140
|
+
process.exitCode = 1;
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const rawModules = options.module && options.module.length > 0 ? parseModuleIds(options.module) : options.yes ? [] : await askMultiSelect(
|
|
1144
|
+
"Select optional modules to include:",
|
|
1145
|
+
optionalModules.map((module) => ({
|
|
1146
|
+
value: module.id,
|
|
1147
|
+
label: module.label,
|
|
1148
|
+
hint: module.description
|
|
1149
|
+
})),
|
|
1150
|
+
[]
|
|
1151
|
+
);
|
|
1152
|
+
const { selectedModules, autoAddedModules } = normalizeModuleSelection2(rawModules);
|
|
1153
|
+
const installFlagProvided = process.argv.includes("--install") || process.argv.includes("--no-install");
|
|
1154
|
+
const shouldInstall = installFlagProvided ? Boolean(options.install) : options.yes ? false : await askConfirm("Install dependencies now?", true);
|
|
1155
|
+
await copyDirectory(templatePaths.base, targetPath);
|
|
1156
|
+
for (const moduleId of selectedModules) {
|
|
1157
|
+
const moduleDefinition = getModuleById(moduleId);
|
|
1158
|
+
await copyDirectory(moduleDefinition.templatePath, targetPath);
|
|
1159
|
+
}
|
|
1160
|
+
let chatSchemaStatus;
|
|
1161
|
+
if (selectedModules.includes("chat")) {
|
|
1162
|
+
chatSchemaStatus = await ensureChatSchemaInProject(targetPath);
|
|
1163
|
+
}
|
|
1164
|
+
const moduleEnvEntries = selectedModules.reduce((acc, moduleId) => {
|
|
1165
|
+
const module = getModuleById(moduleId);
|
|
1166
|
+
return {
|
|
1167
|
+
...acc,
|
|
1168
|
+
...module.env
|
|
1169
|
+
};
|
|
1170
|
+
}, {});
|
|
1171
|
+
if (Object.keys(moduleEnvEntries).length > 0) {
|
|
1172
|
+
const envTargets = [
|
|
1173
|
+
".env",
|
|
1174
|
+
".env.example",
|
|
1175
|
+
".env.development"
|
|
1176
|
+
];
|
|
1177
|
+
for (const envFile of envTargets) {
|
|
1178
|
+
const envPath = path5.join(targetPath, envFile);
|
|
1179
|
+
if (await pathExists(envPath)) {
|
|
1180
|
+
await mergeEnvFile(envPath, moduleEnvEntries);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const dependencyEntries = selectedModules.reduce((acc, moduleId) => {
|
|
1185
|
+
const module = getModuleById(moduleId);
|
|
1186
|
+
return {
|
|
1187
|
+
...acc,
|
|
1188
|
+
...module.dependencies ?? {}
|
|
1189
|
+
};
|
|
1190
|
+
}, {});
|
|
1191
|
+
if (Object.keys(dependencyEntries).length > 0) {
|
|
1192
|
+
await addDependencies(path5.join(targetPath, "package.json"), dependencyEntries);
|
|
1193
|
+
}
|
|
1194
|
+
const betterAuthSecret = randomBytes(32).toString("base64url");
|
|
1195
|
+
await replaceTokensInDirectory(targetPath, {
|
|
1196
|
+
"__PROJECT_NAME__": projectSlug,
|
|
1197
|
+
"__ENABLE_CHAT__": selectedModules.includes("chat") ? "true" : "false",
|
|
1198
|
+
"__BETTER_AUTH_SECRET__": betterAuthSecret
|
|
1199
|
+
});
|
|
1200
|
+
if (shouldInstall) {
|
|
1201
|
+
log.step(`Installing dependencies with ${packageManager}...`);
|
|
1202
|
+
await runInstall(packageManager, targetPath);
|
|
1203
|
+
}
|
|
1204
|
+
finishPrompt("Project created successfully.");
|
|
1205
|
+
log.detail("Path", targetPath);
|
|
1206
|
+
if (projectSlug !== projectDirectoryName) {
|
|
1207
|
+
log.detail("Normalized project id", projectSlug);
|
|
1208
|
+
}
|
|
1209
|
+
log.detail(
|
|
1210
|
+
"Modules",
|
|
1211
|
+
selectedModules.length > 0 ? selectedModules.join(", ") : "none"
|
|
1212
|
+
);
|
|
1213
|
+
if (autoAddedModules.length > 0) {
|
|
1214
|
+
log.detail("Auto-added", autoAddedModules.join(", "));
|
|
1215
|
+
}
|
|
1216
|
+
if (chatSchemaStatus === "added") {
|
|
1217
|
+
log.info("Optional chat schema block was appended to prisma/schema.prisma.");
|
|
1218
|
+
}
|
|
1219
|
+
log.step(`Next: cd ${projectName} && ${packageManager} run dev`);
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// src/commands/migrate.ts
|
|
1224
|
+
import { spawn as spawn3 } from "child_process";
|
|
1225
|
+
import path6 from "path";
|
|
1226
|
+
function createDefaultMigrationName() {
|
|
1227
|
+
const now = /* @__PURE__ */ new Date();
|
|
1228
|
+
const y = now.getFullYear();
|
|
1229
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
1230
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
1231
|
+
const hh = String(now.getHours()).padStart(2, "0");
|
|
1232
|
+
const mm = String(now.getMinutes()).padStart(2, "0");
|
|
1233
|
+
const ss = String(now.getSeconds()).padStart(2, "0");
|
|
1234
|
+
return `auto_${y}${m}${d}${hh}${mm}${ss}`;
|
|
1235
|
+
}
|
|
1236
|
+
async function detectPackageManager(cwd) {
|
|
1237
|
+
if (await pathExists(path6.join(cwd, "bun.lockb"))) {
|
|
1238
|
+
return "bun";
|
|
1239
|
+
}
|
|
1240
|
+
if (await pathExists(path6.join(cwd, "bun.lock"))) {
|
|
1241
|
+
return "bun";
|
|
1242
|
+
}
|
|
1243
|
+
if (await pathExists(path6.join(cwd, "pnpm-lock.yaml"))) {
|
|
1244
|
+
return "pnpm";
|
|
1245
|
+
}
|
|
1246
|
+
if (await pathExists(path6.join(cwd, "yarn.lock"))) {
|
|
1247
|
+
return "yarn";
|
|
1248
|
+
}
|
|
1249
|
+
return "npm";
|
|
1250
|
+
}
|
|
1251
|
+
function buildScriptArgs(manager, migrationName, skipGenerate) {
|
|
1252
|
+
const prismaArgs = ["--name", migrationName];
|
|
1253
|
+
if (skipGenerate) {
|
|
1254
|
+
prismaArgs.push("--skip-generate");
|
|
1255
|
+
}
|
|
1256
|
+
if (manager === "yarn") {
|
|
1257
|
+
return ["db:migrate", ...prismaArgs];
|
|
1258
|
+
}
|
|
1259
|
+
return ["run", "db:migrate", "--", ...prismaArgs];
|
|
1260
|
+
}
|
|
1261
|
+
async function runCommand2(command, args, cwd) {
|
|
1262
|
+
await new Promise((resolve, reject) => {
|
|
1263
|
+
const child = spawn3(command, args, {
|
|
1264
|
+
cwd,
|
|
1265
|
+
stdio: "inherit",
|
|
1266
|
+
shell: process.platform === "win32"
|
|
1267
|
+
});
|
|
1268
|
+
child.on("error", reject);
|
|
1269
|
+
child.on("close", (code) => {
|
|
1270
|
+
if (code === 0) {
|
|
1271
|
+
resolve();
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
reject(new Error(`Migration command failed with code ${code ?? "unknown"}`));
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
function registerMigrateCommand(program2) {
|
|
1279
|
+
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) => {
|
|
1280
|
+
const cwd = process.cwd();
|
|
1281
|
+
const hasPackageJson = await pathExists(path6.join(cwd, "package.json"));
|
|
1282
|
+
const hasPrismaSchema = await pathExists(path6.join(cwd, "prisma", "schema.prisma"));
|
|
1283
|
+
if (!hasPackageJson || !hasPrismaSchema) {
|
|
1284
|
+
log.error(
|
|
1285
|
+
"Run this command from a generated project root (requires package.json + prisma/schema.prisma)."
|
|
1286
|
+
);
|
|
1287
|
+
process.exitCode = 1;
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const packageManager = await detectPackageManager(cwd);
|
|
1291
|
+
const migrationName = options.name?.trim() || createDefaultMigrationName();
|
|
1292
|
+
const args = buildScriptArgs(packageManager, migrationName, Boolean(options.skipGenerate));
|
|
1293
|
+
log.step(`Running migration via ${packageManager}: db:migrate --name ${migrationName}`);
|
|
1294
|
+
await runCommand2(packageManager, args, cwd);
|
|
1295
|
+
log.success("Migration completed successfully.");
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/core/banner.ts
|
|
1300
|
+
var banner = String.raw`
|
|
1301
|
+
_ _ _____ _____ _ ___
|
|
1302
|
+
| \ | | _____ __|_ _/ ____| | |_ _|
|
|
1303
|
+
| \| |/ _ \ \/ / | || | | | | |
|
|
1304
|
+
| |\ | __/> < | || |____| |___ | |
|
|
1305
|
+
|_| \_|\___/_/\_\ |_| \_____|_____||___|
|
|
1306
|
+
`;
|
|
1307
|
+
function printBanner(_argv) {
|
|
1308
|
+
const reset2 = "\x1B[0m";
|
|
1309
|
+
const colored = banner.trim().split("\n").map((line, index) => `${bannerGradient[index % bannerGradient.length]}${line}${reset2}`).join("\n");
|
|
1310
|
+
console.log(colored);
|
|
1311
|
+
console.log(theme.dim(" Outsource-ready Next.js scaffolding CLI"));
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// src/core/cli-command.ts
|
|
1315
|
+
import { Command } from "commander";
|
|
1316
|
+
|
|
1317
|
+
// src/core/colored-help.ts
|
|
1318
|
+
import { Help } from "commander";
|
|
1319
|
+
function colorizeUsageLine(line) {
|
|
1320
|
+
if (!line.startsWith("Usage: ")) {
|
|
1321
|
+
return line;
|
|
1322
|
+
}
|
|
1323
|
+
const usage = line.slice("Usage: ".length);
|
|
1324
|
+
return `${theme.bold(theme.cyan("Usage:"))} ${theme.green(usage)}`;
|
|
1325
|
+
}
|
|
1326
|
+
function colorizeSectionHeader(line) {
|
|
1327
|
+
switch (line.trim()) {
|
|
1328
|
+
case "Options:":
|
|
1329
|
+
return theme.bold(theme.yellow("Options:"));
|
|
1330
|
+
case "Commands:":
|
|
1331
|
+
return theme.bold(theme.magenta("Commands:"));
|
|
1332
|
+
case "Arguments:":
|
|
1333
|
+
return theme.bold(theme.blue("Arguments:"));
|
|
1334
|
+
case "Global Options:":
|
|
1335
|
+
return theme.bold(theme.yellow("Global Options:"));
|
|
1336
|
+
default:
|
|
1337
|
+
return line;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
function colorizeHelpItemLine(line, termWidth, colorTerm) {
|
|
1341
|
+
if (line.trim().length === 0) {
|
|
1342
|
+
return line;
|
|
1343
|
+
}
|
|
1344
|
+
const leadingSpaces = line.length - line.trimStart().length;
|
|
1345
|
+
if (leadingSpaces >= termWidth) {
|
|
1346
|
+
return theme.dim(line);
|
|
1347
|
+
}
|
|
1348
|
+
const termPart = line.slice(0, termWidth).trimEnd();
|
|
1349
|
+
const description = line.slice(termWidth).trimStart();
|
|
1350
|
+
if (!description) {
|
|
1351
|
+
return colorTerm(termPart);
|
|
1352
|
+
}
|
|
1353
|
+
const gap = " ".repeat(Math.max(1, termWidth - termPart.length));
|
|
1354
|
+
return `${colorTerm(termPart)}${gap}${theme.dim(description)}`;
|
|
1355
|
+
}
|
|
1356
|
+
var ColoredHelp = class extends Help {
|
|
1357
|
+
formatHelp(cmd, helper) {
|
|
1358
|
+
const termWidth = helper.padWidth(cmd, helper);
|
|
1359
|
+
const helpWidth = helper.helpWidth || 80;
|
|
1360
|
+
const itemIndentWidth = 2;
|
|
1361
|
+
const itemSeparatorWidth = 2;
|
|
1362
|
+
const formatItem = (term, description, colorTerm) => {
|
|
1363
|
+
if (description) {
|
|
1364
|
+
const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`;
|
|
1365
|
+
const wrapped = helper.wrap(
|
|
1366
|
+
fullText,
|
|
1367
|
+
helpWidth - itemIndentWidth,
|
|
1368
|
+
termWidth + itemSeparatorWidth
|
|
1369
|
+
);
|
|
1370
|
+
return wrapped.split("\n").map((line) => colorizeHelpItemLine(line, termWidth, colorTerm)).join("\n");
|
|
1371
|
+
}
|
|
1372
|
+
return colorTerm(term);
|
|
1373
|
+
};
|
|
1374
|
+
const formatList = (textArray) => textArray.join("\n").replace(/^/gm, " ".repeat(itemIndentWidth));
|
|
1375
|
+
let output = [colorizeUsageLine(`Usage: ${helper.commandUsage(cmd)}`), ""];
|
|
1376
|
+
const commandDescription = helper.commandDescription(cmd);
|
|
1377
|
+
if (commandDescription.length > 0) {
|
|
1378
|
+
output = output.concat([helper.wrap(theme.dim(commandDescription), helpWidth, 0), ""]);
|
|
1379
|
+
}
|
|
1380
|
+
const argumentList = helper.visibleArguments(cmd).map(
|
|
1381
|
+
(argument) => formatItem(
|
|
1382
|
+
helper.argumentTerm(argument),
|
|
1383
|
+
helper.argumentDescription(argument),
|
|
1384
|
+
theme.blue
|
|
1385
|
+
)
|
|
1386
|
+
);
|
|
1387
|
+
if (argumentList.length > 0) {
|
|
1388
|
+
output = output.concat([colorizeSectionHeader("Arguments:"), formatList(argumentList), ""]);
|
|
1389
|
+
}
|
|
1390
|
+
const optionList = helper.visibleOptions(cmd).map(
|
|
1391
|
+
(option) => formatItem(helper.optionTerm(option), helper.optionDescription(option), theme.yellow)
|
|
1392
|
+
);
|
|
1393
|
+
if (optionList.length > 0) {
|
|
1394
|
+
output = output.concat([colorizeSectionHeader("Options:"), formatList(optionList), ""]);
|
|
1395
|
+
}
|
|
1396
|
+
if (this.showGlobalOptions) {
|
|
1397
|
+
const globalOptionList = helper.visibleGlobalOptions(cmd).map(
|
|
1398
|
+
(option) => formatItem(helper.optionTerm(option), helper.optionDescription(option), theme.yellow)
|
|
1399
|
+
);
|
|
1400
|
+
if (globalOptionList.length > 0) {
|
|
1401
|
+
output = output.concat([
|
|
1402
|
+
colorizeSectionHeader("Global Options:"),
|
|
1403
|
+
formatList(globalOptionList),
|
|
1404
|
+
""
|
|
1405
|
+
]);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const commandList = helper.visibleCommands(cmd).map(
|
|
1409
|
+
(subcommand) => formatItem(
|
|
1410
|
+
helper.subcommandTerm(subcommand),
|
|
1411
|
+
helper.subcommandDescription(subcommand),
|
|
1412
|
+
theme.cyan
|
|
1413
|
+
)
|
|
1414
|
+
);
|
|
1415
|
+
if (commandList.length > 0) {
|
|
1416
|
+
output = output.concat([colorizeSectionHeader("Commands:"), formatList(commandList), ""]);
|
|
1417
|
+
}
|
|
1418
|
+
return output.join("\n");
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
// src/core/cli-command.ts
|
|
1423
|
+
var NexTCLICommand = class _NexTCLICommand extends Command {
|
|
1424
|
+
createHelp() {
|
|
1425
|
+
return Object.assign(new ColoredHelp(), this.configureHelp());
|
|
1426
|
+
}
|
|
1427
|
+
createCommand(name) {
|
|
1428
|
+
return new _NexTCLICommand(name);
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
// src/cli.ts
|
|
1433
|
+
var program = new NexTCLICommand();
|
|
1434
|
+
program.name("nextcli").description("Scaffold outsource-ready Next.js projects").version("0.1.0");
|
|
1435
|
+
registerCreateCommand(program);
|
|
1436
|
+
registerAddCommand(program);
|
|
1437
|
+
registerMigrateCommand(program);
|
|
1438
|
+
printBanner(process.argv.slice(2));
|
|
1439
|
+
program.parse(process.argv);
|