fix(macos): prefer supported version-manager Node runtimes (#129564)

This commit is contained in:
Peter Steinberger
2026-08-25 12:39:44 -07:00
committed by GitHub
parent 955d23d6b4
commit 482fbdc161
2 changed files with 71 additions and 35 deletions
@@ -220,26 +220,17 @@ enum CommandResolver {
return []
}
func parseVersion(_ name: String) -> [Int] {
let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name
return trimmed.split(separator: ".").compactMap { Int($0) }
}
let sorted = entries.sorted { a, b in
let va = parseVersion(a)
let vb = parseVersion(b)
let maxCount = max(va.count, vb.count)
for i in 0..<maxCount {
let ai = i < va.count ? va[i] : 0
let bi = i < vb.count ? vb[i] : 0
if ai != bi { return ai > bi }
}
// If identical numerically, keep stable ordering.
return a > b
let sorted = entries.compactMap { entry -> (name: String, version: RuntimeVersion)? in
guard let version = RuntimeVersion.from(string: entry),
RuntimeLocator.isSupportedNodeVersion(version)
else { return nil }
return (entry, version)
}.sorted { first, second in
first.version == second.version ? first.name > second.name : first.version > second.version
}
var paths: [String] = []
for entry in sorted {
for (entry, _) in sorted {
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
let node = binDir.appendingPathComponent("node")
if FileManager().isExecutableFile(atPath: node.path) {
@@ -721,10 +712,4 @@ enum CommandResolver {
args.append(contentsOf: remoteCommand)
return args
}
#if SWIFT_PACKAGE
static func _testNodeManagerBinPaths(home: URL) -> [String] {
self.nodeManagerBinPaths(home: home)
}
#endif
}
@@ -3,28 +3,79 @@ import Testing
@testable import OpenClaw
struct NodeManagerPathsTests {
@Test func `fnm node bins prefer newest installed version`() throws {
@Test func `fnm node bins prefer the newest supported installed version`() throws {
let home = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: home) }
let v20Bin = home
.appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node")
let v25Bin = home
.appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node")
try makeExecutableForTests(at: v20Bin)
try makeExecutableForTests(at: v25Bin)
let v22Node = home
.appendingPathComponent(".local/share/fnm/node-versions/v22.22.3/installation/bin/node")
let v25Node = home
.appendingPathComponent(".local/share/fnm/node-versions/v25.9.0/installation/bin/node")
try makeExecutableForTests(at: v22Node)
try makeExecutableForTests(at: v25Node)
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(bins.first == v25Bin.deletingLastPathComponent().path)
#expect(bins.contains(v20Bin.deletingLastPathComponent().path))
let paths = CommandResolver.preferredPaths(home: home, current: [], projectRoot: home)
let newestIndex = try #require(paths.firstIndex(of: v25Node.deletingLastPathComponent().path))
let olderIndex = try #require(paths.firstIndex(of: v22Node.deletingLastPathComponent().path))
#expect(newestIndex < olderIndex)
}
@Test(arguments: [
(".local/share/fnm/node-versions", "installation/bin"),
(".nvm/versions/node", "bin"),
])
func `unsupported newer manager runtimes cannot hide a supported installed Node`(
managerRoot: String,
binarySuffix: String) async throws
{
let home = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: home) }
let versions = ["v25.8.1", "v24.15.0", "v23.11.0", "v22.22.3"]
for version in versions {
let node = home
.appendingPathComponent(managerRoot)
.appendingPathComponent(version)
.appendingPathComponent(binarySuffix)
.appendingPathComponent("node")
try makeExecutableForTests(at: node)
try "#!/bin/sh\necho \(version)\n".write(to: node, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path)
}
let unsupported = home
.appendingPathComponent(managerRoot)
.appendingPathComponent("v25.8.1")
.appendingPathComponent(binarySuffix)
let expectedNode = home
.appendingPathComponent(managerRoot)
.appendingPathComponent("v24.15.0")
.appendingPathComponent(binarySuffix)
.appendingPathComponent("node")
let searchPaths = CommandResolver.preferredPaths(
home: home,
current: [unsupported.path],
projectRoot: home)
let result = await RuntimeLocator.resolve(searchPaths: searchPaths)
guard case let .success(runtime) = result else {
Issue.record("A newer unsupported manager runtime hid an installed supported Node: \(result)")
return
}
#expect(runtime.path == expectedNode.path)
#expect(runtime.version == RuntimeVersion(major: 24, minor: 15, patch: 0))
}
@Test func `ignores entries without node executable`() throws {
let home = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: home) }
let missingNodeBin = home
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(!bins.contains(missingNodeBin.path))
let paths = CommandResolver.preferredPaths(home: home, current: [], projectRoot: home)
#expect(!paths.contains(missingNodeBin.path))
}
}