Spaces:
Paused
Paused
Upload 6 files
Browse files- cloudflare-keepalive-setup.py +222 -0
- cloudflare-proxy-setup.py +213 -0
- env-builder.html +853 -0
- env-builder.js +797 -0
- health-server.js +979 -0
- start.sh +772 -0
cloudflare-keepalive-setup.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
"""Create or reuse a Cloudflare Worker for Space keep-awake."""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
import urllib.request
|
| 12 |
+
import urllib.error
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
API_BASE = "https://api.cloudflare.com/client/v4"
|
| 16 |
+
KEEPALIVE_STATUS_FILE = Path("/tmp/huggingmes-cloudflare-keepalive-status.json")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
|
| 20 |
+
req = urllib.request.Request(
|
| 21 |
+
f"{API_BASE}{path}",
|
| 22 |
+
data=body,
|
| 23 |
+
method=method,
|
| 24 |
+
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
|
| 25 |
+
)
|
| 26 |
+
try:
|
| 27 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 28 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 29 |
+
except urllib.error.HTTPError as e:
|
| 30 |
+
try:
|
| 31 |
+
error_body = json.loads(e.read().decode("utf-8"))
|
| 32 |
+
errors = error_body.get("errors") or [{"message": "Unknown error"}]
|
| 33 |
+
error_msg = errors[0].get("message", "Unknown error") if errors else "Unknown error"
|
| 34 |
+
except:
|
| 35 |
+
error_msg = f"HTTP {e.code}: {e.reason}"
|
| 36 |
+
raise RuntimeError(f"Cloudflare API {e.code}: {error_msg}")
|
| 37 |
+
if not payload.get("success"):
|
| 38 |
+
errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
|
| 39 |
+
raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
|
| 40 |
+
return payload["result"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def slugify(value: str) -> str:
|
| 44 |
+
cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
| 45 |
+
cleaned = re.sub(r"-{2,}", "-", cleaned)
|
| 46 |
+
return (cleaned or "huggingmes-proxy")[:63].rstrip("-")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_space_host() -> str:
|
| 50 |
+
space_host = os.environ.get("SPACE_HOST", "").strip()
|
| 51 |
+
if space_host:
|
| 52 |
+
return space_host
|
| 53 |
+
|
| 54 |
+
author = os.environ.get("SPACE_AUTHOR_NAME", "").strip()
|
| 55 |
+
repo = os.environ.get("SPACE_REPO_NAME", "").strip()
|
| 56 |
+
if author and repo:
|
| 57 |
+
return f"{author}-{repo}.hf.space".lower()
|
| 58 |
+
|
| 59 |
+
return ""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def derive_keepalive_worker_name() -> str:
|
| 63 |
+
explicit = os.environ.get("CLOUDFLARE_KEEPALIVE_WORKER_NAME", "").strip()
|
| 64 |
+
if explicit:
|
| 65 |
+
return slugify(explicit)
|
| 66 |
+
space_host = get_space_host()
|
| 67 |
+
if space_host:
|
| 68 |
+
return slugify(f"{space_host.replace('.hf.space', '')}-keepalive")
|
| 69 |
+
return "huggingmes-keepalive"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def render_keepalive_worker(target_url: str) -> str:
|
| 73 |
+
return f"""addEventListener("fetch", (event) => {{
|
| 74 |
+
event.respondWith(handleRequest(event.request));
|
| 75 |
+
}});
|
| 76 |
+
|
| 77 |
+
addEventListener("scheduled", (event) => {{
|
| 78 |
+
event.waitUntil(ping("cron"));
|
| 79 |
+
}});
|
| 80 |
+
|
| 81 |
+
const TARGET_URL = {json.dumps(target_url)};
|
| 82 |
+
|
| 83 |
+
async function ping(source) {{
|
| 84 |
+
const startedAt = new Date().toISOString();
|
| 85 |
+
try {{
|
| 86 |
+
const response = await fetch(TARGET_URL, {{
|
| 87 |
+
method: "GET",
|
| 88 |
+
headers: {{
|
| 89 |
+
"user-agent": "HuggingMes Cloudflare KeepAlive",
|
| 90 |
+
"cache-control": "no-cache"
|
| 91 |
+
}},
|
| 92 |
+
cf: {{ cacheTtl: 0, cacheEverything: false }}
|
| 93 |
+
}});
|
| 94 |
+
return {{
|
| 95 |
+
ok: response.ok,
|
| 96 |
+
status: response.status,
|
| 97 |
+
source,
|
| 98 |
+
target: TARGET_URL,
|
| 99 |
+
timestamp: startedAt
|
| 100 |
+
}};
|
| 101 |
+
}} catch (error) {{
|
| 102 |
+
return {{
|
| 103 |
+
ok: false,
|
| 104 |
+
status: 0,
|
| 105 |
+
source,
|
| 106 |
+
target: TARGET_URL,
|
| 107 |
+
timestamp: startedAt,
|
| 108 |
+
error: error.message
|
| 109 |
+
}};
|
| 110 |
+
}}
|
| 111 |
+
}}
|
| 112 |
+
|
| 113 |
+
async function handleRequest(request) {{
|
| 114 |
+
const url = new URL(request.url);
|
| 115 |
+
if (url.pathname === "/" || url.pathname === "/health" || url.pathname === "/ping") {{
|
| 116 |
+
const result = await ping("manual");
|
| 117 |
+
return new Response(JSON.stringify(result, null, 2), {{
|
| 118 |
+
status: result.ok ? 200 : 502,
|
| 119 |
+
headers: {{ "content-type": "application/json; charset=utf-8" }}
|
| 120 |
+
}});
|
| 121 |
+
}}
|
| 122 |
+
return new Response("Not found", {{ status: 404 }});
|
| 123 |
+
}}
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def write_keepalive_status(payload: dict) -> None:
|
| 128 |
+
payload = {
|
| 129 |
+
**payload,
|
| 130 |
+
"timestamp": payload.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 131 |
+
}
|
| 132 |
+
KEEPALIVE_STATUS_FILE.write_text(json.dumps(payload), encoding="utf-8")
|
| 133 |
+
try:
|
| 134 |
+
KEEPALIVE_STATUS_FILE.chmod(0o600)
|
| 135 |
+
except OSError:
|
| 136 |
+
pass
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
|
| 140 |
+
account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
|
| 141 |
+
if not account_id:
|
| 142 |
+
accounts = cf_request("GET", "/accounts", api_token)
|
| 143 |
+
if not accounts:
|
| 144 |
+
raise RuntimeError("No Cloudflare account is available for this token.")
|
| 145 |
+
account_id = accounts[0]["id"]
|
| 146 |
+
|
| 147 |
+
subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
|
| 148 |
+
subdomain = (subdomain_info or {}).get("subdomain", "").strip()
|
| 149 |
+
if not subdomain:
|
| 150 |
+
raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
|
| 151 |
+
return account_id, subdomain
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def setup_keepalive_worker(api_token: str, account_id: str, subdomain: str) -> None:
|
| 155 |
+
enabled = os.environ.get("CLOUDFLARE_KEEPALIVE_ENABLED", "true").strip().lower()
|
| 156 |
+
if enabled in {"0", "false", "no", "off"}:
|
| 157 |
+
write_keepalive_status({"configured": False, "status": "disabled", "message": "Cloudflare keep-awake is disabled."})
|
| 158 |
+
return
|
| 159 |
+
|
| 160 |
+
space_host = get_space_host()
|
| 161 |
+
if not space_host:
|
| 162 |
+
write_keepalive_status({"configured": False, "status": "skipped", "message": "SPACE_HOST could not be determined."})
|
| 163 |
+
return
|
| 164 |
+
|
| 165 |
+
cron = os.environ.get("CLOUDFLARE_KEEPALIVE_CRON", "*/10 * * * *").strip()
|
| 166 |
+
space_host = space_host.removeprefix("https://").removeprefix("http://").split("/")[0]
|
| 167 |
+
target_url = os.environ.get("CLOUDFLARE_KEEPALIVE_URL", f"https://{space_host}/health").strip()
|
| 168 |
+
worker_name = derive_keepalive_worker_name()
|
| 169 |
+
worker_source = render_keepalive_worker(target_url)
|
| 170 |
+
|
| 171 |
+
cf_request(
|
| 172 |
+
"PUT",
|
| 173 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}",
|
| 174 |
+
api_token,
|
| 175 |
+
body=worker_source.encode("utf-8"),
|
| 176 |
+
content_type="application/javascript",
|
| 177 |
+
)
|
| 178 |
+
cf_request(
|
| 179 |
+
"POST",
|
| 180 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
|
| 181 |
+
api_token,
|
| 182 |
+
body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
|
| 183 |
+
)
|
| 184 |
+
cf_request(
|
| 185 |
+
"PUT",
|
| 186 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}/schedules",
|
| 187 |
+
api_token,
|
| 188 |
+
body=json.dumps([{"cron": cron}]).encode("utf-8"),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
worker_url = f"https://{worker_name}.{subdomain}.workers.dev"
|
| 192 |
+
write_keepalive_status(
|
| 193 |
+
{
|
| 194 |
+
"configured": True,
|
| 195 |
+
"status": "configured",
|
| 196 |
+
"workerName": worker_name,
|
| 197 |
+
"workerUrl": worker_url,
|
| 198 |
+
"targetUrl": target_url,
|
| 199 |
+
"cron": cron,
|
| 200 |
+
"message": f"Cloudflare Worker cron pings {target_url} on {cron}.",
|
| 201 |
+
}
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def main() -> int:
|
| 206 |
+
api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
|
| 207 |
+
|
| 208 |
+
if not api_token:
|
| 209 |
+
return 0
|
| 210 |
+
|
| 211 |
+
try:
|
| 212 |
+
account_id, subdomain = resolve_account_and_subdomain(api_token)
|
| 213 |
+
setup_keepalive_worker(api_token, account_id, subdomain)
|
| 214 |
+
return 0
|
| 215 |
+
except Exception as exc:
|
| 216 |
+
print(f"Cloudflare keepalive setup failed: {exc}", file=sys.stderr)
|
| 217 |
+
write_keepalive_status({"configured": False, "status": "error", "message": str(exc)})
|
| 218 |
+
return 1
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
raise SystemExit(main())
|
cloudflare-proxy-setup.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
"""Create or reuse Cloudflare Workers for Telegram proxy and Space keep-awake."""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import secrets
|
| 10 |
+
import sys
|
| 11 |
+
import time
|
| 12 |
+
import urllib.request
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
API_BASE = "https://api.cloudflare.com/client/v4"
|
| 16 |
+
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 17 |
+
ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
|
| 18 |
+
DEFAULT_ALLOWED = [
|
| 19 |
+
# Messaging & social β primary use-case for Cloudflare proxy on HF Spaces
|
| 20 |
+
# (geo-restrictions on Telegram, Discord, WhatsApp, etc.)
|
| 21 |
+
"api.telegram.org",
|
| 22 |
+
"discord.com",
|
| 23 |
+
"discordapp.com",
|
| 24 |
+
"gateway.discord.gg",
|
| 25 |
+
"status.discord.com",
|
| 26 |
+
"slack.com",
|
| 27 |
+
"api.slack.com",
|
| 28 |
+
"web.whatsapp.com",
|
| 29 |
+
# Social β confirmed/likely blocked by HF firewall
|
| 30 |
+
"graph.facebook.com",
|
| 31 |
+
"graph.instagram.com",
|
| 32 |
+
"api.twitter.com",
|
| 33 |
+
"api.x.com",
|
| 34 |
+
# Google
|
| 35 |
+
"googleapis.com",
|
| 36 |
+
"google.com",
|
| 37 |
+
"googleusercontent.com",
|
| 38 |
+
"gstatic.com",
|
| 39 |
+
# Email HTTP APIs (SMTP ports are blocked)
|
| 40 |
+
"api.resend.com",
|
| 41 |
+
"api.sendgrid.com",
|
| 42 |
+
# NOTE: AI-provider domains (api.openai.com, api.anthropic.com, etc.) are
|
| 43 |
+
# intentionally NOT included here. Proxying AI calls routes API keys through
|
| 44 |
+
# the Cloudflare Worker without explicit opt-in. Users who need AI API calls
|
| 45 |
+
# proxied can add specific domains via CLOUDFLARE_PROXY_DOMAINS env var.
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def cf_request(method: str, path: str, token: str, body: bytes | None = None, content_type: str = "application/json"):
|
| 50 |
+
req = urllib.request.Request(
|
| 51 |
+
f"{API_BASE}{path}",
|
| 52 |
+
data=body,
|
| 53 |
+
method=method,
|
| 54 |
+
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
|
| 55 |
+
)
|
| 56 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 57 |
+
payload = json.loads(response.read().decode("utf-8"))
|
| 58 |
+
if not payload.get("success"):
|
| 59 |
+
errors = payload.get("errors") or [{"message": "Unknown Cloudflare API error"}]
|
| 60 |
+
raise RuntimeError(errors[0].get("message", "Unknown Cloudflare API error"))
|
| 61 |
+
return payload["result"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def slugify(value: str) -> str:
|
| 65 |
+
cleaned = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-")
|
| 66 |
+
cleaned = re.sub(r"-{2,}", "-", cleaned)
|
| 67 |
+
return (cleaned or "huggingmes-proxy")[:63].rstrip("-")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def derive_worker_name() -> str:
|
| 71 |
+
explicit = os.environ.get("CLOUDFLARE_WORKER_NAME", "").strip()
|
| 72 |
+
if explicit:
|
| 73 |
+
return slugify(explicit)
|
| 74 |
+
space_host = os.environ.get("SPACE_HOST", "").strip()
|
| 75 |
+
if space_host:
|
| 76 |
+
return slugify(f"{space_host.replace('.hf.space', '')}-proxy")
|
| 77 |
+
return "huggingmes-proxy"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def render_worker(secret_value: str, allowed_targets: list[str], allow_proxy_all: bool) -> str:
|
| 81 |
+
return f"""addEventListener("fetch", (event) => {{
|
| 82 |
+
event.respondWith(handleRequest(event.request));
|
| 83 |
+
}});
|
| 84 |
+
|
| 85 |
+
const PROXY_SHARED_SECRET = {json.dumps(secret_value)};
|
| 86 |
+
const ALLOW_PROXY_ALL = {"true" if allow_proxy_all else "false"};
|
| 87 |
+
const ALLOWED_TARGETS = {json.dumps(allowed_targets)};
|
| 88 |
+
|
| 89 |
+
function isAllowedHost(hostname) {{
|
| 90 |
+
const normalized = String(hostname || "").trim().toLowerCase();
|
| 91 |
+
if (!normalized) return false;
|
| 92 |
+
if (ALLOW_PROXY_ALL) return true;
|
| 93 |
+
return ALLOWED_TARGETS.some((domain) => normalized === domain || normalized.endsWith(`.${{domain}}`));
|
| 94 |
+
}}
|
| 95 |
+
|
| 96 |
+
async function handleRequest(request) {{
|
| 97 |
+
const url = new URL(request.url);
|
| 98 |
+
const queryTarget = url.searchParams.get("proxy_target");
|
| 99 |
+
const targetHost = request.headers.get("x-target-host") || queryTarget;
|
| 100 |
+
|
| 101 |
+
if (PROXY_SHARED_SECRET) {{
|
| 102 |
+
const providedSecret = request.headers.get("x-proxy-key") || url.searchParams.get("proxy_key") || "";
|
| 103 |
+
const telegramStylePath = url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot");
|
| 104 |
+
if (providedSecret !== PROXY_SHARED_SECRET && !(telegramStylePath && !targetHost)) {{
|
| 105 |
+
return new Response("Unauthorized: Invalid proxy key", {{ status: 401 }});
|
| 106 |
+
}}
|
| 107 |
+
}}
|
| 108 |
+
|
| 109 |
+
let targetBase = "";
|
| 110 |
+
if (targetHost) {{
|
| 111 |
+
if (!isAllowedHost(targetHost)) {{
|
| 112 |
+
return new Response(`Forbidden: Host ${{targetHost}} is not allowed.`, {{ status: 403 }});
|
| 113 |
+
}}
|
| 114 |
+
targetBase = `https://${{targetHost}}`;
|
| 115 |
+
}} else if (url.pathname.startsWith("/bot") || url.pathname.startsWith("/file/bot")) {{
|
| 116 |
+
targetBase = "https://api.telegram.org";
|
| 117 |
+
}} else {{
|
| 118 |
+
return new Response("Invalid request: No target host provided.", {{ status: 400 }});
|
| 119 |
+
}}
|
| 120 |
+
|
| 121 |
+
const cleanSearch = new URLSearchParams(url.search);
|
| 122 |
+
cleanSearch.delete("proxy_target");
|
| 123 |
+
cleanSearch.delete("proxy_key");
|
| 124 |
+
const searchStr = cleanSearch.toString();
|
| 125 |
+
const targetUrl = targetBase + url.pathname + (searchStr ? `?${{searchStr}}` : "");
|
| 126 |
+
|
| 127 |
+
const headers = new Headers(request.headers);
|
| 128 |
+
for (const header of ["cf-connecting-ip", "cf-ray", "cf-visitor", "host", "x-real-ip", "x-target-host", "x-proxy-key"]) {{
|
| 129 |
+
headers.delete(header);
|
| 130 |
+
}}
|
| 131 |
+
|
| 132 |
+
try {{
|
| 133 |
+
return await fetch(new Request(targetUrl, {{
|
| 134 |
+
method: request.method,
|
| 135 |
+
headers,
|
| 136 |
+
body: request.body,
|
| 137 |
+
redirect: "follow",
|
| 138 |
+
}}));
|
| 139 |
+
}} catch (error) {{
|
| 140 |
+
return new Response(`Proxy Error: ${{error.message}}`, {{ status: 502 }});
|
| 141 |
+
}}
|
| 142 |
+
}}
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def write_env(proxy_url: str, proxy_secret: str) -> None:
|
| 147 |
+
ENV_FILE.write_text(
|
| 148 |
+
f'export CLOUDFLARE_PROXY_URL="{proxy_url}"\nexport CLOUDFLARE_PROXY_SECRET="{proxy_secret}"\n',
|
| 149 |
+
encoding="utf-8",
|
| 150 |
+
)
|
| 151 |
+
ENV_FILE.chmod(0o600)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def resolve_account_and_subdomain(api_token: str) -> tuple[str, str]:
|
| 155 |
+
account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "").strip()
|
| 156 |
+
if not account_id:
|
| 157 |
+
accounts = cf_request("GET", "/accounts", api_token)
|
| 158 |
+
if not accounts:
|
| 159 |
+
raise RuntimeError("No Cloudflare account is available for this token.")
|
| 160 |
+
account_id = accounts[0]["id"]
|
| 161 |
+
|
| 162 |
+
subdomain_info = cf_request("GET", f"/accounts/{account_id}/workers/subdomain", api_token)
|
| 163 |
+
subdomain = (subdomain_info or {}).get("subdomain", "").strip()
|
| 164 |
+
if not subdomain:
|
| 165 |
+
raise RuntimeError("Cloudflare Workers subdomain is not configured. Enable workers.dev first.")
|
| 166 |
+
return account_id, subdomain
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main() -> int:
|
| 170 |
+
existing_url = os.environ.get("CLOUDFLARE_PROXY_URL", "").strip()
|
| 171 |
+
existing_secret = os.environ.get("CLOUDFLARE_PROXY_SECRET", "").strip()
|
| 172 |
+
api_token = os.environ.get("CLOUDFLARE_WORKERS_TOKEN", "").strip()
|
| 173 |
+
|
| 174 |
+
if existing_url:
|
| 175 |
+
write_env(existing_url, existing_secret)
|
| 176 |
+
|
| 177 |
+
if not api_token:
|
| 178 |
+
return 0
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
account_id, subdomain = resolve_account_and_subdomain(api_token)
|
| 182 |
+
|
| 183 |
+
if not existing_url:
|
| 184 |
+
allowed_raw = os.environ.get("CLOUDFLARE_PROXY_DOMAINS", "").strip()
|
| 185 |
+
allow_proxy_all = allowed_raw == "*"
|
| 186 |
+
extra = [] if allow_proxy_all else [v.strip() for v in allowed_raw.split(",") if v.strip()]
|
| 187 |
+
allowed = list(dict.fromkeys(DEFAULT_ALLOWED + extra))
|
| 188 |
+
worker_name = derive_worker_name()
|
| 189 |
+
proxy_secret = existing_secret or secrets.token_urlsafe(24)
|
| 190 |
+
|
| 191 |
+
cf_request(
|
| 192 |
+
"PUT",
|
| 193 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}",
|
| 194 |
+
api_token,
|
| 195 |
+
body=render_worker(proxy_secret, allowed, allow_proxy_all).encode("utf-8"),
|
| 196 |
+
content_type="application/javascript",
|
| 197 |
+
)
|
| 198 |
+
cf_request(
|
| 199 |
+
"POST",
|
| 200 |
+
f"/accounts/{account_id}/workers/scripts/{worker_name}/subdomain",
|
| 201 |
+
api_token,
|
| 202 |
+
body=json.dumps({"enabled": True, "previews_enabled": True}).encode("utf-8"),
|
| 203 |
+
)
|
| 204 |
+
write_env(f"https://{worker_name}.{subdomain}.workers.dev", proxy_secret)
|
| 205 |
+
|
| 206 |
+
return 0
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
print(f"Cloudflare proxy setup failed: {exc}", file=sys.stderr)
|
| 209 |
+
return 1
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
if __name__ == "__main__":
|
| 213 |
+
raise SystemExit(main())
|
env-builder.html
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>HuggingMes Β· ENV Builder</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
| 10 |
+
|
| 11 |
+
<style>
|
| 12 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 13 |
+
|
| 14 |
+
:root {
|
| 15 |
+
--bg: #0b0c0f;
|
| 16 |
+
--bg2: #111318;
|
| 17 |
+
--bg3: #181c23;
|
| 18 |
+
--bg4: #1e2330;
|
| 19 |
+
--border: #252b38;
|
| 20 |
+
--border2: #2e3648;
|
| 21 |
+
--amber: #f5a623;
|
| 22 |
+
--amber2: #ffbe55;
|
| 23 |
+
--amber-dim: rgba(245,166,35,.12);
|
| 24 |
+
--amber-glow:rgba(245,166,35,.22);
|
| 25 |
+
--green: #3dd68c;
|
| 26 |
+
--red: #f05f5f;
|
| 27 |
+
--blue: #5b8af5;
|
| 28 |
+
--text: #e4e8f0;
|
| 29 |
+
--text2: #8d97ad;
|
| 30 |
+
--text3: #535f76;
|
| 31 |
+
--mono: 'JetBrains Mono', monospace;
|
| 32 |
+
--sans: 'Syne', sans-serif;
|
| 33 |
+
--r: 8px;
|
| 34 |
+
--r2: 12px;
|
| 35 |
+
--sidebar-w: 220px;
|
| 36 |
+
--panel-w: 340px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
html { scroll-behavior: smooth; height: 100%; }
|
| 40 |
+
|
| 41 |
+
body {
|
| 42 |
+
font-family: var(--sans);
|
| 43 |
+
background: var(--bg);
|
| 44 |
+
color: var(--text);
|
| 45 |
+
height: 100vh;
|
| 46 |
+
overflow: hidden;
|
| 47 |
+
display: flex;
|
| 48 |
+
flex-direction: column;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.topbar {
|
| 52 |
+
position: sticky;
|
| 53 |
+
top: 0;
|
| 54 |
+
z-index: 100;
|
| 55 |
+
height: 52px;
|
| 56 |
+
background: rgba(11,12,15,.9);
|
| 57 |
+
backdrop-filter: blur(14px);
|
| 58 |
+
border-bottom: 1px solid var(--border);
|
| 59 |
+
display: flex;
|
| 60 |
+
align-items: center;
|
| 61 |
+
padding: 0 20px;
|
| 62 |
+
gap: 16px;
|
| 63 |
+
flex-shrink: 0;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.topbar-logo {
|
| 67 |
+
display: flex;
|
| 68 |
+
align-items: center;
|
| 69 |
+
gap: 10px;
|
| 70 |
+
flex-shrink: 0;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.topbar-logo .logo-emoji { font-size: 24px; line-height: 1; }
|
| 74 |
+
|
| 75 |
+
.topbar-wordmark {
|
| 76 |
+
font-weight: 800;
|
| 77 |
+
font-size: 14px;
|
| 78 |
+
letter-spacing: -.2px;
|
| 79 |
+
color: var(--text);
|
| 80 |
+
white-space: nowrap;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.topbar-wordmark em {
|
| 84 |
+
color: var(--amber);
|
| 85 |
+
font-style: normal;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.topbar-divider {
|
| 89 |
+
width: 1px;
|
| 90 |
+
height: 22px;
|
| 91 |
+
background: var(--border2);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.topbar-title {
|
| 95 |
+
font-size: 12px;
|
| 96 |
+
font-weight: 600;
|
| 97 |
+
color: var(--text2);
|
| 98 |
+
letter-spacing: .5px;
|
| 99 |
+
text-transform: uppercase;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.topbar-spacer { flex: 1; }
|
| 103 |
+
|
| 104 |
+
.topbar-pill {
|
| 105 |
+
font-family: var(--mono);
|
| 106 |
+
font-size: 10px;
|
| 107 |
+
color: var(--amber);
|
| 108 |
+
background: var(--amber-dim);
|
| 109 |
+
border: 1px solid var(--amber-glow);
|
| 110 |
+
border-radius: 20px;
|
| 111 |
+
padding: 3px 10px;
|
| 112 |
+
letter-spacing: .5px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.layout {
|
| 116 |
+
display: flex;
|
| 117 |
+
flex: 1;
|
| 118 |
+
min-height: 0;
|
| 119 |
+
height: calc(100vh - 52px);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.sidebar-wrap {
|
| 123 |
+
width: var(--sidebar-w);
|
| 124 |
+
flex-shrink: 0;
|
| 125 |
+
border-right: 1px solid var(--border);
|
| 126 |
+
background: var(--bg2);
|
| 127 |
+
display: flex;
|
| 128 |
+
flex-direction: column;
|
| 129 |
+
overflow: hidden;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.sidebar-scroll {
|
| 133 |
+
flex: 1;
|
| 134 |
+
overflow-y: auto;
|
| 135 |
+
padding: 14px 10px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.sidebar-scroll::-webkit-scrollbar { width: 4px; }
|
| 139 |
+
.sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
|
| 140 |
+
.sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
| 141 |
+
|
| 142 |
+
.sb-label {
|
| 143 |
+
font-size: 9px;
|
| 144 |
+
font-weight: 700;
|
| 145 |
+
text-transform: uppercase;
|
| 146 |
+
letter-spacing: 1.2px;
|
| 147 |
+
color: var(--text3);
|
| 148 |
+
padding: 0 8px 10px;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.nav-btn {
|
| 152 |
+
width: 100%;
|
| 153 |
+
display: flex;
|
| 154 |
+
align-items: center;
|
| 155 |
+
gap: 8px;
|
| 156 |
+
padding: 8px 10px;
|
| 157 |
+
border: none;
|
| 158 |
+
background: transparent;
|
| 159 |
+
cursor: pointer;
|
| 160 |
+
border-radius: var(--r);
|
| 161 |
+
text-align: left;
|
| 162 |
+
color: var(--text2);
|
| 163 |
+
font-family: var(--sans);
|
| 164 |
+
font-size: 12.5px;
|
| 165 |
+
font-weight: 500;
|
| 166 |
+
transition: background .15s, color .15s;
|
| 167 |
+
margin-bottom: 2px;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.nav-btn:hover { background: var(--bg3); color: var(--text); }
|
| 171 |
+
.nav-btn.active {
|
| 172 |
+
background: var(--amber-dim);
|
| 173 |
+
color: var(--amber);
|
| 174 |
+
border: 1px solid var(--amber-glow);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.nav-icon { font-size: 13px; flex-shrink: 0; }
|
| 178 |
+
.nav-label { flex: 1; }
|
| 179 |
+
.nav-count {
|
| 180 |
+
font-family: var(--mono);
|
| 181 |
+
font-size: 10px;
|
| 182 |
+
font-weight: 600;
|
| 183 |
+
color: var(--text3);
|
| 184 |
+
background: var(--bg3);
|
| 185 |
+
border-radius: 10px;
|
| 186 |
+
padding: 1px 6px;
|
| 187 |
+
min-width: 20px;
|
| 188 |
+
text-align: center;
|
| 189 |
+
transition: background .2s, color .2s;
|
| 190 |
+
}
|
| 191 |
+
.nav-btn.active .nav-count {
|
| 192 |
+
background: var(--amber-glow);
|
| 193 |
+
color: var(--amber2);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.main {
|
| 197 |
+
flex: 1;
|
| 198 |
+
display: flex;
|
| 199 |
+
flex-direction: column;
|
| 200 |
+
min-width: 0;
|
| 201 |
+
overflow: hidden;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.toolbar {
|
| 205 |
+
display: flex;
|
| 206 |
+
align-items: center;
|
| 207 |
+
gap: 10px;
|
| 208 |
+
padding: 12px 20px;
|
| 209 |
+
border-bottom: 1px solid var(--border);
|
| 210 |
+
background: var(--bg2);
|
| 211 |
+
flex-shrink: 0;
|
| 212 |
+
flex-wrap: wrap;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.search-wrap {
|
| 216 |
+
position: relative;
|
| 217 |
+
flex: 1;
|
| 218 |
+
min-width: 160px;
|
| 219 |
+
max-width: 340px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.search-icon {
|
| 223 |
+
position: absolute;
|
| 224 |
+
left: 10px;
|
| 225 |
+
top: 50%;
|
| 226 |
+
transform: translateY(-50%);
|
| 227 |
+
color: var(--text3);
|
| 228 |
+
pointer-events: none;
|
| 229 |
+
font-size: 12px;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
#search {
|
| 233 |
+
width: 100%;
|
| 234 |
+
background: var(--bg3);
|
| 235 |
+
border: 1px solid var(--border2);
|
| 236 |
+
border-radius: var(--r);
|
| 237 |
+
padding: 7px 10px 7px 30px;
|
| 238 |
+
font-family: var(--mono);
|
| 239 |
+
font-size: 12px;
|
| 240 |
+
color: var(--text);
|
| 241 |
+
outline: none;
|
| 242 |
+
transition: border-color .15s;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
#search:focus { border-color: var(--amber); }
|
| 246 |
+
#search::placeholder { color: var(--text3); }
|
| 247 |
+
|
| 248 |
+
.tb-sep {
|
| 249 |
+
width: 1px;
|
| 250 |
+
height: 24px;
|
| 251 |
+
background: var(--border2);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.btn {
|
| 255 |
+
display: inline-flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
gap: 5px;
|
| 258 |
+
padding: 6px 13px;
|
| 259 |
+
border-radius: var(--r);
|
| 260 |
+
border: 1px solid var(--border2);
|
| 261 |
+
background: var(--bg3);
|
| 262 |
+
color: var(--text2);
|
| 263 |
+
font-family: var(--sans);
|
| 264 |
+
font-size: 11.5px;
|
| 265 |
+
font-weight: 600;
|
| 266 |
+
cursor: pointer;
|
| 267 |
+
transition: all .15s;
|
| 268 |
+
white-space: nowrap;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.btn:hover { background: var(--bg4); color: var(--text); border-color: var(--border2); }
|
| 272 |
+
|
| 273 |
+
.btn-amber {
|
| 274 |
+
background: var(--amber);
|
| 275 |
+
color: #0b0c0f;
|
| 276 |
+
border-color: var(--amber);
|
| 277 |
+
}
|
| 278 |
+
.btn-amber:hover { background: var(--amber2); border-color: var(--amber2); }
|
| 279 |
+
|
| 280 |
+
.btn-ghost {
|
| 281 |
+
background: transparent;
|
| 282 |
+
border-color: transparent;
|
| 283 |
+
color: var(--text3);
|
| 284 |
+
}
|
| 285 |
+
.btn-ghost:hover { background: var(--bg3); color: var(--text2); border-color: var(--border2); }
|
| 286 |
+
|
| 287 |
+
.content-wrap {
|
| 288 |
+
flex: 1;
|
| 289 |
+
display: flex;
|
| 290 |
+
min-height: 0;
|
| 291 |
+
overflow: hidden;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.sections-scroll {
|
| 295 |
+
flex: 1;
|
| 296 |
+
overflow-y: auto;
|
| 297 |
+
padding: 16px 20px 80px;
|
| 298 |
+
min-width: 0;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.sections-scroll::-webkit-scrollbar { width: 5px; }
|
| 302 |
+
.sections-scroll::-webkit-scrollbar-track { background: transparent; }
|
| 303 |
+
.sections-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
| 304 |
+
|
| 305 |
+
.sec { margin-bottom: 28px; }
|
| 306 |
+
.sec.sec-hidden { display: none !important; }
|
| 307 |
+
|
| 308 |
+
.sec-header {
|
| 309 |
+
display: flex;
|
| 310 |
+
align-items: center;
|
| 311 |
+
gap: 8px;
|
| 312 |
+
margin-bottom: 12px;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.sec-icon { font-size: 14px; }
|
| 316 |
+
.sec-title {
|
| 317 |
+
font-size: 11px;
|
| 318 |
+
font-weight: 700;
|
| 319 |
+
text-transform: uppercase;
|
| 320 |
+
letter-spacing: 1.2px;
|
| 321 |
+
color: var(--text3);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.sec-line {
|
| 325 |
+
flex: 1;
|
| 326 |
+
height: 1px;
|
| 327 |
+
background: var(--border);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.cards {
|
| 331 |
+
display: grid;
|
| 332 |
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
| 333 |
+
gap: 10px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.env-card {
|
| 337 |
+
background: var(--bg2);
|
| 338 |
+
border: 1px solid var(--border);
|
| 339 |
+
border-radius: var(--r2);
|
| 340 |
+
padding: 12px;
|
| 341 |
+
transition: border-color .2s, background .2s;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.env-card:hover { border-color: var(--border2); }
|
| 345 |
+
.env-card.hidden { display: none; }
|
| 346 |
+
|
| 347 |
+
.env-card.selected {
|
| 348 |
+
border-color: var(--amber-glow);
|
| 349 |
+
background: linear-gradient(135deg, var(--bg2) 80%, rgba(245,166,35,.04));
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.card-top {
|
| 353 |
+
display: flex;
|
| 354 |
+
align-items: flex-start;
|
| 355 |
+
gap: 9px;
|
| 356 |
+
margin-bottom: 9px;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.card-check {
|
| 360 |
+
width: 15px;
|
| 361 |
+
height: 15px;
|
| 362 |
+
accent-color: var(--amber);
|
| 363 |
+
flex-shrink: 0;
|
| 364 |
+
margin-top: 2px;
|
| 365 |
+
cursor: pointer;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.card-info { flex: 1; min-width: 0; }
|
| 369 |
+
|
| 370 |
+
.card-key {
|
| 371 |
+
font-family: var(--mono);
|
| 372 |
+
font-size: 11.5px;
|
| 373 |
+
font-weight: 600;
|
| 374 |
+
color: var(--text);
|
| 375 |
+
letter-spacing: .3px;
|
| 376 |
+
white-space: nowrap;
|
| 377 |
+
overflow: hidden;
|
| 378 |
+
text-overflow: ellipsis;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.card-lbl {
|
| 382 |
+
font-size: 11px;
|
| 383 |
+
color: var(--text3);
|
| 384 |
+
margin-top: 2px;
|
| 385 |
+
line-height: 1.35;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.badge {
|
| 389 |
+
flex-shrink: 0;
|
| 390 |
+
font-family: var(--mono);
|
| 391 |
+
font-size: 9px;
|
| 392 |
+
font-weight: 700;
|
| 393 |
+
text-transform: uppercase;
|
| 394 |
+
letter-spacing: .6px;
|
| 395 |
+
padding: 2px 7px;
|
| 396 |
+
border-radius: 20px;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* Tag badge styles */
|
| 400 |
+
.badge-critical { background: rgba(240,80,80,.14); color: #f05f5f; border: 1px solid rgba(240,80,80,.3); }
|
| 401 |
+
.badge-credential{ background: rgba(220,140,60,.13); color: #e09040; border: 1px solid rgba(220,140,60,.28); }
|
| 402 |
+
.badge-feature { background: rgba(70,140,250,.12); color: #5a9eff; border: 1px solid rgba(70,140,250,.25); }
|
| 403 |
+
.badge-optional { background: rgba(61,214,140,.10); color: #3dd68c; border: 1px solid rgba(61,214,140,.22); }
|
| 404 |
+
.badge-advanced { background: rgba(160,100,230,.12);color: #b07ae0; border: 1px solid rgba(160,100,230,.25); }
|
| 405 |
+
.badge-build { background: rgba(240,185,60,.12); color: #e0b030; border: 1px solid rgba(240,185,60,.28); }
|
| 406 |
+
|
| 407 |
+
/* Card left-border accents */
|
| 408 |
+
.env-card:has(.badge-critical) { border-left: 3px solid rgba(240,80,80,.4); }
|
| 409 |
+
.env-card:has(.badge-critical):hover { border-left-color: rgba(240,80,80,.7); }
|
| 410 |
+
.env-card:has(.badge-critical).selected { border-left-color: #f05f5f; }
|
| 411 |
+
.env-card:has(.badge-credential){ border-left: 3px solid rgba(220,140,60,.3); }
|
| 412 |
+
|
| 413 |
+
/* Section count badge */
|
| 414 |
+
.sec-count {
|
| 415 |
+
font-family: var(--mono);
|
| 416 |
+
font-size: 10px;
|
| 417 |
+
color: var(--text3);
|
| 418 |
+
background: var(--bg3);
|
| 419 |
+
border: 1px solid var(--border);
|
| 420 |
+
border-radius: 10px;
|
| 421 |
+
padding: 1px 7px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
/* Tag legend */
|
| 425 |
+
.tag-legend {
|
| 426 |
+
margin-bottom: 14px;
|
| 427 |
+
background: var(--bg2);
|
| 428 |
+
border: 1px solid var(--border);
|
| 429 |
+
border-radius: var(--r);
|
| 430 |
+
overflow: hidden;
|
| 431 |
+
}
|
| 432 |
+
.legend-summary {
|
| 433 |
+
display: flex;
|
| 434 |
+
align-items: center;
|
| 435 |
+
gap: 10px;
|
| 436 |
+
padding: 7px 12px;
|
| 437 |
+
cursor: pointer;
|
| 438 |
+
list-style: none;
|
| 439 |
+
user-select: none;
|
| 440 |
+
outline: none;
|
| 441 |
+
}
|
| 442 |
+
.legend-summary::-webkit-details-marker { display: none; }
|
| 443 |
+
.legend-chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
|
| 444 |
+
.legend-hint { font-size: 10px; color: var(--text3); white-space: nowrap; flex-shrink: 0; }
|
| 445 |
+
.tag-legend[open] .legend-hint { opacity: 0; }
|
| 446 |
+
.legend-body {
|
| 447 |
+
padding: 8px 12px 10px;
|
| 448 |
+
border-top: 1px solid var(--border);
|
| 449 |
+
display: flex;
|
| 450 |
+
flex-direction: column;
|
| 451 |
+
gap: 6px;
|
| 452 |
+
}
|
| 453 |
+
.legend-row { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text2); }
|
| 454 |
+
.legend-row .badge { flex-shrink: 0; width: 74px; text-align: center; }
|
| 455 |
+
.legend-tip { font-size: 9.5px; color: var(--text3); margin-top: 4px; padding-top: 6px; border-top: 1px solid var(--border); }
|
| 456 |
+
.toolbar-hint { color: var(--text3); font-size: 12px; margin-right: 6px; white-space: nowrap; }
|
| 457 |
+
|
| 458 |
+
.card-input { position: relative; }
|
| 459 |
+
|
| 460 |
+
.card-input input[type="text"],
|
| 461 |
+
.card-input input[type="password"],
|
| 462 |
+
.card-input input[type="number"],
|
| 463 |
+
.card-input textarea,
|
| 464 |
+
.card-input select {
|
| 465 |
+
width: 100%;
|
| 466 |
+
background: var(--bg3);
|
| 467 |
+
border: 1px solid var(--border);
|
| 468 |
+
border-radius: var(--r);
|
| 469 |
+
padding: 7px 10px;
|
| 470 |
+
font-family: var(--mono);
|
| 471 |
+
font-size: 11.5px;
|
| 472 |
+
color: var(--text);
|
| 473 |
+
outline: none;
|
| 474 |
+
transition: border-color .15s;
|
| 475 |
+
resize: vertical;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.card-input input[type="text"]:focus,
|
| 479 |
+
.card-input input[type="password"]:focus,
|
| 480 |
+
.card-input input[type="number"]:focus,
|
| 481 |
+
.card-input textarea:focus,
|
| 482 |
+
.card-input select:focus {
|
| 483 |
+
border-color: var(--amber);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.card-input textarea { min-height: 64px; }
|
| 487 |
+
.card-input select {
|
| 488 |
+
cursor: pointer;
|
| 489 |
+
appearance: none;
|
| 490 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238d97ad' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
| 491 |
+
background-repeat: no-repeat;
|
| 492 |
+
background-position: right 10px center;
|
| 493 |
+
padding-right: 28px;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.card-input optgroup { color: var(--text2); font-weight: 600; }
|
| 497 |
+
.card-input option { color: var(--text); background: var(--bg3); }
|
| 498 |
+
|
| 499 |
+
.toggle-shell { display: flex; align-items: center; gap: 8px; }
|
| 500 |
+
.tog {
|
| 501 |
+
padding: 5px 14px;
|
| 502 |
+
border-radius: 20px;
|
| 503 |
+
border: 1px solid var(--border2);
|
| 504 |
+
background: var(--bg3);
|
| 505 |
+
color: var(--text3);
|
| 506 |
+
font-family: var(--mono);
|
| 507 |
+
font-size: 11px;
|
| 508 |
+
font-weight: 700;
|
| 509 |
+
cursor: pointer;
|
| 510 |
+
transition: all .18s;
|
| 511 |
+
letter-spacing: .5px;
|
| 512 |
+
}
|
| 513 |
+
.tog.on {
|
| 514 |
+
background: rgba(61,214,140,.15);
|
| 515 |
+
border-color: rgba(61,214,140,.4);
|
| 516 |
+
color: var(--green);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.picker-shell { display: flex; flex-direction: column; gap: 6px; }
|
| 520 |
+
.picker-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
| 521 |
+
|
| 522 |
+
.picker-select {
|
| 523 |
+
flex: 1;
|
| 524 |
+
min-width: 0;
|
| 525 |
+
padding: 6px 28px 6px 8px !important;
|
| 526 |
+
font-size: 11px !important;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.mini-btn {
|
| 530 |
+
padding: 5px 9px;
|
| 531 |
+
border-radius: var(--r);
|
| 532 |
+
border: 1px solid var(--border2);
|
| 533 |
+
background: var(--bg3);
|
| 534 |
+
color: var(--text2);
|
| 535 |
+
font-family: var(--mono);
|
| 536 |
+
font-size: 10px;
|
| 537 |
+
font-weight: 600;
|
| 538 |
+
cursor: pointer;
|
| 539 |
+
transition: all .15s;
|
| 540 |
+
white-space: nowrap;
|
| 541 |
+
}
|
| 542 |
+
.mini-btn:hover { background: var(--bg4); color: var(--text); }
|
| 543 |
+
|
| 544 |
+
.right-panel {
|
| 545 |
+
width: var(--panel-w);
|
| 546 |
+
flex-shrink: 0;
|
| 547 |
+
border-left: 1px solid var(--border);
|
| 548 |
+
background: var(--bg2);
|
| 549 |
+
display: flex;
|
| 550 |
+
flex-direction: column;
|
| 551 |
+
overflow: hidden;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.panel-scroll {
|
| 555 |
+
flex: 1;
|
| 556 |
+
overflow-y: auto;
|
| 557 |
+
padding: 16px;
|
| 558 |
+
display: flex;
|
| 559 |
+
flex-direction: column;
|
| 560 |
+
gap: 16px;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.panel-scroll::-webkit-scrollbar { width: 4px; }
|
| 564 |
+
.panel-scroll::-webkit-scrollbar-track { background: transparent; }
|
| 565 |
+
.panel-scroll::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
| 566 |
+
|
| 567 |
+
.pblock {
|
| 568 |
+
background: var(--bg3);
|
| 569 |
+
border: 1px solid var(--border);
|
| 570 |
+
border-radius: var(--r2);
|
| 571 |
+
overflow: hidden;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.pblock-head {
|
| 575 |
+
display: flex;
|
| 576 |
+
align-items: center;
|
| 577 |
+
justify-content: space-between;
|
| 578 |
+
padding: 10px 14px;
|
| 579 |
+
border-bottom: 1px solid var(--border);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.pblock-title {
|
| 583 |
+
font-size: 10.5px;
|
| 584 |
+
font-weight: 700;
|
| 585 |
+
text-transform: uppercase;
|
| 586 |
+
letter-spacing: 1px;
|
| 587 |
+
color: var(--text3);
|
| 588 |
+
display: flex;
|
| 589 |
+
align-items: center;
|
| 590 |
+
gap: 6px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.pblock-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
|
| 594 |
+
|
| 595 |
+
.pblock-body textarea,
|
| 596 |
+
.pblock-body input[type="text"] {
|
| 597 |
+
width: 100%;
|
| 598 |
+
background: var(--bg);
|
| 599 |
+
border: 1px solid var(--border);
|
| 600 |
+
border-radius: var(--r);
|
| 601 |
+
padding: 8px 10px;
|
| 602 |
+
font-family: var(--mono);
|
| 603 |
+
font-size: 10.5px;
|
| 604 |
+
color: var(--text2);
|
| 605 |
+
outline: none;
|
| 606 |
+
resize: vertical;
|
| 607 |
+
transition: border-color .15s;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.pblock-body textarea:focus,
|
| 611 |
+
.pblock-body input[type="text"]:focus {
|
| 612 |
+
border-color: var(--amber);
|
| 613 |
+
color: var(--text);
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
#importText { min-height: 80px; }
|
| 617 |
+
#bundleOut { min-height: 60px; color: var(--amber2); }
|
| 618 |
+
#envLineOut { font-size: 10px; }
|
| 619 |
+
|
| 620 |
+
.row-btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
| 621 |
+
|
| 622 |
+
#summary {
|
| 623 |
+
font-size: 11.5px;
|
| 624 |
+
color: var(--text2);
|
| 625 |
+
line-height: 1.6;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
#summary strong {
|
| 629 |
+
font-size: 15px;
|
| 630 |
+
color: var(--amber);
|
| 631 |
+
font-family: var(--mono);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.sum-keys {
|
| 635 |
+
margin-top: 8px;
|
| 636 |
+
display: flex;
|
| 637 |
+
flex-wrap: wrap;
|
| 638 |
+
gap: 4px;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.sum-key {
|
| 642 |
+
font-family: var(--mono);
|
| 643 |
+
font-size: 9.5px;
|
| 644 |
+
color: var(--text2);
|
| 645 |
+
background: var(--bg4);
|
| 646 |
+
border: 1px solid var(--border2);
|
| 647 |
+
border-radius: 4px;
|
| 648 |
+
padding: 2px 6px;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
#customSec { margin-top: 8px; }
|
| 652 |
+
|
| 653 |
+
.custom-row {
|
| 654 |
+
display: flex;
|
| 655 |
+
gap: 8px;
|
| 656 |
+
align-items: center;
|
| 657 |
+
margin-bottom: 8px;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.custom-row input {
|
| 661 |
+
flex: 1;
|
| 662 |
+
background: var(--bg3);
|
| 663 |
+
border: 1px solid var(--border);
|
| 664 |
+
border-radius: var(--r);
|
| 665 |
+
padding: 7px 10px;
|
| 666 |
+
font-family: var(--mono);
|
| 667 |
+
font-size: 11px;
|
| 668 |
+
color: var(--text);
|
| 669 |
+
outline: none;
|
| 670 |
+
transition: border-color .15s;
|
| 671 |
+
min-width: 0;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.custom-row input:focus { border-color: var(--amber); }
|
| 675 |
+
.custom-row input:first-child { flex: 0 0 40%; }
|
| 676 |
+
|
| 677 |
+
#toast {
|
| 678 |
+
position: fixed;
|
| 679 |
+
bottom: 24px;
|
| 680 |
+
left: 50%;
|
| 681 |
+
transform: translateX(-50%) translateY(20px);
|
| 682 |
+
background: var(--bg4);
|
| 683 |
+
border: 1px solid var(--border2);
|
| 684 |
+
color: var(--amber);
|
| 685 |
+
font-family: var(--mono);
|
| 686 |
+
font-size: 12px;
|
| 687 |
+
font-weight: 600;
|
| 688 |
+
padding: 9px 20px;
|
| 689 |
+
border-radius: 30px;
|
| 690 |
+
z-index: 9999;
|
| 691 |
+
opacity: 0;
|
| 692 |
+
transition: opacity .2s, transform .2s;
|
| 693 |
+
pointer-events: none;
|
| 694 |
+
box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
#toast.show {
|
| 698 |
+
opacity: 1;
|
| 699 |
+
transform: translateX(-50%) translateY(0);
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 703 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 704 |
+
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
|
| 705 |
+
|
| 706 |
+
@media (max-width: 900px) {
|
| 707 |
+
:root { --panel-w: 280px; --sidebar-w: 180px; }
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
@media (max-width: 700px) {
|
| 711 |
+
.right-panel { display: none; }
|
| 712 |
+
:root { --sidebar-w: 160px; }
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
@media (max-width: 520px) {
|
| 716 |
+
.sidebar-wrap { display: none; }
|
| 717 |
+
.topbar-divider, .topbar-title { display: none; }
|
| 718 |
+
}
|
| 719 |
+
</style>
|
| 720 |
+
</head>
|
| 721 |
+
|
| 722 |
+
<body>
|
| 723 |
+
|
| 724 |
+
<header class="topbar">
|
| 725 |
+
<div class="topbar-logo">
|
| 726 |
+
<span class="logo-emoji">πͺ½</span>
|
| 727 |
+
<span class="topbar-wordmark">Hugging<em>Mes</em></span>
|
| 728 |
+
</div>
|
| 729 |
+
<div class="topbar-divider"></div>
|
| 730 |
+
<span class="topbar-title">ENV Builder</span>
|
| 731 |
+
<div class="topbar-spacer"></div>
|
| 732 |
+
<span class="topbar-pill">v2025</span>
|
| 733 |
+
</header>
|
| 734 |
+
|
| 735 |
+
<div class="layout">
|
| 736 |
+
|
| 737 |
+
<aside class="sidebar-wrap">
|
| 738 |
+
<div class="sidebar-scroll">
|
| 739 |
+
<div id="sidebar"></div>
|
| 740 |
+
</div>
|
| 741 |
+
</aside>
|
| 742 |
+
|
| 743 |
+
<main class="main">
|
| 744 |
+
|
| 745 |
+
<div class="toolbar">
|
| 746 |
+
<span class="toolbar-hint">Tip: Start with <strong>β‘ Required</strong>, then fill keys and click <strong># Generate Bundle</strong>.</span>
|
| 747 |
+
<div class="search-wrap">
|
| 748 |
+
<span class="search-icon">β</span>
|
| 749 |
+
<input id="search" type="text" placeholder="Search variablesβ¦" autocomplete="off" spellcheck="false">
|
| 750 |
+
</div>
|
| 751 |
+
|
| 752 |
+
<div class="tb-sep"></div>
|
| 753 |
+
|
| 754 |
+
<button id="selectRequired" class="btn">β‘ Required</button>
|
| 755 |
+
<button id="selectCommon" class="btn">β
Common</button>
|
| 756 |
+
<button id="selectVisible" class="btn">β Visible</button>
|
| 757 |
+
<button id="clearAll" class="btn btn-ghost">β Clear</button>
|
| 758 |
+
</div>
|
| 759 |
+
|
| 760 |
+
<div class="content-wrap">
|
| 761 |
+
|
| 762 |
+
<div class="sections-scroll">
|
| 763 |
+
|
| 764 |
+
<details class="tag-legend">
|
| 765 |
+
<summary class="legend-summary">
|
| 766 |
+
<div class="legend-chips">
|
| 767 |
+
<span class="badge badge-critical">critical</span>
|
| 768 |
+
<span class="badge badge-credential">credential</span>
|
| 769 |
+
<span class="badge badge-feature">feature</span>
|
| 770 |
+
<span class="badge badge-optional">optional</span>
|
| 771 |
+
<span class="badge badge-advanced">advanced</span>
|
| 772 |
+
<span class="badge badge-build">build</span>
|
| 773 |
+
</div>
|
| 774 |
+
<span class="legend-hint">βΈ Tag legend</span>
|
| 775 |
+
</summary>
|
| 776 |
+
<div class="legend-body">
|
| 777 |
+
<div class="legend-row"><span class="badge badge-critical">critical</span> Required for the space to function at all</div>
|
| 778 |
+
<div class="legend-row"><span class="badge badge-credential">credential</span> API keys, tokens, secrets β keep private</div>
|
| 779 |
+
<div class="legend-row"><span class="badge badge-feature">feature</span> Unlocks an optional feature or integration</div>
|
| 780 |
+
<div class="legend-row"><span class="badge badge-optional">optional</span> Useful but not required; has a default</div>
|
| 781 |
+
<div class="legend-row"><span class="badge badge-advanced">advanced</span> Fine-tuning for specific deployments</div>
|
| 782 |
+
<div class="legend-row"><span class="badge badge-build">build</span> Affects Docker build, not runtime</div>
|
| 783 |
+
<div class="legend-tip">Use <strong>β‘ Required</strong> to auto-select all critical fields, then fill in credential keys.</div>
|
| 784 |
+
</div>
|
| 785 |
+
</details>
|
| 786 |
+
|
| 787 |
+
<div id="sections"></div>
|
| 788 |
+
|
| 789 |
+
<div id="customSec" class="sec" data-section="Custom Env">
|
| 790 |
+
<div class="sec-header">
|
| 791 |
+
<span class="sec-icon">π§</span>
|
| 792 |
+
<span class="sec-title">Custom Env</span>
|
| 793 |
+
<div class="sec-line"></div>
|
| 794 |
+
</div>
|
| 795 |
+
<div id="customRows"></div>
|
| 796 |
+
<button id="addCustom" class="btn" style="margin-top:6px;">+ Add variable</button>
|
| 797 |
+
</div>
|
| 798 |
+
</div>
|
| 799 |
+
|
| 800 |
+
<aside class="right-panel">
|
| 801 |
+
<div class="panel-scroll">
|
| 802 |
+
|
| 803 |
+
<div class="pblock">
|
| 804 |
+
<div class="pblock-head">
|
| 805 |
+
<span class="pblock-title">π Summary</span>
|
| 806 |
+
</div>
|
| 807 |
+
<div class="pblock-body">
|
| 808 |
+
<div id="summary">No variables selected yet.</div>
|
| 809 |
+
</div>
|
| 810 |
+
</div>
|
| 811 |
+
|
| 812 |
+
<div class="pblock">
|
| 813 |
+
<div class="pblock-head">
|
| 814 |
+
<span class="pblock-title">π¦ Bundle Output</span>
|
| 815 |
+
</div>
|
| 816 |
+
<div class="pblock-body">
|
| 817 |
+
<textarea id="bundleOut" placeholder="Select variables and click # Generate Bundleβ¦" readonly spellcheck="false"></textarea>
|
| 818 |
+
<input type="text" id="envLineOut" placeholder="HUGGINGMES_ENV_BUNDLE=β¦" readonly spellcheck="false">
|
| 819 |
+
<div class="row-btns">
|
| 820 |
+
<button id="generateBundle" class="btn btn-amber" style="width:100%;"># Generate Bundle</button>
|
| 821 |
+
</div>
|
| 822 |
+
<div class="row-btns">
|
| 823 |
+
<button id="copyBundle" class="btn btn-amber">β Bundle</button>
|
| 824 |
+
<button id="copyEnvLine" class="btn">β Env Line</button>
|
| 825 |
+
<button id="copyJson" class="btn">β JSON</button>
|
| 826 |
+
<button id="applyBundle" class="btn btn-ghost">βΊ Apply</button>
|
| 827 |
+
</div>
|
| 828 |
+
</div>
|
| 829 |
+
</div>
|
| 830 |
+
|
| 831 |
+
<div class="pblock">
|
| 832 |
+
<div class="pblock-head">
|
| 833 |
+
<span class="pblock-title">π₯ Import</span>
|
| 834 |
+
</div>
|
| 835 |
+
<div class="pblock-body">
|
| 836 |
+
<textarea id="importText" placeholder="Paste .env, JSON, or HUGGINGMES_ENV_BUNDLE=β¦ here" spellcheck="false"></textarea>
|
| 837 |
+
<button id="applyImport" class="btn btn-amber" style="width:100%;">β Import & Apply</button>
|
| 838 |
+
</div>
|
| 839 |
+
</div>
|
| 840 |
+
|
| 841 |
+
</div>
|
| 842 |
+
</aside>
|
| 843 |
+
|
| 844 |
+
</div>
|
| 845 |
+
</main>
|
| 846 |
+
</div>
|
| 847 |
+
|
| 848 |
+
<div id="toast">Copied β</div>
|
| 849 |
+
|
| 850 |
+
<script src="env-builder.js"></script>
|
| 851 |
+
|
| 852 |
+
</body>
|
| 853 |
+
</html>
|
env-builder.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ββ Model Catalogs ββ
|
| 2 |
+
const MODEL_CATALOGS = {
|
| 3 |
+
"LLM_MODEL": {
|
| 4 |
+
"Anthropic": [
|
| 5 |
+
"anthropic/claude-opus-4-7",
|
| 6 |
+
"anthropic/claude-opus-4-6",
|
| 7 |
+
"anthropic/claude-sonnet-4-6",
|
| 8 |
+
"anthropic/claude-sonnet-4-5",
|
| 9 |
+
"anthropic/claude-haiku-4-5",
|
| 10 |
+
"anthropic/claude-haiku-3-5"
|
| 11 |
+
],
|
| 12 |
+
"Gemini": [
|
| 13 |
+
"gemini/gemini-2.5-pro-preview-06-05",
|
| 14 |
+
"gemini/gemini-2.5-flash-preview-05-20",
|
| 15 |
+
"gemini/gemini-2.5-flash",
|
| 16 |
+
"gemini/gemini-2.0-flash",
|
| 17 |
+
"gemini/gemini-1.5-pro",
|
| 18 |
+
"gemini/gemini-1.5-flash",
|
| 19 |
+
"google/gemini-2.5-flash",
|
| 20 |
+
"google/gemini-2.0-flash"
|
| 21 |
+
],
|
| 22 |
+
"OpenAI": [
|
| 23 |
+
"openai/gpt-4.1",
|
| 24 |
+
"openai/gpt-4.1-mini",
|
| 25 |
+
"openai/gpt-4o",
|
| 26 |
+
"openai/gpt-4o-mini",
|
| 27 |
+
"openai/o3",
|
| 28 |
+
"openai/o4-mini",
|
| 29 |
+
"openai/o3-mini"
|
| 30 |
+
],
|
| 31 |
+
"OpenRouter": [
|
| 32 |
+
"openrouter/anthropic/claude-opus-4-7",
|
| 33 |
+
"openrouter/anthropic/claude-sonnet-4-6",
|
| 34 |
+
"openrouter/anthropic/claude-haiku-4-5",
|
| 35 |
+
"openrouter/openai/gpt-4o",
|
| 36 |
+
"openrouter/openai/o3",
|
| 37 |
+
"openrouter/google/gemini-2.5-flash",
|
| 38 |
+
"openrouter/google/gemini-2.5-pro",
|
| 39 |
+
"openrouter/meta-llama/llama-4-maverick",
|
| 40 |
+
"openrouter/deepseek/deepseek-r1",
|
| 41 |
+
"openrouter/deepseek/deepseek-chat-v3-5",
|
| 42 |
+
"openrouter/mistralai/mistral-large"
|
| 43 |
+
],
|
| 44 |
+
"DeepSeek": [
|
| 45 |
+
"deepseek/deepseek-chat",
|
| 46 |
+
"deepseek/deepseek-reasoner"
|
| 47 |
+
],
|
| 48 |
+
"xAI": [
|
| 49 |
+
"xai/grok-3",
|
| 50 |
+
"xai/grok-3-mini",
|
| 51 |
+
"xai/grok-2"
|
| 52 |
+
],
|
| 53 |
+
"HuggingFace": [
|
| 54 |
+
"huggingface/meta-llama/Llama-3.3-70B-Instruct",
|
| 55 |
+
"huggingface/meta-llama/Llama-3.1-70B-Instruct",
|
| 56 |
+
"huggingface/Qwen/Qwen2.5-72B-Instruct",
|
| 57 |
+
"huggingface/mistralai/Mistral-7B-Instruct-v0.3",
|
| 58 |
+
"huggingface/google/gemma-2-27b-it"
|
| 59 |
+
],
|
| 60 |
+
"Moonshot / Kimi": [
|
| 61 |
+
"moonshot/moonshot-v1-128k",
|
| 62 |
+
"kimi-coding/kimi-k2-0711-preview",
|
| 63 |
+
"kimi-coding-cn/kimi-k2-0711-preview"
|
| 64 |
+
],
|
| 65 |
+
"Alibaba": [
|
| 66 |
+
"alibaba/qwen-max",
|
| 67 |
+
"alibaba/qwen-plus",
|
| 68 |
+
"alibaba/qwen-turbo"
|
| 69 |
+
],
|
| 70 |
+
"Minimax": [
|
| 71 |
+
"minimax/minimax-01",
|
| 72 |
+
"minimax-cn/minimax-01"
|
| 73 |
+
],
|
| 74 |
+
"NVIDIA": [
|
| 75 |
+
"nvidia/meta/llama-3.1-70b-instruct",
|
| 76 |
+
"nvidia/meta/llama-3.3-70b-instruct"
|
| 77 |
+
],
|
| 78 |
+
"GLM / ZAI": [
|
| 79 |
+
"zai/glm-4-plus",
|
| 80 |
+
"glm/chatglm-turbo"
|
| 81 |
+
],
|
| 82 |
+
"Vercel AI Gateway": [
|
| 83 |
+
"vercel-ai-gateway/anthropic/claude-sonnet-4-6",
|
| 84 |
+
"vercel-ai-gateway/openai/gpt-4o"
|
| 85 |
+
],
|
| 86 |
+
"Custom / OpenAI-compatible": [
|
| 87 |
+
"custom"
|
| 88 |
+
]
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
// ββ Icons per group ββ
|
| 93 |
+
const ICONS = {
|
| 94 |
+
"All": "π",
|
| 95 |
+
"Core": "β‘",
|
| 96 |
+
"Telegram": "π±",
|
| 97 |
+
"Terminal": "π»",
|
| 98 |
+
"Providers": "π",
|
| 99 |
+
"Cloudflare":"βοΈ",
|
| 100 |
+
"Advanced": "βοΈ",
|
| 101 |
+
"Custom Env":"π§"
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
// ββ Field definitions ββ
|
| 105 |
+
// tag: "critical" | "credential" | "feature" | "optional" | "advanced" | "build"
|
| 106 |
+
const FIELDS = [
|
| 107 |
+
// ββ Core ββ
|
| 108 |
+
{
|
| 109 |
+
"g": "Core", "icon": "β‘",
|
| 110 |
+
"k": "GATEWAY_TOKEN",
|
| 111 |
+
"lbl": "Gateway token β protects the Hermes web UI",
|
| 112 |
+
"type": "password", "secret": 1, "common": 1, "tag": "critical"
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"g": "Core", "icon": "β‘",
|
| 116 |
+
"k": "LLM_MODEL",
|
| 117 |
+
"lbl": "Default model (provider/model-name format)",
|
| 118 |
+
"type": "model", "options_key": "LLM_MODEL",
|
| 119 |
+
"ph": "gemini/gemini-2.5-flash", "common": 1, "tag": "critical"
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"g": "Core", "icon": "β‘",
|
| 123 |
+
"k": "LLM_API_KEY",
|
| 124 |
+
"lbl": "API key for the chosen provider",
|
| 125 |
+
"type": "password", "secret": 1, "common": 1, "tag": "credential"
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// ββ Telegram ββ
|
| 129 |
+
{
|
| 130 |
+
"g": "Telegram", "icon": "π±",
|
| 131 |
+
"k": "TELEGRAM_BOT_TOKEN",
|
| 132 |
+
"lbl": "Telegram bot token from @BotFather",
|
| 133 |
+
"type": "password", "secret": 1, "common": 1, "tag": "credential"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"g": "Telegram", "icon": "π±",
|
| 137 |
+
"k": "TELEGRAM_ALLOWED_USERS",
|
| 138 |
+
"lbl": "Allowed Telegram user IDs (comma-separated)",
|
| 139 |
+
"type": "text", "ph": "123456789,987654321", "common": 1, "tag": "feature"
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"g": "Telegram", "icon": "π±",
|
| 143 |
+
"k": "TELEGRAM_MODE",
|
| 144 |
+
"lbl": "Telegram update mode",
|
| 145 |
+
"type": "select",
|
| 146 |
+
"options": ["webhook", "polling"],
|
| 147 |
+
"ph": "webhook", "tag": "optional"
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"g": "Telegram", "icon": "π±",
|
| 151 |
+
"k": "TELEGRAM_WEBHOOK_URL",
|
| 152 |
+
"lbl": "Override webhook URL (auto-detected from SPACE_HOST if blank)",
|
| 153 |
+
"type": "text", "ph": "https://your-space.hf.space/telegram", "tag": "optional"
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"g": "Telegram", "icon": "π±",
|
| 157 |
+
"k": "TELEGRAM_BASE_URL",
|
| 158 |
+
"lbl": "Custom Telegram API base URL (for proxies)",
|
| 159 |
+
"type": "text", "ph": "https://proxy.example.com/bot", "tag": "optional"
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
// ββ Terminal ββ
|
| 163 |
+
{
|
| 164 |
+
"g": "Terminal", "icon": "π»",
|
| 165 |
+
"k": "DEV_MODE",
|
| 166 |
+
"lbl": "Enable JupyterLab terminal (on by default)",
|
| 167 |
+
"type": "toggle", "ph": "true", "common": 1, "tag": "feature"
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"g": "Terminal", "icon": "π»",
|
| 171 |
+
"k": "JUPYTER_TOKEN",
|
| 172 |
+
"lbl": "Override terminal password (defaults to GATEWAY_TOKEN)",
|
| 173 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"g": "Terminal", "icon": "π»",
|
| 177 |
+
"k": "JUPYTER_ROOT_DIR",
|
| 178 |
+
"lbl": "JupyterLab root directory",
|
| 179 |
+
"type": "text", "ph": "/opt/data/workspace", "tag": "optional"
|
| 180 |
+
},
|
| 181 |
+
|
| 182 |
+
// ββ Providers ββ
|
| 183 |
+
{
|
| 184 |
+
"g": "Providers", "icon": "π",
|
| 185 |
+
"k": "ANTHROPIC_API_KEY",
|
| 186 |
+
"lbl": "Anthropic API key",
|
| 187 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"g": "Providers", "icon": "π",
|
| 191 |
+
"k": "OPENAI_API_KEY",
|
| 192 |
+
"lbl": "OpenAI API key",
|
| 193 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"g": "Providers", "icon": "π",
|
| 197 |
+
"k": "GOOGLE_API_KEY",
|
| 198 |
+
"lbl": "Google / Gemini API key",
|
| 199 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"g": "Providers", "icon": "π",
|
| 203 |
+
"k": "GEMINI_API_KEY",
|
| 204 |
+
"lbl": "Gemini API key (alias for GOOGLE_API_KEY)",
|
| 205 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 206 |
+
},
|
| 207 |
+
{
|
| 208 |
+
"g": "Providers", "icon": "π",
|
| 209 |
+
"k": "OPENROUTER_API_KEY",
|
| 210 |
+
"lbl": "OpenRouter API key",
|
| 211 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"g": "Providers", "icon": "π",
|
| 215 |
+
"k": "DEEPSEEK_API_KEY",
|
| 216 |
+
"lbl": "DeepSeek API key",
|
| 217 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 218 |
+
},
|
| 219 |
+
{
|
| 220 |
+
"g": "Providers", "icon": "π",
|
| 221 |
+
"k": "XAI_API_KEY",
|
| 222 |
+
"lbl": "xAI (Grok) API key",
|
| 223 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"g": "Providers", "icon": "π",
|
| 227 |
+
"k": "HERMES_INFERENCE_PROVIDER",
|
| 228 |
+
"lbl": "Force Hermes inference provider (overrides auto-detect)",
|
| 229 |
+
"type": "select",
|
| 230 |
+
"options": ["auto", "anthropic", "openai", "gemini", "openrouter", "huggingface", "custom", "deepseek", "xai"],
|
| 231 |
+
"ph": "auto", "tag": "advanced"
|
| 232 |
+
},
|
| 233 |
+
{
|
| 234 |
+
"g": "Providers", "icon": "π",
|
| 235 |
+
"k": "CUSTOM_BASE_URL",
|
| 236 |
+
"lbl": "Custom OpenAI-compatible base URL",
|
| 237 |
+
"type": "text", "ph": "https://your-api.example.com/v1", "tag": "feature"
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"g": "Providers", "icon": "π",
|
| 241 |
+
"k": "CUSTOM_API_KEY",
|
| 242 |
+
"lbl": "API key for the custom provider",
|
| 243 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"g": "Providers", "icon": "π",
|
| 247 |
+
"k": "CUSTOM_PROVIDER",
|
| 248 |
+
"lbl": "Provider name for custom endpoints",
|
| 249 |
+
"type": "text", "ph": "custom", "tag": "advanced"
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"g": "Providers", "icon": "π",
|
| 253 |
+
"k": "CUSTOM_MODEL_CONTEXT_LENGTH",
|
| 254 |
+
"lbl": "Context length for custom model",
|
| 255 |
+
"type": "number", "ph": "131072", "tag": "advanced"
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"g": "Providers", "icon": "π",
|
| 259 |
+
"k": "CUSTOM_MODEL_MAX_TOKENS",
|
| 260 |
+
"lbl": "Max output tokens for custom model",
|
| 261 |
+
"type": "number", "ph": "8192", "tag": "advanced"
|
| 262 |
+
},
|
| 263 |
+
|
| 264 |
+
// ββ Cloudflare ββ
|
| 265 |
+
{
|
| 266 |
+
"g": "Cloudflare", "icon": "βοΈ",
|
| 267 |
+
"k": "CLOUDFLARE_WORKERS_TOKEN",
|
| 268 |
+
"lbl": "Cloudflare Workers API token (for Telegram proxy setup)",
|
| 269 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"g": "Cloudflare", "icon": "βοΈ",
|
| 273 |
+
"k": "CLOUDFLARE_PROXY_URL",
|
| 274 |
+
"lbl": "Cloudflare proxy URL for Telegram (if already deployed)",
|
| 275 |
+
"type": "text", "ph": "https://your-worker.your-subdomain.workers.dev", "tag": "feature"
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"g": "Cloudflare", "icon": "βοΈ",
|
| 279 |
+
"k": "CLOUDFLARE_PROXY_DEBUG",
|
| 280 |
+
"lbl": "Enable Cloudflare proxy debug logging",
|
| 281 |
+
"type": "toggle", "ph": "false", "tag": "advanced"
|
| 282 |
+
},
|
| 283 |
+
|
| 284 |
+
// ββ Advanced ββ
|
| 285 |
+
{
|
| 286 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 287 |
+
"k": "WEBHOOK_URL",
|
| 288 |
+
"lbl": "URL to POST a JSON notification on gateway (re)start",
|
| 289 |
+
"type": "text", "ph": "https://...", "tag": "optional"
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 293 |
+
"k": "SPACE_PRIVACY",
|
| 294 |
+
"lbl": "Override Space privacy detection (public/private) β skips HF API call",
|
| 295 |
+
"type": "select", "options": ["public", "private"], "ph": "public", "tag": "advanced"
|
| 296 |
+
},
|
| 297 |
+
{
|
| 298 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 299 |
+
"k": "GATEWAY_READY_TIMEOUT",
|
| 300 |
+
"lbl": "Seconds to wait for gateway API port before failing",
|
| 301 |
+
"type": "number", "ph": "120", "tag": "advanced"
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 305 |
+
"k": "API_SERVER_PORT",
|
| 306 |
+
"lbl": "Hermes gateway internal API port",
|
| 307 |
+
"type": "number", "ph": "8642", "tag": "advanced"
|
| 308 |
+
},
|
| 309 |
+
{
|
| 310 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 311 |
+
"k": "DASHBOARD_PORT",
|
| 312 |
+
"lbl": "Hermes dashboard internal port",
|
| 313 |
+
"type": "number", "ph": "9119", "tag": "advanced"
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 317 |
+
"k": "HERMES_BACKGROUND_NOTIFICATIONS",
|
| 318 |
+
"lbl": "Background process notification level",
|
| 319 |
+
"type": "select",
|
| 320 |
+
"options": ["result", "progress", "none"],
|
| 321 |
+
"ph": "result", "tag": "optional"
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"g": "Advanced", "icon": "βοΈ",
|
| 325 |
+
"k": "TELEGRAM_WEBHOOK_SECRET",
|
| 326 |
+
"lbl": "Secret token for Telegram webhook validation (auto-generated if blank)",
|
| 327 |
+
"type": "password", "secret": 1, "tag": "credential"
|
| 328 |
+
}
|
| 329 |
+
];
|
| 330 |
+
|
| 331 |
+
// ββ Runtime (shared with HuggingClaw env-builder) ββ
|
| 332 |
+
|
| 333 |
+
const BUNDLE_KEY = 'HUGGINGMES_ENV_BUNDLE';
|
| 334 |
+
|
| 335 |
+
const $ = id => document.getElementById(id);
|
| 336 |
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({
|
| 337 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 338 |
+
}[c]));
|
| 339 |
+
const safeKey = k => /^[A-Z_][A-Z0-9_]*$/.test(k) && ![BUNDLE_KEY, 'ENV_BUNDLE'].includes(k);
|
| 340 |
+
|
| 341 |
+
function encodeBundle(obj) {
|
| 342 |
+
const j = JSON.stringify(obj);
|
| 343 |
+
let b = '';
|
| 344 |
+
for (const x of new TextEncoder().encode(j)) b += String.fromCharCode(x);
|
| 345 |
+
return btoa(b).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function decodeBundle(raw) {
|
| 349 |
+
try {
|
| 350 |
+
raw = String(raw || '').trim();
|
| 351 |
+
if (!raw) return {};
|
| 352 |
+
if (raw.includes(BUNDLE_KEY + '=')) raw = raw.split(BUNDLE_KEY + '=').pop().trim();
|
| 353 |
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) raw = raw.slice(1, -1);
|
| 354 |
+
if (raw.startsWith('{')) return JSON.parse(raw);
|
| 355 |
+
const p = raw + '='.repeat((4 - raw.length % 4) % 4);
|
| 356 |
+
const b = atob(p.replace(/-/g, '+').replace(/_/g, '/'));
|
| 357 |
+
const bytes = Uint8Array.from(b, c => c.charCodeAt(0));
|
| 358 |
+
return JSON.parse(new TextDecoder().decode(bytes));
|
| 359 |
+
} catch { return {}; }
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
function parseEnv(text) {
|
| 363 |
+
text = String(text || '').trim();
|
| 364 |
+
if (!text) return {};
|
| 365 |
+
if (text.startsWith('{') || /^[A-Za-z0-9_-]{20,}$/.test(text) || text.includes(BUNDLE_KEY + '=')) {
|
| 366 |
+
return decodeBundle(text);
|
| 367 |
+
}
|
| 368 |
+
const out = {};
|
| 369 |
+
for (let line of text.split(/\r?\n/)) {
|
| 370 |
+
line = line.trim();
|
| 371 |
+
if (!line || line.startsWith('#')) continue;
|
| 372 |
+
if (line.startsWith('export ')) line = line.slice(7).trim();
|
| 373 |
+
const i = line.indexOf('=');
|
| 374 |
+
if (i < 1) continue;
|
| 375 |
+
const key = line.slice(0, i).trim();
|
| 376 |
+
let val = line.slice(i + 1).trim();
|
| 377 |
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
|
| 378 |
+
if (safeKey(key)) out[key] = val;
|
| 379 |
+
}
|
| 380 |
+
return out;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
function showToast(msg = 'Copied!') {
|
| 384 |
+
const t = $('toast');
|
| 385 |
+
t.textContent = msg;
|
| 386 |
+
t.classList.add('show');
|
| 387 |
+
setTimeout(() => t.classList.remove('show'), 1500);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
let activeGroup = 'All';
|
| 391 |
+
let customCount = 0;
|
| 392 |
+
const GROUPS = ['All', ...[...new Set(FIELDS.map(f => f.g))], 'Custom Env'];
|
| 393 |
+
|
| 394 |
+
function renderSidebar() {
|
| 395 |
+
const sb = $('sidebar');
|
| 396 |
+
sb.innerHTML = '<div class="sb-label">Groups</div>';
|
| 397 |
+
GROUPS.forEach(g => {
|
| 398 |
+
const btn = document.createElement('button');
|
| 399 |
+
btn.className = 'nav-btn' + (activeGroup === g ? ' active' : '');
|
| 400 |
+
btn.dataset.group = g;
|
| 401 |
+
const id = 'nc_' + g.replace(/\W/g, '_');
|
| 402 |
+
btn.innerHTML = `<span class="nav-icon">${ICONS[g] || 'π'}</span><span class="nav-label">${esc(g)}</span><span class="nav-count" id="${id}">0</span>`;
|
| 403 |
+
btn.onclick = () => { activeGroup = g; renderSidebar(); filter(); };
|
| 404 |
+
sb.appendChild(btn);
|
| 405 |
+
});
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
function renderOptionsHTML(field) {
|
| 409 |
+
if (field.options_key === 'LLM_MODEL') {
|
| 410 |
+
const groups = MODEL_CATALOGS.LLM_MODEL || {};
|
| 411 |
+
return Object.entries(groups).map(([group, items]) => {
|
| 412 |
+
const options = items.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
|
| 413 |
+
return `<optgroup label="${esc(group)}">${options}</optgroup>`;
|
| 414 |
+
}).join('');
|
| 415 |
+
}
|
| 416 |
+
const src = field.options || MODEL_CATALOGS[field.options_key] || [];
|
| 417 |
+
if (Array.isArray(src)) return src.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
|
| 418 |
+
return '';
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
function defaultValueFor(field) {
|
| 422 |
+
if (field.type === 'toggle') {
|
| 423 |
+
const on = String(field.ph ?? '').toLowerCase();
|
| 424 |
+
return ['1', 'true', 'yes', 'on', 'enabled'].includes(on) ? 'true' : 'false';
|
| 425 |
+
}
|
| 426 |
+
if (field.type === 'select') return String(field.ph ?? '');
|
| 427 |
+
return '';
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
function valueControlHTML(field) {
|
| 431 |
+
const key = esc(field.k);
|
| 432 |
+
const placeholder = esc(field.ph || field.lbl || '');
|
| 433 |
+
const isSecret = !!field.secret;
|
| 434 |
+
const isTextarea = field.type === 'textarea';
|
| 435 |
+
const hasPicker = !!field.options_key || Array.isArray(field.options);
|
| 436 |
+
const inputType = isSecret ? 'password' : (field.type === 'number' ? 'number' : 'text');
|
| 437 |
+
|
| 438 |
+
let control = '';
|
| 439 |
+
if (field.type === 'toggle') {
|
| 440 |
+
const initial = defaultValueFor(field);
|
| 441 |
+
control = `<div class="toggle-shell" data-toggle-row="1" data-field="${key}">
|
| 442 |
+
<input type="hidden" data-key="${key}" value="${initial}">
|
| 443 |
+
<button type="button" class="tog ${initial === 'true' ? 'on' : ''}" data-toggle="${key}">${initial === 'true' ? 'On' : 'Off'}</button>
|
| 444 |
+
</div>`;
|
| 445 |
+
} else if (isTextarea) {
|
| 446 |
+
control = `<textarea data-key="${key}" placeholder="${placeholder}" spellcheck="false"></textarea>`;
|
| 447 |
+
} else {
|
| 448 |
+
control = `<input type="${inputType}" data-key="${key}" placeholder="${placeholder}" spellcheck="false"/>`;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
if (!hasPicker) return control;
|
| 452 |
+
|
| 453 |
+
return `<div class="picker-shell" data-picker-shell="${key}" data-picker-mode="single">
|
| 454 |
+
<div class="picker-row">
|
| 455 |
+
<select class="picker-select" data-pick-for="${key}" aria-label="${esc(field.lbl || field.k)} presets">
|
| 456 |
+
<option value="">Choose presetβ¦</option>
|
| 457 |
+
${renderOptionsHTML(field)}
|
| 458 |
+
<option value="__custom__">Customβ¦</option>
|
| 459 |
+
</select>
|
| 460 |
+
<button type="button" class="mini-btn" data-custom-for="${key}">+ Custom</button>
|
| 461 |
+
<button type="button" class="mini-btn" data-clear-for="${key}">Clear</button>
|
| 462 |
+
</div>
|
| 463 |
+
${control}
|
| 464 |
+
</div>`;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function tagBadgeHTML(f) {
|
| 468 |
+
const t = f.tag || (f.secret ? 'credential' : 'optional');
|
| 469 |
+
return `<span class="badge badge-${t}">${t}</span>`;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function cardHTML(f) {
|
| 473 |
+
const tagStr = (f.tag || '') + ' ' + (f.secret ? 'credential' : '') + ' ' + (f.g + ' ' + f.k + ' ' + (f.lbl || '')).toLowerCase();
|
| 474 |
+
return `<div class="env-card" data-row data-group="${esc(f.g)}" data-tag="${esc(f.tag || '')}" data-search="${esc(tagStr.toLowerCase())}">
|
| 475 |
+
<div class="card-top">
|
| 476 |
+
<input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''} ${f.tag === 'critical' ? 'data-critical="1"' : ''}>
|
| 477 |
+
<div class="card-info">
|
| 478 |
+
<div class="card-key">${esc(f.k)}</div>
|
| 479 |
+
<div class="card-lbl">${esc(f.lbl || '')}</div>
|
| 480 |
+
</div>
|
| 481 |
+
${tagBadgeHTML(f)}
|
| 482 |
+
</div>
|
| 483 |
+
<div class="card-input">${valueControlHTML(f)}</div>
|
| 484 |
+
</div>`;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
function addCustomRow(key = '', val = '', enabled = false) {
|
| 488 |
+
const id = customCount++;
|
| 489 |
+
const row = document.createElement('div');
|
| 490 |
+
row.className = 'custom-row';
|
| 491 |
+
row.dataset.customRow = id;
|
| 492 |
+
row.dataset.enabled = enabled ? '1' : '0';
|
| 493 |
+
row.innerHTML = `
|
| 494 |
+
<input data-ck="${id}" placeholder="CUSTOM_ENV_NAME" value="${esc(key)}">
|
| 495 |
+
<input data-cv="${id}" placeholder="value" value="${esc(val)}">
|
| 496 |
+
<button class="tog${enabled ? ' on' : ''}">${enabled ? 'On' : 'Off'}</button>`;
|
| 497 |
+
$('customRows').appendChild(row);
|
| 498 |
+
row.querySelectorAll('input').forEach(el => el.addEventListener('input', refresh));
|
| 499 |
+
row.querySelector('button').onclick = () => {
|
| 500 |
+
const on = row.dataset.enabled !== '1';
|
| 501 |
+
row.dataset.enabled = on ? '1' : '0';
|
| 502 |
+
row.querySelector('button').textContent = on ? 'On' : 'Off';
|
| 503 |
+
row.querySelector('button').classList.toggle('on', on);
|
| 504 |
+
refresh();
|
| 505 |
+
};
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
function getFieldValueInput(key) { return document.querySelector(`[data-key="${CSS.escape(key)}"]`); }
|
| 509 |
+
|
| 510 |
+
function setFieldValue(key, value) {
|
| 511 |
+
const el = getFieldValueInput(key);
|
| 512 |
+
if (el) el.value = value ?? '';
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function appendCsvValue(existing, next) {
|
| 516 |
+
const parts = String(existing || '').split(',').map(s => s.trim()).filter(Boolean);
|
| 517 |
+
const val = String(next || '').trim();
|
| 518 |
+
if (!val) return parts.join(', ');
|
| 519 |
+
if (!parts.includes(val)) parts.push(val);
|
| 520 |
+
return parts.join(', ');
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
function collect() {
|
| 524 |
+
const obj = {};
|
| 525 |
+
document.querySelectorAll('[data-key]').forEach(el => {
|
| 526 |
+
const key = el.dataset.key;
|
| 527 |
+
if (!key || !safeKey(key)) return;
|
| 528 |
+
const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
|
| 529 |
+
if (!chk || !chk.checked) return;
|
| 530 |
+
const val = String(el.value ?? '').trim();
|
| 531 |
+
if (val) obj[key] = val;
|
| 532 |
+
});
|
| 533 |
+
document.querySelectorAll('[data-custom-row]').forEach(row => {
|
| 534 |
+
const id = row.dataset.customRow;
|
| 535 |
+
const key = (row.querySelector(`[data-ck="${id}"]`)?.value || '').trim();
|
| 536 |
+
const val = (row.querySelector(`[data-cv="${id}"]`)?.value || '').trim();
|
| 537 |
+
if (row.dataset.enabled === '1' && safeKey(key) && val) obj[key] = val;
|
| 538 |
+
});
|
| 539 |
+
return obj;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
function generateBundle() {
|
| 543 |
+
const obj = collect();
|
| 544 |
+
const keys = Object.keys(obj).sort();
|
| 545 |
+
const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
|
| 546 |
+
$('bundleOut').value = bundle;
|
| 547 |
+
$('envLineOut').value = bundle ? `${BUNDLE_KEY}=${bundle}` : '';
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
function refresh() {
|
| 551 |
+
// Refresh summary + counts β does NOT auto-regenerate bundle (requires explicit button click)
|
| 552 |
+
const obj = collect();
|
| 553 |
+
const keys = Object.keys(obj).sort();
|
| 554 |
+
const s = $('summary');
|
| 555 |
+
if (keys.length) {
|
| 556 |
+
s.innerHTML = `<strong>${keys.length}</strong> variable${keys.length > 1 ? 's' : ''} selected<div class="sum-keys">${keys.map(k => `<span class="sum-key">${esc(k)}</span>`).join('')}</div>`;
|
| 557 |
+
} else {
|
| 558 |
+
s.innerHTML = 'No variables selected yet.';
|
| 559 |
+
}
|
| 560 |
+
updateCounts();
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
function markSelected() {
|
| 564 |
+
document.querySelectorAll('[data-row]').forEach(r => r.classList.toggle('selected', !!r.querySelector('[data-check]')?.checked));
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function updateCounts() {
|
| 568 |
+
document.querySelectorAll('[id^="nc_"]').forEach(el => el.textContent = '0');
|
| 569 |
+
const byGrp = {};
|
| 570 |
+
document.querySelectorAll('[data-check]:checked').forEach(ch => {
|
| 571 |
+
const g = ch.closest('[data-row]')?.dataset.group;
|
| 572 |
+
if (g) byGrp[g] = (byGrp[g] || 0) + 1;
|
| 573 |
+
});
|
| 574 |
+
const custOn = document.querySelectorAll('[data-custom-row][data-enabled="1"]').length;
|
| 575 |
+
const total = Object.values(byGrp).reduce((a, b) => a + b, 0) + custOn;
|
| 576 |
+
const allEl = document.getElementById('nc_All'); if (allEl) allEl.textContent = total;
|
| 577 |
+
Object.entries(byGrp).forEach(([g, c]) => {
|
| 578 |
+
const el = document.getElementById('nc_' + g.replace(/\W/g, '_'));
|
| 579 |
+
if (el) el.textContent = c;
|
| 580 |
+
});
|
| 581 |
+
const custEl = document.getElementById('nc_Custom_Env'); if (custEl) custEl.textContent = custOn;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
function filter() {
|
| 585 |
+
const q = $('search').value.trim().toLowerCase();
|
| 586 |
+
document.querySelectorAll('.sec[data-section]').forEach(sec => {
|
| 587 |
+
const grp = sec.dataset.section;
|
| 588 |
+
const gMatch = activeGroup === 'All' || activeGroup === grp;
|
| 589 |
+
if (!gMatch) { sec.classList.add('sec-hidden'); return; }
|
| 590 |
+
let any = false;
|
| 591 |
+
sec.querySelectorAll('[data-row]').forEach(card => {
|
| 592 |
+
const m = !q || card.dataset.search.includes(q);
|
| 593 |
+
card.classList.toggle('hidden', !m);
|
| 594 |
+
if (m) any = true;
|
| 595 |
+
});
|
| 596 |
+
sec.classList.toggle('sec-hidden', !any);
|
| 597 |
+
});
|
| 598 |
+
const cs = $('customSec');
|
| 599 |
+
if (cs) cs.style.display = (activeGroup === 'All' || activeGroup === 'Custom Env') ? '' : 'none';
|
| 600 |
+
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.group === activeGroup));
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
function clearForm() {
|
| 604 |
+
document.querySelectorAll('[data-check]').forEach(c => c.checked = false);
|
| 605 |
+
document.querySelectorAll('[data-key]').forEach(el => {
|
| 606 |
+
if (el.closest('[data-toggle-row]')) {
|
| 607 |
+
el.value = 'false';
|
| 608 |
+
const btn = el.closest('.toggle-shell')?.querySelector('[data-toggle]');
|
| 609 |
+
if (btn) { btn.textContent = 'Off'; btn.classList.remove('on'); }
|
| 610 |
+
return;
|
| 611 |
+
}
|
| 612 |
+
el.value = '';
|
| 613 |
+
});
|
| 614 |
+
$('customRows').innerHTML = '';
|
| 615 |
+
customCount = 0;
|
| 616 |
+
addCustomRow();
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
function applyObj(obj, replace = false) {
|
| 620 |
+
if (replace) clearForm();
|
| 621 |
+
for (const [key, val] of Object.entries(obj || {})) {
|
| 622 |
+
if (!safeKey(key)) continue;
|
| 623 |
+
const inp = getFieldValueInput(key);
|
| 624 |
+
const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
|
| 625 |
+
if (inp && chk) {
|
| 626 |
+
inp.value = val;
|
| 627 |
+
chk.checked = true;
|
| 628 |
+
const btn = inp.closest('[data-toggle-row]')?.querySelector('[data-toggle]');
|
| 629 |
+
if (btn) {
|
| 630 |
+
const on = String(val).trim().toLowerCase() === 'true';
|
| 631 |
+
btn.textContent = on ? 'On' : 'Off';
|
| 632 |
+
btn.classList.toggle('on', on);
|
| 633 |
+
inp.value = on ? 'true' : 'false';
|
| 634 |
+
}
|
| 635 |
+
} else {
|
| 636 |
+
addCustomRow(key, val, true);
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
markSelected(); filter(); refresh();
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
function autoCheck(key) {
|
| 643 |
+
const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
|
| 644 |
+
if (chk && !chk.checked) { chk.checked = true; markSelected(); }
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
function handlePickerChange(sel) {
|
| 648 |
+
const key = sel.dataset.pickFor;
|
| 649 |
+
const value = sel.value;
|
| 650 |
+
if (!key || !value || value === '__custom__') { if (value === '__custom__') sel.value = ''; return; }
|
| 651 |
+
const inp = getFieldValueInput(key);
|
| 652 |
+
if (!inp) return;
|
| 653 |
+
inp.value = value;
|
| 654 |
+
sel.value = '';
|
| 655 |
+
autoCheck(key);
|
| 656 |
+
refresh();
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
function promptCustomModel(btn) {
|
| 660 |
+
const key = btn.dataset.customFor;
|
| 661 |
+
const inp = getFieldValueInput(key);
|
| 662 |
+
if (!inp) return;
|
| 663 |
+
const text = prompt('Enter a custom value', '');
|
| 664 |
+
if (text === null) return;
|
| 665 |
+
const val = String(text).trim();
|
| 666 |
+
if (!val) return;
|
| 667 |
+
inp.value = val;
|
| 668 |
+
autoCheck(key);
|
| 669 |
+
refresh();
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
function resetPickerField(btn) {
|
| 673 |
+
const key = btn.dataset.clearFor;
|
| 674 |
+
const inp = getFieldValueInput(key);
|
| 675 |
+
if (!inp) return;
|
| 676 |
+
if (inp.closest('[data-toggle-row]')) {
|
| 677 |
+
inp.value = 'false';
|
| 678 |
+
const toggleBtn = inp.closest('.toggle-shell')?.querySelector('[data-toggle]');
|
| 679 |
+
if (toggleBtn) { toggleBtn.textContent = 'Off'; toggleBtn.classList.remove('on'); }
|
| 680 |
+
} else {
|
| 681 |
+
inp.value = '';
|
| 682 |
+
}
|
| 683 |
+
refresh();
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
function toggleField(key) {
|
| 687 |
+
const inp = getFieldValueInput(key);
|
| 688 |
+
if (!inp) return;
|
| 689 |
+
const on = String(inp.value || '').trim().toLowerCase() !== 'true';
|
| 690 |
+
inp.value = on ? 'true' : 'false';
|
| 691 |
+
const btn = inp.closest('.toggle-shell')?.querySelector('[data-toggle]');
|
| 692 |
+
if (btn) { btn.textContent = on ? 'On' : 'Off'; btn.classList.toggle('on', on); }
|
| 693 |
+
const chk = document.querySelector(`[data-check="${CSS.escape(key)}"]`);
|
| 694 |
+
if (chk) { chk.checked = on; markSelected(); }
|
| 695 |
+
refresh();
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
function bindFieldEvents() {
|
| 699 |
+
document.querySelectorAll('[data-check]').forEach(el => el.addEventListener('change', () => { markSelected(); refresh(); }));
|
| 700 |
+
document.querySelectorAll('[data-key]').forEach(el => el.addEventListener('input', refresh));
|
| 701 |
+
document.querySelectorAll('[data-toggle]').forEach(btn => btn.addEventListener('click', () => toggleField(btn.dataset.toggle)));
|
| 702 |
+
document.querySelectorAll('[data-pick-for]').forEach(sel => sel.addEventListener('change', () => handlePickerChange(sel)));
|
| 703 |
+
document.querySelectorAll('[data-custom-for]').forEach(btn => btn.addEventListener('click', () => promptCustomModel(btn)));
|
| 704 |
+
document.querySelectorAll('[data-clear-for]').forEach(btn => btn.addEventListener('click', () => resetPickerField(btn)));
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
function renderSections() {
|
| 708 |
+
const grouped = {};
|
| 709 |
+
FIELDS.forEach(f => { (grouped[f.g] ||= []).push(f); });
|
| 710 |
+
const wrap = $('sections');
|
| 711 |
+
wrap.innerHTML = '';
|
| 712 |
+
Object.entries(grouped).forEach(([grp, items]) => {
|
| 713 |
+
const sec = document.createElement('div');
|
| 714 |
+
sec.className = 'sec';
|
| 715 |
+
sec.dataset.section = grp;
|
| 716 |
+
sec.innerHTML = `<div class="sec-header">
|
| 717 |
+
<span class="sec-icon">${ICONS[grp] || 'π'}</span>
|
| 718 |
+
<span class="sec-title">${esc(grp)}</span>
|
| 719 |
+
<div class="sec-line"></div>
|
| 720 |
+
</div>
|
| 721 |
+
<div class="cards">${items.map(cardHTML).join('')}</div>`;
|
| 722 |
+
wrap.appendChild(sec);
|
| 723 |
+
});
|
| 724 |
+
bindFieldEvents();
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
function copyText(text) {
|
| 728 |
+
return navigator.clipboard.writeText(text).then(
|
| 729 |
+
() => showToast('Copied β'),
|
| 730 |
+
() => {
|
| 731 |
+
const ta = document.createElement('textarea');
|
| 732 |
+
ta.value = text;
|
| 733 |
+
ta.style.position = 'fixed';
|
| 734 |
+
ta.style.left = '-9999px';
|
| 735 |
+
document.body.appendChild(ta);
|
| 736 |
+
ta.select();
|
| 737 |
+
document.execCommand('copy');
|
| 738 |
+
ta.remove();
|
| 739 |
+
showToast('Copied β');
|
| 740 |
+
}
|
| 741 |
+
);
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
// ββ Init ββ
|
| 745 |
+
renderSidebar();
|
| 746 |
+
renderSections();
|
| 747 |
+
addCustomRow();
|
| 748 |
+
filter();
|
| 749 |
+
refresh();
|
| 750 |
+
|
| 751 |
+
// ββ Events ββ
|
| 752 |
+
$('search').oninput = filter;
|
| 753 |
+
$('selectRequired').onclick = () => {
|
| 754 |
+
document.querySelectorAll('[data-critical="1"]').forEach(c => c.checked = true);
|
| 755 |
+
markSelected(); refresh();
|
| 756 |
+
showToast('Critical fields selected β');
|
| 757 |
+
};
|
| 758 |
+
$('selectCommon').onclick = () => {
|
| 759 |
+
document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
|
| 760 |
+
markSelected(); refresh();
|
| 761 |
+
};
|
| 762 |
+
$('selectVisible').onclick = () => {
|
| 763 |
+
document.querySelectorAll('.sec:not(.sec-hidden) [data-row]:not(.hidden) [data-check]').forEach(c => c.checked = true);
|
| 764 |
+
markSelected(); refresh();
|
| 765 |
+
};
|
| 766 |
+
$('clearAll').onclick = () => { clearForm(); markSelected(); filter(); refresh(); };
|
| 767 |
+
$('generateBundle').onclick = () => { generateBundle(); showToast('Bundle generated β'); };
|
| 768 |
+
$('applyImport').onclick = () => {
|
| 769 |
+
try { applyObj(parseEnv($('importText').value), true); showToast('Imported β'); }
|
| 770 |
+
catch (e) { showToast('Import failed'); alert(e.message); }
|
| 771 |
+
};
|
| 772 |
+
$('importText').addEventListener('paste', () => {
|
| 773 |
+
setTimeout(() => {
|
| 774 |
+
try {
|
| 775 |
+
const val = $('importText').value.trim();
|
| 776 |
+
if (!val) return;
|
| 777 |
+
applyObj(parseEnv(val), true);
|
| 778 |
+
showToast('Auto-imported β');
|
| 779 |
+
} catch (e) { showToast('Import failed'); }
|
| 780 |
+
}, 0);
|
| 781 |
+
});
|
| 782 |
+
$('importText').addEventListener('input', () => {
|
| 783 |
+
const val = $('importText').value.trim();
|
| 784 |
+
if (!val) return;
|
| 785 |
+
const looksLikeEnv = val.includes('=') || val.startsWith('{') || /^[A-Za-z0-9_\-]{20,}$/.test(val);
|
| 786 |
+
if (looksLikeEnv) {
|
| 787 |
+
try { applyObj(parseEnv(val), true); } catch (e) { /* silent */ }
|
| 788 |
+
}
|
| 789 |
+
});
|
| 790 |
+
$('addCustom').onclick = () => addCustomRow();
|
| 791 |
+
$('applyBundle').onclick = () => {
|
| 792 |
+
try { applyObj(decodeBundle($('bundleOut').value), true); showToast('Bundle applied β'); }
|
| 793 |
+
catch (e) { showToast('Invalid bundle'); }
|
| 794 |
+
};
|
| 795 |
+
$('copyBundle').onclick = () => copyText($('bundleOut').value);
|
| 796 |
+
$('copyEnvLine').onclick = () => copyText($('envLineOut').value);
|
| 797 |
+
$('copyJson').onclick = () => copyText(JSON.stringify(collect(), null, 2));
|
health-server.js
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
const http = require("http");
|
| 4 |
+
const https = require("https");
|
| 5 |
+
const fs = require("fs");
|
| 6 |
+
const net = require("net");
|
| 7 |
+
const crypto = require("crypto");
|
| 8 |
+
|
| 9 |
+
const PORT = Number(process.env.PORT || 7861);
|
| 10 |
+
const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642);
|
| 11 |
+
const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119);
|
| 12 |
+
const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765);
|
| 13 |
+
const JUPYTER_PORT = 8888;
|
| 14 |
+
const GATEWAY_HOST = "127.0.0.1";
|
| 15 |
+
const TERMINAL_BASE = "/terminal";
|
| 16 |
+
const startTime = Date.now();
|
| 17 |
+
const API_SERVER_KEY = process.env.API_SERVER_KEY || "";
|
| 18 |
+
const APP_BASE = "/app";
|
| 19 |
+
const LOGIN_PATH = "/login";
|
| 20 |
+
const SESSION_COOKIE = "huggingmes_session";
|
| 21 |
+
|
| 22 |
+
// ββ Private Space redirect support ββ
|
| 23 |
+
const SPACE_ID = (process.env.SPACE_ID || "").trim();
|
| 24 |
+
function deriveHfSpaceUrl() {
|
| 25 |
+
if (SPACE_ID) return `https://huggingface.co/spaces/${SPACE_ID}`;
|
| 26 |
+
const host = (process.env.SPACE_HOST || "").replace(/\.hf\.space$/i, "");
|
| 27 |
+
const author = (process.env.SPACE_AUTHOR_NAME || "").trim().toLowerCase();
|
| 28 |
+
if (author && host.toLowerCase().startsWith(author + "-")) {
|
| 29 |
+
const spaceName = host.slice(author.length + 1);
|
| 30 |
+
return `https://huggingface.co/spaces/${process.env.SPACE_AUTHOR_NAME}/${spaceName}`;
|
| 31 |
+
}
|
| 32 |
+
return "";
|
| 33 |
+
}
|
| 34 |
+
const HF_SPACE_URL = deriveHfSpaceUrl();
|
| 35 |
+
|
| 36 |
+
// Privacy detection priority:
|
| 37 |
+
// 1. SPACE_PRIVACY env var ("public"/"private") β explicit override, skip API call
|
| 38 |
+
// 2. HF API auto-detect with retry
|
| 39 |
+
// 3. Fail-secure: treat as private if SPACE_ID set
|
| 40 |
+
const _spacPrivacyEnv = (process.env.SPACE_PRIVACY || "").trim().toLowerCase();
|
| 41 |
+
let SPACE_IS_PRIVATE;
|
| 42 |
+
let _privacyDetectionDone = false;
|
| 43 |
+
let _privacyDetectionResolve;
|
| 44 |
+
const privacyDetectionReady = new Promise((res) => { _privacyDetectionResolve = res; });
|
| 45 |
+
|
| 46 |
+
if (_spacPrivacyEnv === "public") {
|
| 47 |
+
SPACE_IS_PRIVATE = false;
|
| 48 |
+
_privacyDetectionDone = true;
|
| 49 |
+
console.log("[health-server] Space privacy: public (SPACE_PRIVACY env override)");
|
| 50 |
+
_privacyDetectionResolve();
|
| 51 |
+
} else if (_spacPrivacyEnv === "private") {
|
| 52 |
+
SPACE_IS_PRIVATE = true;
|
| 53 |
+
_privacyDetectionDone = true;
|
| 54 |
+
console.log("[health-server] Space privacy: private (SPACE_PRIVACY env override)");
|
| 55 |
+
_privacyDetectionResolve();
|
| 56 |
+
} else {
|
| 57 |
+
// Fail-secure default until API call resolves
|
| 58 |
+
SPACE_IS_PRIVATE = !!SPACE_ID;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function detectSpacePrivacy() {
|
| 62 |
+
if (_spacPrivacyEnv === "public" || _spacPrivacyEnv === "private") return;
|
| 63 |
+
if (!SPACE_ID) {
|
| 64 |
+
SPACE_IS_PRIVATE = false;
|
| 65 |
+
_privacyDetectionDone = true;
|
| 66 |
+
_privacyDetectionResolve();
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
const token = (process.env.HF_TOKEN || "").trim();
|
| 70 |
+
const reqOptions = {
|
| 71 |
+
hostname: "huggingface.co",
|
| 72 |
+
path: `/api/spaces/${SPACE_ID}`,
|
| 73 |
+
method: "GET",
|
| 74 |
+
headers: Object.assign(
|
| 75 |
+
{ "User-Agent": "HuggingMes/health-server" },
|
| 76 |
+
token ? { Authorization: `Bearer ${token}` } : {}
|
| 77 |
+
),
|
| 78 |
+
};
|
| 79 |
+
const MAX_ATTEMPTS = 5;
|
| 80 |
+
let detected = false;
|
| 81 |
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
| 82 |
+
try {
|
| 83 |
+
const result = await new Promise((resolve) => {
|
| 84 |
+
const r = https.request(reqOptions, (apiRes) => {
|
| 85 |
+
let body = "";
|
| 86 |
+
apiRes.on("data", (chunk) => { body += chunk; });
|
| 87 |
+
apiRes.on("end", () => {
|
| 88 |
+
try {
|
| 89 |
+
if (apiRes.statusCode === 200) {
|
| 90 |
+
SPACE_IS_PRIVATE = JSON.parse(body).private === true;
|
| 91 |
+
resolve({ ok: true });
|
| 92 |
+
} else if (apiRes.statusCode === 401 || apiRes.statusCode === 403) {
|
| 93 |
+
SPACE_IS_PRIVATE = true;
|
| 94 |
+
resolve({ ok: true });
|
| 95 |
+
} else {
|
| 96 |
+
resolve({ ok: false });
|
| 97 |
+
}
|
| 98 |
+
} catch { resolve({ ok: false }); }
|
| 99 |
+
});
|
| 100 |
+
});
|
| 101 |
+
r.on("error", () => resolve({ ok: false }));
|
| 102 |
+
r.setTimeout(8000, () => { r.destroy(); resolve({ ok: false }); });
|
| 103 |
+
r.end();
|
| 104 |
+
});
|
| 105 |
+
console.log(`[health-server] Privacy detection attempt ${attempt}/${MAX_ATTEMPTS}: ok=${result.ok}`);
|
| 106 |
+
if (result.ok) { detected = true; break; }
|
| 107 |
+
} catch {}
|
| 108 |
+
const delay = Math.min(2000 * attempt, 10000);
|
| 109 |
+
if (attempt < MAX_ATTEMPTS) await new Promise((r) => setTimeout(r, delay));
|
| 110 |
+
}
|
| 111 |
+
if (!detected) {
|
| 112 |
+
console.warn(`[health-server] Privacy detection failed after ${MAX_ATTEMPTS} attempts β defaulting to ${SPACE_IS_PRIVATE ? "private" : "public"}. TIP: Set SPACE_PRIVACY=public in Space secrets to skip API detection.`);
|
| 113 |
+
} else {
|
| 114 |
+
console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
|
| 115 |
+
}
|
| 116 |
+
_privacyDetectionDone = true;
|
| 117 |
+
_privacyDetectionResolve();
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (_spacPrivacyEnv !== "public" && _spacPrivacyEnv !== "private") {
|
| 121 |
+
detectSpacePrivacy();
|
| 122 |
+
setInterval(detectSpacePrivacy, 5 * 60 * 1000);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
|
| 126 |
+
"/tmp/huggingmes-cloudflare-keepalive-status.json";
|
| 127 |
+
|
| 128 |
+
function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) {
|
| 129 |
+
return new Promise((resolve) => {
|
| 130 |
+
const socket = net.createConnection({ port, host });
|
| 131 |
+
const done = (ok) => {
|
| 132 |
+
socket.removeAllListeners();
|
| 133 |
+
socket.destroy();
|
| 134 |
+
resolve(ok);
|
| 135 |
+
};
|
| 136 |
+
socket.setTimeout(timeoutMs);
|
| 137 |
+
socket.once("connect", () => done(true));
|
| 138 |
+
socket.once("timeout", () => done(false));
|
| 139 |
+
socket.once("error", () => done(false));
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function readJson(path, fallback = null) {
|
| 144 |
+
try {
|
| 145 |
+
if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
|
| 146 |
+
} catch {}
|
| 147 |
+
return fallback;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function timingSafeEqualString(left, right) {
|
| 151 |
+
if (!left || !right) return false;
|
| 152 |
+
const leftBuffer = Buffer.from(left);
|
| 153 |
+
const rightBuffer = Buffer.from(right);
|
| 154 |
+
if (leftBuffer.length !== rightBuffer.length) return false;
|
| 155 |
+
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function expectedSessionValue() {
|
| 159 |
+
if (!API_SERVER_KEY) return "";
|
| 160 |
+
return crypto
|
| 161 |
+
.createHmac("sha256", API_SERVER_KEY)
|
| 162 |
+
.update("huggingmes-session-v1")
|
| 163 |
+
.digest("hex");
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function parseCookies(req) {
|
| 167 |
+
const header = req.headers.cookie || "";
|
| 168 |
+
const cookies = {};
|
| 169 |
+
for (const item of header.split(";")) {
|
| 170 |
+
const separator = item.indexOf("=");
|
| 171 |
+
if (separator < 0) continue;
|
| 172 |
+
const name = item.slice(0, separator).trim();
|
| 173 |
+
const value = item.slice(separator + 1).trim();
|
| 174 |
+
if (!name) continue;
|
| 175 |
+
try {
|
| 176 |
+
cookies[name] = decodeURIComponent(value);
|
| 177 |
+
} catch {
|
| 178 |
+
cookies[name] = value;
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
return cookies;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function isHttpsRequest(req) {
|
| 185 |
+
return req.headers["x-forwarded-proto"] === "https";
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function buildSessionCookie(req) {
|
| 189 |
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
| 190 |
+
return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function getBearerToken(req) {
|
| 194 |
+
const value = req.headers.authorization || "";
|
| 195 |
+
const match = /^Bearer\s+(.+)$/i.exec(value);
|
| 196 |
+
return match ? match[1] : "";
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function isAuthorized(req) {
|
| 200 |
+
if (!API_SERVER_KEY) return true;
|
| 201 |
+
return (
|
| 202 |
+
timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) ||
|
| 203 |
+
timingSafeEqualString(
|
| 204 |
+
parseCookies(req)[SESSION_COOKIE],
|
| 205 |
+
expectedSessionValue(),
|
| 206 |
+
)
|
| 207 |
+
);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function sanitizeNext(value) {
|
| 211 |
+
if (!value || typeof value !== "string") return `${APP_BASE}/`;
|
| 212 |
+
if (!value.startsWith("/") || value.startsWith("//")) return `${APP_BASE}/`;
|
| 213 |
+
return value;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function loginUrl(nextPath) {
|
| 217 |
+
return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function renderLoginPage(nextPath, errorMessage = "") {
|
| 221 |
+
const safeNext = sanitizeNext(nextPath);
|
| 222 |
+
return `<!doctype html><html lang="en"><head>
|
| 223 |
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 224 |
+
<title>HuggingMes</title>
|
| 225 |
+
<style>
|
| 226 |
+
:root{color-scheme:dark;--bg:#08080f;--panel:#12111b;--line:#26243a;--text:#f6f4ff;--muted:#7f7a9e;--bad:#fb7185}
|
| 227 |
+
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);padding:24px}
|
| 228 |
+
.card{border:1px solid var(--line);background:var(--panel);border-radius:14px;padding:36px 32px;max-width:400px;width:100%;text-align:center}
|
| 229 |
+
h1{margin:0 0 8px;font-size:1.4rem}
|
| 230 |
+
.sub{color:var(--muted);font-size:.82rem;margin:0 0 24px}
|
| 231 |
+
.row{display:flex;gap:8px;margin-top:16px}
|
| 232 |
+
input{flex:1;background:#0d0c18;border:1px solid var(--line);border-radius:7px;padding:10px 12px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .15s}
|
| 233 |
+
input:focus{border-color:#6366f1}
|
| 234 |
+
button{background:#fff;color:#000;border:none;border-radius:7px;padding:10px 20px;font-weight:700;font-size:.95rem;cursor:pointer;transition:opacity .15s;white-space:nowrap}
|
| 235 |
+
button:hover{opacity:.85}
|
| 236 |
+
.err{color:var(--bad);font-size:.82rem;margin-top:10px}
|
| 237 |
+
code{background:#232234;border:1px solid #34324c;border-radius:5px;padding:2px 6px;font-size:.88em}
|
| 238 |
+
</style></head><body>
|
| 239 |
+
<div class="card">
|
| 240 |
+
<h1>πͺ½ HuggingMes</h1>
|
| 241 |
+
<p class="sub">Enter your <code>GATEWAY_TOKEN</code> to continue</p>
|
| 242 |
+
<form method="post" action="${LOGIN_PATH}">
|
| 243 |
+
<input type="hidden" name="next" value="${escapeHtml(safeNext)}" />
|
| 244 |
+
<div class="row">
|
| 245 |
+
<input type="password" name="token" placeholder="GATEWAY_TOKEN" autofocus autocomplete="current-password" required>
|
| 246 |
+
<button type="submit">Unlock</button>
|
| 247 |
+
</div>
|
| 248 |
+
${errorMessage ? `<p class="err">Invalid token β try again</p>` : ""}
|
| 249 |
+
</form>
|
| 250 |
+
</div>
|
| 251 |
+
</body></html>`;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
function escapeHtml(value) {
|
| 255 |
+
return String(value)
|
| 256 |
+
.replace(/&/g, "&")
|
| 257 |
+
.replace(/</g, "<")
|
| 258 |
+
.replace(/>/g, ">")
|
| 259 |
+
.replace(/"/g, """);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
function readRequestBody(req, limit = 64 * 1024) {
|
| 263 |
+
return new Promise((resolve, reject) => {
|
| 264 |
+
let body = "";
|
| 265 |
+
req.on("data", (chunk) => {
|
| 266 |
+
body += chunk;
|
| 267 |
+
if (body.length > limit) {
|
| 268 |
+
reject(new Error("Request body is too large."));
|
| 269 |
+
req.destroy();
|
| 270 |
+
}
|
| 271 |
+
});
|
| 272 |
+
req.on("end", () => resolve(body));
|
| 273 |
+
req.on("error", reject);
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function requireAuth(req, res) {
|
| 278 |
+
if (isAuthorized(req)) return true;
|
| 279 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 280 |
+
redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`));
|
| 281 |
+
return false;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function wantsHtml(req) {
|
| 285 |
+
const accept = String(req.headers.accept || "");
|
| 286 |
+
return accept.includes("text/html");
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
async function handleLogin(req, res, parsed) {
|
| 290 |
+
const nextPath = sanitizeNext(
|
| 291 |
+
parsed.searchParams.get("next") || `${APP_BASE}/`,
|
| 292 |
+
);
|
| 293 |
+
|
| 294 |
+
if (!API_SERVER_KEY) {
|
| 295 |
+
redirect(res, nextPath);
|
| 296 |
+
return;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
if (req.method === "GET") {
|
| 300 |
+
res.writeHead(200, {
|
| 301 |
+
"content-type": "text/html; charset=utf-8",
|
| 302 |
+
"cache-control": "no-store",
|
| 303 |
+
});
|
| 304 |
+
res.end(renderLoginPage(nextPath));
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
if (req.method !== "POST") {
|
| 309 |
+
res.writeHead(405, { allow: "GET, POST" });
|
| 310 |
+
res.end("Method not allowed");
|
| 311 |
+
return;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
try {
|
| 315 |
+
const body = await readRequestBody(req);
|
| 316 |
+
const params = new URLSearchParams(body);
|
| 317 |
+
const submittedToken = params.get("token") || "";
|
| 318 |
+
const submittedNext = sanitizeNext(params.get("next") || nextPath);
|
| 319 |
+
|
| 320 |
+
if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) {
|
| 321 |
+
res.writeHead(401, {
|
| 322 |
+
"content-type": "text/html; charset=utf-8",
|
| 323 |
+
"cache-control": "no-store",
|
| 324 |
+
});
|
| 325 |
+
res.end(
|
| 326 |
+
renderLoginPage(
|
| 327 |
+
submittedNext,
|
| 328 |
+
"That token did not match GATEWAY_TOKEN.",
|
| 329 |
+
),
|
| 330 |
+
);
|
| 331 |
+
return;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
res.writeHead(302, {
|
| 335 |
+
location: submittedNext,
|
| 336 |
+
"set-cookie": buildSessionCookie(req),
|
| 337 |
+
"cache-control": "no-store",
|
| 338 |
+
});
|
| 339 |
+
res.end();
|
| 340 |
+
} catch (error) {
|
| 341 |
+
res.writeHead(400, {
|
| 342 |
+
"content-type": "text/plain; charset=utf-8",
|
| 343 |
+
"cache-control": "no-store",
|
| 344 |
+
});
|
| 345 |
+
res.end(error.message || "Invalid login request.");
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function proxyRequest(
|
| 350 |
+
req,
|
| 351 |
+
res,
|
| 352 |
+
targetPort,
|
| 353 |
+
rewritePath = (path) => path,
|
| 354 |
+
headerOverrides = {},
|
| 355 |
+
) {
|
| 356 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 357 |
+
const targetPath = rewritePath(parsed.pathname) + parsed.search;
|
| 358 |
+
const headers = {
|
| 359 |
+
...req.headers,
|
| 360 |
+
...headerOverrides,
|
| 361 |
+
host: `${GATEWAY_HOST}:${targetPort}`,
|
| 362 |
+
"x-forwarded-host": req.headers.host || "",
|
| 363 |
+
"x-forwarded-proto": req.headers["x-forwarded-proto"] || "https",
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
const proxy = http.request(
|
| 367 |
+
{
|
| 368 |
+
hostname: GATEWAY_HOST,
|
| 369 |
+
port: targetPort,
|
| 370 |
+
method: req.method,
|
| 371 |
+
path: targetPath,
|
| 372 |
+
headers,
|
| 373 |
+
},
|
| 374 |
+
(upstream) => {
|
| 375 |
+
res.writeHead(upstream.statusCode || 502, upstream.headers);
|
| 376 |
+
upstream.pipe(res);
|
| 377 |
+
},
|
| 378 |
+
);
|
| 379 |
+
|
| 380 |
+
proxy.on("error", (error) => {
|
| 381 |
+
res.writeHead(502, { "content-type": "application/json" });
|
| 382 |
+
res.end(JSON.stringify({ error: "proxy_error", message: error.message }));
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
req.pipe(proxy);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
function redirect(res, location, statusCode = 302) {
|
| 389 |
+
res.writeHead(statusCode, { location });
|
| 390 |
+
res.end();
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
function formatUptime(ms) {
|
| 394 |
+
const total = Math.floor(ms / 1000);
|
| 395 |
+
const days = Math.floor(total / 86400);
|
| 396 |
+
const hours = Math.floor((total % 86400) / 3600);
|
| 397 |
+
const minutes = Math.floor((total % 3600) / 60);
|
| 398 |
+
if (days) return `${days}d ${hours}h ${minutes}m`;
|
| 399 |
+
if (hours) return `${hours}h ${minutes}m`;
|
| 400 |
+
return `${minutes}m`;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
async function statusPayload() {
|
| 404 |
+
const gateway = await canConnect(GATEWAY_PORT);
|
| 405 |
+
const dashboard = await canConnect(DASHBOARD_PORT);
|
| 406 |
+
const telegramWebhook =
|
| 407 |
+
!!process.env.TELEGRAM_WEBHOOK_URL &&
|
| 408 |
+
(await canConnect(TELEGRAM_WEBHOOK_PORT));
|
| 409 |
+
return {
|
| 410 |
+
ok: gateway,
|
| 411 |
+
uptime: formatUptime(Date.now() - startTime),
|
| 412 |
+
startedAt: new Date(startTime).toISOString(),
|
| 413 |
+
gateway,
|
| 414 |
+
dashboard,
|
| 415 |
+
authConfigured: !!API_SERVER_KEY,
|
| 416 |
+
ports: {
|
| 417 |
+
public: PORT,
|
| 418 |
+
gateway: GATEWAY_PORT,
|
| 419 |
+
dashboard: DASHBOARD_PORT,
|
| 420 |
+
telegramWebhook: TELEGRAM_WEBHOOK_PORT,
|
| 421 |
+
},
|
| 422 |
+
telegram: {
|
| 423 |
+
configured: !!process.env.TELEGRAM_BOT_TOKEN,
|
| 424 |
+
webhook: !!process.env.TELEGRAM_WEBHOOK_URL,
|
| 425 |
+
webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "",
|
| 426 |
+
webhookListening: telegramWebhook,
|
| 427 |
+
proxy: process.env.CLOUDFLARE_PROXY_URL || "",
|
| 428 |
+
},
|
| 429 |
+
model:
|
| 430 |
+
process.env.MODEL_FOR_CONFIG ||
|
| 431 |
+
process.env.HERMES_MODEL ||
|
| 432 |
+
process.env.LLM_MODEL ||
|
| 433 |
+
"",
|
| 434 |
+
provider:
|
| 435 |
+
process.env.PROVIDER_FOR_CONFIG ||
|
| 436 |
+
process.env.HERMES_INFERENCE_PROVIDER ||
|
| 437 |
+
"auto",
|
| 438 |
+
keepalive: readJson(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null),
|
| 439 |
+
};
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
function renderPrivateRedirect(targetUrl) {
|
| 443 |
+
const safeUrl = escapeHtml(targetUrl);
|
| 444 |
+
return `<!doctype html><html lang="en"><head>
|
| 445 |
+
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 446 |
+
<meta http-equiv="refresh" content="3;url=${safeUrl}"/>
|
| 447 |
+
<title>HuggingMes β Private Space</title>
|
| 448 |
+
<style>
|
| 449 |
+
:root{color-scheme:dark}
|
| 450 |
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
| 451 |
+
font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;
|
| 452 |
+
background:#08080f;color:#f6f4ff;text-align:center;padding:24px}
|
| 453 |
+
.card{border:1px solid #26243a;background:#12111b;border-radius:14px;padding:36px 32px;max-width:440px}
|
| 454 |
+
h1{margin:0 0 12px;font-size:1.5rem}
|
| 455 |
+
p{color:#b8b3d7;line-height:1.6;margin:0 0 24px}
|
| 456 |
+
.btn{display:inline-flex;align-items:center;justify-content:center;
|
| 457 |
+
background:#fff;color:#000;font-weight:850;font-size:.95rem;
|
| 458 |
+
border-radius:8px;padding:12px 28px;text-decoration:none;transition:opacity .15s}
|
| 459 |
+
.btn:hover{opacity:.85}
|
| 460 |
+
.sub{color:#7f7a9e;font-size:.78rem;margin-top:16px}
|
| 461 |
+
</style></head><body>
|
| 462 |
+
<div class="card">
|
| 463 |
+
<h1>π Private Space</h1>
|
| 464 |
+
<p>This HuggingFace Space is private. You need to be logged in to <strong>huggingface.co</strong> to access it.<br><br>Redirecting you now…</p>
|
| 465 |
+
<a class="btn" href="${safeUrl}">Open on Hugging Face β</a>
|
| 466 |
+
<div class="sub">Redirecting in 3 seconds…</div>
|
| 467 |
+
</div>
|
| 468 |
+
<script>
|
| 469 |
+
// Only auto-redirect when NOT inside an iframe β navigating an iframe to
|
| 470 |
+
// huggingface.co is blocked by X-Frame-Options and causes "refused to connect".
|
| 471 |
+
const _inFrame = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 472 |
+
if (!_inFrame) {
|
| 473 |
+
setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);
|
| 474 |
+
}
|
| 475 |
+
</script>
|
| 476 |
+
</body></html>`;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
function badge(label, state) {
|
| 480 |
+
return `<span class="badge ${state ? "ok" : "off"}">${escapeHtml(label)}</span>`;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
function toneBadge(label, tone = "neutral") {
|
| 484 |
+
return `<span class="badge ${tone}">${escapeHtml(label)}</span>`;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
function valueOrUnset(value, fallback = "Not set") {
|
| 488 |
+
return value
|
| 489 |
+
? escapeHtml(value)
|
| 490 |
+
: `<span class="muted">${escapeHtml(fallback)}</span>`;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
function renderTile({
|
| 494 |
+
title,
|
| 495 |
+
value,
|
| 496 |
+
detail = "",
|
| 497 |
+
tone = "neutral",
|
| 498 |
+
meta = "",
|
| 499 |
+
}) {
|
| 500 |
+
return `<article class="tile ${tone}">
|
| 501 |
+
<div class="tile-head">
|
| 502 |
+
<span class="tile-title">${escapeHtml(title)}</span>
|
| 503 |
+
<span class="tile-dot"></span>
|
| 504 |
+
</div>
|
| 505 |
+
<div class="tile-value">${value}</div>
|
| 506 |
+
${detail ? `<div class="tile-detail">${detail}</div>` : ""}
|
| 507 |
+
${meta ? `<div class="tile-meta">${meta}</div>` : ""}
|
| 508 |
+
</article>`;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
function renderDashboard(data) {
|
| 512 |
+
const telegramTone = data.telegram.configured
|
| 513 |
+
? data.telegram.webhookListening || !data.telegram.webhook
|
| 514 |
+
? "ok"
|
| 515 |
+
: "warn"
|
| 516 |
+
: "warn";
|
| 517 |
+
const keepaliveConfigured = data.keepalive?.configured === true;
|
| 518 |
+
const keepaliveStatus = String(
|
| 519 |
+
data.keepalive?.status ||
|
| 520 |
+
(process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"),
|
| 521 |
+
);
|
| 522 |
+
const keepAliveTone = keepaliveConfigured
|
| 523 |
+
? "ok"
|
| 524 |
+
: process.env.CLOUDFLARE_WORKERS_TOKEN
|
| 525 |
+
? "warn"
|
| 526 |
+
: "neutral";
|
| 527 |
+
const telegramDetail = data.telegram.configured
|
| 528 |
+
? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}`
|
| 529 |
+
: "Not configured";
|
| 530 |
+
const keepAliveDetail = keepaliveConfigured
|
| 531 |
+
? `Pinging <code>${escapeHtml(data.keepalive.targetUrl || "/health")}</code>`
|
| 532 |
+
: keepaliveStatus === "error" && data.keepalive?.message
|
| 533 |
+
? escapeHtml(data.keepalive.message)
|
| 534 |
+
: process.env.CLOUDFLARE_WORKERS_TOKEN
|
| 535 |
+
? "Worker pending or failed"
|
| 536 |
+
: "Not configured";
|
| 537 |
+
const serviceOk = data.gateway && data.dashboard;
|
| 538 |
+
|
| 539 |
+
const tiles = [
|
| 540 |
+
renderTile({
|
| 541 |
+
title: "Gateway",
|
| 542 |
+
value: toneBadge(
|
| 543 |
+
data.gateway ? "Online" : "Offline",
|
| 544 |
+
data.gateway ? "ok" : "off",
|
| 545 |
+
),
|
| 546 |
+
detail: data.gateway
|
| 547 |
+
? `API on port ${data.ports.gateway}`
|
| 548 |
+
: `Unreachable`,
|
| 549 |
+
tone: data.gateway ? "ok" : "off",
|
| 550 |
+
meta: data.authConfigured ? "Protected" : "Unprotected",
|
| 551 |
+
}),
|
| 552 |
+
renderTile({
|
| 553 |
+
title: "Model",
|
| 554 |
+
value: `<code>${valueOrUnset(data.model)}</code>`,
|
| 555 |
+
detail: `Provider: ${valueOrUnset(data.provider || "auto")}`,
|
| 556 |
+
tone: data.model ? "ok" : "warn",
|
| 557 |
+
}),
|
| 558 |
+
renderTile({
|
| 559 |
+
title: "Runtime",
|
| 560 |
+
value: escapeHtml(data.uptime),
|
| 561 |
+
detail: `Port ${data.ports.public}`,
|
| 562 |
+
tone: "neutral",
|
| 563 |
+
}),
|
| 564 |
+
renderTile({
|
| 565 |
+
title: "Telegram",
|
| 566 |
+
value: toneBadge(
|
| 567 |
+
data.telegram.configured ? "Configured" : "Disabled",
|
| 568 |
+
telegramTone,
|
| 569 |
+
),
|
| 570 |
+
detail: telegramDetail,
|
| 571 |
+
tone: telegramTone,
|
| 572 |
+
}),
|
| 573 |
+
renderTile({
|
| 574 |
+
title: "Keep Awake",
|
| 575 |
+
value: toneBadge(
|
| 576 |
+
keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(),
|
| 577 |
+
keepAliveTone,
|
| 578 |
+
),
|
| 579 |
+
detail: keepAliveDetail,
|
| 580 |
+
tone: keepAliveTone,
|
| 581 |
+
}),
|
| 582 |
+
].join("");
|
| 583 |
+
|
| 584 |
+
return `<!doctype html>
|
| 585 |
+
<html lang="en">
|
| 586 |
+
<head>
|
| 587 |
+
<meta charset="utf-8" />
|
| 588 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 589 |
+
<title>HuggingMes</title>
|
| 590 |
+
<style>
|
| 591 |
+
:root { color-scheme: dark; --bg:#08080f; --panel:#12111b; --panel2:#151421; --line:#26243a; --text:#f6f4ff; --muted:#7f7a9e; --soft:#b8b3d7; --good:#22c55e; --warn:#f5c542; --bad:#fb7185; --accent:#6557df; --accent2:#7c6cf2; }
|
| 592 |
+
* { box-sizing:border-box; }
|
| 593 |
+
body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); font-size:13px; }
|
| 594 |
+
main { width:min(720px, calc(100% - 32px)); margin:0 auto; padding:36px 0 44px; }
|
| 595 |
+
header { text-align:center; margin-bottom:22px; }
|
| 596 |
+
h1 { margin:0; font-size:1.65rem; line-height:1; letter-spacing:0; }
|
| 597 |
+
.subtitle { margin-top:12px; color:var(--muted); font-size:.72rem; text-transform:uppercase; letter-spacing:.14em; font-weight:800; }
|
| 598 |
+
.hero-buttons { display:flex; gap:10px; margin:24px 0 20px; }
|
| 599 |
+
.hero-action { display:flex; flex:1; min-height:46px; align-items:center; justify-content:center; border-radius:8px; background:#ffffff; color:#000000; text-decoration:none; font-weight:850; font-size:.98rem; transition:background 0.15s ease; }
|
| 600 |
+
.hero-action:hover { background:#e5e5e5; }
|
| 601 |
+
.hero-action.secondary { background:var(--panel); color:var(--text); border:1px solid var(--line); }
|
| 602 |
+
.hero-action.secondary:hover { background:var(--panel2); }
|
| 603 |
+
.overview { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:10px; }
|
| 604 |
+
.tile { border:1px solid var(--line); background:var(--panel); border-radius:11px; padding:18px; min-height:124px; display:flex; flex-direction:column; gap:10px; position:relative; }
|
| 605 |
+
.tile.ok { border-color:rgba(34,197,94,.22); }
|
| 606 |
+
.tile.warn { border-color:rgba(245,197,66,.24); }
|
| 607 |
+
.tile.off { border-color:rgba(251,113,133,.28); }
|
| 608 |
+
.tile-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
| 609 |
+
.tile-title { color:var(--muted); font-size:.67rem; letter-spacing:.18em; text-transform:uppercase; font-weight:850; }
|
| 610 |
+
.tile-dot { width:7px; height:7px; border-radius:50%; background:var(--line); }
|
| 611 |
+
.tile.ok .tile-dot { background:var(--good); }
|
| 612 |
+
.tile.warn .tile-dot { background:var(--warn); }
|
| 613 |
+
.tile.off .tile-dot { background:var(--bad); }
|
| 614 |
+
.tile-value { font-size:1.12rem; font-weight:850; overflow-wrap:anywhere; }
|
| 615 |
+
.tile-detail { color:var(--soft); line-height:1.45; font-size:.83rem; }
|
| 616 |
+
.tile-meta { color:var(--muted); line-height:1.4; font-size:.75rem; margin-top:auto; overflow-wrap:anywhere; }
|
| 617 |
+
|
| 618 |
+
code { background:#232234; border:1px solid #34324c; border-radius:6px; padding:2px 6px; color:var(--text); font-size:.9em; }
|
| 619 |
+
pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; background:#0d0d0d; border:1px solid var(--line); border-radius:7px; padding:10px; color:var(--soft); font-size:.82rem; line-height:1.45; }
|
| 620 |
+
.row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
|
| 621 |
+
.badge { display:inline-flex; align-items:center; width:max-content; border:1px solid var(--line); border-radius:999px; padding:5px 10px; font-size:.72rem; font-weight:850; line-height:1; text-transform:uppercase; }
|
| 622 |
+
.badge.ok { color:var(--good); border-color:rgba(34,197,94,.34); background:rgba(34,197,94,.11); }
|
| 623 |
+
.badge.warn { color:var(--warn); border-color:rgba(245,197,66,.34); background:rgba(245,197,66,.11); }
|
| 624 |
+
.badge.off { color:var(--bad); border-color:rgba(251,113,133,.34); background:rgba(251,113,133,.11); }
|
| 625 |
+
.badge.neutral { color:var(--soft); }
|
| 626 |
+
.muted { color:var(--muted); }
|
| 627 |
+
.button { display:inline-flex; align-items:center; justify-content:center; min-height:40px; padding:0 16px; border-radius:8px; color:#fff; background:var(--accent); text-decoration:none; font-weight:850; font-size:.9rem; }
|
| 628 |
+
.button.secondary { color:var(--text); background:#242424; border:1px solid var(--line); }
|
| 629 |
+
footer { color:var(--muted); text-align:center; font-size:.74rem; margin-top:18px; }
|
| 630 |
+
footer .live { color:var(--good); }
|
| 631 |
+
.warn-banner { background:rgba(245,197,66,.1); border:1px solid rgba(245,197,66,.35); border-radius:8px; padding:10px 14px; margin-bottom:16px; color:var(--warn); font-size:.82rem; line-height:1.5; }
|
| 632 |
+
.warn-banner strong { font-weight:700; }
|
| 633 |
+
@media (max-width: 700px) { .overview { grid-template-columns:1fr; } main { width:min(100% - 22px, 720px); padding-top:28px; } }
|
| 634 |
+
</style>
|
| 635 |
+
</head>
|
| 636 |
+
<body>
|
| 637 |
+
<main>
|
| 638 |
+
<header>
|
| 639 |
+
<h1>HuggingMes</h1>
|
| 640 |
+
<div class="subtitle">Self-hosted - Hermes Agent</div>
|
| 641 |
+
</header>
|
| 642 |
+
<div class="hero-buttons">
|
| 643 |
+
<a class="hero-action" data-space-link="app" href="${APP_BASE}/">Open Hermes Agent β</a>
|
| 644 |
+
<a class="hero-action secondary" data-space-link="terminal" href="/terminal/">π» Open Terminal β</a>
|
| 645 |
+
<a class="hero-action secondary" data-space-link="env-builder" href="/env-builder">βοΈ ENV Builder β</a>
|
| 646 |
+
</div>
|
| 647 |
+
<section class="overview">
|
| 648 |
+
${tiles}
|
| 649 |
+
</section>
|
| 650 |
+
<footer>Built by <a href="https://github.com/somratpro" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: none;">@somratpro</a></footer>
|
| 651 |
+
</main>
|
| 652 |
+
<script>
|
| 653 |
+
document.querySelectorAll('.local-time').forEach(el => {
|
| 654 |
+
const date = new Date(el.getAttribute('data-iso'));
|
| 655 |
+
if (!isNaN(date)) {
|
| 656 |
+
el.textContent = 'At ' + date.toLocaleTimeString();
|
| 657 |
+
}
|
| 658 |
+
});
|
| 659 |
+
const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
|
| 660 |
+
const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
|
| 661 |
+
const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
|
| 662 |
+
// Server-side value may be stale if privacy detection raced β syncPrivacy() corrects it.
|
| 663 |
+
let SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
|
| 664 |
+
|
| 665 |
+
function applyLinkTargets() {
|
| 666 |
+
const openInNewTab = !SPACE_IS_PRIVATE && (inEmbeddedApp || isDirectHfSpaceHost);
|
| 667 |
+
document.querySelectorAll('a[data-space-link]').forEach((a) => {
|
| 668 |
+
if (openInNewTab) {
|
| 669 |
+
a.setAttribute('target', '_blank');
|
| 670 |
+
a.setAttribute('rel', 'noopener noreferrer');
|
| 671 |
+
} else {
|
| 672 |
+
a.removeAttribute('target');
|
| 673 |
+
a.removeAttribute('rel');
|
| 674 |
+
}
|
| 675 |
+
});
|
| 676 |
+
}
|
| 677 |
+
applyLinkTargets();
|
| 678 |
+
|
| 679 |
+
function syncPrivacy() {
|
| 680 |
+
return fetch('/api/is-private', { cache: 'no-store' })
|
| 681 |
+
.then(r => r.json())
|
| 682 |
+
.then(d => {
|
| 683 |
+
if (d.isPrivate !== SPACE_IS_PRIVATE) {
|
| 684 |
+
SPACE_IS_PRIVATE = d.isPrivate;
|
| 685 |
+
applyLinkTargets();
|
| 686 |
+
}
|
| 687 |
+
return d.isPrivate;
|
| 688 |
+
})
|
| 689 |
+
.catch(() => SPACE_IS_PRIVATE);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
if (isDirectHfSpaceHost) {
|
| 693 |
+
syncPrivacy().then(isPrivate => {
|
| 694 |
+
if (isPrivate) {
|
| 695 |
+
setTimeout(syncPrivacy, 8000);
|
| 696 |
+
setTimeout(syncPrivacy, 16000);
|
| 697 |
+
}
|
| 698 |
+
});
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
// Private redirect β only when NOT in iframe (huggingface.co has X-Frame-Options: DENY)
|
| 702 |
+
if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
|
| 703 |
+
const notice = document.createElement('div');
|
| 704 |
+
notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999';
|
| 705 |
+
notice.innerHTML = '<span style="font-size:1.1rem">π Private Space — Redirecting…</span><a href="' + HF_SPACE_URL + '" style="color:#a5b4fc;font-size:.85rem">Click here if not redirected</a>';
|
| 706 |
+
document.body.appendChild(notice);
|
| 707 |
+
setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
|
| 708 |
+
}
|
| 709 |
+
</script>
|
| 710 |
+
</body>
|
| 711 |
+
</html>`;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
const server = http.createServer(async (req, res) => {
|
| 715 |
+
const parsed = new URL(req.url, "http://localhost");
|
| 716 |
+
const path = parsed.pathname;
|
| 717 |
+
|
| 718 |
+
// Lightweight endpoint for client-side privacy fallback.
|
| 719 |
+
// Called by dashboard JS to correct stale server-rendered SPACE_IS_PRIVATE value.
|
| 720 |
+
// No auth required β not sensitive.
|
| 721 |
+
if (path === "/api/is-private") {
|
| 722 |
+
if (!_privacyDetectionDone) await privacyDetectionReady;
|
| 723 |
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
| 724 |
+
return res.end(JSON.stringify({ isPrivate: SPACE_IS_PRIVATE }));
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
if (path === LOGIN_PATH) {
|
| 728 |
+
await handleLogin(req, res, parsed);
|
| 729 |
+
return;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
// ββ Private Space Guard (server-side) ββ
|
| 733 |
+
// Intercepts browser HTML requests from raw .hf.space hosts when the Space is private.
|
| 734 |
+
// /health and /status are always exempt so uptime monitors keep working.
|
| 735 |
+
const isHtmlReq = (req.headers.accept || "").includes("text/html");
|
| 736 |
+
|
| 737 |
+
// RACE CONDITION FIX: await privacy detection before computing redirect logic.
|
| 738 |
+
// Without this, the fail-secure default (SPACE_IS_PRIVATE=true when SPACE_ID is set)
|
| 739 |
+
// causes public spaces to redirect during the brief window before API detection completes.
|
| 740 |
+
if (isHtmlReq && !_privacyDetectionDone) {
|
| 741 |
+
await Promise.race([
|
| 742 |
+
privacyDetectionReady,
|
| 743 |
+
new Promise((r) => setTimeout(r, 1500)),
|
| 744 |
+
]);
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
// In-app navigation from same origin or HF App iframe β skip private redirect.
|
| 748 |
+
const referer = req.headers.referer || req.headers.referrer || "";
|
| 749 |
+
const isSameOriginNav = !!(referer && typeof req.headers.host === "string" &&
|
| 750 |
+
referer.startsWith(`https://${req.headers.host}`));
|
| 751 |
+
const isFromHFApp = !!(referer && (
|
| 752 |
+
referer.startsWith("https://huggingface.co") ||
|
| 753 |
+
referer.startsWith("https://hf.co")
|
| 754 |
+
));
|
| 755 |
+
|
| 756 |
+
const isDirectHfSpaceReq = SPACE_IS_PRIVATE &&
|
| 757 |
+
HF_SPACE_URL &&
|
| 758 |
+
isHtmlReq &&
|
| 759 |
+
!isSameOriginNav &&
|
| 760 |
+
!isFromHFApp &&
|
| 761 |
+
typeof req.headers.host === "string" &&
|
| 762 |
+
req.headers.host.endsWith(".hf.space");
|
| 763 |
+
|
| 764 |
+
if (path === "/hf-redirect" || path === "/hf-redirect/") {
|
| 765 |
+
if (HF_SPACE_URL) {
|
| 766 |
+
res.writeHead(302, { location: HF_SPACE_URL, "cache-control": "no-store" });
|
| 767 |
+
return res.end();
|
| 768 |
+
}
|
| 769 |
+
res.writeHead(404, { "content-type": "text/plain" });
|
| 770 |
+
return res.end("SPACE_ID not configured.");
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
if (path === "/health" || path === `${APP_BASE}/health`) {
|
| 774 |
+
const data = await statusPayload();
|
| 775 |
+
// Always 200 β health server up means the app is running.
|
| 776 |
+
// Gateway readiness is in the JSON body (gateway: true/false).
|
| 777 |
+
// Returning 503 here caused Docker HEALTHCHECK to fail during gateway
|
| 778 |
+
// startup, keeping HF Space stuck in RUNNING_APP_STARTING indefinitely.
|
| 779 |
+
res.writeHead(200, { "content-type": "application/json" });
|
| 780 |
+
res.end(
|
| 781 |
+
JSON.stringify({
|
| 782 |
+
ok: data.ok,
|
| 783 |
+
gateway: data.gateway,
|
| 784 |
+
uptime: data.uptime,
|
| 785 |
+
}),
|
| 786 |
+
);
|
| 787 |
+
return;
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
if (path === "/status" || path === `${APP_BASE}/status`) {
|
| 791 |
+
const data = await statusPayload();
|
| 792 |
+
res.writeHead(200, { "content-type": "application/json" });
|
| 793 |
+
res.end(JSON.stringify(data, null, 2));
|
| 794 |
+
return;
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
if (path === "/env-builder" || path === "/env-builder/") {
|
| 798 |
+
if (!requireAuth(req, res)) return;
|
| 799 |
+
try {
|
| 800 |
+
const html = fs.readFileSync(require("path").join(__dirname, "env-builder.html"), "utf8");
|
| 801 |
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
| 802 |
+
res.end(html);
|
| 803 |
+
} catch (e) {
|
| 804 |
+
res.writeHead(404, { "content-type": "text/plain" });
|
| 805 |
+
res.end("env-builder.html not found");
|
| 806 |
+
}
|
| 807 |
+
return;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
if (path === "/env-builder.js") {
|
| 811 |
+
if (!requireAuth(req, res)) return;
|
| 812 |
+
try {
|
| 813 |
+
const js = fs.readFileSync(require("path").join(__dirname, "env-builder.js"), "utf8");
|
| 814 |
+
res.writeHead(200, { "content-type": "application/javascript; charset=utf-8" });
|
| 815 |
+
res.end(js);
|
| 816 |
+
} catch (e) {
|
| 817 |
+
res.writeHead(404, { "content-type": "text/plain" });
|
| 818 |
+
res.end("env-builder.js not found");
|
| 819 |
+
}
|
| 820 |
+
return;
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
if (path === "/") {
|
| 824 |
+
if (isDirectHfSpaceReq) {
|
| 825 |
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
| 826 |
+
return res.end(renderPrivateRedirect(HF_SPACE_URL));
|
| 827 |
+
}
|
| 828 |
+
const data = await statusPayload();
|
| 829 |
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
| 830 |
+
res.end(renderDashboard(data));
|
| 831 |
+
return;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
if (path === "/dashboard" || path === "/dashboard/") {
|
| 835 |
+
redirect(res, `${APP_BASE}/${parsed.search}`);
|
| 836 |
+
return;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
if (path === "/telegram" || path.startsWith("/telegram/")) {
|
| 840 |
+
proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT);
|
| 841 |
+
return;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
if (path === APP_BASE || path.startsWith(`${APP_BASE}/`)) {
|
| 845 |
+
if (!requireAuth(req, res)) return;
|
| 846 |
+
proxyRequest(
|
| 847 |
+
req,
|
| 848 |
+
res,
|
| 849 |
+
DASHBOARD_PORT,
|
| 850 |
+
(p) => p.replace(/^\/app/, "") || "/",
|
| 851 |
+
);
|
| 852 |
+
return;
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
if (
|
| 856 |
+
path === "/favicon.ico" ||
|
| 857 |
+
path.startsWith("/assets/") ||
|
| 858 |
+
path.startsWith("/api/") ||
|
| 859 |
+
path.startsWith("/dashboard-plugins/") ||
|
| 860 |
+
path.startsWith("/ds-assets/")
|
| 861 |
+
) {
|
| 862 |
+
if (!requireAuth(req, res)) return;
|
| 863 |
+
proxyRequest(req, res, DASHBOARD_PORT);
|
| 864 |
+
return;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
if (
|
| 868 |
+
[
|
| 869 |
+
"/analytics",
|
| 870 |
+
"/chat",
|
| 871 |
+
"/config",
|
| 872 |
+
"/cron",
|
| 873 |
+
"/docs",
|
| 874 |
+
"/env",
|
| 875 |
+
"/logs",
|
| 876 |
+
"/models",
|
| 877 |
+
"/plugins",
|
| 878 |
+
"/profiles",
|
| 879 |
+
"/sessions",
|
| 880 |
+
"/skills",
|
| 881 |
+
].some((route) => path === route || path.startsWith(`${route}/`))
|
| 882 |
+
) {
|
| 883 |
+
redirect(res, `${APP_BASE}${path}${parsed.search}`);
|
| 884 |
+
return;
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
if (path === "/v1" || path.startsWith("/v1/")) {
|
| 888 |
+
if (!isAuthorized(req)) {
|
| 889 |
+
if (wantsHtml(req)) {
|
| 890 |
+
redirect(res, loginUrl(`${path}${parsed.search}`));
|
| 891 |
+
return;
|
| 892 |
+
}
|
| 893 |
+
res.writeHead(401, {
|
| 894 |
+
"content-type": "application/json",
|
| 895 |
+
"cache-control": "no-store",
|
| 896 |
+
});
|
| 897 |
+
res.end(
|
| 898 |
+
JSON.stringify({
|
| 899 |
+
error: "unauthorized",
|
| 900 |
+
message: "Use Authorization: Bearer <GATEWAY_TOKEN>.",
|
| 901 |
+
}),
|
| 902 |
+
);
|
| 903 |
+
return;
|
| 904 |
+
}
|
| 905 |
+
const upstreamHeaders =
|
| 906 |
+
getBearerToken(req) || !API_SERVER_KEY
|
| 907 |
+
? {}
|
| 908 |
+
: { authorization: `Bearer ${API_SERVER_KEY}` };
|
| 909 |
+
proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders);
|
| 910 |
+
return;
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
if (path === TERMINAL_BASE || path.startsWith(`${TERMINAL_BASE}/`)) {
|
| 914 |
+
if (!requireAuth(req, res)) return;
|
| 915 |
+
canConnect(JUPYTER_PORT).then((up) => {
|
| 916 |
+
if (!up) {
|
| 917 |
+
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
| 918 |
+
res.end("JupyterLab is not running. GATEWAY_TOKEN must be set, and DEV_MODE must not be false.");
|
| 919 |
+
return;
|
| 920 |
+
}
|
| 921 |
+
// Inject the Jupyter token so JupyterLab skips its own login screen.
|
| 922 |
+
// User already authenticated via GATEWAY_TOKEN β no second prompt needed.
|
| 923 |
+
// JUPYTER_TOKEN env may be empty in this process (health-server starts before
|
| 924 |
+
// start_jupyter() exports it), so fall back to API_SERVER_KEY (== GATEWAY_TOKEN),
|
| 925 |
+
// which is what JupyterLab was actually started with.
|
| 926 |
+
const rawJToken = (process.env.JUPYTER_TOKEN || "").trim();
|
| 927 |
+
const jToken = rawJToken || API_SERVER_KEY;
|
| 928 |
+
// JupyterLab 4.x ignores the Authorization header for HTML page loads and
|
| 929 |
+
// shows its own login screen. The reliable fix is to inject ?token= into the
|
| 930 |
+
// URL for the initial HTML request β Jupyter reads it, sets the auth cookie,
|
| 931 |
+
// then redirects to the clean URL. All subsequent requests use the cookie.
|
| 932 |
+
if (jToken && isHtmlReq) {
|
| 933 |
+
const parsed2 = new URL(req.url, "http://localhost");
|
| 934 |
+
if (!parsed2.searchParams.has("token")) {
|
| 935 |
+
const sep = parsed2.search ? "&" : "?";
|
| 936 |
+
redirect(res, `${parsed2.pathname}${parsed2.search}${sep}token=${encodeURIComponent(jToken)}`);
|
| 937 |
+
return;
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
const overrides = jToken ? { authorization: `token ${jToken}` } : {};
|
| 941 |
+
proxyRequest(req, res, JUPYTER_PORT, (p) => p, overrides);
|
| 942 |
+
});
|
| 943 |
+
return;
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
| 947 |
+
res.end("Not found");
|
| 948 |
+
});
|
| 949 |
+
|
| 950 |
+
// ββ WebSocket upgrade (JupyterLab terminals + kernels need this) ββ
|
| 951 |
+
server.on("upgrade", (req, socket, head) => {
|
| 952 |
+
const { pathname } = new URL(req.url, "http://localhost");
|
| 953 |
+
const isJupyter = pathname === TERMINAL_BASE || pathname.startsWith(`${TERMINAL_BASE}/`);
|
| 954 |
+
const targetPort = isJupyter ? JUPYTER_PORT : GATEWAY_PORT;
|
| 955 |
+
const ps = net.createConnection(targetPort, GATEWAY_HOST, () => {
|
| 956 |
+
ps.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`);
|
| 957 |
+
ps.write(`Host: ${GATEWAY_HOST}:${targetPort}\r\n`);
|
| 958 |
+
ps.write(`X-Forwarded-Host: ${req.headers.host || ""}\r\n`);
|
| 959 |
+
ps.write("X-Forwarded-Proto: https\r\n");
|
| 960 |
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
| 961 |
+
const lower = req.rawHeaders[i].toLowerCase();
|
| 962 |
+
if (["host", "x-forwarded-host", "x-forwarded-proto"].includes(lower)) continue;
|
| 963 |
+
ps.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
|
| 964 |
+
}
|
| 965 |
+
ps.write("\r\n");
|
| 966 |
+
if (head && head.length) ps.write(head);
|
| 967 |
+
ps.pipe(socket).pipe(ps);
|
| 968 |
+
});
|
| 969 |
+
ps.on("error", () => socket.destroy());
|
| 970 |
+
ps.on("close", () => socket.destroy());
|
| 971 |
+
socket.on("error", () => ps.destroy());
|
| 972 |
+
socket.on("close", () => ps.destroy());
|
| 973 |
+
});
|
| 974 |
+
|
| 975 |
+
server.timeout = 0;
|
| 976 |
+
server.keepAliveTimeout = 65000;
|
| 977 |
+
server.listen(PORT, "0.0.0.0", () => {
|
| 978 |
+
console.log(`HuggingMes dashboard listening on 0.0.0.0:${PORT}`);
|
| 979 |
+
});
|
start.sh
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
umask 0077
|
| 5 |
+
|
| 6 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
# HuggingMes β Hermes Gateway for HF Spaces
|
| 8 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
+
|
| 10 |
+
# ββ Startup Banner ββ
|
| 11 |
+
APP_DIR="${HUGGINGMES_APP_DIR:-/opt/huggingmes}"
|
| 12 |
+
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
| 13 |
+
PUBLIC_PORT="${PORT:-7861}"
|
| 14 |
+
GATEWAY_API_PORT="${API_SERVER_PORT:-8642}"
|
| 15 |
+
DASHBOARD_PORT="${DASHBOARD_PORT:-9119}"
|
| 16 |
+
TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"
|
| 17 |
+
CF_PROXY_ENV_FILE="/tmp/huggingmes-cloudflare-proxy.env"
|
| 18 |
+
STARTUP_FILE="$HERMES_HOME/workspace/startup.sh"
|
| 19 |
+
|
| 20 |
+
export HERMES_HOME
|
| 21 |
+
export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"
|
| 22 |
+
export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}"
|
| 23 |
+
export API_SERVER_PORT="$GATEWAY_API_PORT"
|
| 24 |
+
export GATEWAY_HEALTH_URL="${GATEWAY_HEALTH_URL:-http://127.0.0.1:${GATEWAY_API_PORT}}"
|
| 25 |
+
export TELEGRAM_WEBHOOK_PORT
|
| 26 |
+
|
| 27 |
+
echo ""
|
| 28 |
+
echo " ββββββββββββββββββββββββββββββββββββββββββββ"
|
| 29 |
+
echo " β πͺ½ HuggingMes Hermes Gateway β"
|
| 30 |
+
echo " ββββββββββββββββββββββββββββββββββββββββββββ"
|
| 31 |
+
echo ""
|
| 32 |
+
|
| 33 |
+
if [ -z "${API_SERVER_KEY:-}" ]; then
|
| 34 |
+
if [ -n "${GATEWAY_TOKEN:-}" ]; then
|
| 35 |
+
export API_SERVER_KEY="$GATEWAY_TOKEN"
|
| 36 |
+
else
|
| 37 |
+
API_SERVER_KEY="$(python3 - <<'PY'
|
| 38 |
+
import secrets
|
| 39 |
+
print(secrets.token_urlsafe(32))
|
| 40 |
+
PY
|
| 41 |
+
)"
|
| 42 |
+
export API_SERVER_KEY
|
| 43 |
+
echo "GATEWAY_TOKEN not set - generated an ephemeral API token for this boot."
|
| 44 |
+
fi
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
# ββ Setup directories ββ
|
| 48 |
+
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home,plugins}
|
| 49 |
+
|
| 50 |
+
# Expose hermes CLI in ~/.local/bin so login shells (terminal backend) find it.
|
| 51 |
+
# Base image PATH includes /opt/data/.local/bin but hermes lives in the venv.
|
| 52 |
+
mkdir -p "$HERMES_HOME/.local/bin"
|
| 53 |
+
ln -sfn /opt/hermes/.venv/bin/hermes "$HERMES_HOME/.local/bin/hermes"
|
| 54 |
+
|
| 55 |
+
# Redirect Hermes plugin dir into volume so plugins survive container restarts
|
| 56 |
+
if [ ! -L "${HOME}/.hermes/plugins" ]; then
|
| 57 |
+
mkdir -p "${HOME}/.hermes"
|
| 58 |
+
rm -rf "${HOME}/.hermes/plugins"
|
| 59 |
+
ln -sfn "$HERMES_HOME/plugins" "${HOME}/.hermes/plugins"
|
| 60 |
+
fi
|
| 61 |
+
|
| 62 |
+
CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"
|
| 63 |
+
export CLOUDFLARE_WORKERS_TOKEN
|
| 64 |
+
if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
|
| 65 |
+
export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"
|
| 66 |
+
echo "Preparing Cloudflare Telegram proxy..."
|
| 67 |
+
python3 "$APP_DIR/cloudflare-proxy-setup.py" || true
|
| 68 |
+
if [ -f "$CF_PROXY_ENV_FILE" ]; then
|
| 69 |
+
. "$CF_PROXY_ENV_FILE"
|
| 70 |
+
fi
|
| 71 |
+
fi
|
| 72 |
+
|
| 73 |
+
if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then
|
| 74 |
+
echo "Preparing Cloudflare Keepalive worker..."
|
| 75 |
+
python3 "$APP_DIR/cloudflare-keepalive-setup.py" || true
|
| 76 |
+
fi
|
| 77 |
+
|
| 78 |
+
if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
| 79 |
+
export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"
|
| 80 |
+
elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then
|
| 81 |
+
export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"
|
| 82 |
+
fi
|
| 83 |
+
|
| 84 |
+
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then
|
| 85 |
+
if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then
|
| 86 |
+
export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"
|
| 87 |
+
fi
|
| 88 |
+
fi
|
| 89 |
+
|
| 90 |
+
if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then
|
| 91 |
+
SECRET_FILE="$HERMES_HOME/.huggingmes-telegram-webhook-secret"
|
| 92 |
+
if [ -f "$SECRET_FILE" ]; then
|
| 93 |
+
export TELEGRAM_WEBHOOK_SECRET
|
| 94 |
+
TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"
|
| 95 |
+
else
|
| 96 |
+
TELEGRAM_WEBHOOK_SECRET="$(python3 - <<'PY'
|
| 97 |
+
import secrets
|
| 98 |
+
print(secrets.token_hex(32))
|
| 99 |
+
PY
|
| 100 |
+
)"
|
| 101 |
+
printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"
|
| 102 |
+
chmod 600 "$SECRET_FILE"
|
| 103 |
+
export TELEGRAM_WEBHOOK_SECRET
|
| 104 |
+
fi
|
| 105 |
+
fi
|
| 106 |
+
|
| 107 |
+
MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"
|
| 108 |
+
MODEL_FOR_CONFIG="$MODEL_INPUT"
|
| 109 |
+
PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"
|
| 110 |
+
LLM_API_KEY="${LLM_API_KEY:-}"
|
| 111 |
+
|
| 112 |
+
if [ -n "$MODEL_INPUT" ]; then
|
| 113 |
+
MODEL_PREFIX="${MODEL_INPUT%%/*}"
|
| 114 |
+
else
|
| 115 |
+
MODEL_PREFIX=""
|
| 116 |
+
fi
|
| 117 |
+
|
| 118 |
+
case "$MODEL_PREFIX" in
|
| 119 |
+
openrouter)
|
| 120 |
+
[ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"
|
| 121 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"
|
| 122 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"
|
| 123 |
+
;;
|
| 124 |
+
huggingface|hf)
|
| 125 |
+
[ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"
|
| 126 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"
|
| 127 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"
|
| 128 |
+
;;
|
| 129 |
+
vercel-ai-gateway|ai-gateway)
|
| 130 |
+
[ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"
|
| 131 |
+
[ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"
|
| 132 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"
|
| 133 |
+
;;
|
| 134 |
+
anthropic)
|
| 135 |
+
[ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"
|
| 136 |
+
;;
|
| 137 |
+
openai|openai-codex)
|
| 138 |
+
[ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
|
| 139 |
+
;;
|
| 140 |
+
google|gemini)
|
| 141 |
+
[ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"
|
| 142 |
+
PROVIDER_FOR_CONFIG="gemini"
|
| 143 |
+
MODEL_FOR_CONFIG="${MODEL_INPUT#*/}" # strip "google/" or "gemini/" prefix β Hermes gemini provider needs bare model name
|
| 144 |
+
;;
|
| 145 |
+
deepseek)
|
| 146 |
+
[ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"
|
| 147 |
+
;;
|
| 148 |
+
kimi-coding|moonshot)
|
| 149 |
+
[ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"
|
| 150 |
+
;;
|
| 151 |
+
kimi-coding-cn|moonshot-cn|kimi-cn)
|
| 152 |
+
[ -n "$LLM_API_KEY" ] && export KIMI_CN_API_KEY="${KIMI_CN_API_KEY:-$LLM_API_KEY}"
|
| 153 |
+
;;
|
| 154 |
+
minimax)
|
| 155 |
+
[ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"
|
| 156 |
+
;;
|
| 157 |
+
minimax-cn)
|
| 158 |
+
[ -n "$LLM_API_KEY" ] && export MINIMAX_CN_API_KEY="${MINIMAX_CN_API_KEY:-$LLM_API_KEY}"
|
| 159 |
+
;;
|
| 160 |
+
xiaomi)
|
| 161 |
+
[ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"
|
| 162 |
+
;;
|
| 163 |
+
zai|z-ai|z.ai|glm)
|
| 164 |
+
[ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"
|
| 165 |
+
;;
|
| 166 |
+
arcee|arcee-ai|arceeai)
|
| 167 |
+
[ -n "$LLM_API_KEY" ] && export ARCEEAI_API_KEY="${ARCEEAI_API_KEY:-$LLM_API_KEY}"
|
| 168 |
+
;;
|
| 169 |
+
gmi|gmi-cloud|gmicloud)
|
| 170 |
+
[ -n "$LLM_API_KEY" ] && export GMI_API_KEY="${GMI_API_KEY:-$LLM_API_KEY}"
|
| 171 |
+
;;
|
| 172 |
+
alibaba)
|
| 173 |
+
[ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"
|
| 174 |
+
;;
|
| 175 |
+
alibaba-coding-plan|alibaba_coding)
|
| 176 |
+
[ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"
|
| 177 |
+
;;
|
| 178 |
+
tencent-tokenhub|tencent|tokenhub|tencentmaas)
|
| 179 |
+
[ -n "$LLM_API_KEY" ] && export TOKENHUB_API_KEY="${TOKENHUB_API_KEY:-$LLM_API_KEY}"
|
| 180 |
+
;;
|
| 181 |
+
nvidia)
|
| 182 |
+
[ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"
|
| 183 |
+
;;
|
| 184 |
+
xai|grok)
|
| 185 |
+
[ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"
|
| 186 |
+
;;
|
| 187 |
+
kilocode)
|
| 188 |
+
[ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"
|
| 189 |
+
;;
|
| 190 |
+
opencode-zen)
|
| 191 |
+
[ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"
|
| 192 |
+
;;
|
| 193 |
+
opencode-go)
|
| 194 |
+
[ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"
|
| 195 |
+
;;
|
| 196 |
+
esac
|
| 197 |
+
|
| 198 |
+
if [ -n "${CUSTOM_BASE_URL:-}" ]; then
|
| 199 |
+
PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"
|
| 200 |
+
[ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"
|
| 201 |
+
fi
|
| 202 |
+
|
| 203 |
+
export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG
|
| 204 |
+
export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"
|
| 205 |
+
export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"
|
| 206 |
+
export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"
|
| 207 |
+
export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"
|
| 208 |
+
export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"
|
| 209 |
+
export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"
|
| 210 |
+
|
| 211 |
+
if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then
|
| 212 |
+
CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"
|
| 213 |
+
export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"
|
| 214 |
+
export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"
|
| 215 |
+
fi
|
| 216 |
+
|
| 217 |
+
# ββ Pool key promotion ββ
|
| 218 |
+
# Mirror first key from comma-separated pool vars into the singular env var.
|
| 219 |
+
# Hermes providers read singular vars; this lets users supply pool keys like
|
| 220 |
+
# ANTHROPIC_API_KEYS=key1,key2 and have them picked up automatically.
|
| 221 |
+
promote_first_pool_key() {
|
| 222 |
+
local singular_var="$1"
|
| 223 |
+
local pool_var="$2"
|
| 224 |
+
local singular_val="${!singular_var:-}"
|
| 225 |
+
local pool_val="${!pool_var:-}"
|
| 226 |
+
[ -n "$singular_val" ] && return 0
|
| 227 |
+
[ -n "$pool_val" ] || return 0
|
| 228 |
+
local first
|
| 229 |
+
first=$(printf '%s' "$pool_val" | tr ',' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF{print; exit}')
|
| 230 |
+
[ -n "$first" ] || return 0
|
| 231 |
+
export "${singular_var}=$first"
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
promote_first_pool_key "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"
|
| 235 |
+
promote_first_pool_key "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"
|
| 236 |
+
promote_first_pool_key "OPENAI_API_KEY" "OPENAI_API_KEYS"
|
| 237 |
+
promote_first_pool_key "GOOGLE_API_KEY" "GOOGLE_API_KEYS"
|
| 238 |
+
promote_first_pool_key "GEMINI_API_KEY" "GEMINI_API_KEYS"
|
| 239 |
+
promote_first_pool_key "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"
|
| 240 |
+
promote_first_pool_key "KIMI_API_KEY" "KIMI_API_KEYS"
|
| 241 |
+
promote_first_pool_key "MINIMAX_API_KEY" "MINIMAX_API_KEYS"
|
| 242 |
+
promote_first_pool_key "NVIDIA_API_KEY" "NVIDIA_API_KEYS"
|
| 243 |
+
promote_first_pool_key "XAI_API_KEY" "XAI_API_KEYS"
|
| 244 |
+
promote_first_pool_key "KILOCODE_API_KEY" "KILOCODE_API_KEYS"
|
| 245 |
+
promote_first_pool_key "GLM_API_KEY" "GLM_API_KEYS"
|
| 246 |
+
promote_first_pool_key "ARCEEAI_API_KEY" "ARCEEAI_API_KEYS"
|
| 247 |
+
promote_first_pool_key "DASHSCOPE_API_KEY" "DASHSCOPE_API_KEYS"
|
| 248 |
+
promote_first_pool_key "GMI_API_KEY" "GMI_API_KEYS"
|
| 249 |
+
promote_first_pool_key "TOKENHUB_API_KEY" "TOKENHUB_API_KEYS"
|
| 250 |
+
|
| 251 |
+
# ββ Build config ββ
|
| 252 |
+
python3 - <<'PY'
|
| 253 |
+
import os
|
| 254 |
+
from pathlib import Path
|
| 255 |
+
|
| 256 |
+
import yaml
|
| 257 |
+
|
| 258 |
+
home = Path(os.environ["HERMES_HOME"])
|
| 259 |
+
path = home / "config.yaml"
|
| 260 |
+
try:
|
| 261 |
+
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
| 262 |
+
except FileNotFoundError:
|
| 263 |
+
config = {}
|
| 264 |
+
|
| 265 |
+
model_name = os.environ.get("MODEL_FOR_CONFIG", "").strip()
|
| 266 |
+
provider_name = os.environ.get("PROVIDER_FOR_CONFIG", "").strip()
|
| 267 |
+
|
| 268 |
+
if model_name:
|
| 269 |
+
model = config.setdefault("model", {})
|
| 270 |
+
model["default"] = model_name # always from env β deploy-time setting
|
| 271 |
+
if provider_name and provider_name != "auto":
|
| 272 |
+
model["provider"] = provider_name # explicit provider (openrouter, huggingface, customβ¦)
|
| 273 |
+
else:
|
| 274 |
+
model.pop("provider", None) # let Hermes infer from model-name prefix
|
| 275 |
+
else:
|
| 276 |
+
model = config.get("model", {})
|
| 277 |
+
print("No LLM_MODEL/HERMES_MODEL set; leaving Hermes model config unchanged.")
|
| 278 |
+
|
| 279 |
+
custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()
|
| 280 |
+
if custom_base and model_name:
|
| 281 |
+
model.setdefault("base_url", custom_base.rstrip("/"))
|
| 282 |
+
if os.environ.get("CUSTOM_API_KEY"):
|
| 283 |
+
model.setdefault("api_key", os.environ["CUSTOM_API_KEY"])
|
| 284 |
+
try:
|
| 285 |
+
model.setdefault("context_length", int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072")))
|
| 286 |
+
model.setdefault("max_tokens", int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192")))
|
| 287 |
+
except ValueError:
|
| 288 |
+
pass
|
| 289 |
+
|
| 290 |
+
config.setdefault("terminal", {}).setdefault("cwd", os.environ.get("MESSAGING_CWD", str(home / "workspace")))
|
| 291 |
+
config.setdefault("compression", {}).setdefault("enabled", True)
|
| 292 |
+
config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))
|
| 293 |
+
config.setdefault("security", {}).setdefault("redact_secrets", True)
|
| 294 |
+
|
| 295 |
+
platforms = config.setdefault("platforms", {})
|
| 296 |
+
|
| 297 |
+
if os.environ.get("TELEGRAM_BOT_TOKEN"):
|
| 298 |
+
telegram = platforms.setdefault("telegram", {})
|
| 299 |
+
telegram.setdefault("enabled", True)
|
| 300 |
+
extra = telegram.setdefault("extra", {})
|
| 301 |
+
if os.environ.get("TELEGRAM_BASE_URL"):
|
| 302 |
+
extra.setdefault("base_url", os.environ["TELEGRAM_BASE_URL"])
|
| 303 |
+
extra.setdefault("base_file_url", os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"])
|
| 304 |
+
if os.environ.get("TELEGRAM_ALLOWED_USERS"):
|
| 305 |
+
config.setdefault("telegram", {}).setdefault("allow_from", [
|
| 306 |
+
item.strip()
|
| 307 |
+
for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")
|
| 308 |
+
if item.strip()
|
| 309 |
+
])
|
| 310 |
+
|
| 311 |
+
path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
|
| 312 |
+
path.chmod(0o600)
|
| 313 |
+
PY
|
| 314 |
+
|
| 315 |
+
# ββ Startup Summary ββ
|
| 316 |
+
HERMES_RUNTIME_VERSION="$(/opt/hermes/.venv/bin/hermes --version 2>/dev/null | awk '{print $NF; exit}' || true)"
|
| 317 |
+
echo ""
|
| 318 |
+
if [ -n "${HERMES_RUNTIME_VERSION:-}" ]; then
|
| 319 |
+
echo "Version : ${HERMES_RUNTIME_VERSION}"
|
| 320 |
+
fi
|
| 321 |
+
echo "Model : ${MODEL_FOR_CONFIG:-unset}"
|
| 322 |
+
echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"
|
| 323 |
+
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
|
| 324 |
+
if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ]; then
|
| 325 |
+
echo "Telegram : webhook"
|
| 326 |
+
else
|
| 327 |
+
echo "Telegram : polling"
|
| 328 |
+
fi
|
| 329 |
+
else
|
| 330 |
+
echo "Telegram : not configured"
|
| 331 |
+
fi
|
| 332 |
+
if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then
|
| 333 |
+
echo "Proxy : ${CLOUDFLARE_PROXY_URL}"
|
| 334 |
+
fi
|
| 335 |
+
echo "Routes : /app/ (Hermes UI), /terminal/ (JupyterLab)"
|
| 336 |
+
echo "Dashboard : http://127.0.0.1:${DASHBOARD_PORT}"
|
| 337 |
+
echo "Gateway : http://127.0.0.1:${GATEWAY_API_PORT}"
|
| 338 |
+
echo ""
|
| 339 |
+
|
| 340 |
+
# ββ JupyterLab terminal (on by default when GATEWAY_TOKEN is set) ββ
|
| 341 |
+
JUPYTER_PID=""
|
| 342 |
+
start_jupyter() {
|
| 343 |
+
if [ "${DEV_MODE:-true}" = "false" ]; then
|
| 344 |
+
echo "JupyterLab disabled (DEV_MODE=false)."
|
| 345 |
+
return 0
|
| 346 |
+
fi
|
| 347 |
+
# Guard: skip if already running
|
| 348 |
+
if [ -n "${JUPYTER_PID:-}" ] && kill -0 "$JUPYTER_PID" 2>/dev/null; then
|
| 349 |
+
return 0
|
| 350 |
+
fi
|
| 351 |
+
local token="${JUPYTER_TOKEN:-${API_SERVER_KEY:-}}"
|
| 352 |
+
if [ -z "$token" ]; then
|
| 353 |
+
echo "WARNING: No GATEWAY_TOKEN or JUPYTER_TOKEN set β JupyterLab skipped (terminal would be unauthenticated)." >&2
|
| 354 |
+
return 0
|
| 355 |
+
fi
|
| 356 |
+
export JUPYTER_TOKEN="$token"
|
| 357 |
+
local VENV_PYTHON="/opt/hermes/.venv/bin/python"
|
| 358 |
+
if ! "$VENV_PYTHON" -c "import jupyterlab" >/dev/null 2>&1; then
|
| 359 |
+
echo "WARNING: jupyterlab not installed in venv; skipping terminal." >&2
|
| 360 |
+
return 0
|
| 361 |
+
fi
|
| 362 |
+
local root_dir="${JUPYTER_ROOT_DIR:-$HERMES_HOME/workspace}"
|
| 363 |
+
mkdir -p "$root_dir"
|
| 364 |
+
ln -sfn "$HERMES_HOME" "$root_dir/HuggingMes" 2>/dev/null || true
|
| 365 |
+
echo "Starting JupyterLab terminal on port 8888 (root: $root_dir)"
|
| 366 |
+
"$VENV_PYTHON" -m jupyterlab \
|
| 367 |
+
--ip 127.0.0.1 \
|
| 368 |
+
--port 8888 \
|
| 369 |
+
--no-browser \
|
| 370 |
+
--IdentityProvider.token="$JUPYTER_TOKEN" \
|
| 371 |
+
--ServerApp.base_url=/terminal/ \
|
| 372 |
+
--ServerApp.terminals_enabled=True \
|
| 373 |
+
--ServerApp.terminado_settings='{"shell_command":["/bin/bash","-i"]}' \
|
| 374 |
+
--ServerApp.allow_origin='*' \
|
| 375 |
+
--ServerApp.allow_remote_access=True \
|
| 376 |
+
--ServerApp.trust_xheaders=True \
|
| 377 |
+
--ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors *'}}" \
|
| 378 |
+
--IdentityProvider.cookie_options="{'SameSite': 'None', 'Secure': True}" \
|
| 379 |
+
--ServerApp.disable_check_xsrf=True \
|
| 380 |
+
--LabApp.news_url=None \
|
| 381 |
+
--LabApp.check_for_updates_class=jupyterlab.NeverCheckForUpdate \
|
| 382 |
+
--ServerApp.log_level=WARN \
|
| 383 |
+
--ServerApp.root_dir="$root_dir" \
|
| 384 |
+
>> "$HERMES_HOME/logs/jupyter.log" 2>&1 &
|
| 385 |
+
JUPYTER_PID=$!
|
| 386 |
+
export JUPYTER_PID
|
| 387 |
+
echo "JupyterLab started (PID: $JUPYTER_PID)"
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
# ββ Trap SIGTERM for graceful shutdown ββ
|
| 391 |
+
DASHBOARD_PID=""
|
| 392 |
+
graceful_shutdown() {
|
| 393 |
+
echo "Shutting down HuggingMes..."
|
| 394 |
+
kill $(jobs -p) 2>/dev/null || true
|
| 395 |
+
exit 0
|
| 396 |
+
}
|
| 397 |
+
trap graceful_shutdown SIGTERM SIGINT
|
| 398 |
+
|
| 399 |
+
# ββ Shell capture wrappers ββ
|
| 400 |
+
# Written to ~/.bashrc so terminal installs are recorded in workspace/startup.sh
|
| 401 |
+
# and replayed on next boot β packages survive Space restarts.
|
| 402 |
+
if [ ! -f "$STARTUP_FILE" ]; then
|
| 403 |
+
touch "$STARTUP_FILE"
|
| 404 |
+
chmod +x "$STARTUP_FILE"
|
| 405 |
+
echo "Created workspace/startup.sh"
|
| 406 |
+
fi
|
| 407 |
+
cat > "$HOME/.bashrc" << 'BASHRC'
|
| 408 |
+
export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH"
|
| 409 |
+
export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"
|
| 410 |
+
if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then
|
| 411 |
+
export PS1="\u@\h:\w\$ "
|
| 412 |
+
fi
|
| 413 |
+
|
| 414 |
+
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
| 415 |
+
STARTUP_FILE="$HERMES_HOME/workspace/startup.sh"
|
| 416 |
+
|
| 417 |
+
_hm_append() {
|
| 418 |
+
[ "${HUGGINGMES_CAPTURE_DISABLE:-0}" = "1" ] && return 0
|
| 419 |
+
local line="$*"
|
| 420 |
+
mkdir -p "$(dirname "$STARTUP_FILE")"
|
| 421 |
+
touch "$STARTUP_FILE"
|
| 422 |
+
chmod +x "$STARTUP_FILE" 2>/dev/null || true
|
| 423 |
+
grep -qxF "$line" "$STARTUP_FILE" 2>/dev/null || echo "$line" >> "$STARTUP_FILE"
|
| 424 |
+
}
|
| 425 |
+
_hm_quote_args() {
|
| 426 |
+
local quoted=()
|
| 427 |
+
local arg
|
| 428 |
+
for arg in "$@"; do
|
| 429 |
+
printf -v arg '%q' "$arg"
|
| 430 |
+
quoted+=("$arg")
|
| 431 |
+
done
|
| 432 |
+
printf '%s' "${quoted[*]}"
|
| 433 |
+
}
|
| 434 |
+
_hm_append_cmd() {
|
| 435 |
+
local cmd="$1"
|
| 436 |
+
shift
|
| 437 |
+
local args
|
| 438 |
+
args=$(_hm_quote_args "$@")
|
| 439 |
+
if [ -n "$args" ]; then
|
| 440 |
+
_hm_append "$cmd $args"
|
| 441 |
+
else
|
| 442 |
+
_hm_append "$cmd"
|
| 443 |
+
fi
|
| 444 |
+
}
|
| 445 |
+
_hm_args_without_flags() {
|
| 446 |
+
local out=()
|
| 447 |
+
for arg in "$@"; do
|
| 448 |
+
case "$arg" in
|
| 449 |
+
''|-|--*|-*) ;;
|
| 450 |
+
*) out+=("$arg") ;;
|
| 451 |
+
esac
|
| 452 |
+
done
|
| 453 |
+
printf '%s\n' "${out[@]}"
|
| 454 |
+
}
|
| 455 |
+
_hm_has_install_targets() {
|
| 456 |
+
local item
|
| 457 |
+
while IFS= read -r item; do
|
| 458 |
+
[ -n "$item" ] && return 0
|
| 459 |
+
done <<EOF
|
| 460 |
+
$(_hm_args_without_flags "$@")
|
| 461 |
+
EOF
|
| 462 |
+
return 1
|
| 463 |
+
}
|
| 464 |
+
_hm_has_arg() {
|
| 465 |
+
local needle="$1"
|
| 466 |
+
shift
|
| 467 |
+
for arg in "$@"; do
|
| 468 |
+
[ "$arg" = "$needle" ] && return 0
|
| 469 |
+
done
|
| 470 |
+
return 1
|
| 471 |
+
}
|
| 472 |
+
_hm_can_sudo_apt() {
|
| 473 |
+
command -v sudo >/dev/null 2>&1 && sudo -n apt-get --version >/dev/null 2>&1
|
| 474 |
+
}
|
| 475 |
+
_hm_apt_install() {
|
| 476 |
+
if [ "$(id -u)" -eq 0 ]; then
|
| 477 |
+
command apt-get update && command apt-get install -y "$@"
|
| 478 |
+
elif _hm_can_sudo_apt; then
|
| 479 |
+
sudo apt-get update && sudo apt-get install -y "$@"
|
| 480 |
+
else
|
| 481 |
+
echo "Error: apt install needs root." >&2
|
| 482 |
+
return 1
|
| 483 |
+
fi
|
| 484 |
+
}
|
| 485 |
+
apt-get() {
|
| 486 |
+
case "${1:-}" in
|
| 487 |
+
install)
|
| 488 |
+
shift
|
| 489 |
+
_hm_apt_install "$@"
|
| 490 |
+
local rc=$?
|
| 491 |
+
if [ $rc -eq 0 ]; then
|
| 492 |
+
_hm_has_install_targets "$@" && _hm_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
|
| 493 |
+
fi
|
| 494 |
+
return $rc
|
| 495 |
+
;;
|
| 496 |
+
update)
|
| 497 |
+
if [ "$(id -u)" -eq 0 ]; then command apt-get "$@"
|
| 498 |
+
elif _hm_can_sudo_apt; then sudo apt-get "$@"
|
| 499 |
+
else command apt-get "$@"; fi
|
| 500 |
+
return $?
|
| 501 |
+
;;
|
| 502 |
+
*) command apt-get "$@"; return $? ;;
|
| 503 |
+
esac
|
| 504 |
+
}
|
| 505 |
+
apt() {
|
| 506 |
+
case "${1:-}" in
|
| 507 |
+
install)
|
| 508 |
+
shift
|
| 509 |
+
_hm_apt_install "$@"
|
| 510 |
+
local rc=$?
|
| 511 |
+
if [ $rc -eq 0 ]; then
|
| 512 |
+
_hm_has_install_targets "$@" && _hm_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"
|
| 513 |
+
fi
|
| 514 |
+
return $rc
|
| 515 |
+
;;
|
| 516 |
+
update)
|
| 517 |
+
if [ "$(id -u)" -eq 0 ]; then command apt "$@"
|
| 518 |
+
elif _hm_can_sudo_apt; then sudo apt "$@"
|
| 519 |
+
else command apt "$@"; fi
|
| 520 |
+
return $?
|
| 521 |
+
;;
|
| 522 |
+
*) command apt "$@"; return $? ;;
|
| 523 |
+
esac
|
| 524 |
+
}
|
| 525 |
+
pip() {
|
| 526 |
+
command pip "$@"
|
| 527 |
+
local rc=$?
|
| 528 |
+
if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
|
| 529 |
+
&& ! _hm_has_arg -r "${@:2}" && ! _hm_has_arg --requirement "${@:2}" \
|
| 530 |
+
&& _hm_has_install_targets "${@:2}"; then
|
| 531 |
+
_hm_append_cmd "pip install" "${@:2}"
|
| 532 |
+
fi
|
| 533 |
+
return $rc
|
| 534 |
+
}
|
| 535 |
+
pip3() {
|
| 536 |
+
command pip3 "$@"
|
| 537 |
+
local rc=$?
|
| 538 |
+
if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \
|
| 539 |
+
&& ! _hm_has_arg -r "${@:2}" && ! _hm_has_arg --requirement "${@:2}" \
|
| 540 |
+
&& _hm_has_install_targets "${@:2}"; then
|
| 541 |
+
_hm_append_cmd "pip install" "${@:2}"
|
| 542 |
+
fi
|
| 543 |
+
return $rc
|
| 544 |
+
}
|
| 545 |
+
uv() {
|
| 546 |
+
command uv "$@"
|
| 547 |
+
local rc=$?
|
| 548 |
+
if [ $rc -eq 0 ] && [ "${1:-}" = "pip" ] && [ "${2:-}" = "install" ] \
|
| 549 |
+
&& ! _hm_has_arg -r "${@:3}" && ! _hm_has_arg --requirements "${@:3}" \
|
| 550 |
+
&& _hm_has_install_targets "${@:3}"; then
|
| 551 |
+
_hm_append_cmd "uv pip install" "${@:3}"
|
| 552 |
+
fi
|
| 553 |
+
return $rc
|
| 554 |
+
}
|
| 555 |
+
npm() {
|
| 556 |
+
command npm "$@"
|
| 557 |
+
local rc=$?
|
| 558 |
+
if [ $rc -eq 0 ] && { [ "${1:-}" = "install" ] || [ "${1:-}" = "i" ]; } && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; } && _hm_has_install_targets "${@:3}"; then
|
| 559 |
+
_hm_append_cmd "npm install -g" "${@:3}"
|
| 560 |
+
fi
|
| 561 |
+
return $rc
|
| 562 |
+
}
|
| 563 |
+
hermes() {
|
| 564 |
+
command hermes "$@"
|
| 565 |
+
local rc=$?
|
| 566 |
+
if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ] && _hm_has_install_targets "${@:3}"; then
|
| 567 |
+
_hm_append_cmd "hermes plugins install" "${@:3}"
|
| 568 |
+
fi
|
| 569 |
+
return $rc
|
| 570 |
+
}
|
| 571 |
+
BASHRC
|
| 572 |
+
cat > "$HOME/.profile" << 'PROFILE'
|
| 573 |
+
[ -n "${BASH_VERSION:-}" ] && [ -f ~/.bashrc ] && . ~/.bashrc
|
| 574 |
+
PROFILE
|
| 575 |
+
echo "Shell capture wrappers ready."
|
| 576 |
+
|
| 577 |
+
# ββ Optional package installs from HF Variables/Secrets ββ
|
| 578 |
+
HM_STARTUP_FAILURES=0
|
| 579 |
+
|
| 580 |
+
if [ -n "${HUGGINGMES_APT_PACKAGES:-}" ]; then
|
| 581 |
+
echo "Installing apt packages from HUGGINGMES_APT_PACKAGES..."
|
| 582 |
+
read -r -a HM_APT_PACKAGES <<< "$HUGGINGMES_APT_PACKAGES"
|
| 583 |
+
if command -v sudo >/dev/null 2>&1; then
|
| 584 |
+
if sudo apt-get update && sudo apt-get install -y "${HM_APT_PACKAGES[@]}"; then
|
| 585 |
+
echo "HUGGINGMES_APT_PACKAGES install complete."
|
| 586 |
+
else
|
| 587 |
+
HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
|
| 588 |
+
echo "ERROR: HUGGINGMES_APT_PACKAGES install failed: ${HUGGINGMES_APT_PACKAGES}" >&2
|
| 589 |
+
fi
|
| 590 |
+
elif [ "$(id -u)" -eq 0 ]; then
|
| 591 |
+
if apt-get update && apt-get install -y "${HM_APT_PACKAGES[@]}"; then
|
| 592 |
+
echo "HUGGINGMES_APT_PACKAGES install complete."
|
| 593 |
+
else
|
| 594 |
+
HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
|
| 595 |
+
echo "ERROR: HUGGINGMES_APT_PACKAGES install failed: ${HUGGINGMES_APT_PACKAGES}" >&2
|
| 596 |
+
fi
|
| 597 |
+
else
|
| 598 |
+
HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
|
| 599 |
+
echo "ERROR: root/sudo unavailable; HUGGINGMES_APT_PACKAGES skipped" >&2
|
| 600 |
+
fi
|
| 601 |
+
fi
|
| 602 |
+
|
| 603 |
+
if [ -n "${HUGGINGMES_PIP_PACKAGES:-}" ]; then
|
| 604 |
+
echo "Installing Python packages from HUGGINGMES_PIP_PACKAGES..."
|
| 605 |
+
read -r -a HM_PIP_PACKAGES <<< "$HUGGINGMES_PIP_PACKAGES"
|
| 606 |
+
if /opt/hermes/.venv/bin/pip install "${HM_PIP_PACKAGES[@]}"; then
|
| 607 |
+
echo "HUGGINGMES_PIP_PACKAGES install complete."
|
| 608 |
+
else
|
| 609 |
+
HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
|
| 610 |
+
echo "ERROR: HUGGINGMES_PIP_PACKAGES install failed: ${HUGGINGMES_PIP_PACKAGES}" >&2
|
| 611 |
+
fi
|
| 612 |
+
fi
|
| 613 |
+
|
| 614 |
+
if [ -n "${HUGGINGMES_NPM_PACKAGES:-}" ]; then
|
| 615 |
+
echo "Installing npm packages from HUGGINGMES_NPM_PACKAGES..."
|
| 616 |
+
read -r -a HM_NPM_PACKAGES <<< "$HUGGINGMES_NPM_PACKAGES"
|
| 617 |
+
if npm install -g "${HM_NPM_PACKAGES[@]}"; then
|
| 618 |
+
echo "HUGGINGMES_NPM_PACKAGES install complete."
|
| 619 |
+
else
|
| 620 |
+
HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
|
| 621 |
+
echo "ERROR: HUGGINGMES_NPM_PACKAGES install failed: ${HUGGINGMES_NPM_PACKAGES}" >&2
|
| 622 |
+
fi
|
| 623 |
+
fi
|
| 624 |
+
|
| 625 |
+
# ββ Arbitrary startup script (HUGGINGMES_RUN) ββ
|
| 626 |
+
# Supports plain bash or base64-encoded scripts (prefix with base64: or b64:).
|
| 627 |
+
# Example: HUGGINGMES_RUN="pip install pandas && npm install -g typescript"
|
| 628 |
+
# Example: HUGGINGMES_RUN="base64:$(base64 -w0 setup.sh)"
|
| 629 |
+
hm_run_startup_auto() {
|
| 630 |
+
local payload="$1"
|
| 631 |
+
[ -n "$payload" ] || return 0
|
| 632 |
+
local script_file
|
| 633 |
+
script_file=$(mktemp "/tmp/huggingmes-startup.XXXXXX.sh")
|
| 634 |
+
{
|
| 635 |
+
echo 'export HUGGINGMES_CAPTURE_DISABLE=1'
|
| 636 |
+
echo '[ -f ~/.bashrc ] && . ~/.bashrc'
|
| 637 |
+
if [[ "$payload" == base64:* ]] || [[ "$payload" == b64:* ]]; then
|
| 638 |
+
printf '%s' "${payload#*:}" | base64 -d
|
| 639 |
+
else
|
| 640 |
+
printf '%s\n' "$payload"
|
| 641 |
+
fi
|
| 642 |
+
} > "$script_file"
|
| 643 |
+
chmod 700 "$script_file"
|
| 644 |
+
echo "[startup:HUGGINGMES_RUN] running script"
|
| 645 |
+
set +e
|
| 646 |
+
bash "$script_file"
|
| 647 |
+
local rc=$?
|
| 648 |
+
set -e
|
| 649 |
+
rm -f "$script_file"
|
| 650 |
+
if [ $rc -eq 0 ]; then
|
| 651 |
+
echo "[startup:HUGGINGMES_RUN] ok"
|
| 652 |
+
else
|
| 653 |
+
HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))
|
| 654 |
+
echo "ERROR: HUGGINGMES_RUN script failed (exit ${rc})" >&2
|
| 655 |
+
fi
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
if [ -n "${HUGGINGMES_RUN:-}" ]; then
|
| 659 |
+
hm_run_startup_auto "$HUGGINGMES_RUN"
|
| 660 |
+
fi
|
| 661 |
+
|
| 662 |
+
# ββ Run workspace startup script ββ
|
| 663 |
+
# Replays install commands recorded by the shell wrappers from previous sessions.
|
| 664 |
+
if [ -s "$STARTUP_FILE" ]; then
|
| 665 |
+
echo "Running workspace/startup.sh..."
|
| 666 |
+
set +e
|
| 667 |
+
HUGGINGMES_CAPTURE_DISABLE=1 bash -l "$STARTUP_FILE"
|
| 668 |
+
set -e
|
| 669 |
+
echo "Workspace startup script complete."
|
| 670 |
+
fi
|
| 671 |
+
|
| 672 |
+
if [ "$HM_STARTUP_FAILURES" -gt 0 ]; then
|
| 673 |
+
echo "Warning: ${HM_STARTUP_FAILURES} startup step(s) failed. Check logs above." >&2
|
| 674 |
+
fi
|
| 675 |
+
|
| 676 |
+
# ββ Start background services ββ
|
| 677 |
+
node "$APP_DIR/health-server.js" &
|
| 678 |
+
HEALTH_PID=$!
|
| 679 |
+
|
| 680 |
+
if [ -n "${WEBHOOK_URL:-}" ]; then
|
| 681 |
+
python3 - <<'PY' >/dev/null 2>&1 &
|
| 682 |
+
import json, os, urllib.request
|
| 683 |
+
body = json.dumps({
|
| 684 |
+
"event": "restart",
|
| 685 |
+
"status": "success",
|
| 686 |
+
"message": "HuggingMes Hermes gateway has started.",
|
| 687 |
+
"model": os.environ.get("MODEL_FOR_CONFIG", ""),
|
| 688 |
+
}).encode()
|
| 689 |
+
req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST", headers={"Content-Type": "application/json"})
|
| 690 |
+
urllib.request.urlopen(req, timeout=10).read()
|
| 691 |
+
PY
|
| 692 |
+
fi
|
| 693 |
+
|
| 694 |
+
# ββ Launch dashboard once (restarts if it dies) ββ
|
| 695 |
+
start_dashboard_once() {
|
| 696 |
+
if [ -n "${DASHBOARD_PID:-}" ] && kill -0 "$DASHBOARD_PID" 2>/dev/null; then
|
| 697 |
+
return 0
|
| 698 |
+
fi
|
| 699 |
+
echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."
|
| 700 |
+
(hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &
|
| 701 |
+
DASHBOARD_PID=$!
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
start_dashboard_once
|
| 705 |
+
start_jupyter
|
| 706 |
+
|
| 707 |
+
# ββ Gateway restart loop ββ
|
| 708 |
+
GATEWAY_RESTART_DELAY="${GATEWAY_RESTART_DELAY:-5}"
|
| 709 |
+
GATEWAY_MAX_RESTARTS="${GATEWAY_MAX_RESTARTS:-0}"
|
| 710 |
+
GATEWAY_RESTART_COUNT=0
|
| 711 |
+
GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"
|
| 712 |
+
|
| 713 |
+
while true; do
|
| 714 |
+
# Monitor health-server β restart if it died unexpectedly
|
| 715 |
+
if [ -n "${HEALTH_PID:-}" ] && ! kill -0 "$HEALTH_PID" 2>/dev/null; then
|
| 716 |
+
echo "Warning: health-server exited (PID $HEALTH_PID dead); restarting..."
|
| 717 |
+
node "$APP_DIR/health-server.js" &
|
| 718 |
+
HEALTH_PID=$!
|
| 719 |
+
echo "Health server restarted (PID: $HEALTH_PID)"
|
| 720 |
+
fi
|
| 721 |
+
|
| 722 |
+
# Monitor Hermes dashboard β restart if it died unexpectedly
|
| 723 |
+
if [ -n "${DASHBOARD_PID:-}" ] && ! kill -0 "$DASHBOARD_PID" 2>/dev/null; then
|
| 724 |
+
echo "Warning: Hermes dashboard exited; restarting..."
|
| 725 |
+
start_dashboard_once
|
| 726 |
+
fi
|
| 727 |
+
|
| 728 |
+
# Monitor JupyterLab β restart if it died unexpectedly
|
| 729 |
+
if [ "${DEV_MODE:-true}" != "false" ] && [ -n "${JUPYTER_PID:-}" ] && ! kill -0 "$JUPYTER_PID" 2>/dev/null; then
|
| 730 |
+
echo "Warning: JupyterLab exited (PID $JUPYTER_PID dead); restarting..."
|
| 731 |
+
unset JUPYTER_PID
|
| 732 |
+
start_jupyter
|
| 733 |
+
fi
|
| 734 |
+
|
| 735 |
+
echo "Launching Hermes gateway..."
|
| 736 |
+
(hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &
|
| 737 |
+
GATEWAY_PID=$!
|
| 738 |
+
|
| 739 |
+
ready=false
|
| 740 |
+
for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do
|
| 741 |
+
if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then
|
| 742 |
+
ready=true
|
| 743 |
+
break
|
| 744 |
+
fi
|
| 745 |
+
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
| 746 |
+
break
|
| 747 |
+
fi
|
| 748 |
+
sleep 1
|
| 749 |
+
done
|
| 750 |
+
|
| 751 |
+
if [ "$ready" != "true" ]; then
|
| 752 |
+
echo ""
|
| 753 |
+
echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"
|
| 754 |
+
echo "----------------------------------------"
|
| 755 |
+
tail -40 "$HERMES_HOME/logs/gateway.log" || true
|
| 756 |
+
exit 1
|
| 757 |
+
fi
|
| 758 |
+
|
| 759 |
+
set +e
|
| 760 |
+
wait "$GATEWAY_PID"
|
| 761 |
+
GATEWAY_EXIT_CODE=$?
|
| 762 |
+
set -e
|
| 763 |
+
|
| 764 |
+
GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))
|
| 765 |
+
if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then
|
| 766 |
+
echo "Gateway exited (code ${GATEWAY_EXIT_CODE}); restart limit (${GATEWAY_MAX_RESTARTS}) reached."
|
| 767 |
+
exit "$GATEWAY_EXIT_CODE"
|
| 768 |
+
fi
|
| 769 |
+
|
| 770 |
+
echo "Gateway exited (code ${GATEWAY_EXIT_CODE}); restarting in ${GATEWAY_RESTART_DELAY}s..."
|
| 771 |
+
sleep "$GATEWAY_RESTART_DELAY"
|
| 772 |
+
done
|