tss-stack 1.0.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/bin/cli.js +467 -0
- package/package.json +31 -0
- package/src/generators/backend.js +330 -0
- package/src/generators/database.js +54 -0
- package/src/generators/frontend.js +314 -0
- package/src/generators/utils.js +60 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const ora = require("ora");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const fs = require("fs-extra");
|
|
7
|
+
const inquirer = require("inquirer");
|
|
8
|
+
const { spawn, execSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
const { generateBackend } = require("../src/generators/backend");
|
|
11
|
+
const { generateFrontend } = require("../src/generators/frontend");
|
|
12
|
+
const { generateDatabase } = require("../src/generators/database");
|
|
13
|
+
const { toPascal } = require("../src/generators/utils");
|
|
14
|
+
|
|
15
|
+
const CONSTANTS = {
|
|
16
|
+
MIN_PORT: 1,
|
|
17
|
+
MAX_PORT: 65535,
|
|
18
|
+
MAX_TABLES: 50,
|
|
19
|
+
MIN_TABLES: 1,
|
|
20
|
+
DELAY_PER_LINE: 20,
|
|
21
|
+
INTRO_DELAY: 300,
|
|
22
|
+
BACKEND_PORT_DEFAULT: "5000",
|
|
23
|
+
FRONTEND_PORT_DEFAULT: 5173,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const COLORS = {
|
|
27
|
+
red: "\x1b[31m",
|
|
28
|
+
green: "\x1b[32m",
|
|
29
|
+
yellow: "\x1b[33m",
|
|
30
|
+
blue: "\x1b[34m",
|
|
31
|
+
cyan: "\x1b[36m",
|
|
32
|
+
bold: "\x1b[1m",
|
|
33
|
+
underline: "\x1b[4m",
|
|
34
|
+
reset: "\x1b[0m",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
|
|
39
|
+
const logger = {
|
|
40
|
+
info(message = "") {
|
|
41
|
+
console.log(message);
|
|
42
|
+
},
|
|
43
|
+
success(message) {
|
|
44
|
+
console.log(`${COLORS.green}[✓]${COLORS.reset} ${message}`);
|
|
45
|
+
},
|
|
46
|
+
warn(message) {
|
|
47
|
+
console.log(`${COLORS.yellow}[!]${COLORS.reset} ${message}`);
|
|
48
|
+
},
|
|
49
|
+
error(message) {
|
|
50
|
+
console.error(`${COLORS.red}[ERROR]${COLORS.reset} ${message}`);
|
|
51
|
+
},
|
|
52
|
+
fatal(message, err = null) {
|
|
53
|
+
console.error(`${COLORS.red}[FATAL]${COLORS.reset} ${message}`);
|
|
54
|
+
if (err) console.error(err);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function validateFolderName(value) {
|
|
60
|
+
if (!value || value.trim() === "") return "Folder name cannot be empty.";
|
|
61
|
+
if (value.includes("..") || value.includes("/") || value.includes("\\")) {
|
|
62
|
+
return "Folder name cannot contain path separators or '..'.";
|
|
63
|
+
}
|
|
64
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-_]*$/.test(value)) {
|
|
65
|
+
return "Folder name can only contain letters, numbers, dashes, and underscores, and must start with a letter or number.";
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateDatabaseName(value) {
|
|
71
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
|
|
72
|
+
return "Database name must start with a letter and contain only letters, numbers, and underscores.";
|
|
73
|
+
}
|
|
74
|
+
if (value.length > 64) return "Database name cannot exceed 64 characters.";
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateTableName(value) {
|
|
79
|
+
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
|
|
80
|
+
return "Table name must start with a lowercase letter and use snake_case (e.g., spare_parts).";
|
|
81
|
+
}
|
|
82
|
+
const reservedWords = [
|
|
83
|
+
"select", "insert", "update", "delete", "create", "drop",
|
|
84
|
+
"table", "index", "key", "primary", "foreign", "user", "order",
|
|
85
|
+
"group", "having", "where", "from", "into", "values", "set"
|
|
86
|
+
];
|
|
87
|
+
if (reservedWords.includes(value.toLowerCase())) {
|
|
88
|
+
return `"${value}" is a reserved SQL keyword. Please use a different name.`;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validatePort(value) {
|
|
94
|
+
const port = Number(value);
|
|
95
|
+
if (!Number.isInteger(port)) return "Port must be an integer.";
|
|
96
|
+
if (port < CONSTANTS.MIN_PORT || port > CONSTANTS.MAX_PORT) {
|
|
97
|
+
return `Port must be between ${CONSTANTS.MIN_PORT} and ${CONSTANTS.MAX_PORT}.`;
|
|
98
|
+
}
|
|
99
|
+
if (port < 1024) logger.warn("Warning: Ports below 1024 require root/admin privileges.");
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateFields(fields) {
|
|
104
|
+
if (!fields || fields.trim() === "") return "At least one field is required.";
|
|
105
|
+
const parsed = fields.split(",").map((f) => f.trim()).filter(Boolean);
|
|
106
|
+
if (parsed.length === 0) return "At least one field is required.";
|
|
107
|
+
if (parsed.length > 50) return "Maximum 50 fields per table allowed.";
|
|
108
|
+
const invalid = parsed.find((field) => {
|
|
109
|
+
if (!/^[a-z][a-z0-9_]*$/.test(field)) return true;
|
|
110
|
+
const reserved = ["id", "created_at", "updated_at"];
|
|
111
|
+
return reserved.includes(field.toLowerCase());
|
|
112
|
+
});
|
|
113
|
+
if (invalid) {
|
|
114
|
+
return `Invalid field name "${invalid}". Use lowercase snake_case starting with a letter.`;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateTableCount(value) {
|
|
120
|
+
const count = Number(value);
|
|
121
|
+
if (!Number.isInteger(count)) return "Enter a whole number.";
|
|
122
|
+
if (count < CONSTANTS.MIN_TABLES) return `Minimum ${CONSTANTS.MIN_TABLES} table required.`;
|
|
123
|
+
if (count > CONSTANTS.MAX_TABLES) return `Maximum ${CONSTANTS.MAX_TABLES} tables allowed for stability.`;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function printTree(lines) {
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
console.log(line);
|
|
130
|
+
await sleep(CONSTANTS.DELAY_PER_LINE);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatTree(lines) {
|
|
135
|
+
if (lines.length === 0) return [];
|
|
136
|
+
return lines.map((line, index) => (index === lines.length - 1 ? line.replace("├──", "└──") : line));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sanitizePackageName(name) {
|
|
140
|
+
return name.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isMySQLInstalled() {
|
|
144
|
+
try {
|
|
145
|
+
execSync("mysql --version", { stdio: "ignore" });
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkNodeVersion() {
|
|
153
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
154
|
+
const MIN_NODE = 16;
|
|
155
|
+
if (major < MIN_NODE) {
|
|
156
|
+
logger.warn(`Warning: Node.js ${MIN_NODE}+ recommended. Current: ${process.versions.node}`);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runCommand(command, args, cwd, spinner, failMessage) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const child = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
165
|
+
|
|
166
|
+
child.stdout.on("data", (data) => process.stdout.write(data));
|
|
167
|
+
child.stderr.on("data", (data) => process.stderr.write(data));
|
|
168
|
+
|
|
169
|
+
child.on("close", (code) => {
|
|
170
|
+
if (code === 0) {
|
|
171
|
+
spinner.succeed();
|
|
172
|
+
resolve();
|
|
173
|
+
} else {
|
|
174
|
+
spinner.fail(failMessage);
|
|
175
|
+
reject(new Error(`${command} exited with code ${code}`));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
child.on("error", (err) => {
|
|
180
|
+
spinner.fail(failMessage);
|
|
181
|
+
reject(err);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function cleanupProject(targetDir) {
|
|
187
|
+
try {
|
|
188
|
+
if (await fs.pathExists(targetDir)) {
|
|
189
|
+
await fs.remove(targetDir);
|
|
190
|
+
logger.warn(`Cleaned up partially generated project: ${targetDir}`);
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logger.error(`Failed to clean up project directory: ${err.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function generateGitIgnore(targetDir) {
|
|
198
|
+
const gitIgnoreContent = `node_modules/
|
|
199
|
+
.env
|
|
200
|
+
*.log
|
|
201
|
+
.DS_Store
|
|
202
|
+
.idea/
|
|
203
|
+
.vscode/
|
|
204
|
+
`;
|
|
205
|
+
const gitIgnorePath = path.join(targetDir, ".gitignore");
|
|
206
|
+
if (!(await fs.pathExists(gitIgnorePath))) {
|
|
207
|
+
await fs.outputFile(gitIgnorePath, gitIgnoreContent);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function promptBasics(targetDir) {
|
|
212
|
+
return inquirer.prompt([
|
|
213
|
+
{
|
|
214
|
+
name: "projectName",
|
|
215
|
+
message: "[1] Project display name:",
|
|
216
|
+
default: targetDir,
|
|
217
|
+
validate: (value) => (!value || value.trim() === "" ? "Project name cannot be empty." : true),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "dbName",
|
|
221
|
+
message: "[2] MySQL database name:",
|
|
222
|
+
default: targetDir.toLowerCase().replace(/-/g, "_") + "_db",
|
|
223
|
+
validate: validateDatabaseName,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "port",
|
|
227
|
+
message: "[3] Backend port number:",
|
|
228
|
+
default: CONSTANTS.BACKEND_PORT_DEFAULT,
|
|
229
|
+
validate: validatePort,
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function promptTableCount() {
|
|
235
|
+
return inquirer.prompt([
|
|
236
|
+
{
|
|
237
|
+
name: "tableCount",
|
|
238
|
+
message: `[4] How many tables does your database need? (1-${CONSTANTS.MAX_TABLES}):`,
|
|
239
|
+
validate: validateTableCount,
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function promptTable(index) {
|
|
245
|
+
return inquirer.prompt([
|
|
246
|
+
{
|
|
247
|
+
name: "name",
|
|
248
|
+
message: `Table ${index + 1} name (snake_case, e.g. spare_parts):`,
|
|
249
|
+
validate: validateTableName,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "fields",
|
|
253
|
+
message: "Fields for this table (comma separated, lowercase snake_case):",
|
|
254
|
+
validate: validateFields,
|
|
255
|
+
filter: (value) => value.split(",").map((f) => f.trim()).filter(Boolean),
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: "operations",
|
|
259
|
+
type: "checkbox",
|
|
260
|
+
message: "Which operations does this table need?",
|
|
261
|
+
choices: [
|
|
262
|
+
{ name: "INSERT (create)", value: "insert", checked: true },
|
|
263
|
+
{ name: "SELECT (read/list)", value: "select", checked: true },
|
|
264
|
+
{ name: "UPDATE (edit)", value: "update" },
|
|
265
|
+
{ name: "DELETE (remove)", value: "delete" },
|
|
266
|
+
],
|
|
267
|
+
validate: (value) => (value.length > 0 ? true : "Select at least one operation."),
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function promptFeatures() {
|
|
273
|
+
return inquirer.prompt([
|
|
274
|
+
{
|
|
275
|
+
name: "needsAuth",
|
|
276
|
+
type: "confirm",
|
|
277
|
+
message: "[5] Add session-based login/register system?",
|
|
278
|
+
default: true,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: "needsReports",
|
|
282
|
+
type: "confirm",
|
|
283
|
+
message: "[6] Add a Reports page?",
|
|
284
|
+
default: true,
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
process.stdout.write("\x1Bc");
|
|
290
|
+
|
|
291
|
+
async function showProjectTree(config) {
|
|
292
|
+
const backendRouteFiles = [
|
|
293
|
+
config.needsAuth ? "│ ├── auth.js" : null,
|
|
294
|
+
...config.tables.map((t) => `│ ├── ${t.name}.js`),
|
|
295
|
+
].filter(Boolean);
|
|
296
|
+
|
|
297
|
+
const frontendPageFiles = [
|
|
298
|
+
config.needsAuth ? "│ ├── Login.jsx" : null,
|
|
299
|
+
...config.tables.map((t) => `│ ├── ${toPascal(t.name)}.jsx`),
|
|
300
|
+
config.needsReports ? "│ └── Reports.jsx" : null,
|
|
301
|
+
].filter(Boolean);
|
|
302
|
+
|
|
303
|
+
const tree = [
|
|
304
|
+
`${COLORS.yellow}${path.basename(config.targetDir)}/${COLORS.reset}`,
|
|
305
|
+
"",
|
|
306
|
+
`├── ${COLORS.blue}backend-project/${COLORS.reset}`,
|
|
307
|
+
"│ ├── config/",
|
|
308
|
+
"│ │ ├── db.js",
|
|
309
|
+
"│ │ └── database.sql",
|
|
310
|
+
"│ ├── middleware/",
|
|
311
|
+
config.needsAuth ? "│ │ └── auth.js" : null,
|
|
312
|
+
"│ ├── routes/",
|
|
313
|
+
...formatTree(backendRouteFiles).map((line) => `│ ${line}`),
|
|
314
|
+
"│ ├── server.js",
|
|
315
|
+
"│ ├── .env.example",
|
|
316
|
+
"│ └── package.json",
|
|
317
|
+
"",
|
|
318
|
+
`└── ${COLORS.blue}frontend-project/${COLORS.reset}`,
|
|
319
|
+
" ├── src/",
|
|
320
|
+
" │ ├── api/",
|
|
321
|
+
" │ │ └── axios.js",
|
|
322
|
+
" │ ├── pages/",
|
|
323
|
+
...formatTree(frontendPageFiles).map((line) => ` │ ${line}`),
|
|
324
|
+
" │ ├── App.jsx",
|
|
325
|
+
" │ └── main.jsx",
|
|
326
|
+
" └── package.json",
|
|
327
|
+
].filter(Boolean);
|
|
328
|
+
|
|
329
|
+
await printTree(tree);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function run() {
|
|
333
|
+
checkNodeVersion();
|
|
334
|
+
|
|
335
|
+
const targetDir = process.argv[2];
|
|
336
|
+
if (!targetDir) logger.fatal("Usage: npx project-folder <folder-name>");
|
|
337
|
+
|
|
338
|
+
const folderValidation = validateFolderName(targetDir);
|
|
339
|
+
if (folderValidation !== true) logger.fatal(folderValidation);
|
|
340
|
+
|
|
341
|
+
const absoluteTargetDir = path.resolve(process.cwd(), targetDir);
|
|
342
|
+
if (await fs.pathExists(absoluteTargetDir)) {
|
|
343
|
+
logger.fatal(`Folder "${targetDir}" already exists.`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
logger.info(`\n${COLORS.bold}[✓] Full Project Folder Structure Generator${COLORS.reset}\n`);
|
|
347
|
+
|
|
348
|
+
const basics = await promptBasics(targetDir);
|
|
349
|
+
const { tableCount } = await promptTableCount();
|
|
350
|
+
|
|
351
|
+
const tables = [];
|
|
352
|
+
for (let i = 0; i < Number(tableCount); i++) {
|
|
353
|
+
tables.push(await promptTable(i));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const features = await promptFeatures();
|
|
357
|
+
|
|
358
|
+
const config = {
|
|
359
|
+
projectName: basics.projectName,
|
|
360
|
+
dbName: basics.dbName,
|
|
361
|
+
port: basics.port,
|
|
362
|
+
tables,
|
|
363
|
+
needsAuth: features.needsAuth,
|
|
364
|
+
needsReports: features.needsReports,
|
|
365
|
+
targetDir: absoluteTargetDir,
|
|
366
|
+
packageName: sanitizePackageName(basics.projectName),
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
logger.info(`\n[⁂] TSS Stack — generating ${COLORS.cyan}${config.projectName}${COLORS.reset}\n`);
|
|
370
|
+
await sleep(CONSTANTS.INTRO_DELAY);
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
await generateDatabase(config);
|
|
374
|
+
await generateBackend(config);
|
|
375
|
+
await generateFrontend(config);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
await cleanupProject(absoluteTargetDir);
|
|
378
|
+
logger.fatal("Project generation failed.", err.message || err);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const backendPath = path.join(absoluteTargetDir, "backend-project");
|
|
382
|
+
const frontendPath = path.join(absoluteTargetDir, "frontend-project");
|
|
383
|
+
|
|
384
|
+
if (!(await fs.pathExists(backendPath))) {
|
|
385
|
+
await cleanupProject(absoluteTargetDir);
|
|
386
|
+
logger.fatal(`Missing backend directory:\n${backendPath}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!(await fs.pathExists(frontendPath))) {
|
|
390
|
+
await cleanupProject(absoluteTargetDir);
|
|
391
|
+
logger.fatal(`Missing frontend directory:\n${frontendPath}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
await generateGitIgnore(absoluteTargetDir);
|
|
395
|
+
await showProjectTree(config);
|
|
396
|
+
logger.info("");
|
|
397
|
+
|
|
398
|
+
const backendSpinner = ora({ text: "Installing backend dependencies...", color: "cyan" }).start();
|
|
399
|
+
try {
|
|
400
|
+
await runCommand(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], backendPath, backendSpinner, "Backend dependency installation failed");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
await cleanupProject(absoluteTargetDir);
|
|
403
|
+
logger.fatal(err.message);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const frontendSpinner = ora({ text: "Installing frontend dependencies...", color: "magenta" }).start();
|
|
407
|
+
try {
|
|
408
|
+
await runCommand(process.platform === "win32" ? "npm.cmd" : "npm", ["install"], frontendPath, frontendSpinner, "Frontend dependency installation failed");
|
|
409
|
+
} catch (err) {
|
|
410
|
+
await cleanupProject(absoluteTargetDir);
|
|
411
|
+
logger.fatal(err.message);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!isMySQLInstalled()) {
|
|
415
|
+
logger.warn("MySQL not detected. Install MySQL to import the database.");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const relativePath = path.relative(process.cwd(), absoluteTargetDir);
|
|
419
|
+
logger.info(`
|
|
420
|
+
${COLORS.green}[✓] Done! ${config.projectName} is ready.${COLORS.reset}
|
|
421
|
+
|
|
422
|
+
${COLORS.bold}Next steps:${COLORS.reset}
|
|
423
|
+
|
|
424
|
+
1. Import your database:
|
|
425
|
+
${COLORS.cyan}mysql -u root -p < ${relativePath}/backend-project/config/database.sql${COLORS.reset}
|
|
426
|
+
|
|
427
|
+
2. Start backend:
|
|
428
|
+
${COLORS.cyan}cd ${relativePath}/backend-project${COLORS.reset}
|
|
429
|
+
${COLORS.cyan}cp .env.example .env${COLORS.reset}
|
|
430
|
+
Fill in your MySQL credentials in .env
|
|
431
|
+
${COLORS.cyan}npm run dev${COLORS.reset}
|
|
432
|
+
|
|
433
|
+
3. Start frontend:
|
|
434
|
+
${COLORS.cyan}cd ${relativePath}/frontend-project${COLORS.reset}
|
|
435
|
+
${COLORS.cyan}npm run dev${COLORS.reset}
|
|
436
|
+
|
|
437
|
+
4. Open in browser:
|
|
438
|
+
${COLORS.underline}http://localhost:${CONSTANTS.FRONTEND_PORT_DEFAULT}${COLORS.reset}
|
|
439
|
+
|
|
440
|
+
${COLORS.yellow}Tip:${COLORS.reset} Check ${COLORS.cyan}.gitignore${COLORS.reset} to exclude node_modules and .env from version control.
|
|
441
|
+
`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
process.on("SIGINT", async () => {
|
|
445
|
+
const targetDir = process.argv[2];
|
|
446
|
+
if (targetDir) await cleanupProject(path.resolve(process.cwd(), targetDir));
|
|
447
|
+
console.log(`\n${COLORS.red}[ABORTED]${COLORS.reset} Process cancelled by user.`);
|
|
448
|
+
process.exit(130);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
process.on("SIGTERM", async () => {
|
|
452
|
+
const targetDir = process.argv[2];
|
|
453
|
+
if (targetDir) await cleanupProject(path.resolve(process.cwd(), targetDir));
|
|
454
|
+
process.exit(143);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
process.on("unhandledRejection", (reason) => {
|
|
458
|
+
logger.fatal("Unhandled Promise Rejection", reason);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
process.on("uncaughtException", (err) => {
|
|
462
|
+
logger.fatal("Uncaught Exception", err);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
run().catch((err) => {
|
|
466
|
+
logger.fatal("Unexpected fatal error.", err.message || err);
|
|
467
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tss-stack",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive full-stack Node.js + React + MySQL project generator",
|
|
5
|
+
"bin": {
|
|
6
|
+
"tss-stack": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=16.0.0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"fs-extra": "^11.2.0",
|
|
17
|
+
"inquirer": "^8.2.6",
|
|
18
|
+
"ora": "^5.4.1"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"cli",
|
|
22
|
+
"fullstack",
|
|
23
|
+
"nodejs",
|
|
24
|
+
"react",
|
|
25
|
+
"mysql",
|
|
26
|
+
"scaffold"
|
|
27
|
+
],
|
|
28
|
+
"author": "Mugisha Brian",
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"type": "commonjs"
|
|
31
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const { toPascal, toRoute } = require("./utils");
|
|
5
|
+
|
|
6
|
+
async function generateBackend(config) {
|
|
7
|
+
const { dbName, port, tables, needsAuth, targetDir } = config;
|
|
8
|
+
const root = path.join(targetDir, "backend-project");
|
|
9
|
+
|
|
10
|
+
const dependencies = {
|
|
11
|
+
express: "^4.18.2",
|
|
12
|
+
mysql2: "^3.6.0",
|
|
13
|
+
cors: "^2.8.5",
|
|
14
|
+
dotenv: "^16.3.1",
|
|
15
|
+
helmet: "^7.1.0",
|
|
16
|
+
"express-session": "^1.17.3",
|
|
17
|
+
bcryptjs: "^2.4.3",
|
|
18
|
+
"express-rate-limit": "^7.1.5",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
await fs.outputFile(
|
|
22
|
+
path.join(root, "package.json"),
|
|
23
|
+
JSON.stringify(
|
|
24
|
+
{
|
|
25
|
+
name: "backend-project",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
scripts: {
|
|
28
|
+
dev: "nodemon server.js",
|
|
29
|
+
start: "node server.js",
|
|
30
|
+
},
|
|
31
|
+
dependencies: needsAuth
|
|
32
|
+
? dependencies
|
|
33
|
+
: {
|
|
34
|
+
express: dependencies.express,
|
|
35
|
+
mysql2: dependencies.mysql2,
|
|
36
|
+
cors: dependencies.cors,
|
|
37
|
+
dotenv: dependencies.dotenv,
|
|
38
|
+
helmet: dependencies.helmet,
|
|
39
|
+
},
|
|
40
|
+
devDependencies: {
|
|
41
|
+
nodemon: "^3.0.1",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await fs.outputFile(
|
|
50
|
+
path.join(root, ".env.example"),
|
|
51
|
+
`DB_HOST=localhost
|
|
52
|
+
DB_USER=root
|
|
53
|
+
DB_PASSWORD=your_password_here
|
|
54
|
+
DB_NAME=${dbName}
|
|
55
|
+
PORT=${port}
|
|
56
|
+
SESSION_SECRET=change_me
|
|
57
|
+
CLIENT_URL=http://localhost:5173
|
|
58
|
+
NODE_ENV=development
|
|
59
|
+
`
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await fs.outputFile(
|
|
63
|
+
path.join(root, "config", "db.js"),
|
|
64
|
+
`const mysql = require("mysql2");
|
|
65
|
+
require("dotenv").config();
|
|
66
|
+
|
|
67
|
+
const pool = mysql.createPool({
|
|
68
|
+
host: process.env.DB_HOST,
|
|
69
|
+
user: process.env.DB_USER,
|
|
70
|
+
password: process.env.DB_PASSWORD,
|
|
71
|
+
database: process.env.DB_NAME,
|
|
72
|
+
waitForConnections: true,
|
|
73
|
+
connectionLimit: 10,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pool.getConnection((err, connection) => {
|
|
77
|
+
if (err) {
|
|
78
|
+
console.error("[ERROR] MySQL connection failed:", err.message);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("[✓] MySQL connected");
|
|
83
|
+
connection.release();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
module.exports = pool.promise();
|
|
87
|
+
`
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (needsAuth) {
|
|
91
|
+
await fs.outputFile(
|
|
92
|
+
path.join(root, "middleware", "auth.js"),
|
|
93
|
+
`module.exports = (req, res, next) => {
|
|
94
|
+
if (req.session && req.session.user) return next();
|
|
95
|
+
return res.status(401).json({ message: "Unauthorized" });
|
|
96
|
+
};
|
|
97
|
+
`
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await fs.outputFile(
|
|
101
|
+
path.join(root, "routes", "auth.js"),
|
|
102
|
+
`const express = require("express");
|
|
103
|
+
const bcrypt = require("bcryptjs");
|
|
104
|
+
const rateLimit = require("express-rate-limit");
|
|
105
|
+
const router = express.Router();
|
|
106
|
+
const db = require("../config/db");
|
|
107
|
+
|
|
108
|
+
const authLimiter = rateLimit({
|
|
109
|
+
windowMs: 15 * 60 * 1000,
|
|
110
|
+
max: 50,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
router.use(authLimiter);
|
|
114
|
+
|
|
115
|
+
router.post("/register", async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const { username, password } = req.body;
|
|
118
|
+
|
|
119
|
+
if (!username || !password) {
|
|
120
|
+
return res.status(400).json({ message: "Username and password required" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (password.length < 6) {
|
|
124
|
+
return res.status(400).json({ message: "Password must be at least 6 characters" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hash = await bcrypt.hash(password, 10);
|
|
128
|
+
|
|
129
|
+
await db.query("INSERT INTO users (username, password) VALUES (?, ?)", [username, hash]);
|
|
130
|
+
|
|
131
|
+
res.json({ message: "User registered successfully" });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
res.status(500).json({ error: err.message });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
router.post("/login", async (req, res) => {
|
|
138
|
+
try {
|
|
139
|
+
const { username, password } = req.body;
|
|
140
|
+
|
|
141
|
+
const [results] = await db.query("SELECT * FROM users WHERE username = ?", [username]);
|
|
142
|
+
|
|
143
|
+
if (results.length === 0) {
|
|
144
|
+
return res.status(401).json({ message: "User not found" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const user = results[0];
|
|
148
|
+
const passwordMatch = await bcrypt.compare(password, user.password);
|
|
149
|
+
|
|
150
|
+
if (!passwordMatch) {
|
|
151
|
+
return res.status(401).json({ message: "Wrong password" });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
req.session.user = {
|
|
155
|
+
id: user.id,
|
|
156
|
+
username: user.username,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
res.json({
|
|
160
|
+
message: "Login successful",
|
|
161
|
+
user: req.session.user,
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
res.status(500).json({ error: err.message });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
router.post("/logout", (req, res) => {
|
|
169
|
+
req.session.destroy(() => {
|
|
170
|
+
res.json({ message: "Logged out" });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
module.exports = router;
|
|
175
|
+
`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const table of tables) {
|
|
180
|
+
const routeName = toRoute(table.name);
|
|
181
|
+
const insertFields = table.fields.join(", ");
|
|
182
|
+
const placeholders = table.fields.map(() => "?").join(", ");
|
|
183
|
+
const values = table.fields.map((field) => `req.body.${field}`).join(", ");
|
|
184
|
+
const updateSet = table.fields.map((field) => `${field} = ?`).join(", ");
|
|
185
|
+
const updateValues = [...table.fields.map((field) => `req.body.${field}`), "req.params.id"].join(", ");
|
|
186
|
+
|
|
187
|
+
let route = `const express = require("express");
|
|
188
|
+
const router = express.Router();
|
|
189
|
+
const db = require("../config/db");
|
|
190
|
+
${needsAuth ? 'const isAuthenticated = require("../middleware/auth");' : ""}
|
|
191
|
+
|
|
192
|
+
`;
|
|
193
|
+
|
|
194
|
+
if (table.operations.includes("insert")) {
|
|
195
|
+
route += `router.post(
|
|
196
|
+
"/",
|
|
197
|
+
${needsAuth ? "isAuthenticated," : ""}
|
|
198
|
+
async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const sql = "INSERT INTO ${table.name} (${insertFields}) VALUES (${placeholders})";
|
|
201
|
+
await db.query(sql, [${values}]);
|
|
202
|
+
res.json({ message: "${toPascal(table.name)} created" });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
res.status(500).json({ error: err.message });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (table.operations.includes("select")) {
|
|
213
|
+
route += `router.get(
|
|
214
|
+
"/",
|
|
215
|
+
${needsAuth ? "isAuthenticated," : ""}
|
|
216
|
+
async (req, res) => {
|
|
217
|
+
try {
|
|
218
|
+
const [rows] = await db.query("SELECT * FROM ${table.name}");
|
|
219
|
+
res.json(rows);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
res.status(500).json({ error: err.message });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (table.operations.includes("update")) {
|
|
230
|
+
route += `router.put(
|
|
231
|
+
"/:id",
|
|
232
|
+
${needsAuth ? "isAuthenticated," : ""}
|
|
233
|
+
async (req, res) => {
|
|
234
|
+
try {
|
|
235
|
+
const sql = "UPDATE ${table.name} SET ${updateSet} WHERE id = ?";
|
|
236
|
+
await db.query(sql, [${updateValues}]);
|
|
237
|
+
res.json({ message: "${toPascal(table.name)} updated" });
|
|
238
|
+
} catch (err) {
|
|
239
|
+
res.status(500).json({ error: err.message });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (table.operations.includes("delete")) {
|
|
248
|
+
route += `router.delete(
|
|
249
|
+
"/:id",
|
|
250
|
+
${needsAuth ? "isAuthenticated," : ""}
|
|
251
|
+
async (req, res) => {
|
|
252
|
+
try {
|
|
253
|
+
await db.query("DELETE FROM ${table.name} WHERE id = ?", [req.params.id]);
|
|
254
|
+
res.json({ message: "${toPascal(table.name)} deleted" });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
res.status(500).json({ error: err.message });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
route += `module.exports = router;
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
await fs.outputFile(path.join(root, "routes", `${routeName}.js`), route);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const routeImports = tables
|
|
271
|
+
.map((t) => `const ${toPascal(t.name)}Route = require("./routes/${toRoute(t.name)}");`)
|
|
272
|
+
.join("\n");
|
|
273
|
+
|
|
274
|
+
const routeMounts = tables
|
|
275
|
+
.map((t) => `app.use("/${toRoute(t.name)}", ${toPascal(t.name)}Route);`)
|
|
276
|
+
.join("\n");
|
|
277
|
+
|
|
278
|
+
const authImport = needsAuth ? 'const authRoutes = require("./routes/auth");' : "";
|
|
279
|
+
const authMount = needsAuth ? 'app.use("/auth", authRoutes);' : "";
|
|
280
|
+
const authMiddleware = needsAuth
|
|
281
|
+
? `const session = require("express-session");
|
|
282
|
+
|
|
283
|
+
app.use(session({
|
|
284
|
+
secret: process.env.SESSION_SECRET || "change_me",
|
|
285
|
+
resave: false,
|
|
286
|
+
saveUninitialized: false,
|
|
287
|
+
cookie: {
|
|
288
|
+
httpOnly: true,
|
|
289
|
+
sameSite: "lax",
|
|
290
|
+
secure: false,
|
|
291
|
+
},
|
|
292
|
+
}));
|
|
293
|
+
`
|
|
294
|
+
: "";
|
|
295
|
+
|
|
296
|
+
const server = `const express = require("express");
|
|
297
|
+
const cors = require("cors");
|
|
298
|
+
const helmet = require("helmet");
|
|
299
|
+
require("dotenv").config();
|
|
300
|
+
|
|
301
|
+
const app = express();
|
|
302
|
+
const db = require("./config/db");
|
|
303
|
+
${authImport}
|
|
304
|
+
${routeImports}
|
|
305
|
+
|
|
306
|
+
app.use(helmet());
|
|
307
|
+
app.use(cors({
|
|
308
|
+
origin: process.env.CLIENT_URL || "http://localhost:5173",
|
|
309
|
+
credentials: true,
|
|
310
|
+
}));
|
|
311
|
+
app.use(express.json());
|
|
312
|
+
${authMiddleware}
|
|
313
|
+
app.get("/health", (req, res) => res.json({ ok: true }));
|
|
314
|
+
${authMount}
|
|
315
|
+
${routeMounts}
|
|
316
|
+
|
|
317
|
+
const port = process.env.PORT || 5000;
|
|
318
|
+
|
|
319
|
+
app.listen(port, () => {
|
|
320
|
+
console.log(\`[✓] Server running on port \${port}\`);
|
|
321
|
+
});
|
|
322
|
+
`;
|
|
323
|
+
|
|
324
|
+
await fs.outputFile(path.join(root, "server.js"), server);
|
|
325
|
+
console.log(" [✓] backend files");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
generateBackend,
|
|
330
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const { toPascal, escapeSqlIdentifier, inferSqlType } = require("./utils");
|
|
5
|
+
|
|
6
|
+
async function generateDatabase(config) {
|
|
7
|
+
const { dbName, tables, needsAuth, targetDir } = config;
|
|
8
|
+
|
|
9
|
+
let sql = `-- ======================================================
|
|
10
|
+
-- Database: ${dbName}
|
|
11
|
+
-- Generated automatically
|
|
12
|
+
-- ======================================================
|
|
13
|
+
|
|
14
|
+
CREATE DATABASE IF NOT EXISTS ${escapeSqlIdentifier(dbName)};
|
|
15
|
+
USE ${escapeSqlIdentifier(dbName)};
|
|
16
|
+
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
if (needsAuth) {
|
|
20
|
+
sql += `CREATE TABLE IF NOT EXISTS users (
|
|
21
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
22
|
+
username VARCHAR(100) NOT NULL UNIQUE,
|
|
23
|
+
password VARCHAR(255) NOT NULL,
|
|
24
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const table of tables) {
|
|
31
|
+
sql += `-- ${toPascal(table.name)} table
|
|
32
|
+
CREATE TABLE IF NOT EXISTS ${escapeSqlIdentifier(table.name)} (
|
|
33
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
for (const field of table.fields) {
|
|
37
|
+
sql += ` ${escapeSqlIdentifier(field)} ${inferSqlType(field)} NOT NULL,
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
sql += ` created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const outputPath = path.join(targetDir, "backend-project", "config", "database.sql");
|
|
48
|
+
await fs.outputFile(outputPath, sql);
|
|
49
|
+
console.log(" [✓] database.sql");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
generateDatabase,
|
|
54
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { toPascal, toRoute } = require("./utils");
|
|
4
|
+
|
|
5
|
+
function generateFrontend(config) {
|
|
6
|
+
const { projectName, tables, needsAuth, needsReports, targetDir } = config;
|
|
7
|
+
const root = path.join(targetDir, "frontend-project");
|
|
8
|
+
|
|
9
|
+
fs.outputFileSync(
|
|
10
|
+
path.join(root, "package.json"),
|
|
11
|
+
JSON.stringify(
|
|
12
|
+
{
|
|
13
|
+
name: "frontend-project",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
scripts: { dev: "vite", build: "vite build" },
|
|
16
|
+
dependencies: {
|
|
17
|
+
react: "^18.2.0",
|
|
18
|
+
"react-dom": "^18.2.0",
|
|
19
|
+
"react-router-dom": "^6.18.0",
|
|
20
|
+
axios: "^1.6.0",
|
|
21
|
+
},
|
|
22
|
+
devDependencies: {
|
|
23
|
+
vite: "^5.0.0",
|
|
24
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
25
|
+
tailwindcss: "^3.3.0",
|
|
26
|
+
autoprefixer: "^10.4.16",
|
|
27
|
+
postcss: "^8.4.31",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
null,
|
|
31
|
+
2
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
fs.outputFileSync(
|
|
36
|
+
path.join(root, "src", "api", "axios.js"),
|
|
37
|
+
`import axios from "axios";
|
|
38
|
+
|
|
39
|
+
const API = axios.create({
|
|
40
|
+
baseURL: "http://localhost:5000",
|
|
41
|
+
withCredentials: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export default API;
|
|
45
|
+
`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
fs.outputFileSync(
|
|
49
|
+
path.join(root, "src", "main.jsx"),
|
|
50
|
+
`import React from "react";
|
|
51
|
+
import ReactDOM from "react-dom/client";
|
|
52
|
+
import App from "./App";
|
|
53
|
+
import "./index.css";
|
|
54
|
+
|
|
55
|
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
56
|
+
<React.StrictMode>
|
|
57
|
+
<App />
|
|
58
|
+
</React.StrictMode>
|
|
59
|
+
);
|
|
60
|
+
`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
fs.outputFileSync(
|
|
64
|
+
path.join(root, "src", "index.css"),
|
|
65
|
+
`@tailwind base;
|
|
66
|
+
@tailwind components;
|
|
67
|
+
@tailwind utilities;
|
|
68
|
+
`
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
for (const table of tables) {
|
|
72
|
+
const name = toPascal(table.name);
|
|
73
|
+
const route = toRoute(table.name);
|
|
74
|
+
const fields = table.fields;
|
|
75
|
+
const ops = table.operations;
|
|
76
|
+
|
|
77
|
+
const stateFields = fields.map((f) => ` ${f}: ""`).join(",\n");
|
|
78
|
+
const formReset = fields.map((f) => `${f}: ""`).join(", ");
|
|
79
|
+
const editSet = fields.map((f) => `${f}: item.${f}`).join(", ");
|
|
80
|
+
const inputs = fields
|
|
81
|
+
.map(
|
|
82
|
+
(f) => ` <input
|
|
83
|
+
type="text"
|
|
84
|
+
placeholder="${f}"
|
|
85
|
+
value={form.${f}}
|
|
86
|
+
onChange={(e) => setForm({ ...form, ${f}: e.target.value })}
|
|
87
|
+
className="border p-2 rounded w-full"
|
|
88
|
+
/>`
|
|
89
|
+
)
|
|
90
|
+
.join("\n");
|
|
91
|
+
|
|
92
|
+
const tableHeaders = ["id", ...fields, "created_at"]
|
|
93
|
+
.map((f) => ` <th className="border px-4 py-2">${f}</th>`)
|
|
94
|
+
.join("\n");
|
|
95
|
+
|
|
96
|
+
const tableRow = ["id", ...fields, "created_at"]
|
|
97
|
+
.map((f) => ` <td className="border px-4 py-2">{item.${f}}</td>`)
|
|
98
|
+
.join("\n");
|
|
99
|
+
|
|
100
|
+
let page = `import { useState, useEffect } from "react";
|
|
101
|
+
import API from "../api/axios";
|
|
102
|
+
|
|
103
|
+
export default function ${name}() {
|
|
104
|
+
const [items, setItems] = useState([]);
|
|
105
|
+
const [form, setForm] = useState({
|
|
106
|
+
${stateFields}
|
|
107
|
+
});
|
|
108
|
+
${ops.includes("update") ? " const [editId, setEditId] = useState(null);" : ""}
|
|
109
|
+
|
|
110
|
+
const fetchAll = async () => {
|
|
111
|
+
const res = await API.get("/${route}");
|
|
112
|
+
setItems(res.data);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
fetchAll();
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
if (ops.includes("insert")) {
|
|
122
|
+
page += ` const handleSubmit = async (e) => {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
${ops.includes("update") ? ` if (editId) {
|
|
125
|
+
await API.put(\`/${route}/\${editId}\`, form);
|
|
126
|
+
setEditId(null);
|
|
127
|
+
} else {
|
|
128
|
+
await API.post("/${route}", form);
|
|
129
|
+
}` : ` await API.post("/${route}", form);`}
|
|
130
|
+
setForm({ ${formReset} });
|
|
131
|
+
fetchAll();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (ops.includes("delete")) {
|
|
138
|
+
page += ` const handleDelete = async (id) => {
|
|
139
|
+
if (!window.confirm("Are you sure you want to delete this?")) return;
|
|
140
|
+
await API.delete(\`/${route}/\${id}\`);
|
|
141
|
+
fetchAll();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (ops.includes("update")) {
|
|
148
|
+
page += ` const handleEdit = (item) => {
|
|
149
|
+
setEditId(item.id);
|
|
150
|
+
setForm({ ${editSet} });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
page += ` return (
|
|
157
|
+
<div className="p-6">
|
|
158
|
+
<h1 className="text-2xl font-bold mb-4">${name}</h1>
|
|
159
|
+
|
|
160
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-3 mb-6 max-w-md">
|
|
161
|
+
${inputs}
|
|
162
|
+
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
|
|
163
|
+
${ops.includes("update") ? 'editId ? "Update" : "Add"' : '"Add"'}
|
|
164
|
+
</button>
|
|
165
|
+
</form>
|
|
166
|
+
|
|
167
|
+
<table className="w-full border-collapse text-sm">
|
|
168
|
+
<thead className="bg-gray-100">
|
|
169
|
+
<tr>
|
|
170
|
+
${tableHeaders}
|
|
171
|
+
${ops.includes("update") || ops.includes("delete") ? ' <th className="border px-4 py-2">Actions</th>' : ""}
|
|
172
|
+
</tr>
|
|
173
|
+
</thead>
|
|
174
|
+
<tbody>
|
|
175
|
+
{items.map((item) => (
|
|
176
|
+
<tr key={item.id} className="hover:bg-gray-50">
|
|
177
|
+
${tableRow}
|
|
178
|
+
${ops.includes("update") || ops.includes("delete") ? ` <td className="border px-4 py-2 space-x-2">
|
|
179
|
+
${ops.includes("update") ? ' <button onClick={() => handleEdit(item)} className="text-blue-600 hover:underline">Edit</button>' : ""}
|
|
180
|
+
${ops.includes("delete") ? ' <button onClick={() => handleDelete(item.id)} className="text-red-600 hover:underline">Delete</button>' : ""}
|
|
181
|
+
</td>` : ""}
|
|
182
|
+
</tr>
|
|
183
|
+
))}
|
|
184
|
+
</tbody>
|
|
185
|
+
</table>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
fs.outputFileSync(path.join(root, "src", "pages", `${name}.jsx`), page);
|
|
192
|
+
console.log(` [✓] pages/${name}.jsx`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (needsAuth) {
|
|
196
|
+
fs.outputFileSync(
|
|
197
|
+
path.join(root, "src", "pages", "Login.jsx"),
|
|
198
|
+
`import { useState } from "react";
|
|
199
|
+
import { useNavigate } from "react-router-dom";
|
|
200
|
+
import API from "../api/axios";
|
|
201
|
+
|
|
202
|
+
export default function Login() {
|
|
203
|
+
const navigate = useNavigate();
|
|
204
|
+
const [form, setForm] = useState({ username: "", password: "" });
|
|
205
|
+
const [error, setError] = useState("");
|
|
206
|
+
|
|
207
|
+
const handleLogin = async (e) => {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
setError("");
|
|
210
|
+
try {
|
|
211
|
+
await API.post("/auth/login", form);
|
|
212
|
+
navigate("/");
|
|
213
|
+
} catch (err) {
|
|
214
|
+
setError(err.response?.data?.message || "Login failed");
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className="p-6 max-w-md">
|
|
220
|
+
<h1 className="text-2xl font-bold mb-4">Login</h1>
|
|
221
|
+
<form onSubmit={handleLogin} className="flex flex-col gap-3">
|
|
222
|
+
<input
|
|
223
|
+
className="border p-2 rounded"
|
|
224
|
+
placeholder="Username"
|
|
225
|
+
value={form.username}
|
|
226
|
+
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
227
|
+
/>
|
|
228
|
+
<input
|
|
229
|
+
className="border p-2 rounded"
|
|
230
|
+
type="password"
|
|
231
|
+
placeholder="Password"
|
|
232
|
+
value={form.password}
|
|
233
|
+
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
234
|
+
/>
|
|
235
|
+
{error ? <p className="text-red-600">{error}</p> : null}
|
|
236
|
+
<button className="bg-blue-600 text-white px-4 py-2 rounded">Login</button>
|
|
237
|
+
</form>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (needsReports) {
|
|
246
|
+
fs.outputFileSync(
|
|
247
|
+
path.join(root, "src", "pages", "Reports.jsx"),
|
|
248
|
+
`export default function Reports() {
|
|
249
|
+
return (
|
|
250
|
+
<div className="p-6">
|
|
251
|
+
<h1 className="text-2xl font-bold mb-4">Reports</h1>
|
|
252
|
+
<p>Build your reports dashboard here.</p>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const imports = tables
|
|
261
|
+
.map((t) => `import ${toPascal(t.name)} from "./pages/${toPascal(t.name)}";`)
|
|
262
|
+
.join("\n");
|
|
263
|
+
|
|
264
|
+
const routes = tables
|
|
265
|
+
.map((t) => ` <Route path="/${toRoute(t.name)}" element={<${toPascal(t.name)} />} />`)
|
|
266
|
+
.join("\n");
|
|
267
|
+
|
|
268
|
+
const navLinks = tables
|
|
269
|
+
.map((t) => ` <Link to="/${toRoute(t.name)}" className="hover:underline">${toPascal(t.name)}</Link>`)
|
|
270
|
+
.join("\n");
|
|
271
|
+
|
|
272
|
+
const app = `import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router-dom";
|
|
273
|
+
${imports}
|
|
274
|
+
${needsAuth ? 'import Login from "./pages/Login";' : ""}
|
|
275
|
+
${needsReports ? 'import Reports from "./pages/Reports";' : ""}
|
|
276
|
+
import API from "./api/axios";
|
|
277
|
+
|
|
278
|
+
function Navbar() {
|
|
279
|
+
const navigate = useNavigate();
|
|
280
|
+
|
|
281
|
+
const logout = async () => {
|
|
282
|
+
await API.post("/auth/logout");
|
|
283
|
+
navigate("/login");
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<nav className="bg-blue-700 text-white px-6 py-3 flex gap-6 items-center">
|
|
288
|
+
<span className="font-bold text-lg">${projectName}</span>
|
|
289
|
+
${navLinks}
|
|
290
|
+
${needsReports ? ' <Link to="/reports" className="hover:underline">Reports</Link>' : ""}
|
|
291
|
+
${needsAuth ? ' <button onClick={logout} className="ml-auto hover:underline">Logout</button>' : ""}
|
|
292
|
+
</nav>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export default function App() {
|
|
297
|
+
return (
|
|
298
|
+
<BrowserRouter>
|
|
299
|
+
<Navbar />
|
|
300
|
+
<Routes>
|
|
301
|
+
${needsAuth ? ' <Route path="/login" element={<Login />} />' : ""}
|
|
302
|
+
${routes}
|
|
303
|
+
${needsReports ? ' <Route path="/reports" element={<Reports />} />' : ""}
|
|
304
|
+
</Routes>
|
|
305
|
+
</BrowserRouter>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
fs.outputFileSync(path.join(root, "src", "App.jsx"), app);
|
|
311
|
+
console.log(" [✓] App.jsx");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = { generateFrontend };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String and SQL helpers for generator templates.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const normalizeInput = (value = "") => String(value).trim();
|
|
6
|
+
|
|
7
|
+
const splitSnakeCase = (value = "") =>
|
|
8
|
+
normalizeInput(value)
|
|
9
|
+
.split("_")
|
|
10
|
+
.map((part) => part.trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
|
|
13
|
+
const capitalize = (value = "") =>
|
|
14
|
+
value ? value.charAt(0).toUpperCase() + value.slice(1) : "";
|
|
15
|
+
|
|
16
|
+
const toPascal = (str = "") => splitSnakeCase(str).map(capitalize).join("");
|
|
17
|
+
|
|
18
|
+
const toCamel = (str = "") => {
|
|
19
|
+
const pascal = toPascal(str);
|
|
20
|
+
return pascal ? pascal.charAt(0).toLowerCase() + pascal.slice(1) : "";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const toRoute = (str = "") =>
|
|
24
|
+
normalizeInput(str)
|
|
25
|
+
.replace(/_+/g, "-")
|
|
26
|
+
.replace(/\s+/g, "-")
|
|
27
|
+
.replace(/-+/g, "-")
|
|
28
|
+
.replace(/^-+|-+$/g, "")
|
|
29
|
+
.toLowerCase();
|
|
30
|
+
|
|
31
|
+
const escapeSqlIdentifier = (identifier = "") => {
|
|
32
|
+
const safe = String(identifier).replace(/`/g, "``");
|
|
33
|
+
return `\`${safe}\``;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function inferSqlType(field = "") {
|
|
37
|
+
const lower = String(field).toLowerCase().trim();
|
|
38
|
+
|
|
39
|
+
if (!lower) return "VARCHAR(255)";
|
|
40
|
+
if (lower === "id" || lower.endsWith("_id")) return "INT";
|
|
41
|
+
if (lower.includes("quantity") || lower.includes("count") || lower.includes("number")) return "INT";
|
|
42
|
+
if (lower.includes("price") || lower.includes("amount") || lower.includes("total") || lower.includes("cost")) {
|
|
43
|
+
return "DECIMAL(10,2)";
|
|
44
|
+
}
|
|
45
|
+
if (lower.includes("date") || lower.includes("birthday")) return "DATE";
|
|
46
|
+
if (lower.includes("time")) return "DATETIME";
|
|
47
|
+
if (lower.includes("email")) return "VARCHAR(255)";
|
|
48
|
+
if (lower.includes("phone")) return "VARCHAR(30)";
|
|
49
|
+
if (lower.includes("description") || lower.includes("notes") || lower.includes("message")) return "TEXT";
|
|
50
|
+
|
|
51
|
+
return "VARCHAR(255)";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
toPascal,
|
|
56
|
+
toCamel,
|
|
57
|
+
toRoute,
|
|
58
|
+
escapeSqlIdentifier,
|
|
59
|
+
inferSqlType,
|
|
60
|
+
};
|