bzl

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

commit f02a03e9889f596388c8cb071c280cc3ef1d1f44
parent 67c08615dc91a28b64ff2d333622a9cbfcd2b3bf
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Sat, 21 Feb 2026 13:01:17 -0700

multi instance docker installer and updater

Diffstat:
MCLEAN_INSTALL/README.md | 40+++++++++++++++++++++++++++++++++-------
ACLEAN_INSTALL/docs/INSTANCE_FLEET_AUTOMATION.md | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/docs/MULTI_INSTANCE_DOCKER.md | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/docs/SERVER_UPDATE.md | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/multi_instance/instances.example.json | 31+++++++++++++++++++++++++++++++
MCLEAN_INSTALL/package.json | 5+++++
ACLEAN_INSTALL/scripts/bzl-instance-create.js | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/scripts/bzl-instances-update.js | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/scripts/multi-instance-init.js | 389+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/scripts/multi-instance-update.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 2++
Adocs/INSTANCE_FLEET_AUTOMATION.md | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/MULTI_INSTANCE_DOCKER.md | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/SERVER_UPDATE.md | 41++++++++++++++++++++++++++++++++++++++++-
Amulti_instance/instances.example.json | 31+++++++++++++++++++++++++++++++
Mpackage.json | 5+++++
Ascripts/bzl-instance-create.js | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/bzl-instances-update.js | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/multi-instance-init.js | 389+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/multi-instance-update.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
20 files changed, 2476 insertions(+), 8 deletions(-)

