Files
orcs-code/src/utils/localInstaller.ts
nehan 4cb963e660 feat(api): improve local provider reliability with readiness and self-healing (#738)
* feat(api): classify openai-compatible provider failures

* Update src/services/api/providerConfig.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(api): harden openai-compatible diagnostics and env fallback

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/errors.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix openaiShim duplicate requests and diagnostics

* remove unused url from http failure classifier

* dedupe env diagnostic warnings

* Remove hardcoded URLs from OpenAI error tests

Removed hardcoded URLs from network failure classification tests.

* Update providerConfig.envDiagnostics.test.ts

* fix(openai-shim): return successful responses and restore localhost classifier tests

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(provider): add truthful local generation readiness checks

Implement Phase 2 provider readiness behavior by adding structured Ollama generation probes, wiring setup flows to readiness states, extending system-check with generation readiness output, and updating focused tests.

* feat(api): add local self-healing fallback retries

Implement Phase 3 self-healing behavior for local OpenAI-compatible providers: retry base URL fallbacks for localhost resolution and endpoint mismatches, plus capability-gated toolless retry for tool-incompatible local models; include diagnostics and focused tests.

* fix(api): address review blockers for local provider reliability

* Update src/utils/providerDiscovery.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/services/api/openaiShim.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: harden readiness probes and cross-platform test stability

* fix: refresh toolless retry payload and stabilize osc clipboard test

* fix: harden Ollama readiness parsing and redact provider URLs

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-20 16:24:02 +08:00

213 lines
6.2 KiB
TypeScript

/**
* Utilities for handling local installation
*/
import { access, chmod, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { type ReleaseChannel, saveGlobalConfig } from './config.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { getErrnoCode } from './errors.js'
import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
import { getFsImplementation } from './fsOperations.js'
import { logError } from './log.js'
import { jsonStringify } from './slowOperations.js'
// Lazy getters: getClaudeConfigHomeDir() is memoized and reads process.env.
// Evaluating at module scope would capture the value before entrypoints like
// hfi.tsx get a chance to set CLAUDE_CONFIG_DIR in main(), and would also
// populate the memoize cache with that stale value for all 150+ other callers.
function getLocalInstallDir(): string {
return join(getClaudeConfigHomeDir(), 'local')
}
function getLegacyLocalInstallDir(homeDir = homedir()): string {
return join(homeDir, '.claude', 'local')
}
export function getCandidateLocalInstallDirs(options?: {
configHomeDir?: string
homeDir?: string
}): string[] {
const homeDir = options?.homeDir ?? homedir()
const configHomeDir = options?.configHomeDir ?? getClaudeConfigHomeDir()
return Array.from(
new Set([join(configHomeDir, 'local'), getLegacyLocalInstallDir(homeDir)]),
)
}
function getCandidateLocalBinaryPaths(localInstallDir: string): string[] {
return [
join(localInstallDir, 'node_modules', '.bin', 'openclaude'),
join(localInstallDir, 'node_modules', '.bin', 'claude'),
]
}
export function isManagedLocalInstallationPath(execPath: string): boolean {
const normalizedExecPath = execPath.replace(/\\+/g, '/')
return (
normalizedExecPath.includes('/.openclaude/local/node_modules/') ||
normalizedExecPath.includes('/.claude/local/node_modules/')
)
}
export function getLocalClaudePath(): string {
return join(getLocalInstallDir(), 'openclaude')
}
/**
* Check if we're running from our managed local installation
*/
export function isRunningFromLocalInstallation(): boolean {
return isManagedLocalInstallationPath(process.argv[1] || '')
}
/**
* Write `content` to `path` only if the file does not already exist.
* Uses O_EXCL ('wx') for atomic create-if-missing.
*/
async function writeIfMissing(
path: string,
content: string,
mode?: number,
): Promise<boolean> {
try {
await writeFile(path, content, { encoding: 'utf8', flag: 'wx', mode })
return true
} catch (e) {
if (getErrnoCode(e) === 'EEXIST') return false
throw e
}
}
/**
* Ensure the local package environment is set up
* Creates the directory, package.json, and wrapper script
*/
export async function ensureLocalPackageEnvironment(): Promise<boolean> {
try {
const localInstallDir = getLocalInstallDir()
// Create installation directory (recursive, idempotent)
await getFsImplementation().mkdir(localInstallDir)
// Create package.json if it doesn't exist
await writeIfMissing(
join(localInstallDir, 'package.json'),
jsonStringify(
{ name: 'openclaude-local', version: '0.0.1', private: true },
null,
2,
),
)
// Create the wrapper script if it doesn't exist
const wrapperPath = getLocalClaudePath()
const created = await writeIfMissing(
wrapperPath,
`#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/openclaude" "$@"`,
0o755,
)
if (created) {
// Mode in writeFile is masked by umask; chmod to ensure executable bit.
await chmod(wrapperPath, 0o755)
}
return true
} catch (error) {
logError(error)
return false
}
}
/**
* Install or update Claude CLI package in the local directory
* @param channel - Release channel to use (latest or stable)
* @param specificVersion - Optional specific version to install (overrides channel)
*/
export async function installOrUpdateClaudePackage(
channel: ReleaseChannel,
specificVersion?: string | null,
): Promise<'in_progress' | 'success' | 'install_failed'> {
try {
// First ensure the environment is set up
if (!(await ensureLocalPackageEnvironment())) {
return 'install_failed'
}
// Use specific version if provided, otherwise use channel tag
const versionSpec = specificVersion
? specificVersion
: channel === 'stable'
? 'stable'
: 'latest'
const result = await execFileNoThrowWithCwd(
'npm',
['install', `${MACRO.PACKAGE_URL}@${versionSpec}`],
{ cwd: getLocalInstallDir(), maxBuffer: 1000000 },
)
if (result.code !== 0) {
const error = new Error(
`Failed to install Claude CLI package: ${result.stderr}`,
)
logError(error)
return result.code === 190 ? 'in_progress' : 'install_failed'
}
// Set installMethod to 'local' to prevent npm permission warnings
saveGlobalConfig(current => ({
...current,
installMethod: 'local',
}))
return 'success'
} catch (error) {
logError(error)
return 'install_failed'
}
}
/**
* Check if local installation exists.
* Pure existence probe — callers use this to choose update path / UI hints.
*/
export async function localInstallationExists(): Promise<boolean> {
for (const localInstallDir of getCandidateLocalInstallDirs()) {
for (const binaryPath of getCandidateLocalBinaryPaths(localInstallDir)) {
try {
await access(binaryPath)
return true
} catch {
// Try next candidate
}
}
}
return false
}
export async function getDetectedLocalInstallDir(): Promise<string | null> {
for (const localInstallDir of getCandidateLocalInstallDirs()) {
for (const binaryPath of getCandidateLocalBinaryPaths(localInstallDir)) {
try {
await access(binaryPath)
return localInstallDir
} catch {
// Try next candidate
}
}
}
return null
}
/**
* Get shell type to determine appropriate path setup
*/
export function getShellType(): string {
const shellPath = process.env.SHELL || ''
if (shellPath.includes('zsh')) return 'zsh'
if (shellPath.includes('bash')) return 'bash'
if (shellPath.includes('fish')) return 'fish'
return 'unknown'
}