diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index e08e8304894d..8949f5414ad6 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -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.. 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 } diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift index e9e36d5f2b06..138c6217bafb 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift @@ -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)) } }