Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
18 kB · 561 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562import 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<void> { 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<string | undefined> { 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<string | undefined> { const config = await getMaxVersionConfig() if (process.env.USER_TYPE === 'ant') { return config.ant_message || undefined } return config.external_message || undefined}
async function getMaxVersionConfig(): Promise<MaxVersionConfig> { try { return await getDynamicConfig_BLOCKS_ON_INIT<MaxVersionConfig>( '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 updatesconst 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<boolean> { 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<void> { 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<string | null> { // 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<string | null> { 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<NpmDistTags> { // 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<string, unknown> 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<string | null> { 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<NpmDistTags> { 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<string[]> { 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<InstallStatus> { 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<void> { 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', }) } }}