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
JavaScript
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);
});