Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user