From dda323460b6ada14c2c91c3429aadb1c3b126a8f Mon Sep 17 00:00:00 2001 From: sm0keyyy Date: Sat, 5 Sep 2026 22:56:57 -0400 Subject: [PATCH] fix(mac): resolve a Nix-provided node when spawning the codeburn CLI On a nix-darwin machine node lives only in /etc/profiles/per-user/$USER/bin, which appears in neither additionalPathEntries nor userNodePaths. The CLI shim begins '#!/usr/bin/env node', so the spawn dies with 'env: node: No such file or directory' and exit status 127. GUI apps normally inherit those directories from 'launchctl config user path', stored in /var/db/com.apple.xpc.launchd/config/user.plist, which a macOS system update deletes. Re-applying it is not durable, so name the Nix locations in userNodePaths instead. That feeds both installedArgv() discovery and augmentedPath() interpreter resolution. Also adds ~/.local/bin, the bin directory for the common 'prefix = ~/.local' npm configuration, which installedArgv() could not otherwise discover. Entries are appended, so existing matches keep their precedence. --- .../Security/CodeburnCLI.swift | 28 +++++++- .../CodeburnCLIPathTests.swift | 68 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift index 6facf258..bdcaca84 100644 --- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift +++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift @@ -20,7 +20,7 @@ enum CodeburnCLI { ) -> [String] { let home = homeDirectory var paths: [String] = [] - for dir in ["\(home)/.volta/bin", "\(home)/.npm-global/bin", "\(home)/.asdf/shims"] { + for dir in ["\(home)/.volta/bin", "\(home)/.npm-global/bin", "\(home)/.local/bin", "\(home)/.asdf/shims"] { paths.append(dir) } // `mise use -g npm:codeburn` installs the CLI under its npm backend, but @@ -43,6 +43,32 @@ enum CodeburnCLI { } } } + paths.append(contentsOf: nixPaths(homeDirectory: home)) + return paths + } + + /// Nix keeps Node outside every location above. nix-darwin exposes the per-user profile at + /// `/etc/profiles/per-user/$USER/bin` and the system profile at `/run/current-system/sw/bin`; + /// standalone `nix profile` uses `~/.nix-profile/bin`, which on the XDG state layout resolves + /// through `~/.local/state/nix/profiles/profile/bin`. + /// + /// These matter because a macOS system update clears `launchctl config user path`, the only + /// mechanism that put those directories on a GUI-launched app's PATH. After the update the + /// app inherits the bare `/usr/bin:/bin:/usr/sbin:/sbin`, so the CLI's `#!/usr/bin/env node` + /// shim can no longer resolve `node` and every spawn dies with exit 127. Naming the + /// directories here keeps the app working without any machine-level launchd configuration. + private static func nixPaths(homeDirectory: String) -> [String] { + var paths: [String] = [] + let user = (homeDirectory as NSString).lastPathComponent + if !user.isEmpty { + paths.append("/etc/profiles/per-user/\(user)/bin") + } + paths.append(contentsOf: [ + "\(homeDirectory)/.nix-profile/bin", + "\(homeDirectory)/.local/state/nix/profiles/profile/bin", + "/run/current-system/sw/bin", + "/nix/var/nix/profiles/default/bin", + ]) return paths } private static let persistedPathFilename = "codeburn-cli-path.v1" diff --git a/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift b/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift index 01ec7abc..8568b179 100644 --- a/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift @@ -71,4 +71,72 @@ struct CodeburnCLIPathTests { #expect(path.split(separator: ":").filter { $0 == Substring(customShims) }.count == 1) } + + @Test("Spotlight-minimal PATH can launch a CLI whose only node is a Nix profile") + func spotlightCanLaunchNixCLI() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CodeburnCLIPathTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let home = root.appendingPathComponent("home", isDirectory: true) + let wrapper = home.appendingPathComponent(".local/bin/codeburn") + let nodeBin = home.appendingPathComponent(".nix-profile/bin/node") + try FileManager.default.createDirectory( + at: wrapper.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: nodeBin.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // Mirrors the real shim, which resolves `node` through PATH via `#!/usr/bin/env node`. + try "#!/bin/sh\nexec node \"$@\"\n".write(to: wrapper, atomically: true, encoding: .utf8) + try "#!/bin/sh\nprintf 'nix-node-ok\\n'\n".write(to: nodeBin, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodeBin.path) + + let augmentedPath = CodeburnCLI.augmentedPath( + "/usr/bin:/bin", + homeDirectory: home.path, + environment: [:] + ) + // Keep the behavior fixture independent of tools installed on the CI host. + // Every retained entry came from the production augmentation above. + let isolatedPath = augmentedPath + .split(separator: ":") + .map(String.init) + .filter { $0 == "/usr/bin" || $0 == "/bin" || $0.hasPrefix(home.path + "/") } + .joined(separator: ":") + + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["--", wrapper.path, "--version"] + process.environment = [ + "HOME": home.path, + "PATH": isolatedPath, + ] + process.standardOutput = stdout + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + + #expect(process.terminationStatus == 0) + #expect(output == "nix-node-ok\n") + } + + @Test("nix-darwin per-user profile is derived from the home directory") + func nixDarwinPerUserProfileIsIncluded() { + let path = CodeburnCLI.augmentedPath( + "/usr/bin:/bin", + homeDirectory: "/Users/test", + environment: [:] + ) + let entries = path.split(separator: ":").map(String.init) + + #expect(entries.contains("/etc/profiles/per-user/test/bin")) + #expect(entries.contains("/run/current-system/sw/bin")) + } }