import ArgumentParser import Foundation import RunnerCore /// `gitea-macos-runner config …` — create and inspect configuration. struct ConfigCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "config", abstract: "Create and inspect the runner configuration.", subcommands: [Init.self, Show.self, Path.self] ) /// `config init` — write a commented example config. struct Init: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "init", abstract: "Write an example config.json, creating parent directories.", discussion: """ Writes to ~/.config/gitea-macos-runner/config.json unless --config says \ otherwise. Refuses to overwrite an existing file without --force. The \ written file carries "_comment" keys explaining each section; they are \ ignored when the config is read back. """ ) @OptionGroup var options: GlobalOptions /// Overwrite an existing file. @Flag(name: .shortAndLong, help: "Overwrite an existing config file.") var force: Bool = false /// Seed `gitea.instanceURL` instead of the placeholder. @Option(name: .long, help: "Gitea instance URL to seed into the config.") var instanceURL: String? func run() async throws { var config = RunnerConfig.default if let instanceURL { guard let url = URL(string: instanceURL), url.scheme != nil, url.host != nil else { throw ValidationError("not a valid absolute URL: \(instanceURL)") } config.gitea.instanceURL = url } var example = ConfigCommand.loadExampleDocument() if let instanceURL, example != nil { example = example?.replacingOccurrences( of: "https://gitea.example.com", with: instanceURL ) } let path = RunnerConfig.expandTilde(options.configPath) let written = try config.writeExample(to: path, exampleContents: example, overwrite: force) guard written else { CLI.error("\(path) already exists; pass --force to overwrite") throw ExitCode(1) } print("wrote \(path)") print("") if let contents = try? String(contentsOfFile: path, encoding: .utf8) { print(contents) } print("edit it, then run: gitea-macos-runner doctor") } } /// `config show` — print the effective, validated configuration. /// /// Token values are redacted; token *sources* are shown, which is what you /// actually need when debugging "why does it say no registration token". struct Show: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "show", abstract: "Print the effective configuration with secrets redacted." ) @OptionGroup var options: GlobalOptions func run() async throws { let config = try options.loadConfig() let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let encoded = try encoder.encode(config) var object = (try JSONSerialization.jsonObject(with: encoded)) as? [String: Any] ?? [:] if var gitea = object["gitea"] as? [String: Any] { if gitea["adminToken"] != nil { gitea["adminToken"] = "" } if gitea["registrationToken"] != nil { gitea["registrationToken"] = "" } object["gitea"] = gitea } let redacted = try JSONSerialization.data( withJSONObject: object, options: [.prettyPrinted, .sortedKeys] ) print(String(data: redacted, encoding: .utf8) ?? "{}") // The sources matter more than the values: "no registration token" // is almost always a path problem, not a secret problem. print("") print("config path: \(RunnerConfig.expandTilde(options.configPath))") print("store directory: \(config.storeDirectoryURL.path)") print("labels: \(config.runner.labels.joined(separator: ", "))") print("register --labels: \(config.labelSet.registrationArgument())") let downloadURL = (try? config.runner.resolvedDownloadURL)?.absoluteString ?? "" print("runner download: \(downloadURL)") let adminSource = ConfigCommand.describeSource( inline: config.gitea.adminToken, file: config.gitea.adminTokenFile, resolved: (try? config.resolveAdminToken()) ?? nil ) let registrationSource = ConfigCommand.describeSource( inline: config.gitea.registrationToken, file: config.gitea.registrationTokenFile, resolved: (try? config.resolveStaticRegistrationToken()) ?? nil, fallback: config.gitea.fetchRegistrationTokenViaAPI ? "admin API (fetchRegistrationTokenViaAPI)" : nil ) print("admin token: \(adminSource)") print("registration token: \(registrationSource)") let insecure = config.insecureTokenFilePaths if !insecure.isEmpty { print("") CLI.note("warning: group/world readable token files: \(insecure.joined(separator: ", "))") } } } /// `config path` — print the config path being used. struct Path: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "path", abstract: "Print the configuration file path." ) @OptionGroup var options: GlobalOptions func run() async throws { print(RunnerConfig.expandTilde(options.configPath)) } } /// Describes where a secret comes from, without printing it. static func describeSource( inline: String?, file: String?, resolved: String?, fallback: String? = nil ) -> String { let value = resolved if let file, !file.isEmpty { let expanded = RunnerConfig.expandTilde(file) let readable = (value?.isEmpty == false) return "\(expanded) (\(readable ? "readable" : "MISSING or empty"))" } if let inline, !inline.isEmpty { return "inline value in config.json (prefer a file)" } return fallback ?? "not configured" } /// Finds `Resources/config.example.json` next to the binary or in a checkout. /// /// The example is not an SPM resource bundle and `make bundle` does not copy /// it into the app, so several plausible locations are tried; `writeExample` /// falls back to a plain serialization when none is found. static func loadExampleDocument() -> String? { var candidates: [URL] = [] if let resource = Bundle.main.url(forResource: "config.example", withExtension: "json") { candidates.append(resource) } candidates.append(Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/config.example.json")) if let executableURL = Bundle.main.executableURL?.resolvingSymlinksInPath() { let directory = executableURL.deletingLastPathComponent() candidates.append(directory.appendingPathComponent("Resources/config.example.json")) candidates.append( directory.deletingLastPathComponent().appendingPathComponent("Resources/config.example.json") ) } // Sources/gitea-macos-runner/CommandConfig.swift → repository root. let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() candidates.append(repositoryRoot.appendingPathComponent("Resources/config.example.json")) candidates.append( URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent("Resources/config.example.json") ) for candidate in candidates { if let contents = try? String(contentsOf: candidate, encoding: .utf8) { return contents } } return nil } }