diff --git a/muxplex/cli.py b/muxplex/cli.py index 774f03d..44a7626 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -2,6 +2,7 @@ import argparse import os +import shutil import sys from pathlib import Path @@ -56,6 +57,29 @@ def serve( uvicorn.run(app, host=host, port=port, log_level="warning") +def _check_dependencies() -> None: + """Verify required external programs are installed. + + Checks for tmux and ttyd. Prints a helpful error message and exits with + code 1 if any are missing. + """ + missing = [] + if shutil.which("tmux") is None: + missing.append(("tmux", "sudo apt install tmux / brew install tmux")) + if shutil.which("ttyd") is None: + missing.append(("ttyd", "sudo apt install ttyd / brew install ttyd")) + + if missing: + print("\n ERROR: Required dependencies not found:\n", file=sys.stderr) + for name, install_hint in missing: + print(f" {name}: {install_hint}", file=sys.stderr) + print( + "\n For details: https://github.com/bkrabach/muxplex#prerequisites\n", + file=sys.stderr, + ) + sys.exit(1) + + def _install_launchd(executable: str) -> None: """Install a macOS launchd agent plist to ~/Library/LaunchAgents/.""" label = "com.muxplex" @@ -191,6 +215,7 @@ def main() -> None: elif args.command == "reset-secret": reset_secret() else: + _check_dependencies() serve( host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl ) diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 2a5d5ec..63a8004 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -330,6 +330,85 @@ def test_install_service_help_text_mentions_background_service(): assert "service" in help_text +def test_check_dependencies_exits_when_ttyd_missing(monkeypatch): + """_check_dependencies() must sys.exit(1) when ttyd is not in PATH.""" + import shutil + import pytest + from muxplex.cli import _check_dependencies + + orig_which = shutil.which + + def fake_which(name): + if name == "ttyd": + return None + return orig_which(name) + + monkeypatch.setattr(shutil, "which", fake_which) + + with pytest.raises(SystemExit) as exc_info: + _check_dependencies() + assert exc_info.value.code == 1 + + +def test_check_dependencies_exits_when_tmux_missing(monkeypatch): + """_check_dependencies() must sys.exit(1) when tmux is not in PATH.""" + import shutil + import pytest + from muxplex.cli import _check_dependencies + + orig_which = shutil.which + + def fake_which(name): + if name == "tmux": + return None + return orig_which(name) + + monkeypatch.setattr(shutil, "which", fake_which) + + with pytest.raises(SystemExit) as exc_info: + _check_dependencies() + assert exc_info.value.code == 1 + + +def test_check_dependencies_passes_when_all_present(monkeypatch): + """_check_dependencies() must not raise when both tmux and ttyd are found.""" + import shutil + from muxplex.cli import _check_dependencies + + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") + + # Should not raise + _check_dependencies() + + +def test_main_check_dependencies_called_for_serve(monkeypatch): + """main() must call _check_dependencies() when subcommand is serve.""" + from muxplex.cli import main + + calls = [] + monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: calls.append(True)) + + with patch("muxplex.cli.serve"): + with patch("sys.argv", ["muxplex"]): + main() + + assert len(calls) == 1, "_check_dependencies must be called once for serve" + + +def test_main_check_dependencies_not_called_for_install_service(monkeypatch): + """main() must NOT call _check_dependencies() for install-service subcommand.""" + from muxplex.cli import main + + calls = [] + monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: calls.append(True)) + + with patch("muxplex.cli.install_service"): + with patch("sys.argv", ["muxplex", "install-service"]): + main() + + assert len(calls) == 0, "_check_dependencies must NOT be called for install-service" + + def test_dunder_main_calls_main(): """python -m muxplex must call cli.main().""" import importlib.util