From 84b002c9977d2426661cbea677945f7b5caaacd4 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 29 Nov 2024 18:55:00 -0500 Subject: [PATCH] feat: Working create command. --- Package.swift | 1 - Sources/CliClient/CliClient.swift | 34 +++++ Sources/CliClient/FileClient.swift | 18 +-- Sources/hpa/BuildCommand.swift | 14 +- Sources/hpa/CreateProjectCommand.swift | 163 +++++++++++++++++++++- Sources/hpa/GlobalOptions.swift | 24 +++- Sources/hpa/Helpers.swift | 132 +++++++++++++----- Tests/CliClientTests/CliClientTests.swift | 6 +- 8 files changed, 332 insertions(+), 60 deletions(-) diff --git a/Package.swift b/Package.swift index e0990a0..f483a5c 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), - .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"), .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0") ], diff --git a/Sources/CliClient/CliClient.swift b/Sources/CliClient/CliClient.swift index e96e07d..5b5ee90 100644 --- a/Sources/CliClient/CliClient.swift +++ b/Sources/CliClient/CliClient.swift @@ -1,6 +1,7 @@ import Dependencies import DependenciesMacros import Foundation +import ShellClient public extension DependencyValues { var cliClient: CliClient { @@ -14,6 +15,23 @@ public struct CliClient: Sendable { public var decoder: @Sendable () -> JSONDecoder = { .init() } public var encoder: @Sendable () -> JSONEncoder = { .init() } public var loadConfiguration: @Sendable () throws -> Configuration + public var runCommand: @Sendable ([String], Bool, ShellCommand.Shell) async throws -> Void + + public func runCommand( + quiet: Bool, + shell: ShellCommand.Shell, + _ args: [String] + ) async throws { + try await runCommand(args, quiet, shell) + } + + public func runCommand( + quiet: Bool, + shell: ShellCommand.Shell, + _ args: String... + ) async throws { + try await runCommand(args, quiet, shell) + } } extension CliClient: DependencyKey { @@ -41,6 +59,22 @@ extension CliClient: DependencyKey { } return try .fromEnv(env, encoder: encoder, decoder: decoder) + } runCommand: { args, quiet, shell in + @Dependency(\.asyncShellClient) var shellClient + if !quiet { + try await shellClient.foreground(.init( + shell: shell, + environment: ProcessInfo.processInfo.environment, + in: nil, + args + )) + } + try await shellClient.background(.init( + shell: shell, + environment: ProcessInfo.processInfo.environment, + in: nil, + args + )) } } diff --git a/Sources/CliClient/FileClient.swift b/Sources/CliClient/FileClient.swift index c8abf22..cd56282 100644 --- a/Sources/CliClient/FileClient.swift +++ b/Sources/CliClient/FileClient.swift @@ -66,13 +66,13 @@ private struct LiveFileClient: @unchecked Sendable { @Dependency(\.logger) var logger logger.trace("Begin load file for: \(path(for: url))") -// if url.absoluteString.split(separator: ".json").count > 0 { -// // Handle json file. -// let data = try Data(contentsOf: url) -// let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:] -// env.merge(dict, uniquingKeysWith: { $1 }) -// return -// } + if url.absoluteString.contains(".json") { + // Handle json file. + let data = try Data(contentsOf: url) + let dict = (try? decoder.decode([String: String].self, from: data)) ?? [:] + env.merge(dict, uniquingKeysWith: { $1 }) + return + } let string = try String(contentsOfFile: path(for: url), encoding: .utf8) @@ -82,7 +82,9 @@ private struct LiveFileClient: @unchecked Sendable { for line in lines { logger.trace("Line: \(line)") let strippedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - let splitLine = strippedLine.split(separator: "=") + let splitLine = strippedLine.split(separator: "=").map { + $0.replacingOccurrences(of: "\"", with: "") + } logger.trace("Split Line: \(splitLine)") guard splitLine.count >= 2 else { continue } diff --git a/Sources/hpa/BuildCommand.swift b/Sources/hpa/BuildCommand.swift index 5dceab1..e33f05c 100644 --- a/Sources/hpa/BuildCommand.swift +++ b/Sources/hpa/BuildCommand.swift @@ -8,13 +8,14 @@ struct BuildCommand: AsyncParsableCommand { static let configuration = CommandConfiguration.playbookCommandConfiguration( commandName: commandName, - abstract: "Build a home performance assesment project." + abstract: "Build a home performance assesment project.", + examples: (label: "Build Project", example: "\(commandName) /path/to/project") ) @OptionGroup var globals: GlobalOptions @Argument( - help: "The project directory.", + help: "Path to the project directory.", completion: .directory ) var projectDir: String @@ -25,11 +26,12 @@ struct BuildCommand: AsyncParsableCommand { var extraArgs: [String] = [] mutating func run() async throws { - let args = [ + try await runPlaybook( + commandName: Self.commandName, + globals: globals, + extraArgs: extraArgs, "--tags", "build-project", "--extra-vars", "project_dir=\(projectDir)" - ] + extraArgs - - try await runPlaybook(commandName: Self.commandName, globals: globals, args: args) + ) } } diff --git a/Sources/hpa/CreateProjectCommand.swift b/Sources/hpa/CreateProjectCommand.swift index bf96e75..91594fa 100644 --- a/Sources/hpa/CreateProjectCommand.swift +++ b/Sources/hpa/CreateProjectCommand.swift @@ -1,15 +1,170 @@ import ArgumentParser +import CliClient +import Dependencies +import Foundation +import Logging struct CreateProjectCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "create-project", - abstract: "Create a home performance assesment project." + static let commandName = "create" + + static let configuration = CommandConfiguration.playbookCommandConfiguration( + commandName: commandName, + abstract: "Create a home performance assesment project.", + examples: ( + label: "Create Assesment", + example: "\(commandName) /new/assement/path" + ) ) @OptionGroup var globals: GlobalOptions + @Option( + name: .shortAndLong, + help: "The template repository to use." + ) + var repo: String? + + @Option( + name: .shortAndLong, + help: "The repo branch or version to use." + ) + var branch: String? + + @Option( + name: .shortAndLong, + help: "Path to local template directory to use.", + completion: .directory + ) + var templateDir: String? + + @Flag( + name: .shortAndLong, + help: "Force using a local template directory." + ) + var localTemplateDir = false + + @Argument( + help: "Path to the project directory.", + completion: .directory + ) + var projectDir: String + + @Argument( + help: "Extra arguments passed to the playbook." + ) + var extraArgs: [String] = [] + mutating func run() async throws { - fatalError() + try await _run() + } + + private func _run() async throws { + try await withSetupLogger(commandName: Self.commandName, globals: globals) { + @Dependency(\.cliClient) var cliClient + @Dependency(\.logger) var logger + + let encoder = cliClient.encoder() + + let configuration = try cliClient.loadConfiguration() + + logger.debug("Configuration: \(configuration)") + + let jsonData = try parseOptions( + command: self, + configuration: configuration, + logger: logger, + encoder: encoder + ) + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw CreateError.encodingError + } + + logger.debug("JSON string: \(jsonString)") + + try await runPlaybook( + commandName: Self.commandName, + globals: self.globals, + configuration: configuration, + extraArgs: extraArgs, + "--tags", "setup-project", + "--extra-vars", "project_dir=\(self.projectDir)", + "--extra-vars", "'\(jsonString)'" + ) + } } } + +private func parseOptions( + command: CreateProjectCommand, + configuration: Configuration, + logger: Logger, + encoder: JSONEncoder +) throws -> Data { + let templateDir = command.templateDir ?? configuration.templateDir + let templateRepo = command.repo ?? configuration.templateRepo + let version = (command.branch ?? configuration.templateRepoVersion) ?? "main" + + logger.debug(""" + (\(command.localTemplateDir), \(String(describing: templateDir)), \(String(describing: templateRepo))) + """) + + switch (command.localTemplateDir, templateDir, templateRepo) { + case (true, .none, _): + // User supplied they wanted to use a local template directory, but we could not find + // the path set from command line or in configuration. + throw CreateError.templateDirNotFound + case let (false, _, .some(repo)): + // User did not supply they wanted to use a local template directory, and we found a repo url that was + // either set by the command line or found in the configuration. + logger.debug("Using repo.") + return try encoder.encode(TemplateRepo(repo: repo, version: version)) + case let (true, .some(templateDir), _): + // User supplied they wanted to use a local template directory, and we found the template directory + // either set by the command line or in the configuration. + logger.debug("Using template directory.") + return try encoder.encode(TemplateDirJson(path: templateDir)) + case let (false, .some(templateDir), _): + // User supplied they did not wanted to use a local template directory, and we found the template directory + // either set by the command line or in the configuration, and no repo was found / handled previously. + logger.debug("Using template directory.") + return try encoder.encode(TemplateDirJson(path: templateDir)) + case (_, .none, .none): + // We could not find a repo or template directory. + throw CreateError.templateDirOrRepoNotSpecified + } +} + +private struct TemplateDirJson: Encodable { + + let template: Template + + init(path: String) { + self.template = .init(path: path) + } + + struct Template: Encodable { + let path: String + } +} + +private struct TemplateRepo: Encodable { + + let template: Template + + init(repo: String, version: String?) { + self.template = .init(repo: repo, version: version ?? "main") + } + + struct Template: Encodable { + let repo: String + let version: String + } +} + +enum CreateError: Error { + case encodingError + case templateDirNotFound + case templateDirOrRepoNotSpecified +} diff --git a/Sources/hpa/GlobalOptions.swift b/Sources/hpa/GlobalOptions.swift index 81b2ae9..52dab6c 100644 --- a/Sources/hpa/GlobalOptions.swift +++ b/Sources/hpa/GlobalOptions.swift @@ -15,8 +15,28 @@ struct GlobalOptions: ParsableArguments { var inventoryPath: String? @Flag( - name: .long, - help: "Increase logging level." + 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? + } diff --git a/Sources/hpa/Helpers.swift b/Sources/hpa/Helpers.swift index 1760126..da67918 100644 --- a/Sources/hpa/Helpers.swift +++ b/Sources/hpa/Helpers.swift @@ -7,30 +7,47 @@ import Rainbow import ShellClient extension CommandConfiguration { - static func playbookCommandConfiguration(commandName: String, abstract: String) -> Self { - Self( + static func playbookCommandConfiguration( + commandName: String, + abstract: String, + examples: (label: String, example: String)... + ) -> Self { + guard examples.count > 0 else { + fatalError("Did not supply any examples.") + } + return Self( commandName: commandName, abstract: "\(abstract.blue)", discussion: """ - \("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to - be at the end with `--` before any arguments otherwise there will - be an "Unkown option" error. + \("NOTE:".yellow) Most options are not required if you have a configuration file setup. - \("Example of passing extra args to the playbook:".yellow) + \("Examples:".yellow) - $ hpa \(commandName) /my/project -- --vault-id "myId@$SCRIPTS/vault-gopass-client" + \(examples.map { "\($0.label.green.italic)\n $ hpa \($0.example)" }.joined(separator: "\n")) - \("See Also:".yellow) + \("Passing extra args to the playbook.".green.italic) + $ hpa \(examples[0].example) -- --vault-id "myId@$SCRIPTS/vault-gopass-client" - You can run the following command to see the options that can be passed to the playbook - invocation. + \("See Also:".yellow) \("Ansible playbook options.".italic) $ ansible-playbook --help + + \("IMPORTANT NOTE:".red) Any extra arguments to pass to the playbook invocation have to + be at the end with `--` before any arguments otherwise there will + be an "Unkown option" error. See examples above. """ ) } } +extension GlobalOptions { + + var shellOrDefault: ShellCommand.Shell { + guard let shell else { return .zsh(useDashC: true) } + return .custom(path: shell, useDashC: true) + } +} + func ensureString( globals: GlobalOptions, configuration: Configuration, @@ -41,37 +58,78 @@ func ensureString( return global } guard let configuration = configuration[keyPath: configurationKeyPath] else { - throw PlaybookNotFound() + throw RunPlaybookError.playbookNotFound } return configuration } +func withSetupLogger( + commandName: String, + globals: GlobalOptions, + 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 { + switch globals.verbose { + case 0: + $0.logger.logLevel = .info + case 1: + $0.logger.logLevel = .debug + case 2...: + $0.logger.logLevel = .trace + default: + $0.logger.logLevel = .info + } + } + $0.logger[metadataKey: "command"] = "\(commandName.blue)" + } operation: { + try await operation() + } +} + func runPlaybook( commandName: String, globals: GlobalOptions, - args: [String] + configuration: Configuration? = nil, + extraArgs: [String], + _ args: String... ) async throws { - try await withDependencies { - $0.logger = .init(label: "\("hpa".yellow)") - switch globals.verbose { - case 0: - $0.logger.logLevel = .info - case 1: - $0.logger.logLevel = .debug - case 2: - $0.logger.logLevel = .trace - default: - $0.logger.logLevel = .info + try await runPlaybook( + commandName: commandName, + globals: globals, + configuration: configuration, + extraArgs: extraArgs, + args + ) +} + +private extension CliClient { + func ensuredConfiguration(_ configuration: Configuration?) throws -> Configuration { + guard let configuration else { + return try loadConfiguration() } - $0.logger[metadataKey: "command"] = "\(commandName.blue)" - } operation: { + return configuration + } +} + +func runPlaybook( + commandName: String, + globals: GlobalOptions, + configuration: Configuration? = nil, + extraArgs: [String], + _ args: [String] +) async throws { + try await withSetupLogger(commandName: commandName, globals: globals) { @Dependency(\.cliClient) var cliClient @Dependency(\.logger) var logger - @Dependency(\.asyncShellClient) var shellClient logger.debug("Begin run playbook: \(globals)") - let configuration = try cliClient.loadConfiguration() - logger.debug("Loaded configuration: \(configuration)") + + let configuration = try cliClient.ensuredConfiguration(configuration) + + logger.debug("Configuration: \(configuration)") let playbookDir = try ensureString( globals: globals, @@ -91,19 +149,21 @@ func runPlaybook( var playbookArgs = [ "ansible-playbook", playbook, "--inventory", inventory - ] + args + ] if let defaultArgs = configuration.defaultPlaybookArgs { playbookArgs.append(defaultArgs) } - try await shellClient.foreground(.init( - shell: .zsh(useDashC: true), - environment: ProcessInfo.processInfo.environment, - in: nil, - playbookArgs - )) + try await cliClient.runCommand( + quiet: globals.quietOnlyPlaybook ? true : globals.quiet, + shell: globals.shellOrDefault, + playbookArgs + args + extraArgs + ) } } -struct PlaybookNotFound: Error {} +enum RunPlaybookError: Error { + case playbookNotFound + case configurationError +} diff --git a/Tests/CliClientTests/CliClientTests.swift b/Tests/CliClientTests/CliClientTests.swift index e4d749c..d00c7d8 100644 --- a/Tests/CliClientTests/CliClientTests.swift +++ b/Tests/CliClientTests/CliClientTests.swift @@ -17,7 +17,7 @@ func testFindConfigPaths() throws { @Test func loadConfiguration() throws { - try withTestLogger(key: "loadConfiguration", logLevel: .trace) { + try withTestLogger(key: "loadConfiguration", logLevel: .debug) { $0.cliClient = .liveValue $0.fileClient = .liveValue } operation: { @@ -37,7 +37,7 @@ func withTestLogger( ) rethrows { try withDependencies { $0.logger = .init(label: label) - $0.logger[metadataKey: "instance"] = "\(key)" + $0.logger[metadataKey: "test"] = "\(key)" $0.logger.logLevel = logLevel } operation: { try operation() @@ -53,7 +53,7 @@ func withTestLogger( ) rethrows { try withDependencies { $0.logger = .init(label: label) - $0.logger[metadataKey: "instance"] = "\(key)" + $0.logger[metadataKey: "test"] = "\(key)" $0.logger.logLevel = logLevel setupDependencies(&$0) } operation: {