如何在Swift脚本中运行终端命令?(例如,xcodebuild)

154

我想用Swift替换我的CI Bash脚本。但是我不知道如何调用像lsxcodebuild这样的正常终端命令。

#!/usr/bin/env xcrun swift

import Foundation // Works
println("Test") // Works
ls // Fails
xcodebuild -workspace myApp.xcworkspace // Fails

$ ./script.swift
./script.swift:5:1: error: use of unresolved identifier 'ls'
ls // Fails
^
... etc ....
16个回答

212
如果您希望像在命令行中一样“精确”使用命令行参数(而不是分离所有参数),请尝试以下方法。
(此答案改进了LegoLess的答案,可用于Swift 5)
import Foundation

func shell(_ command: String) -> String {
    let task = Process()
    let pipe = Pipe()
    
    task.standardOutput = pipe
    task.standardError = pipe
    task.arguments = ["-c", command]
    task.launchPath = "/bin/zsh"
    task.standardInput = nil
    task.launch()
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!
    
    return output
}

// Example usage:
shell("ls -la")
已更新/更安全的函数调用 10/23/21: 可能会在上述shell命令中遇到运行时错误,如果是这样,请尝试切换到下面更新的调用。您需要在新的shell命令周围使用do catch语句,但希望这能为您节省一些搜索捕获意外错误的时间。

解释: 由于task.launch()不是一个可抛出的函数,因此无法被捕获,我发现有时仅仅调用它就会导致应用程序崩溃。经过大量的互联网搜索,我发现Process类已经将task.launch()废弃,转而采用较新的函数task.run(),该函数可以正确地抛出错误而不会使应用程序崩溃。要了解有关更新方法的更多信息,请参见:https://eclecticlight.co/2019/02/02/scripting-in-swift-process-deprecations/
import Foundation

@discardableResult // Add to suppress warnings when you don't want/need a result
func safeShell(_ command: String) throws -> String {
    let task = Process()
    let pipe = Pipe()
    
    task.standardOutput = pipe
    task.standardError = pipe
    task.arguments = ["-c", command]
    task.executableURL = URL(fileURLWithPath: "/bin/zsh") //<--updated
    task.standardInput = nil

    try task.run() //<--updated
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!
    
    return output
}

示例:

// Example usage capturing error:
do {
    try safeShell("ls -la")
}
catch {
    print("\(error)") //handle or silence the error here
}

// Example usage where you don't care about the error and want a nil back instead
let result = try? safeShell("ls -la")

// Example usage where you don't care about the error or the return value
try? safeShell("ls -la")

注意:针对最后一种情况,您正在使用try?并且没有使用结果,由于某种原因,即使它被标记为@discardableResult,编译器仍然会发出警告。 这只发生在try?中,而不是在do-try-catch块内或从抛出函数中。 无论哪种方式,您都可以安全地忽略它。

14
这个答案应该排得更靠前,因为它解决了许多先前回答所存在的问题。 - Steven Hepting
1
+1. 对于OSX用户来说,需要注意/bin/bash指的是bash-3.2。如果你想使用bash的更高级功能,请更改路径(通常/usr/bin/env bash是一个不错的选择)。 - Aserre
1
很棒的代码片段!一个小建议:由于函数已经throws,你不需要使用do..try..catch块,只需使用try task.run()即可。 - Przemysław Wrzesiński
感谢Przemysław Wrzesiński提出的编辑建议!我已经更新了它。 - user3064009
2
在调用task.run()之前,您还应添加task.standardInput = nil。如果子进程尝试从stdin读取数据,内核将_默默停止_子进程,这可能会非常难以诊断。这可能很微妙,因为子进程可以在_监视_ stdin 的同时执行有用的工作。当您在键盘上输入内容时,子进程会看到有等待的输入并调用read()。内核通过发送SIGSTOP来响应,并使子进程冻结。我很惊讶这不是Process()的默认设置。 - Jim Hayes
显示剩余2条评论

160

如果您在Swift代码中不使用命令输出,则以下内容就足够了:

#!/usr/bin/env swift

import Foundation

@discardableResult
func shell(_ args: String...) -> Int32 {
    let task = Process()
    task.launchPath = "/usr/bin/env"
    task.arguments = args
    task.launch()
    task.waitUntilExit()
    return task.terminationStatus
}

shell("ls")
shell("xcodebuild", "-workspace", "myApp.xcworkspace")

更新:适用于Swift3/Xcode8


