From 6f5ec929a187739b0b06d2935cac4dc7537ac22c Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 18 May 2026 16:34:10 +0530 Subject: [PATCH] feat(config): add install-method stamping + Docker detection (#27843) * feat(config): add install-method stamping + Docker detection Dockerfile stamps "docker", install.sh stamps "git", and cmd_postinstall stamps "pip" into ~/.hermes/.install_method. detect_install_method() reads the stamp first, then falls back to managed-system / container / .git heuristics. Adds Docker upgrade guidance. Tracking: #27826 * fix(stamp): move Docker stamp to entrypoint, install.sh stamp after print_success The Dockerfile stamp was overwritten by the VOLUME overlay at container start. Moving it to entrypoint.sh ensures it persists. The install.sh stamp now writes after print_success so it only lands on full success. --- Dockerfile | 1 + docker/entrypoint.sh | 3 ++ hermes_cli/config.py | 41 +++++++++++++++++-- hermes_cli/main.py | 3 ++ scripts/install.sh | 2 + .../hermes_cli/test_pip_install_detection.py | 31 ++++++++++++-- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index bde3412ed7f..6e8f0209636 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,5 +115,6 @@ RUN uv pip install --no-cache-dir --no-deps -e "." ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data ENV PATH="/opt/data/.local/bin:${PATH}" +RUN mkdir -p /opt/data VOLUME [ "/opt/data" ] ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 09e870543a2..9af045e226f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -61,6 +61,9 @@ fi # --- Running as hermes from here --- source "${INSTALL_DIR}/.venv/bin/activate" +# Stamp install method for detect_install_method() +echo "docker" > "${HERMES_HOME:=/opt/data}/.install_method" 2>/dev/null || true + # Create essential directory structure. Cache and platform directories # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on # demand by the application — don't pre-create them here so new installs diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 84898623fb7..e69c51a4d3b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -188,21 +188,42 @@ def is_managed() -> bool: return get_managed_system() is not None +_NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)" + + def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" managed_system = get_managed_system() if managed_system == "Homebrew": return "brew upgrade hermes-agent" if managed_system == "NixOS": - return "sudo nixos-rebuild switch" + return _NIX_UPDATE_MSG return None def detect_install_method(project_root: Optional[Path] = None) -> str: - """Detect how Hermes was installed: 'nixos', 'homebrew', 'git', or 'pip'.""" + """Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'. + + Resolution order: + 1. Stamped ``~/.hermes/.install_method`` file (written by installers) + 2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew) + 3. Container detection (/.dockerenv, /run/.containerenv, cgroup) + 4. .git directory presence -> 'git' + 5. Fallback -> 'pip' + """ + stamp = get_hermes_home() / ".install_method" + try: + method = stamp.read_text(encoding="utf-8").strip().lower() + if method: + return method + except OSError: + pass managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") + from hermes_constants import is_container + if is_container(): + return "docker" if project_root is None: project_root = Path(__file__).parent.parent.resolve() if (project_root / ".git").is_dir(): @@ -210,12 +231,24 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: return "pip" +def stamp_install_method(method: str) -> None: + """Write the install method to ~/.hermes/.install_method.""" + stamp = get_hermes_home() / ".install_method" + try: + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.write_text(method + "\n", encoding="utf-8") + except OSError: + pass + + def recommended_update_command_for_method(method: str) -> str: - """Return the update command for a given install method.""" + """Return the update command or guidance for a given install method.""" if method == "nixos": - return "sudo nixos-rebuild switch" + return _NIX_UPDATE_MSG if method == "homebrew": return "brew upgrade hermes-agent" + if method == "docker": + return "docker pull nousresearch/hermes-agent:latest" if method == "pip": import shutil uv = shutil.which("uv") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 575835b2c7d..fe287543673 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1735,8 +1735,11 @@ def cmd_setup(args): def cmd_postinstall(args): """One-shot bootstrap for pip users: install non-Python deps + run setup.""" + from hermes_cli.config import stamp_install_method from hermes_cli.dep_ensure import ensure_dependency + stamp_install_method("pip") + print("⚕ Hermes post-install bootstrap") print() diff --git a/scripts/install.sh b/scripts/install.sh index 9b1b7469bb8..c34c64267c6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1996,6 +1996,8 @@ main() { maybe_start_gateway print_success + + echo "git" > "$HERMES_HOME/.install_method" } if [ -n "$ENSURE_DEPS" ]; then diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index b0f4cbd75ad..da3dd35e329 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -4,7 +4,8 @@ from unittest.mock import patch def test_pip_install_detected_when_no_git_dir(tmp_path): """When PROJECT_ROOT has no .git, detect as pip install.""" - with patch("hermes_cli.config.get_managed_system", return_value=None): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "pip" @@ -13,7 +14,8 @@ def test_pip_install_detected_when_no_git_dir(tmp_path): def test_git_install_detected_when_git_dir_exists(tmp_path): """When PROJECT_ROOT has .git, detect as git install.""" (tmp_path / ".git").mkdir() - with patch("hermes_cli.config.get_managed_system", return_value=None): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "git" @@ -22,7 +24,8 @@ def test_git_install_detected_when_git_dir_exists(tmp_path): def test_managed_install_takes_precedence(tmp_path): """When HERMES_MANAGED is set, that takes precedence over git detection.""" (tmp_path / ".git").mkdir() - with patch("hermes_cli.config.get_managed_system", return_value="NixOS"): + with patch("hermes_cli.config.get_managed_system", return_value="NixOS"), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "nixos" @@ -35,3 +38,25 @@ def test_recommended_update_command_pip(): assert "pip install" in cmd or "uv pip install" in cmd assert "--upgrade" in cmd assert "hermes-agent" in cmd + + +def test_stamp_file_takes_precedence(tmp_path): + (tmp_path / ".git").mkdir() + (tmp_path / ".install_method").write_text("docker\n") + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "docker" + + +def test_docker_detected_via_dockerenv(tmp_path): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \ + patch("hermes_constants.is_container", return_value=True): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "docker" + + +def test_recommended_update_command_docker(): + from hermes_cli.config import recommended_update_command_for_method + assert "docker pull" in recommended_update_command_for_method("docker")