4bnode 3.5.2 → 4.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.
Files changed (43) hide show
  1. package/README.md +139 -114
  2. package/add-api.js +288 -156
  3. package/add-crud.js +226 -0
  4. package/add-login.js +152 -319
  5. package/add-mongo-delete.js +67 -138
  6. package/add-mongo-insert.js +87 -204
  7. package/add-mongo-read.js +100 -216
  8. package/add-mongo-schema.js +431 -199
  9. package/add-mongo-update.js +89 -202
  10. package/add-mongo.js +125 -218
  11. package/add-serialport.js +53 -98
  12. package/add-socket.js +43 -66
  13. package/add-websocket.js +68 -140
  14. package/index.js +288 -155
  15. package/lib/env.js +18 -0
  16. package/lib/indexFile.js +87 -0
  17. package/lib/names.js +21 -0
  18. package/lib/project.js +122 -0
  19. package/lib/routeFile.js +56 -0
  20. package/lib/ui.js +161 -0
  21. package/package.json +9 -16
  22. package/skeleton/dev-api.js +2785 -0
  23. package/skeleton/index.js +10 -3
  24. package/skeleton/package.json +9 -5
  25. package/skeleton/public/fonts/NotoSans-Bold.woff2 +0 -0
  26. package/skeleton/public/fonts/NotoSans-Light.woff2 +0 -0
  27. package/skeleton/public/fonts/NotoSans-Medium.woff2 +0 -0
  28. package/skeleton/public/fonts/NotoSans-Regular.woff2 +0 -0
  29. package/skeleton/public/fonts/NotoSans-SemiBold.woff2 +0 -0
  30. package/skeleton/public/images/mongo.png +0 -0
  31. package/skeleton/public/images/mysql.png +0 -0
  32. package/skeleton/public/images/postgresql.png +0 -0
  33. package/skeleton/public/images/sqlite.png +0 -0
  34. package/skeleton/public/index.html +7562 -64
  35. package/README_4bnode.md +0 -197
  36. package/src/auth/passport.js +0 -21
  37. package/src/db.js +0 -13
  38. package/test-server/.env.development +0 -1
  39. package/test-server/.env.production +0 -1
  40. package/test-server/index.js +0 -82
  41. package/test-server/package-lock.json +0 -1267
  42. package/test-server/package.json +0 -20
  43. package/test-server/public/index.html +0 -90
