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.

509 lines (461 loc) 17.3 kB
#!/usr/bin/env node import fs from "fs"; import path from "path"; import crypto from "crypto"; import { execSync, execFileSync } from "child_process"; import { fileURLToPath } from "url"; import chalk from "chalk"; import { input, select, password } from "@inquirer/prompts"; import { sanitizeName } from "./lib/names.js"; import { checkMongoConfig, checkRoutesDir, checkModelsExist, listModels, listRoutes, getModelSchemaDetailed, } from "./lib/project.js"; import { showBanner, createSpinner, showSuccess, showNextSteps, showError, showHeader, } from "./lib/ui.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const skeletonPath = path.join(__dirname, "skeleton"); function copyRecursiveSync(src, dest) { const stats = fs.statSync(src); if (stats.isDirectory()) { if (!fs.existsSync(dest)) { fs.mkdirSync(dest); } fs.readdirSync(src).forEach((child) => { copyRecursiveSync(path.join(src, child), path.join(dest, child)); }); } else { fs.copyFileSync(src, dest); } } // Install every skeleton dependency at its LATEST version instead of the pinned // ranges shipped in skeleton/package.json — so a freshly-scaffolded project always // starts on current libraries. npm rewrites package.json to the resolved ^x.y.z // ranges. Throws if any @latest can't be resolved (offline, etc.); the caller // falls back to a plain `npm install` using the pinned versions. function installLatestDeps(targetPath) { const pkg = JSON.parse( fs.readFileSync(path.join(targetPath, "package.json"), "utf8") ); const runInstall = (names, dev) => { if (!names.length) return; const spec = names.map((n) => `${n}@latest`).join(" "); execSync(`npm install ${dev ? "-D " : ""}${spec}`, { stdio: "pipe", cwd: targetPath, }); }; runInstall(Object.keys(pkg.dependencies || {}), false); runInstall(Object.keys(pkg.devDependencies || {}), true); } function updatePackageName(destinationPath, projectName) { const packageJsonPath = path.join(destinationPath, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); packageJson.name = projectName; fs.writeFileSync( packageJsonPath, JSON.stringify(packageJson, null, 2), "utf8" ); } // Salted scrypt (slow KDF) so a leaked .passkey can't be brute-forced or // reversed via rainbow tables. Format: scrypt$<saltHex>$<hashHex>. function hashPasskey(passphrase) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(String(passphrase), salt, 64); return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`; } // A few obviously-weak passkeys to reject outright. const WEAK_PASSKEYS = new Set([ "password", "passw0rd", "12345678", "123456789", "1234567890", "qwertyui", "11111111", "00000000", "iloveyou", "letmein1", ]); // True if every consecutive char steps by +1 (ascending, e.g. "123456", // "abcdef") or by -1 (descending/reverse, e.g. "654321", "fedcba"). function isSequential(s) { if (s.length < 2) return false; let asc = true; let desc = true; for (let i = 1; i < s.length; i++) { const step = s.charCodeAt(i) - s.charCodeAt(i - 1); if (step !== 1) asc = false; if (step !== -1) desc = false; } return asc || desc; } // Returns an error string for a weak passkey, or null if it's acceptable. function passkeyWeakness(v) { const s = String(v || ""); if (s.length < 8) return "Passkey must be at least 8 characters"; if (WEAK_PASSKEYS.has(s.toLowerCase())) return "That passkey is too common — pick something less guessable"; if (/^(.)\1+$/.test(s)) return "Don't use a single repeated character (e.g. 11111111)"; if (isSequential(s)) return "Don't use a sequential or reversed run (e.g. 12345678 or 87654321)"; return null; } async function promptPasskey() { const passkey = await password({ message: "Set a dashboard passkey (min 8 chars, letters + numbers recommended):", mask: "*", validate: (v) => passkeyWeakness(v) || true, }); await password({ message: "Confirm passkey:", mask: "*", validate: (v) => { if (v !== passkey) return "Passkeys do not match"; return true; }, }); return passkey; } function savePasskey(projectPath, passkey) { const hash = hashPasskey(passkey); fs.writeFileSync(path.join(projectPath, ".4bnode", ".passkey"), hash, "utf8"); } async function createSkeletonApp(projectName) { showBanner(); const targetPath = path.resolve(projectName); const scaffoldSpinner = createSpinner("Scaffolding project structure..."); scaffoldSpinner.start(); if (!fs.existsSync(targetPath)) { fs.mkdirSync(targetPath, { recursive: true }); } copyRecursiveSync(skeletonPath, targetPath); updatePackageName(targetPath, projectName); // npm strips .gitignore from published packages — stored as _gitignore and renamed here. const gitignoreSrc = path.join(targetPath, "_gitignore"); const gitignoreDest = path.join(targetPath, ".gitignore"); if (fs.existsSync(gitignoreSrc)) { fs.renameSync(gitignoreSrc, gitignoreDest); } scaffoldSpinner.succeed(chalk.hex("#00f260").bold("Project scaffolded")); // Ask for dashboard passkey console.log(); const passkey = await promptPasskey(); savePasskey(targetPath, passkey); console.log(chalk.hex("#00f260").bold(" ✔ Dashboard passkey set")); const installSpinner = createSpinner("Installing latest dependencies..."); installSpinner.start(); try { installLatestDeps(targetPath); installSpinner.succeed(chalk.hex("#00f260").bold("Latest dependencies installed")); } catch { // Offline / registry hiccup: fall back to the pinned versions in package.json. execSync("npm install", { stdio: "pipe", cwd: targetPath }); installSpinner.succeed( chalk.hex("#00f260").bold("Dependencies installed (pinned versions — could not reach npm for latest)") ); } showSuccess([ `Project ${chalk.hex("#00c6ff").bold(projectName)} created at ${chalk.gray(targetPath)}`, `Express 5.x + Dotenv 17.x + CORS ready to go`, ]); showNextSteps([ `cd ${projectName}`, "npm run dev", "Open http://localhost:3000/_dev", ]); } function runCommand(script, args = []) { execFileSync(process.execPath, [path.join(__dirname, script), ...args], { stdio: "inherit", }); } // ── Add menu options ──────────────────────────────── const addOptions = [ { name: "API Endpoint Create a new route", value: "api", script: "add-api.js", }, { name: "MongoDB Setup database connection", value: "mongo", script: "add-mongo.js", }, { name: "CRUD Add create/read/update/delete routes", value: "crud", script: "add-crud.js", requiresRoutes: true, requiresModels: true, }, { name: "Login & Auth Add JWT authentication", value: "login", script: "add-login.js", requiresRoutes: true, requiresModels: true, }, { name: "API Docs OpenAPI 3 spec + Swagger UI at /docs", value: "docs", script: "add-docs.js", requiresRoutes: true, }, { name: "Security Helmet, rate-limit, RBAC, validation", value: "security", script: "add-security.js", }, { name: "Socket.IO Real-time with Socket.IO", value: "socket", script: "add-socket.js", }, { name: "WebSocket Native WebSocket support", value: "websocket", script: "add-websocket.js", }, { name: "SerialPort Serial port communication", value: "serialport", script: "add-serialport.js", }, { name: "Email Transactional email (Brevo, SendGrid, SMTP…)", value: "email", script: "add-email.js", }, { name: "Bonjour/mDNS Advertise this app on the local network", value: "bonjour", script: "add-bonjour.js", }, ]; // ── List ──────────────────────────────────────────── function showList(filter) { showHeader("list", "Project overview"); const c = chalk.hex("#00c6ff"); const g = chalk.hex("#22c55e"); const y = chalk.hex("#ffd200"); const d = chalk.gray; const showModels = !filter || filter === "models"; const showRoutes = !filter || filter === "routes"; if (showModels) { const models = listModels(); console.log(y.bold(" Models")); console.log(d(" ─────────────────────────────────")); if (models.length === 0) { console.log(d(" No models found.\n")); } else { models.forEach((file) => { const name = path.basename(file, ".js"); const fields = getModelSchemaDetailed(name); const fieldNames = fields.map((f) => { let label = f.name; if (f.required) label = g(label); if (f.unique) label = `${label}${y("*")}`; return label; }); console.log(` ${c(name)} ${d("→")} ${fieldNames.join(d(", "))}`); }); console.log(); console.log(d(` ${g("green")} = required ${y("*")} = unique`)); console.log(); } } if (showRoutes) { const routes = listRoutes(); console.log(y.bold(" Routes")); console.log(d(" ─────────────────────────────────")); if (routes.length === 0) { console.log(d(" No routes found.\n")); } else { routes.forEach((file) => { const name = path.basename(file, ".js"); console.log(` ${c(name)} ${d(`→ src/routes/${file}`)}`); }); console.log(); } } // Show config status if (!filter) { console.log(y.bold(" Config")); console.log(d(" ─────────────────────────────────")); console.log(` MongoDB ${checkMongoConfig() ? g("✔ configured") : d("✖ not configured")}`); console.log(` Routes dir ${checkRoutesDir() ? g("✔ exists") : d("✖ not found")}`); console.log(); } } // ── Help ──────────────────────────────────────────── function showHelp() { showBanner(); const c = chalk.hex("#00c6ff"); const d = chalk.gray; const h = chalk.hex("#ffd200").bold; console.log(h(" COMMANDS\n")); console.log(` ${c("init")} ${d("[name]")} Create a new Node.js project`); console.log(` ${c("add")} ${d("[feature]")} Add a feature (interactive menu)`); console.log(` ${c("schema")} ${d("[name]")} Manage schemas (create/view/edit)`); console.log(` ${c("list")} ${d("[filter]")} List models, routes & config status`); console.log(` ${c("ui")} Start dev server with visual dashboard`); console.log(` ${c("passkey")} Change dashboard passkey`); console.log(); console.log(h(" ADD FEATURES\n")); console.log(` ${c("add api")} ${d("[name]")} API endpoint`); console.log(` ${c("add mongo")} MongoDB connection`); console.log(` ${c("add crud")} CRUD routes (create/read/update/delete)`); console.log(` ${c("add login")} JWT authentication & login`); console.log(` ${c("add docs")} OpenAPI 3 spec + Swagger UI at /docs`); console.log(` ${c("add security")} Helmet, rate-limit, RBAC, validation`); console.log(` ${c("add socket")} Socket.IO integration`); console.log(` ${c("add websocket")} Native WebSocket support`); console.log(` ${c("add serialport")} Serial port communication`); console.log(` ${c("add email")} Transactional email (Brevo, SendGrid, SMTP)`); console.log(` ${c("add bonjour")} Advertise on the local network (Bonjour/mDNS)`); console.log(); console.log(h(" OPTIONS\n")); console.log(` ${c("-h, --help")} Show this help message`); console.log(); } // ── Main ──────────────────────────────────────────── const args = process.argv.slice(2); if (args.length < 1 || args.includes("-h") || args.includes("--help")) { showHelp(); process.exit(0); } const command = args[0]; const subCommand = args[1]; const extraParam = args[2]; async function promptName(label) { return input({ message: `Enter ${label}:`, validate: (v) => { try { sanitizeName(v); return true; } catch (e) { return e.message; } }, }); } async function handleAdd(featureOverride, paramOverride) { let feature = featureOverride || subCommand; const param = paramOverride || extraParam; if (!feature) { showHeader("add", "What would you like to add?"); const choice = await select({ message: "Select a feature:", choices: addOptions.map((o) => ({ name: o.name, value: o.value })), }); feature = choice; } // Map feature to option const option = addOptions.find((o) => o.value === feature); if (!option) { showError(`Unknown feature "${feature}". Run 4bnode add to see options.`); process.exit(1); } // Pre-flight checks with helpful guidance const missing = []; if (option.requiresMongo && !checkMongoConfig()) { missing.push(" 1. 4bnode add mongo Setup database first"); } if (option.requiresRoutes && !checkRoutesDir()) { missing.push(` ${missing.length + 1}. 4bnode add api <name> Create a route first`); } if (option.requiresModels && !checkModelsExist()) { missing.push(` ${missing.length + 1}. 4bnode schema Create a model first`); } if (missing.length > 0) { showError(`Cannot run "${feature}" yet. Run these first:\n\n${missing.join("\n")}`); process.exit(1); } // Handle features that need a name param if (feature === "api") { const name = param || await promptName("endpoint name"); runCommand(option.script, [sanitizeName(name)]); } else { runCommand(option.script); } } async function main() { if (command === "init") { const name = subCommand || await promptName("project name"); createSkeletonApp(sanitizeName(name)); } else if (command === "add") { await handleAdd(); } else if (command === "schema") { // Top-level schema manager: create / view / edit if (subCommand) { runCommand("add-mongo-schema.js", [subCommand]); } else { runCommand("add-mongo-schema.js"); } } else if (command === "list") { showList(subCommand); } else if (command === "passkey") { // Must be inside a 4bnode project const projectRoot = process.cwd(); const passkeyFile = path.join(projectRoot, ".4bnode", ".passkey"); if (!fs.existsSync(path.join(projectRoot, ".4bnode"))) { showError("Not inside a 4bnode project. Run 'npx 4bnode init <name>' first."); process.exit(1); } showBanner(); console.log(); const newPasskey = await promptPasskey(); savePasskey(projectRoot, newPasskey); console.log(chalk.hex("#00f260").bold("\n ✔ Dashboard passkey updated successfully\n")); } else if (command === "ui") { // Must be inside a 4bnode project (has package.json + .4bnode/) const projectRoot = process.cwd(); const hasPackageJson = fs.existsSync(path.join(projectRoot, "package.json")); const hasDevApi = fs.existsSync(path.join(projectRoot, ".4bnode", "dev-api.js")) || fs.existsSync(path.join(projectRoot, "dev-api.js")); if (!hasPackageJson || !hasDevApi) { showError("Not inside a 4bnode project. Run 'npx 4bnode init <name>' first, then cd into the project folder."); process.exit(1); } console.log(chalk.hex("#00f260").bold("\n Starting dev server...\n")); execSync("npm run dev", { stdio: "inherit", cwd: projectRoot, }); } // Legacy commands → route through handleAdd else { // Legacy: "add-mongo-schema" or "edit" → schema manager if (command === "add-mongo-schema" || command === "edit") { runCommand("add-mongo-schema.js", subCommand ? [command === "edit" ? "--edit" : "", subCommand].filter(Boolean) : (command === "edit" ? ["--edit"] : [])); return; } const legacyMap = { "add-api": "api", "add-mongo": "mongo", "add-crud": "crud", "add-login": "login", "add-docs": "docs", "add-security": "security", "add-mongo-insert": "crud", "add-mongo-update": "crud", "add-mongo-read": "crud", "add-mongo-delete": "crud", "add-socket": "socket", "add-websocket": "websocket", "add-serialport": "serialport", "add-email": "email", "add-bonjour": "bonjour", }; const mapped = legacyMap[command]; if (mapped) { await handleAdd(mapped, subCommand); } else { showError(`Unknown command "${command}". Run 4bnode --help for usage.`); process.exit(1); } } } main().catch((err) => { if (err.name === "ExitPromptError") process.exit(0); showError(err.message); process.exit(1); });