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 { 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) } }