import ArgumentParser import Foundation import Logging import RunnerCore /// Options every subcommand accepts. struct GlobalOptions: ParsableArguments { /// Path to `config.json`. Tilde-expanded. @Option(name: [.customLong("config"), .customShort("c")], help: "Path to config.json (default: ~/.config/gitea-macos-runner/config.json)") var configPath: String = RunnerConfig.defaultPath /// Emit debug-level logs. @Flag(name: .long, help: "Verbose logging.") var verbose: Bool = false /// Loads and validates the configuration named by ``configPath``. func loadConfig() throws -> RunnerConfig { try RunnerConfig.load(from: configPath).validated() } } /// Root command. /// /// The tool is both the daemon and its own admin CLI: `daemon` is what /// `launchd` starts, and everything else is operator-facing. @main struct GiteaMacOSRunner: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "gitea-macos-runner", abstract: "Run Gitea Actions macOS jobs in fresh, ephemeral Virtualization.framework VMs.", discussion: """ Each queued job that matches this host's labels gets a brand-new macOS VM \ cloned from a base image, an ephemeral runner registered with Gitea, and a \ teardown as soon as the job finishes. Nothing is reused between jobs. Start with `doctor` to verify the host, then `config init`, then \ `image build`, then `service install`. """, version: RunnerVersion.current, subcommands: [ DaemonCommand.self, ImageCommand.self, VMCommand.self, ServiceCommand.self, DoctorCommand.self, ConfigCommand.self, ], defaultSubcommand: nil ) } /// Shared helpers for command bodies. enum CLI { /// Prints to stderr. static func error(_ message: String) { FileHandle.standardError.write(Data(("error: " + message + "\n").utf8)) } /// Prints a note to stderr, so it does not pollute pipeable stdout. static func note(_ message: String) { FileHandle.standardError.write(Data((message + "\n").utf8)) } /// Prints an "unimplemented" notice and exits non-zero. static func unimplemented(_ what: String) throws -> Never { error("\(what): unimplemented") throw ExitCode(1) } /// Routes swift-log to stderr, leaving stdout for command output. /// /// Only the first call has any effect: `LoggingSystem.bootstrap` traps when /// called twice, and subcommands are free to call this independently. static func bootstrapLogging(verbose: Bool) { loggingBootstrap.once { let level: Logger.Level = verbose ? .debug : .info LoggingSystem.bootstrap { label in var handler = StreamLogHandler.standardError(label: label) handler.logLevel = level return handler } } } private static let loggingBootstrap = OnceFlag() /// Asks a yes/no question on stderr. Answers `false` when stdin is not a /// terminal, so a piped invocation never blocks forever. static func confirm(_ question: String) -> Bool { guard isatty(fileno(stdin)) == 1 else { return false } FileHandle.standardError.write(Data((question + " [y/N] ").utf8)) guard let answer = readLine(strippingNewline: true)?.lowercased() else { return false } return answer == "y" || answer == "yes" } /// Formats a byte count as a human-readable size. static func formatBytes(_ bytes: Int64) -> String { let units = ["B", "KB", "MB", "GB", "TB"] var value = Double(bytes) var unit = 0 while value >= 1024, unit < units.count - 1 { value /= 1024 unit += 1 } return unit == 0 ? "\(Int(value)) \(units[unit])" : String(format: "%.1f %@", value, units[unit]) } /// Renders a fixed-width progress bar, e.g. `[####------] 40%`. static func progressBar(_ fraction: Double, width: Int = 30) -> String { let clamped = min(max(fraction, 0), 1) let filled = Int((Double(width) * clamped).rounded()) let bar = String(repeating: "#", count: filled) + String(repeating: "-", count: width - filled) return String(format: "[%@] %3d%%", bar, Int((clamped * 100).rounded())) } } /// A thread-safe "run this exactly once" latch. final class OnceFlag: @unchecked Sendable { private let lock = NSLock() private var done = false func once(_ body: () -> Void) { lock.lock() defer { lock.unlock() } guard !done else { return } done = true body() } } /// Serializes progress output arriving from arbitrary threads and keeps it on a /// single rewritten stderr line. final class ProgressPrinter: @unchecked Sendable { private let lock = NSLock() private var lastLine = "" private var lastGroup: String? /// Whether carriage-return rewriting means anything here. /// /// Piped to a file or captured by `launchd`, `\r` produces one unreadable /// mega-line, so each update becomes its own line instead. `FileHandle` /// writes go straight to the descriptor either way — there is no buffer to /// flush, which is what makes a stall attributable to the stage last /// printed rather than to output sitting unwritten. private let isInteractive = isatty(fileno(stderr)) == 1 /// Rewrites the current line. /// /// - Parameters: /// - line: The text to show. /// - group: Names the stage this line belongs to. When it changes, the /// outgoing stage's final line is sealed with a newline rather than /// overwritten — so `installing macOS [####] 100%` is still on screen /// when the operator scrolls back to work out where the last hour went, /// instead of being replaced by whatever came next. func update(_ line: String, group: String? = nil) { lock.lock() defer { lock.unlock() } if let group, let lastGroup, group != lastGroup, !lastLine.isEmpty, isInteractive { emit("\n") lastLine = "" } if let group { lastGroup = group } guard line != lastLine else { return } lastLine = line guard isInteractive else { emit(line + "\n") return } emit("\r" + line + pad(line)) } /// Emits a standalone line without losing the status line under it. func line(_ text: String) { lock.lock() let carried = lastLine lock.unlock() finish(text) if !carried.isEmpty { update(carried) } } /// Ends the line so subsequent output starts cleanly. func finish(_ line: String? = nil) { lock.lock() defer { lock.unlock() } if let line { emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n") } else if !lastLine.isEmpty, isInteractive { emit("\n") } lastLine = "" } /// Trailing blanks that erase whatever the previous, longer line left behind. private func pad(_ line: String) -> String { String(repeating: " ", count: max(0, 78 - line.count)) } /// Writes straight to the descriptor. Call with ``lock`` held. private func emit(_ text: String) { FileHandle.standardError.write(Data(text.utf8)) } }