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