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