tss-stack 1.2.3 → 1.3.1
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 +592 -510
- package/package.json +1 -1
- package/src/generators/backend.js +404 -358
- package/src/generators/database.js +169 -55
- package/src/generators/frontend.js +794 -542
- package/src/generators/utils.js +113 -60
package/bin/cli.js
CHANGED
|
@@ -1,510 +1,592 @@
|
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (port
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const parsed = raw
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
child.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
{
|
|
267
|
-
name: "
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
+
/* ==========================================================================
|
|
16
|
+
CONSTANTS & CONFIG
|
|
17
|
+
========================================================================== */
|
|
18
|
+
|
|
19
|
+
const CONSTANTS = {
|
|
20
|
+
MIN_PORT: 1,
|
|
21
|
+
MAX_PORT: 65535,
|
|
22
|
+
MAX_TABLES: 50,
|
|
23
|
+
MIN_TABLES: 1,
|
|
24
|
+
DELAY_PER_LINE: 20,
|
|
25
|
+
INTRO_DELAY: 300,
|
|
26
|
+
BACKEND_PORT_DEFAULT: "5000",
|
|
27
|
+
FRONTEND_PORT_DEFAULT: 5173,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const COLORS = {
|
|
31
|
+
red: "\x1b[31m",
|
|
32
|
+
green: "\x1b[32m",
|
|
33
|
+
yellow: "\x1b[33m",
|
|
34
|
+
blue: "\x1b[34m",
|
|
35
|
+
cyan: "\x1b[36m",
|
|
36
|
+
bold: "\x1b[1m",
|
|
37
|
+
underline: "\x1b[4m",
|
|
38
|
+
reset: "\x1b[0m",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
|
|
43
|
+
/* ==========================================================================
|
|
44
|
+
LOGGER
|
|
45
|
+
========================================================================== */
|
|
46
|
+
|
|
47
|
+
const logger = {
|
|
48
|
+
info(message = "") { console.log(message); },
|
|
49
|
+
success(message) { console.log(`${COLORS.green}[✓]${COLORS.reset} ${message}`); },
|
|
50
|
+
warn(message) { console.log(`${COLORS.yellow}[!]${COLORS.reset} ${message}`); },
|
|
51
|
+
error(message) { console.error(`${COLORS.red}[ERROR]${COLORS.reset} ${message}`); },
|
|
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
|
+
/* ==========================================================================
|
|
60
|
+
VALIDATORS
|
|
61
|
+
========================================================================== */
|
|
62
|
+
|
|
63
|
+
function validateFolderName(value) {
|
|
64
|
+
if (!value || value.trim() === "") return "Folder name cannot be empty.";
|
|
65
|
+
if (value.includes("..") || value.includes("/") || value.includes("\\")) {
|
|
66
|
+
return "Folder name cannot contain path separators or '..'.";
|
|
67
|
+
}
|
|
68
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-_]*$/.test(value)) {
|
|
69
|
+
return "Folder name can only contain letters, numbers, dashes, and underscores.";
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateDatabaseName(value) {
|
|
75
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)) {
|
|
76
|
+
return "Database name must start with a letter and contain only letters, numbers, and underscores.";
|
|
77
|
+
}
|
|
78
|
+
if (value.length > 64) return "Database name cannot exceed 64 characters.";
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateTableName(value) {
|
|
83
|
+
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
|
|
84
|
+
return "Table name must start with a lowercase letter and use snake_case (e.g., spare_parts).";
|
|
85
|
+
}
|
|
86
|
+
const reservedWords = [
|
|
87
|
+
"select", "insert", "update", "delete", "create", "drop",
|
|
88
|
+
"table", "index", "key", "primary", "foreign", "user", "order",
|
|
89
|
+
"group", "having", "where", "from", "into", "values", "set",
|
|
90
|
+
];
|
|
91
|
+
if (reservedWords.includes(value.toLowerCase())) {
|
|
92
|
+
return `"${value}" is a reserved SQL keyword. Please use a different name.`;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validatePort(value) {
|
|
98
|
+
const port = Number(value);
|
|
99
|
+
if (!Number.isInteger(port)) return "Port must be an integer.";
|
|
100
|
+
if (port < CONSTANTS.MIN_PORT || port > CONSTANTS.MAX_PORT) {
|
|
101
|
+
return `Port must be between ${CONSTANTS.MIN_PORT} and ${CONSTANTS.MAX_PORT}.`;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateFields(value) {
|
|
107
|
+
const raw = Array.isArray(value) ? value.join(",") : String(value ?? "");
|
|
108
|
+
if (raw.trim() === "") return "At least one field is required.";
|
|
109
|
+
|
|
110
|
+
const parsed = raw.split(",").map((f) => f.trim()).filter(Boolean);
|
|
111
|
+
if (parsed.length === 0) return "At least one field is required.";
|
|
112
|
+
if (parsed.length > 50) return "Maximum 50 fields per table allowed.";
|
|
113
|
+
|
|
114
|
+
const invalid = parsed.find((field) => {
|
|
115
|
+
if (!/^[a-z][a-z0-9_]*$/.test(field)) return true;
|
|
116
|
+
const reserved = ["id", "created_at", "updated_at"];
|
|
117
|
+
return reserved.includes(field.toLowerCase());
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (invalid) {
|
|
121
|
+
return `Invalid field name "${invalid}". Use lowercase snake_case starting with a letter. Note: id, created_at and updated_at are added automatically.`;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateTableCount(value) {
|
|
127
|
+
const count = Number(value);
|
|
128
|
+
if (!Number.isInteger(count)) return "Enter a whole number.";
|
|
129
|
+
if (count < CONSTANTS.MIN_TABLES) return `Minimum ${CONSTANTS.MIN_TABLES} table required.`;
|
|
130
|
+
if (count > CONSTANTS.MAX_TABLES) return `Maximum ${CONSTANTS.MAX_TABLES} tables allowed.`;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ==========================================================================
|
|
135
|
+
HELPERS
|
|
136
|
+
========================================================================== */
|
|
137
|
+
|
|
138
|
+
async function printTree(lines) {
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
console.log(line);
|
|
141
|
+
await sleep(CONSTANTS.DELAY_PER_LINE);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatTree(lines) {
|
|
146
|
+
if (lines.length === 0) return [];
|
|
147
|
+
return lines.map((line, index) =>
|
|
148
|
+
index === lines.length - 1 ? line.replace("├──", "└──") : line
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sanitizePackageName(name) {
|
|
153
|
+
return name.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isMySQLInstalled() {
|
|
157
|
+
try {
|
|
158
|
+
execSync("mysql --version", { stdio: "ignore" });
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function checkNodeVersion() {
|
|
166
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
167
|
+
const MIN_NODE = 16;
|
|
168
|
+
if (major < MIN_NODE) {
|
|
169
|
+
logger.warn(`Node.js ${MIN_NODE}+ recommended. Current: ${process.versions.node}`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function runCommand(command, args, cwd, spinner, failMessage) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const child = spawn(command, args.filter(Boolean), {
|
|
178
|
+
cwd,
|
|
179
|
+
shell: process.platform === "win32",
|
|
180
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
child.on("close", (code) => {
|
|
184
|
+
if (code === 0) { spinner.succeed(); resolve(); }
|
|
185
|
+
else { spinner.fail(failMessage); reject(new Error(`${command} exited with code ${code}`)); }
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
child.on("error", (err) => { spinner.fail(failMessage); reject(err); });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function cleanupProject(targetDir) {
|
|
193
|
+
try {
|
|
194
|
+
if (await fs.pathExists(targetDir)) {
|
|
195
|
+
await fs.remove(targetDir);
|
|
196
|
+
logger.warn(`Cleaned up partially generated project: ${targetDir}`);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error(`Failed to clean up: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function generateGitIgnore(targetDir) {
|
|
204
|
+
const content = `node_modules/\n.env\n*.log\n.DS_Store\n.idea/\n.vscode/\n`;
|
|
205
|
+
const dest = path.join(targetDir, ".gitignore");
|
|
206
|
+
if (!(await fs.pathExists(dest))) await fs.outputFile(dest, content);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ==========================================================================
|
|
210
|
+
PROMPTS
|
|
211
|
+
========================================================================== */
|
|
212
|
+
|
|
213
|
+
async function promptBasics(targetDir) {
|
|
214
|
+
return inquirer.prompt([
|
|
215
|
+
{
|
|
216
|
+
name: "projectName",
|
|
217
|
+
message: "[1] Project display name:",
|
|
218
|
+
default: targetDir,
|
|
219
|
+
validate: (v) => (!v || v.trim() === "" ? "Project name cannot be empty." : true),
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "dbName",
|
|
223
|
+
message: "[2] MySQL database name:",
|
|
224
|
+
default: targetDir.toLowerCase().replace(/-/g, "_") + "_db",
|
|
225
|
+
validate: validateDatabaseName,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "port",
|
|
229
|
+
message: "[3] Backend port number:",
|
|
230
|
+
default: CONSTANTS.BACKEND_PORT_DEFAULT,
|
|
231
|
+
validate: validatePort,
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function promptTableCount() {
|
|
237
|
+
return inquirer.prompt([
|
|
238
|
+
{
|
|
239
|
+
name: "tableCount",
|
|
240
|
+
message: `[4] How many tables does your database need? (1-${CONSTANTS.MAX_TABLES}):`,
|
|
241
|
+
validate: validateTableCount,
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ============================================================
|
|
247
|
+
// ONLY THE CHANGED FUNCTION — drop this into your cli.js
|
|
248
|
+
// replacing the existing promptTable() function.
|
|
249
|
+
// Everything else in cli.js stays exactly the same.
|
|
250
|
+
// ============================================================
|
|
251
|
+
|
|
252
|
+
async function promptTable(index) {
|
|
253
|
+
// ── Step A: basic table info ─────────────────────────────
|
|
254
|
+
const base = await inquirer.prompt([
|
|
255
|
+
{
|
|
256
|
+
name: "name",
|
|
257
|
+
message: `Table ${index + 1} name (snake_case, e.g. spare_parts):`,
|
|
258
|
+
validate: validateTableName,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "fields",
|
|
262
|
+
message: "Fields for this table (comma separated, lowercase snake_case):",
|
|
263
|
+
validate: validateFields,
|
|
264
|
+
filter: (value) => value.split(",").map((f) => f.trim()).filter(Boolean),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "operations",
|
|
268
|
+
type: "checkbox",
|
|
269
|
+
message: "Which operations does this table need?",
|
|
270
|
+
choices: [
|
|
271
|
+
{ name: "INSERT (create)", value: "insert", checked: true },
|
|
272
|
+
{ name: "SELECT (read/list)", value: "select", checked: true },
|
|
273
|
+
{ name: "UPDATE (edit)", value: "update" },
|
|
274
|
+
{ name: "DELETE (remove)", value: "delete" },
|
|
275
|
+
],
|
|
276
|
+
validate: (value) => (value.length > 0 ? true : "Select at least one operation."),
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "reports",
|
|
280
|
+
type: "confirm",
|
|
281
|
+
message: "Generate a report for this table?",
|
|
282
|
+
default: true,
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
// ── Step B: FK prompts — only for fields ending in _id ───
|
|
287
|
+
// Detect candidate FK fields automatically so the user
|
|
288
|
+
// doesn't have to think about it — just confirm or skip.
|
|
289
|
+
const fkFields = base.fields.filter((f) => f.endsWith("_id"));
|
|
290
|
+
const foreignKeys = [];
|
|
291
|
+
|
|
292
|
+
for (const field of fkFields) {
|
|
293
|
+
// Guess the referenced table name from the field name:
|
|
294
|
+
// spare_part_id → spare_parts (strip _id, pluralise naively)
|
|
295
|
+
const guessedTable = field.replace(/_id$/, "") + "s";
|
|
296
|
+
|
|
297
|
+
logger.info(
|
|
298
|
+
`\n ${COLORS.yellow}[FK]${COLORS.reset} "${field}" looks like a foreign key.`
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const fkAnswer = await inquirer.prompt([
|
|
302
|
+
{
|
|
303
|
+
name: "confirm",
|
|
304
|
+
type: "confirm",
|
|
305
|
+
message: ` Add a FOREIGN KEY constraint for "${field}"?`,
|
|
306
|
+
default: true,
|
|
307
|
+
},
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
if (!fkAnswer.confirm) continue;
|
|
311
|
+
|
|
312
|
+
const fkDetails = await inquirer.prompt([
|
|
313
|
+
{
|
|
314
|
+
name: "refTable",
|
|
315
|
+
message: ` References which table?`,
|
|
316
|
+
default: guessedTable,
|
|
317
|
+
validate: validateTableName,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "refColumn",
|
|
321
|
+
message: ` References which column in that table?`,
|
|
322
|
+
default: "id",
|
|
323
|
+
validate: (v) =>
|
|
324
|
+
/^[a-z][a-z0-9_]*$/.test(v)
|
|
325
|
+
? true
|
|
326
|
+
: "Use lowercase snake_case.",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: "onDelete",
|
|
330
|
+
type: "list",
|
|
331
|
+
message: ` ON DELETE behaviour:`,
|
|
332
|
+
// CASCADE — delete child rows when parent is deleted
|
|
333
|
+
// SET NULL — set the FK column to NULL (column must allow NULL)
|
|
334
|
+
// RESTRICT — block parent deletion if children exist (safest default)
|
|
335
|
+
choices: [
|
|
336
|
+
{ name: "RESTRICT (block deletion of referenced row)", value: "RESTRICT" },
|
|
337
|
+
{ name: "CASCADE (delete this row too)", value: "CASCADE" },
|
|
338
|
+
{ name: "SET NULL (set column to NULL)", value: "SET NULL" },
|
|
339
|
+
],
|
|
340
|
+
default: "RESTRICT",
|
|
341
|
+
},
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
foreignKeys.push({
|
|
345
|
+
field, // e.g. spare_part_id
|
|
346
|
+
refTable: fkDetails.refTable, // e.g. spare_parts
|
|
347
|
+
refColumn: fkDetails.refColumn, // e.g. id
|
|
348
|
+
onDelete: fkDetails.onDelete, // e.g. RESTRICT
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { ...base, foreignKeys };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function promptFeatures() {
|
|
356
|
+
return inquirer.prompt([
|
|
357
|
+
{
|
|
358
|
+
name: "needsAuth",
|
|
359
|
+
type: "confirm",
|
|
360
|
+
message: "[5] Add session-based login/register system?",
|
|
361
|
+
default: true,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: "needsReports",
|
|
365
|
+
type: "confirm",
|
|
366
|
+
message: "[6] Add a Reports page?",
|
|
367
|
+
default: true,
|
|
368
|
+
},
|
|
369
|
+
]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ==========================================================================
|
|
373
|
+
TREE PREVIEW
|
|
374
|
+
========================================================================== */
|
|
375
|
+
|
|
376
|
+
async function showProjectTree(config) {
|
|
377
|
+
const reportTables = config.tables.filter((t) => t.reports && config.needsReports);
|
|
378
|
+
|
|
379
|
+
const backendRouteFiles = [
|
|
380
|
+
config.needsAuth ? "│ │ ├── auth.js" : null,
|
|
381
|
+
...config.tables.map((t, i) => {
|
|
382
|
+
const isLast = i === config.tables.length - 1 && !config.needsReports;
|
|
383
|
+
return `│ │ ${isLast ? "└──" : "├──"} ${t.name}.js`;
|
|
384
|
+
}),
|
|
385
|
+
config.needsReports && reportTables.length > 0 ? "│ │ └── reports.js" : null,
|
|
386
|
+
].filter(Boolean);
|
|
387
|
+
|
|
388
|
+
const frontendContextFiles = config.needsAuth ? ["│ │ ├── AuthContext.jsx"] : [];
|
|
389
|
+
const frontendComponentFiles = config.needsAuth ? ["│ │ └── PrivateRoute.jsx"] : [];
|
|
390
|
+
|
|
391
|
+
const frontendPageFiles = [
|
|
392
|
+
"│ │ ├── Home.jsx",
|
|
393
|
+
config.needsAuth ? "│ │ ├── Login.jsx" : null,
|
|
394
|
+
...config.tables.map((t) => `│ │ ├── ${toPascal(t.name)}.jsx`),
|
|
395
|
+
config.needsReports ? "│ │ └── Reports.jsx" : null,
|
|
396
|
+
].filter(Boolean);
|
|
397
|
+
|
|
398
|
+
const reportConfigFiles = reportTables.map((t, i) => {
|
|
399
|
+
const isLast = i === reportTables.length - 1;
|
|
400
|
+
return ` │ │ ${isLast ? "└──" : "├──"} ${t.name}.report.js`;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const tree = [
|
|
404
|
+
`${COLORS.yellow}${path.basename(config.targetDir)}/${COLORS.reset}`,
|
|
405
|
+
"",
|
|
406
|
+
`├── ${COLORS.blue}backend-project/${COLORS.reset}`,
|
|
407
|
+
"│ ├── config/",
|
|
408
|
+
"│ │ ├── db.js",
|
|
409
|
+
"│ │ └── database.sql",
|
|
410
|
+
"│ ├── middleware/",
|
|
411
|
+
config.needsAuth ? "│ │ └── auth.js" : "│ │ (none)",
|
|
412
|
+
"│ ├── routes/",
|
|
413
|
+
...backendRouteFiles,
|
|
414
|
+
"│ ├── server.js",
|
|
415
|
+
"│ ├── .env.example",
|
|
416
|
+
"│ └── package.json",
|
|
417
|
+
"",
|
|
418
|
+
`└── ${COLORS.blue}frontend-project/${COLORS.reset}`,
|
|
419
|
+
" ├── src/",
|
|
420
|
+
" │ ├── api/",
|
|
421
|
+
" │ │ └── axios.js",
|
|
422
|
+
config.needsAuth ? " │ ├── context/" : null,
|
|
423
|
+
...frontendContextFiles,
|
|
424
|
+
config.needsAuth ? " │ ├── components/" : null,
|
|
425
|
+
...frontendComponentFiles,
|
|
426
|
+
config.needsReports && reportTables.length > 0 ? " │ ├── reports/" : null,
|
|
427
|
+
config.needsReports && reportTables.length > 0 ? " │ │ ├── index.js" : null,
|
|
428
|
+
...reportConfigFiles,
|
|
429
|
+
config.needsReports && reportTables.length > 0 ? " │ ├── shared/" : null,
|
|
430
|
+
config.needsReports && reportTables.length > 0 ? " │ │ ├── MetricCards.jsx" : null,
|
|
431
|
+
config.needsReports && reportTables.length > 0 ? " │ │ └── ReportTable.jsx" : null,
|
|
432
|
+
" │ ├── pages/",
|
|
433
|
+
...frontendPageFiles,
|
|
434
|
+
" │ ├── App.jsx",
|
|
435
|
+
" │ ├── main.jsx",
|
|
436
|
+
" │ └── index.css",
|
|
437
|
+
" ├── vite.config.js",
|
|
438
|
+
" ├── tailwind.config.js",
|
|
439
|
+
" ├── index.html",
|
|
440
|
+
" └── package.json",
|
|
441
|
+
].filter(Boolean);
|
|
442
|
+
|
|
443
|
+
await printTree(tree);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* ==========================================================================
|
|
447
|
+
MAIN
|
|
448
|
+
========================================================================== */
|
|
449
|
+
|
|
450
|
+
async function run() {
|
|
451
|
+
checkNodeVersion();
|
|
452
|
+
|
|
453
|
+
const targetDir = process.argv[2];
|
|
454
|
+
if (!targetDir) logger.fatal("Usage: npx tss-stack <folder-name>");
|
|
455
|
+
|
|
456
|
+
const folderValidation = validateFolderName(targetDir);
|
|
457
|
+
if (folderValidation !== true) logger.fatal(folderValidation);
|
|
458
|
+
|
|
459
|
+
const absoluteTargetDir = path.resolve(process.cwd(), targetDir);
|
|
460
|
+
if (await fs.pathExists(absoluteTargetDir)) {
|
|
461
|
+
logger.fatal(`Folder "${targetDir}" already exists.`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
logger.info(`\n${COLORS.bold}[✓] Full Project Folder Structure Generator${COLORS.reset}\n`);
|
|
465
|
+
|
|
466
|
+
const basics = await promptBasics(targetDir);
|
|
467
|
+
const { tableCount } = await promptTableCount();
|
|
468
|
+
|
|
469
|
+
const tables = [];
|
|
470
|
+
for (let i = 0; i < Number(tableCount); i++) {
|
|
471
|
+
tables.push(await promptTable(i));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const features = await promptFeatures();
|
|
475
|
+
|
|
476
|
+
const config = {
|
|
477
|
+
projectName: basics.projectName,
|
|
478
|
+
dbName: basics.dbName,
|
|
479
|
+
port: basics.port,
|
|
480
|
+
tables,
|
|
481
|
+
needsAuth: features.needsAuth,
|
|
482
|
+
needsReports: features.needsReports,
|
|
483
|
+
targetDir: absoluteTargetDir,
|
|
484
|
+
packageName: sanitizePackageName(basics.projectName),
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// Clear the interview from terminal
|
|
488
|
+
process.stdout.write("\x1Bc");
|
|
489
|
+
|
|
490
|
+
logger.info(`\n[⁂] TSS Stack — generating ${COLORS.cyan}${config.projectName}${COLORS.reset}\n`);
|
|
491
|
+
await sleep(CONSTANTS.INTRO_DELAY);
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
await generateDatabase(config);
|
|
495
|
+
await generateBackend(config);
|
|
496
|
+
await generateFrontend(config);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
await cleanupProject(absoluteTargetDir);
|
|
499
|
+
logger.fatal("Project generation failed.", err.message || err);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const backendPath = path.join(absoluteTargetDir, "backend-project");
|
|
503
|
+
const frontendPath = path.join(absoluteTargetDir, "frontend-project");
|
|
504
|
+
|
|
505
|
+
if (!(await fs.pathExists(backendPath))) {
|
|
506
|
+
await cleanupProject(absoluteTargetDir);
|
|
507
|
+
logger.fatal(`Missing backend directory: ${backendPath}`);
|
|
508
|
+
}
|
|
509
|
+
if (!(await fs.pathExists(frontendPath))) {
|
|
510
|
+
await cleanupProject(absoluteTargetDir);
|
|
511
|
+
logger.fatal(`Missing frontend directory: ${frontendPath}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
await generateGitIgnore(absoluteTargetDir);
|
|
515
|
+
await showProjectTree(config);
|
|
516
|
+
logger.info("");
|
|
517
|
+
|
|
518
|
+
const backendSpinner = ora({ text: "Installing backend dependencies...", color: "cyan" }).start();
|
|
519
|
+
try {
|
|
520
|
+
await runCommand(
|
|
521
|
+
process.platform === "win32" ? "npm.cmd" : "npm",
|
|
522
|
+
["install"], backendPath, backendSpinner,
|
|
523
|
+
"Backend dependency installation failed"
|
|
524
|
+
);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
await cleanupProject(absoluteTargetDir);
|
|
527
|
+
logger.fatal(err.message);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const frontendSpinner = ora({ text: "Installing frontend dependencies...", color: "magenta" }).start();
|
|
531
|
+
try {
|
|
532
|
+
await runCommand(
|
|
533
|
+
process.platform === "win32" ? "npm.cmd" : "npm",
|
|
534
|
+
["install"], frontendPath, frontendSpinner,
|
|
535
|
+
"Frontend dependency installation failed"
|
|
536
|
+
);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
await cleanupProject(absoluteTargetDir);
|
|
539
|
+
logger.fatal(err.message);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!isMySQLInstalled()) {
|
|
543
|
+
logger.warn("MySQL not detected. Install MySQL to import the database.");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const relativePath = path.relative(process.cwd(), absoluteTargetDir);
|
|
547
|
+
logger.info(`
|
|
548
|
+
${COLORS.green}[✓] Done! ${config.projectName} is ready.${COLORS.reset}
|
|
549
|
+
|
|
550
|
+
${COLORS.bold}Next steps:${COLORS.reset}
|
|
551
|
+
|
|
552
|
+
1. Import your database:
|
|
553
|
+
${COLORS.cyan}mysql -u root -p < ${relativePath}/backend-project/config/database.sql${COLORS.reset}
|
|
554
|
+
|
|
555
|
+
2. Start backend:
|
|
556
|
+
${COLORS.cyan}cd ${relativePath}/backend-project${COLORS.reset}
|
|
557
|
+
${COLORS.cyan}cp .env.example .env${COLORS.reset}
|
|
558
|
+
Fill in your MySQL credentials in .env
|
|
559
|
+
${COLORS.cyan}npm run dev${COLORS.reset}
|
|
560
|
+
|
|
561
|
+
3. Start frontend:
|
|
562
|
+
${COLORS.cyan}cd ${relativePath}/frontend-project${COLORS.reset}
|
|
563
|
+
${COLORS.cyan}npm run dev${COLORS.reset}
|
|
564
|
+
|
|
565
|
+
4. Open in browser:
|
|
566
|
+
${COLORS.underline}http://localhost:${CONSTANTS.FRONTEND_PORT_DEFAULT}${COLORS.reset}
|
|
567
|
+
|
|
568
|
+
${COLORS.yellow}Tip:${COLORS.reset} Check ${COLORS.cyan}.gitignore${COLORS.reset} to exclude node_modules and .env from version control.
|
|
569
|
+
`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/* ==========================================================================
|
|
573
|
+
PROCESS HANDLERS
|
|
574
|
+
========================================================================== */
|
|
575
|
+
|
|
576
|
+
process.on("SIGINT", async () => {
|
|
577
|
+
const targetDir = process.argv[2];
|
|
578
|
+
if (targetDir) await cleanupProject(path.resolve(process.cwd(), targetDir));
|
|
579
|
+
console.log(`\n${COLORS.red}[ABORTED]${COLORS.reset} Process cancelled by user.`);
|
|
580
|
+
process.exit(130);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
process.on("SIGTERM", async () => {
|
|
584
|
+
const targetDir = process.argv[2];
|
|
585
|
+
if (targetDir) await cleanupProject(path.resolve(process.cwd(), targetDir));
|
|
586
|
+
process.exit(143);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
process.on("unhandledRejection", (reason) => { logger.fatal("Unhandled Promise Rejection", reason); });
|
|
590
|
+
process.on("uncaughtException", (err) => { logger.fatal("Uncaught Exception", err); });
|
|
591
|
+
|
|
592
|
+
run().catch((err) => { logger.fatal("Unexpected fatal error.", err.message || err); });
|