import Constants import Dependencies import DependenciesMacros import Foundation import ShellClient extension DependencyValues { /// Runs shell commands. public var commandClient: CommandClient { get { self[CommandClient.self] } set { self[CommandClient.self] = newValue } } } @DependencyClient public struct CommandClient: Sendable { /// Runs a shell command. public var runCommand: @Sendable (RunCommandOptions) async throws -> Void /// Runs a shell command, sets up logging, and returns an output value. /// /// This is useful when you need to give some output value to the caller, /// generally for the ability of that output to be piped into other commands. /// @discardableResult public func run( logging logginOptions: LoggingOptions, quiet: Bool = false, shell: String? = nil, in workingDirectory: String? = nil, makeArguments: @Sendable @escaping () async throws -> ([String], T) ) async throws -> T { try await logginOptions.withLogger { let (arguments, returnValue) = try await makeArguments() try await self.run( quiet: quiet, shell: shell, arguments ) return returnValue } } /// Runs a shell command and sets up logging. public func run( logging logginOptions: LoggingOptions, quiet: Bool = false, shell: String? = nil, in workingDirectory: String? = nil, makeArguments: @Sendable @escaping () async throws -> [String] ) async throws { try await run( logging: logginOptions, quiet: quiet, shell: shell, in: workingDirectory, makeArguments: { try await (makeArguments(), ()) } ) } /// Runs a shell command. public func run( 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, in: workingDirectory, arguments ) } public struct RunCommandOptions: Sendable, Equatable { public let arguments: [String] public let quiet: Bool public let shell: String? public let workingDirectory: String? public init( arguments: [String], quiet: Bool, shell: String? = nil, workingDirectory: String? = nil ) { self.arguments = arguments self.quiet = quiet self.shell = shell self.workingDirectory = workingDirectory } } } /// Represents logger setup options used when running commands. /// /// This set's up metadata tags and keys appropriately for the command being run, /// which can aid in debugging. /// public struct LoggingOptions: Equatable, Sendable { /// The command name / key that the logger is running for. public let commandName: String /// The log level. public let logLevel: Logger.Level /// Create the logging options. /// /// - Parameters: /// - commandName: The command name / key that the logger is running for. /// - logLevel: The log level. public init(commandName: String, logLevel: Logger.Level) { self.commandName = commandName self.logLevel = logLevel } /// Perform an operation with a setup logger. /// /// - Parameters: /// - operation: The operation to perform with the setup logger. @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() } } } extension CommandClient: DependencyKey { public static let testValue: CommandClient = Self() public static func live( environment: [String: String] ) -> CommandClient { .init { options in @Dependency(\.asyncShellClient) var shellClient if !options.quiet { try await shellClient.foreground( .init( shell: .init(options.shell), environment: environment, in: options.workingDirectory, options.arguments )) } else { try await shellClient.background( .init( shell: .init(options.shell), environment: environment, in: options.workingDirectory, options.arguments )) } } } public static var liveValue: CommandClient { .live(environment: ProcessInfo.processInfo.environment) } } @_spi(Internal) extension CommandClient { /// Create a command client that can capture the arguments / options. /// /// This is used for testing. public static func capturing(_ client: CapturingClient) -> Self { .init { options in await client.set(options) } } /// Captures the arguments / options passed into the command client's run commands. /// @dynamicMemberLookup public actor CapturingClient: Sendable { public private(set) var options: RunCommandOptions? public init() {} public func set( _ options: RunCommandOptions ) { 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 { #if os(macOS) self = .zsh(useDashC: true) #else // Generally we're in a docker container when this occurs, which does not have `zsh` installed. self = .sh(useDashC: true) #endif } } }