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