4bnode 4.1.2 → 4.1.3

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 (62) hide show
  1. package/add-security.js +0 -18
  2. package/app-1/.4bnode/.passkey +1 -0
  3. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  4. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  5. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  6. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  7. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  8. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  9. package/app-1/.4bnode/assets/js/App.js +139 -0
  10. package/app-1/.4bnode/assets/js/Root.js +40 -0
  11. package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
  12. package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
  13. package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
  14. package/app-1/.4bnode/assets/js/context.js +7 -0
  15. package/app-1/.4bnode/assets/js/lib.js +113 -0
  16. package/app-1/.4bnode/assets/js/main.js +5 -0
  17. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  18. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
  19. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  20. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
  21. package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
  22. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
  23. package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
  24. package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
  25. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  26. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
  27. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
  28. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  29. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  30. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  31. package/app-1/.4bnode/codegen.js +1249 -0
  32. package/app-1/.4bnode/dev-api.js +3621 -0
  33. package/app-1/.4bnode/ui.html +1578 -0
  34. package/app-1/README.md +47 -0
  35. package/app-1/ecosystem.config.cjs +25 -0
  36. package/app-1/index.js +145 -0
  37. package/app-1/package.json +35 -0
  38. package/app-1/public/index.html +292 -0
  39. package/app-1/src/db.js +13 -0
  40. package/app-1/src/docs.js +84 -0
  41. package/app-1/src/middleware/errorHandler.js +15 -0
  42. package/app-1/src/middleware/roles.js +14 -0
  43. package/app-1/src/middleware/validate.js +20 -0
  44. package/app-1/src/models/login.js +9 -0
  45. package/app-1/src/openapi.json +62 -0
  46. package/app-1/src/routes/products.js +26 -0
  47. package/app-1/src/validators/products.js +7 -0
  48. package/app-1/src/validators/user.js +7 -0
  49. package/index.js +2 -17
  50. package/lib/codegen.js +69 -43
  51. package/lib/codegen.test.js +21 -2
  52. package/package.json +1 -1
  53. package/skeleton/.4bnode/assets/js/App.js +0 -2
  54. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
  55. package/skeleton/.4bnode/assets/js/lib.js +0 -3
  56. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
  57. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
  58. package/skeleton/.4bnode/codegen.js +69 -43
  59. package/skeleton/.4bnode/dev-api.js +173 -87
  60. package/skeleton/_gitignore +0 -1
  61. package/skeleton/index.js +4 -8
  62. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
