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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user