Fix packaged shell PATH order (#5125)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-11 12:39:57 -07:00
committed by Jinwoo-H
co-authored by Orca
parent aabbbc2bdd
commit eec1207b8d
2 changed files with 36 additions and 14 deletions
+18 -2
View File
@@ -186,14 +186,30 @@ describe('mergePathSegments', () => {
)
})
it('skips segments already on PATH so re-hydration is a no-op', () => {
it('promotes shell segments already on PATH so shell ordering wins', () => {
process.env.PATH = joinPath('/Users/tester/.cargo/bin', '/usr/bin')
const added = mergePathSegments(['/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin'])
expect(added).toEqual(['/Users/tester/.opencode/bin'])
expect(process.env.PATH).toBe(
joinPath('/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin', '/usr/bin')
joinPath('/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin', '/usr/bin')
)
})
it('moves user-local shell paths ahead of packaged Homebrew fallbacks', () => {
process.env.PATH = joinPath(
'/opt/homebrew/bin',
'/Users/tester/.local/bin',
'/usr/bin',
'/bin'
)
const added = mergePathSegments(['/Users/tester/.local/bin', '/opt/homebrew/bin'])
expect(added).toEqual([])
expect(process.env.PATH).toBe(
joinPath('/Users/tester/.local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin')
)
})
+18 -12
View File
@@ -181,25 +181,31 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise<Hydratio
}
/**
* Prepend newly-discovered PATH segments to process.env.PATH, preserving
* existing ordering and avoiding duplicates. Returns the segments that were
* actually added so callers can log/telemetry on nontrivial hydrations.
* Promote shell-discovered PATH segments to the front of process.env.PATH,
* preserving shell ordering and avoiding duplicates. Returns the segments that
* were newly added so callers can log/telemetry on nontrivial hydrations.
*/
export function mergePathSegments(segments: string[]): string[] {
if (segments.length === 0) {
return []
}
const current = process.env.PATH ?? ''
const existing = new Set(current.split(delimiter).filter(Boolean))
// Why: Node 22+ Set.prototype.difference preserves insertion order of the
// receiver, so [...incoming.difference(existing)] gives us the new entries
// in the order the shell provided them (first-match-wins on PATH).
const added = [...new Set(segments).difference(existing)]
if (added.length === 0) {
const currentSegments = current.split(delimiter).filter(Boolean)
const shellSegments = [...new Set(segments)]
const shellSegmentSet = new Set(shellSegments)
const existing = new Set(currentSegments)
const added = shellSegments.filter((segment) => !existing.has(segment))
const merged = [
...shellSegments,
...currentSegments.filter((segment) => !shellSegmentSet.has(segment))
]
const next = merged.join(delimiter)
if (next === current) {
return []
}
// Why: prepend so shell-provided entries win over the hardcoded fallbacks.
// The user's rc files are the source of truth for `which`-style resolution.
process.env.PATH = [...added, ...current.split(delimiter).filter(Boolean)].join(delimiter)
// Why: shell-provided entries must win over hardcoded packaged-app fallbacks.
// A seeded fallback can point at a stale CLI while the user's shell resolves
// a healthy one from the same directory list in a different order.
process.env.PATH = next
return added
}