diff --git a/CLEAN_INSTALL/README.md b/CLEAN_INSTALL/README.md @@ -1,3 +1,15 @@ +Bzl is a self-hostable, modular social platform designed for creators and communities who want full control of their space. Launch your own instance, extend it with plugins, and shape the experience to fit your world. + +🔗 Try the live dev instance: https://chat.bzl.one/ + +🚀 Learn more & quick start: https://bzl.one/ + +💬 Follow development in real time: https://chat.bzl.one/ REGISTRATION CODE IS "bzl" + +If you're experimenting, building plugins, or just curious how Bzl works in the wild — jump into the official instance and say hi. + + + # Bzl (MVP) Keyword-driven ephemeral posts ("pollination") that auto-expire after a TTL and broadcast live over WebSockets. Each post has a tiny chat room. @@ -20,24 +32,38 @@ Media uploads: - Full feature rundown (video script aid): `docs/FUNCTIONALITY_REFERENCE.md` - Plugins (MVP): `docs/PLUGINS.md` +- UI rack layout (draft): `docs/UI_RACK_LAYOUT.md` - Directory plugins (draft): `docs/DIRECTORY_SPEC.md` - Moderation spec: `docs/MODERATION_MVP_SPEC.md` - Self-hosted installer plan: `docs/SELF_HOSTED_INSTALLER_PLAN.md` +- Multi-instance docker stack: `docs/MULTI_INSTANCE_DOCKER.md` +- Instance fleet automation (discovery + bulk update): `docs/INSTANCE_FLEET_AUTOMATION.md` - Issue tracker guide: `docs/ISSUE_TRACKER.md` +- Updating a live server (git + docker): `docs/SERVER_UPDATE.md` ## Run locally +### Docker + +To run a local instance of the app, the following command can be used: +```bash +docker compose -f compose.yaml up --build --remove-orphans +``` + +Optionally the `-d` flag can be specified to let the app run as a background process. + +### Manual + 1. Install Node.js (recommended: Node 18+) 2. From this folder: - - Optional first-time wizard: `npm run init` (Windows PowerShell: `npm.cmd run init`) (or run `INSTALL.cmd` / `INSTALL.ps1` / `INSTALL.sh`) + - Optional first-time wizard: `npm run init` (Windows PowerShell: `npm.cmd run init`) - `npm install` (or `npm.cmd install` in Windows PowerShell if `npm` is blocked by execution policy) - `npm start` (or `npm.cmd start` in Windows PowerShell) - - Recommended: GUI launcher: `LAUNCHER.cmd` / `LAUNCHER.ps1` — includes Cloudflare setup + core auto-update - - Launcher UI URL: `http://127.0.0.1:8787` (it should auto-open; if not, paste this into your browser) - - Or use the launcher: `LAUNCH.cmd` (Windows) / `LAUNCH.ps1` / `LAUNCH.sh` - - Or launch with a quick Cloudflare tunnel: `LAUNCH_TUNNEL.cmd` / `LAUNCH_TUNNEL.ps1` / `LAUNCH_TUNNEL.sh` + - Recommended: GUI launcher: `LAUNCHER.cmd` / `LAUNCHER.ps1` (or `npm run launcher:ui`) — includes Cloudflare setup + core auto-update + - Or a launcher (opens the URL when ready): `npm run launch` + - Or launcher + Cloudflare quick tunnel: `npm run launch:tunnel` 3. Open: - - `http://localhost:3000` + - `http://localhost:3000` - or from another device on the same Wi-Fi/LAN: `http://<your-laptop-ip>:3000` If another device can't connect, allow inbound connections for Node on your firewall. @@ -169,7 +195,7 @@ Rate limit env vars: ## Open source -Bzl is released under the MIT License. See `LICENSE` in the repository root. +Bzl is released under the MIT License. See `LICENSE`. Community docs: - Contributing: `CONTRIBUTING.md` diff --git a/CLEAN_INSTALL/docs/INSTANCE_FLEET_AUTOMATION.md b/CLEAN_INSTALL/docs/INSTANCE_FLEET_AUTOMATION.md @@ -0,0 +1,60 @@ +# Instance Fleet Automation (Detect + Update + Create) + +Use this when you run multiple Bzl clones in separate folders on one server (for example `/Bzl`, `/srv/bzl-staging`, `/opt/community/Bzl`). + +## 1) Detect instances from root paths + +List discovered Bzl instances: + +```bash +npm run instances:scan -- --roots=/ --max-depth=4 +``` + +By default, detection looks for folders containing: +- `package.json` with `"name": "bzl"` +- `server.js` +- one compose file (`compose.yaml`, `compose.yml`, or `docker-compose.yml`) + +## 2) Update all discovered instances + +Bulk update: + +```bash +npm run instances:update -- --roots=/ --max-depth=4 +``` + +This performs, per instance: +1. `git fetch` +2. `git checkout main` +3. `git pull --ff-only origin main` +4. `docker compose -f <compose-file> up -d --build --remove-orphans` + +Useful flags: +- `--skip-git` +- `--skip-build` +- `--branch=main` +- `--remote=origin` +- `--dry-run` + +## 3) Create a new instance in a new folder + +Provision a fresh clone + `.env` + docker startup: + +```bash +npm run instance:create -- --path=/srv/bzl-new --port=3405 --registration-code='replace-me' --hostname=new.example.com +``` + +Default repo source is `https://github.com/bzlapp/Bzl.git` on `main`. + +Useful flags: +- `--repo=...` +- `--branch=...` +- `--no-start` +- `--dry-run` + +## Caddy + DNS reminders + +After creating/updating instances: +- ensure each hostname reverse proxies to the correct local port +- verify `curl http://127.0.0.1:<port>/api/health` +- confirm public hostname routes to the expected instance diff --git a/CLEAN_INSTALL/docs/MULTI_INSTANCE_DOCKER.md b/CLEAN_INSTALL/docs/MULTI_INSTANCE_DOCKER.md @@ -0,0 +1,100 @@ +# Multi-instance Docker Setup (Single Server) + +This workflow lets you run multiple Bzl instances on one host, each with its own: +- persistent data volume +- hostname +- registration code + +It also supports generating Cloudflare tunnel ingress config and optional automated `cloudflared tunnel route dns` calls. + +--- + +## 1) Create/edit config + +Run once: + +```bash +npm run multi:init +``` + +If `multi_instance/instances.json` does not exist, the script creates a template and exits. + +Edit: +- `multi_instance/instances.json` + +Key fields: +- `instances[].id` - stable identifier (used for service/env filenames) +- `instances[].hostname` - public hostname for that instance +- `instances[].hostPort` - unique localhost port each instance maps to +- `instances[].registrationCode` - per-instance registration code +- `cloudflared.*` - tunnel config + optional DNS route automation + +--- + +## 2) Generate compose + env + DNS checklist + +```bash +npm run multi:init +``` + +Generated files: +- `multi_instance/docker-compose.yml` +- `multi_instance/env/<id>.env` (one per instance) +- `multi_instance/DNS_CHECKLIST.md` +- Cloudflared config (path from `cloudflared.configPath`, default `~/.cloudflared/config.yml`) + +Optional DNS automation: + +```bash +npm run multi:init -- --route-dns +``` + +This runs `cloudflared tunnel route dns ...` for each configured hostname. + +--- + +## 3) Start all instances + +```bash +docker compose -f multi_instance/docker-compose.yml up -d --build --remove-orphans +``` + +Then verify local health endpoints from the host: + +```bash +curl -fsS http://127.0.0.1:<hostPort>/api/health +``` + +--- + +## 4) Update all instances to latest source-of-truth + +Use the updater script from the repo root: + +```bash +npm run multi:update +``` + +Default behavior: +1. `git fetch origin` +2. `git checkout main` +3. `git pull --ff-only origin main` +4. regenerate multi-instance config outputs +5. `docker compose -f multi_instance/docker-compose.yml up -d --build --remove-orphans` + +Options: +- `--skip-git` (skip fetch/pull) +- `--skip-build` (restart/update without image rebuild) +- `--route-dns` (also rerun DNS route commands during regeneration) +- `--dry-run` (show the docker command without executing it) +- `--config=/path/to/instances.json` + +--- + +## DNS reminders + +After generating or changing hostnames: +- ensure each hostname is routed to the intended tunnel (`cloudflared tunnel route dns ...`) +- confirm Cloudflare SSL/TLS mode is compatible (Full / Full strict recommended) +- restart tunnel process/service if ingress config changed +- verify each hostname reaches the expected instance (`/api/health`) diff --git a/CLEAN_INSTALL/docs/SERVER_UPDATE.md b/CLEAN_INSTALL/docs/SERVER_UPDATE.md @@ -0,0 +1,161 @@ +# Updating a Live Bzl Server (Git + Docker) + +This doc is for a typical droplet setup where you: +- have the `bzlapp/Bzl` repo checked out on the server (example: `~/Bzl`) +- run Bzl via Docker / Docker Compose using that checkout (builds the image from the local Dockerfile) + +If you’re not sure which setup you have, run `docker ps` and see whether Bzl is running as a container. + +--- + +## The “golden” update flow (Compose + local build) + +From the droplet host (not inside the container): + +```bash +cd ~/Bzl + +# 1) make sure you're on the right branch and clean +git status +git checkout main + +# 2) pull latest code +git fetch origin +git pull --ff-only origin main + +# 3) rebuild + recreate container(s) +docker compose up -d --build +``` + +That’s it. + +### Why this order matters +- If you rebuild before pulling, you rebuild the *old* code. +- A `git pull` does **not** update a running container unless you rebuild/recreate it (or you’re bind-mounting the code into the container). + +--- + +## Restart only (no code update) + +```bash +docker compose restart +``` + +Or, if you run a single container named `bzl`: + +```bash +docker restart bzl +``` + +--- + +## Multi-instance stack update (single host, many Bzl instances) + +If you run multiple Bzl instances from `multi_instance/docker-compose.yml`, use: + +```bash +npm run multi:update +``` + +This script pulls latest `main`, regenerates per-instance env/compose from `multi_instance/instances.json`, and runs: + +```bash +docker compose -f multi_instance/docker-compose.yml up -d --build --remove-orphans +``` + +See: `docs/MULTI_INSTANCE_DOCKER.md` + +--- + +## Auto-detect + update all Bzl clones across server folders + +If your instances live in separate folders (for example one in `/Bzl` and another elsewhere), use fleet automation: + +```bash +# detect instances first +npm run instances:scan -- --roots=/ --max-depth=4 + +# then update all detected instances +npm run instances:update -- --roots=/ --max-depth=4 +``` + +To create a brand-new instance folder: + +```bash +npm run instance:create -- --path=/srv/bzl-new --port=3405 --registration-code='replace-me' --hostname=new.example.com +``` + +See: `docs/INSTANCE_FLEET_AUTOMATION.md` + +--- + +## Common git mistake (your screenshot) + +If you run: + +```bash +git pull main +``` + +Git interprets `main` as a *remote name* (not a branch), and you’ll get: +`fatal: 'main' does not appear to be a git repository` + +Use this instead: + +```bash +git pull --ff-only origin main +``` + +Quick sanity checks: + +```bash +git remote -v +git branch --show-current +git log -1 --oneline +``` + +--- + +## If you have local changes on the droplet + +If `git status` shows modified files, decide whether you want to keep them. + +To throw away local changes and match GitHub exactly: + +```bash +git fetch origin +git reset --hard origin/main +``` + +Then rebuild: + +```bash +docker compose up -d --build +``` + +--- + +## Confirm you’re actually updated + +After updating/restarting: + +```bash +docker ps +docker logs --tail 80 bzl +``` + +In the UI, the Moderation → Server panel should reflect the latest version string. + +If the UI still looks unchanged, hard-refresh your browser: +- Chrome/Edge: `Ctrl+Shift+R` + +--- + +## Next step (recommended): in-app core updates + +For “Clean Install” desktop setups, the Launcher UI can already do opt-in updates via GitHub Releases. + +For live servers, we can add a similar admin-only “Update core” flow (pull + rebuild + restart) later, but it needs extra safety: +- confirmation prompts + backup +- clear logs of what changed +- no “surprise” updates diff --git a/CLEAN_INSTALL/multi_instance/instances.example.json b/CLEAN_INSTALL/multi_instance/instances.example.json @@ -0,0 +1,31 @@ +{ + "projectName": "bzl-multi", + "networkName": "bzl_multi_net", + "cloudflared": { + "enabled": true, + "tunnel": "bzl", + "routeDns": false, + "overwriteDns": true, + "configPath": "~/.cloudflared/config.yml", + "credentialsFile": "~/.cloudflared/TUNNEL-UUID.json" + }, + "instances": [ + { + "id": "official", + "hostname": "chat.example.com", + "hostPort": 3301, + "registrationCode": "replace-with-random-code", + "env": { + "SESSION_TTL_MS": "2592000000", + "DEFAULT_TTL_MS": "3600000" + } + }, + { + "id": "staging", + "hostname": "staging.example.com", + "hostPort": 3302, + "registrationCode": "replace-with-another-code", + "env": {} + } + ] +} diff --git a/CLEAN_INSTALL/package.json b/CLEAN_INSTALL/package.json @@ -18,6 +18,11 @@ "restore-data": "node scripts/restore-data.js", "build:plugin:directory-server": "node scripts/build-directory-server-plugin.js", "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js", + "multi:init": "node scripts/multi-instance-init.js", + "multi:update": "node scripts/multi-instance-update.js", + "instances:scan": "node scripts/bzl-instances-update.js", + "instances:update": "node scripts/bzl-instances-update.js --update", + "instance:create": "node scripts/bzl-instance-create.js", "stream:init": "node scripts/stream-pack-init.js", "stream:up": "node scripts/stream-pack-up.js", "stream:down": "node scripts/stream-pack-down.js", diff --git a/CLEAN_INSTALL/scripts/bzl-instance-create.js b/CLEAN_INSTALL/scripts/bzl-instance-create.js @@ -0,0 +1,153 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { spawnSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); + +function log(msg) { + console.log(`[instance-create] ${msg}`); +} + +function fail(msg) { + console.error(`[instance-create] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const out = {}; + for (const raw of process.argv.slice(2)) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1); + else out[a.slice(2)] = "1"; + } + return out; +} + +function expandHome(value) { + const v = String(value || "").trim(); + if (!v.startsWith("~")) return v; + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (!home) return v; + if (v === "~") return home; + if (v.startsWith("~/") || v.startsWith("~\\")) return path.join(home, v.slice(2)); + return v; +} + +function displayPath(filePath) { + const abs = path.resolve(filePath); + const rel = path.relative(process.cwd(), abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/"); + return abs.replace(/\\/g, "/"); +} + +function run(cmd, args, cwd, { dryRun = false } = {}) { + const printable = `${cmd} ${args.join(" ")}`; + if (dryRun) { + log(`[dry-run] ${printable} (cwd=${displayPath(cwd)})`); + return 0; + } + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) fail(`${printable} failed to start: ${result.error?.message || result.error}`); + const code = result.status || 0; + if (code !== 0) fail(`${printable} failed with exit code ${code}`); + return code; +} + +function randomCode(bytes = 18) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function parseEnv(body) { + const map = new Map(); + const lines = String(body || "").split(/\r?\n/); + for (const line of lines) { + const s = String(line || ""); + const m = s.match(/^([A-Z0-9_]+)=(.*)$/); + if (!m) continue; + map.set(m[1], m[2]); + } + return map; +} + +function setEnvKey(body, key, value) { + const lines = String(body || "").split(/\r?\n/); + let replaced = false; + const out = lines.map((line) => { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!m) return line; + if (m[1] !== key) return line; + replaced = true; + return `${key}=${value}`; + }); + if (!replaced) out.push(`${key}=${value}`); + return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; +} + +function main() { + const args = parseArgs(); + const repo = String(args.repo || "https://github.com/bzlapp/Bzl.git").trim(); + const branch = String(args.branch || "main").trim(); + const targetPathRaw = String(args.path || "").trim(); + const hostname = String(args.hostname || "").trim().toLowerCase(); + const dryRun = args["dry-run"] === "1"; + const noStart = args["no-start"] === "1"; + + if (!targetPathRaw) fail("Missing --path=/absolute/or/relative/path"); + const targetPath = path.resolve(expandHome(targetPathRaw)); + + const port = Number(args.port || 0); + if (!Number.isInteger(port) || port < 1024 || port > 65535) fail("--port must be an integer between 1024 and 65535."); + + const registrationCode = String(args["registration-code"] || randomCode()).trim(); + if (!registrationCode) fail("registration code resolved empty"); + + const exists = fs.existsSync(targetPath); + if (exists) { + const list = fs.readdirSync(targetPath).filter((x) => x !== "." && x !== ".."); + if (list.length) fail(`Target path is not empty: ${displayPath(targetPath)}`); + } else if (!dryRun) { + fs.mkdirSync(targetPath, { recursive: true }); + } + + run("git", ["clone", "--branch", branch, repo, targetPath], ROOT, { dryRun }); + + const envExample = path.join(targetPath, ".env.example"); + const envFile = path.join(targetPath, ".env"); + + if (dryRun) { + log(`[dry-run] would create/update ${displayPath(envFile)} with PORT=${port}, HOST=0.0.0.0, REGISTRATION_CODE=...`); + } else { + let base = ""; + if (fs.existsSync(envFile)) base = fs.readFileSync(envFile, "utf8"); + else if (fs.existsSync(envExample)) base = fs.readFileSync(envExample, "utf8"); + else base = ""; + + let next = setEnvKey(base, "PORT", String(port)); + next = setEnvKey(next, "HOST", "0.0.0.0"); + next = setEnvKey(next, "REGISTRATION_CODE", registrationCode); + fs.writeFileSync(envFile, next, "utf8"); + log(`Wrote ${displayPath(envFile)}`); + } + + if (!noStart) { + run("docker", ["compose", "-f", "compose.yaml", "up", "-d", "--build", "--remove-orphans"], targetPath, { dryRun }); + } + + console.log(""); + console.log("Instance created."); + console.log(`- Path: ${displayPath(targetPath)}`); + console.log(`- Port: ${port}`); + console.log(`- Registration code: ${registrationCode}`); + if (hostname) { + console.log(""); + console.log("Caddy reminder:"); + console.log(`${hostname} {`); + console.log(` reverse_proxy 127.0.0.1:${port}`); + console.log("}"); + } +} + +main(); diff --git a/CLEAN_INSTALL/scripts/bzl-instances-update.js b/CLEAN_INSTALL/scripts/bzl-instances-update.js @@ -0,0 +1,281 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const BZL_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml"]; +const DEFAULT_MAX_DEPTH = 4; + +function log(msg) { + console.log(`[instances] ${msg}`); +} + +function warn(msg) { + console.warn(`[instances] WARN: ${msg}`); +} + +function fail(msg) { + console.error(`[instances] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const out = {}; + for (const raw of process.argv.slice(2)) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1); + else out[a.slice(2)] = "1"; + } + return out; +} + +function expandHome(value) { + const v = String(value || "").trim(); + if (!v.startsWith("~")) return v; + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (!home) return v; + if (v === "~") return home; + if (v.startsWith("~/") || v.startsWith("~\\")) return path.join(home, v.slice(2)); + return v; +} + +function displayPath(filePath) { + const abs = path.resolve(filePath); + const rel = path.relative(process.cwd(), abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/"); + return abs.replace(/\\/g, "/"); +} + +function splitList(raw, fallback) { + const text = String(raw || "").trim(); + if (!text) return fallback; + return text + .split(",") + .map((x) => expandHome(String(x || "").trim())) + .filter(Boolean); +} + +function shouldSkipDir(name) { + const n = String(name || "").trim().toLowerCase(); + if (!n) return true; + return new Set([ + ".git", + "node_modules", + "data", + "dist", + "clean_install", + "multi_instance", + "tmp", + "temp", + "proc", + "sys", + "dev", + "run", + "usr", + "lib", + "lib64", + "bin", + "sbin", + "etc", + "boot", + "mnt", + "media", + "snap", + "lost+found", + "__pycache__" + ]).has(n); +} + +function readPackageName(dir) { + try { + const pkgPath = path.join(dir, "package.json"); + const raw = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + return String(raw?.name || "").trim().toLowerCase(); + } catch { + return ""; + } +} + +function findComposeFile(dir) { + for (const name of BZL_COMPOSE_FILES) { + const full = path.join(dir, name); + if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; + } + return ""; +} + +function detectBzlInstance(dir) { + try { + const hasServer = fs.existsSync(path.join(dir, "server.js")); + const hasPkg = fs.existsSync(path.join(dir, "package.json")); + if (!hasServer || !hasPkg) return null; + const pkgName = readPackageName(dir); + if (pkgName !== "bzl") return null; + const composeFile = findComposeFile(dir); + if (!composeFile) return null; + return { rootDir: dir, composeFile }; + } catch { + return null; + } +} + +function discoverFromRoot(rootDir, maxDepth) { + const found = []; + const queue = [{ dir: rootDir, depth: 0 }]; + const seen = new Set(); + while (queue.length) { + const current = queue.shift(); + const abs = path.resolve(current.dir); + if (seen.has(abs)) continue; + seen.add(abs); + + const instance = detectBzlInstance(abs); + if (instance) { + found.push(instance); + continue; + } + if (current.depth >= maxDepth) continue; + + let entries = []; + try { + entries = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.isSymbolicLink()) continue; + if (shouldSkipDir(entry.name)) continue; + queue.push({ dir: path.join(abs, entry.name), depth: current.depth + 1 }); + } + } + return found; +} + +function run(cmd, args, cwd, { dryRun = false, allowFail = false } = {}) { + const printable = `${cmd} ${args.join(" ")}`; + if (dryRun) { + log(`[dry-run] ${printable} (cwd=${displayPath(cwd)})`); + return 0; + } + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) { + if (allowFail) { + warn(`${printable} failed to start: ${result.error?.message || result.error}`); + return 1; + } + fail(`${printable} failed to start: ${result.error?.message || result.error}`); + } + const code = result.status || 0; + if (code !== 0 && !allowFail) fail(`${printable} failed with exit code ${code}`); + return code; +} + +function defaultRoots() { + const list = []; + const cwd = process.cwd(); + list.push(cwd); + if (process.platform !== "win32") { + list.push("/", "/root", "/home", "/opt", "/srv", "/var/www"); + } else { + const driveRoot = path.parse(cwd).root; + if (driveRoot) list.push(driveRoot); + } + const uniq = []; + for (const item of list) { + const abs = path.resolve(expandHome(item)); + if (!fs.existsSync(abs)) continue; + if (uniq.includes(abs)) continue; + uniq.push(abs); + } + return uniq; +} + +function printDiscovered(instances) { + if (!instances.length) { + log("No Bzl instances discovered."); + return; + } + log(`Discovered ${instances.length} instance(s):`); + for (const [i, inst] of instances.entries()) { + console.log(` ${i + 1}. ${displayPath(inst.rootDir)} (compose: ${path.basename(inst.composeFile)})`); + } +} + +function main() { + const args = parseArgs(); + const update = args.update === "1"; + const skipGit = args["skip-git"] === "1"; + const skipBuild = args["skip-build"] === "1"; + const dryRun = args["dry-run"] === "1"; + const remote = String(args.remote || "origin").trim(); + const branch = String(args.branch || "main").trim(); + const maxDepth = Number.isInteger(Number(args["max-depth"])) ? Math.max(1, Number(args["max-depth"])) : DEFAULT_MAX_DEPTH; + const roots = splitList(args.roots, defaultRoots()) + .map((r) => path.resolve(r)) + .filter((r, idx, arr) => fs.existsSync(r) && arr.indexOf(r) === idx); + + if (!roots.length) fail("No valid search roots. Use --roots=/path1,/path2"); + + log(`Scanning roots: ${roots.map((r) => displayPath(r)).join(", ")} (max-depth=${maxDepth})`); + + const foundRaw = []; + for (const root of roots) foundRaw.push(...discoverFromRoot(root, maxDepth)); + + const byRoot = new Map(); + for (const inst of foundRaw) { + const key = path.resolve(inst.rootDir); + if (!byRoot.has(key)) byRoot.set(key, inst); + } + const instances = Array.from(byRoot.values()).sort((a, b) => a.rootDir.localeCompare(b.rootDir)); + printDiscovered(instances); + + if (!update) { + console.log(""); + console.log("Tip: run with --update to pull/rebuild all detected instances."); + return; + } + if (!instances.length) return; + + const summary = []; + for (const inst of instances) { + const rel = displayPath(inst.rootDir); + log(`Updating ${rel}`); + let ok = true; + let notes = []; + try { + if (!skipGit) { + const inGit = run("git", ["-C", inst.rootDir, "rev-parse", "--is-inside-work-tree"], inst.rootDir, { + dryRun, + allowFail: true + }); + if (inGit !== 0) { + notes.push("not a git checkout (skipped git pull)"); + } else { + run("git", ["-C", inst.rootDir, "fetch", remote], inst.rootDir, { dryRun }); + run("git", ["-C", inst.rootDir, "checkout", branch], inst.rootDir, { dryRun }); + run("git", ["-C", inst.rootDir, "pull", "--ff-only", remote, branch], inst.rootDir, { dryRun }); + } + } + const composeArgs = ["compose", "-f", inst.composeFile.replace(/\\/g, "/"), "up", "-d", "--remove-orphans"]; + if (!skipBuild) composeArgs.push("--build"); + run("docker", composeArgs, inst.rootDir, { dryRun }); + summary.push({ instance: rel, ok: true, notes: notes.join("; ") }); + } catch (e) { + ok = false; + notes.push(e?.message || String(e)); + summary.push({ instance: rel, ok, notes: notes.join("; ") }); + warn(`Failed update for ${rel}: ${notes.join("; ")}`); + } + } + + console.log(""); + console.log("Update summary:"); + for (const row of summary) { + const status = row.ok ? "OK" : "FAIL"; + console.log(`- [${status}] ${row.instance}${row.notes ? ` - ${row.notes}` : ""}`); + } +} + +main(); diff --git a/CLEAN_INSTALL/scripts/multi-instance-init.js b/CLEAN_INSTALL/scripts/multi-instance-init.js @@ -0,0 +1,389 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); +const MULTI_DIR = path.join(ROOT, "multi_instance"); +const DEFAULT_CONFIG_PATH = path.join(MULTI_DIR, "instances.json"); +const DEFAULT_EXAMPLE_PATH = path.join(MULTI_DIR, "instances.example.json"); + +function log(msg) { + console.log(`[multi-init] ${msg}`); +} + +function warn(msg) { + console.warn(`[multi-init] WARN: ${msg}`); +} + +function fail(msg) { + console.error(`[multi-init] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (const raw of args) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) { + out[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + out[a.slice(2)] = "1"; + } + return out; +} + +function expandHome(p) { + const value = String(p || "").trim(); + if (!value.startsWith("~")) return value; + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (!home) return value; + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(home, value.slice(2)); + return value; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (e) { + fail(`Failed to read JSON: ${filePath} (${e?.message || e})`); + } +} + +function writeText(filePath, body) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, body, "utf8"); +} + +function displayPath(filePath) { + const abs = path.resolve(filePath); + const rel = path.relative(process.cwd(), abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/"); + return abs.replace(/\\/g, "/"); +} + +function envValue(v) { + const raw = String(v ?? ""); + if (!raw) return ""; + if (/^[A-Za-z0-9._:@/+,-]+$/.test(raw)) return raw; + return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function looksLikeHost(h) { + const host = String(h || "").trim().toLowerCase(); + if (!host) return false; + if (host.includes("://")) return false; + if (host.length > 253) return false; + if (!/^[a-z0-9.-]+$/.test(host)) return false; + if (host.startsWith(".") || host.endsWith(".") || host.includes("..")) return false; + return host.includes("."); +} + +function normalizeId(s) { + return String(s || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 32); +} + +function templateConfig() { + return { + projectName: "bzl-multi", + networkName: "bzl_multi_net", + cloudflared: { + enabled: true, + tunnel: "bzl", + routeDns: false, + overwriteDns: true, + configPath: "~/.cloudflared/config.yml", + credentialsFile: "~/.cloudflared/TUNNEL-UUID.json" + }, + instances: [ + { + id: "official", + hostname: "chat.example.com", + hostPort: 3301, + registrationCode: "replace-with-random-code", + env: { + SESSION_TTL_MS: "2592000000", + DEFAULT_TTL_MS: "3600000" + } + }, + { + id: "staging", + hostname: "staging.example.com", + hostPort: 3302, + registrationCode: "replace-with-another-code", + env: {} + } + ] + }; +} + +function ensureConfigFile(configPath, examplePath) { + const template = `${JSON.stringify(templateConfig(), null, 2)}\n`; + if (!fs.existsSync(examplePath)) { + writeText(examplePath, template); + log(`Wrote example config: ${displayPath(examplePath)}`); + } + if (!fs.existsSync(configPath)) { + writeText(configPath, template); + fail( + `Created ${displayPath(configPath)}. Edit hostnames/ports/registration codes, then run this command again.` + ); + } +} + +function validateConfig(raw) { + const projectName = normalizeId(raw?.projectName || "bzl-multi") || "bzl-multi"; + const networkName = normalizeId(raw?.networkName || "bzl_multi_net").replace(/-/g, "_") || "bzl_multi_net"; + const cf = raw?.cloudflared && typeof raw.cloudflared === "object" ? raw.cloudflared : {}; + const cloudflared = { + enabled: Boolean(cf.enabled), + tunnel: String(cf.tunnel || "bzl").trim(), + routeDns: Boolean(cf.routeDns), + overwriteDns: cf.overwriteDns == null ? true : Boolean(cf.overwriteDns), + configPath: String(cf.configPath || "~/.cloudflared/config.yml").trim(), + credentialsFile: String(cf.credentialsFile || "~/.cloudflared/TUNNEL-UUID.json").trim() + }; + + const instancesRaw = Array.isArray(raw?.instances) ? raw.instances : []; + if (!instancesRaw.length) fail("Config must include at least one instance."); + + const ids = new Set(); + const hostnames = new Set(); + const ports = new Set(); + const instances = []; + + for (const [index, row] of instancesRaw.entries()) { + const id = normalizeId(row?.id); + if (!id) fail(`instances[${index}].id is required (letters/numbers/dashes).`); + if (ids.has(id)) fail(`Duplicate instance id: ${id}`); + ids.add(id); + + const hostname = String(row?.hostname || "").trim().toLowerCase(); + if (!looksLikeHost(hostname)) fail(`instances[${index}] (${id}) has invalid hostname: ${hostname}`); + if (hostnames.has(hostname)) fail(`Duplicate hostname: ${hostname}`); + hostnames.add(hostname); + + const hostPort = Number(row?.hostPort || 0); + if (!Number.isInteger(hostPort) || hostPort < 1024 || hostPort > 65535) { + fail(`instances[${index}] (${id}) hostPort must be an integer between 1024 and 65535.`); + } + if (ports.has(hostPort)) fail(`Duplicate hostPort: ${hostPort}`); + ports.add(hostPort); + + const envRaw = row?.env && typeof row.env === "object" ? row.env : {}; + const env = {}; + for (const [key, value] of Object.entries(envRaw)) { + const k = String(key || "").trim().toUpperCase(); + if (!k || !/^[A-Z0-9_]+$/.test(k)) continue; + env[k] = String(value ?? ""); + } + + instances.push({ + id, + serviceName: `bzl_${id.replace(/-/g, "_")}`, + volumeName: `${projectName}_${id.replace(/-/g, "_")}_data`, + hostname, + hostPort, + registrationCode: String(row?.registrationCode || "").trim(), + env + }); + } + + if (cloudflared.enabled && !cloudflared.tunnel) fail("cloudflared.tunnel is required when cloudflared.enabled=true."); + if (cloudflared.enabled && !cloudflared.configPath) fail("cloudflared.configPath is required when cloudflared.enabled=true."); + if (cloudflared.enabled && !cloudflared.credentialsFile) { + warn("cloudflared.credentialsFile is empty; update it before starting cloudflared."); + } + + return { projectName, networkName, cloudflared, instances }; +} + +function renderEnvFile(instance) { + const lines = [ + `# Generated by scripts/multi-instance-init.js`, + `# Instance: ${instance.id} (${instance.hostname})`, + "", + "PORT=3000", + "HOST=0.0.0.0", + `REGISTRATION_CODE=${envValue(instance.registrationCode)}` + ]; + const keys = Object.keys(instance.env).sort(); + if (keys.length) { + lines.push("", "# Extra per-instance env overrides"); + for (const key of keys) lines.push(`${key}=${envValue(instance.env[key])}`); + } + lines.push(""); + return lines.join("\n"); +} + +function renderCompose(cfg) { + const lines = ["services:"]; + for (const inst of cfg.instances) { + lines.push( + ` ${inst.serviceName}:`, + ` build:`, + ` context: ..`, + ` dockerfile: Dockerfile`, + ` image: bzl:latest`, + ` container_name: ${inst.serviceName}`, + ` restart: unless-stopped`, + ` env_file:`, + ` - ./env/${inst.id}.env`, + ` ports:`, + ` - "127.0.0.1:${inst.hostPort}:3000"`, + ` volumes:`, + ` - ${inst.volumeName}:/app/data`, + ` networks:`, + ` - ${cfg.networkName}` + ); + } + lines.push("", "volumes:"); + for (const inst of cfg.instances) lines.push(` ${inst.volumeName}:`); + lines.push("", "networks:", ` ${cfg.networkName}:`, ` name: ${cfg.networkName}`, ""); + return lines.join("\n"); +} + +function renderCloudflaredConfig(cfg) { + const lines = [ + `# Generated by scripts/multi-instance-init.js`, + `tunnel: ${cfg.cloudflared.tunnel}`, + `credentials-file: ${expandHome(cfg.cloudflared.credentialsFile)}`, + `ingress:` + ]; + for (const inst of cfg.instances) { + lines.push(` - hostname: ${inst.hostname}`, ` service: http://127.0.0.1:${inst.hostPort}`); + } + lines.push(` - service: http_status:404`, ""); + return lines.join("\n"); +} + +function renderChecklist(cfg, composePath) { + const lines = [ + "# Multi-instance DNS / deployment checklist", + "", + `Compose file: ${displayPath(composePath)}`, + "", + "## 1) Bring up all instances", + "```bash", + `docker compose -f ${displayPath(composePath)} up -d --build --remove-orphans`, + "```", + "", + "## 2) Validate each local instance responds", + "```bash" + ]; + for (const inst of cfg.instances) lines.push(`curl -fsS http://127.0.0.1:${inst.hostPort}/api/health`); + lines.push("```", ""); + if (cfg.cloudflared.enabled) { + lines.push( + "## 3) Cloudflare DNS routing", + "", + "Run these once per hostname (safe to re-run with overwrite):", + "```bash" + ); + for (const inst of cfg.instances) { + const overwrite = cfg.cloudflared.overwriteDns ? "--overwrite-dns " : ""; + lines.push(`cloudflared tunnel route dns ${overwrite}${cfg.cloudflared.tunnel} ${inst.hostname}`); + } + lines.push( + "```", + "", + "## 4) Start/restart the tunnel", + "```bash", + `cloudflared tunnel run ${cfg.cloudflared.tunnel}`, + "```", + "", + "## 5) DNS sanity reminders", + "- Verify each hostname resolves to Cloudflare tunnel (proxied CNAME).", + "- Keep SSL/TLS mode in Cloudflare set to Full (strict preferred).", + "- Ensure your tunnel credentials file matches the tunnel UUID in config.", + "- If a route changed recently, allow DNS propagation time and re-test `/api/health` through the hostname." + ); + } else { + lines.push("## 3) DNS sanity reminders", "- Point each hostname to your reverse proxy/tunnel endpoint.", "- Ensure TLS termination/proxy forwards to the mapped host ports above."); + } + lines.push(""); + return lines.join("\n"); +} + +function runCommand(cmd, args, cwd) { + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) throw result.error; + return result.status || 0; +} + +function maybeRouteDns(cfg, forceRouteFlag) { + if (!cfg.cloudflared.enabled) return; + if (!(cfg.cloudflared.routeDns || forceRouteFlag)) return; + + log("Applying cloudflared DNS routes..."); + for (const inst of cfg.instances) { + const args = ["tunnel", "route", "dns"]; + if (cfg.cloudflared.overwriteDns) args.push("--overwrite-dns"); + args.push(cfg.cloudflared.tunnel, inst.hostname); + try { + const code = runCommand("cloudflared", args, ROOT); + if (code !== 0) warn(`cloudflared route failed (${inst.hostname}), exit code ${code}.`); + } catch (e) { + warn(`cloudflared route failed (${inst.hostname}): ${e?.message || e}`); + } + } +} + +function main() { + const args = parseArgs(); + const configPath = args.config + ? path.resolve(process.cwd(), String(args.config)) + : DEFAULT_CONFIG_PATH; + const examplePath = args.example + ? path.resolve(process.cwd(), String(args.example)) + : DEFAULT_EXAMPLE_PATH; + const forceRouteDns = args["route-dns"] === "1"; + + fs.mkdirSync(MULTI_DIR, { recursive: true }); + ensureConfigFile(configPath, examplePath); + + const cfg = validateConfig(readJson(configPath)); + const envDir = path.join(path.dirname(configPath), "env"); + const composePath = path.join(path.dirname(configPath), "docker-compose.yml"); + const checklistPath = path.join(path.dirname(configPath), "DNS_CHECKLIST.md"); + + for (const inst of cfg.instances) { + const envPath = path.join(envDir, `${inst.id}.env`); + writeText(envPath, renderEnvFile(inst)); + log(`Wrote ${displayPath(envPath)}`); + } + + writeText(composePath, renderCompose(cfg)); + log(`Wrote ${displayPath(composePath)}`); + + writeText(checklistPath, renderChecklist(cfg, composePath)); + log(`Wrote ${displayPath(checklistPath)}`); + + if (cfg.cloudflared.enabled) { + const cfPath = path.resolve(expandHome(cfg.cloudflared.configPath)); + writeText(cfPath, renderCloudflaredConfig(cfg)); + log(`Wrote cloudflared config: ${cfPath}`); + } + + maybeRouteDns(cfg, forceRouteDns); + + console.log(""); + console.log("Next:"); + console.log(` 1) docker compose -f ${displayPath(composePath)} up -d --build --remove-orphans`); + if (cfg.cloudflared.enabled) { + console.log(` 2) cloudflared tunnel run ${cfg.cloudflared.tunnel}`); + } + console.log(` 3) Review ${displayPath(checklistPath)} for DNS validation reminders.`); +} + +main(); diff --git a/CLEAN_INSTALL/scripts/multi-instance-update.js b/CLEAN_INSTALL/scripts/multi-instance-update.js @@ -0,0 +1,101 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); + +function log(msg) { + console.log(`[multi-update] ${msg}`); +} + +function fail(msg) { + console.error(`[multi-update] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (const raw of args) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) { + out[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + out[a.slice(2)] = "1"; + } + return out; +} + +function run(cmd, args, cwd = ROOT, { allowFail = false } = {}) { + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) fail(`${cmd} failed to start: ${result.error?.message || result.error}`); + const code = result.status || 0; + if (code !== 0 && !allowFail) fail(`${cmd} ${args.join(" ")} failed with exit code ${code}`); + return code; +} + +function main() { + const args = parseArgs(); + const remote = String(args.remote || "origin").trim(); + const branch = String(args.branch || "main").trim(); + const configPath = args.config + ? path.resolve(process.cwd(), String(args.config)) + : path.join(ROOT, "multi_instance", "instances.json"); + const composePath = args.compose + ? path.resolve(process.cwd(), String(args.compose)) + : path.join(path.dirname(configPath), "docker-compose.yml"); + + const skipGit = args["skip-git"] === "1"; + const skipBuild = args["skip-build"] === "1"; + const routeDns = args["route-dns"] === "1"; + const dryRun = args["dry-run"] === "1"; + + if (!skipGit) { + log(`Pulling latest source-of-truth from ${remote}/${branch}...`); + run("git", ["fetch", remote], ROOT); + run("git", ["checkout", branch], ROOT); + run("git", ["pull", "--ff-only", remote, branch], ROOT); + } else { + log("Skipping git fetch/pull (--skip-git)."); + } + + if (!fs.existsSync(configPath)) { + fail(`Missing config: ${configPath}\nRun: node scripts/multi-instance-init.js --config=${configPath}`); + } + + log("Regenerating multi-instance compose/env files..."); + const initArgs = [`--config=${configPath}`]; + if (routeDns) initArgs.push("--route-dns"); + run(process.execPath, [path.join(ROOT, "scripts", "multi-instance-init.js"), ...initArgs], ROOT); + + if (!fs.existsSync(composePath)) fail(`Expected compose file was not generated: ${composePath}`); + + const composeRef = composePath.replace(/\\/g, "/"); + const upArgs = ["compose", "-f", composeRef, "up", "-d", "--remove-orphans"]; + if (!skipBuild) upArgs.push("--build"); + + if (dryRun) { + log("Dry run mode enabled (--dry-run). No docker compose command executed."); + console.log(""); + console.log("Would run:"); + console.log(`docker ${upArgs.join(" ")}`); + return; + } + + log("Updating all instances..."); + run("docker", upArgs, ROOT); + + log("Container status:"); + run("docker", ["compose", "-f", composeRef, "ps"], ROOT, { allowFail: true }); + + console.log(""); + console.log("Done."); + console.log(`- Updated compose stack: ${path.relative(ROOT, composePath).replace(/\\/g, "/")}`); + console.log("- Verify health for each hostname and /api/health endpoint."); + console.log("- If cloudflared ingress changed, restart tunnel service/process."); +} + +main(); diff --git a/README.md b/README.md @@ -36,6 +36,8 @@ Media uploads: - Directory plugins (draft): `docs/DIRECTORY_SPEC.md` - Moderation spec: `docs/MODERATION_MVP_SPEC.md` - Self-hosted installer plan: `docs/SELF_HOSTED_INSTALLER_PLAN.md` +- Multi-instance docker stack: `docs/MULTI_INSTANCE_DOCKER.md` +- Instance fleet automation (discovery + bulk update): `docs/INSTANCE_FLEET_AUTOMATION.md` - Issue tracker guide: `docs/ISSUE_TRACKER.md` - Updating a live server (git + docker): `docs/SERVER_UPDATE.md` diff --git a/docs/INSTANCE_FLEET_AUTOMATION.md b/docs/INSTANCE_FLEET_AUTOMATION.md @@ -0,0 +1,60 @@ +# Instance Fleet Automation (Detect + Update + Create) + +Use this when you run multiple Bzl clones in separate folders on one server (for example `/Bzl`, `/srv/bzl-staging`, `/opt/community/Bzl`). + +## 1) Detect instances from root paths + +List discovered Bzl instances: + +```bash +npm run instances:scan -- --roots=/ --max-depth=4 +``` + +By default, detection looks for folders containing: +- `package.json` with `"name": "bzl"` +- `server.js` +- one compose file (`compose.yaml`, `compose.yml`, or `docker-compose.yml`) + +## 2) Update all discovered instances + +Bulk update: + +```bash +npm run instances:update -- --roots=/ --max-depth=4 +``` + +This performs, per instance: +1. `git fetch` +2. `git checkout main` +3. `git pull --ff-only origin main` +4. `docker compose -f <compose-file> up -d --build --remove-orphans` + +Useful flags: +- `--skip-git` +- `--skip-build` +- `--branch=main` +- `--remote=origin` +- `--dry-run` + +## 3) Create a new instance in a new folder + +Provision a fresh clone + `.env` + docker startup: + +```bash +npm run instance:create -- --path=/srv/bzl-new --port=3405 --registration-code='replace-me' --hostname=new.example.com +``` + +Default repo source is `https://github.com/bzlapp/Bzl.git` on `main`. + +Useful flags: +- `--repo=...` +- `--branch=...` +- `--no-start` +- `--dry-run` + +## Caddy + DNS reminders + +After creating/updating instances: +- ensure each hostname reverse proxies to the correct local port +- verify `curl http://127.0.0.1:<port>/api/health` +- confirm public hostname routes to the expected instance diff --git a/docs/MULTI_INSTANCE_DOCKER.md b/docs/MULTI_INSTANCE_DOCKER.md @@ -0,0 +1,100 @@ +# Multi-instance Docker Setup (Single Server) + +This workflow lets you run multiple Bzl instances on one host, each with its own: +- persistent data volume +- hostname +- registration code + +It also supports generating Cloudflare tunnel ingress config and optional automated `cloudflared tunnel route dns` calls. + +--- + +## 1) Create/edit config + +Run once: + +```bash +npm run multi:init +``` + +If `multi_instance/instances.json` does not exist, the script creates a template and exits. + +Edit: +- `multi_instance/instances.json` + +Key fields: +- `instances[].id` - stable identifier (used for service/env filenames) +- `instances[].hostname` - public hostname for that instance +- `instances[].hostPort` - unique localhost port each instance maps to +- `instances[].registrationCode` - per-instance registration code +- `cloudflared.*` - tunnel config + optional DNS route automation + +--- + +## 2) Generate compose + env + DNS checklist + +```bash +npm run multi:init +``` + +Generated files: +- `multi_instance/docker-compose.yml` +- `multi_instance/env/<id>.env` (one per instance) +- `multi_instance/DNS_CHECKLIST.md` +- Cloudflared config (path from `cloudflared.configPath`, default `~/.cloudflared/config.yml`) + +Optional DNS automation: + +```bash +npm run multi:init -- --route-dns +``` + +This runs `cloudflared tunnel route dns ...` for each configured hostname. + +--- + +## 3) Start all instances + +```bash +docker compose -f multi_instance/docker-compose.yml up -d --build --remove-orphans +``` + +Then verify local health endpoints from the host: + +```bash +curl -fsS http://127.0.0.1:<hostPort>/api/health +``` + +--- + +## 4) Update all instances to latest source-of-truth + +Use the updater script from the repo root: + +```bash +npm run multi:update +``` + +Default behavior: +1. `git fetch origin` +2. `git checkout main` +3. `git pull --ff-only origin main` +4. regenerate multi-instance config outputs +5. `docker compose -f multi_instance/docker-compose.yml up -d --build --remove-orphans` + +Options: +- `--skip-git` (skip fetch/pull) +- `--skip-build` (restart/update without image rebuild) +- `--route-dns` (also rerun DNS route commands during regeneration) +- `--dry-run` (show the docker command without executing it) +- `--config=/path/to/instances.json` + +--- + +## DNS reminders + +After generating or changing hostnames: +- ensure each hostname is routed to the intended tunnel (`cloudflared tunnel route dns ...`) +- confirm Cloudflare SSL/TLS mode is compatible (Full / Full strict recommended) +- restart tunnel process/service if ingress config changed +- verify each hostname reaches the expected instance (`/api/health`) diff --git a/docs/SERVER_UPDATE.md b/docs/SERVER_UPDATE.md @@ -49,6 +49,46 @@ docker restart bzl --- +## Multi-instance stack update (single host, many Bzl instances) + +If you run multiple Bzl instances from `multi_instance/docker-compose.yml`, use: + +```bash +npm run multi:update +``` + +This script pulls latest `main`, regenerates per-instance env/compose from `multi_instance/instances.json`, and runs: + +```bash +docker compose -f multi_instance/docker-compose.yml up -d --build --remove-orphans +``` + +See: `docs/MULTI_INSTANCE_DOCKER.md` + +--- + +## Auto-detect + update all Bzl clones across server folders + +If your instances live in separate folders (for example one in `/Bzl` and another elsewhere), use fleet automation: + +```bash +# detect instances first +npm run instances:scan -- --roots=/ --max-depth=4 + +# then update all detected instances +npm run instances:update -- --roots=/ --max-depth=4 +``` + +To create a brand-new instance folder: + +```bash +npm run instance:create -- --path=/srv/bzl-new --port=3405 --registration-code='replace-me' --hostname=new.example.com +``` + +See: `docs/INSTANCE_FLEET_AUTOMATION.md` + +--- + ## Common git mistake (your screenshot) If you run: @@ -119,4 +159,3 @@ For live servers, we can add a similar admin-only “Update core” flow (pull + - confirmation prompts + backup - clear logs of what changed - no “surprise” updates - diff --git a/multi_instance/instances.example.json b/multi_instance/instances.example.json @@ -0,0 +1,31 @@ +{ + "projectName": "bzl-multi", + "networkName": "bzl_multi_net", + "cloudflared": { + "enabled": true, + "tunnel": "bzl", + "routeDns": false, + "overwriteDns": true, + "configPath": "~/.cloudflared/config.yml", + "credentialsFile": "~/.cloudflared/TUNNEL-UUID.json" + }, + "instances": [ + { + "id": "official", + "hostname": "chat.example.com", + "hostPort": 3301, + "registrationCode": "replace-with-random-code", + "env": { + "SESSION_TTL_MS": "2592000000", + "DEFAULT_TTL_MS": "3600000" + } + }, + { + "id": "staging", + "hostname": "staging.example.com", + "hostPort": 3302, + "registrationCode": "replace-with-another-code", + "env": {} + } + ] +} diff --git a/package.json b/package.json @@ -19,6 +19,11 @@ "build:clean-install-zip": "node scripts/build-clean-install-zip.js", "build:plugin:directory-server": "node scripts/build-directory-server-plugin.js", "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js", + "multi:init": "node scripts/multi-instance-init.js", + "multi:update": "node scripts/multi-instance-update.js", + "instances:scan": "node scripts/bzl-instances-update.js", + "instances:update": "node scripts/bzl-instances-update.js --update", + "instance:create": "node scripts/bzl-instance-create.js", "stream:init": "node scripts/stream-pack-init.js", "stream:detect-ip": "node scripts/stream-pack-detect-ip.js", "stream:up": "node scripts/stream-pack-up.js", diff --git a/scripts/bzl-instance-create.js b/scripts/bzl-instance-create.js @@ -0,0 +1,153 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { spawnSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); + +function log(msg) { + console.log(`[instance-create] ${msg}`); +} + +function fail(msg) { + console.error(`[instance-create] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const out = {}; + for (const raw of process.argv.slice(2)) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1); + else out[a.slice(2)] = "1"; + } + return out; +} + +function expandHome(value) { + const v = String(value || "").trim(); + if (!v.startsWith("~")) return v; + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (!home) return v; + if (v === "~") return home; + if (v.startsWith("~/") || v.startsWith("~\\")) return path.join(home, v.slice(2)); + return v; +} + +function displayPath(filePath) { + const abs = path.resolve(filePath); + const rel = path.relative(process.cwd(), abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/"); + return abs.replace(/\\/g, "/"); +} + +function run(cmd, args, cwd, { dryRun = false } = {}) { + const printable = `${cmd} ${args.join(" ")}`; + if (dryRun) { + log(`[dry-run] ${printable} (cwd=${displayPath(cwd)})`); + return 0; + } + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) fail(`${printable} failed to start: ${result.error?.message || result.error}`); + const code = result.status || 0; + if (code !== 0) fail(`${printable} failed with exit code ${code}`); + return code; +} + +function randomCode(bytes = 18) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function parseEnv(body) { + const map = new Map(); + const lines = String(body || "").split(/\r?\n/); + for (const line of lines) { + const s = String(line || ""); + const m = s.match(/^([A-Z0-9_]+)=(.*)$/); + if (!m) continue; + map.set(m[1], m[2]); + } + return map; +} + +function setEnvKey(body, key, value) { + const lines = String(body || "").split(/\r?\n/); + let replaced = false; + const out = lines.map((line) => { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!m) return line; + if (m[1] !== key) return line; + replaced = true; + return `${key}=${value}`; + }); + if (!replaced) out.push(`${key}=${value}`); + return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`; +} + +function main() { + const args = parseArgs(); + const repo = String(args.repo || "https://github.com/bzlapp/Bzl.git").trim(); + const branch = String(args.branch || "main").trim(); + const targetPathRaw = String(args.path || "").trim(); + const hostname = String(args.hostname || "").trim().toLowerCase(); + const dryRun = args["dry-run"] === "1"; + const noStart = args["no-start"] === "1"; + + if (!targetPathRaw) fail("Missing --path=/absolute/or/relative/path"); + const targetPath = path.resolve(expandHome(targetPathRaw)); + + const port = Number(args.port || 0); + if (!Number.isInteger(port) || port < 1024 || port > 65535) fail("--port must be an integer between 1024 and 65535."); + + const registrationCode = String(args["registration-code"] || randomCode()).trim(); + if (!registrationCode) fail("registration code resolved empty"); + + const exists = fs.existsSync(targetPath); + if (exists) { + const list = fs.readdirSync(targetPath).filter((x) => x !== "." && x !== ".."); + if (list.length) fail(`Target path is not empty: ${displayPath(targetPath)}`); + } else if (!dryRun) { + fs.mkdirSync(targetPath, { recursive: true }); + } + + run("git", ["clone", "--branch", branch, repo, targetPath], ROOT, { dryRun }); + + const envExample = path.join(targetPath, ".env.example"); + const envFile = path.join(targetPath, ".env"); + + if (dryRun) { + log(`[dry-run] would create/update ${displayPath(envFile)} with PORT=${port}, HOST=0.0.0.0, REGISTRATION_CODE=...`); + } else { + let base = ""; + if (fs.existsSync(envFile)) base = fs.readFileSync(envFile, "utf8"); + else if (fs.existsSync(envExample)) base = fs.readFileSync(envExample, "utf8"); + else base = ""; + + let next = setEnvKey(base, "PORT", String(port)); + next = setEnvKey(next, "HOST", "0.0.0.0"); + next = setEnvKey(next, "REGISTRATION_CODE", registrationCode); + fs.writeFileSync(envFile, next, "utf8"); + log(`Wrote ${displayPath(envFile)}`); + } + + if (!noStart) { + run("docker", ["compose", "-f", "compose.yaml", "up", "-d", "--build", "--remove-orphans"], targetPath, { dryRun }); + } + + console.log(""); + console.log("Instance created."); + console.log(`- Path: ${displayPath(targetPath)}`); + console.log(`- Port: ${port}`); + console.log(`- Registration code: ${registrationCode}`); + if (hostname) { + console.log(""); + console.log("Caddy reminder:"); + console.log(`${hostname} {`); + console.log(` reverse_proxy 127.0.0.1:${port}`); + console.log("}"); + } +} + +main(); diff --git a/scripts/bzl-instances-update.js b/scripts/bzl-instances-update.js @@ -0,0 +1,281 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const BZL_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml"]; +const DEFAULT_MAX_DEPTH = 4; + +function log(msg) { + console.log(`[instances] ${msg}`); +} + +function warn(msg) { + console.warn(`[instances] WARN: ${msg}`); +} + +function fail(msg) { + console.error(`[instances] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const out = {}; + for (const raw of process.argv.slice(2)) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) out[a.slice(2, eq)] = a.slice(eq + 1); + else out[a.slice(2)] = "1"; + } + return out; +} + +function expandHome(value) { + const v = String(value || "").trim(); + if (!v.startsWith("~")) return v; + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (!home) return v; + if (v === "~") return home; + if (v.startsWith("~/") || v.startsWith("~\\")) return path.join(home, v.slice(2)); + return v; +} + +function displayPath(filePath) { + const abs = path.resolve(filePath); + const rel = path.relative(process.cwd(), abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/"); + return abs.replace(/\\/g, "/"); +} + +function splitList(raw, fallback) { + const text = String(raw || "").trim(); + if (!text) return fallback; + return text + .split(",") + .map((x) => expandHome(String(x || "").trim())) + .filter(Boolean); +} + +function shouldSkipDir(name) { + const n = String(name || "").trim().toLowerCase(); + if (!n) return true; + return new Set([ + ".git", + "node_modules", + "data", + "dist", + "clean_install", + "multi_instance", + "tmp", + "temp", + "proc", + "sys", + "dev", + "run", + "usr", + "lib", + "lib64", + "bin", + "sbin", + "etc", + "boot", + "mnt", + "media", + "snap", + "lost+found", + "__pycache__" + ]).has(n); +} + +function readPackageName(dir) { + try { + const pkgPath = path.join(dir, "package.json"); + const raw = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + return String(raw?.name || "").trim().toLowerCase(); + } catch { + return ""; + } +} + +function findComposeFile(dir) { + for (const name of BZL_COMPOSE_FILES) { + const full = path.join(dir, name); + if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; + } + return ""; +} + +function detectBzlInstance(dir) { + try { + const hasServer = fs.existsSync(path.join(dir, "server.js")); + const hasPkg = fs.existsSync(path.join(dir, "package.json")); + if (!hasServer || !hasPkg) return null; + const pkgName = readPackageName(dir); + if (pkgName !== "bzl") return null; + const composeFile = findComposeFile(dir); + if (!composeFile) return null; + return { rootDir: dir, composeFile }; + } catch { + return null; + } +} + +function discoverFromRoot(rootDir, maxDepth) { + const found = []; + const queue = [{ dir: rootDir, depth: 0 }]; + const seen = new Set(); + while (queue.length) { + const current = queue.shift(); + const abs = path.resolve(current.dir); + if (seen.has(abs)) continue; + seen.add(abs); + + const instance = detectBzlInstance(abs); + if (instance) { + found.push(instance); + continue; + } + if (current.depth >= maxDepth) continue; + + let entries = []; + try { + entries = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.isSymbolicLink()) continue; + if (shouldSkipDir(entry.name)) continue; + queue.push({ dir: path.join(abs, entry.name), depth: current.depth + 1 }); + } + } + return found; +} + +function run(cmd, args, cwd, { dryRun = false, allowFail = false } = {}) { + const printable = `${cmd} ${args.join(" ")}`; + if (dryRun) { + log(`[dry-run] ${printable} (cwd=${displayPath(cwd)})`); + return 0; + } + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) { + if (allowFail) { + warn(`${printable} failed to start: ${result.error?.message || result.error}`); + return 1; + } + fail(`${printable} failed to start: ${result.error?.message || result.error}`); + } + const code = result.status || 0; + if (code !== 0 && !allowFail) fail(`${printable} failed with exit code ${code}`); + return code; +} + +function defaultRoots() { + const list = []; + const cwd = process.cwd(); + list.push(cwd); + if (process.platform !== "win32") { + list.push("/", "/root", "/home", "/opt", "/srv", "/var/www"); + } else { + const driveRoot = path.parse(cwd).root; + if (driveRoot) list.push(driveRoot); + } + const uniq = []; + for (const item of list) { + const abs = path.resolve(expandHome(item)); + if (!fs.existsSync(abs)) continue; + if (uniq.includes(abs)) continue; + uniq.push(abs); + } + return uniq; +} + +function printDiscovered(instances) { + if (!instances.length) { + log("No Bzl instances discovered."); + return; + } + log(`Discovered ${instances.length} instance(s):`); + for (const [i, inst] of instances.entries()) { + console.log(` ${i + 1}. ${displayPath(inst.rootDir)} (compose: ${path.basename(inst.composeFile)})`); + } +} + +function main() { + const args = parseArgs(); + const update = args.update === "1"; + const skipGit = args["skip-git"] === "1"; + const skipBuild = args["skip-build"] === "1"; + const dryRun = args["dry-run"] === "1"; + const remote = String(args.remote || "origin").trim(); + const branch = String(args.branch || "main").trim(); + const maxDepth = Number.isInteger(Number(args["max-depth"])) ? Math.max(1, Number(args["max-depth"])) : DEFAULT_MAX_DEPTH; + const roots = splitList(args.roots, defaultRoots()) + .map((r) => path.resolve(r)) + .filter((r, idx, arr) => fs.existsSync(r) && arr.indexOf(r) === idx); + + if (!roots.length) fail("No valid search roots. Use --roots=/path1,/path2"); + + log(`Scanning roots: ${roots.map((r) => displayPath(r)).join(", ")} (max-depth=${maxDepth})`); + + const foundRaw = []; + for (const root of roots) foundRaw.push(...discoverFromRoot(root, maxDepth)); + + const byRoot = new Map(); + for (const inst of foundRaw) { + const key = path.resolve(inst.rootDir); + if (!byRoot.has(key)) byRoot.set(key, inst); + } + const instances = Array.from(byRoot.values()).sort((a, b) => a.rootDir.localeCompare(b.rootDir)); + printDiscovered(instances); + + if (!update) { + console.log(""); + console.log("Tip: run with --update to pull/rebuild all detected instances."); + return; + } + if (!instances.length) return; + + const summary = []; + for (const inst of instances) { + const rel = displayPath(inst.rootDir); + log(`Updating ${rel}`); + let ok = true; + let notes = []; + try { + if (!skipGit) { + const inGit = run("git", ["-C", inst.rootDir, "rev-parse", "--is-inside-work-tree"], inst.rootDir, { + dryRun, + allowFail: true + }); + if (inGit !== 0) { + notes.push("not a git checkout (skipped git pull)"); + } else { + run("git", ["-C", inst.rootDir, "fetch", remote], inst.rootDir, { dryRun }); + run("git", ["-C", inst.rootDir, "checkout", branch], inst.rootDir, { dryRun }); + run("git", ["-C", inst.rootDir, "pull", "--ff-only", remote, branch], inst.rootDir, { dryRun }); + } + } + const composeArgs = ["compose", "-f", inst.composeFile.replace(/\\/g, "/"), "up", "-d", "--remove-orphans"]; + if (!skipBuild) composeArgs.push("--build"); + run("docker", composeArgs, inst.rootDir, { dryRun }); + summary.push({ instance: rel, ok: true, notes: notes.join("; ") }); + } catch (e) { + ok = false; + notes.push(e?.message || String(e)); + summary.push({ instance: rel, ok, notes: notes.join("; ") }); + warn(`Failed update for ${rel}: ${notes.join("; ")}`); + } + } + + console.log(""); + console.log("Update summary:"); + for (const row of summary) { + const status = row.ok ? "OK" : "FAIL"; + console.log(`- [${status}] ${row.instance}${row.notes ? ` - ${row.notes}` : ""}`); + } +} + +main(); diff --git a/scripts/multi-instance-init.js b/scripts/multi-instance-init.js @@ -0,0 +1,389 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); +const MULTI_DIR = path.join(ROOT, "multi_instance"); +const DEFAULT_CONFIG_PATH = path.join(MULTI_DIR, "instances.json"); +const DEFAULT_EXAMPLE_PATH = path.join(MULTI_DIR, "instances.example.json"); + +function log(msg) { + console.log(`[multi-init] ${msg}`); +} + +function warn(msg) { + console.warn(`[multi-init] WARN: ${msg}`); +} + +function fail(msg) { + console.error(`[multi-init] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (const raw of args) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) { + out[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + out[a.slice(2)] = "1"; + } + return out; +} + +function expandHome(p) { + const value = String(p || "").trim(); + if (!value.startsWith("~")) return value; + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (!home) return value; + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(home, value.slice(2)); + return value; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (e) { + fail(`Failed to read JSON: ${filePath} (${e?.message || e})`); + } +} + +function writeText(filePath, body) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, body, "utf8"); +} + +function displayPath(filePath) { + const abs = path.resolve(filePath); + const rel = path.relative(process.cwd(), abs); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel.replace(/\\/g, "/"); + return abs.replace(/\\/g, "/"); +} + +function envValue(v) { + const raw = String(v ?? ""); + if (!raw) return ""; + if (/^[A-Za-z0-9._:@/+,-]+$/.test(raw)) return raw; + return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function looksLikeHost(h) { + const host = String(h || "").trim().toLowerCase(); + if (!host) return false; + if (host.includes("://")) return false; + if (host.length > 253) return false; + if (!/^[a-z0-9.-]+$/.test(host)) return false; + if (host.startsWith(".") || host.endsWith(".") || host.includes("..")) return false; + return host.includes("."); +} + +function normalizeId(s) { + return String(s || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 32); +} + +function templateConfig() { + return { + projectName: "bzl-multi", + networkName: "bzl_multi_net", + cloudflared: { + enabled: true, + tunnel: "bzl", + routeDns: false, + overwriteDns: true, + configPath: "~/.cloudflared/config.yml", + credentialsFile: "~/.cloudflared/TUNNEL-UUID.json" + }, + instances: [ + { + id: "official", + hostname: "chat.example.com", + hostPort: 3301, + registrationCode: "replace-with-random-code", + env: { + SESSION_TTL_MS: "2592000000", + DEFAULT_TTL_MS: "3600000" + } + }, + { + id: "staging", + hostname: "staging.example.com", + hostPort: 3302, + registrationCode: "replace-with-another-code", + env: {} + } + ] + }; +} + +function ensureConfigFile(configPath, examplePath) { + const template = `${JSON.stringify(templateConfig(), null, 2)}\n`; + if (!fs.existsSync(examplePath)) { + writeText(examplePath, template); + log(`Wrote example config: ${displayPath(examplePath)}`); + } + if (!fs.existsSync(configPath)) { + writeText(configPath, template); + fail( + `Created ${displayPath(configPath)}. Edit hostnames/ports/registration codes, then run this command again.` + ); + } +} + +function validateConfig(raw) { + const projectName = normalizeId(raw?.projectName || "bzl-multi") || "bzl-multi"; + const networkName = normalizeId(raw?.networkName || "bzl_multi_net").replace(/-/g, "_") || "bzl_multi_net"; + const cf = raw?.cloudflared && typeof raw.cloudflared === "object" ? raw.cloudflared : {}; + const cloudflared = { + enabled: Boolean(cf.enabled), + tunnel: String(cf.tunnel || "bzl").trim(), + routeDns: Boolean(cf.routeDns), + overwriteDns: cf.overwriteDns == null ? true : Boolean(cf.overwriteDns), + configPath: String(cf.configPath || "~/.cloudflared/config.yml").trim(), + credentialsFile: String(cf.credentialsFile || "~/.cloudflared/TUNNEL-UUID.json").trim() + }; + + const instancesRaw = Array.isArray(raw?.instances) ? raw.instances : []; + if (!instancesRaw.length) fail("Config must include at least one instance."); + + const ids = new Set(); + const hostnames = new Set(); + const ports = new Set(); + const instances = []; + + for (const [index, row] of instancesRaw.entries()) { + const id = normalizeId(row?.id); + if (!id) fail(`instances[${index}].id is required (letters/numbers/dashes).`); + if (ids.has(id)) fail(`Duplicate instance id: ${id}`); + ids.add(id); + + const hostname = String(row?.hostname || "").trim().toLowerCase(); + if (!looksLikeHost(hostname)) fail(`instances[${index}] (${id}) has invalid hostname: ${hostname}`); + if (hostnames.has(hostname)) fail(`Duplicate hostname: ${hostname}`); + hostnames.add(hostname); + + const hostPort = Number(row?.hostPort || 0); + if (!Number.isInteger(hostPort) || hostPort < 1024 || hostPort > 65535) { + fail(`instances[${index}] (${id}) hostPort must be an integer between 1024 and 65535.`); + } + if (ports.has(hostPort)) fail(`Duplicate hostPort: ${hostPort}`); + ports.add(hostPort); + + const envRaw = row?.env && typeof row.env === "object" ? row.env : {}; + const env = {}; + for (const [key, value] of Object.entries(envRaw)) { + const k = String(key || "").trim().toUpperCase(); + if (!k || !/^[A-Z0-9_]+$/.test(k)) continue; + env[k] = String(value ?? ""); + } + + instances.push({ + id, + serviceName: `bzl_${id.replace(/-/g, "_")}`, + volumeName: `${projectName}_${id.replace(/-/g, "_")}_data`, + hostname, + hostPort, + registrationCode: String(row?.registrationCode || "").trim(), + env + }); + } + + if (cloudflared.enabled && !cloudflared.tunnel) fail("cloudflared.tunnel is required when cloudflared.enabled=true."); + if (cloudflared.enabled && !cloudflared.configPath) fail("cloudflared.configPath is required when cloudflared.enabled=true."); + if (cloudflared.enabled && !cloudflared.credentialsFile) { + warn("cloudflared.credentialsFile is empty; update it before starting cloudflared."); + } + + return { projectName, networkName, cloudflared, instances }; +} + +function renderEnvFile(instance) { + const lines = [ + `# Generated by scripts/multi-instance-init.js`, + `# Instance: ${instance.id} (${instance.hostname})`, + "", + "PORT=3000", + "HOST=0.0.0.0", + `REGISTRATION_CODE=${envValue(instance.registrationCode)}` + ]; + const keys = Object.keys(instance.env).sort(); + if (keys.length) { + lines.push("", "# Extra per-instance env overrides"); + for (const key of keys) lines.push(`${key}=${envValue(instance.env[key])}`); + } + lines.push(""); + return lines.join("\n"); +} + +function renderCompose(cfg) { + const lines = ["services:"]; + for (const inst of cfg.instances) { + lines.push( + ` ${inst.serviceName}:`, + ` build:`, + ` context: ..`, + ` dockerfile: Dockerfile`, + ` image: bzl:latest`, + ` container_name: ${inst.serviceName}`, + ` restart: unless-stopped`, + ` env_file:`, + ` - ./env/${inst.id}.env`, + ` ports:`, + ` - "127.0.0.1:${inst.hostPort}:3000"`, + ` volumes:`, + ` - ${inst.volumeName}:/app/data`, + ` networks:`, + ` - ${cfg.networkName}` + ); + } + lines.push("", "volumes:"); + for (const inst of cfg.instances) lines.push(` ${inst.volumeName}:`); + lines.push("", "networks:", ` ${cfg.networkName}:`, ` name: ${cfg.networkName}`, ""); + return lines.join("\n"); +} + +function renderCloudflaredConfig(cfg) { + const lines = [ + `# Generated by scripts/multi-instance-init.js`, + `tunnel: ${cfg.cloudflared.tunnel}`, + `credentials-file: ${expandHome(cfg.cloudflared.credentialsFile)}`, + `ingress:` + ]; + for (const inst of cfg.instances) { + lines.push(` - hostname: ${inst.hostname}`, ` service: http://127.0.0.1:${inst.hostPort}`); + } + lines.push(` - service: http_status:404`, ""); + return lines.join("\n"); +} + +function renderChecklist(cfg, composePath) { + const lines = [ + "# Multi-instance DNS / deployment checklist", + "", + `Compose file: ${displayPath(composePath)}`, + "", + "## 1) Bring up all instances", + "```bash", + `docker compose -f ${displayPath(composePath)} up -d --build --remove-orphans`, + "```", + "", + "## 2) Validate each local instance responds", + "```bash" + ]; + for (const inst of cfg.instances) lines.push(`curl -fsS http://127.0.0.1:${inst.hostPort}/api/health`); + lines.push("```", ""); + if (cfg.cloudflared.enabled) { + lines.push( + "## 3) Cloudflare DNS routing", + "", + "Run these once per hostname (safe to re-run with overwrite):", + "```bash" + ); + for (const inst of cfg.instances) { + const overwrite = cfg.cloudflared.overwriteDns ? "--overwrite-dns " : ""; + lines.push(`cloudflared tunnel route dns ${overwrite}${cfg.cloudflared.tunnel} ${inst.hostname}`); + } + lines.push( + "```", + "", + "## 4) Start/restart the tunnel", + "```bash", + `cloudflared tunnel run ${cfg.cloudflared.tunnel}`, + "```", + "", + "## 5) DNS sanity reminders", + "- Verify each hostname resolves to Cloudflare tunnel (proxied CNAME).", + "- Keep SSL/TLS mode in Cloudflare set to Full (strict preferred).", + "- Ensure your tunnel credentials file matches the tunnel UUID in config.", + "- If a route changed recently, allow DNS propagation time and re-test `/api/health` through the hostname." + ); + } else { + lines.push("## 3) DNS sanity reminders", "- Point each hostname to your reverse proxy/tunnel endpoint.", "- Ensure TLS termination/proxy forwards to the mapped host ports above."); + } + lines.push(""); + return lines.join("\n"); +} + +function runCommand(cmd, args, cwd) { + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) throw result.error; + return result.status || 0; +} + +function maybeRouteDns(cfg, forceRouteFlag) { + if (!cfg.cloudflared.enabled) return; + if (!(cfg.cloudflared.routeDns || forceRouteFlag)) return; + + log("Applying cloudflared DNS routes..."); + for (const inst of cfg.instances) { + const args = ["tunnel", "route", "dns"]; + if (cfg.cloudflared.overwriteDns) args.push("--overwrite-dns"); + args.push(cfg.cloudflared.tunnel, inst.hostname); + try { + const code = runCommand("cloudflared", args, ROOT); + if (code !== 0) warn(`cloudflared route failed (${inst.hostname}), exit code ${code}.`); + } catch (e) { + warn(`cloudflared route failed (${inst.hostname}): ${e?.message || e}`); + } + } +} + +function main() { + const args = parseArgs(); + const configPath = args.config + ? path.resolve(process.cwd(), String(args.config)) + : DEFAULT_CONFIG_PATH; + const examplePath = args.example + ? path.resolve(process.cwd(), String(args.example)) + : DEFAULT_EXAMPLE_PATH; + const forceRouteDns = args["route-dns"] === "1"; + + fs.mkdirSync(MULTI_DIR, { recursive: true }); + ensureConfigFile(configPath, examplePath); + + const cfg = validateConfig(readJson(configPath)); + const envDir = path.join(path.dirname(configPath), "env"); + const composePath = path.join(path.dirname(configPath), "docker-compose.yml"); + const checklistPath = path.join(path.dirname(configPath), "DNS_CHECKLIST.md"); + + for (const inst of cfg.instances) { + const envPath = path.join(envDir, `${inst.id}.env`); + writeText(envPath, renderEnvFile(inst)); + log(`Wrote ${displayPath(envPath)}`); + } + + writeText(composePath, renderCompose(cfg)); + log(`Wrote ${displayPath(composePath)}`); + + writeText(checklistPath, renderChecklist(cfg, composePath)); + log(`Wrote ${displayPath(checklistPath)}`); + + if (cfg.cloudflared.enabled) { + const cfPath = path.resolve(expandHome(cfg.cloudflared.configPath)); + writeText(cfPath, renderCloudflaredConfig(cfg)); + log(`Wrote cloudflared config: ${cfPath}`); + } + + maybeRouteDns(cfg, forceRouteDns); + + console.log(""); + console.log("Next:"); + console.log(` 1) docker compose -f ${displayPath(composePath)} up -d --build --remove-orphans`); + if (cfg.cloudflared.enabled) { + console.log(` 2) cloudflared tunnel run ${cfg.cloudflared.tunnel}`); + } + console.log(` 3) Review ${displayPath(checklistPath)} for DNS validation reminders.`); +} + +main(); diff --git a/scripts/multi-instance-update.js b/scripts/multi-instance-update.js @@ -0,0 +1,101 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); + +function log(msg) { + console.log(`[multi-update] ${msg}`); +} + +function fail(msg) { + console.error(`[multi-update] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (const raw of args) { + const a = String(raw || ""); + if (!a.startsWith("--")) continue; + const eq = a.indexOf("="); + if (eq > -1) { + out[a.slice(2, eq)] = a.slice(eq + 1); + continue; + } + out[a.slice(2)] = "1"; + } + return out; +} + +function run(cmd, args, cwd = ROOT, { allowFail = false } = {}) { + const result = spawnSync(cmd, args, { cwd, stdio: "inherit", shell: false }); + if (result.error) fail(`${cmd} failed to start: ${result.error?.message || result.error}`); + const code = result.status || 0; + if (code !== 0 && !allowFail) fail(`${cmd} ${args.join(" ")} failed with exit code ${code}`); + return code; +} + +function main() { + const args = parseArgs(); + const remote = String(args.remote || "origin").trim(); + const branch = String(args.branch || "main").trim(); + const configPath = args.config + ? path.resolve(process.cwd(), String(args.config)) + : path.join(ROOT, "multi_instance", "instances.json"); + const composePath = args.compose + ? path.resolve(process.cwd(), String(args.compose)) + : path.join(path.dirname(configPath), "docker-compose.yml"); + + const skipGit = args["skip-git"] === "1"; + const skipBuild = args["skip-build"] === "1"; + const routeDns = args["route-dns"] === "1"; + const dryRun = args["dry-run"] === "1"; + + if (!skipGit) { + log(`Pulling latest source-of-truth from ${remote}/${branch}...`); + run("git", ["fetch", remote], ROOT); + run("git", ["checkout", branch], ROOT); + run("git", ["pull", "--ff-only", remote, branch], ROOT); + } else { + log("Skipping git fetch/pull (--skip-git)."); + } + + if (!fs.existsSync(configPath)) { + fail(`Missing config: ${configPath}\nRun: node scripts/multi-instance-init.js --config=${configPath}`); + } + + log("Regenerating multi-instance compose/env files..."); + const initArgs = [`--config=${configPath}`]; + if (routeDns) initArgs.push("--route-dns"); + run(process.execPath, [path.join(ROOT, "scripts", "multi-instance-init.js"), ...initArgs], ROOT); + + if (!fs.existsSync(composePath)) fail(`Expected compose file was not generated: ${composePath}`); + + const composeRef = composePath.replace(/\\/g, "/"); + const upArgs = ["compose", "-f", composeRef, "up", "-d", "--remove-orphans"]; + if (!skipBuild) upArgs.push("--build"); + + if (dryRun) { + log("Dry run mode enabled (--dry-run). No docker compose command executed."); + console.log(""); + console.log("Would run:"); + console.log(`docker ${upArgs.join(" ")}`); + return; + } + + log("Updating all instances..."); + run("docker", upArgs, ROOT); + + log("Container status:"); + run("docker", ["compose", "-f", composeRef, "ps"], ROOT, { allowFail: true }); + + console.log(""); + console.log("Done."); + console.log(`- Updated compose stack: ${path.relative(ROOT, composePath).replace(/\\/g, "/")}`); + console.log("- Verify health for each hostname and /api/health endpoint."); + console.log("- If cloudflared ingress changed, restart tunnel service/process."); +} + +main();