bzl

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

bzl-init.js (9456B)


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