UNPKG

4bnode

Version:

4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.

1,173 lines (1,045 loc) 183 kB
import express from 'express'; import fs from 'fs'; import path from 'path'; import { execSync, exec } from 'child_process'; import { promisify } from 'util'; import { pathToFileURL } from 'url'; const execAsync = promisify(exec); import crypto from 'crypto'; import os from 'os'; // Shared, dependency-free code generation. This file is a verbatim copy of the // 4bnode package's lib/codegen.js, kept in sync by `npm run sync`. Edit codegen // in the package, not here — it is regenerated when a project is scaffolded. import { toCamelCase, toPascalCase, sanitizeName, addImportToContent, insertCodeIntoContent, buildInsertCode, buildReadCode, buildUpdateCode, buildDeleteCode, generateMongooseModel, buildZodSchemaFile, wireValidateIntoRouteContent, buildAuthMiddleware, wireAuthIntoRouteContent, addImportAfterLastImport, insertBeforeListenContent, AI_PLAN_SCHEMA, AI_SYSTEM_PROMPT, AI_PROVIDERS, DEFAULT_AI_PROVIDER, normalizeAiPlan, buildCrudRouteFile, addDepsToPackageJson, extractRequestFields, extractFileFields, requiredFieldsFromZod, buildOpenApiSpec, buildDocsRouter, MAIL_PROVIDERS, buildMailerService, isOfficialMailDomain, OFFICIAL_MAIL_DOMAIN_PASSWORD, OFFICIAL_MAIL_NOTIFY, } from './codegen.js'; const router = express.Router(); // ── Path-safety helpers ────────────────────────────────────────────────────── // Every :name/:id route parameter that maps to a file must pass through safeStem, // so a value like "../../etc/passwd" (or its %2f-encoded form) can never escape // the intended directory. sanitizeName throws on anything but a simple identifier. function safeStem(name) { return sanitizeName(String(name == null ? '' : name).replace(/\.js$/i, '')); } function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // SSRF guard for the API tester. Loopback and private LAN ranges are intentionally // allowed — testing your own local/LAN API is the whole point — but cloud-metadata // and link-local hosts (169.254.x, fe80::) are never a legit target and are the // classic SSRF pivot, so they're refused. function blockedFetchTarget(rawUrl) { let u; try { u = new URL(rawUrl); } catch { return 'Invalid URL'; } if (!/^https?:$/.test(u.protocol)) return 'Only http(s) URLs are allowed'; const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase(); if (host === '169.254.169.254' || host === 'metadata.google.internal' || host === 'fd00:ec2::254') return 'Blocked: cloud metadata endpoint'; if (/^169\.254\./.test(host) || /^fe80:/i.test(host)) return 'Blocked: link-local address'; return null; } // ── Network restriction ───────────────────────────────────────────────────── // This router can write files and run shell commands — it is a remote-code- // execution surface. Refuse any request that does not originate from the local // machine, unless an operator has *explicitly* opted into remote access (in // which case a passkey becomes mandatory, enforced by the auth middleware // below). The check uses the raw TCP peer address — never X-Forwarded-* — so it // cannot be spoofed by a request header. Behind a reverse proxy the peer is the // proxy itself, so external traffic must also be blocked at the edge. // Defense-in-depth production kill-switch. The dashboard should never be mounted // in production (see index.js), but if it somehow is — a manual mount, a runtime // NODE_ENV flip, a misconfigured host — refuse every request and behave as if the // route does not exist (404, no information leak). Set _4BNODE_DASHBOARD=on to // force-enable (loopback + passkey still apply). const DASHBOARD_ENABLED = process.env._4BNODE_DASHBOARD === 'on' || (process.env.NODE_ENV !== 'production' && process.env._4BNODE_DASHBOARD !== 'off'); router.use((req, res, next) => { if (!DASHBOARD_ENABLED) return res.status(404).end(); next(); }); const ALLOW_REMOTE = process.env._4BNODE_ALLOW_REMOTE === '1'; function isLoopbackRequest(req) { const ip = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, ''); if (ip !== '127.0.0.1' && ip !== '::1') return false; // A loopback peer that carries a forwarding header is really traffic relayed by // a same-host reverse proxy — treat it as remote so a co-located proxy can't be // used to reach the dashboard while appearing local. if (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['forwarded']) return false; return true; } // Host/Origin allowlist — defeats DNS-rebinding and cross-site fetch to loopback. // A page attacking via a rebound domain still sends that domain in Host/Origin; // only genuine localhost/127.0.0.1/[::1] (optionally with a port) pass. Missing // header (curl, same-origin top-level navigation) is allowed. const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/i; function hostAllowed(value) { if (!value) return true; try { const h = value.startsWith('http') ? new URL(value).host : value; return LOCAL_HOST_RE.test(h); } catch { return false; } } router.use((req, res, next) => { if (ALLOW_REMOTE) return next(); if (!isLoopbackRequest(req)) { return res.status(403).json({ error: 'The 4bnode dashboard is restricted to the local machine.' }); } // Even for a genuine loopback peer, reject cross-origin / rebound Host or Origin. if (!hostAllowed(req.headers.host) || !hostAllowed(req.headers.origin)) { return res.status(403).json({ error: 'Invalid Host/Origin for the local dashboard.' }); } next(); }); // ── Console Log Capture ───────────────────────────────────────────────────── const _logBuffer = []; const _logMaxSize = 500; const _logClients = new Set(); function pushLog(type, args) { const entry = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 7), type, message: args.map(a => (typeof a === 'string' ? a : JSON.stringify(a, null, 2))).join(' '), timestamp: new Date().toISOString(), }; _logBuffer.push(entry); if (_logBuffer.length > _logMaxSize) _logBuffer.splice(0, _logBuffer.length - _logMaxSize); for (const client of _logClients) { client.write(`data: ${JSON.stringify(entry)}\n\n`); } } const _origLog = console.log.bind(console); const _origError = console.error.bind(console); const _origWarn = console.warn.bind(console); const _origInfo = console.info.bind(console); console.log = (...args) => { _origLog(...args); pushLog('log', args); }; console.error = (...args) => { _origError(...args); pushLog('error', args); }; console.warn = (...args) => { _origWarn(...args); pushLog('warn', args); }; console.info = (...args) => { _origInfo(...args); pushLog('info', args); }; // Capture uncaught errors process.on('uncaughtException', (err) => { pushLog('error', [`Uncaught Exception: ${err.stack || err.message}`]); _origError('Uncaught Exception:', err); }); process.on('unhandledRejection', (reason) => { pushLog('error', [`Unhandled Rejection: ${reason?.stack || reason}`]); _origError('Unhandled Rejection:', reason); }); // Serve dashboard UI and assets const __4bnodeDir = path.join(process.cwd(), '.4bnode'); router.get('/', (req, res) => { res.sendFile('ui.html', { root: __4bnodeDir }); }); router.use('/assets', express.static(path.join(__4bnodeDir, 'assets'))); // ── Passkey Authentication ────────────────────────────────────────────────── function getPasskeyHash() { const file = path.join(__4bnodeDir, '.passkey'); if (!fs.existsSync(file)) return null; return fs.readFileSync(file, 'utf8').trim() || null; } // Constant-time verification. Supports the current salted-scrypt format // (`scrypt$<saltHex>$<hashHex>`) and the legacy unsalted sha256 hex digest so // apps generated before the upgrade keep working. function verifyPasskey(candidate, stored) { if (!candidate || !stored) return false; try { if (stored.startsWith('scrypt$')) { const [, saltHex, hashHex] = stored.split('$'); const salt = Buffer.from(saltHex, 'hex'); const expected = Buffer.from(hashHex, 'hex'); const actual = crypto.scryptSync(String(candidate), salt, expected.length); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); } const expected = Buffer.from(stored, 'hex'); const actual = crypto.createHash('sha256').update(String(candidate)).digest(); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); } catch { return false; } } // Brute-force throttle. After AUTH_MAX_FAILS wrong guesses from one peer, lock it // out for AUTH_LOCK_MS. In-memory (clears on restart) — enough to stop online // guessing, which is the realistic attack once the dashboard is reachable. const AUTH_MAX_FAILS = 5; const AUTH_LOCK_MS = 60_000; const _authFails = new Map(); // ip -> { fails, lockedUntil } function authClientKey(req) { return String((req.socket && req.socket.remoteAddress) || 'unknown').replace(/^::ffff:/, ''); } function authLockState(req) { const e = _authFails.get(authClientKey(req)); if (e && e.lockedUntil > Date.now()) { return { locked: true, retryAfter: Math.ceil((e.lockedUntil - Date.now()) / 1000) }; } return { locked: false, retryAfter: 0 }; } function recordAuthFailure(req) { const key = authClientKey(req); const e = _authFails.get(key) || { fails: 0, lockedUntil: 0 }; e.fails += 1; if (e.fails >= AUTH_MAX_FAILS) { e.lockedUntil = Date.now() + AUTH_LOCK_MS; e.fails = 0; } _authFails.set(key, e); } function recordAuthSuccess(req) { _authFails.delete(authClientKey(req)); } // Migrate a legacy unsalted-sha256 .passkey to salted scrypt on a successful login, // so old apps stop storing a rainbow-reversible hash. No-op if already scrypt. function upgradeLegacyPasskey(candidate, stored) { if (!stored || stored.startsWith('scrypt$')) return; try { const salt = crypto.randomBytes(16); const hash = 'scrypt$' + salt.toString('hex') + '$' + crypto.scryptSync(String(candidate), salt, 32).toString('hex'); fs.writeFileSync(path.join(__4bnodeDir, '.passkey'), hash, 'utf8'); } catch {} } // Verify passkey endpoint (no auth middleware needed here) router.post('/api/auth', (req, res) => { const lock = authLockState(req); if (lock.locked) { res.set('Retry-After', String(lock.retryAfter)); return res.status(429).json({ error: `Too many attempts. Try again in ${lock.retryAfter}s.` }); } const { passkey } = req.body || {}; const stored = getPasskeyHash(); if (!stored) { // No passkey configured: permit only loopback dev. A missing passkey must // never authenticate a remote caller (deny by default). if (!isLoopbackRequest(req)) { return res.status(401).json({ error: 'A dashboard passkey must be configured for remote access.' }); } return res.json({ ok: true }); } if (!verifyPasskey(passkey, stored)) { // Only count a real guess (non-empty) so an unauthenticated page load that // probes with an empty passkey doesn't burn the lockout budget. if (passkey) recordAuthFailure(req); const after = authLockState(req); if (after.locked) { res.set('Retry-After', String(after.retryAfter)); return res.status(429).json({ error: `Too many attempts. Try again in ${after.retryAfter}s.` }); } return res.status(401).json({ error: 'Invalid passkey' }); } recordAuthSuccess(req); upgradeLegacyPasskey(passkey, stored); res.json({ ok: true }); }); // Auth middleware — protect all /api/* routes below router.use('/api', (req, res, next) => { if (req.path === '/auth') return next(); // SSE endpoint handles its own auth via query param (EventSource can't send headers) if (req.path === '/logs/stream') return next(); const stored = getPasskeyHash(); if (!stored) { // Deny by default: a missing passkey may only pass for loopback dev, never // for a remote caller (relevant when _4BNODE_ALLOW_REMOTE is enabled). if (!isLoopbackRequest(req)) return res.status(401).json({ error: 'Unauthorized' }); return next(); } const lock = authLockState(req); if (lock.locked) { res.set('Retry-After', String(lock.retryAfter)); return res.status(429).json({ error: 'Too many attempts. Try again later.' }); } const passkey = req.headers['x-passkey']; if (!verifyPasskey(passkey, stored)) { if (passkey) recordAuthFailure(req); return res.status(401).json({ error: 'Unauthorized' }); } upgradeLegacyPasskey(passkey, stored); next(); }); // --------------------------------------------------------------------------- // Utility functions (name helpers + code builders are imported from ./codegen.js) // --------------------------------------------------------------------------- function listModels() { const modelsDir = path.join(process.cwd(), "src", "models"); if (!fs.existsSync(modelsDir)) { return []; } return fs.readdirSync(modelsDir).filter((f) => f.endsWith(".js")); } function listRoutes() { const routesDir = path.join(process.cwd(), "src", "routes"); if (!fs.existsSync(routesDir)) { return []; } return fs.readdirSync(routesDir).filter((f) => f.endsWith(".js")); } function checkMongoConfig() { const envPath = path.join(process.cwd(), ".env"); return ( fs.existsSync(envPath) && fs.readFileSync(envPath, "utf8").includes("MONGO_URI") ); } function getEnvValue(filePath, key) { if (!fs.existsSync(filePath)) return ''; const content = fs.readFileSync(filePath, 'utf8'); const match = content.match(new RegExp(`^${key}=(.*)`, 'm')); return match ? match[1].trim() : ''; } function extractSchemaBlock(content) { const marker = "new mongoose.Schema("; const idx = content.indexOf(marker); if (idx === -1) return null; // Find the opening { after the marker const braceStart = content.indexOf("{", idx + marker.length); if (braceStart === -1) return null; // Match balanced braces to find the outer schema object let depth = 0; for (let i = braceStart; i < content.length; i++) { if (content[i] === "{") depth++; else if (content[i] === "}") { depth--; if (depth === 0) return content.slice(braceStart + 1, i); } } return null; } // The fields a route actually accepts in its body — used to generate a Zod schema // scoped to THAT route (not the whole model, which many routes share with differing // field subsets). Pulls types from the model where known; skips File fields (not in // req.body). Returns null when the route takes no validatable body. function routeValidatorFields(type, opts) { const { findField, passwordField, hasPassword, registerFields, allFieldsCombined, modelName } = opts; const modelFields = {}; try { if (modelName) (getModelSchemaDetailed(sanitizeName(modelName)).fields || []).forEach((f) => { modelFields[f.name] = f; }); } catch {} const make = (name, fallbackType, required) => { const mf = modelFields[name]; return { name, type: (mf && mf.type) || fallbackType || 'String', required }; }; if (type === 'login') { const fields = [make(findField, 'String', true)]; if (hasPassword) fields.push(make(passwordField, 'String', true)); return fields; } if (type === 'register') { const regFields = (registerFields && registerFields.length) ? registerFields : [findField]; return regFields.map((f) => make(f, 'String', true)); } if (type === 'crud' || type === 'methods') { return (allFieldsCombined || []) .filter((f) => f.type !== 'File') .map((f) => { const mf = modelFields[f.name]; const required = mf ? !!mf.required : f.required !== false; return { name: f.name, type: (f.type && f.type !== 'File' && f.type) || (mf && mf.type) || 'String', required }; }); } return null; } function getModelSchemaDetailed(modelName) { const modelFilePath = path.join( process.cwd(), "src", "models", modelName + ".js" ); if (!fs.existsSync(modelFilePath)) { return { fields: [], indexes: [] }; } const content = fs.readFileSync(modelFilePath, "utf8"); const schemaContent = extractSchemaBlock(content); if (!schemaContent) return { fields: [], indexes: [] }; const fields = []; // Match each field block: fieldName: { type: ..., required: ..., unique: ..., default: ... } const fieldRegex = /(\w+)\s*:\s*\{([^}]*)\}/g; let fm; while ((fm = fieldRegex.exec(schemaContent)) !== null) { const name = fm[1]; const props = fm[2]; const typeMatch = props.match(/type:\s*([\w.]+)/); const type = typeMatch ? typeMatch[1].replace("mongoose.Schema.Types.", "") : "String"; const required = /required:\s*true/.test(props); const unique = /unique:\s*true/.test(props); const indexed = /index:\s*true/.test(props); const refMatch = props.match(/ref:\s*['"](\w+)['"]/); const defaultMatch = props.match(/default:\s*(.+?)(?:,\s*\w+:|$)/); const field = { name, type, required, unique }; if (indexed) field.index = true; if (refMatch) field.ref = refMatch[1]; if (defaultMatch) { field.default = defaultMatch[1].trim(); } fields.push(field); } // Parse compound/text indexes: SchemaName.index({ ... }) const indexes = []; const indexRegex = /\w+Schema\.index\(\s*\{([^}]+)\}(?:\s*,\s*\{([^}]*)\})?\s*\)/g; let idxMatch; while ((idxMatch = indexRegex.exec(content)) !== null) { const fieldsStr = idxMatch[1]; const optsStr = idxMatch[2] || ''; const idxFields = []; const fieldEntries = fieldsStr.split(',').map(s => s.trim()).filter(Boolean); for (const entry of fieldEntries) { const [key, val] = entry.split(':').map(s => s.trim()); if (val === "'text'" || val === '"text"') { idxFields.push({ field: key, direction: 'text' }); } else { idxFields.push({ field: key, direction: parseInt(val) || 1 }); } } const isUnique = /unique:\s*true/.test(optsStr); const isText = idxFields.some(f => f.direction === 'text'); indexes.push({ fields: idxFields, unique: isUnique, type: isText ? 'text' : 'compound' }); } return { fields, indexes }; } function getModelSchema(modelName) { const modelFilePath = path.join( process.cwd(), "src", "models", modelName + ".js" ); if (!fs.existsSync(modelFilePath)) { return []; } const content = fs.readFileSync(modelFilePath, "utf8"); const schemaContent = extractSchemaBlock(content); if (!schemaContent) return []; const fieldRegex = /(\w+)\s*:\s*\{/g; const fields = []; let fieldMatch; while ((fieldMatch = fieldRegex.exec(schemaContent)) !== null) { fields.push(fieldMatch[1]); } return fields; } // --------------------------------------------------------------------------- // Route file manipulation functions // --------------------------------------------------------------------------- // File-based wrappers around the shared pure string helpers in codegen.js. function addImportToRoute(filePath, importStatement) { const original = fs.readFileSync(filePath, "utf8"); const content = addImportToContent(original, importStatement); if (content !== original) { fs.writeFileSync(filePath, content, "utf8"); } return content; } function insertCodeIntoRoute(filePath, codeBlock) { const content = insertCodeIntoContent( fs.readFileSync(filePath, "utf8"), codeBlock ); fs.writeFileSync(filePath, content, "utf8"); } function detectHttpMethod(filePath) { const content = fs.readFileSync(filePath, "utf8"); const methodRegex = /router\.(get|post|put|patch|delete)\((['"`])\/.*?\2,\s*async\s*\(req,\s*res/; const match = content.match(methodRegex); return match ? match[1].toLowerCase() : "post"; } function getRequestDataSource(method) { return method === "get" || method === "delete" ? "req.query" : "req.body"; } // CRUD route builders and the Mongoose model generator are imported from // ./codegen.js. `generateSchemaFileContent` is kept as a thin alias so existing // call sites below remain unchanged. const generateSchemaFileContent = generateMongooseModel; // --------------------------------------------------------------------------- // API Routes // --------------------------------------------------------------------------- // GET /api/dashboard router.get('/api/dashboard', (req, res) => { try { const modelFiles = listModels(); const models = modelFiles.map((f) => { const name = path.basename(f, '.js'); const { fields, indexes } = getModelSchemaDetailed(name); return { name, fields, indexes }; }); const routes = listRoutes(); const mongoConfigured = checkMongoConfig(); const routesDirExists = fs.existsSync(path.join(process.cwd(), 'src', 'routes')); let dbType = null; const indexPath = path.join(process.cwd(), 'index.js'); if (fs.existsSync(indexPath)) { const indexContent = fs.readFileSync(indexPath, 'utf8'); if (indexContent.includes('import connectDB') || indexContent.includes('mongoose')) { dbType = 'mongodb'; } } const authConfigured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'auth.js')); let mongoUri = ''; if (mongoConfigured) { mongoUri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI'); } res.json({ models, routes, mongoConfigured, dbType, routesDirExists, authConfigured, mongoUri }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/models router.get('/api/models', (req, res) => { try { const modelFiles = listModels(); const names = modelFiles.map((f) => path.basename(f, '.js')); res.json(names); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/models/:name router.get('/api/models/:name', (req, res) => { try { const name = safeStem(req.params.name); const { fields, indexes } = getModelSchemaDetailed(name); res.json({ name, fields, indexes }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/models/:name/data — fetch stored documents from MongoDB collection router.get('/api/models/:name/data', async (req, res) => { try { const name = safeStem(req.params.name); const rawLimit = parseInt(req.query.limit); const limit = rawLimit === 0 ? 0 : Math.min(rawLimit || 50, 500); const skip = parseInt(req.query.skip) || 0; const pascalName = toPascalCase(name); // Dynamically import mongoose from the project's node_modules const projectMongoose = path.join(process.cwd(), 'node_modules', 'mongoose', 'index.js'); if (!fs.existsSync(projectMongoose)) { return res.status(400).json({ error: 'Mongoose is not installed. Set up MongoDB first.' }); } const mongoose = (await import(pathToFileURL(projectMongoose).href)).default; // Connect if not already connected if (mongoose.connection.readyState === 0) { const uri = getEnvValue(path.join(process.cwd(), '.env'), 'MONGO_URI'); if (!uri) return res.status(400).json({ error: 'MONGO_URI not configured.' }); await mongoose.connect(uri); } const collection = mongoose.connection.db.collection(name + 's'); const total = await collection.countDocuments(); const query = collection.find({}).skip(skip); if (limit > 0) query.limit(limit); const docs = await query.toArray(); res.json({ docs, total, limit, skip }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/models/:name/share — generate shareable endpoint documentation router.get('/api/models/:name/share', (req, res) => { try { const name = safeStem(req.params.name); const { fields } = getModelSchemaDetailed(name); const routes = listRoutes(); const routeFile = routes.find(r => r === name + '.js' || r === name + 's.js'); let endpoints = []; if (routeFile) { const filePath = path.join(process.cwd(), 'src', 'routes', routeFile); const content = fs.readFileSync(filePath, 'utf8'); const regex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi; let m; while ((m = regex.exec(content)) !== null) { endpoints.push({ method: m[1].toUpperCase(), path: m[2] }); } } // Determine the route prefix const indexPath = path.join(process.cwd(), 'index.js'); let prefix = '/api/' + name; if (fs.existsSync(indexPath)) { const indexContent = fs.readFileSync(indexPath, 'utf8'); const prefixMatch = indexContent.match(new RegExp(`app\\.use\\(['"](\\/[^'"]+)['"].*${routeFile ? routeFile.replace('.js', '') : name}`)); if (prefixMatch) prefix = prefixMatch[1]; } res.json({ name, fields, endpoints, prefix, routeFile: routeFile || null }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/models router.post('/api/models', (req, res) => { try { if (!checkMongoConfig()) { return res.status(400).json({ error: 'Database must be configured before creating schemas. Set up MongoDB first.' }); } const { name, fields, indexes } = req.body; const safeName = sanitizeName(name); const pascalName = toPascalCase(safeName); const modelsDir = path.join(process.cwd(), 'src', 'models'); if (!fs.existsSync(modelsDir)) { fs.mkdirSync(modelsDir, { recursive: true }); } const filePath = path.join(modelsDir, safeName + '.js'); if (fs.existsSync(filePath)) { return res.status(409).json({ error: `Model "${safeName}" already exists.` }); } const content = generateSchemaFileContent(pascalName, fields, indexes); fs.writeFileSync(filePath, content, 'utf8'); refreshDocsSpec(); res.json({ message: `Model "${safeName}" created.`, file: `src/models/${safeName}.js` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/models/:name router.put('/api/models/:name', (req, res) => { try { const name = safeStem(req.params.name); const { fields, indexes } = req.body; const pascalName = toPascalCase(name); const filePath = path.join(process.cwd(), 'src', 'models', name + '.js'); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: `Model "${name}" not found.` }); } const content = generateSchemaFileContent(pascalName, fields, indexes); fs.writeFileSync(filePath, content, 'utf8'); refreshDocsSpec(); res.json({ message: `Model "${name}" updated.`, file: `src/models/${name}.js` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/models/:name router.delete('/api/models/:name', (req, res) => { try { const name = safeStem(req.params.name); const filePath = path.join(process.cwd(), 'src', 'models', name + '.js'); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: `Model "${name}" not found.` }); } fs.unlinkSync(filePath); refreshDocsSpec(); res.json({ message: `Model "${name}" deleted.` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/routes router.get('/api/routes', (req, res) => { try { const routes = listRoutes(); res.json(routes); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/routes/:name/content router.get('/api/routes/:name/content', (req, res) => { try { const name = safeStem(req.params.name); const filePath = path.join(process.cwd(), 'src', 'routes', name + '.js'); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: `Route file "${name}.js" not found.` }); } const content = fs.readFileSync(filePath, 'utf8'); res.json({ name, content }); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/routes/:name router.delete('/api/routes/:name', (req, res) => { try { const name = safeStem(req.params.name); const routeFile = name + '.js'; const safeName = name; const filePath = path.join(process.cwd(), 'src', 'routes', routeFile); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: `Route file "${routeFile}" not found.` }); } // Find the Zod validator this route uses (if any) before deleting the route, // so we can clean it up too — otherwise validators/<name>.js is orphaned. let validatorPath = null; try { const routeContent = fs.readFileSync(filePath, 'utf8'); const vi = routeContent.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/); if (vi) validatorPath = path.join(process.cwd(), 'src', 'validators', vi[1] + '.js'); } catch {} // Delete the route file fs.unlinkSync(filePath); // Delete its validator (only this route used it — validators are 1:1 with routes). if (validatorPath && fs.existsSync(validatorPath)) { try { fs.unlinkSync(validatorPath); } catch {} } refreshDocsSpec(); // Send response BEFORE modifying index.js (nodemon restart kills connection) res.json({ message: `Route "${safeName}" deleted successfully.` }); // Remove import and registration from index.js const indexPath = path.join(process.cwd(), 'index.js'); if (fs.existsSync(indexPath)) { let indexContent = fs.readFileSync(indexPath, 'utf8'); // Remove import line indexContent = indexContent.replace(new RegExp(`import\\s+\\w+Router\\s+from\\s+['\"]\\./src/routes/${escapeRegExp(safeName)}\\.js['\"];?\\n?`, 'g'), ''); // Remove app.use registration line indexContent = indexContent.replace(new RegExp(`app\\.use\\(['\"][^'\"]*${escapeRegExp(safeName)}['\"],\\s*\\w+Router\\);?\\n?`, 'g'), ''); fs.writeFileSync(indexPath, indexContent, 'utf8'); } } catch (err) { if (!res.headersSent) res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // POST /api/routes/:name/add-method — add handlers to existing route // --------------------------------------------------------------------------- router.post('/api/routes/:name/add-method', async (req, res) => { try { const name = safeStem(req.params.name); const routeFile = name + '.js'; const filePath = path.join(process.cwd(), 'src', 'routes', routeFile); if (!fs.existsSync(filePath)) { return res.status(404).json({ error: `Route file "${routeFile}" not found.` }); } const { routeType = 'methods', methods: methodList = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body; const pascalModel = modelName ? toPascalCase(modelName) : ''; const hasPassword = !!passwordField; const findField = loginField || 'email'; let content = fs.readFileSync(filePath, 'utf8'); // Check for duplicate methods const existingMethods = []; const existCheck = /router\.(get|post|put|patch|delete)\(/gi; let em; while ((em = existCheck.exec(content)) !== null) existingMethods.push(em[1].toLowerCase()); const newMethods = methodList.filter(m => !existingMethods.includes(m.toLowerCase())); // Helper: add import if not present function addImport(imp) { if (!content.includes(imp.split(' from ')[0])) { const lastImport = content.lastIndexOf('import '); const lineEnd = content.indexOf('\n', lastImport); content = content.slice(0, lineEnd + 1) + imp + '\n' + content.slice(lineEnd + 1); } } // Helper: insert handler before export function insertHandler(handler) { const exportIdx = content.lastIndexOf('export default router;'); if (exportIdx !== -1) { content = content.slice(0, exportIdx) + handler + '\n\n' + content.slice(exportIdx); } else { content += '\n' + handler; } } let handlers = ''; const packagesToInstall = []; if (routeType === 'methods') { if (newMethods.length === 0) { return res.status(400).json({ error: 'All selected methods already exist in this route.' }); } const allFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields]; const fileFields = allFields.filter(f => f.type === 'File'); const bodyFields = allFields.filter(f => f.type !== 'File'); const hasFileFields = fileFields.length > 0; if (hasFileFields) { addImport("import multer from 'multer';"); if (!content.includes('const upload = multer')) { content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });"); } packagesToInstall.push('multer'); } newMethods.forEach(m => { const dataSource = (m === 'get' || m === 'delete') ? 'req.query' : 'req.body'; let mw = ''; let destruct = ''; if (hasFileFields && m !== 'get' && m !== 'delete') { if (fileFields.length === 1) mw = `upload.single('${fileFields[0].name}'), `; else mw = `upload.fields([${fileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `; } if (bodyFields.length > 0) destruct += ` const { ${bodyFields.map(f => f.name).join(', ')} } = ${dataSource};\n`; if (hasFileFields && m !== 'get' && m !== 'delete') { if (fileFields.length === 1) destruct += ` const ${fileFields[0].name} = req.file;\n`; else fileFields.forEach(f => { destruct += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`; }); } if (allFields.length > 0) { 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`; } else { handlers += `\nrouter.${m}('/', async (req, res) => {\n res.json({ msg: '${name} ${m.toUpperCase()} endpoint works' });\n});\n`; } }); } else if (routeType === 'crud' && modelName) { addImport(`import ${pascalModel} from '../models/${modelName}.js';`); const crudMethods = newMethods.length > 0 ? newMethods : ['get', 'post', 'put', 'delete'].filter(m => !existingMethods.includes(m)); const allCrudFields = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields]; const crudBodyFields = allCrudFields.filter(f => f.type !== 'File'); const crudFileFields = allCrudFields.filter(f => f.type === 'File'); const crudHasFiles = crudFileFields.length > 0; const crudHasFields = allCrudFields.length > 0; if (crudHasFiles) { addImport("import multer from 'multer';"); if (!content.includes('const upload = multer')) { content = content.replace('const router = express.Router();', "const router = express.Router();\n\nconst upload = multer({ dest: 'uploads/' });"); } packagesToInstall.push('multer'); } let dest = ''; let obj = 'req.body'; if (crudHasFields) { const parts = []; if (crudBodyFields.length > 0) { dest = `const { ${crudBodyFields.map(f => f.name).join(', ')} } = req.body;`; parts.push(...crudBodyFields.map(f => f.name)); } if (crudFileFields.length > 0) { const fileLines = crudFileFields.map(f => { if (crudFileFields.length === 1) return `const ${f.name} = req.file ? req.file.path : undefined;`; return `const ${f.name} = req.files?.['${f.name}']?.[0]?.path || undefined;`; }).join('\\n '); dest = (dest ? dest + '\\n ' : '') + fileLines; parts.push(...crudFileFields.map(f => f.name)); } obj = `{ ${parts.join(', ')} }`; } let mw = ''; if (crudHasFiles) { if (crudFileFields.length === 1) mw = `upload.single('${crudFileFields[0].name}'), `; else mw = `upload.fields([${crudFileFields.map(f => `{ name: '${f.name}', maxCount: 1 }`).join(', ')}]), `; } if (crudMethods.includes('get')) { 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`; } if (crudMethods.includes('post')) { 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`; } if (crudMethods.includes('put')) { 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`; } if (crudMethods.includes('patch')) { 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`; } if (crudMethods.includes('delete')) { 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`; } } else if (routeType === 'login' && modelName) { addImport(`import ${pascalModel} from '../models/${modelName}.js';`); if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); } addImport("import jwt from 'jsonwebtoken';"); packagesToInstall.push('jsonwebtoken'); if (hasPassword) { 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`; } else { 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`; } // Ensure JWT_SECRET const envFile = path.join(process.cwd(), '.env'); if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex')); } else if (routeType === 'register' && modelName) { addImport(`import ${pascalModel} from '../models/${modelName}.js';`); if (hasPassword) { addImport("import bcrypt from 'bcrypt';"); packagesToInstall.push('bcrypt'); } addImport("import jwt from 'jsonwebtoken';"); packagesToInstall.push('jsonwebtoken'); const regFields = registerFields.length > 0 ? registerFields : [findField]; const allDestructured = regFields.join(', '); if (hasPassword) { const nonPwFields = regFields.filter(f => f !== passwordField); 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`; } else { 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`; } const envFile = path.join(process.cwd(), '.env'); if (!fs.existsSync(envFile) || !fs.readFileSync(envFile, 'utf8').includes('JWT_SECRET')) updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex')); } if (!handlers) { return res.status(400).json({ error: 'Nothing to add.' }); } // Install packages if (packagesToInstall.length > 0) { await installDeps([...new Set(packagesToInstall)]); } insertHandler(handlers); fs.writeFileSync(filePath, content, 'utf8'); refreshDocsSpec(); const label = routeType === 'methods' ? newMethods.map(m => m.toUpperCase()).join(', ') : routeType.toUpperCase(); res.json({ message: `${label} added to "${name}" route.`, content }); } catch (err) { if (!res.headersSent) res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // POST /api/crud/preview router.post('/api/crud/preview', (req, res) => { try { const { modelName, routeFile, operations } = req.body; const pascalName = toPascalCase(modelName); const fields = getModelSchema(modelName); const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile); let dataSource = 'req.body'; if (fs.existsSync(routeFilePath)) { const method = detectHttpMethod(routeFilePath); dataSource = getRequestDataSource(method); } const needsBcrypt = fields.includes('password') && (operations.includes('create') || operations.includes('update')); let code = ''; // Import block let importBlock = `import ${pascalName} from '../models/${modelName}.js';`; if (needsBcrypt) { importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`; } code += importBlock + '\n'; if (operations.includes('create')) { code += buildInsertCode(pascalName, fields, dataSource) + '\n'; } if (operations.includes('read')) { code += buildReadCode(pascalName) + '\n'; } if (operations.includes('update')) { code += buildUpdateCode(pascalName, fields, dataSource) + '\n'; } if (operations.includes('delete')) { code += buildDeleteCode(pascalName) + '\n'; } res.json({ code }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/crud/generate router.post('/api/crud/generate', (req, res) => { try { const { modelName, routeFile, operations } = req.body; const pascalName = toPascalCase(modelName); const fields = getModelSchema(modelName); const routeFilePath = path.join(process.cwd(), 'src', 'routes', routeFile); if (!fs.existsSync(routeFilePath)) { return res.status(404).json({ error: `Route file "${routeFile}" not found.` }); } if (fields.length === 0) { return res.status(400).json({ error: 'No fields found in the selected model.' }); } const method = detectHttpMethod(routeFilePath); const dataSource = getRequestDataSource(method); const needsBcrypt = fields.includes('password') && (operations.includes('create') || operations.includes('update')); // Build import let importBlock = `import ${pascalName} from '../models/${modelName}.js';`; if (needsBcrypt) { importBlock = `import bcrypt from 'bcrypt';\n${importBlock}`; } addImportToRoute(routeFilePath, importBlock); // Add selected operations const added = []; if (operations.includes('create')) { insertCodeIntoRoute(routeFilePath, buildInsertCode(pascalName, fields, dataSource)); added.push('POST /create'); } if (operations.includes('read')) { insertCodeIntoRoute(routeFilePath, buildReadCode(pascalName)); added.push('GET / and GET /:id'); } if (operations.includes('update')) { insertCodeIntoRoute(routeFilePath, buildUpdateCode(pascalName, fields, dataSource)); added.push('PUT /:id'); } if (operations.includes('delete')) { insertCodeIntoRoute(routeFilePath, buildDeleteCode(pascalName)); added.push('DELETE /:id'); } res.json({ message: `CRUD operations added to ${routeFile}`, model: modelName, routes: added, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/env router.get('/api/env', (req, res) => { try { const envPath = path.join(process.cwd(), '.env'); const content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''; res.json({ content }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PUT /api/env router.put('/api/env', (req, res) => { try { const { content } = req.body; const envPath = path.join(process.cwd(), '.env'); fs.writeFileSync(envPath, content, 'utf8'); res.json({ message: '.env updated.' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // Index.js manipulation helpers // --------------------------------------------------------------------------- function readIndexFile() { return fs.readFileSync(path.join(process.cwd(), 'index.js'), 'utf8'); } function writeIndexFile(content) { fs.writeFileSync(path.join(process.cwd(), 'index.js'), content, 'utf8'); } // File-based wrappers around the shared pure index.js transforms in codegen.js. function addImportToIndex(importLine) { writeIndexFile(addImportAfterLastImport(readIndexFile(), importLine)); } function insertBeforeListen(codeBlock) { writeIndexFile(insertBeforeListenContent(readIndexFile(), codeBlock)); } function replaceAppListen(newBlock) { let content = readIndexFile(); if (content.includes('startServer(')) { content = content.replace(/const listener = app\.listen\(tryPort\)/, 'const listener = server.listen(tryPort)'); if (!content.includes('server.listen(tryPort)')) { content = content.replace(/app\.listen\(tryPort\)/, 'server.listen(tryPort)'); } } else { content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, newBlock.trim() + '\n'); } writeIndexFile(content); } function removeSocketIOCode() { let content = readIndexFile(); // Remove Socket.IO import content = content.replace(/import\s*\{\s*Server\s*\}\s*from\s*["']socket\.io["'];\s*\n?/g, ''); // Remove http import only if WebSocket doesn't need it if (!content.includes('WebSocketServer({ server')) { content = content.replace(/import\s+http\s+from\s*["']http["'];\s*\n?/g, ''); } // Remove server = http.createServer(app) only if WebSocket doesn't need it if (!content.includes('WebSocketServer({ server')) { content = content.replace(/const\s+server\s*=\s*http\.createServer\(app\);\s*\n?/g, ''); } // Remove io setup block (use \n}); to match outer closing brace at column 0) content = content.replace(/const\s+io\s*=\s*new\s+Server\(server[\s\S]*?\n\}\);\s*\n?/g, ''); // Remove io.on("connection"...) block (nested callbacks — match \n}); at column 0) content = content.replace(/io\.on\(["']connection["'][\s\S]*?\n\}\);\s*\n?/g, ''); // Remove req.io middleware 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, ''); // Replace server.listen back to app.listen if no WebSocket uses server if (!content.includes('WebS