4bnode 4.1.1 → 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.
- package/add-security.js +0 -18
- package/app-1/.4bnode/.passkey +1 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +139 -0
- package/app-1/.4bnode/assets/js/Root.js +40 -0
- package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
- package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/app-1/.4bnode/assets/js/context.js +7 -0
- package/app-1/.4bnode/assets/js/lib.js +113 -0
- package/app-1/.4bnode/assets/js/main.js +5 -0
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/app-1/.4bnode/codegen.js +1249 -0
- package/app-1/.4bnode/dev-api.js +3621 -0
- package/app-1/.4bnode/ui.html +1578 -0
- package/app-1/README.md +47 -0
- package/app-1/ecosystem.config.cjs +25 -0
- package/app-1/index.js +145 -0
- package/app-1/package.json +35 -0
- package/app-1/public/index.html +292 -0
- package/app-1/src/db.js +13 -0
- package/app-1/src/docs.js +84 -0
- package/app-1/src/middleware/errorHandler.js +15 -0
- package/app-1/src/middleware/roles.js +14 -0
- package/app-1/src/middleware/validate.js +20 -0
- package/app-1/src/models/login.js +9 -0
- package/app-1/src/openapi.json +62 -0
- package/app-1/src/routes/products.js +26 -0
- package/app-1/src/validators/products.js +7 -0
- package/app-1/src/validators/user.js +7 -0
- package/index.js +2 -17
- package/lib/codegen.js +69 -43
- package/lib/codegen.test.js +21 -2
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +0 -2
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
- package/skeleton/.4bnode/assets/js/lib.js +0 -3
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
- package/skeleton/.4bnode/codegen.js +69 -43
- package/skeleton/.4bnode/dev-api.js +173 -87
- package/skeleton/_gitignore +0 -1
- package/skeleton/ecosystem.config.cjs +25 -0
- package/skeleton/index.js +4 -8
- 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
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 ? '▼' : '▶') + '</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' ? '↓' : r.dir === 'sent' ? '↑' : '●') + '</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">↑ 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 → 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">↑ 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">↓ 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 → 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">↓ 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">● 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;
|