/ utils / nativeInstaller / installer.ts
installer.ts
   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  
  13  import { constants as fsConstants, type Stats } from 'fs'
  14  import {
  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'
  31  import { homedir } from 'os'
  32  import { basename, delimiter, dirname, join, resolve } from 'path'
  33  import {
  34    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  35    logEvent,
  36  } from 'src/services/analytics/index.js'
  37  import { getMaxVersion, shouldSkipVersion } from '../autoUpdater.js'
  38  import { registerCleanup } from '../cleanupRegistry.js'
  39  import { getGlobalConfig, saveGlobalConfig } from '../config.js'
  40  import { logForDebugging } from '../debug.js'
  41  import { getCurrentInstallationType } from '../doctorDiagnostic.js'
  42  import { env } from '../env.js'
  43  import { envDynamic } from '../envDynamic.js'
  44  import { isEnvTruthy } from '../envUtils.js'
  45  import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js'
  46  import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
  47  import { getShellType } from '../localInstaller.js'
  48  import * as lockfile from '../lockfile.js'
  49  import { logError } from '../log.js'
  50  import { gt, gte } from '../semver.js'
  51  import {
  52    filterClaudeAliases,
  53    getShellConfigPaths,
  54    readFileLines,
  55    writeFileLines,
  56  } from '../shellConfig.js'
  57  import { sleep } from '../sleep.js'
  58  import {
  59    getUserBinDir,
  60    getXDGCacheHome,
  61    getXDGDataHome,
  62    getXDGStateHome,
  63  } from '../xdg.js'
  64  import { downloadVersion, getLatestVersion } from './download.js'
  65  import {
  66    acquireProcessLifetimeLock,
  67    cleanupStaleLocks,
  68    isLockActive,
  69    isPidBasedLockingEnabled,
  70    readLockContent,
  71    withLock,
  72  } from './pidLock.js'
  73  
  74  export 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.
  79  const LOCK_STALE_MS = 7 * 24 * 60 * 60 * 1000
  80  
  81  export type SetupMessage = {
  82    message: string
  83    userActionRequired: boolean
  84    type: 'path' | 'alias' | 'info' | 'error'
  85  }
  86  
  87  export 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  
 111  export function getBinaryName(platform: string): string {
 112    return platform.startsWith('win32') ? 'claude.exe' : 'claude'
 113  }
 114  
 115  function 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  
 134  async 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  
 153  async 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
 181  async 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  
 300  async 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  
 328  async 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  
 385  async 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  
 424  async 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   */
 441  async 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  
 490  async function versionIsAvailable(version: string): Promise<boolean> {
 491    const { installPath } = await getVersionPaths(version)
 492    return isPossibleClaudeBinary(installPath)
 493  }
 494  
 495  async 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
 623  export 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  
 639  async 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  
 800  export 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  
 942  type 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.
 954  let inFlightInstall: Promise<InstallLatestResult> | null = null
 955  
 956  export 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  
 976  async 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  
1018  async 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  
1033  function 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   */
1048  export 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  
1158  function 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   */
1171  async 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  
1184  export 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   */
1446  async 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   */
1465  export 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   */
1492  export 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  
1525  async 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  
1609  async 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  
1656  export 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  }