mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix(agents): detect all user-installed CLIs by hydrating PATH from login shell (#843)
* fix(agents): detect opencode/pi CLIs installed under ~/.opencode/bin and ~/.vite-plus/bin Packaged Electron inherits a minimal PATH without shell rc files, so agent install-script fallback dirs stay invisible to `which` probes — the Agents settings page then shows OpenCode/Pi as "Not installed" even when the user can run them from Terminal. Add ~/bin, ~/.opencode/bin, and ~/.vite-plus/bin to the packaged PATH augmentation so preflight detection matches shell behavior. Fixes #829 * feat(agents): hydrate PATH from user's login shell + Agents Refresh button Packaged Electron inherits a minimal launchd PATH that misses whatever the user's shell rc files append — ~/.opencode/bin, ~/.cargo/bin, nvm shims, pyenv, custom tool dirs. The preceding commit hardcoded two known install locations; this replaces that whack-a-mole pattern with a generic approach. On packaged startup (non-Windows), spawn `${SHELL} -ilc 'echo $PATH'` with a 5s timeout, parse the delimited PATH, and prepend any new segments to process.env.PATH. The result is cached for the app session so we pay the shell-init cost at most once. Surface a Refresh button in Settings > Agents that forces a re-probe and re-detects installed agents — handy right after installing a new CLI, no restart needed. Live-verified that a `zsh -ilc` spawn with a minimal launchd-style env still resolves the user's full PATH (32+ segments including the rc-appended dirs). * refactor(hydrate-shell-path): simplify dedup with Set + Set.difference Set preserves insertion order, so PATH first-match-wins semantics are preserved without manual tracking. Set.prototype.difference (Node 22+) expresses the new-segments calculation in mergePathSegments as the set-difference operation it always was.
This commit is contained in:
parent
660b5f4149
commit
9d56108e89
10 changed files with 555 additions and 10 deletions
|
|
@ -27,6 +27,7 @@ import {
|
|||
installUncaughtPipeErrorGuard,
|
||||
patchPackagedProcessPath
|
||||
} from './startup/configure-process'
|
||||
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
||||
import { RateLimitService } from './rate-limits/service'
|
||||
import { attachMainWindowServices } from './window/attach-main-window-services'
|
||||
import { createMainWindow } from './window/createMainWindow'
|
||||
|
|
@ -53,6 +54,21 @@ let starNag: StarNagService | null = null
|
|||
|
||||
installUncaughtPipeErrorGuard()
|
||||
patchPackagedProcessPath()
|
||||
// Why: patchPackagedProcessPath seeds a minimal list of well-known system
|
||||
// dirs synchronously so early IPC (e.g. preflight before the shell spawn
|
||||
// completes) doesn't miss homebrew/nix. Kick off the login-shell probe in
|
||||
// parallel for packaged runs — when it resolves, its PATH is prepended and
|
||||
// detectInstalledAgents picks up whatever the user's rc files put on PATH
|
||||
// (cargo/pyenv/volta/custom tool install dirs) without hardcoding each one.
|
||||
// Dev runs already inherit a complete PATH from the launching terminal, so
|
||||
// the spawn cost is only paid where it's needed.
|
||||
if (app.isPackaged && process.platform !== 'win32') {
|
||||
void hydrateShellPath().then((result) => {
|
||||
if (result.ok) {
|
||||
mergePathSegments(result.segments)
|
||||
}
|
||||
})
|
||||
}
|
||||
configureDevUserDataPath(is.dev)
|
||||
installDevParentDisconnectQuit(is.dev)
|
||||
installDevParentWatchdog(is.dev)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, execFileMock, execFileAsyncMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
execFileAsyncMock: vi.fn()
|
||||
}))
|
||||
const { handleMock, execFileMock, execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock } =
|
||||
vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
execFileAsyncMock: vi.fn(),
|
||||
hydrateShellPathMock: vi.fn(),
|
||||
mergePathSegmentsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
|
|
@ -17,10 +20,16 @@ vi.mock('child_process', () => {
|
|||
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
|
||||
})
|
||||
return {
|
||||
execFile: execFileWithPromisify
|
||||
execFile: execFileWithPromisify,
|
||||
spawn: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../startup/hydrate-shell-path', () => ({
|
||||
hydrateShellPath: hydrateShellPathMock,
|
||||
mergePathSegments: mergePathSegmentsMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetPreflightCache,
|
||||
detectInstalledAgents,
|
||||
|
|
@ -36,6 +45,8 @@ describe('preflight', () => {
|
|||
beforeEach(() => {
|
||||
handleMock.mockReset()
|
||||
execFileAsyncMock.mockReset()
|
||||
hydrateShellPathMock.mockReset()
|
||||
mergePathSegmentsMock.mockReset()
|
||||
_resetPreflightCache()
|
||||
|
||||
for (const key of Object.keys(handlers)) {
|
||||
|
|
@ -180,4 +191,67 @@ describe('preflight', () => {
|
|||
|
||||
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor'])
|
||||
})
|
||||
|
||||
it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => {
|
||||
// Why: the Agents settings Refresh button calls this path. It must (1) ask
|
||||
// the shell hydrator for a fresh PATH, (2) merge any new segments, then
|
||||
// (3) re-run `which` so newly-installed CLIs appear without a restart.
|
||||
hydrateShellPathMock.mockResolvedValueOnce({
|
||||
segments: ['/Users/test/.opencode/bin'],
|
||||
ok: true
|
||||
})
|
||||
mergePathSegmentsMock.mockReturnValueOnce(['/Users/test/.opencode/bin'])
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'opencode') {
|
||||
return { stdout: '/Users/test/.opencode/bin/opencode\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
registerPreflightHandlers()
|
||||
|
||||
const result = (await handlers['preflight:refreshAgents']()) as {
|
||||
agents: string[]
|
||||
addedPathSegments: string[]
|
||||
shellHydrationOk: boolean
|
||||
}
|
||||
|
||||
expect(result).toEqual({
|
||||
agents: ['opencode'],
|
||||
addedPathSegments: ['/Users/test/.opencode/bin'],
|
||||
shellHydrationOk: true
|
||||
})
|
||||
expect(hydrateShellPathMock).toHaveBeenCalledWith({ force: true })
|
||||
})
|
||||
|
||||
it('still re-detects when the shell spawn fails — relies on the existing PATH', async () => {
|
||||
hydrateShellPathMock.mockResolvedValueOnce({ segments: [], ok: false })
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'claude') {
|
||||
return { stdout: '/Users/test/.local/bin/claude\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
registerPreflightHandlers()
|
||||
|
||||
const result = (await handlers['preflight:refreshAgents']()) as {
|
||||
agents: string[]
|
||||
addedPathSegments: string[]
|
||||
shellHydrationOk: boolean
|
||||
}
|
||||
|
||||
expect(result.shellHydrationOk).toBe(false)
|
||||
expect(result.addedPathSegments).toEqual([])
|
||||
expect(result.agents).toEqual(['claude'])
|
||||
// Why: when hydration fails, we must not call merge — nothing to merge —
|
||||
// otherwise we'd log a no-op "added 0 segments" event on every refresh.
|
||||
expect(mergePathSegmentsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { execFile } from 'child_process'
|
|||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
|
|
@ -60,6 +61,32 @@ export async function detectInstalledAgents(): Promise<string[]> {
|
|||
return checks.filter((c) => c.installed).map((c) => c.id)
|
||||
}
|
||||
|
||||
export type RefreshAgentsResult = {
|
||||
/** Agents detected after hydrating PATH from the user's login shell. */
|
||||
agents: string[]
|
||||
/** PATH segments that were added this refresh (empty if nothing new). */
|
||||
addedPathSegments: string[]
|
||||
/** True when the shell spawn succeeded. False = relied on existing PATH. */
|
||||
shellHydrationOk: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-spawn the user's login shell to refresh process.env.PATH, then re-run
|
||||
* agent detection. Called by the Agents settings pane when the user clicks
|
||||
* Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case
|
||||
* without requiring an app restart.
|
||||
*/
|
||||
export async function refreshShellPathAndDetectAgents(): Promise<RefreshAgentsResult> {
|
||||
const hydration = await hydrateShellPath({ force: true })
|
||||
const added = hydration.ok ? mergePathSegments(hydration.segments) : []
|
||||
const agents = await detectInstalledAgents()
|
||||
return {
|
||||
agents,
|
||||
addedPathSegments: added,
|
||||
shellHydrationOk: hydration.ok
|
||||
}
|
||||
}
|
||||
|
||||
async function isGhAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('gh', ['auth', 'status'], {
|
||||
|
|
@ -110,4 +137,8 @@ export function registerPreflightHandlers(): void {
|
|||
ipcMain.handle('preflight:detectAgents', async (): Promise<string[]> => {
|
||||
return detectInstalledAgents()
|
||||
})
|
||||
|
||||
ipcMain.handle('preflight:refreshAgents', async (): Promise<RefreshAgentsResult> => {
|
||||
return refreshShellPathAndDetectAgents()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ vi.mock('electron', () => {
|
|||
}),
|
||||
quit: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
isPackaged: false,
|
||||
commandLine: {
|
||||
appendSwitch: vi.fn()
|
||||
}
|
||||
|
|
@ -23,6 +24,70 @@ afterEach(() => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('patchPackagedProcessPath', () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const originalHome = process.env.HOME
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('prepends agent-CLI install dirs (~/.opencode/bin, ~/.vite-plus/bin) for packaged darwin runs', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { patchPackagedProcessPath } = await import('./configure-process')
|
||||
|
||||
setPlatform('darwin')
|
||||
Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
|
||||
process.env.HOME = '/Users/tester'
|
||||
process.env.PATH = '/usr/bin:/bin'
|
||||
|
||||
patchPackagedProcessPath()
|
||||
|
||||
const segments = (process.env.PATH ?? '').split(':')
|
||||
// Why: issue #829 — ~/.opencode/bin and ~/.vite-plus/bin are the documented
|
||||
// fallback install locations for the opencode and Pi CLI install scripts.
|
||||
// Without them on PATH, GUI-launched Orca reports both as "Not installed"
|
||||
// even when `which` resolves them in the user's shell.
|
||||
expect(segments).toContain('/Users/tester/.opencode/bin')
|
||||
expect(segments).toContain('/Users/tester/.vite-plus/bin')
|
||||
expect(segments).toContain('/Users/tester/bin')
|
||||
})
|
||||
|
||||
it('leaves PATH untouched when the app is not packaged', async () => {
|
||||
const { app } = await import('electron')
|
||||
const { patchPackagedProcessPath } = await import('./configure-process')
|
||||
|
||||
setPlatform('darwin')
|
||||
Object.defineProperty(app, 'isPackaged', { configurable: true, value: false })
|
||||
process.env.HOME = '/Users/tester'
|
||||
process.env.PATH = '/usr/bin:/bin'
|
||||
|
||||
patchPackagedProcessPath()
|
||||
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('configureDevUserDataPath', () => {
|
||||
it('uses an explicit dev userData override when provided', async () => {
|
||||
const { app } = await import('electron')
|
||||
|
|
|
|||
|
|
@ -52,7 +52,19 @@ export function patchPackagedProcessPath(): void {
|
|||
]
|
||||
|
||||
if (home) {
|
||||
extraPaths.push(join(home, '.local/bin'), join(home, '.nix-profile/bin'))
|
||||
extraPaths.push(
|
||||
join(home, 'bin'),
|
||||
join(home, '.local/bin'),
|
||||
join(home, '.nix-profile/bin'),
|
||||
// Why: several agent CLIs ship install scripts that drop binaries into
|
||||
// tool-specific ~/.<name>/bin directories (opencode's documented fallback,
|
||||
// Pi's vite-plus installer). GUI-launched Electron inherits a minimal PATH
|
||||
// without shell rc files, so these stay invisible to `which` probes — and
|
||||
// the Agents settings page reports them as "Not installed" even when the
|
||||
// user can run them from Terminal. See stablyai/orca#829.
|
||||
join(home, '.opencode/bin'),
|
||||
join(home, '.vite-plus/bin')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: CLI tools installed via Node version managers (nvm, volta, asdf, fnm,
|
||||
|
|
|
|||
117
src/main/startup/hydrate-shell-path.test.ts
Normal file
117
src/main/startup/hydrate-shell-path.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_resetHydrateShellPathCache,
|
||||
hydrateShellPath,
|
||||
mergePathSegments
|
||||
} from './hydrate-shell-path'
|
||||
|
||||
describe('hydrateShellPath', () => {
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
beforeEach(() => {
|
||||
_resetHydrateShellPathCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('invokes the provided shell with a custom spawner and returns its segments', async () => {
|
||||
let capturedShell = ''
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: '/bin/zsh',
|
||||
spawner: async (shell) => {
|
||||
capturedShell = shell
|
||||
return {
|
||||
segments: ['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'],
|
||||
ok: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(capturedShell).toBe('/bin/zsh')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.segments).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
|
||||
})
|
||||
|
||||
it('caches the hydration result so repeated calls do not re-spawn', async () => {
|
||||
let spawnCount = 0
|
||||
const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => {
|
||||
spawnCount += 1
|
||||
return { segments: ['/a'], ok: true }
|
||||
}
|
||||
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
|
||||
expect(spawnCount).toBe(1)
|
||||
})
|
||||
|
||||
it('re-spawns when force:true is passed — matches the Refresh button contract', async () => {
|
||||
let spawnCount = 0
|
||||
const spawner = async (): Promise<{ segments: string[]; ok: boolean }> => {
|
||||
spawnCount += 1
|
||||
return { segments: ['/a'], ok: true }
|
||||
}
|
||||
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
|
||||
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner, force: true })
|
||||
|
||||
expect(spawnCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns ok:false when no shell is available (Windows path)', async () => {
|
||||
const result = await hydrateShellPath({
|
||||
shellOverride: null,
|
||||
spawner: async () => {
|
||||
throw new Error('spawner must not run when shell is null')
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toEqual({ segments: [], ok: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergePathSegments', () => {
|
||||
const originalPath = process.env.PATH
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
})
|
||||
|
||||
it('prepends new segments ahead of existing PATH entries', () => {
|
||||
process.env.PATH = '/usr/bin:/bin'
|
||||
|
||||
const added = mergePathSegments(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
|
||||
|
||||
expect(added).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
|
||||
expect(process.env.PATH).toBe(
|
||||
'/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin:/bin'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips segments already on PATH so re-hydration is a no-op', () => {
|
||||
process.env.PATH = '/Users/tester/.cargo/bin:/usr/bin'
|
||||
|
||||
const added = mergePathSegments(['/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin'])
|
||||
|
||||
expect(added).toEqual(['/Users/tester/.opencode/bin'])
|
||||
expect(process.env.PATH).toBe('/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin')
|
||||
})
|
||||
|
||||
it('returns [] and leaves PATH untouched when given nothing', () => {
|
||||
process.env.PATH = '/usr/bin:/bin'
|
||||
|
||||
expect(mergePathSegments([])).toEqual([])
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin')
|
||||
})
|
||||
})
|
||||
186
src/main/startup/hydrate-shell-path.ts
Normal file
186
src/main/startup/hydrate-shell-path.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { spawn } from 'child_process'
|
||||
import { delimiter } from 'path'
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
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
|
||||
|
||||
type HydrationResult = {
|
||||
/** PATH segments extracted from the login shell, in order, de-duplicated. */
|
||||
segments: string[]
|
||||
/** True when the shell spawn succeeded and returned a non-empty PATH. */
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
let cached: Promise<HydrationResult> | null = null
|
||||
|
||||
/** @internal - tests need a clean hydration cache between cases. */
|
||||
export function _resetHydrateShellPathCache(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
function pickShell(): string | null {
|
||||
if (process.platform === 'win32') {
|
||||
return 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): 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(delimiter)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
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}'`
|
||||
let finished = false
|
||||
let stdout = ''
|
||||
|
||||
const child = spawn(shell, ['-ilc', command], {
|
||||
// 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
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
// 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
|
||||
}
|
||||
resolve({ segments: [], ok: false })
|
||||
}, SPAWN_TIMEOUT_MS)
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
child.on('error', () => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
clearTimeout(timer)
|
||||
resolve({ segments: [], ok: false })
|
||||
})
|
||||
|
||||
child.on('close', () => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
clearTimeout(timer)
|
||||
const segments = parseCapturedPath(stdout)
|
||||
resolve({ segments, ok: segments.length > 0 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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 login 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 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 })
|
||||
return cached
|
||||
}
|
||||
cached = (options.spawner ?? spawnShellAndReadPath)(shell)
|
||||
return cached
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend newly-discovered PATH segments to process.env.PATH, preserving
|
||||
* existing ordering and avoiding duplicates. Returns the segments that were
|
||||
* actually added so callers can log/telemetry on nontrivial hydrations.
|
||||
*/
|
||||
export function mergePathSegments(segments: string[]): string[] {
|
||||
if (segments.length === 0) {
|
||||
return []
|
||||
}
|
||||
const current = process.env.PATH ?? ''
|
||||
const existing = new Set(current.split(delimiter).filter(Boolean))
|
||||
// Why: Node 22+ Set.prototype.difference preserves insertion order of the
|
||||
// receiver, so [...incoming.difference(existing)] gives us the new entries
|
||||
// in the order the shell provided them (first-match-wins on PATH).
|
||||
const added = [...new Set(segments).difference(existing)]
|
||||
if (added.length === 0) {
|
||||
return []
|
||||
}
|
||||
// Why: prepend so shell-provided entries win over the hardcoded fallbacks.
|
||||
// The user's rc files are the source of truth for `which`-style resolution.
|
||||
process.env.PATH = [...added, ...current.split(delimiter).filter(Boolean)].join(delimiter)
|
||||
return added
|
||||
}
|
||||
7
src/preload/api-types.d.ts
vendored
7
src/preload/api-types.d.ts
vendored
|
|
@ -159,9 +159,16 @@ export type PreflightStatus = {
|
|||
gh: { installed: boolean; authenticated: boolean }
|
||||
}
|
||||
|
||||
export type RefreshAgentsResult = {
|
||||
agents: string[]
|
||||
addedPathSegments: string[]
|
||||
shellHydrationOk: boolean
|
||||
}
|
||||
|
||||
export type PreflightApi = {
|
||||
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
|
||||
detectAgents: () => Promise<string[]>
|
||||
refreshAgents: () => Promise<RefreshAgentsResult>
|
||||
}
|
||||
|
||||
export type StatsApi = {
|
||||
|
|
|
|||
|
|
@ -460,7 +460,12 @@ const api = {
|
|||
git: { installed: boolean }
|
||||
gh: { installed: boolean; authenticated: boolean }
|
||||
}> => ipcRenderer.invoke('preflight:check', args),
|
||||
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents')
|
||||
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents'),
|
||||
refreshAgents: (): Promise<{
|
||||
agents: string[]
|
||||
addedPathSegments: string[]
|
||||
shellHydrationOk: boolean
|
||||
}> => ipcRenderer.invoke('preflight:refreshAgents')
|
||||
},
|
||||
|
||||
notifications: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, Terminal } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, RefreshCw, Terminal } from 'lucide-react'
|
||||
import type { GlobalSettings, TuiAgent } from '../../../../shared/types'
|
||||
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
|
||||
import { Button } from '../ui/button'
|
||||
|
|
@ -205,6 +205,7 @@ function AgentRow({
|
|||
|
||||
export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React.JSX.Element {
|
||||
const [detectedIds, setDetectedIds] = useState<Set<string> | null>(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.preflight.detectAgents().then((ids) => {
|
||||
|
|
@ -212,6 +213,22 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
|
|||
})
|
||||
}, [])
|
||||
|
||||
const handleRefresh = useCallback(async (): Promise<void> => {
|
||||
// Why: refresh re-spawns the user's login shell to re-capture PATH
|
||||
// (preflight:refreshAgents on the main side). This handles the
|
||||
// "installed a new CLI, Orca doesn't see it yet" case without a restart.
|
||||
if (isRefreshing) {
|
||||
return
|
||||
}
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
const result = await window.api.preflight.refreshAgents()
|
||||
setDetectedIds(new Set(result.agents))
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [isRefreshing])
|
||||
|
||||
const defaultAgent = settings.defaultTuiAgent
|
||||
const cmdOverrides = settings.agentCmdOverrides ?? {}
|
||||
|
||||
|
|
@ -296,6 +313,21 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
|
|||
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{detectedAgents.length} detected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={isRefreshing}
|
||||
title="Re-read your shell PATH and re-detect installed agents"
|
||||
className={cn(
|
||||
'ml-auto flex items-center gap-1.5 rounded-lg px-2 py-1 text-[11px] font-medium transition-colors',
|
||||
isRefreshing
|
||||
? 'text-muted-foreground/60'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn('size-3', isRefreshing && 'animate-spin')} />
|
||||
{isRefreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
Loading…
Reference in a new issue