7
"NSTask"已更名为"Process"。 - Mateusz
5
在Swift 4中,Process()还存在吗?我收到了一个未定义的符号错误。 :/ - Arnaldo Capo
1
我可以确认这个在Xcode 9.1 / Swift 4中可行!我在一个Xcode控制台应用程序中尝试了它,没有第一行 :-) - blackjacx
4
我尝试了一下,得到了这个结果:https://i.imgur.com/Ge1OOCG.png。 - code-8
7
该过程仅在 macOS 上可用。 - shallowThought
显示剩余8条评论

39

这里的问题是你不能混用Bash和Swift。你已经知道如何从命令行运行Swift脚本,现在需要添加在Swift中执行Shell命令的方法。总结来自PracticalSwift博客:

func shell(_ launchPath: String, _ arguments: [String]) -> String?
{
    let task = Process()
    task.launchPath = launchPath
    task.arguments = arguments

    let pipe = Pipe()
    task.standardOutput = pipe
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: String.Encoding.utf8)

    return output
}
以下的 Swift 代码将会使用参数执行 xcodebuild,并输出结果。
shell("xcodebuild", ["-workspace", "myApp.xcworkspace"]);

如果要搜索目录内容(这也是Bash中ls所做的),我建议在Swift中直接使用NSFileManager扫描目录,而不是使用Bash输出,因为后者解析起来可能会很麻烦。


1
太好了 - 我进行了一些编辑以使其编译,但是当尝试调用 shell("ls", []) 时我遇到了运行时异常 - 'NSInvalidArgumentException',原因:'启动路径不可访问'。有什么想法吗? - Robert
6
NSTask不会像shell那样搜索可执行文件(使用环境中的PATH变量)。启动路径必须是绝对路径(例如"/bin/ls")或相对于当前工作目录的路径。 - Martin R
NSTask无法从用户环境中获取路径,因为PATH基本上是shell的概念,不可达。 - Legoless
太好了 - 现在它可以工作了。我发布了完整的脚本+一些修改以保证完整性。谢谢。 - Robert
2
使用 shell("cd", "~/Desktop/"),我得到了以下错误信息: /usr/bin/cd: line 4: cd: ~/Desktop/: 没有那个文件或目录。 - Zaporozhchenko Oleksandr
这个例子中有很多已弃用的方法。你能修复一下吗? - Gargo

24

Swift 3.0 中的实用函数

此函数还返回任务的终止状态并等待完成。

func shell(launchPath: String, arguments: [String] = []) -> (String? , Int32) {
    let task = Process()
    task.launchPath = launchPath
    task.arguments = arguments

    let pipe = Pipe()
    task.standardOutput = pipe
    task.standardError = pipe
    task.launch()
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)
    task.waitUntilExit()
    return (output, task.terminationStatus)
}

5
缺少import Foundation。 (翻译:import Foundation is missing.) - Binarian
4
遗憾的是,不适用于iOS。 - Raphael

17

如果你想在调用命令时使用Bash环境,请使用以下Bash函数,该函数使用修复版本的Legoless。我不得不从shell函数的结果中删除一个尾随换行符。

Swift 3.0:(Xcode8)

import Foundation

func shell(launchPath: String, arguments: [String]) -> String
{
    let task = Process()
    task.launchPath = launchPath
    task.arguments = arguments

    let pipe = Pipe()
    task.standardOutput = pipe
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: String.Encoding.utf8)!
    if output.characters.count > 0 {
        //remove newline character.
        let lastIndex = output.index(before: output.endIndex)
        return output[output.startIndex ..< lastIndex]
    }
    return output
}

func bash(command: String, arguments: [String]) -> String {
    let whichPathForCommand = shell(launchPath: "/bin/bash", arguments: [ "-l", "-c", "which \(command)" ])
    return shell(launchPath: whichPathForCommand, arguments: arguments)
}

例如,获取当前工作目录的当前工作git分支:

let currentBranch = bash("git", arguments: ["describe", "--contains", "--all", "HEAD"])
print("current branch:\(currentBranch)")

17

仅供更新,自从Apple弃用了.launchPath和launch()两个函数,这里提供一个更新的Swift 4实用函数,应该更具未来性。

注意:目前Apple关于替代方案(run(), executableURL等)的文档基本为空。

import Foundation

// wrapper function for shell commands
// must provide full path to executable
func shell(_ launchPath: String, _ arguments: [String] = []) -> (String?, Int32) {
  let task = Process()
  task.executableURL = URL(fileURLWithPath: launchPath)
  task.arguments = arguments

  let pipe = Pipe()
  task.standardOutput = pipe
  task.standardError = pipe

  do {
    try task.run()
  } catch {
    // handle errors
    print("Error: \(error.localizedDescription)")
  }

  let data = pipe.fileHandleForReading.readDataToEndOfFile()
  let output = String(data: data, encoding: .utf8)

  task.waitUntilExit()
  return (output, task.terminationStatus)
}