@@ -0,0 +1,3621 @@
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
+ import { pathToFileURL } from 'url';
7
+ const execAsync = promisify(exec);
8
+ import crypto from 'crypto';
9
+ // Shared, dependency-free code generation. This file is a verbatim copy of the
10
+ // 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen
11
+ // in the package, not here — it is regenerated when a project is scaffolded.
12
+ import {
13
+ toCamelCase,
14
+ toPascalCase,
15
+ sanitizeName,
16
+ addImportToContent,
17
+ insertCodeIntoContent,
18
+ buildInsertCode,
19
+ buildReadCode,
20
+ buildUpdateCode,
21
+ buildDeleteCode,
22
+ generateMongooseModel,
23
+ buildZodSchemaFile,
24
+ wireValidateIntoRouteContent,
25
+ buildAuthMiddleware,
26
+ wireAuthIntoRouteContent,
27
+ addImportAfterLastImport,
28
+ insertBeforeListenContent,
29
+ AI_PLAN_SCHEMA,
30
+ AI_SYSTEM_PROMPT,
31
+ AI_PROVIDERS,
32
+ DEFAULT_AI_PROVIDER,
33
+ normalizeAiPlan,
34
+ buildCrudRouteFile,
35
+ addDepsToPackageJson,
36
+ extractRequestFields,
37
+ buildOpenApiSpec,
38
+ buildDocsRouter,
39
+ MAIL_PROVIDERS,
40
+ buildMailerService,
41
+ } from './codegen.js';
42
+
43
+ const router = express.Router();
44
+
45
+ // ── Network restriction ─────────────────────────────────────────────────────
46
+ // This router can write files and run shell commands — it is a remote-code-
47
+ // execution surface. Refuse any request that does not originate from the local
48
+ // machine, unless an operator has *explicitly* opted into remote access (in
49
+ // which case a passkey becomes mandatory, enforced by the auth middleware
50
+ // below). The check uses the raw TCP peer address — never X-Forwarded-* — so it
51
+ // cannot be spoofed by a request header. Behind a reverse proxy the peer is the
52
+ // proxy itself, so external traffic must also be blocked at the edge.
53
+ // Defense-in-depth production kill-switch. The dashboard should never be mounted
54
+ // in production (see index.js), but if it somehow is — a manual mount, a runtime
55
+ // NODE_ENV flip, a misconfigured host — refuse every request and behave as if the
56
+ // route does not exist (404, no information leak). Set _4BNODE_DASHBOARD=on to
57
+ // force-enable (loopback + passkey still apply).
58
+ const DASHBOARD_ENABLED =
59
+ process.env._4BNODE_DASHBOARD === 'on' ||
60
+ (process.env.NODE_ENV !== 'production' && process.env._4BNODE_DASHBOARD !== 'off');
61
+ router.use((req, res, next) => {
62
+ if (!DASHBOARD_ENABLED) return res.status(404).end();
63
+ next();
64
+ });
65
+
66
+ const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1';
67
+ function isLoopbackRequest(req) {
68
+ const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '');
69
+ return ip === '127.0.0.1' || ip === '::1';
70
+ }
71
+ router.use((req, res, next) => {
72
+ if (ALLOW_REMOTE || isLoopbackRequest(req)) return next();
73
+ return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' });
74
+ });
75
+
76
+ // ── Console Log Capture ─────────────────────────────────────────────────────
77
+ const _logBuffer = [];
78
+ const _logMaxSize = 500;
79
+ const _logClients = new Set();
80
+
81
+ function pushLog(type, args) {
82
+ const entry = {
83
+ id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
84
+ type,
85
+ message: args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' '),
86
+ timestamp: new Date().toISOString(),
87
+ };
88
+ _logBuffer.push(entry);
89
+ if (_logBuffer.length > _logMaxSize) _logBuffer.splice(0, _logBuffer.length - _logMaxSize);
90
+ for (const client of _logClients) {
91
+ client.write(`data: ${JSON.stringify(entry)}\n\n`);
92
+ }
93
+ }
94
+
95
+ const _origLog = console.log.bind(console);
96
+ const _origError = console.error.bind(console);
97
+ const _origWarn = console.warn.bind(console);
98
+ const _origInfo = console.info.bind(console);
99
+
100
+ console.log = (...args) => { _origLog(...args); pushLog('log', args); };
101
+ console.error = (...args) => { _origError(...args); pushLog('error', args); };
102
+ console.warn = (...args) => { _origWarn(...args); pushLog('warn', args); };
103
+ console.info = (...args) => { _origInfo(...args); pushLog('info', args); };
104
+
105
+ // Capture uncaught errors
106
+ process.on('uncaughtException', (err) => {
107
+ pushLog('error', [`Uncaught Exception: ${err.stack || err.message}`]);
108
+ _origError('Uncaught Exception:', err);
109
+ });
110
+ process.on('unhandledRejection', (reason) => {
111
+ pushLog('error', [`Unhandled Rejection: ${reason?.stack || reason}`]);
112
+ _origError('Unhandled Rejection:', reason);
113
+ });
114
+
115
+ // Serve dashboard UI and assets
116
+ const __4bnodeDir = path.join(process.cwd(), '.4bnode');
117
+ router.get('/', (req, res) => {
118
+ res.sendFile('ui.html', { root: __4bnodeDir });
119
+ });
120
+ router.use('/assets', express.static(path.join(__4bnodeDir, 'assets')));
121
+
122
+ // ── Passkey Authentication ──────────────────────────────────────────────────
123
+ function getPasskeyHash() {
124
+ const file = path.join(__4bnodeDir, '.passkey');
125
+ if (!fs.existsSync(file)) return null;
126
+ return fs.readFileSync(file, 'utf8').trim() || null;
127
+ }
128
+
129
+ // Constant-time verification. Supports the current salted-scrypt format
130
+ // (`scrypt$<saltHex>$<hashHex>`) and the legacy unsalted sha256 hex digest so
131
+ // apps generated before the upgrade keep working.
132
+ function verifyPasskey(candidate, stored) {
133
+ if (!candidate || !stored) return false;
134
+ try {
135
+ if (stored.startsWith('scrypt$')) {
136
+ const [, saltHex, hashHex] = stored.split('$');
137
+ const salt = Buffer.from(saltHex, 'hex');
138
+ const expected = Buffer.from(hashHex, 'hex');
139
+ const actual = crypto.scryptSync(String(candidate), salt, expected.length);
140
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
141
+ }
142
+ const expected = Buffer.from(stored, 'hex');
143
+ const actual = crypto.createHash('sha256').update(String(candidate)).digest();
144
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ // Brute-force throttle. After AUTH_MAX_FAILS wrong guesses from one peer, lock it
151
+ // out for AUTH_LOCK_MS. In-memory (clears on restart) — enough to stop online
152
+ // guessing, which is the realistic attack once the dashboard is reachable.
153
+ const AUTH_MAX_FAILS = 5;
154
+ const AUTH_LOCK_MS = 60_000;
155
+ const _authFails = new Map(); // ip -> { fails, lockedUntil }
156
+ function authClientKey(req) {
157
+ return String((req.socket && req.socket.remoteAddress) || 'unknown').replace(/^::ffff:/, '');
158
+ }
159
+ function authLockState(req) {
160
+ const e = _authFails.get(authClientKey(req));
161
+ if (e && e.lockedUntil > Date.now()) {
162
+ return { locked: true, retryAfter: Math.ceil((e.lockedUntil - Date.now()) / 1000) };
163
+ }
164
+ return { locked: false, retryAfter: 0 };
165
+ }
166
+ function recordAuthFailure(req) {
167
+ const key = authClientKey(req);
168
+ const e = _authFails.get(key) || { fails: 0, lockedUntil: 0 };
169
+ e.fails += 1;
170
+ if (e.fails >= AUTH_MAX_FAILS) { e.lockedUntil = Date.now() + AUTH_LOCK_MS; e.fails = 0; }
171
+ _authFails.set(key, e);
172
+ }
173
+ function recordAuthSuccess(req) {
174
+ _authFails.delete(authClientKey(req));
175
+ }
176
+
177
+ // Verify passkey endpoint (no auth middleware needed here)
178
+ router.post('/api/auth', (req, res) => {
179
+ const lock = authLockState(req);
180
+ if (lock.locked) {
181
+ res.set('Retry-After', String(lock.retryAfter));
182
+ return res.status(429).json({ error: `Too many attempts. Try again in ${lock.retryAfter}s.` });
183
+ }
184
+ const { passkey } = req.body || {};
185
+ const stored = getPasskeyHash();
186
+ if (!stored) {
187
+ // No passkey configured: permit only loopback dev. A missing passkey must
188
+ // never authenticate a remote caller (deny by default).
189
+ if (!isLoopbackRequest(req)) {
190
+ return res.status(401).json({ error: 'A dashboard passkey must be configured for remote access.' });
191
+ }
192
+ return res.json({ ok: true });
193
+ }
194
+ if (!verifyPasskey(passkey, stored)) {
195
+ // Only count a real guess (non-empty) so an unauthenticated page load that
196
+ // probes with an empty passkey doesn't burn the lockout budget.
197
+ if (passkey) recordAuthFailure(req);
198
+ const after = authLockState(req);
199
+ if (after.locked) {
200
+ res.set('Retry-After', String(after.retryAfter));
201
+ return res.status(429).json({ error: `Too many attempts. Try again in ${after.retryAfter}s.` });
202
+ }
203
+ return res.status(401).json({ error: 'Invalid passkey' });
204
+ }
205
+ recordAuthSuccess(req);
206
+ res.json({ ok: true });
207
+ });
208
+
209
+ // Auth middleware — protect all /api/* routes below
210
+ router.use('/api', (req, res, next) => {
211
+ if (req.path === '/auth') return next();
212
+ // SSE endpoint handles its own auth via query param (EventSource can't send headers)
213
+ if (req.path === '/logs/stream') return next();
214
+ const stored = getPasskeyHash();
215
+ if (!stored) {
216
+ // Deny by default: a missing passkey may only pass for loopback dev, never
217
+ // for a remote caller (relevant when _4BNODE_ALLOW_REMOTE is enabled).
218
+ if (!isLoopbackRequest(req)) return res.status(401).json({ error: 'Unauthorized' });
219
+ return next();
220
+ }
221
+ const lock = authLockState(req);
222
+ if (lock.locked) {
223
+ res.set('Retry-After', String(lock.retryAfter));
224
+ return res.status(429).json({ error: 'Too many attempts. Try again later.' });
225
+ }
226
+ const passkey = req.headers['x-passkey'];
227
+ if (!verifyPasskey(passkey, stored)) {
228
+ if (passkey) recordAuthFailure(req);
229
+ return res.status(401).json({ error: 'Unauthorized' });
230
+ }
231
+ next();
232
+ });
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Utility functions (name helpers + code builders are imported from ./codegen.js)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function listModels() {
239
+ const modelsDir = path.join(process.cwd(), "src", "models");
240
+ if (!fs.existsSync(modelsDir)) {
241
+ return [];
242
+ }
243
+ return fs.readdirSync(modelsDir).filter((f) => f.endsWith(".js"));
244
+ }
245
+
246
+ function listRoutes() {
247
+ const routesDir = path.join(process.cwd(), "src", "routes");
248
+ if (!fs.existsSync(routesDir)) {
249
+ return [];
250
+ }
251
+ return fs.readdirSync(routesDir).filter((f) => f.endsWith(".js"));
252
+ }
253
+
254
+ function checkMongoConfig() {
255
+ const envPath = path.join(process.cwd(), ".env");
256
+ return (
257
+ fs.existsSync(envPath) &&
258
+ fs.readFileSync(envPath, "utf8").includes("MONGO_URI")
259
+ );
260
+ }
261
+
262
+ function getEnvValue(filePath, key) {
263
+ if (!fs.existsSync(filePath)) return '';
264
+ const content = fs.readFileSync(filePath, 'utf8');
265
+ const match = content.match(new RegExp(`^${key}=(.*)`, 'm'));
266
+ return match ? match[1].trim() : '';
267
+ }
268
+
269
+ function extractSchemaBlock(content) {
270
+ const marker = "new mongoose.Schema(";
271
+ const idx = content.indexOf(marker);
272
+ if (idx === -1) return null;
273
+ // Find the opening { after the marker
274
+ const braceStart = content.indexOf("{", idx + marker.length);
275
+ if (braceStart === -1) return null;
276
+ // Match balanced braces to find the outer schema object
277
+ let depth = 0;
278
+ for (let i = braceStart; i < content.length; i++) {
279
+ if (content[i] === "{") depth++;
280
+ else if (content[i] === "}") {
281
+ depth--;
282
+ if (depth === 0) return content.slice(braceStart + 1, i);
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ // The fields a route actually accepts in its body — used to generate a Zod schema
289
+ // scoped to THAT route (not the whole model, which many routes share with differing
290
+ // field subsets). Pulls types from the model where known; skips File fields (not in
291
+ // req.body). Returns null when the route takes no validatable body.
292
+ function routeValidatorFields(type, opts) {
293
+ const { findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName } = opts;
294
+ const modelFields = {};
295
+ try {
296
+ if (modelName) (getModelSchemaDetailed(sanitizeName(modelName)).fields || []).forEach((f) => { modelFields[f.name] = f; });
297
+ } catch {}
298
+ const make = (name, fallbackType, required) => {
299
+ const mf = modelFields[name];
300
+ return { name, type: (mf && mf.type) || fallbackType || 'String', required };
301
+ };
302
+ if (type === 'login') {
303
+ const fields = [make(findField, 'String', true)];
304
+ if (hasPassword) fields.push(make(passwordField, 'String', true));
305
+ return fields;
306
+ }
307
+ if (type === 'register') {
308
+ const regFields = (registerFields && registerFields.length) ? registerFields : [findField];
309
+ return regFields.map((f) => make(f, 'String', true));
310
+ }
311
+ if (type === 'crud' || type === 'methods') {
312
+ return (allFieldsCombined || [])
313
+ .filter((f) => f.type !== 'File')
314
+ .map((f) => {
315
+ const mf = modelFields[f.name];
316
+ const required = mf ? !!mf.required : f.required !== false;
317
+ return { name: f.name, type: (f.type && f.type !== 'File' && f.type) || (mf && mf.type) || 'String', required };
318
+ });
319
+ }
320
+ return null;
321
+ }
322
+
323
+ function getModelSchemaDetailed(modelName) {
324
+ const modelFilePath = path.join(
325
+ process.cwd(),
326
+ "src",
327
+ "models",
328
+ modelName + ".js"
329
+ );
330
+ if (!fs.existsSync(modelFilePath)) {
331
+ return { fields: [], indexes: [] };
332
+ }
333
+
334
+ const content = fs.readFileSync(modelFilePath, "utf8");
335
+ const schemaContent = extractSchemaBlock(content);
336
+ if (!schemaContent) return { fields: [], indexes: [] };
337
+ const fields = [];
338
+ // Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... }
339
+ const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g;
340
+ let fm;
341
+ while ((fm = fieldRegex.exec(schemaContent)) !== null) {
342
+ const name = fm[1];
343
+ const props = fm[2];
344
+ const typeMatch = props.match(/type:\s*([\w.]+)/);
345
+ const type = typeMatch
346
+ ? typeMatch[1].replace("mongoose.Schema.Types.", "")
347
+ : "String";
348
+ const required = /required:\s*true/.test(props);
349
+ const unique = /unique:\s*true/.test(props);
350
+ const indexed = /index:\s*true/.test(props);
351
+ const refMatch = props.match(/ref:\s*['"](\w+)['"]/);
352
+ const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/);
353
+ const field = { name, type, required, unique };
354
+ if (indexed) field.index = true;
355
+ if (refMatch) field.ref = refMatch[1];
356
+ if (defaultMatch) {
357
+ field.default = defaultMatch[1].trim();
358
+ }
359
+ fields.push(field);
360
+ }
361
+
362
+ // Parse compound/text indexes: SchemaName.index({ ... })
363
+ const indexes = [];
364
+ const indexRegex = /\w+Schema\.index\(\s*\{([^}]+)\}(?:\s*,\s*\{([^}]*)\})?\s*\)/g;
365
+ let idxMatch;
366
+ while ((idxMatch = indexRegex.exec(content)) !== null) {
367
+ const fieldsStr = idxMatch[1];
368
+ const optsStr = idxMatch[2] || '';
369
+ const idxFields = [];
370
+ const fieldEntries = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
371
+ for (const entry of fieldEntries) {
372
+ const [key, val] = entry.split(':').map(s => s.trim());
373
+ if (val === "'text'" || val === '"text"') {
374
+ idxFields.push({ field: key, direction: 'text' });
375
+ } else {
376
+ idxFields.push({ field: key, direction: parseInt(val) || 1 });
377
+ }
378
+ }
379
+ const isUnique = /unique:\s*true/.test(optsStr);
380
+ const isText = idxFields.some(f => f.direction === 'text');
381
+ indexes.push({ fields: idxFields, unique: isUnique, type: isText ? 'text' : 'compound' });
382
+ }
383
+
384
+ return { fields, indexes };
385
+ }
386
+
387
+ function getModelSchema(modelName) {
388
+ const modelFilePath = path.join(
389
+ process.cwd(),
390
+ "src",
391
+ "models",
392
+ modelName + ".js"
393
+ );
394
+ if (!fs.existsSync(modelFilePath)) {
395
+ return [];
396
+ }
397
+
398
+ const content = fs.readFileSync(modelFilePath, "utf8");
399
+ const schemaContent = extractSchemaBlock(content);
400
+ if (!schemaContent) return [];
401
+
402
+ const fieldRegex = /(\w+)\s*:\s*\{/g;
403
+ const fields = [];
404
+ let fieldMatch;
405
+ while ((fieldMatch = fieldRegex.exec(schemaContent)) !== null) {
406
+ fields.push(fieldMatch[1]);
407
+ }
408
+ return fields;
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Route file manipulation functions
413
+ // ---------------------------------------------------------------------------
414
+
415
+ // File-based wrappers around the shared pure string helpers in codegen.js.
416
+ function addImportToRoute(filePath, importStatement) {
417
+ const original = fs.readFileSync(filePath, "utf8");
418
+ const content = addImportToContent(original, importStatement);
419
+ if (content !== original) {
420
+ fs.writeFileSync(filePath, content, "utf8");
421
+ }
422
+ return content;
423
+ }
424
+
425
+ function insertCodeIntoRoute(filePath, codeBlock) {
426
+ const content = insertCodeIntoContent(
427
+ fs.readFileSync(filePath, "utf8"),
428
+ codeBlock
429
+ );
430
+ fs.writeFileSync(filePath, content, "utf8");
431
+ }
432
+
433
+ function detectHttpMethod(filePath) {
434
+ const content = fs.readFileSync(filePath, "utf8");
435
+ const methodRegex =
436
+ /router\.(get|post|put|patch|delete)\((['"`])\/.*?\2,\s*async\s*\(req,\s*res/;
437
+ const match = content.match(methodRegex);
438
+ return match ? match[1].toLowerCase() : "post";
439
+ }
440
+
441
+ function getRequestDataSource(method) {
442
+ return method === "get" || method === "delete" ? "req.query" : "req.body";
443
+ }
444
+
445
+ // CRUD route builders and the Mongoose model generator are imported from
446
+ // ./codegen.js. `generateSchemaFileContent` is kept as a thin alias so existing
447
+ // call sites below remain unchanged.
448
+ const generateSchemaFileContent = generateMongooseModel;
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // API Routes
452
+ // ---------------------------------------------------------------------------
453
+
454
+ // GET /api/dashboard
455
+ router.get('/api/dashboard', (req, res) => {
456
+ try {
457
+ const modelFiles = listModels();
458
+ const models = modelFiles.map((f) => {
459
+ const name = path.basename(f, '.js');
460
+ const { fields, indexes } = getModelSchemaDetailed(name);
461
+ return { name, fields, indexes };
462
+ });
463
+ const routes = listRoutes();
464
+ const mongoConfigured = checkMongoConfig();
465
+ const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
466
+
467
+ let dbType = null;
468
+ const indexPath = path.join(process.cwd(), 'index.js');
469
+ if (fs.existsSync(indexPath)) {
470
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
471
+ if (indexContent.includes('import connectDB') || indexContent.includes('mongoose')) {
472
+ dbType = 'mongodb';
473
+ }
474
+ }
475
+
476
+ const authConfigured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
477
+
478
+ let mongoUri = '';
479
+ if (mongoConfigured) {
480
+ mongoUri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
481
+ }
482
+
483
+ res.json({ models, routes, mongoConfigured, dbType, routesDirExists, authConfigured, mongoUri });
484
+ } catch (err) {
485
+ res.status(500).json({ error: err.message });
486
+ }
487
+ });
488
+
489
+ // GET /api/models
490
+ router.get('/api/models', (req, res) => {
491
+ try {
492
+ const modelFiles = listModels();
493
+ const names = modelFiles.map((f) => path.basename(f, '.js'));
494
+ res.json(names);
495
+ } catch (err) {
496
+ res.status(500).json({ error: err.message });
497
+ }
498
+ });
499
+
500
+ // GET /api/models/:name
501
+ router.get('/api/models/:name', (req, res) => {
502
+ try {
503
+ const name = req.params.name;
504
+ const { fields, indexes } = getModelSchemaDetailed(name);
505
+ res.json({ name, fields, indexes });
506
+ } catch (err) {
507
+ res.status(500).json({ error: err.message });
508
+ }
509
+ });
510
+
511
+ // GET /api/models/:name/data — fetch stored documents from MongoDB collection
512
+ router.get('/api/models/:name/data', async (req, res) => {
513
+ try {
514
+ const name = req.params.name;
515
+ const rawLimit = parseInt(req.query.limit);
516
+ const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500);
517
+ const skip = parseInt(req.query.skip) || 0;
518
+ const pascalName = toPascalCase(name);
519
+
520
+ // Dynamically import mongoose from the project's node_modules
521
+ const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js');
522
+ if (!fs.existsSync(projectMongoose)) {
523
+ return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' });
524
+ }
525
+ const mongoose = (await import(pathToFileURL(projectMongoose).href)).default;
526
+
527
+ // Connect if not already connected
528
+ if (mongoose.connection.readyState === 0) {
529
+ const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI');
530
+ if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' });
531
+ await mongoose.connect(uri);
532
+ }
533
+
534
+ const collection = mongoose.connection.db.collection(name + 's');
535
+ const total = await collection.countDocuments();
536
+ const query = collection.find({}).skip(skip);
537
+ if (limit > 0) query.limit(limit);
538
+ const docs = await query.toArray();
539
+
540
+ res.json({ docs, total, limit, skip });
541
+ } catch (err) {
542
+ res.status(500).json({ error: err.message });
543
+ }
544
+ });
545
+
546
+ // GET /api/models/:name/share — generate shareable endpoint documentation
547
+ router.get('/api/models/:name/share', (req, res) => {
548
+ try {
549
+ const name = req.params.name;
550
+ const { fields } = getModelSchemaDetailed(name);
551
+ const routes = listRoutes();
552
+ const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js');
553
+
554
+ let endpoints = [];
555
+ if (routeFile) {
556
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
557
+ const content = fs.readFileSync(filePath, 'utf8');
558
+ const regex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi;
559
+ let m;
560
+ while ((m = regex.exec(content)) !== null) {
561
+ endpoints.push({ method: m[1].toUpperCase(), path: m[2] });
562
+ }
563
+ }
564
+
565
+ // Determine the route prefix
566
+ const indexPath = path.join(process.cwd(), 'index.js');
567
+ let prefix = '/api/' + name;
568
+ if (fs.existsSync(indexPath)) {
569
+ const indexContent = fs.readFileSync(indexPath, 'utf8');
570
+ const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${routeFile ? routeFile.replace('.js', '') : name}`));
571
+ if (prefixMatch) prefix = prefixMatch[1];
572
+ }
573
+
574
+ res.json({ name, fields, endpoints, prefix, routeFile: routeFile || null });
575
+ } catch (err) {
576
+ res.status(500).json({ error: err.message });
577
+ }
578
+ });
579
+
580
+ // POST /api/models
581
+ router.post('/api/models', (req, res) => {
582
+ try {
583
+ if (!checkMongoConfig()) {
584
+ return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' });
585
+ }
586
+
587
+ const { name, fields, indexes } = req.body;
588
+ const safeName = sanitizeName(name);
589
+ const pascalName = toPascalCase(safeName);
590
+
591
+ const modelsDir = path.join(process.cwd(), 'src', 'models');
592
+ if (!fs.existsSync(modelsDir)) {
593
+ fs.mkdirSync(modelsDir, { recursive: true });
594
+ }
595
+
596
+ const filePath = path.join(modelsDir, safeName + '.js');
597
+ if (fs.existsSync(filePath)) {
598
+ return res.status(409).json({ error: `Model "${safeName}" already exists.` });
599
+ }
600
+
601
+ const content = generateSchemaFileContent(pascalName, fields, indexes);
602
+ fs.writeFileSync(filePath, content, 'utf8');
603
+ refreshDocsSpec();
604
+ res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` });
605
+ } catch (err) {
606
+ res.status(500).json({ error: err.message });
607
+ }
608
+ });
609
+
610
+ // PUT /api/models/:name
611
+ router.put('/api/models/:name', (req, res) => {
612
+ try {
613
+ const name = req.params.name;
614
+ const { fields, indexes } = req.body;
615
+ const pascalName = toPascalCase(name);
616
+
617
+ const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
618
+ if (!fs.existsSync(filePath)) {
619
+ return res.status(404).json({ error: `Model "${name}" not found.` });
620
+ }
621
+
622
+ const content = generateSchemaFileContent(pascalName, fields, indexes);
623
+ fs.writeFileSync(filePath, content, 'utf8');
624
+ refreshDocsSpec();
625
+ res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` });
626
+ } catch (err) {
627
+ res.status(500).json({ error: err.message });
628
+ }
629
+ });
630
+
631
+ // DELETE /api/models/:name
632
+ router.delete('/api/models/:name', (req, res) => {
633
+ try {
634
+ const name = req.params.name;
635
+ const filePath = path.join(process.cwd(), 'src', 'models', name + '.js');
636
+ if (!fs.existsSync(filePath)) {
637
+ return res.status(404).json({ error: `Model "${name}" not found.` });
638
+ }
639
+
640
+ fs.unlinkSync(filePath);
641
+ refreshDocsSpec();
642
+ res.json({ message: `Model "${name}" deleted.` });
643
+ } catch (err) {
644
+ res.status(500).json({ error: err.message });
645
+ }
646
+ });
647
+
648
+ // GET /api/routes
649
+ router.get('/api/routes', (req, res) => {
650
+ try {
651
+ const routes = listRoutes();
652
+ res.json(routes);
653
+ } catch (err) {
654
+ res.status(500).json({ error: err.message });
655
+ }
656
+ });
657
+
658
+ // GET /api/routes/:name/content
659
+ router.get('/api/routes/:name/content', (req, res) => {
660
+ try {
661
+ const name = req.params.name;
662
+ const filePath = path.join(process.cwd(), 'src', 'routes', name);
663
+ if (!fs.existsSync(filePath)) {
664
+ return res.status(404).json({ error: `Route file "${name}" not found.` });
665
+ }
666
+
667
+ const content = fs.readFileSync(filePath, 'utf8');
668
+ res.json({ name, content });
669
+ } catch (err) {
670
+ res.status(500).json({ error: err.message });
671
+ }
672
+ });
673
+
674
+ // DELETE /api/routes/:name
675
+ router.delete('/api/routes/:name', (req, res) => {
676
+ try {
677
+ const name = req.params.name;
678
+ const routeFile = name.endsWith('.js') ? name : name + '.js';
679
+ const safeName = routeFile.replace('.js', '');
680
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
681
+
682
+ if (!fs.existsSync(filePath)) {
683
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
684
+ }
685
+
686
+ // Delete the route file
687
+ fs.unlinkSync(filePath);
688
+ refreshDocsSpec();
689
+
690
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
691
+ res.json({ message: `Route "${safeName}" deleted successfully.` });
692
+
693
+ // Remove import and registration from index.js
694
+ const indexPath = path.join(process.cwd(), 'index.js');
695
+ if (fs.existsSync(indexPath)) {
696
+ let indexContent = fs.readFileSync(indexPath, 'utf8');
697
+ // Remove import line
698
+ indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${safeName}\\.js['\"];?\\n?`, 'g'), '');
699
+ // Remove app.use registration line
700
+ indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${safeName}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), '');
701
+ fs.writeFileSync(indexPath, indexContent, 'utf8');
702
+ }
703
+ } catch (err) {
704
+ if (!res.headersSent) res.status(500).json({ error: err.message });
705
+ }
706
+ });
707
+
708
+ // ---------------------------------------------------------------------------
709
+ // POST /api/routes/:name/add-method — add handlers to existing route
710
+ // ---------------------------------------------------------------------------
711
+ router.post('/api/routes/:name/add-method', async (req, res) => {
712
+ try {
713
+ const name = req.params.name.replace(/\.js$/, '');
714
+ const routeFile = name + '.js';
715
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
716
+ if (!fs.existsSync(filePath)) {
717
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
718
+ }
719
+
720
+ const { routeType = 'methods', methods: methodList = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
721
+ const pascalModel = modelName ? toPascalCase(modelName) : '';
722
+ const hasPassword = !!passwordField;
723
+ const findField = loginField || 'email';
724
+
725
+ let content = fs.readFileSync(filePath, 'utf8');
726
+
727
+ // Check for duplicate methods
728
+ const existingMethods = [];
729
+ const existCheck = /router\.(get|post|put|patch|delete)\(/gi;
730
+ let em;
731
+ while ((em = existCheck.exec(content)) !== null) existingMethods.push(em[1].toLowerCase());
732
+
733
+ const newMethods = methodList.filter(m => !existingMethods.includes(m.toLowerCase()));
734
+
735
+ // Helper: add import if not present
736
+ function addImport(imp) {
737
+ if (!content.includes(imp.split(' from ')[0])) {
738
+ const lastImport = content.lastIndexOf('import ');
739
+ const lineEnd = content.indexOf('\n', lastImport);
740
+ content = content.slice(0, lineEnd + 1) + imp + '\n' + content.slice(lineEnd + 1);
741
+ }
742
+ }
743
+
744
+ // Helper: insert handler before export
745
+ function insertHandler(handler) {
746
+ const exportIdx = content.lastIndexOf('export default router;');
747
+ if (exportIdx !== -1) {
748
+ content = content.slice(0, exportIdx) + handler + '\n\n' + content.slice(exportIdx);
749
+ } else {
750
+ content += '\n' + handler;
751
+ }
752
+ }
753
+
754
+ let handlers = '';
755
+ const packagesToInstall = [];
756
+
757
+ if (routeType === 'methods') {
758
+ if (newMethods.length === 0) {
759
+ return res.status(400).json({ error: 'All selected methods already exist in this route.' });
760
+ }
761
+
762
+ const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
763
+ const fileFields = allFields.filter(f => f.type === 'File');
764
+ const bodyFields = allFields.filter(f => f.type !== 'File');
765
+ const hasFileFields = fileFields.length > 0;
766
+
767
+ if (hasFileFields) {
768
+ addImport("import multer from 'multer';");
769
+ if (!content.includes('const upload = multer')) {
770
+ content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
771
+ }
772
+ packagesToInstall.push('multer');
773
+ }
774
+
775
+ newMethods.forEach(m => {
776
+ const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
777
+ let mw = '';
778
+ let destruct = '';
779
+
780
+ if (hasFileFields && m !== 'get' && m !== 'delete') {
781
+ if (fileFields.length === 1) mw = `upload.single('${fileFields[0].name}'), `;
782
+ else mw = `upload.fields([${fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
783
+ }
784
+ if (bodyFields.length > 0) destruct += ` const { ${bodyFields.map(f => f.name).join(', ')} } = ${dataSource};\n`;
785
+ if (hasFileFields && m !== 'get' && m !== 'delete') {
786
+ if (fileFields.length === 1) destruct += ` const ${fileFields[0].name} = req.file;\n`;
787
+ else fileFields.forEach(f => { destruct += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`; });
788
+ }
789
+
790
+ if (allFields.length > 0) {
791
+ 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`;
792
+ } else {
793
+ handlers += `\nrouter.${m}('/', async (req, res) => {\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`;
794
+ }
795
+ });
796
+
797
+ } else if (routeType === 'crud' && modelName) {
798
+ addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
799
+ const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m));
800
+ const allCrudFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
801
+ const crudBodyFields = allCrudFields.filter(f => f.type !== 'File');
802
+ const crudFileFields = allCrudFields.filter(f => f.type === 'File');
803
+ const crudHasFiles = crudFileFields.length > 0;
804
+ const crudHasFields = allCrudFields.length > 0;
805
+
806
+ if (crudHasFiles) {
807
+ addImport("import multer from 'multer';");
808
+ if (!content.includes('const upload = multer')) {
809
+ content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });");
810
+ }
811
+ packagesToInstall.push('multer');
812
+ }
813
+
814
+ let dest = '';
815
+ let obj = 'req.body';
816
+ if (crudHasFields) {
817
+ const parts = [];
818
+ if (crudBodyFields.length > 0) {
819
+ dest = `const { ${crudBodyFields.map(f => f.name).join(', ')} } = req.body;`;
820
+ parts.push(...crudBodyFields.map(f => f.name));
821
+ }
822
+ if (crudFileFields.length > 0) {
823
+ const fileLines = crudFileFields.map(f => {
824
+ if (crudFileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
825
+ return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
826
+ }).join('\\n ');
827
+ dest = (dest ? dest + '\\n ' : '') + fileLines;
828
+ parts.push(...crudFileFields.map(f => f.name));
829
+ }
830
+ obj = `{ ${parts.join(', ')} }`;
831
+ }
832
+
833
+ let mw = '';
834
+ if (crudHasFiles) {
835
+ if (crudFileFields.length === 1) mw = `upload.single('${crudFileFields[0].name}'), `;
836
+ else mw = `upload.fields([${crudFileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `;
837
+ }
838
+
839
+ if (crudMethods.includes('get')) {
840
+ 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`;
841
+ }
842
+ if (crudMethods.includes('post')) {
843
+ handlers += `\n// Create\nrouter.post('/', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = new ${pascalModel}(${obj});\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`;
844
+ }
845
+ if (crudMethods.includes('put')) {
846
+ handlers += `\n// Update\nrouter.put('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', 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`;
847
+ }
848
+ if (crudMethods.includes('patch')) {
849
+ handlers += `\n// Partial Update\nrouter.patch('/:id', ${mw}async (req, res) => {\n try {\n ${dest}\n const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${obj}, { returnDocument: 'after', 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`;
850
+ }
851
+ if (crudMethods.includes('delete')) {
852
+ 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`;
853
+ }
854
+
855
+ } else if (routeType === 'login' && modelName) {
856
+ addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
857
+ if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
858
+ addImport("import jwt from 'jsonwebtoken';");
859
+ packagesToInstall.push('jsonwebtoken');
860
+
861
+ if (hasPassword) {
862
+ 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(401).json({ message: 'Invalid credentials' });\n\n const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});\n if (!isMatch) return res.status(401).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`;
863
+ } else {
864
+ 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(401).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`;
865
+ }
866
+
867
+ // Ensure JWT_SECRET
868
+ const envFile = path.join(process.cwd(), '.env');
869
+ if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
870
+
871
+ } else if (routeType === 'register' && modelName) {
872
+ addImport(`import ${pascalModel} from '../models/${modelName}.js';`);
873
+ if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); }
874
+ addImport("import jwt from 'jsonwebtoken';");
875
+ packagesToInstall.push('jsonwebtoken');
876
+
877
+ const regFields = registerFields.length > 0 ? registerFields : [findField];
878
+ const allDestructured = regFields.join(', ');
879
+
880
+ if (hasPassword) {
881
+ const nonPwFields = regFields.filter(f => f !== passwordField);
882
+ 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`;
883
+ } else {
884
+ 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`;
885
+ }
886
+
887
+ const envFile = path.join(process.cwd(), '.env');
888
+ if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
889
+ }
890
+
891
+ if (!handlers) {
892
+ return res.status(400).json({ error: 'Nothing to add.' });
893
+ }
894
+
895
+ // Install packages
896
+ if (packagesToInstall.length > 0) {
897
+ await installDeps([...new Set(packagesToInstall)]);
898
+ }
899
+
900
+ insertHandler(handlers);
901
+ fs.writeFileSync(filePath, content, 'utf8');
902
+ refreshDocsSpec();
903
+
904
+ const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase();
905
+ res.json({ message: `${label} added to "${name}" route.`, content });
906
+ } catch (err) {
907
+ if (!res.headersSent) res.status(500).json({ error: err.message });
908
+ }
909
+ });
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // POST /api/crud/preview
913
+ router.post('/api/crud/preview', (req, res) => {
914
+ try {
915
+ const { modelName, routeFile, operations } = req.body;
916
+ const pascalName = toPascalCase(modelName);
917
+ const fields = getModelSchema(modelName);
918
+ const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
919
+
920
+ let dataSource = 'req.body';
921
+ if (fs.existsSync(routeFilePath)) {
922
+ const method = detectHttpMethod(routeFilePath);
923
+ dataSource = getRequestDataSource(method);
924
+ }
925
+
926
+ const needsBcrypt = fields.includes('password') &&
927
+ (operations.includes('create') || operations.includes('update'));
928
+
929
+ let code = '';
930
+
931
+ // Import block
932
+ let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
933
+ if (needsBcrypt) {
934
+ importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
935
+ }
936
+ code += importBlock + '\n';
937
+
938
+ if (operations.includes('create')) {
939
+ code += buildInsertCode(pascalName, fields, dataSource) + '\n';
940
+ }
941
+ if (operations.includes('read')) {
942
+ code += buildReadCode(pascalName) + '\n';
943
+ }
944
+ if (operations.includes('update')) {
945
+ code += buildUpdateCode(pascalName, fields, dataSource) + '\n';
946
+ }
947
+ if (operations.includes('delete')) {
948
+ code += buildDeleteCode(pascalName) + '\n';
949
+ }
950
+
951
+ res.json({ code });
952
+ } catch (err) {
953
+ res.status(500).json({ error: err.message });
954
+ }
955
+ });
956
+
957
+ // POST /api/crud/generate
958
+ router.post('/api/crud/generate', (req, res) => {
959
+ try {
960
+ const { modelName, routeFile, operations } = req.body;
961
+ const pascalName = toPascalCase(modelName);
962
+ const fields = getModelSchema(modelName);
963
+ const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
964
+
965
+ if (!fs.existsSync(routeFilePath)) {
966
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
967
+ }
968
+
969
+ if (fields.length === 0) {
970
+ return res.status(400).json({ error: 'No fields found in the selected model.' });
971
+ }
972
+
973
+ const method = detectHttpMethod(routeFilePath);
974
+ const dataSource = getRequestDataSource(method);
975
+ const needsBcrypt = fields.includes('password') &&
976
+ (operations.includes('create') || operations.includes('update'));
977
+
978
+ // Build import
979
+ let importBlock = `import ${pascalName} from '../models/${modelName}.js';`;
980
+ if (needsBcrypt) {
981
+ importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`;
982
+ }
983
+ addImportToRoute(routeFilePath, importBlock);
984
+
985
+ // Add selected operations
986
+ const added = [];
987
+
988
+ if (operations.includes('create')) {
989
+ insertCodeIntoRoute(routeFilePath, buildInsertCode(pascalName, fields, dataSource));
990
+ added.push('POST /create');
991
+ }
992
+
993
+ if (operations.includes('read')) {
994
+ insertCodeIntoRoute(routeFilePath, buildReadCode(pascalName));
995
+ added.push('GET / and GET /:id');
996
+ }
997
+
998
+ if (operations.includes('update')) {
999
+ insertCodeIntoRoute(routeFilePath, buildUpdateCode(pascalName, fields, dataSource));
1000
+ added.push('PUT /:id');
1001
+ }
1002
+
1003
+ if (operations.includes('delete')) {
1004
+ insertCodeIntoRoute(routeFilePath, buildDeleteCode(pascalName));
1005
+ added.push('DELETE /:id');
1006
+ }
1007
+
1008
+ res.json({
1009
+ message: `CRUD operations added to ${routeFile}`,
1010
+ model: modelName,
1011
+ routes: added,
1012
+ });
1013
+ } catch (err) {
1014
+ res.status(500).json({ error: err.message });
1015
+ }
1016
+ });
1017
+
1018
+ // GET /api/env
1019
+ router.get('/api/env', (req, res) => {
1020
+ try {
1021
+ const envPath = path.join(process.cwd(), '.env');
1022
+ const content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
1023
+ res.json({ content });
1024
+ } catch (err) {
1025
+ res.status(500).json({ error: err.message });
1026
+ }
1027
+ });
1028
+
1029
+ // PUT /api/env
1030
+ router.put('/api/env', (req, res) => {
1031
+ try {
1032
+ const { content } = req.body;
1033
+ const envPath = path.join(process.cwd(), '.env');
1034
+ fs.writeFileSync(envPath, content, 'utf8');
1035
+ res.json({ message: '.env updated.' });
1036
+ } catch (err) {
1037
+ res.status(500).json({ error: err.message });
1038
+ }
1039
+ });
1040
+
1041
+ // ---------------------------------------------------------------------------
1042
+ // Index.js manipulation helpers
1043
+ // ---------------------------------------------------------------------------
1044
+
1045
+ function readIndexFile() {
1046
+ return fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf8');
1047
+ }
1048
+
1049
+ function writeIndexFile(content) {
1050
+ fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8');
1051
+ }
1052
+
1053
+ // File-based wrappers around the shared pure index.js transforms in codegen.js.
1054
+ function addImportToIndex(importLine) {
1055
+ writeIndexFile(addImportAfterLastImport(readIndexFile(), importLine));
1056
+ }
1057
+
1058
+ function insertBeforeListen(codeBlock) {
1059
+ writeIndexFile(insertBeforeListenContent(readIndexFile(), codeBlock));
1060
+ }
1061
+
1062
+ function replaceAppListen(newBlock) {
1063
+ let content = readIndexFile();
1064
+ if (content.includes('startServer(')) {
1065
+ content = content.replace(/const listener = app\.listen\(tryPort\)/, 'const listener = server.listen(tryPort)');
1066
+ if (!content.includes('server.listen(tryPort)')) {
1067
+ content = content.replace(/app\.listen\(tryPort\)/, 'server.listen(tryPort)');
1068
+ }
1069
+ } else {
1070
+ content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, newBlock.trim() + '\n');
1071
+ }
1072
+ writeIndexFile(content);
1073
+ }
1074
+
1075
+ function removeSocketIOCode() {
1076
+ let content = readIndexFile();
1077
+ // Remove Socket.IO import
1078
+ content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
1079
+ // Remove http import only if WebSocket doesn't need it
1080
+ if (!content.includes('WebSocketServer({ server')) {
1081
+ content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
1082
+ }
1083
+ // Remove server = http.createServer(app) only if WebSocket doesn't need it
1084
+ if (!content.includes('WebSocketServer({ server')) {
1085
+ content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
1086
+ }
1087
+ // Remove io setup block (use \n}); to match outer closing brace at column 0)
1088
+ content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
1089
+ // Remove io.on("connection"...) block (nested callbacks — match \n}); at column 0)
1090
+ content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
1091
+ // Remove req.io middleware
1092
+ 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, '');
1093
+ // Replace server.listen back to app.listen if no WebSocket uses server
1094
+ if (!content.includes('WebSocketServer({ server') && content.includes('server.listen(port')) {
1095
+ content = content.replace(/server\.listen\(port[\s\S]*?\n\}\);?\s*\n?/m, `app.listen(port, () => {
1096
+ console.log(\`Server is running on port \${port}\`);
1097
+ });\n`);
1098
+ }
1099
+ // Clean up multiple blank lines
1100
+ content = content.replace(/\n{3,}/g, '\n\n');
1101
+ writeIndexFile(content);
1102
+ }
1103
+
1104
+ function removeWebSocketCode() {
1105
+ let content = readIndexFile();
1106
+ // Remove ws import
1107
+ content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
1108
+ // Remove WebSocketServer setup (both same-port and separate-port)
1109
+ content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
1110
+ // Remove wss.on('connection'...) block (nested callbacks — match \n}); at column 0)
1111
+ content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
1112
+ // Remove console.log for WS port
1113
+ content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
1114
+ // Remove http import only if Socket.IO doesn't need it
1115
+ if (!content.includes('new Server(server')) {
1116
+ content = content.replace(/import\s+http\s+from\s*['"]http['"];\s*\n?/g, '');
1117
+ }
1118
+ // Remove server = http.createServer(app) only if Socket.IO doesn't need it
1119
+ if (!content.includes('new Server(server')) {
1120
+ content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
1121
+ // Replace server.listen back to app.listen
1122
+ if (content.includes('server.listen(port')) {
1123
+ content = content.replace(/server\.listen\(port[\s\S]*?\n\}\);?\s*\n?/m, `app.listen(port, () => {
1124
+ console.log(\`Server is running on port \${port}\`);
1125
+ });\n`);
1126
+ }
1127
+ }
1128
+ // Clean up multiple blank lines
1129
+ content = content.replace(/\n{3,}/g, '\n\n');
1130
+ writeIndexFile(content);
1131
+ }
1132
+
1133
+ function updateEnvFile(filePath, key, value) {
1134
+ if (fs.existsSync(filePath)) {
1135
+ let content = fs.readFileSync(filePath, 'utf8');
1136
+ const regex = new RegExp(`^${key}=.*`, 'm');
1137
+ if (regex.test(content)) {
1138
+ content = content.replace(regex, `${key}=${value}`);
1139
+ } else {
1140
+ content = content.trimEnd() + '\n' + `${key}=${value}\n`;
1141
+ }
1142
+ fs.writeFileSync(filePath, content, 'utf8');
1143
+ } else {
1144
+ fs.writeFileSync(filePath, `${key}=${value}\n`, 'utf8');
1145
+ }
1146
+ }
1147
+
1148
+ // Record packages in package.json so the app stays installable even if the live
1149
+ // `npm install` below is offline/interrupted. Always call this BEFORE npm install.
1150
+ function recordDeps(names, dev = false) {
1151
+ try {
1152
+ const pkgPath = path.join(process.cwd(), 'package.json');
1153
+ if (!fs.existsSync(pkgPath)) return;
1154
+ const list = Array.isArray(names) ? names : [names];
1155
+ const { content, changed } = addDepsToPackageJson(fs.readFileSync(pkgPath, 'utf8'), list, { dev });
1156
+ if (changed) fs.writeFileSync(pkgPath, content, 'utf8');
1157
+ } catch {}
1158
+ }
1159
+
1160
+ // Record deps then run npm install (best-effort). Replaces bare execAsync('npm install …').
1161
+ async function installDeps(names, { dev = false } = {}) {
1162
+ const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
1163
+ if (list.length === 0) return;
1164
+ recordDeps(list, dev);
1165
+ try { await execAsync(`npm install ${dev ? '-D ' : ''}${list.join(' ')}`, { cwd: process.cwd() }); } catch {}
1166
+ }
1167
+
1168
+ // ---------------------------------------------------------------------------
1169
+ // GET /api/status
1170
+ // ---------------------------------------------------------------------------
1171
+ router.get('/api/status', (req, res) => {
1172
+ try {
1173
+ const indexPath = path.join(process.cwd(), 'index.js');
1174
+ const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
1175
+
1176
+ const mongoConfigured = indexContent.includes("import connectDB") || indexContent.includes("connectDB");
1177
+ const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes'));
1178
+ const authMiddlewareExists = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js'));
1179
+ const socketConfigured = indexContent.includes('setupSocketIO') || indexContent.includes('socket.io') || indexContent.includes('Server(server');
1180
+ const websocketConfigured = indexContent.includes('setupWebSocket') || indexContent.includes('WebSocketServer') || indexContent.includes("from 'ws'");
1181
+ const serialportConfigured = indexContent.includes('SerialPort') || indexContent.includes("from 'serialport'");
1182
+
1183
+ const mwExists = (f) => fs.existsSync(path.join(process.cwd(), 'src', 'middleware', f));
1184
+ const security = {
1185
+ helmet: indexContent.includes("from 'helmet'") || indexContent.includes('app.use(helmet'),
1186
+ rateLimit: indexContent.includes("from 'express-rate-limit'") || indexContent.includes('rateLimit('),
1187
+ errorHandler: mwExists('errorHandler.js'),
1188
+ roles: mwExists('roles.js'),
1189
+ validation: mwExists('validate.js'),
1190
+ };
1191
+
1192
+ // Folder name, sanitized into a valid default database name (Mongo db names
1193
+ // can't contain / \ . " $ * < > : | ? or spaces).
1194
+ const appName = (path.basename(process.cwd()) || 'app')
1195
+ .replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase() || 'app';
1196
+
1197
+ res.json({
1198
+ appName,
1199
+ mongoConfigured,
1200
+ routesDirExists,
1201
+ authMiddlewareExists,
1202
+ socketConfigured,
1203
+ websocketConfigured,
1204
+ serialportConfigured,
1205
+ security,
1206
+ aiConfigured: !!getActiveAiKey(),
1207
+ docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
1208
+ mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js'))
1209
+ });
1210
+ } catch (err) {
1211
+ res.status(500).json({ error: err.message });
1212
+ }
1213
+ });
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // Security configuration
1217
+ // ---------------------------------------------------------------------------
1218
+ // GET/POST rate-limit config — stored in .env (read by index.js at startup).
1219
+ router.get('/api/security/rate-limit', (req, res) => {
1220
+ try {
1221
+ const envPath = path.join(process.cwd(), '.env');
1222
+ const windowMs = Number(getEnvValue(envPath, 'RATE_LIMIT_WINDOW_MS')) || 15 * 60 * 1000;
1223
+ const max = Number(getEnvValue(envPath, 'RATE_LIMIT_MAX')) || 100;
1224
+ res.json({ windowMinutes: Math.max(1, Math.round(windowMs / 60000)), max });
1225
+ } catch (err) {
1226
+ res.status(500).json({ error: err.message });
1227
+ }
1228
+ });
1229
+ router.post('/api/security/rate-limit', (req, res) => {
1230
+ try {
1231
+ const envPath = path.join(process.cwd(), '.env');
1232
+ const windowMinutes = Math.max(1, Math.min(1440, Number(req.body && req.body.windowMinutes) || 15));
1233
+ const max = Math.max(1, Math.min(1000000, Number(req.body && req.body.max) || 100));
1234
+ updateEnvFile(envPath, 'RATE_LIMIT_WINDOW_MS', String(windowMinutes * 60000));
1235
+ updateEnvFile(envPath, 'RATE_LIMIT_MAX', String(max));
1236
+ res.json({ message: 'Rate limit updated.', windowMinutes, max });
1237
+ } catch (err) {
1238
+ res.status(500).json({ error: err.message });
1239
+ }
1240
+ });
1241
+
1242
+ // ---------------------------------------------------------------------------
1243
+ // AI Builder — describe a backend in English, generate models + CRUD routes
1244
+ // ---------------------------------------------------------------------------
1245
+ // Currently selected provider (AI_PROVIDER in .env), validated against AI_PROVIDERS.
1246
+ function getAiProvider() {
1247
+ const fromFile = getEnvValue(path.join(process.cwd(), '.env'), 'AI_PROVIDER');
1248
+ const id = (fromFile && fromFile.trim()) || (process.env.AI_PROVIDER && process.env.AI_PROVIDER.trim()) || DEFAULT_AI_PROVIDER;
1249
+ return AI_PROVIDERS[id] ? id : DEFAULT_AI_PROVIDER;
1250
+ }
1251
+
1252
+ function getProviderKey(providerId) {
1253
+ const provider = AI_PROVIDERS[providerId];
1254
+ if (!provider) return '';
1255
+ const fromEnv = process.env[provider.keyEnv];
1256
+ if (fromEnv && fromEnv.trim()) return fromEnv.trim();
1257
+ return getEnvValue(path.join(process.cwd(), '.env'), provider.keyEnv);
1258
+ }
1259
+
1260
+ // Active provider's API key (used by the plan endpoint).
1261
+ function getActiveAiKey() {
1262
+ return getProviderKey(getAiProvider());
1263
+ }
1264
+
1265
+ // Effective model: explicit AI_MODEL override, else the active provider's default.
1266
+ function getAiModel(providerId) {
1267
+ const fromFile = getEnvValue(path.join(process.cwd(), '.env'), 'AI_MODEL');
1268
+ const override = (fromFile && fromFile.trim()) || (process.env.AI_MODEL && process.env.AI_MODEL.trim());
1269
+ return override || AI_PROVIDERS[providerId].defaultModel;
1270
+ }
1271
+
1272
+ // Fields for a route's model — from the plan if present, else from an existing model on disk.
1273
+ function fieldsForModel(modelName, plan) {
1274
+ const inPlan = (plan.models || []).find((m) => m.name === modelName);
1275
+ if (inPlan) return inPlan.fields.map((f) => f.name);
1276
+ return getModelSchema(modelName);
1277
+ }
1278
+
1279
+ // ── OpenAI: chat completions with a forced function call ──
1280
+ async function callOpenAIPlan(prompt, model, apiKey) {
1281
+ const resp = await fetch('https://api.openai.com/v1/chat/completions', {
1282
+ method: 'POST',
1283
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
1284
+ body: JSON.stringify({
1285
+ model,
1286
+ messages: [
1287
+ { role: 'system', content: AI_SYSTEM_PROMPT },
1288
+ { role: 'user', content: prompt },
1289
+ ],
1290
+ tools: [{ type: 'function', function: { name: 'emit_plan', description: 'Emit the backend plan to generate.', parameters: AI_PLAN_SCHEMA } }],
1291
+ tool_choice: { type: 'function', function: { name: 'emit_plan' } },
1292
+ }),
1293
+ });
1294
+ let data;
1295
+ try { data = await resp.json(); } catch { data = null; }
1296
+ if (!resp.ok) {
1297
+ const e = new Error((data && data.error && data.error.message) || `OpenAI API error (${resp.status})`);
1298
+ e.status = resp.status === 401 ? 401 : 502;
1299
+ throw e;
1300
+ }
1301
+ const toolCall = data?.choices?.[0]?.message?.tool_calls?.[0];
1302
+ if (!toolCall?.function?.arguments) throw new Error('The model did not return a structured plan. Try rephrasing.');
1303
+ try { return JSON.parse(toolCall.function.arguments); }
1304
+ catch { throw new Error('The model returned an unparseable plan. Try again.'); }
1305
+ }
1306
+
1307
+ // ── Anthropic: messages with a forced tool call ──
1308
+ async function callAnthropicPlan(prompt, model, apiKey) {
1309
+ const resp = await fetch('https://api.anthropic.com/v1/messages', {
1310
+ method: 'POST',
1311
+ headers: { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
1312
+ body: JSON.stringify({
1313
+ model,
1314
+ max_tokens: 4096,
1315
+ system: AI_SYSTEM_PROMPT,
1316
+ tools: [{ name: 'emit_plan', description: 'Emit the backend plan to generate.', input_schema: AI_PLAN_SCHEMA }],
1317
+ tool_choice: { type: 'tool', name: 'emit_plan' },
1318
+ messages: [{ role: 'user', content: prompt }],
1319
+ }),
1320
+ });
1321
+ let data;
1322
+ try { data = await resp.json(); } catch { data = null; }
1323
+ if (!resp.ok) {
1324
+ const e = new Error((data && data.error && data.error.message) || `Anthropic API error (${resp.status})`);
1325
+ e.status = resp.status === 401 ? 401 : 502;
1326
+ throw e;
1327
+ }
1328
+ const toolUse = Array.isArray(data.content) ? data.content.find((c) => c.type === 'tool_use') : null;
1329
+ if (!toolUse) throw new Error('The model did not return a structured plan. Try rephrasing.');
1330
+ return toolUse.input;
1331
+ }
1332
+
1333
+ // Dispatch a plan request to the active provider.
1334
+ async function callAiPlan(prompt) {
1335
+ const providerId = getAiProvider();
1336
+ const apiKey = getProviderKey(providerId);
1337
+ if (!apiKey) {
1338
+ const e = new Error(`No ${AI_PROVIDERS[providerId].label} API key configured. Add it in AI Builder settings.`);
1339
+ e.status = 400;
1340
+ throw e;
1341
+ }
1342
+ const model = getAiModel(providerId);
1343
+ return providerId === 'anthropic'
1344
+ ? callAnthropicPlan(prompt, model, apiKey)
1345
+ : callOpenAIPlan(prompt, model, apiKey);
1346
+ }
1347
+
1348
+ // GET /api/ai/config — active provider, effective model, and per-provider status
1349
+ router.get('/api/ai/config', (req, res) => {
1350
+ try {
1351
+ const provider = getAiProvider();
1352
+ const providers = Object.values(AI_PROVIDERS).map((p) => ({
1353
+ id: p.id,
1354
+ label: p.label,
1355
+ keyPlaceholder: p.keyPlaceholder,
1356
+ keysUrl: p.keysUrl,
1357
+ defaultModel: p.defaultModel,
1358
+ configured: !!getProviderKey(p.id),
1359
+ }));
1360
+ res.json({ provider, model: getAiModel(provider), providers });
1361
+ } catch (err) {
1362
+ res.status(500).json({ error: err.message });
1363
+ }
1364
+ });
1365
+
1366
+ // POST /api/ai/key — save a provider's API key and make it the active provider
1367
+ router.post('/api/ai/key', (req, res) => {
1368
+ try {
1369
+ const providerId = (req.body && req.body.provider ? String(req.body.provider) : '').trim();
1370
+ const key = (req.body && req.body.key ? String(req.body.key) : '').trim();
1371
+ const provider = AI_PROVIDERS[providerId];
1372
+ if (!provider) return res.status(400).json({ error: 'Unknown provider.' });
1373
+ if (!key) return res.status(400).json({ error: 'API key is required.' });
1374
+
1375
+ const envPath = path.join(process.cwd(), '.env');
1376
+ updateEnvFile(envPath, provider.keyEnv, key);
1377
+ updateEnvFile(envPath, 'AI_PROVIDER', providerId);
1378
+ process.env[provider.keyEnv] = key; // usable immediately, no restart
1379
+ process.env.AI_PROVIDER = providerId;
1380
+ res.json({ message: `${provider.label} key saved and selected.`, provider: providerId });
1381
+ } catch (err) {
1382
+ res.status(500).json({ error: err.message });
1383
+ }
1384
+ });
1385
+
1386
+ // POST /api/ai/provider — switch the active provider (key must already exist)
1387
+ router.post('/api/ai/provider', (req, res) => {
1388
+ try {
1389
+ const providerId = (req.body && req.body.provider ? String(req.body.provider) : '').trim();
1390
+ const provider = AI_PROVIDERS[providerId];
1391
+ if (!provider) return res.status(400).json({ error: 'Unknown provider.' });
1392
+ if (!getProviderKey(providerId)) {
1393
+ return res.status(400).json({ error: `Add a ${provider.label} key first.` });
1394
+ }
1395
+ updateEnvFile(path.join(process.cwd(), '.env'), 'AI_PROVIDER', providerId);
1396
+ process.env.AI_PROVIDER = providerId;
1397
+ res.json({ message: `Switched to ${provider.label}.`, provider: providerId });
1398
+ } catch (err) {
1399
+ res.status(500).json({ error: err.message });
1400
+ }
1401
+ });
1402
+
1403
+ // POST /api/ai/plan — turn a prompt into a normalized plan + code previews
1404
+ router.post('/api/ai/plan', async (req, res) => {
1405
+ try {
1406
+ const prompt = (req.body && req.body.prompt ? String(req.body.prompt) : '').trim();
1407
+ if (!prompt) return res.status(400).json({ error: 'Describe what you want to build.' });
1408
+
1409
+ const raw = await callAiPlan(prompt);
1410
+ const plan = normalizeAiPlan(raw);
1411
+ if (plan.models.length === 0 && plan.routes.length === 0) {
1412
+ return res.status(422).json({ error: 'Could not derive a buildable plan. Try being more specific.' });
1413
+ }
1414
+
1415
+ const preview = {
1416
+ summary: plan.summary,
1417
+ models: plan.models.map((m) => ({
1418
+ name: m.name,
1419
+ fields: m.fields,
1420
+ code: generateMongooseModel(toPascalCase(m.name), m.fields),
1421
+ })),
1422
+ routes: plan.routes.map((r) => ({
1423
+ name: r.name,
1424
+ model: r.model,
1425
+ operations: r.operations,
1426
+ code: buildCrudRouteFile(r.name, r.model, fieldsForModel(r.model, plan), r.operations),
1427
+ })),
1428
+ };
1429
+ res.json({ plan, preview });
1430
+ } catch (err) {
1431
+ res.status(err.status || 500).json({ error: err.message });
1432
+ }
1433
+ });
1434
+
1435
+ // POST /api/ai/apply — generate the model + route files from an approved plan
1436
+ router.post('/api/ai/apply', async (req, res) => {
1437
+ try {
1438
+ const plan = normalizeAiPlan(req.body && req.body.plan ? req.body.plan : {});
1439
+ if (plan.models.length === 0 && plan.routes.length === 0) {
1440
+ return res.status(400).json({ error: 'Empty plan.' });
1441
+ }
1442
+ if (!checkMongoConfig()) {
1443
+ return res.status(400).json({ error: 'Configure MongoDB before generating models.' });
1444
+ }
1445
+
1446
+ const created = { models: [], routes: [], skipped: [] };
1447
+
1448
+ const modelsDir = path.join(process.cwd(), 'src', 'models');
1449
+ fs.mkdirSync(modelsDir, { recursive: true });
1450
+ for (const m of plan.models) {
1451
+ const file = path.join(modelsDir, m.name + '.js');
1452
+ if (fs.existsSync(file)) { created.skipped.push('model:' + m.name); continue; }
1453
+ fs.writeFileSync(file, generateMongooseModel(toPascalCase(m.name), m.fields), 'utf8');
1454
+ created.models.push(m.name);
1455
+ }
1456
+
1457
+ const routesDir = path.join(process.cwd(), 'src', 'routes');
1458
+ fs.mkdirSync(routesDir, { recursive: true });
1459
+ for (const r of plan.routes) {
1460
+ const file = path.join(routesDir, r.name + '.js');
1461
+ if (fs.existsSync(file)) { created.skipped.push('route:' + r.name); continue; }
1462
+ const fields = fieldsForModel(r.model, plan);
1463
+ fs.writeFileSync(file, buildCrudRouteFile(r.name, r.model, fields, r.operations), 'utf8');
1464
+ created.routes.push(r.name);
1465
+ const camel = toCamelCase(r.name);
1466
+ addImportToIndex(`import ${camel}Router from './src/routes/${r.name}.js';`);
1467
+ const reg = `app.use('/api/${r.name}', ${camel}Router);`;
1468
+ if (!readIndexFile().includes(reg)) insertBeforeListen(reg);
1469
+ }
1470
+
1471
+ const anyPassword = plan.routes.some((r) => fieldsForModel(r.model, plan).includes('password'));
1472
+ if (anyPassword) {
1473
+ await installDeps(['bcrypt']);
1474
+ }
1475
+
1476
+ res.json({
1477
+ message: `Generated ${created.models.length} model(s) and ${created.routes.length} route(s).`,
1478
+ ...created,
1479
+ });
1480
+ } catch (err) {
1481
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1482
+ }
1483
+ });
1484
+
1485
+ // ---------------------------------------------------------------------------
1486
+ // API Documentation (OpenAPI spec + self-contained docs page at /docs)
1487
+ // ---------------------------------------------------------------------------
1488
+ function projectTitle() {
1489
+ try {
1490
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
1491
+ return pkg.name || 'API';
1492
+ } catch { return 'API'; }
1493
+ }
1494
+
1495
+ // All models → { name, fields[] } for the OpenAPI components/schemas.
1496
+ function collectModels() {
1497
+ const dir = path.join(process.cwd(), 'src', 'models');
1498
+ if (!fs.existsSync(dir)) return [];
1499
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.js')).map((f) => {
1500
+ const name = f.replace(/\.js$/, '');
1501
+ const detailed = getModelSchemaDetailed(name);
1502
+ return { name, fields: (detailed && detailed.fields) || [] };
1503
+ });
1504
+ }
1505
+
1506
+ // Parse every route file into endpoint descriptors (method, path, params, query,
1507
+ // body fields with model types) for the OpenAPI builder.
1508
+ function collectEndpoints() {
1509
+ const routesDir = path.join(process.cwd(), 'src', 'routes');
1510
+ if (!fs.existsSync(routesDir)) return [];
1511
+ const indexPath = path.join(process.cwd(), 'index.js');
1512
+ const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
1513
+ const out = [];
1514
+ fs.readdirSync(routesDir).filter((f) => f.endsWith('.js')).forEach((file) => {
1515
+ const name = file.replace(/\.js$/, '');
1516
+ const content = fs.readFileSync(path.join(routesDir, file), 'utf8');
1517
+
1518
+ let prefix = '/api/' + name;
1519
+ const reg = indexContent.match(new RegExp("app\\.use\\(['\"]([^'\"]+)['\"]\\s*,\\s*\\w+Router\\s*\\)[^\\n]*" + name, 'i'))
1520
+ || indexContent.match(new RegExp("app\\.use\\(['\"]([^'\"]+)['\"]\\s*,\\s*" + toCamelCase(name) + "Router"));
1521
+ if (reg) prefix = reg[1];
1522
+
1523
+ const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
1524
+ const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
1525
+
1526
+ const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
1527
+ let m;
1528
+ while ((m = epRegex.exec(content)) !== null) {
1529
+ const method = m[1].toUpperCase();
1530
+ const epath = m[2];
1531
+ const body = m[3] || '';
1532
+ const bodyFields = extractRequestFields(body, 'body').map((fn) => {
1533
+ const meta = modelFields.find((x) => x.name === fn);
1534
+ return { name: fn, type: (meta && meta.type) || 'String', required: !!(meta && meta.required) };
1535
+ });
1536
+ const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
1537
+ const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
1538
+ const hasAuth = /authorization|jwt|bearer|x-api-key/i.test(body);
1539
+ const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
1540
+ out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields: [], hasAuth });
1541
+ }
1542
+ });
1543
+ return out;
1544
+ }
1545
+
1546
+ function generateSpecFile() {
1547
+ const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels() });
1548
+ fs.writeFileSync(path.join(process.cwd(), 'src', 'openapi.json'), JSON.stringify(spec, null, 2), 'utf8');
1549
+ return spec;
1550
+ }
1551
+
1552
+ // Keep the API docs in sync automatically: whenever routes or models change, the
1553
+ // spec is regenerated — but only if docs have been set up. Writing openapi.json is
1554
+ // a .json write, so it doesn't trigger a nodemon restart.
1555
+ function refreshDocsSpec() {
1556
+ try {
1557
+ if (fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'))) generateSpecFile();
1558
+ } catch {}
1559
+ }
1560
+
1561
+ const docsAuthFile = () => path.join(process.cwd(), 'src', 'docs-auth.json');
1562
+
1563
+ // GET /api/docs — setup status + endpoint count + whether docs are password-protected
1564
+ router.get('/api/docs', (req, res) => {
1565
+ try {
1566
+ const configured = fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'));
1567
+ res.json({ configured, endpointCount: collectEndpoints().length, passwordProtected: fs.existsSync(docsAuthFile()) });
1568
+ } catch (err) {
1569
+ res.status(500).json({ error: err.message });
1570
+ }
1571
+ });
1572
+
1573
+ // POST /api/docs/setup — create the docs route, generate the spec, wire index.js
1574
+ router.post('/api/docs/setup', (req, res) => {
1575
+ try {
1576
+ // Writing src/docs.js and index.js triggers a nodemon restart (it watches .js
1577
+ // files). Respond FIRST so the restart can't drop this request, THEN write.
1578
+ res.json({ message: 'API docs set up at /docs.', endpointCount: collectEndpoints().length });
1579
+ fs.writeFileSync(path.join(process.cwd(), 'src', 'docs.js'), buildDocsRouter(), 'utf8');
1580
+ generateSpecFile();
1581
+ addImportToIndex("import docsRouter from './src/docs.js';");
1582
+ const reg = "app.use('/docs', docsRouter);";
1583
+ if (!readIndexFile().includes(reg)) insertBeforeListen(reg);
1584
+ } catch (err) {
1585
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1586
+ }
1587
+ });
1588
+
1589
+ // POST /api/docs/regenerate — rebuild the spec + refresh the docs page
1590
+ router.post('/api/docs/regenerate', (req, res) => {
1591
+ try {
1592
+ const docsPath = path.join(process.cwd(), 'src', 'docs.js');
1593
+ if (!fs.existsSync(docsPath)) {
1594
+ return res.status(400).json({ error: 'Set up API docs first.' });
1595
+ }
1596
+ res.json({ message: 'API spec regenerated.', endpointCount: collectEndpoints().length });
1597
+ generateSpecFile();
1598
+ fs.writeFileSync(docsPath, buildDocsRouter(), 'utf8');
1599
+ } catch (err) {
1600
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1601
+ }
1602
+ });
1603
+
1604
+ // POST /api/docs/password — protect /docs with a password (single password, no username)
1605
+ router.post('/api/docs/password', (req, res) => {
1606
+ try {
1607
+ const password = String((req.body && req.body.password) || '');
1608
+ if (password.length < 6) {
1609
+ return res.status(400).json({ error: 'Password must be at least 6 characters.' });
1610
+ }
1611
+ const hash = crypto.createHash('sha256').update(password).digest('hex');
1612
+ fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
1613
+ fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
1614
+ const docsPath = path.join(process.cwd(), 'src', 'docs.js');
1615
+ const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
1616
+ res.json({
1617
+ message: needsUpgrade
1618
+ ? 'Docs password set — updating the docs route (server will restart).'
1619
+ : 'Docs are now password-protected.',
1620
+ });
1621
+ if (needsUpgrade) fs.writeFileSync(docsPath, buildDocsRouter(), 'utf8');
1622
+ } catch (err) {
1623
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1624
+ }
1625
+ });
1626
+
1627
+ // DELETE /api/docs/password — remove protection (docs become open again)
1628
+ router.delete('/api/docs/password', (req, res) => {
1629
+ try {
1630
+ if (fs.existsSync(docsAuthFile())) fs.unlinkSync(docsAuthFile());
1631
+ res.json({ message: 'Docs password removed.' });
1632
+ } catch (err) {
1633
+ res.status(500).json({ error: err.message });
1634
+ }
1635
+ });
1636
+
1637
+ // ---------------------------------------------------------------------------
1638
+ // POST /api/add-api
1639
+ // ---------------------------------------------------------------------------
1640
+ router.post('/api/add-api', async (req, res) => {
1641
+ try {
1642
+ const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [], authRequired = false } = req.body;
1643
+ // Legacy support: single method field
1644
+ const method = req.body.method;
1645
+
1646
+ const safeName = sanitizeName(name);
1647
+ const camelName = toCamelCase(safeName);
1648
+ const pascalModel = modelName ? toPascalCase(modelName) : '';
1649
+ const type = routeType || (method ? 'methods' : 'plain');
1650
+ const methodList = method ? [method] : methods;
1651
+ const hasPassword = !!passwordField;
1652
+
1653
+ const routesDir = path.join(process.cwd(), 'src', 'routes');
1654
+ if (!fs.existsSync(routesDir)) {
1655
+ fs.mkdirSync(routesDir, { recursive: true });
1656
+ }
1657
+
1658
+ const routeFilePath = path.join(routesDir, safeName + '.js');
1659
+
1660
+ // Install packages if needed
1661
+ const packagesToInstall = [];
1662
+ if (type === 'login' || type === 'register') {
1663
+ if (hasPassword) packagesToInstall.push('bcrypt');
1664
+ packagesToInstall.push('jsonwebtoken');
1665
+ }
1666
+ const allFieldsCombined = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
1667
+ const needsMulter = allFieldsCombined.some(f => f.type === 'File');
1668
+ if (needsMulter) packagesToInstall.push('multer');
1669
+ if (authRequired && type !== 'login' && type !== 'register') packagesToInstall.push('jsonwebtoken');
1670
+ if (packagesToInstall.length > 0) {
1671
+ recordDeps(packagesToInstall);
1672
+ await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
1673
+ }
1674
+
1675
+ // Build imports
1676
+ let imports = "import express from 'express';\n";
1677
+ if ((type === 'login' || type === 'register') && hasPassword) {
1678
+ imports += "import bcrypt from 'bcrypt';\n";
1679
+ }
1680
+ if (type === 'login' || type === 'register') {
1681
+ imports += "import jwt from 'jsonwebtoken';\n";
1682
+ }
1683
+ if ((type === 'login' || type === 'register' || type === 'crud') && modelName) {
1684
+ imports += `import ${pascalModel} from '../models/${modelName}.js';\n`;
1685
+ } else if (type === 'methods' && modelName && selectedFields.length > 0) {
1686
+ imports += `import ${pascalModel} from '../models/${modelName}.js';\n`;
1687
+ }
1688
+
1689
+ // Build handlers
1690
+ let handlers = '';
1691
+ const findField = loginField || 'email';
1692
+
1693
+ if (type === 'plain') {
1694
+ handlers += `
1695
+ // Add your route handlers here
1696
+ // Example: router.get('/', (req, res) => res.json({ message: '${safeName} works' }));
1697
+ `;
1698
+ } else if (type === 'methods') {
1699
+ // Combine selected model fields + custom fields
1700
+ const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
1701
+ const hasFileFields = allFields.some(f => f.type === 'File');
1702
+ const isMultipart = hasFileFields;
1703
+ const bodyFields = allFields.filter(f => f.type !== 'File');
1704
+ const fileFields = allFields.filter(f => f.type === 'File');
1705
+
1706
+ if (isMultipart) {
1707
+ imports += "import multer from 'multer';\n";
1708
+ }
1709
+
1710
+ methodList.forEach(m => {
1711
+ const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body';
1712
+ let middlewareStr = '';
1713
+ let destructLines = '';
1714
+
1715
+ if (isMultipart && m !== 'get' && m !== 'delete') {
1716
+ if (fileFields.length === 0) {
1717
+ middlewareStr = 'upload.none(), ';
1718
+ } else if (fileFields.length === 1) {
1719
+ middlewareStr = `upload.single('${fileFields[0].name}'), `;
1720
+ } else {
1721
+ const fieldsArg = fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ');
1722
+ middlewareStr = `upload.fields([${fieldsArg}]), `;
1723
+ }
1724
+ }
1725
+
1726
+ if (bodyFields.length > 0) {
1727
+ const fieldNames = bodyFields.map(f => f.name).join(', ');
1728
+ destructLines += ` const { ${fieldNames} } = ${dataSource};\n`;
1729
+ }
1730
+ if (fileFields.length > 0 && m !== 'get' && m !== 'delete') {
1731
+ if (fileFields.length === 1) {
1732
+ destructLines += ` const ${fileFields[0].name} = req.file;\n`;
1733
+ } else {
1734
+ fileFields.forEach(f => {
1735
+ destructLines += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`;
1736
+ });
1737
+ }
1738
+ }
1739
+
1740
+ if (allFields.length > 0) {
1741
+ handlers += `
1742
+ router.${m}('/', ${middlewareStr}async (req, res) => {
1743
+ ${destructLines}
1744
+ // TODO: Add your logic here
1745
+
1746
+ res.json({ msg: '${safeName} ${m.toUpperCase()} endpoint works' });
1747
+ });
1748
+ `;
1749
+ } else {
1750
+ handlers += `
1751
+ router.${m}('/', async (req, res) => {
1752
+ res.json({ msg: '${safeName} ${m.toUpperCase()} endpoint works' });
1753
+ });
1754
+ `;
1755
+ }
1756
+ });
1757
+
1758
+ if (isMultipart) {
1759
+ // Add multer setup after router declaration
1760
+ handlers = `\nconst upload = multer({ dest: 'uploads/' });\n` + handlers;
1761
+ }
1762
+ } else if (type === 'crud' && modelName) {
1763
+ const crudMethods = methodList.length > 0 ? methodList : ['get', 'post', 'put', 'delete'];
1764
+ const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
1765
+ const bodyFields = allFields.filter(f => f.type !== 'File');
1766
+ const fileFields = allFields.filter(f => f.type === 'File');
1767
+ const hasFileFields = fileFields.length > 0;
1768
+ const hasFields = allFields.length > 0;
1769
+
1770
+ if (hasFileFields) {
1771
+ imports += "import multer from 'multer';\n";
1772
+ }
1773
+
1774
+ // Build destructure and object construction
1775
+ let destructure = '';
1776
+ let buildObj = 'req.body';
1777
+ if (hasFields) {
1778
+ const parts = [];
1779
+ if (bodyFields.length > 0) {
1780
+ destructure = `const { ${bodyFields.map(f => f.name).join(', ')} } = req.body;`;
1781
+ parts.push(...bodyFields.map(f => f.name));
1782
+ }
1783
+ if (fileFields.length > 0) {
1784
+ const fileLines = fileFields.map(f => {
1785
+ if (fileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`;
1786
+ return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`;
1787
+ }).join('\n ');
1788
+ destructure = (destructure ? destructure + '\n ' : '') + fileLines;
1789
+ parts.push(...fileFields.map(f => f.name));
1790
+ }
1791
+ buildObj = `{ ${parts.join(', ')} }`;
1792
+ }
1793
+
1794
+ // Multer middleware string for write methods
1795
+ let middlewareStr = '';
1796
+ if (hasFileFields) {
1797
+ if (fileFields.length === 1) {
1798
+ middlewareStr = `upload.single('${fileFields[0].name}'), `;
1799
+ } else {
1800
+ const fieldsArg = fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ');
1801
+ middlewareStr = `upload.fields([${fieldsArg}]), `;
1802
+ }
1803
+ }
1804
+
1805
+ if (crudMethods.includes('get')) {
1806
+ handlers += `
1807
+ // Get all
1808
+ router.get('/', async (req, res) => {
1809
+ try {
1810
+ const items = await ${pascalModel}.find();
1811
+ res.json(items);
1812
+ } catch (err) {
1813
+ res.status(500).json({ message: err.message });
1814
+ }
1815
+ });
1816
+
1817
+ // Get by ID
1818
+ router.get('/:id', async (req, res) => {
1819
+ try {
1820
+ const item = await ${pascalModel}.findById(req.params.id);
1821
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1822
+ res.json(item);
1823
+ } catch (err) {
1824
+ res.status(500).json({ message: err.message });
1825
+ }
1826
+ });
1827
+ `;
1828
+ }
1829
+
1830
+ if (crudMethods.includes('post')) {
1831
+ handlers += `
1832
+ // Create
1833
+ router.post('/', ${middlewareStr}async (req, res) => {
1834
+ try {
1835
+ ${destructure}
1836
+ const item = new ${pascalModel}(${buildObj});
1837
+ const saved = await item.save();
1838
+ res.status(201).json(saved);
1839
+ } catch (err) {
1840
+ res.status(400).json({ message: err.message });
1841
+ }
1842
+ });
1843
+ `;
1844
+ }
1845
+
1846
+ if (crudMethods.includes('put')) {
1847
+ handlers += `
1848
+ // Update
1849
+ router.put('/:id', ${middlewareStr}async (req, res) => {
1850
+ try {
1851
+ ${destructure}
1852
+ const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${buildObj}, { returnDocument: 'after', runValidators: true });
1853
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1854
+ res.json(item);
1855
+ } catch (err) {
1856
+ res.status(400).json({ message: err.message });
1857
+ }
1858
+ });
1859
+ `;
1860
+ }
1861
+
1862
+ if (crudMethods.includes('patch')) {
1863
+ handlers += `
1864
+ // Partial Update
1865
+ router.patch('/:id', ${middlewareStr}async (req, res) => {
1866
+ try {
1867
+ ${destructure}
1868
+ const item = await ${pascalModel}.findByIdAndUpdate(req.params.id, ${buildObj}, { returnDocument: 'after', runValidators: true });
1869
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1870
+ res.json(item);
1871
+ } catch (err) {
1872
+ res.status(400).json({ message: err.message });
1873
+ }
1874
+ });
1875
+ `;
1876
+ }
1877
+
1878
+ if (crudMethods.includes('delete')) {
1879
+ handlers += `
1880
+ // Delete
1881
+ router.delete('/:id', async (req, res) => {
1882
+ try {
1883
+ const item = await ${pascalModel}.findByIdAndDelete(req.params.id);
1884
+ if (!item) return res.status(404).json({ message: '${pascalModel} not found' });
1885
+ res.json({ message: '${pascalModel} deleted' });
1886
+ } catch (err) {
1887
+ res.status(500).json({ message: err.message });
1888
+ }
1889
+ });
1890
+ `;
1891
+ }
1892
+
1893
+ if (hasFileFields) {
1894
+ handlers = `\nconst upload = multer({ dest: 'uploads/' });\n` + handlers;
1895
+ }
1896
+ } else if (type === 'register' && modelName) {
1897
+ const regFields = registerFields.length > 0 ? registerFields : [findField];
1898
+ const allDestructured = regFields.join(', ');
1899
+ const pwFieldName = hasPassword ? passwordField : null;
1900
+ const nonPwFields = regFields.filter(f => f !== pwFieldName);
1901
+
1902
+ if (hasPassword) {
1903
+ const assignFields = nonPwFields.map(f => f).join(', ');
1904
+ handlers += `
1905
+ // Register
1906
+ router.post('/', async (req, res) => {
1907
+ try {
1908
+ const { ${allDestructured} } = req.body;
1909
+ const existing = await ${pascalModel}.findOne({ ${findField} });
1910
+ if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });
1911
+
1912
+ const salt = await bcrypt.genSalt(10);
1913
+ const hashedPassword = await bcrypt.hash(${pwFieldName}, salt);
1914
+ const item = new ${pascalModel}({ ${assignFields}, ${pwFieldName}: hashedPassword });
1915
+ const saved = await item.save();
1916
+
1917
+ const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1918
+ res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });
1919
+ } catch (err) {
1920
+ res.status(400).json({ message: err.message });
1921
+ }
1922
+ });
1923
+ `;
1924
+ } else {
1925
+ handlers += `
1926
+ // Register
1927
+ router.post('/', async (req, res) => {
1928
+ try {
1929
+ const { ${allDestructured} } = req.body;
1930
+ const existing = await ${pascalModel}.findOne({ ${findField} });
1931
+ if (existing) return res.status(400).json({ message: '${pascalModel} already exists' });
1932
+
1933
+ const item = new ${pascalModel}({ ${allDestructured} });
1934
+ const saved = await item.save();
1935
+
1936
+ const token = jwt.sign({ id: saved._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1937
+ res.status(201).json({ token, ${modelName}: { id: saved._id, ${findField}: saved.${findField} } });
1938
+ } catch (err) {
1939
+ res.status(400).json({ message: err.message });
1940
+ }
1941
+ });
1942
+ `;
1943
+ }
1944
+ } else if (type === 'login' && modelName) {
1945
+ if (hasPassword) {
1946
+ handlers += `
1947
+ // Login
1948
+ router.post('/', async (req, res) => {
1949
+ try {
1950
+ const { ${findField}, ${passwordField} } = req.body;
1951
+ const ${modelName} = await ${pascalModel}.findOne({ ${findField} });
1952
+ if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });
1953
+
1954
+ const isMatch = await bcrypt.compare(${passwordField}, ${modelName}.${passwordField});
1955
+ if (!isMatch) return res.status(400).json({ message: 'Invalid credentials' });
1956
+
1957
+ const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1958
+ res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });
1959
+ } catch (err) {
1960
+ res.status(500).json({ message: err.message });
1961
+ }
1962
+ });
1963
+ `;
1964
+ } else {
1965
+ handlers += `
1966
+ // Login
1967
+ router.post('/', async (req, res) => {
1968
+ try {
1969
+ const { ${findField} } = req.body;
1970
+ const ${modelName} = await ${pascalModel}.findOne({ ${findField} });
1971
+ if (!${modelName}) return res.status(404).json({ message: '${pascalModel} not found' });
1972
+
1973
+ const token = jwt.sign({ id: ${modelName}._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
1974
+ res.json({ token, ${modelName}: { id: ${modelName}._id, ${findField}: ${modelName}.${findField} } });
1975
+ } catch (err) {
1976
+ res.status(500).json({ message: err.message });
1977
+ }
1978
+ });
1979
+ `;
1980
+ }
1981
+ }
1982
+
1983
+ let content = `${imports}
1984
+ const router = express.Router();
1985
+ ${handlers}
1986
+ export default router;
1987
+ `;
1988
+
1989
+ // Per-route validation: generate a Zod schema from the fields THIS route
1990
+ // accepts (not the whole model — one model is used by many routes, each with a
1991
+ // different subset of fields). Named after the route (e.g. login → loginSchema),
1992
+ // it's wired into the route's write endpoints. File fields are skipped (they're
1993
+ // not in req.body). Types come from the model where known.
1994
+ const validatorFields = routeValidatorFields(type, {
1995
+ findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName,
1996
+ });
1997
+ if (validatorFields && validatorFields.length) {
1998
+ const validatorsDir = path.join(process.cwd(), 'src', 'validators');
1999
+ fs.mkdirSync(validatorsDir, { recursive: true });
2000
+ fs.writeFileSync(path.join(validatorsDir, safeName + '.js'), buildZodSchemaFile(safeName, validatorFields), 'utf8');
2001
+ content = wireValidateIntoRouteContent(content, safeName);
2002
+ }
2003
+
2004
+ // Require JWT auth on this route: ensure the auth middleware + JWT_SECRET exist,
2005
+ // then protect every endpoint. Login/register are exempt (they issue tokens).
2006
+ if (authRequired && type !== 'login' && type !== 'register') {
2007
+ const authPath = path.join(process.cwd(), 'src', 'middleware', 'auth.js');
2008
+ if (!fs.existsSync(authPath)) {
2009
+ fs.mkdirSync(path.dirname(authPath), { recursive: true });
2010
+ fs.writeFileSync(authPath, buildAuthMiddleware(), 'utf8');
2011
+ }
2012
+ const envFile = path.join(process.cwd(), '.env');
2013
+ if (!getEnvValue(envFile, 'JWT_SECRET')) {
2014
+ updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
2015
+ }
2016
+ content = wireAuthIntoRouteContent(content);
2017
+ }
2018
+
2019
+ fs.writeFileSync(routeFilePath, content, 'utf8');
2020
+
2021
+ 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();
2022
+
2023
+ // Ensure JWT_SECRET exists in env files for login/register routes
2024
+ if (type === 'login' || type === 'register') {
2025
+ const envFile = path.join(process.cwd(), '.env');
2026
+ if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) {
2027
+ updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
2028
+ }
2029
+ }
2030
+
2031
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
2032
+ res.json({
2033
+ message: `Route "${safeName}" created with ${label} endpoints.`,
2034
+ file: `src/routes/${safeName}.js`
2035
+ });
2036
+
2037
+ // Build clean path: ensure prefix ends with / and no double slashes
2038
+ let prefix = pathPrefix || '/api/';
2039
+ if (!prefix.endsWith('/')) prefix += '/';
2040
+ const fullPath = (prefix + safeName).replace(/\/+/g, '/');
2041
+
2042
+ // Update index.js (triggers nodemon restart)
2043
+ const importLine = `import ${camelName}Router from './src/routes/${safeName}.js';`;
2044
+ addImportToIndex(importLine);
2045
+
2046
+ const routeRegistration = `app.use('${fullPath}', ${camelName}Router);`;
2047
+ let indexContent = readIndexFile();
2048
+ if (!indexContent.includes(routeRegistration)) {
2049
+ insertBeforeListen(routeRegistration);
2050
+ }
2051
+ refreshDocsSpec();
2052
+ } catch (err) {
2053
+ if (!res.headersSent) res.status(500).json({ error: err.message });
2054
+ }
2055
+ });
2056
+
2057
+ // ---------------------------------------------------------------------------
2058
+ // POST /api/add-mongo
2059
+ // ---------------------------------------------------------------------------
2060
+ router.post('/api/add-mongo', async (req, res) => {
2061
+ try {
2062
+ const uri = (req.body.uri || '').trim();
2063
+ const alreadyConfigured = checkMongoConfig();
2064
+
2065
+ if (!uri) {
2066
+ return res.status(400).json({ error: 'MongoDB URI is required' });
2067
+ }
2068
+
2069
+ updateEnvFile(path.join(process.cwd(), '.env'), 'MONGO_URI', uri);
2070
+
2071
+ if (alreadyConfigured) {
2072
+ // Edit mode — just update .env, no need to reinstall or modify index.js
2073
+ res.json({
2074
+ message: 'MongoDB connection updated. Restart the server to apply changes.',
2075
+ files: ['.env']
2076
+ });
2077
+ return;
2078
+ }
2079
+
2080
+ // First-time setup: install mongoose, create db.js, modify index.js
2081
+ recordDeps(['mongoose']);
2082
+ await execAsync('npm install mongoose', { cwd: process.cwd() });
2083
+
2084
+ const dbDir = path.join(process.cwd(), 'src');
2085
+ if (!fs.existsSync(dbDir)) {
2086
+ fs.mkdirSync(dbDir, { recursive: true });
2087
+ }
2088
+
2089
+ const dbContent = `import mongoose from 'mongoose';
2090
+
2091
+ const connectDB = async () => {
2092
+ try {
2093
+ const conn = await mongoose.connect(process.env.MONGO_URI);
2094
+ console.log(\`MongoDB connected: \${conn.connection.host}\`);
2095
+ } catch (err) {
2096
+ console.error('MongoDB connection error:', err.message);
2097
+ console.error('Check your MONGO_URI in .env and ensure MongoDB is running.');
2098
+ }
2099
+ };
2100
+
2101
+ export default connectDB;
2102
+ `;
2103
+ fs.writeFileSync(path.join(dbDir, 'db.js'), dbContent, 'utf8');
2104
+
2105
+ // Send response BEFORE modifying index.js (nodemon restart kills connection)
2106
+ res.json({
2107
+ message: 'MongoDB setup complete.',
2108
+ files: ['src/db.js', '.env', 'index.js']
2109
+ });
2110
+
2111
+ // Modify index.js LAST (triggers nodemon restart)
2112
+ addImportToIndex("import connectDB from './src/db.js';");
2113
+ let indexContent = readIndexFile();
2114
+ if (!indexContent.includes('connectDB()')) {
2115
+ const appExpress = "const app = express();";
2116
+ const idx = indexContent.indexOf(appExpress);
2117
+ if (idx !== -1) {
2118
+ indexContent = indexContent.slice(0, idx) + 'connectDB();\n' + indexContent.slice(idx);
2119
+ writeIndexFile(indexContent);
2120
+ }
2121
+ }
2122
+ } catch (err) {
2123
+ if (!res.headersSent) res.status(500).json({ error: err.message });
2124
+ }
2125
+ });
2126
+
2127
+ // ---------------------------------------------------------------------------
2128
+ // POST /api/add-login
2129
+ // ---------------------------------------------------------------------------
2130
+ router.post('/api/add-login', async (req, res) => {
2131
+ try {
2132
+ const { routeFile, modelName, fields, tokenExpiry } = req.body;
2133
+ const pascalModel = toPascalCase(modelName);
2134
+ const hasPassword = fields.includes('password');
2135
+
2136
+ // Install packages FIRST before modifying files
2137
+ const packages = hasPassword ? 'jsonwebtoken bcrypt' : 'jsonwebtoken';
2138
+ recordDeps(String(packages).split(/\s+/).filter(Boolean));
2139
+ await execAsync(`npm install ${packages}`, { cwd: process.cwd() });
2140
+
2141
+ // Create src/middleware/auth.js if it doesn't exist
2142
+ const middlewareDir = path.join(process.cwd(), 'src', 'middleware');
2143
+ const authFilePath = path.join(middlewareDir, 'auth.js');
2144
+ if (!fs.existsSync(authFilePath)) {
2145
+ if (!fs.existsSync(middlewareDir)) {
2146
+ fs.mkdirSync(middlewareDir, { recursive: true });
2147
+ }
2148
+ const authContent = `import jwt from 'jsonwebtoken';
2149
+
2150
+ const auth = (req, res, next) => {
2151
+ const token = req.header('Authorization')?.replace('Bearer ', '');
2152
+ if (!token) {
2153
+ return res.status(401).json({ message: 'No token, authorization denied' });
2154
+ }
2155
+ try {
2156
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
2157
+ req.user = decoded;
2158
+ next();
2159
+ } catch (err) {
2160
+ res.status(401).json({ message: 'Token is not valid' });
2161
+ }
2162
+ };
2163
+
2164
+ export default auth;
2165
+ `;
2166
+ fs.writeFileSync(authFilePath, authContent, 'utf8');
2167
+ }
2168
+
2169
+ // Build login endpoint code
2170
+ const fieldsDestructure = fields.join(', ');
2171
+
2172
+ let passwordCheck = '';
2173
+ if (hasPassword) {
2174
+ passwordCheck = `
2175
+ const isMatch = await bcrypt.compare(password, user.password);
2176
+ if (!isMatch) {
2177
+ return res.status(400).json({ message: 'Invalid credentials' });
2178
+ }`;
2179
+ }
2180
+
2181
+ let tokenCode = '';
2182
+ if (tokenExpiry) {
2183
+ tokenCode = ` const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
2184
+ const refreshToken = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d' });
2185
+ res.json({ token, refreshToken });`;
2186
+ } else {
2187
+ tokenCode = ` const token = jwt.sign(payload, process.env.JWT_SECRET);
2188
+ res.json({ token });`;
2189
+ }
2190
+
2191
+ const loginCode = `
2192
+ router.post('/login', async (req, res) => {
2193
+ const { ${fieldsDestructure} } = req.body;
2194
+ try {
2195
+ const user = await ${pascalModel}.findOne({ ${fields[0]}: ${fields[0]} });
2196
+ if (!user) {
2197
+ return res.status(404).json({ message: 'User not found' });
2198
+ }${passwordCheck}
2199
+ const payload = { id: user._id };
2200
+ ${tokenCode}
2201
+ } catch (err) {
2202
+ console.error(err);
2203
+ res.status(500).json({ message: 'Server error' });
2204
+ }
2205
+ });
2206
+ `;
2207
+
2208
+ // Add login endpoint to the selected route file
2209
+ const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile);
2210
+ if (!fs.existsSync(routeFilePath)) {
2211
+ return res.status(404).json({ error: `Route file "${routeFile}" not found.` });
2212
+ }
2213
+
2214
+ // Add imports to route file
2215
+ addImportToRoute(routeFilePath, "import jwt from 'jsonwebtoken';");
2216
+ if (hasPassword) {
2217
+ addImportToRoute(routeFilePath, "import bcrypt from 'bcrypt';");
2218
+ }
2219
+ addImportToRoute(routeFilePath, `import ${pascalModel} from '../models/${modelName}.js';`);
2220
+
2221
+ // Insert login code
2222
+ insertCodeIntoRoute(routeFilePath, loginCode);
2223
+
2224
+ // If tokenExpiry, create refresh-token route
2225
+ if (tokenExpiry) {
2226
+ const refreshRouteDir = path.join(process.cwd(), 'src', 'routes');
2227
+ const refreshFilePath = path.join(refreshRouteDir, 'refresh-token.js');
2228
+ if (!fs.existsSync(refreshFilePath)) {
2229
+ const refreshContent = `import express from 'express';
2230
+ import jwt from 'jsonwebtoken';
2231
+
2232
+ const router = express.Router();
2233
+
2234
+ router.post('/', async (req, res) => {
2235
+ const { refreshToken } = req.body;
2236
+ if (!refreshToken) {
2237
+ return res.status(401).json({ message: 'Refresh token required' });
2238
+ }
2239
+ try {
2240
+ const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
2241
+ const payload = { id: decoded.id };
2242
+ const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '15m' });
2243
+ res.json({ token });
2244
+ } catch (err) {
2245
+ res.status(401).json({ message: 'Invalid refresh token' });
2246
+ }
2247
+ });
2248
+
2249
+ export default router;
2250
+ `;
2251
+ fs.writeFileSync(refreshFilePath, refreshContent, 'utf8');
2252
+
2253
+ // Register in index.js
2254
+ addImportToIndex("import refreshTokenRouter from './src/routes/refresh-token.js';");
2255
+ const routeReg = "app.use('/api/refresh-token', refreshTokenRouter);";
2256
+ let idxContent = readIndexFile();
2257
+ if (!idxContent.includes(routeReg)) {
2258
+ insertBeforeListen(routeReg);
2259
+ }
2260
+ }
2261
+ }
2262
+
2263
+ // Generate JWT secret and update .env
2264
+ updateEnvFile(path.join(process.cwd(), '.env'), 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
2265
+
2266
+ const filesModified = ['src/middleware/auth.js', `src/routes/${routeFile}`, '.env'];
2267
+ if (tokenExpiry) filesModified.push('src/routes/refresh-token.js');
2268
+
2269
+ // Send response BEFORE index.js modifications (nodemon restart kills connection)
2270
+ res.json({
2271
+ message: 'JWT authentication setup complete.',
2272
+ files: filesModified
2273
+ });
2274
+ } catch (err) {
2275
+ if (!res.headersSent) res.status(500).json({ error: err.message });
2276
+ }
2277
+ });
2278
+
2279
+ // ---------------------------------------------------------------------------
2280
+ // POST /api/add-socket — create src/services/socket.js
2281
+ // ---------------------------------------------------------------------------
2282
+ router.post('/api/add-socket', async (req, res) => {
2283
+ try {
2284
+ const { corsOrigins } = req.body;
2285
+
2286
+ // Install packages first
2287
+ const currentContent = readIndexFile();
2288
+ const hasWebSocket = currentContent.includes('setupWebSocket') || currentContent.includes('WebSocketServer');
2289
+
2290
+ recordDeps(['socket.io']);
2291
+ if (hasWebSocket) {
2292
+ try { await execAsync('npm uninstall ws && npm install socket.io', { cwd: process.cwd() }); } catch {
2293
+ try { await execAsync('npm install socket.io', { cwd: process.cwd() }); } catch {}
2294
+ }
2295
+ } else {
2296
+ await execAsync('npm install socket.io', { cwd: process.cwd() });
2297
+ }
2298
+
2299
+ // Send response before modifying files
2300
+ res.json({ message: 'Socket.IO setup complete.', files: ['src/services/socket.js', 'index.js'] });
2301
+
2302
+ // Build cors origin value
2303
+ let originValue;
2304
+ if (corsOrigins === '*') originValue = '"*"';
2305
+ else originValue = JSON.stringify(corsOrigins.split(',').map(s => s.trim()));
2306
+
2307
+ // Create src/services/socket.js
2308
+ const servicesDir = path.join(process.cwd(), 'src', 'services');
2309
+ if (!fs.existsSync(servicesDir)) fs.mkdirSync(servicesDir, { recursive: true });
2310
+
2311
+ const socketFile = `import { Server } from "socket.io";
2312
+ import http from "http";
2313
+
2314
+ export function setupSocketIO(app) {
2315
+ const server = http.createServer(app);
2316
+
2317
+ const io = new Server(server, {
2318
+ cors: {
2319
+ origin: ${originValue},
2320
+ methods: ["GET", "POST"],
2321
+ },
2322
+ });
2323
+
2324
+ io.on("connection", (socket) => {
2325
+ console.log("A user connected");
2326
+ socket.on("disconnect", () => {
2327
+ console.log("User disconnected");
2328
+ });
2329
+ });
2330
+
2331
+ // Make io accessible in routes via req.io
2332
+ app.use((req, res, next) => {
2333
+ req.io = io;
2334
+ next();
2335
+ });
2336
+
2337
+ return { server, io };
2338
+ }
2339
+ `;
2340
+
2341
+ fs.writeFileSync(path.join(servicesDir, 'socket.js'), socketFile, 'utf8');
2342
+
2343
+ // Remove old WebSocket service file if present
2344
+ const wsFile = path.join(servicesDir, 'websocket.js');
2345
+ if (fs.existsSync(wsFile)) fs.unlinkSync(wsFile);
2346
+
2347
+ // Update index.js — clean approach
2348
+ let content = readIndexFile();
2349
+
2350
+ // Remove old inline socket.io/websocket code
2351
+ content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
2352
+ content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
2353
+ content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
2354
+ content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
2355
+ content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
2356
+ content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
2357
+ 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, '');
2358
+ content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
2359
+ content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
2360
+ content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
2361
+ // Remove old import lines for services
2362
+ content = content.replace(/import\s*\{\s*setupSocketIO\s*\}\s*from\s*['"]\.\/src\/services\/socket\.js['"];\s*\n?/g, '');
2363
+ content = content.replace(/import\s*\{\s*setupWebSocket\s*\}\s*from\s*['"]\.\/src\/services\/websocket\.js['"];\s*\n?/g, '');
2364
+ content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupSocketIO\(app\);\s*\n?/g, '');
2365
+ content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupWebSocket\(app\);\s*\n?/g, '');
2366
+ content = content.replace(/setupWebSocket\(app[^)]*\);\s*\n?/g, '');
2367
+
2368
+ // Add import for socket service
2369
+ const lastImport = content.lastIndexOf('import ');
2370
+ const lineEnd = content.indexOf('\n', lastImport);
2371
+ content = content.slice(0, lineEnd + 1) + 'import { setupSocketIO } from "./src/services/socket.js";\n' + content.slice(lineEnd + 1);
2372
+
2373
+ // Add setup call before listen
2374
+ const listenIdx = content.search(/\/\/ ── Start Server|async function startServer|function startServer/);
2375
+ if (listenIdx !== -1) {
2376
+ content = content.slice(0, listenIdx) + 'const { server } = setupSocketIO(app);\n\n' + content.slice(listenIdx);
2377
+ }
2378
+
2379
+ // Replace app.listen with server.listen in startServer
2380
+ content = content.replace('const listener = app.listen(tryPort)', 'const listener = server.listen(tryPort)');
2381
+ // Also handle direct app.listen
2382
+ if (content.includes('app.listen(port') && !content.includes('startServer')) {
2383
+ content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, 'server.listen(port, () => {\n console.log(`Server is running on port ${port}`);\n});\n');
2384
+ }
2385
+
2386
+ // Revert server.listen back to app.listen for old-style
2387
+ content = content.replace('server.listen(port', 'server.listen(port');
2388
+
2389
+ content = content.replace(/\n{3,}/g, '\n\n');
2390
+ writeIndexFile(content);
2391
+ } catch (err) {
2392
+ if (!res.headersSent) res.status(500).json({ error: err.message });
2393
+ }
2394
+ });
2395
+
2396
+ // ---------------------------------------------------------------------------
2397
+ // POST /api/add-websocket — create src/services/websocket.js
2398
+ // ---------------------------------------------------------------------------
2399
+ router.post('/api/add-websocket', async (req, res) => {
2400
+ try {
2401
+ const { samePort, wsPort } = req.body;
2402
+
2403
+ // Install packages first
2404
+ const currentContent = readIndexFile();
2405
+ const hasSocketIO = currentContent.includes('setupSocketIO') || currentContent.includes('socket.io');
2406
+
2407
+ recordDeps(['ws']);
2408
+ if (hasSocketIO) {
2409
+ try { await execAsync('npm uninstall socket.io && npm install ws', { cwd: process.cwd() }); } catch {
2410
+ try { await execAsync('npm install ws', { cwd: process.cwd() }); } catch {}
2411
+ }
2412
+ } else {
2413
+ await execAsync('npm install ws', { cwd: process.cwd() });
2414
+ }
2415
+
2416
+ // Send response before modifying files
2417
+ res.json({ message: 'WebSocket setup complete.', files: ['src/services/websocket.js', 'index.js'] });
2418
+
2419
+ // Create src/services/websocket.js
2420
+ const servicesDir = path.join(process.cwd(), 'src', 'services');
2421
+ if (!fs.existsSync(servicesDir)) fs.mkdirSync(servicesDir, { recursive: true });
2422
+
2423
+ let wsFileContent;
2424
+ if (samePort) {
2425
+ wsFileContent = `import { WebSocketServer } from "ws";
2426
+ import http from "http";
2427
+
2428
+ export function setupWebSocket(app) {
2429
+ const server = http.createServer(app);
2430
+ const wss = new WebSocketServer({ server });
2431
+
2432
+ wss.on("connection", (ws) => {
2433
+ console.log("WebSocket client connected");
2434
+ ws.on("message", (message) => {
2435
+ console.log("Received:", message.toString());
2436
+ });
2437
+ ws.on("close", () => {
2438
+ console.log("WebSocket client disconnected");
2439
+ });
2440
+ });
2441
+
2442
+ return { server, wss };
2443
+ }
2444
+ `;
2445
+ } else {
2446
+ const p = wsPort || 8080;
2447
+ wsFileContent = `import { WebSocketServer } from "ws";
2448
+
2449
+ export function setupWebSocket() {
2450
+ const wss = new WebSocketServer({ port: ${p} });
2451
+
2452
+ wss.on("connection", (ws) => {
2453
+ console.log("WebSocket client connected");
2454
+ ws.on("message", (message) => {
2455
+ console.log("Received:", message.toString());
2456
+ });
2457
+ ws.on("close", () => {
2458
+ console.log("WebSocket client disconnected");
2459
+ });
2460
+ });
2461
+
2462
+ console.log("WebSocket server running on port ${p}");
2463
+ return { wss };
2464
+ }
2465
+ `;
2466
+ }
2467
+
2468
+ fs.writeFileSync(path.join(servicesDir, 'websocket.js'), wsFileContent, 'utf8');
2469
+
2470
+ // Remove old Socket.IO service file if present
2471
+ const sioFile = path.join(servicesDir, 'socket.js');
2472
+ if (fs.existsSync(sioFile)) fs.unlinkSync(sioFile);
2473
+
2474
+ // Update index.js — clean approach
2475
+ let content = readIndexFile();
2476
+
2477
+ // Remove old inline socket.io/websocket code
2478
+ content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, '');
2479
+ content = content.replace(/import\s*\{\s*WebSocketServer\s*\}\s*from\s*['"]ws['"];\s*\n?/g, '');
2480
+ content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, '');
2481
+ content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, '');
2482
+ content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, '');
2483
+ content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, '');
2484
+ 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, '');
2485
+ content = content.replace(/const\s+wss\s*=\s*new\s+WebSocketServer\(\{[\s\S]*?\n?\}\);\s*\n?/g, '');
2486
+ content = content.replace(/wss\.on\(['"]connection['"][\s\S]*?\n\}\);\s*\n?/g, '');
2487
+ content = content.replace(/console\.log\(['"]WebSocket server running on port.*?['"]?\);\s*\n?/g, '');
2488
+ // Remove old service imports
2489
+ content = content.replace(/import\s*\{\s*setupSocketIO\s*\}\s*from\s*['"]\.\/src\/services\/socket\.js['"];\s*\n?/g, '');
2490
+ content = content.replace(/import\s*\{\s*setupWebSocket\s*\}\s*from\s*['"]\.\/src\/services\/websocket\.js['"];\s*\n?/g, '');
2491
+ content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupSocketIO\(app\);\s*\n?/g, '');
2492
+ content = content.replace(/const\s*\{\s*server[^}]*\}\s*=\s*setupWebSocket\(app\);\s*\n?/g, '');
2493
+ content = content.replace(/setupWebSocket\(app[^)]*\);\s*\n?/g, '');
2494
+ content = content.replace(/setupWebSocket\(\);\s*\n?/g, '');
2495
+
2496
+ // Add import for websocket service
2497
+ const lastImport = content.lastIndexOf('import ');
2498
+ const lineEnd = content.indexOf('\n', lastImport);
2499
+ content = content.slice(0, lineEnd + 1) + 'import { setupWebSocket } from "./src/services/websocket.js";\n' + content.slice(lineEnd + 1);
2500
+
2501
+ // Add setup call before listen
2502
+ const listenIdx = content.search(/\/\/ ── Start Server|async function startServer|function startServer/);
2503
+ if (listenIdx !== -1) {
2504
+ if (samePort) {
2505
+ content = content.slice(0, listenIdx) + 'const { server } = setupWebSocket(app);\n\n' + content.slice(listenIdx);
2506
+ // Replace app.listen with server.listen
2507
+ content = content.replace('const listener = app.listen(tryPort)', 'const listener = server.listen(tryPort)');
2508
+ if (content.includes('app.listen(port') && !content.includes('startServer')) {
2509
+ content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, 'server.listen(port, () => {\n console.log(`Server is running on port ${port}`);\n});\n');
2510
+ }
2511
+ } else {
2512
+ content = content.slice(0, listenIdx) + 'setupWebSocket();\n\n' + content.slice(listenIdx);
2513
+ }
2514
+ }
2515
+
2516
+ content = content.replace(/\n{3,}/g, '\n\n');
2517
+ writeIndexFile(content);
2518
+ } catch (err) {
2519
+ if (!res.headersSent) res.status(500).json({ error: err.message });
2520
+ }
2521
+ });
2522
+
2523
+ // ---------------------------------------------------------------------------
2524
+ // GET /api/serialports — list configured serial ports from index.js
2525
+ // ---------------------------------------------------------------------------
2526
+ router.get('/api/serialports', (req, res) => {
2527
+ try {
2528
+ const indexPath = path.join(process.cwd(), 'index.js');
2529
+ if (!fs.existsSync(indexPath)) return res.json([]);
2530
+ const content = fs.readFileSync(indexPath, 'utf8');
2531
+ const ports = [];
2532
+ // Match: const varName = new SerialPort({ path: '...', baudRate: 9600 })
2533
+ 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;
2534
+ let m;
2535
+ while ((m = regex.exec(content)) !== null) {
2536
+ ports.push({ name: m[1], path: m[2], baudRate: parseInt(m[3]) });
2537
+ }
2538
+ res.json(ports);
2539
+ } catch (err) {
2540
+ res.status(500).json({ error: err.message });
2541
+ }
2542
+ });
2543
+
2544
+ // ---------------------------------------------------------------------------
2545
+ // POST /api/add-serialport — add a named serial port connection
2546
+ // ---------------------------------------------------------------------------
2547
+ router.post('/api/add-serialport', async (req, res) => {
2548
+ try {
2549
+ const name = (req.body.name || 'serialPort').replace(/[^a-zA-Z0-9_]/g, '');
2550
+ const portPath = req.body.portPath || '/dev/ttyUSB0';
2551
+ const baud = req.body.baudRate || 9600;
2552
+ const parserName = name + 'Parser';
2553
+ const envPathKey = name.toUpperCase() + '_PATH';
2554
+ const envBaudKey = name.toUpperCase() + '_BAUD';
2555
+
2556
+ // Install FIRST before modifying index.js
2557
+ const indexContent = readIndexFile();
2558
+ if (!indexContent.includes("from 'serialport'")) {
2559
+ recordDeps(['serialport', '@serialport/parser-readline']);
2560
+ await execAsync('npm install serialport @serialport/parser-readline', { cwd: process.cwd() });
2561
+ }
2562
+
2563
+ // Update .env
2564
+ const envFile = path.join(process.cwd(), '.env');
2565
+ updateEnvFile(envFile, envPathKey, portPath);
2566
+ updateEnvFile(envFile, envBaudKey, String(baud));
2567
+
2568
+ // Send response BEFORE modifying index.js
2569
+ res.json({
2570
+ message: 'Serial port "' + name + '" added.',
2571
+ files: ['index.js', '.env']
2572
+ });
2573
+
2574
+ // Modify index.js LAST
2575
+ addImportToIndex("import { SerialPort } from 'serialport';");
2576
+ addImportToIndex("import { ReadlineParser } from '@serialport/parser-readline';");
2577
+
2578
+ const serialCode = `const ${name} = new SerialPort({
2579
+ path: process.env.${envPathKey} || '${portPath}',
2580
+ baudRate: parseInt(process.env.${envBaudKey}) || ${baud}
2581
+ });
2582
+
2583
+ const ${parserName} = ${name}.pipe(new ReadlineParser({ delimiter: '\\r\\n' }));
2584
+
2585
+ ${name}.on('open', () => {
2586
+ console.log('${name}: port opened');
2587
+ });
2588
+
2589
+ ${parserName}.on('data', (data) => {
2590
+ console.log('${name} data:', data);
2591
+ });
2592
+
2593
+ ${name}.on('error', (err) => {
2594
+ console.error('${name} error:', err.message);
2595
+ });`;
2596
+
2597
+ let current = readIndexFile();
2598
+ if (!current.includes(`const ${name} = new SerialPort`)) {
2599
+ insertBeforeListen(serialCode);
2600
+ }
2601
+ } catch (err) {
2602
+ if (!res.headersSent) res.status(500).json({ error: err.message });
2603
+ }
2604
+ });
2605
+
2606
+ // ---------------------------------------------------------------------------
2607
+ // DELETE /api/serialports/:name — remove a serial port from index.js
2608
+ // ---------------------------------------------------------------------------
2609
+ router.delete('/api/serialports/:name', (req, res) => {
2610
+ try {
2611
+ const name = req.params.name;
2612
+ const parserName = name + 'Parser';
2613
+ let content = readIndexFile();
2614
+
2615
+ // Remove the SerialPort block: const name = new SerialPort({...});
2616
+ content = content.replace(new RegExp(`const\\s+${name}\\s*=\\s*new\\s+SerialPort\\(\\{[\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
2617
+ // Remove parser line
2618
+ content = content.replace(new RegExp(`const\\s+${parserName}\\s*=\\s*${name}\\.pipe[\\s\\S]*?;\\s*\\n?`, 'g'), '');
2619
+ // Remove event handlers
2620
+ content = content.replace(new RegExp(`${name}\\.on\\(['"]open['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
2621
+ content = content.replace(new RegExp(`${parserName}\\.on\\(['"]data['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
2622
+ content = content.replace(new RegExp(`${name}\\.on\\(['"]error['"][\\s\\S]*?\\}\\);\\s*\\n?`, 'g'), '');
2623
+
2624
+ // If no more SerialPort instances, remove imports
2625
+ if (!content.includes('new SerialPort')) {
2626
+ content = content.replace(/import\s*\{\s*SerialPort\s*\}\s*from\s*['"]serialport['"];\s*\n?/g, '');
2627
+ content = content.replace(/import\s*\{\s*ReadlineParser\s*\}\s*from\s*['"]@serialport\/parser-readline['"];\s*\n?/g, '');
2628
+ }
2629
+
2630
+ // Clean up multiple blank lines
2631
+ content = content.replace(/\n{3,}/g, '\n\n');
2632
+ writeIndexFile(content);
2633
+
2634
+ // Remove env vars
2635
+ const envPathKey = name.toUpperCase() + '_PATH';
2636
+ const envBaudKey = name.toUpperCase() + '_BAUD';
2637
+ const envFilePath = path.join(process.cwd(), '.env');
2638
+ if (fs.existsSync(envFilePath)) {
2639
+ let envContent = fs.readFileSync(envFilePath, 'utf8');
2640
+ envContent = envContent.replace(new RegExp(`^${envPathKey}=.*\\n?`, 'm'), '');
2641
+ envContent = envContent.replace(new RegExp(`^${envBaudKey}=.*\\n?`, 'm'), '');
2642
+ fs.writeFileSync(envFilePath, envContent, 'utf8');
2643
+ }
2644
+
2645
+ res.json({ message: 'Serial port "' + name + '" removed.' });
2646
+ } catch (err) {
2647
+ res.status(500).json({ error: err.message });
2648
+ }
2649
+ });
2650
+
2651
+ // ---------------------------------------------------------------------------
2652
+ // API Tester — field configs (stored in .4bnode/tester-fields.json)
2653
+ // ---------------------------------------------------------------------------
2654
+ const testerFieldsPath = path.join(process.cwd(), '.4bnode', 'tester-fields.json');
2655
+
2656
+ function loadTesterFields() {
2657
+ try {
2658
+ if (fs.existsSync(testerFieldsPath)) return JSON.parse(fs.readFileSync(testerFieldsPath, 'utf8'));
2659
+ } catch {}
2660
+ return {};
2661
+ }
2662
+
2663
+ function saveTesterFields(data) {
2664
+ const dir = path.dirname(testerFieldsPath);
2665
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2666
+ fs.writeFileSync(testerFieldsPath, JSON.stringify(data, null, 2), 'utf8');
2667
+ }
2668
+
2669
+ const testerDataPath = path.join(process.cwd(), '.4bnode', 'tester-data.json');
2670
+
2671
+ function loadTesterData() {
2672
+ try {
2673
+ if (fs.existsSync(testerDataPath)) return JSON.parse(fs.readFileSync(testerDataPath, 'utf8'));
2674
+ } catch {}
2675
+ return { tests: [], responses: {} };
2676
+ }
2677
+
2678
+ function saveTesterData(data) {
2679
+ const dir = path.dirname(testerDataPath);
2680
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2681
+ fs.writeFileSync(testerDataPath, JSON.stringify(data, null, 2), 'utf8');
2682
+ }
2683
+
2684
+ // GET /api/api-tester/data — load saved tests and responses
2685
+ router.get('/api/api-tester/data', (req, res) => {
2686
+ res.json(loadTesterData());
2687
+ });
2688
+
2689
+ // PUT /api/api-tester/data — save tests and responses
2690
+ router.put('/api/api-tester/data', (req, res) => {
2691
+ const { tests, responses } = req.body;
2692
+ saveTesterData({ tests: tests || [], responses: responses || {} });
2693
+ res.json({ message: 'Saved' });
2694
+ });
2695
+
2696
+ // GET /api/api-tester/fields/:routeKey — get saved fields for a route
2697
+ router.get('/api/api-tester/fields/:routeKey', (req, res) => {
2698
+ const all = loadTesterFields();
2699
+ const key = decodeURIComponent(req.params.routeKey);
2700
+ res.json({ fields: all[key] || null });
2701
+ });
2702
+
2703
+ // PUT /api/api-tester/fields/:routeKey — save fields for a route
2704
+ router.put('/api/api-tester/fields/:routeKey', (req, res) => {
2705
+ const { fields } = req.body;
2706
+ const key = decodeURIComponent(req.params.routeKey);
2707
+ const all = loadTesterFields();
2708
+ all[key] = fields;
2709
+ saveTesterFields(all);
2710
+ res.json({ message: 'Fields saved' });
2711
+ });
2712
+
2713
+ // ---------------------------------------------------------------------------
2714
+ // API Tester — proxy requests to avoid CORS issues
2715
+ // ---------------------------------------------------------------------------
2716
+ router.post('/api/api-tester/send', async (req, res) => {
2717
+ try {
2718
+ const { method, url, headers: reqHeaders, body: reqBody, bodyType = 'json', formFields = [] } = req.body;
2719
+ if (!url) return res.status(400).json({ error: 'URL is required' });
2720
+
2721
+ const fetchOpts = { method: (method || 'GET').toUpperCase() };
2722
+ const headers = { ...(reqHeaders || {}) };
2723
+
2724
+ if (['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
2725
+ if (bodyType === 'form-data' && formFields.length > 0) {
2726
+ // Build multipart form data
2727
+ const boundary = '----4bnode' + crypto.randomBytes(8).toString('hex');
2728
+ let bodyParts = [];
2729
+ for (const field of formFields) {
2730
+ if (!field.key?.trim()) continue;
2731
+ if (field.fileData) {
2732
+ // File field (base64 encoded data)
2733
+ const fileBuffer = Buffer.from(field.fileData, 'base64');
2734
+ bodyParts.push(
2735
+ `--${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`
2736
+ );
2737
+ bodyParts.push(fileBuffer);
2738
+ bodyParts.push('\r\n');
2739
+ } else {
2740
+ bodyParts.push(
2741
+ `--${boundary}\r\nContent-Disposition: form-data; name="${field.key}"\r\n\r\n${field.value || ''}\r\n`
2742
+ );
2743
+ }
2744
+ }
2745
+ bodyParts.push(`--${boundary}--\r\n`);
2746
+ // Combine string and buffer parts
2747
+ const bufferParts = bodyParts.map(p => typeof p === 'string' ? Buffer.from(p) : p);
2748
+ fetchOpts.body = Buffer.concat(bufferParts);
2749
+ headers['Content-Type'] = `multipart/form-data; boundary=${boundary}`;
2750
+ } else if (bodyType === 'x-www-form-urlencoded' && formFields.length > 0) {
2751
+ fetchOpts.body = formFields
2752
+ .filter(f => f.key?.trim())
2753
+ .map(f => encodeURIComponent(f.key) + '=' + encodeURIComponent(f.value || ''))
2754
+ .join('&');
2755
+ headers['Content-Type'] = 'application/x-www-form-urlencoded';
2756
+ } else if (bodyType === 'binary' && reqBody) {
2757
+ // Binary body (base64 encoded)
2758
+ fetchOpts.body = Buffer.from(reqBody, 'base64');
2759
+ } else if (reqBody) {
2760
+ fetchOpts.body = typeof reqBody === 'string' ? reqBody : JSON.stringify(reqBody);
2761
+ }
2762
+ }
2763
+
2764
+ // Remove Content-Type for form-data (let boundary be set correctly)
2765
+ if (bodyType === 'form-data') {
2766
+ // Already set above with boundary
2767
+ }
2768
+
2769
+ fetchOpts.headers = headers;
2770
+
2771
+ const start = Date.now();
2772
+ const response = await fetch(url, fetchOpts);
2773
+ const elapsed = Date.now() - start;
2774
+ const text = await response.text();
2775
+ const respHeaders = {};
2776
+ response.headers.forEach((v, k) => { respHeaders[k] = v; });
2777
+ res.json({
2778
+ status: response.status,
2779
+ statusText: response.statusText,
2780
+ headers: respHeaders,
2781
+ body: text,
2782
+ time: elapsed,
2783
+ size: Buffer.byteLength(text),
2784
+ });
2785
+ } catch (err) {
2786
+ res.json({ status: 0, statusText: 'Connection Error', body: err.message, headers: {}, time: 0, size: 0 });
2787
+ }
2788
+ });
2789
+
2790
+ // GET /api/api-tester/routes — list all routes with endpoints for import
2791
+ router.get('/api/api-tester/routes', (req, res) => {
2792
+ try {
2793
+ const routes = listRoutes();
2794
+ const result = [];
2795
+ const indexPath = path.join(process.cwd(), 'index.js');
2796
+ const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
2797
+
2798
+ routes.forEach(routeFile => {
2799
+ const name = routeFile.replace('.js', '');
2800
+ const filePath = path.join(process.cwd(), 'src', 'routes', routeFile);
2801
+ if (!fs.existsSync(filePath)) return;
2802
+ const content = fs.readFileSync(filePath, 'utf8');
2803
+
2804
+ let prefix = '/api/' + name;
2805
+ const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${name}`));
2806
+ if (prefixMatch) prefix = prefixMatch[1];
2807
+
2808
+ const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
2809
+ let modelFields = [];
2810
+ if (modelImportMatch) {
2811
+ modelFields = getModelSchemaDetailed(modelImportMatch[2]).fields;
2812
+ }
2813
+
2814
+ const endpoints = [];
2815
+ const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,([\s\S]*?)(?=\nrouter\.|$)/gi;
2816
+ let m;
2817
+ while ((m = epRegex.exec(content)) !== null) {
2818
+ const method = m[1].toUpperCase();
2819
+ const epath = m[2];
2820
+ const body = m[3] || '';
2821
+ const bodyFields = [];
2822
+ const queryFields = [];
2823
+ const fileFields = [];
2824
+ extractRequestFields(body, 'body').forEach(fname => {
2825
+ const meta = modelFields.find(mf => mf.name === fname);
2826
+ bodyFields.push({ name: fname, type: meta?.type || 'String', required: meta?.required || false, unique: meta?.unique || false });
2827
+ });
2828
+ extractRequestFields(body, 'query').forEach(fname => {
2829
+ queryFields.push({ name: fname, type: 'String' });
2830
+ });
2831
+ // Detect file fields from multer middleware and req.file/req.files
2832
+ const uploadSingleMatch = body.match(/upload\.single\(\s*['"](\w+)['"]\s*\)/);
2833
+ if (uploadSingleMatch) fileFields.push({ name: uploadSingleMatch[1] });
2834
+ const uploadFieldsRegex = /name:\s*['"](\w+)['"]/g;
2835
+ const uploadFieldsBlock = body.match(/upload\.fields\(\s*\[([\s\S]*?)\]\s*\)/);
2836
+ if (uploadFieldsBlock) {
2837
+ let ufm;
2838
+ while ((ufm = uploadFieldsRegex.exec(uploadFieldsBlock[1])) !== null) {
2839
+ if (!fileFields.find(f => f.name === ufm[1])) fileFields.push({ name: ufm[1] });
2840
+ }
2841
+ }
2842
+ // Also detect from req.file / req.files assignments
2843
+ const singleFileMatch = body.match(/const\s+(\w+)\s*=\s*req\.file\b/);
2844
+ if (singleFileMatch && !fileFields.find(f => f.name === singleFileMatch[1])) fileFields.push({ name: singleFileMatch[1] });
2845
+ const multiFileRegex = /const\s+(\w+)\s*=\s*req\.files\??\[?\s*\[?['"]([\w]+)['"]\]?/g;
2846
+ let fm;
2847
+ while ((fm = multiFileRegex.exec(body)) !== null) {
2848
+ if (!fileFields.find(f => f.name === fm[1])) fileFields.push({ name: fm[1] });
2849
+ }
2850
+
2851
+ const hasAuth = /auth\s*,/.test(m[0]);
2852
+ const params = [];
2853
+ const paramRegex = /:(\w+)/g;
2854
+ let pm;
2855
+ while ((pm = paramRegex.exec(epath)) !== null) params.push(pm[1]);
2856
+
2857
+ endpoints.push({
2858
+ method,
2859
+ path: epath,
2860
+ fullPath: prefix + (epath === '/' ? '' : epath),
2861
+ bodyFields,
2862
+ queryFields,
2863
+ fileFields,
2864
+ params,
2865
+ hasAuth,
2866
+ });
2867
+ }
2868
+ const hasAuth = content.includes("import auth");
2869
+ result.push({ name, routeFile, prefix, endpoints, hasAuth });
2870
+ });
2871
+ res.json(result);
2872
+ } catch (err) {
2873
+ res.status(500).json({ error: err.message });
2874
+ }
2875
+ });
2876
+
2877
+ // ---------------------------------------------------------------------------
2878
+ // Logs — history, SSE stream, clear
2879
+ // ---------------------------------------------------------------------------
2880
+ router.get('/api/logs', (req, res) => {
2881
+ res.json(_logBuffer);
2882
+ });
2883
+
2884
+ router.get('/api/logs/stream', (req, res) => {
2885
+ // EventSource can't send custom headers, so verify passkey from query param
2886
+ const stored = getPasskeyHash();
2887
+ if (stored) {
2888
+ const lock = authLockState(req);
2889
+ if (lock.locked) {
2890
+ res.set('Retry-After', String(lock.retryAfter));
2891
+ return res.status(429).json({ error: 'Too many attempts. Try again later.' });
2892
+ }
2893
+ const pk = req.query.passkey;
2894
+ if (!verifyPasskey(pk, stored)) {
2895
+ if (pk) recordAuthFailure(req);
2896
+ return res.status(401).json({ error: 'Unauthorized' });
2897
+ }
2898
+ }
2899
+ res.writeHead(200, {
2900
+ 'Content-Type': 'text/event-stream',
2901
+ 'Cache-Control': 'no-cache',
2902
+ Connection: 'keep-alive',
2903
+ });
2904
+ res.write(':\n\n');
2905
+ _logClients.add(res);
2906
+ req.on('close', () => _logClients.delete(res));
2907
+ });
2908
+
2909
+ router.delete('/api/logs', (req, res) => {
2910
+ _logBuffer.length = 0;
2911
+ res.json({ message: 'Logs cleared' });
2912
+ });
2913
+
2914
+ // ---------------------------------------------------------------------------
2915
+ // Share Collection — save and serve standalone shared page
2916
+ // ---------------------------------------------------------------------------
2917
+ const sharedDir = path.join(process.cwd(), '.4bnode', 'shared');
2918
+
2919
+ router.post('/api/api-tester/share-collection', (req, res) => {
2920
+ try {
2921
+ const { tests, responses } = req.body;
2922
+ if (!tests?.length) return res.status(400).json({ error: 'No tests to share' });
2923
+ if (!fs.existsSync(sharedDir)) fs.mkdirSync(sharedDir, { recursive: true });
2924
+ let appName = 'API Collection';
2925
+ try { appName = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).name || appName; } catch {}
2926
+ // Use fixed ID so the share URL stays the same — always updates the same file
2927
+ const id = crypto.createHash('md5').update(appName).digest('hex').slice(0, 12);
2928
+ fs.writeFileSync(path.join(sharedDir, id + '.json'), JSON.stringify({ tests, responses, appName, createdAt: new Date().toISOString() }));
2929
+ res.json({ id });
2930
+ } catch (err) {
2931
+ res.status(500).json({ error: err.message });
2932
+ }
2933
+ });
2934
+
2935
+ router.get('/shared/:id', (req, res) => {
2936
+ try {
2937
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
2938
+ res.setHeader('Pragma', 'no-cache');
2939
+ const filePath = path.join(sharedDir, req.params.id + '.json');
2940
+ if (!fs.existsSync(filePath)) return res.status(404).send('Collection not found');
2941
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
2942
+ if (!data.tests) data.tests = [];
2943
+ if (!data.responses) data.responses = {};
2944
+ const rawAppName = data.appName || 'My App';
2945
+ // Convert kebab-case/snake_case to Title Case: "test-app" → "Test App"
2946
+ const appName = rawAppName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2947
+
2948
+ const safeJson = (obj) => JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
2949
+ const escHtml = (s) => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2950
+ const safeAppName = escHtml(appName);
2951
+
2952
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
2953
+ <title>${safeAppName} - API Documentation</title>
2954
+ <style>
2955
+ *{margin:0;padding:0;box-sizing:border-box}
2956
+ body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column}
2957
+ .topbar{height:52px;background:#161b26;border-bottom:1px solid #1e2433;display:flex;align-items:center;padding:0 24px;gap:12px;flex-shrink:0}
2958
+ .topbar-name{font-size:16px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
2959
+ .topbar-sep{width:1px;height:22px;background:#1e2433;margin:0 8px}
2960
+ .topbar-label{font-size:12px;color:#64748b;font-weight:500}
2961
+ .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}
2962
+ .topbar-date{font-size:10px;color:#475569}
2963
+ .layout{display:flex;flex:1;overflow:hidden}
2964
+ .sidebar{width:300px;min-width:300px;background:#161b26;border-right:1px solid #1e2433;display:flex;flex-direction:column;overflow:hidden}
2965
+ .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}
2966
+ .sidebar-search{padding:8px 12px;border-bottom:1px solid #1e2433}
2967
+ .sidebar-search input{width:100%;padding:7px 12px;background:#0f1117;border:1px solid #1e2433;border-radius:6px;color:#e2e8f0;font-size:12px;outline:none}
2968
+ .sidebar-search input:focus{border-color:#7c3aed}
2969
+ .sidebar-search input::placeholder{color:#475569}
2970
+ .sidebar-list{flex:1;overflow-y:auto}
2971
+ .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;color:#e2e8f0}
2972
+ .sidebar-item:hover{background:rgba(124,58,237,0.06)}
2973
+ .sidebar-item.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
2974
+ .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}
2975
+ .sidebar-child:hover{background:rgba(124,58,237,0.06)}
2976
+ .sidebar-child.active{background:rgba(124,58,237,0.1);border-left-color:#7c3aed}
2977
+ .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}
2978
+ .mb-get{background:rgba(52,211,153,0.15);color:#34d399}
2979
+ .mb-post{background:rgba(245,158,11,0.15);color:#f59e0b}
2980
+ .mb-put{background:rgba(59,130,246,0.15);color:#3b82f6}
2981
+ .mb-patch{background:rgba(139,92,246,0.15);color:#8b5cf6}
2982
+ .mb-delete{background:rgba(248,113,113,0.15);color:#f87171}
2983
+ .item-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e2e8f0}
2984
+ .item-arrow{font-size:10px;color:#475569;width:14px;text-align:center;flex-shrink:0}
2985
+ .item-badge{font-size:9px;padding:2px 7px;border-radius:8px;background:rgba(124,58,237,0.15);color:#a78bfa;font-weight:600}
2986
+ .sb{font-size:9px;font-weight:700;padding:3px 7px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace;flex-shrink:0}
2987
+ .s2{background:rgba(52,211,153,0.15);color:#34d399}
2988
+ .s3{background:rgba(96,165,250,0.15);color:#60a5fa}
2989
+ .s4{background:rgba(251,191,36,0.15);color:#fbbf24}
2990
+ .s5{background:rgba(248,113,113,0.15);color:#f87171}
2991
+ .s0{background:rgba(248,113,113,0.15);color:#f87171}
2992
+ .child-label{font-size:11px;color:#94a3b8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2993
+ .child-time{font-size:10px;color:#475569;flex-shrink:0}
2994
+ .main{flex:1;display:flex;flex-direction:column;overflow:hidden}
2995
+ .main-content{flex:1;overflow-y:auto;padding:24px}
2996
+ .empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:#475569;gap:12px}
2997
+ .empty-state svg{width:48px;height:48px;stroke:#2d3548;stroke-width:1.5;fill:none}
2998
+ .card{background:#161b26;border:1px solid #1e2433;border-radius:10px;padding:18px;margin-bottom:16px}
2999
+ .card-header{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid #1e2433}
3000
+ .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}
3001
+ .endpoint-url code{flex:1;word-break:break-all}
3002
+ .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}
3003
+ .copy-btn:hover{border-color:#7c3aed;color:#a78bfa}
3004
+ .stitle{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#475569;margin-bottom:10px}
3005
+ 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}
3006
+ .field-row{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#0f1117;border-radius:6px;margin-bottom:4px;border:1px solid #1e2433}
3007
+ .field-name{font-family:'SF Mono','Fira Code',monospace;font-size:12px;font-weight:500;color:#e2e8f0;min-width:120px}
3008
+ .field-type{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(124,58,237,0.12);color:#a78bfa;font-weight:500}
3009
+ .field-req{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(52,211,153,0.12);color:#34d399;font-weight:600}
3010
+ .field-uniq{font-size:10px;padding:2px 7px;border-radius:4px;background:rgba(251,191,36,0.12);color:#fbbf24;font-weight:600}
3011
+ .code-tabs{display:flex;gap:0;border-bottom:1px solid #1e2433;margin-bottom:0}
3012
+ .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}
3013
+ .code-tab:hover{color:#94a3b8}
3014
+ .code-tab.active{color:#a78bfa;border-bottom-color:#7c3aed;font-weight:600}
3015
+ .header-row{display:flex;padding:6px 12px;font-size:12px;gap:12px;border-bottom:1px solid rgba(30,36,51,0.5)}
3016
+ .header-key{font-family:'SF Mono','Fira Code',monospace;font-size:11px;font-weight:600;color:#a78bfa;min-width:160px}
3017
+ .header-val{font-family:'SF Mono','Fira Code',monospace;font-size:11px;color:#94a3b8;word-break:break-all}
3018
+ .type-sio{background:rgba(52,211,153,0.15);color:#34d399}
3019
+ .type-ws{background:rgba(96,165,250,0.15);color:#60a5fa}
3020
+ .dir-recv{background:rgba(52,211,153,0.15);color:#34d399}
3021
+ .dir-sent{background:rgba(245,158,11,0.15);color:#f59e0b}
3022
+ .dir-sys{background:rgba(148,163,184,0.15);color:#94a3b8}
3023
+ @keyframes spin{to{transform:rotate(360deg)}}
3024
+ .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}
3025
+ </style></head><body>
3026
+ <div class="topbar">
3027
+ <span class="topbar-name">${safeAppName}</span>
3028
+ <span class="topbar-sep"></span>
3029
+ <span class="topbar-label">API Documentation</span>
3030
+ <span class="topbar-count">${data.tests.length} endpoint${data.tests.length !== 1 ? 's' : ''}</span>
3031
+ <span class="topbar-date">Shared ${new Date(data.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
3032
+ </div>
3033
+ <div class="layout">
3034
+ <div class="sidebar">
3035
+ <div class="sidebar-header"><span>Endpoints</span></div>
3036
+ <div class="sidebar-search"><input id="search-inp" placeholder="Search endpoints..." /></div>
3037
+ <div class="sidebar-list" id="sidebar-list"></div>
3038
+ </div>
3039
+ <div class="main">
3040
+ <div class="main-content" id="main-content">
3041
+ <div class="empty-state">
3042
+ <svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
3043
+ <span style="font-size:14px;font-weight:500">Select an endpoint from the sidebar</span>
3044
+ <span style="font-size:12px">View request details, example code, and saved responses</span>
3045
+ </div>
3046
+ </div>
3047
+ </div>
3048
+ </div>
3049
+ <script id="shared-data" type="application/json">${safeJson({ tests: data.tests, responses: data.responses || {} })}</script>
3050
+ <script>
3051
+ (function(){
3052
+ var _d = JSON.parse(document.getElementById('shared-data').textContent);
3053
+ var tests = _d.tests;
3054
+ var responses = _d.responses;
3055
+ var activeTestId = null;
3056
+ var activeRespId = null;
3057
+ var expanded = {};
3058
+ var activeCodeTab = {};
3059
+ var searchQuery = '';
3060
+
3061
+ tests.forEach(function(t){ t.id = String(t.id); });
3062
+ var normResp = {};
3063
+ Object.keys(responses).forEach(function(k){
3064
+ var key = String(k);
3065
+ var arr = responses[k];
3066
+ if (Array.isArray(arr)) arr.forEach(function(r){ r.id = String(r.id); });
3067
+ normResp[key] = arr;
3068
+ });
3069
+ responses = normResp;
3070
+
3071
+ function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
3072
+
3073
+ // Copy via event delegation
3074
+ document.addEventListener('click', function(e) {
3075
+ var btn = e.target.closest('[data-copy]');
3076
+ if (!btn) return;
3077
+ var target = btn.getAttribute('data-copy');
3078
+ var text = '';
3079
+ if (target === 'prev-pre') {
3080
+ var pre = btn.closest('div').querySelector('pre');
3081
+ if (pre) text = pre.textContent;
3082
+ } else if (target === 'url') {
3083
+ var code = btn.closest('.endpoint-url');
3084
+ if (code) text = code.querySelector('code').textContent;
3085
+ }
3086
+ if (text) {
3087
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3088
+ navigator.clipboard.writeText(text);
3089
+ } else {
3090
+ var ta = document.createElement('textarea');
3091
+ ta.value = text; ta.style.cssText = 'position:fixed;opacity:0';
3092
+ document.body.appendChild(ta); ta.select();
3093
+ document.execCommand('copy');
3094
+ document.body.removeChild(ta);
3095
+ }
3096
+ var orig = btn.textContent;
3097
+ btn.textContent = 'Copied!';
3098
+ btn.style.color = '#34d399';
3099
+ setTimeout(function(){ btn.textContent = orig; btn.style.color = ''; }, 1500);
3100
+ }
3101
+ });
3102
+
3103
+ // Generate code snippets
3104
+ var _isForm = function(t) { return (t.bodyType === 'form-data' || t.bodyType === 'x-www-form-urlencoded') && t.formFields && t.formFields.length > 0; };
3105
+ var _hasBody = function(t) { return ['POST','PUT','PATCH'].indexOf(t.method) !== -1; };
3106
+
3107
+ function genCurl(t) {
3108
+ var nl = String.fromCharCode(10);
3109
+ var bs = String.fromCharCode(92);
3110
+ var c = 'curl -X ' + t.method + " '" + t.url + "'";
3111
+ if (_isForm(t)) {
3112
+ t.formFields.forEach(function(f) {
3113
+ if (!f.key) return;
3114
+ if (f.type === 'file') {
3115
+ c += ' ' + bs + nl + " -F '" + f.key + "=@/path/to/" + (f.fileName || 'file') + "'";
3116
+ } else {
3117
+ c += ' ' + bs + nl + " -F '" + f.key + '=' + (f.value || '') + "'";
3118
+ }
3119
+ });
3120
+ } else {
3121
+ if (t.headers) t.headers.forEach(function(h) { if (h.key) c += ' ' + bs + nl + " -H '" + h.key + ': ' + (h.value||'') + "'"; });
3122
+ if (t.body && _hasBody(t)) {
3123
+ c += ' ' + bs + nl + " -d '" + t.body.replace(/'/g, "'") + "'";
3124
+ }
3125
+ }
3126
+ return c;
3127
+ }
3128
+ function genFetch(t) {
3129
+ var nl = String.fromCharCode(10);
3130
+ var lines = [];
3131
+ if (_isForm(t)) {
3132
+ lines.push('const formData = new FormData();');
3133
+ t.formFields.forEach(function(f) {
3134
+ if (!f.key) return;
3135
+ if (f.type === 'file') {
3136
+ lines.push("formData.append('" + f.key + "', fileInput.files[0]); // " + (f.fileName || 'file'));
3137
+ } else {
3138
+ lines.push("formData.append('" + f.key + "', '" + (f.value || '') + "');");
3139
+ }
3140
+ });
3141
+ lines.push('');
3142
+ lines.push("fetch('" + t.url + "', {");
3143
+ lines.push(" method: '" + t.method + "',");
3144
+ lines.push(' body: formData,');
3145
+ lines.push('})');
3146
+ } else {
3147
+ lines.push("fetch('" + t.url + "', {");
3148
+ lines.push(" method: '" + t.method + "',");
3149
+ var hdrs = {};
3150
+ if (t.headers) t.headers.forEach(function(h) { if (h.key) hdrs[h.key] = h.value || ''; });
3151
+ if (Object.keys(hdrs).length > 0) lines.push(' headers: ' + JSON.stringify(hdrs, null, 4) + ',');
3152
+ if (t.body && _hasBody(t)) {
3153
+ var formatted = t.body;
3154
+ try { formatted = JSON.stringify(JSON.parse(t.body)); } catch(e) {}
3155
+ lines.push(' body: JSON.stringify(' + formatted + '),');
3156
+ }
3157
+ lines.push('})');
3158
+ }
3159
+ lines.push('.then(res => res.json())');
3160
+ lines.push('.then(data => console.log(data))');
3161
+ lines.push('.catch(err => console.error(err));');
3162
+ return lines.join(nl);
3163
+ }
3164
+ function genAxios(t) {
3165
+ var nl = String.fromCharCode(10);
3166
+ var m = t.method.toLowerCase();
3167
+ var hdrs = {};
3168
+ if (t.headers) t.headers.forEach(function(h) { if (h.key) hdrs[h.key] = h.value || ''; });
3169
+ var lines = [];
3170
+ if (_isForm(t)) {
3171
+ lines.push('const formData = new FormData();');
3172
+ t.formFields.forEach(function(f) {
3173
+ if (!f.key) return;
3174
+ if (f.type === 'file') {
3175
+ lines.push("formData.append('" + f.key + "', fileInput.files[0]); // " + (f.fileName || 'file'));
3176
+ } else {
3177
+ lines.push("formData.append('" + f.key + "', '" + (f.value || '') + "');");
3178
+ }
3179
+ });
3180
+ lines.push('');
3181
+ lines.push("const { data } = await axios." + m + "('" + t.url + "', formData, {");
3182
+ lines.push(" headers: { 'Content-Type': 'multipart/form-data' },");
3183
+ lines.push('});');
3184
+ } else if (_hasBody(t) && t.body) {
3185
+ var d = t.body;
3186
+ try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
3187
+ lines.push('const { data } = await axios.' + m + '(');
3188
+ lines.push(" '" + t.url + "',");
3189
+ lines.push(' ' + d.split(nl).join(nl + ' '));
3190
+ if (Object.keys(hdrs).length > 0) lines.push(' , { headers: ' + JSON.stringify(hdrs) + ' }');
3191
+ lines.push(');');
3192
+ } else {
3193
+ lines.push("const { data } = await axios." + m + "('" + t.url + "'");
3194
+ if (Object.keys(hdrs).length > 0) lines.push(' , { headers: ' + JSON.stringify(hdrs) + ' }');
3195
+ lines.push(');');
3196
+ }
3197
+ lines.push('console.log(data);');
3198
+ return lines.join(nl);
3199
+ }
3200
+
3201
+ function filteredTests() {
3202
+ if (!searchQuery) return tests;
3203
+ var q = searchQuery.toLowerCase();
3204
+ return tests.filter(function(t) {
3205
+ return (t.name||'').toLowerCase().includes(q) || (t.url||'').toLowerCase().includes(q) || (t.method||'').toLowerCase().includes(q);
3206
+ });
3207
+ }
3208
+
3209
+ function renderSidebar() {
3210
+ var sb = document.getElementById('sidebar-list');
3211
+ var h = '';
3212
+ var ft = filteredTests();
3213
+ ft.forEach(function(t) {
3214
+ var resps = responses[t.id] || [];
3215
+ var isActive = activeTestId === t.id && !activeRespId;
3216
+ var isExp = expanded[t.id];
3217
+ var tt = t.type || 'rest';
3218
+ h += '<div class="sidebar-item' + (isActive ? ' active' : '') + '" data-test="' + t.id + '">';
3219
+ if (resps.length > 0) h += '<span class="item-arrow">' + (isExp ? '&#9660;' : '&#9654;') + '</span>';
3220
+ else h += '<span class="item-arrow"></span>';
3221
+ if (tt === 'socketio') h += '<span class="mb type-sio">SIO</span>';
3222
+ else if (tt === 'websocket') h += '<span class="mb type-ws">WS</span>';
3223
+ else h += '<span class="mb mb-' + (t.method||'GET').toLowerCase() + '">' + (t.method||'GET') + '</span>';
3224
+ h += '<span class="item-name">' + esc(t.name) + '</span>';
3225
+ if (resps.length > 0) h += '<span class="item-badge">' + resps.length + '</span>';
3226
+ h += '</div>';
3227
+ if (isExp && resps.length > 0) {
3228
+ resps.forEach(function(r) {
3229
+ var isRActive = activeRespId === r.id && activeTestId === t.id;
3230
+ var isSocket = r.dir;
3231
+ h += '<div class="sidebar-child' + (isRActive ? ' active' : '') + '" data-test="' + t.id + '" data-resp="' + r.id + '">';
3232
+ if (isSocket) {
3233
+ var dirCls = r.dir === 'received' ? 'dir-recv' : r.dir === 'sent' ? 'dir-sent' : 'dir-sys';
3234
+ h += '<span class="sb ' + dirCls + '">' + (r.dir === 'received' ? '&#8595;' : r.dir === 'sent' ? '&#8593;' : '&#9679;') + '</span>';
3235
+ } else {
3236
+ h += '<span class="sb s' + Math.floor(r.status/100) + '">' + r.status + '</span>';
3237
+ }
3238
+ h += '<span class="child-label">' + esc(r.label) + '</span>';
3239
+ if (!isSocket && r.time) h += '<span class="child-time">' + r.time + 'ms</span>';
3240
+ h += '</div>';
3241
+ });
3242
+ }
3243
+ });
3244
+ if (ft.length === 0) h = '<div style="padding:20px;text-align:center;color:#475569;font-size:12px">No endpoints found</div>';
3245
+ sb.innerHTML = h;
3246
+ }
3247
+
3248
+ // Search
3249
+ document.getElementById('search-inp').addEventListener('input', function(e) {
3250
+ searchQuery = e.target.value;
3251
+ renderSidebar();
3252
+ });
3253
+
3254
+ document.getElementById('sidebar-list').addEventListener('click', function(e) {
3255
+ var el = e.target.closest('.sidebar-item, .sidebar-child');
3256
+ if (!el) return;
3257
+ var testId = el.getAttribute('data-test');
3258
+ var respId = el.getAttribute('data-resp');
3259
+ if (respId) {
3260
+ activeTestId = testId; activeRespId = respId;
3261
+ renderSidebar(); renderRespView(testId, respId);
3262
+ } else {
3263
+ var wasActive = activeTestId === testId && !activeRespId;
3264
+ activeTestId = testId; activeRespId = null;
3265
+ var resps = responses[testId] || [];
3266
+ if (resps.length > 0) { if (wasActive) expanded[testId] = !expanded[testId]; else expanded[testId] = true; }
3267
+ renderSidebar(); renderTestView(testId);
3268
+ }
3269
+ });
3270
+
3271
+ function renderTestView(id) {
3272
+ var t = tests.find(function(x){ return x.id === id; });
3273
+ if (!t) return;
3274
+ var tt = t.type || 'rest';
3275
+ var main = document.getElementById('main-content');
3276
+
3277
+ if (tt === 'socketio' || tt === 'websocket') {
3278
+ var nl = String.fromCharCode(10);
3279
+ var resps = responses[id] || [];
3280
+ var sentLogs = resps.filter(function(r) { return r.dir === 'sent'; });
3281
+ var recvLogs = resps.filter(function(r) { return r.dir === 'received'; });
3282
+ var sysLogs = resps.filter(function(r) { return r.dir === 'system'; });
3283
+
3284
+ var h = '<div class="card"><div class="card-header">';
3285
+ if (tt === 'socketio') h += '<span class="mb type-sio" style="font-size:11px;padding:4px 10px">Socket.IO</span>';
3286
+ else h += '<span class="mb type-ws" style="font-size:11px;padding:4px 10px">WebSocket</span>';
3287
+ h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span></div>';
3288
+
3289
+ h += '<div class="stitle">Connection URL</div>';
3290
+ h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy</button></div>';
3291
+ h += '</div>';
3292
+
3293
+ // ── Emit Events Section ──
3294
+ h += '<div class="card">';
3295
+ h += '<div class="card-header"><span style="font-size:14px;font-weight:600;color:#f59e0b">&#8593; Emit Events</span>';
3296
+ h += '<span style="font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(245,158,11,0.12);color:#f59e0b;font-weight:600;margin-left:auto">Client &#8594; Server</span></div>';
3297
+
3298
+ if (tt === 'socketio' && t.event) {
3299
+ h += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">';
3300
+ h += '<span class="stitle" style="margin:0;min-width:70px">Event</span>';
3301
+ h += '<span style="font-size:13px;padding:6px 14px;border-radius:6px;background:rgba(245,158,11,0.1);color:#f59e0b;font-family:SF Mono,monospace;font-weight:600;border:1px solid rgba(245,158,11,0.2)">' + esc(t.event) + '</span>';
3302
+ h += '</div>';
3303
+ }
3304
+ if (tt === 'websocket' && t.message) {
3305
+ h += '<div class="stitle">Message</div>';
3306
+ }
3307
+
3308
+ if (t.data || t.message) {
3309
+ h += '<div class="stitle" style="margin-bottom:6px">Payload</div>';
3310
+ var d = t.data || t.message || ''; try { d = JSON.stringify(JSON.parse(d), null, 2); } catch(e) {}
3311
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(d) + '</pre>';
3312
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3313
+ }
3314
+
3315
+ // Emit code example
3316
+ if (tt === 'socketio') {
3317
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3318
+ var emitCode = "// Client-side emit" + nl;
3319
+ emitCode += "import { io } from 'socket.io-client';" + nl;
3320
+ emitCode += "const socket = io('" + t.url + "');" + nl + nl;
3321
+ if (t.event) {
3322
+ var ed = t.data || '{}'; try { ed = JSON.stringify(JSON.parse(ed), null, 2); } catch(e) {}
3323
+ emitCode += "socket.emit('" + t.event + "', " + ed + ");" + nl + nl;
3324
+ }
3325
+ emitCode += "// Server-side handler" + nl;
3326
+ emitCode += "io.on('connection', (socket) => {" + nl;
3327
+ if (t.event) {
3328
+ emitCode += " socket.on('" + t.event + "', (data) => {" + nl;
3329
+ emitCode += " console.log('" + t.event + ":', data);" + nl;
3330
+ emitCode += " });" + nl;
3331
+ }
3332
+ emitCode += "});" + nl + nl;
3333
+ emitCode += "// Emit from Express route" + nl;
3334
+ if (t.event) {
3335
+ var ed2 = t.data || '{}'; try { ed2 = JSON.stringify(JSON.parse(ed2), null, 2); } catch(e) {}
3336
+ emitCode += "req.io.emit('" + t.event + "', " + ed2 + ");";
3337
+ } else {
3338
+ emitCode += "req.io.emit('event_name', { key: 'value' });";
3339
+ }
3340
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(emitCode) + '</pre>';
3341
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3342
+ } else {
3343
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3344
+ var wsSendCode = "const ws = new WebSocket('" + t.url + "');" + nl + nl;
3345
+ wsSendCode += "ws.onopen = () => {" + nl;
3346
+ if (t.message) {
3347
+ wsSendCode += " ws.send(JSON.stringify(" + (t.message || "{}") + "));" + nl;
3348
+ } else {
3349
+ wsSendCode += " ws.send(JSON.stringify({ key: 'value' }));" + nl;
3350
+ }
3351
+ wsSendCode += "};";
3352
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(wsSendCode) + '</pre>';
3353
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3354
+ }
3355
+
3356
+ // Saved emitted logs
3357
+ if (sentLogs.length > 0) {
3358
+ h += '<div class="stitle" style="margin-bottom:8px">Saved Emits (' + sentLogs.length + ')</div>';
3359
+ sentLogs.forEach(function(r) {
3360
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
3361
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:10px 14px;margin-bottom:6px">';
3362
+ h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:' + (body ? '8px' : '0') + '">';
3363
+ h += '<span class="sb dir-sent" style="font-size:9px;padding:2px 6px">&#8593; EMIT</span>';
3364
+ h += '<span style="font-size:12px;font-weight:500">' + esc(r.label) + '</span>';
3365
+ h += '</div>';
3366
+ if (body) {
3367
+ h += '<div style="position:relative"><pre style="margin:0;font-size:11px">' + esc(body) + '</pre>';
3368
+ h += '<button class="copy-btn" style="position:absolute;top:4px;right:4px;font-size:10px;padding:2px 8px" data-copy="prev-pre">Copy</button></div>';
3369
+ }
3370
+ h += '</div>';
3371
+ });
3372
+ }
3373
+ h += '</div>';
3374
+
3375
+ // ── Event Listeners Section ──
3376
+ h += '<div class="card">';
3377
+ h += '<div class="card-header"><span style="font-size:14px;font-weight:600;color:#34d399">&#8595; Event Listeners</span>';
3378
+ h += '<span style="font-size:10px;padding:3px 10px;border-radius:10px;background:rgba(52,211,153,0.12);color:#34d399;font-weight:600;margin-left:auto">Server &#8594; Client</span></div>';
3379
+
3380
+ if (tt === 'socketio' && t.listeners && t.listeners.length > 0) {
3381
+ h += '<div class="stitle" style="margin-bottom:8px">Listening On</div>';
3382
+ h += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">';
3383
+ t.listeners.forEach(function(l) {
3384
+ h += '<span style="font-size:12px;padding:6px 14px;border-radius:6px;background:rgba(52,211,153,0.1);color:#34d399;font-family:SF Mono,monospace;font-weight:600;border:1px solid rgba(52,211,153,0.2)">' + esc(l) + '</span>';
3385
+ });
3386
+ h += '</div>';
3387
+
3388
+ // Listener code example
3389
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3390
+ var listenCode = "// Client-side listeners" + nl;
3391
+ listenCode += "import { io } from 'socket.io-client';" + nl;
3392
+ listenCode += "const socket = io('" + t.url + "');" + nl;
3393
+ t.listeners.forEach(function(l) {
3394
+ listenCode += nl + "socket.on('" + l + "', (data) => {" + nl;
3395
+ listenCode += " console.log('" + l + ":', data);" + nl;
3396
+ listenCode += "});" + nl;
3397
+ });
3398
+ listenCode += nl + "// Server-side broadcast" + nl;
3399
+ t.listeners.forEach(function(l) {
3400
+ listenCode += "io.emit('" + l + "', data);" + nl;
3401
+ });
3402
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(listenCode) + '</pre>';
3403
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3404
+ } else if (tt === 'websocket') {
3405
+ h += '<div class="stitle" style="margin-bottom:6px">Code</div>';
3406
+ var wsRecvCode = "const ws = new WebSocket('" + t.url + "');" + nl + nl;
3407
+ wsRecvCode += "ws.onmessage = (event) => {" + nl;
3408
+ wsRecvCode += " const data = JSON.parse(event.data);" + nl;
3409
+ wsRecvCode += " console.log('Received:', data);" + nl;
3410
+ wsRecvCode += "};" + nl + nl;
3411
+ wsRecvCode += "ws.onclose = () => console.log('Disconnected');";
3412
+ h += '<div style="position:relative"><pre style="margin-bottom:14px">' + esc(wsRecvCode) + '</pre>';
3413
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button></div>';
3414
+ } else {
3415
+ h += '<div style="font-size:12px;color:#475569;padding:8px 0">No listeners configured</div>';
3416
+ }
3417
+
3418
+ // Saved received logs
3419
+ if (recvLogs.length > 0) {
3420
+ h += '<div class="stitle" style="margin-bottom:8px">Received Events (' + recvLogs.length + ')</div>';
3421
+ recvLogs.forEach(function(r) {
3422
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
3423
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:10px 14px;margin-bottom:6px">';
3424
+ h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:' + (body ? '8px' : '0') + '">';
3425
+ h += '<span class="sb dir-recv" style="font-size:9px;padding:2px 6px">&#8595; RECV</span>';
3426
+ h += '<span style="font-size:12px;font-weight:500">' + esc(r.label) + '</span>';
3427
+ h += '</div>';
3428
+ if (body) {
3429
+ h += '<div style="position:relative"><pre style="margin:0;font-size:11px">' + esc(body) + '</pre>';
3430
+ h += '<button class="copy-btn" style="position:absolute;top:4px;right:4px;font-size:10px;padding:2px 8px" data-copy="prev-pre">Copy</button></div>';
3431
+ }
3432
+ h += '</div>';
3433
+ });
3434
+ }
3435
+
3436
+ // System logs
3437
+ if (sysLogs.length > 0) {
3438
+ h += '<div class="stitle" style="margin-top:12px;margin-bottom:8px">System Events (' + sysLogs.length + ')</div>';
3439
+ sysLogs.forEach(function(r) {
3440
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;padding:8px 14px;margin-bottom:4px;display:flex;align-items:center;gap:8px">';
3441
+ h += '<span class="sb dir-sys" style="font-size:9px;padding:2px 6px">&#9679; SYS</span>';
3442
+ h += '<span style="font-size:12px;color:#94a3b8">' + esc(r.label) + '</span>';
3443
+ h += '</div>';
3444
+ });
3445
+ }
3446
+
3447
+ h += '</div>';
3448
+ main.innerHTML = h; return;
3449
+ }
3450
+
3451
+ var mc = { GET:'#34d399', POST:'#f59e0b', PUT:'#3b82f6', PATCH:'#8b5cf6', DELETE:'#f87171' };
3452
+ var formatted = t.body || '';
3453
+ try { formatted = JSON.stringify(JSON.parse(formatted), null, 2); } catch(e) {}
3454
+ var hasBody = ['POST','PUT','PATCH'].indexOf(t.method) !== -1;
3455
+ var isFormData = t.bodyType === 'form-data' || t.bodyType === 'x-www-form-urlencoded';
3456
+ var hasFormFields = t.formFields && t.formFields.length > 0;
3457
+ var hasJsonBody = hasBody && t.body;
3458
+ var bodyFields = [];
3459
+ if (hasJsonBody && !isFormData) { try { var bo = JSON.parse(t.body); bodyFields = Object.keys(bo); } catch(e) {} }
3460
+
3461
+ var h = '';
3462
+ // Header
3463
+ h += '<div class="card"><div class="card-header">';
3464
+ h += '<span class="mb mb-' + t.method.toLowerCase() + '" style="font-size:12px;padding:4px 10px">' + t.method + '</span>';
3465
+ h += '<span style="font-size:16px;font-weight:600">' + esc(t.name) + '</span>';
3466
+ 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>';
3467
+ h += '</div>';
3468
+
3469
+ // URL
3470
+ h += '<div class="endpoint-url"><code>' + esc(t.url) + '</code><button class="copy-btn" data-copy="url">Copy URL</button></div>';
3471
+
3472
+ // Headers
3473
+ if (t.headers && t.headers.length > 0) {
3474
+ h += '<div class="stitle">Headers</div>';
3475
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;margin-bottom:16px;overflow:hidden">';
3476
+ t.headers.forEach(function(hdr) {
3477
+ if (!hdr.key) return;
3478
+ h += '<div class="header-row"><span class="header-key">' + esc(hdr.key) + '</span><span class="header-val">' + esc(hdr.value||'') + '</span></div>';
3479
+ });
3480
+ h += '</div>';
3481
+ }
3482
+
3483
+ // Form Data Fields
3484
+ if (hasFormFields) {
3485
+ 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>';
3486
+ t.formFields.forEach(function(f) {
3487
+ if (!f.key) return;
3488
+ var isFile = f.type === 'file';
3489
+ h += '<div class="field-row">';
3490
+ h += '<span class="field-name">' + esc(f.key) + '</span>';
3491
+ if (isFile) {
3492
+ h += '<span class="field-type" style="background:rgba(245,158,11,0.12);color:#f59e0b">File</span>';
3493
+ } else {
3494
+ h += '<span class="field-type">Text</span>';
3495
+ }
3496
+ if (f.value && !isFile) h += '<span style="font-size:11px;color:#64748b;font-family:monospace;margin-left:auto">' + esc(f.value) + '</span>';
3497
+ h += '</div>';
3498
+ });
3499
+ h += '<div style="margin-top:12px"></div>';
3500
+ }
3501
+
3502
+ // JSON Body Fields
3503
+ if (!isFormData && hasJsonBody && bodyFields.length > 0) {
3504
+ h += '<div class="stitle">Body Fields</div>';
3505
+ var bodyObj = JSON.parse(t.body);
3506
+ bodyFields.forEach(function(f) {
3507
+ var val = bodyObj[f];
3508
+ var type = typeof val === 'number' ? 'Number' : typeof val === 'boolean' ? 'Boolean' : 'String';
3509
+ h += '<div class="field-row"><span class="field-name">' + esc(f) + '</span><span class="field-type">' + type + '</span></div>';
3510
+ });
3511
+ h += '<div style="margin-top:12px"></div>';
3512
+ }
3513
+
3514
+ // Request Body Example (JSON only)
3515
+ if (!isFormData && hasJsonBody) {
3516
+ h += '<div class="stitle">Request Body Example</div>';
3517
+ h += '<pre style="margin-bottom:16px">' + esc(formatted) + '</pre>';
3518
+ }
3519
+
3520
+ // Code Snippets
3521
+ var codeTab = activeCodeTab[id] || 'curl';
3522
+ h += '<div class="stitle">Code Examples</div>';
3523
+ h += '<div style="background:#0f1117;border:1px solid #1e2433;border-radius:8px;overflow:hidden;margin-bottom:16px">';
3524
+ h += '<div class="code-tabs">';
3525
+ ['curl','fetch','axios'].forEach(function(tab) {
3526
+ 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>';
3527
+ });
3528
+ h += '</div>';
3529
+ var code = codeTab === 'curl' ? genCurl(t) : codeTab === 'fetch' ? genFetch(t) : genAxios(t);
3530
+ h += '<div style="position:relative"><pre style="border:none;border-radius:0;margin:0">' + esc(code) + '</pre>';
3531
+ h += '<button class="copy-btn" style="position:absolute;top:8px;right:8px" data-copy="prev-pre">Copy</button>';
3532
+ h += '</div></div>';
3533
+
3534
+ h += '</div>';
3535
+
3536
+ // Saved Responses
3537
+ var resps = responses[id] || [];
3538
+ if (resps.length > 0) {
3539
+ h += '<div class="stitle" style="margin-top:8px">Saved Responses (' + resps.length + ')</div>';
3540
+ resps.forEach(function(r) {
3541
+ var sc = Math.floor(r.status/100);
3542
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
3543
+ h += '<div class="card" style="padding:0;overflow:hidden">';
3544
+ h += '<div style="padding:10px 16px;border-bottom:1px solid #1e2433;display:flex;align-items:center;gap:10px">';
3545
+ h += '<span class="sb s' + sc + '" style="font-size:11px;padding:3px 8px">' + r.status + ' ' + esc(r.statusText||'') + '</span>';
3546
+ h += '<span style="font-size:13px;font-weight:500">' + esc(r.label) + '</span>';
3547
+ if (r.time) h += '<span style="font-size:11px;color:#475569;margin-left:auto">' + r.time + 'ms</span>';
3548
+ h += '</div>';
3549
+ if (r.requestBody && r.requestBody.trim()) {
3550
+ var rb = r.requestBody; try { rb = JSON.stringify(JSON.parse(rb), null, 2); } catch(e) {}
3551
+ 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>';
3552
+ }
3553
+ h += '<div style="padding:12px 16px"><div class="stitle" style="margin-bottom:6px">Response Body</div><pre style="margin:0">' + esc(body) + '</pre></div>';
3554
+ h += '</div>';
3555
+ });
3556
+ }
3557
+
3558
+ main.innerHTML = h;
3559
+ }
3560
+
3561
+ function renderRespView(testId, respId) {
3562
+ var resps = responses[testId] || [];
3563
+ var r = resps.find(function(x){ return x.id === respId; });
3564
+ if (!r) return;
3565
+ var main = document.getElementById('main-content');
3566
+ var t = tests.find(function(x){ return x.id === testId; });
3567
+ var isSocket = r.dir;
3568
+
3569
+ var h = '<div class="card"><div class="card-header">';
3570
+ if (isSocket) {
3571
+ var dirCls = r.dir === 'received' ? 'dir-recv' : r.dir === 'sent' ? 'dir-sent' : 'dir-sys';
3572
+ h += '<span class="sb ' + dirCls + '" style="font-size:12px;padding:4px 10px">' + (r.dir === 'received' ? 'Received' : r.dir === 'sent' ? 'Sent' : 'System') + '</span>';
3573
+ } else {
3574
+ h += '<span class="sb s' + Math.floor(r.status/100) + '" style="font-size:12px;padding:4px 10px">' + r.status + ' ' + esc(r.statusText||'') + '</span>';
3575
+ }
3576
+ h += '<span style="font-size:16px;font-weight:600">' + esc(r.label) + '</span>';
3577
+ if (!isSocket && r.time) h += '<span style="font-size:11px;color:#475569;margin-left:auto">' + r.time + 'ms</span>';
3578
+ h += '</div>';
3579
+
3580
+ if (t) { h += '<div class="endpoint-url" style="margin-bottom:16px"><code>' + esc(t.url) + '</code></div>'; }
3581
+
3582
+ if (!isSocket && r.requestBody && r.requestBody.trim()) {
3583
+ var rb = r.requestBody; try { rb = JSON.stringify(JSON.parse(rb), null, 2); } catch(e) {}
3584
+ h += '<div class="stitle">Request Body</div><pre style="margin-bottom:16px">' + esc(rb) + '</pre>';
3585
+ }
3586
+ var body = r.body || ''; try { body = JSON.stringify(JSON.parse(body), null, 2); } catch(e) {}
3587
+ h += '<div class="stitle">' + (isSocket ? 'Data' : 'Response Body') + '</div><pre>' + esc(body) + '</pre>';
3588
+ h += '</div>';
3589
+
3590
+ main.innerHTML = h;
3591
+ }
3592
+
3593
+ // Code tab switching
3594
+ document.getElementById('main-content').addEventListener('click', function(e) {
3595
+ var tab = e.target.closest('[data-codetab]');
3596
+ if (tab) {
3597
+ var testId = tab.getAttribute('data-testid');
3598
+ activeCodeTab[testId] = tab.getAttribute('data-codetab');
3599
+ renderTestView(testId);
3600
+ return;
3601
+ }
3602
+ });
3603
+
3604
+ try { renderSidebar(); } catch(e) { console.error('renderSidebar error:', e); }
3605
+ if (tests.length > 0) {
3606
+ activeTestId = tests[0].id;
3607
+ var firstResps = responses[tests[0].id] || [];
3608
+ if (firstResps.length > 0) expanded[tests[0].id] = true;
3609
+ try { renderSidebar(); } catch(e) { console.error('renderSidebar error:', e); }
3610
+ try { renderTestView(tests[0].id); } catch(e) { console.error('renderTestView error:', e); }
3611
+ }
3612
+ })();
3613
+ </script></body></html>`;
3614
+
3615
+ res.type('html').send(html);
3616
+ } catch (err) {
3617
+ res.status(500).send('Error loading collection');
3618
+ }
3619
+ });
3620
+
3621
+ export default router;