From fbfbab8bb88529bac804da3c93858a1f3a0caff4 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 16:46:31 -0700 Subject: [PATCH] feat: implement launchd service commands (install, uninstall, start, stop, restart, status, logs) --- muxplex/service.py | 65 ++++++++++++++++--- muxplex/tests/test_service.py | 117 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 8 deletions(-) diff --git a/muxplex/service.py b/muxplex/service.py index 5c8b469..fbeae15 100644 --- a/muxplex/service.py +++ b/muxplex/service.py @@ -33,6 +33,35 @@ Environment=PATH={safe_path} WantedBy=default.target """ +_LAUNCHD_PLIST_TEMPLATE = """\ + + + + + Label + com.muxplex + ProgramArguments + + {muxplex_bin} + serve + + EnvironmentVariables + + PATH + {safe_path} + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/muxplex.log + StandardErrorPath + /tmp/muxplex.err + + +""" + # --------------------------------------------------------------------------- # Platform detection # --------------------------------------------------------------------------- @@ -122,36 +151,56 @@ def _systemd_logs() -> None: # --------------------------------------------------------------------------- -# Private stubs — launchd (macOS) +# Private implementations — launchd (macOS) # --------------------------------------------------------------------------- def _launchd_install() -> None: - raise NotImplementedError("launchd install not implemented") + muxplex_bin = _resolve_muxplex_bin() + base_path = os.environ.get("PATH", "/usr/bin:/bin") + safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}" + plist_content = _LAUNCHD_PLIST_TEMPLATE.format( + muxplex_bin=muxplex_bin, safe_path=safe_path + ) + _LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True) + _LAUNCHD_PLIST_PATH.write_text(plist_content) + uid = os.getuid() + subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True + ) + _prompt_host_if_localhost() def _launchd_uninstall() -> None: - raise NotImplementedError("launchd uninstall not implemented") + uid = os.getuid() + subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=True) + _LAUNCHD_PLIST_PATH.unlink(missing_ok=True) def _launchd_start() -> None: - raise NotImplementedError("launchd start not implemented") + uid = os.getuid() + subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True + ) def _launchd_stop() -> None: - raise NotImplementedError("launchd stop not implemented") + uid = os.getuid() + subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=True) def _launchd_restart() -> None: - raise NotImplementedError("launchd restart not implemented") + _launchd_stop() + _launchd_start() def _launchd_status() -> None: - raise NotImplementedError("launchd status not implemented") + uid = os.getuid() + subprocess.run(["launchctl", "print", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=True) def _launchd_logs() -> None: - raise NotImplementedError("launchd logs not implemented") + subprocess.run(["tail", "-f", "/tmp/muxplex.log"], check=True) # --------------------------------------------------------------------------- diff --git a/muxplex/tests/test_service.py b/muxplex/tests/test_service.py index 2af6d8e..44f36c5 100644 --- a/muxplex/tests/test_service.py +++ b/muxplex/tests/test_service.py @@ -148,3 +148,120 @@ def test_systemd_logs_calls_journalctl(monkeypatch): monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd))) svc._systemd_logs() assert ["journalctl", "--user", "-u", "muxplex", "-f"] in calls + + +# --------------------------------------------------------------------------- +# launchd tests +# --------------------------------------------------------------------------- + + +def test_launchd_install_writes_plist_and_bootstraps(monkeypatch, tmp_path): + """_launchd_install writes plist with 'com.muxplex' and 'serve' (no --host/--port) + and calls launchctl bootstrap with gui/{uid}.""" + import os + + import muxplex.service as svc + + plist_dir = tmp_path / "LaunchAgents" + plist_path = plist_dir / "com.muxplex.plist" + + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir) + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path) + monkeypatch.setattr(os, "getuid", lambda: 501) + + calls = [] + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd))) + + # Suppress interactive prompt + monkeypatch.setattr(svc, "_prompt_host_if_localhost", lambda: None) + + svc._launchd_install() + + # Plist file must exist and contain expected content + assert plist_path.exists(), "plist file was not written" + content = plist_path.read_text() + assert "com.muxplex" in content, "plist must contain 'com.muxplex'" + assert "serve" in content, "plist ProgramArguments must include 'serve'" + assert "--host" not in content, "plist must NOT contain --host" + assert "--port" not in content, "plist must NOT contain --port" + + # bootstrap must be called with gui/501 + bootstrap_calls = [c for c in calls if "bootstrap" in c] + assert bootstrap_calls, "launchctl bootstrap not called" + bootstrap_cmd = bootstrap_calls[0] + assert "gui/501" in bootstrap_cmd, ( + f"bootstrap must use gui/501, got: {bootstrap_cmd}" + ) + + +def test_launchd_uninstall_bootouts_and_removes(monkeypatch, tmp_path): + """_launchd_uninstall calls launchctl bootout and removes the plist file.""" + import os + + import muxplex.service as svc + + plist_dir = tmp_path / "LaunchAgents" + plist_dir.mkdir(parents=True) + plist_path = plist_dir / "com.muxplex.plist" + plist_path.write_text("") + + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir) + monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path) + monkeypatch.setattr(os, "getuid", lambda: 501) + + calls = [] + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd))) + + svc._launchd_uninstall() + + # bootout must be called + bootout_calls = [c for c in calls if "bootout" in c] + assert bootout_calls, "launchctl bootout not called" + bootout_cmd = bootout_calls[0] + assert "gui/501" in " ".join(bootout_cmd), ( + f"bootout must reference gui/501, got: {bootout_cmd}" + ) + assert "com.muxplex" in " ".join(bootout_cmd), ( + f"bootout must reference com.muxplex, got: {bootout_cmd}" + ) + + # plist must be removed + assert not plist_path.exists(), "plist file was not deleted" + + +def test_launchd_stop_calls_bootout(monkeypatch): + """_launchd_stop runs launchctl bootout gui/{uid}/com.muxplex.""" + import os + + import muxplex.service as svc + + monkeypatch.setattr(os, "getuid", lambda: 501) + + calls = [] + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd))) + + svc._launchd_stop() + + bootout_calls = [c for c in calls if "bootout" in c] + assert bootout_calls, "launchctl bootout not called" + bootout_cmd = bootout_calls[0] + assert "gui/501" in " ".join(bootout_cmd), ( + f"bootout must reference gui/501, got: {bootout_cmd}" + ) + assert "com.muxplex" in " ".join(bootout_cmd), ( + f"bootout must reference com.muxplex, got: {bootout_cmd}" + ) + + +def test_launchd_logs_tails_log_file(monkeypatch): + """_launchd_logs runs exactly ['tail', '-f', '/tmp/muxplex.log'].""" + import muxplex.service as svc + + calls = [] + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd))) + + svc._launchd_logs() + + assert ["tail", "-f", "/tmp/muxplex.log"] in calls, ( + f"Expected ['tail', '-f', '/tmp/muxplex.log'], got: {calls}" + )