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.

472 lines (399 loc) 14.9 kB
#!/usr/bin/env node 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); });