diff --git a/Package.swift b/Package.swift index edba5ed..f5ea50a 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,7 @@ let package = Package( .target( name: "CliClient", dependencies: [ + "CommandClient", "CodersClient", "ConfigurationClient", "PlaybookClient", @@ -64,6 +65,7 @@ let package = Package( .target( name: "CommandClient", dependencies: [ + "Constants", .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "ShellClient", package: "swift-shell-client") @@ -104,16 +106,10 @@ let package = Package( .product(name: "DependenciesMacros", package: "swift-dependencies") ] ), - .target( - name: "LoggingExtensions", - dependencies: [ - "Constants", - .product(name: "ShellClient", package: "swift-shell-client") - ] - ), .target( name: "PlaybookClient", dependencies: [ + "CommandClient", "ConfigurationClient", "FileClient", .product(name: "Dependencies", package: "swift-dependencies"), diff --git a/Sources/CliClient/CliClient+Commands.swift b/Sources/CliClient/CliClient+Commands.swift index ca2e578..b569f0b 100644 --- a/Sources/CliClient/CliClient+Commands.swift +++ b/Sources/CliClient/CliClient+Commands.swift @@ -47,7 +47,7 @@ public extension CliClient { ) let configuration = try await configurationClient.findAndLoad() - try await playbookClient.installPlaybook(configuration) + try await playbookClient.repository.install(configuration) } func runPlaybookCommand( @@ -62,7 +62,7 @@ public extension CliClient { let configuration = try await configurationClient.ensuredConfiguration(options.configuration) logger.trace("Configuration: \(configuration)") - let playbookDirectory = try await playbookClient.playbookDirectory(configuration) + let playbookDirectory = try await playbookClient.repository.directory(configuration) let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" logger.trace("Playbook path: \(playbookPath)") diff --git a/Sources/CliClient/CliClient+RunPlaybook.swift b/Sources/CliClient/CliClient+RunPlaybook.swift index e604ac8..e66371f 100644 --- a/Sources/CliClient/CliClient+RunPlaybook.swift +++ b/Sources/CliClient/CliClient+RunPlaybook.swift @@ -12,7 +12,7 @@ extension CliClient.RunPlaybook { @Dependency(\.logger) var logger @Dependency(\.playbookClient) var playbookClient - let playbookDirectory = try await playbookClient.playbookDirectory(configuration) + let playbookDirectory = try await playbookClient.repository.directory(configuration) let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" logger.trace("Playbook path: \(playbookPath)") @@ -111,7 +111,7 @@ extension CliClient.PlaybookOptions.Route { @Dependency(\.logger) var logger @Dependency(\.playbookClient) var playbookClient - let playbookDirectory = try await playbookClient.playbookDirectory(configuration) + let playbookDirectory = try await playbookClient.repository.directory(configuration) let playbookPath = "\(playbookDirectory)/\(Constants.playbookFileName)" logger.trace("Playbook path: \(playbookPath)") diff --git a/Sources/CommandClient/CommandClient.swift b/Sources/CommandClient/CommandClient.swift index 4ce91ee..44b2a76 100644 --- a/Sources/CommandClient/CommandClient.swift +++ b/Sources/CommandClient/CommandClient.swift @@ -1,6 +1,8 @@ +import Constants import Dependencies import DependenciesMacros import Foundation +import LoggingExtensions import ShellClient public extension DependencyValues { @@ -18,37 +20,93 @@ public struct CommandClient: Sendable { /// Runs a shell command. public var runCommand: @Sendable (RunCommandOptions) async throws -> Void - /// Runs a shell command. + /// Runs a shell command and sets up logging. public func run( - quiet: Bool, - shell: ShellCommand.Shell, - _ arguments: [String] + logging logginOptions: LoggingOptions, + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, + makeArguments: @Sendable @escaping () async throws -> [String] ) async throws { - try await runCommand(.init(arguments: arguments, quiet: quiet, shell: shell)) + try await logginOptions.withLogger { + let arguments = try await makeArguments() + try await self.run( + quiet: quiet, + shell: shell, + arguments + ) + } } /// Runs a shell command. public func run( - quiet: Bool, - shell: ShellCommand.Shell, + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, + _ arguments: [String] + ) async throws { + try await runCommand(.init( + arguments: arguments, + quiet: quiet, + shell: shell, + workingDirectory: workingDirectory + )) + } + + /// Runs a shell command. + public func run( + quiet: Bool = false, + shell: String? = nil, + in workingDirectory: String? = nil, _ arguments: String... ) async throws { - try await run(quiet: quiet, shell: shell, arguments) + try await run( + quiet: quiet, + shell: shell, + in: workingDirectory, + arguments + ) } public struct RunCommandOptions: Sendable, Equatable { public let arguments: [String] public let quiet: Bool - public let shell: ShellCommand.Shell + public let shell: String? + public let workingDirectory: String? public init( arguments: [String], quiet: Bool, - shell: ShellCommand.Shell + shell: String? = nil, + workingDirectory: String? = nil ) { self.arguments = arguments self.quiet = quiet self.shell = shell + self.workingDirectory = workingDirectory + } + } +} + +public struct LoggingOptions: Equatable, Sendable { + public let commandName: String + public let logLevel: Logger.Level + + public init(commandName: String, logLevel: Logger.Level) { + self.commandName = commandName + self.logLevel = logLevel + } + + @discardableResult + public func withLogger( + operation: @Sendable @escaping () async throws -> T + ) async rethrows -> T { + try await withDependencies { + $0.logger = .init(label: "\(Constants.executableName)") + $0.logger.logLevel = logLevel + $0.logger[metadataKey: "command"] = "\(commandName.blue)" + } operation: { + try await operation() } } } @@ -57,26 +115,32 @@ extension CommandClient: DependencyKey { public static let testValue: CommandClient = Self() - public static var liveValue: CommandClient { + public static func live( + environment: [String: String] + ) -> CommandClient { .init { options in @Dependency(\.asyncShellClient) var shellClient if !options.quiet { try await shellClient.foreground(.init( - shell: options.shell, - environment: ProcessInfo.processInfo.environment, - in: nil, + shell: .init(options.shell), + environment: environment, + in: options.workingDirectory, options.arguments )) } else { try await shellClient.background(.init( - shell: options.shell, - environment: ProcessInfo.processInfo.environment, - in: nil, + shell: .init(options.shell), + environment: environment, + in: options.workingDirectory, options.arguments )) } } } + + public static var liveValue: CommandClient { + .live(environment: ProcessInfo.processInfo.environment) + } } @_spi(Internal) @@ -93,21 +157,32 @@ public extension CommandClient { /// Captures the arguments / options passed into the command client's run commands. /// + @dynamicMemberLookup actor CapturingClient: Sendable { - public private(set) var quiet: Bool? - public private(set) var shell: ShellCommand.Shell? - public private(set) var arguments: [String]? + public private(set) var options: RunCommandOptions? public init() {} public func set( _ options: RunCommandOptions ) { - quiet = options.quiet - shell = options.shell - arguments = options.arguments + self.options = options + } + + public subscript(dynamicMember keyPath: KeyPath) -> T? { + options?[keyPath: keyPath] } } } extension ShellCommand.Shell: @retroactive @unchecked Sendable {} + +extension ShellCommand.Shell { + init(_ path: String?) { + if let path { + self = .custom(path: path, useDashC: true) + } else { + self = .zsh(useDashC: true) + } + } +} diff --git a/Sources/LoggingExtensions/LoggingExtensions.swift b/Sources/LoggingExtensions/LoggingExtensions.swift deleted file mode 100644 index 6b6248f..0000000 --- a/Sources/LoggingExtensions/LoggingExtensions.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Constants -import Dependencies -import Foundation -import ShellClient - -public struct LoggingOptions: Equatable, Sendable { - public let commandName: String - public let logLevel: Logger.Level - - public init(commandName: String, logLevel: Logger.Level) { - self.commandName = commandName - self.logLevel = logLevel - } - - @discardableResult - public func withLogger( - operation: @Sendable @escaping () async throws -> T - ) async rethrows -> T { - try await withDependencies { - $0.logger = .init(label: "\(Constants.executableName)") - $0.logger.logLevel = logLevel - $0.logger[metadataKey: "command"] = "\(commandName.blue)" - } operation: { - try await operation() - } - } -} diff --git a/Sources/PlaybookClient/PlaybookClient+Repository.swift b/Sources/PlaybookClient/PlaybookClient+Repository.swift new file mode 100644 index 0000000..005de58 --- /dev/null +++ b/Sources/PlaybookClient/PlaybookClient+Repository.swift @@ -0,0 +1,79 @@ +import CommandClient +import ConfigurationClient +import Dependencies +import FileClient +import Foundation +import ShellClient + +extension PlaybookClient.Repository { + + static func findDirectory( + configuration: Configuration? = nil + ) async throws -> String { + @Dependency(\.configurationClient) var configurationClient + + var configuration: Configuration! = configuration + if configuration == nil { + configuration = try await configurationClient.findAndLoad() + } + + return configuration.playbook?.directory + ?? PlaybookClient.Constants.defaultInstallationPath + } + + static func installPlaybook( + configuration: Configuration? + ) async throws { + @Dependency(\.commandClient) var commandClient + @Dependency(\.configurationClient) var configurationClient + @Dependency(\.fileClient) var fileClient + @Dependency(\.logger) var logger + + var configuration: Configuration! = configuration + + if configuration == nil { + configuration = try await configurationClient.findAndLoad() + } + + let (path, version) = parsePlaybookPathAndVerion( + configuration?.playbook + ) + + let parentDirectory = URL(filePath: path) + .deletingLastPathComponent() + + let parentExists = try await fileClient.isDirectory(parentDirectory) + if !parentExists { + try await fileClient.createDirectory(parentDirectory) + } + + let playbookExists = try await fileClient.isDirectory(URL(filePath: path)) + + if !playbookExists { + logger.debug("Cloning playbook to: \(path)") + try await commandClient.run( + "git", "clone", "--branch", version, + PlaybookClient.Constants.playbookRepoUrl, path + ) + } else { + logger.debug("Playbook exists, ensuring it's up to date.") + try await commandClient.run( + in: path, + "git", "pull", "--tags" + ) + try await commandClient.run( + in: path, + "git", "checkout", version + ) + } + } + + static func parsePlaybookPathAndVerion( + _ configuration: Configuration.Playbook? + ) -> (path: String, version: String) { + return ( + path: configuration?.directory ?? PlaybookClient.Constants.defaultInstallationPath, + version: configuration?.version ?? PlaybookClient.Constants.playbookRepoVersion + ) + } +} diff --git a/Sources/PlaybookClient/PlaybookClient.swift b/Sources/PlaybookClient/PlaybookClient.swift index f50058e..ef0b7f7 100644 --- a/Sources/PlaybookClient/PlaybookClient.swift +++ b/Sources/PlaybookClient/PlaybookClient.swift @@ -16,64 +16,40 @@ public extension DependencyValues { @DependencyClient public struct PlaybookClient: Sendable { - - // TODO: Remove the configuration and have it passed in. - public var installPlaybook: @Sendable (Configuration) async throws -> Void - public var playbookDirectory: @Sendable (Configuration) async throws -> String - + public var repository: Repository } -extension PlaybookClient: DependencyKey { - public static let testValue: PlaybookClient = Self() +public extension PlaybookClient { - public static var liveValue: PlaybookClient { - .init { - try await install(config: $0.playbook) - } playbookDirectory: { - $0.playbook?.directory ?? Constants.defaultInstallationPath + @DependencyClient + struct Repository: Sendable { + public var install: @Sendable (Configuration?) async throws -> Void + public var directory: @Sendable (Configuration?) async throws -> String + + public func install() async throws { + try await install(nil) + } + + public func directory() async throws -> String { + try await directory(nil) + } + + public static var liveValue: Self { + .init { + try await installPlaybook(configuration: $0) + } directory: { + try await findDirectory(configuration: $0) + } } } } -private func install(config: Configuration.Playbook?) async throws { - @Dependency(\.fileClient) var fileClient - @Dependency(\.logger) var logger - @Dependency(\.asyncShellClient) var shell +extension PlaybookClient: DependencyKey { + public static let testValue: PlaybookClient = Self(repository: Repository()) - let (path, version) = parsePlaybookPathAndVerion(config) - - let parentDirectory = URL(filePath: path) - .deletingLastPathComponent() - - let parentExists = try await fileClient.isDirectory(parentDirectory) - if !parentExists { - try await fileClient.createDirectory(parentDirectory) - } - - let playbookExists = try await fileClient.isDirectory(URL(filePath: path)) - - if !playbookExists { - try await shell.foreground(.init([ - "git", "clone", - "--branch", version, - PlaybookClient.Constants.playbookRepoUrl, path - ])) - } else { - logger.debug("Playbook exists, ensuring it's up to date.") - try await shell.foreground(.init( - in: path, - ["git", "pull", "--tags"] - )) - try await shell.foreground(.init( - in: path, - ["git", "checkout", version] - )) + public static var liveValue: PlaybookClient { + .init( + repository: .liveValue + ) } } - -private func parsePlaybookPathAndVerion(_ configuration: Configuration.Playbook?) -> (path: String, version: String) { - return ( - path: configuration?.directory ?? PlaybookClient.Constants.defaultInstallationPath, - version: configuration?.version ?? PlaybookClient.Constants.playbookRepoVersion - ) -}