"""Install and manage OS-level gateway services.""" from __future__ import annotations import os import plistlib import re import subprocess import sys from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any, Literal from nanobot.gateway import GatewayStartOptions, build_gateway_command ServiceManagerKind = Literal["auto", "systemd", "launchd"] @dataclass(frozen=True) class GatewayServiceOptions: """Inputs used to render one system service.""" start: GatewayStartOptions name: str = "nanobot-gateway" manager: ServiceManagerKind = "auto" enable: bool = True start_now: bool = True python_executable: str = sys.executable @dataclass(frozen=True) class GatewayServiceResult: """Result from service install/uninstall operations.""" ok: bool message: str manager: str path: Path | None commands: tuple[tuple[str, ...], ...] = () content: str | None = None class GatewayServiceInstaller: """Render and install systemd user services or macOS LaunchAgents.""" def __init__( self, *, platform_name: str | None = None, subprocess_run: Callable[..., Any] = subprocess.run, home: Path | None = None, ) -> None: self.platform_name = platform_name or _platform_name() self._subprocess_run = subprocess_run self.home = home or Path.home() def install(self, options: GatewayServiceOptions, *, dry_run: bool = False) -> GatewayServiceResult: manager = self._resolve_manager(options.manager) if manager == "systemd": return self._install_systemd(options, dry_run=dry_run) if manager == "launchd": return self._install_launchd(options, dry_run=dry_run) return GatewayServiceResult(False, f"unsupported_service_manager:{manager}", manager, None) def uninstall( self, *, name: str = "nanobot-gateway", manager: ServiceManagerKind = "auto", dry_run: bool = False, ) -> GatewayServiceResult: resolved = self._resolve_manager(manager) if resolved == "systemd": return self._uninstall_systemd(name=name, dry_run=dry_run) if resolved == "launchd": return self._uninstall_launchd(name=name, dry_run=dry_run) return GatewayServiceResult(False, f"unsupported_service_manager:{resolved}", resolved, None) def _install_systemd( self, options: GatewayServiceOptions, *, dry_run: bool, ) -> GatewayServiceResult: unit_name = _systemd_unit_name(options.name) path = self.home / ".config" / "systemd" / "user" / unit_name command = build_gateway_command(options.python_executable, options.start) content = _systemd_unit_content( description=f"Nanobot Gateway ({options.name})", command=command, working_directory=_working_directory_text(options.start), ) commands: list[tuple[str, ...]] = [("systemctl", "--user", "daemon-reload")] if options.enable: commands.append(("systemctl", "--user", "enable", unit_name)) if options.start_now: commands.append(("systemctl", "--user", "restart", unit_name)) if dry_run: return GatewayServiceResult(True, "service_install_dry_run", "systemd", path, tuple(commands), content) _working_directory(options.start).mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") for command_args in commands: self._subprocess_run(list(command_args), check=True) return GatewayServiceResult(True, "service_installed", "systemd", path, tuple(commands), content) def _uninstall_systemd( self, *, name: str, dry_run: bool, ) -> GatewayServiceResult: unit_name = _systemd_unit_name(name) path = self.home / ".config" / "systemd" / "user" / unit_name commands = ( ("systemctl", "--user", "disable", "--now", unit_name), ("systemctl", "--user", "daemon-reload"), ) if dry_run: return GatewayServiceResult(True, "service_uninstall_dry_run", "systemd", path, commands) self._run_best_effort(commands[0]) path.unlink(missing_ok=True) self._subprocess_run(list(commands[1]), check=True) return GatewayServiceResult(True, "service_uninstalled", "systemd", path, commands) def _install_launchd( self, options: GatewayServiceOptions, *, dry_run: bool, ) -> GatewayServiceResult: label = _launchd_label(options.name) path = self.home / "Library" / "LaunchAgents" / f"{label}.plist" log_stem = _safe_service_name(options.name) stdout_path = self.home / ".nanobot" / "logs" / f"{log_stem}.launchd.log" stderr_path = self.home / ".nanobot" / "logs" / f"{log_stem}.launchd.err.log" payload = { "Label": label, "ProgramArguments": build_gateway_command(options.python_executable, options.start), "WorkingDirectory": _working_directory_text(options.start), "RunAtLoad": bool(options.enable), "KeepAlive": {"SuccessfulExit": False}, "StandardOutPath": str(stdout_path), "StandardErrorPath": str(stderr_path), } content = plistlib.dumps(payload, sort_keys=False).decode("utf-8") domain = _launchd_domain() commands: list[tuple[str, ...]] = [] if options.start_now: commands.append(("launchctl", "bootstrap", domain, str(path))) if options.enable: commands.append(("launchctl", "enable", f"{domain}/{label}")) if options.start_now: commands.append(("launchctl", "kickstart", "-k", f"{domain}/{label}")) if dry_run: return GatewayServiceResult(True, "service_install_dry_run", "launchd", path, tuple(commands), content) _working_directory(options.start).mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True) stdout_path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") if options.start_now: self._run_best_effort(("launchctl", "bootout", domain, str(path))) for command_args in commands: self._subprocess_run(list(command_args), check=True) return GatewayServiceResult(True, "service_installed", "launchd", path, tuple(commands), content) def _uninstall_launchd( self, *, name: str, dry_run: bool, ) -> GatewayServiceResult: label = _launchd_label(name) path = self.home / "Library" / "LaunchAgents" / f"{label}.plist" domain = _launchd_domain() commands = ( ("launchctl", "bootout", domain, str(path)), ("launchctl", "disable", f"{domain}/{label}"), ) if dry_run: return GatewayServiceResult(True, "service_uninstall_dry_run", "launchd", path, commands) for command_args in commands: self._run_best_effort(command_args) path.unlink(missing_ok=True) return GatewayServiceResult(True, "service_uninstalled", "launchd", path, commands) def _resolve_manager(self, manager: ServiceManagerKind) -> str: if manager != "auto": return manager if self.platform_name == "Darwin": return "launchd" if self.platform_name == "Linux": return "systemd" return self.platform_name.lower() def _run_best_effort(self, command_args: tuple[str, ...]) -> None: self._subprocess_run(list(command_args), check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def _platform_name() -> str: if sys.platform == "darwin": return "Darwin" if sys.platform.startswith("linux"): return "Linux" if sys.platform.startswith("win"): return "Windows" return sys.platform def _working_directory(options: GatewayStartOptions) -> Path: if options.workspace: return Path(options.workspace).expanduser() return Path.home() def _working_directory_text(options: GatewayStartOptions) -> str: if options.workspace: return os.path.expanduser(options.workspace) return str(Path.home()) def _systemd_unit_name(name: str) -> str: stem = _safe_service_name(name) return stem if stem.endswith(".service") else f"{stem}.service" def _launchd_label(name: str) -> str: if name.startswith("ai.nanobot."): return name suffix = _safe_service_name(name).removeprefix("nanobot-").replace("-", ".") return f"ai.nanobot.{suffix}" def _safe_service_name(name: str) -> str: value = name.strip().lower() value = re.sub(r"[^a-z0-9_.-]+", "-", value) value = value.strip(".-") return value or "nanobot-gateway" def _launchd_domain() -> str: getuid = getattr(os, "getuid", None) if getuid is None: return "gui/current" return f"gui/{getuid()}" def _systemd_unit_content( *, description: str, command: list[str], working_directory: str, ) -> str: quoted_command = " ".join(_systemd_quote(part) for part in command) return "\n".join( [ "[Unit]", f"Description={description}", "After=network-online.target", "Wants=network-online.target", "", "[Service]", "Type=simple", f"WorkingDirectory={_systemd_quote(str(working_directory))}", f"ExecStart={quoted_command}", "Restart=always", "RestartSec=10", "Environment=PYTHONUNBUFFERED=1", "NoNewPrivileges=yes", "", "[Install]", "WantedBy=default.target", "", ] ) def _systemd_quote(value: str) -> str: if value and not re.search(r"\s|['\"\\]", value): return value return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'