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 { 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 { 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 {} 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 } 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() // Check for existing lock: 1 stat() on the happy path (fresh lock or ENOENT), // 2 on stale-lock recovery (re-verify staleness immediately before unlink). try { const stats = await fs.stat(lockPath) const age = Date.now() - stats.mtimeMs if (age < LOCK_TIMEOUT_MS) { return false } // Lock is stale, remove it before taking over. Re-verify staleness // immediately before unlinking to close a TOCTOU race: if two processes // both observe the stale lock, A unlinks + writes a fresh lock, then B // would unlink A's fresh lock and both believe they hold it. A fresh // lock has a recent mtime, so re-checking staleness makes B back off. try { const recheck = await fs.stat(lockPath) if (Date.now() - recheck.mtimeMs < LOCK_TIMEOUT_MS) { 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 with O_EXCL (flag: 'wx'). If another process // wins the race and creates it first, we get EEXIST and back off. // Lazy-mkdir the config dir on ENOENT. try { await writeFile(lockPath, `${process.pid}`, { encoding: 'utf8', flag: 'wx', }) return true } catch (err) { const code = getErrnoCode(err) if (code === 'EEXIST') { return false } if (code === 'ENOENT') { try { // fs.mkdir from getFsImplementation() is always recursive:true and // swallows EEXIST internally, so a dir-creation race cannot reach the // catch below — only writeFile's EEXIST (true lock contention) can. await fs.mkdir(getClaudeConfigHomeDir()) await writeFile(lockPath, `${process.pid}`, { encoding: 'utf8', flag: 'wx', }) return true } catch (mkdirErr) { if (getErrnoCode(mkdirErr) === 'EEXIST') { return false } logError(mkdirErr as Error) return false } } logError(err as Error) return false } } /** * 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 = 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 execFileNoThrowWithCwd( 'npm', ['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'], { abortSignal: AbortSignal.timeout(5000), 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()}`) } return null } return result.stdout.trim() } 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 execFileNoThrowWithCwd( 'npm', ['view', MACRO.PACKAGE_URL, 'dist-tags', '--json', '--prefer-online'], { abortSignal: AbortSignal.timeout(5000), 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 (ant-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 execFileNoThrowWithCwd( 'npm', ['view', packageUrl, 'versions', '--json', '--prefer-online'], // Longer timeout for version list { abortSignal: AbortSignal.timeout(30000), 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() // Check if we're using npm from Windows path in WSL if (!env.isRunningWithBun() && 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' } 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 // 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 packageManager = env.isRunningWithBun() ? 'bun' : 'npm' const installResult = await execFileNoThrowWithCwd( packageManager, ['install', '-g', 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', }) } } }