diff --git a/.travis.yml b/.travis.yml index 06f52f3..75c79ae 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,14 @@ -os: linux -language: generic -sudo: required -dist: trusty -install: - - eval "$(curl -sL https://gist.githubusercontent.com/kylef/5c0475ff02b7c7671d2a/raw/9f442512a46d7a2af7b850d65a7e9bd31edfb09b/swiftenv-install.sh)" -script: - - swift test +language: swift + +branches: + only: + - master + +xcode_project: ShellOut.xcodeproj +xcode_scheme: ShellOut-package +osx_image: xcode9.3 +xcode_sdk: iphonesimulator11.3 + +script: + - xcodebuild -scheme ShellOut-package -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone X,OS=11.3' test + \ No newline at end of file diff --git a/Sources/Data+Extensions.swift b/Sources/Data+Extensions.swift new file mode 100644 index 0000000..4b2b681 --- /dev/null +++ b/Sources/Data+Extensions.swift @@ -0,0 +1,17 @@ +import Foundation + +extension Data { + func shellOutput() -> String { + guard let output = String(data: self, encoding: .utf8) else { + return "" + } + + guard !output.hasSuffix("\n") else { + let endIndex = output.index(before: output.endIndex) + return String(output[.. Process { + let process = Process() + process.launchPath = "/bin/bash" + process.arguments = arguments + return process + } + + @discardableResult func launchBash(outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil) throws -> String { + // Because FileHandle's readabilityHandler might be called from a + // different queue from the calling queue, avoid a data race by + // protecting reads and writes to outputData and errorData on + // a single dispatch queue. + let outputQueue = DispatchQueue(label: "bash-output-queue") + + var outputData = Data() + var errorData = Data() + + let outputPipe = Pipe() + standardOutput = outputPipe + + let errorPipe = Pipe() + standardError = errorPipe + + #if !os(Linux) + outputPipe.fileHandleForReading.readabilityHandler = { handler in + outputQueue.async { + let data = handler.availableData + outputData.append(data) + outputHandle?.write(data) + } + } + + errorPipe.fileHandleForReading.readabilityHandler = { handler in + outputQueue.async { + let data = handler.availableData + errorData.append(data) + errorHandle?.write(data) + } + } + #endif + + launch() + + #if os(Linux) + outputQueue.sync { + outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + } + #endif + + waitUntilExit() + + outputHandle?.closeFile() + errorHandle?.closeFile() + + #if !os(Linux) + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + #endif + + // Block until all writes have occurred to outputData and errorData, + // and then read the data back out. + return try outputQueue.sync { + if terminationStatus != 0 { + throw ShellOutError( + terminationStatus: terminationStatus, + errorData: errorData, + outputData: outputData + ) + } + + return outputData.shellOutput() + } + } + + func launchBash(withCompletion completion: @escaping Completion) { + + var outputData = Data() + var errorData = Data() + + let outputPipe = Pipe() + standardOutput = outputPipe + + let errorPipe = Pipe() + standardError = errorPipe + + // Because FileHandle's readabilityHandler might be called from a + // different queue from the calling queue, avoid a data race by + // protecting reads and writes to outputData and errorData on + // a single dispatch queue. + let outputQueue = DispatchQueue(label: "bash-output-queue") + + #if !os(Linux) + outputPipe.fileHandleForReading.readabilityHandler = { handler in + outputQueue.async { + let data = handler.availableData + outputData.append(data) + } + } + + errorPipe.fileHandleForReading.readabilityHandler = { handler in + outputQueue.async { + let data = handler.availableData + errorData.append(data) + } + } + #endif + + launch() + + #if os(Linux) + outputQueue.sync { + outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + } + #endif + + waitUntilExit() + + #if !os(Linux) + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + #endif + + do { + // Block until all writes have occurred to outputData and errorData, + // and then read the data back out. + return try outputQueue.sync { + if terminationStatus != 0 { + throw ShellOutError( + terminationStatus: terminationStatus, + errorData: errorData, + outputData: outputData + ) + } + + let value = outputData.shellOutput() + + DispatchQueue.main.async { + completion({return value}) + } + } + } catch { + DispatchQueue.main.async { + completion({throw error}) + } + } + } +} diff --git a/Sources/ShellOut.swift b/Sources/ShellOut.swift index 290b9d0..1afe362 100644 --- a/Sources/ShellOut.swift +++ b/Sources/ShellOut.swift @@ -7,7 +7,98 @@ import Foundation import Dispatch -// MARK: - API +// MARK: - Asynchronously + +public typealias Completion = (_ inner: () throws -> String) -> Void + +/** + * Run a shell command asynchronously using Bash + * + * - 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 completion: The outcome of the execution. + * + * - throws: `ShellOutError` in case the command couldn't be performed, or it returned an error + * + * Use this function to "shell out" in a Swift script or command line tool + * For example: `shellOut(to: "echo", arguments: ["Hello World"]) { (completion) in + * do { + * let output = try completion() + * XCTAssertEqual(output, "Hello world") + * } catch { + * XCTFail("Command failed to execute") + * } + * }` + */ +public func shellOut(to command: String, + arguments: [String] = [], + at path: String = ".", + withCompletion completion: @escaping Completion) { + + let command = "cd \(path.escapingSpaces) && \(command) \(arguments.joined(separator: " "))" + let process = Process.makeBashProcess(withArguments: ["-c", command]) + + let shellOutQueue = DispatchQueue(label: "shell-out-queue") + + shellOutQueue.async { + process.launchBash(withCompletion: completion) + } +} + +/** + * Run a series of shell commands asynchronously using Bash + * + * - parameter commands: The commands to run + * - parameter path: The path to execute the commands at (defaults to current folder) + * - parameter completion: The outcome of the execution. + * + * - throws: `ShellOutError` in case the command couldn't be performed, or it returned an error + * + * Use this function to "shell out" in a Swift script or command line tool + * For example: `shellOut(to: "echo", arguments: ["Hello World"]) { (completion) in + * do { + * let output = try completion() + * XCTAssertEqual(output, "Hello world") + * } catch { + * XCTFail("Command failed to execute") + * } + * }` + */ +public func shellOut(to commands: [String], + arguments: [String] = [], + at path: String = ".", + withCompletion completion: @escaping Completion) { + let command = commands.joined(separator: " && ") + shellOut(to: command, arguments: arguments, at: path, withCompletion: completion) +} + +/** + * Run a pre-defined shell command asynchronously using Bash + * + * - parameter command: The command to run + * - parameter path: The path to execute the commands at (defaults to current folder) + * + * - throws: `ShellOutError` in case the command couldn't be performed, or it returned an error + * + * Use this function to "shell out" in a Swift script or command line tool + * For example: `shellOut(to: .gitCommit(message: "Commit"), at: "~/CurrentFolder")` + * For example: `shellOut(to: .gitCommit(message: "Commit"), at: "~/CurrentFolder") { (completion) in + * do { + * _ = try completion() + * } catch { + * XCTFail("Command failed to execute") + * } + * }` + * See `ShellOutCommand` for more info. + */ +public func shellOut(to command: ShellOutCommand, + at path: String = ".", + withCompletion completion: @escaping Completion) { + shellOut(to: command.string, at: path, withCompletion: completion) +} + +// MARK: - Synchronously /** * Run a shell command using Bash @@ -27,13 +118,13 @@ import Dispatch * For example: `shellOut(to: "mkdir", arguments: ["NewFolder"], at: "~/CurrentFolder")` */ @discardableResult public func shellOut(to command: String, - arguments: [String] = [], - at path: String = ".", - outputHandle: FileHandle? = nil, - errorHandle: FileHandle? = nil) throws -> String { - let process = Process() + arguments: [String] = [], + at path: String = ".", + outputHandle: FileHandle? = nil, + errorHandle: FileHandle? = nil) throws -> String { let command = "cd \(path.escapingSpaces) && \(command) \(arguments.joined(separator: " "))" - return try process.launchBash(with: command, outputHandle: outputHandle, errorHandle: errorHandle) + let process = Process.makeBashProcess(withArguments: ["-c", command]) + return try process.launchBash(outputHandle: outputHandle, errorHandle: errorHandle) } /** @@ -82,376 +173,3 @@ import Dispatch errorHandle: FileHandle? = nil) throws -> String { return try shellOut(to: command.string, at: path, outputHandle: outputHandle, errorHandle: errorHandle) } - -/// 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 string: String - - /// Initialize a value using a string that makes up the underlying command - public init(string: String) { - self.string = string - } -} - -/// Git commands -public extension ShellOutCommand { - /// Initialize a git repository - static func gitInit() -> ShellOutCommand { - return ShellOutCommand(string: "git init") - } - - /// Clone a git repository at a given URL - static func gitClone(url: URL, to path: String? = nil) -> ShellOutCommand { - var command = "git clone \(url.absoluteString)" - path.map { command.append(argument: $0) } - command.append(" --quiet") - - return ShellOutCommand(string: command) - } - - /// Create a git commit with a given message (also adds all untracked file to the index) - static func gitCommit(message: String) -> ShellOutCommand { - var command = "git add . && git commit -a -m" - command.append(argument: message) - command.append(" --quiet") - - return ShellOutCommand(string: command) - } - - /// Perform a git push - static func gitPush(remote: String? = nil, branch: String? = nil) -> ShellOutCommand { - var command = "git push" - remote.map { command.append(argument: $0) } - branch.map { command.append(argument: $0) } - command.append(" --quiet") - - return ShellOutCommand(string: command) - } - - /// Perform a git pull - static func gitPull(remote: String? = nil, branch: String? = nil) -> ShellOutCommand { - var command = "git pull" - remote.map { command.append(argument: $0) } - branch.map { command.append(argument: $0) } - command.append(" --quiet") - - return ShellOutCommand(string: command) - } - - /// Run a git submodule update - static func gitSubmoduleUpdate(initializeIfNeeded: Bool = true, recursive: Bool = true) -> ShellOutCommand { - var command = "git submodule update" - - if initializeIfNeeded { - command.append(" --init") - } - - if recursive { - command.append(" --recursive") - } - - command.append(" --quiet") - return ShellOutCommand(string: command) - } - - /// Checkout a given git branch - static func gitCheckout(branch: String) -> ShellOutCommand { - let command = "git checkout".appending(argument: branch) - .appending(" --quiet") - - return ShellOutCommand(string: command) - } -} - -/// File system commands -public extension ShellOutCommand { - /// Create a folder with a given name - static func createFolder(named name: String) -> ShellOutCommand { - let command = "mkdir".appending(argument: name) - return ShellOutCommand(string: command) - } - - /// 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 { - var command = "echo" - command.append(argument: contents) - command.append(" > ") - command.append(argument: name) - - return ShellOutCommand(string: command) - } - - /// Move a file from one path to another - static func moveFile(from originPath: String, to targetPath: String) -> ShellOutCommand { - let command = "mv".appending(argument: originPath) - .appending(argument: targetPath) - - return ShellOutCommand(string: command) - } - - /// Copy a file from one path to another - static func copyFile(from originPath: String, to targetPath: String) -> ShellOutCommand { - let command = "cp".appending(argument: originPath) - .appending(argument: targetPath) - - return ShellOutCommand(string: command) - } - - /// Remove a file - static func removeFile(from path: String, arguments: [String] = ["-f"]) -> ShellOutCommand { - let command = "rm".appending(arguments: arguments) - .appending(argument: path) - - return ShellOutCommand(string: command) - } - - /// Open a file using its designated application - static func openFile(at path: String) -> ShellOutCommand { - let command = "open".appending(argument: path) - return ShellOutCommand(string: command) - } - - /// Read a file as a string - static func readFile(at path: String) -> ShellOutCommand { - let command = "cat".appending(argument: path) - return ShellOutCommand(string: command) - } - - /// Create a symlink at a given path, to a given target - static func createSymlink(to targetPath: String, at linkPath: String) -> ShellOutCommand { - let command = "ln -s".appending(argument: targetPath) - .appending(argument: linkPath) - - return ShellOutCommand(string: command) - } - - /// Expand a symlink at a given path, returning its target path - static func expandSymlink(at path: String) -> ShellOutCommand { - let command = "readlink".appending(argument: path) - return ShellOutCommand(string: command) - } -} - -/// Marathon commands -public extension ShellOutCommand { - /// Run a Marathon Swift script - static func runMarathonScript(at path: String, arguments: [String] = []) -> ShellOutCommand { - let command = "marathon run".appending(argument: path) - .appending(arguments: arguments) - - return ShellOutCommand(string: command) - } - - /// Update all Swift packages managed by Marathon - static func updateMarathonPackages() -> ShellOutCommand { - return ShellOutCommand(string: "marathon update") - } -} - -/// 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 { - let command = "swift package init --type \(type.rawValue)" - return ShellOutCommand(string: command) - } - - /// Update all Swift package dependencies - static func updateSwiftPackages() -> ShellOutCommand { - return ShellOutCommand(string: "swift package update") - } - - /// Generate an Xcode project for a Swift package - static func generateSwiftPackageXcodeProject() -> ShellOutCommand { - return ShellOutCommand(string: "swift package generate-xcodeproj") - } - - /// Build a Swift package using a given configuration (see SwiftBuildConfiguration for options) - static func buildSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { - return ShellOutCommand(string: "swift build -c \(configuration.rawValue)") - } - - /// Test a Swift package using a given configuration (see SwiftBuildConfiguration for options) - static func testSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { - return ShellOutCommand(string: "swift test -c \(configuration.rawValue)") - } -} - -/// Fastlane commands -public extension ShellOutCommand { - /// Run Fastlane using a given lane - static func runFastlane(usingLane lane: String) -> ShellOutCommand { - let command = "fastlane".appending(argument: lane) - return ShellOutCommand(string: command) - } -} - -/// CocoaPods commands -public extension ShellOutCommand { - /// Update all CocoaPods dependencies - static func updateCocoaPods() -> ShellOutCommand { - return ShellOutCommand(string: "pod update") - } - - /// Install all CocoaPods dependencies - static func installCocoaPods() -> ShellOutCommand { - return ShellOutCommand(string: "pod install") - } -} - -/// 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 - } -} - -// MARK: - Private - -private extension Process { - @discardableResult func launchBash(with command: String, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil) throws -> String { - launchPath = "/bin/bash" - arguments = ["-c", command] - - // Because FileHandle's readabilityHandler might be called from a - // different queue from the calling queue, avoid a data race by - // protecting reads and writes to outputData and errorData on - // a single dispatch queue. - let outputQueue = DispatchQueue(label: "bash-output-queue") - - var outputData = Data() - var errorData = Data() - - let outputPipe = Pipe() - standardOutput = outputPipe - - let errorPipe = Pipe() - standardError = errorPipe - - #if !os(Linux) - outputPipe.fileHandleForReading.readabilityHandler = { handler in - outputQueue.async { - let data = handler.availableData - outputData.append(data) - outputHandle?.write(data) - } - } - - errorPipe.fileHandleForReading.readabilityHandler = { handler in - outputQueue.async { - let data = handler.availableData - errorData.append(data) - errorHandle?.write(data) - } - } - #endif - - launch() - - #if os(Linux) - outputQueue.sync { - outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - } - #endif - - waitUntilExit() - - outputHandle?.closeFile() - errorHandle?.closeFile() - - #if !os(Linux) - outputPipe.fileHandleForReading.readabilityHandler = nil - errorPipe.fileHandleForReading.readabilityHandler = nil - #endif - - // Block until all writes have occurred to outputData and errorData, - // and then read the data back out. - return try outputQueue.sync { - if terminationStatus != 0 { - throw ShellOutError( - terminationStatus: terminationStatus, - errorData: errorData, - outputData: outputData - ) - } - - return outputData.shellOutput() - } - } -} - -private extension Data { - func shellOutput() -> String { - guard let output = String(data: self, encoding: .utf8) else { - return "" - } - - guard !output.hasSuffix("\n") else { - let endIndex = output.index(before: output.endIndex) - return String(output[.. String { - return "\(self) \"\(argument)\"" - } - - func appending(arguments: [String]) -> String { - return appending(argument: arguments.joined(separator: "\" \"")) - } - - mutating func append(argument: String) { - self = appending(argument: argument) - } - - mutating func append(arguments: [String]) { - self = appending(arguments: arguments) - } -} diff --git a/Sources/ShellOutCommand.swift b/Sources/ShellOutCommand.swift new file mode 100644 index 0000000..d1e7357 --- /dev/null +++ b/Sources/ShellOutCommand.swift @@ -0,0 +1,230 @@ +import Foundation + +/// 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 string: String + + /// Initialize a value using a string that makes up the underlying command + public init(string: String) { + self.string = string + } +} + +/// Git commands +public extension ShellOutCommand { + /// Initialize a git repository + static func gitInit() -> ShellOutCommand { + return ShellOutCommand(string: "git init") + } + + /// Clone a git repository at a given URL + static func gitClone(url: URL, to path: String? = nil) -> ShellOutCommand { + var command = "git clone \(url.absoluteString)" + path.map { command.append(argument: $0) } + command.append(" --quiet") + + return ShellOutCommand(string: command) + } + + /// Create a git commit with a given message (also adds all untracked file to the index) + static func gitCommit(message: String) -> ShellOutCommand { + var command = "git add . && git commit -a -m" + command.append(argument: message) + command.append(" --quiet") + + return ShellOutCommand(string: command) + } + + /// Perform a git push + static func gitPush(remote: String? = nil, branch: String? = nil) -> ShellOutCommand { + var command = "git push" + remote.map { command.append(argument: $0) } + branch.map { command.append(argument: $0) } + command.append(" --quiet") + + return ShellOutCommand(string: command) + } + + /// Perform a git pull + static func gitPull(remote: String? = nil, branch: String? = nil) -> ShellOutCommand { + var command = "git pull" + remote.map { command.append(argument: $0) } + branch.map { command.append(argument: $0) } + command.append(" --quiet") + + return ShellOutCommand(string: command) + } + + /// Run a git submodule update + static func gitSubmoduleUpdate(initializeIfNeeded: Bool = true, recursive: Bool = true) -> ShellOutCommand { + var command = "git submodule update" + + if initializeIfNeeded { + command.append(" --init") + } + + if recursive { + command.append(" --recursive") + } + + command.append(" --quiet") + return ShellOutCommand(string: command) + } + + /// Checkout a given git branch + static func gitCheckout(branch: String) -> ShellOutCommand { + let command = "git checkout".appending(argument: branch) + .appending(" --quiet") + + return ShellOutCommand(string: command) + } +} + +/// File system commands +public extension ShellOutCommand { + /// Create a folder with a given name + static func createFolder(named name: String) -> ShellOutCommand { + let command = "mkdir".appending(argument: name) + return ShellOutCommand(string: command) + } + + /// 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 { + var command = "echo" + command.append(argument: contents) + command.append(" > ") + command.append(argument: name) + + return ShellOutCommand(string: command) + } + + /// Move a file from one path to another + static func moveFile(from originPath: String, to targetPath: String) -> ShellOutCommand { + let command = "mv".appending(argument: originPath) + .appending(argument: targetPath) + + return ShellOutCommand(string: command) + } + + /// Copy a file from one path to another + static func copyFile(from originPath: String, to targetPath: String) -> ShellOutCommand { + let command = "cp".appending(argument: originPath) + .appending(argument: targetPath) + + return ShellOutCommand(string: command) + } + + /// Remove a file + static func removeFile(from path: String, arguments: [String] = ["-f"]) -> ShellOutCommand { + let command = "rm".appending(arguments: arguments) + .appending(argument: path) + + return ShellOutCommand(string: command) + } + + /// Open a file using its designated application + static func openFile(at path: String) -> ShellOutCommand { + let command = "open".appending(argument: path) + return ShellOutCommand(string: command) + } + + /// Read a file as a string + static func readFile(at path: String) -> ShellOutCommand { + let command = "cat".appending(argument: path) + return ShellOutCommand(string: command) + } + + /// Create a symlink at a given path, to a given target + static func createSymlink(to targetPath: String, at linkPath: String) -> ShellOutCommand { + let command = "ln -s".appending(argument: targetPath) + .appending(argument: linkPath) + + return ShellOutCommand(string: command) + } + + /// Expand a symlink at a given path, returning its target path + static func expandSymlink(at path: String) -> ShellOutCommand { + let command = "readlink".appending(argument: path) + return ShellOutCommand(string: command) + } +} + +/// Marathon commands +public extension ShellOutCommand { + /// Run a Marathon Swift script + static func runMarathonScript(at path: String, arguments: [String] = []) -> ShellOutCommand { + let command = "marathon run".appending(argument: path) + .appending(arguments: arguments) + + return ShellOutCommand(string: command) + } + + /// Update all Swift packages managed by Marathon + static func updateMarathonPackages() -> ShellOutCommand { + return ShellOutCommand(string: "marathon update") + } +} + +/// 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 { + let command = "swift package init --type \(type.rawValue)" + return ShellOutCommand(string: command) + } + + /// Update all Swift package dependencies + static func updateSwiftPackages() -> ShellOutCommand { + return ShellOutCommand(string: "swift package update") + } + + /// Generate an Xcode project for a Swift package + static func generateSwiftPackageXcodeProject() -> ShellOutCommand { + return ShellOutCommand(string: "swift package generate-xcodeproj") + } + + /// Build a Swift package using a given configuration (see SwiftBuildConfiguration for options) + static func buildSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { + return ShellOutCommand(string: "swift build -c \(configuration.rawValue)") + } + + /// Test a Swift package using a given configuration (see SwiftBuildConfiguration for options) + static func testSwiftPackage(withConfiguration configuration: SwiftBuildConfiguration = .debug) -> ShellOutCommand { + return ShellOutCommand(string: "swift test -c \(configuration.rawValue)") + } +} + +/// Fastlane commands +public extension ShellOutCommand { + /// Run Fastlane using a given lane + static func runFastlane(usingLane lane: String) -> ShellOutCommand { + let command = "fastlane".appending(argument: lane) + return ShellOutCommand(string: command) + } +} + +/// CocoaPods commands +public extension ShellOutCommand { + /// Update all CocoaPods dependencies + static func updateCocoaPods() -> ShellOutCommand { + return ShellOutCommand(string: "pod update") + } + + /// Install all CocoaPods dependencies + static func installCocoaPods() -> ShellOutCommand { + return ShellOutCommand(string: "pod install") + } +} diff --git a/Sources/ShellOutError.swift b/Sources/ShellOutError.swift new file mode 100644 index 0000000..892ceed --- /dev/null +++ b/Sources/ShellOutError.swift @@ -0,0 +1,32 @@ +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 + } +} diff --git a/Sources/String+Extensions.swift b/Sources/String+Extensions.swift new file mode 100644 index 0000000..06b0c94 --- /dev/null +++ b/Sources/String+Extensions.swift @@ -0,0 +1,23 @@ +import Foundation + +extension String { + var escapingSpaces: String { + return replacingOccurrences(of: " ", with: "\\ ") + } + + func appending(argument: String) -> String { + return "\(self) \"\(argument)\"" + } + + func appending(arguments: [String]) -> String { + return appending(argument: arguments.joined(separator: "\" \"")) + } + + mutating func append(argument: String) { + self = appending(argument: argument) + } + + mutating func append(arguments: [String]) { + self = appending(arguments: arguments) + } +} diff --git a/Tests/ShellOutTests/ShellOutTests+Linux.swift b/Tests/ShellOutTests/ShellOutTests+Linux.swift index 508ad34..14326ca 100644 --- a/Tests/ShellOutTests/ShellOutTests+Linux.swift +++ b/Tests/ShellOutTests/ShellOutTests+Linux.swift @@ -4,6 +4,7 @@ * Licensed under the MIT license. See LICENSE file. */ +import Foundation import XCTest @testable import ShellOut @@ -18,7 +19,16 @@ extension ShellOutTests { ("testSeriesOfCommandsAtPath", testSeriesOfCommandsAtPath), ("testThrowingError", testThrowingError), ("testGitCommands", testGitCommands), - ("testSwiftPackageManagerCommands", testSwiftPackageManagerCommands) + ("testSwiftPackageManagerCommands", testSwiftPackageManagerCommands), + ("testWithoutArgumentsAsynchronously", testWithoutArguments), + ("testWithArgumentsAsynchronously", testWithArguments), + ("testWithInlineArgumentsAsynchronously", testWithInlineArguments), + ("testSingleCommandAtPathAsynchronously", testSingleCommandAtPath), + ("testSeriesOfCommandsAsynchronously", testSeriesOfCommands), + ("testSeriesOfCommandsAtPathAsynchronously", testSeriesOfCommandsAtPath), + ("testThrowingErrorAsynchronously", testThrowingError), + ("testGitCommandsAsynchronously", testGitCommands), + ("testSwiftPackageManagerCommandsAsynchronously", testSwiftPackageManagerCommands) ] } #endif diff --git a/Tests/ShellOutTests/ShellOutTests.swift b/Tests/ShellOutTests/ShellOutTests.swift index 90a8fd3..9cc6816 100644 --- a/Tests/ShellOutTests/ShellOutTests.swift +++ b/Tests/ShellOutTests/ShellOutTests.swift @@ -4,67 +4,282 @@ * Licensed under the MIT license. See LICENSE file. */ +import Foundation import XCTest @testable import ShellOut class ShellOutTests: XCTestCase { + + // MARK: - Asynchronously + + func testWithoutArgumentsAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion") + shellOut(to: "uptime") { (completion) in + exp1.fulfill() + do { + let output = try completion() + XCTAssertTrue(output.contains("load average")) + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp1], timeout: time) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testWithArgumentsAsynchronously() { + let time: Double = 5 + let exp = expectation(description: "completion") + shellOut(to: "echo", arguments: ["Hello world"]) { (completion) in + exp.fulfill() + do { + let output = try completion() + XCTAssertEqual(output, "Hello world") + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp], timeout: time) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testWithInlineArgumentsAsynchronously() { + let time: Double = 5 + let exp = expectation(description: "completion") + shellOut(to: "echo \"Hello world\"") { (completion) in + exp.fulfill() + do { + let output = try completion() + XCTAssertEqual(output, "Hello world") + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp], timeout: time) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testSingleCommandAtPathAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion1") + let exp2 = expectation(description: "completion2") + shellOut(to: "echo \"Hello\" > \(NSTemporaryDirectory())ShellOutTests-SingleCommand.txt") { (completion1) in + exp1.fulfill() + do { + _ = try completion1() + shellOut(to: "cat ShellOutTests-SingleCommand.txt", at: NSTemporaryDirectory(), withCompletion: { (completion2) in + exp2.fulfill() + do { + let output = try completion2() + XCTAssertEqual(output, "Hello") + } catch { + XCTFail("Command failed to execute") + } + }) + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp1, exp2], timeout: time, enforceOrder: true) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testSingleCommandAtPathContainingSpaceAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion1") + let exp2 = expectation(description: "completion2") + let exp3 = expectation(description: "completion3") + shellOut(to: "mkdir -p \"ShellOut Test Folder\"", at: NSTemporaryDirectory()) { (completion1) in + exp1.fulfill() + do { + _ = try completion1() + shellOut(to: "echo \"Hello\" > File", at: NSTemporaryDirectory() + "ShellOut Test Folder", withCompletion: { (completion2) in + exp2.fulfill() + do { + _ = try completion2() + shellOut(to: "cat \(NSTemporaryDirectory())ShellOut\\ Test\\ Folder/File", withCompletion: { (completion3) in + exp3.fulfill() + do { + let output = try completion3() + XCTAssertEqual(output, "Hello") + } catch { + XCTFail("Command failed to execute") + } + }) + } catch { + XCTFail("Command failed to execute") + } + }) + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp1, exp2, exp3], timeout: time, enforceOrder: true) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testSingleCommandAtPathContainingTildeAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion") + shellOut(to: "ls", at: "~") { (completion) in + exp1.fulfill() + do { + let output = try completion() + XCTAssertFalse(output.isEmpty) + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp1], timeout: time) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testSeriesOfCommandsAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion") + shellOut(to: ["echo \"Hello\"", "echo \"world\""]) { (completion) in + exp1.fulfill() + do { + let output = try completion() + XCTAssertEqual(output, "Hello\nworld") + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp1], timeout: time) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testSeriesOfCommandsAtPathAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion1") + let exp2 = expectation(description: "completion1") + shellOut(to: [ + "cd \(NSTemporaryDirectory())", + "mkdir -p ShellOutTests", + "echo \"Hello again\" > ShellOutTests/MultipleCommands.txt" + ]) { (completion1) in + exp1.fulfill() + do { + _ = try completion1() + shellOut(to: [ + "cd ShellOutTests", + "cat MultipleCommands.txt" + ], at: NSTemporaryDirectory(), withCompletion: { (completion2) in + exp2.fulfill() + do { + let output = try completion2() + XCTAssertEqual(output, "Hello again") + } catch { + XCTFail("Command failed to execute") + } + }) + } catch { + XCTFail("Command failed to execute") + } + } + let result = XCTWaiter.wait(for: [exp1, exp2], timeout: time, enforceOrder: true) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + func testThrowingErrorAsynchronously() { + let time: Double = 5 + let exp1 = expectation(description: "completion") + shellOut(to: "cd", arguments: ["notADirectory"]) { (completion) in + exp1.fulfill() + do { + _ = try completion() + XCTFail("Expected expression to throw") + } catch let error as ShellOutError { + XCTAssertTrue(error.message.contains("notADirectory")) + XCTAssertTrue(error.output.isEmpty) + XCTAssertTrue(error.terminationStatus != 0) + } catch { + XCTFail("Invalid error type: \(error)") + } + } + let result = XCTWaiter.wait(for: [exp1], timeout: time) + if result != .completed { + XCTFail("Condition was not satisfied during \(time) seconds") + } + } + + // MARK: - Synchronously + func testWithoutArguments() throws { let uptime = try shellOut(to: "uptime") XCTAssertTrue(uptime.contains("load average")) } - + func testWithArguments() throws { let echo = try shellOut(to: "echo", arguments: ["Hello world"]) XCTAssertEqual(echo, "Hello world") } - + func testWithInlineArguments() throws { let echo = try shellOut(to: "echo \"Hello world\"") XCTAssertEqual(echo, "Hello world") } - + func testSingleCommandAtPath() throws { try shellOut(to: "echo \"Hello\" > \(NSTemporaryDirectory())ShellOutTests-SingleCommand.txt") - + let textFileContent = try shellOut(to: "cat ShellOutTests-SingleCommand.txt", at: NSTemporaryDirectory()) - + XCTAssertEqual(textFileContent, "Hello") } - + func testSingleCommandAtPathContainingSpace() throws { try shellOut(to: "mkdir -p \"ShellOut Test Folder\"", at: NSTemporaryDirectory()) try shellOut(to: "echo \"Hello\" > File", at: NSTemporaryDirectory() + "ShellOut Test Folder") - + let output = try shellOut(to: "cat \(NSTemporaryDirectory())ShellOut\\ Test\\ Folder/File") XCTAssertEqual(output, "Hello") } - + func testSingleCommandAtPathContainingTilde() throws { let homeContents = try shellOut(to: "ls", at: "~") XCTAssertFalse(homeContents.isEmpty) } - + func testSeriesOfCommands() throws { let echo = try shellOut(to: ["echo \"Hello\"", "echo \"world\""]) XCTAssertEqual(echo, "Hello\nworld") } - + func testSeriesOfCommandsAtPath() throws { try shellOut(to: [ "cd \(NSTemporaryDirectory())", "mkdir -p ShellOutTests", "echo \"Hello again\" > ShellOutTests/MultipleCommands.txt" - ]) - + ]) + let textFileContent = try shellOut(to: [ "cd ShellOutTests", "cat MultipleCommands.txt" - ], at: NSTemporaryDirectory()) - + ], at: NSTemporaryDirectory()) + XCTAssertEqual(textFileContent, "Hello again") } - + func testThrowingError() { do { try shellOut(to: "cd", arguments: ["notADirectory"]) @@ -77,28 +292,28 @@ class ShellOutTests: XCTestCase { XCTFail("Invalid error type: \(error)") } } - + func testErrorDescription() { let errorMessage = "Hey, I'm an error!" let output = "Some output" - + let error = ShellOutError( terminationStatus: 7, errorData: errorMessage.data(using: .utf8)!, outputData: output.data(using: .utf8)! ) - + let expectedErrorDescription = """ ShellOut encountered an error Status code: 7 Message: "Hey, I'm an error!" Output: "Some output" """ - + XCTAssertEqual("\(error)", expectedErrorDescription) XCTAssertEqual(error.localizedDescription, expectedErrorDescription) } - + func testCapturingOutputWithHandle() throws { let pipe = Pipe() let output = try shellOut(to: "echo", arguments: ["Hello"], outputHandle: pipe.fileHandleForWriting) @@ -106,10 +321,10 @@ class ShellOutTests: XCTestCase { XCTAssertEqual(output, "Hello") XCTAssertEqual(output + "\n", String(data: capturedData, encoding: .utf8)) } - + func testCapturingErrorWithHandle() throws { let pipe = Pipe() - + do { try shellOut(to: "cd", arguments: ["notADirectory"], errorHandle: pipe.fileHandleForWriting) XCTFail("Expected expression to throw") @@ -117,59 +332,59 @@ class ShellOutTests: XCTestCase { XCTAssertTrue(error.message.contains("notADirectory")) XCTAssertTrue(error.output.isEmpty) XCTAssertTrue(error.terminationStatus != 0) - + let capturedData = pipe.fileHandleForReading.readDataToEndOfFile() XCTAssertEqual(error.message + "\n", String(data: capturedData, encoding: .utf8)) } catch { XCTFail("Invalid error type: \(error)") } } - + func testGitCommands() throws { // Setup & clear state let tempFolderPath = NSTemporaryDirectory() try shellOut(to: "rm -rf GitTestOrigin", at: tempFolderPath) try shellOut(to: "rm -rf GitTestClone", at: tempFolderPath) - + // Create a origin repository and make a commit with a file let originPath = tempFolderPath + "/GitTestOrigin" try shellOut(to: .createFolder(named: "GitTestOrigin"), at: tempFolderPath) try shellOut(to: .gitInit(), at: originPath) try shellOut(to: .createFile(named: "Test", contents: "Hello world"), at: originPath) try shellOut(to: .gitCommit(message: "Commit"), at: originPath) - + // Clone to a new repository and read the file let clonePath = tempFolderPath + "/GitTestClone" let cloneURL = URL(fileURLWithPath: originPath) try shellOut(to: .gitClone(url: cloneURL, to: "GitTestClone"), at: tempFolderPath) - + let filePath = clonePath + "/Test" XCTAssertEqual(try shellOut(to: .readFile(at: filePath)), "Hello world") - + // Make a new commit in the origin repository try shellOut(to: .createFile(named: "Test", contents: "Hello again"), at: originPath) try shellOut(to: .gitCommit(message: "Commit"), at: originPath) - + // Pull the commit in the clone repository and read the file again try shellOut(to: .gitPull(), at: clonePath) XCTAssertEqual(try shellOut(to: .readFile(at: filePath)), "Hello again") } - + func testSwiftPackageManagerCommands() throws { // Setup & clear state let tempFolderPath = NSTemporaryDirectory() try shellOut(to: "rm -rf SwiftPackageManagerTest", at: tempFolderPath) try shellOut(to: .createFolder(named: "SwiftPackageManagerTest"), at: tempFolderPath) - + // Create a Swift package and verify that it has a Package.swift file let packagePath = tempFolderPath + "/SwiftPackageManagerTest" try shellOut(to: .createSwiftPackage(), at: packagePath) XCTAssertFalse(try shellOut(to: .readFile(at: packagePath + "/Package.swift")).isEmpty) - + // Build the package and verify that there's a .build folder try shellOut(to: .buildSwiftPackage(), at: packagePath) XCTAssertTrue(try shellOut(to: "ls -a", at: packagePath).contains(".build")) - + // Generate an Xcode project try shellOut(to: .generateSwiftPackageXcodeProject(), at: packagePath) XCTAssertTrue(try shellOut(to: "ls -a", at: packagePath).contains("SwiftPackageManagerTest.xcodeproj")) diff --git a/build-xcode-proj.sh b/build-xcode-proj.sh new file mode 100644 index 0000000..d540a09 --- /dev/null +++ b/build-xcode-proj.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +swift package generate-xcodeproj