99 lines
4.1 KiB
Swift
99 lines
4.1 KiB
Swift
import Foundation
|
|
|
|
public struct ProcessResult: Sendable {
|
|
public let status: Int32
|
|
public let stdout: String
|
|
public let stderr: String
|
|
public var ok: Bool { status == 0 }
|
|
}
|
|
|
|
/// Minimal blocking command runner. Outputs here are tiny (a line of JSON), so
|
|
/// the read-to-EOF pattern is safe. Always call off the main thread.
|
|
public enum ProcessRunner {
|
|
/// Wrap a string in single quotes for safe interpolation into a `runShell`
|
|
/// command, escaping any embedded single quote. Use for paths/endpoints that
|
|
/// reach the shell.
|
|
public static func shellQuote(_ s: String) -> String {
|
|
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
|
}
|
|
|
|
public static func run(_ launchPath: String, _ args: [String]) -> ProcessResult {
|
|
let p = Process()
|
|
p.executableURL = URL(fileURLWithPath: launchPath)
|
|
p.arguments = args
|
|
let out = Pipe()
|
|
let err = Pipe()
|
|
p.standardOutput = out
|
|
p.standardError = err
|
|
do {
|
|
try p.run()
|
|
} catch {
|
|
return ProcessResult(status: -1, stdout: "", stderr: "spawn failed: \(error.localizedDescription)")
|
|
}
|
|
let o = out.fileHandleForReading.readDataToEndOfFile()
|
|
let e = err.fileHandleForReading.readDataToEndOfFile()
|
|
p.waitUntilExit()
|
|
return ProcessResult(status: p.terminationStatus,
|
|
stdout: String(decoding: o, as: UTF8.self),
|
|
stderr: String(decoding: e, as: UTF8.self))
|
|
}
|
|
|
|
/// Run a command string under an interactive login shell (`/bin/zsh -ilc`) so
|
|
/// the user's full PATH (bun in ~/.bun/bin, uv, …) is available even from a GUI
|
|
/// app's minimal environment. `-i` is required because bun's PATH is exported
|
|
/// from ~/.zshrc, which a non-interactive login shell does NOT source.
|
|
/// Output is drained on background queues to avoid pipe-buffer deadlock, and
|
|
/// the process is terminated if it exceeds `timeout`.
|
|
public static func runShell(_ command: String, timeout: TimeInterval, cwd: String? = nil) -> ProcessResult {
|
|
let p = Process()
|
|
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
p.arguments = ["-ilc", command]
|
|
if let cwd { p.currentDirectoryURL = URL(fileURLWithPath: cwd) }
|
|
let out = Pipe()
|
|
let err = Pipe()
|
|
p.standardOutput = out
|
|
p.standardError = err
|
|
|
|
let sem = DispatchSemaphore(value: 0)
|
|
p.terminationHandler = { _ in sem.signal() }
|
|
|
|
let group = DispatchGroup()
|
|
let q = DispatchQueue(label: "procrunner.read", attributes: .concurrent)
|
|
let box = OutputBox()
|
|
for (handle, isErr) in [(out.fileHandleForReading, false), (err.fileHandleForReading, true)] {
|
|
group.enter()
|
|
q.async {
|
|
let d = handle.readDataToEndOfFile()
|
|
box.set(d, isErr: isErr)
|
|
group.leave()
|
|
}
|
|
}
|
|
|
|
do { try p.run() } catch {
|
|
return ProcessResult(status: -1, stdout: "", stderr: "spawn failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
var timedOut = false
|
|
if sem.wait(timeout: .now() + timeout) == .timedOut {
|
|
timedOut = true
|
|
p.terminate()
|
|
sem.wait()
|
|
}
|
|
group.wait()
|
|
let (o, e) = box.get()
|
|
let stderr = timedOut ? "timed out after \(Int(timeout))s\n" + String(decoding: e, as: UTF8.self)
|
|
: String(decoding: e, as: UTF8.self)
|
|
return ProcessResult(status: timedOut ? -2 : p.terminationStatus,
|
|
stdout: String(decoding: o, as: UTF8.self),
|
|
stderr: stderr)
|
|
}
|
|
}
|
|
|
|
/// Thread-safe collector for the two output streams drained concurrently.
|
|
private final class OutputBox: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var outData = Data()
|
|
private var errData = Data()
|
|
func set(_ d: Data, isErr: Bool) { lock.lock(); if isErr { errData = d } else { outData = d }; lock.unlock() }
|
|
func get() -> (Data, Data) { lock.lock(); defer { lock.unlock() }; return (outData, errData) }
|
|
}
|