this repo has no description
at pr14-status-view 333 lines 11 kB view raw
1import Foundation 2 3public enum PathPurpose: Hashable, Sendable { 4 case rpc 5 case tty 6 case nodeTooling 7} 8 9public struct PathDebugSnapshot: Equatable, Sendable { 10 public let codexBinary: String? 11 public let claudeBinary: String? 12 public let effectivePATH: String 13 public let loginShellPATH: String? 14 15 public static let empty = PathDebugSnapshot( 16 codexBinary: nil, 17 claudeBinary: nil, 18 effectivePATH: "", 19 loginShellPATH: nil) 20 21 public init(codexBinary: String?, claudeBinary: String?, effectivePATH: String, loginShellPATH: String?) { 22 self.codexBinary = codexBinary 23 self.claudeBinary = claudeBinary 24 self.effectivePATH = effectivePATH 25 self.loginShellPATH = loginShellPATH 26 } 27} 28 29public enum BinaryLocator { 30 public static func resolveClaudeBinary( 31 env: [String: String] = ProcessInfo.processInfo.environment, 32 loginPATH: [String]? = LoginShellPathCache.shared.current, 33 fileManager: FileManager = .default, 34 home: String = NSHomeDirectory()) -> String? 35 { 36 self.resolveBinary( 37 name: "claude", 38 overrideKey: "CLAUDE_CLI_PATH", 39 env: env, 40 loginPATH: loginPATH, 41 fileManager: fileManager, 42 home: home) 43 } 44 45 public static func resolveCodexBinary( 46 env: [String: String] = ProcessInfo.processInfo.environment, 47 loginPATH: [String]? = LoginShellPathCache.shared.current, 48 fileManager: FileManager = .default, 49 home: String = NSHomeDirectory()) -> String? 50 { 51 self.resolveBinary( 52 name: "codex", 53 overrideKey: "CODEX_CLI_PATH", 54 env: env, 55 loginPATH: loginPATH, 56 fileManager: fileManager, 57 home: home) 58 } 59 60 // swiftlint:disable function_parameter_count 61 private static func resolveBinary( 62 name: String, 63 overrideKey: String, 64 env: [String: String], 65 loginPATH: [String]?, 66 fileManager: FileManager, 67 home: String) -> String? 68 { 69 // swiftlint:enable function_parameter_count 70 // 1) Explicit override 71 if let override = env[overrideKey], fileManager.isExecutableFile(atPath: override) { 72 return override 73 } 74 75 // 2) Existing PATH 76 if let existingPATH = env["PATH"], 77 let pathHit = self.find( 78 name, 79 in: existingPATH.split(separator: ":").map(String.init), 80 fileManager: fileManager) 81 { 82 return pathHit 83 } 84 85 // 3) Login-shell PATH (captured once per launch) 86 if let loginPATH, 87 let pathHit = self.find(name, in: loginPATH, fileManager: fileManager) 88 { 89 return pathHit 90 } 91 92 // 4) Deterministic candidates 93 let directCandidates = [ 94 "/opt/homebrew/bin/\(name)", 95 "/usr/local/bin/\(name)", 96 "\(home)/.local/bin/\(name)", 97 "\(home)/bin/\(name)", 98 "\(home)/.bun/bin/\(name)", 99 "\(home)/.npm-global/bin/\(name)", 100 ] 101 if let hit = directCandidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) { 102 return hit 103 } 104 105 // 5) Version managers (bounded scan) 106 if let nvmHit = self.scanManagedVersions( 107 root: "\(home)/.nvm/versions/node", 108 binary: name, 109 fileManager: fileManager) 110 { 111 return nvmHit 112 } 113 if let fnmHit = self.scanManagedVersions( 114 root: "\(home)/.local/share/fnm", 115 binary: name, 116 fileManager: fileManager) 117 { 118 return fnmHit 119 } 120 121 return nil 122 } 123 124 public static func directories( 125 for purposes: Set<PathPurpose>, 126 env: [String: String], 127 loginPATH: [String]?, 128 fileManager: FileManager = .default, 129 home: String = NSHomeDirectory()) -> [String] 130 { 131 guard purposes.contains(.rpc) || purposes.contains(.tty) else { return [] } 132 var dirs: [String] = [] 133 if let codex = self.resolveCodexBinary( 134 env: env, 135 loginPATH: loginPATH, 136 fileManager: fileManager, 137 home: home) 138 { 139 dirs.append(URL(fileURLWithPath: codex).deletingLastPathComponent().path) 140 } 141 if let claude = self.resolveClaudeBinary( 142 env: env, 143 loginPATH: loginPATH, 144 fileManager: fileManager, 145 home: home) 146 { 147 dirs.append(URL(fileURLWithPath: claude).deletingLastPathComponent().path) 148 } 149 return dirs 150 } 151 152 private static func find(_ binary: String, in paths: [String], fileManager: FileManager) -> String? { 153 for path in paths where !path.isEmpty { 154 let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)" 155 if fileManager.isExecutableFile(atPath: candidate) { 156 return candidate 157 } 158 } 159 return nil 160 } 161 162 private static func scanManagedVersions(root: String, binary: String, fileManager: FileManager) -> String? { 163 guard let versions = try? fileManager.contentsOfDirectory(atPath: root) else { return nil } 164 for version in versions.sorted(by: >) { // newest first 165 let candidate = "\(root)/\(version)/bin/\(binary)" 166 if fileManager.isExecutableFile(atPath: candidate) { 167 return candidate 168 } 169 } 170 return nil 171 } 172} 173 174public enum PathBuilder { 175 public static func effectivePATH( 176 purposes: Set<PathPurpose>, 177 env: [String: String] = ProcessInfo.processInfo.environment, 178 loginPATH: [String]? = LoginShellPathCache.shared.current, 179 resolvedBinaryPaths: [String]? = nil, 180 home: String = NSHomeDirectory()) -> String 181 { 182 var parts: [String] = [] 183 184 if let existing = env["PATH"], !existing.isEmpty { 185 parts.append(contentsOf: existing.split(separator: ":").map(String.init)) 186 } else { 187 parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]) 188 } 189 190 // Minimal static baseline 191 parts.append("/opt/homebrew/bin") 192 parts.append("/usr/local/bin") 193 parts.append("\(home)/.local/bin") 194 parts.append("\(home)/bin") 195 parts.append("\(home)/.bun/bin") 196 parts.append("\(home)/.npm-global/bin") 197 parts.append("\(home)/.local/share/fnm") 198 parts.append("\(home)/.fnm") 199 200 // Directories for resolved binaries 201 let binaries = resolvedBinaryPaths 202 ?? BinaryLocator.directories(for: purposes, env: env, loginPATH: loginPATH, home: home) 203 parts.append(contentsOf: binaries) 204 205 // Optional login-shell PATH captured once per launch 206 if let loginPATH { 207 parts.append(contentsOf: loginPATH) 208 } 209 210 var seen = Set<String>() 211 let deduped = parts.compactMap { part -> String? in 212 guard !part.isEmpty else { return nil } 213 if seen.insert(part).inserted { 214 return part 215 } 216 return nil 217 } 218 219 return deduped.joined(separator: ":") 220 } 221 222 public static func debugSnapshot( 223 purposes: Set<PathPurpose>, 224 env: [String: String] = ProcessInfo.processInfo.environment, 225 home: String = NSHomeDirectory()) -> PathDebugSnapshot 226 { 227 let login = LoginShellPathCache.shared.current 228 let effective = self.effectivePATH( 229 purposes: purposes, 230 env: env, 231 loginPATH: login, 232 home: home) 233 let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home) 234 let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home) 235 let loginString = login?.joined(separator: ":") 236 return PathDebugSnapshot( 237 codexBinary: codex, 238 claudeBinary: claude, 239 effectivePATH: effective, 240 loginShellPATH: loginString) 241 } 242} 243 244enum LoginShellPathCapturer { 245 static func capture( 246 shell: String? = ProcessInfo.processInfo.environment["SHELL"], 247 timeout: TimeInterval = 2.0) -> [String]? 248 { 249 let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" 250 let process = Process() 251 process.executableURL = URL(fileURLWithPath: shellPath) 252 process.arguments = ["-l", "-c", "printf %s \"$PATH\""] 253 let stdout = Pipe() 254 process.standardOutput = stdout 255 process.standardError = Pipe() 256 do { 257 try process.run() 258 } catch { 259 return nil 260 } 261 262 let deadline = Date().addingTimeInterval(timeout) 263 while process.isRunning, Date() < deadline { 264 Thread.sleep(forTimeInterval: 0.05) 265 } 266 267 if process.isRunning { 268 process.terminate() 269 return nil 270 } 271 272 let data = stdout.fileHandleForReading.readDataToEndOfFile() 273 guard let text = String(data: data, encoding: .utf8)? 274 .trimmingCharacters(in: .whitespacesAndNewlines), 275 !text.isEmpty else { return nil } 276 return text.split(separator: ":").map(String.init) 277 } 278} 279 280public final class LoginShellPathCache: @unchecked Sendable { 281 public static let shared = LoginShellPathCache() 282 283 private let lock = NSLock() 284 private var captured: [String]? 285 private var isCapturing = false 286 private var callbacks: [([String]?) -> Void] = [] 287 288 public var current: [String]? { 289 self.lock.lock() 290 let value = self.captured 291 self.lock.unlock() 292 return value 293 } 294 295 public func captureOnce( 296 shell: String? = ProcessInfo.processInfo.environment["SHELL"], 297 timeout: TimeInterval = 2.0, 298 onFinish: (([String]?) -> Void)? = nil) 299 { 300 self.lock.lock() 301 if let captured { 302 self.lock.unlock() 303 onFinish?(captured) 304 return 305 } 306 307 if let onFinish { 308 self.callbacks.append(onFinish) 309 } 310 311 if self.isCapturing { 312 self.lock.unlock() 313 return 314 } 315 316 self.isCapturing = true 317 self.lock.unlock() 318 319 DispatchQueue.global(qos: .utility).async { [weak self] in 320 let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) 321 guard let self else { return } 322 323 self.lock.lock() 324 self.captured = result 325 self.isCapturing = false 326 let callbacks = self.callbacks 327 self.callbacks.removeAll() 328 self.lock.unlock() 329 330 callbacks.forEach { $0(result) } 331 } 332 } 333}