diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index 4272d04..aaa45db 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -16,7 +16,7 @@ public struct CliClient: Sendable { public var encoder: @Sendable () -> JSONEncoder = { .init() } public var loadConfiguration: @Sendable () throws -> Configuration public var runCommand: @Sendable ([String], Bool, ShellCommand.Shell) async throws -> Void - public var createConfiguration: @Sendable (String) throws -> Void + public var createConfiguration: @Sendable (_ path: String, _ json: Bool) throws -> Void public func runCommand( quiet: Bool, @@ -50,16 +50,14 @@ extension CliClient: DependencyKey { } encoder: { encoder } loadConfiguration: { - let urls = try findConfigurationFiles(env: env) + let url = try findConfigurationFiles(env: env) var env = env - logger.trace("Loading configuration from: \(urls)") - - for url in urls { - try fileClient.loadFile(url, &env, decoder) - } + logger.trace("Loading configuration from: \(url)") + try fileClient.loadFile(url, &env, decoder) return try .fromEnv(env, encoder: encoder, decoder: decoder) + } runCommand: { args, quiet, shell in @Dependency(\.asyncShellClient) var shellClient if !quiet { @@ -76,8 +74,27 @@ extension CliClient: DependencyKey { in: nil, args )) - } createConfiguration: { path in - try fileClient.write(path, Data(Configuration.fileTemplate.utf8)) + } createConfiguration: { path, json in + + // Early out if a file exists at the path already. + guard !fileClient.fileExists(path) else { + throw CliClientError.fileExistsAtPath(path) + } + + var path = path + let data: Data + + if !json { + // Write the default env template. + data = Data(Configuration.fileTemplate.utf8) + } else { + if !path.contains(".json") { + path += ".json" + } + data = try jsonEncoder.encode(Configuration.mock) + } + + try fileClient.write(path, data) } } @@ -87,3 +104,13 @@ extension CliClient: DependencyKey { public static let testValue: CliClient = Self() } + +enum CliClientError: Error { + case fileExistsAtPath(String) +} + +private let jsonEncoder: JSONEncoder = { + var encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + return encoder +}() diff --git a/Sources/CliClient/Configuration.swift b/Sources/CliClient/Configuration.swift index 20e1a82..9f9bf1b 100644 --- a/Sources/CliClient/Configuration.swift +++ b/Sources/CliClient/Configuration.swift @@ -3,7 +3,7 @@ import Foundation import ShellClient /// Represents the configuration. -public struct Configuration: Decodable { +public struct Configuration: Codable { public let playbookDir: String? public let inventoryPath: String? @@ -41,8 +41,21 @@ public struct Configuration: Decodable { return try decoder.decode(Configuration.self, from: data) } + static var mock: Self { + .init( + playbookDir: "/path/to/playbook", + inventoryPath: "/path/to/inventory.ini", + templateRepo: "https://git.example.com/consult-template.git", + templateRepoVersion: "main", + templateDir: "/path/to/local/template", + defaultPlaybookArgs: "--vault-id=myId@$SCRIPTS/vault-gopass-client" + ) + } + static var fileTemplate: String { """ + # vi: ft=sh + # Example configuration, uncomment the lines and set the values appropriate for your # usage. diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift index c8bf3f4..283442c 100644 --- a/Sources/CliClient/FileClient.swift +++ b/Sources/CliClient/FileClient.swift @@ -18,6 +18,7 @@ public struct FileClient: Sendable { public var homeDir: @Sendable () -> URL = { URL(string: "~/")! } public var isDirectory: @Sendable (URL) -> Bool = { _ in false } public var isReadable: @Sendable (URL) -> Bool = { _ in false } + public var fileExists: @Sendable (String) -> Bool = { _ in false } public var write: @Sendable (String, Data) throws -> Void } @@ -33,6 +34,7 @@ extension FileClient: DependencyKey { homeDir: { client.homeDir }, isDirectory: { client.isDirectory(url: $0) }, isReadable: { client.isReadable(url: $0) }, + fileExists: { client.fileExists(at: $0) }, write: { path, data in try data.write(to: URL(filePath: path)) } @@ -66,6 +68,10 @@ private struct LiveFileClient: @unchecked Sendable { try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) } + func fileExists(at path: String) -> Bool { + fileManager.fileExists(atPath: path) + } + func loadFile(at url: URL, into env: inout [String: String], decoder: JSONDecoder) throws { @Dependency(\.logger) var logger logger.trace("Begin load file for: \(path(for: url))") diff --git a/Sources/CliClient/Helpers.swift b/Sources/CliClient/Helpers.swift index 6e05506..f469da4 100644 --- a/Sources/CliClient/Helpers.swift +++ b/Sources/CliClient/Helpers.swift @@ -5,7 +5,7 @@ import ShellClient @_spi(Internal) public func findConfigurationFiles( env: [String: String] = ProcessInfo.processInfo.environment -) throws -> [URL] { +) throws -> URL { @Dependency(\.logger) var logger @Dependency(\.fileClient) var fileClient @@ -13,50 +13,47 @@ public func findConfigurationFiles( logger.trace("Env: \(env)") let homeDir = fileClient.homeDir() - var url = homeDir.appending(path: ".hparc") - - if fileClient.isReadable(url) { - logger.debug("Found configuration in home directory") - return [url] - } + // Check for environment variable pointing to a directory that the + // the configuration lives. if let configHome = env["HPA_CONFIG_HOME"] { - url = .init(filePath: configHome) + let url = URL(filePath: configHome).appending(path: "config") - if fileClient.isDirectory(url) { - logger.debug("Found configuration directory from hpa config home env var.") - return try fileClient.contentsOfDirectory(url) - } if fileClient.isReadable(url) { logger.debug("Found configuration from hpa config home env var.") - return [url] + return url } } + // Check home directory for a `.hparc` file. + let url = homeDir.appending(path: ".hparc") + if fileClient.isReadable(url) { + logger.debug("Found configuration in home directory") + return url + } + + // Check for environment variable pointing to a directory that the + // the configuration lives. if let pwd = env["PWD"] { - url = .init(filePath: "\(pwd)").appending(path: ".hparc") + let url = URL(filePath: "\(pwd)").appending(path: ".hparc") if fileClient.isReadable(url) { logger.debug("Found configuration in current working directory.") - return [url] + return url } } + // Check in xdg config home, under an hpa-playbook directory. if let xdgConfigHome = env["XDG_CONFIG_HOME"] { logger.debug("XDG Config Home: \(xdgConfigHome)") - url = .init(filePath: "\(xdgConfigHome)") + let url = URL(filePath: "\(xdgConfigHome)") .appending(path: "hpa-playbook") + .appending(path: "config") logger.debug("XDG Config url: \(url.absoluteString)") - if fileClient.isDirectory(url) { - logger.debug("Found configuration in xdg config home.") - return try fileClient.contentsOfDirectory(url) - } - if fileClient.isReadable(url) { - logger.debug("Not directory, but readable.") - return [url] + return url } } diff --git a/Sources/hpa/GenerateConfigCommand.swift b/Sources/hpa/GenerateConfigCommand.swift index 0d5e832..4660f90 100644 --- a/Sources/hpa/GenerateConfigCommand.swift +++ b/Sources/hpa/GenerateConfigCommand.swift @@ -11,8 +11,14 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { abstract: "\("Generate a local configuration file.".blue)", discussion: """ - If a directory is not supplied then a configuration file will be created - at \("'~/.config/hpa-playbook/config'".yellow). + \("NOTE:".yellow) If a directory is not supplied then a configuration file will be created + at \("'~/.config/hpa-playbook/config'".yellow). + + \("Example:".yellow) + + \("Create a directory and generate the configuration file.".green) + $ mkdir -p ~/.config/hpa-playbook + $ hpa generate-config --path ~/.config/hpa-playbook """ ) @@ -24,6 +30,12 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { ) var path: String? + @Flag( + name: .shortAndLong, + help: "Generate a json file, instead of default env style" + ) + var json: Bool = false + @OptionGroup var globals: BasicGlobalOptions mutating func run() async throws { @@ -48,7 +60,7 @@ struct GenerateConfigurationCommand: AsyncParsableCommand { actualPath = "\(path)/config" } - try cliClient.createConfiguration(actualPath) + try cliClient.createConfiguration(actualPath, json) } } } diff --git a/Sources/hpa/GlobalOptions.swift b/Sources/hpa/GlobalOptions.swift index 07130d3..a830180 100644 --- a/Sources/hpa/GlobalOptions.swift +++ b/Sources/hpa/GlobalOptions.swift @@ -22,6 +22,7 @@ struct BasicGlobalOptions: ParsableArguments { } +@dynamicMemberLookup struct GlobalOptions: ParsableArguments { @Option( @@ -44,4 +45,9 @@ struct GlobalOptions: ParsableArguments { @OptionGroup var basic: BasicGlobalOptions + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { basic[keyPath: keyPath] } + set { basic[keyPath: keyPath] = newValue } + } + }