// Single public entrypoint for HF Spaces: dashboard + reverse proxy to OpenClaw + JupyterLab. const http = require("http"); const https = require("https"); const fs = require("fs"); const net = require("net"); function isTrue(value) { return /^(true|1|yes|on)$/i.test(String(value || "").trim()); } function normalizeBase(value, fallback) { const raw = String(value || fallback || "").trim() || fallback; if (!raw) return fallback; const base = raw.startsWith("/") ? raw : `/${raw}`; return base.replace(/\/+$/, "") || fallback; } const PORT = Number.parseInt(process.env.PORT || "7861", 10); const GATEWAY_PORT = Number.parseInt(process.env.GATEWAY_PORT || "7860", 10); const GATEWAY_HOST = "127.0.0.1"; const JUPYTER_PORT = Number.parseInt(process.env.JUPYTER_PORT || "8888", 10); const JUPYTER_HOST = "127.0.0.1"; const JUPYTER_BASE = normalizeBase(process.env.JUPYTER_BASE, "/terminal"); const GATEWAY_TOKEN = (process.env.GATEWAY_TOKEN || "").trim(); const DEV_MODE_ENABLED = isTrue(process.env.DEV_MODE); // Default true. Only false when DEV_MODE=false or HUGGINGCLAW_JUPYTER_ENABLED=false is explicitly set. const JUPYTER_ENABLED = !/^(false|0|no|off)$/i.test(String(process.env.DEV_MODE || "").trim()) && !/^(false|0|no|off)$/i.test(String(process.env.HUGGINGCLAW_JUPYTER_ENABLED || "").trim()); const startTime = Date.now(); const LLM_MODEL = process.env.LLM_MODEL || "Not Set"; const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN; const WHATSAPP_ENABLED = isTrue(process.env.WHATSAPP_ENABLED); const WHATSAPP_STATUS_FILE = "/tmp/huggingclaw-wa-status.json"; const HF_BACKUP_ENABLED = !!process.env.HF_TOKEN; const SYNC_INTERVAL = (process.env.SYNC_INTERVAL || "180").trim() || "180"; const BACKUP_DATASET_NAME = (process.env.BACKUP_DATASET_NAME || process.env.BACKUP_DATASET || "huggingclaw-backup").trim() || "huggingclaw-backup"; const DEVDATA_DATASET_NAME = (process.env.DEVDATA_DATASET_NAME || "huggingclaw-devdata").trim() || "huggingclaw-devdata"; const DEVDATA_SYNC_INTERVAL = (process.env.DEVDATA_SYNC_INTERVAL || "180").trim() || "180"; const DEVDATA_SEPARATE_DATASET = DEVDATA_DATASET_NAME !== BACKUP_DATASET_NAME; const DEVDATA_ENABLED = JUPYTER_ENABLED && HF_BACKUP_ENABLED && DEVDATA_SEPARATE_DATASET && !/^(off|false|0|no)$/i.test((process.env.DEVDATA || "on").trim()); const APP_BASE = normalizeBase(process.env.APP_BASE, "/app"); const SYNC_STATUS_FILE = "/tmp/sync-status.json"; // ── Private Space redirect support ── // HF automatically sets SPACE_ID as "username/spacename" in every Space container. const SPACE_ID = (process.env.SPACE_ID || "").trim(); function deriveHfSpaceUrl() { if (SPACE_ID) return `https://huggingface.co/spaces/${SPACE_ID}`; const host = (process.env.SPACE_HOST || "").replace(/\.hf\.space$/i, ""); const author = (process.env.SPACE_AUTHOR_NAME || "").trim().toLowerCase(); if (author && host.toLowerCase().startsWith(author + "-")) { const spaceName = host.slice(author.length + 1); return `https://huggingface.co/spaces/${process.env.SPACE_AUTHOR_NAME}/${spaceName}`; } return ""; } const HF_SPACE_URL = deriveHfSpaceUrl(); // Auto-detect space privacy via HF API at startup. // Caches result so every request doesn't hit the API. let SPACE_IS_PRIVATE = false; async function detectSpacePrivacy() { if (!SPACE_ID) return; try { const token = (process.env.HF_TOKEN || "").trim(); const reqOptions = { hostname: "huggingface.co", path: `/api/spaces/${SPACE_ID}`, method: "GET", headers: Object.assign( { "User-Agent": "HuggingClaw/health-server" }, token ? { Authorization: `Bearer ${token}` } : {} ), }; await new Promise((resolve) => { const r = https.request(reqOptions, (res) => { let body = ""; res.on("data", (chunk) => { body += chunk; }); res.on("end", () => { try { if (res.statusCode === 200) { const data = JSON.parse(body); SPACE_IS_PRIVATE = data.private === true; } else if (res.statusCode === 404 && !token) { // 404 with no token usually means private space SPACE_IS_PRIVATE = true; } } catch {} resolve(); }); }); r.on("error", resolve); r.setTimeout(5000, () => { r.destroy(); resolve(); }); r.end(); }); console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`); } catch { // Network error — default to false (safe) } } detectSpacePrivacy(); const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingclaw-cloudflare-keepalive-status.json"; function parseRequestUrl(url) { try { return new URL(url, "http://localhost"); } catch { return new URL("http://localhost/"); } } function getSyncStatus() { try { if (fs.existsSync(SYNC_STATUS_FILE)) return JSON.parse(fs.readFileSync(SYNC_STATUS_FILE, "utf8")); } catch {} if (HF_BACKUP_ENABLED) return { status: "configured", message: `Backup enabled. Waiting for sync window (${SYNC_INTERVAL}s).` }; return { status: "unknown", message: "No sync data yet" }; } function readGuardianStatus() { if (!WHATSAPP_ENABLED) return { configured: false, connected: false, pairing: false }; try { if (fs.existsSync(WHATSAPP_STATUS_FILE)) { const p = JSON.parse(fs.readFileSync(WHATSAPP_STATUS_FILE, "utf8")); return { configured: p.configured !== false, connected: p.connected === true, pairing: p.pairing === true }; } } catch {} return { configured: true, connected: false, pairing: false }; } function getKeepaliveStatus() { try { if (fs.existsSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE)) return JSON.parse(fs.readFileSync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, "utf8")); } catch {} return null; } function probePort(host, port, path, timeoutMs = 1500) { return new Promise((resolve) => { const req = http.get({ hostname: host, port, path, timeout: timeoutMs }, (res) => { res.resume(); resolve(res.statusCode >= 200 && res.statusCode < 400); }); req.on("timeout", () => { req.destroy(); resolve(false); }); req.on("error", () => resolve(false)); }); } function formatUptime(ms) { const t = Math.floor(ms / 1000); const d = Math.floor(t / 86400), h = Math.floor((t % 86400) / 3600), m = Math.floor((t % 3600) / 60); if (d) return `${d}d ${h}h ${m}m`; if (h) return `${h}h ${m}m`; return `${m}m`; } function escapeHtml(v) { return String(v).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); } function parseCookies(req) { const h = req.headers.cookie || ""; return Object.fromEntries(h.split(";").map(c => c.trim().split("=")).filter(p => p.length >= 2).map(([k, ...v]) => [k.trim(), decodeURIComponent(v.join("=").trim())])); } // Constant-time comparison — prevent timing attacks on token check function safeEqual(a, b) { if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false; let d = 0; for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i); return d === 0; } function isEnvBuilderAuthed(req) { if (!GATEWAY_TOKEN) return true; // unprotected when no token set return safeEqual(parseCookies(req).hc_env_auth || "", GATEWAY_TOKEN); } function readBody(req) { return new Promise((resolve) => { let body = ""; req.on("data", chunk => { body += chunk; if (body.length > 4096) { body = ""; req.destroy(); } }); req.on("end", () => resolve(body)); req.on("error", () => resolve("")); }); } function renderEnvBuilderLogin(error = false) { return ` HuggingClaw — Env Builder

