feat: add CLI entry point (muxplex serve, muxplex install-service) and __main__.py
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Allow running muxplex as: python -m muxplex"""
|
||||
|
||||
from muxplex.cli import main
|
||||
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user