This commit is contained in:
233
Sources/CommandClient/CommandClient.swift
Normal file
233
Sources/CommandClient/CommandClient.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
import Constants
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import ShellClient
|
||||
|
||||
public extension DependencyValues {
|
||||
|
||||
/// Runs shell commands.
|
||||
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<T>(
|
||||
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<T>(
|
||||
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)
|
||||
public extension CommandClient {
|
||||
|
||||
/// Create a command client that can capture the arguments / options.
|
||||
///
|
||||
/// This is used for testing.
|
||||
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
|
||||
actor CapturingClient: Sendable {
|
||||
public private(set) var options: RunCommandOptions?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func set(
|
||||
_ options: RunCommandOptions
|
||||
) {
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<RunCommandOptions, T>) -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user