source dump of claude code
at main 1708 lines 55 kB view raw
1/** 2 * Native Installer Implementation 3 * 4 * This module implements the file-based native installer system described in 5 * docs/native-installer.md. It provides: 6 * - Directory structure management with symlinks 7 * - Version installation and activation 8 * - Multi-process safety with locking 9 * - Simple fallback mechanism using modification time 10 * - Support for both JS and native builds 11 */ 12 13import { constants as fsConstants, type Stats } from 'fs' 14import { 15 access, 16 chmod, 17 copyFile, 18 lstat, 19 mkdir, 20 readdir, 21 readlink, 22 realpath, 23 rename, 24 rm, 25 rmdir, 26 stat, 27 symlink, 28 unlink, 29 writeFile, 30} from 'fs/promises' 31import { homedir } from 'os' 32import { basename, delimiter, dirname, join, resolve } from 'path' 33import { 34 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 35 logEvent, 36} from 'src/services/analytics/index.js' 37import { getMaxVersion, shouldSkipVersion } from '../autoUpdater.js' 38import { registerCleanup } from '../cleanupRegistry.js' 39import { getGlobalConfig, saveGlobalConfig } from '../config.js' 40import { logForDebugging } from '../debug.js' 41import { getCurrentInstallationType } from '../doctorDiagnostic.js' 42import { env } from '../env.js' 43import { envDynamic } from '../envDynamic.js' 44import { isEnvTruthy } from '../envUtils.js' 45import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js' 46import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' 47import { getShellType } from '../localInstaller.js' 48import * as lockfile from '../lockfile.js' 49import { logError } from '../log.js' 50import { gt, gte } from '../semver.js' 51import { 52 filterClaudeAliases, 53 getShellConfigPaths, 54 readFileLines, 55 writeFileLines, 56} from '../shellConfig.js' 57import { sleep } from '../sleep.js' 58import { 59 getUserBinDir, 60 getXDGCacheHome, 61 getXDGDataHome, 62 getXDGStateHome, 63} from '../xdg.js' 64import { downloadVersion, getLatestVersion } from './download.js' 65import { 66 acquireProcessLifetimeLock, 67 cleanupStaleLocks, 68 isLockActive, 69 isPidBasedLockingEnabled, 70 readLockContent, 71 withLock, 72} from './pidLock.js' 73 74export const VERSION_RETENTION_COUNT = 2 75 76// 7 days in milliseconds - used for mtime-based lock stale timeout. 77// This is long enough to survive laptop sleep durations while still 78// allowing cleanup of abandoned locks from crashed processes within a reasonable time. 79const LOCK_STALE_MS = 7 * 24 * 60 * 60 * 1000 80 81export type SetupMessage = { 82 message: string 83 userActionRequired: boolean 84 type: 'path' | 'alias' | 'info' | 'error' 85} 86 87export function getPlatform(): string { 88 // Use env.platform which already handles platform detection and defaults to 'linux' 89 const os = env.platform 90 91 const arch = 92 process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : null 93 94 if (!arch) { 95 const error = new Error(`Unsupported architecture: ${process.arch}`) 96 logForDebugging( 97 `Native installer does not support architecture: ${process.arch}`, 98 { level: 'error' }, 99 ) 100 throw error 101 } 102 103 // Check for musl on Linux and adjust platform accordingly 104 if (os === 'linux' && envDynamic.isMuslEnvironment()) { 105 return `linux-${arch}-musl` 106 } 107 108 return `${os}-${arch}` 109} 110 111export function getBinaryName(platform: string): string { 112 return platform.startsWith('win32') ? 'claude.exe' : 'claude' 113} 114 115function getBaseDirectories() { 116 const platform = getPlatform() 117 const executableName = getBinaryName(platform) 118 119 return { 120 // Data directories (permanent storage) 121 versions: join(getXDGDataHome(), 'claude', 'versions'), 122 123 // Cache directories (can be deleted) 124 staging: join(getXDGCacheHome(), 'claude', 'staging'), 125 126 // State directories 127 locks: join(getXDGStateHome(), 'claude', 'locks'), 128 129 // User bin 130 executable: join(getUserBinDir(), executableName), 131 } 132} 133 134async function isPossibleClaudeBinary(filePath: string): Promise<boolean> { 135 try { 136 const stats = await stat(filePath) 137 // before download, the version lock file (located at the same filePath) will be size 0 138 // also, we allow small sizes because we want to treat small wrapper scripts as valid 139 if (!stats.isFile() || stats.size === 0) { 140 return false 141 } 142 143 // Check if file is executable. Note: On Windows, this relies on file extensions 144 // (.exe, .bat, .cmd) and ACL permissions rather than Unix permission bits, 145 // so it may not work perfectly for all executable files on Windows. 146 await access(filePath, fsConstants.X_OK) 147 return true 148 } catch { 149 return false 150 } 151} 152 153async function getVersionPaths(version: string) { 154 const dirs = getBaseDirectories() 155 156 // Create directories, but not the executable path (which is a file) 157 const dirsToCreate = [dirs.versions, dirs.staging, dirs.locks] 158 await Promise.all(dirsToCreate.map(dir => mkdir(dir, { recursive: true }))) 159 160 // Ensure parent directory of executable exists 161 const executableParentDir = dirname(dirs.executable) 162 await mkdir(executableParentDir, { recursive: true }) 163 164 const installPath = join(dirs.versions, version) 165 166 // Create an empty file if it doesn't exist 167 try { 168 await stat(installPath) 169 } catch { 170 await writeFile(installPath, '', { encoding: 'utf8' }) 171 } 172 173 return { 174 stagingPath: join(dirs.staging, version), 175 installPath, 176 } 177} 178 179// Execute a callback while holding a lock on a version file 180// Returns false if the file is already locked, true if callback executed 181async function tryWithVersionLock( 182 versionFilePath: string, 183 callback: () => void | Promise<void>, 184 retries = 0, 185): Promise<boolean> { 186 const dirs = getBaseDirectories() 187 188 const lockfilePath = getLockFilePathFromVersionPath(dirs, versionFilePath) 189 190 // Ensure the locks directory exists 191 await mkdir(dirs.locks, { recursive: true }) 192 193 if (isPidBasedLockingEnabled()) { 194 // Use PID-based locking with optional retries 195 let attempts = 0 196 const maxAttempts = retries + 1 197 const minTimeout = retries > 0 ? 1000 : 100 198 const maxTimeout = retries > 0 ? 5000 : 500 199 200 while (attempts < maxAttempts) { 201 const success = await withLock( 202 versionFilePath, 203 lockfilePath, 204 async () => { 205 try { 206 await callback() 207 } catch (error) { 208 logError(error) 209 throw error 210 } 211 }, 212 ) 213 214 if (success) { 215 logEvent('tengu_version_lock_acquired', { 216 is_pid_based: true, 217 is_lifetime_lock: false, 218 attempts: attempts + 1, 219 }) 220 return true 221 } 222 223 attempts++ 224 if (attempts < maxAttempts) { 225 // Wait before retrying with exponential backoff 226 const timeout = Math.min( 227 minTimeout * Math.pow(2, attempts - 1), 228 maxTimeout, 229 ) 230 await sleep(timeout) 231 } 232 } 233 234 logEvent('tengu_version_lock_failed', { 235 is_pid_based: true, 236 is_lifetime_lock: false, 237 attempts: maxAttempts, 238 }) 239 logLockAcquisitionError( 240 versionFilePath, 241 new Error('Lock held by another process'), 242 ) 243 return false 244 } 245 246 // Use mtime-based locking (proper-lockfile) with 30-day stale timeout 247 let release: (() => Promise<void>) | null = null 248 try { 249 // Lock acquisition phase - catch lock errors and return false 250 // Use 30 days for stale to match lockCurrentVersion() - this ensures we never 251 // consider a running process's lock as stale during normal usage (including 252 // laptop sleep). 30 days allows eventual cleanup of abandoned locks from 253 // crashed processes while being long enough for any realistic session. 254 try { 255 release = await lockfile.lock(versionFilePath, { 256 stale: LOCK_STALE_MS, 257 retries: { 258 retries, 259 minTimeout: retries > 0 ? 1000 : 100, 260 maxTimeout: retries > 0 ? 5000 : 500, 261 }, 262 lockfilePath, 263 // Handle lock compromise gracefully to prevent unhandled rejections 264 // This can happen if another process deletes the lock directory while we hold it 265 onCompromised: (err: Error) => { 266 logForDebugging( 267 `NON-FATAL: Version lock was compromised during operation: ${err.message}`, 268 { level: 'info' }, 269 ) 270 }, 271 }) 272 } catch (lockError) { 273 logEvent('tengu_version_lock_failed', { 274 is_pid_based: false, 275 is_lifetime_lock: false, 276 }) 277 logLockAcquisitionError(versionFilePath, lockError) 278 return false 279 } 280 281 // Operation phase - log errors but let them propagate 282 try { 283 await callback() 284 logEvent('tengu_version_lock_acquired', { 285 is_pid_based: false, 286 is_lifetime_lock: false, 287 }) 288 return true 289 } catch (error) { 290 logError(error) 291 throw error 292 } 293 } finally { 294 if (release) { 295 await release() 296 } 297 } 298} 299 300async function atomicMoveToInstallPath( 301 stagedBinaryPath: string, 302 installPath: string, 303) { 304 // Create installation directory if it doesn't exist 305 await mkdir(dirname(installPath), { recursive: true }) 306 307 // Move from staging to final location atomically 308 const tempInstallPath = `${installPath}.tmp.${process.pid}.${Date.now()}` 309 310 try { 311 // Copy to temp next to install path, then rename. A direct rename from staging 312 // would fail with EXDEV if staging and install are on different filesystems. 313 await copyFile(stagedBinaryPath, tempInstallPath) 314 await chmod(tempInstallPath, 0o755) 315 await rename(tempInstallPath, installPath) 316 logForDebugging(`Atomically installed binary to ${installPath}`) 317 } catch (error) { 318 // Clean up temp file if it exists 319 try { 320 await unlink(tempInstallPath) 321 } catch { 322 // Ignore cleanup errors 323 } 324 throw error 325 } 326} 327 328async function installVersionFromPackage( 329 stagingPath: string, 330 installPath: string, 331) { 332 try { 333 // Extract binary from npm package structure in staging 334 const nodeModulesDir = join(stagingPath, 'node_modules', '@anthropic-ai') 335 const entries = await readdir(nodeModulesDir) 336 const nativePackage = entries.find((entry: string) => 337 entry.startsWith('claude-cli-native-'), 338 ) 339 340 if (!nativePackage) { 341 logEvent('tengu_native_install_package_failure', { 342 stage_find_package: true, 343 error_package_not_found: true, 344 }) 345 const error = new Error('Could not find platform-specific native package') 346 throw error 347 } 348 349 const stagedBinaryPath = join(nodeModulesDir, nativePackage, 'cli') 350 351 try { 352 await stat(stagedBinaryPath) 353 } catch { 354 logEvent('tengu_native_install_package_failure', { 355 stage_binary_exists: true, 356 error_binary_not_found: true, 357 }) 358 const error = new Error('Native binary not found in staged package') 359 throw error 360 } 361 362 await atomicMoveToInstallPath(stagedBinaryPath, installPath) 363 364 // Clean up staging directory 365 await rm(stagingPath, { recursive: true, force: true }) 366 367 logEvent('tengu_native_install_package_success', {}) 368 } catch (error) { 369 // Log if not already logged above 370 const msg = errorMessage(error) 371 if ( 372 !msg.includes('Could not find platform-specific') && 373 !msg.includes('Native binary not found') 374 ) { 375 logEvent('tengu_native_install_package_failure', { 376 stage_atomic_move: true, 377 error_move_failed: true, 378 }) 379 } 380 logError(toError(error)) 381 throw error 382 } 383} 384 385async function installVersionFromBinary( 386 stagingPath: string, 387 installPath: string, 388) { 389 try { 390 // For direct binary downloads (GCS, generic bucket), the binary is directly in staging 391 const platform = getPlatform() 392 const binaryName = getBinaryName(platform) 393 const stagedBinaryPath = join(stagingPath, binaryName) 394 395 try { 396 await stat(stagedBinaryPath) 397 } catch { 398 logEvent('tengu_native_install_binary_failure', { 399 stage_binary_exists: true, 400 error_binary_not_found: true, 401 }) 402 const error = new Error('Staged binary not found') 403 throw error 404 } 405 406 await atomicMoveToInstallPath(stagedBinaryPath, installPath) 407 408 // Clean up staging directory 409 await rm(stagingPath, { recursive: true, force: true }) 410 411 logEvent('tengu_native_install_binary_success', {}) 412 } catch (error) { 413 if (!errorMessage(error).includes('Staged binary not found')) { 414 logEvent('tengu_native_install_binary_failure', { 415 stage_atomic_move: true, 416 error_move_failed: true, 417 }) 418 } 419 logError(toError(error)) 420 throw error 421 } 422} 423 424async function installVersion( 425 stagingPath: string, 426 installPath: string, 427 downloadType: 'npm' | 'binary', 428) { 429 // Use the explicit download type instead of guessing 430 if (downloadType === 'npm') { 431 await installVersionFromPackage(stagingPath, installPath) 432 } else { 433 await installVersionFromBinary(stagingPath, installPath) 434 } 435} 436 437/** 438 * Performs the core update operation: download (if needed), install, and update symlink. 439 * Returns whether a new install was performed (vs just updating symlink). 440 */ 441async function performVersionUpdate( 442 version: string, 443 forceReinstall: boolean, 444): Promise<boolean> { 445 const { stagingPath: baseStagingPath, installPath } = 446 await getVersionPaths(version) 447 const { executable: executablePath } = getBaseDirectories() 448 449 // For lockless updates, use a unique staging path to avoid conflicts between concurrent downloads 450 const stagingPath = isEnvTruthy(process.env.ENABLE_LOCKLESS_UPDATES) 451 ? `${baseStagingPath}.${process.pid}.${Date.now()}` 452 : baseStagingPath 453 454 // Only download if not already installed (or if force reinstall) 455 const needsInstall = !(await versionIsAvailable(version)) || forceReinstall 456 if (needsInstall) { 457 logForDebugging( 458 forceReinstall 459 ? `Force reinstalling native installer version ${version}` 460 : `Downloading native installer version ${version}`, 461 ) 462 const downloadType = await downloadVersion(version, stagingPath) 463 await installVersion(stagingPath, installPath, downloadType) 464 } else { 465 logForDebugging(`Version ${version} already installed, updating symlink`) 466 } 467 468 // Create direct symlink from ~/.local/bin/claude to the version binary 469 await removeDirectoryIfEmpty(executablePath) 470 await updateSymlink(executablePath, installPath) 471 472 // Verify the executable was actually created/updated 473 if (!(await isPossibleClaudeBinary(executablePath))) { 474 let installPathExists = false 475 try { 476 await stat(installPath) 477 installPathExists = true 478 } catch { 479 // installPath doesn't exist 480 } 481 throw new Error( 482 `Failed to create executable at ${executablePath}. ` + 483 `Source file exists: ${installPathExists}. ` + 484 `Check write permissions to ${executablePath}.`, 485 ) 486 } 487 return needsInstall 488} 489 490async function versionIsAvailable(version: string): Promise<boolean> { 491 const { installPath } = await getVersionPaths(version) 492 return isPossibleClaudeBinary(installPath) 493} 494 495async function updateLatest( 496 channelOrVersion: string, 497 forceReinstall: boolean = false, 498): Promise<{ 499 success: boolean 500 latestVersion: string 501 lockFailed?: boolean 502 lockHolderPid?: number 503}> { 504 const startTime = Date.now() 505 let version = await getLatestVersion(channelOrVersion) 506 const { executable: executablePath } = getBaseDirectories() 507 508 logForDebugging(`Checking for native installer update to version ${version}`) 509 510 // Check if max version is set (server-side kill switch for auto-updates) 511 if (!forceReinstall) { 512 const maxVersion = await getMaxVersion() 513 if (maxVersion && gt(version, maxVersion)) { 514 logForDebugging( 515 `Native installer: maxVersion ${maxVersion} is set, capping update from ${version} to ${maxVersion}`, 516 ) 517 // If we're already at or above maxVersion, skip the update entirely 518 if (gte(MACRO.VERSION, maxVersion)) { 519 logForDebugging( 520 `Native installer: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`, 521 ) 522 logEvent('tengu_native_update_skipped_max_version', { 523 latency_ms: Date.now() - startTime, 524 max_version: 525 maxVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 526 available_version: 527 version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 528 }) 529 return { success: true, latestVersion: version } 530 } 531 version = maxVersion 532 } 533 } 534 535 // Early exit: if we're already running this exact version AND both the version binary 536 // and executable exist and are valid. We need to proceed if the executable doesn't exist, 537 // is invalid (e.g., empty/corrupted from a failed install), or we're running via npx. 538 if ( 539 !forceReinstall && 540 version === MACRO.VERSION && 541 (await versionIsAvailable(version)) && 542 (await isPossibleClaudeBinary(executablePath)) 543 ) { 544 logForDebugging(`Found ${version} at ${executablePath}, skipping install`) 545 logEvent('tengu_native_update_complete', { 546 latency_ms: Date.now() - startTime, 547 was_new_install: false, 548 was_force_reinstall: false, 549 was_already_running: true, 550 }) 551 return { success: true, latestVersion: version } 552 } 553 554 // Check if this version should be skipped due to minimumVersion setting 555 if (!forceReinstall && shouldSkipVersion(version)) { 556 logEvent('tengu_native_update_skipped_minimum_version', { 557 latency_ms: Date.now() - startTime, 558 target_version: 559 version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 560 }) 561 return { success: true, latestVersion: version } 562 } 563 564 // Track if we're actually installing or just symlinking 565 let wasNewInstall = false 566 let latencyMs: number 567 568 if (isEnvTruthy(process.env.ENABLE_LOCKLESS_UPDATES)) { 569 // Lockless: rely on atomic operations, errors propagate 570 wasNewInstall = await performVersionUpdate(version, forceReinstall) 571 latencyMs = Date.now() - startTime 572 } else { 573 // Lock-based updates 574 const { installPath } = await getVersionPaths(version) 575 // If force reinstall, remove any existing lock to bypass stale locks 576 if (forceReinstall) { 577 await forceRemoveLock(installPath) 578 } 579 580 const lockAcquired = await tryWithVersionLock( 581 installPath, 582 async () => { 583 wasNewInstall = await performVersionUpdate(version, forceReinstall) 584 }, 585 3, // retries 586 ) 587 588 latencyMs = Date.now() - startTime 589 590 // Lock acquisition failed - get lock holder PID for error message 591 if (!lockAcquired) { 592 const dirs = getBaseDirectories() 593 let lockHolderPid: number | undefined 594 if (isPidBasedLockingEnabled()) { 595 const lockfilePath = getLockFilePathFromVersionPath(dirs, installPath) 596 if (isLockActive(lockfilePath)) { 597 lockHolderPid = readLockContent(lockfilePath)?.pid 598 } 599 } 600 logEvent('tengu_native_update_lock_failed', { 601 latency_ms: latencyMs, 602 lock_holder_pid: lockHolderPid, 603 }) 604 return { 605 success: false, 606 latestVersion: version, 607 lockFailed: true, 608 lockHolderPid, 609 } 610 } 611 } 612 613 logEvent('tengu_native_update_complete', { 614 latency_ms: latencyMs, 615 was_new_install: wasNewInstall, 616 was_force_reinstall: forceReinstall, 617 }) 618 logForDebugging(`Successfully updated to version ${version}`) 619 return { success: true, latestVersion: version } 620} 621 622// Exported for testing 623export async function removeDirectoryIfEmpty(path: string): Promise<void> { 624 // rmdir alone handles all cases: ENOTDIR if path is a file, ENOTEMPTY if 625 // directory is non-empty, ENOENT if missing. No need to stat+readdir first. 626 try { 627 await rmdir(path) 628 logForDebugging(`Removed empty directory at ${path}`) 629 } catch (error) { 630 const code = getErrnoCode(error) 631 // Expected cases (not-a-dir, missing, not-empty) — silently skip. 632 // ENOTDIR is the normal path: executablePath is typically a symlink. 633 if (code !== 'ENOTDIR' && code !== 'ENOENT' && code !== 'ENOTEMPTY') { 634 logForDebugging(`Could not remove directory at ${path}: ${error}`) 635 } 636 } 637} 638 639async function updateSymlink( 640 symlinkPath: string, 641 targetPath: string, 642): Promise<boolean> { 643 const platform = getPlatform() 644 const isWindows = platform.startsWith('win32') 645 646 // On Windows, directly copy the executable instead of creating a symlink 647 if (isWindows) { 648 try { 649 // Ensure parent directory exists 650 const parentDir = dirname(symlinkPath) 651 await mkdir(parentDir, { recursive: true }) 652 653 // Check if file already exists and has same content 654 let existingStats: Stats | undefined 655 try { 656 existingStats = await stat(symlinkPath) 657 } catch { 658 // symlinkPath doesn't exist 659 } 660 661 if (existingStats) { 662 try { 663 const targetStats = await stat(targetPath) 664 // If sizes match, assume files are the same (avoid reading large files) 665 if (existingStats.size === targetStats.size) { 666 return false 667 } 668 } catch { 669 // Continue with copy if we can't compare 670 } 671 // Use rename strategy to handle file locking on Windows 672 // Rename always works even for running executables, unlike delete 673 const oldFileName = `${symlinkPath}.old.${Date.now()}` 674 await rename(symlinkPath, oldFileName) 675 676 // Try to copy new executable, with rollback on failure 677 try { 678 await copyFile(targetPath, symlinkPath) 679 // Success - try immediate cleanup of old file (non-blocking) 680 try { 681 await unlink(oldFileName) 682 } catch { 683 // File still running - ignore, Windows will clean up eventually 684 } 685 } catch (copyError) { 686 // Copy failed - restore the old executable 687 try { 688 await rename(oldFileName, symlinkPath) 689 } catch (restoreError) { 690 // Critical: User left without working executable - prioritize restore error 691 const errorWithCause = new Error( 692 `Failed to restore old executable: ${restoreError}`, 693 { cause: copyError }, 694 ) 695 logError(errorWithCause) 696 throw errorWithCause 697 } 698 throw copyError 699 } 700 } else { 701 // First-time installation (no existing file to rename) 702 // Copy the executable directly; handle ENOENT from copyFile itself 703 // rather than a stat() pre-check (avoids TOCTOU + extra syscall) 704 try { 705 await copyFile(targetPath, symlinkPath) 706 } catch (e) { 707 if (isENOENT(e)) { 708 throw new Error(`Source file does not exist: ${targetPath}`) 709 } 710 throw e 711 } 712 } 713 // chmod is not needed on Windows - executability is determined by .exe extension 714 return true 715 } catch (error) { 716 logError( 717 new Error( 718 `Failed to copy executable from ${targetPath} to ${symlinkPath}: ${error}`, 719 ), 720 ) 721 return false 722 } 723 } 724 725 // For non-Windows platforms, use symlinks as before 726 // Ensure parent directory exists (same as Windows path above) 727 const parentDir = dirname(symlinkPath) 728 try { 729 await mkdir(parentDir, { recursive: true }) 730 logForDebugging(`Created directory ${parentDir} for symlink`) 731 } catch (mkdirError) { 732 logError( 733 new Error(`Failed to create directory ${parentDir}: ${mkdirError}`), 734 ) 735 return false 736 } 737 738 // Check if symlink already exists and points to the correct target 739 try { 740 let symlinkExists = false 741 try { 742 await stat(symlinkPath) 743 symlinkExists = true 744 } catch { 745 // symlinkPath doesn't exist 746 } 747 748 if (symlinkExists) { 749 try { 750 const currentTarget = await readlink(symlinkPath) 751 const resolvedCurrentTarget = resolve( 752 dirname(symlinkPath), 753 currentTarget, 754 ) 755 const resolvedTargetPath = resolve(targetPath) 756 757 if (resolvedCurrentTarget === resolvedTargetPath) { 758 return false 759 } 760 } catch { 761 // Path exists but is not a symlink - will remove it below 762 } 763 764 // Remove existing file/symlink before creating new one 765 await unlink(symlinkPath) 766 } 767 } catch (error) { 768 logError(new Error(`Failed to check/remove existing symlink: ${error}`)) 769 } 770 771 // Use atomic rename to avoid race conditions. Create symlink with temporary name 772 // then atomically rename to final name. This ensures the symlink always exists 773 // and is always valid, even with concurrent updates. 774 const tempSymlink = `${symlinkPath}.tmp.${process.pid}.${Date.now()}` 775 try { 776 await symlink(targetPath, tempSymlink) 777 778 // Atomically rename to final name (replaces existing) 779 await rename(tempSymlink, symlinkPath) 780 logForDebugging( 781 `Atomically updated symlink ${symlinkPath} -> ${targetPath}`, 782 ) 783 return true 784 } catch (error) { 785 // Clean up temp symlink if it exists 786 try { 787 await unlink(tempSymlink) 788 } catch { 789 // Ignore cleanup errors 790 } 791 logError( 792 new Error( 793 `Failed to create symlink from ${symlinkPath} to ${targetPath}: ${error}`, 794 ), 795 ) 796 return false 797 } 798} 799 800export async function checkInstall( 801 force: boolean = false, 802): Promise<SetupMessage[]> { 803 // Skip all installation checks if disabled via environment variable 804 if (isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) { 805 return [] 806 } 807 808 // Get the actual installation type and config 809 const installationType = await getCurrentInstallationType() 810 811 // Skip checks for development builds - config.installMethod from a previous 812 // native installation shouldn't trigger warnings when running dev builds 813 if (installationType === 'development') { 814 return [] 815 } 816 817 const config = getGlobalConfig() 818 819 // Only show warnings if: 820 // 1. User is actually running from native installation, OR 821 // 2. User has explicitly set installMethod to 'native' in config (they're trying to use native) 822 // 3. force is true (used during installation process) 823 const shouldCheckNative = 824 force || installationType === 'native' || config.installMethod === 'native' 825 826 if (!shouldCheckNative) { 827 return [] 828 } 829 830 const dirs = getBaseDirectories() 831 const messages: SetupMessage[] = [] 832 const localBinDir = dirname(dirs.executable) 833 const resolvedLocalBinPath = resolve(localBinDir) 834 const platform = getPlatform() 835 const isWindows = platform.startsWith('win32') 836 837 // Check if bin directory exists 838 try { 839 await access(localBinDir) 840 } catch { 841 messages.push({ 842 message: `installMethod is native, but directory ${localBinDir} does not exist`, 843 userActionRequired: true, 844 type: 'error', 845 }) 846 } 847 848 // Check if claude executable exists and is valid. 849 // On non-Windows, call readlink directly and route errno — ENOENT means 850 // the executable is missing, EINVAL means it exists but isn't a symlink. 851 // This avoids an access()→readlink() TOCTOU where deletion between the 852 // two calls produces a misleading "Not a symlink" diagnostic. 853 // isPossibleClaudeBinary stats the path internally, so we don't pre-check 854 // with access() — that would be a TOCTOU between access and the stat. 855 if (isWindows) { 856 // On Windows it's a copied executable, not a symlink 857 if (!(await isPossibleClaudeBinary(dirs.executable))) { 858 messages.push({ 859 message: `installMethod is native, but claude command is missing or invalid at ${dirs.executable}`, 860 userActionRequired: true, 861 type: 'error', 862 }) 863 } 864 } else { 865 try { 866 const target = await readlink(dirs.executable) 867 const absoluteTarget = resolve(dirname(dirs.executable), target) 868 if (!(await isPossibleClaudeBinary(absoluteTarget))) { 869 messages.push({ 870 message: `Claude symlink points to missing or invalid binary: ${target}`, 871 userActionRequired: true, 872 type: 'error', 873 }) 874 } 875 } catch (e) { 876 if (isENOENT(e)) { 877 messages.push({ 878 message: `installMethod is native, but claude command not found at ${dirs.executable}`, 879 userActionRequired: true, 880 type: 'error', 881 }) 882 } else { 883 // EINVAL (not a symlink) or other — check as regular binary 884 if (!(await isPossibleClaudeBinary(dirs.executable))) { 885 messages.push({ 886 message: `${dirs.executable} exists but is not a valid Claude binary`, 887 userActionRequired: true, 888 type: 'error', 889 }) 890 } 891 } 892 } 893 } 894 895 // Check if bin directory is in PATH 896 const isInCurrentPath = (process.env.PATH || '') 897 .split(delimiter) 898 .some(entry => { 899 try { 900 const resolvedEntry = resolve(entry) 901 // On Windows, perform case-insensitive comparison for paths 902 if (isWindows) { 903 return ( 904 resolvedEntry.toLowerCase() === resolvedLocalBinPath.toLowerCase() 905 ) 906 } 907 return resolvedEntry === resolvedLocalBinPath 908 } catch { 909 return false 910 } 911 }) 912 913 if (!isInCurrentPath) { 914 if (isWindows) { 915 // Windows-specific PATH instructions 916 const windowsBinPath = localBinDir.replace(/\//g, '\\') 917 messages.push({ 918 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.`, 919 userActionRequired: true, 920 type: 'path', 921 }) 922 } else { 923 // Unix-style PATH instructions 924 const shellType = getShellType() 925 const configPaths = getShellConfigPaths() 926 const configFile = configPaths[shellType as keyof typeof configPaths] 927 const displayPath = configFile 928 ? configFile.replace(homedir(), '~') 929 : 'your shell config file' 930 931 messages.push({ 932 message: `Native installation exists but ~/.local/bin is not in your PATH. Run:\n\necho 'export PATH="$HOME/.local/bin:$PATH"' >> ${displayPath} && source ${displayPath}`, 933 userActionRequired: true, 934 type: 'path', 935 }) 936 } 937 } 938 939 return messages 940} 941 942type InstallLatestResult = { 943 latestVersion: string | null 944 wasUpdated: boolean 945 lockFailed?: boolean 946 lockHolderPid?: number 947} 948 949// In-process singleflight guard. NativeAutoUpdater remounts whenever the 950// prompt suggestions overlay toggles (PromptInput.tsx:2916), and the 951// isUpdating guard does not survive the remount. Each remount kicked off a 952// fresh 271MB binary download while previous ones were still in flight. 953// Telemetry: session 42fed33f saw arrayBuffers climb to 91GB at ~650MB/s. 954let inFlightInstall: Promise<InstallLatestResult> | null = null 955 956export function installLatest( 957 channelOrVersion: string, 958 forceReinstall: boolean = false, 959): Promise<InstallLatestResult> { 960 if (forceReinstall) { 961 return installLatestImpl(channelOrVersion, forceReinstall) 962 } 963 if (inFlightInstall) { 964 logForDebugging('installLatest: joining in-flight call') 965 return inFlightInstall 966 } 967 const promise = installLatestImpl(channelOrVersion, forceReinstall) 968 inFlightInstall = promise 969 const clear = (): void => { 970 inFlightInstall = null 971 } 972 void promise.then(clear, clear) 973 return promise 974} 975 976async function installLatestImpl( 977 channelOrVersion: string, 978 forceReinstall: boolean = false, 979): Promise<InstallLatestResult> { 980 const updateResult = await updateLatest(channelOrVersion, forceReinstall) 981 982 if (!updateResult.success) { 983 return { 984 latestVersion: null, 985 wasUpdated: false, 986 lockFailed: updateResult.lockFailed, 987 lockHolderPid: updateResult.lockHolderPid, 988 } 989 } 990 991 // Installation succeeded (early return above covers failure). Mark as native 992 // and disable legacy auto-updater to protect symlinks. 993 const config = getGlobalConfig() 994 if (config.installMethod !== 'native') { 995 saveGlobalConfig(current => ({ 996 ...current, 997 installMethod: 'native', 998 // Disable legacy auto-updater to prevent npm sessions from deleting native symlinks. 999 // Native installations use NativeAutoUpdater instead, which respects native installation. 1000 autoUpdates: false, 1001 // Mark this as protection-based, not user preference 1002 autoUpdatesProtectedForNative: true, 1003 })) 1004 logForDebugging( 1005 'Native installer: Set installMethod to "native" and disabled legacy auto-updater for protection', 1006 ) 1007 } 1008 1009 void cleanupOldVersions() 1010 1011 return { 1012 latestVersion: updateResult.latestVersion, 1013 wasUpdated: updateResult.success, 1014 lockFailed: false, 1015 } 1016} 1017 1018async function getVersionFromSymlink( 1019 symlinkPath: string, 1020): Promise<string | null> { 1021 try { 1022 const target = await readlink(symlinkPath) 1023 const absoluteTarget = resolve(dirname(symlinkPath), target) 1024 if (await isPossibleClaudeBinary(absoluteTarget)) { 1025 return absoluteTarget 1026 } 1027 } catch { 1028 // Not a symlink / doesn't exist / target doesn't exist 1029 } 1030 return null 1031} 1032 1033function getLockFilePathFromVersionPath( 1034 dirs: ReturnType<typeof getBaseDirectories>, 1035 versionPath: string, 1036) { 1037 const versionName = basename(versionPath) 1038 return join(dirs.locks, `${versionName}.lock`) 1039} 1040 1041/** 1042 * Acquire a lock on the current running version to prevent it from being deleted 1043 * This lock is held for the entire lifetime of the process 1044 * 1045 * Uses PID-based locking (when enabled) which can immediately detect crashed processes 1046 * (unlike mtime-based locking which requires a 30-day timeout) 1047 */ 1048export async function lockCurrentVersion(): Promise<void> { 1049 const dirs = getBaseDirectories() 1050 1051 // Only lock if we're running from the versions directory 1052 if (!process.execPath.includes(dirs.versions)) { 1053 return 1054 } 1055 1056 const versionPath = resolve(process.execPath) 1057 try { 1058 const lockfilePath = getLockFilePathFromVersionPath(dirs, versionPath) 1059 1060 // Ensure locks directory exists 1061 await mkdir(dirs.locks, { recursive: true }) 1062 1063 if (isPidBasedLockingEnabled()) { 1064 // Acquire PID-based lock and hold it for the process lifetime 1065 // PID-based locking allows immediate detection of crashed processes 1066 // while still surviving laptop sleep (process is suspended but PID exists) 1067 const acquired = await acquireProcessLifetimeLock( 1068 versionPath, 1069 lockfilePath, 1070 ) 1071 1072 if (!acquired) { 1073 logEvent('tengu_version_lock_failed', { 1074 is_pid_based: true, 1075 is_lifetime_lock: true, 1076 }) 1077 logLockAcquisitionError( 1078 versionPath, 1079 new Error('Lock already held by another process'), 1080 ) 1081 return 1082 } 1083 1084 logEvent('tengu_version_lock_acquired', { 1085 is_pid_based: true, 1086 is_lifetime_lock: true, 1087 }) 1088 logForDebugging(`Acquired PID lock on running version: ${versionPath}`) 1089 } else { 1090 // Acquire mtime-based lock and never release it (until process exits) 1091 // Use 30 days for stale to prevent the lock from being considered stale during 1092 // normal usage. This is critical because laptop sleep suspends the process, 1093 // stopping the mtime heartbeat. 30 days is long enough for any realistic session 1094 // while still allowing eventual cleanup of abandoned locks. 1095 let release: (() => Promise<void>) | undefined 1096 try { 1097 release = await lockfile.lock(versionPath, { 1098 stale: LOCK_STALE_MS, 1099 retries: 0, // Don't retry - if we can't lock, that's fine 1100 lockfilePath, 1101 // Handle lock compromise gracefully (e.g., if another process deletes the lock directory) 1102 onCompromised: (err: Error) => { 1103 logForDebugging( 1104 `NON-FATAL: Lock on running version was compromised: ${err.message}`, 1105 { level: 'info' }, 1106 ) 1107 }, 1108 }) 1109 logEvent('tengu_version_lock_acquired', { 1110 is_pid_based: false, 1111 is_lifetime_lock: true, 1112 }) 1113 logForDebugging( 1114 `Acquired mtime-based lock on running version: ${versionPath}`, 1115 ) 1116 1117 // Release lock explicitly; proper-lockfile's cleanup is unreliable with signal-exit v3+v4 1118 registerCleanup(async () => { 1119 try { 1120 await release?.() 1121 } catch { 1122 // Lock may already be released 1123 } 1124 }) 1125 } catch (lockError) { 1126 if (isENOENT(lockError)) { 1127 logForDebugging( 1128 `Cannot lock current version - file does not exist: ${versionPath}`, 1129 { level: 'info' }, 1130 ) 1131 return 1132 } 1133 logEvent('tengu_version_lock_failed', { 1134 is_pid_based: false, 1135 is_lifetime_lock: true, 1136 }) 1137 logLockAcquisitionError(versionPath, lockError) 1138 return 1139 } 1140 } 1141 } catch (error) { 1142 if (isENOENT(error)) { 1143 logForDebugging( 1144 `Cannot lock current version - file does not exist: ${versionPath}`, 1145 { level: 'info' }, 1146 ) 1147 return 1148 } 1149 // We fallback to previous behavior where we don't acquire a lock on a running version 1150 // This ~mostly works but using native binaries like ripgrep will fail 1151 logForDebugging( 1152 `NON-FATAL: Failed to lock current version during execution ${errorMessage(error)}`, 1153 { level: 'info' }, 1154 ) 1155 } 1156} 1157 1158function logLockAcquisitionError(versionPath: string, lockError: unknown) { 1159 logError( 1160 new Error( 1161 `NON-FATAL: Lock acquisition failed for ${versionPath} (expected in multi-process scenarios)`, 1162 { cause: lockError }, 1163 ), 1164 ) 1165} 1166 1167/** 1168 * Force-remove a lock file for a given version path. 1169 * Used when --force is specified to bypass stale locks. 1170 */ 1171async function forceRemoveLock(versionFilePath: string): Promise<void> { 1172 const dirs = getBaseDirectories() 1173 const lockfilePath = getLockFilePathFromVersionPath(dirs, versionFilePath) 1174 1175 try { 1176 await unlink(lockfilePath) 1177 logForDebugging(`Force-removed lock file at ${lockfilePath}`) 1178 } catch (error) { 1179 // Log but don't throw - we'll try to acquire the lock anyway 1180 logForDebugging(`Failed to force-remove lock file: ${errorMessage(error)}`) 1181 } 1182} 1183 1184export async function cleanupOldVersions(): Promise<void> { 1185 // Yield to ensure we don't block startup 1186 await Promise.resolve() 1187 1188 const dirs = getBaseDirectories() 1189 const oneHourAgo = Date.now() - 3600000 1190 1191 // Clean up old renamed executables on Windows (no longer running at startup) 1192 if (getPlatform().startsWith('win32')) { 1193 const executableDir = dirname(dirs.executable) 1194 try { 1195 const files = await readdir(executableDir) 1196 let cleanedCount = 0 1197 for (const file of files) { 1198 if (!/^claude\.exe\.old\.\d+$/.test(file)) continue 1199 try { 1200 await unlink(join(executableDir, file)) 1201 cleanedCount++ 1202 } catch { 1203 // File might still be in use by another process 1204 } 1205 } 1206 if (cleanedCount > 0) { 1207 logForDebugging( 1208 `Cleaned up ${cleanedCount} old Windows executables on startup`, 1209 ) 1210 } 1211 } catch (error) { 1212 if (!isENOENT(error)) { 1213 logForDebugging(`Failed to clean up old Windows executables: ${error}`) 1214 } 1215 } 1216 } 1217 1218 // Clean up orphaned staging directories older than 1 hour 1219 try { 1220 const stagingEntries = await readdir(dirs.staging) 1221 let stagingCleanedCount = 0 1222 for (const entry of stagingEntries) { 1223 const stagingPath = join(dirs.staging, entry) 1224 try { 1225 // stat() is load-bearing here (we need mtime). There is a theoretical 1226 // TOCTOU where a concurrent installer could freshen a stale staging 1227 // dir between stat and rm — but the 1-hour threshold makes this 1228 // vanishingly unlikely, and rm({force:true}) tolerates concurrent 1229 // deletion. 1230 const stats = await stat(stagingPath) 1231 if (stats.mtime.getTime() < oneHourAgo) { 1232 await rm(stagingPath, { recursive: true, force: true }) 1233 stagingCleanedCount++ 1234 logForDebugging(`Cleaned up old staging directory: ${entry}`) 1235 } 1236 } catch { 1237 // Ignore individual errors 1238 } 1239 } 1240 if (stagingCleanedCount > 0) { 1241 logForDebugging( 1242 `Cleaned up ${stagingCleanedCount} orphaned staging directories`, 1243 ) 1244 logEvent('tengu_native_staging_cleanup', { 1245 cleaned_count: stagingCleanedCount, 1246 }) 1247 } 1248 } catch (error) { 1249 if (!isENOENT(error)) { 1250 logForDebugging(`Failed to clean up staging directories: ${error}`) 1251 } 1252 } 1253 1254 // Clean up stale PID locks (crashed processes) — cleanupStaleLocks handles ENOENT 1255 if (isPidBasedLockingEnabled()) { 1256 const staleLocksCleaned = cleanupStaleLocks(dirs.locks) 1257 if (staleLocksCleaned > 0) { 1258 logForDebugging(`Cleaned up ${staleLocksCleaned} stale version locks`) 1259 logEvent('tengu_native_stale_locks_cleanup', { 1260 cleaned_count: staleLocksCleaned, 1261 }) 1262 } 1263 } 1264 1265 // Single readdir of versions dir. Partition into temp files vs candidate binaries, 1266 // stat'ing each entry at most once. 1267 let versionEntries: string[] 1268 try { 1269 versionEntries = await readdir(dirs.versions) 1270 } catch (error) { 1271 if (!isENOENT(error)) { 1272 logForDebugging(`Failed to readdir versions directory: ${error}`) 1273 } 1274 return 1275 } 1276 1277 type VersionInfo = { 1278 name: string 1279 path: string 1280 resolvedPath: string 1281 mtime: Date 1282 } 1283 const versionFiles: VersionInfo[] = [] 1284 let tempFilesCleanedCount = 0 1285 1286 for (const entry of versionEntries) { 1287 const entryPath = join(dirs.versions, entry) 1288 if (/\.tmp\.\d+\.\d+$/.test(entry)) { 1289 // Orphaned temp install file — pattern: {version}.tmp.{pid}.{timestamp} 1290 try { 1291 const stats = await stat(entryPath) 1292 if (stats.mtime.getTime() < oneHourAgo) { 1293 await unlink(entryPath) 1294 tempFilesCleanedCount++ 1295 logForDebugging(`Cleaned up orphaned temp install file: ${entry}`) 1296 } 1297 } catch { 1298 // Ignore individual errors 1299 } 1300 continue 1301 } 1302 // Candidate version binary — stat once, reuse for isFile/size/mtime/mode 1303 try { 1304 const stats = await stat(entryPath) 1305 if (!stats.isFile()) continue 1306 if ( 1307 process.platform !== 'win32' && 1308 stats.size > 0 && 1309 (stats.mode & 0o111) === 0 1310 ) { 1311 // Check executability via mode bits from the existing stat result — 1312 // avoids a second syscall (access(X_OK)) and the TOCTOU window between 1313 // stat and access. Skip on Windows: libuv only sets execute bits for 1314 // .exe/.com/.bat/.cmd, but version files are extensionless semver 1315 // strings (e.g. "1.2.3"), so this check would reject all of them. 1316 // The previous access(X_OK) passed any readable file on Windows anyway. 1317 continue 1318 } 1319 versionFiles.push({ 1320 name: entry, 1321 path: entryPath, 1322 resolvedPath: resolve(entryPath), 1323 mtime: stats.mtime, 1324 }) 1325 } catch { 1326 // Skip files we can't stat 1327 } 1328 } 1329 1330 if (tempFilesCleanedCount > 0) { 1331 logForDebugging( 1332 `Cleaned up ${tempFilesCleanedCount} orphaned temp install files`, 1333 ) 1334 logEvent('tengu_native_temp_files_cleanup', { 1335 cleaned_count: tempFilesCleanedCount, 1336 }) 1337 } 1338 1339 if (versionFiles.length === 0) { 1340 return 1341 } 1342 1343 try { 1344 // Identify protected versions 1345 const currentBinaryPath = process.execPath 1346 const protectedVersions = new Set<string>() 1347 if (currentBinaryPath && currentBinaryPath.includes(dirs.versions)) { 1348 protectedVersions.add(resolve(currentBinaryPath)) 1349 } 1350 1351 const currentSymlinkVersion = await getVersionFromSymlink(dirs.executable) 1352 if (currentSymlinkVersion) { 1353 protectedVersions.add(currentSymlinkVersion) 1354 } 1355 1356 // Protect versions with active locks (running in other processes) 1357 for (const v of versionFiles) { 1358 if (protectedVersions.has(v.resolvedPath)) continue 1359 1360 const lockFilePath = getLockFilePathFromVersionPath(dirs, v.resolvedPath) 1361 let hasActiveLock = false 1362 if (isPidBasedLockingEnabled()) { 1363 hasActiveLock = isLockActive(lockFilePath) 1364 } else { 1365 try { 1366 hasActiveLock = await lockfile.check(v.resolvedPath, { 1367 stale: LOCK_STALE_MS, 1368 lockfilePath: lockFilePath, 1369 }) 1370 } catch { 1371 hasActiveLock = false 1372 } 1373 } 1374 if (hasActiveLock) { 1375 protectedVersions.add(v.resolvedPath) 1376 logForDebugging(`Protecting locked version from cleanup: ${v.name}`) 1377 } 1378 } 1379 1380 // Eligible versions: not protected, sorted newest first (reuse cached mtime) 1381 const eligibleVersions = versionFiles 1382 .filter(v => !protectedVersions.has(v.resolvedPath)) 1383 .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) 1384 1385 const versionsToDelete = eligibleVersions.slice(VERSION_RETENTION_COUNT) 1386 1387 if (versionsToDelete.length === 0) { 1388 logEvent('tengu_native_version_cleanup', { 1389 total_count: versionFiles.length, 1390 deleted_count: 0, 1391 protected_count: protectedVersions.size, 1392 retained_count: VERSION_RETENTION_COUNT, 1393 lock_failed_count: 0, 1394 error_count: 0, 1395 }) 1396 return 1397 } 1398 1399 let deletedCount = 0 1400 let lockFailedCount = 0 1401 let errorCount = 0 1402 1403 await Promise.all( 1404 versionsToDelete.map(async version => { 1405 try { 1406 const deleted = await tryWithVersionLock(version.path, async () => { 1407 await unlink(version.path) 1408 }) 1409 if (deleted) { 1410 deletedCount++ 1411 } else { 1412 lockFailedCount++ 1413 logForDebugging( 1414 `Skipping deletion of ${version.name} - locked by another process`, 1415 ) 1416 } 1417 } catch (error) { 1418 errorCount++ 1419 logError( 1420 new Error(`Failed to delete version ${version.name}: ${error}`), 1421 ) 1422 } 1423 }), 1424 ) 1425 1426 logEvent('tengu_native_version_cleanup', { 1427 total_count: versionFiles.length, 1428 deleted_count: deletedCount, 1429 protected_count: protectedVersions.size, 1430 retained_count: VERSION_RETENTION_COUNT, 1431 lock_failed_count: lockFailedCount, 1432 error_count: errorCount, 1433 }) 1434 } catch (error) { 1435 if (!isENOENT(error)) { 1436 logError(new Error(`Version cleanup failed: ${error}`)) 1437 } 1438 } 1439} 1440 1441/** 1442 * Check if a given path is managed by npm 1443 * @param executablePath - The path to check (can be a symlink) 1444 * @returns true if the path is npm-managed, false otherwise 1445 */ 1446async function isNpmSymlink(executablePath: string): Promise<boolean> { 1447 // Resolve symlink to its target if applicable 1448 let targetPath = executablePath 1449 const stats = await lstat(executablePath) 1450 if (stats.isSymbolicLink()) { 1451 targetPath = await realpath(executablePath) 1452 } 1453 1454 // checking npm prefix isn't guaranteed to work, as prefix can change 1455 // and users may set --prefix manually when installing 1456 // thus we use this heuristic: 1457 return targetPath.endsWith('.js') || targetPath.includes('node_modules') 1458} 1459 1460/** 1461 * Remove the claude symlink from the executable directory 1462 * This is used when switching away from native installation 1463 * Will only remove if it's a native binary symlink, not npm-managed JS files 1464 */ 1465export async function removeInstalledSymlink(): Promise<void> { 1466 const dirs = getBaseDirectories() 1467 1468 try { 1469 // Check if this is an npm-managed installation 1470 if (await isNpmSymlink(dirs.executable)) { 1471 logForDebugging( 1472 `Skipping removal of ${dirs.executable} - appears to be npm-managed`, 1473 ) 1474 return 1475 } 1476 1477 // It's a native binary symlink, safe to remove 1478 await unlink(dirs.executable) 1479 logForDebugging(`Removed claude symlink at ${dirs.executable}`) 1480 } catch (error) { 1481 if (isENOENT(error)) { 1482 return 1483 } 1484 logError(new Error(`Failed to remove claude symlink: ${error}`)) 1485 } 1486} 1487 1488/** 1489 * Clean up old claude aliases from shell configuration files 1490 * Only handles alias removal, not PATH setup 1491 */ 1492export async function cleanupShellAliases(): Promise<SetupMessage[]> { 1493 const messages: SetupMessage[] = [] 1494 const configMap = getShellConfigPaths() 1495 1496 for (const [shellType, configFile] of Object.entries(configMap)) { 1497 try { 1498 const lines = await readFileLines(configFile) 1499 if (!lines) continue 1500 1501 const { filtered, hadAlias } = filterClaudeAliases(lines) 1502 1503 if (hadAlias) { 1504 await writeFileLines(configFile, filtered) 1505 messages.push({ 1506 message: `Removed claude alias from ${configFile}. Run: unalias claude`, 1507 userActionRequired: true, 1508 type: 'alias', 1509 }) 1510 logForDebugging(`Cleaned up claude alias from ${shellType} config`) 1511 } 1512 } catch (error) { 1513 logError(error) 1514 messages.push({ 1515 message: `Failed to clean up ${configFile}: ${error}`, 1516 userActionRequired: false, 1517 type: 'error', 1518 }) 1519 } 1520 } 1521 1522 return messages 1523} 1524 1525async function manualRemoveNpmPackage( 1526 packageName: string, 1527): Promise<{ success: boolean; error?: string; warning?: string }> { 1528 try { 1529 // Get npm global prefix 1530 const prefixResult = await execFileNoThrowWithCwd('npm', [ 1531 'config', 1532 'get', 1533 'prefix', 1534 ]) 1535 if (prefixResult.code !== 0 || !prefixResult.stdout) { 1536 return { 1537 success: false, 1538 error: 'Failed to get npm global prefix', 1539 } 1540 } 1541 1542 const globalPrefix = prefixResult.stdout.trim() 1543 let manuallyRemoved = false 1544 1545 // Helper to try removing a file. unlink alone is sufficient — it throws 1546 // ENOENT if the file is missing, which the catch handles identically. 1547 // A stat() pre-check would add a syscall and a TOCTOU window where 1548 // concurrent cleanup causes a false-negative return. 1549 async function tryRemove(filePath: string, description: string) { 1550 try { 1551 await unlink(filePath) 1552 logForDebugging(`Manually removed ${description}: ${filePath}`) 1553 return true 1554 } catch { 1555 return false 1556 } 1557 } 1558 1559 if (getPlatform().startsWith('win32')) { 1560 // Windows - only remove executables, not the package directory 1561 const binCmd = join(globalPrefix, 'claude.cmd') 1562 const binPs1 = join(globalPrefix, 'claude.ps1') 1563 const binExe = join(globalPrefix, 'claude') 1564 1565 if (await tryRemove(binCmd, 'bin script')) { 1566 manuallyRemoved = true 1567 } 1568 1569 if (await tryRemove(binPs1, 'PowerShell script')) { 1570 manuallyRemoved = true 1571 } 1572 1573 if (await tryRemove(binExe, 'bin executable')) { 1574 manuallyRemoved = true 1575 } 1576 } else { 1577 // Unix/Mac - only remove symlink, not the package directory 1578 const binSymlink = join(globalPrefix, 'bin', 'claude') 1579 1580 if (await tryRemove(binSymlink, 'bin symlink')) { 1581 manuallyRemoved = true 1582 } 1583 } 1584 1585 if (manuallyRemoved) { 1586 logForDebugging(`Successfully removed ${packageName} manually`) 1587 const nodeModulesPath = getPlatform().startsWith('win32') 1588 ? join(globalPrefix, 'node_modules', packageName) 1589 : join(globalPrefix, 'lib', 'node_modules', packageName) 1590 1591 return { 1592 success: true, 1593 warning: `${packageName} executables removed, but node_modules directory was left intact for safety. You may manually delete it later at: ${nodeModulesPath}`, 1594 } 1595 } else { 1596 return { success: false } 1597 } 1598 } catch (manualError) { 1599 logForDebugging(`Manual removal failed: ${manualError}`, { 1600 level: 'error', 1601 }) 1602 return { 1603 success: false, 1604 error: `Manual removal failed: ${manualError}`, 1605 } 1606 } 1607} 1608 1609async function attemptNpmUninstall( 1610 packageName: string, 1611): Promise<{ success: boolean; error?: string; warning?: string }> { 1612 const { code, stderr } = await execFileNoThrowWithCwd( 1613 'npm', 1614 ['uninstall', '-g', packageName], 1615 // eslint-disable-next-line custom-rules/no-process-cwd -- matches original behavior 1616 { cwd: process.cwd() }, 1617 ) 1618 1619 if (code === 0) { 1620 logForDebugging(`Removed global npm installation of ${packageName}`) 1621 return { success: true } 1622 } else if (stderr && !stderr.includes('npm ERR! code E404')) { 1623 // Check for ENOTEMPTY error and try manual removal 1624 if (stderr.includes('npm error code ENOTEMPTY')) { 1625 logForDebugging( 1626 `Failed to uninstall global npm package ${packageName}: ${stderr}`, 1627 { level: 'error' }, 1628 ) 1629 logForDebugging(`Attempting manual removal due to ENOTEMPTY error`) 1630 1631 const manualResult = await manualRemoveNpmPackage(packageName) 1632 if (manualResult.success) { 1633 return { success: true, warning: manualResult.warning } 1634 } else if (manualResult.error) { 1635 return { 1636 success: false, 1637 error: `Failed to remove global npm installation of ${packageName}: ${stderr}. Manual removal also failed: ${manualResult.error}`, 1638 } 1639 } 1640 } 1641 1642 // Only report as error if it's not a "package not found" error 1643 logForDebugging( 1644 `Failed to uninstall global npm package ${packageName}: ${stderr}`, 1645 { level: 'error' }, 1646 ) 1647 return { 1648 success: false, 1649 error: `Failed to remove global npm installation of ${packageName}: ${stderr}`, 1650 } 1651 } 1652 1653 return { success: false } // Package not found, not an error 1654} 1655 1656export async function cleanupNpmInstallations(): Promise<{ 1657 removed: number 1658 errors: string[] 1659 warnings: string[] 1660}> { 1661 const errors: string[] = [] 1662 const warnings: string[] = [] 1663 let removed = 0 1664 1665 // Always attempt to remove @anthropic-ai/claude-code 1666 const codePackageResult = await attemptNpmUninstall( 1667 '@anthropic-ai/claude-code', 1668 ) 1669 if (codePackageResult.success) { 1670 removed++ 1671 if (codePackageResult.warning) { 1672 warnings.push(codePackageResult.warning) 1673 } 1674 } else if (codePackageResult.error) { 1675 errors.push(codePackageResult.error) 1676 } 1677 1678 // Also attempt to remove MACRO.PACKAGE_URL if it's defined and different 1679 if (MACRO.PACKAGE_URL && MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code') { 1680 const macroPackageResult = await attemptNpmUninstall(MACRO.PACKAGE_URL) 1681 if (macroPackageResult.success) { 1682 removed++ 1683 if (macroPackageResult.warning) { 1684 warnings.push(macroPackageResult.warning) 1685 } 1686 } else if (macroPackageResult.error) { 1687 errors.push(macroPackageResult.error) 1688 } 1689 } 1690 1691 // Check for local installation at ~/.claude/local 1692 const localInstallDir = join(homedir(), '.claude', 'local') 1693 1694 try { 1695 await rm(localInstallDir, { recursive: true }) 1696 removed++ 1697 logForDebugging(`Removed local installation at ${localInstallDir}`) 1698 } catch (error) { 1699 if (!isENOENT(error)) { 1700 errors.push(`Failed to remove ${localInstallDir}: ${error}`) 1701 logForDebugging(`Failed to remove local installation: ${error}`, { 1702 level: 'error', 1703 }) 1704 } 1705 } 1706 1707 return { removed, errors, warnings } 1708}