xlyu0523 commited on
Commit
d5c8824
Β·
verified Β·
1 Parent(s): 08c44ba

Upload 6 files

Browse files
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
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
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, "&amp;")
257
+ .replace(/</g, "&lt;")
258
+ .replace(/>/g, "&gt;")
259
+ .replace(/"/g, "&quot;");
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&hellip;</p>
465
+ <a class="btn" href="${safeUrl}">Open on Hugging Face β†’</a>
466
+ <div class="sub">Redirecting in 3 seconds&hellip;</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 &mdash; Redirecting&hellip;</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