// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen import Foundation /// Runs `brew install node` on the user's explicit request (from `NodeInstallHelpSheet`), when a /// stdio External MCP server's command couldn't be found and Homebrew is present. /// /// Deliberately does NOT use `Process.waitUntilExit()` — see `LoginShellEnvironment`'s doc comment /// in `MCPTransport.swift` for the full story: that call hung for real, twice, in this app's test /// process, even with careful draining and multiple timeout layers. `terminationHandler` + /// `readabilityHandler` (both callback-driven, no blocking wait) is the pattern this codebase /// already uses safely elsewhere (`StdioMCPTransport.prepare()`), so this reuses it instead of /// introducing a second instance of the primitive that just caused two real hangs. enum NodeInstallHelper { struct InstallResult: Sendable { let success: Bool let output: String } static func installNode(homebrewPrefix: String, timeout: TimeInterval = 180) async -> InstallResult { await withCheckedContinuation { continuation in let proc = Process() proc.executableURL = URL(fileURLWithPath: "\(homebrewPrefix)/bin/brew") proc.arguments = ["install", "node"] let outPipe = Pipe() let errPipe = Pipe() proc.standardOutput = outPipe proc.standardError = errPipe let ioQueue = DispatchQueue(label: "confab.nodeinstall.io") var collected = Data() let resumeLock = NSLock() var resumed = false func finish(_ result: InstallResult) { resumeLock.lock() defer { resumeLock.unlock() } guard !resumed else { return } resumed = true outPipe.fileHandleForReading.readabilityHandler = nil errPipe.fileHandleForReading.readabilityHandler = nil continuation.resume(returning: result) } outPipe.fileHandleForReading.readabilityHandler = { handle in let chunk = handle.availableData ioQueue.sync { collected.append(chunk) } } errPipe.fileHandleForReading.readabilityHandler = { handle in let chunk = handle.availableData ioQueue.sync { collected.append(chunk) } } proc.terminationHandler = { p in ioQueue.sync { let text = String(data: collected, encoding: .utf8) ?? "" finish(InstallResult(success: p.terminationStatus == 0, output: text)) } } do { try proc.run() } catch { finish(InstallResult(success: false, output: "Failed to launch brew: \(error.localizedDescription)")) return } DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { guard proc.isRunning else { return } proc.terminate() ioQueue.sync { let text = String(data: collected, encoding: .utf8) ?? "" finish(InstallResult(success: false, output: text + "\n[Timed out after \(Int(timeout))s]")) } } } } }