mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { delimiter, dirname, join } from 'node:path'
|
|
|
|
type ResolveCommandOptions = {
|
|
pathEnv?: string | null
|
|
platform?: NodeJS.Platform
|
|
homePath?: string
|
|
}
|
|
|
|
function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
|
|
if (platform === 'win32') {
|
|
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
|
|
}
|
|
|
|
return [commandName]
|
|
}
|
|
|
|
function splitPath(pathEnv: string | null | undefined): string[] {
|
|
if (!pathEnv) {
|
|
return []
|
|
}
|
|
|
|
return pathEnv
|
|
.split(delimiter)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function parseVersionSegment(raw: string): number[] {
|
|
return raw
|
|
.replace(/^v/i, '')
|
|
.split('.')
|
|
.map((segment) => Number.parseInt(segment, 10))
|
|
.map((segment) => (Number.isFinite(segment) ? segment : 0))
|
|
}
|
|
|
|
function compareVersionDesc(left: string, right: string): number {
|
|
const leftParts = parseVersionSegment(left)
|
|
const rightParts = parseVersionSegment(right)
|
|
const length = Math.max(leftParts.length, rightParts.length)
|
|
|
|
for (let index = 0; index < length; index += 1) {
|
|
const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0)
|
|
if (delta !== 0) {
|
|
return delta
|
|
}
|
|
}
|
|
|
|
return right.localeCompare(left)
|
|
}
|
|
|
|
function findFirstExecutable(
|
|
platform: NodeJS.Platform,
|
|
directories: string[],
|
|
executableNames: string[]
|
|
): string | null {
|
|
for (const directory of directories) {
|
|
for (const executableName of executableNames) {
|
|
const candidate = join(directory, executableName)
|
|
if (isRunnableCommand(platform, candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean {
|
|
try {
|
|
const stats = statSync(candidate)
|
|
if (!stats.isFile()) {
|
|
return false
|
|
}
|
|
if (platform === 'win32') {
|
|
return true
|
|
}
|
|
// Why: GUI fallback probing should skip placeholders/directories so spawn
|
|
// can continue to a runnable CLI instead of failing later with EACCES/EISDIR.
|
|
accessSync(candidate, constants.X_OK)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function getVersionManagerDirectories(
|
|
platform: NodeJS.Platform,
|
|
homePath: string,
|
|
executableNames: string[]
|
|
): string[] {
|
|
const directories = [
|
|
join(homePath, '.volta', 'bin'),
|
|
join(homePath, '.asdf', 'shims'),
|
|
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
|
|
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
|
|
// directory, similar to asdf. Without this, users who installed node
|
|
// or CLI tools through mise can't be found by the fallback probe.
|
|
join(homePath, '.local', 'share', 'mise', 'shims')
|
|
]
|
|
|
|
// Why: GUI-launched Electron apps do not inherit shell init from version
|
|
// managers like nvm, so `spawn('codex')` can fail for users who installed
|
|
// Codex under a Node-managed bin directory even though Terminal can run it.
|
|
// Probe the newest installed nvm version explicitly so rate-limit tracking
|
|
// and account login use the same binary the shell would expose.
|
|
const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node')
|
|
if (existsSync(nvmVersionsDir)) {
|
|
const nvmVersionDirectories = readdirSync(nvmVersionsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort(compareVersionDesc)
|
|
.map((entry) => join(nvmVersionsDir, entry, 'bin'))
|
|
|
|
const firstNvmMatch = findFirstExecutable(platform, nvmVersionDirectories, executableNames)
|
|
if (firstNvmMatch) {
|
|
directories.unshift(dirname(firstNvmMatch))
|
|
}
|
|
}
|
|
|
|
if (platform === 'win32') {
|
|
// Why: Anthropic's native Windows installer places claude.exe here, and
|
|
// GUI-launched Orca may not inherit the user's PATH entry for it.
|
|
directories.push(join(homePath, '.local', 'bin'))
|
|
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
|
|
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
|
|
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
|
|
} else {
|
|
directories.push(join(homePath, '.local', 'bin'))
|
|
// Why: pnpm uses platform-specific global bin directories that differ from
|
|
// npm's ~/.local/bin. macOS follows the ~/Library convention while Linux
|
|
// uses the XDG-compatible ~/.local/share path. Without these, users who
|
|
// installed via `pnpm add -g` can't be found by the fallback probe.
|
|
if (platform === 'darwin') {
|
|
directories.push(join(homePath, 'Library', 'pnpm'))
|
|
} else {
|
|
directories.push(join(homePath, '.local', 'share', 'pnpm'))
|
|
}
|
|
directories.push(join(homePath, '.yarn', 'bin'))
|
|
}
|
|
|
|
// Why: bun uses ~/.bun/bin on all platforms for globally installed packages.
|
|
directories.push(join(homePath, '.bun', 'bin'))
|
|
|
|
return directories
|
|
}
|
|
|
|
export function resolveCliCommand(
|
|
commandName: string,
|
|
options: ResolveCommandOptions = {}
|
|
): string {
|
|
const platform = options.platform ?? process.platform
|
|
const executableNames = getExecutableNames(platform, commandName)
|
|
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
|
|
const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames)
|
|
if (pathCandidate) {
|
|
return pathCandidate
|
|
}
|
|
|
|
const homePath = options.homePath ?? homedir()
|
|
const versionManagerCandidate = findFirstExecutable(
|
|
platform,
|
|
getVersionManagerDirectories(platform, homePath, executableNames),
|
|
executableNames
|
|
)
|
|
return versionManagerCandidate ?? commandName
|
|
}
|
|
|
|
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
|
|
return resolveCliCommand('codex', options)
|
|
}
|
|
|
|
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
|
|
return resolveCliCommand('claude', options)
|
|
}
|
|
|
|
// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node
|
|
// version manager directories. CLI tools like codex/claude are Node scripts
|
|
// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute,
|
|
// not just to be *found*. This function returns the version manager bin paths
|
|
// so the caller can augment process.env.PATH at startup.
|
|
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
|
|
const platform = options.platform ?? process.platform
|
|
const homePath = options.homePath ?? homedir()
|
|
const nodeNames = getExecutableNames(platform, 'node')
|
|
return getVersionManagerDirectories(platform, homePath, nodeNames)
|
|
}
|