fix(cli): propagate install failures, restart service on partial upgrade, prefer uv tool over pip
Bug 1 — upgrade returned exit 0 on partial failure (macOS pip ImportError case):
When the install subprocess fails (non-zero exit), upgrade() now sets
_install_failed=True and calls sys.exit(1) after the finally block instead
of silently returning. A clear error message is printed so the caller /
script can detect the failure.
Files: muxplex/cli.py upgrade() install dispatch blocks.
Bug 2 — macOS launchd agent left unloaded when install fails:
(a) Added _have_launchctl() helper mirroring the _have_systemctl() guard
introduced in v0.6.1. Every launchctl subprocess call in upgrade() and
doctor() is now guarded — if launchctl is absent a clear note is printed
and the step is skipped.
(b) Wrapped stop + install + start in try/finally so the service-start step
ALWAYS executes regardless of install outcome (success or failure).
Applied to both the launchctl path (macOS) and the systemctl path (Linux/
WSL) — same structural issue existed in both, only the macOS variant was
observed in the field.
Files: muxplex/cli.py _have_launchctl(), upgrade() try/finally.
Bug 3 — upgrade chose system pip3 over uv even when install was uv-tool-managed:
Added uv-tool-managed detection: resolves the muxplex script on PATH and
checks whether the target lives under ~/.local/share/uv/tools/. When
detected, uses 'uv tool install --reinstall --force muxplex' (package name,
not a git URL) so the correct uv tool-environment is upgraded. Falls back to
pip when uv is absent from PATH — unchanged.
Files: muxplex/cli.py upgrade() uv-tool detection + install dispatch.
Refs: v0.6.1 added _have_systemctl() and the Linux no-systemctl guard;
this commit brings parity for macOS and closes the remaining failure modes
observed during the fleet update.
This commit is contained in:
+151
-107
@@ -26,6 +26,11 @@ def _have_systemctl() -> bool:
|
|||||||
return shutil.which("systemctl") is not None
|
return shutil.which("systemctl") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _have_launchctl() -> bool:
|
||||||
|
"""Return True if launchctl is on PATH (used to gate service-management steps)."""
|
||||||
|
return shutil.which("launchctl") is not None
|
||||||
|
|
||||||
|
|
||||||
def _get_install_info() -> dict:
|
def _get_install_info() -> dict:
|
||||||
"""Detect how muxplex was installed using PEP 610 direct_url.json.
|
"""Detect how muxplex was installed using PEP 610 direct_url.json.
|
||||||
|
|
||||||
@@ -431,17 +436,22 @@ def doctor() -> None:
|
|||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
|
||||||
if plist.exists():
|
if plist.exists():
|
||||||
uid = os.getuid()
|
if _have_launchctl():
|
||||||
result = subprocess.run(
|
uid = os.getuid()
|
||||||
["launchctl", "print", f"gui/{uid}/com.muxplex"],
|
result = subprocess.run(
|
||||||
capture_output=True,
|
["launchctl", "print", f"gui/{uid}/com.muxplex"],
|
||||||
text=True,
|
capture_output=True,
|
||||||
)
|
text=True,
|
||||||
if result.returncode == 0:
|
)
|
||||||
print(f" {ok_mark} Service: launchd agent running")
|
if result.returncode == 0:
|
||||||
|
print(f" {ok_mark} Service: launchd agent running")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f" {warn_mark} Service: launchd agent installed but not running ({plist})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f" {warn_mark} Service: launchd agent installed but not running ({plist})"
|
f" {warn_mark} Service: launchctl not found — cannot check status"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
@@ -515,12 +525,39 @@ def upgrade(*, force: bool = False) -> None:
|
|||||||
else:
|
else:
|
||||||
print(" Status: --force specified — skipping version check")
|
print(" Status: --force specified — skipping version check")
|
||||||
|
|
||||||
# 1. Detect platform and stop service
|
# Bug 3: Detect whether this install is managed by uv tool.
|
||||||
|
# If the resolved muxplex binary lives inside the uv tools directory we
|
||||||
|
# always use `uv tool install --reinstall --force muxplex` regardless of
|
||||||
|
# the PEP-610 source tag so that the correct tool-environment is upgraded.
|
||||||
|
_uv_tools_dir = str(Path.home() / ".local" / "share" / "uv" / "tools")
|
||||||
|
_muxplex_script = shutil.which("muxplex")
|
||||||
|
_is_uv_managed = False
|
||||||
|
if _muxplex_script:
|
||||||
|
try:
|
||||||
|
if _uv_tools_dir in str(Path(_muxplex_script).resolve()):
|
||||||
|
_is_uv_managed = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# install_target: pypi / uv-tool-managed → package name; git → git URL
|
||||||
|
install_target = (
|
||||||
|
"muxplex"
|
||||||
|
if info["source"] == "pypi" or _is_uv_managed
|
||||||
|
else "git+https://github.com/bkrabach/muxplex"
|
||||||
|
)
|
||||||
|
uv_path = shutil.which("uv")
|
||||||
|
|
||||||
|
# Pre-compute macOS service identifiers — used in both stop and finally blocks.
|
||||||
|
label = "com.muxplex"
|
||||||
|
uid = os.getuid()
|
||||||
|
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||||
|
|
||||||
|
# 1. Stop service
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
label = "com.muxplex"
|
# Bug 2a: guard every launchctl call with _have_launchctl()
|
||||||
uid = os.getuid()
|
if not _have_launchctl():
|
||||||
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
print(" ! launchctl not found — skipping service management step")
|
||||||
if plist.exists():
|
elif plist.exists():
|
||||||
print(" Stopping launchd service...")
|
print(" Stopping launchd service...")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True
|
["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True
|
||||||
@@ -545,116 +582,123 @@ def upgrade(*, force: bool = False) -> None:
|
|||||||
else:
|
else:
|
||||||
print(" No active systemd service found (skipping stop)")
|
print(" No active systemd service found (skipping stop)")
|
||||||
|
|
||||||
# 2. Reinstall via uv tool install
|
# 2+4. Install (try) with guaranteed service restart in finally.
|
||||||
|
# Bug 1+2b: try/finally ensures the start step always runs — success OR failure.
|
||||||
|
_install_failed = False
|
||||||
print(" Installing latest version...")
|
print(" Installing latest version...")
|
||||||
install_target = (
|
try:
|
||||||
"muxplex"
|
# Bug 3: dispatch — uv-tool-managed gets --reinstall; plain uv/pip otherwise
|
||||||
if info["source"] == "pypi"
|
if uv_path:
|
||||||
else "git+https://github.com/bkrabach/muxplex"
|
if _is_uv_managed:
|
||||||
)
|
# uv-tool install: always reinstall the package by name
|
||||||
uv_path = shutil.which("uv")
|
install_cmd = [
|
||||||
if uv_path:
|
uv_path,
|
||||||
result = subprocess.run(
|
"tool",
|
||||||
[
|
|
||||||
uv_path,
|
|
||||||
"tool",
|
|
||||||
"install",
|
|
||||||
install_target,
|
|
||||||
"--force",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
print(f" ERROR: uv tool install failed:\n{result.stderr}")
|
|
||||||
return
|
|
||||||
print(" Installed successfully")
|
|
||||||
else:
|
|
||||||
# Fallback: pip
|
|
||||||
pip_path = shutil.which("pip") or shutil.which("pip3")
|
|
||||||
if pip_path:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
pip_path,
|
|
||||||
"install",
|
"install",
|
||||||
"--upgrade",
|
"--reinstall",
|
||||||
install_target,
|
"--force",
|
||||||
],
|
"muxplex",
|
||||||
capture_output=True,
|
]
|
||||||
text=True,
|
else:
|
||||||
)
|
install_cmd = [uv_path, "tool", "install", install_target, "--force"]
|
||||||
|
result = subprocess.run(install_cmd, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f" ERROR: pip install failed:\n{result.stderr}")
|
print(f" ERROR: uv tool install failed:\n{result.stderr}")
|
||||||
return
|
_install_failed = True
|
||||||
print(" Installed successfully")
|
else:
|
||||||
|
print(" Installed successfully")
|
||||||
else:
|
else:
|
||||||
print(" ERROR: neither uv nor pip found — cannot upgrade")
|
# Bug 3: uv absent → fall back to pip
|
||||||
return
|
pip_path = shutil.which("pip") or shutil.which("pip3")
|
||||||
|
if pip_path:
|
||||||
|
result = subprocess.run(
|
||||||
|
[pip_path, "install", "--upgrade", install_target],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ERROR: pip install failed:\n{result.stderr}")
|
||||||
|
_install_failed = True
|
||||||
|
else:
|
||||||
|
print(" Installed successfully")
|
||||||
|
else:
|
||||||
|
print(" ERROR: neither uv nor pip found — cannot upgrade")
|
||||||
|
_install_failed = True
|
||||||
|
|
||||||
# 3. Regenerate service file (picks up any plist/unit changes)
|
if not _install_failed:
|
||||||
if sys.platform == "darwin" or _have_systemctl():
|
# 3. Regenerate service file (picks up any plist/unit changes)
|
||||||
print(" Regenerating service file...")
|
if sys.platform == "darwin" or _have_systemctl():
|
||||||
from muxplex.service import service_install # noqa: PLC0415
|
print(" Regenerating service file...")
|
||||||
|
from muxplex.service import service_install # noqa: PLC0415
|
||||||
|
|
||||||
service_install()
|
service_install()
|
||||||
else:
|
else:
|
||||||
print(" ! systemctl not found — skipping service file regeneration")
|
print(" ! systemctl not found — skipping service file regeneration")
|
||||||
|
finally:
|
||||||
# 4. Restart service
|
# 4. Restart service — ALWAYS runs after install, even on failure (best-effort).
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
label = "com.muxplex"
|
# Bug 2a: guard launchctl
|
||||||
uid = os.getuid()
|
if _have_launchctl():
|
||||||
plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
|
if plist.exists():
|
||||||
if plist.exists():
|
print(" Starting launchd service...")
|
||||||
print(" Starting launchd service...")
|
result = subprocess.run(
|
||||||
|
["launchctl", "bootstrap", f"gui/{uid}", str(plist)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(" Service started")
|
||||||
|
else:
|
||||||
|
# Fallback to legacy load for older macOS
|
||||||
|
subprocess.run(
|
||||||
|
["launchctl", "load", str(plist)], capture_output=True
|
||||||
|
)
|
||||||
|
print(" Service started (legacy)")
|
||||||
|
else:
|
||||||
|
print(" Service file not found — run: muxplex service install")
|
||||||
|
elif _have_systemctl():
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["launchctl", "bootstrap", f"gui/{uid}", str(plist)],
|
["systemctl", "--user", "is-enabled", "muxplex"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
|
print(" Restarting systemd service...")
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "daemon-reload"], capture_output=True
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "--user", "start", "muxplex"], capture_output=True
|
||||||
|
)
|
||||||
print(" Service started")
|
print(" Service started")
|
||||||
else:
|
else:
|
||||||
# Fallback to legacy load for older macOS
|
print(" Service not enabled — run: muxplex service install")
|
||||||
subprocess.run(["launchctl", "load", str(plist)], capture_output=True)
|
|
||||||
print(" Service started (legacy)")
|
|
||||||
else:
|
else:
|
||||||
print(" Service file not found — run: muxplex service install")
|
# No service manager — ask the user to restart manually
|
||||||
elif _have_systemctl():
|
pid_hint = ""
|
||||||
result = subprocess.run(
|
try:
|
||||||
["systemctl", "--user", "is-enabled", "muxplex"],
|
pid_result = subprocess.run(
|
||||||
capture_output=True,
|
["pgrep", "-f", "muxplex serve"],
|
||||||
text=True,
|
capture_output=True,
|
||||||
)
|
text=True,
|
||||||
if result.returncode == 0:
|
)
|
||||||
print(" Restarting systemd service...")
|
pid_str = pid_result.stdout.strip()
|
||||||
subprocess.run(
|
if pid_str:
|
||||||
["systemctl", "--user", "daemon-reload"], capture_output=True
|
pid_hint = f" (running PID: {pid_str})"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(
|
||||||
|
" ! systemd not detected — restart muxplex manually to pick up the new version"
|
||||||
|
+ pid_hint
|
||||||
)
|
)
|
||||||
subprocess.run(
|
|
||||||
["systemctl", "--user", "start", "muxplex"], capture_output=True
|
# Bug 1: propagate install failure as a non-zero exit so callers / scripts
|
||||||
)
|
# can detect the partial upgrade. Service restart was attempted above (best-effort).
|
||||||
print(" Service started")
|
if _install_failed:
|
||||||
else:
|
|
||||||
print(" Service not enabled — run: muxplex service install")
|
|
||||||
else:
|
|
||||||
# No systemd available — ask the user to restart manually
|
|
||||||
pid_hint = ""
|
|
||||||
try:
|
|
||||||
pid_result = subprocess.run(
|
|
||||||
["pgrep", "-f", "muxplex serve"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
pid_str = pid_result.stdout.strip()
|
|
||||||
if pid_str:
|
|
||||||
pid_hint = f" (running PID: {pid_str})"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
print(
|
print(
|
||||||
" ! systemd not detected — restart muxplex manually to pick up the new version"
|
"\n ERROR: upgrade failed — muxplex service has been restarted (best-effort).\n"
|
||||||
+ pid_hint
|
|
||||||
)
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# 5. Doctor check
|
# 5. Doctor check
|
||||||
print("\n Verifying...")
|
print("\n Verifying...")
|
||||||
|
|||||||
@@ -2483,3 +2483,384 @@ def test_doctor_no_systemctl_does_not_crash(monkeypatch, capsys):
|
|||||||
|
|
||||||
# Must not raise — the original bug was FileNotFoundError on systems without systemctl
|
# Must not raise — the original bug was FileNotFoundError on systems without systemctl
|
||||||
doctor()
|
doctor()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# v0.6.2 fixes: install failure propagation, service-restart-on-failure,
|
||||||
|
# prefer uv over pip for uv-tool-managed installs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_have_launchctl_helper_exists():
|
||||||
|
"""_have_launchctl must be importable from muxplex.cli."""
|
||||||
|
from muxplex.cli import _have_launchctl # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def test_have_launchctl_returns_true_when_present(monkeypatch):
|
||||||
|
"""_have_launchctl() returns True when launchctl is on PATH."""
|
||||||
|
import shutil as _shutil
|
||||||
|
|
||||||
|
from muxplex.cli import _have_launchctl
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_shutil,
|
||||||
|
"which",
|
||||||
|
lambda name: "/usr/bin/launchctl" if name == "launchctl" else None,
|
||||||
|
)
|
||||||
|
assert _have_launchctl() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_have_launchctl_returns_false_when_absent(monkeypatch):
|
||||||
|
"""_have_launchctl() returns False when launchctl is absent."""
|
||||||
|
import shutil as _shutil
|
||||||
|
|
||||||
|
from muxplex.cli import _have_launchctl
|
||||||
|
|
||||||
|
monkeypatch.setattr(_shutil, "which", lambda name: None)
|
||||||
|
assert _have_launchctl() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_propagates_install_failure_as_exit1(monkeypatch, capsys):
|
||||||
|
"""upgrade() must sys.exit(1) when the install subprocess returns non-zero.
|
||||||
|
|
||||||
|
Regression test for Bug 1 (v0.6.2): previously upgrade() silently returned
|
||||||
|
exit 0 even when pip/uv failed mid-flight.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
# Fail only on the install command (uv or pip)
|
||||||
|
if (
|
||||||
|
isinstance(cmd, list)
|
||||||
|
and cmd
|
||||||
|
and ("uv" in str(cmd[0]) or "pip" in str(cmd[0]))
|
||||||
|
):
|
||||||
|
return type(
|
||||||
|
"R", (), {"returncode": 1, "stdout": "", "stderr": "install error"}
|
||||||
|
)()
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available (v0.6.1 \u2192 v0.6.2)"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
assert exc_info.value.code == 1, (
|
||||||
|
f"upgrade() must exit(1) on install failure, got code {exc_info.value.code}"
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "error" in out.lower() or "fail" in out.lower(), (
|
||||||
|
f"upgrade() must print an error message on install failure; got: {out!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_restarts_systemctl_after_failed_install(monkeypatch, capsys):
|
||||||
|
"""upgrade() must attempt to restart the systemctl service even when install fails.
|
||||||
|
|
||||||
|
Regression test for Bug 2 (v0.6.2) — systemctl path: the start step must
|
||||||
|
run in the finally block regardless of install outcome.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(list(cmd) if isinstance(cmd, list) else cmd)
|
||||||
|
# Fail the install command
|
||||||
|
if isinstance(cmd, list) and cmd and "uv" in str(cmd[0]):
|
||||||
|
return type("R", (), {"returncode": 1, "stdout": "", "stderr": "uv fail"})()
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
# systemctl start must still have been called after the failed install
|
||||||
|
start_calls = [
|
||||||
|
c for c in calls if isinstance(c, list) and "systemctl" in c and "start" in c
|
||||||
|
]
|
||||||
|
assert len(start_calls) > 0, (
|
||||||
|
"upgrade() must call systemctl start after a failed install (try/finally)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify call ordering: install attempt precedes start
|
||||||
|
install_idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, c in enumerate(calls)
|
||||||
|
if isinstance(c, list) and c and "uv" in str(c[0])
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
start_idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, c in enumerate(calls)
|
||||||
|
if isinstance(c, list) and "systemctl" in c and "start" in c
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
assert install_idx >= 0, "install command must have been attempted"
|
||||||
|
assert start_idx > install_idx, (
|
||||||
|
"systemctl start must be called AFTER the failed install attempt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_restarts_launchctl_after_failed_install(monkeypatch, capsys, tmp_path):
|
||||||
|
"""upgrade() must attempt to re-load the launchd agent even when install fails (macOS).
|
||||||
|
|
||||||
|
Regression test for Bug 2 (v0.6.2) — launchctl path: the bootstrap step
|
||||||
|
must run in the finally block regardless of install outcome.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
# Set up a fake macOS home with the launchd plist present
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
plist_dir = fake_home / "Library" / "LaunchAgents"
|
||||||
|
plist_dir.mkdir(parents=True)
|
||||||
|
(plist_dir / "com.muxplex.plist").write_text("")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||||
|
monkeypatch.setattr(sys, "platform", "darwin")
|
||||||
|
monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: True)
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(list(cmd) if isinstance(cmd, list) else cmd)
|
||||||
|
if isinstance(cmd, list) and cmd and "uv" in str(cmd[0]):
|
||||||
|
return type("R", (), {"returncode": 1, "stdout": "", "stderr": "uv fail"})()
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
# launchctl bootstrap (or legacy load) must have been called after the failed install
|
||||||
|
restart_calls = [
|
||||||
|
c
|
||||||
|
for c in calls
|
||||||
|
if isinstance(c, list)
|
||||||
|
and "launchctl" in c
|
||||||
|
and ("bootstrap" in c or "load" in c)
|
||||||
|
]
|
||||||
|
assert len(restart_calls) > 0, (
|
||||||
|
"upgrade() must call launchctl bootstrap/load after a failed install (try/finally)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify ordering: install attempt before restart
|
||||||
|
install_idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, c in enumerate(calls)
|
||||||
|
if isinstance(c, list) and c and "uv" in str(c[0])
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
restart_idx = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, c in enumerate(calls)
|
||||||
|
if isinstance(c, list)
|
||||||
|
and "launchctl" in c
|
||||||
|
and ("bootstrap" in c or "load" in c)
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
assert install_idx >= 0, "install command must have been attempted"
|
||||||
|
assert restart_idx > install_idx, (
|
||||||
|
"launchctl bootstrap/load must be called AFTER the failed install attempt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_no_launchctl_on_linux_uses_systemctl(monkeypatch, capsys):
|
||||||
|
"""On Linux without launchctl, upgrade() uses systemctl and never calls launchctl.
|
||||||
|
|
||||||
|
Simulates a Linux host where launchctl is not installed. The upgrade must
|
||||||
|
complete normally (no SystemExit) using the systemctl path.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
subprocess_calls: list = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
subprocess_calls.append(list(cmd) if isinstance(cmd, list) else cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
def fake_which(name):
|
||||||
|
if name == "launchctl":
|
||||||
|
return None # launchctl absent on Linux
|
||||||
|
return f"/usr/bin/{name}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", fake_which)
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
|
with patch("muxplex.service.service_install", lambda: None):
|
||||||
|
cli_mod.upgrade() # must not raise
|
||||||
|
|
||||||
|
launchctl_calls = [
|
||||||
|
c for c in subprocess_calls if isinstance(c, list) and "launchctl" in c
|
||||||
|
]
|
||||||
|
assert len(launchctl_calls) == 0, (
|
||||||
|
f"upgrade() must not call launchctl on Linux; got: {launchctl_calls}"
|
||||||
|
)
|
||||||
|
systemctl_calls = [
|
||||||
|
c for c in subprocess_calls if isinstance(c, list) and "systemctl" in c
|
||||||
|
]
|
||||||
|
assert len(systemctl_calls) > 0, (
|
||||||
|
"upgrade() must call systemctl on Linux when available"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_prefers_uv_tool_when_uv_managed(monkeypatch, capsys):
|
||||||
|
"""upgrade() calls 'uv tool install --reinstall --force muxplex' for uv-tool installs.
|
||||||
|
|
||||||
|
Regression test for Bug 3 (v0.6.2): when the running muxplex binary
|
||||||
|
resolves to inside the uv tools directory, the upgrade must use the uv
|
||||||
|
tool reinstall path (not pip).
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
# Build a fake muxplex path that looks like a uv-tool-managed install.
|
||||||
|
# Use Path.home() so _uv_tools_dir computation matches inside upgrade().
|
||||||
|
uv_tools_fake = str(Path.home() / ".local" / "share" / "uv" / "tools")
|
||||||
|
fake_muxplex_resolved = f"{uv_tools_fake}/muxplex/bin/muxplex"
|
||||||
|
fake_uv_path = str(Path.home() / ".local" / "bin" / "uv")
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(list(cmd) if isinstance(cmd, list) else cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
def fake_which(name):
|
||||||
|
if name == "uv":
|
||||||
|
return fake_uv_path
|
||||||
|
if name == "muxplex":
|
||||||
|
# Return the already-resolved path under the uv tools dir so that
|
||||||
|
# Path(fake_muxplex_resolved).resolve() == fake_muxplex_resolved
|
||||||
|
return fake_muxplex_resolved
|
||||||
|
return f"/usr/bin/{name}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", fake_which)
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available (v0.6.1 \u2192 v0.6.2)"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
|
with patch("muxplex.service.service_install", lambda: None):
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
uv_reinstall_calls = [
|
||||||
|
c
|
||||||
|
for c in calls
|
||||||
|
if isinstance(c, list) and "tool" in c and "install" in c and "--reinstall" in c
|
||||||
|
]
|
||||||
|
assert len(uv_reinstall_calls) > 0, (
|
||||||
|
"upgrade() must call 'uv tool install --reinstall' when install is uv-tool-managed"
|
||||||
|
)
|
||||||
|
install_cmd = uv_reinstall_calls[0]
|
||||||
|
assert "--reinstall" in install_cmd, "command must include --reinstall"
|
||||||
|
assert "--force" in install_cmd, "command must include --force"
|
||||||
|
assert "muxplex" in install_cmd, "command must target 'muxplex' package"
|
||||||
|
# Must not have fallen through to pip
|
||||||
|
pip_calls = [c for c in calls if isinstance(c, list) and c and "pip" in str(c[0])]
|
||||||
|
assert len(pip_calls) == 0, (
|
||||||
|
"upgrade() must not call pip when uv is available and install is uv-managed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_falls_back_to_pip_when_uv_absent(monkeypatch, capsys):
|
||||||
|
"""upgrade() uses pip install when uv is not on PATH.
|
||||||
|
|
||||||
|
Regression test for Bug 3 (v0.6.2): uv absent \u2192 pip must be the installer.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import muxplex.cli as cli_mod
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def mock_run(cmd, **kwargs):
|
||||||
|
calls.append(list(cmd) if isinstance(cmd, list) else cmd)
|
||||||
|
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||||
|
|
||||||
|
def fake_which(name):
|
||||||
|
if name == "uv":
|
||||||
|
return None # uv absent
|
||||||
|
if name in ("pip", "pip3"):
|
||||||
|
return f"/usr/local/bin/{name}"
|
||||||
|
return f"/usr/bin/{name}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(shutil, "which", fake_which)
|
||||||
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||||
|
monkeypatch.setattr(cli_mod, "doctor", lambda: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli_mod,
|
||||||
|
"_check_for_update",
|
||||||
|
lambda info: (True, "update available"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sys, "platform", "linux")
|
||||||
|
|
||||||
|
with patch("muxplex.service.service_install", lambda: None):
|
||||||
|
cli_mod.upgrade()
|
||||||
|
|
||||||
|
pip_calls = [c for c in calls if isinstance(c, list) and c and "pip" in str(c[0])]
|
||||||
|
assert len(pip_calls) > 0, "upgrade() must call pip install when uv is absent"
|
||||||
|
uv_calls = [c for c in calls if isinstance(c, list) and c and "uv" in str(c[0])]
|
||||||
|
assert len(uv_calls) == 0, "upgrade() must not call uv when it is absent from PATH"
|
||||||
|
|||||||
Reference in New Issue
Block a user