/
ShellOut.swift
440 lines (365 loc) · 15.6 KB
/
ShellOut.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
/**
* ShellOut
* Copyright (c) John Sundell 2017
* Licensed under the MIT license. See LICENSE file.
*/
import Foundation
// MARK: - API
/**
* Run a shell command 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 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
* (at the moment this is only supported on macOS)
*
* - returns: The output of running the command
* - 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: "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()
let command = "cd \(path.escapingSpaces) && \(command) \(arguments.joined(separator: " "))"
return try process.launchBash(with: command, outputHandle: outputHandle, errorHandle: errorHandle)
}
/**
* Run a series of shell commands using Bash
*
* - parameter commands: The commands to run
* - parameter path: The path to execute the commands at (defaults to current folder)
* - 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
* (at the moment this is only supported on macOS)
*
* - returns: The output of running the command
* - 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: ["mkdir NewFolder", "cd NewFolder"], at: "~/CurrentFolder")`
*/
@discardableResult public func shellOut(to commands: [String],
at path: String = ".",
outputHandle: FileHandle? = nil,
errorHandle: FileHandle? = nil) throws -> String {
let command = commands.joined(separator: " && ")
return try shellOut(to: command, at: path, outputHandle: outputHandle, errorHandle: errorHandle)
}
/**
* Run a pre-defined shell command using Bash
*
* - parameter command: The command to run
* - parameter path: The path to execute the commands at (defaults to current folder)
* - 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
*
* - returns: The output of running the command
* - 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")`
*
* See `ShellOutCommand` for more info.
*/
@discardableResult public func shellOut(to command: ShellOutCommand,
at path: String = ".",
outputHandle: FileHandle? = nil,
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(" --recurisve")
}
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]
var outputData = Data()
var errorData = Data()
let outputPipe = Pipe()
standardOutput = outputPipe
let errorPipe = Pipe()
standardError = errorPipe
#if !os(Linux)
outputPipe.fileHandleForReading.readabilityHandler = { handler in
let data = handler.availableData
outputData.append(data)
outputHandle?.write(data)
}
errorPipe.fileHandleForReading.readabilityHandler = { handler in
let data = handler.availableData
errorData.append(data)
errorHandle?.write(data)
}
#endif
launch()
#if os(Linux)
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
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[..<endIndex])
}
return output
}
}
private 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)
}
}