diff --git a/README.md b/README.md index 4ba83c0..6f13d1e 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,6 @@ All serve options read from `~/.config/muxplex/settings.json` by default. CLI fl | `muxplex upgrade` | Upgrade to latest version and restart service | | `muxplex show-password` | Show the current muxplex password | | `muxplex reset-secret` | Regenerate signing secret (invalidates sessions) | -| `muxplex install-service` | *(deprecated — use `muxplex service install`)* | ### Service management @@ -196,7 +195,7 @@ muxplex/ └── muxplex/ # Python package ├── __init__.py ├── __main__.py # `python -m muxplex` entry point - ├── cli.py # CLI argument parsing and `install-service` subcommand + ├── cli.py # CLI entry point and subcommand dispatch ├── main.py # FastAPI app: session API, bell hooks, WebSocket proxy to ttyd, static frontend ├── sessions.py # tmux session discovery and snapshot capture ├── bells.py # Bell/activity notification tracking diff --git a/muxplex/cli.py b/muxplex/cli.py index c369a3a..d326c7a 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -579,18 +579,6 @@ def main() -> None: help="Force reinstall even if already up to date", ) - # Intercept deprecated 'install-service' before argparse sees it - if len(sys.argv) > 1 and sys.argv[1] == "install-service": - print( - "\u26a0 'muxplex install-service' is deprecated." - " Use 'muxplex service install' instead.", - file=sys.stderr, - ) - from muxplex.service import service_install # noqa: PLC0415 - - service_install() - return - args = parser.parse_args() if args.command == "show-password": diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 3b1e104..5ee57f8 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -7,12 +7,10 @@ import stat from pathlib import Path from unittest.mock import MagicMock, 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() with None defaults (settings layer resolves).""" from muxplex.cli import main @@ -24,7 +22,6 @@ def test_main_calls_serve_by_default(): host=None, port=None, auth=None, session_ttl=None ) - def test_main_passes_custom_host_and_port(): """main() with --host/--port must forward them to serve(); unset flags are None.""" from muxplex.cli import main @@ -36,7 +33,6 @@ def test_main_passes_custom_host_and_port(): host="192.168.1.1", port=9000, auth=None, session_ttl=None ) - def test_main_default_host_is_localhost(): """Default --host must be None (settings layer resolves to 127.0.0.1).""" from muxplex.cli import main @@ -47,7 +43,6 @@ def test_main_default_host_is_localhost(): _, kwargs = mock_serve.call_args assert kwargs["host"] is None - def test_main_passes_auth_flag(): """main() with --auth password must forward auth='password'; unset flags are None.""" from muxplex.cli import main @@ -59,7 +54,6 @@ def test_main_passes_auth_flag(): host=None, port=None, auth="password", session_ttl=None ) - def test_main_passes_session_ttl_flag(): """main() with --session-ttl 3600 must forward session_ttl=3600; unset flags are None.""" from muxplex.cli import main @@ -71,27 +65,6 @@ def test_main_passes_session_ttl_flag(): host=None, port=None, auth=None, session_ttl=3600 ) - -def test_main_install_service_subcommand(): - """main() with 'install-service' must invoke service_install() (new module).""" - from muxplex.cli import main - - with patch("muxplex.service.service_install") as mock_install: - with patch("sys.argv", ["muxplex", "install-service"]): - main() - mock_install.assert_called_once() - - -def test_main_install_service_system_flag(): - """main() with 'install-service --system' still calls service_install() (--system ignored; new module auto-detects).""" - from muxplex.cli import main - - with patch("muxplex.service.service_install") as mock_install: - with patch("sys.argv", ["muxplex", "install-service", "--system"]): - main() - mock_install.assert_called_once() - - def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys): """show_password() prints the password when MUXPLEX_AUTH=password and file exists.""" from muxplex.cli import show_password @@ -111,7 +84,6 @@ def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys): captured = capsys.readouterr() assert "my-test-password" in captured.out - def test_show_password_no_file(tmp_path, monkeypatch, capsys): """show_password() tells user no file found when in password mode with no file.""" from muxplex.cli import show_password @@ -129,7 +101,6 @@ def test_show_password_no_file(tmp_path, monkeypatch, capsys): output_lower = captured.out.lower() assert "no password" in output_lower or "not found" in output_lower - def test_show_password_pam_mode(monkeypatch, capsys): """show_password() reports PAM mode when pam_available() is True and not password mode.""" from muxplex.cli import show_password @@ -142,7 +113,6 @@ def test_show_password_pam_mode(monkeypatch, capsys): captured = capsys.readouterr() assert "pam" in captured.out.lower() - def test_reset_secret_writes_new_secret(tmp_path, monkeypatch): """reset_secret() writes a new secret file with content longer than 20 chars.""" from muxplex.cli import reset_secret @@ -158,7 +128,6 @@ def test_reset_secret_writes_new_secret(tmp_path, monkeypatch): content = secret_path.read_text().strip() assert len(content) > 20, f"Secret must be longer than 20 chars, got {len(content)}" - def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch): """reset_secret() sets file permissions to 0o600.""" from muxplex.cli import reset_secret @@ -173,7 +142,6 @@ def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch): file_mode = stat.S_IMODE(secret_path.stat().st_mode) assert file_mode == 0o600, f"Expected 0o600, got {oct(file_mode)}" - def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): """reset_secret() prints a warning that sessions are now invalid.""" from muxplex.cli import reset_secret @@ -190,23 +158,6 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): f"Expected 'invalid' or 'warning' in output, got: {captured.out!r}" ) - -def test_install_service_deprecated_alias_prints_warning(capsys): - """install-service must print deprecation warning and forward to service install.""" - from muxplex.cli import main - - with ( - patch("sys.argv", ["muxplex", "install-service"]), - patch("muxplex.service.service_install") as mock_install, - ): - main() - - captured = capsys.readouterr() - assert "deprecated" in captured.err.lower() - assert "service install" in captured.err.lower() - mock_install.assert_called_once() - - def test_check_dependencies_exits_when_ttyd_missing(monkeypatch): """_check_dependencies() must sys.exit(1) when ttyd is not in PATH.""" import shutil @@ -226,7 +177,6 @@ def test_check_dependencies_exits_when_ttyd_missing(monkeypatch): _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 @@ -246,7 +196,6 @@ def test_check_dependencies_exits_when_tmux_missing(monkeypatch): _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 @@ -257,7 +206,6 @@ def test_check_dependencies_passes_when_all_present(monkeypatch): # 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 @@ -271,21 +219,6 @@ def test_main_check_dependencies_called_for_serve(monkeypatch): 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.service.service_install"): - 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 @@ -298,12 +231,10 @@ def test_dunder_main_calls_main(): exec(Path(spec.origin).read_text()) # noqa: S102 mock_main.assert_called_once() - # --------------------------------------------------------------------------- # doctor() tests # --------------------------------------------------------------------------- - def test_doctor_shows_python_version(capsys): """doctor must show Python version.""" from muxplex.cli import doctor @@ -312,7 +243,6 @@ def test_doctor_shows_python_version(capsys): out = capsys.readouterr().out assert "Python" in out - def test_doctor_checks_tmux(capsys, monkeypatch): """doctor must check for tmux.""" import subprocess @@ -333,7 +263,6 @@ def test_doctor_checks_tmux(capsys, monkeypatch): out = capsys.readouterr().out assert "tmux" in out - def test_doctor_reports_missing_ttyd(capsys, monkeypatch): """doctor must report when ttyd is missing.""" from muxplex.cli import doctor @@ -351,7 +280,6 @@ def test_doctor_reports_missing_ttyd(capsys, monkeypatch): assert "ttyd" in out assert "not found" in out - def test_doctor_shows_platform(capsys): """doctor must show platform info.""" from muxplex.cli import doctor @@ -360,7 +288,6 @@ def test_doctor_shows_platform(capsys): out = capsys.readouterr().out assert "Platform" in out - def test_doctor_subcommand_registered(): """doctor must be a valid subcommand in main() argparse.""" import io @@ -378,7 +305,6 @@ def test_doctor_subcommand_registered(): help_text = buf.getvalue().lower() assert "doctor" in help_text - def test_main_dispatches_to_doctor(monkeypatch): """main() with 'doctor' subcommand must invoke doctor().""" from muxplex.cli import main @@ -393,12 +319,10 @@ def test_main_dispatches_to_doctor(monkeypatch): "doctor() must be called once when 'doctor' subcommand is used" ) - # --------------------------------------------------------------------------- # upgrade / update subcommand tests # --------------------------------------------------------------------------- - def test_upgrade_subcommand_registered(): """upgrade must be a valid subcommand.""" import io @@ -416,7 +340,6 @@ def test_upgrade_subcommand_registered(): help_text = buf.getvalue().lower() assert "upgrade" in help_text - def test_update_alias_registered(): """update must be a valid subcommand (alias for upgrade).""" import io @@ -434,7 +357,6 @@ def test_update_alias_registered(): help_text = buf.getvalue().lower() assert "update" in help_text - def test_upgrade_calls_uv_tool_install(monkeypatch, capsys): """upgrade must attempt uv tool install when update is available.""" import subprocess @@ -464,7 +386,6 @@ def test_upgrade_calls_uv_tool_install(monkeypatch, capsys): uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] assert len(uv_calls) > 0, "upgrade must call uv tool install" - def test_main_dispatches_to_upgrade(monkeypatch): """main() with 'upgrade' subcommand must invoke upgrade().""" from muxplex.cli import main @@ -477,7 +398,6 @@ def test_main_dispatches_to_upgrade(monkeypatch): assert len(calls) == 1, "upgrade() must be called once for 'upgrade' subcommand" - def test_main_dispatches_update_to_upgrade(monkeypatch): """main() with 'update' subcommand must also invoke upgrade().""" from muxplex.cli import main @@ -490,12 +410,10 @@ def test_main_dispatches_update_to_upgrade(monkeypatch): assert len(calls) == 1, "upgrade() must be called once for 'update' subcommand" - # --------------------------------------------------------------------------- # Smart version-check tests (_get_install_info / _check_for_update) # --------------------------------------------------------------------------- - def test_get_install_info_returns_dict(): """_get_install_info must return a dict with all required keys.""" from muxplex.cli import _get_install_info @@ -507,7 +425,6 @@ def test_get_install_info_returns_dict(): assert "url" in info assert info["source"] in ("git", "editable", "pypi", "unknown") - def test_check_for_update_editable_returns_false(): """Editable installs must never suggest an update.""" from muxplex.cli import _check_for_update @@ -517,7 +434,6 @@ def test_check_for_update_editable_returns_false(): assert available is False assert "editable" in msg - def test_upgrade_force_skips_version_check(monkeypatch, capsys): """upgrade(force=True) must skip the version check and proceed to install.""" import subprocess @@ -550,7 +466,6 @@ def test_upgrade_force_skips_version_check(monkeypatch, capsys): uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] assert len(uv_calls) > 0, "upgrade(force=True) must still call uv tool install" - def test_upgrade_already_up_to_date_skips_install(monkeypatch, capsys): """upgrade() must print 'up to date' and NOT call uv when version check says current.""" import subprocess @@ -581,7 +496,6 @@ def test_upgrade_already_up_to_date_skips_install(monkeypatch, capsys): uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] assert len(uv_calls) == 0, "uv must NOT be called when already up to date" - def test_upgrade_force_flag_registered(): """upgrade --force must be accepted by argparse without error.""" import io @@ -599,12 +513,10 @@ def test_upgrade_force_flag_registered(): help_text = buf.getvalue() assert "--force" in help_text - # --------------------------------------------------------------------------- # serve() settings.json integration tests # --------------------------------------------------------------------------- - def test_serve_reads_host_from_settings(tmp_path, monkeypatch): """serve(host=None) must use host from settings.json.""" settings_file = tmp_path / "settings.json" @@ -626,7 +538,6 @@ def test_serve_reads_host_from_settings(tmp_path, monkeypatch): assert len(calls) == 1 assert calls[0]["host"] == "192.168.0.1" - def test_serve_cli_flag_overrides_settings(tmp_path, monkeypatch): """serve(host='10.0.0.1') must override settings.json host.""" settings_file = tmp_path / "settings.json" @@ -648,7 +559,6 @@ def test_serve_cli_flag_overrides_settings(tmp_path, monkeypatch): assert len(calls) == 1 assert calls[0]["host"] == "10.0.0.1" - def test_serve_falls_back_to_default_when_no_settings_file(tmp_path, monkeypatch): """serve() with no settings file and no CLI flags uses hardcoded defaults.""" settings_file = tmp_path / "nonexistent_settings.json" @@ -671,7 +581,6 @@ def test_serve_falls_back_to_default_when_no_settings_file(tmp_path, monkeypatch assert calls[0]["host"] == "127.0.0.1" assert calls[0]["port"] == 8088 - def test_serve_port_from_settings(tmp_path, monkeypatch): """serve(port=None) must use port from settings.json.""" settings_file = tmp_path / "settings.json" @@ -693,7 +602,6 @@ def test_serve_port_from_settings(tmp_path, monkeypatch): assert len(calls) == 1 assert calls[0]["port"] == 9999 - def test_serve_session_ttl_from_settings(tmp_path, monkeypatch): """serve(session_ttl=None) must use session_ttl from settings.json.""" settings_file = tmp_path / "settings.json" @@ -710,7 +618,6 @@ def test_serve_session_ttl_from_settings(tmp_path, monkeypatch): assert os.environ.get("MUXPLEX_SESSION_TTL") == "3600" - def test_serve_session_ttl_zero_is_valid(tmp_path, monkeypatch): """serve(session_ttl=0) must work — 0 means browser session, a valid value.""" settings_file = tmp_path / "settings.json" @@ -727,13 +634,11 @@ def test_serve_session_ttl_zero_is_valid(tmp_path, monkeypatch): assert os.environ.get("MUXPLEX_SESSION_TTL") == "0" - # --------------------------------------------------------------------------- # argparse refactoring tests — None defaults, serve flags on both parsers, # upgrade alias, install-service deprecation warning # --------------------------------------------------------------------------- - def test_main_passes_none_for_unset_flags(): """main() with no flags passes None for host/port/auth/session_ttl to serve().""" from muxplex.cli import main @@ -745,7 +650,6 @@ def test_main_passes_none_for_unset_flags(): host=None, port=None, auth=None, session_ttl=None ) - def test_main_passes_explicit_host_only(): """main() with --host 10.0.0.1 passes host='10.0.0.1', others as None.""" from muxplex.cli import main @@ -757,7 +661,6 @@ def test_main_passes_explicit_host_only(): host="10.0.0.1", port=None, auth=None, session_ttl=None ) - def test_main_serve_subcommand_accepts_flags(): """'muxplex serve --host 10.0.0.1 --port 9000' passes values to serve().""" from muxplex.cli import main @@ -771,20 +674,6 @@ def test_main_serve_subcommand_accepts_flags(): host="10.0.0.1", port=9000, auth=None, session_ttl=None ) - -def test_install_service_subcommand_prints_deprecation_warning(capsys): - """'muxplex install-service' must print a deprecation warning to stderr.""" - from muxplex.cli import main - - with patch("muxplex.service.service_install"): - with patch("sys.argv", ["muxplex", "install-service"]): - main() - - captured = capsys.readouterr() - assert "deprecated" in captured.err.lower() - assert "muxplex service install" in captured.err - - def test_help_shows_single_upgrade_line(): """Help output shows 'upgrade (update)' alias notation, not two separate subcommand entries.""" import io @@ -807,7 +696,6 @@ def test_help_shows_single_upgrade_line(): f"Got help text:\n{help_text}" ) - def test_doctor_shows_serve_config(tmp_path, monkeypatch, capsys): """doctor() must show the current serve config (host, port, auth).""" import json @@ -829,12 +717,10 @@ def test_doctor_shows_serve_config(tmp_path, monkeypatch, capsys): assert "9999" in out assert "password" in out - # --------------------------------------------------------------------------- # service subcommand dispatch tests # --------------------------------------------------------------------------- - def test_service_install_dispatches(): """muxplex service install must call service_install().""" from muxplex.cli import main @@ -844,7 +730,6 @@ def test_service_install_dispatches(): main() mock_fn.assert_called_once() - def test_service_uninstall_dispatches(): """muxplex service uninstall must call service_uninstall().""" from muxplex.cli import main @@ -854,7 +739,6 @@ def test_service_uninstall_dispatches(): main() mock_fn.assert_called_once() - def test_service_start_dispatches(): """muxplex service start must call service_start().""" from muxplex.cli import main @@ -864,7 +748,6 @@ def test_service_start_dispatches(): main() mock_fn.assert_called_once() - def test_service_stop_dispatches(): """muxplex service stop must call service_stop().""" from muxplex.cli import main @@ -874,7 +757,6 @@ def test_service_stop_dispatches(): main() mock_fn.assert_called_once() - def test_service_restart_dispatches(): """muxplex service restart must call service_restart().""" from muxplex.cli import main @@ -884,7 +766,6 @@ def test_service_restart_dispatches(): main() mock_fn.assert_called_once() - def test_service_status_dispatches(): """muxplex service status must call service_status().""" from muxplex.cli import main @@ -894,7 +775,6 @@ def test_service_status_dispatches(): main() mock_fn.assert_called_once() - def test_service_logs_dispatches(): """muxplex service logs must call service_logs().""" from muxplex.cli import main @@ -904,20 +784,6 @@ def test_service_logs_dispatches(): main() mock_fn.assert_called_once() - -def test_install_service_deprecated_calls_service_install(capsys): - """'muxplex install-service' deprecated alias must call service_install() and print deprecation warning.""" - from muxplex.cli import main - - with patch("muxplex.service.service_install") as mock_service_install: - with patch("sys.argv", ["muxplex", "install-service"]): - main() - - mock_service_install.assert_called_once() - captured = capsys.readouterr() - assert "deprecated" in captured.err.lower() - - def test_service_subcommand_in_help(): """'service' must appear in muxplex --help output.""" import io @@ -935,21 +801,10 @@ def test_service_subcommand_in_help(): help_text = buf.getvalue().lower() assert "service" in help_text - # --------------------------------------------------------------------------- # task-6: Verify old install_service/launchd/systemd removed from cli.py # --------------------------------------------------------------------------- - -def test_old_install_service_removed_from_cli(): - """install_service must no longer exist in muxplex.cli (moved to muxplex.service).""" - import muxplex.cli as cli_mod - - assert not hasattr(cli_mod, "install_service"), ( - "install_service should be removed from cli.py; use muxplex.service.service_install" - ) - - def test_old_install_launchd_removed_from_cli(): """_install_launchd must no longer exist in muxplex.cli (moved to muxplex.service).""" import muxplex.cli as cli_mod @@ -958,7 +813,6 @@ def test_old_install_launchd_removed_from_cli(): "_install_launchd should be removed from cli.py; functionality is in muxplex.service" ) - def test_old_install_systemd_removed_from_cli(): """_install_systemd must no longer exist in muxplex.cli (moved to muxplex.service).""" import muxplex.cli as cli_mod @@ -967,7 +821,6 @@ def test_old_install_systemd_removed_from_cli(): "_install_systemd should be removed from cli.py; functionality is in muxplex.service" ) - def test_upgrade_uses_service_module_install(monkeypatch, capsys): """upgrade() must call muxplex.service.service_install instead of cli.install_service.""" import subprocess diff --git a/muxplex/tests/test_readme.py b/muxplex/tests/test_readme.py index a19aa3b..c30c6f4 100644 --- a/muxplex/tests/test_readme.py +++ b/muxplex/tests/test_readme.py @@ -43,23 +43,3 @@ def test_readme_shows_restart_workflow(): assert "muxplex service restart" in README, ( "README must include 'muxplex service restart' in the example" ) - - -def test_readme_no_plain_install_service_in_install_sections(): - """README install/setup sections must not use plain 'muxplex install-service' as active command.""" - lines = README.splitlines() - # Find lines that are in bash code blocks and contain install-service without being - # marked deprecated or commented out - in_code_block = False - for i, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("```"): - in_code_block = not in_code_block - if in_code_block and "install-service" in stripped: - # Allow lines that are comments (# deprecated) or explicitly deprecated - if not stripped.startswith("#"): - # This is an active command — it should NOT be 'muxplex install-service' - assert "muxplex install-service" not in stripped, ( - f"Line {i + 1} has active 'muxplex install-service' command in code block: {line!r}. " - "Update to use 'muxplex service install' instead." - )