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