diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 92cfefa7..197c0574 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -948,13 +948,24 @@ def launchd_uninstall(): print("✓ Service uninstalled") def launchd_start(): - refresh_launchd_plist_if_needed() plist_path = get_launchd_plist_path() label = get_launchd_label() + + # Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade) + if not plist_path.exists(): + print("↻ launchd plist missing; regenerating service definition") + plist_path.parent.mkdir(parents=True, exist_ok=True) + plist_path.write_text(generate_launchd_plist(), encoding="utf-8") + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + subprocess.run(["launchctl", "start", label], check=True) + print("✓ Service started") + return + + refresh_launchd_plist_if_needed() try: subprocess.run(["launchctl", "start", label], check=True) except subprocess.CalledProcessError as e: - if e.returncode != 3 or not plist_path.exists(): + if e.returncode != 3: raise print("↻ launchd job was unloaded; reloading service definition") subprocess.run(["launchctl", "load", str(plist_path)], check=True) diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index ce74f3f6..89ac8421 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -240,6 +240,33 @@ class TestLaunchdPlistRefresh: assert any("unload" in s for s in cmd_strs) assert any("start" in s for s in cmd_strs) + def test_launchd_start_recreates_missing_plist_and_loads_service(self, tmp_path, monkeypatch): + """launchd_start self-heals when the plist file is missing entirely.""" + plist_path = tmp_path / "ai.hermes.gateway.plist" + assert not plist_path.exists() + + monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + + calls = [] + def fake_run(cmd, check=False, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + gateway_cli.launchd_start() + + # Should have created the plist + assert plist_path.exists() + assert "--replace" in plist_path.read_text() + + cmd_strs = [" ".join(c) for c in calls] + # Should load the new plist, then start + assert any("load" in s for s in cmd_strs) + assert any("start" in s for s in cmd_strs) + # Should NOT call unload (nothing to unload) + assert not any("unload" in s for s in cmd_strs) + class TestCmdUpdateLaunchdRestart: """cmd_update correctly detects and handles launchd on macOS."""