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
25 changes: 22 additions & 3 deletions mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/CodeburnCLIPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
Loading