diff --git a/Sources/Argument.swift b/Sources/Argument.swift index 13146f6..7321fbb 100644 --- a/Sources/Argument.swift +++ b/Sources/Argument.swift @@ -1,7 +1,7 @@ import Foundation -public enum Argument { +public enum Argument: Equatable { case quoted(QuotedString) case verbatim(String) @@ -12,15 +12,6 @@ public enum Argument { public init(verbatim string: String) { self = .verbatim(string) } - - public var string: String { - switch self { - case let .quoted(value): - return value.quoted - case let .verbatim(string): - return string - } - } } @@ -32,7 +23,14 @@ extension Argument: ExpressibleByStringLiteral { extension Argument: CustomStringConvertible { - public var description: String { string } + public var description: String { + switch self { + case let .quoted(value): + return value.quoted + case let .verbatim(string): + return string + } + } } diff --git a/Sources/QuotedString.swift b/Sources/QuotedString.swift index 2c5b8f7..d177a7e 100644 --- a/Sources/QuotedString.swift +++ b/Sources/QuotedString.swift @@ -1,7 +1,7 @@ import ShellQuote -public struct QuotedString { +public struct QuotedString: Equatable { public var unquoted: String public var quoted: String diff --git a/Sources/SafeString.swift b/Sources/SafeString.swift deleted file mode 100644 index 06c3893..0000000 --- a/Sources/SafeString.swift +++ /dev/null @@ -1,29 +0,0 @@ -import ShellQuote - - -public struct SafeString { - public var value: String - - public init(_ value: String) throws { - guard !ShellQuote.hasUnsafeContent(value) else { - throw ShellOutCommand.Error(message: "Command must not contain characters that require quoting, was: \(value)") - } - self.value = value - } - - public init(unchecked value: String) { - self.value = value - } -} - -extension SafeString: CustomStringConvertible { - public var description: String { value } -} - - -extension String { - public var checked: SafeString { - get throws { try .init(self) } - } - public var unchecked: SafeString { .init(unchecked: self) } -} diff --git a/Sources/ShellOut.swift b/Sources/ShellOut.swift index 9566b85..158f8d8 100644 --- a/Sources/ShellOut.swift +++ b/Sources/ShellOut.swift @@ -13,12 +13,11 @@ import Algorithms // MARK: - API /** - * Run a shell command using Bash + * Run a shell command * * - parameter command: The command to run * - parameter arguments: The arguments to pass to the command * - parameter path: The path to execute the commands at (defaults to current folder) - * - parameter process: Which process to use to perform the command (default: A new one) * - parameter outputHandle: Any `FileHandle` that any output (STDOUT) should be redirected to * (at the moment this is only supported on macOS) * - parameter errorHandle: Any `FileHandle` that any error output (STDERR) should be redirected to @@ -32,18 +31,17 @@ import Algorithms * For example: `shellOut(to: "mkdir", arguments: ["NewFolder"], at: "~/CurrentFolder")` */ @discardableResult public func shellOut( - to command: SafeString, - arguments: [Argument] = [], + to command: String, + arguments: [String] = [], at path: String = ".", logger: Logger? = nil, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil, environment: [String : String]? = nil ) async throws -> (stdout: String, stderr: String) { - let command = "\(command) \(arguments.map(\.string).joined(separator: " "))" - - return try await TSCBasic.Process.launchBash( - with: command, + try await TSCBasic.Process.launch( + command: command, + arguments: arguments, logger: logger, outputHandle: outputHandle, errorHandle: errorHandle, @@ -56,11 +54,10 @@ import Algorithms } /** - * Run a pre-defined shell command using Bash + * Run a pre-defined shell command * * - parameter command: The command to run * - parameter path: The path to execute the commands at (defaults to current folder) - * - parameter process: Which process to use to perform the command (default: A new one) * - parameter outputHandle: Any `FileHandle` that any output (STDOUT) should be redirected to * - parameter errorHandle: Any `FileHandle` that any error output (STDERR) should be redirected to * - parameter environment: The environment for the command. @@ -92,315 +89,13 @@ import Algorithms ) } -/// Structure used to pre-define commands for use with ShellOut -public struct ShellOutCommand { - /// The string that makes up the command that should be run on the command line - public var command: SafeString - - public var arguments: [Argument] - - /// Initialize a value using a string that makes up the underlying command - public init(command: String, arguments: [Argument] = []) throws { - self.init(command: try SafeString(command), arguments: arguments) - } - - public init(command: SafeString, arguments: [Argument] = []) { - self.command = command - self.arguments = arguments - } - - public var string: String { - ([command.value] + arguments.map(\.string)) - .joined(separator: " ") - } - - public func appending(arguments newArguments: [Argument]) -> Self { - .init(command: command, arguments: arguments + newArguments) - } - - public func appending(argument: Argument) -> Self { - appending(arguments: [argument]) - } - - public mutating func append(arguments newArguments: [Argument]) { - self.arguments = self.arguments + newArguments - } - - public mutating func append(argument: Argument) { - append(arguments: [argument]) - } -} - -/// Git commands -public extension ShellOutCommand { - /// Initialize a git repository - static func gitInit() -> ShellOutCommand { - .init(command: "git".unchecked, arguments: ["init".verbatim]) - } - - /// Clone a git repository at a given URL - static func gitClone(url: URL, to path: String? = nil, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { - var command = git(allowingPrompt: allowingPrompt) - .appending(arguments: ["clone", url.absoluteString].quoted) - - path.map { command.append(argument: $0.quoted) } - - if quiet { - command.append(argument: "--quiet".verbatim) - } - - return command - } - - /// Create a git commit with a given message (also adds all untracked file to the index) - static func gitCommit(message: String, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { - var command = git(allowingPrompt: allowingPrompt) - .appending(arguments: ["add . && git commit -a -m".verbatim]) - command.append(argument: message.quoted) - - if quiet { - command.append(argument: "--quiet".verbatim) - } - - return command - } - - /// Perform a git push - static func gitPush(remote: String? = nil, branch: String? = nil, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { - var command = git(allowingPrompt: allowingPrompt) - .appending(arguments: ["push".verbatim]) - remote.map { command.append(argument: $0.verbatim) } - branch.map { command.append(argument: $0.verbatim) } - - if quiet { - command.append(argument: "--quiet".verbatim) - } - - return command - } - - /// Perform a git pull - static func gitPull(remote: String? = nil, branch: String? = nil, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { - var command = git(allowingPrompt: allowingPrompt) - .appending(arguments: ["pull".verbatim]) - remote.map { command.append(argument: $0.quoted) } - branch.map { command.append(argument: $0.quoted) } - - if quiet { - command.append(argument: "--quiet".verbatim) - } - - return command - } - - /// Run a git submodule update - static func gitSubmoduleUpdate(initializeIfNeeded: Bool = true, recursive: Bool = true, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { - var command = git(allowingPrompt: allowingPrompt) - .appending(arguments: ["submodule update".verbatim]) - - if initializeIfNeeded { - command.append(argument: "--init".verbatim) - } - - if recursive { - command.append(argument: "--recursive".verbatim) - } - - if quiet { - command.append(argument: "--quiet".verbatim) - } - - return command - } - - /// Checkout a given git branch - static func gitCheckout(branch: String, quiet: Bool = true) -> ShellOutCommand { - var command = ShellOutCommand(command: "git".unchecked, - arguments: ["checkout".verbatim, branch.quoted]) - - if quiet { - command.append(argument: "--quiet".verbatim) - } - - return command - } - - private static func git(allowingPrompt: Bool) -> Self { - allowingPrompt - ? .init(command: "git".unchecked) - : .init(command: "env".unchecked, - arguments: ["GIT_TERMINAL_PROMPT=0", "git"].verbatim) - - } -} - -/// File system commands -public extension ShellOutCommand { - /// Create a folder with a given name - static func createFolder(named name: String) -> ShellOutCommand { - .init(command: "mkdir".unchecked, arguments: [name.quoted]) - } - - /// Create a file with a given name and contents (will overwrite any existing file with the same name) - static func createFile(named name: String, contents: String) -> ShellOutCommand { - .init(command: "echo".unchecked, arguments: [contents.quoted]) - .appending(argument: ">".verbatim) - .appending(argument: name.quoted) - } - - /// Move a file from one path to another - static func moveFile(from originPath: String, to targetPath: String) -> ShellOutCommand { - .init(command: "mv".unchecked, arguments: [originPath, targetPath].quoted) - } - - /// Copy a file from one path to another - static func copyFile(from originPath: String, to targetPath: String) -> ShellOutCommand { - .init(command: "cp".unchecked, arguments: [originPath, targetPath].quoted) - } - - /// Remove a file - static func removeFile(from path: String, arguments: [String] = ["-f"]) -> ShellOutCommand { - .init(command: "rm".unchecked, arguments: arguments.quoted + [path.quoted]) - } - - /// Open a file using its designated application - static func openFile(at path: String) -> ShellOutCommand { - .init(command: "open".unchecked, arguments: [path.quoted]) - } - - /// Read a file as a string - static func readFile(at path: String) -> ShellOutCommand { - .init(command: "cat".unchecked, arguments: [path.quoted]) - } - - /// Create a symlink at a given path, to a given target - static func createSymlink(to targetPath: String, at linkPath: String) -> ShellOutCommand { - .init(command: "ln".unchecked, arguments: ["-s", targetPath, linkPath].quoted) - } - - /// Expand a symlink at a given path, returning its target path - static func expandSymlink(at path: String) -> ShellOutCommand { - .init(command: "readlink".unchecked, arguments: [path.quoted]) - } -} - -/// Marathon commands -public extension ShellOutCommand { - /// Run a Marathon Swift script - static func runMarathonScript(at path: String, arguments: [String] = []) -> ShellOutCommand { - .init(command: "marathon".unchecked, - arguments: ["run", path].quoted + arguments.quoted) - } - - /// Update all Swift packages managed by Marathon - static func updateMarathonPackages() -> ShellOutCommand { - .init(command: "marathon".unchecked, - arguments: ["update".verbatim]) - } -} - -/// Swift Package Manager commands -public extension ShellOutCommand { - /// Enum defining available package types when using the Swift Package Manager - enum SwiftPackageType: String { - case library - case executable - } - - /// Enum defining available build configurations when using the Swift Package Manager - enum SwiftBuildConfiguration: String { - case debug - case release - } - - /// Create a Swift package with a given type (see SwiftPackageType for options) - static func createSwiftPackage(withType type: SwiftPackageType = .library) -> ShellOutCommand { - .init(command: "swift".unchecked, - arguments: ["package init --type \(type)".verbatim]) - } - - /// Update all Swift package dependencies - static func updateSwiftPackages() -> ShellOutCommand { - .init(command: "swift".unchecked, arguments: ["package", "update"].verbatim) - } - - /// Build a Swift package using a given configuration (see SwiftBuildConfiguration for options) - static func buildSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { - .init(command: "swift".unchecked, - arguments: ["build -c \(configuration)".verbatim]) - } - - /// Test a Swift package using a given configuration (see SwiftBuildConfiguration for options) - static func testSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { - .init(command: "swift".unchecked, - arguments: ["test -c \(configuration)".verbatim]) - } -} - -/// Fastlane commands -public extension ShellOutCommand { - /// Run Fastlane using a given lane - static func runFastlane(usingLane lane: String) -> ShellOutCommand { - .init(command: "fastlane".unchecked, arguments: [lane.quoted]) - } -} - -/// CocoaPods commands -public extension ShellOutCommand { - /// Update all CocoaPods dependencies - static func updateCocoaPods() -> ShellOutCommand { - .init(command: "pod".unchecked, arguments: ["update".verbatim]) - } - - /// Install all CocoaPods dependencies - static func installCocoaPods() -> ShellOutCommand { - .init(command: "pod".unchecked, arguments: ["install".verbatim]) - } -} - -/// Error type thrown by the `shellOut()` function, in case the given command failed -public struct ShellOutError: Swift.Error { - /// The termination status of the command that was run - public let terminationStatus: Int32 - /// The error message as a UTF8 string, as returned through `STDERR` - public var message: String { return errorData.shellOutput() } - /// The raw error buffer data, as returned through `STDERR` - public let errorData: Data - /// The raw output buffer data, as retuned through `STDOUT` - public let outputData: Data - /// The output of the command as a UTF8 string, as returned through `STDOUT` - public var output: String { return outputData.shellOutput() } -} - -extension ShellOutError: CustomStringConvertible { - public var description: String { - return """ - ShellOut encountered an error - Status code: \(terminationStatus) - Message: "\(message)" - Output: "\(output)" - """ - } -} - -extension ShellOutError: LocalizedError { - public var errorDescription: String? { - return description - } -} - -extension ShellOutCommand { - // TODO: consolidate with ShellOutError - struct Error: Swift.Error { - var message: String - } -} // MARK: - Private private extension TSCBasic.Process { - @discardableResult static func launchBash( - with command: String, + @discardableResult static func launch( + command: String, + arguments: [String], logger: Logger? = nil, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil, @@ -408,7 +103,7 @@ private extension TSCBasic.Process { at: String? = nil ) async throws -> (stdout: String, stderr: String) { let process = try Self.init( - arguments: ["/bin/bash", "-c", command], + arguments: [command] + arguments, environment: environment ?? ProcessEnv.vars, workingDirectory: at.map { try .init(validating: $0) } ?? TSCBasic.localFileSystem.currentWorkingDirectory ?? .root, outputRedirection: .collect(redirectStderr: false), @@ -444,15 +139,3 @@ private extension TSCBasic.Process { } } -private extension Data { - func shellOutput() -> String { - let output = String(decoding: self, as: UTF8.self) - - guard !output.hasSuffix("\n") else { - return String(output.dropLast()) - } - - return output - - } -} diff --git a/Sources/ShellOutCommand+git.swift b/Sources/ShellOutCommand+git.swift new file mode 100644 index 0000000..cde1aa4 --- /dev/null +++ b/Sources/ShellOutCommand+git.swift @@ -0,0 +1,104 @@ +import Foundation + + +/// Git commands +public extension ShellOutCommand { + /// Initialize a git repository + static func gitInit() -> ShellOutCommand { + .init(command: "git", arguments: ["init"]) + } + + /// Clone a git repository at a given URL + static func gitClone(url: URL, to path: String? = nil, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { + var command = git(allowingPrompt: allowingPrompt) + .appending(arguments: ["clone", url.absoluteString]) + + path.map { command.append(argument: $0) } + + if quiet { + command.append(argument: "--quiet") + } + + return command + } + + /// Create a git commit with a given message (also adds all untracked file to the index) + static func gitCommit(message: String, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { + var command = git(allowingPrompt: allowingPrompt) + .appending(arguments: ["commit", "-a", "-m", "\(message.quoted)"]) + + if quiet { + command.append(argument: "--quiet") + } + + return command + } + + /// Perform a git push + static func gitPush(remote: String? = nil, branch: String? = nil, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { + var command = git(allowingPrompt: allowingPrompt) + .appending(arguments: ["push"]) + remote.map { command.append(argument: $0) } + branch.map { command.append(argument: $0) } + + if quiet { + command.append(argument: "--quiet") + } + + return command + } + + /// Perform a git pull + static func gitPull(remote: String? = nil, branch: String? = nil, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { + var command = git(allowingPrompt: allowingPrompt) + .appending(arguments: ["pull"]) + remote.map { command.append(argument: $0) } + branch.map { command.append(argument: $0) } + + if quiet { + command.append(argument: "--quiet") + } + + return command + } + + /// Run a git submodule update + static func gitSubmoduleUpdate(initializeIfNeeded: Bool = true, recursive: Bool = true, allowingPrompt: Bool = true, quiet: Bool = true) -> ShellOutCommand { + var command = git(allowingPrompt: allowingPrompt) + .appending(arguments: ["submodule update"]) + + if initializeIfNeeded { + command.append(argument: "--init") + } + + if recursive { + command.append(argument: "--recursive") + } + + if quiet { + command.append(argument: "--quiet") + } + + return command + } + + /// Checkout a given git branch + static func gitCheckout(branch: String, quiet: Bool = true) -> ShellOutCommand { + var command = ShellOutCommand(command: "git", + arguments: ["checkout", branch]) + + if quiet { + command.append(argument: "--quiet") + } + + return command + } + + private static func git(allowingPrompt: Bool) -> Self { + allowingPrompt + ? .init(command: "git") + : .init(command: "env", + arguments: ["GIT_TERMINAL_PROMPT=0", "git"]) + + } +} diff --git a/Sources/ShellOutCommand+other.swift b/Sources/ShellOutCommand+other.swift new file mode 100644 index 0000000..8cee001 --- /dev/null +++ b/Sources/ShellOutCommand+other.swift @@ -0,0 +1,94 @@ +public extension ShellOutCommand { + static func bash(arguments: [Argument]) -> Self { + let arguments = arguments.first == "-c" ? Array(arguments.dropFirst()) : arguments + return .init(command: "bash", arguments: ["-c", arguments.map(\.description).joined(separator: " ")]) + } +} + + +/// File system commands +public extension ShellOutCommand { + /// Create a folder with a given name + static func createFolder(named name: String) -> ShellOutCommand { + .init(command: "mkdir", arguments: [name]) + } + + /// Create a file with a given name and contents (will overwrite any existing file with the same name) + static func createFile(named name: String, contents: String) -> ShellOutCommand { + .bash(arguments: ["-c", #"echo \#(contents.quoted) > \#(name.quoted)"#.verbatim]) + } + + /// Move a file from one path to another + static func moveFile(from originPath: String, to targetPath: String) -> ShellOutCommand { + .init(command: "mv", arguments: [originPath, targetPath]) + } + + /// Copy a file from one path to another + static func copyFile(from originPath: String, to targetPath: String) -> ShellOutCommand { + .init(command: "cp", arguments: [originPath, targetPath]) + } + + /// Remove a file + static func removeFile(from path: String, arguments: [String] = ["-f"]) -> ShellOutCommand { + .init(command: "rm", arguments: arguments + [path]) + } + + /// Open a file using its designated application + static func openFile(at path: String) -> ShellOutCommand { + .init(command: "open", arguments: [path]) + } + + /// Read a file as a string + static func readFile(at path: String) -> ShellOutCommand { + .init(command: "cat", arguments: [path]) + } + + /// Create a symlink at a given path, to a given target + static func createSymlink(to targetPath: String, at linkPath: String) -> ShellOutCommand { + .init(command: "ln", arguments: ["-s", targetPath, linkPath]) + } + + /// Expand a symlink at a given path, returning its target path + static func expandSymlink(at path: String) -> ShellOutCommand { + .init(command: "readlink", arguments: [path]) + } +} + + +/// Swift Package Manager commands +public extension ShellOutCommand { + /// Enum defining available package types when using the Swift Package Manager + enum SwiftPackageType: String { + case library + case executable + } + + /// Enum defining available build configurations when using the Swift Package Manager + enum SwiftBuildConfiguration: String { + case debug + case release + } + + /// Create a Swift package with a given type (see SwiftPackageType for options) + static func createSwiftPackage(withType type: SwiftPackageType = .library) -> ShellOutCommand { + .init(command: "swift", + arguments: ["package", "init", "--type", "\(type)"]) + } + + /// Update all Swift package dependencies + static func updateSwiftPackages() -> ShellOutCommand { + .init(command: "swift", arguments: ["package", "update"]) + } + + /// Build a Swift package using a given configuration (see SwiftBuildConfiguration for options) + static func buildSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { + .init(command: "swift", + arguments: ["build", "-c", "\(configuration)"]) + } + + /// Test a Swift package using a given configuration (see SwiftBuildConfiguration for options) + static func testSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { + .init(command: "swift", + arguments: ["test", "-c", "\(configuration)"]) + } +} diff --git a/Sources/ShellOutCommand.swift b/Sources/ShellOutCommand.swift new file mode 100644 index 0000000..245f26c --- /dev/null +++ b/Sources/ShellOutCommand.swift @@ -0,0 +1,35 @@ +/// Structure used to pre-define commands for use with ShellOut +public struct ShellOutCommand { + /// The string that makes up the command that should be run on the command line + public var command: String + + public var arguments: [String] + + /// Initialize a value using a string that makes up the underlying command + public init(command: String, arguments: [String] = []) { + self.command = command + self.arguments = arguments + } + + public func appending(arguments newArguments: [String]) -> Self { + .init(command: command, arguments: arguments + newArguments) + } + + public func appending(argument: String) -> Self { + appending(arguments: [argument]) + } + + public mutating func append(arguments newArguments: [String]) { + self.arguments = self.arguments + newArguments + } + + public mutating func append(argument: String) { + append(arguments: [argument]) + } +} + +extension ShellOutCommand: CustomStringConvertible { + public var description: String { + ([command] + arguments).joined(separator: " ") + } +} diff --git a/Sources/ShellOutError.swift b/Sources/ShellOutError.swift new file mode 100644 index 0000000..c782c83 --- /dev/null +++ b/Sources/ShellOutError.swift @@ -0,0 +1,46 @@ +import Foundation + + +/// Error type thrown by the `shellOut()` function, in case the given command failed +public struct ShellOutError: Swift.Error { + /// The termination status of the command that was run + public let terminationStatus: Int32 + /// The error message as a UTF8 string, as returned through `STDERR` + public var message: String { return errorData.shellOutput() } + /// The raw error buffer data, as returned through `STDERR` + public let errorData: Data + /// The raw output buffer data, as retuned through `STDOUT` + public let outputData: Data + /// The output of the command as a UTF8 string, as returned through `STDOUT` + public var output: String { return outputData.shellOutput() } +} + +extension ShellOutError: CustomStringConvertible { + public var description: String { + return """ + ShellOut encountered an error + Status code: \(terminationStatus) + Message: "\(message)" + Output: "\(output)" + """ + } +} + +extension ShellOutError: LocalizedError { + public var errorDescription: String? { + return description + } +} + + +private extension Data { + func shellOutput() -> String { + let output = String(decoding: self, as: UTF8.self) + + guard !output.hasSuffix("\n") else { + return String(output.dropLast()) + } + + return output + } +} diff --git a/Tests/ShellOutTests/ShellOutTests.swift b/Tests/ShellOutTests/ShellOutTests.swift index d5fb448..ab75ebc 100644 --- a/Tests/ShellOutTests/ShellOutTests.swift +++ b/Tests/ShellOutTests/ShellOutTests.swift @@ -27,48 +27,48 @@ func XCTAssertEqualAsync( class ShellOutTests: XCTestCase { func test_appendArguments() throws { - var cmd = try ShellOutCommand(command: "foo") - XCTAssertEqual(cmd.string, "foo") - cmd.append(arguments: [";", "bar"].quoted) - XCTAssertEqual(cmd.string, "foo ';' bar" ) - cmd.append(arguments: ["> baz".verbatim]) - XCTAssertEqual(cmd.string, "foo ';' bar > baz" ) + var cmd = ShellOutCommand(command: "foo") + XCTAssertEqual(cmd.description, "foo") + cmd.append(arguments: [";", "bar"]) + XCTAssertEqual(cmd.description, "foo ; bar" ) + cmd.append(arguments: ["> baz"]) + XCTAssertEqual(cmd.description, "foo ; bar > baz" ) } func test_appendingArguments() throws { - let cmd = try ShellOutCommand(command: "foo") + let cmd = ShellOutCommand(command: "foo") XCTAssertEqual( - cmd.appending(arguments: [";", "bar"].quoted).string, - "foo ';' bar" + cmd.appending(arguments: [";", "bar"]).description, + "foo ; bar" ) XCTAssertEqual( - cmd.appending(arguments: [";", "bar"].quoted) - .appending(arguments: ["> baz".verbatim]) - .string, - "foo ';' bar > baz" + cmd.appending(arguments: [";", "bar"]) + .appending(arguments: ["> baz"]) + .description, + "foo ; bar > baz" ) } func testWithoutArguments() async throws { - let uptime = try await shellOut(to: "uptime".checked).stdout + let uptime = try await shellOut(to: "uptime").stdout XCTAssertTrue(uptime.contains("load average")) } func testWithArguments() async throws { - let echo = try await shellOut(to: "echo".checked, arguments: ["Hello world".quoted]).stdout + let echo = try await shellOut(to: "echo", arguments: ["Hello world"]).stdout XCTAssertEqual(echo, "Hello world") } func testSingleCommandAtPath() async throws { let tempDir = NSTemporaryDirectory() try await shellOut( - to: "echo".checked, - arguments: ["Hello", ">".verbatim, "\(tempDir)ShellOutTests-SingleCommand.txt".quoted] + to: "bash", + arguments: ["-c", #"echo Hello > "\#(tempDir)/ShellOutTests-SingleCommand.txt""#] ) let textFileContent = try await shellOut( - to: "cat".checked, - arguments: ["ShellOutTests-SingleCommand.txt".quoted], + to: "cat", + arguments: ["ShellOutTests-SingleCommand.txt"], at: tempDir ).stdout @@ -76,26 +76,26 @@ class ShellOutTests: XCTestCase { } func testSingleCommandAtPathContainingSpace() async throws { - try await shellOut(to: "mkdir".checked, - arguments: ["-p".verbatim, "ShellOut Test Folder".quoted], + try await shellOut(to: "mkdir", + arguments: ["-p", "ShellOut Test Folder"], at: NSTemporaryDirectory()) - try await shellOut(to: "echo".checked, arguments: ["Hello", ">", "File"].verbatim, + try await shellOut(to: "bash", arguments: ["-c", "echo Hello > File"], at: NSTemporaryDirectory() + "ShellOut Test Folder") let output = try await shellOut( - to: "cat".checked, - arguments: ["\(NSTemporaryDirectory())ShellOut Test Folder/File".quoted]).stdout + to: "cat", + arguments: ["\(NSTemporaryDirectory())ShellOut Test Folder/File"]).stdout XCTAssertEqual(output, "Hello") } func testSingleCommandAtPathContainingTilde() async throws { - let homeContents = try await shellOut(to: "ls".checked, arguments: ["-a"], at: "~").stdout + let homeContents = try await shellOut(to: "ls", arguments: ["-a"], at: "~").stdout XCTAssertFalse(homeContents.isEmpty) } func testThrowingError() async { do { - try await shellOut(to: "cd".checked, arguments: ["notADirectory".verbatim]) + try await shellOut(to: .bash(arguments: ["cd notADirectory"])) XCTFail("Expected expression to throw") } catch let error as ShellOutError { XCTAssertTrue(error.message.contains("notADirectory")) @@ -129,8 +129,8 @@ class ShellOutTests: XCTestCase { func testCapturingOutputWithHandle() async throws { let pipe = Pipe() - let output = try await shellOut(to: "echo".checked, - arguments: ["Hello".verbatim], + let output = try await shellOut(to: "echo", + arguments: ["Hello"], outputHandle: pipe.fileHandleForWriting).stdout let capturedData = pipe.fileHandleForReading.readDataToEndOfFile() XCTAssertEqual(output, "Hello") @@ -141,9 +141,8 @@ class ShellOutTests: XCTestCase { let pipe = Pipe() do { - try await shellOut(to: "cd".checked, - arguments: ["notADirectory".verbatim], - errorHandle: pipe.fileHandleForWriting) + try await shellOut(to: .bash(arguments: ["cd notADirectory"]), + errorHandle: pipe.fileHandleForWriting) XCTFail("Expected expression to throw") } catch let error as ShellOutError { XCTAssertTrue(error.message.contains("notADirectory")) @@ -157,21 +156,14 @@ class ShellOutTests: XCTestCase { } } - func test_createFile() async throws { - let tempFolderPath = NSTemporaryDirectory() - try await shellOut(to: .createFile(named: "Test", contents: "Hello world"), - at: tempFolderPath, logger: .init(label: "test")) - await XCTAssertEqualAsync(try await shellOut(to: .readFile(at: tempFolderPath + "Test")).stdout, "Hello world") - } - func testGitCommands() async throws { // Setup & clear state let tempFolderPath = NSTemporaryDirectory() - try await shellOut(to: "rm".checked, - arguments: ["-rf", "GitTestOrigin"].verbatim, + try await shellOut(to: "rm", + arguments: ["-rf", "GitTestOrigin"], at: tempFolderPath, logger: .init(label: "test")) - try await shellOut(to: "rm".checked, - arguments: ["-rf", "GitTestClone"].verbatim, + try await shellOut(to: "rm", + arguments: ["-rf", "GitTestClone"], at: tempFolderPath, logger: .init(label: "test")) // Create a origin repository and make a commit with a file @@ -179,6 +171,7 @@ class ShellOutTests: XCTestCase { try await shellOut(to: .createFolder(named: "GitTestOrigin"), at: tempFolderPath, logger: .init(label: "test")) try await shellOut(to: .gitInit(), at: originPath, logger: .init(label: "test")) try await shellOut(to: .createFile(named: "Test", contents: "Hello world"), at: originPath, logger: .init(label: "test")) + try await shellOut(to: "git", arguments: ["add", "."], at: originPath, logger: .init(label: "test")) try await shellOut(to: .gitCommit(message: "Commit"), at: originPath, logger: .init(label: "test")) // Clone to a new repository and read the file @@ -198,22 +191,31 @@ class ShellOutTests: XCTestCase { await XCTAssertEqualAsync(try await shellOut(to: .readFile(at: filePath), logger: .init(label: "test")).stdout, "Hello again") } - func testArgumentQuoting() async throws { - await XCTAssertEqualAsync(try await shellOut(to: "echo".checked, - arguments: ["foo ; echo bar".quoted]).stdout, - "foo ; echo bar") - await XCTAssertEqualAsync(try await shellOut(to: "echo".checked, - arguments: ["foo ; echo bar".verbatim]).stdout, - "foo\nbar") + func testBash() async throws { + // Without explicit -c parameter + await XCTAssertEqualAsync(try await shellOut(to: .bash(arguments: ["echo", "foo"])).stdout, + "foo") + // With explicit -c parameter + await XCTAssertEqualAsync(try await shellOut(to: .bash(arguments: ["-c", "echo", "foo"])).stdout, + "foo") + } + + func testBashArgumentQuoting() async throws { + await XCTAssertEqualAsync(try await shellOut(to: .bash(arguments: ["echo", + "foo ; echo bar".quoted])).stdout, + "foo ; echo bar") + await XCTAssertEqualAsync(try await shellOut(to: .bash(arguments: ["echo", + "foo ; echo bar".verbatim])).stdout, + "foo\nbar") } func test_Argument_ExpressibleByStringLiteral() throws { - XCTAssertEqual(("foo" as Argument).string, "foo") - XCTAssertEqual(("foo bar" as Argument).string, "'foo bar'") + XCTAssertEqual(("foo" as Argument).description, "foo") + XCTAssertEqual(("foo bar" as Argument).description, "'foo bar'") } func test_Argument_url() throws { - XCTAssertEqual(Argument.url(.init(string: "https://example.com")!).string, + XCTAssertEqual(Argument.url(.init(string: "https://example.com")!).description, "https://example.com") } @@ -228,10 +230,10 @@ class ShellOutTests: XCTestCase { .appendingPathComponent("\(sampleGitRepoName).zip").path let path = "\(tempDir)/\(sampleGitRepoName)" try! Foundation.FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: false, attributes: nil) - try! await ShellOut.shellOut(to: .init(command: "unzip", arguments: [sampleGitRepoZipFile.quoted]), at: tempDir) + try! await ShellOut.shellOut(to: .init(command: "unzip", arguments: [sampleGitRepoZipFile]), at: tempDir) // MUT - await XCTAssertEqualAsync(try await shellOut(to: try ShellOutCommand(command: "git", arguments: ["tag"]), + await XCTAssertEqualAsync(try await shellOut(to: ShellOutCommand(command: "git", arguments: ["tag"]), at: path).stdout, """ 0.2.0 0.2.1