Download src/utils/nativeInstaller/installer.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 54.8 kB
-
https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/nativeInstaller/installer.ts
- Command line
-
hf download hf://chenbhao/codev@afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/nativeInstaller/installer.ts
-
curl -L -o installer.ts https://huggingface.co/chenbhao/codev/resolve/afbc7edaae4e6bb0102ba024b49ce8be89d2264c/src/utils/nativeInstaller/installer.ts
54.8 kB
| /** | |
| * Native Installer Implementation | |
| * | |
| * This module implements the file-based native installer system described in | |
| * docs/native-installer.md. It provides: | |
| * - Directory structure management with symlinks | |
| * - Version installation and activation | |
| * - Multi-process safety with locking | |
| * - Simple fallback mechanism using modification time | |
| * - Support for both JS and native builds | |
| */ | |
| import { constants as fsConstants, type Stats } from 'fs' | |
| import { | |
| access, | |
| chmod, | |
| copyFile, | |
| lstat, | |
| mkdir, | |
| readdir, | |
| readlink, | |
| realpath, | |
| rename, | |
| rm, | |
| rmdir, | |
| stat, | |
| symlink, | |
| unlink, | |
| writeFile, | |
| } from 'fs/promises' | |
| import { homedir } from 'os' | |
| import { basename, delimiter, dirname, join, resolve } from 'path' | |
| import { | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| logEvent, | |
| } from 'src/services/analytics/index.js' | |
| import { getMaxVersion, shouldSkipVersion } from '../autoUpdater.js' | |
| import { registerCleanup } from '../cleanupRegistry.js' | |
| import { getGlobalConfig, saveGlobalConfig } from '../config.js' | |
| import { logForDebugging } from '../debug.js' | |
| import { getCurrentInstallationType } from '../doctorDiagnostic.js' | |
| import { env } from '../env.js' | |
| import { envDynamic } from '../envDynamic.js' | |
| import { isEnvTruthy } from '../envUtils.js' | |
| import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js' | |
| import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' | |
| import { getShellType } from '../localInstaller.js' | |
| import * as lockfile from '../lockfile.js' | |
| import { logError } from '../log.js' | |
| import { gt, gte } from '../semver.js' | |
| import { | |
| filterClaudeAliases, | |
| getShellConfigPaths, | |
| readFileLines, | |
| writeFileLines, | |
| } from '../shellConfig.js' | |
| import { sleep } from '../sleep.js' | |
| import { | |
| getUserBinDir, | |
| getXDGCacheHome, | |
| getXDGDataHome, | |
| getXDGStateHome, | |
| } from '../xdg.js' | |
| import { downloadVersion, getLatestVersion } from './download.js' | |
| import { | |
| acquireProcessLifetimeLock, | |
| cleanupStaleLocks, | |
| isLockActive, | |
| isPidBasedLockingEnabled, | |
| readLockContent, | |
| withLock, | |
| } from './pidLock.js' | |
| export const VERSION_RETENTION_COUNT = 2 | |
| // 7 days in milliseconds - used for mtime-based lock stale timeout. | |
| // This is long enough to survive laptop sleep durations while still | |
| // allowing cleanup of abandoned locks from crashed processes within a reasonable time. | |
| const LOCK_STALE_MS = 7 * 24 * 60 * 60 * 1000 | |
| export type SetupMessage = { | |
| message: string | |
| userActionRequired: boolean | |
| type: 'path' | 'alias' | 'info' | 'error' | |
| } | |
| export function getPlatform(): string { | |
| // Use env.platform which already handles platform detection and defaults to 'linux' | |
| const os = env.platform | |
| const arch = | |
| process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : null | |
| if (!arch) { | |
| const error = new Error(`Unsupported architecture: ${process.arch}`) | |
| logForDebugging( | |
| `Native installer does not support architecture: ${process.arch}`, | |
| { level: 'error' }, | |
| ) | |
| throw error | |
| } | |
| // Check for musl on Linux and adjust platform accordingly | |
| if (os === 'linux' && envDynamic.isMuslEnvironment()) { | |
| return `linux-${arch}-musl` | |
| } | |
| return `${os}-${arch}` | |
| } | |
| export function getBinaryName(platform: string): string { | |
| return platform.startsWith('win32') ? 'claude.exe' : 'claude' | |
| } | |
| function getBaseDirectories() { | |
| const platform = getPlatform() | |
| const executableName = getBinaryName(platform) | |
| return { | |
| // Data directories (permanent storage) | |
| versions: join(getXDGDataHome(), 'claude', 'versions'), | |
| // Cache directories (can be deleted) | |
| staging: join(getXDGCacheHome(), 'claude', 'staging'), | |
| // State directories | |
| locks: join(getXDGStateHome(), 'claude', 'locks'), | |
| // User bin | |
| executable: join(getUserBinDir(), executableName), | |
| } | |
| } | |
| async function isPossibleClaudeBinary(filePath: string): Promise<boolean> { | |
| try { | |
| const stats = await stat(filePath) | |
| // before download, the version lock file (located at the same filePath) will be size 0 | |
| // also, we allow small sizes because we want to treat small wrapper scripts as valid | |
| if (!stats.isFile() || stats.size === 0) { | |
| return false | |
| } | |
| // Check if file is executable. Note: On Windows, this relies on file extensions | |
| // (.exe, .bat, .cmd) and ACL permissions rather than Unix permission bits, | |
| // so it may not work perfectly for all executable files on Windows. | |
| await access(filePath, fsConstants.X_OK) | |
| return true | |
| } catch { | |
| return false | |
| } | |
| } | |
| async function getVersionPaths(version: string) { | |
| const dirs = getBaseDirectories() | |
| // Create directories, but not the executable path (which is a file) | |
| const dirsToCreate = [dirs.versions, dirs.staging, dirs.locks] | |
| await Promise.all(dirsToCreate.map(dir => mkdir(dir, { recursive: true }))) | |
| // Ensure parent directory of executable exists | |
| const executableParentDir = dirname(dirs.executable) | |
| await mkdir(executableParentDir, { recursive: true }) | |
| const installPath = join(dirs.versions, version) | |
| // Create an empty file if it doesn't exist | |
| try { | |
| await stat(installPath) | |
| } catch { | |
| await writeFile(installPath, '', { encoding: 'utf8' }) | |
| } | |
| return { | |
| stagingPath: join(dirs.staging, version), | |
| installPath, | |
| } | |
| } | |
| // Execute a callback while holding a lock on a version file | |
| // Returns false if the file is already locked, true if callback executed | |
| async function tryWithVersionLock( | |
| versionFilePath: string, | |
| callback: () => void | Promise<void>, | |
| retries = 0, | |
| ): Promise<boolean> { | |
| const dirs = getBaseDirectories() | |
| const lockfilePath = getLockFilePathFromVersionPath(dirs, versionFilePath) | |
| // Ensure the locks directory exists | |
| await mkdir(dirs.locks, { recursive: true }) | |
| if (isPidBasedLockingEnabled()) { | |
| // Use PID-based locking with optional retries | |
| let attempts = 0 | |
| const maxAttempts = retries + 1 | |
| const minTimeout = retries > 0 ? 1000 : 100 | |
| const maxTimeout = retries > 0 ? 5000 : 500 | |
| while (attempts < maxAttempts) { | |
| const success = await withLock( | |
| versionFilePath, | |
| lockfilePath, | |
| async () => { | |
| try { | |
| await callback() | |
| } catch (error) { | |
| logError(error) | |
| throw error | |
| } | |
| }, | |
| ) | |
| if (success) { | |
| logEvent('tengu_version_lock_acquired', { | |
| is_pid_based: true, | |
| is_lifetime_lock: false, | |
| attempts: attempts + 1, | |
| }) | |
| return true | |
| } | |
| attempts++ | |
| if (attempts < maxAttempts) { | |
| // Wait before retrying with exponential backoff | |
| const timeout = Math.min( | |
| minTimeout * Math.pow(2, attempts - 1), | |
| maxTimeout, | |
| ) | |
| await sleep(timeout) | |
| } | |
| } | |
| logEvent('tengu_version_lock_failed', { | |
| is_pid_based: true, | |
| is_lifetime_lock: false, | |
| attempts: maxAttempts, | |
| }) | |
| logLockAcquisitionError( | |
| versionFilePath, | |
| new Error('Lock held by another process'), | |
| ) | |
| return false | |
| } | |
| // Use mtime-based locking (proper-lockfile) with 30-day stale timeout | |
| let release: (() => Promise<void>) | null = null | |
| try { | |
| // Lock acquisition phase - catch lock errors and return false | |
| // Use 30 days for stale to match lockCurrentVersion() - this ensures we never | |
| // consider a running process's lock as stale during normal usage (including | |
| // laptop sleep). 30 days allows eventual cleanup of abandoned locks from | |
| // crashed processes while being long enough for any realistic session. | |
| try { | |
| release = await lockfile.lock(versionFilePath, { | |
| stale: LOCK_STALE_MS, | |
| retries: { | |
| retries, | |
| minTimeout: retries > 0 ? 1000 : 100, | |
| maxTimeout: retries > 0 ? 5000 : 500, | |
| }, | |
| lockfilePath, | |
| // Handle lock compromise gracefully to prevent unhandled rejections | |
| // This can happen if another process deletes the lock directory while we hold it | |
| onCompromised: (err: Error) => { | |
| logForDebugging( | |
| `NON-FATAL: Version lock was compromised during operation: ${err.message}`, | |
| { level: 'info' }, | |
| ) | |
| }, | |
| }) | |
| } catch (lockError) { | |
| logEvent('tengu_version_lock_failed', { | |
| is_pid_based: false, | |
| is_lifetime_lock: false, | |
| }) | |
| logLockAcquisitionError(versionFilePath, lockError) | |
| return false | |
| } | |
| // Operation phase - log errors but let them propagate | |
| try { | |
| await callback() | |
| logEvent('tengu_version_lock_acquired', { | |
| is_pid_based: false, | |
| is_lifetime_lock: false, | |
| }) | |
| return true | |
| } catch (error) { | |
| logError(error) | |
| throw error | |
| } | |
| } finally { | |
| if (release) { | |
| await release() | |
| } | |
| } | |
| } | |
| async function atomicMoveToInstallPath( | |
| stagedBinaryPath: string, | |
| installPath: string, | |
| ) { | |
| // Create installation directory if it doesn't exist | |
| await mkdir(dirname(installPath), { recursive: true }) | |
| // Move from staging to final location atomically | |
| const tempInstallPath = `${installPath}.tmp.${process.pid}.${Date.now()}` | |
| try { | |
| // Copy to temp next to install path, then rename. A direct rename from staging | |
| // would fail with EXDEV if staging and install are on different filesystems. | |
| await copyFile(stagedBinaryPath, tempInstallPath) | |
| await chmod(tempInstallPath, 0o755) | |
| await rename(tempInstallPath, installPath) | |
| logForDebugging(`Atomically installed binary to ${installPath}`) | |
| } catch (error) { | |
| // Clean up temp file if it exists | |
| try { | |
| await unlink(tempInstallPath) | |
| } catch { | |
| // Ignore cleanup errors | |
| } | |
| throw error | |
| } | |
| } | |
| async function installVersionFromPackage( | |
| stagingPath: string, | |
| installPath: string, | |
| ) { | |
| try { | |
| // Extract binary from npm package structure in staging | |
| const nodeModulesDir = join(stagingPath, 'node_modules', '@anthropic-ai') | |
| const entries = await readdir(nodeModulesDir) | |
| const nativePackage = entries.find((entry: string) => | |
| entry.startsWith('claude-cli-native-'), | |
| ) | |
| if (!nativePackage) { | |
| logEvent('tengu_native_install_package_failure', { | |
| stage_find_package: true, | |
| error_package_not_found: true, | |
| }) | |
| const error = new Error('Could not find platform-specific native package') | |
| throw error | |
| } | |
| const stagedBinaryPath = join(nodeModulesDir, nativePackage, 'cli') | |
| try { | |
| await stat(stagedBinaryPath) | |
| } catch { | |
| logEvent('tengu_native_install_package_failure', { | |
| stage_binary_exists: true, | |
| error_binary_not_found: true, | |
| }) | |
| const error = new Error('Native binary not found in staged package') | |
| throw error | |
| } | |
| await atomicMoveToInstallPath(stagedBinaryPath, installPath) | |
| // Clean up staging directory | |
| await rm(stagingPath, { recursive: true, force: true }) | |
| logEvent('tengu_native_install_package_success', {}) | |
| } catch (error) { | |
| // Log if not already logged above | |
| const msg = errorMessage(error) | |
| if ( | |
| !msg.includes('Could not find platform-specific') && | |
| !msg.includes('Native binary not found') | |
| ) { | |
| logEvent('tengu_native_install_package_failure', { | |
| stage_atomic_move: true, | |
| error_move_failed: true, | |
| }) | |
| } | |
| logError(toError(error)) | |
| throw error | |
| } | |
| } | |
| async function installVersionFromBinary( | |
| stagingPath: string, | |
| installPath: string, | |
| ) { | |
| try { | |
| // For direct binary downloads (GCS, generic bucket), the binary is directly in staging | |
| const platform = getPlatform() | |
| const binaryName = getBinaryName(platform) | |
| const stagedBinaryPath = join(stagingPath, binaryName) | |
| try { | |
| await stat(stagedBinaryPath) | |
| } catch { | |
| logEvent('tengu_native_install_binary_failure', { | |
| stage_binary_exists: true, | |
| error_binary_not_found: true, | |
| }) | |
| const error = new Error('Staged binary not found') | |
| throw error | |
| } | |
| await atomicMoveToInstallPath(stagedBinaryPath, installPath) | |
| // Clean up staging directory | |
| await rm(stagingPath, { recursive: true, force: true }) | |
| logEvent('tengu_native_install_binary_success', {}) | |
| } catch (error) { | |
| if (!errorMessage(error).includes('Staged binary not found')) { | |
| logEvent('tengu_native_install_binary_failure', { | |
| stage_atomic_move: true, | |
| error_move_failed: true, | |
| }) | |
| } | |
| logError(toError(error)) | |
| throw error | |
| } | |
| } | |
| async function installVersion( | |
| stagingPath: string, | |
| installPath: string, | |
| downloadType: 'npm' | 'binary', | |
| ) { | |
| // Use the explicit download type instead of guessing | |
| if (downloadType === 'npm') { | |
| await installVersionFromPackage(stagingPath, installPath) | |
| } else { | |
| await installVersionFromBinary(stagingPath, installPath) | |
| } | |
| } | |
| /** | |
| * Performs the core update operation: download (if needed), install, and update symlink. | |
| * Returns whether a new install was performed (vs just updating symlink). | |
| */ | |
| async function performVersionUpdate( | |
| version: string, | |
| forceReinstall: boolean, | |
| ): Promise<boolean> { | |
| const { stagingPath: baseStagingPath, installPath } = | |
| await getVersionPaths(version) | |
| const { executable: executablePath } = getBaseDirectories() | |
| // For lockless updates, use a unique staging path to avoid conflicts between concurrent downloads | |
| const stagingPath = isEnvTruthy(process.env.ENABLE_LOCKLESS_UPDATES) | |
| ? `${baseStagingPath}.${process.pid}.${Date.now()}` | |
| : baseStagingPath | |
| // Only download if not already installed (or if force reinstall) | |
| const needsInstall = !(await versionIsAvailable(version)) || forceReinstall | |
| if (needsInstall) { | |
| logForDebugging( | |
| forceReinstall | |
| ? `Force reinstalling native installer version ${version}` | |
| : `Downloading native installer version ${version}`, | |
| ) | |
| const downloadType = await downloadVersion(version, stagingPath) | |
| await installVersion(stagingPath, installPath, downloadType) | |
| } else { | |
| logForDebugging(`Version ${version} already installed, updating symlink`) | |
| } | |
| // Create direct symlink from ~/.local/bin/claude to the version binary | |
| await removeDirectoryIfEmpty(executablePath) | |
| await updateSymlink(executablePath, installPath) | |
| // Verify the executable was actually created/updated | |
| if (!(await isPossibleClaudeBinary(executablePath))) { | |
| let installPathExists = false | |
| try { | |
| await stat(installPath) | |
| installPathExists = true | |
| } catch { | |
| // installPath doesn't exist | |
| } | |
| throw new Error( | |
| `Failed to create executable at ${executablePath}. ` + | |
| `Source file exists: ${installPathExists}. ` + | |
| `Check write permissions to ${executablePath}.`, | |
| ) | |
| } | |
| return needsInstall | |
| } | |
| async function versionIsAvailable(version: string): Promise<boolean> { | |
| const { installPath } = await getVersionPaths(version) | |
| return isPossibleClaudeBinary(installPath) | |
| } | |
| async function updateLatest( | |
| channelOrVersion: string, | |
| forceReinstall: boolean = false, | |
| ): Promise<{ | |
| success: boolean | |
| latestVersion: string | |
| lockFailed?: boolean | |
| lockHolderPid?: number | |
| }> { | |
| const startTime = Date.now() | |
| let version = await getLatestVersion(channelOrVersion) | |
| const { executable: executablePath } = getBaseDirectories() | |
| logForDebugging(`Checking for native installer update to version ${version}`) | |
| // Check if max version is set (server-side kill switch for auto-updates) | |
| if (!forceReinstall) { | |
| const maxVersion = await getMaxVersion() | |
| if (maxVersion && gt(version, maxVersion)) { | |
| logForDebugging( | |
| `Native installer: maxVersion ${maxVersion} is set, capping update from ${version} to ${maxVersion}`, | |
| ) | |
| // If we're already at or above maxVersion, skip the update entirely | |
| if (gte(MACRO.VERSION, maxVersion)) { | |
| logForDebugging( | |
| `Native installer: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`, | |
| ) | |
| logEvent('tengu_native_update_skipped_max_version', { | |
| latency_ms: Date.now() - startTime, | |
| max_version: | |
| maxVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| available_version: | |
| version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| return { success: true, latestVersion: version } | |
| } | |
| version = maxVersion | |
| } | |
| } | |
| // Early exit: if we're already running this exact version AND both the version binary | |
| // and executable exist and are valid. We need to proceed if the executable doesn't exist, | |
| // is invalid (e.g., empty/corrupted from a failed install), or we're running via npx. | |
| if ( | |
| !forceReinstall && | |
| version === MACRO.VERSION && | |
| (await versionIsAvailable(version)) && | |
| (await isPossibleClaudeBinary(executablePath)) | |
| ) { | |
| logForDebugging(`Found ${version} at ${executablePath}, skipping install`) | |
| logEvent('tengu_native_update_complete', { | |
| latency_ms: Date.now() - startTime, | |
| was_new_install: false, | |
| was_force_reinstall: false, | |
| was_already_running: true, | |
| }) | |
| return { success: true, latestVersion: version } | |
| } | |
| // Check if this version should be skipped due to minimumVersion setting | |
| if (!forceReinstall && shouldSkipVersion(version)) { | |
| logEvent('tengu_native_update_skipped_minimum_version', { | |
| latency_ms: Date.now() - startTime, | |
| target_version: | |
| version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| return { success: true, latestVersion: version } | |
| } | |
| // Track if we're actually installing or just symlinking | |
| let wasNewInstall = false | |
| let latencyMs: number | |
| if (isEnvTruthy(process.env.ENABLE_LOCKLESS_UPDATES)) { | |
| // Lockless: rely on atomic operations, errors propagate | |
| wasNewInstall = await performVersionUpdate(version, forceReinstall) | |
| latencyMs = Date.now() - startTime | |
| } else { | |
| // Lock-based updates | |
| const { installPath } = await getVersionPaths(version) | |
| // If force reinstall, remove any existing lock to bypass stale locks | |
| if (forceReinstall) { | |
| await forceRemoveLock(installPath) | |
| } | |
| const lockAcquired = await tryWithVersionLock( | |
| installPath, | |
| async () => { | |
| wasNewInstall = await performVersionUpdate(version, forceReinstall) | |
| }, | |
| 3, // retries | |
| ) | |
| latencyMs = Date.now() - startTime | |
| // Lock acquisition failed - get lock holder PID for error message | |
| if (!lockAcquired) { | |
| const dirs = getBaseDirectories() | |
| let lockHolderPid: number | undefined | |
| if (isPidBasedLockingEnabled()) { | |
| const lockfilePath = getLockFilePathFromVersionPath(dirs, installPath) | |
| if (isLockActive(lockfilePath)) { | |
| lockHolderPid = readLockContent(lockfilePath)?.pid | |
| } | |
| } | |
| logEvent('tengu_native_update_lock_failed', { | |
| latency_ms: latencyMs, | |
| lock_holder_pid: lockHolderPid, | |
| }) | |
| return { | |
| success: false, | |
| latestVersion: version, | |
| lockFailed: true, | |
| lockHolderPid, | |
| } | |
| } | |
| } | |
| logEvent('tengu_native_update_complete', { | |
| latency_ms: latencyMs, | |
| was_new_install: wasNewInstall, | |
| was_force_reinstall: forceReinstall, | |
| }) | |
| logForDebugging(`Successfully updated to version ${version}`) | |
| return { success: true, latestVersion: version } | |
| } | |
| // Exported for testing | |
| export async function removeDirectoryIfEmpty(path: string): Promise<void> { | |
| // rmdir alone handles all cases: ENOTDIR if path is a file, ENOTEMPTY if | |
| // directory is non-empty, ENOENT if missing. No need to stat+readdir first. | |
| try { | |
| await rmdir(path) | |
| logForDebugging(`Removed empty directory at ${path}`) | |
| } catch (error) { | |
| const code = getErrnoCode(error) | |
| // Expected cases (not-a-dir, missing, not-empty) — silently skip. | |
| // ENOTDIR is the normal path: executablePath is typically a symlink. | |
| if (code !== 'ENOTDIR' && code !== 'ENOENT' && code !== 'ENOTEMPTY') { | |
| logForDebugging(`Could not remove directory at ${path}: ${error}`) | |
| } | |
| } | |
| } | |
| async function updateSymlink( | |
| symlinkPath: string, | |
| targetPath: string, | |
| ): Promise<boolean> { | |
| const platform = getPlatform() | |
| const isWindows = platform.startsWith('win32') | |
| // On Windows, directly copy the executable instead of creating a symlink | |
| if (isWindows) { | |
| try { | |
| // Ensure parent directory exists | |
| const parentDir = dirname(symlinkPath) | |
| await mkdir(parentDir, { recursive: true }) | |
| // Check if file already exists and has same content | |
| let existingStats: Stats | undefined | |
| try { | |
| existingStats = await stat(symlinkPath) | |
| } catch { | |
| // symlinkPath doesn't exist | |
| } | |
| if (existingStats) { | |
| try { | |
| const targetStats = await stat(targetPath) | |
| // If sizes match, assume files are the same (avoid reading large files) | |
| if (existingStats.size === targetStats.size) { | |
| return false | |
| } | |
| } catch { | |
| // Continue with copy if we can't compare | |
| } | |
| // Use rename strategy to handle file locking on Windows | |
| // Rename always works even for running executables, unlike delete | |
| const oldFileName = `${symlinkPath}.old.${Date.now()}` | |
| await rename(symlinkPath, oldFileName) | |
| // Try to copy new executable, with rollback on failure | |
| try { | |
| await copyFile(targetPath, symlinkPath) | |
| // Success - try immediate cleanup of old file (non-blocking) | |
| try { | |
| await unlink(oldFileName) | |
| } catch { | |
| // File still running - ignore, Windows will clean up eventually | |
| } | |
| } catch (copyError) { | |
| // Copy failed - restore the old executable | |
| try { | |
| await rename(oldFileName, symlinkPath) | |
| } catch (restoreError) { | |
| // Critical: User left without working executable - prioritize restore error | |
| const errorWithCause = new Error( | |
| `Failed to restore old executable: ${restoreError}`, | |
| { cause: copyError }, | |
| ) | |
| logError(errorWithCause) | |
| throw errorWithCause | |
| } | |
| throw copyError | |
| } | |
| } else { | |
| // First-time installation (no existing file to rename) | |
| // Copy the executable directly; handle ENOENT from copyFile itself | |
| // rather than a stat() pre-check (avoids TOCTOU + extra syscall) | |
| try { | |
| await copyFile(targetPath, symlinkPath) | |
| } catch (e) { | |
| if (isENOENT(e)) { | |
| throw new Error(`Source file does not exist: ${targetPath}`) | |
| } | |
| throw e | |
| } | |
| } | |
| // chmod is not needed on Windows - executability is determined by .exe extension | |
| return true | |
| } catch (error) { | |
| logError( | |
| new Error( | |
| `Failed to copy executable from ${targetPath} to ${symlinkPath}: ${error}`, | |
| ), | |
| ) | |
| return false | |
| } | |
| } | |
| // For non-Windows platforms, use symlinks as before | |
| // Ensure parent directory exists (same as Windows path above) | |
| const parentDir = dirname(symlinkPath) | |
| try { | |
| await mkdir(parentDir, { recursive: true }) | |
| logForDebugging(`Created directory ${parentDir} for symlink`) | |
| } catch (mkdirError) { | |
| logError( | |
| new Error(`Failed to create directory ${parentDir}: ${mkdirError}`), | |
| ) | |
| return false | |
| } | |
| // Check if symlink already exists and points to the correct target | |
| try { | |
| let symlinkExists = false | |
| try { | |
| await stat(symlinkPath) | |
| symlinkExists = true | |
| } catch { | |
| // symlinkPath doesn't exist | |
| } | |
| if (symlinkExists) { | |
| try { | |
| const currentTarget = await readlink(symlinkPath) | |
| const resolvedCurrentTarget = resolve( | |
| dirname(symlinkPath), | |
| currentTarget, | |
| ) | |
| const resolvedTargetPath = resolve(targetPath) | |
| if (resolvedCurrentTarget === resolvedTargetPath) { | |
| return false | |
| } | |
| } catch { | |
| // Path exists but is not a symlink - will remove it below | |
| } | |
| // Remove existing file/symlink before creating new one | |
| await unlink(symlinkPath) | |
| } | |
| } catch (error) { | |
| logError(new Error(`Failed to check/remove existing symlink: ${error}`)) | |
| } | |
| // Use atomic rename to avoid race conditions. Create symlink with temporary name | |
| // then atomically rename to final name. This ensures the symlink always exists | |
| // and is always valid, even with concurrent updates. | |
| const tempSymlink = `${symlinkPath}.tmp.${process.pid}.${Date.now()}` | |
| try { | |
| await symlink(targetPath, tempSymlink) | |
| // Atomically rename to final name (replaces existing) | |
| await rename(tempSymlink, symlinkPath) | |
| logForDebugging( | |
| `Atomically updated symlink ${symlinkPath} -> ${targetPath}`, | |
| ) | |
| return true | |
| } catch (error) { | |
| // Clean up temp symlink if it exists | |
| try { | |
| await unlink(tempSymlink) | |
| } catch { | |
| // Ignore cleanup errors | |
| } | |
| logError( | |
| new Error( | |
| `Failed to create symlink from ${symlinkPath} to ${targetPath}: ${error}`, | |
| ), | |
| ) | |
| return false | |
| } | |
| } | |
| export async function checkInstall( | |
| force: boolean = false, | |
| ): Promise<SetupMessage[]> { | |
| // Skip all installation checks if disabled via environment variable | |
| if (isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) { | |
| return [] | |
| } | |
| // Get the actual installation type and config | |
| const installationType = await getCurrentInstallationType() | |
| // Skip checks for development builds - config.installMethod from a previous | |
| // native installation shouldn't trigger warnings when running dev builds | |
| if (installationType === 'development') { | |
| return [] | |
| } | |
| const config = getGlobalConfig() | |
| // Only show warnings if: | |
| // 1. User is actually running from native installation, OR | |
| // 2. User has explicitly set installMethod to 'native' in config (they're trying to use native) | |
| // 3. force is true (used during installation process) | |
| const shouldCheckNative = | |
| force || installationType === 'native' || config.installMethod === 'native' | |
| if (!shouldCheckNative) { | |
| return [] | |
| } | |
| const dirs = getBaseDirectories() | |
| const messages: SetupMessage[] = [] | |
| const localBinDir = dirname(dirs.executable) | |
| const resolvedLocalBinPath = resolve(localBinDir) | |
| const platform = getPlatform() | |
| const isWindows = platform.startsWith('win32') | |
| // Check if bin directory exists | |
| try { | |
| await access(localBinDir) | |
| } catch { | |
| messages.push({ | |
| message: `installMethod is native, but directory ${localBinDir} does not exist`, | |
| userActionRequired: true, | |
| type: 'error', | |
| }) | |
| } | |
| // Check if claude executable exists and is valid. | |
| // On non-Windows, call readlink directly and route errno — ENOENT means | |
| // the executable is missing, EINVAL means it exists but isn't a symlink. | |
| // This avoids an access()→readlink() TOCTOU where deletion between the | |
| // two calls produces a misleading "Not a symlink" diagnostic. | |
| // isPossibleClaudeBinary stats the path internally, so we don't pre-check | |
| // with access() — that would be a TOCTOU between access and the stat. | |
| if (isWindows) { | |
| // On Windows it's a copied executable, not a symlink | |
| if (!(await isPossibleClaudeBinary(dirs.executable))) { | |
| messages.push({ | |
| message: `installMethod is native, but claude command is missing or invalid at ${dirs.executable}`, | |
| userActionRequired: true, | |
| type: 'error', | |
| }) | |
| } | |
| } else { | |
| try { | |
| const target = await readlink(dirs.executable) | |
| const absoluteTarget = resolve(dirname(dirs.executable), target) | |
| if (!(await isPossibleClaudeBinary(absoluteTarget))) { | |
| messages.push({ | |
| message: `Claude symlink points to missing or invalid binary: ${target}`, | |
| userActionRequired: true, | |
| type: 'error', | |
| }) | |
| } | |
| } catch (e) { | |
| if (isENOENT(e)) { | |
| messages.push({ | |
| message: `installMethod is native, but claude command not found at ${dirs.executable}`, | |
| userActionRequired: true, | |
| type: 'error', | |
| }) | |
| } else { | |
| // EINVAL (not a symlink) or other — check as regular binary | |
| if (!(await isPossibleClaudeBinary(dirs.executable))) { | |
| messages.push({ | |
| message: `${dirs.executable} exists but is not a valid Claude binary`, | |
| userActionRequired: true, | |
| type: 'error', | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| // Check if bin directory is in PATH | |
| const isInCurrentPath = (process.env.PATH || '') | |
| .split(delimiter) | |
| .some(entry => { | |
| try { | |
| const resolvedEntry = resolve(entry) | |
| // On Windows, perform case-insensitive comparison for paths | |
| if (isWindows) { | |
| return ( | |
| resolvedEntry.toLowerCase() === resolvedLocalBinPath.toLowerCase() | |
| ) | |
| } | |
| return resolvedEntry === resolvedLocalBinPath | |
| } catch { | |
| return false | |
| } | |
| }) | |
| if (!isInCurrentPath) { | |
| if (isWindows) { | |
| // Windows-specific PATH instructions | |
| const windowsBinPath = localBinDir.replace(/\//g, '\\') | |
| messages.push({ | |
| message: `Native installation exists but ${windowsBinPath} is not in your PATH. Add it by opening: System Properties → Environment Variables → Edit User PATH → New → Add the path above. Then restart your terminal.`, | |
| userActionRequired: true, | |
| type: 'path', | |
| }) | |
| } else { | |
| // Unix-style PATH instructions | |
| const shellType = getShellType() | |
| const configPaths = getShellConfigPaths() | |
| const configFile = configPaths[shellType as keyof typeof configPaths] | |
| const displayPath = configFile | |
| ? configFile.replace(homedir(), '~') | |
| : 'your shell config file' | |
| messages.push({ | |
| message: `Native installation exists but ~/.local/bin is not in your PATH. Run:\n\necho 'export PATH="$HOME/.local/bin:$PATH"' >> ${displayPath} && source ${displayPath}`, | |
| userActionRequired: true, | |
| type: 'path', | |
| }) | |
| } | |
| } | |
| return messages | |
| } | |
| type InstallLatestResult = { | |
| latestVersion: string | null | |
| wasUpdated: boolean | |
| lockFailed?: boolean | |
| lockHolderPid?: number | |
| } | |
| // In-process singleflight guard. NativeAutoUpdater remounts whenever the | |
| // prompt suggestions overlay toggles (PromptInput.tsx:2916), and the | |
| // isUpdating guard does not survive the remount. Each remount kicked off a | |
| // fresh 271MB binary download while previous ones were still in flight. | |
| // Telemetry: session 42fed33f saw arrayBuffers climb to 91GB at ~650MB/s. | |
| let inFlightInstall: Promise<InstallLatestResult> | null = null | |
| export function installLatest( | |
| channelOrVersion: string, | |
| forceReinstall: boolean = false, | |
| ): Promise<InstallLatestResult> { | |
| if (forceReinstall) { | |
| return installLatestImpl(channelOrVersion, forceReinstall) | |
| } | |
| if (inFlightInstall) { | |
| logForDebugging('installLatest: joining in-flight call') | |
| return inFlightInstall | |
| } | |
| const promise = installLatestImpl(channelOrVersion, forceReinstall) | |
| inFlightInstall = promise | |
| const clear = (): void => { | |
| inFlightInstall = null | |
| } | |
| void promise.then(clear, clear) | |
| return promise | |
| } | |
| async function installLatestImpl( | |
| channelOrVersion: string, | |
| forceReinstall: boolean = false, | |
| ): Promise<InstallLatestResult> { | |
| const updateResult = await updateLatest(channelOrVersion, forceReinstall) | |
| if (!updateResult.success) { | |
| return { | |
| latestVersion: null, | |
| wasUpdated: false, | |
| lockFailed: updateResult.lockFailed, | |
| lockHolderPid: updateResult.lockHolderPid, | |
| } | |
| } | |
| // Installation succeeded (early return above covers failure). Mark as native | |
| // and disable legacy auto-updater to protect symlinks. | |
| const config = getGlobalConfig() | |
| if (config.installMethod !== 'native') { | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| installMethod: 'native', | |
| // Disable legacy auto-updater to prevent npm sessions from deleting native symlinks. | |
| // Native installations use NativeAutoUpdater instead, which respects native installation. | |
| autoUpdates: false, | |
| // Mark this as protection-based, not user preference | |
| autoUpdatesProtectedForNative: true, | |
| })) | |
| logForDebugging( | |
| 'Native installer: Set installMethod to "native" and disabled legacy auto-updater for protection', | |
| ) | |
| } | |
| void cleanupOldVersions() | |
| return { | |
| latestVersion: updateResult.latestVersion, | |
| wasUpdated: updateResult.success, | |
| lockFailed: false, | |
| } | |
| } | |
| async function getVersionFromSymlink( | |
| symlinkPath: string, | |
| ): Promise<string | null> { | |
| try { | |
| const target = await readlink(symlinkPath) | |
| const absoluteTarget = resolve(dirname(symlinkPath), target) | |
| if (await isPossibleClaudeBinary(absoluteTarget)) { | |
| return absoluteTarget | |
| } | |
| } catch { | |
| // Not a symlink / doesn't exist / target doesn't exist | |
| } | |
| return null | |
| } | |
| function getLockFilePathFromVersionPath( | |
| dirs: ReturnType<typeof getBaseDirectories>, | |
| versionPath: string, | |
| ) { | |
| const versionName = basename(versionPath) | |
| return join(dirs.locks, `${versionName}.lock`) | |
| } | |
| /** | |
| * Acquire a lock on the current running version to prevent it from being deleted | |
| * This lock is held for the entire lifetime of the process | |
| * | |
| * Uses PID-based locking (when enabled) which can immediately detect crashed processes | |
| * (unlike mtime-based locking which requires a 30-day timeout) | |
| */ | |
| export async function lockCurrentVersion(): Promise<void> { | |
| const dirs = getBaseDirectories() | |
| // Only lock if we're running from the versions directory | |
| if (!process.execPath.includes(dirs.versions)) { | |
| return | |
| } | |
| const versionPath = resolve(process.execPath) | |
| try { | |
| const lockfilePath = getLockFilePathFromVersionPath(dirs, versionPath) | |
| // Ensure locks directory exists | |
| await mkdir(dirs.locks, { recursive: true }) | |
| if (isPidBasedLockingEnabled()) { | |
| // Acquire PID-based lock and hold it for the process lifetime | |
| // PID-based locking allows immediate detection of crashed processes | |
| // while still surviving laptop sleep (process is suspended but PID exists) | |
| const acquired = await acquireProcessLifetimeLock( | |
| versionPath, | |
| lockfilePath, | |
| ) | |
| if (!acquired) { | |
| logEvent('tengu_version_lock_failed', { | |
| is_pid_based: true, | |
| is_lifetime_lock: true, | |
| }) | |
| logLockAcquisitionError( | |
| versionPath, | |
| new Error('Lock already held by another process'), | |
| ) | |
| return | |
| } | |
| logEvent('tengu_version_lock_acquired', { | |
| is_pid_based: true, | |
| is_lifetime_lock: true, | |
| }) | |
| logForDebugging(`Acquired PID lock on running version: ${versionPath}`) | |
| } else { | |
| // Acquire mtime-based lock and never release it (until process exits) | |
| // Use 30 days for stale to prevent the lock from being considered stale during | |
| // normal usage. This is critical because laptop sleep suspends the process, | |
| // stopping the mtime heartbeat. 30 days is long enough for any realistic session | |
| // while still allowing eventual cleanup of abandoned locks. | |
| let release: (() => Promise<void>) | undefined | |
| try { | |
| release = await lockfile.lock(versionPath, { | |
| stale: LOCK_STALE_MS, | |
| retries: 0, // Don't retry - if we can't lock, that's fine | |
| lockfilePath, | |
| // Handle lock compromise gracefully (e.g., if another process deletes the lock directory) | |
| onCompromised: (err: Error) => { | |
| logForDebugging( | |
| `NON-FATAL: Lock on running version was compromised: ${err.message}`, | |
| { level: 'info' }, | |
| ) | |
| }, | |
| }) | |
| logEvent('tengu_version_lock_acquired', { | |
| is_pid_based: false, | |
| is_lifetime_lock: true, | |
| }) | |
| logForDebugging( | |
| `Acquired mtime-based lock on running version: ${versionPath}`, | |
| ) | |
| // Release lock explicitly; proper-lockfile's cleanup is unreliable with signal-exit v3+v4 | |
| registerCleanup(async () => { | |
| try { | |
| await release?.() | |
| } catch { | |
| // Lock may already be released | |
| } | |
| }) | |
| } catch (lockError) { | |
| if (isENOENT(lockError)) { | |
| logForDebugging( | |
| `Cannot lock current version - file does not exist: ${versionPath}`, | |
| { level: 'info' }, | |
| ) | |
| return | |
| } | |
| logEvent('tengu_version_lock_failed', { | |
| is_pid_based: false, | |
| is_lifetime_lock: true, | |
| }) | |
| logLockAcquisitionError(versionPath, lockError) | |
| return | |
| } | |
| } | |
| } catch (error) { | |
| if (isENOENT(error)) { | |
| logForDebugging( | |
| `Cannot lock current version - file does not exist: ${versionPath}`, | |
| { level: 'info' }, | |
| ) | |
| return | |
| } | |
| // We fallback to previous behavior where we don't acquire a lock on a running version | |
| // This ~mostly works but using native binaries like ripgrep will fail | |
| logForDebugging( | |
| `NON-FATAL: Failed to lock current version during execution ${errorMessage(error)}`, | |
| { level: 'info' }, | |
| ) | |
| } | |
| } | |
| function logLockAcquisitionError(versionPath: string, lockError: unknown) { | |
| logError( | |
| new Error( | |
| `NON-FATAL: Lock acquisition failed for ${versionPath} (expected in multi-process scenarios)`, | |
| { cause: lockError }, | |
| ), | |
| ) | |
| } | |
| /** | |
| * Force-remove a lock file for a given version path. | |
| * Used when --force is specified to bypass stale locks. | |
| */ | |
| async function forceRemoveLock(versionFilePath: string): Promise<void> { | |
| const dirs = getBaseDirectories() | |
| const lockfilePath = getLockFilePathFromVersionPath(dirs, versionFilePath) | |
| try { | |
| await unlink(lockfilePath) | |
| logForDebugging(`Force-removed lock file at ${lockfilePath}`) | |
| } catch (error) { | |
| // Log but don't throw - we'll try to acquire the lock anyway | |
| logForDebugging(`Failed to force-remove lock file: ${errorMessage(error)}`) | |
| } | |
| } | |
| export async function cleanupOldVersions(): Promise<void> { | |
| // Yield to ensure we don't block startup | |
| await Promise.resolve() | |
| const dirs = getBaseDirectories() | |
| const oneHourAgo = Date.now() - 3600000 | |
| // Clean up old renamed executables on Windows (no longer running at startup) | |
| if (getPlatform().startsWith('win32')) { | |
| const executableDir = dirname(dirs.executable) | |
| try { | |
| const files = await readdir(executableDir) | |
| let cleanedCount = 0 | |
| for (const file of files) { | |
| if (!/^claude\.exe\.old\.\d+$/.test(file)) continue | |
| try { | |
| await unlink(join(executableDir, file)) | |
| cleanedCount++ | |
| } catch { | |
| // File might still be in use by another process | |
| } | |
| } | |
| if (cleanedCount > 0) { | |
| logForDebugging( | |
| `Cleaned up ${cleanedCount} old Windows executables on startup`, | |
| ) | |
| } | |
| } catch (error) { | |
| if (!isENOENT(error)) { | |
| logForDebugging(`Failed to clean up old Windows executables: ${error}`) | |
| } | |
| } | |
| } | |
| // Clean up orphaned staging directories older than 1 hour | |
| try { | |
| const stagingEntries = await readdir(dirs.staging) | |
| let stagingCleanedCount = 0 | |
| for (const entry of stagingEntries) { | |
| const stagingPath = join(dirs.staging, entry) | |
| try { | |
| // stat() is load-bearing here (we need mtime). There is a theoretical | |
| // TOCTOU where a concurrent installer could freshen a stale staging | |
| // dir between stat and rm — but the 1-hour threshold makes this | |
| // vanishingly unlikely, and rm({force:true}) tolerates concurrent | |
| // deletion. | |
| const stats = await stat(stagingPath) | |
| if (stats.mtime.getTime() < oneHourAgo) { | |
| await rm(stagingPath, { recursive: true, force: true }) | |
| stagingCleanedCount++ | |
| logForDebugging(`Cleaned up old staging directory: ${entry}`) | |
| } | |
| } catch { | |
| // Ignore individual errors | |
| } | |
| } | |
| if (stagingCleanedCount > 0) { | |
| logForDebugging( | |
| `Cleaned up ${stagingCleanedCount} orphaned staging directories`, | |
| ) | |
| logEvent('tengu_native_staging_cleanup', { | |
| cleaned_count: stagingCleanedCount, | |
| }) | |
| } | |
| } catch (error) { | |
| if (!isENOENT(error)) { | |
| logForDebugging(`Failed to clean up staging directories: ${error}`) | |
| } | |
| } | |
| // Clean up stale PID locks (crashed processes) — cleanupStaleLocks handles ENOENT | |
| if (isPidBasedLockingEnabled()) { | |
| const staleLocksCleaned = cleanupStaleLocks(dirs.locks) | |
| if (staleLocksCleaned > 0) { | |
| logForDebugging(`Cleaned up ${staleLocksCleaned} stale version locks`) | |
| logEvent('tengu_native_stale_locks_cleanup', { | |
| cleaned_count: staleLocksCleaned, | |
| }) | |
| } | |
| } | |
| // Single readdir of versions dir. Partition into temp files vs candidate binaries, | |
| // stat'ing each entry at most once. | |
| let versionEntries: string[] | |
| try { | |
| versionEntries = await readdir(dirs.versions) | |
| } catch (error) { | |
| if (!isENOENT(error)) { | |
| logForDebugging(`Failed to readdir versions directory: ${error}`) | |
| } | |
| return | |
| } | |
| type VersionInfo = { | |
| name: string | |
| path: string | |
| resolvedPath: string | |
| mtime: Date | |
| } | |
| const versionFiles: VersionInfo[] = [] | |
| let tempFilesCleanedCount = 0 | |
| for (const entry of versionEntries) { | |
| const entryPath = join(dirs.versions, entry) | |
| if (/\.tmp\.\d+\.\d+$/.test(entry)) { | |
| // Orphaned temp install file — pattern: {version}.tmp.{pid}.{timestamp} | |
| try { | |
| const stats = await stat(entryPath) | |
| if (stats.mtime.getTime() < oneHourAgo) { | |
| await unlink(entryPath) | |
| tempFilesCleanedCount++ | |
| logForDebugging(`Cleaned up orphaned temp install file: ${entry}`) | |
| } | |
| } catch { | |
| // Ignore individual errors | |
| } | |
| continue | |
| } | |
| // Candidate version binary — stat once, reuse for isFile/size/mtime/mode | |
| try { | |
| const stats = await stat(entryPath) | |
| if (!stats.isFile()) continue | |
| if ( | |
| process.platform !== 'win32' && | |
| stats.size > 0 && | |
| (stats.mode & 0o111) === 0 | |
| ) { | |
| // Check executability via mode bits from the existing stat result — | |
| // avoids a second syscall (access(X_OK)) and the TOCTOU window between | |
| // stat and access. Skip on Windows: libuv only sets execute bits for | |
| // .exe/.com/.bat/.cmd, but version files are extensionless semver | |
| // strings (e.g. "1.2.3"), so this check would reject all of them. | |
| // The previous access(X_OK) passed any readable file on Windows anyway. | |
| continue | |
| } | |
| versionFiles.push({ | |
| name: entry, | |
| path: entryPath, | |
| resolvedPath: resolve(entryPath), | |
| mtime: stats.mtime, | |
| }) | |
| } catch { | |
| // Skip files we can't stat | |
| } | |
| } | |
| if (tempFilesCleanedCount > 0) { | |
| logForDebugging( | |
| `Cleaned up ${tempFilesCleanedCount} orphaned temp install files`, | |
| ) | |
| logEvent('tengu_native_temp_files_cleanup', { | |
| cleaned_count: tempFilesCleanedCount, | |
| }) | |
| } | |
| if (versionFiles.length === 0) { | |
| return | |
| } | |
| try { | |
| // Identify protected versions | |
| const currentBinaryPath = process.execPath | |
| const protectedVersions = new Set<string>() | |
| if (currentBinaryPath && currentBinaryPath.includes(dirs.versions)) { | |
| protectedVersions.add(resolve(currentBinaryPath)) | |
| } | |
| const currentSymlinkVersion = await getVersionFromSymlink(dirs.executable) | |
| if (currentSymlinkVersion) { | |
| protectedVersions.add(currentSymlinkVersion) | |
| } | |
| // Protect versions with active locks (running in other processes) | |
| for (const v of versionFiles) { | |
| if (protectedVersions.has(v.resolvedPath)) continue | |
| const lockFilePath = getLockFilePathFromVersionPath(dirs, v.resolvedPath) | |
| let hasActiveLock = false | |
| if (isPidBasedLockingEnabled()) { | |
| hasActiveLock = isLockActive(lockFilePath) | |
| } else { | |
| try { | |
| hasActiveLock = await lockfile.check(v.resolvedPath, { | |
| stale: LOCK_STALE_MS, | |
| lockfilePath: lockFilePath, | |
| }) | |
| } catch { | |
| hasActiveLock = false | |
| } | |
| } | |
| if (hasActiveLock) { | |
| protectedVersions.add(v.resolvedPath) | |
| logForDebugging(`Protecting locked version from cleanup: ${v.name}`) | |
| } | |
| } | |
| // Eligible versions: not protected, sorted newest first (reuse cached mtime) | |
| const eligibleVersions = versionFiles | |
| .filter(v => !protectedVersions.has(v.resolvedPath)) | |
| .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) | |
| const versionsToDelete = eligibleVersions.slice(VERSION_RETENTION_COUNT) | |
| if (versionsToDelete.length === 0) { | |
| logEvent('tengu_native_version_cleanup', { | |
| total_count: versionFiles.length, | |
| deleted_count: 0, | |
| protected_count: protectedVersions.size, | |
| retained_count: VERSION_RETENTION_COUNT, | |
| lock_failed_count: 0, | |
| error_count: 0, | |
| }) | |
| return | |
| } | |
| let deletedCount = 0 | |
| let lockFailedCount = 0 | |
| let errorCount = 0 | |
| await Promise.all( | |
| versionsToDelete.map(async version => { | |
| try { | |
| const deleted = await tryWithVersionLock(version.path, async () => { | |
| await unlink(version.path) | |
| }) | |
| if (deleted) { | |
| deletedCount++ | |
| } else { | |
| lockFailedCount++ | |
| logForDebugging( | |
| `Skipping deletion of ${version.name} - locked by another process`, | |
| ) | |
| } | |
| } catch (error) { | |
| errorCount++ | |
| logError( | |
| new Error(`Failed to delete version ${version.name}: ${error}`), | |
| ) | |
| } | |
| }), | |
| ) | |
| logEvent('tengu_native_version_cleanup', { | |
| total_count: versionFiles.length, | |
| deleted_count: deletedCount, | |
| protected_count: protectedVersions.size, | |
| retained_count: VERSION_RETENTION_COUNT, | |
| lock_failed_count: lockFailedCount, | |
| error_count: errorCount, | |
| }) | |
| } catch (error) { | |
| if (!isENOENT(error)) { | |
| logError(new Error(`Version cleanup failed: ${error}`)) | |
| } | |
| } | |
| } | |
| /** | |
| * Check if a given path is managed by npm | |
| * @param executablePath - The path to check (can be a symlink) | |
| * @returns true if the path is npm-managed, false otherwise | |
| */ | |
| async function isNpmSymlink(executablePath: string): Promise<boolean> { | |
| // Resolve symlink to its target if applicable | |
| let targetPath = executablePath | |
| const stats = await lstat(executablePath) | |
| if (stats.isSymbolicLink()) { | |
| targetPath = await realpath(executablePath) | |
| } | |
| // checking npm prefix isn't guaranteed to work, as prefix can change | |
| // and users may set --prefix manually when installing | |
| // thus we use this heuristic: | |
| return targetPath.endsWith('.js') || targetPath.includes('node_modules') | |
| } | |
| /** | |
| * Remove the claude symlink from the executable directory | |
| * This is used when switching away from native installation | |
| * Will only remove if it's a native binary symlink, not npm-managed JS files | |
| */ | |
| export async function removeInstalledSymlink(): Promise<void> { | |
| const dirs = getBaseDirectories() | |
| try { | |
| // Check if this is an npm-managed installation | |
| if (await isNpmSymlink(dirs.executable)) { | |
| logForDebugging( | |
| `Skipping removal of ${dirs.executable} - appears to be npm-managed`, | |
| ) | |
| return | |
| } | |
| // It's a native binary symlink, safe to remove | |
| await unlink(dirs.executable) | |
| logForDebugging(`Removed claude symlink at ${dirs.executable}`) | |
| } catch (error) { | |
| if (isENOENT(error)) { | |
| return | |
| } | |
| logError(new Error(`Failed to remove claude symlink: ${error}`)) | |
| } | |
| } | |
| /** | |
| * Clean up old claude aliases from shell configuration files | |
| * Only handles alias removal, not PATH setup | |
| */ | |
| export async function cleanupShellAliases(): Promise<SetupMessage[]> { | |
| const messages: SetupMessage[] = [] | |
| const configMap = getShellConfigPaths() | |
| for (const [shellType, configFile] of Object.entries(configMap)) { | |
| try { | |
| const lines = await readFileLines(configFile) | |
| if (!lines) continue | |
| const { filtered, hadAlias } = filterClaudeAliases(lines) | |
| if (hadAlias) { | |
| await writeFileLines(configFile, filtered) | |
| messages.push({ | |
| message: `Removed claude alias from ${configFile}. Run: unalias claude`, | |
| userActionRequired: true, | |
| type: 'alias', | |
| }) | |
| logForDebugging(`Cleaned up claude alias from ${shellType} config`) | |
| } | |
| } catch (error) { | |
| logError(error) | |
| messages.push({ | |
| message: `Failed to clean up ${configFile}: ${error}`, | |
| userActionRequired: false, | |
| type: 'error', | |
| }) | |
| } | |
| } | |
| return messages | |
| } | |
| async function manualRemoveNpmPackage( | |
| packageName: string, | |
| ): Promise<{ success: boolean; error?: string; warning?: string }> { | |
| try { | |
| // Get npm global prefix | |
| const prefixResult = await execFileNoThrowWithCwd('npm', [ | |
| 'config', | |
| 'get', | |
| 'prefix', | |
| ]) | |
| if (prefixResult.code !== 0 || !prefixResult.stdout) { | |
| return { | |
| success: false, | |
| error: 'Failed to get npm global prefix', | |
| } | |
| } | |
| const globalPrefix = prefixResult.stdout.trim() | |
| let manuallyRemoved = false | |
| // Helper to try removing a file. unlink alone is sufficient — it throws | |
| // ENOENT if the file is missing, which the catch handles identically. | |
| // A stat() pre-check would add a syscall and a TOCTOU window where | |
| // concurrent cleanup causes a false-negative return. | |
| async function tryRemove(filePath: string, description: string) { | |
| try { | |
| await unlink(filePath) | |
| logForDebugging(`Manually removed ${description}: ${filePath}`) | |
| return true | |
| } catch { | |
| return false | |
| } | |
| } | |
| if (getPlatform().startsWith('win32')) { | |
| // Windows - only remove executables, not the package directory | |
| const binCmd = join(globalPrefix, 'claude.cmd') | |
| const binPs1 = join(globalPrefix, 'claude.ps1') | |
| const binExe = join(globalPrefix, 'claude') | |
| if (await tryRemove(binCmd, 'bin script')) { | |
| manuallyRemoved = true | |
| } | |
| if (await tryRemove(binPs1, 'PowerShell script')) { | |
| manuallyRemoved = true | |
| } | |
| if (await tryRemove(binExe, 'bin executable')) { | |
| manuallyRemoved = true | |
| } | |
| } else { | |
| // Unix/Mac - only remove symlink, not the package directory | |
| const binSymlink = join(globalPrefix, 'bin', 'claude') | |
| if (await tryRemove(binSymlink, 'bin symlink')) { | |
| manuallyRemoved = true | |
| } | |
| } | |
| if (manuallyRemoved) { | |
| logForDebugging(`Successfully removed ${packageName} manually`) | |
| const nodeModulesPath = getPlatform().startsWith('win32') | |
| ? join(globalPrefix, 'node_modules', packageName) | |
| : join(globalPrefix, 'lib', 'node_modules', packageName) | |
| return { | |
| success: true, | |
| warning: `${packageName} executables removed, but node_modules directory was left intact for safety. You may manually delete it later at: ${nodeModulesPath}`, | |
| } | |
| } else { | |
| return { success: false } | |
| } | |
| } catch (manualError) { | |
| logForDebugging(`Manual removal failed: ${manualError}`, { | |
| level: 'error', | |
| }) | |
| return { | |
| success: false, | |
| error: `Manual removal failed: ${manualError}`, | |
| } | |
| } | |
| } | |
| async function attemptNpmUninstall( | |
| packageName: string, | |
| ): Promise<{ success: boolean; error?: string; warning?: string }> { | |
| const { code, stderr } = await execFileNoThrowWithCwd( | |
| 'npm', | |
| ['uninstall', '-g', packageName], | |
| // eslint-disable-next-line custom-rules/no-process-cwd -- matches original behavior | |
| { cwd: process.cwd() }, | |
| ) | |
| if (code === 0) { | |
| logForDebugging(`Removed global npm installation of ${packageName}`) | |
| return { success: true } | |
| } else if (stderr && !stderr.includes('npm ERR! code E404')) { | |
| // Check for ENOTEMPTY error and try manual removal | |
| if (stderr.includes('npm error code ENOTEMPTY')) { | |
| logForDebugging( | |
| `Failed to uninstall global npm package ${packageName}: ${stderr}`, | |
| { level: 'error' }, | |
| ) | |
| logForDebugging(`Attempting manual removal due to ENOTEMPTY error`) | |
| const manualResult = await manualRemoveNpmPackage(packageName) | |
| if (manualResult.success) { | |
| return { success: true, warning: manualResult.warning } | |
| } else if (manualResult.error) { | |
| return { | |
| success: false, | |
| error: `Failed to remove global npm installation of ${packageName}: ${stderr}. Manual removal also failed: ${manualResult.error}`, | |
| } | |
| } | |
| } | |
| // Only report as error if it's not a "package not found" error | |
| logForDebugging( | |
| `Failed to uninstall global npm package ${packageName}: ${stderr}`, | |
| { level: 'error' }, | |
| ) | |
| return { | |
| success: false, | |
| error: `Failed to remove global npm installation of ${packageName}: ${stderr}`, | |
| } | |
| } | |
| return { success: false } // Package not found, not an error | |
| } | |
| export async function cleanupNpmInstallations(): Promise<{ | |
| removed: number | |
| errors: string[] | |
| warnings: string[] | |
| }> { | |
| const errors: string[] = [] | |
| const warnings: string[] = [] | |
| let removed = 0 | |
| // Always attempt to remove @anthropic-ai/claude-code | |
| const codePackageResult = await attemptNpmUninstall( | |
| '@anthropic-ai/claude-code', | |
| ) | |
| if (codePackageResult.success) { | |
| removed++ | |
| if (codePackageResult.warning) { | |
| warnings.push(codePackageResult.warning) | |
| } | |
| } else if (codePackageResult.error) { | |
| errors.push(codePackageResult.error) | |
| } | |
| // Also attempt to remove MACRO.PACKAGE_URL if it's defined and different | |
| if (MACRO.PACKAGE_URL && MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code') { | |
| const macroPackageResult = await attemptNpmUninstall(MACRO.PACKAGE_URL) | |
| if (macroPackageResult.success) { | |
| removed++ | |
| if (macroPackageResult.warning) { | |
| warnings.push(macroPackageResult.warning) | |
| } | |
| } else if (macroPackageResult.error) { | |
| errors.push(macroPackageResult.error) | |
| } | |
| } | |
| // Check for local installation at ~/.claude/local | |
| const localInstallDir = join(homedir(), '.claude', 'local') | |
| try { | |
| await rm(localInstallDir, { recursive: true }) | |
| removed++ | |
| logForDebugging(`Removed local installation at ${localInstallDir}`) | |
| } catch (error) { | |
| if (!isENOENT(error)) { | |
| errors.push(`Failed to remove ${localInstallDir}: ${error}`) | |
| logForDebugging(`Failed to remove local installation: ${error}`, { | |
| level: 'error', | |
| }) | |
| } | |
| } | |
| return { removed, errors, warnings } | |
| } | |