Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}
Loading