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("&", "&") 495 .replaceAll("<", "<") 496 .replaceAll(">", ">") 497 .replaceAll('"', """) 498 .replaceAll("'", "'"); 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));