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