diff --git a/muxplex/cli.py b/muxplex/cli.py index 26ff24c..774f03d 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -56,10 +56,44 @@ def serve( uvicorn.run(app, host=host, port=port, log_level="warning") -def install_service(*, system: bool = False) -> None: - """Install muxplex as a systemd service.""" - executable = sys.executable +def _install_launchd(executable: str) -> None: + """Install a macOS launchd agent plist to ~/Library/LaunchAgents/.""" + label = "com.muxplex" + plist = f""" + + + + Label + {label} + ProgramArguments + + {executable} + -m + muxplex + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/muxplex.log + StandardErrorPath + /tmp/muxplex.err + + +""" + path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(plist) + print(f"Launch agent written to {path}") + print("Enable with:") + print(f" launchctl load {path}") + print("Disable with:") + print(f" launchctl unload {path}") + +def _install_systemd(executable: str, *, system: bool = False) -> None: + """Install a Linux systemd service unit file.""" _raw_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin") _safe_path = ":".join(p for p in _raw_path.split(":") if not p.startswith("/mnt/")) _safe_path = _safe_path or "/usr/local/bin:/usr/bin:/bin" @@ -97,6 +131,16 @@ WantedBy={"multi-user.target" if system else "default.target"} print(f"Enable with:\n {reload_cmd}") +def install_service(*, system: bool = False) -> None: + """Install muxplex as a background service (launchd on macOS, systemd on Linux).""" + executable = sys.executable + + if sys.platform == "darwin": + _install_launchd(executable) + else: + _install_systemd(executable, system=system) + + def main() -> None: """CLI entry point.""" parser = argparse.ArgumentParser( @@ -124,7 +168,10 @@ def main() -> None: sub = parser.add_subparsers(dest="command") sub.add_parser("serve", help="Start the server (default)") - svc = sub.add_parser("install-service", help="Install systemd service unit") + svc = sub.add_parser( + "install-service", + help="Install as a background service (systemd on Linux, launchd on macOS)", + ) svc.add_argument( "--system", action="store_true", help="System-wide (requires sudo)" ) diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 76247f6..2a5d5ec 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -260,6 +260,76 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): ) +def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch): + """install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/.""" + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "darwin") + + install_service(system=False) + + plist_path = fake_home / "Library" / "LaunchAgents" / "com.muxplex.plist" + assert plist_path.exists(), "Plist file must be created on macOS" + content = plist_path.read_text() + assert "com.muxplex" in content + assert "RunAtLoad" in content + assert "ProgramArguments" in content + assert "LaunchAgents" in str(plist_path) + + +def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch): + """install_service() on macOS must NOT write a systemd unit file.""" + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "darwin") + + install_service(system=False) + + systemd_path = fake_home / ".config" / "systemd" / "user" / "muxplex.service" + assert not systemd_path.exists(), "No systemd unit file should be written on macOS" + + +def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch): + """install_service() on Linux writes a systemd unit to ~/.config/systemd/user/.""" + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "linux") + + install_service(system=False) + + unit_path = fake_home / ".config" / "systemd" / "user" / "muxplex.service" + assert unit_path.exists(), "Systemd unit file must be created on Linux" + content = unit_path.read_text() + assert "[Unit]" in content + assert "[Service]" in content + + +def test_install_service_help_text_mentions_background_service(): + """install-service help must mention 'service', not just 'systemd'.""" + import io + from muxplex.cli import main + + buf = io.StringIO() + with patch("sys.argv", ["muxplex", "install-service", "--help"]): + try: + with patch("sys.stdout", buf): + main() + except SystemExit: + pass + + help_text = buf.getvalue().lower() + assert "service" in help_text + + def test_dunder_main_calls_main(): """python -m muxplex must call cli.main().""" import importlib.util