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:
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("&", "&")
@@ -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("&", "&")
@@ -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);