import ArgumentParser import Foundation import RunnerCore import RunnerHost /// `gitea-macos-runner service …` — manage the `launchd` LaunchAgent. /// /// - Important: This installs a **LaunchAgent** in the logged-in user's session, /// never a LaunchDaemon. Virtualization needs a GUI session, and macOS 15+ /// additionally needs an unlocked `login.keychain` to start a VM — neither of /// which exists in the system context. The host should be set to log in /// automatically. struct ServiceCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "service", abstract: "Install, remove, or inspect the launchd LaunchAgent.", subcommands: [Install.self, Uninstall.self, Status.self] ) /// `service install` — write the plist and load the job. struct Install: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "install", abstract: "Write ~/Library/LaunchAgents/\(LaunchdService.label).plist and load it.", discussion: """ Points the agent at the installed, signed .app bundle — not at a bare \ binary. The com.apple.security.virtualization entitlement only survives \ on the signed bundle, so a daemon started from .build/ cannot start VMs. """ ) @OptionGroup var options: GlobalOptions /// Path to the installed executable inside the signed `.app`. @Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).") var executable: String? /// How to configure Local Network access, if it is not already. /// /// Unset means "decide at run time": ask on a terminal, skip with a /// pointer otherwise. `none` suppresses the question outright, for a /// scripted install that has its own arrangements. @Option( name: .customLong("grant-local-network"), help: "Configure macOS Local Network access during install: allowlist, prompt, or none.") var grantLocalNetwork: LocalNetworkGrantChoice? func run() async throws { let executablePath = executable ?? LaunchdService.defaultExecutablePath // Only pass --config when it is not the default; a plist that // hard-codes the default path is one more thing to keep in sync. let configPath = options.configPath == RunnerConfig.defaultPath ? nil : options.configPath if (try? options.loadConfig()) == nil { CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed") } // Done before install (which also does it) purely so the operator // is told: an agent silently vanishing from launchctl is alarming // if you do not know a rename happened. for legacy in LaunchdService.removeLegacyAgents() { CLI.note("removed legacy agent \(legacy) (renamed to \(LaunchdService.label))") } try LaunchdService.install(executablePath: executablePath, configPath: configPath) print("installed \(LaunchdService.agentPlistURL.path)") print("program: \(RunnerConfig.expandTilde(executablePath)) daemon") print("logs: \(LaunchdService.logDirectoryURL.path)") print("") offerLocalNetworkGrant() print("check it with: gitea-macos-runner service status") } /// Offers to configure Local Network access, if it is not already. /// /// This is where the question belongs. The agent that was just /// installed is the process that will be blocked, it has no UI to ask /// with, and the symptom when it is blocked — every guest boots, no job /// starts, `No route to host` — points nowhere near the cause. Asking /// now costs one prompt; not asking costs a debugging session. /// /// Never fatal: a failed or declined grant leaves a perfectly good /// installed agent, so this reports and returns rather than throwing. private func offerLocalNetworkGrant() { guard grantLocalNetwork != .skip else { return } guard !LocalNetworkPolicy.status().coversGuestRange else { return } let method: LocalNetworkPermission.Method switch grantLocalNetwork { case .allowlist: method = .allowlist case .prompt: method = .prompt case .skip: return // handled above; here for exhaustiveness case nil: // Not asked for either way: decide from the terminal. A piped // or launchd-driven install must not stop on a question, so it // gets the pointer and carries on. guard isatty(fileno(stdin)) == 1 else { CLI.note(""" note: macOS Local Network access is not configured. Until it is, guests \ boot but SSH fails with "No route to host". Configure it with \ `gitea-macos-runner permissions grant`. """) print("") return } CLI.note(""" macOS Local Network access is not configured. Without it the agent starts \ guests fine but cannot reach them, and every job fails with "No route to \ host". Granting it writes a subnet allowlist with sudo and needs a reboot. """) guard CLI.confirm("configure it now?") else { CLI.note("skipped; run `gitea-macos-runner permissions grant` later") print("") return } method = .allowlist } // Deliberately swallowed. The agent is installed and correct at // this point; a declined sudo password should not turn a successful // install into a failure. do { try LocalNetworkGrantFlow.run(method: method) } catch { CLI.note("could not configure it: \(error)") CLI.note("the agent is installed; run `gitea-macos-runner permissions grant` to retry") } print("") } } /// `--grant-local-network`'s values: the two grant methods plus an explicit /// opt-out, which the method enum itself has no business carrying. /// /// The opt-out case is spelled `skip` rather than `none` so that /// `choice == .skip` cannot be read as `Optional.none` — the option is /// itself optional, and "not passed" means something different from /// "passed `none`". enum LocalNetworkGrantChoice: String, ExpressibleByArgument, CaseIterable { case allowlist case prompt case skip = "none" } /// `service uninstall` — unload and remove the plist. struct Uninstall: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "uninstall", abstract: "Unload the LaunchAgent and remove its plist." ) @OptionGroup var options: GlobalOptions func run() async throws { let path = LaunchdService.agentPlistURL.path let existed = FileManager.default.fileExists(atPath: path) let legacy = LaunchdService.removeLegacyAgents() try LaunchdService.uninstall() for label in legacy { print("removed legacy agent \(label)") } print(existed ? "removed \(path)" : "not installed (\(path))") } } /// `service status` — report whether the agent is installed and running. struct Status: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "status", abstract: "Report LaunchAgent installation and run state." ) @OptionGroup var options: GlobalOptions func run() async throws { let status = try LaunchdService.status() print("label: \(LaunchdService.label)") print("plist: \(status.plistPath)") print("installed: \(status.installed ? "yes" : "no")") print("loaded: \(status.loaded ? "yes" : "no")") if let pid = status.pid { print("pid: \(pid)") } if let lastExitStatus = status.lastExitStatus { print("last exit: \(lastExitStatus)") } print("logs: \(LaunchdService.logDirectoryURL.path)") if status.installed, !status.loaded { print("") CLI.note("installed but not loaded — reinstall with `service install`, or check the logs above") throw ExitCode(1) } } } }