From 87a01c25f5b34200485e047603eadeca2ed14cd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yishun=20Tang=20=C2=B7=20CrazyAnt?= Date: Mon, 7 Sep 2026 10:16:48 +0800 Subject: [PATCH] fix(mac): prefer the node beside the CLI over a version manager's default The app built PATH by appending its curated directories after the inherited value, so whichever `node` came first won -- on an nvm machine that is the default alias, which can be an older major than the one the CLI was installed under. Every spawn then died on the engine gate ("codeburn requires Node.js >= 22.13.0 (current: v20.20.2)") and the popover showed "Could not load Today" with nothing pointing at Node. augmentedPath now takes the resolved CLI path and, when an executable node sits in the same directory, moves that directory to the front of PATH -- promoting it if already present, dropping nothing. That interpreter is the one that installed the CLI and satisfies its engine range. A bare `codeburn` or a CLI with no sibling node leaves the inherited order untouched, so #1263's Nix layout is unaffected. makeProcess resolved the CLI twice, once for PATH and once for argv; it now resolves once and passes the same value to both. Three tests cover the reorder, the promote-not-duplicate case, and the untouched case; removing only the reorder block makes the first two fail with the reported shape while every pre-existing test still passes. --- .../Security/CodeburnCLI.swift | 25 +++++- .../CodeburnCLIPathTests.swift | 84 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) 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)) + } + } }