From 75650936f4283e2902ae7cfe522feef9912d64d7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 9 Aug 2026 18:42:15 -0700 Subject: [PATCH] fix(source-control): hydrate Windows shell profile PATH (#13418) --- src/main/index.ts | 36 ++- src/main/ipc/settings.ts | 2 +- src/main/runtime/orca-runtime.test.ts | 2 +- src/main/runtime/orca-runtime.ts | 2 +- src/main/startup/hydrate-shell-path.test.ts | 20 ++ src/main/startup/hydrate-shell-path.ts | 177 +++++++++-- .../hydrate-shell-path.windows.test.ts | 297 ++++++++++++++++++ .../windows-shell-path-hydration.test.ts | 214 +++++++++++++ .../startup/windows-shell-path-hydration.ts | 107 +++++++ 9 files changed, 822 insertions(+), 35 deletions(-) create mode 100644 src/main/startup/hydrate-shell-path.windows.test.ts create mode 100644 src/main/startup/windows-shell-path-hydration.test.ts create mode 100644 src/main/startup/windows-shell-path-hydration.ts diff --git a/src/main/index.ts b/src/main/index.ts index a8153952c02..ddf79fd9455 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 275ba54c82a..df3817c949c 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec656c70144..7967d422f46 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1815,7 +1815,7 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ disabledTuiAgents: ['claude'] }), expect.objectContaining({ shouldContinue: expect.any(Function), - shouldHydrateShellPath: process.platform !== 'win32' + shouldHydrateShellPath: true }) ) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f0127702406..526acea823b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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() diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index 7ff6ee2ae81..316baad7280 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -93,6 +93,26 @@ describe('hydrateShellPath', () => { expect(spawnCount).toBe(2) }) + it('continues the probe queue after a rejected hydration', async () => { + const rejectedSpawner = vi.fn(async () => { + throw new Error('profile failed') + }) + const successfulSpawner = vi.fn(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, diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 9411b0437d3..87747399615 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -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 | 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() /** @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 { 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 | 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 { } 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 { + 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() + 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 } diff --git a/src/main/startup/hydrate-shell-path.windows.test.ts b/src/main/startup/hydrate-shell-path.windows.test.ts new file mode 100644 index 00000000000..ee1957c8a42 --- /dev/null +++ b/src/main/startup/hydrate-shell-path.windows.test.ts @@ -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 + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((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(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(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() + 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(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() + + 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() + const gitBashResult = deferred() + let activeProbes = 0 + let maxActiveProbes = 0 + const powerShellSpawner = vi.fn(async () => { + activeProbes += 1 + maxActiveProbes = Math.max(maxActiveProbes, activeProbes) + const result = await powerShellResult.promise + activeProbes -= 1 + return result + }) + const gitBashSpawner = vi.fn(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() + const spawner = vi.fn(() => 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') + }) +}) diff --git a/src/main/startup/windows-shell-path-hydration.test.ts b/src/main/startup/windows-shell-path-hydration.test.ts new file mode 100644 index 00000000000..87175008bfd --- /dev/null +++ b/src/main/startup/windows-shell-path-hydration.test.ts @@ -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 = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((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() + const second = deferred() + const hydrate = vi.fn<() => Promise>() + 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() + const latest = deferred() + const hydrate = vi.fn<() => Promise>() + 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() + const second = deferred() + const hydrate = vi.fn<() => Promise>() + 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>() + 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) + }) +}) diff --git a/src/main/startup/windows-shell-path-hydration.ts b/src/main/startup/windows-shell-path-hydration.ts new file mode 100644 index 00000000000..083ea8dbe57 --- /dev/null +++ b/src/main/startup/windows-shell-path-hydration.ts @@ -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 + 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 + } + } + } +}