feat: implement launchd service commands (install, uninstall, start, stop, restart, status, logs)
This commit is contained in:
+57
-8
@@ -33,6 +33,35 @@ Environment=PATH={safe_path}
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_LAUNCHD_PLIST_TEMPLATE = """\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.muxplex</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{muxplex_bin}</string>
|
||||||
|
<string>serve</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>{safe_path}</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/muxplex.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/muxplex.err</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Platform detection
|
# Platform detection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -122,36 +151,56 @@ def _systemd_logs() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Private stubs — launchd (macOS)
|
# Private implementations — launchd (macOS)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _launchd_install() -> None:
|
def _launchd_install() -> None:
|
||||||
raise NotImplementedError("launchd install not implemented")
|
muxplex_bin = _resolve_muxplex_bin()
|
||||||
|
base_path = os.environ.get("PATH", "/usr/bin:/bin")
|
||||||
|
safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}"
|
||||||
|
plist_content = _LAUNCHD_PLIST_TEMPLATE.format(
|
||||||
|
muxplex_bin=muxplex_bin, safe_path=safe_path
|
||||||
|
)
|
||||||
|
_LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
_LAUNCHD_PLIST_PATH.write_text(plist_content)
|
||||||
|
uid = os.getuid()
|
||||||
|
subprocess.run(
|
||||||
|
["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True
|
||||||
|
)
|
||||||
|
_prompt_host_if_localhost()
|
||||||
|
|
||||||
|
|
||||||
def _launchd_uninstall() -> None:
|
def _launchd_uninstall() -> None:
|
||||||
raise NotImplementedError("launchd uninstall not implemented")
|
uid = os.getuid()
|
||||||
|
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=True)
|
||||||
|
_LAUNCHD_PLIST_PATH.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _launchd_start() -> None:
|
def _launchd_start() -> None:
|
||||||
raise NotImplementedError("launchd start not implemented")
|
uid = os.getuid()
|
||||||
|
subprocess.run(
|
||||||
|
["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _launchd_stop() -> None:
|
def _launchd_stop() -> None:
|
||||||
raise NotImplementedError("launchd stop not implemented")
|
uid = os.getuid()
|
||||||
|
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=True)
|
||||||
|
|
||||||
|
|
||||||
def _launchd_restart() -> None:
|
def _launchd_restart() -> None:
|
||||||
raise NotImplementedError("launchd restart not implemented")
|
_launchd_stop()
|
||||||
|
_launchd_start()
|
||||||
|
|
||||||
|
|
||||||
def _launchd_status() -> None:
|
def _launchd_status() -> None:
|
||||||
raise NotImplementedError("launchd status not implemented")
|
uid = os.getuid()
|
||||||
|
subprocess.run(["launchctl", "print", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=True)
|
||||||
|
|
||||||
|
|
||||||
def _launchd_logs() -> None:
|
def _launchd_logs() -> None:
|
||||||
raise NotImplementedError("launchd logs not implemented")
|
subprocess.run(["tail", "-f", "/tmp/muxplex.log"], check=True)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -148,3 +148,120 @@ def test_systemd_logs_calls_journalctl(monkeypatch):
|
|||||||
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)))
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)))
|
||||||
svc._systemd_logs()
|
svc._systemd_logs()
|
||||||
assert ["journalctl", "--user", "-u", "muxplex", "-f"] in calls
|
assert ["journalctl", "--user", "-u", "muxplex", "-f"] in calls
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# launchd tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_launchd_install_writes_plist_and_bootstraps(monkeypatch, tmp_path):
|
||||||
|
"""_launchd_install writes plist with 'com.muxplex' and 'serve' (no --host/--port)
|
||||||
|
and calls launchctl bootstrap with gui/{uid}."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import muxplex.service as svc
|
||||||
|
|
||||||
|
plist_dir = tmp_path / "LaunchAgents"
|
||||||
|
plist_path = plist_dir / "com.muxplex.plist"
|
||||||
|
|
||||||
|
monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir)
|
||||||
|
monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path)
|
||||||
|
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)))
|
||||||
|
|
||||||
|
# Suppress interactive prompt
|
||||||
|
monkeypatch.setattr(svc, "_prompt_host_if_localhost", lambda: None)
|
||||||
|
|
||||||
|
svc._launchd_install()
|
||||||
|
|
||||||
|
# Plist file must exist and contain expected content
|
||||||
|
assert plist_path.exists(), "plist file was not written"
|
||||||
|
content = plist_path.read_text()
|
||||||
|
assert "com.muxplex" in content, "plist must contain 'com.muxplex'"
|
||||||
|
assert "serve" in content, "plist ProgramArguments must include 'serve'"
|
||||||
|
assert "--host" not in content, "plist must NOT contain --host"
|
||||||
|
assert "--port" not in content, "plist must NOT contain --port"
|
||||||
|
|
||||||
|
# bootstrap must be called with gui/501
|
||||||
|
bootstrap_calls = [c for c in calls if "bootstrap" in c]
|
||||||
|
assert bootstrap_calls, "launchctl bootstrap not called"
|
||||||
|
bootstrap_cmd = bootstrap_calls[0]
|
||||||
|
assert "gui/501" in bootstrap_cmd, (
|
||||||
|
f"bootstrap must use gui/501, got: {bootstrap_cmd}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launchd_uninstall_bootouts_and_removes(monkeypatch, tmp_path):
|
||||||
|
"""_launchd_uninstall calls launchctl bootout and removes the plist file."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import muxplex.service as svc
|
||||||
|
|
||||||
|
plist_dir = tmp_path / "LaunchAgents"
|
||||||
|
plist_dir.mkdir(parents=True)
|
||||||
|
plist_path = plist_dir / "com.muxplex.plist"
|
||||||
|
plist_path.write_text("<plist/>")
|
||||||
|
|
||||||
|
monkeypatch.setattr(svc, "_LAUNCHD_PLIST_DIR", plist_dir)
|
||||||
|
monkeypatch.setattr(svc, "_LAUNCHD_PLIST_PATH", plist_path)
|
||||||
|
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)))
|
||||||
|
|
||||||
|
svc._launchd_uninstall()
|
||||||
|
|
||||||
|
# bootout must be called
|
||||||
|
bootout_calls = [c for c in calls if "bootout" in c]
|
||||||
|
assert bootout_calls, "launchctl bootout not called"
|
||||||
|
bootout_cmd = bootout_calls[0]
|
||||||
|
assert "gui/501" in " ".join(bootout_cmd), (
|
||||||
|
f"bootout must reference gui/501, got: {bootout_cmd}"
|
||||||
|
)
|
||||||
|
assert "com.muxplex" in " ".join(bootout_cmd), (
|
||||||
|
f"bootout must reference com.muxplex, got: {bootout_cmd}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# plist must be removed
|
||||||
|
assert not plist_path.exists(), "plist file was not deleted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_launchd_stop_calls_bootout(monkeypatch):
|
||||||
|
"""_launchd_stop runs launchctl bootout gui/{uid}/com.muxplex."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import muxplex.service as svc
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "getuid", lambda: 501)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)))
|
||||||
|
|
||||||
|
svc._launchd_stop()
|
||||||
|
|
||||||
|
bootout_calls = [c for c in calls if "bootout" in c]
|
||||||
|
assert bootout_calls, "launchctl bootout not called"
|
||||||
|
bootout_cmd = bootout_calls[0]
|
||||||
|
assert "gui/501" in " ".join(bootout_cmd), (
|
||||||
|
f"bootout must reference gui/501, got: {bootout_cmd}"
|
||||||
|
)
|
||||||
|
assert "com.muxplex" in " ".join(bootout_cmd), (
|
||||||
|
f"bootout must reference com.muxplex, got: {bootout_cmd}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_launchd_logs_tails_log_file(monkeypatch):
|
||||||
|
"""_launchd_logs runs exactly ['tail', '-f', '/tmp/muxplex.log']."""
|
||||||
|
import muxplex.service as svc
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)))
|
||||||
|
|
||||||
|
svc._launchd_logs()
|
||||||
|
|
||||||
|
assert ["tail", "-f", "/tmp/muxplex.log"] in calls, (
|
||||||
|
f"Expected ['tail', '-f', '/tmp/muxplex.log'], got: {calls}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user