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.

335 lines (285 loc) 9.61 kB
#!/usr/bin/env node import fs from "fs"; import path from "path"; import { select, confirm, input, checkbox } from "@inquirer/prompts"; import { toCamelCase, sanitizeName, sanitizeFieldName } from "./lib/names.js"; import { getProjectRoot, listModels, getModelSchemaDetailed, } from "./lib/project.js"; import { addRouteRegistration } from "./lib/indexFile.js"; import { showHeader, showTaskDone, showError, showFileAction, installDeps, } from "./lib/ui.js"; const methods = ["get", "post", "put", "delete", "patch"]; function checkDuplicateMethod(apiFile, method) { const content = fs.readFileSync(apiFile, "utf8"); return new RegExp(`router\\.${method}\\(`).test(content); } function formatFieldLabel(field) { let label = `${field.name} (${field.type}`; if (field.required) label += ", required"; if (field.unique) label += ", unique"; if (field.default !== undefined) label += `, default: ${field.default}`; label += ")"; return label; } async function collectCustomFields() { const customFields = []; let addMore = true; while (addMore) { const fieldName = await input({ message: "Enter field name:", validate: (v) => { try { sanitizeFieldName(v); return true; } catch (e) { return e.message; } }, }); const fieldType = await select({ message: `Type for "${fieldName}":`, choices: [ { name: "String", value: "String" }, { name: "Number", value: "Number" }, { name: "Boolean", value: "Boolean" }, { name: "Date", value: "Date" }, { name: "Array", value: "Array" }, { name: "Object", value: "Object" }, { name: "File (multipart)", value: "File" }, ], }); customFields.push({ name: sanitizeFieldName(fieldName), type: fieldType }); addMore = await confirm({ message: "Add another field?", default: false, }); } return customFields; } function buildMethodBlock({ method, endpointName, authMiddleware, fields, multerUploadLine, }) { const middlewares = []; if (authMiddleware) middlewares.push("auth"); if (multerUploadLine) middlewares.push(multerUploadLine); const middlewareStr = middlewares.length ? middlewares.join(", ") + ", " : ""; if (fields.length === 0) { return ` router.${method}('/', ${middlewareStr}async (req, res) => { res.json({ msg: '${endpointName} ${method.toUpperCase()} endpoint works' }); });`; } const dataSource = method === "get" || method === "delete" ? "req.query" : "req.body"; const bodyFields = fields.filter((f) => f.type !== "File"); const fileFields = fields.filter((f) => f.type === "File"); let destructLines = ""; if (bodyFields.length > 0) { const fieldNames = bodyFields.map((f) => f.name).join(", "); destructLines += ` const { ${fieldNames} } = ${dataSource};\n`; } if (fileFields.length > 0) { if (fileFields.length === 1) { destructLines += ` const ${fileFields[0].name} = req.file;\n`; } else { for (const f of fileFields) { destructLines += ` const ${f.name} = req.files?.['${f.name}']?.[0];\n`; } } } return ` router.${method}('/', ${middlewareStr}async (req, res) => { ${destructLines} // TODO: Add your logic here res.json({ msg: '${endpointName} ${method.toUpperCase()} endpoint works' }); });`; } async function main() { let endpointName = process.argv[2]; if (!endpointName) { endpointName = await input({ message: "Enter endpoint name:", validate: (v) => { try { sanitizeName(v); return true; } catch (e) { return e.message; } }, }); } endpointName = sanitizeName(endpointName); showHeader("add-api", `Creating endpoint: ${endpointName}`); const projectRoot = getProjectRoot(); const apiDir = path.join(projectRoot, "src", "routes"); const apiFile = path.join(apiDir, `${endpointName}.js`); const variableName = toCamelCase(endpointName); const selectedPath = `/api/${endpointName}`; const method = await select({ message: "Choose HTTP method:", choices: methods.map((m) => ({ name: m.toUpperCase(), value: m })), }); let isAuthorized = false; const authFilePath = path.join(projectRoot, "src", "middleware", "auth.js"); if (fs.existsSync(authFilePath)) { isAuthorized = await confirm({ message: "Should this API be authorized?", default: false, }); } // --- Field selection --- const allFields = []; const modelFiles = listModels(); if (modelFiles.length > 0) { const useModel = await confirm({ message: "Select fields from an existing model?", default: true, }); if (useModel) { const selectedModelFile = await select({ message: "Select model:", choices: modelFiles.map((f) => ({ name: f, value: path.basename(f, ".js"), })), }); const modelFields = getModelSchemaDetailed(selectedModelFile); if (modelFields.length > 0) { const selectedFieldNames = await checkbox({ message: "Select fields to use from model:", choices: modelFields.map((f) => ({ name: formatFieldLabel(f), value: f.name, checked: true, })), }); for (const name of selectedFieldNames) { const field = modelFields.find((f) => f.name === name); allFields.push({ name, type: field ? field.type : "String" }); } } } } // --- Add custom fields --- const addCustom = await confirm({ message: "Add custom fields (received from client)?", default: allFields.length === 0, }); if (addCustom) { const customFields = await collectCustomFields(); allFields.push(...customFields); } // --- Auto-detect multipart from File fields --- const hasFileFields = allFields.some((f) => f.type === "File"); // --- Duplicate check --- if (fs.existsSync(apiFile) && checkDuplicateMethod(apiFile, method)) { showError( `Endpoint ${selectedPath} [${method.toUpperCase()}] already exists.` ); process.exit(1); } if (!fs.existsSync(apiDir)) { fs.mkdirSync(apiDir, { recursive: true }); } // --- Build multer config --- let multerImport = ""; let multerSetup = ""; let multerUploadLine = ""; const needsMulter = hasFileFields; if (needsMulter) { multerImport = "import multer from 'multer';\n"; multerSetup = `\nconst upload = multer({ dest: 'uploads/' });\n`; const fileFields = allFields.filter((f) => f.type === "File"); if (fileFields.length === 0) { multerUploadLine = "upload.none()"; } else if (fileFields.length === 1) { multerUploadLine = `upload.single('${fileFields[0].name}')`; } else { const fieldsArg = fileFields .map((f) => `{ name: '${f.name}', maxCount: 1 }`) .join(", "); multerUploadLine = `upload.fields([${fieldsArg}])`; } } // --- Build method block --- const authMiddleware = isAuthorized ? "auth, " : ""; const methodBlock = buildMethodBlock({ method, endpointName, authMiddleware: isAuthorized, fields: allFields, multerUploadLine, }); // --- Write route file --- let apiContent; if (fs.existsSync(apiFile)) { apiContent = fs.readFileSync(apiFile, "utf8"); // Add multer import if needed and not already present if (needsMulter && !apiContent.includes("import multer")) { const firstImportEnd = apiContent.indexOf("\n"); apiContent = apiContent.slice(0, firstImportEnd + 1) + multerImport + apiContent.slice(firstImportEnd + 1); } if (needsMulter && !apiContent.includes("const upload = multer")) { const routerLine = apiContent.indexOf("const router = express.Router();"); const afterRouter = routerLine + "const router = express.Router();".length; apiContent = apiContent.slice(0, afterRouter) + multerSetup + apiContent.slice(afterRouter); } const exportIndex = apiContent.lastIndexOf("export default router;"); apiContent = apiContent.slice(0, exportIndex) + methodBlock.trim() + "\n\n" + apiContent.slice(exportIndex); showFileAction("updated", `src/routes/${endpointName}.js`); } else { apiContent = `import express from 'express'; ${needsMulter ? multerImport : ""}${isAuthorized ? "import auth from '../middleware/auth.js';\n" : ""}const router = express.Router(); ${needsMulter ? multerSetup : ""} ${methodBlock.trim()} export default router;`; showFileAction("created", `src/routes/${endpointName}.js`); } fs.writeFileSync(apiFile, apiContent.trim() + "\n", "utf8"); addRouteRegistration( `import ${variableName} from './src/routes/${endpointName}.js';`, `app.use('${selectedPath}', ${variableName});` ); showFileAction("updated", "index.js"); // Install multer if needed if (needsMulter) { await installDeps("multer"); } const summary = [ isAuthorized ? "Protected with auth middleware" : "No auth middleware", `Content type: ${hasFileFields ? "multipart/form-data" : "application/json"}`, `Route file: src/routes/${endpointName}.js`, ]; if (allFields.length > 0) { summary.push( `Fields: ${allFields.map((f) => `${f.name} (${f.type})`).join(", ")}` ); } showTaskDone(`API endpoint ready: ${method.toUpperCase()} ${selectedPath}`, summary); } main().catch((err) => { if (err.name === "ExitPromptError") process.exit(0); showError(err.message); process.exit(1); });