Files
gitea-macos-vm-orchestrator/Sources/gitea-macos-runner/CommandConfig.swift
T

206 lines
8.5 KiB
Swift

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"] = "<redacted>" }
if gitea["registrationToken"] != nil { gitea["registrationToken"] = "<redacted>" }
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 ?? "<invalid>"
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
}
}