⚙️ Env Builder

Enter your GATEWAY_TOKEN to continue

${error ? '

Invalid token — try again

' : ""}
`; } function badge(label, tone = "neutral") { return `${escapeHtml(label)}`; } function tile({ title, value, detail = "", tone = "neutral", meta = "" }) { return `
${escapeHtml(title)}
${value}
${detail ? `
${detail}
` : ""} ${meta ? `
${meta}
` : ""}
`; } function renderDashboard(data) { const syncStatus = String(data.sync?.status || "unknown"); const syncTone = ["success","restored","synced","configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral"; const kaConf = data.keepalive?.configured === true; const kaStatus = String(data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured")); const kaTone = kaConf ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral"; const tiles = [ tile({ title: "Gateway", value: badge(data.gatewayReady ? "Online" : "Offline", data.gatewayReady ? "ok" : "off"), detail: `OpenClaw on internal port ${GATEWAY_PORT}`, tone: data.gatewayReady ? "ok" : "off" }), tile({ title: "Model", value: `${escapeHtml(LLM_MODEL)}`, detail: "Primary LLM configured", tone: "neutral" }), tile({ title: "Runtime", value: escapeHtml(data.uptimeHuman), detail: `Public port ${PORT}`, tone: "neutral" }), tile({ title: "Telegram", value: badge(TELEGRAM_ENABLED ? "Enabled" : "Disabled", TELEGRAM_ENABLED ? "ok" : "neutral"), detail: TELEGRAM_ENABLED ? "Bot channel active" : "Not configured", tone: TELEGRAM_ENABLED ? "ok" : "neutral" }), ]; tiles.push( tile({ title: "Backup", value: badge(syncStatus.toUpperCase(), syncTone), detail: escapeHtml(data.sync?.message || "No status yet"), tone: syncTone, meta: data.sync?.timestamp ? `` : "" }), tile({ title: "Keep Awake", value: badge(kaConf ? "CF Cron" : kaStatus.toUpperCase(), kaTone), detail: kaConf ? `Pinging ${escapeHtml(data.keepalive?.targetUrl || "/health")}` : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured", tone: kaTone }), ); if (JUPYTER_ENABLED) { tiles.push(tile({ title: "Terminal", value: badge(data.jupyterReady ? "Online" : "Starting…", data.jupyterReady ? "ok" : "warn"), detail: `JupyterLab at ${JUPYTER_BASE}/`, tone: data.jupyterReady ? "ok" : "warn" })); tiles.push(tile({ title: "DevData", value: badge(DEVDATA_ENABLED ? "Enabled" : "Disabled", DEVDATA_ENABLED ? "ok" : "neutral"), detail: DEVDATA_ENABLED ? `Separate dataset ${escapeHtml(DEVDATA_DATASET_NAME)}` : DEVDATA_SEPARATE_DATASET ? "Separate Jupyter dataset backup inactive" : "DevData dataset must be separate from main backup dataset", tone: DEVDATA_ENABLED ? "ok" : "neutral", meta: `Sync interval ${escapeHtml(DEVDATA_SYNC_INTERVAL)}s`, })); } const tilesHtml = tiles.join(""); return ` HuggingClaw

🦞 HuggingClaw

OpenClaw Gateway
Open Control UI → ${JUPYTER_ENABLED ? `💻 Open Terminal →` : ""} ⚙️ Env Builder →
${tilesHtml}
`; } function renderPrivateRedirect(targetUrl) { const safeUrl = escapeHtml(targetUrl); return ` HuggingClaw — Private Space

🔒 Private Space

This HuggingFace Space is private. You need to be logged in to huggingface.co to access it.

Redirecting you now…

Open on Hugging Face →
Redirecting in 3 seconds…
`; } function renderEnvBuilder() { try { return fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8"); } catch (exc) { return `Env Builder unavailable
${escapeHtml(exc.message)}
`; } } // ── Generic proxy ── function proxiedPath(url, { stripPrefix = "" } = {}) { if (!stripPrefix) return url.pathname + url.search; if (url.pathname === stripPrefix) return "/" + url.search; if (url.pathname.startsWith(stripPrefix + "/")) { return url.pathname.slice(stripPrefix.length) + url.search; } return url.pathname + url.search; } function rewriteProxyHeaders(headers, { publicPrefix = "", targetHost = "", targetPort = "" } = {}) { const next = { ...headers }; // Keep browser redirects inside the public HF Space path. Backends may emit // root-relative redirects ("/login") or absolute redirects pointing at their // internal listener ("http://127.0.0.1:8888/..."). Both break from a browser // if we do not normalize them back to the public mount path. if (publicPrefix && typeof next.location === "string") { try { const internalOrigins = new Set([ "http://huggingclaw.local", `http://${targetHost}:${targetPort}`, `http://localhost:${targetPort}`, `http://127.0.0.1:${targetPort}`, ]); const location = new URL(next.location, "http://huggingclaw.local"); if (internalOrigins.has(location.origin)) { let path = location.pathname; if (path !== publicPrefix && !path.startsWith(publicPrefix + "/")) { path = publicPrefix + (path.startsWith("/") ? path : `/${path}`); } next.location = path + location.search + location.hash; } } catch {} } return next; } function sendServiceUnavailable(res) { if (!res.headersSent) { res.writeHead(503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "starting", message: "Service is initializing… please wait." })); } else { res.end(); } } function proxyHTTP(req, res, targetHost, targetPort, options = {}) { const url = parseRequestUrl(req.url); const headers = { ...req.headers, host: `${targetHost}:${targetPort}`, "x-forwarded-for": req.socket.remoteAddress, "x-forwarded-host": req.headers.host, "x-forwarded-proto": "https", "x-forwarded-prefix": options.publicPrefix || "", }; const canReplayRequest = req.method === "GET" || req.method === "HEAD"; const proxyOnce = (path, retryOn404) => { const pr = http.request({ hostname: targetHost, port: targetPort, path, method: req.method, headers }, (pres) => { if (canReplayRequest && retryOn404 && pres.statusCode === 404 && options.stripPrefix) { pres.resume(); return proxyOnce(proxiedPath(url, { stripPrefix: options.stripPrefix }), false); } res.writeHead(pres.statusCode, rewriteProxyHeaders(pres.headers, { ...options, targetHost, targetPort })); pres.pipe(res); pres.on("error", () => res.end()); }); req.on("error", () => pr.destroy()); res.on("error", () => pr.destroy()); pr.on("error", () => sendServiceUnavailable(res)); req.pipe(pr); }; // First try the public path as-is because OpenClaw and JupyterLab are both // configured with base paths. If a backend still returns 404, retry with the // mount prefix stripped; that covers images built before the base-path config // took effect and avoids the common HF Spaces "404 at /app or /terminal" trap. proxyOnce(url.pathname + url.search, !!options.retryWithoutPrefixOn404); } // ── HTTP server ── const server = http.createServer(async (req, res) => { const { pathname } = parseRequestUrl(req.url); if (pathname === "/health") { const gatewayReady = await probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"); res.writeHead(gatewayReady ? 200 : 503, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ status: gatewayReady ? "ok" : "degraded", gatewayReady, uptime: formatUptime(Date.now() - startTime), sync: getSyncStatus(), keepalive: getKeepaliveStatus() })); } if (pathname === "/status") { const [gatewayReady, jupyterReady] = await Promise.all([ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"), JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false), ]); res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ model: LLM_MODEL, uptime: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() })); } // Private space redirect — send users to the authenticated HF Spaces page. // Works for both direct .hf.space links AND programmatic shares. if (pathname === "/hf-redirect" || pathname === "/hf-redirect/") { if (HF_SPACE_URL) { res.writeHead(302, { Location: HF_SPACE_URL, "Cache-Control": "no-store" }); return res.end(); } res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("SPACE_ID not configured."); } // ── Private Space Guard (server-side) ── // Triggers automatically when SPACE_IS_PRIVATE=true (detected via HF API at startup). // Only intercepts browser navigation (Accept: text/html) — API calls, assets, // and WebSocket upgrades pass through untouched. // /health and /status are always exempt so uptime monitors keep working. const isHtmlRequest = (req.headers.accept || "").includes("text/html"); const isDirectHfSpaceRequest = SPACE_IS_PRIVATE && HF_SPACE_URL && isHtmlRequest && typeof req.headers.host === "string" && req.headers.host.endsWith(".hf.space"); if (pathname === "/env-builder/login") { if (req.method === "POST") { const body = await readBody(req); const token = decodeURIComponent((body.match(/(?:^|&)token=([^&]*)/) || [])[1] || "").replace(/\+/g, " "); if (safeEqual(token, GATEWAY_TOKEN)) { const cookie = `hc_env_auth=${encodeURIComponent(GATEWAY_TOKEN)}; Path=/env-builder; HttpOnly; SameSite=Strict; Max-Age=86400`; res.writeHead(302, { Location: "/env-builder", "Set-Cookie": cookie, "Cache-Control": "no-store" }); return res.end(); } res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderEnvBuilderLogin(true)); } res.writeHead(302, { Location: "/env-builder", "Cache-Control": "no-store" }); return res.end(); } if (pathname === "/env-builder/logout") { res.writeHead(302, { Location: "/env-builder", "Set-Cookie": "hc_env_auth=; Path=/env-builder; HttpOnly; Max-Age=0", "Cache-Control": "no-store" }); return res.end(); } if (pathname === "/env-builder" || pathname === "/env-builder/") { if (isDirectHfSpaceRequest) { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderPrivateRedirect(HF_SPACE_URL)); } if (!isEnvBuilderAuthed(req)) { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderEnvBuilderLogin(false)); } res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderEnvBuilder()); } if (pathname === "/env-builder.js") { if (!isEnvBuilderAuthed(req)) { res.writeHead(401, { "Content-Type": "text/plain" }); return res.end("Unauthorized"); } try { const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8"); res.writeHead(200, { "Content-Type": "application/javascript" }); return res.end(js); } catch (exc) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end(`env-builder.js not found: ${exc.message}`); } } if (pathname === "/" || pathname === "/dashboard") { if (isDirectHfSpaceRequest) { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderPrivateRedirect(HF_SPACE_URL)); } const [gatewayReady, jupyterReady] = await Promise.all([ probePort(GATEWAY_HOST, GATEWAY_PORT, "/health"), JUPYTER_ENABLED ? probePort(JUPYTER_HOST, JUPYTER_PORT, `${JUPYTER_BASE}/login`) : Promise.resolve(false), ]); res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderDashboard({ uptimeHuman: formatUptime(Date.now() - startTime), gatewayReady, jupyterReady, sync: getSyncStatus(), whatsapp: readGuardianStatus(), keepalive: getKeepaliveStatus() })); } // JupyterLab terminal if (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")) { if (!JUPYTER_ENABLED) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ status: "disabled", message: "JupyterLab terminal is disabled. Remove DEV_MODE=false to re-enable." })); } if (isDirectHfSpaceRequest) { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderPrivateRedirect(HF_SPACE_URL)); } return proxyHTTP(req, res, JUPYTER_HOST, JUPYTER_PORT, { publicPrefix: JUPYTER_BASE, // Jupyter is started with --ServerApp.base_url=/terminal/, so keep the // /terminal prefix when proxying. Stripping it breaks static/theme URLs. stripPrefix: "", retryWithoutPrefixOn404: false, }); } // OpenClaw Control UI mounted under /app. Retry without the mount prefix on // 404 so deployments keep working across OpenClaw basePath behavior changes. if (pathname === APP_BASE || pathname.startsWith(APP_BASE + "/")) { if (isDirectHfSpaceRequest) { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderPrivateRedirect(HF_SPACE_URL)); } return proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT, { publicPrefix: APP_BASE, stripPrefix: APP_BASE, retryWithoutPrefixOn404: true, }); } // Favicon — serve a minimal inline SVG so browsers don't proxy to the gateway if (pathname === "/favicon.ico" || pathname === "/favicon.svg") { const svg = '🦞'; res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400" }); return res.end(svg); } // OpenClaw gateway API/static fallback (everything else) if (isDirectHfSpaceRequest) { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(renderPrivateRedirect(HF_SPACE_URL)); } proxyHTTP(req, res, GATEWAY_HOST, GATEWAY_PORT); }); // ── WebSocket upgrade (JupyterLab kernels + terminals need this) ── server.on("upgrade", (req, socket, head) => { const { pathname, search } = parseRequestUrl(req.url); const isJupyter = JUPYTER_ENABLED && (pathname === JUPYTER_BASE || pathname.startsWith(JUPYTER_BASE + "/")); const isApp = pathname === APP_BASE || pathname.startsWith(APP_BASE + "/"); const [targetHost, targetPort] = isJupyter ? [JUPYTER_HOST, JUPYTER_PORT] : [GATEWAY_HOST, GATEWAY_PORT]; const publicPrefix = isJupyter ? JUPYTER_BASE : isApp ? APP_BASE : ""; const targetPath = pathname + search; const ps = net.connect(targetPort, targetHost, () => { ps.write(`${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n`); ps.write(`Host: ${targetHost}:${targetPort}\r\n`); ps.write(`X-Forwarded-For: ${req.socket.remoteAddress || ""}\r\n`); ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`); ps.write("X-Forwarded-Proto: https\r\n"); if (publicPrefix) ps.write(`X-Forwarded-Prefix: ${publicPrefix}\r\n`); for (let i = 0; i < req.rawHeaders.length; i += 2) { const header = req.rawHeaders[i]; const lower = header.toLowerCase(); if (["host", "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix"].includes(lower)) continue; ps.write(`${header}: ${req.rawHeaders[i + 1]}\r\n`); } ps.write("\r\n"); if (head && head.length) ps.write(head); ps.pipe(socket).pipe(ps); }); ps.on("error", () => socket.destroy()); ps.on("close", () => socket.destroy()); socket.on("error", () => ps.destroy()); socket.on("close", () => ps.destroy()); }); server.timeout = 0; server.keepAliveTimeout = 65000; server.on("error", (err) => console.error(`[health-server] Server error:`, err)); server.listen(PORT, "0.0.0.0", () => console.log(`🦞 HuggingClaw :${PORT} → Gateway :${GATEWAY_PORT}${JUPYTER_ENABLED ? ` | Terminal :${JUPYTER_PORT} at ${JUPYTER_BASE}/` : " | Terminal disabled"}`), );