diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index df9694843..10bf2c7ce 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -119,14 +119,35 @@ def is_windows() -> bool: # Service Configuration # ============================================================================= -SERVICE_NAME = "hermes-gateway" +_SERVICE_BASE = "hermes-gateway" SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" +def get_service_name() -> str: + """Derive a systemd service name scoped to this HERMES_HOME. + + Default ``~/.hermes`` returns ``hermes-gateway`` (backward compatible). + Any other HERMES_HOME appends a short hash so multiple installations + can each have their own systemd service without conflicting. + """ + import hashlib + from pathlib import Path as _Path # local import to avoid monkeypatch interference + home = _Path(os.getenv("HERMES_HOME", _Path.home() / ".hermes")).resolve() + default = (_Path.home() / ".hermes").resolve() + if home == default: + return _SERVICE_BASE + suffix = hashlib.sha256(str(home).encode()).hexdigest()[:8] + return f"{_SERVICE_BASE}-{suffix}" + + +SERVICE_NAME = _SERVICE_BASE # backward-compat for external importers; prefer get_service_name() + + def get_systemd_unit_path(system: bool = False) -> Path: + name = get_service_name() if system: - return Path("/etc/systemd/system") / f"{SERVICE_NAME}.service" - return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service" + return Path("/etc/systemd/system") / f"{name}.service" + return Path.home() / ".config" / "systemd" / "user" / f"{name}.service" def _systemctl_cmd(system: bool = False) -> list[str]: @@ -362,6 +383,8 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) sane_path = f"{venv_bin}:{node_bin}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" hermes_cli = shutil.which("hermes") or f"{python_path} -m hermes_cli.main" + hermes_home = str(Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")).resolve()) + if system: username, group_name, home_dir = _system_service_identity(run_as_user) return f"""[Unit] @@ -380,6 +403,7 @@ Environment="USER={username}" Environment="LOGNAME={username}" Environment="PATH={sane_path}" Environment="VIRTUAL_ENV={venv_dir}" +Environment="HERMES_HOME={hermes_home}" Restart=on-failure RestartSec=10 KillMode=mixed @@ -403,6 +427,7 @@ ExecStop={hermes_cli} gateway stop WorkingDirectory={working_dir} Environment="PATH={sane_path}" Environment="VIRTUAL_ENV={venv_dir}" +Environment="HERMES_HOME={hermes_home}" Restart=on-failure RestartSec=10 KillMode=mixed @@ -455,7 +480,7 @@ def _print_linger_enable_warning(username: str, detail: str | None = None) -> No print(f" sudo loginctl enable-linger {username}") print() print(" Then restart the gateway:") - print(f" systemctl --user restart {SERVICE_NAME}.service") + print(f" systemctl --user restart {get_service_name()}.service") print() @@ -526,7 +551,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8") subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True) - subprocess.run(_systemctl_cmd(system) + ["enable", SERVICE_NAME], check=True) + subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True) print() print(f"✓ {_service_scope_label(system).capitalize()} service installed and enabled!") @@ -534,7 +559,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str print("Next steps:") print(f" {'sudo ' if system else ''}hermes gateway start{scope_flag} # Start the service") print(f" {'sudo ' if system else ''}hermes gateway status{scope_flag} # Check status") - print(f" {'journalctl' if system else 'journalctl --user'} -u {SERVICE_NAME} -f # View logs") + print(f" {'journalctl' if system else 'journalctl --user'} -u {get_service_name()} -f # View logs") print() if system: @@ -552,8 +577,8 @@ def systemd_uninstall(system: bool = False): if system: _require_root_for_system_service("uninstall") - subprocess.run(_systemctl_cmd(system) + ["stop", SERVICE_NAME], check=False) - subprocess.run(_systemctl_cmd(system) + ["disable", SERVICE_NAME], check=False) + subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False) + subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False) unit_path = get_systemd_unit_path(system=system) if unit_path.exists(): @@ -569,7 +594,7 @@ def systemd_start(system: bool = False): if system: _require_root_for_system_service("start") refresh_systemd_unit_if_needed(system=system) - subprocess.run(_systemctl_cmd(system) + ["start", SERVICE_NAME], check=True) + subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -578,7 +603,7 @@ def systemd_stop(system: bool = False): system = _select_systemd_scope(system) if system: _require_root_for_system_service("stop") - subprocess.run(_systemctl_cmd(system) + ["stop", SERVICE_NAME], check=True) + subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True) print(f"✓ {_service_scope_label(system).capitalize()} service stopped") @@ -588,7 +613,7 @@ def systemd_restart(system: bool = False): if system: _require_root_for_system_service("restart") refresh_systemd_unit_if_needed(system=system) - subprocess.run(_systemctl_cmd(system) + ["restart", SERVICE_NAME], check=True) + subprocess.run(_systemctl_cmd(system) + ["restart", get_service_name()], check=True) print(f"✓ {_service_scope_label(system).capitalize()} service restarted") @@ -613,12 +638,12 @@ def systemd_status(deep: bool = False, system: bool = False): print() subprocess.run( - _systemctl_cmd(system) + ["status", SERVICE_NAME, "--no-pager"], + _systemctl_cmd(system) + ["status", get_service_name(), "--no-pager"], capture_output=False, ) result = subprocess.run( - _systemctl_cmd(system) + ["is-active", SERVICE_NAME], + _systemctl_cmd(system) + ["is-active", get_service_name()], capture_output=True, text=True, ) @@ -657,7 +682,7 @@ def systemd_status(deep: bool = False, system: bool = False): if deep: print() print("Recent logs:") - subprocess.run(_journalctl_cmd(system) + ["-u", SERVICE_NAME, "-n", "20", "--no-pager"]) + subprocess.run(_journalctl_cmd(system) + ["-u", get_service_name(), "-n", "20", "--no-pager"]) # ============================================================================= @@ -1118,7 +1143,7 @@ def _is_service_running() -> bool: if user_unit_exists: result = subprocess.run( - _systemctl_cmd(False) + ["is-active", SERVICE_NAME], + _systemctl_cmd(False) + ["is-active", get_service_name()], capture_output=True, text=True ) if result.stdout.strip() == "active": @@ -1126,7 +1151,7 @@ def _is_service_running() -> bool: if system_unit_exists: result = subprocess.run( - _systemctl_cmd(True) + ["is-active", SERVICE_NAME], + _systemctl_cmd(True) + ["is-active", get_service_name()], capture_output=True, text=True ) if result.stdout.strip() == "active": diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0a16c32d6..2cb220df6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2306,14 +2306,16 @@ def cmd_update(args): # installation's gateway — safe with multiple installations. try: from gateway.status import get_running_pid, remove_pid_file + from hermes_cli.gateway import get_service_name import signal as _signal + _gw_service_name = get_service_name() existing_pid = get_running_pid() has_systemd_service = False try: check = subprocess.run( - ["systemctl", "--user", "is-active", "hermes-gateway"], + ["systemctl", "--user", "is-active", _gw_service_name], capture_output=True, text=True, timeout=5, ) has_systemd_service = check.stdout.strip() == "active" @@ -2340,7 +2342,7 @@ def cmd_update(args): _time.sleep(1) # Brief pause for port/socket release print("→ Restarting gateway service...") restart = subprocess.run( - ["systemctl", "--user", "restart", "hermes-gateway"], + ["systemctl", "--user", "restart", _gw_service_name], capture_output=True, text=True, timeout=15, ) if restart.returncode == 0: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index db7ce0641..be490e930 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -275,8 +275,13 @@ def show_status(args): print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) if sys.platform.startswith('linux'): + try: + from hermes_cli.gateway import get_service_name + _gw_svc = get_service_name() + except Exception: + _gw_svc = "hermes-gateway" result = subprocess.run( - ["systemctl", "--user", "is-active", "hermes-gateway"], + ["systemctl", "--user", "is-active", _gw_svc], capture_output=True, text=True ) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index d70405ce3..40ff75f16 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -133,7 +133,13 @@ def uninstall_gateway_service(): if platform.system() != "Linux": return False - service_file = Path.home() / ".config" / "systemd" / "user" / "hermes-gateway.service" + try: + from hermes_cli.gateway import get_service_name + svc_name = get_service_name() + except Exception: + svc_name = "hermes-gateway" + + service_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service" if not service_file.exists(): return False @@ -141,14 +147,14 @@ def uninstall_gateway_service(): try: # Stop the service subprocess.run( - ["systemctl", "--user", "stop", "hermes-gateway"], + ["systemctl", "--user", "stop", svc_name], capture_output=True, check=False ) # Disable the service subprocess.run( - ["systemctl", "--user", "disable", "hermes-gateway"], + ["systemctl", "--user", "disable", svc_name], capture_output=True, check=False ) diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 29da657e2..52d43fd08 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -39,7 +39,7 @@ def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, "")) def fake_run(cmd, capture_output=False, text=False, check=False): - if cmd[:4] == ["systemctl", "--user", "status", gateway.SERVICE_NAME]: + if cmd[:4] == ["systemctl", "--user", "status", gateway.get_service_name()]: return SimpleNamespace(returncode=0, stdout="", stderr="") if cmd[:3] == ["systemctl", "--user", "is-active"]: return SimpleNamespace(returncode=0, stdout="active\n", stderr="") @@ -76,7 +76,7 @@ def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys): assert unit_path.exists() assert [cmd for cmd, _ in calls] == [ ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "enable", gateway.SERVICE_NAME], + ["systemctl", "--user", "enable", gateway.get_service_name()], ] assert helper_calls == [True] assert "User service installed and enabled" in out @@ -110,7 +110,7 @@ def test_systemd_install_system_scope_skips_linger_and_uses_systemctl(monkeypatc assert unit_path.read_text(encoding="utf-8") == "scope=True user=alice\n" assert [cmd for cmd, _ in calls] == [ ["systemctl", "daemon-reload"], - ["systemctl", "enable", gateway.SERVICE_NAME], + ["systemctl", "enable", gateway.get_service_name()], ] assert helper_calls == [] assert "Configured to run as: alice" not in out # generated test unit has no User= line diff --git a/tests/hermes_cli/test_gateway_linger.py b/tests/hermes_cli/test_gateway_linger.py index cdc07f95f..b21e3f762 100644 --- a/tests/hermes_cli/test_gateway_linger.py +++ b/tests/hermes_cli/test_gateway_linger.py @@ -114,7 +114,7 @@ def test_systemd_install_calls_linger_helper(monkeypatch, tmp_path, capsys): assert unit_path.exists() assert [cmd for cmd, _ in calls] == [ ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "enable", gateway.SERVICE_NAME], + ["systemctl", "--user", "enable", gateway.get_service_name()], ] assert helper_calls == [True] assert "User service installed and enabled" in out diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index ce41a57a1..708d0ee8d 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -26,7 +26,7 @@ class TestSystemdServiceRefresh: assert unit_path.read_text(encoding="utf-8") == "new unit\n" assert calls[:2] == [ ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "start", gateway_cli.SERVICE_NAME], + ["systemctl", "--user", "start", gateway_cli.get_service_name()], ] def test_systemd_restart_refreshes_outdated_unit(self, tmp_path, monkeypatch): @@ -49,7 +49,7 @@ class TestSystemdServiceRefresh: assert unit_path.read_text(encoding="utf-8") == "new unit\n" assert calls[:2] == [ ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "restart", gateway_cli.SERVICE_NAME], + ["systemctl", "--user", "restart", gateway_cli.get_service_name()], ] @@ -92,9 +92,9 @@ class TestGatewayServiceDetection: ) def fake_run(cmd, capture_output=True, text=True, **kwargs): - if cmd == ["systemctl", "--user", "is-active", gateway_cli.SERVICE_NAME]: + if cmd == ["systemctl", "--user", "is-active", gateway_cli.get_service_name()]: return SimpleNamespace(returncode=0, stdout="inactive\n", stderr="") - if cmd == ["systemctl", "is-active", gateway_cli.SERVICE_NAME]: + if cmd == ["systemctl", "is-active", gateway_cli.get_service_name()]: return SimpleNamespace(returncode=0, stdout="active\n", stderr="") raise AssertionError(f"Unexpected command: {cmd}")