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.
472 lines (399 loc) • 14.9 kB
JavaScript
import fs from "fs";
import path from "path";
import chalk from "chalk";
import { input, select, checkbox } from "@inquirer/prompts";
import { toCamelCase, toPascalCase, sanitizeName, sanitizeFieldName } from "./lib/names.js";
import { getProjectRoot, listModels, getModelSchemaDetailed } from "./lib/project.js";
import {
showHeader,
showTaskDone,
showWarning,
showError,
showFileAction,
showInfo,
} from "./lib/ui.js";
// ── Presets ────────────────────────────────────────
const presets = {
user: [
{ name: "name", type: "String", required: true },
{ name: "email", type: "String", required: true, unique: true },
{ name: "password", type: "String", required: true },
{ name: "role", type: "String", default: "user" },
],
product: [
{ name: "name", type: "String", required: true },
{ name: "price", type: "Number", required: true },
{ name: "description", type: "String" },
{ name: "category", type: "String" },
{ name: "inStock", type: "Boolean", default: true },
],
post: [
{ name: "title", type: "String", required: true },
{ name: "content", type: "String", required: true },
{ name: "author", type: "String" },
{ name: "tags", type: "Array" },
{ name: "published", type: "Boolean", default: false },
],
};
const validTypes = ["String", "Number", "Boolean", "Date", "Array", "ObjectId", "Mixed", "Buffer"];
// ── Format field for display ───────────────────────
function formatField(f) {
const c = chalk.hex("#00c6ff");
const d = chalk.gray;
const g = chalk.hex("#22c55e");
const y = chalk.hex("#ffd200");
let line = ` ${c(f.name)} ${d(":")} ${g(f.type)}`;
const tags = [];
if (f.required) tags.push(y("required"));
if (f.unique) tags.push(y("unique"));
if (f.default !== undefined) tags.push(d(`default=${f.default}`));
if (tags.length > 0) line += ` ${tags.join(" ")}`;
return line;
}
// ── Generate schema file ───────────────────────────
function generateSchemaFile(schemaName, fields) {
const projectRoot = getProjectRoot();
const modelsDir = path.join(projectRoot, "src", "models");
if (!fs.existsSync(modelsDir)) {
fs.mkdirSync(modelsDir, { recursive: true });
}
const camelName = toCamelCase(schemaName);
const pascalName = toPascalCase(schemaName);
const schemaFilePath = path.join(modelsDir, `${camelName}.js`);
const fieldDefs = fields
.map((f) => {
let def = ` ${f.name}: { type: ${
f.type === "ObjectId" ? "mongoose.Schema.Types.ObjectId" : f.type
}`;
if (f.required) def += ", required: true";
if (f.unique) def += ", unique: true";
if (f.default !== undefined) def += `, default: ${JSON.stringify(f.default)}`;
def += " }";
return def;
})
.join(",\n");
const content = `import mongoose from 'mongoose';
const ${pascalName}Schema = new mongoose.Schema({
${fieldDefs}
}, { timestamps: true });
const ${pascalName} = mongoose.model('${pascalName}', ${pascalName}Schema);
export default ${pascalName};
`;
fs.writeFileSync(schemaFilePath, content, "utf8");
return schemaFilePath;
}
// ── View models ────────────────────────────────────
function viewModel(modelFile) {
const modelName = path.basename(modelFile, ".js");
const pascalName = toPascalCase(modelName);
const fields = getModelSchemaDetailed(modelName);
console.log();
console.log(chalk.hex("#ffd200").bold(` ${pascalName} Schema`));
console.log(chalk.gray(" ─────────────────────────────────"));
if (fields.length === 0) {
console.log(chalk.gray(" No fields found."));
} else {
fields.forEach((f) => console.log(formatField(f)));
}
console.log(chalk.gray(" ─────────────────────────────────"));
console.log(chalk.gray(` timestamps: true`));
console.log(chalk.gray(` file: src/models/${modelFile}`));
console.log();
}
// ── Edit model ─────────────────────────────────────
async function editModel(modelFile) {
const modelName = path.basename(modelFile, ".js");
const pascalName = toPascalCase(modelName);
const fields = getModelSchemaDetailed(modelName);
viewModel(modelFile);
let editing = true;
while (editing) {
const action = await select({
message: `Edit ${pascalName}:`,
choices: [
{ name: "Add field", value: "add" },
...(fields.length > 0
? [
{ name: "Remove field", value: "remove" },
{ name: "Toggle required", value: "required" },
{ name: "Toggle unique", value: "unique" },
]
: []),
{ name: "Done — save & exit", value: "done" },
],
});
if (action === "add") {
const typeChoices = validTypes.map((t) => ({ name: t, value: t }));
showInfo("Add fields one by one. Press enter with empty name to stop.");
const added = [];
while (true) {
const fieldNameRaw = await input({ message: "Field name (enter to stop):" });
if (!fieldNameRaw.trim()) break;
let fieldName;
try {
fieldName = sanitizeFieldName(fieldNameRaw);
} catch (e) {
showWarning(e.message);
continue;
}
if (fields.find((f) => f.name === fieldName)) {
showWarning(`Field "${fieldName}" already exists, skipping.`);
continue;
}
const fieldType = await select({
message: `Type for "${fieldName.trim()}":`,
choices: typeChoices,
});
const isRequired = await select({
message: `Required?`,
choices: [
{ name: "No", value: false },
{ name: "Yes", value: true },
],
});
const isUnique = await select({
message: `Unique?`,
choices: [
{ name: "No", value: false },
{ name: "Yes", value: true },
],
});
const nf = { name: fieldName.trim(), type: fieldType, required: isRequired, unique: isUnique };
fields.push(nf);
added.push(nf);
console.log(chalk.hex("#22c55e")(` + ${nf.name}: ${nf.type}${nf.required ? " (required)" : ""}${nf.unique ? " (unique)" : ""}`));
}
} else if (action === "remove") {
const toRemove = await checkbox({
message: "Select fields to remove:",
choices: fields.map((f) => ({ name: `${f.name}: ${f.type}`, value: f.name })),
validate: (v) => (v.length > 0 ? true : "Select at least one."),
});
for (const name of toRemove) {
const idx = fields.findIndex((f) => f.name === name);
if (idx !== -1) {
fields.splice(idx, 1);
console.log(chalk.hex("#ef4444")(` - ${name} removed`));
}
}
} else if (action === "required") {
const toggled = await checkbox({
message: "Toggle required (checked = required):",
choices: fields.map((f) => ({
name: f.name,
value: f.name,
checked: f.required || false,
})),
});
fields.forEach((f) => {
f.required = toggled.includes(f.name);
});
showInfo("Required fields updated.");
} else if (action === "unique") {
const toggled = await checkbox({
message: "Toggle unique (checked = unique):",
choices: fields.map((f) => ({
name: f.name,
value: f.name,
checked: f.unique || false,
})),
});
fields.forEach((f) => {
f.unique = toggled.includes(f.name);
});
showInfo("Unique fields updated.");
} else {
editing = false;
}
}
if (fields.length === 0) {
showWarning("No fields left. Schema not saved.");
return;
}
console.log();
generateSchemaFile(modelName, fields);
showFileAction("updated", `src/models/${modelFile}`);
const fieldList = fields.map(
(f) => `${f.name}: ${f.type}${f.required ? " (required)" : ""}${f.unique ? " (unique)" : ""}`
);
showTaskDone(`Schema "${modelName}" updated`, fieldList);
}
// ── Create new schema ──────────────────────────────
async function createSchema(schemaName) {
const presetKey = schemaName.toLowerCase();
const hasPreset = presets[presetKey];
let fields = [];
// Offer preset if available
if (hasPreset) {
const usePreset = await select({
message: `Use "${presetKey}" preset (${hasPreset.length} fields)?`,
choices: [
{ name: `Yes — ${hasPreset.map((f) => f.name).join(", ")}`, value: true },
{ name: "No — define fields manually", value: false },
],
});
if (usePreset) {
fields = hasPreset.map((f) => ({ ...f }));
fields.forEach((f) => console.log(chalk.hex("#22c55e")(
` + ${f.name}: ${f.type}${f.required ? " (required)" : ""}${f.unique ? " (unique)" : ""}`
)));
}
}
// Manual field entry — fully selection-based
if (fields.length === 0) {
showInfo("Add fields one by one. Press enter with empty name to finish.");
const typeChoices = validTypes.map((t) => ({ name: t, value: t }));
while (true) {
const fieldName = await input({ message: "Field name (enter to finish):" });
if (!fieldName.trim()) break;
if (fields.find((f) => f.name === fieldName.trim())) {
showWarning(`Field "${fieldName.trim()}" already exists, skipping.`);
continue;
}
const fieldType = await select({
message: `Type for "${fieldName.trim()}":`,
choices: typeChoices,
});
const isRequired = await select({
message: "Required?",
choices: [
{ name: "No", value: false },
{ name: "Yes", value: true },
],
});
const isUnique = await select({
message: "Unique?",
choices: [
{ name: "No", value: false },
{ name: "Yes", value: true },
],
});
const nf = { name: fieldName.trim(), type: fieldType, required: isRequired, unique: isUnique };
fields.push(nf);
console.log(chalk.hex("#22c55e")(
` + ${nf.name}: ${nf.type}${nf.required ? " (required)" : ""}${nf.unique ? " (unique)" : ""}`
));
}
}
if (fields.length === 0) {
showWarning("No fields defined. Schema not created.");
process.exit(0);
}
console.log();
generateSchemaFile(schemaName, fields);
showFileAction("created", `src/models/${toCamelCase(schemaName)}.js`);
const fieldList = fields.map(
(f) => `${f.name}: ${f.type}${f.required ? " (required)" : ""}${f.unique ? " (unique)" : ""}`
);
showTaskDone(`Schema "${schemaName}" created`, fieldList);
}
// ── Main ───────────────────────────────────────────
async function main() {
const rawArgs = process.argv.slice(2);
const isEditMode = rawArgs.includes("--edit");
const positionalArgs = rawArgs.filter((a) => !a.startsWith("--"));
const existingModels = listModels();
// ── Direct edit mode (from 4bnode edit) ──────────
if (isEditMode) {
if (existingModels.length === 0) {
showError("No models found. Create one first with: 4bnode schema");
process.exit(1);
}
showHeader("edit", "Edit a schema");
let modelFile;
if (positionalArgs[0]) {
// Try to match the given name to an existing model
const target = positionalArgs[0].toLowerCase();
modelFile = existingModels.find(
(f) => path.basename(f, ".js").toLowerCase() === target
);
if (!modelFile) {
showError(`Model "${positionalArgs[0]}" not found.`);
console.log();
showInfo(`Available models: ${existingModels.map((f) => path.basename(f, ".js")).join(", ")}`);
process.exit(1);
}
} else {
modelFile = await select({
message: "Select model to edit:",
choices: existingModels.map((f) => ({
name: path.basename(f, ".js"),
value: f,
})),
});
}
await editModel(modelFile);
return;
}
// ── Normal schema flow ───────────────────────────
let schemaName = positionalArgs[0];
// If models exist and no name given, show action menu
if (existingModels.length > 0 && !schemaName) {
showHeader("schema", "Manage schemas");
const action = await select({
message: "What do you want to do?",
choices: [
{ name: "Create new schema", value: "create" },
{ name: "View existing schema", value: "view" },
{ name: "Edit existing schema", value: "edit" },
],
});
if (action === "view") {
if (existingModels.length === 1) {
viewModel(existingModels[0]);
} else {
const modelFile = await select({
message: "Select model to view:",
choices: existingModels.map((f) => ({
name: path.basename(f, ".js"),
value: f,
})),
});
viewModel(modelFile);
}
return;
}
if (action === "edit") {
if (existingModels.length === 1) {
await editModel(existingModels[0]);
} else {
const modelFile = await select({
message: "Select model to edit:",
choices: existingModels.map((f) => ({
name: path.basename(f, ".js"),
value: f,
})),
});
await editModel(modelFile);
}
return;
}
// action === "create" — fall through to create flow
schemaName = await input({
message: "Enter schema name:",
validate: (v) => {
try { sanitizeName(v); return true; } catch (e) { return e.message; }
},
});
schemaName = sanitizeName(schemaName);
} else if (!schemaName) {
showHeader("schema", "Create a new schema");
schemaName = await input({
message: "Enter schema name:",
validate: (v) => {
try { sanitizeName(v); return true; } catch (e) { return e.message; }
},
});
schemaName = sanitizeName(schemaName);
} else {
schemaName = sanitizeName(schemaName);
showHeader("schema", `Schema: ${schemaName}`);
}
await createSchema(schemaName);
}
main().catch((err) => {
if (err.name === "ExitPromptError") process.exit(0);
showError(err.message);
process.exit(1);
});