diff --git a/muxplex/__main__.py b/muxplex/__main__.py new file mode 100644 index 0000000..d57b9c5 --- /dev/null +++ b/muxplex/__main__.py @@ -0,0 +1,5 @@ +"""Allow running muxplex as: python -m muxplex""" + +from muxplex.cli import main + +main() diff --git a/muxplex/cli.py b/muxplex/cli.py new file mode 100644 index 0000000..66e8d1b --- /dev/null +++ b/muxplex/cli.py @@ -0,0 +1,85 @@ +"""muxplex CLI — web-based tmux session dashboard.""" + +import argparse +import os +import sys +from pathlib import Path + +# Module-level path constants (overridable in tests via monkeypatch) +_system_service_path = Path("/etc/systemd/system/muxplex.service") + + +def serve(host: str = "0.0.0.0", port: int = 8088) -> None: + """Start the muxplex server.""" + import uvicorn # noqa: PLC0415 + + os.environ.setdefault("MUXPLEX_PORT", str(port)) + + from muxplex.main import app # noqa: PLC0415 + + print(f" muxplex → http://{host}:{port}") + 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 + + unit = f"""\ +[Unit] +Description=muxplex — web-based tmux session dashboard +After=network.target + +[Service] +Type=simple +ExecStart={executable} -m muxplex +Restart=on-failure +RestartSec=5s +Environment=PATH={os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")} + +[Install] +WantedBy={"multi-user.target" if system else "default.target"} +""" + + if system: + path = _system_service_path + reload_cmd = ( + "sudo systemctl daemon-reload && sudo systemctl enable --now muxplex" + ) + else: + path = Path.home() / ".config" / "systemd" / "user" / "muxplex.service" + path.parent.mkdir(parents=True, exist_ok=True) + reload_cmd = ( + "systemctl --user daemon-reload && systemctl --user enable --now muxplex" + ) + + path.write_text(unit) + print(f"Service file written to {path}") + print(f"Enable with:\n {reload_cmd}") + + +def main() -> None: + """CLI entry point.""" + parser = argparse.ArgumentParser( + prog="muxplex", + description="muxplex — web-based tmux session dashboard", + ) + parser.add_argument( + "--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)" + ) + parser.add_argument("--port", type=int, default=8088, help="Port (default: 8088)") + + 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.add_argument( + "--system", action="store_true", help="System-wide (requires sudo)" + ) + + args = parser.parse_args() + + if args.command == "install-service": + install_service(system=args.system) + else: + serve(host=args.host, port=args.port) diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py new file mode 100644 index 0000000..f603f30 --- /dev/null +++ b/muxplex/tests/test_cli.py @@ -0,0 +1,98 @@ +"""Tests for muxplex/cli.py — CLI entry point.""" + +from pathlib import Path +from unittest.mock import patch + + + +def test_cli_module_importable(): + """muxplex.cli must be importable.""" + from muxplex.cli import main # noqa: F401 + + +def test_main_calls_serve_by_default(): + """Calling main() with no args must invoke serve().""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex"]): + main() + mock_serve.assert_called_once_with(host="0.0.0.0", port=8088) + + +def test_main_passes_custom_host_and_port(): + """main() with --host/--port must forward them to serve().""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex", "--host", "127.0.0.1", "--port", "9000"]): + main() + mock_serve.assert_called_once_with(host="127.0.0.1", port=9000) + + +def test_main_install_service_subcommand(): + """main() with 'install-service' must invoke install_service().""" + from muxplex.cli import main + + with patch("muxplex.cli.install_service") as mock_install: + with patch("sys.argv", ["muxplex", "install-service"]): + main() + mock_install.assert_called_once_with(system=False) + + +def test_main_install_service_system_flag(): + """main() with 'install-service --system' passes system=True.""" + from muxplex.cli import main + + with patch("muxplex.cli.install_service") as mock_install: + with patch("sys.argv", ["muxplex", "install-service", "--system"]): + main() + mock_install.assert_called_once_with(system=True) + + +def test_install_service_user_mode_writes_unit_file(tmp_path, monkeypatch): + """install_service(system=False) writes a unit file 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)) + + install_service(system=False) + + unit_path = fake_home / ".config" / "systemd" / "user" / "muxplex.service" + assert unit_path.exists() + content = unit_path.read_text() + assert "[Unit]" in content + assert "[Service]" in content + assert "[Install]" in content + assert "muxplex" in content + assert "default.target" in content + + +def test_install_service_system_mode_target(tmp_path, monkeypatch): + """install_service(system=True) targets multi-user.target in the unit file.""" + from muxplex.cli import install_service + + # Redirect the system path to tmp so we don't write to /etc + unit_path = tmp_path / "muxplex.service" + monkeypatch.setattr("muxplex.cli._system_service_path", unit_path) + + install_service(system=True) + + assert unit_path.exists() + content = unit_path.read_text() + assert "multi-user.target" in content + + +def test_dunder_main_calls_main(): + """python -m muxplex must call cli.main().""" + with patch("muxplex.cli.main") as mock_main: + # Simulate `python -m muxplex` by exec'ing __main__.py + import muxplex.__main__ # noqa: F401 + + # The import itself calls main() at module level + # Re-exec to test: + mock_main.reset_mock() + exec(Path("muxplex/__main__.py").read_text()) + mock_main.assert_called_once()