fix(source-control): hydrate Windows shell profile PATH (#13418)

This commit is contained in:
OrcaWin
2026-08-09 18:42:15 -07:00
committed by GitHub
parent b075a95b06
commit 75650936f4
9 changed files with 822 additions and 35 deletions
+35 -1
View File
@@ -159,6 +159,7 @@ import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration'
import {
acquireSingleInstanceLock,
logSingleInstanceLockBypass,
@@ -2124,6 +2125,21 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
const windowsShellPathHydration = createWindowsShellPathHydration()
if (process.platform === 'win32') {
const settings = store.getSettings()
if (app.isPackaged) {
void windowsShellPathHydration.hydrate(
settings.terminalWindowsShell,
settings.terminalWindowsPowerShellImplementation
)
} else {
windowsShellPathHydration.configure(
settings.terminalWindowsShell,
settings.terminalWindowsPowerShellImplementation
)
}
}
wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null)
logStartupMilestone('store-loaded')
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
@@ -2133,6 +2149,22 @@ void app.whenReady().then(async () => {
// Why: synchronize fallback WSL distro updates to runner.
setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null)
}
if (
('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) &&
process.platform === 'win32'
) {
if (app.isPackaged) {
void windowsShellPathHydration.hydrate(
settings.terminalWindowsShell,
settings.terminalWindowsPowerShellImplementation
)
} else {
windowsShellPathHydration.configure(
settings.terminalWindowsShell,
settings.terminalWindowsPowerShellImplementation
)
}
}
if ('showMenuBarIcon' in updates) {
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
@@ -2742,7 +2774,7 @@ void app.whenReady().then(async () => {
if (isAgentStatusHooksEnabled(store.getSettings())) {
const managedHookStore = store
void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = managedHookStore.getSettings()
@@ -2916,6 +2948,8 @@ void app.whenReady().then(async () => {
}
})
// Why: Git hooks inherit process.env, so Source Control must not open before profile PATH settles.
await windowsShellPathHydration.whenReady()
startTerminalRuntimeStartupServices()
app.on('activate', handleMacAppActivation)
+1 -1
View File
@@ -172,7 +172,7 @@ export function registerSettingsHandlers(
if (hookSettingChanged) {
try {
await applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled, result, {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = store.getSettings()
+1 -1
View File
@@ -1815,7 +1815,7 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({ disabledTuiAgents: ['claude'] }),
expect.objectContaining({
shouldContinue: expect.any(Function),
shouldHydrateShellPath: process.platform !== 'win32'
shouldHydrateShellPath: true
})
)
})
+1 -1
View File
@@ -3607,7 +3607,7 @@ export class OrcaRuntimeService {
return
}
await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
shouldHydrateShellPath: app.isPackaged,
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const current = this.store?.getSettings()
@@ -93,6 +93,26 @@ describe('hydrateShellPath', () => {
expect(spawnCount).toBe(2)
})
it('continues the probe queue after a rejected hydration', async () => {
const rejectedSpawner = vi.fn<HydrationSpawner>(async () => {
throw new Error('profile failed')
})
const successfulSpawner = vi.fn<HydrationSpawner>(async () => ({
segments: ['/current'],
ok: true,
failureReason: 'none'
}))
await expect(
hydrateShellPath({ shellOverride: '/bin/zsh', spawner: rejectedSpawner, force: true })
).rejects.toThrow('profile failed')
await expect(
hydrateShellPath({ shellOverride: '/bin/bash', spawner: successfulSpawner, force: true })
).resolves.toEqual({ segments: ['/current'], ok: true, failureReason: 'none' })
expect(successfulSpawner).toHaveBeenCalledWith('/bin/bash')
})
it('returns failureReason:no_shell when no shell is available (Windows path)', async () => {
const result = await hydrateShellPath({
shellOverride: null,
+146 -31
View File
@@ -1,18 +1,14 @@
import { spawn } from 'node:child_process'
import { delimiter } from 'node:path'
import { delimiter, win32 as pathWin32 } from 'node:path'
import type { ShellHydrationFailureReason } from '../../shared/types'
import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell'
// Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd
// that does not include dirs appended by the user's shell rc files (~/.zshrc,
// ~/.bashrc). Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta
// Why: GUI-launched Electron can miss PATH entries added by shell profiles.
// Tools installed into ~/.opencode/bin, ~/.cargo/bin, pyenv/volta/fnm
// shims, and countless other user-local locations end up invisible to our
// `which` probe even though they work fine from Terminal (see stablyai/orca#829).
//
// Rather than play whack-a-mole adding every agent's install dir to a hardcoded
// list, we spawn the user's login shell once per app session and read the PATH
// it would export. This matches the behavior of every popular Electron app that
// handles this problem (Hyper, VS Code, Cursor, etc. via shell-env/fix-path) —
// we implement it inline to avoid adding a dependency.
// Probe the profile-loading shell once instead of hard-coding every tool's install path.
const DELIMITER = '__ORCA_SHELL_PATH__'
const SPAWN_TIMEOUT_MS = 5000
@@ -34,15 +30,35 @@ export type HydrationResult =
}
let cached: Promise<HydrationResult> | null = null
let probeQueue = Promise.resolve()
let configuredWindowsShell = 'powershell.exe'
let configuredWindowsGitBashPath: string | null = null
let configuredWindowsFallbackShell: string | null = null
let windowsShellConfigurationVersion = 0
const windowsIntroducedPathKeys = new Set<string>()
/** @internal - tests need a clean hydration cache between cases. */
export function _resetHydrateShellPathCache(): void {
cached = null
probeQueue = Promise.resolve()
configuredWindowsShell = 'powershell.exe'
configuredWindowsGitBashPath = null
configuredWindowsFallbackShell = null
windowsShellConfigurationVersion = 0
windowsIntroducedPathKeys.clear()
}
function pickShell(): string | null {
if (process.platform === 'win32') {
return null
const family = resolveWindowsShellStartupFamily(configuredWindowsShell)
if (family === 'cmd') {
return null
}
if (family === 'posix') {
return configuredWindowsGitBashPath
}
const basename = pathWin32.basename(configuredWindowsShell).toLowerCase()
return basename === 'powershell.exe' || basename === 'pwsh.exe' ? configuredWindowsShell : null
}
const shell = process.env.SHELL
if (shell && shell.length > 0) {
@@ -51,7 +67,7 @@ function pickShell(): string | null {
return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
}
function parseCapturedPath(stdout: string): string[] {
function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] {
const cleaned = stdout.replace(ANSI_RE, '')
const first = cleaned.indexOf(DELIMITER)
if (first < 0) {
@@ -70,32 +86,48 @@ function parseCapturedPath(stdout: string): string[] {
return [
...new Set(
value
.split(delimiter)
.split(pathDelimiter)
.map((s) => s.trim())
.filter(Boolean)
)
]
}
function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } {
if (process.platform !== 'win32') {
const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'`
return { args: ['-ilc', command], pathDelimiter: delimiter }
}
if (resolveWindowsShellStartupFamily(shell) === 'posix') {
// Why: native child processes cannot resolve Git Bash's /c/... PATH entries.
const command = `printf '%s' '${DELIMITER}'; cygpath -wp "$PATH"; printf '%s' '${DELIMITER}'`
return { args: ['-ilc', command], pathDelimiter: ';' }
}
const command =
`[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` +
`[Console]::Write('${DELIMITER}'); [Console]::Write($env:Path); ` +
`[Console]::Write('${DELIMITER}')`
// Why: omitting -NoProfile is the behavior this probe exists to capture.
return { args: ['-NoLogo', '-Command', command], pathDelimiter: ';' }
}
function spawnShellAndReadPath(shell: string): Promise<HydrationResult> {
return new Promise((resolve) => {
// Why: printing $PATH between delimiters is resilient to rc-file banners,
// MOTDs, and `echo` invocations that shells like fish print unprompted.
// `-ilc` runs the shell as a login+interactive so both .profile/.zprofile
// and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees.
const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'`
// Why: delimiters isolate PATH from profile banners and MOTDs.
const probe = shellPathProbe(shell)
let finished = false
let stdout = ''
let timer: ReturnType<typeof setTimeout> | null = null
const child = spawn(shell, ['-ilc', command], {
const child = spawn(shell, probe.args, {
// Why: inherit current env so the shell sees the same baseline, then let
// it layer its own rc files on top. Do NOT forward stdio — some shells
// (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup,
// and we don't want that in Orca's console.
env: process.env,
stdio: ['ignore', 'pipe', 'ignore'],
detached: false
detached: false,
windowsHide: true
})
const cleanup = (): void => {
@@ -137,7 +169,7 @@ function spawnShellAndReadPath(shell: string): Promise<HydrationResult> {
}
const onClose = (): void => {
const segments = parseCapturedPath(stdout)
const segments = parseCapturedPath(stdout, probe.pathDelimiter)
if (segments.length === 0) {
finish({ segments: [], ok: false, failureReason: 'empty_path' })
return
@@ -160,7 +192,7 @@ type HydrateOptions = {
}
/**
* Spawn the user's login shell once and return the PATH it would export.
* Spawn the user's profile-loading shell once and return the PATH it would export.
* Caches the promise for the lifetime of the process — call
* `_resetHydrateShellPathCache()` in tests or `hydrateShellPath({ force: true })`
* when the user asks to re-probe (e.g. after installing a new CLI).
@@ -169,17 +201,92 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise<Hydratio
if (cached && !options.force) {
return cached
}
const platform = process.platform
const configurationVersion = windowsShellConfigurationVersion
const shell = options.shellOverride !== undefined ? options.shellOverride : pickShell()
if (!shell) {
// Windows uses cmd/PowerShell rather than a POSIX login shell — the
// `patchPackagedProcessPath` static list is sufficient there.
cached = Promise.resolve({ segments: [], ok: false, failureReason: 'no_shell' })
return cached
}
cached = (options.spawner ?? spawnShellAndReadPath)(shell)
const spawner = options.spawner ?? spawnShellAndReadPath
const fallbackShell = options.shellOverride === undefined ? configuredWindowsFallbackShell : null
const probe = probeQueue.then(async () => {
const result = await spawner(shell)
if (!result.ok && result.failureReason === 'spawn_error' && fallbackShell) {
return spawner(fallbackShell)
}
return result
})
// Why: one rejected profile must not block later refreshes or shell changes.
probeQueue = probe.then(
() => undefined,
() => undefined
)
cached = probe.then((result) => {
if (
platform === 'win32' &&
options.shellOverride === undefined &&
configurationVersion !== windowsShellConfigurationVersion
) {
return hydrateShellPath()
}
return result
})
return cached
}
export function configureWindowsShellPathHydration(
shell: string | null | undefined,
gitBashPath: string | null = null,
fallbackShell: string | null = null
): void {
const next = shell?.trim() || 'powershell.exe'
if (
next === configuredWindowsShell &&
gitBashPath === configuredWindowsGitBashPath &&
fallbackShell === configuredWindowsFallbackShell
) {
return
}
clearWindowsIntroducedPathSegments()
configuredWindowsShell = next
configuredWindowsGitBashPath = gitBashPath
configuredWindowsFallbackShell = fallbackShell
windowsShellConfigurationVersion += 1
cached = null
}
function uniquePathSegments(segments: string[], pathKey: (segment: string) => string): string[] {
const seen = new Set<string>()
return segments.filter((segment) => {
const key = pathKey(segment)
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
function windowsPathKey(segment: string): string {
const normalized = pathWin32.normalize(segment)
const root = pathWin32.parse(normalized).root
const withoutTrailingSlash =
normalized.length > root.length ? normalized.replace(/[\\/]+$/, '') : normalized
return withoutTrailingSlash.toLowerCase()
}
function clearWindowsIntroducedPathSegments(): void {
if (process.platform !== 'win32' || windowsIntroducedPathKeys.size === 0) {
return
}
const currentSegments = (process.env.PATH ?? '').split(pathWin32.delimiter).filter(Boolean)
process.env.PATH = currentSegments
.filter((segment) => !windowsIntroducedPathKeys.has(windowsPathKey(segment)))
.join(pathWin32.delimiter)
windowsIntroducedPathKeys.clear()
}
/**
* Promote shell-discovered PATH segments to the front of process.env.PATH,
* preserving shell ordering and avoiding duplicates. Returns the segments that
@@ -190,16 +297,19 @@ export function mergePathSegments(segments: string[]): string[] {
return []
}
const current = process.env.PATH ?? ''
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 pathDelimiter = process.platform === 'win32' ? pathWin32.delimiter : delimiter
const currentSegments = current.split(pathDelimiter).filter(Boolean)
const pathKey =
process.platform === 'win32' ? windowsPathKey : (segment: string): string => segment
const shellSegments = uniquePathSegments(segments, pathKey)
const shellSegmentSet = new Set(shellSegments.map(pathKey))
const existing = new Set(currentSegments.map(pathKey))
const added = shellSegments.filter((segment) => !existing.has(pathKey(segment)))
const merged = [
...shellSegments,
...currentSegments.filter((segment) => !shellSegmentSet.has(segment))
...currentSegments.filter((segment) => !shellSegmentSet.has(pathKey(segment)))
]
const next = merged.join(delimiter)
const next = merged.join(pathDelimiter)
if (next === current) {
return []
}
@@ -207,5 +317,10 @@ export function mergePathSegments(segments: string[]): string[] {
// 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
if (process.platform === 'win32') {
for (const segment of added) {
windowsIntroducedPathKeys.add(windowsPathKey(segment))
}
}
return added
}
@@ -0,0 +1,297 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import {
_resetHydrateShellPathCache,
configureWindowsShellPathHydration,
hydrateShellPath,
mergePathSegments,
type HydrationResult
} from './hydrate-shell-path'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
type HydrationSpawner = (shell: string) => Promise<HydrationResult>
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => {
resolve = next
})
return { promise, resolve }
}
function createMockShellProcess(): ChildProcessWithoutNullStreams {
const proc = new EventEmitter() as ChildProcessWithoutNullStreams
Object.assign(proc, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
stdin: new EventEmitter(),
kill: vi.fn()
})
return proc
}
describe('Windows shell PATH hydration', () => {
const originalPath = process.env.PATH
beforeEach(() => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
_resetHydrateShellPathCache()
spawnMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('hydrates the default PowerShell profile PATH on Windows (#13328)', async () => {
const spawner = vi.fn<HydrationSpawner>(async () => ({
segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'],
ok: true,
failureReason: 'none'
}))
await expect(hydrateShellPath({ spawner })).resolves.toEqual({
segments: ['C:\\Users\\tester\\AppData\\Local\\fnm'],
ok: true,
failureReason: 'none'
})
expect(spawner).toHaveBeenCalledWith('powershell.exe')
})
it('uses the configured PowerShell executable', async () => {
const spawner = vi.fn<HydrationSpawner>(async () => ({
segments: ['C:\\tools'],
ok: true,
failureReason: 'none'
}))
configureWindowsShellPathHydration('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
await hydrateShellPath({ spawner })
expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
})
it('falls back to Windows PowerShell when preferred pwsh cannot spawn', async () => {
const spawner = vi.fn<HydrationSpawner>()
spawner
.mockResolvedValueOnce({ segments: [], ok: false, failureReason: 'spawn_error' })
.mockResolvedValueOnce({
segments: ['C:\\WindowsPowerShell-profile'],
ok: true,
failureReason: 'none'
})
configureWindowsShellPathHydration('pwsh.exe', null, 'powershell.exe')
await expect(hydrateShellPath({ spawner })).resolves.toEqual({
segments: ['C:\\WindowsPowerShell-profile'],
ok: true,
failureReason: 'none'
})
expect(spawner.mock.calls).toEqual([['pwsh.exe'], ['powershell.exe']])
})
it('uses the resolved Git Bash executable for the configured sentinel', async () => {
const spawner = vi.fn<HydrationSpawner>(async () => ({
segments: ['C:\\tools'],
ok: true,
failureReason: 'none'
}))
configureWindowsShellPathHydration('git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe')
await hydrateShellPath({ spawner })
expect(spawner).toHaveBeenCalledWith('C:\\Program Files\\Git\\bin\\bash.exe')
})
it('loads PowerShell profiles and parses their Windows PATH', async () => {
const proc = createMockShellProcess()
spawnMock.mockReturnValue(proc)
const resultPromise = hydrateShellPath({ force: true })
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
proc.stdout.emit(
'data',
Buffer.from(
'profile banner\r\n__ORCA_SHELL_PATH__C:\\profile-node;C:\\Windows__ORCA_SHELL_PATH__'
)
)
proc.emit('close', 0, null)
await expect(resultPromise).resolves.toEqual({
segments: ['C:\\profile-node', 'C:\\Windows'],
ok: true,
failureReason: 'none'
})
expect(spawnMock).toHaveBeenCalledWith(
'powershell.exe',
['-NoLogo', '-Command', expect.stringContaining('$env:Path')],
expect.objectContaining({ windowsHide: true })
)
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('-NoProfile')
})
it('converts a Git Bash PATH to Windows segments before parsing it', async () => {
const proc = createMockShellProcess()
spawnMock.mockReturnValue(proc)
const resultPromise = hydrateShellPath({
shellOverride: 'C:\\Program Files\\Git\\bin\\bash.exe',
force: true
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
proc.stdout.emit(
'data',
Buffer.from('__ORCA_SHELL_PATH__C:\\git-node;C:\\Windows__ORCA_SHELL_PATH__')
)
proc.emit('close', 0, null)
await expect(resultPromise).resolves.toEqual({
segments: ['C:\\git-node', 'C:\\Windows'],
ok: true,
failureReason: 'none'
})
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
['-ilc', expect.stringContaining('cygpath -wp "$PATH"')],
expect.objectContaining({ windowsHide: true })
)
})
it.each(['cmd.exe', 'wsl.exe', 'git-bash', 'C:\\Tools\\zsh.exe'])(
'does not merge %s guest or profile-less paths',
async (shell) => {
const spawner = vi.fn<HydrationSpawner>()
configureWindowsShellPathHydration(shell)
await expect(hydrateShellPath({ spawner })).resolves.toEqual({
segments: [],
ok: false,
failureReason: 'no_shell'
})
expect(spawner).not.toHaveBeenCalled()
}
)
it('serializes shell changes, discards stale results, and caches the newest request', async () => {
const powerShellResult = deferred<HydrationResult>()
const gitBashResult = deferred<HydrationResult>()
let activeProbes = 0
let maxActiveProbes = 0
const powerShellSpawner = vi.fn<HydrationSpawner>(async () => {
activeProbes += 1
maxActiveProbes = Math.max(maxActiveProbes, activeProbes)
const result = await powerShellResult.promise
activeProbes -= 1
return result
})
const gitBashSpawner = vi.fn<HydrationSpawner>(async () => {
activeProbes += 1
maxActiveProbes = Math.max(maxActiveProbes, activeProbes)
const result = await gitBashResult.promise
activeProbes -= 1
return result
})
process.env.PATH = 'C:\\Windows'
const powerShellReady = hydrateShellPath({ spawner: powerShellSpawner, force: true })
const powerShellMerged = powerShellReady.then((result) => mergePathSegments(result.segments))
await vi.waitFor(() => expect(powerShellSpawner).toHaveBeenCalledWith('powershell.exe'))
configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe')
const gitBashReady = hydrateShellPath({ spawner: gitBashSpawner, force: true })
const gitBashMerged = gitBashReady.then((result) => mergePathSegments(result.segments))
const cachedGitBashReady = hydrateShellPath({ spawner: gitBashSpawner })
expect(cachedGitBashReady).toBe(gitBashReady)
expect(gitBashSpawner).not.toHaveBeenCalled()
powerShellResult.resolve({
segments: ['C:\\stale-powershell'],
ok: true,
failureReason: 'none'
})
await vi.waitFor(() => expect(gitBashSpawner).toHaveBeenCalledWith('C:\\Git\\bin\\bash.exe'))
gitBashResult.resolve({
segments: ['C:\\current-git-bash'],
ok: true,
failureReason: 'none'
})
await Promise.all([powerShellMerged, gitBashMerged, cachedGitBashReady])
expect(maxActiveProbes).toBe(1)
expect(powerShellSpawner).toHaveBeenCalledOnce()
expect(gitBashSpawner).toHaveBeenCalledOnce()
expect(process.env.PATH).toBe('C:\\current-git-bash;C:\\Windows')
})
it('discards an active probe when the configured shell has no profile', async () => {
const powerShellResult = deferred<HydrationResult>()
const spawner = vi.fn<HydrationSpawner>(() => powerShellResult.promise)
const staleReady = hydrateShellPath({ spawner, force: true })
await vi.waitFor(() => expect(spawner).toHaveBeenCalledOnce())
configureWindowsShellPathHydration('cmd.exe')
const currentReady = hydrateShellPath({ spawner })
powerShellResult.resolve({
segments: ['C:\\stale-powershell'],
ok: true,
failureReason: 'none'
})
await expect(Promise.all([staleReady, currentReady])).resolves.toEqual([
{ segments: [], ok: false, failureReason: 'no_shell' },
{ segments: [], ok: false, failureReason: 'no_shell' }
])
expect(spawner).toHaveBeenCalledOnce()
})
it('deduplicates PATH entries case-insensitively', () => {
process.env.PATH = 'C:\\Tools;C:\\Windows'
expect(mergePathSegments(['c:\\tools', 'C:\\profile-node'])).toEqual(['C:\\profile-node'])
expect(process.env.PATH).toBe('c:\\tools;C:\\profile-node;C:\\Windows')
})
it('deduplicates PATH entries with trailing slashes', () => {
process.env.PATH = 'C:\\Windows\\;C:\\Tools'
expect(mergePathSegments(['C:\\Windows', 'C:\\profile-node\\'])).toEqual(['C:\\profile-node\\'])
expect(process.env.PATH).toBe('C:\\Windows;C:\\profile-node\\;C:\\Tools')
})
it('removes profile entries when switching to a shell without a profile', () => {
process.env.PATH = 'C:\\Inherited;C:\\Windows'
mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited'])
configureWindowsShellPathHydration('cmd.exe')
expect(process.env.PATH).toBe('C:\\Inherited;C:\\Windows')
})
it('replaces profile entries introduced by the previously configured shell', () => {
process.env.PATH = 'C:\\Inherited;C:\\Windows'
mergePathSegments(['C:\\PowerShell-profile', 'C:\\Inherited'])
configureWindowsShellPathHydration('git-bash', 'C:\\Git\\bin\\bash.exe')
mergePathSegments(['C:\\GitBash-profile', 'C:\\Inherited'])
expect(process.env.PATH).toBe('C:\\GitBash-profile;C:\\Inherited;C:\\Windows')
})
})
@@ -0,0 +1,214 @@
import { describe, expect, it, vi } from 'vitest'
import { createWindowsShellPathHydration } from './windows-shell-path-hydration'
import type { HydrationResult } from './hydrate-shell-path'
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((next) => {
resolve = next
})
return { promise, resolve }
}
function success(segment: string): HydrationResult {
return { segments: [segment], ok: true, failureReason: 'none' }
}
describe('Windows shell PATH hydration coordination', () => {
it('serializes probes and never merges a superseded shell result', async () => {
const first = deferred<HydrationResult>()
const second = deferred<HydrationResult>()
const hydrate = vi.fn<() => Promise<HydrationResult>>()
hydrate.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const merge = vi.fn<(segments: string[]) => string[]>(() => [])
const configure = vi.fn()
const coordinator = createWindowsShellPathHydration({
configure,
hydrate,
merge,
resolveGitBashPath: (shell) => (shell === 'git-bash' ? 'C:\\Git\\bin\\bash.exe' : null)
})
const powerShellReady = coordinator.hydrate('powershell.exe')
await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(1))
const gitBashReady = coordinator.hydrate('git-bash')
first.resolve(success('C:\\stale-powershell'))
await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2))
expect(merge).not.toHaveBeenCalled()
second.resolve(success('C:\\current-git-bash'))
await Promise.all([powerShellReady, gitBashReady])
expect(merge).toHaveBeenCalledOnce()
expect(merge).toHaveBeenCalledWith(['C:\\current-git-bash'])
expect(configure).toHaveBeenLastCalledWith('git-bash', 'C:\\Git\\bin\\bash.exe', null)
})
it('coalesces queued shell changes to the latest configured shell', async () => {
const active = deferred<HydrationResult>()
const latest = deferred<HydrationResult>()
const hydrate = vi.fn<() => Promise<HydrationResult>>()
hydrate.mockReturnValueOnce(active.promise).mockReturnValueOnce(latest.promise)
const merge = vi.fn<(segments: string[]) => string[]>(() => [])
const coordinator = createWindowsShellPathHydration({ hydrate, merge })
const activeReady = coordinator.hydrate('powershell.exe')
await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(1))
const skippedReady = coordinator.hydrate('cmd.exe')
const latestReady = coordinator.hydrate('powershell.exe')
active.resolve(success('C:\\stale'))
await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2))
latest.resolve(success('C:\\latest'))
await Promise.all([activeReady, skippedReady, latestReady])
expect(hydrate).toHaveBeenCalledTimes(2)
expect(merge).toHaveBeenCalledOnce()
expect(merge).toHaveBeenCalledWith(['C:\\latest'])
})
it('keeps the startup barrier behind shell changes queued while it waits', async () => {
const first = deferred<HydrationResult>()
const second = deferred<HydrationResult>()
const hydrate = vi.fn<() => Promise<HydrationResult>>()
hydrate.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const coordinator = createWindowsShellPathHydration({ hydrate })
void coordinator.hydrate('powershell.exe', 'powershell.exe')
await vi.waitFor(() => expect(hydrate).toHaveBeenCalledOnce())
let startupReady = false
const startupBarrier = coordinator.whenReady().then(() => {
startupReady = true
})
void coordinator.hydrate('git-bash')
first.resolve(success('C:\\stale'))
await vi.waitFor(() => expect(hydrate).toHaveBeenCalledTimes(2))
expect(startupReady).toBe(false)
second.resolve(success('C:\\latest'))
await startupBarrier
expect(startupReady).toBe(true)
})
it('uses the terminal safety chain for a custom PowerShell path', () => {
const configure = vi.fn()
const coordinator = createWindowsShellPathHydration({
configure,
resolvePowerShellChain: () => [
'C:\\Resolved\\pwsh.exe',
'C:\\Resolved\\powershell.exe',
'C:\\Resolved\\cmd.exe'
]
})
coordinator.configure('C:\\Program Files\\PowerShell\\7\\pwsh.exe', 'pwsh.exe')
expect(configure).toHaveBeenCalledWith(
'C:\\Resolved\\pwsh.exe',
null,
'C:\\Resolved\\powershell.exe'
)
})
it('configures refresh callers without spawning in development', () => {
const configure = vi.fn()
const hydrate = vi.fn<() => Promise<HydrationResult>>()
const coordinator = createWindowsShellPathHydration({
configure,
hydrate,
resolveGitBashPath: () => 'C:\\Git\\bin\\bash.exe'
})
coordinator.configure('git-bash')
expect(configure).toHaveBeenCalledWith('git-bash', 'C:\\Git\\bin\\bash.exe', null)
expect(hydrate).not.toHaveBeenCalled()
})
it.each([
{
implementation: 'auto' as const,
expectedShell: 'pwsh.exe',
expectedFallback: 'powershell.exe'
},
{
implementation: 'pwsh.exe' as const,
expectedShell: 'pwsh.exe',
expectedFallback: 'powershell.exe'
},
{
implementation: 'powershell.exe' as const,
expectedShell: 'powershell.exe',
expectedFallback: null
}
])(
'configures $implementation to hydrate the effective PowerShell profile',
({ implementation, expectedShell, expectedFallback }) => {
const configure = vi.fn()
const coordinator = createWindowsShellPathHydration({
configure,
resolvePowerShellChain: (family) =>
family === 'pwsh.exe'
? ['pwsh.exe', 'powershell.exe', 'cmd.exe']
: ['powershell.exe', 'cmd.exe']
})
coordinator.configure('powershell.exe', implementation)
expect(configure).toHaveBeenCalledWith(expectedShell, null, expectedFallback)
}
)
it('reconfigures when the PowerShell implementation changes', () => {
const configure = vi.fn()
const coordinator = createWindowsShellPathHydration({
configure,
resolvePowerShellChain: (family) =>
family === 'pwsh.exe'
? ['pwsh.exe', 'powershell.exe', 'cmd.exe']
: ['powershell.exe', 'cmd.exe']
})
coordinator.configure('powershell.exe', 'powershell.exe')
coordinator.configure('powershell.exe', 'pwsh.exe')
expect(configure).toHaveBeenNthCalledWith(1, 'powershell.exe', null, null)
expect(configure).toHaveBeenNthCalledWith(2, 'pwsh.exe', null, 'powershell.exe')
})
it('uses inbox PowerShell when pwsh has no safe executable behind its Store alias', () => {
const configure = vi.fn()
const coordinator = createWindowsShellPathHydration({
configure,
resolvePowerShellChain: () => [
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'C:\\Windows\\System32\\cmd.exe'
]
})
coordinator.configure('powershell.exe', 'auto')
expect(configure).toHaveBeenCalledWith(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
null,
null
)
})
it('skips hydration when no PowerShell executable resolves safely', () => {
const configure = vi.fn()
const coordinator = createWindowsShellPathHydration({
configure,
resolvePowerShellChain: () => ['C:\\Windows\\System32\\cmd.exe']
})
coordinator.configure('powershell.exe', 'auto')
expect(configure).toHaveBeenCalledWith('C:\\Windows\\System32\\cmd.exe', null, null)
})
})
@@ -0,0 +1,107 @@
import { win32 as pathWin32 } from 'node:path'
import { resolveWindowsGitBashShellPath } from '../git-bash'
import {
resolveEffectiveWindowsPowerShell,
type WindowsPowerShellImplementation,
type WindowsPowerShellShellFamily
} from '../providers/windows-powershell'
import { resolveWindowsPowerShellSpawnChain } from '../providers/windows-powershell-executable'
import {
configureWindowsShellPathHydration,
hydrateShellPath,
mergePathSegments,
type HydrationResult
} from './hydrate-shell-path'
type WindowsShellPathHydrationOptions = {
configure?: (
shell: string | null | undefined,
gitBashPath: string | null,
fallbackShell: string | null
) => void
hydrate?: () => Promise<HydrationResult>
merge?: (segments: string[]) => string[]
resolveGitBashPath?: (shell: string) => string | null
resolvePowerShellChain?: (family: 'powershell.exe' | 'pwsh.exe') => string[]
warn?: (error: unknown) => void
}
export function createWindowsShellPathHydration(options: WindowsShellPathHydrationOptions = {}) {
const configure = options.configure ?? configureWindowsShellPathHydration
const hydrate = options.hydrate ?? hydrateShellPath
const merge = options.merge ?? mergePathSegments
const resolveGitBashPath = options.resolveGitBashPath ?? resolveWindowsGitBashShellPath
const resolvePowerShellChain =
options.resolvePowerShellChain ?? resolveWindowsPowerShellSpawnChain
const warn =
options.warn ??
((error: unknown) => {
console.warn('[shell-path] Windows profile hydration failed; using inherited PATH:', error)
})
let generation = 0
let ready = Promise.resolve()
const setConfiguredShell = (
shell: string | null | undefined,
implementation?: WindowsPowerShellImplementation
): void => {
const requestedShell = shell?.trim() || 'powershell.exe'
const basename = pathWin32.basename(requestedShell).toLowerCase()
const shellFamily: WindowsPowerShellShellFamily =
basename === 'powershell.exe' || basename === 'pwsh.exe' ? basename : undefined
const effectivePowerShell = resolveEffectiveWindowsPowerShell({
shellFamily,
implementation,
pwshAvailable: true
})
if (!effectivePowerShell) {
configure(requestedShell, resolveGitBashPath(requestedShell), null)
return
}
const spawnChain = resolvePowerShellChain(effectivePowerShell)
const profileShell = spawnChain[0] ?? 'cmd.exe'
const fallbackShell = spawnChain.slice(1).find((candidate) => {
const candidateBasename = pathWin32.basename(candidate).toLowerCase()
return candidateBasename === 'powershell.exe' || candidateBasename === 'pwsh.exe'
})
configure(profileShell, null, fallbackShell ?? null)
}
return {
configure: (shell, implementation?: WindowsPowerShellImplementation) => {
generation += 1
setConfiguredShell(shell, implementation)
},
hydrate: (shell, implementation?: WindowsPowerShellImplementation) => {
generation += 1
const requestGeneration = generation
setConfiguredShell(shell, implementation)
ready = ready.then(async () => {
if (requestGeneration !== generation) {
return
}
try {
const result = await hydrate()
if (requestGeneration === generation && result.ok) {
merge(result.segments)
}
} catch (error) {
if (requestGeneration === generation) {
warn(error)
}
}
})
return ready
},
whenReady: async () => {
let pending = ready
while (true) {
await pending
if (pending === ready) {
return
}
pending = ready
}
}
}
}