import axios from 'axios' import { constants as fsConstants } from 'fs' import { access, writeFile } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' import { getDynamicConfig_BLOCKS_ON_INIT } from 'src/services/analytics/growthbook.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { createCombinedAbortSignal } from './combinedAbortSignal.js' import { getAPIProvider } from './model/providers.js' import { logForDebugging } from './debug.js' import { env } from './env.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { ClaudeError, getErrnoCode, isENOENT } from './errors.js' import { execFileNoThrowWithCwd } from './execFileNoThrow.js' import { detectGlobalPackageManager, getGlobalInstallArgs, } from './globalPackageManager.js' import { getFsImplementation } from './fsOperations.js' import { gracefulShutdownSync } from './gracefulShutdown.js' import { logError } from './log.js' import { gte, lt } from './semver.js' import { getInitialSettings } from './settings/settings.js' import { filterClaudeAliases, getShellConfigPaths, readFileLines, writeFileLines, } from './shellConfig.js' import { jsonParse } from './slowOperations.js' const GCS_BUCKET_URL = 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases' class AutoUpdaterError extends ClaudeError {} async function withTimeoutSignal( timeoutMs: number, run: (signal: AbortSignal) => Promise, ): Promise { const { signal, cleanup } = createCombinedAbortSignal(undefined, { timeoutMs, }) try { return await run(signal) } finally { cleanup() } } export type InstallStatus = | 'success' | 'no_permissions' | 'install_failed' | 'in_progress' export type AutoUpdaterResult = { version: string | null status: InstallStatus notifications?: string[] } export type MaxVersionConfig = { external?: string ant?: string external_message?: string ant_message?: string } /** * Checks if the current version meets the minimum required version from Statsig config * Terminates the process with an error message if the version is too old * * NOTE ON SHA-BASED VERSIONING: * We use SemVer-compliant versioning with build metadata format (X.X.X+SHA) for continuous deployment. * According to SemVer specs, build metadata (the +SHA part) is ignored when comparing versions. * * Versioning approach: * 1. For version requirements/compatibility (assertMinVersion), we use semver comparison that ignores build metadata * 2. For updates ('claude update'), we use exact string comparison to detect any change, including SHA * - This ensures users always get the latest build, even when only the SHA changes * - The UI clearly shows both versions including build metadata * * This approach keeps version comparison logic simple while maintaining traceability via the SHA. */ export async function assertMinVersion(): Promise { if (process.env.NODE_ENV === 'test') { return } // Skip version check for third-party providers using upstream Anthropic // builds — the min version kill-switch is first-party-specific. Builds // with a custom PACKAGE_URL (like OpenClaude) should still be checked. if ( getAPIProvider() !== 'firstParty' && MACRO.PACKAGE_URL === '@anthropic-ai/claude-code' ) { return } try { const versionConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ minVersion: string }>('tengu_version_config', { minVersion: '0.0.0' }) if ( versionConfig.minVersion && lt(MACRO.VERSION, versionConfig.minVersion) ) { // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(` It looks like your version of Claude Code (${MACRO.VERSION}) needs an update. A newer version (${versionConfig.minVersion} or higher) is required to continue. To update, please run: claude update This will ensure you have access to the latest features and improvements. `) gracefulShutdownSync(1) } } catch (error) { logError(error as Error) } } /** * Returns the maximum allowed version for the current user type. * For ants, returns the `ant` field (dev version format). * For external users, returns the `external` field (clean semver). * This is used as a server-side kill switch to pause auto-updates during incidents. * Returns undefined if no cap is configured. */ export async function getMaxVersion(): Promise { const config = await getMaxVersionConfig() if (process.env.USER_TYPE === 'ant') { return config.ant || undefined } return config.external || undefined } /** * Returns the server-driven message explaining the known issue, if configured. * Shown in the warning banner when the current version exceeds the max allowed version. */ export async function getMaxVersionMessage(): Promise { const config = await getMaxVersionConfig() if (process.env.USER_TYPE === 'ant') { return config.ant_message || undefined } return config.external_message || undefined } async function getMaxVersionConfig(): Promise { try { return await getDynamicConfig_BLOCKS_ON_INIT( 'tengu_max_version_config', {}, ) } catch (error) { logError(error as Error) return {} } } /** * Checks if a target version should be skipped due to user's minimumVersion setting. * This is used when switching to stable channel - the user can choose to stay on their * current version until stable catches up, preventing downgrades. */ export function shouldSkipVersion(targetVersion: string): boolean { const settings = getInitialSettings() const minimumVersion = settings?.minimumVersion if (!minimumVersion) { return false } // Skip if target version is less than minimum const shouldSkip = !gte(targetVersion, minimumVersion) if (shouldSkip) { logForDebugging( `Skipping update to ${targetVersion} - below minimumVersion ${minimumVersion}`, ) } return shouldSkip } // Lock file for auto-updater to prevent concurrent updates const LOCK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minute timeout for locks /** * Get the path to the lock file * This is a function to ensure it's evaluated at runtime after test setup */ export function getLockFilePath(): string { return join(getClaudeConfigHomeDir(), '.update.lock') } /** * Attempts to acquire a lock for auto-updater * @returns true if lock was acquired, false if another process holds the lock */ async function acquireLock(): Promise { const fs = getFsImplementation() const lockPath = getLockFilePath() const tempLockPath = `${lockPath}.${process.pid}.${Date.now()}.tmp` // Check for existing lock try { const stats = await fs.stat(lockPath) const age = Date.now() - stats.mtimeMs if (age < LOCK_TIMEOUT_MS) { // Lock is fresh - check if the owning process is still alive const lockData = await fs.readFile(lockPath, { encoding: 'utf8' }).catch(() => null) const lockPid = lockData ? parseInt(lockData.trim(), 10) : NaN if (!isNaN(lockPid) && isProcessAlive(lockPid)) { return false } // Process is dead, treat as stale and continue to recovery } // Lock is stale or owner dead - attempt recovery try { const recheck = await fs.stat(lockPath) if (Date.now() - recheck.mtimeMs < LOCK_TIMEOUT_MS) { // Another process may have just acquired it const lockData = await fs.readFile(lockPath, { encoding: 'utf8' }).catch(() => null) const lockPid = lockData ? parseInt(lockData.trim(), 10) : NaN if (!isNaN(lockPid) && isProcessAlive(lockPid)) { return false } } await fs.unlink(lockPath) } catch (err) { if (!isENOENT(err)) { logError(err as Error) return false } } } catch (err) { if (!isENOENT(err)) { logError(err as Error) return false } // ENOENT: no lock file, proceed to create one } // Create lock file atomically: write to unique temp file, then rename // This avoids TOCTOU between stat/unlink and create try { await writeFile(tempLockPath, `${process.pid}`, { encoding: 'utf8', flag: 'wx', }) await fs.rename(tempLockPath, lockPath) return true } catch (err) { // Clean up temp file on error await fs.unlink(tempLockPath).catch(() => {}) const code = getErrnoCode(err) if (code === 'EEXIST') { return false } if (code === 'ENOENT') { try { await fs.mkdir(getClaudeConfigHomeDir()) await writeFile(tempLockPath, `${process.pid}`, { encoding: 'utf8', flag: 'wx', }) await fs.rename(tempLockPath, lockPath) return true } catch (mkdirErr) { await fs.unlink(tempLockPath).catch(() => {}) if (getErrnoCode(mkdirErr) === 'EEXIST') { return false } logError(mkdirErr as Error) return false } } logError(err as Error) return false } } /** * Check if a process is alive by sending signal 0 */ function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0) return true } catch (err) { const code = getErrnoCode(err) // ESRCH = no such process, EPERM = process exists but we can't signal it return code !== 'ESRCH' } } /** * Releases the update lock if it's held by this process */ async function releaseLock(): Promise { const fs = getFsImplementation() const lockPath = getLockFilePath() try { const lockData = await fs.readFile(lockPath, { encoding: 'utf8' }) if (lockData === `${process.pid}`) { await fs.unlink(lockPath) } } catch (err) { if (isENOENT(err)) { return } logError(err as Error) } } async function getInstallationPrefix(): Promise { // Run from home directory to avoid reading project-level .npmrc/.bunfig.toml const isBun = env.isRunningWithBun() let prefixResult: Awaited> | null = null if (isBun) { prefixResult = await execFileNoThrowWithCwd('bun', ['pm', 'bin', '-g'], { cwd: homedir(), }) } else { prefixResult = await execFileNoThrowWithCwd( 'npm', ['-g', 'config', 'get', 'prefix'], { cwd: homedir() }, ) } if (prefixResult.code !== 0) { logError(new Error(`Failed to check ${isBun ? 'bun' : 'npm'} permissions`)) return null } return prefixResult.stdout.trim() } export async function checkGlobalInstallPermissions(): Promise<{ hasPermissions: boolean npmPrefix: string | null }> { try { const prefix = await getInstallationPrefix() if (!prefix) { return { hasPermissions: false, npmPrefix: null } } try { await access(prefix, fsConstants.W_OK) return { hasPermissions: true, npmPrefix: prefix } } catch { logError( new AutoUpdaterError( 'Insufficient permissions for global npm install.', ), ) return { hasPermissions: false, npmPrefix: prefix } } } catch (error) { logError(error as Error) return { hasPermissions: false, npmPrefix: null } } } export async function getLatestVersion( channel: ReleaseChannel, ): Promise { const npmTag = channel === 'stable' ? 'stable' : 'latest' // Run from home directory to avoid reading project-level .npmrc // which could be maliciously crafted to redirect to an attacker's registry const result = await withTimeoutSignal(5000, abortSignal => execFileNoThrowWithCwd( 'npm', ['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'], { abortSignal, cwd: homedir() }, ), ) if (result.code !== 0) { logForDebugging(`npm view failed with code ${result.code}`) if (result.stderr) { logForDebugging(`npm stderr: ${result.stderr.trim()}`) } else { logForDebugging('npm stderr: (empty)') } if (result.stdout) { logForDebugging(`npm stdout: ${result.stdout.trim()}`) } // npm may be unavailable (bun/pnpm/yarn-only installs) or transiently // failing — fall back to a direct registry request so update checks still // work without npm on the PATH. return getLatestVersionFromRegistryHttp(npmTag) } return result.stdout.trim() } /** * Look up a dist-tag's version directly from the public npm registry over HTTP. * Used as a fallback when `npm view` is unavailable or fails. */ async function getLatestVersionFromRegistryHttp( tag: string, ): Promise { try { const response = await axios.get( `https://registry.npmjs.org/${MACRO.PACKAGE_URL}`, { timeout: 10_000 }, ) const distTags = ( response.data as { 'dist-tags'?: Record } )?.['dist-tags'] const version = distTags?.[tag] return typeof version === 'string' ? version : null } catch (error) { logForDebugging(`Registry HTTP lookup for ${MACRO.PACKAGE_URL} failed: ${error}`) return null } } export type NpmDistTags = { latest: string | null stable: string | null } /** * Get npm dist-tags (latest and stable versions) from the registry. * This is used by the doctor command to show users what versions are available. */ export async function getNpmDistTags(): Promise { // Run from home directory to avoid reading project-level .npmrc const result = await withTimeoutSignal(5000, abortSignal => execFileNoThrowWithCwd( 'npm', ['view', MACRO.PACKAGE_URL, 'dist-tags', '--json', '--prefer-online'], { abortSignal, cwd: homedir() }, ), ) if (result.code !== 0) { logForDebugging(`npm view dist-tags failed with code ${result.code}`) return { latest: null, stable: null } } try { const parsed = jsonParse(result.stdout.trim()) as Record return { latest: typeof parsed.latest === 'string' ? parsed.latest : null, stable: typeof parsed.stable === 'string' ? parsed.stable : null, } } catch (error) { logForDebugging(`Failed to parse dist-tags: ${error}`) return { latest: null, stable: null } } } /** * Get the latest version from GCS bucket for a given release channel. * This is used by installations that don't have npm (e.g. package manager installs). */ export async function getLatestVersionFromGcs( channel: ReleaseChannel, ): Promise { try { const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, { timeout: 5000, responseType: 'text', }) return response.data.trim() } catch (error) { logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`) return null } } /** * Get available versions from GCS bucket (for native installations). * Fetches both latest and stable channel pointers. */ export async function getGcsDistTags(): Promise { const [latest, stable] = await Promise.all([ getLatestVersionFromGcs('latest'), getLatestVersionFromGcs('stable'), ]) return { latest, stable } } /** * Get version history from npm registry (internal-only feature) * Returns versions sorted newest-first, limited to the specified count * * Uses NATIVE_PACKAGE_URL when available because: * 1. Native installation is the primary installation method for ant users * 2. Not all JS package versions have corresponding native packages * 3. This prevents rollback from listing versions that don't have native binaries */ export async function getVersionHistory(limit: number): Promise { if (process.env.USER_TYPE !== 'ant') { return [] } // Use native package URL when available to ensure we only show versions // that have native binaries (not all JS package versions have native builds) const packageUrl = MACRO.NATIVE_PACKAGE_URL ?? MACRO.PACKAGE_URL // Run from home directory to avoid reading project-level .npmrc const result = await withTimeoutSignal(30000, abortSignal => execFileNoThrowWithCwd( 'npm', ['view', packageUrl, 'versions', '--json', '--prefer-online'], // Longer timeout for version list { abortSignal, cwd: homedir() }, ), ) if (result.code !== 0) { logForDebugging(`npm view versions failed with code ${result.code}`) if (result.stderr) { logForDebugging(`npm stderr: ${result.stderr.trim()}`) } return [] } try { const versions = jsonParse(result.stdout.trim()) as string[] // Take last N versions, then reverse to get newest first return versions.slice(-limit).reverse() } catch (error) { logForDebugging(`Failed to parse version history: ${error}`) return [] } } export async function installGlobalPackage( specificVersion?: string | null, ): Promise { if (!(await acquireLock())) { logError( new AutoUpdaterError('Another process is currently installing an update'), ) // Log the lock contention logEvent('tengu_auto_updater_lock_contention', { pid: process.pid, currentVersion: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return 'in_progress' } try { await removeClaudeAliasesFromShellConfigs() // Resolve the package manager that owns this install (npm/yarn/pnpm/bun), // falling back to npm/bun by runtime when detection is inconclusive. This is // the single source of truth for how we drive a global install. const packageManager = (await detectGlobalPackageManager()) ?? (env.isRunningWithBun() ? 'bun' : 'npm') // Check if we're using npm from Windows path in WSL if (packageManager === 'npm' && env.isNpmFromWindowsPath()) { logError(new Error('Windows NPM detected in WSL environment')) logEvent('tengu_auto_updater_windows_npm_in_wsl', { currentVersion: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(` Error: Windows NPM detected in WSL You're running Claude Code in WSL but using the Windows NPM installation from /mnt/c/. This configuration is not supported for updates. To fix this issue: 1. Install Node.js within your Linux distribution: e.g. sudo apt install nodejs npm 2. Make sure Linux NPM is in your PATH before the Windows version 3. Try updating again with 'claude update' `) return 'install_failed' } // The permission probe inspects the npm/bun global prefix; only meaningful // for those managers. pnpm/yarn manage their own global store, so we let the // install command itself surface any permission error there. if (packageManager === 'npm' || packageManager === 'bun') { const { hasPermissions } = await checkGlobalInstallPermissions() if (!hasPermissions) { return 'no_permissions' } } // Use specific version if provided, otherwise use latest const packageSpec = specificVersion ? `${MACRO.PACKAGE_URL}@${specificVersion}` : `${MACRO.PACKAGE_URL}@latest` // Run from home directory to avoid reading project-level .npmrc/.bunfig.toml // which could be maliciously crafted to redirect to an attacker's registry const installResult = await execFileNoThrowWithCwd( packageManager, getGlobalInstallArgs(packageManager, packageSpec), { cwd: homedir() }, ) if (installResult.code !== 0) { const error = new AutoUpdaterError( `Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`, ) logError(error) return 'install_failed' } // Set installMethod to 'global' to track npm global installations saveGlobalConfig(current => ({ ...current, installMethod: 'global', })) return 'success' } finally { // Ensure we always release the lock await releaseLock() } } /** * Remove claude aliases from shell configuration files * This helps clean up old installation methods when switching to native or npm global */ async function removeClaudeAliasesFromShellConfigs(): Promise { const configMap = getShellConfigPaths() // Process each shell config file for (const [, 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) logForDebugging(`Removed claude alias from ${configFile}`) } } catch (error) { // Don't fail the whole operation if one file can't be processed logForDebugging(`Failed to remove alias from ${configFile}: ${error}`, { level: 'error', }) } } }