233 lines
7.1 KiB
Python
233 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""One-shot bootstrap launcher for Hermes Web UI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import venv
|
|
import webbrowser
|
|
from pathlib import Path
|
|
|
|
|
|
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
|
|
REPO_ROOT = Path(__file__).resolve().parent
|
|
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
|
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
|
|
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
|
|
# the environment is already fully configured (e.g. managed hosting).
|
|
|
|
|
|
def info(msg: str) -> None:
|
|
print(f"[bootstrap] {msg}", flush=True)
|
|
|
|
|
|
def is_wsl() -> bool:
|
|
if platform.system() != "Linux":
|
|
return False
|
|
release = platform.release().lower()
|
|
return (
|
|
"microsoft" in release or "wsl" in release or bool(os.getenv("WSL_DISTRO_NAME"))
|
|
)
|
|
|
|
|
|
def ensure_supported_platform() -> None:
|
|
if platform.system() == "Windows" and not is_wsl():
|
|
raise RuntimeError(
|
|
"Native Windows is not supported for this bootstrap yet. "
|
|
"Please run it from Linux, macOS, or inside WSL2."
|
|
)
|
|
|
|
|
|
def discover_agent_dir() -> Path | None:
|
|
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
|
|
candidates = [
|
|
os.getenv("HERMES_WEBUI_AGENT_DIR", ""),
|
|
str(home / "hermes-agent"),
|
|
str(REPO_ROOT.parent / "hermes-agent"),
|
|
str(Path.home() / ".hermes" / "hermes-agent"),
|
|
str(Path.home() / "hermes-agent"),
|
|
]
|
|
for raw in candidates:
|
|
if not raw:
|
|
continue
|
|
candidate = Path(raw).expanduser().resolve()
|
|
if candidate.exists() and (candidate / "run_agent.py").exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def discover_launcher_python(agent_dir: Path | None) -> str:
|
|
env_python = os.getenv("HERMES_WEBUI_PYTHON")
|
|
if env_python:
|
|
return env_python
|
|
if agent_dir:
|
|
for rel in ("venv/bin/python", "venv/Scripts/python.exe"):
|
|
candidate = agent_dir / rel
|
|
if candidate.exists():
|
|
return str(candidate)
|
|
for rel in (".venv/bin/python", ".venv/Scripts/python.exe"):
|
|
candidate = REPO_ROOT / rel
|
|
if candidate.exists():
|
|
return str(candidate)
|
|
return shutil.which("python3") or shutil.which("python") or sys.executable
|
|
|
|
|
|
def ensure_python_has_webui_deps(python_exe: str) -> str:
|
|
check = subprocess.run(
|
|
[python_exe, "-c", "import yaml"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if check.returncode == 0:
|
|
return python_exe
|
|
|
|
venv_dir = REPO_ROOT / ".venv"
|
|
venv_python = venv_dir / (
|
|
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
|
|
)
|
|
if not venv_python.exists():
|
|
info(f"Creating local virtualenv at {venv_dir}")
|
|
venv.EnvBuilder(with_pip=True).create(venv_dir)
|
|
|
|
info("Installing WebUI dependencies into local virtualenv")
|
|
subprocess.run(
|
|
[str(venv_python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
|
|
check=True,
|
|
)
|
|
subprocess.run(
|
|
[
|
|
str(venv_python),
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--quiet",
|
|
"-r",
|
|
str(REPO_ROOT / "requirements.txt"),
|
|
],
|
|
check=True,
|
|
)
|
|
return str(venv_python)
|
|
|
|
|
|
def hermes_command_exists() -> bool:
|
|
return shutil.which("hermes") is not None
|
|
|
|
|
|
def install_hermes_agent() -> None:
|
|
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
|
|
subprocess.run(
|
|
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
|
|
)
|
|
|
|
|
|
def wait_for_health(url: str, timeout: float = 25.0) -> bool:
|
|
deadline = time.time() + timeout
|
|
# Validate URL scheme to prevent file:// and other dangerous schemes
|
|
if not url.startswith(("http://", "https://")):
|
|
raise ValueError(f"Invalid health check URL: {url}")
|
|
while time.time() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=2) as response: # nosec B310
|
|
if b'"status": "ok"' in response.read():
|
|
return True
|
|
except Exception:
|
|
time.sleep(0.4)
|
|
return False
|
|
|
|
|
|
def open_browser(url: str) -> None:
|
|
try:
|
|
webbrowser.open(url)
|
|
except Exception as exc:
|
|
info(f"Could not open browser automatically: {exc}")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Bootstrap Hermes Web UI onboarding.")
|
|
parser.add_argument("port", nargs="?", type=int, default=DEFAULT_PORT)
|
|
parser.add_argument("--host", default=DEFAULT_HOST)
|
|
parser.add_argument(
|
|
"--no-browser",
|
|
action="store_true",
|
|
help="Do not open a browser tab automatically.",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-agent-install",
|
|
action="store_true",
|
|
help="Fail instead of attempting the official Hermes installer.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
ensure_supported_platform()
|
|
|
|
agent_dir = discover_agent_dir()
|
|
if not agent_dir and not hermes_command_exists():
|
|
if args.skip_agent_install:
|
|
raise RuntimeError(
|
|
"Hermes Agent was not found and auto-install was disabled."
|
|
)
|
|
install_hermes_agent()
|
|
agent_dir = discover_agent_dir()
|
|
|
|
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir))
|
|
state_dir = Path(
|
|
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui"))
|
|
).expanduser()
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
log_path = state_dir / f"bootstrap-{args.port}.log"
|
|
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_HOST"] = args.host
|
|
env["HERMES_WEBUI_PORT"] = str(args.port)
|
|
env.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
|
|
if agent_dir:
|
|
env["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
|
|
|
|
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
|
|
with log_path.open("ab") as log_file:
|
|
proc = subprocess.Popen(
|
|
[python_exe, str(REPO_ROOT / "server.py")],
|
|
cwd=str(agent_dir or REPO_ROOT),
|
|
env=env,
|
|
stdout=log_file,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
)
|
|
|
|
health_url = f"http://{args.host}:{args.port}/health"
|
|
if not wait_for_health(health_url):
|
|
raise RuntimeError(
|
|
f"Web UI did not become healthy at {health_url}. "
|
|
f"Check the log at {log_path}. Server PID: {proc.pid}"
|
|
)
|
|
|
|
app_url = (
|
|
f"http://localhost:{args.port}"
|
|
if args.host in ("127.0.0.1", "localhost")
|
|
else f"http://{args.host}:{args.port}"
|
|
)
|
|
info(f"Web UI is ready: {app_url}")
|
|
info(f"Log file: {log_path}")
|
|
if not args.no_browser:
|
|
open_browser(app_url)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as exc:
|
|
print(f"[bootstrap] ERROR: {exc}", file=sys.stderr)
|
|
raise SystemExit(1)
|