/** * 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 { 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 { 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 { 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 { 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' }