diff --git a/muxplex/cli.py b/muxplex/cli.py
index 1d42842..8d49712 100644
--- a/muxplex/cli.py
+++ b/muxplex/cli.py
@@ -287,7 +287,18 @@ def doctor() -> None:
if sys.platform == "darwin":
plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
if plist.exists():
- print(f" {ok_mark} Service: launchd agent installed ({plist})")
+ uid = os.getuid()
+ result = subprocess.run(
+ ["launchctl", "print", f"gui/{uid}/com.muxplex"],
+ capture_output=True,
+ text=True,
+ )
+ 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:
print(
f" {warn_mark} Service: not installed (run: muxplex install-service)"
@@ -336,21 +347,25 @@ def _install_launchd(executable: str) -> None:
import shutil
# Prefer the entry point script ('muxplex') so macOS shows the correct
- # process name in Activity Monitor, launchctl list, and login items.
- # Fall back to 'python -m muxplex' if the entry point isn't on PATH.
+ # process name in Activity Monitor and launchctl list.
muxplex_bin = shutil.which("muxplex")
if muxplex_bin:
program_args = f"""
{muxplex_bin}
+ --host
+ 0.0.0.0
"""
else:
program_args = f"""
{executable}
-m
muxplex
+ --host
+ 0.0.0.0
"""
label = "com.muxplex"
+ uid = os.getuid()
plist = f"""
@@ -372,12 +387,44 @@ def _install_launchd(executable: str) -> None:
"""
path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Unload existing service first (ignore errors if not loaded)
+ subprocess.run(
+ ["launchctl", "bootout", f"gui/{uid}/{label}"],
+ capture_output=True,
+ )
+
path.write_text(plist)
print(f"Launch agent written to {path}")
- print("Enable with:")
- print(f" launchctl load {path}")
- print("Disable with:")
- print(f" launchctl unload {path}")
+ print()
+
+ # Load the service using the modern bootstrap API
+ result = subprocess.run(
+ ["launchctl", "bootstrap", f"gui/{uid}", str(path)],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ print(f" Service started (launchctl bootstrap gui/{uid})")
+ else:
+ # Fallback to legacy load for older macOS
+ result2 = subprocess.run(
+ ["launchctl", "load", str(path)],
+ capture_output=True,
+ text=True,
+ )
+ if result2.returncode == 0:
+ print(" Service started (launchctl load)")
+ else:
+ print(" Could not auto-start. Try manually:")
+ print(f" launchctl bootstrap gui/{uid} {path}")
+
+ print()
+ print("Management commands:")
+ print(f" Stop: launchctl bootout gui/{uid}/{label}")
+ print(f" Start: launchctl bootstrap gui/{uid} {path}")
+ print(f" Status: launchctl print gui/{uid}/{label}")
+ print(" Logs: tail -f /tmp/muxplex.log")
def _install_systemd(executable: str, *, system: bool = False) -> None:
@@ -453,10 +500,14 @@ def upgrade(*, force: bool = False) -> None:
# 1. Detect platform and stop service
if sys.platform == "darwin":
- plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
+ label = "com.muxplex"
+ uid = os.getuid()
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
if plist.exists():
print(" Stopping launchd service...")
- subprocess.run(["launchctl", "unload", str(plist)], capture_output=True)
+ subprocess.run(
+ ["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True
+ )
else:
print(" No launchd service found (skipping stop)")
else:
@@ -521,11 +572,22 @@ def upgrade(*, force: bool = False) -> None:
# 4. Restart service
if sys.platform == "darwin":
- plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist"
+ label = "com.muxplex"
+ uid = os.getuid()
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
if plist.exists():
print(" Starting launchd service...")
- subprocess.run(["launchctl", "load", str(plist)], capture_output=True)
- print(" Service started")
+ 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 install-service")
else:
diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py
index 3453fe6..3131fde 100644
--- a/muxplex/tests/test_cli.py
+++ b/muxplex/tests/test_cli.py
@@ -263,12 +263,18 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
"""install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/."""
+ import subprocess
from muxplex.cli import install_service
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("sys.platform", "darwin")
+ monkeypatch.setattr(
+ subprocess,
+ "run",
+ lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
+ )
install_service(system=False)
@@ -283,12 +289,18 @@ def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch):
def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
"""install_service() on macOS must NOT write a systemd unit file."""
+ import subprocess
from muxplex.cli import install_service
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
monkeypatch.setattr("sys.platform", "darwin")
+ monkeypatch.setattr(
+ subprocess,
+ "run",
+ lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
+ )
install_service(system=False)
@@ -296,6 +308,64 @@ def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch):
assert not systemd_path.exists(), "No systemd unit file should be written on macOS"
+def test_install_service_uses_modern_launchctl_on_macos(tmp_path, monkeypatch, capsys):
+ """install-service on macOS must use launchctl bootstrap (not load)."""
+ import subprocess
+ from muxplex.cli import install_service
+
+ fake_home = tmp_path / "home"
+ fake_home.mkdir()
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
+ monkeypatch.setattr("sys.platform", "darwin")
+ monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}")
+
+ calls = []
+
+ def mock_run(cmd, **kwargs):
+ calls.append(cmd)
+ return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
+
+ monkeypatch.setattr(subprocess, "run", mock_run)
+
+ install_service(system=False)
+
+ launchctl_cmds = [c for c in calls if isinstance(c, list) and "launchctl" in str(c)]
+ assert any(
+ "bootout" in str(c) for c in launchctl_cmds
+ ), "must bootout old service before loading"
+ assert any(
+ "bootstrap" in str(c) for c in launchctl_cmds
+ ), "must use launchctl bootstrap (modern API)"
+ # Must NOT use deprecated load
+ assert not any(
+ c == ["launchctl", "load"] or (isinstance(c, list) and c[:2] == ["launchctl", "load"])
+ for c in launchctl_cmds
+ ), "must NOT use deprecated launchctl load"
+
+
+def test_install_service_plist_includes_host_flag(tmp_path, monkeypatch):
+ """The generated plist must include --host 0.0.0.0 for network access."""
+ import subprocess
+ from muxplex.cli import install_service
+
+ fake_home = tmp_path / "home"
+ fake_home.mkdir()
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
+ monkeypatch.setattr("sys.platform", "darwin")
+ monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}")
+ monkeypatch.setattr(
+ subprocess,
+ "run",
+ lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
+ )
+
+ install_service(system=False)
+
+ plist_path = fake_home / "Library" / "LaunchAgents" / "com.muxplex.plist"
+ content = plist_path.read_text()
+ assert "0.0.0.0" in content, "plist must bind to 0.0.0.0 for network access"
+
+
def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch):
"""install_service() on Linux writes a systemd unit to ~/.config/systemd/user/."""
from muxplex.cli import install_service