bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

bzl-init.js (9757B)


      1 const fs = require("fs");
      2 const path = require("path");
      3 const crypto = require("crypto");
      4 const { spawn } = require("child_process");
      5 const readline = require("readline");
      6 
      7 const ROOT = path.join(__dirname, "..");
      8 const ENV_EXAMPLE = path.join(ROOT, ".env.example");
      9 const ENV_FILE = path.join(ROOT, ".env");
     10 
     11 function log(msg) {
     12   console.log(`[bzl-init] ${msg}`);
     13 }
     14 
     15 function warn(msg) {
     16   console.warn(`[bzl-init] WARN: ${msg}`);
     17 }
     18 
     19 function fail(msg) {
     20   console.error(`[bzl-init] ERROR: ${msg}`);
     21   process.exit(1);
     22 }
     23 
     24 function nodeMajor() {
     25   const m = String(process.versions.node || "").match(/^(\d+)\./);
     26   return m ? Number(m[1]) : 0;
     27 }
     28 
     29 function isWin() {
     30   return process.platform === "win32";
     31 }
     32 
     33 function randomToken(bytes = 24) {
     34   return crypto.randomBytes(bytes).toString("hex");
     35 }
     36 
     37 function question(rl, prompt, { defaultValue = "" } = {}) {
     38   const suffix = defaultValue ? ` (${defaultValue})` : "";
     39   return new Promise((resolve) => {
     40     rl.question(`${prompt}${suffix}: `, (answer) => {
     41       const v = String(answer || "").trim();
     42       resolve(v || defaultValue);
     43     });
     44   });
     45 }
     46 
     47 function questionYesNo(rl, prompt, { defaultYes = true } = {}) {
     48   const def = defaultYes ? "Y/n" : "y/N";
     49   return new Promise((resolve) => {
     50     rl.question(`${prompt} [${def}]: `, (answer) => {
     51       const v = String(answer || "").trim().toLowerCase();
     52       if (!v) return resolve(defaultYes);
     53       if (v === "y" || v === "yes") return resolve(true);
     54       if (v === "n" || v === "no") return resolve(false);
     55       resolve(defaultYes);
     56     });
     57   });
     58 }
     59 
     60 function parseEnvTemplate(template) {
     61   const lines = String(template || "").split(/\r?\n/);
     62   const entries = [];
     63   for (const line of lines) {
     64     const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
     65     if (m) {
     66       entries.push({ type: "kv", key: m[1], raw: line });
     67     } else {
     68       entries.push({ type: "raw", raw: line });
     69     }
     70   }
     71   return entries;
     72 }
     73 
     74 function renderEnv(entries, values) {
     75   return (
     76     entries
     77       .map((e) => {
     78         if (e.type !== "kv") return e.raw;
     79         const key = e.key;
     80         if (!(key in values)) return e.raw;
     81         const val = String(values[key] ?? "");
     82         return `${key}=${val}`;
     83       })
     84       .join("\n")
     85       .replace(/\n{3,}/g, "\n\n")
     86       .trimEnd() + "\n"
     87   );
     88 }
     89 
     90 function npmCmd() {
     91   return isWin() ? "npm.cmd" : "npm";
     92 }
     93 
     94 function spawnInherit(cmd, args, opts = {}) {
     95   return new Promise((resolve, reject) => {
     96     const child = spawn(cmd, args, { stdio: "inherit", cwd: ROOT, env: process.env, ...opts });
     97     child.on("error", reject);
     98     child.on("exit", (code) => resolve(code || 0));
     99   });
    100 }
    101 
    102 async function runNpm(args) {
    103   if (isWin()) {
    104     // On Windows, `.cmd` isn't directly executable via CreateProcess. Run through `cmd.exe /c`.
    105     return await spawnInherit("cmd", ["/d", "/s", "/c", npmCmd(), ...args]);
    106   }
    107   return await spawnInherit(npmCmd(), args);
    108 }
    109 
    110 function hasNodeModules() {
    111   try {
    112     return fs.existsSync(path.join(ROOT, "node_modules")) && fs.statSync(path.join(ROOT, "node_modules")).isDirectory();
    113   } catch {
    114     return false;
    115   }
    116 }
    117 
    118 function ensureDataDirs() {
    119   const dirs = [
    120     path.join(ROOT, "data"),
    121     path.join(ROOT, "data", "uploads"),
    122     path.join(ROOT, "data", "plugins"),
    123     path.join(ROOT, "data", "plugin-data")
    124   ];
    125   for (const d of dirs) {
    126     try {
    127       fs.mkdirSync(d, { recursive: true });
    128     } catch {
    129       // ignore
    130     }
    131   }
    132 }
    133 
    134 function usersFilePath() {
    135   return process.env.USERS_FILE || path.join(ROOT, "data", "users.json");
    136 }
    137 
    138 function normalizeUsername(username) {
    139   if (typeof username !== "string") return "";
    140   const cleaned = username.trim().toLowerCase();
    141   if (!cleaned) return "";
    142   if (cleaned.length > 32) return "";
    143   if (!/^[a-z0-9_][a-z0-9_.-]*$/.test(cleaned)) return "";
    144   return cleaned;
    145 }
    146 
    147 function hashPassword(password, saltHex) {
    148   const salt = Buffer.from(saltHex, "hex");
    149   const derived = crypto.scryptSync(String(password), salt, 64);
    150   return derived.toString("hex");
    151 }
    152 
    153 function loadUsers() {
    154   const file = usersFilePath();
    155   try {
    156     const raw = fs.readFileSync(file, "utf8");
    157     const data = JSON.parse(raw);
    158     const list = Array.isArray(data) ? data : Array.isArray(data?.users) ? data.users : [];
    159     return { version: 1, users: Array.isArray(list) ? list : [] };
    160   } catch (e) {
    161     if (e?.code === "ENOENT") return { version: 1, users: [] };
    162     throw e;
    163   }
    164 }
    165 
    166 function userExists(username) {
    167   const u = normalizeUsername(username);
    168   if (!u) return false;
    169   const data = loadUsers();
    170   return (data.users || []).some((x) => normalizeUsername(String(x?.username || "")) === u);
    171 }
    172 
    173 function saveUsers(data) {
    174   const file = usersFilePath();
    175   fs.mkdirSync(path.dirname(file), { recursive: true });
    176   fs.writeFileSync(file, JSON.stringify({ version: 1, users: data.users }, null, 2) + "\n", "utf8");
    177 }
    178 
    179 function addUser({ username, password }) {
    180   const u = normalizeUsername(username);
    181   if (!u) throw new Error("Invalid username. Allowed: a-z 0-9 _ . - (max 32 chars).");
    182   if (typeof password !== "string" || password.length < 4) throw new Error("Password too short (min 4).");
    183 
    184   const data = loadUsers();
    185   const exists = (data.users || []).some((x) => normalizeUsername(String(x?.username || "")) === u);
    186   if (exists) throw new Error(`User "${u}" already exists in ${usersFilePath()}`);
    187 
    188   const salt = crypto.randomBytes(16).toString("hex");
    189   const hash = hashPassword(password, salt);
    190   const isFirst = (data.users || []).length === 0;
    191   const role = isFirst ? "owner" : "member";
    192 
    193   data.users.push({
    194     username: u,
    195     salt,
    196     hash,
    197     role,
    198     customRoles: [],
    199     mutedUntil: 0,
    200     suspendedUntil: 0,
    201     banned: false,
    202     pronouns: "",
    203     bioHtml: "",
    204     themeSongUrl: "",
    205     links: [],
    206     starredPostIds: [],
    207     hiddenPostIds: [],
    208     createdAt: Date.now()
    209   });
    210 
    211   saveUsers(data);
    212   return { username: u, role };
    213 }
    214 
    215 async function main() {
    216   if (nodeMajor() < 18) {
    217     fail(`Node.js 18+ required. Detected: ${process.versions.node}`);
    218   }
    219   if (!fs.existsSync(ENV_EXAMPLE)) {
    220     fail("Missing .env.example (expected at repo root).");
    221   }
    222 
    223   log("Welcome! This wizard sets up local configuration for Bzl.");
    224   log("It can also install dependencies and create your first user.");
    225 
    226   const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
    227   try {
    228     const portRaw = await question(rl, "Port", { defaultValue: "3000" });
    229     const port = Math.max(1, Math.min(65535, Math.floor(Number(portRaw || 3000))));
    230 
    231     const host = await question(rl, "Host bind address", { defaultValue: "0.0.0.0" });
    232     const allowRegistrations = await questionYesNo(rl, "Enable account registration code?", { defaultYes: false });
    233 
    234     let registrationCode = "";
    235     if (allowRegistrations) {
    236       const code = await question(rl, "Registration code (leave blank to generate)", { defaultValue: "" });
    237       registrationCode = code || randomToken(18);
    238       log(`Registration code set to: ${registrationCode}`);
    239     }
    240 
    241     const createOwner = await questionYesNo(rl, "Create first user now (becomes owner if first)?", { defaultYes: true });
    242     let ownerUsername = "";
    243     if (createOwner) {
    244       ownerUsername = await question(rl, "Owner username (a-z 0-9 _ . -)", { defaultValue: "owner" });
    245     }
    246 
    247     const installDeps = !hasNodeModules()
    248       ? await questionYesNo(rl, "node_modules not found. Run npm install now?", { defaultYes: true })
    249       : await questionYesNo(rl, "Run npm install to ensure dependencies are up to date?", { defaultYes: false });
    250 
    251     // .env
    252     const template = fs.readFileSync(ENV_EXAMPLE, "utf8");
    253     const entries = parseEnvTemplate(template);
    254     const values = {
    255       PORT: String(port),
    256       HOST: host,
    257       REGISTRATION_CODE: registrationCode ? `"${registrationCode}"` : ""
    258     };
    259 
    260     if (fs.existsSync(ENV_FILE)) {
    261       const overwrite = await questionYesNo(rl, ".env already exists. Overwrite it?", { defaultYes: false });
    262       if (overwrite) {
    263         fs.writeFileSync(ENV_FILE, renderEnv(entries, values), "utf8");
    264         log("Wrote .env");
    265       } else {
    266         log("Keeping existing .env");
    267       }
    268     } else {
    269       fs.writeFileSync(ENV_FILE, renderEnv(entries, values), "utf8");
    270       log("Wrote .env");
    271     }
    272 
    273     ensureDataDirs();
    274 
    275     if (installDeps) {
    276       log("Installing dependencies...");
    277       const code = await runNpm(["install"]);
    278       if (code !== 0) fail("npm install failed.");
    279     }
    280 
    281     if (createOwner && ownerUsername) {
    282       if (userExists(ownerUsername)) {
    283         warn(`User "${normalizeUsername(ownerUsername)}" already exists. Skipping user creation.`);
    284       } else {
    285         log("Creating user (password input will be visible)...");
    286         let created = null;
    287         for (let attempt = 1; attempt <= 3; attempt += 1) {
    288           const pass1 = await question(rl, "Password", { defaultValue: "" });
    289           const pass2 = await question(rl, "Confirm password", { defaultValue: "" });
    290           if (pass1 !== pass2) {
    291             warn("Passwords did not match. Try again.");
    292             continue;
    293           }
    294           try {
    295             created = addUser({ username: ownerUsername, password: pass1 });
    296             break;
    297           } catch (e) {
    298             warn(e?.message || String(e));
    299           }
    300         }
    301         if (!created) fail("Failed to create user after 3 attempts.");
    302         log(`Created "${created.username}" (${created.role}).`);
    303       }
    304     }
    305 
    306     log("Setup complete.");
    307     log("Start the server with:");
    308     log("  npm start");
    309     log("Or supervised (auto-restart) with:");
    310     log("  npm run start:supervised");
    311     log(`Then open: http://localhost:${port}`);
    312   } finally {
    313     rl.close();
    314   }
    315 }
    316 
    317 main().catch((err) => {
    318   fail(err?.message || String(err));
    319 });