diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e698c..e09e423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ ## [Unreleased] +### Added +- **`mm setup` / `mm uninstall` registram os agents do launchd** depois de + `brew` / `pipx` / `pip`. Homebrew instalava só o CLI; log diário e + alerta de bateria ficavam no `./install.sh`. `mm setup` grava os plists + em `~/Library/LaunchAgents/` apontando para o `mm` invocado (caminho + absoluto, sem seguir o symlink do Cellar) e manda stdout/stderr do + agent para Application Support. `mm uninstall` tira só os agents — + o CLI e o CSV ficam. `./install.sh` passa a chamar `mm setup`. + ## [0.1.2] — 2026-08-14 ### Added diff --git a/COMMANDS.md b/COMMANDS.md index f8f764e..b244dcf 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -19,6 +19,7 @@ mm --version # installed version | **Hardware/system** | [`mm`](#mm-status), [`mm battery`](#mm-battery), [`mm health`](#mm-health), [`mm disk`](#mm-disk), [`mm clean`](#mm-clean), [`mm net`](#mm-net) | | **Visualization** | [`mm watch`](#mm-watch), [`mm doctor`](#mm-doctor) | | **Battery — history** | [`mm log`](#mm-log), [`mm history`](#mm-history), [`mm alerts`](#mm-alerts) | +| **Setup** | [`mm setup`](#mm-setup), [`mm uninstall`](#mm-uninstall) | | **Security** | [`mm security`](#mm-security) | | **Development** | [`mm dev`](#mm-dev), [`mm dev --all`](#mm-dev---all), [`mm dev --check`](#mm-dev---check) | @@ -224,6 +225,36 @@ mm alerts --- +## Setup + +### `mm setup` +**What it does:** registers the two launchd agents so Homebrew / pipx / pip installs get the same background automation as `./install.sh`. + +**Writes** `~/Library/LaunchAgents/com.macmanager.battery-log.plist` and `com.macmanager.battery-alert.plist`, pointing at the `mm` you just ran (absolute path, without following Homebrew's Cellar symlink). Agent stdout/stderr go to `~/Library/Application Support/mac-manager/`. + +**When to use:** once after `brew install` or `pipx install`. Safe to re-run after an upgrade. + +```bash +mm setup +``` + +macOS only. Exits `1` on Linux or if the `mm` binary cannot be found. + +--- + +### `mm uninstall` +**What it does:** unloads and deletes those launchd agents. Does **not** uninstall the CLI, the venv, or the CSV history. + +**When to use:** you want to stop daily logs and notifications but keep `mm`. + +```bash +mm uninstall +``` + +To remove the CLI itself: `brew uninstall mac-manager` or `pipx uninstall mac-manager`. Source installs can still run `./uninstall.sh` (agents + symlink + venv). + +--- + ## Security ### `mm security` @@ -315,7 +346,7 @@ mm dev --check ## Commands invoked by launchd (background) -You normally **don't** run these manually — they stay active automatically after `install.sh`: +You normally **don't** run these manually — they stay active after `mm setup` (or `./install.sh`): | When it runs | Command | What it does | |---|---|---| @@ -327,10 +358,10 @@ To confirm they're active: launchctl list | grep macmanager ``` -To stop/restart manually: +To stop/restart: ```bash -launchctl unload ~/Library/LaunchAgents/com.macmanager.battery-log.plist -launchctl load ~/Library/LaunchAgents/com.macmanager.battery-log.plist +mm uninstall # stop +mm setup # start / refresh after an upgrade ``` --- @@ -361,6 +392,7 @@ mm history -n 30 ## Output and exit codes - All commands exit with `0` on success. +- `mm setup` exits `1` on non-macOS or if the `mm` binary cannot be found / launchctl rejects the plist. - `mm alerts` always exits with `0` (even when it fires a critical alert — it's just a notification). - Commands that fail to collect a metric (e.g. sandbox blocking) return `WARN`/`?` instead of erroring out. @@ -370,12 +402,14 @@ mm history -n 30 |---|---| | `~/Library/Application Support/mac-manager/battery.csv` | Append-only history of battery measurements | | `~/Library/Application Support/mac-manager/.alert_state` | Timestamps of the last notification of each type (JSON, controls cooldown) | -| `logs/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` (source install) | -| `logs/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` (source install) | +| `~/Library/Application Support/mac-manager/launchd-log.{out,err}` | stdout/stderr of the agent that runs `mm log` | +| `~/Library/Application Support/mac-manager/launchd-alert.{out,err}` | stdout/stderr of the agent that runs `mm alerts` | ## Reinstall / uninstall ```bash -./install.sh # creates venv, symlink ~/.local/bin/mm and loads launchd agents -./uninstall.sh # removes everything (preserves CSVs in Application Support) +mm setup # register (or refresh) launchd agents — Homebrew, pipx, source +mm uninstall # remove launchd agents only; keeps CLI and CSV +./install.sh # source install: venv, symlink ~/.local/bin/mm, then mm setup +./uninstall.sh # source install: mm uninstall + symlink + venv ``` diff --git a/README.md b/README.md index 1404c32..30bea3d 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,15 @@ pipx install mac-manager # isolated, recommended pip install --user mac-manager ``` -After installation, the `mm` command is on your `PATH`. +After installation, the `mm` command is on your `PATH`. Register the +background agents (daily battery log + alerts) with: -> `pipx`/`pip` installs give you the CLI only. The background -> `launchd` agents (daily log + battery alerts) are set up by the -> source install below, or you can register them manually later. +```bash +mm setup +``` + +`mm uninstall` removes those agents. It does **not** uninstall the CLI — +use `brew uninstall mac-manager` or `pipx uninstall mac-manager` for that. ### From source @@ -90,6 +94,12 @@ source ~/.zshrc | `mm history -n 20` | Last N recorded measurements | | `mm alerts` | Runs the alert check (called by launchd) | +### Setup +| Command | What it does | +|---|---| +| `mm setup` | Registers launchd agents (daily log at 09:00 + alerts every 15 min) | +| `mm uninstall` | Removes those agents; keeps the CLI and battery history | + ### Security | Command | What it does | |---|---| @@ -153,11 +163,19 @@ mac-manager/ ## Uninstallation ```bash -./uninstall.sh +mm uninstall # launchd agents only (Homebrew / pipx / source) +./uninstall.sh # source install: agents + symlink + venv +``` + +`mm uninstall` does not remove the CLI. For packaged installs: + +```bash +brew uninstall mac-manager +# or +pipx uninstall mac-manager ``` -Removes launchd agents, symlink and venv. Preserves battery history in -`~/Library/Application Support/mac-manager/`. +Battery history in `~/Library/Application Support/mac-manager/` is preserved. ## Status diff --git a/install.sh b/install.sh index 5319ae3..a4b7c1d 100755 --- a/install.sh +++ b/install.sh @@ -32,23 +32,16 @@ mkdir -p "$HOME/.local/bin" ln -sf "$SCRIPT_DIR/mm" "$HOME/.local/bin/mm" echo "==> symlink: $HOME/.local/bin/mm -> $SCRIPT_DIR/mm" -# 4. Ensure logs directory -mkdir -p "$SCRIPT_DIR/logs" - -# 5. Install launchd agents (daily log + alerts) -LAUNCH_DIR="$HOME/Library/LaunchAgents" -mkdir -p "$LAUNCH_DIR" - -for name in com.macmanager.battery-log com.macmanager.battery-alert; do - src="launchd/${name}.plist" - dest="$LAUNCH_DIR/${name}.plist" - sed "s|{{SCRIPT_DIR}}|$SCRIPT_DIR|g" "$src" > "$dest" - launchctl unload "$dest" 2>/dev/null || true - launchctl load "$dest" - echo "==> launchd: $name loaded" -done +# 4. Register launchd agents via the same path brew/pipx users get: +# `mm setup` writes plists that point at this wrapper and log into +# ~/Library/Application Support/mac-manager/. +if "$HOME/.local/bin/mm" setup; then + echo "==> launchd agents registered" +else + echo "[warning] could not register launchd agents. Run: mm setup" >&2 +fi -# 6. PATH check +# 5. PATH check case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) @@ -75,4 +68,5 @@ echo "Active automation:" echo " • Daily log at 09:00 → ~/Library/Application Support/mac-manager/battery.csv" echo " • Battery alerts every 15 min (native notifications)" echo "" -echo "To uninstall everything: ./uninstall.sh" +echo "To unregister agents: mm uninstall" +echo "To uninstall everything (source install): ./uninstall.sh" diff --git a/launchd/com.macmanager.battery-alert.plist b/launchd/com.macmanager.battery-alert.plist index 14de836..dc1a7db 100644 --- a/launchd/com.macmanager.battery-alert.plist +++ b/launchd/com.macmanager.battery-alert.plist @@ -1,6 +1,7 @@ + Label @@ -8,7 +9,7 @@ ProgramArguments - {{SCRIPT_DIR}}/mm + {{MM_BIN}} alerts @@ -20,8 +21,8 @@ StandardOutPath - {{SCRIPT_DIR}}/logs/launchd-alert.out + {{LOGS_DIR}}/launchd-alert.out StandardErrorPath - {{SCRIPT_DIR}}/logs/launchd-alert.err + {{LOGS_DIR}}/launchd-alert.err diff --git a/launchd/com.macmanager.battery-log.plist b/launchd/com.macmanager.battery-log.plist index bcefd1c..2a8bc28 100644 --- a/launchd/com.macmanager.battery-log.plist +++ b/launchd/com.macmanager.battery-log.plist @@ -1,6 +1,7 @@ + Label @@ -8,7 +9,7 @@ ProgramArguments - {{SCRIPT_DIR}}/mm + {{MM_BIN}} log @@ -25,8 +26,8 @@ StandardOutPath - {{SCRIPT_DIR}}/logs/launchd-log.out + {{LOGS_DIR}}/launchd-log.out StandardErrorPath - {{SCRIPT_DIR}}/logs/launchd-log.err + {{LOGS_DIR}}/launchd-log.err diff --git a/macmanager/agents.py b/macmanager/agents.py new file mode 100644 index 0000000..b597a4d --- /dev/null +++ b/macmanager/agents.py @@ -0,0 +1,309 @@ +"""Registro dos agents launchd (log diário + alertas de bateria). + +`brew`/`pipx` instalam só o CLI. `mm setup` grava os plists em +`~/Library/LaunchAgents/` apontando para o `mm` que o usuário acabou +de invocar — caminho absoluto, sem seguir symlink, para o Cellar do +Homebrew não quebrar no próximo upgrade. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from subprocess import CompletedProcess +from xml.sax.saxutils import escape + +from macmanager.logger import ensure_logs +from macmanager.ui import console, safe_text + +LABEL_LOG = "com.macmanager.battery-log" +LABEL_ALERT = "com.macmanager.battery-alert" + +LaunchctlRunner = Callable[[Sequence[str]], CompletedProcess[str]] + + +@dataclass(frozen=True) +class AgentSpec: + label: str + extra_args: tuple[str, ...] + stdout_name: str + stderr_name: str + start_interval: int | None = None + calendar_hour: int | None = None + calendar_minute: int | None = None + summary: str = "" + + +AGENTS: tuple[AgentSpec, ...] = ( + AgentSpec( + label=LABEL_LOG, + extra_args=("log",), + stdout_name="launchd-log.out", + stderr_name="launchd-log.err", + calendar_hour=9, + calendar_minute=0, + summary="daily at 09:00 → mm log", + ), + AgentSpec( + label=LABEL_ALERT, + extra_args=("alerts",), + stdout_name="launchd-alert.out", + stderr_name="launchd-alert.err", + start_interval=900, + summary="every 15 min → mm alerts", + ), +) + + +class MmBinaryNotFoundError(FileNotFoundError): + """Não achamos um executável `mm` para colocar no plist.""" + + +def launch_agents_dir(*, home: Path | None = None) -> Path: + return (home or Path.home()) / "Library" / "LaunchAgents" + + +def _absolute(path: Path) -> Path: + """Absoluto sem seguir symlink — `/opt/homebrew/bin/mm` precisa sobreviver ao upgrade.""" + return path.expanduser().absolute() + + +def resolve_mm_bin( + *, + argv0: str | None = None, + executable: str | None = None, + env: Mapping[str, str] | None = None, + which: Callable[[str], str | None] | None = None, +) -> Path: + """Caminho que o launchd deve exec. Prioridade: env, argv0, venv, PATH.""" + environ = os.environ if env is None else env + override = (environ.get("MACMANAGER_BIN") or "").strip() + if override: + path = _absolute(Path(override)) + if not path.is_file(): + raise MmBinaryNotFoundError(f"MACMANAGER_BIN is not a file: {path}") + return path + + raw = argv0 if argv0 is not None else sys.argv[0] + candidate = _absolute(Path(raw)) + if candidate.name in {"mm", "mm.exe"} and candidate.is_file(): + return candidate + + py = Path(executable if executable is not None else sys.executable) + sibling = _absolute(py.parent / "mm") + if sibling.is_file(): + return sibling + + finder = shutil.which if which is None else which + found = finder("mm") + if found: + return _absolute(Path(found)) + + raise MmBinaryNotFoundError( + "could not find the `mm` executable. Run `mm setup` via the installed " + "command (not `python -m`), or set MACMANAGER_BIN to its absolute path." + ) + + +def _xml_string(value: str) -> str: + return f"{escape(value)}" + + +def render_plist(*, spec: AgentSpec, mm_bin: Path, logs_dir: Path) -> str: + """Gera o XML do LaunchAgent. Paths entram escapados (SSID não, mas `&` em path sim).""" + args = "\n".join(f" {_xml_string(part)}" for part in (str(mm_bin), *spec.extra_args)) + if spec.start_interval is not None: + schedule = f" StartInterval\n {spec.start_interval}" + else: + hour = spec.calendar_hour if spec.calendar_hour is not None else 9 + minute = spec.calendar_minute if spec.calendar_minute is not None else 0 + schedule = ( + " StartCalendarInterval\n" + " \n" + " Hour\n" + f" {hour}\n" + " Minute\n" + f" {minute}\n" + " " + ) + stdout = _xml_string(str(logs_dir / spec.stdout_name)) + stderr = _xml_string(str(logs_dir / spec.stderr_name)) + return ( + '\n' + '\n' + '\n' + "\n" + " Label\n" + f" {_xml_string(spec.label)}\n" + "\n" + " ProgramArguments\n" + " \n" + f"{args}\n" + " \n" + "\n" + f"{schedule}\n" + "\n" + " RunAtLoad\n" + " \n" + "\n" + " StandardOutPath\n" + f" {stdout}\n" + " StandardErrorPath\n" + f" {stderr}\n" + "\n" + "\n" + ) + + +def _default_launchctl(args: Sequence[str]) -> CompletedProcess[str]: + return subprocess.run(list(args), capture_output=True, text=True, check=False) + + +def _gui_target(uid: int, label: str) -> str: + return f"gui/{uid}/{label}" + + +def unload_agent( + spec: AgentSpec, + plist: Path, + *, + uid: int, + runner: LaunchctlRunner, +) -> None: + """Tira o agent do launchd. bootout primeiro; unload se o macOS for antigo.""" + bootout = runner(["launchctl", "bootout", _gui_target(uid, spec.label)]) + if bootout.returncode != 0 and plist.is_file(): + runner(["launchctl", "unload", str(plist)]) + + +def load_agent( + spec: AgentSpec, + plist: Path, + *, + uid: int, + runner: LaunchctlRunner, +) -> CompletedProcess[str]: + loaded = runner(["launchctl", "bootstrap", f"gui/{uid}", str(plist)]) + if loaded.returncode == 0: + return loaded + return runner(["launchctl", "load", str(plist)]) + + +def install_agents( + *, + mm_bin: Path, + agents_dir: Path, + logs_dir: Path, + uid: int, + runner: LaunchctlRunner, + darwin: bool, +) -> list[tuple[AgentSpec, Path, bool]]: + """Escreve os plists e, no Mac, (re)carrega no launchd. + + Retorna (spec, path, loaded). `loaded` é True se o launchctl aceitou + ou se pulamos o launchctl (`darwin=False`, nos testes). + """ + agents_dir.mkdir(parents=True, exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) + results: list[tuple[AgentSpec, Path, bool]] = [] + for spec in AGENTS: + dest = agents_dir / f"{spec.label}.plist" + dest.write_text(render_plist(spec=spec, mm_bin=mm_bin, logs_dir=logs_dir), encoding="utf-8") + loaded = True + if darwin: + unload_agent(spec, dest, uid=uid, runner=runner) + loaded = load_agent(spec, dest, uid=uid, runner=runner).returncode == 0 + results.append((spec, dest, loaded)) + return results + + +def remove_agents( + *, + agents_dir: Path, + uid: int, + runner: LaunchctlRunner, + darwin: bool, +) -> list[str]: + """Descarrega e apaga os plists. Devolve os labels que existiam no disco.""" + removed: list[str] = [] + for spec in AGENTS: + dest = agents_dir / f"{spec.label}.plist" + if not dest.is_file(): + continue + if darwin: + unload_agent(spec, dest, uid=uid, runner=runner) + dest.unlink() + removed.append(spec.label) + return removed + + +def cmd_setup(args=None) -> int: + """Registra os agents. Só faz sentido no macOS.""" + system = platform.system() + if system != "Darwin": + console.print("[red]mm setup[/] is only available on macOS (launchd).") + return 1 + + try: + mm_bin = resolve_mm_bin() + except MmBinaryNotFoundError as exc: + console.print(f"[red]{safe_text(exc)}[/]") + return 1 + + logs_dir = ensure_logs() + results = install_agents( + mm_bin=mm_bin, + agents_dir=launch_agents_dir(), + logs_dir=logs_dir, + uid=os.getuid(), + runner=_default_launchctl, + darwin=True, + ) + + console.print("[bold]Registered launchd agents[/]") + failed = False + for spec, dest, loaded in results: + mark = "[green]ok[/]" if loaded else "[red]launchctl failed[/]" + if not loaded: + failed = True + console.print(f" {mark} {spec.label} ({spec.summary})") + console.print(f" {safe_text(dest)}") + console.print() + console.print(f"Binary: {safe_text(mm_bin)}") + console.print(f"Logs: {safe_text(logs_dir)}") + console.print() + console.print("Daily log at 09:00 and battery alerts every 15 min.") + console.print("To stop them later: [bold]mm uninstall[/]") + return 1 if failed else 0 + + +def cmd_uninstall(args=None) -> int: + """Remove só os agents. brew/pipx/venv ficam — isso não é `brew uninstall`.""" + darwin = platform.system() == "Darwin" + removed = remove_agents( + agents_dir=launch_agents_dir(), + uid=os.getuid(), + runner=_default_launchctl, + darwin=darwin, + ) + if not removed: + console.print("No Mac Manager launchd agents were installed.") + return 0 + + console.print("[bold]Removed launchd agents[/]") + for label in removed: + console.print(f" • {label}") + console.print() + console.print("Battery history in ~/Library/Application Support/mac-manager/ was preserved.") + console.print() + console.print("To remove the CLI itself:") + console.print(" brew uninstall mac-manager") + console.print(" # or: pipx uninstall mac-manager") + return 0 diff --git a/macmanager/cli.py b/macmanager/cli.py index 7f8dd17..2d93569 100644 --- a/macmanager/cli.py +++ b/macmanager/cli.py @@ -49,6 +49,14 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("alerts", help="Evaluates and fires alerts (called by launchd)") sub.add_parser("status", help="Quick summary (battery + system + disk)") sub.add_parser("security", help="Security audit (FileVault, SIP, Firewall, ...)") + sub.add_parser( + "setup", + help="Register launchd agents (daily log + battery alerts)", + ) + sub.add_parser( + "uninstall", + help="Remove launchd agents (does not uninstall the CLI)", + ) dev = sub.add_parser("dev", help="Inventory of development tools") dev.add_argument( @@ -101,6 +109,8 @@ def main(argv: list[str] | None = None) -> int: "alerts": "macmanager.alerts:cmd_alerts", "security": "macmanager.security:cmd_security", "dev": "macmanager.dev:cmd_dev", + "setup": "macmanager.agents:cmd_setup", + "uninstall": "macmanager.agents:cmd_uninstall", "status": None, } @@ -111,8 +121,8 @@ def main(argv: list[str] | None = None) -> int: module_path, fn_name = handler.split(":") module = __import__(module_path, fromlist=[fn_name]) - getattr(module, fn_name)(args) - return 0 + result = getattr(module, fn_name)(args) + return int(result) if result is not None else 0 if __name__ == "__main__": diff --git a/mm b/mm index b79b0a1..24fb722 100755 --- a/mm +++ b/mm @@ -20,6 +20,10 @@ if [ ! -x "$VENV_PY" ]; then exit 1 fi +# Absolute wrapper path for `mm setup` — launchd must exec this file, +# not `python -m` (argv0 is cli.py when we use -m). +export MACMANAGER_BIN="$SCRIPT_DIR/mm" + # Run from the project root to ensure the package is discovered, # regardless of the caller's cwd (terminal or launchd). cd "$SCRIPT_DIR" diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..b0b3b69 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,310 @@ +"""Contract tests for `macmanager.agents`. + +Não chamamos launchctl de verdade — o CI roda em Linux. O contrato é: +plist válido, path do `mm` sem seguir symlink, setup/uninstall idempotentes. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from io import StringIO +from pathlib import Path +from subprocess import CompletedProcess + +import pytest +from rich.console import Console + +from macmanager.agents import ( + AGENTS, + LABEL_ALERT, + LABEL_LOG, + MmBinaryNotFoundError, + cmd_setup, + cmd_uninstall, + install_agents, + launch_agents_dir, + remove_agents, + render_plist, + resolve_mm_bin, +) +from macmanager.cli import build_parser + + +def _touch_mm(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\necho mm\n", encoding="utf-8") + path.chmod(0o755) + return path + + +def _ok(args: Sequence[str]) -> CompletedProcess[str]: + return CompletedProcess(list(args), 0, "", "") + + +def _fail(args: Sequence[str]) -> CompletedProcess[str]: + return CompletedProcess(list(args), 1, "", "failed") + + +class TestLaunchAgentsDir: + def test_under_library(self, tmp_path: Path) -> None: + assert launch_agents_dir(home=tmp_path) == tmp_path / "Library" / "LaunchAgents" + + +class TestResolveMmBin: + def test_env_override_wins(self, tmp_path: Path) -> None: + mm = _touch_mm(tmp_path / "custom" / "mm") + resolved = resolve_mm_bin( + argv0=str(tmp_path / "other" / "mm"), + env={"MACMANAGER_BIN": str(mm)}, + which=lambda _name: None, + ) + assert resolved == mm + + def test_env_override_must_exist(self, tmp_path: Path) -> None: + with pytest.raises(MmBinaryNotFoundError, match="MACMANAGER_BIN"): + resolve_mm_bin( + env={"MACMANAGER_BIN": str(tmp_path / "missing")}, + which=lambda _name: None, + ) + + def test_argv0_named_mm(self, tmp_path: Path) -> None: + mm = _touch_mm(tmp_path / "bin" / "mm") + resolved = resolve_mm_bin(argv0=str(mm), env={}, which=lambda _name: None) + assert resolved == mm + + def test_does_not_follow_homebrew_symlink(self, tmp_path: Path) -> None: + cellar = _touch_mm(tmp_path / "Cellar" / "mac-manager" / "0.1.2" / "libexec" / "bin" / "mm") + shim = tmp_path / "opt" / "homebrew" / "bin" / "mm" + shim.parent.mkdir(parents=True) + shim.symlink_to(cellar) + resolved = resolve_mm_bin(argv0=str(shim), env={}, which=lambda _name: None) + assert resolved == shim + assert resolved.resolve() == cellar + + def test_venv_sibling_of_python(self, tmp_path: Path) -> None: + python = _touch_mm(tmp_path / "venv" / "bin" / "python") + mm = _touch_mm(tmp_path / "venv" / "bin" / "mm") + resolved = resolve_mm_bin( + argv0=str(tmp_path / "macmanager" / "cli.py"), + executable=str(python), + env={}, + which=lambda _name: None, + ) + assert resolved == mm + + def test_which_fallback(self, tmp_path: Path) -> None: + mm = _touch_mm(tmp_path / "path" / "mm") + resolved = resolve_mm_bin( + argv0=str(tmp_path / "cli.py"), + executable=str(tmp_path / "python3"), + env={}, + which=lambda name: str(mm) if name == "mm" else None, + ) + assert resolved == mm + + def test_raises_when_nothing_found(self, tmp_path: Path) -> None: + with pytest.raises(MmBinaryNotFoundError, match="could not find"): + resolve_mm_bin( + argv0=str(tmp_path / "cli.py"), + executable=str(tmp_path / "python3"), + env={}, + which=lambda _name: None, + ) + + +class TestRenderPlist: + def test_log_agent_uses_calendar_interval(self, tmp_path: Path) -> None: + spec = AGENTS[0] + xml = render_plist(spec=spec, mm_bin=tmp_path / "mm", logs_dir=tmp_path / "logs") + assert spec.label in xml + assert "log" in xml + assert "StartCalendarInterval" in xml + assert "9" in xml + assert "0" in xml + assert "StartInterval" not in xml + assert str(tmp_path / "logs" / "launchd-log.out") in xml + + def test_alert_agent_uses_start_interval(self, tmp_path: Path) -> None: + spec = AGENTS[1] + xml = render_plist(spec=spec, mm_bin=tmp_path / "mm", logs_dir=tmp_path / "logs") + assert spec.label in xml + assert "alerts" in xml + assert "StartInterval" in xml + assert "900" in xml + assert "StartCalendarInterval" not in xml + + def test_escapes_xml_in_paths(self, tmp_path: Path) -> None: + mm = tmp_path / "a&b" / "mm" + logs = tmp_path / "logs" + xml = render_plist(spec=AGENTS[0], mm_bin=mm, logs_dir=logs) + assert "a&b" in xml + assert "logs<x>" in xml + assert "a&b" not in xml + + +class TestInstallAndRemove: + def test_writes_both_plists_without_launchctl(self, tmp_path: Path) -> None: + calls: list[list[str]] = [] + + def runner(args: Sequence[str]) -> CompletedProcess[str]: + calls.append(list(args)) + return _ok(args) + + mm = tmp_path / "mm" + agents_dir = tmp_path / "LaunchAgents" + logs_dir = tmp_path / "logs" + results = install_agents( + mm_bin=mm, + agents_dir=agents_dir, + logs_dir=logs_dir, + uid=501, + runner=runner, + darwin=False, + ) + assert calls == [] + assert {spec.label for spec, _path, loaded in results} == {LABEL_LOG, LABEL_ALERT} + assert all(loaded for _spec, _path, loaded in results) + log_plist = (agents_dir / f"{LABEL_LOG}.plist").read_text(encoding="utf-8") + assert str(mm) in log_plist + assert logs_dir.is_dir() + + def test_reloads_via_bootout_then_bootstrap(self, tmp_path: Path) -> None: + calls: list[list[str]] = [] + + def runner(args: Sequence[str]) -> CompletedProcess[str]: + calls.append(list(args)) + return _ok(args) + + install_agents( + mm_bin=tmp_path / "mm", + agents_dir=tmp_path / "LaunchAgents", + logs_dir=tmp_path / "logs", + uid=501, + runner=runner, + darwin=True, + ) + labels = [c[2] for c in calls if c[:2] == ["launchctl", "bootout"]] + assert f"gui/501/{LABEL_LOG}" in labels + assert f"gui/501/{LABEL_ALERT}" in labels + bootstraps = [c for c in calls if c[:2] == ["launchctl", "bootstrap"]] + assert len(bootstraps) == 2 + assert all(c[2] == "gui/501" for c in bootstraps) + + def test_falls_back_to_load_when_bootstrap_fails(self, tmp_path: Path) -> None: + def runner(args: Sequence[str]) -> CompletedProcess[str]: + if "bootstrap" in args or "bootout" in args: + return _fail(args) + return _ok(args) + + results = install_agents( + mm_bin=tmp_path / "mm", + agents_dir=tmp_path / "LaunchAgents", + logs_dir=tmp_path / "logs", + uid=501, + runner=runner, + darwin=True, + ) + assert all(loaded for _spec, _path, loaded in results) + + def test_remove_deletes_plists(self, tmp_path: Path) -> None: + agents_dir = tmp_path / "LaunchAgents" + install_agents( + mm_bin=tmp_path / "mm", + agents_dir=agents_dir, + logs_dir=tmp_path / "logs", + uid=501, + runner=_ok, + darwin=False, + ) + removed = remove_agents(agents_dir=agents_dir, uid=501, runner=_ok, darwin=False) + assert removed == [LABEL_LOG, LABEL_ALERT] + assert list(agents_dir.glob("*.plist")) == [] + + def test_remove_is_idempotent_when_missing(self, tmp_path: Path) -> None: + removed = remove_agents( + agents_dir=tmp_path / "LaunchAgents", + uid=501, + runner=_ok, + darwin=False, + ) + assert removed == [] + + +class TestCmdSetup: + def test_refuses_non_darwin(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Linux") + buf = StringIO() + monkeypatch.setattr( + "macmanager.agents.console", + Console(file=buf, force_terminal=True, width=80), + ) + assert cmd_setup() == 1 + assert "only available on macOS" in buf.getvalue() + + def test_registers_on_darwin(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + mm = _touch_mm(tmp_path / "mm") + agents_dir = tmp_path / "LaunchAgents" + logs_dir = tmp_path / "logs" + monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Darwin") + monkeypatch.setattr("macmanager.agents.resolve_mm_bin", lambda: mm) + monkeypatch.setattr("macmanager.agents.ensure_logs", lambda: logs_dir) + monkeypatch.setattr("macmanager.agents.launch_agents_dir", lambda: agents_dir) + monkeypatch.setattr("macmanager.agents.os.getuid", lambda: 501) + monkeypatch.setattr("macmanager.agents._default_launchctl", _ok) + buf = StringIO() + monkeypatch.setattr( + "macmanager.agents.console", + Console(file=buf, force_terminal=True, width=80), + ) + assert cmd_setup() == 0 + out = buf.getvalue() + assert LABEL_LOG in out + assert LABEL_ALERT in out + assert (agents_dir / f"{LABEL_LOG}.plist").is_file() + + +class TestCmdUninstall: + def test_reports_when_nothing_installed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Linux") + monkeypatch.setattr( + "macmanager.agents.launch_agents_dir", lambda: tmp_path / "LaunchAgents" + ) + buf = StringIO() + monkeypatch.setattr( + "macmanager.agents.console", + Console(file=buf, force_terminal=True, width=80), + ) + assert cmd_uninstall() == 0 + assert "No Mac Manager launchd agents" in buf.getvalue() + + def test_removes_agents(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + agents_dir = tmp_path / "LaunchAgents" + install_agents( + mm_bin=tmp_path / "mm", + agents_dir=agents_dir, + logs_dir=tmp_path / "logs", + uid=501, + runner=_ok, + darwin=False, + ) + monkeypatch.setattr("macmanager.agents.platform.system", lambda: "Darwin") + monkeypatch.setattr("macmanager.agents.launch_agents_dir", lambda: agents_dir) + monkeypatch.setattr("macmanager.agents.os.getuid", lambda: 501) + monkeypatch.setattr("macmanager.agents._default_launchctl", _ok) + buf = StringIO() + monkeypatch.setattr( + "macmanager.agents.console", + Console(file=buf, force_terminal=True, width=80), + ) + assert cmd_uninstall() == 0 + assert "Removed launchd agents" in buf.getvalue() + assert list(agents_dir.glob("*.plist")) == [] + + +class TestParser: + def test_setup_and_uninstall_are_advertised(self) -> None: + help_text = build_parser().format_help() + assert "setup" in help_text + assert "uninstall" in help_text diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1633e68..e0303b5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -21,6 +21,7 @@ PUBLIC_MODULES = [ "macmanager", + "macmanager.agents", "macmanager.alerts", "macmanager.battery", "macmanager.cache", @@ -75,6 +76,8 @@ def test_cli_parser_builds_and_advertises_all_commands() -> None: "status", "security", "dev", + "setup", + "uninstall", ): assert command in help_text, f"CLI help is missing the `{command}` subcommand" diff --git a/uninstall.sh b/uninstall.sh index e288bc9..3ba1203 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Removes launchd agents, symlink and venv. Does not delete the CSV logs. +# Removes launchd agents (via `mm uninstall` when available), symlink and +# venv. Does not delete the CSV logs. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -7,6 +8,11 @@ LAUNCH_DIR="$HOME/Library/LaunchAgents" echo "==> Mac Manager · uninstall" +if [ -x "$HOME/.local/bin/mm" ]; then + "$HOME/.local/bin/mm" uninstall || true +fi + +# Fallback for installs that predate `mm uninstall`. for name in com.macmanager.battery-log com.macmanager.battery-alert; do plist="$LAUNCH_DIR/${name}.plist" if [ -f "$plist" ]; then