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