@@ -0,0 +1,2785 @@
1
+ import express from 'express';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execSync, exec } from 'child_process';
5
+ import { promisify } from 'util';
6
+ const execAsync = promisify(exec);
7
+ import crypto from 'crypto';
8
+
9
+ const router = express.Router();
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Utility functions (self-contained, no external imports)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function toCamelCase(str) {
16
+ return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
17
+ }
18
+
19
+ function toPascalCase(str) {
20
+ const camel = toCamelCase(str);
21
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
22
+ }
23
+
24
+ function sanitizeName(input) {
25
+ if (!input || typeof input !== "string") {
26
+ throw new Error("Name is required.");
27
+ }
28
+ const trimmed = input.trim();
29
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(trimmed)) {
30
+ throw new Error(
31
+ `Invalid name "${trimmed}". Use only letters, numbers, hyphens, and underscores. Must start with a letter.`
32
+ );
33
+ }
34
+ return trimmed;
35
+ }
36
+
37
+ function listModels() {
38
+ const modelsDir = path.join(process.cwd(), "src", "models");
39
+ if (!fs.existsSync(modelsDir)) {
40
+ return [];
41
+ }
42
+ return fs.readdirSync(modelsDir).filter((f) => f.endsWith(".js"));
43
+ }
44
+
45
+ function listRoutes() {
46
+ const routesDir = path.join(process.cwd(), "src", "routes");
47
+ if (!fs.existsSync(routesDir)) {
48
+ return [];
49
+ }
50
+ return fs.readdirSync(routesDir).filter((f) => f.endsWith(".js"));
51
+ }
52
+
53
+ function checkMongoConfig() {
54
+ const envPath = path.join(process.cwd(), ".env.development");
55
+ return (
56
+ fs.existsSync(envPath) &&
57
+ fs.readFileSync(envPath, "utf8").includes("MONGO_URI")
58
+ );
59
+ }
60
+
61
+ function getEnvValue(filePath, key) {
62
+ if (!fs.existsSync(filePath)) return '';
63
+ const content = fs.readFileSync(filePath, 'utf8');
64
+ const match = content.match(new RegExp(`^${key}=(.*)`, 'm'));
65
+ return match ? match[1].trim() : '';
66
+ }
67
+
68
+ function extractSchemaBlock(content) {
69
+ const marker = "new mongoose.Schema(";
70
+ const idx = content.indexOf(marker);
71
+ if (idx === -1) return null;
72
+ // Find the opening { after the marker
73
+ const braceStart = content.indexOf("{", idx + marker.length);
74
+ if (braceStart === -1) return null;
75
+ // Match balanced braces to find the outer schema object
76
+ let depth = 0;
77
+ for (let i = braceStart; i < content.length; i++) {
78
+ if (content[i] === "{") depth++;
79
+ else if (content[i] === "}") {
80
+ depth--;
81
+ if (depth === 0) return content.slice(braceStart + 1, i);
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function getModelSchemaDetailed(modelName) {
88
+ const modelFilePath = path.join(
89
+ process.cwd(),
90
+ "src",
91
+ "models",
92
+ modelName + ".js"
93
+ );
94
+ if (!fs.existsSync(modelFilePath)) {
95
+ return [];
96
+ }
97
+
98
+ const content = fs.readFileSync(modelFilePath, "utf8");
99
+ const schemaContent = extractSchemaBlock(content);
100
+ if (!schemaContent) return [];
101
+ const fields = [];
102
+ // Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... }
103
+ const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g;
104
+ let fm;
105
+ while ((fm = fieldRegex.exec(schemaContent)) !== null) {
106
+ const name = fm[1];
107
+ const props = fm[2];
108
+ const typeMatch = props.match(/type:\s*([\w.]+)/);
109
+ const type = typeMatch
110
+ ? typeMatch[1].replace("mongoose.Schema.Types.", "")
111
+ : "String";
112
+ const required = /required:\s*true/.test(props);
113
+ const unique = /unique:\s*true/.test(props);
114
+ const refMatch = props.match(/ref:\s*['"](\w+)['"]/);
115
+ const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/);
116
+ const field = { name, type, required, unique };
117
+ if (refMatch) field.ref = refMatch[1];
118
+ if (defaultMatch) {
119
+ field.default = defaultMatch[1].trim();
120
+ }
121
+ fields.push(field);
122
+ }
123
+ return fields;
124
+ }
125
+
126
+ function getModelSchema(modelName) {
127
+ const modelFilePath = path.join(
128
+ process.cwd(),
129
+ "src",
130
+ "models",
131
+ modelName + ".js"
132
+ );
133
+ if (!fs.existsSync(modelFilePath)) {
134
+ return [];
135
+ }
136
+
137
+ const content = fs.readFileSync(modelFilePath, "utf8");
138
+ const schemaContent = extractSchemaBlock(content);
139
+ if (!schemaContent) return [];
140
+
141
+ const fieldRegex = /(\w+)\s*:\s*\{/g;
142
+ const fields = [];
143
+ let fieldMatch;
144
+ while ((fieldMatch = fieldRegex.exec(schemaContent)) !== null) {
145
+ fields.push(fieldMatch[1]);
146
+ }
147
+ return fields;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Route file manipulation functions
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function addImportToRoute(filePath, importStatement) {
155
+ let content = fs.readFileSync(filePath, "utf8");
156
+ if (!content.includes(importStatement.trim())) {
157
+ content = importStatement.trim() + "\n" + content;
158
+ fs.writeFileSync(filePath, content, "utf8");
159
+ }
160
+ return content;
161
+ }
162
+
163
+ function insertCodeIntoRoute(filePath, codeBlock) {
164
+ let content = fs.readFileSync(filePath, "utf8");
165
+
166
+ // Prefer inserting before "export default router;" as it's more reliable
167
+ const exportIndex = content.lastIndexOf("export default router;");
168
+ if (exportIndex !== -1) {
169
+ content =
170
+ content.slice(0, exportIndex) +
171
+ codeBlock.trim() +
172
+ "\n\n" +
173
+ content.slice(exportIndex);
174
+ } else {
175
+ // Fallback to inserting before last "});"
176
+ const lastClose = content.lastIndexOf("});");
177
+ if (lastClose !== -1) {
178
+ content =
179
+ content.slice(0, lastClose) +
180
+ codeBlock.trim() +
181
+ "\n" +
182
+ content.slice(lastClose);
183
+ } else {
184
+ content += "\n" + codeBlock.trim() + "\n";
185
+ }
186
+ }
187
+
188
+ fs.writeFileSync(filePath, content, "utf8");
189
+ }
190
+
191
+ function detectHttpMethod(filePath) {
192
+ const content = fs.readFileSync(filePath, "utf8");
193
+ const methodRegex =
194
+ /router\.(get|post|put|patch|delete)\((['"`])\/.*?\2,\s*async\s*\(req,\s*res/;
195
+ const match = content.match(methodRegex);
196
+ return match ? match[1].toLowerCase() : "post";
197
+ }
198
+
199
+ function getRequestDataSource(method) {
200
+ return method === "get" || method === "delete" ? "req.query" : "req.body";
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // CRUD code builders
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function buildInsertCode(pascalName, fields, dataSource) {
208
+ const assignments = fields
209
+ .map((f) => ` ${f}: ${dataSource}.${f}`)
210
+ .join(",\n");
211
+
212
+ let passwordLogic = "";
213
+ if (fields.includes("password")) {
214
+ passwordLogic = `
215
+ if (newData.password) {
216
+ const salt = await bcrypt.genSalt(10);
217
+ newData.password = await bcrypt.hash(newData.password, salt);
218
+ }`;
219
+ }
220
+
221
+ return `
222
+ router.post('/create', async (req, res) => {
223
+ const newData = {
224
+ ${assignments}
225
+ };
226
+ ${passwordLogic}
227
+ try {
228
+ const newDocument = new ${pascalName}(newData);
229
+ await newDocument.save();
230
+ res.json({ message: 'Data inserted successfully', data: newDocument });
231
+ } catch (err) {
232
+ console.error(err);
233
+ res.status(500).json({ message: 'Server error' });
234
+ }
235
+ });`;
236
+ }
237
+
238
+ function buildReadCode(pascalName) {
239
+ return `
240
+ router.get('/', async (req, res) => {
241
+ try {
242
+ const documents = await ${pascalName}.find();
243
+ res.json({ data: documents });
244
+ } catch (err) {
245
+ console.error(err);
246
+ res.status(500).json({ message: 'Server error' });
247
+ }
248
+ });
249
+
250
+ router.get('/:id', async (req, res) => {
251
+ try {
252
+ const document = await ${pascalName}.findById(req.params.id);
253
+ if (!document) {
254
+ return res.status(404).json({ message: 'Document not found' });
255
+ }
256
+ res.json({ data: document });
257
+ } catch (err) {
258
+ console.error(err);
259
+ res.status(500).json({ message: 'Server error' });
260
+ }
261
+ });`;
262
+ }
263
+
264
+ function buildUpdateCode(pascalName, fields, dataSource) {
265
+ const assignments = fields
266
+ .map((f) => ` ${f}: ${dataSource}.${f}`)
267
+ .join(",\n");
268
+
269
+ let passwordLogic = "";
270
+ if (fields.includes("password")) {
271
+ passwordLogic = `
272
+ if (updatedData.password) {
273
+ const salt = await bcrypt.genSalt(10);
274
+ updatedData.password = await bcrypt.hash(updatedData.password, salt);
275
+ }`;
276
+ }
277
+
278
+ return `
279
+ router.put('/:id', async (req, res) => {
280
+ const updatedData = {
281
+ ${assignments}
282
+ };
283
+ ${passwordLogic}
284
+ try {
285
+ const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { new: true });
286
+ if (!updatedDocument) {
287
+ return res.status(404).json({ message: 'Document not found' });
288
+ }
289
+ res.json({ message: 'Data updated successfully', data: updatedDocument });
290
+ } catch (err) {
291
+ console.error(err);
292
+ res.status(500).json({ message: 'Server error' });
293
+ }
294
+ });`;
295
+ }
296
+
297
+ function buildDeleteCode(pascalName) {
298
+ return `
299
+ router.delete('/:id', async (req, res) => {
300
+ try {
301
+ const deletedDocument = await ${pascalName}.findByIdAndDelete(req.params.id);
302
+ if (!deletedDocument) {
303
+ return res.status(404).json({ message: 'Document not found' });
304
+ }
305
+ res.json({ message: 'Data deleted successfully', data: deletedDocument });
306
+ } catch (err) {
307
+ console.error(err);
308
+ res.status(500).json({ message: 'Server error' });
309
+ }
310
+ });`;
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Schema file generation helper
315
+ // ---------------------------------------------------------------------------
316
+
317
+ function generateSchemaFileContent(pascalName, fields) {
318
+ const fieldLines = fields.map((f) => {
319
+ const type = f.type || 'String';
320
+ const parts = [type === 'ObjectId' ? 'type: mongoose.Schema.Types.ObjectId' : `type: ${type}`];
321
+ if (f.ref) parts.push(`ref: '${f.ref}'`);
322
+ if (f.required) parts.push('required: true');
323
+ if (f.unique) parts.push('unique: true');
324
+ if (f.default !== undefined && f.default !== '') parts.push(`default: ${f.default}`);
325
+ return ` ${f.name}: { ${parts.join(', ')} }`;
326
+ });
327
+
328
+ return `import mongoose from 'mongoose';
329
+
330
+ const ${pascalName}Schema = new mongoose.Schema({
331
+ ${fieldLines.join(',\n')}
332
+ }, { timestamps: true });
333
+
334
+ const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
335
+
336
+ export default ${pascalName};
337
+ `;
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // API Routes
342
+ // ---------------------------------------------------------------------------
343
+
344
+ // GET /api/dashboard
345
+ router.get('/api/dashboard', (req, res) => {
346
+ try {
347
+ const modelFiles = listModels();
348
+ const models = modelFiles.map((f) => {
349
+ const name = path.basename(f, '.js');
350
+ const fields = getModelSchemaDetailed(name);
351
+ return { name, fields };
352
+ });
353
+ const routes = listRoutes();
354
+ const mongoConfigured = checkMongoConfig();
355
+ const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
356
+
357
+ let dbType = null;
358
+ const indexPath = path.join(process.cwd(), 'index.js');
359
+ if (fs.existsSync(indexPath)) {
360
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
361
+ if (indexContent.includes('import connectDB') || indexContent.includes('mongoose')) {
362
+ dbType = 'mongodb';
363
+ }
364
+ }
365
+
366
+ const authConfigured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
367
+
368
+ let mongoUri = '', mongoProdUri = '';
369
+ if (mongoConfigured) {
370
+ const devEnv = path.join(process.cwd(), '.env.development');
371
+ const prodEnv = path.join(process.cwd(), '.env.production');
372
+ mongoUri = getEnvValue(devEnv, 'MONGO_URI');
373
+ mongoProdUri = getEnvValue(prodEnv, 'MONGO_URI');
374
+ }
375
+
376
+ res.json({ models, routes, mongoConfigured, dbType, routesDirExists, authConfigured, mongoUri, mongoProdUri });
377
+ } catch (err) {
378
+ res.status(500).json({ error: err.message });
379
+ }
380
+ });
381
+
382
+ // GET /api/models
383
+ router.get('/api/models', (req, res) => {
384
+ try {
385
+ const modelFiles = listModels();
386
+ const names = modelFiles.map((f) => path.basename(f, '.js'));
387
+ res.json(names);
388
+ } catch (err) {
389
+ res.status(500).json({ error: err.message });
390
+ }
391
+ });
392
+
393
+ // GET /api/models/:name
394
+ router.get('/api/models/:name', (req, res) => {
395
+ try {
396
+ const name = req.params.name;
397
+ const fields = getModelSchemaDetailed(name);
398
+ res.json({ name, fields });
399
+ } catch (err) {
400
+ res.status(500).json({ error: err.message });
401
+ }
402
+ });
403
+
404
+ // GET /api/models/:name/data — fetch stored documents from MongoDB collection
405
+ router.get('/api/models/:name/data', async (req, res) => {
406
+ try {
407
+ const name = req.params.name;
408
+ const limit = Math.min(parseInt(req.query.limit) || 50, 500);
409
+ const skip = parseInt(req.query.skip) || 0;
410
+ const pascalName = toPascalCase(name);
411
+
412
+ // Dynamically import mongoose from the project's node_modules
413
+ const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
414
+ if (!fs.existsSync(projectMongoose)) {
415
+ return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' });
416
+ }
417
+ const mongoose = (await import(projectMongoose)).default;
418
+
419
+ // Connect if not already connected
420
+ if (mongoose.connection.readyState === 0) {
421
+ const devEnv = path.join(process.cwd(), '.env.development');
422
+ const uri = getEnvValue(devEnv, 'MONGO_URI');
423
+ if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
424
+ await mongoose.connect(uri);
425
+ }
426
+
427
+ const collection = mongoose.connection.db.collection(name + 's');
428
+ const total = await collection.countDocuments();
429
+ const docs = await collection.find({}).skip(skip).limit(limit).toArray();
430
+
431
+ res.json({ docs, total, limit, skip });
432
+ } catch (err) {
433
+ res.status(500).json({ error: err.message });
434
+ }
435
+ });
436
+
437
+ // GET /api/models/:name/share — generate shareable endpoint documentation
438
+ router.get('/api/models/:name/share', (req, res) => {
439
+ try {
440
+ const name = req.params.name;
441
+ const fields = getModelSchemaDetailed(name);
442
+ const routes = listRoutes();
443
+ const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
444
+
445
+ let endpoints = [];
446
+ if (routeFile) {
447
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
448
+ const content = fs.readFileSync(filePath, 'utf8');
449
+ const regex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi;
450
+ let m;
451
+ while ((m = regex.exec(content)) !== null) {
452
+ endpoints.push({ method: m[1].toUpperCase(), path: m[2] });
453
+ }
454
+ }
455
+
456
+ // Determine the route prefix
457
+ const indexPath = path.join(process.cwd(), 'index.js');
458
+ let prefix = '/api/' + name;
459
+ if (fs.existsSync(indexPath)) {
460
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
461
+ const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${routeFile ? routeFile.replace('.js', '') : name}`));
462
+ if (prefixMatch) prefix = prefixMatch[1];
463
+ }
464
+
465
+ res.json({ name, fields, endpoints, prefix, routeFile: routeFile || null });
466
+ } catch (err) {
467
+ res.status(500).json({ error: err.message });
468
+ }
469
+ });
470
+
471
+ // POST /api/models
472
+ router.post('/api/models', (req, res) => {
473
+ try {
474
+ if (!checkMongoConfig()) {
475
+ return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' });
476
+ }
477
+
478
+ const { name, fields } = req.body;
479
+ const safeName = sanitizeName(name);
480
+ const pascalName = toPascalCase(safeName);
481
+
482
+ const modelsDir = path.join(process.cwd(), 'src', 'models');
483
+ if (!fs.existsSync(modelsDir)) {
484
+ fs.mkdirSync(modelsDir, { recursive: true });
485
+ }
486
+
487
+ const filePath = path.join(modelsDir, safeName + '.js');
488
+ if (fs.existsSync(filePath)) {
489
+ return res.status(409).json({ error: `Model "${safeName}" already exists.` });
490
+ }
491
+
492
+ const content = generateSchemaFileContent(pascalName, fields);
493
+ fs.writeFileSync(filePath, content, 'utf8');
494
+ res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
495
+ } catch (err) {
496
+ res.status(500).json({ error: err.message });
497
+ }
498
+ });
499
+
500
+ // PUT /api/models/:name
501
+ router.put('/api/models/:name', (req, res) => {
502
+ try {
503
+ const name = req.params.name;
504
+ const { fields } = req.body;
505
+ const pascalName = toPascalCase(name);
506
+
507
+ const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
508
+ if (!fs.existsSync(filePath)) {
509
+ return res.status(404).json({ error: `Model "${name}" not found.` });
510
+ }
511
+
512
+ const content = generateSchemaFileContent(pascalName, fields);
513
+ fs.writeFileSync(filePath, content, 'utf8');
514
+ res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
515
+ } catch (err) {
516
+ res.status(500).json({ error: err.message });
517
+ }
518
+ });
519
+
520
+ // DELETE /api/models/:name
521
+ router.delete('/api/models/:name', (req, res) => {
522
+ try {
523
+ const name = req.params.name;
524
+ const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
525
+ if (!fs.existsSync(filePath)) {
526
+ return res.status(404).json({ error: `Model "${name}" not found.` });
527
+ }
528
+
529
+ fs.unlinkSync(filePath);
530
+ res.json({ message: `Model "${name}" deleted.` });
531
+ } catch (err) {
532
+ res.status(500).json({ error: err.message });
533
+ }
534
+ });
535
+
536
+ // GET /api/routes
537
+ router.get('/api/routes', (req, res) => {
538
+ try {
539
+ const routes = listRoutes();
540
+ res.json(routes);
541
+ } catch (err) {
542
+ res.status(500).json({ error: err.message });
543
+ }
544
+ });
545
+
546
+ // GET /api/routes/:name/content
547
+ router.get('/api/routes/:name/content', (req, res) => {
548
+ try {
549
+ const name = req.params.name;
550
+ const filePath = path.join(process.cwd(), 'src', 'routes', name);
551
+ if (!fs.existsSync(filePath)) {
552
+ return res.status(404).json({ error: `Route file "${name}" not found.` });
553
+ }
554
+
555
+ const content = fs.readFileSync(filePath, 'utf8');
556
+ res.json({ name, content });
557
+ } catch (err) {
558
+ res.status(500).json({ error: err.message });
559
+ }
560
+ });
561
+
562
+ // DELETE /api/routes/:name
563
+ router.delete('/api/routes/:name', (req, res) => {
564
+ try {
565
+ const name = req.params.name;
566
+ const routeFile = name.endsWith('.js') ? name : name + '.js';
567
+ const safeName = routeFile.replace('.js', '');
568
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
569
+
570
+ if (!fs.existsSync(filePath)) {
571
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
572
+ }
573
+
574
+ // Delete the route file
575
+ fs.unlinkSync(filePath);
576
+
577
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
578
+ res.json({ message: `Route "${safeName}" deleted successfully.` });
579
+
580
+ // Remove import and registration from index.js
581
+ const indexPath = path.join(process.cwd(), 'index.js');
582
+ if (fs.existsSync(indexPath)) {
583
+ let indexContent = fs.readFileSync(indexPath, 'utf8');
584
+ // Remove import line
585
+ indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${safeName}\\.js['\"];?\\n?`, 'g'), '');
586
+ // Remove app.use registration line
587
+ indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${safeName}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
588
+ fs.writeFileSync(indexPath, indexContent, 'utf8');
589
+ }
590
+ } catch (err) {
591
+ if (!res.headersSent) res.status(500).json({ error: err.message });
592
+ }
593
+ });
594
+
595
+ // PUT /api/routes/:name/content — edit route source code
596
+ // ---------------------------------------------------------------------------
597
+ router.put('/api/routes/:name/content', (req, res) => {
598
+ try {
599
+ const name = req.params.name;
600
+ const { content } = req.body;
601
+ if (!content || typeof content !== 'string') {
602
+ return res.status(400).json({ error: 'Content is required.' });
603
+ }
604
+ const routeFile = name.endsWith('.js') ? name : name + '.js';
605
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
606
+ if (!fs.existsSync(filePath)) {
607
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
608
+ }
609
+ fs.writeFileSync(filePath, content, 'utf8');
610
+ res.json({ message: `Route "${routeFile}" updated successfully.` });
611
+ } catch (err) {
612
+ if (!res.headersSent) res.status(500).json({ error: err.message });
613
+ }
614
+ });
615
+
616
+ // ---------------------------------------------------------------------------
617
+ // POST /api/routes/:name/add-method — add handlers to existing route
618
+ // ---------------------------------------------------------------------------
619
+ router.post('/api/routes/:name/add-method', async (req, res) => {
620
+ try {
621
+ const name = req.params.name.replace(/\.js$/, '');
622
+ const routeFile = name + '.js';
623
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
624
+ if (!fs.existsSync(filePath)) {
625
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
626
+ }
627
+
628
+ const { routeType = 'methods', methods: methodList = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
629
+ const pascalModel = modelName ? toPascalCase(modelName) : '';
630
+ const hasPassword = !!passwordField;
631
+ const findField = loginField || 'email';
632
+
633
+ let content = fs.readFileSync(filePath, 'utf8');
634
+
635
+ // Check for duplicate methods
636
+ const existingMethods = [];
637
+ const existCheck = /router\.(get|post|put|patch|delete)\(/gi;
638
+ let em;
639
+ while ((em = existCheck.exec(content)) !== null) existingMethods.push(em[1].toLowerCase());
640
+
641
+ const newMethods = methodList.filter(m => !existingMethods.includes(m.toLowerCase()));
642
+
643
+ // Helper: add import if not present
644
+ function addImport(imp) {
645
+ if (!content.includes(imp.split(' from ')[0])) {
646
+ const lastImport = content.lastIndexOf('import ');
647
+ const lineEnd = content.indexOf('\n', lastImport);
648
+ content = content.slice(0, lineEnd + 1) + imp + '\n' + content.slice(lineEnd + 1);
649
+ }
650
+ }
651
+
652
+ // Helper: insert handler before export
653
+ function insertHandler(handler) {
654
+ const exportIdx = content.lastIndexOf('export default router;');
655
+ if (exportIdx !== -1) {
656
+ content = content.slice(0, exportIdx) + handler + '\n\n' + content.slice(exportIdx);
657
+ } else {
658
+ content += '\n' + handler;
659
+ }
660
+ }
661
+
662
+ let handlers = '';
663
+ const packagesToInstall = [];
664
+
665
+ if (routeType === 'methods') {
666
+ if (newMethods.length === 0) {
667
+ return res.status(400).json({ error: 'All selected methods already exist in this route.' });
668
+ }
669
+
670
+ const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
671
+ const fileFields = allFields.filter(f => f.type === 'File');
672
+ const bodyFields = allFields.filter(f => f.type !== 'File');
673
+ const hasFileFields = fileFields.length > 0;
674
+
675
+ if (hasFileFields) {
676
+ addImport("import multer from 'multer';");
677
+ if (!content.includes('const upload = multer')) {
678
+ content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
679
+ }
680
+ packagesToInstall.push('multer');
681
+ }
682
+
683
+ newMethods.forEach(m => {
684
+ const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
685
+ let mw = '';
686
+ let destruct = '';
687
+
688
+ if (hasFileFields && m !== 'get' && m !== 'delete') {
689
+ if (fileFields.length === 1) mw = `upload.single('${fileFields[0].name}'), `;
690
+ else mw = `upload.fields([${fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
691
+ }
692
+ if (bodyFields.length > 0) destruct += ` const { ${bodyFields.map(f => f.name).join(', ')} } = ${dataSource};\n`;
693
+ if (hasFileFields && m !== 'get' && m !== 'delete') {
694
+ if (fileFields.length === 1) destruct += ` const ${fileFields[0].name} = req.file;\n`;
695
+ else fileFields.forEach(f => { destruct += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`; });
696
+ }
697
+
698
+ if (allFields.length > 0) {
699
+ handlers += `\nrouter.${m}('/', ${mw}async (req, res) => {\n${destruct}\n // TODO: Add your logic here\n\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
700
+ } else {
701
+ handlers += `\nrouter.${m}('/', async (req, res) => {\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
702
+ }
703
+ });
704
+
705
+ } else if (routeType === 'crud' && modelName) {
706
+ addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
707
+ const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m));
708
+
709
+ if (crudMethods.includes('get')) {
710
+ handlers += `\n// Get all\nrouter.get('/', async (req, res) => {\n try {\n const items = await ${pascalModel}.find();\n res.json(items);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n\n// Get by ID\nrouter.get('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findById(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
711
+ }
712
+ if (crudMethods.includes('post')) {
713
+ handlers += `\n// Create\nrouter.post('/', async (req, res) => {\n try {\n const item = new ${pascalModel}(req.body);\n const saved = await item.save();\n res.status(201).json(saved);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
714
+ }
715
+ if (crudMethods.includes('put')) {
716
+ handlers += `\n// Update\nrouter.put('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
717
+ }
718
+ if (crudMethods.includes('patch')) {
719
+ handlers += `\n// Partial Update\nrouter.patch('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json(item);\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
720
+ }
721
+ if (crudMethods.includes('delete')) {
722
+ handlers += `\n// Delete\nrouter.delete('/:id', async (req, res) => {\n try {\n const item = await ${pascalModel}.findByIdAndDelete(req.params.id);\n if (!item) return res.status(404).json({ message: '${pascalModel} not found' });\n res.json({ message: '${pascalModel} deleted' });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
723
+ }
724
+
725
+ } else if (routeType === 'login' && modelName) {
726
+ addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
727
+ if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
728
+ addImport("import jwt from 'jsonwebtoken';");
729
+ packagesToInstall.push('jsonwebtoken');
730
+
731
+ if (hasPassword) {
732
+ handlers += `\n// Login\nrouter.post('/', async (req, res) => {\n try {\n const { ${findField}, ${passwordField} } = req.body;\n const ${modelName} = await ${pascalModel}.findOne({ ${findField} });\n if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });\n\n const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});\n if (!isMatch) return res.status(400).json({ message: 'Invalid credentials' });\n\n const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
733
+ } else {
734
+ handlers += `\n// Login\nrouter.post('/', async (req, res) => {\n try {\n const { ${findField} } = req.body;\n const ${modelName} = await ${pascalModel}.findOne({ ${findField} });\n if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });\n\n const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });\n } catch (err) {\n res.status(500).json({ message: err.message });\n }\n});\n`;
735
+ }
736
+
737
+ // Ensure JWT_SECRET
738
+ const devEnv = path.join(process.cwd(), '.env.development');
739
+ const prodEnv = path.join(process.cwd(), '.env.production');
740
+ if (fs.existsSync(devEnv) && !fs.readFileSync(devEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(devEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
741
+ if (fs.existsSync(prodEnv) && !fs.readFileSync(prodEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(prodEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
742
+
743
+ } else if (routeType === 'register' && modelName) {
744
+ addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
745
+ if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
746
+ addImport("import jwt from 'jsonwebtoken';");
747
+ packagesToInstall.push('jsonwebtoken');
748
+
749
+ const regFields = registerFields.length > 0 ? registerFields : [findField];
750
+ const allDestructured = regFields.join(', ');
751
+
752
+ if (hasPassword) {
753
+ const nonPwFields = regFields.filter(f => f !== passwordField);
754
+ handlers += `\n// Register\nrouter.post('/', async (req, res) => {\n try {\n const { ${allDestructured} } = req.body;\n const existing = await ${pascalModel}.findOne({ ${findField} });\n if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });\n\n const salt = await bcrypt.genSalt(10);\n const hashedPassword = await bcrypt.hash(${passwordField}, salt);\n const item = new ${pascalModel}({ ${nonPwFields.join(', ')}, ${passwordField}: hashedPassword });\n const saved = await item.save();\n\n const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
755
+ } else {
756
+ handlers += `\n// Register\nrouter.post('/', async (req, res) => {\n try {\n const { ${allDestructured} } = req.body;\n const existing = await ${pascalModel}.findOne({ ${findField} });\n if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });\n\n const item = new ${pascalModel}({ ${allDestructured} });\n const saved = await item.save();\n\n const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });\n res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });\n } catch (err) {\n res.status(400).json({ message: err.message });\n }\n});\n`;
757
+ }
758
+
759
+ const devEnv = path.join(process.cwd(), '.env.development');
760
+ const prodEnv = path.join(process.cwd(), '.env.production');
761
+ if (fs.existsSync(devEnv) && !fs.readFileSync(devEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(devEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
762
+ if (fs.existsSync(prodEnv) && !fs.readFileSync(prodEnv, 'utf8').includes('JWT_SECRET')) updateEnvFile(prodEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
763
+ }
764
+
765
+ if (!handlers) {
766
+ return res.status(400).json({ error: 'Nothing to add.' });
767
+ }
768
+
769
+ // Install packages
770
+ if (packagesToInstall.length > 0) {
771
+ try { await execAsync(`npm install ${[...new Set(packagesToInstall)].join(' ')}`, { cwd: process.cwd() }); } catch {}
772
+ }
773
+
774
+ insertHandler(handlers);
775
+ fs.writeFileSync(filePath, content, 'utf8');
776
+
777
+ const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase();
778
+ res.json({ message: `${label} added to "${name}" route.`, content });
779
+ } catch (err) {
780
+ if (!res.headersSent) res.status(500).json({ error: err.message });
781
+ }
782
+ });
783
+
784
+ // GET /api/routes/:name/share — generate shareable endpoint docs with field metadata
785
+ router.get('/api/routes/:name/share', (req, res) => {
786
+ try {
787
+ const name = req.params.name.replace(/\.js$/, '');
788
+ const routeFile = name + '.js';
789
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
790
+ if (!fs.existsSync(filePath)) {
791
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
792
+ }
793
+ const content = fs.readFileSync(filePath, 'utf8');
794
+
795
+ // Determine the route prefix from index.js
796
+ const indexPath = path.join(process.cwd(), 'index.js');
797
+ let prefix = '/api/' + name;
798
+ if (fs.existsSync(indexPath)) {
799
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
800
+ const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
801
+ if (prefixMatch) prefix = prefixMatch[1];
802
+ }
803
+
804
+ // Check if route imports a model
805
+ const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
806
+ let modelFields = [];
807
+ if (modelImportMatch) {
808
+ modelFields = getModelSchemaDetailed(modelImportMatch[2]);
809
+ }
810
+
811
+ // Parse each endpoint with body/query fields
812
+ const endpoints = [];
813
+ const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*(?:auth\s*,\s*)?(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>\s*\{([\s\S]*?)(?=\nrouter\.|$)/gi;
814
+ let m;
815
+ while ((m = epRegex.exec(content)) !== null) {
816
+ const method = m[1].toUpperCase();
817
+ const epath = m[2];
818
+ const body = m[3] || '';
819
+
820
+ const bodyFields = [];
821
+ const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
822
+ if (bodyMatch) {
823
+ bodyMatch[1].split(',').forEach(f => {
824
+ const fname = f.trim();
825
+ if (fname) {
826
+ const meta = modelFields.find(mf => mf.name === fname);
827
+ bodyFields.push({
828
+ name: fname,
829
+ type: meta?.type || 'String',
830
+ required: meta?.required || false,
831
+ unique: meta?.unique || false,
832
+ });
833
+ }
834
+ });
835
+ }
836
+
837
+ const queryFields = [];
838
+ const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
839
+ if (queryMatch) {
840
+ queryMatch[1].split(',').forEach(f => {
841
+ const fname = f.trim();
842
+ if (fname) queryFields.push({ name: fname, type: 'String' });
843
+ });
844
+ }
845
+
846
+ // Check for params
847
+ const params = [];
848
+ const paramMatches = epath.match(/:(\w+)/g);
849
+ if (paramMatches) {
850
+ paramMatches.forEach(p => params.push(p.replace(':', '')));
851
+ }
852
+
853
+ endpoints.push({ method, path: epath, fullPath: prefix + (epath === '/' ? '' : epath), bodyFields, queryFields, params });
854
+ }
855
+
856
+ res.json({
857
+ name,
858
+ prefix,
859
+ routeFile,
860
+ modelName: modelImportMatch ? modelImportMatch[2] : null,
861
+ modelFields,
862
+ endpoints,
863
+ });
864
+ } catch (err) {
865
+ res.status(500).json({ error: err.message });
866
+ }
867
+ });
868
+
869
+ // ---------------------------------------------------------------------------
870
+ // POST /api/crud/preview
871
+ router.post('/api/crud/preview', (req, res) => {
872
+ try {
873
+ const { modelName, routeFile, operations } = req.body;
874
+ const pascalName = toPascalCase(modelName);
875
+ const fields = getModelSchema(modelName);
876
+ const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
877
+
878
+ let dataSource = 'req.body';
879
+ if (fs.existsSync(routeFilePath)) {
880
+ const method = detectHttpMethod(routeFilePath);
881
+ dataSource = getRequestDataSource(method);
882
+ }
883
+
884
+ const needsBcrypt = fields.includes('password') &&
885
+ (operations.includes('create') || operations.includes('update'));
886
+
887
+ let code = '';
888
+
889
+ // Import block
890
+ let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
891
+ if (needsBcrypt) {
892
+ importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
893
+ }
894
+ code += importBlock + '\n';
895
+
896
+ if (operations.includes('create')) {
897
+ code += buildInsertCode(pascalName, fields, dataSource) + '\n';
898
+ }
899
+ if (operations.includes('read')) {
900
+ code += buildReadCode(pascalName) + '\n';
901
+ }
902
+ if (operations.includes('update')) {
903
+ code += buildUpdateCode(pascalName, fields, dataSource) + '\n';
904
+ }
905
+ if (operations.includes('delete')) {
906
+ code += buildDeleteCode(pascalName) + '\n';
907
+ }
908
+
909
+ res.json({ code });
910
+ } catch (err) {
911
+ res.status(500).json({ error: err.message });
912
+ }
913
+ });
914
+
915
+ // POST /api/crud/generate
916
+ router.post('/api/crud/generate', (req, res) => {
917
+ try {
918
+ const { modelName, routeFile, operations } = req.body;
919
+ const pascalName = toPascalCase(modelName);
920
+ const fields = getModelSchema(modelName);
921
+ const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
922
+
923
+ if (!fs.existsSync(routeFilePath)) {
924
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
925
+ }
926
+
927
+ if (fields.length === 0) {
928
+ return res.status(400).json({ error: 'No fields found in the selected model.' });
929
+ }
930
+
931
+ const method = detectHttpMethod(routeFilePath);
932
+ const dataSource = getRequestDataSource(method);
933
+ const needsBcrypt = fields.includes('password') &&
934
+ (operations.includes('create') || operations.includes('update'));
935
+
936
+ // Build import
937
+ let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
938
+ if (needsBcrypt) {
939
+ importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
940
+ }
941
+ addImportToRoute(routeFilePath, importBlock);
942
+
943
+ // Add selected operations
944
+ const added = [];
945
+
946
+ if (operations.includes('create')) {
947
+ insertCodeIntoRoute(routeFilePath, buildInsertCode(pascalName, fields, dataSource));
948
+ added.push('POST /create');
949
+ }
950
+
951
+ if (operations.includes('read')) {
952
+ insertCodeIntoRoute(routeFilePath, buildReadCode(pascalName));
953
+ added.push('GET / and GET /:id');
954
+ }
955
+
956
+ if (operations.includes('update')) {
957
+ insertCodeIntoRoute(routeFilePath, buildUpdateCode(pascalName, fields, dataSource));
958
+ added.push('PUT /:id');
959
+ }
960
+
961
+ if (operations.includes('delete')) {
962
+ insertCodeIntoRoute(routeFilePath, buildDeleteCode(pascalName));
963
+ added.push('DELETE /:id');
964
+ }
965
+
966
+ res.json({
967
+ message: `CRUD operations added to ${routeFile}`,
968
+ model: modelName,
969
+ routes: added,
970
+ });
971
+ } catch (err) {
972
+ res.status(500).json({ error: err.message });
973
+ }
974
+ });
975
+
976
+ // GET /api/env
977
+ router.get('/api/env', (req, res) => {
978
+ try {
979
+ const devEnv = path.join(process.cwd(), '.env.development');
980
+ const prodEnv = path.join(process.cwd(), '.env.production');
981
+ const devContent = fs.existsSync(devEnv) ? fs.readFileSync(devEnv, 'utf8') : '';
982
+ const prodContent = fs.existsSync(prodEnv) ? fs.readFileSync(prodEnv, 'utf8') : '';
983
+ res.json({ development: devContent, production: prodContent });
984
+ } catch (err) {
985
+ res.status(500).json({ error: err.message });
986
+ }
987
+ });
988
+
989
+ // PUT /api/env
990
+ router.put('/api/env', (req, res) => {
991
+ try {
992
+ const { environment, content } = req.body;
993
+ if (!environment || !['development', 'production'].includes(environment)) {
994
+ return res.status(400).json({ error: 'Invalid environment. Must be "development" or "production".' });
995
+ }
996
+ const envPath = path.join(process.cwd(), `.env.${environment}`);
997
+ fs.writeFileSync(envPath, content, 'utf8');
998
+ res.json({ message: `Environment file (.env.${environment}) updated.` });
999
+ } catch (err) {
1000
+ res.status(500).json({ error: err.message });
1001
+ }
1002
+ });
1003
+
1004
+ // ---------------------------------------------------------------------------
1005
+ // Index.js manipulation helpers
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ function readIndexFile() {
1009
+ return fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf8');
1010
+ }
1011
+
1012
+ function writeIndexFile(content) {
1013
+ fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8');
1014
+ }
1015
+
1016
+ function addImportToIndex(importLine) {
1017
+ let content = readIndexFile();
1018
+ if (content.includes(importLine.trim())) return;
1019
+ const lines = content.split('\n');
1020
+ let lastImportIdx = -1;
1021
+ lines.forEach((line, i) => {
1022
+ if (line.startsWith('import ')) lastImportIdx = i;
1023
+ });
1024
+ lines.splice(lastImportIdx + 1, 0, importLine.trim());
1025
+ writeIndexFile(lines.join('\n'));
1026
+ }
1027
+
1028
+ function insertBeforeListen(codeBlock) {
1029
+ let content = readIndexFile();
1030
+ const listenRegex = /(app|server)\.listen\(port/m;
1031
+ const match = content.match(listenRegex);
1032
+ if (match) {
1033
+ content = content.slice(0, match.index) + codeBlock.trim() + '\n\n' + content.slice(match.index);
1034
+ }
1035
+ writeIndexFile(content);
1036
+ }
1037
+
1038
+ function replaceAppListen(newBlock) {
1039
+ let content = readIndexFile();
1040
+ content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, newBlock.trim() + '\n');
1041
+ writeIndexFile(content);
1042
+ }
1043
+
1044
+ function removeSocketIOCode() {
1045
+ let content = readIndexFile();
1046
+ // Remove Socket.IO import
1047
+ content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
1048
+ // Remove http import only if WebSocket doesn't need it
1049
+ if (!content.includes('WebSocketServer({ server')) {
1050
+ content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
1051
+ }
1052
+ // Remove server = http.createServer(app) only if WebSocket doesn't need it
1053
+ if (!content.includes('WebSocketServer({ server')) {
1054
+ content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
1055
+ }
1056
+ // Remove io setup block (use \n}); to match outer closing brace at column 0)
1057
+ content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
1058
+ // Remove io.on("connection"...) block (nested callbacks — match \n}); at column 0)
1059
+ content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
1060
+ // Remove req.io middleware
1061
+ content = content.replace(/app\.use\(\(req,\s*res,\s*next\)\s*=>\s*\{\s*\n?\s*req\.io\s*=\s*io;\s*\n?\s*next\(\);\s*\n?\s*\}\);\s*\n?/g, '');
1062
+ // Replace server.listen back to app.listen if no WebSocket uses server
1063
+ if (!content.includes('WebSocketServer({ server') && content.includes('server.listen(port')) {
1064
+ content = content.replace(/server\.listen\(port[\s\S]*?\n\}\);?\s*\n?/m, `app.listen(port, () => {
1065
+ console.log(\`Server is running on port \${port}\`);
1066
+ });\n`);
1067
+ }
1068
+ // Clean up multiple blank lines
1069
+ content = content.replace(/\n{3,}/g, '\n\n');
1070
+ writeIndexFile(content);
1071
+ }
1072
+
1073
+ function removeWebSocketCode() {
1074
+ let content = readIndexFile();
1075
+ // Remove ws import
1076
+ content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
1077
+ // Remove WebSocketServer setup (both same-port and separate-port)
1078
+ content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
1079
+ // Remove wss.on('connection'...) block (nested callbacks — match \n}); at column 0)
1080
+ content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
1081
+ // Remove console.log for WS port
1082
+ content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
1083
+ // Remove http import only if Socket.IO doesn't need it
1084
+ if (!content.includes('new Server(server')) {
1085
+ content = content.replace(/import\s+http\s+from\s*['"]http['"];\s*\n?/g, '');
1086
+ }
1087
+ // Remove server = http.createServer(app) only if Socket.IO doesn't need it
1088
+ if (!content.includes('new Server(server')) {
1089
+ content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
1090
+ // Replace server.listen back to app.listen
1091
+ if (content.includes('server.listen(port')) {
1092
+ content = content.replace(/server\.listen\(port[\s\S]*?\n\}\);?\s*\n?/m, `app.listen(port, () => {
1093
+ console.log(\`Server is running on port \${port}\`);
1094
+ });\n`);
1095
+ }
1096
+ }
1097
+ // Clean up multiple blank lines
1098
+ content = content.replace(/\n{3,}/g, '\n\n');
1099
+ writeIndexFile(content);
1100
+ }
1101
+
1102
+ function updateEnvFile(filePath, key, value) {
1103
+ if (fs.existsSync(filePath)) {
1104
+ let content = fs.readFileSync(filePath, 'utf8');
1105
+ const regex = new RegExp(`^${key}=.*`, 'm');
1106
+ if (regex.test(content)) {
1107
+ content = content.replace(regex, `${key}=${value}`);
1108
+ } else {
1109
+ content = content.trimEnd() + '\n' + `${key}=${value}\n`;
1110
+ }
1111
+ fs.writeFileSync(filePath, content, 'utf8');
1112
+ } else {
1113
+ fs.writeFileSync(filePath, `${key}=${value}\n`, 'utf8');
1114
+ }
1115
+ }
1116
+
1117
+ // ---------------------------------------------------------------------------
1118
+ // GET /api/status
1119
+ // ---------------------------------------------------------------------------
1120
+ router.get('/api/status', (req, res) => {
1121
+ try {
1122
+ const indexPath = path.join(process.cwd(), 'index.js');
1123
+ const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
1124
+
1125
+ const mongoConfigured = indexContent.includes("import connectDB") || indexContent.includes("connectDB");
1126
+ const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
1127
+ const authMiddlewareExists = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
1128
+ const socketConfigured = indexContent.includes('socket.io') || indexContent.includes('Server(server');
1129
+ const websocketConfigured = indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
1130
+ const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
1131
+
1132
+ res.json({
1133
+ mongoConfigured,
1134
+ routesDirExists,
1135
+ authMiddlewareExists,
1136
+ socketConfigured,
1137
+ websocketConfigured,
1138
+ serialportConfigured
1139
+ });
1140
+ } catch (err) {
1141
+ res.status(500).json({ error: err.message });
1142
+ }
1143
+ });
1144
+
1145
+ // ---------------------------------------------------------------------------
1146
+ // POST /api/add-api
1147
+ // ---------------------------------------------------------------------------
1148
+ router.post('/api/add-api', async (req, res) => {
1149
+ try {
1150
+ const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
1151
+ // Legacy support: single method field
1152
+ const method = req.body.method;
1153
+
1154
+ const safeName = sanitizeName(name);
1155
+ const camelName = toCamelCase(safeName);
1156
+ const pascalModel = modelName ? toPascalCase(modelName) : '';
1157
+ const type = routeType || (method ? 'methods' : 'plain');
1158
+ const methodList = method ? [method] : methods;
1159
+ const hasPassword = !!passwordField;
1160
+
1161
+ const routesDir = path.join(process.cwd(), 'src', 'routes');
1162
+ if (!fs.existsSync(routesDir)) {
1163
+ fs.mkdirSync(routesDir, { recursive: true });
1164
+ }
1165
+
1166
+ const routeFilePath = path.join(routesDir, safeName + '.js');
1167
+
1168
+ // Install packages if needed
1169
+ const packagesToInstall = [];
1170
+ if (type === 'login' || type === 'register') {
1171
+ if (hasPassword) packagesToInstall.push('bcrypt');
1172
+ packagesToInstall.push('jsonwebtoken');
1173
+ }
1174
+ const allFieldsCombined = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
1175
+ const needsMulter = allFieldsCombined.some(f => f.type === 'File');
1176
+ if (needsMulter) packagesToInstall.push('multer');
1177
+ if (packagesToInstall.length > 0) {
1178
+ await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
1179
+ }
1180
+
1181
+ // Build imports
1182
+ let imports = "import express from 'express';\n";
1183
+ if ((type === 'login' || type === 'register') && hasPassword) {
1184
+ imports += "import bcrypt from 'bcrypt';\n";
1185
+ }
1186
+ if (type === 'login' || type === 'register') {
1187
+ imports += "import jwt from 'jsonwebtoken';\n";
1188
+ }
1189
+ if ((type === 'login' || type === 'register' || type === 'crud') && modelName) {
1190
+ imports += `import ${pascalModel} from '../models/${modelName}.js';\n`;
1191
+ } else if (type === 'methods' && modelName && selectedFields.length > 0) {
1192
+ imports += `import ${pascalModel} from '../models/${modelName}.js';\n`;
1193
+ }
1194
+
1195
+ // Build handlers
1196
+ let handlers = '';
1197
+ const findField = loginField || 'email';
1198
+
1199
+ if (type === 'plain') {
1200
+ handlers += `
1201
+ // Add your route handlers here
1202
+ // Example: router.get('/', (req, res) => res.json({ message: '${safeName} works' }));
1203
+ `;
1204
+ } else if (type === 'methods') {
1205
+ // Combine selected model fields + custom fields
1206
+ const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
1207
+ const hasFileFields = allFields.some(f => f.type === 'File');
1208
+ const isMultipart = hasFileFields;
1209
+ const bodyFields = allFields.filter(f => f.type !== 'File');
1210
+ const fileFields = allFields.filter(f => f.type === 'File');
1211
+
1212
+ if (isMultipart) {
1213
+ imports += "import multer from 'multer';\n";
1214
+ }
1215
+
1216
+ methodList.forEach(m => {
1217
+ const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
1218
+ let middlewareStr = '';
1219
+ let destructLines = '';
1220
+
1221
+ if (isMultipart && m !== 'get' && m !== 'delete') {
1222
+ if (fileFields.length === 0) {
1223
+ middlewareStr = 'upload.none(), ';
1224
+ } else if (fileFields.length === 1) {
1225
+ middlewareStr = `upload.single('${fileFields[0].name}'), `;
1226
+ } else {
1227
+ const fieldsArg = fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ');
1228
+ middlewareStr = `upload.fields([${fieldsArg}]), `;
1229
+ }
1230
+ }
1231
+
1232
+ if (bodyFields.length > 0) {
1233
+ const fieldNames = bodyFields.map(f => f.name).join(', ');
1234
+ destructLines += ` const { ${fieldNames} } = ${dataSource};\n`;
1235
+ }
1236
+ if (fileFields.length > 0 && m !== 'get' && m !== 'delete') {
1237
+ if (fileFields.length === 1) {
1238
+ destructLines += ` const ${fileFields[0].name} = req.file;\n`;
1239
+ } else {
1240
+ fileFields.forEach(f => {
1241
+ destructLines += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`;
1242
+ });
1243
+ }
1244
+ }
1245
+
1246
+ if (allFields.length > 0) {
1247
+ handlers += `
1248
+ router.${m}('/', ${middlewareStr}async (req, res) => {
1249
+ ${destructLines}
1250
+ // TODO: Add your logic here
1251
+
1252
+ res.json({ msg: '${safeName} ${m.toUpperCase()} endpoint works' });
1253
+ });
1254
+ `;
1255
+ } else {
1256
+ handlers += `
1257
+ router.${m}('/', async (req, res) => {
1258
+ res.json({ msg: '${safeName} ${m.toUpperCase()} endpoint works' });
1259
+ });
1260
+ `;
1261
+ }
1262
+ });
1263
+
1264
+ if (isMultipart) {
1265
+ // Add multer setup after router declaration
1266
+ handlers = `\nconst upload = multer({ dest: 'uploads/' });\n` + handlers;
1267
+ }
1268
+ } else if (type === 'crud' && modelName) {
1269
+ const crudMethods = methodList.length > 0 ? methodList : ['get', 'post', 'put', 'delete'];
1270
+
1271
+ if (crudMethods.includes('get')) {
1272
+ handlers += `
1273
+ // Get all
1274
+ router.get('/', async (req, res) => {
1275
+ try {
1276
+ const items = await ${pascalModel}.find();
1277
+ res.json(items);
1278
+ } catch (err) {
1279
+ res.status(500).json({ message: err.message });
1280
+ }
1281
+ });
1282
+
1283
+ // Get by ID
1284
+ router.get('/:id', async (req, res) => {
1285
+ try {
1286
+ const item = await ${pascalModel}.findById(req.params.id);
1287
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1288
+ res.json(item);
1289
+ } catch (err) {
1290
+ res.status(500).json({ message: err.message });
1291
+ }
1292
+ });
1293
+ `;
1294
+ }
1295
+
1296
+ if (crudMethods.includes('post')) {
1297
+ handlers += `
1298
+ // Create
1299
+ router.post('/', async (req, res) => {
1300
+ try {
1301
+ const item = new ${pascalModel}(req.body);
1302
+ const saved = await item.save();
1303
+ res.status(201).json(saved);
1304
+ } catch (err) {
1305
+ res.status(400).json({ message: err.message });
1306
+ }
1307
+ });
1308
+ `;
1309
+ }
1310
+
1311
+ if (crudMethods.includes('put')) {
1312
+ handlers += `
1313
+ // Update
1314
+ router.put('/:id', async (req, res) => {
1315
+ try {
1316
+ const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
1317
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1318
+ res.json(item);
1319
+ } catch (err) {
1320
+ res.status(400).json({ message: err.message });
1321
+ }
1322
+ });
1323
+ `;
1324
+ }
1325
+
1326
+ if (crudMethods.includes('patch')) {
1327
+ handlers += `
1328
+ // Partial Update
1329
+ router.patch('/:id', async (req, res) => {
1330
+ try {
1331
+ const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
1332
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1333
+ res.json(item);
1334
+ } catch (err) {
1335
+ res.status(400).json({ message: err.message });
1336
+ }
1337
+ });
1338
+ `;
1339
+ }
1340
+
1341
+ if (crudMethods.includes('delete')) {
1342
+ handlers += `
1343
+ // Delete
1344
+ router.delete('/:id', async (req, res) => {
1345
+ try {
1346
+ const item = await ${pascalModel}.findByIdAndDelete(req.params.id);
1347
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1348
+ res.json({ message: '${pascalModel} deleted' });
1349
+ } catch (err) {
1350
+ res.status(500).json({ message: err.message });
1351
+ }
1352
+ });
1353
+ `;
1354
+ }
1355
+ } else if (type === 'register' && modelName) {
1356
+ const regFields = registerFields.length > 0 ? registerFields : [findField];
1357
+ const allDestructured = regFields.join(', ');
1358
+ const pwFieldName = hasPassword ? passwordField : null;
1359
+ const nonPwFields = regFields.filter(f => f !== pwFieldName);
1360
+
1361
+ if (hasPassword) {
1362
+ const assignFields = nonPwFields.map(f => f).join(', ');
1363
+ handlers += `
1364
+ // Register
1365
+ router.post('/', async (req, res) => {
1366
+ try {
1367
+ const { ${allDestructured} } = req.body;
1368
+ const existing = await ${pascalModel}.findOne({ ${findField} });
1369
+ if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });
1370
+
1371
+ const salt = await bcrypt.genSalt(10);
1372
+ const hashedPassword = await bcrypt.hash(${pwFieldName}, salt);
1373
+ const item = new ${pascalModel}({ ${assignFields}, ${pwFieldName}: hashedPassword });
1374
+ const saved = await item.save();
1375
+
1376
+ const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1377
+ res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });
1378
+ } catch (err) {
1379
+ res.status(400).json({ message: err.message });
1380
+ }
1381
+ });
1382
+ `;
1383
+ } else {
1384
+ handlers += `
1385
+ // Register
1386
+ router.post('/', async (req, res) => {
1387
+ try {
1388
+ const { ${allDestructured} } = req.body;
1389
+ const existing = await ${pascalModel}.findOne({ ${findField} });
1390
+ if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });
1391
+
1392
+ const item = new ${pascalModel}({ ${allDestructured} });
1393
+ const saved = await item.save();
1394
+
1395
+ const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1396
+ res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });
1397
+ } catch (err) {
1398
+ res.status(400).json({ message: err.message });
1399
+ }
1400
+ });
1401
+ `;
1402
+ }
1403
+ } else if (type === 'login' && modelName) {
1404
+ if (hasPassword) {
1405
+ handlers += `
1406
+ // Login
1407
+ router.post('/', async (req, res) => {
1408
+ try {
1409
+ const { ${findField}, ${passwordField} } = req.body;
1410
+ const ${modelName} = await ${pascalModel}.findOne({ ${findField} });
1411
+ if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });
1412
+
1413
+ const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});
1414
+ if (!isMatch) return res.status(400).json({ message: 'Invalid credentials' });
1415
+
1416
+ const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1417
+ res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });
1418
+ } catch (err) {
1419
+ res.status(500).json({ message: err.message });
1420
+ }
1421
+ });
1422
+ `;
1423
+ } else {
1424
+ handlers += `
1425
+ // Login
1426
+ router.post('/', async (req, res) => {
1427
+ try {
1428
+ const { ${findField} } = req.body;
1429
+ const ${modelName} = await ${pascalModel}.findOne({ ${findField} });
1430
+ if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });
1431
+
1432
+ const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1433
+ res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });
1434
+ } catch (err) {
1435
+ res.status(500).json({ message: err.message });
1436
+ }
1437
+ });
1438
+ `;
1439
+ }
1440
+ }
1441
+
1442
+ const content = `${imports}
1443
+ const router = express.Router();
1444
+ ${handlers}
1445
+ export default router;
1446
+ `;
1447
+
1448
+ fs.writeFileSync(routeFilePath, content, 'utf8');
1449
+
1450
+ const label = type === 'methods' ? methodList.map(m => m.toUpperCase()).join(', ') : type === 'crud' ? 'CRUD (' + (methodList.length > 0 ? methodList.map(m => m.toUpperCase()).join(', ') : 'ALL') + ')' : type === 'plain' ? 'PLAIN' : type.toUpperCase();
1451
+
1452
+ // Ensure JWT_SECRET exists in env files for login/register routes
1453
+ if (type === 'login' || type === 'register') {
1454
+ const devEnv = path.join(process.cwd(), '.env.development');
1455
+ const prodEnv = path.join(process.cwd(), '.env.production');
1456
+ const devContent = fs.existsSync(devEnv) ? fs.readFileSync(devEnv, 'utf8') : '';
1457
+ const prodContent = fs.existsSync(prodEnv) ? fs.readFileSync(prodEnv, 'utf8') : '';
1458
+ if (!devContent.includes('JWT_SECRET')) {
1459
+ updateEnvFile(devEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
1460
+ }
1461
+ if (!prodContent.includes('JWT_SECRET')) {
1462
+ updateEnvFile(prodEnv, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
1463
+ }
1464
+ }
1465
+
1466
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
1467
+ res.json({
1468
+ message: `Route "${safeName}" created with ${label} endpoints.`,
1469
+ file: `src/routes/${safeName}.js`
1470
+ });
1471
+
1472
+ // Build clean path: ensure prefix ends with / and no double slashes
1473
+ let prefix = pathPrefix || '/api/';
1474
+ if (!prefix.endsWith('/')) prefix += '/';
1475
+ const fullPath = (prefix + safeName).replace(/\/+/g, '/');
1476
+
1477
+ // Update index.js (triggers nodemon restart)
1478
+ const importLine = `import ${camelName}Router from './src/routes/${safeName}.js';`;
1479
+ addImportToIndex(importLine);
1480
+
1481
+ const routeRegistration = `app.use('${fullPath}', ${camelName}Router);`;
1482
+ let indexContent = readIndexFile();
1483
+ if (!indexContent.includes(routeRegistration)) {
1484
+ insertBeforeListen(routeRegistration);
1485
+ }
1486
+ } catch (err) {
1487
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1488
+ }
1489
+ });
1490
+
1491
+ // ---------------------------------------------------------------------------
1492
+ // POST /api/add-mongo
1493
+ // ---------------------------------------------------------------------------
1494
+ router.post('/api/add-mongo', async (req, res) => {
1495
+ try {
1496
+ const uri = (req.body.uri || '').trim();
1497
+ const prodUri = (req.body.prodUri || '').trim();
1498
+ const alreadyConfigured = checkMongoConfig();
1499
+
1500
+ if (!uri) {
1501
+ return res.status(400).json({ error: 'MongoDB URI is required' });
1502
+ }
1503
+
1504
+ // Update env files
1505
+ const devEnv = path.join(process.cwd(), '.env.development');
1506
+ const prodEnv = path.join(process.cwd(), '.env.production');
1507
+ updateEnvFile(devEnv, 'MONGO_URI', uri);
1508
+ updateEnvFile(prodEnv, 'MONGO_URI', prodUri || uri);
1509
+
1510
+ if (alreadyConfigured) {
1511
+ // Edit mode — just update env files, no need to reinstall or modify index.js
1512
+ res.json({
1513
+ message: 'MongoDB connection updated. Restart the server to apply changes.',
1514
+ files: ['.env.development', '.env.production']
1515
+ });
1516
+ return;
1517
+ }
1518
+
1519
+ // First-time setup: install mongoose, create db.js, modify index.js
1520
+ await execAsync('npm install mongoose', { cwd: process.cwd() });
1521
+
1522
+ const dbDir = path.join(process.cwd(), 'src');
1523
+ if (!fs.existsSync(dbDir)) {
1524
+ fs.mkdirSync(dbDir, { recursive: true });
1525
+ }
1526
+
1527
+ const dbContent = `import mongoose from 'mongoose';
1528
+
1529
+ const connectDB = async () => {
1530
+ try {
1531
+ const conn = await mongoose.connect(process.env.MONGO_URI);
1532
+ console.log(\`MongoDB connected: \${conn.connection.host}\`);
1533
+ } catch (err) {
1534
+ console.error('MongoDB connection error:', err.message);
1535
+ console.error('Check your MONGO_URI in .env.development and ensure MongoDB is running.');
1536
+ }
1537
+ };
1538
+
1539
+ export default connectDB;
1540
+ `;
1541
+ fs.writeFileSync(path.join(dbDir, 'db.js'), dbContent, 'utf8');
1542
+
1543
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
1544
+ res.json({
1545
+ message: 'MongoDB setup complete.',
1546
+ files: ['src/db.js', '.env.development', '.env.production', 'index.js']
1547
+ });
1548
+
1549
+ // Modify index.js LAST (triggers nodemon restart)
1550
+ addImportToIndex("import connectDB from './src/db.js';");
1551
+ let indexContent = readIndexFile();
1552
+ if (!indexContent.includes('connectDB()')) {
1553
+ const appExpress = "const app = express();";
1554
+ const idx = indexContent.indexOf(appExpress);
1555
+ if (idx !== -1) {
1556
+ indexContent = indexContent.slice(0, idx) + 'connectDB();\n' + indexContent.slice(idx);
1557
+ writeIndexFile(indexContent);
1558
+ }
1559
+ }
1560
+ } catch (err) {
1561
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1562
+ }
1563
+ });
1564
+
1565
+ // ---------------------------------------------------------------------------
1566
+ // POST /api/add-login
1567
+ // ---------------------------------------------------------------------------
1568
+ router.post('/api/add-login', async (req, res) => {
1569
+ try {
1570
+ const { routeFile, modelName, fields, tokenExpiry } = req.body;
1571
+ const pascalModel = toPascalCase(modelName);
1572
+ const hasPassword = fields.includes('password');
1573
+
1574
+ // Install packages FIRST before modifying files
1575
+ const packages = hasPassword ? 'jsonwebtoken bcrypt' : 'jsonwebtoken';
1576
+ await execAsync(`npm install ${packages}`, { cwd: process.cwd() });
1577
+
1578
+ // Create src/middleware/auth.js if it doesn't exist
1579
+ const middlewareDir = path.join(process.cwd(), 'src', 'middleware');
1580
+ const authFilePath = path.join(middlewareDir, 'auth.js');
1581
+ if (!fs.existsSync(authFilePath)) {
1582
+ if (!fs.existsSync(middlewareDir)) {
1583
+ fs.mkdirSync(middlewareDir, { recursive: true });
1584
+ }
1585
+ const authContent = `import jwt from 'jsonwebtoken';
1586
+
1587
+ const auth = (req, res, next) => {
1588
+ const token = req.header('Authorization')?.replace('Bearer ', '');
1589
+ if (!token) {
1590
+ return res.status(401).json({ message: 'No token, authorization denied' });
1591
+ }
1592
+ try {
1593
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
1594
+ req.user = decoded;
1595
+ next();
1596
+ } catch (err) {
1597
+ res.status(401).json({ message: 'Token is not valid' });
1598
+ }
1599
+ };
1600
+
1601
+ export default auth;
1602
+ `;
1603
+ fs.writeFileSync(authFilePath, authContent, 'utf8');
1604
+ }
1605
+
1606
+ // Build login endpoint code
1607
+ const fieldsDestructure = fields.join(', ');
1608
+
1609
+ let passwordCheck = '';
1610
+ if (hasPassword) {
1611
+ passwordCheck = `
1612
+ const isMatch = await bcrypt.compare(password, user.password);
1613
+ if (!isMatch) {
1614
+ return res.status(400).json({ message: 'Invalid credentials' });
1615
+ }`;
1616
+ }
1617
+
1618
+ let tokenCode = '';
1619
+ if (tokenExpiry) {
1620
+ tokenCode = ` const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
1621
+ const refreshToken = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' });
1622
+ res.json({ token, refreshToken });`;
1623
+ } else {
1624
+ tokenCode = ` const token = jwt.sign(payload, process.env.JWT_SECRET);
1625
+ res.json({ token });`;
1626
+ }
1627
+
1628
+ const loginCode = `
1629
+ router.post('/login', async (req, res) => {
1630
+ const { ${fieldsDestructure} } = req.body;
1631
+ try {
1632
+ const user = await ${pascalModel}.findOne({ ${fields[0]}: ${fields[0]} });
1633
+ if (!user) {
1634
+ return res.status(404).json({ message: 'User not found' });
1635
+ }${passwordCheck}
1636
+ const payload = { id: user._id };
1637
+ ${tokenCode}
1638
+ } catch (err) {
1639
+ console.error(err);
1640
+ res.status(500).json({ message: 'Server error' });
1641
+ }
1642
+ });
1643
+ `;
1644
+
1645
+ // Add login endpoint to the selected route file
1646
+ const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
1647
+ if (!fs.existsSync(routeFilePath)) {
1648
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
1649
+ }
1650
+
1651
+ // Add imports to route file
1652
+ addImportToRoute(routeFilePath, "import jwt from 'jsonwebtoken';");
1653
+ if (hasPassword) {
1654
+ addImportToRoute(routeFilePath, "import bcrypt from 'bcrypt';");
1655
+ }
1656
+ addImportToRoute(routeFilePath, `import ${pascalModel} from '../models/${modelName}.js';`);
1657
+
1658
+ // Insert login code
1659
+ insertCodeIntoRoute(routeFilePath, loginCode);
1660
+
1661
+ // If tokenExpiry, create refresh-token route
1662
+ if (tokenExpiry) {
1663
+ const refreshRouteDir = path.join(process.cwd(), 'src', 'routes');
1664
+ const refreshFilePath = path.join(refreshRouteDir, 'refresh-token.js');
1665
+ if (!fs.existsSync(refreshFilePath)) {
1666
+ const refreshContent = `import express from 'express';
1667
+ import jwt from 'jsonwebtoken';
1668
+
1669
+ const router = express.Router();
1670
+
1671
+ router.post('/', async (req, res) => {
1672
+ const { refreshToken } = req.body;
1673
+ if (!refreshToken) {
1674
+ return res.status(401).json({ message: 'Refresh token required' });
1675
+ }
1676
+ try {
1677
+ const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
1678
+ const payload = { id: decoded.id };
1679
+ const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
1680
+ res.json({ token });
1681
+ } catch (err) {
1682
+ res.status(401).json({ message: 'Invalid refresh token' });
1683
+ }
1684
+ });
1685
+
1686
+ export default router;
1687
+ `;
1688
+ fs.writeFileSync(refreshFilePath, refreshContent, 'utf8');
1689
+
1690
+ // Register in index.js
1691
+ addImportToIndex("import refreshTokenRouter from './src/routes/refresh-token.js';");
1692
+ const routeReg = "app.use('/api/refresh-token', refreshTokenRouter);";
1693
+ let idxContent = readIndexFile();
1694
+ if (!idxContent.includes(routeReg)) {
1695
+ insertBeforeListen(routeReg);
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+ // Generate JWT secrets and update env files
1701
+ const devSecret = crypto.randomBytes(32).toString('hex');
1702
+ const prodSecret = crypto.randomBytes(32).toString('hex');
1703
+ const devEnv = path.join(process.cwd(), '.env.development');
1704
+ const prodEnv = path.join(process.cwd(), '.env.production');
1705
+ updateEnvFile(devEnv, 'JWT_SECRET', devSecret);
1706
+ updateEnvFile(prodEnv, 'JWT_SECRET', prodSecret);
1707
+
1708
+ const filesModified = ['src/middleware/auth.js', `src/routes/${routeFile}`, '.env.development', '.env.production'];
1709
+ if (tokenExpiry) filesModified.push('src/routes/refresh-token.js');
1710
+
1711
+ // Send response BEFORE index.js modifications (nodemon restart kills connection)
1712
+ res.json({
1713
+ message: 'JWT authentication setup complete.',
1714
+ files: filesModified
1715
+ });
1716
+ } catch (err) {
1717
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1718
+ }
1719
+ });
1720
+
1721
+ // ---------------------------------------------------------------------------
1722
+ // POST /api/add-socket
1723
+ // ---------------------------------------------------------------------------
1724
+ router.post('/api/add-socket', async (req, res) => {
1725
+ try {
1726
+ const { corsOrigins } = req.body;
1727
+
1728
+ // Remove existing WebSocket code if present
1729
+ if (readIndexFile().includes('WebSocketServer')) {
1730
+ removeWebSocketCode();
1731
+ try { await execAsync('npm uninstall ws', { cwd: process.cwd() }); } catch {}
1732
+ }
1733
+
1734
+ // Install FIRST before modifying index.js
1735
+ await execAsync('npm install socket.io', { cwd: process.cwd() });
1736
+
1737
+ // Add imports to index.js
1738
+ addImportToIndex('import { Server } from "socket.io";');
1739
+ addImportToIndex('import http from "http";');
1740
+
1741
+ // Build cors origin value
1742
+ let originValue;
1743
+ if (corsOrigins === '*') {
1744
+ originValue = '"*"';
1745
+ } else {
1746
+ const origins = corsOrigins.split(',').map(s => s.trim());
1747
+ originValue = JSON.stringify(origins);
1748
+ }
1749
+
1750
+ // Insert server + io setup before routes
1751
+ const socketCode = `const server = http.createServer(app);
1752
+ const io = new Server(server, {
1753
+ cors: {
1754
+ origin: ${originValue},
1755
+ methods: ["GET", "POST"]
1756
+ }
1757
+ });
1758
+
1759
+ io.on("connection", (socket) => {
1760
+ console.log("A user connected");
1761
+ socket.on("disconnect", () => {
1762
+ console.log("User disconnected");
1763
+ });
1764
+ });
1765
+
1766
+ app.use((req, res, next) => {
1767
+ req.io = io;
1768
+ next();
1769
+ });`;
1770
+
1771
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
1772
+ res.json({
1773
+ message: 'Socket.IO setup complete.',
1774
+ files: ['index.js']
1775
+ });
1776
+
1777
+ let indexContent = readIndexFile();
1778
+ if (!indexContent.includes('new Server(server')) {
1779
+ insertBeforeListen(socketCode);
1780
+
1781
+ // Replace app.listen with server.listen
1782
+ indexContent = readIndexFile();
1783
+ if (indexContent.includes('app.listen(port')) {
1784
+ const serverListenBlock = `server.listen(port, () => {
1785
+ console.log(\`Server is running on port \${port}\`);
1786
+ });`;
1787
+ replaceAppListen(serverListenBlock);
1788
+ }
1789
+ }
1790
+ } catch (err) {
1791
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1792
+ }
1793
+ });
1794
+
1795
+ // ---------------------------------------------------------------------------
1796
+ // POST /api/add-websocket
1797
+ // ---------------------------------------------------------------------------
1798
+ router.post('/api/add-websocket', async (req, res) => {
1799
+ try {
1800
+ const { samePort, wsPort } = req.body;
1801
+
1802
+ // Remove existing Socket.IO code if present
1803
+ if (readIndexFile().includes('socket.io') || readIndexFile().includes('new Server(server')) {
1804
+ removeSocketIOCode();
1805
+ try { await execAsync('npm uninstall socket.io', { cwd: process.cwd() }); } catch {}
1806
+ }
1807
+
1808
+ // Install FIRST before modifying index.js
1809
+ await execAsync('npm install ws', { cwd: process.cwd() });
1810
+
1811
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
1812
+ res.json({
1813
+ message: 'WebSocket setup complete.',
1814
+ files: ['index.js']
1815
+ });
1816
+
1817
+ // Add import
1818
+ addImportToIndex("import { WebSocketServer } from 'ws';");
1819
+
1820
+ if (samePort) {
1821
+ // Also need http
1822
+ addImportToIndex("import http from 'http';");
1823
+
1824
+ const wsCode = `const server = http.createServer(app);
1825
+ const wss = new WebSocketServer({ server });
1826
+
1827
+ wss.on('connection', (ws) => {
1828
+ console.log('WebSocket client connected');
1829
+ ws.on('message', (message) => {
1830
+ console.log('Received:', message.toString());
1831
+ });
1832
+ ws.on('close', () => {
1833
+ console.log('WebSocket client disconnected');
1834
+ });
1835
+ });`;
1836
+
1837
+ if (!readIndexFile().includes('new WebSocketServer')) {
1838
+ insertBeforeListen(wsCode);
1839
+
1840
+ // Replace app.listen with server.listen
1841
+ let currentContent = readIndexFile();
1842
+ if (currentContent.includes('app.listen(port')) {
1843
+ const serverListenBlock = `server.listen(port, () => {
1844
+ console.log(\`Server is running on port \${port}\`);
1845
+ });`;
1846
+ replaceAppListen(serverListenBlock);
1847
+ }
1848
+ }
1849
+ } else {
1850
+ // Separate port
1851
+ const port = wsPort || 8080;
1852
+ const wsCode = `const wss = new WebSocketServer({ port: ${port} });
1853
+
1854
+ wss.on('connection', (ws) => {
1855
+ console.log('WebSocket client connected');
1856
+ ws.on('message', (message) => {
1857
+ console.log('Received:', message.toString());
1858
+ });
1859
+ ws.on('close', () => {
1860
+ console.log('WebSocket client disconnected');
1861
+ });
1862
+ });
1863
+
1864
+ console.log('WebSocket server running on port ${port}');`;
1865
+
1866
+ if (!readIndexFile().includes('new WebSocketServer')) {
1867
+ insertBeforeListen(wsCode);
1868
+ }
1869
+ }
1870
+ } catch (err) {
1871
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1872
+ }
1873
+ });
1874
+
1875
+ // ---------------------------------------------------------------------------
1876
+ // GET /api/serialports — list configured serial ports from index.js
1877
+ // ---------------------------------------------------------------------------
1878
+ router.get('/api/serialports', (req, res) => {
1879
+ try {
1880
+ const indexPath = path.join(process.cwd(), 'index.js');
1881
+ if (!fs.existsSync(indexPath)) return res.json([]);
1882
+ const content = fs.readFileSync(indexPath, 'utf8');
1883
+ const ports = [];
1884
+ // Match: const varName = new SerialPort({ path: '...', baudRate: 9600 })
1885
+ const regex = /const\s+(\w+)\s*=\s*new\s+SerialPort\(\{\s*\n?\s*path:\s*(?:process\.env\.\w+\s*\|\|\s*)?['"]([^'"]+)['"]\s*,\s*\n?\s*baudRate:\s*(?:parseInt\(process\.env\.\w+\)\s*\|\|\s*)?(\d+)/g;
1886
+ let m;
1887
+ while ((m = regex.exec(content)) !== null) {
1888
+ ports.push({ name: m[1], path: m[2], baudRate: parseInt(m[3]) });
1889
+ }
1890
+ res.json(ports);
1891
+ } catch (err) {
1892
+ res.status(500).json({ error: err.message });
1893
+ }
1894
+ });
1895
+
1896
+ // ---------------------------------------------------------------------------
1897
+ // POST /api/add-serialport — add a named serial port connection
1898
+ // ---------------------------------------------------------------------------
1899
+ router.post('/api/add-serialport', async (req, res) => {
1900
+ try {
1901
+ const name = (req.body.name || 'serialPort').replace(/[^a-zA-Z0-9_]/g, '');
1902
+ const portPath = req.body.portPath || '/dev/ttyUSB0';
1903
+ const baud = req.body.baudRate || 9600;
1904
+ const parserName = name + 'Parser';
1905
+ const envPathKey = name.toUpperCase() + '_PATH';
1906
+ const envBaudKey = name.toUpperCase() + '_BAUD';
1907
+
1908
+ // Install FIRST before modifying index.js
1909
+ const indexContent = readIndexFile();
1910
+ if (!indexContent.includes("from 'serialport'")) {
1911
+ await execAsync('npm install serialport @serialport/parser-readline', { cwd: process.cwd() });
1912
+ }
1913
+
1914
+ // Update env files
1915
+ const devEnv = path.join(process.cwd(), '.env.development');
1916
+ const prodEnv = path.join(process.cwd(), '.env.production');
1917
+ updateEnvFile(devEnv, envPathKey, portPath);
1918
+ updateEnvFile(devEnv, envBaudKey, String(baud));
1919
+ updateEnvFile(prodEnv, envPathKey, portPath);
1920
+ updateEnvFile(prodEnv, envBaudKey, String(baud));
1921
+
1922
+ // Send response BEFORE modifying index.js
1923
+ res.json({
1924
+ message: 'Serial port "' + name + '" added.',
1925
+ files: ['index.js', '.env.development', '.env.production']
1926
+ });
1927
+
1928
+ // Modify index.js LAST
1929
+ addImportToIndex("import { SerialPort } from 'serialport';");
1930
+ addImportToIndex("import { ReadlineParser } from '@serialport/parser-readline';");
1931
+
1932
+ const serialCode = `const ${name} = new SerialPort({
1933
+ path: process.env.${envPathKey} || '${portPath}',
1934
+ baudRate: parseInt(process.env.${envBaudKey}) || ${baud}
1935
+ });
1936
+
1937
+ const ${parserName} = ${name}.pipe(new ReadlineParser({ delimiter: '\\r\\n' }));
1938
+
1939
+ ${name}.on('open', () => {
1940
+ console.log('${name}: port opened');
1941
+ });
1942
+
1943
+ ${parserName}.on('data', (data) => {
1944
+ console.log('${name} data:', data);
1945
+ });
1946
+
1947
+ ${name}.on('error', (err) => {
1948
+ console.error('${name} error:', err.message);
1949
+ });`;
1950
+
1951
+ let current = readIndexFile();
1952
+ if (!current.includes(`const ${name} = new SerialPort`)) {
1953
+ insertBeforeListen(serialCode);
1954
+ }
1955
+ } catch (err) {
1956
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1957
+ }
1958
+ });
1959
+
1960
+ // ---------------------------------------------------------------------------
1961
+ // DELETE /api/serialports/:name — remove a serial port from index.js
1962
+ // ---------------------------------------------------------------------------
1963
+ router.delete('/api/serialports/:name', (req, res) => {
1964
+ try {
1965
+ const name = req.params.name;
1966
+ const parserName = name + 'Parser';
1967
+ let content = readIndexFile();
1968
+
1969
+ // Remove the SerialPort block: const name = new SerialPort({...});
1970
+ content = content.replace(new RegExp(`const\\s+${name}\\s*=\\s*new\\s+SerialPort\\(\\{[\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
1971
+ // Remove parser line
1972
+ content = content.replace(new RegExp(`const\\s+${parserName}\\s*=\\s*${name}\\.pipe[\\s\\S]*?;\\s*\\n?`, 'g'), '');
1973
+ // Remove event handlers
1974
+ content = content.replace(new RegExp(`${name}\\.on\\(['"]open['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
1975
+ content = content.replace(new RegExp(`${parserName}\\.on\\(['"]data['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
1976
+ content = content.replace(new RegExp(`${name}\\.on\\(['"]error['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
1977
+
1978
+ // If no more SerialPort instances, remove imports
1979
+ if (!content.includes('new SerialPort')) {
1980
+ content = content.replace(/import\s*\{\s*SerialPort\s*\}\s*from\s*['"]serialport['"];\s*\n?/g, '');
1981
+ content = content.replace(/import\s*\{\s*ReadlineParser\s*\}\s*from\s*['"]@serialport\/parser-readline['"];\s*\n?/g, '');
1982
+ }
1983
+
1984
+ // Clean up multiple blank lines
1985
+ content = content.replace(/\n{3,}/g, '\n\n');
1986
+ writeIndexFile(content);
1987
+
1988
+ // Remove env vars
1989
+ const envPathKey = name.toUpperCase() + '_PATH';
1990
+ const envBaudKey = name.toUpperCase() + '_BAUD';
1991
+ for (const envFile of ['.env.development', '.env.production']) {
1992
+ const envPath = path.join(process.cwd(), envFile);
1993
+ if (fs.existsSync(envPath)) {
1994
+ let envContent = fs.readFileSync(envPath, 'utf8');
1995
+ envContent = envContent.replace(new RegExp(`^${envPathKey}=.*\\n?`, 'm'), '');
1996
+ envContent = envContent.replace(new RegExp(`^${envBaudKey}=.*\\n?`, 'm'), '');
1997
+ fs.writeFileSync(envPath, envContent, 'utf8');
1998
+ }
1999
+ }
2000
+
2001
+ res.json({ message: 'Serial port "' + name + '" removed.' });
2002
+ } catch (err) {
2003
+ res.status(500).json({ error: err.message });
2004
+ }
2005
+ });
2006
+
2007
+ // ---------------------------------------------------------------------------
2008
+ // API Tester — field configs (stored in .4bnode/tester-fields.json)
2009
+ // ---------------------------------------------------------------------------
2010
+ const testerFieldsPath = path.join(process.cwd(), '.4bnode', 'tester-fields.json');
2011
+
2012
+ function loadTesterFields() {
2013
+ try {
2014
+ if (fs.existsSync(testerFieldsPath)) return JSON.parse(fs.readFileSync(testerFieldsPath, 'utf8'));
2015
+ } catch {}
2016
+ return {};
2017
+ }
2018
+
2019
+ function saveTesterFields(data) {
2020
+ const dir = path.dirname(testerFieldsPath);
2021
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2022
+ fs.writeFileSync(testerFieldsPath, JSON.stringify(data, null, 2), 'utf8');
2023
+ }
2024
+
2025
+ const testerDataPath = path.join(process.cwd(), '.4bnode', 'tester-data.json');
2026
+
2027
+ function loadTesterData() {
2028
+ try {
2029
+ if (fs.existsSync(testerDataPath)) return JSON.parse(fs.readFileSync(testerDataPath, 'utf8'));
2030
+ } catch {}
2031
+ return { tests: [], responses: {} };
2032
+ }
2033
+
2034
+ function saveTesterData(data) {
2035
+ const dir = path.dirname(testerDataPath);
2036
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2037
+ fs.writeFileSync(testerDataPath, JSON.stringify(data, null, 2), 'utf8');
2038
+ }
2039
+
2040
+ // GET /api/api-tester/data — load saved tests and responses
2041
+ router.get('/api/api-tester/data', (req, res) => {
2042
+ res.json(loadTesterData());
2043
+ });
2044
+
2045
+ // PUT /api/api-tester/data — save tests and responses
2046
+ router.put('/api/api-tester/data', (req, res) => {
2047
+ const { tests, responses } = req.body;
2048
+ saveTesterData({ tests: tests || [], responses: responses || {} });
2049
+ res.json({ message: 'Saved' });
2050
+ });
2051
+
2052
+ // GET /api/api-tester/fields/:routeKey — get saved fields for a route
2053
+ router.get('/api/api-tester/fields/:routeKey', (req, res) => {
2054
+ const all = loadTesterFields();
2055
+ const key = decodeURIComponent(req.params.routeKey);
2056
+ res.json({ fields: all[key] || null });
2057
+ });
2058
+
2059
+ // PUT /api/api-tester/fields/:routeKey — save fields for a route
2060
+ router.put('/api/api-tester/fields/:routeKey', (req, res) => {
2061
+ const { fields } = req.body;
2062
+ const key = decodeURIComponent(req.params.routeKey);
2063
+ const all = loadTesterFields();
2064
+ all[key] = fields;
2065
+ saveTesterFields(all);
2066
+ res.json({ message: 'Fields saved' });
2067
+ });
2068
+
2069
+ // ---------------------------------------------------------------------------
2070
+ // API Tester — proxy requests to avoid CORS issues
2071
+ // ---------------------------------------------------------------------------
2072
+ router.post('/api/api-tester/send', async (req, res) => {
2073
+ try {
2074
+ const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
2075
+ if (!url) return res.status(400).json({ error: 'URL is required' });
2076
+
2077
+ const fetchOpts = { method: (method || 'GET').toUpperCase() };
2078
+ const headers = { ...(reqHeaders || {}) };
2079
+
2080
+ if (['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
2081
+ if (bodyType === 'form-data' && formFields.length > 0) {
2082
+ // Build multipart form data
2083
+ const boundary = '----4bnode' + crypto.randomBytes(8).toString('hex');
2084
+ let bodyParts = [];
2085
+ for (const field of formFields) {
2086
+ if (!field.key?.trim()) continue;
2087
+ if (field.fileData) {
2088
+ // File field (base64 encoded data)
2089
+ const fileBuffer = Buffer.from(field.fileData, 'base64');
2090
+ bodyParts.push(
2091
+ `--${boundary}\r\nContent-Disposition: form-data; name="${field.key}"; filename="${field.fileName || 'file'}"\r\nContent-Type: ${field.fileType || 'application/octet-stream'}\r\n\r\n`
2092
+ );
2093
+ bodyParts.push(fileBuffer);
2094
+ bodyParts.push('\r\n');
2095
+ } else {
2096
+ bodyParts.push(
2097
+ `--${boundary}\r\nContent-Disposition: form-data; name="${field.key}"\r\n\r\n${field.value || ''}\r\n`
2098
+ );
2099
+ }
2100
+ }
2101
+ bodyParts.push(`--${boundary}--\r\n`);
2102
+ // Combine string and buffer parts
2103
+ const bufferParts = bodyParts.map(p => typeof p === 'string' ? Buffer.from(p) : p);
2104
+ fetchOpts.body = Buffer.concat(bufferParts);
2105
+ headers['Content-Type'] = `multipart/form-data; boundary=${boundary}`;
2106
+ } else if (bodyType === 'x-www-form-urlencoded' && formFields.length > 0) {
2107
+ fetchOpts.body = formFields
2108
+ .filter(f => f.key?.trim())
2109
+ .map(f => encodeURIComponent(f.key) + '=' + encodeURIComponent(f.value || ''))
2110
+ .join('&');
2111
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
2112
+ } else if (bodyType === 'binary' && reqBody) {
2113
+ // Binary body (base64 encoded)
2114
+ fetchOpts.body = Buffer.from(reqBody, 'base64');
2115
+ } else if (reqBody) {
2116
+ fetchOpts.body = typeof reqBody === 'string' ? reqBody : JSON.stringify(reqBody);
2117
+ }
2118
+ }
2119
+
2120
+ // Remove Content-Type for form-data (let boundary be set correctly)
2121
+ if (bodyType === 'form-data') {
2122
+ // Already set above with boundary
2123
+ }
2124
+
2125
+ fetchOpts.headers = headers;
2126
+
2127
+ const start = Date.now();
2128
+ const response = await fetch(url, fetchOpts);
2129
+ const elapsed = Date.now() - start;
2130
+ const text = await response.text();
2131
+ const respHeaders = {};
2132
+ response.headers.forEach((v, k) => { respHeaders[k] = v; });
2133
+ res.json({
2134
+ status: response.status,
2135
+ statusText: response.statusText,
2136
+ headers: respHeaders,
2137
+ body: text,
2138
+ time: elapsed,
2139
+ size: Buffer.byteLength(text),
2140
+ });
2141
+ } catch (err) {
2142
+ res.json({ status: 0, statusText: 'Connection Error', body: err.message, headers: {}, time: 0, size: 0 });
2143
+ }
2144
+ });
2145
+
2146
+ // GET /api/api-tester/routes — list all routes with endpoints for import
2147
+ router.get('/api/api-tester/routes', (req, res) => {
2148
+ try {
2149
+ const routes = listRoutes();
2150
+ const result = [];
2151
+ const indexPath = path.join(process.cwd(), 'index.js');
2152
+ const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
2153
+
2154
+ routes.forEach(routeFile => {
2155
+ const name = routeFile.replace('.js', '');
2156
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
2157
+ if (!fs.existsSync(filePath)) return;
2158
+ const content = fs.readFileSync(filePath, 'utf8');
2159
+
2160
+ let prefix = '/api/' + name;
2161
+ const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
2162
+ if (prefixMatch) prefix = prefixMatch[1];
2163
+
2164
+ const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
2165
+ let modelFields = [];
2166
+ if (modelImportMatch) {
2167
+ modelFields = getModelSchemaDetailed(modelImportMatch[2]);
2168
+ }
2169
+
2170
+ const endpoints = [];
2171
+ const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,([\s\S]*?)(?=\nrouter\.|$)/gi;
2172
+ let m;
2173
+ while ((m = epRegex.exec(content)) !== null) {
2174
+ const method = m[1].toUpperCase();
2175
+ const epath = m[2];
2176
+ const body = m[3] || '';
2177
+ const bodyFields = [];
2178
+ const queryFields = [];
2179
+ const fileFields = [];
2180
+ const bodyMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.body/);
2181
+ if (bodyMatch) {
2182
+ bodyMatch[1].split(',').forEach(f => {
2183
+ const fname = f.trim();
2184
+ if (fname) {
2185
+ const meta = modelFields.find(mf => mf.name === fname);
2186
+ bodyFields.push({ name: fname, type: meta?.type || 'String', required: meta?.required || false, unique: meta?.unique || false });
2187
+ }
2188
+ });
2189
+ }
2190
+ const queryMatch = body.match(/const\s*\{\s*([^}]+)\}\s*=\s*req\.query/);
2191
+ if (queryMatch) {
2192
+ queryMatch[1].split(',').forEach(f => {
2193
+ const fname = f.trim();
2194
+ if (fname) queryFields.push({ name: fname, type: 'String' });
2195
+ });
2196
+ }
2197
+ // Detect file fields from multer middleware and req.file/req.files
2198
+ const uploadSingleMatch = body.match(/upload\.single\(\s*['"](\w+)['"]\s*\)/);
2199
+ if (uploadSingleMatch) fileFields.push({ name: uploadSingleMatch[1] });
2200
+ const uploadFieldsRegex = /name:\s*['"](\w+)['"]/g;
2201
+ const uploadFieldsBlock = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]\s*\)/);
2202
+ if (uploadFieldsBlock) {
2203
+ let ufm;
2204
+ while ((ufm = uploadFieldsRegex.exec(uploadFieldsBlock[1])) !== null) {
2205
+ if (!fileFields.find(f => f.name === ufm[1])) fileFields.push({ name: ufm[1] });
2206
+ }
2207
+ }
2208
+ // Also detect from req.file / req.files assignments
2209
+ const singleFileMatch = body.match(/const\s+(\w+)\s*=\s*req\.file\b/);
2210
+ if (singleFileMatch && !fileFields.find(f => f.name === singleFileMatch[1])) fileFields.push({ name: singleFileMatch[1] });
2211
+ const multiFileRegex = /const\s+(\w+)\s*=\s*req\.files\??\[?\s*\[?['"]([\w]+)['"]\]?/g;
2212
+ let fm;
2213
+ while ((fm = multiFileRegex.exec(body)) !== null) {
2214
+ if (!fileFields.find(f => f.name === fm[1])) fileFields.push({ name: fm[1] });
2215
+ }
2216
+
2217
+ const hasAuth = /auth\s*,/.test(m[0]);
2218
+ const params = [];
2219
+ const paramRegex = /:(\w+)/g;
2220
+ let pm;
2221
+ while ((pm = paramRegex.exec(epath)) !== null) params.push(pm[1]);
2222
+
2223
+ endpoints.push({
2224
+ method,
2225
+ path: epath,
2226
+ fullPath: prefix + (epath === '/' ? '' : epath),
2227
+ bodyFields,
2228
+ queryFields,
2229
+ fileFields,
2230
+ params,
2231
+ hasAuth,
2232
+ });
2233
+ }
2234
+ const hasAuth = content.includes("import auth");
2235
+ result.push({ name, routeFile, prefix, endpoints, hasAuth });
2236
+ });
2237
+ res.json(result);
2238
+ } catch (err) {
2239
+ res.status(500).json({ error: err.message });
2240
+ }
2241
+ });
2242
+
2243
+ // ---------------------------------------------------------------------------
2244
+ // Share Collection — save and serve standalone shared page
2245
+ // ---------------------------------------------------------------------------
2246
+ const sharedDir = path.join(process.cwd(), '.4bnode', 'shared');
2247
+
2248
+ router.post('/api/api-tester/share-collection', (req, res) => {
2249
+ try {
2250
+ const { tests, responses } = req.body;
2251
+ if (!tests?.length) return res.status(400).json({ error: 'No tests to share' });
2252
+ if (!fs.existsSync(sharedDir)) fs.mkdirSync(sharedDir, { recursive: true });
2253
+ let appName = 'API Collection';
2254
+ try { appName = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).name || appName; } catch {}
2255
+ // Use fixed ID so the share URL stays the same — always updates the same file
2256
+ const id = crypto.createHash('md5').update(appName).digest('hex').slice(0, 12);
2257
+ fs.writeFileSync(path.join(sharedDir, id + '.json'), JSON.stringify({ tests, responses, appName, createdAt: new Date().toISOString() }));
2258
+ res.json({ id });
2259
+ } catch (err) {
2260
+ res.status(500).json({ error: err.message });
2261
+ }
2262
+ });
2263
+
2264
+ router.get('/shared/:id', (req, res) => {
2265
+ try {
2266
+ const filePath = path.join(sharedDir, req.params.id + '.json');
2267
+ if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
2268
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2269
+ const rawAppName = data.appName || 'My App';
2270
+ // Convert kebab-case/snake_case to Title Case: "test-app" → "Test App"
2271
+ const appName = rawAppName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2272
+
2273
+ const safeJson = (obj) => JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
2274
+ const escHtml = (s) => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2275
+ const safeAppName = escHtml(appName);
2276
+
2277
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
2278
+ <title>${safeAppName} - API Documentation</title>
2279
+ <style>
2280
+ *{margin:0;padding:0;box-sizing:border-box}
2281
+ body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column}
2282
+ .topbar{height:52px;background:#161b26;border-bottom:1px solid #1e2433;display:flex;align-items:center;padding:0 24px;gap:12px;flex-shrink:0}
2283
+ .topbar-name{font-size:16px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
2284
+ .topbar-sep{width:1px;height:22px;background:#1e2433;margin:0 8px}
2285
+ .topbar-label{font-size:12px;color:#64748b;font-weight:500}
2286
+ .topbar-count{font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600;margin-left:auto}
2287
+ .topbar-date{font-size:10px;color:#475569}
2288
+ .layout{display:flex;flex:1;overflow:hidden}
2289
+ .sidebar{width:300px;min-width:300px;background:#161b26;border-right:1px solid #1e2433;display:flex;flex-direction:column;overflow:hidden}
2290
+ .sidebar-header{padding:14px 18px;border-bottom:1px solid #1e2433;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:#475569;display:flex;justify-content:space-between;align-items:center}
2291
+ .sidebar-search{padding:8px 12px;border-bottom:1px solid #1e2433}
2292
+ .sidebar-search input{width:100%;padding:7px 12px;background:#0f1117;border:1px solid #1e2433;border-radius:6px;color:#e2e8f0;font-size:12px;outline:none}
2293
+ .sidebar-search input:focus{border-color:#7c3aed}
2294
+ .sidebar-search input::placeholder{color:#475569}
2295
+ .sidebar-list{flex:1;overflow-y:auto}
2296
+ .sidebar-item{padding:10px 14px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.5);display:flex;align-items:center;gap:10px;transition:all 0.12s;border-left:3px solid transparent}
2297
+ .sidebar-item:hover{background:rgba(124,58,237,0.06)}
2298
+ .sidebar-item.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
2299
+ .sidebar-child{padding:8px 14px 8px 48px;cursor:pointer;border-bottom:1px solid rgba(30,36,51,0.3);display:flex;align-items:center;gap:8px;transition:all 0.12s;border-left:3px solid transparent}
2300
+ .sidebar-child:hover{background:rgba(124,58,237,0.06)}
2301
+ .sidebar-child.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
2302
+ .mb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;text-transform:uppercase;flex-shrink:0;letter-spacing:0.3px}
2303
+ .mb-get{background:rgba(52,211,153,0.15);color:#34d399}
2304
+ .mb-post{background:rgba(245,158,11,0.15);color:#f59e0b}
2305
+ .mb-put{background:rgba(59,130,246,0.15);color:#3b82f6}
2306
+ .mb-patch{background:rgba(139,92,246,0.15);color:#8b5cf6}
2307
+ .mb-delete{background:rgba(248,113,113,0.15);color:#f87171}
2308
+ .item-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2309
+ .item-arrow{font-size:10px;color:#475569;width:14px;text-align:center;flex-shrink:0}
2310
+ .item-badge{font-size:9px;padding:2px 7px;border-radius:8px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600}
2311
+ .sb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;flex-shrink:0}
2312
+ .s2{background:rgba(52,211,153,0.15);color:#34d399}
2313
+ .s3{background:rgba(96,165,250,0.15);color:#60a5fa}
2314
+ .s4{background:rgba(251,191,36,0.15);color:#fbbf24}
2315
+ .s5{background:rgba(248,113,113,0.15);color:#f87171}
2316
+ .s0{background:rgba(248,113,113,0.15);color:#f87171}
2317
+ .child-label{font-size:11px;color:#94a3b8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2318
+ .child-time{font-size:10px;color:#475569;flex-shrink:0}
2319
+ .main{flex:1;display:flex;flex-direction:column;overflow:hidden}
2320
+ .main-content{flex:1;overflow-y:auto;padding:24px}
2321
+ .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#475569;gap:12px}
2322
+ .empty-state svg{width:48px;height:48px;stroke:#2d3548;stroke-width:1.5;fill:none}
2323
+ .card{background:#161b26;border:1px solid #1e2433;border-radius:10px;padding:18px;margin-bottom:16px}
2324
+ .card-header{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid #1e2433}
2325
+ .endpoint-url{font-family:'SF Mono','Fira Code',monospace;font-size:13px;color:#e2e8f0;background:#0f1117;padding:10px 14px;border-radius:8px;border:1px solid #1e2433;margin-bottom:16px;display:flex;align-items:center;gap:10px;justify-content:space-between}
2326
+ .endpoint-url code{flex:1;word-break:break-all}
2327
+ .copy-btn{padding:5px 12px;border:1px solid #1e2433;border-radius:6px;background:#0f1117;color:#94a3b8;font-size:11px;cursor:pointer;font-family:inherit;transition:all 0.15s;white-space:nowrap}
2328
+ .copy-btn:hover{border-color:#7c3aed;color:#a78bfa}
2329
+ .stitle{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#475569;margin-bottom:10px}
2330
+ pre{font-family:'SF Mono','Fira Code',monospace;font-size:12px;line-height:1.7;color:#94a3b8;white-space:pre-wrap;word-break:break-word;background:#0f1117;padding:14px;border-radius:8px;border:1px solid #1e2433;position:relative}
2331
+ .field-row{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#0f1117;border-radius:6px;margin-bottom:4px;border:1px solid #1e2433}
2332
+ .field-name{font-family:'SF Mono','Fira Code',monospace;font-size:12px;font-weight:500;color:#e2e8f0;min-width:120px}
2333
+ .field-type{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(124,58,237,0.12);color:#a78bfa;font-weight:500}
2334
+ .field-req{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(52,211,153,0.12);color:#34d399;font-weight:600}
2335
+ .field-uniq{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(251,191,36,0.12);color:#fbbf24;font-weight:600}
2336
+ .code-tabs{display:flex;gap:0;border-bottom:1px solid #1e2433;margin-bottom:0}
2337
+ .code-tab{padding:7px 16px;font-size:11px;font-weight:500;color:#475569;background:none;border:none;cursor:pointer;border-bottom:2px solid transparent;font-family:inherit;transition:all 0.15s}
2338
+ .code-tab:hover{color:#94a3b8}
2339
+ .code-tab.active{color:#a78bfa;border-bottom-color:#7c3aed;font-weight:600}
2340
+ .header-row{display:flex;padding:6px 12px;font-size:12px;gap:12px;border-bottom:1px solid rgba(30,36,51,0.5)}
2341
+ .header-key{font-family:'SF Mono','Fira Code',monospace;font-size:11px;font-weight:600;color:#a78bfa;min-width:160px}
2342
+ .header-val{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:#94a3b8;word-break:break-all}
2343
+ .type-sio{background:rgba(52,211,153,0.15);color:#34d399}
2344
+ .type-ws{background:rgba(96,165,250,0.15);color:#60a5fa}
2345
+ .dir-recv{background:rgba(52,211,153,0.15);color:#34d399}
2346
+ .dir-sent{background:rgba(245,158,11,0.15);color:#f59e0b}
2347
+ .dir-sys{background:rgba(148,163,184,0.15);color:#94a3b8}
2348
+ @keyframes spin{to{transform:rotate(360deg)}}
2349
+ .spinner{width:16px;height:16px;border:2px solid #1e2433;border-top-color:#7c3aed;border-radius:50%;animation:spin 0.6s linear infinite;display:inline-block}
2350
+ </style></head><body>
2351
+ <div class="topbar">
2352
+ <span class="topbar-name">${safeAppName}</span>
2353
+ <span class="topbar-sep"></span>
2354
+ <span class="topbar-label">API Documentation</span>
2355
+ <span class="topbar-count">${data.tests.length} endpoint${data.tests.length !== 1 ? 's' : ''}</span>
2356
+ <span class="topbar-date">Shared ${new Date(data.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
2357
+ </div>
2358
+ <div class="layout">
2359
+ <div class="sidebar">
2360
+ <div class="sidebar-header"><span>Endpoints</span></div>
2361
+ <div class="sidebar-search"><input id="search-inp" placeholder="Search endpoints..." /></div>
2362
+ <div class="sidebar-list" id="sidebar-list"></div>
2363
+ </div>
2364
+ <div class="main">
2365
+ <div class="main-content" id="main-content">
2366
+ <div class="empty-state">
2367
+ <svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
2368
+ <span style="font-size:14px;font-weight:500">Select an endpoint from the sidebar</span>
2369
+ <span style="font-size:12px">View request details, example code, and saved responses</span>
2370
+ </div>
2371
+ </div>
2372
+ </div>
2373
+ </div>
2374
+ <script id="shared-data" type="application/json">${safeJson({ tests: data.tests, responses: data.responses || {} })}</script>
2375
+ <script>
2376
+ (function(){
2377
+ var _d = JSON.parse(document.getElementById('shared-data').textContent);
2378
+ var tests = _d.tests;
2379
+ var responses = _d.responses;
2380
+ var activeTestId = null;
2381
+ var activeRespId = null;
2382
+ var expanded = {};
2383
+ var activeCodeTab = {};
2384
+ var searchQuery = '';
2385
+
2386
+ tests.forEach(function(t){ t.id = String(t.id); });
2387
+ var normResp = {};
2388
+ Object.keys(responses).forEach(function(k){
2389
+ var key = String(k);
2390
+ var arr = responses[k];
2391
+ if (Array.isArray(arr)) arr.forEach(function(r){ r.id = String(r.id); });
2392
+ normResp[key] = arr;
2393
+ });
2394
+ responses = normResp;
2395
+
2396
+ function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
2397
+
2398
+ // Copy via event delegation
2399
+ document.addEventListener('click', function(e) {
2400
+ var btn = e.target.closest('[data-copy]');
2401
+ if (!btn) return;
2402
+ var target = btn.getAttribute('data-copy');
2403
+ var text = '';
2404
+ if (target === 'prev-pre') {
2405
+ var pre = btn.closest('div').querySelector('pre');
2406
+ if (pre) text = pre.textContent;
2407
+ } else if (target === 'url') {
2408
+ var code = btn.closest('.endpoint-url');
2409
+ if (code) text = code.querySelector('code').textContent;
2410
+ }
2411
+ if (text) {
2412
+ navigator.clipboard.writeText(text);
2413
+ var orig = btn.textContent;
2414
+ btn.textContent = 'Copied!';
2415
+ btn.style.color = '#34d399';
2416
+ setTimeout(function(){ btn.textContent = orig; btn.style.color = ''; }, 1500);
2417
+ }
2418
+ });
2419
+
2420
+ // Generate code snippets
2421
+ var _isForm = function(t) { return (t.bodyType === 'form-data' || t.bodyType === 'x-www-form-urlencoded') && t.formFields && t.formFields.length > 0; };
2422
+ var _hasBody = function(t) { return ['POST','PUT','PATCH'].indexOf(t.method) !== -1; };
2423
+
2424
+ function genCurl(t) {
2425
+ var nl = String.fromCharCode(10);
2426
+ var bs = String.fromCharCode(92);
2427
+ var c = 'curl -X ' + t.method + " '" + t.url + "'";
2428
+ if (_isForm(t)) {
2429
+ t.formFields.forEach(function(f) {
2430
+ if (!f.key) return;
2431
+ if (f.type === 'file') {
2432
+ c += ' ' + bs + nl + " -F '" + f.key + "=@/path/to/" + (f.fileName || 'file') + "'";
2433
+ } else {
2434
+ c += ' ' + bs + nl + " -F '" + f.key + '=' + (f.value || '') + "'";
2435
+ }
2436
+ });
2437
+ } else {
2438
+ if (t.headers) t.headers.forEach(function(h) { if (h.key) c += ' ' + bs + nl + " -H '" + h.key + ': ' + (h.value||'') + "'"; });
2439
+ if (t.body && _hasBody(t)) {
2440
+ c += ' ' + bs + nl + " -d '" + t.body.replace(/'/g, "'") + "'";
2441
+ }
2442
+ }
2443
+ return c;
2444
+ }
2445
+ function genFetch(t) {
2446
+ var nl = String.fromCharCode(10);
2447
+ var lines = [];
2448
+ if (_isForm(t)) {
2449
+ lines.push('const formData = new FormData();');
2450
+ t.formFields.forEach(function(f) {
2451
+ if (!f.key) return;
2452
+ if (f.type === 'file') {
2453
+ lines.push("formData.append('" + f.key + "', fileInput.files[0]); // " + (f.fileName || 'file'));
2454
+ } else {
2455
+ lines.push("formData.append('" + f.key + "', '" + (f.value || '') + "');");
2456
+ }
2457
+ });
2458
+ lines.push('');
2459
+ lines.push("fetch('" + t.url + "', {");
2460
+ lines.push(" method: '" + t.method + "',");
2461
+ lines.push(' body: formData,');
2462
+ lines.push('})');
2463
+ } else {
2464
+ lines.push("fetch('" + t.url + "', {");
2465
+ lines.push(" method: '" + t.method + "',");
2466
+ var hdrs = {};
2467
+ if (t.headers) t.headers.forEach(function(h) { if (h.key) hdrs[h.key] = h.value || ''; });
2468
+ if (Object.keys(hdrs).length > 0) lines.push(' headers: ' + JSON.stringify(hdrs, null, 4) + ',');
2469
+ if (t.body && _hasBody(t)) {
2470
+ var formatted = t.body;
2471
+ try { formatted = JSON.stringify(JSON.parse(t.body)); } catch(e) {}
2472
+ lines.push(' body: JSON.stringify(' + formatted + '),');
2473
+ }
2474
+ lines.push('})');
2475
+ }
2476
+ lines.push('.then(res => res.json())');
2477
+ lines.push('.then(data => console.log(data))');
2478
+ lines.push('.catch(err => console.error(err));');
2479
+ return lines.join(nl);
2480
+ }
2481
+ function genAxios(t) {
2482
+ var nl = String.fromCharCode(10);
2483
+ var m = t.method.toLowerCase();
2484
+ var hdrs = {};
2485
+ if (t.headers) t.headers.forEach(function(h) { if (h.key) hdrs[h.key] = h.value || ''; });
2486
+ var lines = [];
2487
+ if (_isForm(t)) {
2488
+ lines.push('const formData = new FormData();');
2489
+ t.formFields.forEach(function(f) {
2490
+ if (!f.key) return;
2491
+ if (f.type === 'file') {
2492
+ lines.push("formData.append('" + f.key + "', fileInput.files[0]); // " + (f.fileName || 'file'));
2493
+ } else {
2494
+ lines.push("formData.append('" + f.key + "', '" + (f.value || '') + "');");
2495
+ }
2496
+ });
2497
+ lines.push('');
2498
+ lines.push("const { data } = await axios." + m + "('" + t.url + "', formData, {");
2499
+ lines.push(" headers: { 'Content-Type': 'multipart/form-data' },");
2500
+ lines.push('});');
2501
+ } else if (_hasBody(t) && t.body) {
2502
+ var d = t.body;
2503
+ try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
2504
+ lines.push('const { data } = await axios.' + m + '(');
2505
+ lines.push(" '" + t.url + "',");
2506
+ lines.push(' ' + d.split(nl).join(nl + ' '));
2507
+ if (Object.keys(hdrs).length > 0) lines.push(' , { headers: ' + JSON.stringify(hdrs) + ' }');
2508
+ lines.push(');');
2509
+ } else {
2510
+ lines.push("const { data } = await axios." + m + "('" + t.url + "'");
2511
+ if (Object.keys(hdrs).length > 0) lines.push(' , { headers: ' + JSON.stringify(hdrs) + ' }');
2512
+ lines.push(');');
2513
+ }
2514
+ lines.push('console.log(data);');
2515
+ return lines.join(nl);
2516
+ }
2517
+
2518
+ function filteredTests() {
2519
+ if (!searchQuery) return tests;
2520
+ var q = searchQuery.toLowerCase();
2521
+ return tests.filter(function(t) {
2522
+ return (t.name||'').toLowerCase().includes(q) || (t.url||'').toLowerCase().includes(q) || (t.method||'').toLowerCase().includes(q);
2523
+ });
2524
+ }
2525
+
2526
+ function renderSidebar() {
2527
+ var sb = document.getElementById('sidebar-list');
2528
+ var h = '';
2529
+ var ft = filteredTests();
2530
+ ft.forEach(function(t) {
2531
+ var resps = responses[t.id] || [];
2532
+ var isActive = activeTestId === t.id && !activeRespId;
2533
+ var isExp = expanded[t.id];
2534
+ var tt = t.type || 'rest';
2535
+ h += '<div class="sidebar-item' + (isActive ? ' active' : '') + '" data-test="' + t.id + '">';
2536
+ if (resps.length > 0) h += '<span class="item-arrow">' + (isExp ? '&#9660;' : '&#9654;') + '</span>';
2537
+ else h += '<span class="item-arrow"></span>';
2538
+ if (tt === 'socketio') h += '<span class="mb type-sio">SIO</span>';
2539
+ else if (tt === 'websocket') h += '<span class="mb type-ws">WS</span>';
2540
+ else h += '<span class="mb mb-' + (t.method||'GET').toLowerCase() + '">' + (t.method||'GET') + '</span>';
2541
+ h += '<span class="item-name">' + esc(t.name) + '</span>';
2542
+ if (resps.length > 0) h += '<span class="item-badge">' + resps.length + '</span>';
2543
+ h += '</div>';
2544
+ if (isExp && resps.length > 0) {
2545
+ resps.forEach(function(r) {
2546
+ var isRActive = activeRespId === r.id && activeTestId === t.id;
2547
+ var isSocket = r.dir;
2548
+ h += '<div class="sidebar-child' + (isRActive ? ' active' : '') + '" data-test="' + t.id + '" data-resp="' + r.id + '">';
2549
+ if (isSocket) {
2550
+ var dirCls = r.dir === 'received' ? 'dir-recv' : r.dir === 'sent' ? 'dir-sent' : 'dir-sys';
2551
+ h += '<span class="sb ' + dirCls + '">' + (r.dir === 'received' ? '&#8595;' : r.dir === 'sent' ? '&#8593;' : '&#9679;') + '</span>';
2552
+ } else {
2553
+ h += '<span class="sb s' + Math.floor(r.status/100) + '">' + r.status + '</span>';
2554
+ }
2555
+ h += '<span class="child-label">' + esc(r.label) + '</span>';
2556
+ if (!isSocket && r.time) h += '<span class="child-time">' + r.time + 'ms</span>';
2557
+ h += '</div>';
2558
+ });
2559
+ }
2560
+ });
2561
+ if (ft.length === 0) h = '<div style="padding:20px;text-align:center;color:#475569;font-size:12px">No endpoints found</div>';
2562
+ sb.innerHTML = h;
2563
+ }
2564
+
2565
+ // Search
2566
+ document.getElementById('search-inp').addEventListener('input', function(e) {
2567
+ searchQuery = e.target.value;
2568
+ renderSidebar();
2569
+ });
2570
+
2571
+ document.getElementById('sidebar-list').addEventListener('click', function(e) {
2572
+ var el = e.target.closest('.sidebar-item, .sidebar-child');
2573
+ if (!el) return;
2574
+ var testId = el.getAttribute('data-test');
2575
+ var respId = el.getAttribute('data-resp');
2576
+ if (respId) {
2577
+ activeTestId = testId; activeRespId = respId;
2578
+ renderSidebar(); renderRespView(testId, respId);
2579
+ } else {
2580
+ var wasActive = activeTestId === testId && !activeRespId;
2581
+ activeTestId = testId; activeRespId = null;
2582
+ var resps = responses[testId] || [];
2583
+ if (resps.length > 0) { if (wasActive) expanded[testId] = !expanded[testId]; else expanded[testId] = true; }
2584
+ renderSidebar(); renderTestView(testId);
2585
+ }
2586
+ });
2587
+
2588
+ function renderTestView(id) {
2589
+ var t = tests.find(function(x){ return x.id === id; });
2590
+ if (!t) return;
2591
+ var tt = t.type || 'rest';
2592
+ var main = document.getElementById('main-content');
2593
+
2594
+ if (tt === 'socketio' || tt === 'websocket') {
2595
+ var h = '<div class="card"><div class="card-header">';
2596
+ if (tt === 'socketio') h += '<span class="mb type-sio" style="font-size:11px;padding:4px 10px">Socket.IO</span>';
2597
+ else h += '<span class="mb type-ws" style="font-size:11px;padding:4px 10px">WebSocket</span>';
2598
+ h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span></div>';
2599
+ h += '<div class="stitle">Connection URL</div>';
2600
+ h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy</button></div>';
2601
+ if (tt === 'socketio' && t.event) { h += '<div class="stitle">Emit Event</div><pre style="margin-bottom:16px">' + esc(t.event) + '</pre>'; }
2602
+ if (t.data || t.message) {
2603
+ h += '<div class="stitle">' + (tt === 'socketio' ? 'Event Data' : 'Message') + '</div>';
2604
+ var d = t.data || t.message || ''; try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
2605
+ h += '<pre style="margin-bottom:16px">' + esc(d) + '</pre>';
2606
+ }
2607
+ if (tt === 'socketio' && t.listeners && t.listeners.length > 0) {
2608
+ h += '<div class="stitle">Listeners</div><div style="display:flex;flex-wrap:wrap;gap:6px">';
2609
+ t.listeners.forEach(function(l) { h += '<span style="font-size:11px;padding:4px 10px;border-radius:6px;background:rgba(52,211,153,0.1);color:#34d399">' + esc(l) + '</span>'; });
2610
+ h += '</div>';
2611
+ }
2612
+ h += '</div>'; main.innerHTML = h; return;
2613
+ }
2614
+
2615
+ var mc = { GET:'#34d399', POST:'#f59e0b', PUT:'#3b82f6', PATCH:'#8b5cf6', DELETE:'#f87171' };
2616
+ var formatted = t.body || '';
2617
+ try { formatted = JSON.stringify(JSON.parse(formatted), null, 2); } catch(e) {}
2618
+ var hasBody = ['POST','PUT','PATCH'].indexOf(t.method) !== -1;
2619
+ var isFormData = t.bodyType === 'form-data' || t.bodyType === 'x-www-form-urlencoded';
2620
+ var hasFormFields = t.formFields && t.formFields.length > 0;
2621
+ var hasJsonBody = hasBody && t.body;
2622
+ var bodyFields = [];
2623
+ if (hasJsonBody && !isFormData) { try { var bo = JSON.parse(t.body); bodyFields = Object.keys(bo); } catch(e) {} }
2624
+
2625
+ var h = '';
2626
+ // Header
2627
+ h += '<div class="card"><div class="card-header">';
2628
+ h += '<span class="mb mb-' + t.method.toLowerCase() + '" style="font-size:12px;padding:4px 10px">' + t.method + '</span>';
2629
+ h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span>';
2630
+ if (isFormData) h += '<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:rgba(245,158,11,0.12);color:#f59e0b;font-weight:600;margin-left:8px">' + (t.bodyType === 'form-data' ? 'multipart/form-data' : 'x-www-form-urlencoded') + '</span>';
2631
+ h += '</div>';
2632
+
2633
+ // URL
2634
+ h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy URL</button></div>';
2635
+
2636
+ // Headers
2637
+ if (t.headers && t.headers.length > 0) {
2638
+ h += '<div class="stitle">Headers</div>';
2639
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;margin-bottom:16px;overflow:hidden">';
2640
+ t.headers.forEach(function(hdr) {
2641
+ if (!hdr.key) return;
2642
+ h += '<div class="header-row"><span class="header-key">' + esc(hdr.key) + '</span><span class="header-val">' + esc(hdr.value||'') + '</span></div>';
2643
+ });
2644
+ h += '</div>';
2645
+ }
2646
+
2647
+ // Form Data Fields
2648
+ if (hasFormFields) {
2649
+ h += '<div class="stitle">Request Fields <span style="font-weight:400;text-transform:none;letter-spacing:0;color:#64748b">(' + (t.bodyType || 'form-data') + ')</span></div>';
2650
+ t.formFields.forEach(function(f) {
2651
+ if (!f.key) return;
2652
+ var isFile = f.type === 'file';
2653
+ h += '<div class="field-row">';
2654
+ h += '<span class="field-name">' + esc(f.key) + '</span>';
2655
+ if (isFile) {
2656
+ h += '<span class="field-type" style="background:rgba(245,158,11,0.12);color:#f59e0b">File</span>';
2657
+ } else {
2658
+ h += '<span class="field-type">Text</span>';
2659
+ }
2660
+ if (f.value && !isFile) h += '<span style="font-size:11px;color:#64748b;font-family:monospace;margin-left:auto">' + esc(f.value) + '</span>';
2661
+ h += '</div>';
2662
+ });
2663
+ h += '<div style="margin-top:12px"></div>';
2664
+ }
2665
+
2666
+ // JSON Body Fields
2667
+ if (!isFormData && hasJsonBody && bodyFields.length > 0) {
2668
+ h += '<div class="stitle">Body Fields</div>';
2669
+ var bodyObj = JSON.parse(t.body);
2670
+ bodyFields.forEach(function(f) {
2671
+ var val = bodyObj[f];
2672
+ var type = typeof val === 'number' ? 'Number' : typeof val === 'boolean' ? 'Boolean' : 'String';
2673
+ h += '<div class="field-row"><span class="field-name">' + esc(f) + '</span><span class="field-type">' + type + '</span></div>';
2674
+ });
2675
+ h += '<div style="margin-top:12px"></div>';
2676
+ }
2677
+
2678
+ // Request Body Example (JSON only)
2679
+ if (!isFormData && hasJsonBody) {
2680
+ h += '<div class="stitle">Request Body Example</div>';
2681
+ h += '<pre style="margin-bottom:16px">' + esc(formatted) + '</pre>';
2682
+ }
2683
+
2684
+ // Code Snippets
2685
+ var codeTab = activeCodeTab[id] || 'curl';
2686
+ h += '<div class="stitle">Code Examples</div>';
2687
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;overflow:hidden;margin-bottom:16px">';
2688
+ h += '<div class="code-tabs">';
2689
+ ['curl','fetch','axios'].forEach(function(tab) {
2690
+ h += '<button class="code-tab' + (codeTab === tab ? ' active' : '') + '" data-codetab="' + tab + '" data-testid="' + id + '">' + (tab === 'curl' ? 'cURL' : tab.charAt(0).toUpperCase() + tab.slice(1)) + '</button>';
2691
+ });
2692
+ h += '</div>';
2693
+ var code = codeTab === 'curl' ? genCurl(t) : codeTab === 'fetch' ? genFetch(t) : genAxios(t);
2694
+ h += '<div style="position:relative"><pre style="border:none;border-radius:0;margin:0">' + esc(code) + '</pre>';
2695
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button>';
2696
+ h += '</div></div>';
2697
+
2698
+ h += '</div>';
2699
+
2700
+ // Saved Responses
2701
+ var resps = responses[id] || [];
2702
+ if (resps.length > 0) {
2703
+ h += '<div class="stitle" style="margin-top:8px">Saved Responses (' + resps.length + ')</div>';
2704
+ resps.forEach(function(r) {
2705
+ var sc = Math.floor(r.status/100);
2706
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
2707
+ h += '<div class="card" style="padding:0;overflow:hidden">';
2708
+ h += '<div style="padding:10px 16px;border-bottom:1px solid #1e2433;display:flex;align-items:center;gap:10px">';
2709
+ h += '<span class="sb s' + sc + '" style="font-size:11px;padding:3px 8px">' + r.status + ' ' + esc(r.statusText||'') + '</span>';
2710
+ h += '<span style="font-size:13px;font-weight:500">' + esc(r.label) + '</span>';
2711
+ if (r.time) h += '<span style="font-size:11px;color:#475569;margin-left:auto">' + r.time + 'ms</span>';
2712
+ h += '</div>';
2713
+ if (r.requestBody && r.requestBody.trim()) {
2714
+ var rb = r.requestBody; try { rb = JSON.stringify(JSON.parse(rb), null, 2); } catch(e) {}
2715
+ h += '<div style="padding:12px 16px;border-bottom:1px solid #1e2433"><div class="stitle" style="margin-bottom:6px">Request Body</div><pre style="margin:0">' + esc(rb) + '</pre></div>';
2716
+ }
2717
+ h += '<div style="padding:12px 16px"><div class="stitle" style="margin-bottom:6px">Response Body</div><pre style="margin:0">' + esc(body) + '</pre></div>';
2718
+ h += '</div>';
2719
+ });
2720
+ }
2721
+
2722
+ main.innerHTML = h;
2723
+ }
2724
+
2725
+ function renderRespView(testId, respId) {
2726
+ var resps = responses[testId] || [];
2727
+ var r = resps.find(function(x){ return x.id === respId; });
2728
+ if (!r) return;
2729
+ var main = document.getElementById('main-content');
2730
+ var t = tests.find(function(x){ return x.id === testId; });
2731
+ var isSocket = r.dir;
2732
+
2733
+ var h = '<div class="card"><div class="card-header">';
2734
+ if (isSocket) {
2735
+ var dirCls = r.dir === 'received' ? 'dir-recv' : r.dir === 'sent' ? 'dir-sent' : 'dir-sys';
2736
+ h += '<span class="sb ' + dirCls + '" style="font-size:12px;padding:4px 10px">' + (r.dir === 'received' ? 'Received' : r.dir === 'sent' ? 'Sent' : 'System') + '</span>';
2737
+ } else {
2738
+ h += '<span class="sb s' + Math.floor(r.status/100) + '" style="font-size:12px;padding:4px 10px">' + r.status + ' ' + esc(r.statusText||'') + '</span>';
2739
+ }
2740
+ h += '<span style="font-size:16px;font-weight:600">' + esc(r.label) + '</span>';
2741
+ if (!isSocket && r.time) h += '<span style="font-size:11px;color:#475569;margin-left:auto">' + r.time + 'ms</span>';
2742
+ h += '</div>';
2743
+
2744
+ if (t) { h += '<div class="endpoint-url" style="margin-bottom:16px"><code>' + esc(t.url) + '</code></div>'; }
2745
+
2746
+ if (!isSocket && r.requestBody && r.requestBody.trim()) {
2747
+ var rb = r.requestBody; try { rb = JSON.stringify(JSON.parse(rb), null, 2); } catch(e) {}
2748
+ h += '<div class="stitle">Request Body</div><pre style="margin-bottom:16px">' + esc(rb) + '</pre>';
2749
+ }
2750
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
2751
+ h += '<div class="stitle">' + (isSocket ? 'Data' : 'Response Body') + '</div><pre>' + esc(body) + '</pre>';
2752
+ h += '</div>';
2753
+
2754
+ main.innerHTML = h;
2755
+ }
2756
+
2757
+ // Code tab switching
2758
+ document.getElementById('main-content').addEventListener('click', function(e) {
2759
+ var tab = e.target.closest('[data-codetab]');
2760
+ if (tab) {
2761
+ var testId = tab.getAttribute('data-testid');
2762
+ activeCodeTab[testId] = tab.getAttribute('data-codetab');
2763
+ renderTestView(testId);
2764
+ return;
2765
+ }
2766
+ });
2767
+
2768
+ renderSidebar();
2769
+ if (tests.length > 0) {
2770
+ activeTestId = tests[0].id;
2771
+ var firstResps = responses[tests[0].id] || [];
2772
+ if (firstResps.length > 0) expanded[tests[0].id] = true;
2773
+ renderSidebar();
2774
+ renderTestView(tests[0].id);
2775
+ }
2776
+ })();
2777
+ </script></body></html>`;
2778
+
2779
+ res.type('html').send(html);
2780
+ } catch (err) {
2781
+ res.status(500).send('Error loading collection');
2782
+ }
2783
+ });
2784
+
2785
+ export default router;