bzl

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

commit b29dce0e46364d740b5fecd5811bc33a7736ab08
parent 172cbe10f185923bd3abc08ad7cfe64187e5a97b
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Mon, 16 Feb 2026 16:26:52 -0700

feat: Launcher Update options

Launcher now points to this repo as source of truth for Bzl Core

Diffstat:
MCLEAN_INSTALL/README.md | 2+-
MCLEAN_INSTALL/scripts/launcher-ui.html | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MCLEAN_INSTALL/scripts/launcher-ui.js | 446++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MREADME.md | 2+-
Mscripts/launcher-ui.html | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mscripts/launcher-ui.js | 443++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 1118 insertions(+), 17 deletions(-)

diff --git a/CLEAN_INSTALL/README.md b/CLEAN_INSTALL/README.md @@ -32,7 +32,7 @@ Media uploads: - Optional first-time wizard: `npm run init` (Windows PowerShell: `npm.cmd run init`) (or run `INSTALL.cmd` / `INSTALL.ps1` / `INSTALL.sh`) - `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` + - 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` diff --git a/CLEAN_INSTALL/scripts/launcher-ui.html b/CLEAN_INSTALL/scripts/launcher-ui.html @@ -144,6 +144,27 @@ <div class="card"> <div class="row" style="justify-content: space-between"> <div> + <div><b>Updates</b> <span id="updPill" class="pill">Checking...</span></div> + <div class="hint" id="updText">Checks GitHub releases for updates and preserves your <span class="mono">data/</span> and <span class="mono">.env</span>.</div> + <div class="row" style="margin-top: 10px"> + <label style="display: flex; gap: 8px; align-items: center"> + <input id="chkUpdEnable" type="checkbox" /> + Enable updates (opt-in) + </label> + <select id="updSelect" style="flex: 1; min-width: 240px"></select> + <button class="btn" id="btnUpdNotes">Release notes</button> + </div> + </div> + <div class="row"> + <button class="btn" id="btnUpdCheck">Check</button> + <button class="btn primary" id="btnUpdApply">Apply update</button> + </div> + </div> + </div> + + <div class="card"> + <div class="row" style="justify-content: space-between"> + <div> <div><b>Cloudflare tunnel (optional)</b></div> <div class="hint">Quick tunnel prints a public URL in the log. Named tunnel requires <span class="mono">CLOUDFLARED_TUNNEL</span>.</div> </div> @@ -234,12 +255,20 @@ const inpCfCfg = document.getElementById("inpCfCfg"); const cfCredsHint = document.getElementById("cfCredsHint"); const publicUrlEl = document.getElementById("publicUrl"); + const updPill = document.getElementById("updPill"); + const updText = document.getElementById("updText"); const cfLoginUrlEl = document.getElementById("cfLoginUrl"); let cfTunnels = []; let cfCredsById = new Map(); + const updEnabledKey = "bzlLauncherUpdatesEnabled"; + const updSelect = document.getElementById("updSelect"); + const updNotes = document.getElementById("btnUpdNotes"); + const updEnable = document.getElementById("chkUpdEnable"); + let updReleases = []; + let updSelected = null; function append(line) { - logEl.textContent += line + "\\n"; + logEl.textContent += line + "\n"; logEl.scrollTop = logEl.scrollHeight; } @@ -291,10 +320,57 @@ async function refresh() { const st = await api("/api/status"); statusPill.textContent = st.healthy ? "Running" : st.serverRunning ? "Starting..." : "Stopped"; - statusText.textContent = st.localUrl + (st.healthy ? " (healthy)" : ""); + const v = st.version ? ` v${st.version}` : ""; + statusText.textContent = st.localUrl + v + (st.healthy ? " (healthy)" : ""); if (st.namedPublicUrl) setPublicUrl(st.namedPublicUrl); } + async function refreshUpdate() { + if (!updPill || !updText) return; + const enabled = !!(updEnable && updEnable.checked); + if (!enabled) { + updPill.textContent = "Updates disabled"; + updText.textContent = "Enable updates to check GitHub releases and choose a version."; + if (updSelect) updSelect.innerHTML = ""; + updSelected = null; + if (updNotes) updNotes.disabled = true; + return; + } + try { + const st = await api("/api/update/releases"); + if (!st.ok) { + updPill.textContent = "Update check failed"; + updText.textContent = st.error || "Failed to check updates."; + return; + } + updReleases = Array.isArray(st.releases) ? st.releases : []; + const current = st.currentVersion ? `v${st.currentVersion}` : "(unknown)"; + const latest = updReleases[0]?.version ? `v${updReleases[0].version}` : "(unknown)"; + + updPill.textContent = "Ready"; + updText.textContent = `Current ${current}. Select a version to view notes or apply. Latest: ${latest}.`; + + if (updSelect) { + const opts = updReleases + .filter((r) => r && r.tag && r.asset && r.asset.url) + .map((r) => ({ + tag: r.tag, + label: `${r.version ? "v" + r.version : r.tag}${r.publishedAt ? " · " + r.publishedAt.slice(0, 10) : ""}${ + r.prerelease ? " · prerelease" : "" + }` + })); + updSelect.innerHTML = opts.map((o) => `<option value="${o.tag}">${o.label}</option>`).join(""); + const want = updSelected?.tag || opts[0]?.tag || ""; + if (want) updSelect.value = want; + updSelected = updReleases.find((r) => r.tag === updSelect.value) || null; + } + if (updNotes) updNotes.disabled = !(updSelected && updSelected.htmlUrl); + } catch (e) { + updPill.textContent = "Update check failed"; + updText.textContent = e?.message || String(e); + } + } + async function refreshCloudflared() { const st = await api("/api/cloudflared/status"); if (!st.exists) { @@ -344,15 +420,11 @@ try { const f = await api("/api/cloudflared/files"); if (!f.ok) return; - setDatalistOptions( - "cfCfgList", - [f.dir ? f.dir + "\\\\config.yml" : "", ...(f.configCandidates || [])].filter(Boolean) - ); + setDatalistOptions("cfCfgList", [f.dir ? f.dir + "\\\\config.yml" : "", ...(f.configCandidates || [])].filter(Boolean)); setDatalistOptions( "cfCredsList", (f.credentials || []).map((c) => c.path) ); - cfCredsById = new Map((f.credentials || []).filter((c) => c.tunnelId).map((c) => [c.tunnelId, c.path])); if (!inpCfCfg.value && f.configCandidates && f.configCandidates[0]) inpCfCfg.value = f.configCandidates[0]; } catch {} @@ -376,6 +448,40 @@ document.getElementById("btnTunnelQuick").onclick = async () => api("/api/tunnel/start", { mode: "quick" }); document.getElementById("btnTunnelNamed").onclick = async () => api("/api/tunnel/start", { mode: "named" }); document.getElementById("btnTunnelStop").onclick = async () => api("/api/tunnel/stop", {}); + if (updEnable) { + updEnable.checked = localStorage.getItem(updEnabledKey) === "1"; + updEnable.onchange = () => { + localStorage.setItem(updEnabledKey, updEnable.checked ? "1" : "0"); + refreshUpdate(); + }; + } + document.getElementById("btnUpdCheck").onclick = async () => refreshUpdate(); + if (updSelect) { + updSelect.onchange = () => { + updSelected = updReleases.find((r) => r.tag === updSelect.value) || null; + if (updNotes) updNotes.disabled = !(updSelected && updSelected.htmlUrl); + }; + } + if (updNotes) { + updNotes.onclick = () => { + const url = updSelected?.htmlUrl || ""; + if (url) window.open(url, "_blank", "noopener,noreferrer"); + }; + } + document.getElementById("btnUpdApply").onclick = async () => { + if (!updEnable || !updEnable.checked) { + append("update: enable updates first"); + return; + } + const tag = updSelected?.tag || (updSelect ? updSelect.value : ""); + if (!tag) { + append("update: pick a version first"); + return; + } + append(`update: applying ${tag} (launcher will close)...`); + const r = await api("/api/update/apply", { tag }); + append(r.ok ? "update: applying now (re-open launcher after it exits)" : `update: failed (${r.error || "?"})`); + }; document.getElementById("btnCopyPublic").onclick = async () => { const u = publicUrlEl?.dataset?.url || ""; const ok = await copyText(u); @@ -482,12 +588,14 @@ (async () => { const snap = await api("/api/log"); - logEl.textContent = (snap.lines || []).join("\\n") + "\\n"; + logEl.textContent = (snap.lines || []).join("\n") + "\n"; refresh(); + refreshUpdate(); refreshCloudflared(); refreshCloudflaredFiles(); refreshCloudflaredTunnels(); setInterval(refresh, 1000); + setInterval(refreshUpdate, 60000); setInterval(refreshCloudflared, 3000); setInterval(refreshCloudflaredFiles, 5000); setInterval(refreshCloudflaredTunnels, 30000); @@ -499,8 +607,8 @@ const msg = JSON.parse(ev.data || "{}"); if (msg.line) { append(msg.line); - const m = String(msg.line).match(/https?:\\/\\/[^\\s]+/i); - if (m && /trycloudflare\\.com/i.test(m[0])) setPublicUrl(m[0]); + const m = String(msg.line).match(/https?:\/\/[^\s]+/i); + if (m && /trycloudflare\.com/i.test(m[0])) setPublicUrl(m[0]); } } catch {} }; diff --git a/CLEAN_INSTALL/scripts/launcher-ui.js b/CLEAN_INSTALL/scripts/launcher-ui.js @@ -2,8 +2,10 @@ const http = require("http"); const https = require("https"); const fs = require("fs"); const path = require("path"); +const os = require("os"); const crypto = require("crypto"); const { spawn } = require("child_process"); +const AdmZip = require("adm-zip"); const ROOT = path.join(__dirname, ".."); const ENV_PATH = path.join(ROOT, ".env"); @@ -88,6 +90,405 @@ function getConfiguredHost(env) { return String(env?.HOST || "0.0.0.0").trim() || "0.0.0.0"; } +function readPackageVersion() { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); + return String(pkg?.version || "").trim() || "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function compareVersions(a, b) { + const pa = String(a || "") + .trim() + .replace(/^v/i, "") + .split(".") + .map((n) => Number(n || 0)); + const pb = String(b || "") + .trim() + .replace(/^v/i, "") + .split(".") + .map((n) => Number(n || 0)); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const x = Number.isFinite(pa[i]) ? pa[i] : 0; + const y = Number.isFinite(pb[i]) ? pb[i] : 0; + if (x > y) return 1; + if (x < y) return -1; + } + return 0; +} + +function fetchJson(url, { headers = {}, timeoutMs = 15_000 } = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const lib = u.protocol === "http:" ? http : https; + const req = lib.request( + { + method: "GET", + hostname: u.hostname, + port: u.port || (u.protocol === "http:" ? 80 : 443), + path: u.pathname + u.search, + headers: { + "User-Agent": "Bzl-Launcher-UI", + Accept: "application/vnd.github+json", + ...headers + } + }, + (res) => { + let body = ""; + res.on("data", (d) => (body += String(d || ""))); + res.on("end", () => { + const code = Number(res.statusCode || 0); + if (code < 200 || code >= 300) { + return reject(new Error(`HTTP_${code}`)); + } + try { + resolve(JSON.parse(body || "{}")); + } catch (e) { + reject(e); + } + }); + } + ); + req.on("error", reject); + req.setTimeout(timeoutMs, () => { + try { + req.destroy(new Error("TIMEOUT")); + } catch { + // ignore + } + }); + req.end(); + }); +} + +function downloadToFile(url, filePath, { timeoutMs = 60_000 } = {}) { + return new Promise((resolve, reject) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const out = fs.createWriteStream(filePath); + const u = new URL(url); + const lib = u.protocol === "http:" ? http : https; + + const req = lib.request( + { + method: "GET", + hostname: u.hostname, + port: u.port || (u.protocol === "http:" ? 80 : 443), + path: u.pathname + u.search, + headers: { + "User-Agent": "Bzl-Launcher-UI", + Accept: "application/octet-stream" + } + }, + (res) => { + const code = Number(res.statusCode || 0); + const loc = String(res.headers.location || ""); + if ([301, 302, 303, 307, 308].includes(code) && loc) { + out.close(() => { + try { + fs.unlinkSync(filePath); + } catch { + // ignore + } + downloadToFile(loc, filePath, { timeoutMs }).then(resolve, reject); + }); + return; + } + if (code < 200 || code >= 300) { + out.close(() => reject(new Error(`HTTP_${code}`))); + return; + } + res.pipe(out); + out.on("finish", () => out.close(() => resolve(true))); + } + ); + + req.on("error", (e) => { + try { + out.close(() => reject(e)); + } catch { + reject(e); + } + }); + req.setTimeout(timeoutMs, () => { + try { + req.destroy(new Error("TIMEOUT")); + } catch { + // ignore + } + }); + req.end(); + }); +} + +function copyRecursive(src, dst, { skipNames = new Set() } = {}) { + if (!fs.existsSync(src)) return; + const st = fs.statSync(src); + if (st.isDirectory()) { + const base = path.basename(src); + if (skipNames.has(base)) return; + fs.mkdirSync(dst, { recursive: true }); + for (const name of fs.readdirSync(src)) { + copyRecursive(path.join(src, name), path.join(dst, name), { skipNames }); + } + return; + } + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); +} + +function findExtractedRoot(dir) { + const mustHave = ["package.json", "server.js"]; + const has = (p) => mustHave.every((f) => fs.existsSync(path.join(p, f))); + if (has(dir)) return dir; + let items = []; + try { + items = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); + } catch { + items = []; + } + if (items.length === 1) { + const only = path.join(dir, items[0]); + if (has(only)) return only; + } + for (const name of items) { + const p = path.join(dir, name); + if (has(p)) return p; + } + return ""; +} + +async function getLatestReleaseInfo() { + const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim(); + const apiUrl = `https://api.github.com/repos/${repo}/releases/latest`; + const rel = await fetchJson(apiUrl); + const tag = String(rel?.tag_name || "").trim(); + const latestVersion = tag.replace(/^v/i, ""); + const assets = Array.isArray(rel?.assets) ? rel.assets : []; + const asset = + assets.find((a) => /^Bzl-CLEAN_INSTALL-v.+\.zip$/i.test(String(a?.name || ""))) || + assets.find((a) => /\.zip$/i.test(String(a?.name || ""))) || + null; + return { + repo, + latestVersion, + tag, + htmlUrl: String(rel?.html_url || ""), + publishedAt: String(rel?.published_at || ""), + asset: asset + ? { + name: String(asset.name || ""), + url: String(asset.browser_download_url || ""), + size: Number(asset.size || 0) + } + : null + }; +} + +function pickCleanInstallAsset(assets) { + const list = Array.isArray(assets) ? assets : []; + const best = + list.find((a) => /^Bzl-CLEAN_INSTALL-v.+\.zip$/i.test(String(a?.name || ""))) || + list.find((a) => /\.zip$/i.test(String(a?.name || ""))) || + null; + if (!best) return null; + return { + name: String(best.name || ""), + url: String(best.browser_download_url || ""), + size: Number(best.size || 0) + }; +} + +async function getReleasesList({ perPage = 20 } = {}) { + const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim(); + const apiUrl = `https://api.github.com/repos/${repo}/releases?per_page=${Math.max(1, Math.min(50, Number(perPage) || 20))}`; + const releases = await fetchJson(apiUrl); + const list = Array.isArray(releases) ? releases : []; + return { + repo, + releases: list.map((r) => { + const tag = String(r?.tag_name || "").trim(); + const version = tag.replace(/^v/i, ""); + return { + tag, + version, + name: String(r?.name || "") || tag, + htmlUrl: String(r?.html_url || ""), + publishedAt: String(r?.published_at || ""), + prerelease: Boolean(r?.prerelease), + draft: Boolean(r?.draft), + asset: pickCleanInstallAsset(r?.assets) + }; + }) + }; +} + +async function getReleaseByTag(tag) { + const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim(); + const t = String(tag || "").trim(); + if (!t) throw new Error("Missing tag."); + const apiUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(t)}`; + const rel = await fetchJson(apiUrl); + const relTag = String(rel?.tag_name || "").trim(); + const version = relTag.replace(/^v/i, ""); + return { + repo, + tag: relTag, + version, + name: String(rel?.name || "") || relTag, + htmlUrl: String(rel?.html_url || ""), + publishedAt: String(rel?.published_at || ""), + asset: pickCleanInstallAsset(rel?.assets) + }; +} + +function writeHelperScript({ helperPath, installDir, stageRootDir }) { + const js = ` +const fs = require("fs"); +const path = require("path"); + +function sleep(ms){ return new Promise(r => setTimeout(r, ms)); } +function exists(p){ try { return fs.existsSync(p); } catch { return false; } } + +function copyRecursive(src, dst) { + const st = fs.statSync(src); + if (st.isDirectory()) { + fs.mkdirSync(dst, { recursive: true }); + for (const name of fs.readdirSync(src)) copyRecursive(path.join(src, name), path.join(dst, name)); + return; + } + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); +} + +function removeRecursive(p) { + try { fs.rmSync(p, { recursive: true, force: true }); } catch {} +} + +async function main() { + const installDir = ${JSON.stringify(installDir)}; + const stageRootDir = ${JSON.stringify(stageRootDir)}; + const extracted = path.join(stageRootDir, "extracted-root"); + if (!exists(extracted)) { + console.error("[bzl-update] Missing extracted root:", extracted); + process.exit(2); + } + + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const backupDir = installDir + "_backup_" + ts; + + // Give the launcher a chance to exit and release handles, then retry rename. + let renamed = false; + for (let i=0;i<80;i++){ + try { + if (!exists(installDir)) break; + fs.renameSync(installDir, backupDir); + renamed = true; + break; + } catch (e) { + await sleep(250); + } + } + if (!renamed && exists(installDir)) { + console.error("[bzl-update] Failed to rename install dir after retries."); + process.exit(3); + } + + try { + fs.renameSync(extracted, installDir); + } catch (e) { + console.error("[bzl-update] Rename into place failed, falling back to copy:", e && e.message ? e.message : String(e)); + try { + fs.mkdirSync(installDir, { recursive: true }); + copyRecursive(extracted, installDir); + } catch (e2) { + console.error("[bzl-update] Copy fallback failed:", e2 && e2.message ? e2.message : String(e2)); + process.exit(4); + } + } + + // Cleanup staging (best-effort). + removeRecursive(stageRootDir); + console.log("[bzl-update] Updated successfully. Backup at:", backupDir); +} + +main().catch((e)=>{ console.error("[bzl-update] Fatal:", e && e.stack ? e.stack : String(e)); process.exit(1); }); +`; + fs.writeFileSync(helperPath, js.trimStart(), "utf8"); +} + +async function stageAndApplyUpdate({ tag } = {}) { + const currentVersion = readPackageVersion(); + const info = tag ? await getReleaseByTag(tag) : await getLatestReleaseInfo(); + if (!info.asset?.url) return { ok: false, error: "No update asset (.zip) found on the latest GitHub release." }; + + const parent = path.dirname(ROOT); + const stageRootDir = path.join(parent, `.bzl_update_stage_${Date.now()}`); + const zipPath = path.join(stageRootDir, "update.zip"); + const extractDir = path.join(stageRootDir, "extract"); + const extractedRootFinal = path.join(stageRootDir, "extracted-root"); + fs.mkdirSync(stageRootDir, { recursive: true }); + + pushLog("ui", `update: downloading ${info.asset.name} (${info.asset.size || 0} bytes)`); + await downloadToFile(info.asset.url, zipPath); + pushLog("ui", `update: downloaded to ${zipPath}`); + + pushLog("ui", "update: extracting..."); + fs.mkdirSync(extractDir, { recursive: true }); + const zip = new AdmZip(zipPath); + zip.extractAllTo(extractDir, true); + const extractedRoot = findExtractedRoot(extractDir); + if (!extractedRoot) return { ok: false, error: "Extracted zip did not look like a Bzl clean-install folder." }; + + // Move extracted to a stable dir within the staging root so the helper has a predictable path. + try { + if (path.resolve(extractedRoot) !== path.resolve(extractedRootFinal)) { + fs.renameSync(extractedRoot, extractedRootFinal); + } + } catch { + copyRecursive(extractedRoot, extractedRootFinal, { skipNames: new Set() }); + } + + // Preserve local state into the staged build. + try { + if (fs.existsSync(ENV_PATH)) fs.copyFileSync(ENV_PATH, path.join(extractedRootFinal, ".env")); + } catch { + // ignore + } + try { + const srcData = path.join(ROOT, "data"); + const dstData = path.join(extractedRootFinal, "data"); + copyRecursive(srcData, dstData, { skipNames: new Set() }); + } catch { + // ignore + } + + // Write and spawn a helper that swaps the folder after we exit. + const helperPath = path.join(stageRootDir, "apply-update.js"); + writeHelperScript({ helperPath, installDir: ROOT, stageRootDir }); + + pushLog("ui", "update: stopping services..."); + try { + stopTunnel(); + } catch {} + try { + stopBzl(); + } catch {} + + pushLog("ui", "update: applying (launcher will exit)..."); + const child = spawn(process.execPath, [helperPath], { detached: true, stdio: "ignore" }); + child.unref(); + + return { + ok: true, + currentVersion, + latestVersion: info.version || info.latestVersion, + tag: info.tag || "", + applying: true + }; +} + function escapeHtml(s) { return String(s || "") .replaceAll("&", "&amp;") @@ -221,7 +622,7 @@ function makeRing(maxLines = 600) { } const logRing = makeRing(800); -const subscribers = new Set(); +const subscribers = new Set(); // res objects for SSE function pushLog(kind, msg) { const line = `[${nowIso()}] ${kind}: ${msg}`; @@ -739,11 +1140,51 @@ const srv = http.createServer(async (req, res) => { tunnelRunning: isRunning(tunnelChild), localUrl, healthy, - namedPublicUrl: namedHostname ? `https://${namedHostname}` : "" + namedPublicUrl: namedHostname ? `https://${namedHostname}` : "", + version: readPackageVersion() }); return; } + if (req.method === "GET" && url.pathname === "/api/update/status") { + try { + const currentVersion = readPackageVersion(); + const latest = await getLatestReleaseInfo(); + const available = latest.latestVersion && compareVersions(latest.latestVersion, currentVersion) > 0; + json(res, 200, { ok: true, currentVersion, available, ...latest }); + } catch (e) { + json(res, 200, { ok: false, error: e?.message || String(e) }); + } + return; + } + + if (req.method === "GET" && url.pathname === "/api/update/releases") { + try { + const currentVersion = readPackageVersion(); + const list = await getReleasesList({ perPage: 20 }); + json(res, 200, { ok: true, currentVersion, ...list }); + } catch (e) { + json(res, 200, { ok: false, error: e?.message || String(e) }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/api/update/apply") { + withTokenHeaders(res); + try { + const body = await readJsonBody(req); + const tag = String(body?.tag || "").trim(); + const r = await stageAndApplyUpdate({ tag: tag || "" }); + json(res, 200, r); + if (r.ok && r.applying) { + setTimeout(() => process.exit(0), 750); + } + } catch (e) { + json(res, 500, { ok: false, error: e?.message || String(e) }); + } + return; + } + if (req.method === "GET" && url.pathname === "/api/cloudflared/status") { const env = readEnvFile(); const port = getConfiguredPort(env); @@ -873,7 +1314,6 @@ const srv = http.createServer(async (req, res) => { lastCloudflaredCreate = { ...result }; try { const pairs = { - // Use UUID for consistency; it matches the credentials JSON filename. CLOUDFLARED_TUNNEL: result.tunnelId || name, CLOUDFLARED_CONFIG: defaultCloudflaredConfigPath() }; diff --git a/README.md b/README.md @@ -43,7 +43,7 @@ Optionally the `-d` flag can be specified to let the app run as a background pro - 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` (or `npm run launcher:ui`) + - 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: diff --git a/scripts/launcher-ui.html b/scripts/launcher-ui.html @@ -144,6 +144,27 @@ <div class="card"> <div class="row" style="justify-content: space-between"> <div> + <div><b>Updates</b> <span id="updPill" class="pill">Checking...</span></div> + <div class="hint" id="updText">Checks GitHub releases for updates and preserves your <span class="mono">data/</span> and <span class="mono">.env</span>.</div> + <div class="row" style="margin-top: 10px"> + <label style="display: flex; gap: 8px; align-items: center"> + <input id="chkUpdEnable" type="checkbox" /> + Enable updates (opt-in) + </label> + <select id="updSelect" style="flex: 1; min-width: 240px"></select> + <button class="btn" id="btnUpdNotes">Release notes</button> + </div> + </div> + <div class="row"> + <button class="btn" id="btnUpdCheck">Check</button> + <button class="btn primary" id="btnUpdApply">Apply update</button> + </div> + </div> + </div> + + <div class="card"> + <div class="row" style="justify-content: space-between"> + <div> <div><b>Cloudflare tunnel (optional)</b></div> <div class="hint">Quick tunnel prints a public URL in the log. Named tunnel requires <span class="mono">CLOUDFLARED_TUNNEL</span>.</div> </div> @@ -234,9 +255,17 @@ const inpCfCfg = document.getElementById("inpCfCfg"); const cfCredsHint = document.getElementById("cfCredsHint"); const publicUrlEl = document.getElementById("publicUrl"); + const updPill = document.getElementById("updPill"); + const updText = document.getElementById("updText"); const cfLoginUrlEl = document.getElementById("cfLoginUrl"); let cfTunnels = []; let cfCredsById = new Map(); + const updEnabledKey = "bzlLauncherUpdatesEnabled"; + const updSelect = document.getElementById("updSelect"); + const updNotes = document.getElementById("btnUpdNotes"); + const updEnable = document.getElementById("chkUpdEnable"); + let updReleases = []; + let updSelected = null; function append(line) { logEl.textContent += line + "\n"; @@ -291,10 +320,57 @@ async function refresh() { const st = await api("/api/status"); statusPill.textContent = st.healthy ? "Running" : st.serverRunning ? "Starting..." : "Stopped"; - statusText.textContent = st.localUrl + (st.healthy ? " (healthy)" : ""); + const v = st.version ? ` v${st.version}` : ""; + statusText.textContent = st.localUrl + v + (st.healthy ? " (healthy)" : ""); if (st.namedPublicUrl) setPublicUrl(st.namedPublicUrl); } + async function refreshUpdate() { + if (!updPill || !updText) return; + const enabled = !!(updEnable && updEnable.checked); + if (!enabled) { + updPill.textContent = "Updates disabled"; + updText.textContent = "Enable updates to check GitHub releases and choose a version."; + if (updSelect) updSelect.innerHTML = ""; + updSelected = null; + if (updNotes) updNotes.disabled = true; + return; + } + try { + const st = await api("/api/update/releases"); + if (!st.ok) { + updPill.textContent = "Update check failed"; + updText.textContent = st.error || "Failed to check updates."; + return; + } + updReleases = Array.isArray(st.releases) ? st.releases : []; + const current = st.currentVersion ? `v${st.currentVersion}` : "(unknown)"; + const latest = updReleases[0]?.version ? `v${updReleases[0].version}` : "(unknown)"; + + updPill.textContent = "Ready"; + updText.textContent = `Current ${current}. Select a version to view notes or apply. Latest: ${latest}.`; + + if (updSelect) { + const opts = updReleases + .filter((r) => r && r.tag && r.asset && r.asset.url) + .map((r) => ({ + tag: r.tag, + label: `${r.version ? "v" + r.version : r.tag}${r.publishedAt ? " · " + r.publishedAt.slice(0, 10) : ""}${ + r.prerelease ? " · prerelease" : "" + }` + })); + updSelect.innerHTML = opts.map((o) => `<option value="${o.tag}">${o.label}</option>`).join(""); + const want = updSelected?.tag || opts[0]?.tag || ""; + if (want) updSelect.value = want; + updSelected = updReleases.find((r) => r.tag === updSelect.value) || null; + } + if (updNotes) updNotes.disabled = !(updSelected && updSelected.htmlUrl); + } catch (e) { + updPill.textContent = "Update check failed"; + updText.textContent = e?.message || String(e); + } + } + async function refreshCloudflared() { const st = await api("/api/cloudflared/status"); if (!st.exists) { @@ -372,6 +448,40 @@ document.getElementById("btnTunnelQuick").onclick = async () => api("/api/tunnel/start", { mode: "quick" }); document.getElementById("btnTunnelNamed").onclick = async () => api("/api/tunnel/start", { mode: "named" }); document.getElementById("btnTunnelStop").onclick = async () => api("/api/tunnel/stop", {}); + if (updEnable) { + updEnable.checked = localStorage.getItem(updEnabledKey) === "1"; + updEnable.onchange = () => { + localStorage.setItem(updEnabledKey, updEnable.checked ? "1" : "0"); + refreshUpdate(); + }; + } + document.getElementById("btnUpdCheck").onclick = async () => refreshUpdate(); + if (updSelect) { + updSelect.onchange = () => { + updSelected = updReleases.find((r) => r.tag === updSelect.value) || null; + if (updNotes) updNotes.disabled = !(updSelected && updSelected.htmlUrl); + }; + } + if (updNotes) { + updNotes.onclick = () => { + const url = updSelected?.htmlUrl || ""; + if (url) window.open(url, "_blank", "noopener,noreferrer"); + }; + } + document.getElementById("btnUpdApply").onclick = async () => { + if (!updEnable || !updEnable.checked) { + append("update: enable updates first"); + return; + } + const tag = updSelected?.tag || (updSelect ? updSelect.value : ""); + if (!tag) { + append("update: pick a version first"); + return; + } + append(`update: applying ${tag} (launcher will close)...`); + const r = await api("/api/update/apply", { tag }); + append(r.ok ? "update: applying now (re-open launcher after it exits)" : `update: failed (${r.error || "?"})`); + }; document.getElementById("btnCopyPublic").onclick = async () => { const u = publicUrlEl?.dataset?.url || ""; const ok = await copyText(u); @@ -480,10 +590,12 @@ const snap = await api("/api/log"); logEl.textContent = (snap.lines || []).join("\n") + "\n"; refresh(); + refreshUpdate(); refreshCloudflared(); refreshCloudflaredFiles(); refreshCloudflaredTunnels(); setInterval(refresh, 1000); + setInterval(refreshUpdate, 60000); setInterval(refreshCloudflared, 3000); setInterval(refreshCloudflaredFiles, 5000); setInterval(refreshCloudflaredTunnels, 30000); diff --git a/scripts/launcher-ui.js b/scripts/launcher-ui.js @@ -2,8 +2,10 @@ const http = require("http"); const https = require("https"); const fs = require("fs"); const path = require("path"); +const os = require("os"); const crypto = require("crypto"); const { spawn } = require("child_process"); +const AdmZip = require("adm-zip"); const ROOT = path.join(__dirname, ".."); const ENV_PATH = path.join(ROOT, ".env"); @@ -88,6 +90,405 @@ function getConfiguredHost(env) { return String(env?.HOST || "0.0.0.0").trim() || "0.0.0.0"; } +function readPackageVersion() { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); + return String(pkg?.version || "").trim() || "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function compareVersions(a, b) { + const pa = String(a || "") + .trim() + .replace(/^v/i, "") + .split(".") + .map((n) => Number(n || 0)); + const pb = String(b || "") + .trim() + .replace(/^v/i, "") + .split(".") + .map((n) => Number(n || 0)); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const x = Number.isFinite(pa[i]) ? pa[i] : 0; + const y = Number.isFinite(pb[i]) ? pb[i] : 0; + if (x > y) return 1; + if (x < y) return -1; + } + return 0; +} + +function fetchJson(url, { headers = {}, timeoutMs = 15_000 } = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const lib = u.protocol === "http:" ? http : https; + const req = lib.request( + { + method: "GET", + hostname: u.hostname, + port: u.port || (u.protocol === "http:" ? 80 : 443), + path: u.pathname + u.search, + headers: { + "User-Agent": "Bzl-Launcher-UI", + Accept: "application/vnd.github+json", + ...headers + } + }, + (res) => { + let body = ""; + res.on("data", (d) => (body += String(d || ""))); + res.on("end", () => { + const code = Number(res.statusCode || 0); + if (code < 200 || code >= 300) { + return reject(new Error(`HTTP_${code}`)); + } + try { + resolve(JSON.parse(body || "{}")); + } catch (e) { + reject(e); + } + }); + } + ); + req.on("error", reject); + req.setTimeout(timeoutMs, () => { + try { + req.destroy(new Error("TIMEOUT")); + } catch { + // ignore + } + }); + req.end(); + }); +} + +function downloadToFile(url, filePath, { timeoutMs = 60_000 } = {}) { + return new Promise((resolve, reject) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const out = fs.createWriteStream(filePath); + const u = new URL(url); + const lib = u.protocol === "http:" ? http : https; + + const req = lib.request( + { + method: "GET", + hostname: u.hostname, + port: u.port || (u.protocol === "http:" ? 80 : 443), + path: u.pathname + u.search, + headers: { + "User-Agent": "Bzl-Launcher-UI", + Accept: "application/octet-stream" + } + }, + (res) => { + const code = Number(res.statusCode || 0); + const loc = String(res.headers.location || ""); + if ([301, 302, 303, 307, 308].includes(code) && loc) { + out.close(() => { + try { + fs.unlinkSync(filePath); + } catch { + // ignore + } + downloadToFile(loc, filePath, { timeoutMs }).then(resolve, reject); + }); + return; + } + if (code < 200 || code >= 300) { + out.close(() => reject(new Error(`HTTP_${code}`))); + return; + } + res.pipe(out); + out.on("finish", () => out.close(() => resolve(true))); + } + ); + + req.on("error", (e) => { + try { + out.close(() => reject(e)); + } catch { + reject(e); + } + }); + req.setTimeout(timeoutMs, () => { + try { + req.destroy(new Error("TIMEOUT")); + } catch { + // ignore + } + }); + req.end(); + }); +} + +function copyRecursive(src, dst, { skipNames = new Set() } = {}) { + if (!fs.existsSync(src)) return; + const st = fs.statSync(src); + if (st.isDirectory()) { + const base = path.basename(src); + if (skipNames.has(base)) return; + fs.mkdirSync(dst, { recursive: true }); + for (const name of fs.readdirSync(src)) { + copyRecursive(path.join(src, name), path.join(dst, name), { skipNames }); + } + return; + } + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); +} + +function findExtractedRoot(dir) { + const mustHave = ["package.json", "server.js"]; + const has = (p) => mustHave.every((f) => fs.existsSync(path.join(p, f))); + if (has(dir)) return dir; + let items = []; + try { + items = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); + } catch { + items = []; + } + if (items.length === 1) { + const only = path.join(dir, items[0]); + if (has(only)) return only; + } + for (const name of items) { + const p = path.join(dir, name); + if (has(p)) return p; + } + return ""; +} + +async function getLatestReleaseInfo() { + const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim(); + const apiUrl = `https://api.github.com/repos/${repo}/releases/latest`; + const rel = await fetchJson(apiUrl); + const tag = String(rel?.tag_name || "").trim(); + const latestVersion = tag.replace(/^v/i, ""); + const assets = Array.isArray(rel?.assets) ? rel.assets : []; + const asset = + assets.find((a) => /^Bzl-CLEAN_INSTALL-v.+\.zip$/i.test(String(a?.name || ""))) || + assets.find((a) => /\.zip$/i.test(String(a?.name || ""))) || + null; + return { + repo, + latestVersion, + tag, + htmlUrl: String(rel?.html_url || ""), + publishedAt: String(rel?.published_at || ""), + asset: asset + ? { + name: String(asset.name || ""), + url: String(asset.browser_download_url || ""), + size: Number(asset.size || 0) + } + : null + }; +} + +function pickCleanInstallAsset(assets) { + const list = Array.isArray(assets) ? assets : []; + const best = + list.find((a) => /^Bzl-CLEAN_INSTALL-v.+\.zip$/i.test(String(a?.name || ""))) || + list.find((a) => /\.zip$/i.test(String(a?.name || ""))) || + null; + if (!best) return null; + return { + name: String(best.name || ""), + url: String(best.browser_download_url || ""), + size: Number(best.size || 0) + }; +} + +async function getReleasesList({ perPage = 20 } = {}) { + const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim(); + const apiUrl = `https://api.github.com/repos/${repo}/releases?per_page=${Math.max(1, Math.min(50, Number(perPage) || 20))}`; + const releases = await fetchJson(apiUrl); + const list = Array.isArray(releases) ? releases : []; + return { + repo, + releases: list.map((r) => { + const tag = String(r?.tag_name || "").trim(); + const version = tag.replace(/^v/i, ""); + return { + tag, + version, + name: String(r?.name || "") || tag, + htmlUrl: String(r?.html_url || ""), + publishedAt: String(r?.published_at || ""), + prerelease: Boolean(r?.prerelease), + draft: Boolean(r?.draft), + asset: pickCleanInstallAsset(r?.assets) + }; + }) + }; +} + +async function getReleaseByTag(tag) { + const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim(); + const t = String(tag || "").trim(); + if (!t) throw new Error("Missing tag."); + const apiUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(t)}`; + const rel = await fetchJson(apiUrl); + const relTag = String(rel?.tag_name || "").trim(); + const version = relTag.replace(/^v/i, ""); + return { + repo, + tag: relTag, + version, + name: String(rel?.name || "") || relTag, + htmlUrl: String(rel?.html_url || ""), + publishedAt: String(rel?.published_at || ""), + asset: pickCleanInstallAsset(rel?.assets) + }; +} + +function writeHelperScript({ helperPath, installDir, stageRootDir }) { + const js = ` +const fs = require("fs"); +const path = require("path"); + +function sleep(ms){ return new Promise(r => setTimeout(r, ms)); } +function exists(p){ try { return fs.existsSync(p); } catch { return false; } } + +function copyRecursive(src, dst) { + const st = fs.statSync(src); + if (st.isDirectory()) { + fs.mkdirSync(dst, { recursive: true }); + for (const name of fs.readdirSync(src)) copyRecursive(path.join(src, name), path.join(dst, name)); + return; + } + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); +} + +function removeRecursive(p) { + try { fs.rmSync(p, { recursive: true, force: true }); } catch {} +} + +async function main() { + const installDir = ${JSON.stringify(installDir)}; + const stageRootDir = ${JSON.stringify(stageRootDir)}; + const extracted = path.join(stageRootDir, "extracted-root"); + if (!exists(extracted)) { + console.error("[bzl-update] Missing extracted root:", extracted); + process.exit(2); + } + + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const backupDir = installDir + "_backup_" + ts; + + // Give the launcher a chance to exit and release handles, then retry rename. + let renamed = false; + for (let i=0;i<80;i++){ + try { + if (!exists(installDir)) break; + fs.renameSync(installDir, backupDir); + renamed = true; + break; + } catch (e) { + await sleep(250); + } + } + if (!renamed && exists(installDir)) { + console.error("[bzl-update] Failed to rename install dir after retries."); + process.exit(3); + } + + try { + fs.renameSync(extracted, installDir); + } catch (e) { + console.error("[bzl-update] Rename into place failed, falling back to copy:", e && e.message ? e.message : String(e)); + try { + fs.mkdirSync(installDir, { recursive: true }); + copyRecursive(extracted, installDir); + } catch (e2) { + console.error("[bzl-update] Copy fallback failed:", e2 && e2.message ? e2.message : String(e2)); + process.exit(4); + } + } + + // Cleanup staging (best-effort). + removeRecursive(stageRootDir); + console.log("[bzl-update] Updated successfully. Backup at:", backupDir); +} + +main().catch((e)=>{ console.error("[bzl-update] Fatal:", e && e.stack ? e.stack : String(e)); process.exit(1); }); +`; + fs.writeFileSync(helperPath, js.trimStart(), "utf8"); +} + +async function stageAndApplyUpdate({ tag } = {}) { + const currentVersion = readPackageVersion(); + const info = tag ? await getReleaseByTag(tag) : await getLatestReleaseInfo(); + if (!info.asset?.url) return { ok: false, error: "No update asset (.zip) found on the latest GitHub release." }; + + const parent = path.dirname(ROOT); + const stageRootDir = path.join(parent, `.bzl_update_stage_${Date.now()}`); + const zipPath = path.join(stageRootDir, "update.zip"); + const extractDir = path.join(stageRootDir, "extract"); + const extractedRootFinal = path.join(stageRootDir, "extracted-root"); + fs.mkdirSync(stageRootDir, { recursive: true }); + + pushLog("ui", `update: downloading ${info.asset.name} (${info.asset.size || 0} bytes)`); + await downloadToFile(info.asset.url, zipPath); + pushLog("ui", `update: downloaded to ${zipPath}`); + + pushLog("ui", "update: extracting..."); + fs.mkdirSync(extractDir, { recursive: true }); + const zip = new AdmZip(zipPath); + zip.extractAllTo(extractDir, true); + const extractedRoot = findExtractedRoot(extractDir); + if (!extractedRoot) return { ok: false, error: "Extracted zip did not look like a Bzl clean-install folder." }; + + // Move extracted to a stable dir within the staging root so the helper has a predictable path. + try { + if (path.resolve(extractedRoot) !== path.resolve(extractedRootFinal)) { + fs.renameSync(extractedRoot, extractedRootFinal); + } + } catch { + copyRecursive(extractedRoot, extractedRootFinal, { skipNames: new Set() }); + } + + // Preserve local state into the staged build. + try { + if (fs.existsSync(ENV_PATH)) fs.copyFileSync(ENV_PATH, path.join(extractedRootFinal, ".env")); + } catch { + // ignore + } + try { + const srcData = path.join(ROOT, "data"); + const dstData = path.join(extractedRootFinal, "data"); + copyRecursive(srcData, dstData, { skipNames: new Set() }); + } catch { + // ignore + } + + // Write and spawn a helper that swaps the folder after we exit. + const helperPath = path.join(stageRootDir, "apply-update.js"); + writeHelperScript({ helperPath, installDir: ROOT, stageRootDir }); + + pushLog("ui", "update: stopping services..."); + try { + stopTunnel(); + } catch {} + try { + stopBzl(); + } catch {} + + pushLog("ui", "update: applying (launcher will exit)..."); + const child = spawn(process.execPath, [helperPath], { detached: true, stdio: "ignore" }); + child.unref(); + + return { + ok: true, + currentVersion, + latestVersion: info.version || info.latestVersion, + tag: info.tag || "", + applying: true + }; +} + function escapeHtml(s) { return String(s || "") .replaceAll("&", "&amp;") @@ -739,11 +1140,51 @@ const srv = http.createServer(async (req, res) => { tunnelRunning: isRunning(tunnelChild), localUrl, healthy, - namedPublicUrl: namedHostname ? `https://${namedHostname}` : "" + namedPublicUrl: namedHostname ? `https://${namedHostname}` : "", + version: readPackageVersion() }); return; } + if (req.method === "GET" && url.pathname === "/api/update/status") { + try { + const currentVersion = readPackageVersion(); + const latest = await getLatestReleaseInfo(); + const available = latest.latestVersion && compareVersions(latest.latestVersion, currentVersion) > 0; + json(res, 200, { ok: true, currentVersion, available, ...latest }); + } catch (e) { + json(res, 200, { ok: false, error: e?.message || String(e) }); + } + return; + } + + if (req.method === "GET" && url.pathname === "/api/update/releases") { + try { + const currentVersion = readPackageVersion(); + const list = await getReleasesList({ perPage: 20 }); + json(res, 200, { ok: true, currentVersion, ...list }); + } catch (e) { + json(res, 200, { ok: false, error: e?.message || String(e) }); + } + return; + } + + if (req.method === "POST" && url.pathname === "/api/update/apply") { + withTokenHeaders(res); + try { + const body = await readJsonBody(req); + const tag = String(body?.tag || "").trim(); + const r = await stageAndApplyUpdate({ tag: tag || "" }); + json(res, 200, r); + if (r.ok && r.applying) { + setTimeout(() => process.exit(0), 750); + } + } catch (e) { + json(res, 500, { ok: false, error: e?.message || String(e) }); + } + return; + } + if (req.method === "GET" && url.pathname === "/api/cloudflared/status") { const env = readEnvFile(); const port = getConfiguredPort(env);