206 lines
8.5 KiB
Swift
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
|
|
}
|
|
}
|