diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index bdcaca84..afa4ef1b 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -131,16 +131,20 @@ enum CodeburnCLI { ) -> Process { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + // Resolved once so the PATH we build and the argv we run can never disagree + // about which install of the CLI this launch is talking about. + let argv = baseArgv() var environment = ProcessInfo.processInfo.environment environment["PATH"] = augmentedPath( environment["PATH"] ?? "", homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path, - environment: environment + environment: environment, + resolvedCLI: argv.first ) process.environment = environment // `env --` treats everything following as argv, not VAR=val pairs -- guards against an // argument accidentally resembling an env assignment. - process.arguments = ["--"] + baseArgv() + subcommand + process.arguments = ["--"] + argv + subcommand // The menubar runs as an accessory app with no foreground window, and macOS // background-throttles accessory apps and their children. Without this lift the // codeburn subprocess parses 5-10x slower than the same command run from a @@ -154,12 +158,27 @@ enum CodeburnCLI { return safeArgPattern.firstMatch(in: s, range: range) != nil } + /// `resolvedCLI` defaults to the CLI this app would actually launch; tests pass + /// a fixture path so PATH ordering can be asserted without a real install. static func augmentedPath( _ existing: String, homeDirectory: String, - environment: [String: String] + environment: [String: String], + resolvedCLI: String? = nil ) -> String { var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init) + // The CLI's shebang resolves `node` through PATH, so whichever node comes + // first wins — and a version manager's default can easily be older than + // the 22.13 the CLI requires, which surfaces as "Could not load Today" + // rather than anything pointing at Node. The interpreter that sits beside + // the CLI we are about to run is known to satisfy it, so it goes first. + if let cli = resolvedCLI ?? baseArgv().first, cli.hasPrefix("/") { + let binDir = (cli as NSString).deletingLastPathComponent + if FileManager.default.isExecutableFile(atPath: "\(binDir)/node") { + parts.removeAll { $0 == binDir } + parts.insert(binDir, at: 0) + } + } let userPaths = userNodePaths(homeDirectory: homeDirectory, environment: environment) for extra in additionalPathEntries + userPaths where !parts.contains(extra) { parts.append(extra) diff --git a/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift b/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift index 8568b179..8165ecea 100644 --- a/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift @@ -139,4 +139,88 @@ struct CodeburnCLIPathTests { #expect(entries.contains("/etc/profiles/per-user/test/bin")) #expect(entries.contains("/run/current-system/sw/bin")) } + + /// Regression: the app picked up whichever `node` came first on the inherited + /// PATH, so an nvm default of v20 shadowed the v24 that installed the CLI and + /// every refresh failed with "codeburn requires Node.js >= 22.13.0". + @Test("interpreter beside the CLI wins over an older node on PATH") + func siblingNodeIsPreferredOverInheritedPath() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CodeburnCLIPathTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let newBin = root.appendingPathComponent("node/v24/bin", isDirectory: true) + let oldBin = root.appendingPathComponent("node/v20/bin", isDirectory: true) + try FileManager.default.createDirectory(at: newBin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: oldBin, withIntermediateDirectories: true) + for bin in [newBin, oldBin] { + let node = bin.appendingPathComponent("node") + try "#!/bin/sh\n".write(to: node, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path) + } + + let path = CodeburnCLI.augmentedPath( + "\(oldBin.path):/usr/bin:/bin", + homeDirectory: root.appendingPathComponent("home").path, + environment: [:], + resolvedCLI: newBin.appendingPathComponent("codeburn").path + ) + let entries = path.split(separator: ":").map(String.init) + + #expect(entries.first == newBin.path) + #expect(entries.firstIndex(of: newBin.path)! < entries.firstIndex(of: oldBin.path)!) + // The stale entry still has to survive: it is where the rest of that + // toolchain lives, it just no longer decides which node runs. + #expect(entries.contains(oldBin.path)) + } + + @Test("a CLI directory already on PATH is promoted, not duplicated") + func siblingNodeDirectoryIsNotDuplicated() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CodeburnCLIPathTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let node = bin.appendingPathComponent("node") + try "#!/bin/sh\n".write(to: node, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path) + + let path = CodeburnCLI.augmentedPath( + "/usr/bin:\(bin.path):/bin", + homeDirectory: root.appendingPathComponent("home").path, + environment: [:], + resolvedCLI: bin.appendingPathComponent("codeburn").path + ) + let entries = path.split(separator: ":").map(String.init) + + #expect(entries.first == bin.path) + #expect(entries.filter { $0 == bin.path }.count == 1) + } + + /// A bare `codeburn` (PATH lookup) or a directory with no interpreter beside it + /// must leave the inherited order untouched -- reordering PATH on a guess would + /// change which tools every other lookup resolves to. + @Test("PATH order is untouched when there is no sibling interpreter") + func inheritedOrderSurvivesWithoutSiblingNode() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CodeburnCLIPathTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let bin = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + + for cli in [bin.appendingPathComponent("codeburn").path, "codeburn"] { + let path = CodeburnCLI.augmentedPath( + "/usr/bin:/bin", + homeDirectory: root.appendingPathComponent("home").path, + environment: [:], + resolvedCLI: cli + ) + let entries = path.split(separator: ":").map(String.init) + #expect(entries.first == "/usr/bin") + #expect(entries.dropFirst().first == "/bin") + #expect(!entries.contains(bin.path)) + } + } }