feat: preflight dependency check for tmux and ttyd at startup

- Add _check_dependencies() that checks shutil.which() for tmux and ttyd
- Print clear error with per-platform install hints when either is missing
- Call _check_dependencies() in main() before serve — not for install-service,
  show-password, or reset-secret (those don't need the binary dependencies)
- Tests: exits on missing ttyd, exits on missing tmux, passes when both present,
  called for serve but not for install-service
This commit is contained in:
Brian Krabach
2026-03-30 06:30:45 -07:00
parent eb8b09a3cf
commit 54ad5fbeea
2 changed files with 104 additions and 0 deletions
+25
View File
@@ -2,6 +2,7 @@
import argparse import argparse
import os import os
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
@@ -56,6 +57,29 @@ def serve(
uvicorn.run(app, host=host, port=port, log_level="warning") 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: def _install_launchd(executable: str) -> None:
"""Install a macOS launchd agent plist to ~/Library/LaunchAgents/.""" """Install a macOS launchd agent plist to ~/Library/LaunchAgents/."""
label = "com.muxplex" label = "com.muxplex"
@@ -191,6 +215,7 @@ def main() -> None:
elif args.command == "reset-secret": elif args.command == "reset-secret":
reset_secret() reset_secret()
else: else:
_check_dependencies()
serve( serve(
host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl
) )
+79
View File
@@ -330,6 +330,85 @@ def test_install_service_help_text_mentions_background_service():
assert "service" in help_text 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(): def test_dunder_main_calls_main():
"""python -m muxplex must call cli.main().""" """python -m muxplex must call cli.main()."""
import importlib.util import importlib.util