mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
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 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).
|
|
//
|
|
// 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
|
|
|
|
// ANSI escape sequences can leak into the captured output when the user's rc
|
|
// files print banners or set colored prompts. Strip them before parsing.
|
|
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g // eslint-disable-line no-control-regex
|
|
|
|
// Why: the discriminator lets telemetry classify *why* hydration failed, not
|
|
// just whether it did. Five resolve sites in this file each tag the result
|
|
// with the right reason. The shared alias keeps the enum in lockstep with the
|
|
// telemetry schema (compile-time guard in telemetry-events.ts).
|
|
export type HydrationResult =
|
|
| { ok: true; segments: string[]; failureReason: 'none' }
|
|
| {
|
|
ok: false
|
|
segments: []
|
|
failureReason: Exclude<ShellHydrationFailureReason, 'none'>
|
|
}
|
|
|
|
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') {
|
|
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) {
|
|
return shell
|
|
}
|
|
return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'
|
|
}
|
|
|
|
function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] {
|
|
const cleaned = stdout.replace(ANSI_RE, '')
|
|
const first = cleaned.indexOf(DELIMITER)
|
|
if (first < 0) {
|
|
return []
|
|
}
|
|
const second = cleaned.indexOf(DELIMITER, first + DELIMITER.length)
|
|
if (second < 0) {
|
|
return []
|
|
}
|
|
const value = cleaned.slice(first + DELIMITER.length, second).trim()
|
|
if (!value) {
|
|
return []
|
|
}
|
|
// Why: Set preserves insertion order, and PATH resolution is first-match-wins,
|
|
// so de-duping this way keeps the user's rc-file ordering intact.
|
|
return [
|
|
...new Set(
|
|
value
|
|
.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: 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, 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,
|
|
windowsHide: true
|
|
})
|
|
|
|
const cleanup = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
child.stdout.off('data', onStdoutData)
|
|
child.off('error', onError)
|
|
child.off('close', onClose)
|
|
}
|
|
const finish = (result: HydrationResult): void => {
|
|
if (finished) {
|
|
return
|
|
}
|
|
finished = true
|
|
cleanup()
|
|
resolve(result)
|
|
}
|
|
|
|
timer = setTimeout(() => {
|
|
// Why: slow rc files (corporate env setup, nvm eager init) can exceed
|
|
// our budget. Kill the shell and fall back to process.env rather than
|
|
// blocking the Agents pane indefinitely.
|
|
try {
|
|
child.kill('SIGKILL')
|
|
} catch {
|
|
// ignore
|
|
}
|
|
finish({ segments: [], ok: false, failureReason: 'timeout' })
|
|
}, SPAWN_TIMEOUT_MS)
|
|
|
|
const onStdoutData = (chunk: Buffer): void => {
|
|
stdout += chunk.toString('utf8')
|
|
}
|
|
|
|
const onError = (): void => {
|
|
finish({ segments: [], ok: false, failureReason: 'spawn_error' })
|
|
}
|
|
|
|
const onClose = (): void => {
|
|
const segments = parseCapturedPath(stdout, probe.pathDelimiter)
|
|
if (segments.length === 0) {
|
|
finish({ segments: [], ok: false, failureReason: 'empty_path' })
|
|
return
|
|
}
|
|
finish({ segments, ok: true, failureReason: 'none' })
|
|
}
|
|
|
|
child.stdout.on('data', onStdoutData)
|
|
child.on('error', onError)
|
|
child.on('close', onClose)
|
|
})
|
|
}
|
|
|
|
type HydrateOptions = {
|
|
force?: boolean
|
|
/** Override for tests — defaults to running `spawn` against the real shell. */
|
|
spawner?: (shell: string) => Promise<HydrationResult>
|
|
/** Override for tests — defaults to `pickShell()`. */
|
|
shellOverride?: string | null
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
export function hydrateShellPath(options: HydrateOptions = {}): Promise<HydrationResult> {
|
|
if (cached && !options.force) {
|
|
return cached
|
|
}
|
|
const platform = process.platform
|
|
const configurationVersion = windowsShellConfigurationVersion
|
|
const shell = options.shellOverride !== undefined ? options.shellOverride : pickShell()
|
|
if (!shell) {
|
|
cached = Promise.resolve({ segments: [], ok: false, failureReason: 'no_shell' })
|
|
return cached
|
|
}
|
|
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
|
|
* were newly added so callers can log/telemetry on nontrivial hydrations.
|
|
*/
|
|
export function mergePathSegments(segments: string[]): string[] {
|
|
if (segments.length === 0) {
|
|
return []
|
|
}
|
|
const current = process.env.PATH ?? ''
|
|
const 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(pathKey(segment)))
|
|
]
|
|
const next = merged.join(pathDelimiter)
|
|
if (next === current) {
|
|
return []
|
|
}
|
|
// Why: shell-provided entries must win over hardcoded packaged-app fallbacks.
|
|
// A seeded fallback can point at a stale CLI while the user's shell resolves
|
|
// a healthy one from the same directory list in a different order.
|
|
process.env.PATH = next
|
|
if (process.platform === 'win32') {
|
|
for (const segment of added) {
|
|
windowsIntroducedPathKeys.add(windowsPathKey(segment))
|
|
}
|
|
}
|
|
return added
|
|
}
|