this repo has no description
at fix/fnm-node-path-resolution 352 lines 12 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 // fnm: check default alias first, then scan node-versions 114 let fnmDefaultBin = "\(home)/.local/share/fnm/aliases/default/bin/\(name)" 115 if fileManager.isExecutableFile(atPath: fnmDefaultBin) { 116 return fnmDefaultBin 117 } 118 if let fnmHit = self.scanFnmVersions( 119 root: "\(home)/.local/share/fnm/node-versions", 120 binary: name, 121 fileManager: fileManager) 122 { 123 return fnmHit 124 } 125 126 return nil 127 } 128 129 public static func directories( 130 for purposes: Set<PathPurpose>, 131 env: [String: String], 132 loginPATH: [String]?, 133 fileManager: FileManager = .default, 134 home: String = NSHomeDirectory()) -> [String] 135 { 136 guard purposes.contains(.rpc) || purposes.contains(.tty) else { return [] } 137 var dirs: [String] = [] 138 if let codex = self.resolveCodexBinary( 139 env: env, 140 loginPATH: loginPATH, 141 fileManager: fileManager, 142 home: home) 143 { 144 dirs.append(URL(fileURLWithPath: codex).deletingLastPathComponent().path) 145 } 146 if let claude = self.resolveClaudeBinary( 147 env: env, 148 loginPATH: loginPATH, 149 fileManager: fileManager, 150 home: home) 151 { 152 dirs.append(URL(fileURLWithPath: claude).deletingLastPathComponent().path) 153 } 154 return dirs 155 } 156 157 private static func find(_ binary: String, in paths: [String], fileManager: FileManager) -> String? { 158 for path in paths where !path.isEmpty { 159 let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)" 160 if fileManager.isExecutableFile(atPath: candidate) { 161 return candidate 162 } 163 } 164 return nil 165 } 166 167 private static func scanManagedVersions(root: String, binary: String, fileManager: FileManager) -> String? { 168 guard let versions = try? fileManager.contentsOfDirectory(atPath: root) else { return nil } 169 for version in versions.sorted(by: >) { // newest first 170 let candidate = "\(root)/\(version)/bin/\(binary)" 171 if fileManager.isExecutableFile(atPath: candidate) { 172 return candidate 173 } 174 } 175 return nil 176 } 177 178 /// fnm uses node-versions/v*/installation/bin/ structure 179 private static func scanFnmVersions(root: String, binary: String, fileManager: FileManager) -> String? { 180 guard let versions = try? fileManager.contentsOfDirectory(atPath: root) else { return nil } 181 for version in versions.sorted(by: >) { // newest first 182 let candidate = "\(root)/\(version)/installation/bin/\(binary)" 183 if fileManager.isExecutableFile(atPath: candidate) { 184 return candidate 185 } 186 } 187 return nil 188 } 189} 190 191public enum PathBuilder { 192 public static func effectivePATH( 193 purposes: Set<PathPurpose>, 194 env: [String: String] = ProcessInfo.processInfo.environment, 195 loginPATH: [String]? = LoginShellPathCache.shared.current, 196 resolvedBinaryPaths: [String]? = nil, 197 home: String = NSHomeDirectory()) -> String 198 { 199 var parts: [String] = [] 200 201 if let existing = env["PATH"], !existing.isEmpty { 202 parts.append(contentsOf: existing.split(separator: ":").map(String.init)) 203 } else { 204 parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]) 205 } 206 207 // Minimal static baseline 208 parts.append("/opt/homebrew/bin") 209 parts.append("/usr/local/bin") 210 parts.append("\(home)/.local/bin") 211 parts.append("\(home)/bin") 212 parts.append("\(home)/.bun/bin") 213 parts.append("\(home)/.npm-global/bin") 214 // fnm: add aliases/default/bin so node is available for node-based CLI scripts 215 parts.append("\(home)/.local/share/fnm/aliases/default/bin") 216 // nvm: add default alias bin for node availability 217 parts.append("\(home)/.nvm/alias/default/bin") 218 219 // Directories for resolved binaries 220 let binaries = resolvedBinaryPaths 221 ?? BinaryLocator.directories(for: purposes, env: env, loginPATH: loginPATH, home: home) 222 parts.append(contentsOf: binaries) 223 224 // Optional login-shell PATH captured once per launch 225 if let loginPATH { 226 parts.append(contentsOf: loginPATH) 227 } 228 229 var seen = Set<String>() 230 let deduped = parts.compactMap { part -> String? in 231 guard !part.isEmpty else { return nil } 232 if seen.insert(part).inserted { 233 return part 234 } 235 return nil 236 } 237 238 return deduped.joined(separator: ":") 239 } 240 241 public static func debugSnapshot( 242 purposes: Set<PathPurpose>, 243 env: [String: String] = ProcessInfo.processInfo.environment, 244 home: String = NSHomeDirectory()) -> PathDebugSnapshot 245 { 246 let login = LoginShellPathCache.shared.current 247 let effective = self.effectivePATH( 248 purposes: purposes, 249 env: env, 250 loginPATH: login, 251 home: home) 252 let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home) 253 let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home) 254 let loginString = login?.joined(separator: ":") 255 return PathDebugSnapshot( 256 codexBinary: codex, 257 claudeBinary: claude, 258 effectivePATH: effective, 259 loginShellPATH: loginString) 260 } 261} 262 263enum LoginShellPathCapturer { 264 static func capture( 265 shell: String? = ProcessInfo.processInfo.environment["SHELL"], 266 timeout: TimeInterval = 2.0) -> [String]? 267 { 268 let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" 269 let process = Process() 270 process.executableURL = URL(fileURLWithPath: shellPath) 271 process.arguments = ["-l", "-c", "printf %s \"$PATH\""] 272 let stdout = Pipe() 273 process.standardOutput = stdout 274 process.standardError = Pipe() 275 do { 276 try process.run() 277 } catch { 278 return nil 279 } 280 281 let deadline = Date().addingTimeInterval(timeout) 282 while process.isRunning, Date() < deadline { 283 Thread.sleep(forTimeInterval: 0.05) 284 } 285 286 if process.isRunning { 287 process.terminate() 288 return nil 289 } 290 291 let data = stdout.fileHandleForReading.readDataToEndOfFile() 292 guard let text = String(data: data, encoding: .utf8)? 293 .trimmingCharacters(in: .whitespacesAndNewlines), 294 !text.isEmpty else { return nil } 295 return text.split(separator: ":").map(String.init) 296 } 297} 298 299public final class LoginShellPathCache: @unchecked Sendable { 300 public static let shared = LoginShellPathCache() 301 302 private let lock = NSLock() 303 private var captured: [String]? 304 private var isCapturing = false 305 private var callbacks: [([String]?) -> Void] = [] 306 307 public var current: [String]? { 308 self.lock.lock() 309 let value = self.captured 310 self.lock.unlock() 311 return value 312 } 313 314 public func captureOnce( 315 shell: String? = ProcessInfo.processInfo.environment["SHELL"], 316 timeout: TimeInterval = 2.0, 317 onFinish: (([String]?) -> Void)? = nil) 318 { 319 self.lock.lock() 320 if let captured { 321 self.lock.unlock() 322 onFinish?(captured) 323 return 324 } 325 326 if let onFinish { 327 self.callbacks.append(onFinish) 328 } 329 330 if self.isCapturing { 331 self.lock.unlock() 332 return 333 } 334 335 self.isCapturing = true 336 self.lock.unlock() 337 338 DispatchQueue.global(qos: .utility).async { [weak self] in 339 let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) 340 guard let self else { return } 341 342 self.lock.lock() 343 self.captured = result 344 self.isCapturing = false 345 let callbacks = self.callbacks 346 self.callbacks.removeAll() 347 self.lock.unlock() 348 349 callbacks.forEach { $0(result) } 350 } 351 } 352}