mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(source-control): hydrate Windows shell profile PATH (#13418)
This commit is contained in:
+35
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1815,7 +1815,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect.objectContaining({ disabledTuiAgents: ['claude'] }),
|
||||
expect.objectContaining({
|
||||
shouldContinue: expect.any(Function),
|
||||
shouldHydrateShellPath: process.platform !== 'win32'
|
||||
shouldHydrateShellPath: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user