* fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh When users run 'python3 bootstrap.py' directly (the primary documented entry point in README), HERMES_WEBUI_HOST, HERMES_WEBUI_PORT and other .env settings were silently ignored because the shell-level 'source .env' in start.sh was never executed. Add _load_repo_dotenv() in bootstrap.py that reads REPO_ROOT/.env into os.environ before DEFAULT_HOST / DEFAULT_PORT are evaluated at module level. Uses unconditional assignment matching 'set -a; source .env' shell semantics. Only loads the repo .env (bootstrap config) — not ~/.hermes/.env, which the server still loads independently at startup for provider credentials. Reported in #730 by @leap233 who had HERMES_WEBUI_HOST=0.0.0.0 and HERMES_WEBUI_PORT=18787 in the webui .env; running bootstrap.py directly caused the server to ignore both settings. Tests: 15 new tests in tests/test_bootstrap_dotenv.py covering the full loader (key=value, comments, blank lines, quoted values, no-file, unreadable-file, overwrite semantics, values with = signs) and structural assertions that _load_repo_dotenv() is called before DEFAULT_HOST/PORT. 1613 tests total. * fix: address review feedback on PR #791 - bootstrap.py: document overwrite semantics and 'export' note in docstring - bootstrap.py: handle 'export FOO=bar' prefix (strip before splitting on =) - bootstrap.py: print warning to stderr on .env parse failure (not silent swallow) - bootstrap.py: add side-effect comment at _load_repo_dotenv() call site - CHANGELOG.md: restore v0.50.124 and v0.50.123 headers (were merged into v0.50.125 section, making three consecutive ### Fixed blocks with no ## header between them) - tests: fix test_noop_when_dotenv_unreadable to assert warning is emitted - tests: tighten test_does_not_set_empty_values with concrete assertion - tests: add test_export_prefix_stripped - tests: remove dead _import_bootstrap_with_env() helper (never called) 1614 tests total --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
277 lines
8.8 KiB
Python
277 lines
8.8 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
|
|
|
|
|
|
def _load_repo_dotenv() -> None:
|
|
"""Load REPO_ROOT/.env into os.environ.
|
|
|
|
Mirrors what start.sh does via ``set -a; source .env`` so that running
|
|
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
|
|
Variables are set unconditionally (matching shell source semantics), so a
|
|
value in .env overrides one already present in the shell environment.
|
|
To keep a CLI-supplied value, unset it from .env or launch via start.sh
|
|
and override there.
|
|
|
|
Only loads the webui repo .env — not ~/.hermes/.env, which the server
|
|
loads independently at startup for provider credentials.
|
|
|
|
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
|
|
from .env values if copy-pasting from a shell rc file.
|
|
"""
|
|
env_path = REPO_ROOT / ".env"
|
|
if not env_path.exists():
|
|
return
|
|
try:
|
|
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
k = k.strip()
|
|
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
|
|
if k.startswith("export "):
|
|
k = k[7:].strip()
|
|
v = v.strip().strip('"').strip("'")
|
|
if k:
|
|
os.environ[k] = v
|
|
except Exception as exc:
|
|
import sys as _sys
|
|
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
|
|
|
|
|
|
# Side effect: loads REPO_ROOT/.env into os.environ on import.
|
|
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
|
|
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
|
|
_load_repo_dotenv()
|
|
|
|
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)
|