bzl

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

launcher-ui.js (46960B)


      1 const http = require("http");
      2 const https = require("https");
      3 const fs = require("fs");
      4 const path = require("path");
      5 const os = require("os");
      6 const crypto = require("crypto");
      7 const { spawn } = require("child_process");
      8 const AdmZip = require("adm-zip");
      9 
     10 const ROOT = path.join(__dirname, "..");
     11 const ENV_PATH = path.join(ROOT, ".env");
     12 const HTML_PATH = path.join(__dirname, "launcher-ui.html");
     13 
     14 const UI_HOST = "127.0.0.1";
     15 function getUiPort() {
     16   const raw = String(process.env.BZL_LAUNCHER_PORT || "8787").trim();
     17   const n = Number(raw);
     18   if (!Number.isFinite(n)) return 8787;
     19   const p = Math.floor(n);
     20   if (p < 1 || p > 65535) return 8787;
     21   return p;
     22 }
     23 const UI_PORT = getUiPort();
     24 const apiToken = crypto.randomBytes(18).toString("hex");
     25 
     26 function nowIso() {
     27   return new Date().toISOString();
     28 }
     29 
     30 function crashLogPath() {
     31   return path.join(ROOT, "launcher-ui.crash.log");
     32 }
     33 
     34 function appendCrashLog(line) {
     35   try {
     36     fs.appendFileSync(crashLogPath(), `${nowIso()} ${line}\n`, "utf8");
     37   } catch {
     38     // ignore
     39   }
     40 }
     41 
     42 process.on("uncaughtException", (err) => {
     43   const msg = err?.stack || err?.message || String(err);
     44   console.error("[launcher-ui] FATAL:", msg);
     45   appendCrashLog(`FATAL uncaughtException: ${msg}`);
     46   process.exit(1);
     47 });
     48 
     49 process.on("unhandledRejection", (reason) => {
     50   const msg = reason?.stack || reason?.message || String(reason);
     51   console.error("[launcher-ui] FATAL:", msg);
     52   appendCrashLog(`FATAL unhandledRejection: ${msg}`);
     53   process.exit(1);
     54 });
     55 
     56 function readEnvFile() {
     57   try {
     58     const raw = fs.readFileSync(ENV_PATH, "utf8");
     59     const out = {};
     60     for (const line of raw.split(/\r?\n/)) {
     61       const s = String(line || "").trim();
     62       if (!s || s.startsWith("#")) continue;
     63       const m = s.match(/^([A-Z0-9_]+)=(.*)$/);
     64       if (!m) continue;
     65       let v = String(m[2] || "").trim();
     66       if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
     67       out[m[1]] = v;
     68     }
     69     return out;
     70   } catch {
     71     return {};
     72   }
     73 }
     74 
     75 function mergeEnv(baseEnv, envFile) {
     76   const out = { ...(baseEnv || {}) };
     77   const src = envFile && typeof envFile === "object" ? envFile : {};
     78   for (const [k, v] of Object.entries(src)) {
     79     if (out[k] == null || String(out[k]) === "") out[k] = String(v ?? "");
     80   }
     81   return out;
     82 }
     83 
     84 function getConfiguredPort(env) {
     85   const p = Number(env?.PORT || 3000);
     86   return Number.isFinite(p) && p > 0 ? Math.floor(p) : 3000;
     87 }
     88 
     89 function getConfiguredHost(env) {
     90   return String(env?.HOST || "0.0.0.0").trim() || "0.0.0.0";
     91 }
     92 
     93 function readPackageVersion() {
     94   try {
     95     const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
     96     return String(pkg?.version || "").trim() || "0.0.0";
     97   } catch {
     98     return "0.0.0";
     99   }
    100 }
    101 
    102 function compareVersions(a, b) {
    103   const pa = String(a || "")
    104     .trim()
    105     .replace(/^v/i, "")
    106     .split(".")
    107     .map((n) => Number(n || 0));
    108   const pb = String(b || "")
    109     .trim()
    110     .replace(/^v/i, "")
    111     .split(".")
    112     .map((n) => Number(n || 0));
    113   for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    114     const x = Number.isFinite(pa[i]) ? pa[i] : 0;
    115     const y = Number.isFinite(pb[i]) ? pb[i] : 0;
    116     if (x > y) return 1;
    117     if (x < y) return -1;
    118   }
    119   return 0;
    120 }
    121 
    122 function fetchJson(url, { headers = {}, timeoutMs = 15_000 } = {}) {
    123   return new Promise((resolve, reject) => {
    124     const u = new URL(url);
    125     const lib = u.protocol === "http:" ? http : https;
    126     const req = lib.request(
    127       {
    128         method: "GET",
    129         hostname: u.hostname,
    130         port: u.port || (u.protocol === "http:" ? 80 : 443),
    131         path: u.pathname + u.search,
    132         headers: {
    133           "User-Agent": "Bzl-Launcher-UI",
    134           Accept: "application/vnd.github+json",
    135           ...headers
    136         }
    137       },
    138       (res) => {
    139         let body = "";
    140         res.on("data", (d) => (body += String(d || "")));
    141         res.on("end", () => {
    142           const code = Number(res.statusCode || 0);
    143           if (code < 200 || code >= 300) {
    144             return reject(new Error(`HTTP_${code}`));
    145           }
    146           try {
    147             resolve(JSON.parse(body || "{}"));
    148           } catch (e) {
    149             reject(e);
    150           }
    151         });
    152       }
    153     );
    154     req.on("error", reject);
    155     req.setTimeout(timeoutMs, () => {
    156       try {
    157         req.destroy(new Error("TIMEOUT"));
    158       } catch {
    159         // ignore
    160       }
    161     });
    162     req.end();
    163   });
    164 }
    165 
    166 function downloadToFile(url, filePath, { timeoutMs = 60_000 } = {}) {
    167   return new Promise((resolve, reject) => {
    168     fs.mkdirSync(path.dirname(filePath), { recursive: true });
    169     const out = fs.createWriteStream(filePath);
    170     const u = new URL(url);
    171     const lib = u.protocol === "http:" ? http : https;
    172 
    173     const req = lib.request(
    174       {
    175         method: "GET",
    176         hostname: u.hostname,
    177         port: u.port || (u.protocol === "http:" ? 80 : 443),
    178         path: u.pathname + u.search,
    179         headers: {
    180           "User-Agent": "Bzl-Launcher-UI",
    181           Accept: "application/octet-stream"
    182         }
    183       },
    184       (res) => {
    185         const code = Number(res.statusCode || 0);
    186         const loc = String(res.headers.location || "");
    187         if ([301, 302, 303, 307, 308].includes(code) && loc) {
    188           out.close(() => {
    189             try {
    190               fs.unlinkSync(filePath);
    191             } catch {
    192               // ignore
    193             }
    194             downloadToFile(loc, filePath, { timeoutMs }).then(resolve, reject);
    195           });
    196           return;
    197         }
    198         if (code < 200 || code >= 300) {
    199           out.close(() => reject(new Error(`HTTP_${code}`)));
    200           return;
    201         }
    202         res.pipe(out);
    203         out.on("finish", () => out.close(() => resolve(true)));
    204       }
    205     );
    206 
    207     req.on("error", (e) => {
    208       try {
    209         out.close(() => reject(e));
    210       } catch {
    211         reject(e);
    212       }
    213     });
    214     req.setTimeout(timeoutMs, () => {
    215       try {
    216         req.destroy(new Error("TIMEOUT"));
    217       } catch {
    218         // ignore
    219       }
    220     });
    221     req.end();
    222   });
    223 }
    224 
    225 function copyRecursive(src, dst, { skipNames = new Set() } = {}) {
    226   if (!fs.existsSync(src)) return;
    227   const st = fs.statSync(src);
    228   if (st.isDirectory()) {
    229     const base = path.basename(src);
    230     if (skipNames.has(base)) return;
    231     fs.mkdirSync(dst, { recursive: true });
    232     for (const name of fs.readdirSync(src)) {
    233       copyRecursive(path.join(src, name), path.join(dst, name), { skipNames });
    234     }
    235     return;
    236   }
    237   fs.mkdirSync(path.dirname(dst), { recursive: true });
    238   fs.copyFileSync(src, dst);
    239 }
    240 
    241 function findExtractedRoot(dir) {
    242   const mustHave = ["package.json", "server.js"];
    243   const has = (p) => mustHave.every((f) => fs.existsSync(path.join(p, f)));
    244   if (has(dir)) return dir;
    245   let items = [];
    246   try {
    247     items = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
    248   } catch {
    249     items = [];
    250   }
    251   if (items.length === 1) {
    252     const only = path.join(dir, items[0]);
    253     if (has(only)) return only;
    254   }
    255   for (const name of items) {
    256     const p = path.join(dir, name);
    257     if (has(p)) return p;
    258   }
    259   return "";
    260 }
    261 
    262 async function getLatestReleaseInfo() {
    263   const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim();
    264   const apiUrl = `https://api.github.com/repos/${repo}/releases/latest`;
    265   const rel = await fetchJson(apiUrl);
    266   const tag = String(rel?.tag_name || "").trim();
    267   const latestVersion = tag.replace(/^v/i, "");
    268   const assets = Array.isArray(rel?.assets) ? rel.assets : [];
    269   const asset =
    270     assets.find((a) => /^Bzl-CLEAN_INSTALL-v.+\.zip$/i.test(String(a?.name || ""))) ||
    271     assets.find((a) => /\.zip$/i.test(String(a?.name || ""))) ||
    272     null;
    273   return {
    274     repo,
    275     latestVersion,
    276     tag,
    277     htmlUrl: String(rel?.html_url || ""),
    278     publishedAt: String(rel?.published_at || ""),
    279     asset: asset
    280       ? {
    281           name: String(asset.name || ""),
    282           url: String(asset.browser_download_url || ""),
    283           size: Number(asset.size || 0)
    284         }
    285       : null
    286   };
    287 }
    288 
    289 function pickCleanInstallAsset(assets) {
    290   const list = Array.isArray(assets) ? assets : [];
    291   const best =
    292     list.find((a) => /^Bzl-CLEAN_INSTALL-v.+\.zip$/i.test(String(a?.name || ""))) ||
    293     list.find((a) => /\.zip$/i.test(String(a?.name || ""))) ||
    294     null;
    295   if (!best) return null;
    296   return {
    297     name: String(best.name || ""),
    298     url: String(best.browser_download_url || ""),
    299     size: Number(best.size || 0)
    300   };
    301 }
    302 
    303 async function getReleasesList({ perPage = 20 } = {}) {
    304   const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim();
    305   const apiUrl = `https://api.github.com/repos/${repo}/releases?per_page=${Math.max(1, Math.min(50, Number(perPage) || 20))}`;
    306   const releases = await fetchJson(apiUrl);
    307   const list = Array.isArray(releases) ? releases : [];
    308   return {
    309     repo,
    310     releases: list.map((r) => {
    311       const tag = String(r?.tag_name || "").trim();
    312       const version = tag.replace(/^v/i, "");
    313       return {
    314         tag,
    315         version,
    316         name: String(r?.name || "") || tag,
    317         htmlUrl: String(r?.html_url || ""),
    318         publishedAt: String(r?.published_at || ""),
    319         prerelease: Boolean(r?.prerelease),
    320         draft: Boolean(r?.draft),
    321         asset: pickCleanInstallAsset(r?.assets)
    322       };
    323     })
    324   };
    325 }
    326 
    327 async function getReleaseByTag(tag) {
    328   const repo = String(process.env.BZL_UPDATE_REPO || "bzlapp/Bzl").trim();
    329   const t = String(tag || "").trim();
    330   if (!t) throw new Error("Missing tag.");
    331   const apiUrl = `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(t)}`;
    332   const rel = await fetchJson(apiUrl);
    333   const relTag = String(rel?.tag_name || "").trim();
    334   const version = relTag.replace(/^v/i, "");
    335   return {
    336     repo,
    337     tag: relTag,
    338     version,
    339     name: String(rel?.name || "") || relTag,
    340     htmlUrl: String(rel?.html_url || ""),
    341     publishedAt: String(rel?.published_at || ""),
    342     asset: pickCleanInstallAsset(rel?.assets)
    343   };
    344 }
    345 
    346 function writeHelperScript({ helperPath, installDir, stageRootDir }) {
    347   const js = `
    348 const fs = require("fs");
    349 const path = require("path");
    350 
    351 function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
    352 function exists(p){ try { return fs.existsSync(p); } catch { return false; } }
    353 
    354 function copyRecursive(src, dst) {
    355   const st = fs.statSync(src);
    356   if (st.isDirectory()) {
    357     fs.mkdirSync(dst, { recursive: true });
    358     for (const name of fs.readdirSync(src)) copyRecursive(path.join(src, name), path.join(dst, name));
    359     return;
    360   }
    361   fs.mkdirSync(path.dirname(dst), { recursive: true });
    362   fs.copyFileSync(src, dst);
    363 }
    364 
    365 function removeRecursive(p) {
    366   try { fs.rmSync(p, { recursive: true, force: true }); } catch {}
    367 }
    368 
    369 async function main() {
    370   const installDir = ${JSON.stringify(installDir)};
    371   const stageRootDir = ${JSON.stringify(stageRootDir)};
    372   const extracted = path.join(stageRootDir, "extracted-root");
    373   if (!exists(extracted)) {
    374     console.error("[bzl-update] Missing extracted root:", extracted);
    375     process.exit(2);
    376   }
    377 
    378   const ts = new Date().toISOString().replace(/[:.]/g, "-");
    379   const backupDir = installDir + "_backup_" + ts;
    380 
    381   // Give the launcher a chance to exit and release handles, then retry rename.
    382   let renamed = false;
    383   for (let i=0;i<80;i++){
    384     try {
    385       if (!exists(installDir)) break;
    386       fs.renameSync(installDir, backupDir);
    387       renamed = true;
    388       break;
    389     } catch (e) {
    390       await sleep(250);
    391     }
    392   }
    393   if (!renamed && exists(installDir)) {
    394     console.error("[bzl-update] Failed to rename install dir after retries.");
    395     process.exit(3);
    396   }
    397 
    398   try {
    399     fs.renameSync(extracted, installDir);
    400   } catch (e) {
    401     console.error("[bzl-update] Rename into place failed, falling back to copy:", e && e.message ? e.message : String(e));
    402     try {
    403       fs.mkdirSync(installDir, { recursive: true });
    404       copyRecursive(extracted, installDir);
    405     } catch (e2) {
    406       console.error("[bzl-update] Copy fallback failed:", e2 && e2.message ? e2.message : String(e2));
    407       process.exit(4);
    408     }
    409   }
    410 
    411   // Cleanup staging (best-effort).
    412   removeRecursive(stageRootDir);
    413   console.log("[bzl-update] Updated successfully. Backup at:", backupDir);
    414 }
    415 
    416 main().catch((e)=>{ console.error("[bzl-update] Fatal:", e && e.stack ? e.stack : String(e)); process.exit(1); });
    417 `;
    418   fs.writeFileSync(helperPath, js.trimStart(), "utf8");
    419 }
    420 
    421 async function stageAndApplyUpdate({ tag } = {}) {
    422   const currentVersion = readPackageVersion();
    423   const info = tag ? await getReleaseByTag(tag) : await getLatestReleaseInfo();
    424   if (!info.asset?.url) return { ok: false, error: "No update asset (.zip) found on the latest GitHub release." };
    425 
    426   const parent = path.dirname(ROOT);
    427   const stageRootDir = path.join(parent, `.bzl_update_stage_${Date.now()}`);
    428   const zipPath = path.join(stageRootDir, "update.zip");
    429   const extractDir = path.join(stageRootDir, "extract");
    430   const extractedRootFinal = path.join(stageRootDir, "extracted-root");
    431   fs.mkdirSync(stageRootDir, { recursive: true });
    432 
    433   pushLog("ui", `update: downloading ${info.asset.name} (${info.asset.size || 0} bytes)`);
    434   await downloadToFile(info.asset.url, zipPath);
    435   pushLog("ui", `update: downloaded to ${zipPath}`);
    436 
    437   pushLog("ui", "update: extracting...");
    438   fs.mkdirSync(extractDir, { recursive: true });
    439   const zip = new AdmZip(zipPath);
    440   zip.extractAllTo(extractDir, true);
    441   const extractedRoot = findExtractedRoot(extractDir);
    442   if (!extractedRoot) return { ok: false, error: "Extracted zip did not look like a Bzl clean-install folder." };
    443 
    444   // Move extracted to a stable dir within the staging root so the helper has a predictable path.
    445   try {
    446     if (path.resolve(extractedRoot) !== path.resolve(extractedRootFinal)) {
    447       fs.renameSync(extractedRoot, extractedRootFinal);
    448     }
    449   } catch {
    450     copyRecursive(extractedRoot, extractedRootFinal, { skipNames: new Set() });
    451   }
    452 
    453   // Preserve local state into the staged build.
    454   try {
    455     if (fs.existsSync(ENV_PATH)) fs.copyFileSync(ENV_PATH, path.join(extractedRootFinal, ".env"));
    456   } catch {
    457     // ignore
    458   }
    459   try {
    460     const srcData = path.join(ROOT, "data");
    461     const dstData = path.join(extractedRootFinal, "data");
    462     copyRecursive(srcData, dstData, { skipNames: new Set() });
    463   } catch {
    464     // ignore
    465   }
    466 
    467   // Write and spawn a helper that swaps the folder after we exit.
    468   const helperPath = path.join(stageRootDir, "apply-update.js");
    469   writeHelperScript({ helperPath, installDir: ROOT, stageRootDir });
    470 
    471   pushLog("ui", "update: stopping services...");
    472   try {
    473     stopTunnel();
    474   } catch {}
    475   try {
    476     stopBzl();
    477   } catch {}
    478 
    479   pushLog("ui", "update: applying (launcher will exit)...");
    480   const child = spawn(process.execPath, [helperPath], { detached: true, stdio: "ignore" });
    481   child.unref();
    482 
    483   return {
    484     ok: true,
    485     currentVersion,
    486     latestVersion: info.version || info.latestVersion,
    487     tag: info.tag || "",
    488     applying: true
    489   };
    490 }
    491 
    492 function escapeHtml(s) {
    493   return String(s || "")
    494     .replaceAll("&", "&amp;")
    495     .replaceAll("<", "&lt;")
    496     .replaceAll(">", "&gt;")
    497     .replaceAll('"', "&quot;")
    498     .replaceAll("'", "&#039;");
    499 }
    500 
    501 function spawnDetached(cmd, args) {
    502   const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
    503   child.on("error", () => {});
    504   child.unref();
    505 }
    506 
    507 function escapePsSingleQuoted(s) {
    508   return `'${String(s || "").replaceAll("'", "''")}'`;
    509 }
    510 
    511 function openUrl(url) {
    512   const u = String(url || "").trim();
    513   if (!u) return;
    514   if (process.platform === "win32") {
    515     const trySpawn = (cmd, args, onFail) => {
    516       try {
    517         const child = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
    518         child.once("error", () => (typeof onFail === "function" ? onFail() : undefined));
    519         child.unref();
    520       } catch {
    521         if (typeof onFail === "function") onFail();
    522       }
    523     };
    524 
    525     // Prefer PowerShell; it avoids some cmd.exe `start` edge-cases.
    526     trySpawn("powershell.exe", ["-NoProfile", "-Command", `Start-Process ${escapePsSingleQuoted(u)}`], () =>
    527       trySpawn("cmd.exe", ["/d", "/s", "/c", "start", "", u], () =>
    528         trySpawn("rundll32.exe", ["url.dll,FileProtocolHandler", u])
    529       )
    530     );
    531     return;
    532   }
    533   if (process.platform === "darwin") {
    534     spawnDetached("open", [u]);
    535     return;
    536   }
    537   spawnDetached("xdg-open", [u]);
    538 }
    539 
    540 function json(res, status, body) {
    541   res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
    542   res.end(JSON.stringify(body));
    543 }
    544 
    545 function getJson(url, { timeoutMs = 1200 } = {}) {
    546   return new Promise((resolve) => {
    547     let settled = false;
    548     const done = (v) => {
    549       if (settled) return;
    550       settled = true;
    551       resolve(v);
    552     };
    553 
    554     try {
    555       const u = new URL(url);
    556       const mod = u.protocol === "https:" ? https : http;
    557       const req = mod.request(
    558         {
    559           method: "GET",
    560           protocol: u.protocol,
    561           hostname: u.hostname,
    562           port: u.port,
    563           path: `${u.pathname || "/"}${u.search || ""}`,
    564           headers: { Accept: "application/json", "Cache-Control": "no-store" }
    565         },
    566         (res) => {
    567           let buf = "";
    568           res.setEncoding("utf8");
    569           res.on("data", (c) => (buf += c));
    570           res.on("end", () => {
    571             const status = Number(res.statusCode || 0);
    572             if (status < 200 || status >= 300) return done({ ok: false, status });
    573             try {
    574               return done({ ok: true, json: JSON.parse(buf || "{}") });
    575             } catch {
    576               return done({ ok: true, json: {} });
    577             }
    578           });
    579         }
    580       );
    581       req.on("error", () => done({ ok: false }));
    582       req.setTimeout(timeoutMs, () => {
    583         try {
    584           req.destroy(new Error("timeout"));
    585         } catch {
    586           // ignore
    587         }
    588         done({ ok: false, timeout: true });
    589       });
    590       req.end();
    591     } catch {
    592       done({ ok: false });
    593     }
    594   });
    595 }
    596 
    597 async function readJsonBody(req) {
    598   return await new Promise((resolve) => {
    599     let buf = "";
    600     req.on("data", (c) => (buf += c));
    601     req.on("end", () => {
    602       try {
    603         resolve(JSON.parse(buf || "{}"));
    604       } catch {
    605         resolve({});
    606       }
    607     });
    608   });
    609 }
    610 
    611 function makeRing(maxLines = 600) {
    612   const lines = [];
    613   return {
    614     push(line) {
    615       lines.push(line);
    616       while (lines.length > maxLines) lines.shift();
    617     },
    618     list() {
    619       return lines.slice();
    620     }
    621   };
    622 }
    623 
    624 const logRing = makeRing(800);
    625 const subscribers = new Set(); // res objects for SSE
    626 
    627 function pushLog(kind, msg) {
    628   const line = `[${nowIso()}] ${kind}: ${msg}`;
    629   logRing.push(line);
    630   for (const res of subscribers) {
    631     try {
    632       res.write(`data: ${JSON.stringify({ line })}\n\n`);
    633     } catch {
    634       // ignore
    635     }
    636   }
    637 }
    638 
    639 function teeChild(child, prefix) {
    640   if (!child) return;
    641   const on = (data, stream) => {
    642     const text = String(data || "");
    643     for (const line of text.split(/\r?\n/)) {
    644       if (!line.trim()) continue;
    645       pushLog(prefix, line);
    646     }
    647     try {
    648       stream.write(data);
    649     } catch {
    650       // ignore
    651     }
    652   };
    653   if (child.stdout) child.stdout.on("data", (d) => on(d, process.stdout));
    654   if (child.stderr) child.stderr.on("data", (d) => on(d, process.stderr));
    655 }
    656 
    657 let bzlChild = null;
    658 let tunnelChild = null;
    659 let cloudflaredSetupChild = null;
    660 let lastCloudflaredCreate = null;
    661 let lastCloudflaredLoginUrl = "";
    662 
    663 function isRunning(child) {
    664   return Boolean(child && !child.killed);
    665 }
    666 
    667 async function healthcheck(url, { timeoutMs = 1200 } = {}) {
    668   const res = await getJson(url, { timeoutMs });
    669   return res?.ok === true && res?.json?.ok === true;
    670 }
    671 
    672 function startBzl({ supervised = true } = {}) {
    673   if (isRunning(bzlChild)) return { ok: true, already: true };
    674   const envFile = readEnvFile();
    675   const childEnv = mergeEnv(process.env, envFile);
    676   const entry = supervised ? path.join(ROOT, "scripts", "service-runner.js") : path.join(ROOT, "server.js");
    677   bzlChild = spawn(process.execPath, [entry], { cwd: ROOT, env: childEnv, stdio: ["ignore", "pipe", "pipe"] });
    678   teeChild(bzlChild, "bzl");
    679   pushLog("ui", `Bzl starting (${supervised ? "supervised" : "direct"})...`);
    680   bzlChild.on("exit", (code, signal) => {
    681     const detail = signal ? `signal=${signal}` : `code=${code}`;
    682     pushLog("ui", `Bzl exited (${detail})`);
    683     bzlChild = null;
    684   });
    685   return { ok: true };
    686 }
    687 
    688 function stopBzl() {
    689   if (!isRunning(bzlChild)) return { ok: true, already: true };
    690   try {
    691     bzlChild.kill("SIGTERM");
    692   } catch {
    693     // ignore
    694   }
    695   return { ok: true };
    696 }
    697 
    698 function startTunnel({ mode = "quick", port }) {
    699   if (isRunning(tunnelChild)) return { ok: true, already: true };
    700   const envFile = readEnvFile();
    701   const childEnv = mergeEnv(process.env, envFile);
    702   const localUrl = `http://localhost:${port}`;
    703 
    704   let args = [];
    705   if (mode === "named") {
    706     const tunnel = normalizeTunnelIdOrName(childEnv.CLOUDFLARED_TUNNEL);
    707     if (!tunnel) return { ok: false, error: "Set CLOUDFLARED_TUNNEL in .env to use named tunnels." };
    708     const cfg = String(childEnv.CLOUDFLARED_CONFIG || "").trim();
    709     args = cfg ? ["--config", cfg, "tunnel", "run", tunnel] : ["tunnel", "run", tunnel];
    710   } else {
    711     args = ["tunnel", "--url", localUrl];
    712   }
    713 
    714   tunnelChild = spawn("cloudflared", args, { cwd: ROOT, env: childEnv, stdio: ["ignore", "pipe", "pipe"] });
    715   teeChild(tunnelChild, "tunnel");
    716   pushLog("ui", `cloudflared starting (${mode})...`);
    717   tunnelChild.on("exit", (code, signal) => {
    718     const detail = signal ? `signal=${signal}` : `code=${code}`;
    719     pushLog("ui", `cloudflared exited (${detail})`);
    720     tunnelChild = null;
    721   });
    722   return { ok: true };
    723 }
    724 
    725 function stopTunnel() {
    726   if (!isRunning(tunnelChild)) return { ok: true, already: true };
    727   try {
    728     tunnelChild.kill("SIGTERM");
    729   } catch {
    730     // ignore
    731   }
    732   return { ok: true };
    733 }
    734 
    735 function getHomeDir() {
    736   return process.env.USERPROFILE || process.env.HOME || "";
    737 }
    738 
    739 function cloudflaredDir() {
    740   const home = getHomeDir();
    741   return home ? path.join(home, ".cloudflared") : "";
    742 }
    743 
    744 function defaultCloudflaredConfigPath() {
    745   const dir = cloudflaredDir();
    746   return dir ? path.join(dir, "config.yml") : "";
    747 }
    748 
    749 function cloudflaredCertPath() {
    750   const dir = cloudflaredDir();
    751   return dir ? path.join(dir, "cert.pem") : "";
    752 }
    753 
    754 function expandEnvVars(p) {
    755   let out = String(p || "").trim();
    756   if (!out) return "";
    757   if (out.startsWith("~")) {
    758     const home = getHomeDir();
    759     if (home) out = path.join(home, out.slice(1));
    760   }
    761   out = out.replaceAll(/%([A-Za-z0-9_]+)%/g, (m, k) => String(process.env[k] || m));
    762   return out;
    763 }
    764 
    765 function parseCreateOutput(output) {
    766   const text = String(output || "");
    767   const uuidMatch = text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
    768   const credMatch =
    769     text.match(/written to\s+(\S+?\.json)/i) ||
    770     text.match(/credentials?-file:\s*(\S+?\.json)/i) ||
    771     text.match(/cred(?:entials)?-file:\s*(\S+?\.json)/i);
    772   return {
    773     tunnelId: uuidMatch ? uuidMatch[0] : "",
    774     credentialsFile: credMatch ? String(credMatch[1] || "").trim().replace(/[.)\]]+$/, "") : ""
    775   };
    776 }
    777 
    778 function guessCredentialsFilePath(tunnelId) {
    779   const dir = cloudflaredDir();
    780   const id = String(tunnelId || "").trim();
    781   if (!dir || !id) return "";
    782   const p = path.join(dir, `${id}.json`);
    783   return fs.existsSync(p) ? p : "";
    784 }
    785 
    786 function parseUuidFromText(s) {
    787   const m = String(s || "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
    788   return m ? m[0] : "";
    789 }
    790 
    791 function normalizeTunnelIdOrName(raw) {
    792   const s = String(raw || "").trim();
    793   if (!s) return "";
    794   const uuid = parseUuidFromText(s);
    795   if (uuid) return uuid;
    796   return s.replace(/\.json$/i, "").trim();
    797 }
    798 
    799 function stripYamlScalar(s) {
    800   return String(s || "")
    801     .trim()
    802     .replace(/^['"]|['"]$/g, "")
    803     .trim();
    804 }
    805 
    806 function readCloudflaredConfigSummary(configPath) {
    807   const cfg = expandEnvVars(configPath || "");
    808   if (!cfg || !fs.existsSync(cfg)) return { ok: false, configPath: cfg };
    809   try {
    810     const raw = fs.readFileSync(cfg, "utf8");
    811     let tunnel = "";
    812     let credentialsFile = "";
    813     let hostname = "";
    814     let service = "";
    815 
    816     for (const line of raw.split(/\r?\n/)) {
    817       const t = line.match(/^\s*tunnel\s*:\s*(.+?)\s*$/i);
    818       if (t && !tunnel) tunnel = stripYamlScalar(t[1]);
    819       const c = line.match(/^\s*credentials-file\s*:\s*(.+?)\s*$/i);
    820       if (c && !credentialsFile) credentialsFile = stripYamlScalar(c[1]);
    821       const h = line.match(/^\s*-\s*hostname\s*:\s*(.+?)\s*$/i);
    822       if (h && !hostname) hostname = stripYamlScalar(h[1]);
    823       const s = line.match(/^\s*service\s*:\s*(.+?)\s*$/i);
    824       if (s && hostname && !service) service = stripYamlScalar(s[1]);
    825     }
    826 
    827     return {
    828       ok: true,
    829       configPath: cfg,
    830       tunnel,
    831       credentialsFile,
    832       hostname,
    833       service
    834     };
    835   } catch {
    836     return { ok: false, configPath: cfg };
    837   }
    838 }
    839 
    840 function writeCloudflaredConfig({ configPath, tunnelId, credentialsFile, hostname, port }) {
    841   const cfg = expandEnvVars(configPath || defaultCloudflaredConfigPath());
    842   if (!cfg) throw new Error("Missing config path.");
    843   const t = String(tunnelId || "").trim();
    844   const cred = expandEnvVars(credentialsFile || "");
    845   const host = String(hostname || "").trim();
    846   const p = Number(port || 0);
    847   if (!t) throw new Error("Missing tunnel id.");
    848   if (!cred) throw new Error("Missing credentials file path.");
    849   if (!host || !host.includes(".")) throw new Error("Invalid hostname (example: bzl.example.com).");
    850   if (!Number.isFinite(p) || p < 1 || p > 65535) throw new Error("Invalid port.");
    851 
    852   fs.mkdirSync(path.dirname(cfg), { recursive: true });
    853   const yaml = [
    854     `tunnel: ${t}`,
    855     `credentials-file: ${cred}`,
    856     `ingress:`,
    857     `  - hostname: ${host}`,
    858     `    service: http://localhost:${Math.floor(p)}`,
    859     `  - service: http_status:404`,
    860     ``
    861   ].join("\n");
    862   fs.writeFileSync(cfg, yaml, "utf8");
    863   return cfg;
    864 }
    865 
    866 function runCloudflaredCommand(args, { label = "cloudflared", capture = false, quiet = false } = {}) {
    867   if (isRunning(cloudflaredSetupChild)) return Promise.resolve({ ok: false, error: "Another cloudflared command is running." });
    868   return new Promise((resolve) => {
    869     let output = "";
    870     try {
    871       cloudflaredSetupChild = spawn("cloudflared", args, { cwd: ROOT, env: mergeEnv(process.env, readEnvFile()), stdio: ["ignore", "pipe", "pipe"] });
    872     } catch (e) {
    873       cloudflaredSetupChild = null;
    874       return resolve({ ok: false, error: e?.message || String(e) });
    875     }
    876 
    877     const child = cloudflaredSetupChild;
    878     if (!quiet) pushLog("ui", `${label}: cloudflared ${args.join(" ")}`);
    879     const onData = (d) => {
    880       if (capture) output += String(d || "");
    881     };
    882     if (child.stdout) child.stdout.on("data", onData);
    883     if (child.stderr) child.stderr.on("data", onData);
    884     if (!quiet) teeChild(child, "cf");
    885     child.on("error", (e) => {
    886       if (!quiet) pushLog("ui", `${label}: failed to start (${e?.message || e})`);
    887     });
    888     child.on("exit", (code, signal) => {
    889       const detail = signal ? `signal=${signal}` : `code=${code}`;
    890       if (!quiet) pushLog("ui", `${label}: exited (${detail})`);
    891       cloudflaredSetupChild = null;
    892       resolve({ ok: Number(code || 0) === 0, code: Number(code || 0), output });
    893     });
    894   });
    895 }
    896 
    897 function startCloudflaredLogin() {
    898   if (isRunning(cloudflaredSetupChild)) return { ok: false, error: "Another cloudflared command is running." };
    899   const cert = cloudflaredCertPath();
    900   if (cert && fs.existsSync(cert)) {
    901     pushLog("ui", `login: already logged in (cert exists at ${cert})`);
    902     pushLog("ui", `login: to switch accounts, move/delete cert.pem then press Login again`);
    903     return { ok: true, alreadyLoggedIn: true, certPath: cert };
    904   }
    905   const args = ["tunnel", "login"];
    906   let opened = false;
    907   let buf = "";
    908 
    909   try {
    910     cloudflaredSetupChild = spawn("cloudflared", args, {
    911       cwd: ROOT,
    912       env: mergeEnv(process.env, readEnvFile()),
    913       stdio: ["ignore", "pipe", "pipe"]
    914     });
    915   } catch (e) {
    916     cloudflaredSetupChild = null;
    917     return { ok: false, error: e?.message || String(e) };
    918   }
    919 
    920   const child = cloudflaredSetupChild;
    921   pushLog("ui", `login: cloudflared ${args.join(" ")}`);
    922 
    923   const maybeOpenFromText = (text) => {
    924     buf += String(text || "");
    925     const lines = buf.split(/\r?\n/);
    926     buf = lines.pop() || "";
    927     for (const line of lines) {
    928       const m = String(line || "").match(/https?:\/\/\S+/i);
    929       if (m) {
    930         lastCloudflaredLoginUrl = m[0];
    931         if (!opened) {
    932           opened = true;
    933           openUrl(m[0]);
    934         }
    935       }
    936     }
    937   };
    938 
    939   if (child.stdout) child.stdout.on("data", (d) => maybeOpenFromText(String(d || "")));
    940   if (child.stderr) child.stderr.on("data", (d) => maybeOpenFromText(String(d || "")));
    941 
    942   teeChild(child, "cf");
    943   child.on("error", (e) => pushLog("ui", `login: failed to start (${e?.message || e})`));
    944   child.on("exit", (code, signal) => {
    945     const detail = signal ? `signal=${signal}` : `code=${code}`;
    946     pushLog("ui", `login: exited (${detail})`);
    947     cloudflaredSetupChild = null;
    948   });
    949 
    950   return { ok: true, started: true };
    951 }
    952 
    953 async function probeCloudflared() {
    954   if (!probeCloudflared.cache) probeCloudflared.cache = { ts: 0, result: { ok: false, exists: false, version: "" } };
    955   if (Date.now() - probeCloudflared.cache.ts < 10_000) return probeCloudflared.cache.result;
    956 
    957   const res = await runCloudflaredCommand(["--version"], { label: "probe", capture: true, quiet: true }).catch(() => ({
    958     ok: false,
    959     output: ""
    960   }));
    961   const out = String(res?.output || "").trim();
    962   if (!res?.ok) {
    963     const result = { ok: false, exists: false, version: "" };
    964     probeCloudflared.cache = { ts: Date.now(), result };
    965     return result;
    966   }
    967   const line = out.split(/\r?\n/).find((l) => l.toLowerCase().includes("cloudflared")) || out.split(/\r?\n/)[0] || "";
    968   const result = { ok: true, exists: true, version: line.trim() };
    969   probeCloudflared.cache = { ts: Date.now(), result };
    970   return result;
    971 }
    972 
    973 function extractJsonArray(text) {
    974   const s = String(text || "");
    975   const start = s.indexOf("[");
    976   const end = s.lastIndexOf("]");
    977   if (start === -1 || end === -1 || end < start) return null;
    978   const chunk = s.slice(start, end + 1);
    979   try {
    980     return JSON.parse(chunk);
    981   } catch {
    982     return null;
    983   }
    984 }
    985 
    986 async function listCloudflaredTunnels() {
    987   const r = await runCloudflaredCommand(["tunnel", "list", "--output", "json"], { label: "tunnels", capture: true, quiet: true });
    988   if (!r.ok) return { ok: false, code: r.code, tunnels: [], error: "cloudflared tunnel list failed" };
    989   const arr = extractJsonArray(r.output);
    990   if (!Array.isArray(arr)) return { ok: false, code: r.code, tunnels: [], error: "Failed to parse tunnel list output." };
    991   const tunnels = arr
    992     .map((t) => ({
    993       id: String(t?.id || t?.ID || "").trim(),
    994       name: String(t?.name || t?.Name || "").trim()
    995     }))
    996     .filter((t) => t.id && t.name);
    997   tunnels.sort((a, b) => a.name.localeCompare(b.name));
    998   return { ok: true, code: 0, tunnels };
    999 }
   1000 
   1001 function listCloudflaredFiles() {
   1002   const dir = cloudflaredDir();
   1003   const out = {
   1004     dir,
   1005     certPath: cloudflaredCertPath(),
   1006     certExists: false,
   1007     configCandidates: [],
   1008     credentials: []
   1009   };
   1010   if (out.certPath) out.certExists = fs.existsSync(out.certPath);
   1011   if (!dir || !fs.existsSync(dir)) return out;
   1012 
   1013   try {
   1014     const items = fs.readdirSync(dir, { withFileTypes: true });
   1015     for (const it of items) {
   1016       if (!it.isFile()) continue;
   1017       const name = it.name;
   1018       const abs = path.join(dir, name);
   1019       const lower = name.toLowerCase();
   1020       if (lower === "config.yml" || lower === "config.yaml") out.configCandidates.push(abs);
   1021       if (lower.endsWith(".json")) {
   1022         const m = name.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/i);
   1023         out.credentials.push({ path: abs, tunnelId: m ? m[1] : "", name });
   1024       }
   1025     }
   1026   } catch {
   1027     // ignore
   1028   }
   1029 
   1030   out.configCandidates.sort((a, b) => a.localeCompare(b));
   1031   out.credentials.sort((a, b) => (a.tunnelId || a.name).localeCompare(b.tunnelId || b.name));
   1032   return out;
   1033 }
   1034 
   1035 function writeEnvKeyValues(pairs) {
   1036   const existing = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8") : "";
   1037   const lines = existing.split(/\r?\n/);
   1038   const map = new Map();
   1039   for (const [k, v] of Object.entries(pairs || {})) {
   1040     const key = String(k || "").trim().toUpperCase();
   1041     if (!key) continue;
   1042     map.set(key, String(v ?? ""));
   1043   }
   1044   const seen = new Set();
   1045   const out = [];
   1046   for (const line of lines) {
   1047     const m = String(line || "").match(/^([A-Z0-9_]+)=(.*)$/);
   1048     if (!m) {
   1049       out.push(line);
   1050       continue;
   1051     }
   1052     const key = m[1];
   1053     if (!map.has(key)) {
   1054       out.push(line);
   1055       continue;
   1056     }
   1057     const val = map.get(key);
   1058     seen.add(key);
   1059     out.push(`${key}=${val}`);
   1060   }
   1061   for (const [key, val] of map.entries()) {
   1062     if (seen.has(key)) continue;
   1063     out.push(`${key}=${val}`);
   1064   }
   1065   fs.writeFileSync(ENV_PATH, out.join("\n").trimEnd() + "\n", "utf8");
   1066 }
   1067 
   1068 function renderHtmlTemplate() {
   1069   const tpl = fs.readFileSync(HTML_PATH, "utf8");
   1070   const env = readEnvFile();
   1071   return tpl
   1072     .replaceAll("__PORT__", escapeHtml(String(getConfiguredPort(env))))
   1073     .replaceAll("__HOST__", escapeHtml(String(getConfiguredHost(env))))
   1074     .replaceAll("__REGISTRATION_CODE__", escapeHtml(String(env.REGISTRATION_CODE || "")))
   1075     .replaceAll("__CLOUDFLARED_TUNNEL__", escapeHtml(String(env.CLOUDFLARED_TUNNEL || "")))
   1076     .replaceAll("__CLOUDFLARED_CONFIG__", escapeHtml(String(env.CLOUDFLARED_CONFIG || "")))
   1077     .replaceAll("__API_TOKEN__", escapeHtml(apiToken));
   1078 }
   1079 
   1080 function requireLocal(req) {
   1081   const ip = String(req.socket.remoteAddress || "");
   1082   return ip === "127.0.0.1" || ip === "::1" || ip.endsWith("127.0.0.1");
   1083 }
   1084 
   1085 function requireToken(req) {
   1086   const h = String(req.headers["x-bzl-launcher"] || "");
   1087   return h === apiToken;
   1088 }
   1089 
   1090 function withTokenHeaders(res) {
   1091   res.setHeader("X-Bzl-Launcher", apiToken);
   1092 }
   1093 
   1094 const srv = http.createServer(async (req, res) => {
   1095   const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
   1096   if (!requireLocal(req)) {
   1097     res.writeHead(403);
   1098     res.end("Forbidden");
   1099     return;
   1100   }
   1101 
   1102   if (req.method === "GET" && url.pathname === "/") {
   1103     res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
   1104     res.end(renderHtmlTemplate());
   1105     return;
   1106   }
   1107 
   1108   if (req.method === "GET" && url.pathname === "/api/log") {
   1109     json(res, 200, { ok: true, lines: logRing.list() });
   1110     return;
   1111   }
   1112 
   1113   if (req.method === "GET" && url.pathname === "/api/log/stream") {
   1114     res.writeHead(200, {
   1115       "Content-Type": "text/event-stream",
   1116       "Cache-Control": "no-store",
   1117       Connection: "keep-alive"
   1118     });
   1119     subscribers.add(res);
   1120     req.on("close", () => subscribers.delete(res));
   1121     return;
   1122   }
   1123 
   1124   if (url.pathname.startsWith("/api/") && req.method !== "GET") {
   1125     if (!requireToken(req)) {
   1126       json(res, 401, { ok: false, error: "Missing token." });
   1127       return;
   1128     }
   1129   }
   1130 
   1131   if (req.method === "GET" && url.pathname === "/api/status") {
   1132     const env = readEnvFile();
   1133     const port = getConfiguredPort(env);
   1134     const localUrl = `http://localhost:${port}`;
   1135     const healthy = await healthcheck(`http://127.0.0.1:${port}/api/health`);
   1136     const namedHostname = readCloudflaredConfigSummary(expandEnvVars(String(env.CLOUDFLARED_CONFIG || "")) || defaultCloudflaredConfigPath())?.hostname || "";
   1137     json(res, 200, {
   1138       ok: true,
   1139       serverRunning: isRunning(bzlChild),
   1140       tunnelRunning: isRunning(tunnelChild),
   1141       localUrl,
   1142       healthy,
   1143       namedPublicUrl: namedHostname ? `https://${namedHostname}` : "",
   1144       version: readPackageVersion()
   1145     });
   1146     return;
   1147   }
   1148 
   1149   if (req.method === "GET" && url.pathname === "/api/update/status") {
   1150     try {
   1151       const currentVersion = readPackageVersion();
   1152       const latest = await getLatestReleaseInfo();
   1153       const available = latest.latestVersion && compareVersions(latest.latestVersion, currentVersion) > 0;
   1154       json(res, 200, { ok: true, currentVersion, available, ...latest });
   1155     } catch (e) {
   1156       json(res, 200, { ok: false, error: e?.message || String(e) });
   1157     }
   1158     return;
   1159   }
   1160 
   1161   if (req.method === "GET" && url.pathname === "/api/update/releases") {
   1162     try {
   1163       const currentVersion = readPackageVersion();
   1164       const list = await getReleasesList({ perPage: 20 });
   1165       json(res, 200, { ok: true, currentVersion, ...list });
   1166     } catch (e) {
   1167       json(res, 200, { ok: false, error: e?.message || String(e) });
   1168     }
   1169     return;
   1170   }
   1171 
   1172   if (req.method === "POST" && url.pathname === "/api/update/apply") {
   1173     withTokenHeaders(res);
   1174     try {
   1175       const body = await readJsonBody(req);
   1176       const tag = String(body?.tag || "").trim();
   1177       const r = await stageAndApplyUpdate({ tag: tag || "" });
   1178       json(res, 200, r);
   1179       if (r.ok && r.applying) {
   1180         setTimeout(() => process.exit(0), 750);
   1181       }
   1182     } catch (e) {
   1183       json(res, 500, { ok: false, error: e?.message || String(e) });
   1184     }
   1185     return;
   1186   }
   1187 
   1188   if (req.method === "GET" && url.pathname === "/api/cloudflared/status") {
   1189     const env = readEnvFile();
   1190     const port = getConfiguredPort(env);
   1191     const probe = await probeCloudflared();
   1192     const cfgPath = expandEnvVars(String(env.CLOUDFLARED_CONFIG || "")) || defaultCloudflaredConfigPath();
   1193     const cert = cloudflaredCertPath();
   1194     const cfgSummary = readCloudflaredConfigSummary(cfgPath);
   1195     const expectedCred = guessCredentialsFilePath(String(env.CLOUDFLARED_TUNNEL || ""));
   1196     json(res, 200, {
   1197       ok: true,
   1198       exists: probe.exists,
   1199       version: probe.version,
   1200       setupRunning: isRunning(cloudflaredSetupChild),
   1201       certPath: cert,
   1202       certExists: cert ? fs.existsSync(cert) : false,
   1203       lastLoginUrl: lastCloudflaredLoginUrl || "",
   1204       defaultConfigPath: defaultCloudflaredConfigPath(),
   1205       configPath: cfgPath,
   1206       configExists: cfgPath ? fs.existsSync(cfgPath) : false,
   1207       envTunnel: String(env.CLOUDFLARED_TUNNEL || ""),
   1208       envConfig: String(env.CLOUDFLARED_CONFIG || ""),
   1209       configTunnel: cfgSummary?.ok ? cfgSummary.tunnel : "",
   1210       configCredentialsFile: cfgSummary?.ok ? cfgSummary.credentialsFile : "",
   1211       configHostname: cfgSummary?.ok ? cfgSummary.hostname : "",
   1212       configService: cfgSummary?.ok ? cfgSummary.service : "",
   1213       expectedCredentialsFile: expectedCred,
   1214       expectedCredentialsFileExists: expectedCred ? fs.existsSync(expectedCred) : false,
   1215       configCredentialsFileExists: cfgSummary?.ok && cfgSummary.credentialsFile ? fs.existsSync(expandEnvVars(cfgSummary.credentialsFile)) : false,
   1216       suggestedLocalUrl: `http://localhost:${port}`,
   1217       lastCreate: lastCloudflaredCreate || null
   1218     });
   1219     return;
   1220   }
   1221 
   1222   if (req.method === "GET" && url.pathname === "/api/cloudflared/files") {
   1223     const f = listCloudflaredFiles();
   1224     json(res, 200, { ok: true, ...f });
   1225     return;
   1226   }
   1227 
   1228   if (req.method === "GET" && url.pathname === "/api/cloudflared/tunnels") {
   1229     const r = await listCloudflaredTunnels();
   1230     json(res, 200, r);
   1231     return;
   1232   }
   1233 
   1234   if (req.method === "POST" && url.pathname === "/api/start") {
   1235     const body = await readJsonBody(req);
   1236     const supervised = body?.supervised !== false;
   1237     withTokenHeaders(res);
   1238     json(res, 200, startBzl({ supervised }));
   1239     return;
   1240   }
   1241 
   1242   if (req.method === "POST" && url.pathname === "/api/stop") {
   1243     withTokenHeaders(res);
   1244     json(res, 200, stopBzl());
   1245     return;
   1246   }
   1247 
   1248   if (req.method === "POST" && url.pathname === "/api/open") {
   1249     const env = readEnvFile();
   1250     const port = getConfiguredPort(env);
   1251     openUrl(`http://localhost:${port}`);
   1252     withTokenHeaders(res);
   1253     json(res, 200, { ok: true });
   1254     return;
   1255   }
   1256 
   1257   if (req.method === "POST" && url.pathname === "/api/tunnel/start") {
   1258     const body = await readJsonBody(req);
   1259     const env = readEnvFile();
   1260     const port = getConfiguredPort(env);
   1261     const mode = String(body?.mode || "quick") === "named" ? "named" : "quick";
   1262     withTokenHeaders(res);
   1263     json(res, 200, startTunnel({ mode, port }));
   1264     return;
   1265   }
   1266 
   1267   if (req.method === "POST" && url.pathname === "/api/cloudflared/cancel") {
   1268     const ok = isRunning(cloudflaredSetupChild);
   1269     try {
   1270       if (cloudflaredSetupChild && !cloudflaredSetupChild.killed) cloudflaredSetupChild.kill("SIGTERM");
   1271     } catch {
   1272       // ignore
   1273     }
   1274     withTokenHeaders(res);
   1275     json(res, 200, { ok: true, canceled: ok });
   1276     return;
   1277   }
   1278 
   1279   if (req.method === "POST" && url.pathname === "/api/cloudflared/login") {
   1280     withTokenHeaders(res);
   1281     json(res, 200, startCloudflaredLogin());
   1282     return;
   1283   }
   1284 
   1285   if (req.method === "POST" && url.pathname === "/api/cloudflared/create") {
   1286     const body = await readJsonBody(req);
   1287     const name = String(body?.name || "").trim();
   1288     if (!name) {
   1289       withTokenHeaders(res);
   1290       json(res, 400, { ok: false, error: "Missing tunnel name." });
   1291       return;
   1292     }
   1293     const r = await runCloudflaredCommand(["tunnel", "create", name], { label: `create:${name}`, capture: true });
   1294     const parsed = parseCreateOutput(r.output);
   1295     let ok = r.ok;
   1296     const result = { name, ...parsed };
   1297 
   1298     if (!ok) {
   1299       const out = String(r.output || "");
   1300       if (/tunnel with name already exists/i.test(out) || /name already exists/i.test(out)) {
   1301         const list = await listCloudflaredTunnels();
   1302         const found = list.ok ? (list.tunnels || []).find((t) => t.name === name) : null;
   1303         if (found?.id) {
   1304           ok = true;
   1305           result.tunnelId = found.id;
   1306           result.credentialsFile = result.credentialsFile || guessCredentialsFilePath(found.id) || "";
   1307           result.reused = true;
   1308           pushLog("ui", `create:${name}: tunnel already exists; using ${found.id}`);
   1309         }
   1310       }
   1311     }
   1312 
   1313     if (ok) {
   1314       lastCloudflaredCreate = { ...result };
   1315       try {
   1316         const pairs = {
   1317           CLOUDFLARED_TUNNEL: result.tunnelId || name,
   1318           CLOUDFLARED_CONFIG: defaultCloudflaredConfigPath()
   1319         };
   1320         writeEnvKeyValues(pairs);
   1321         pushLog("ui", "Saved .env (CLOUDFLARED_TUNNEL / CLOUDFLARED_CONFIG)");
   1322       } catch {
   1323         // ignore
   1324       }
   1325     }
   1326     withTokenHeaders(res);
   1327     json(res, 200, { ok, code: ok ? 0 : r.code, ...result });
   1328     return;
   1329   }
   1330 
   1331   if (req.method === "POST" && url.pathname === "/api/cloudflared/route-dns") {
   1332     const body = await readJsonBody(req);
   1333     const hostname = String(body?.hostname || "").trim();
   1334     const tunnel = String(body?.tunnel || "").trim();
   1335     const force = body?.force !== false;
   1336     if (!hostname || !hostname.includes(".")) {
   1337       withTokenHeaders(res);
   1338       json(res, 400, { ok: false, error: "Invalid hostname (example: bzl.example.com)." });
   1339       return;
   1340     }
   1341     if (!tunnel) {
   1342       withTokenHeaders(res);
   1343       json(res, 400, { ok: false, error: "Missing tunnel (name or UUID)." });
   1344       return;
   1345     }
   1346     withTokenHeaders(res);
   1347     // cloudflared disables interspersed flags for this command; flags must come before positional args.
   1348     const args = ["tunnel", "route", "dns"];
   1349     if (force) args.push("--overwrite-dns");
   1350     args.push(tunnel, hostname);
   1351     json(res, 200, await runCloudflaredCommand(args, { label: `route:${hostname}` }));
   1352     return;
   1353   }
   1354 
   1355   if (req.method === "POST" && url.pathname === "/api/cloudflared/write-config") {
   1356     const body = await readJsonBody(req);
   1357     const env = readEnvFile();
   1358     const port = Number(body?.port || getConfiguredPort(env));
   1359     const hostname = String(body?.hostname || "").trim();
   1360     const tunnelId = String(body?.tunnelId || env.CLOUDFLARED_TUNNEL || lastCloudflaredCreate?.tunnelId || "").trim();
   1361     const credentialsFile =
   1362       String(body?.credentialsFile || env.CLOUDFLARED_CREDS || lastCloudflaredCreate?.credentialsFile || guessCredentialsFilePath(tunnelId) || "").trim();
   1363     const configPath = String(body?.configPath || env.CLOUDFLARED_CONFIG || defaultCloudflaredConfigPath() || "").trim();
   1364 
   1365     try {
   1366       // If a credentials JSON is selected, prefer its UUID to avoid "Invalid tunnel secret" mismatches.
   1367       const uuidFromCreds = parseUuidFromText(credentialsFile);
   1368       const effectiveTunnelId = uuidFromCreds || tunnelId;
   1369       if (uuidFromCreds && tunnelId && uuidFromCreds !== tunnelId) {
   1370         pushLog("ui", `cloudflared: tunnel/creds mismatch; using ${uuidFromCreds} from credentials file`);
   1371       }
   1372 
   1373       const writtenTo = writeCloudflaredConfig({ configPath, tunnelId: effectiveTunnelId, credentialsFile, hostname, port });
   1374       writeEnvKeyValues({ CLOUDFLARED_TUNNEL: effectiveTunnelId, CLOUDFLARED_CONFIG: writtenTo });
   1375       pushLog("ui", `Wrote cloudflared config: ${writtenTo}`);
   1376       withTokenHeaders(res);
   1377       json(res, 200, { ok: true, configPath: writtenTo });
   1378     } catch (e) {
   1379       withTokenHeaders(res);
   1380       json(res, 400, { ok: false, error: e?.message || String(e) });
   1381     }
   1382     return;
   1383   }
   1384 
   1385   if (req.method === "POST" && url.pathname === "/api/tunnel/stop") {
   1386     withTokenHeaders(res);
   1387     json(res, 200, stopTunnel());
   1388     return;
   1389   }
   1390 
   1391   if (req.method === "POST" && url.pathname === "/api/config") {
   1392     const body = await readJsonBody(req);
   1393     const pairs = {};
   1394     for (const k of ["PORT", "HOST", "REGISTRATION_CODE", "CLOUDFLARED_TUNNEL", "CLOUDFLARED_CONFIG"]) {
   1395       if (k in (body || {})) pairs[k] = String(body[k] ?? "");
   1396     }
   1397     if ("CLOUDFLARED_TUNNEL" in pairs) pairs.CLOUDFLARED_TUNNEL = normalizeTunnelIdOrName(pairs.CLOUDFLARED_TUNNEL);
   1398     try {
   1399       writeEnvKeyValues(pairs);
   1400       pushLog("ui", "Saved .env");
   1401       withTokenHeaders(res);
   1402       json(res, 200, { ok: true });
   1403     } catch (e) {
   1404       json(res, 500, { ok: false, error: e?.message || String(e) });
   1405     }
   1406     return;
   1407   }
   1408 
   1409   res.writeHead(404);
   1410   res.end("Not found");
   1411 });
   1412 
   1413 srv.on("error", (err) => {
   1414   const code = String(err?.code || "");
   1415   const url = `http://${UI_HOST}:${UI_PORT}`;
   1416   if (code === "EADDRINUSE") {
   1417     console.error(`[launcher-ui] Port ${UI_PORT} is already in use.`);
   1418     console.error(`[launcher-ui] If another Launcher UI is already running, open: ${url}`);
   1419     appendCrashLog(`listen EADDRINUSE on ${url}`);
   1420     openUrl(url);
   1421     process.exit(0);
   1422     return;
   1423   }
   1424   const msg = err?.stack || err?.message || String(err);
   1425   console.error("[launcher-ui] Failed to start:", msg);
   1426   appendCrashLog(`listen error: ${msg}`);
   1427   process.exit(1);
   1428 });
   1429 
   1430 srv.listen(UI_PORT, UI_HOST, () => {
   1431   const url = `http://${UI_HOST}:${UI_PORT}`;
   1432   pushLog("ui", `Launcher UI running at ${url}`);
   1433   pushLog("ui", `Token header: X-Bzl-Launcher: ${apiToken}`);
   1434   pushLog("ui", `Root: ${ROOT}`);
   1435   pushLog("ui", `CWD: ${process.cwd()}`);
   1436   console.log(`[launcher-ui] Running at ${url}`);
   1437   console.log("[launcher-ui] If it doesn't auto-open, paste the URL into your browser.");
   1438   console.log(`[launcher-ui] Root: ${ROOT}`);
   1439   console.log(`[launcher-ui] CWD: ${process.cwd()}`);
   1440   openUrl(url);
   1441 });
   1442 
   1443 process.on("SIGINT", () => process.exit(0));
   1444 process.on("SIGTERM", () => process.exit(0));