// valid directory listing test
let (goodOutput, goodStatus) = shell("/bin/ls", ["-la"])
if let out = goodOutput { print("\(out)") }
print("Returned \(goodStatus)\n")

// invalid test
let (badOutput, badStatus) = shell("ls")

应该能够直接将此内容粘贴到游乐场中以查看其实际效果。


2
在Swift 5的Playground中,只要Playground设置平台为“macOS”,就可以正常工作。而“iOS”播放平台会收到“无法在范围内找到'Process'”的错误消息。 - l --marc l

14

基于Legoless答案的完整脚本

#!/usr/bin/env swift

import Foundation

func printShell(launchPath: String, arguments: [String] = []) {
    let output = shell(launchPath: launchPath, arguments: arguments)

    if (output != nil) {
        print(output!)
    }
}

func shell(launchPath: String, arguments: [String] = []) -> String? {
    let task = Process()
    task.launchPath = launchPath
    task.arguments = arguments

    let pipe = Pipe()
    task.standardOutput = pipe
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: String.Encoding.utf8)

    return output
}

// > ls
// > ls -a -g
printShell(launchPath: "/bin/ls")
printShell(launchPath: "/bin/ls", arguments:["-a", "-g"])

8

更新到Swift 4.0(处理String的更改)

func shell(launchPath: String, arguments: [String]) -> String
{
    let task = Process()
    task.launchPath = launchPath
    task.arguments = arguments

    let pipe = Pipe()
    task.standardOutput = pipe
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: String.Encoding.utf8)!
    if output.count > 0 {
        //remove newline character.
        let lastIndex = output.index(before: output.endIndex)
        return String(output[output.startIndex ..< lastIndex])
    }
    return output
}

func bash(command: String, arguments: [String]) -> String {
    let whichPathForCommand = shell(launchPath: "/bin/bash", arguments: [ "-l", "-c", "which \(command)" ])
    return shell(launchPath: whichPathForCommand, arguments: arguments)
}

give the example - Gowtham Sooryaraj

7

在尝试了这里发布的一些解决方案后,我发现使用-c参数标志执行命令是最好的方法。

@discardableResult func shell(_ command: String) -> (String?, Int32) {
    let task = Process()

    task.launchPath = "/bin/bash"
    task.arguments = ["-c", command]

    let pipe = Pipe()
    task.standardOutput = pipe
    task.standardError = pipe
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)
    task.waitUntilExit()
    return (output, task.terminationStatus)
}


let _ = shell("mkdir ~/Desktop/test")

4
import Foundation

enum Commands {
  struct Result {
    public let statusCode: Int32
    public let output: String
  }
  
  static func run(_ command: String,
                  environment: [String: String]? = nil,
                  executableURL: String = "/bin/bash",
                  dashc: String = "-c") -> Result {
    // create process
    func create(_ executableURL: String,
                dashc: String,
                environment: [String: String]?) -> Process {
      let process = Process()
      if #available(macOS 10.13, *) {
        process.executableURL = URL(fileURLWithPath: executableURL)
      } else {
        process.launchPath = "/bin/bash"
      }
      if let environment = environment {
        process.environment = environment
      }
      process.arguments = [dashc, command]
      return process
    }
    // run process
    func run(_ process: Process) throws {
      if #available(macOS 10.13, *) {
        try process.run()
      } else {
        process.launch()
      }
      process.waitUntilExit()
    }
    // read data
    func fileHandleData(fileHandle: FileHandle) throws -> String? {
      var outputData: Data?
      if #available(macOS 10.15.4, *) {
        outputData = try fileHandle.readToEnd()
      } else {
        outputData = fileHandle.readDataToEndOfFile()
      }
      if let outputData = outputData {
        return String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
      }
      return nil
    }
    
    let process = create(executableURL, dashc: dashc, environment: environment)
    
    let outputPipe = Pipe()
    process.standardOutput = outputPipe
    
    let errorPipe = Pipe()
    process.standardError = errorPipe
    
    do {
      try run(process)
      
      let outputActual = try fileHandleData(fileHandle: outputPipe.fileHandleForReading) ?? ""
      let errorActual = try fileHandleData(fileHandle: errorPipe.fileHandleForReading) ?? ""
      
      if process.terminationStatus == EXIT_SUCCESS {
        return Result(statusCode: process.terminationStatus, output: outputActual)
      }
      return Result(statusCode: process.terminationStatus, output: errorActual)
    } catch let error {
      return Result(statusCode: process.terminationStatus, output: error.localizedDescription)
    }
  }
}

使用方法

let result = Commands.run("ls")
debugPrint(result.output)
debugPrint(result.statusCode)

或者使用swift-commands
import Commands

Commands.Bash.system("ls")

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接