import Foundation import Vapor final class PreBuildRunner { static func run(hooks: [PreBuildHook], session: BuildSession, logger: Logger) async -> Bool { for hook in hooks { session.broadcast(BuildLogMessage(type: .stdout, text: "\n⚙️ Hook: \(hook.name)\n", exitCode: nil)) let ok = await runHook(hook, session: session) if !ok { session.broadcast(BuildLogMessage(type: .stderr, text: "❌ Hook '\(hook.name)' failed.", exitCode: nil)) if !hook.continueOnFailure { return false } } else { session.broadcast(BuildLogMessage(type: .stdout, text: "✅ \(hook.name) done.\n", exitCode: nil)) } } return true } private static func runHook(_ hook: PreBuildHook, session: BuildSession) async -> Bool { await withCheckedContinuation { cont in let p = Process() p.executableURL = URL(fileURLWithPath: "/bin/zsh") p.arguments = ["-l", "-c", hook.command] p.currentDirectoryURL = URL(fileURLWithPath: hook.workingDirectory) var env = ProcessInfo.processInfo.environment env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:" + (env["PATH"] ?? "") p.environment = env let out = Pipe(); let err = Pipe() p.standardOutput = out; p.standardError = err var timedOut = false let wd = DispatchWorkItem { timedOut = true; p.terminate() session.broadcast(BuildLogMessage(type: .stderr, text: "⏱ Hook '\(hook.name)' timed out.", exitCode: nil)) } DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(hook.timeoutSeconds), execute: wd) let g = DispatchGroup() g.enter() out.fileHandleForReading.readabilityHandler = { h in let d = h.availableData if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return } if let t = String(data: d, encoding: .utf8) { session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) } } g.enter() err.fileHandleForReading.readabilityHandler = { h in let d = h.availableData if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return } if let t = String(data: d, encoding: .utf8) { session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) } } p.terminationHandler = { proc in wd.cancel() g.notify(queue: .global()) { cont.resume(returning: proc.terminationStatus == 0 && !timedOut) } } do { try p.run() } catch { wd.cancel() session.broadcast(BuildLogMessage(type: .stderr, text: "Launch failed: \(error)", exitCode: nil)) cont.resume(returning: false) } } } }