diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index 5b5ee90..4272d04 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -16,6 +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 func runCommand( quiet: Bool, @@ -41,14 +42,14 @@ extension CliClient: DependencyKey { encoder: JSONEncoder = .init(), env: [String: String] ) -> Self { - .init { + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + return .init { decoder } encoder: { encoder } loadConfiguration: { - @Dependency(\.logger) var logger - @Dependency(\.fileClient) var fileClient - let urls = try findConfigurationFiles(env: env) var env = env @@ -75,6 +76,8 @@ extension CliClient: DependencyKey { in: nil, args )) + } createConfiguration: { path in + try fileClient.write(path, Data(Configuration.fileTemplate.utf8)) } } diff --git a/Sources/CliClient/Configuration.swift b/Sources/CliClient/Configuration.swift index b8fcbd5..20e1a82 100644 --- a/Sources/CliClient/Configuration.swift +++ b/Sources/CliClient/Configuration.swift @@ -40,4 +40,27 @@ public struct Configuration: Decodable { let data = try encoder.encode(env) return try decoder.decode(Configuration.self, from: data) } + + static var fileTemplate: String { + """ + # Example configuration, uncomment the lines and set the values appropriate for your + # usage. + + # Set this to the location of the ansible-hpa-playbook on your local machine. + #HPA_PLAYBOOK_DIR="/path/to/ansible-hpa-playbook" + + # Set this to the location of a template repository, which is used to create new assessment projects. + #HPA_TEMPLATE_REPO="https://git.example.com/your/template.git" + + # Specify a branch, version, or sha of the template repository. + #HPA_TEMPLATE_VERSION="main" # branch, version, or sha + + # Set this to a location of a template directory to use to create new projects. + #HPA_TEMPLATE_DIR="/path/to/local/template" + + # Extra arguments that get passed directly to the ansible-playbook command. + #HPA_DEFAULT_PLAYBOOK_ARGS="--vault-id=consults@$SCRIPTS/vault-gopass-client" + + """ + } } diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift index cd56282..c8bf3f4 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 write: @Sendable (String, Data) throws -> Void } @_spi(Internal) @@ -31,7 +32,10 @@ extension FileClient: DependencyKey { loadFile: { try client.loadFile(at: $0, into: &$1, decoder: $2) }, homeDir: { client.homeDir }, isDirectory: { client.isDirectory(url: $0) }, - isReadable: { client.isReadable(url: $0) } + isReadable: { client.isReadable(url: $0) }, + write: { path, data in + try data.write(to: URL(filePath: path)) + } ) } diff --git a/Sources/hpa/Application.swift b/Sources/hpa/Application.swift index 5a5fd28..17db3b5 100644 --- a/Sources/hpa/Application.swift +++ b/Sources/hpa/Application.swift @@ -9,7 +9,10 @@ struct Application: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "hpa", abstract: "A utility for working with ansible hpa playbook.", - subcommands: [BuildCommand.self, CreateProjectCommand.self, CreateProjectTemplateCommand.self] + subcommands: [ + BuildCommand.self, CreateCommand.self, CreateProjectTemplateCommand.self, + GenerateConfigurationCommand.self + ] ) } diff --git a/Sources/hpa/CreateProjectCommand.swift b/Sources/hpa/CreateCommand.swift similarity index 98% rename from Sources/hpa/CreateProjectCommand.swift rename to Sources/hpa/CreateCommand.swift index 91594fa..8adf4b0 100644 --- a/Sources/hpa/CreateProjectCommand.swift +++ b/Sources/hpa/CreateCommand.swift @@ -4,7 +4,7 @@ import Dependencies import Foundation import Logging -struct CreateProjectCommand: AsyncParsableCommand { +struct CreateCommand: AsyncParsableCommand { static let commandName = "create" @@ -97,7 +97,7 @@ struct CreateProjectCommand: AsyncParsableCommand { } private func parseOptions( - command: CreateProjectCommand, + command: CreateCommand, configuration: Configuration, logger: Logger, encoder: JSONEncoder diff --git a/Sources/hpa/GenerateConfigCommand.swift b/Sources/hpa/GenerateConfigCommand.swift new file mode 100644 index 0000000..0d5e832 --- /dev/null +++ b/Sources/hpa/GenerateConfigCommand.swift @@ -0,0 +1,54 @@ +import ArgumentParser +import CliClient +import Dependencies + +struct GenerateConfigurationCommand: AsyncParsableCommand { + + static let commandName = "generate-config" + + static let configuration = CommandConfiguration( + commandName: commandName, + 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). + + """ + ) + + @Option( + name: .shortAndLong, + help: "Directory to generate the configuration in.", + completion: .directory + ) + var path: String? + + @OptionGroup var globals: BasicGlobalOptions + + mutating func run() async throws { + try await _run() + } + + private func _run() async throws { + try await withSetupLogger(commandName: Self.commandName, globals: globals) { + @Dependency(\.cliClient) var cliClient + + let actualPath: String + + if let path { + actualPath = "\(path)/config" + } else { + let path = "~/.config/hpa-playbook/" + try await cliClient.runCommand( + quiet: false, + shell: globals.shellOrDefault, + "mkdir", "-p", path + ) + actualPath = "\(path)/config" + } + + try cliClient.createConfiguration(actualPath) + } + } +} diff --git a/Sources/hpa/GlobalOptions.swift b/Sources/hpa/GlobalOptions.swift index 52dab6c..07130d3 100644 --- a/Sources/hpa/GlobalOptions.swift +++ b/Sources/hpa/GlobalOptions.swift @@ -1,5 +1,27 @@ import ArgumentParser +struct BasicGlobalOptions: ParsableArguments { + @Flag( + name: .shortAndLong, + help: "Increase logging level (can be passed multiple times)." + ) + var verbose: Int + + @Flag( + name: .shortAndLong, + help: "Supress logging." + ) + var quiet = false + + @Option( + name: .shortAndLong, + help: "Optional shell to use when calling shell commands.", + completion: .file() + ) + var shell: String? + +} + struct GlobalOptions: ParsableArguments { @Option( @@ -14,29 +36,12 @@ struct GlobalOptions: ParsableArguments { ) var inventoryPath: String? - @Flag( - name: .shortAndLong, - help: "Increase logging level (can be passed multiple times)." - ) - var verbose: Int - - @Flag( - name: .shortAndLong, - help: "Supress logging." - ) - var quiet = false - @Flag( name: .long, help: "Supress only playbook logging." ) var quietOnlyPlaybook = false - @Option( - name: .shortAndLong, - help: "Optional shell to use when calling shell commands.", - completion: .file() - ) - var shell: String? + @OptionGroup var basic: BasicGlobalOptions } diff --git a/Sources/hpa/Helpers.swift b/Sources/hpa/Helpers.swift index da67918..2cffed8 100644 --- a/Sources/hpa/Helpers.swift +++ b/Sources/hpa/Helpers.swift @@ -40,7 +40,7 @@ extension CommandConfiguration { } } -extension GlobalOptions { +extension BasicGlobalOptions { var shellOrDefault: ShellCommand.Shell { guard let shell else { return .zsh(useDashC: true) } @@ -48,6 +48,11 @@ extension GlobalOptions { } } +extension GlobalOptions { + + var shellOrDefault: ShellCommand.Shell { basic.shellOrDefault } +} + func ensureString( globals: GlobalOptions, configuration: Configuration, @@ -65,13 +70,14 @@ func ensureString( func withSetupLogger( commandName: String, - globals: GlobalOptions, + globals: BasicGlobalOptions, + quietOnlyPlaybook: Bool = false, dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in }, operation: @escaping () async throws -> Void ) async rethrows { try await withDependencies { $0.logger = .init(label: "\("hpa".yellow)") - if globals.quietOnlyPlaybook || !globals.quiet { + if quietOnlyPlaybook || !globals.quiet { switch globals.verbose { case 0: $0.logger.logLevel = .info @@ -89,6 +95,21 @@ func withSetupLogger( } } +func withSetupLogger( + commandName: String, + globals: GlobalOptions, + dependencies setupDependencies: (inout DependencyValues) -> Void = { _ in }, + operation: @escaping () async throws -> Void +) async rethrows { + try await withSetupLogger( + commandName: commandName, + globals: globals.basic, + quietOnlyPlaybook: globals.quietOnlyPlaybook, + dependencies: setupDependencies, + operation: operation + ) +} + func runPlaybook( commandName: String, globals: GlobalOptions, @@ -156,7 +177,7 @@ func runPlaybook( } try await cliClient.runCommand( - quiet: globals.quietOnlyPlaybook ? true : globals.quiet, + quiet: globals.quietOnlyPlaybook ? true : globals.basic.quiet, shell: globals.shellOrDefault, playbookArgs + args + extraArgs )