source dump of claude code
at main 561 lines 18 kB view raw
1import axios from 'axios' 2import { constants as fsConstants } from 'fs' 3import { access, writeFile } from 'fs/promises' 4import { homedir } from 'os' 5import { join } from 'path' 6import { getDynamicConfig_BLOCKS_ON_INIT } from 'src/services/analytics/growthbook.js' 7import { 8 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9 logEvent, 10} from 'src/services/analytics/index.js' 11import { type ReleaseChannel, saveGlobalConfig } from './config.js' 12import { logForDebugging } from './debug.js' 13import { env } from './env.js' 14import { getClaudeConfigHomeDir } from './envUtils.js' 15import { ClaudeError, getErrnoCode, isENOENT } from './errors.js' 16import { execFileNoThrowWithCwd } from './execFileNoThrow.js' 17import { getFsImplementation } from './fsOperations.js' 18import { gracefulShutdownSync } from './gracefulShutdown.js' 19import { logError } from './log.js' 20import { gte, lt } from './semver.js' 21import { getInitialSettings } from './settings/settings.js' 22import { 23 filterClaudeAliases, 24 getShellConfigPaths, 25 readFileLines, 26 writeFileLines, 27} from './shellConfig.js' 28import { jsonParse } from './slowOperations.js' 29 30const GCS_BUCKET_URL = 31 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases' 32 33class AutoUpdaterError extends ClaudeError {} 34 35export type InstallStatus = 36 | 'success' 37 | 'no_permissions' 38 | 'install_failed' 39 | 'in_progress' 40 41export type AutoUpdaterResult = { 42 version: string | null 43 status: InstallStatus 44 notifications?: string[] 45} 46 47export type MaxVersionConfig = { 48 external?: string 49 ant?: string 50 external_message?: string 51 ant_message?: string 52} 53 54/** 55 * Checks if the current version meets the minimum required version from Statsig config 56 * Terminates the process with an error message if the version is too old 57 * 58 * NOTE ON SHA-BASED VERSIONING: 59 * We use SemVer-compliant versioning with build metadata format (X.X.X+SHA) for continuous deployment. 60 * According to SemVer specs, build metadata (the +SHA part) is ignored when comparing versions. 61 * 62 * Versioning approach: 63 * 1. For version requirements/compatibility (assertMinVersion), we use semver comparison that ignores build metadata 64 * 2. For updates ('claude update'), we use exact string comparison to detect any change, including SHA 65 * - This ensures users always get the latest build, even when only the SHA changes 66 * - The UI clearly shows both versions including build metadata 67 * 68 * This approach keeps version comparison logic simple while maintaining traceability via the SHA. 69 */ 70export async function assertMinVersion(): Promise<void> { 71 if (process.env.NODE_ENV === 'test') { 72 return 73 } 74 75 try { 76 const versionConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ 77 minVersion: string 78 }>('tengu_version_config', { minVersion: '0.0.0' }) 79 80 if ( 81 versionConfig.minVersion && 82 lt(MACRO.VERSION, versionConfig.minVersion) 83 ) { 84 // biome-ignore lint/suspicious/noConsole:: intentional console output 85 console.error(` 86It looks like your version of Claude Code (${MACRO.VERSION}) needs an update. 87A newer version (${versionConfig.minVersion} or higher) is required to continue. 88 89To update, please run: 90 claude update 91 92This will ensure you have access to the latest features and improvements. 93`) 94 gracefulShutdownSync(1) 95 } 96 } catch (error) { 97 logError(error as Error) 98 } 99} 100 101/** 102 * Returns the maximum allowed version for the current user type. 103 * For ants, returns the `ant` field (dev version format). 104 * For external users, returns the `external` field (clean semver). 105 * This is used as a server-side kill switch to pause auto-updates during incidents. 106 * Returns undefined if no cap is configured. 107 */ 108export async function getMaxVersion(): Promise<string | undefined> { 109 const config = await getMaxVersionConfig() 110 if (process.env.USER_TYPE === 'ant') { 111 return config.ant || undefined 112 } 113 return config.external || undefined 114} 115 116/** 117 * Returns the server-driven message explaining the known issue, if configured. 118 * Shown in the warning banner when the current version exceeds the max allowed version. 119 */ 120export async function getMaxVersionMessage(): Promise<string | undefined> { 121 const config = await getMaxVersionConfig() 122 if (process.env.USER_TYPE === 'ant') { 123 return config.ant_message || undefined 124 } 125 return config.external_message || undefined 126} 127 128async function getMaxVersionConfig(): Promise<MaxVersionConfig> { 129 try { 130 return await getDynamicConfig_BLOCKS_ON_INIT<MaxVersionConfig>( 131 'tengu_max_version_config', 132 {}, 133 ) 134 } catch (error) { 135 logError(error as Error) 136 return {} 137 } 138} 139 140/** 141 * Checks if a target version should be skipped due to user's minimumVersion setting. 142 * This is used when switching to stable channel - the user can choose to stay on their 143 * current version until stable catches up, preventing downgrades. 144 */ 145export function shouldSkipVersion(targetVersion: string): boolean { 146 const settings = getInitialSettings() 147 const minimumVersion = settings?.minimumVersion 148 if (!minimumVersion) { 149 return false 150 } 151 // Skip if target version is less than minimum 152 const shouldSkip = !gte(targetVersion, minimumVersion) 153 if (shouldSkip) { 154 logForDebugging( 155 `Skipping update to ${targetVersion} - below minimumVersion ${minimumVersion}`, 156 ) 157 } 158 return shouldSkip 159} 160 161// Lock file for auto-updater to prevent concurrent updates 162const LOCK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minute timeout for locks 163 164/** 165 * Get the path to the lock file 166 * This is a function to ensure it's evaluated at runtime after test setup 167 */ 168export function getLockFilePath(): string { 169 return join(getClaudeConfigHomeDir(), '.update.lock') 170} 171 172/** 173 * Attempts to acquire a lock for auto-updater 174 * @returns true if lock was acquired, false if another process holds the lock 175 */ 176async function acquireLock(): Promise<boolean> { 177 const fs = getFsImplementation() 178 const lockPath = getLockFilePath() 179 180 // Check for existing lock: 1 stat() on the happy path (fresh lock or ENOENT), 181 // 2 on stale-lock recovery (re-verify staleness immediately before unlink). 182 try { 183 const stats = await fs.stat(lockPath) 184 const age = Date.now() - stats.mtimeMs 185 if (age < LOCK_TIMEOUT_MS) { 186 return false 187 } 188 // Lock is stale, remove it before taking over. Re-verify staleness 189 // immediately before unlinking to close a TOCTOU race: if two processes 190 // both observe the stale lock, A unlinks + writes a fresh lock, then B 191 // would unlink A's fresh lock and both believe they hold it. A fresh 192 // lock has a recent mtime, so re-checking staleness makes B back off. 193 try { 194 const recheck = await fs.stat(lockPath) 195 if (Date.now() - recheck.mtimeMs < LOCK_TIMEOUT_MS) { 196 return false 197 } 198 await fs.unlink(lockPath) 199 } catch (err) { 200 if (!isENOENT(err)) { 201 logError(err as Error) 202 return false 203 } 204 } 205 } catch (err) { 206 if (!isENOENT(err)) { 207 logError(err as Error) 208 return false 209 } 210 // ENOENT: no lock file, proceed to create one 211 } 212 213 // Create lock file atomically with O_EXCL (flag: 'wx'). If another process 214 // wins the race and creates it first, we get EEXIST and back off. 215 // Lazy-mkdir the config dir on ENOENT. 216 try { 217 await writeFile(lockPath, `${process.pid}`, { 218 encoding: 'utf8', 219 flag: 'wx', 220 }) 221 return true 222 } catch (err) { 223 const code = getErrnoCode(err) 224 if (code === 'EEXIST') { 225 return false 226 } 227 if (code === 'ENOENT') { 228 try { 229 // fs.mkdir from getFsImplementation() is always recursive:true and 230 // swallows EEXIST internally, so a dir-creation race cannot reach the 231 // catch below — only writeFile's EEXIST (true lock contention) can. 232 await fs.mkdir(getClaudeConfigHomeDir()) 233 await writeFile(lockPath, `${process.pid}`, { 234 encoding: 'utf8', 235 flag: 'wx', 236 }) 237 return true 238 } catch (mkdirErr) { 239 if (getErrnoCode(mkdirErr) === 'EEXIST') { 240 return false 241 } 242 logError(mkdirErr as Error) 243 return false 244 } 245 } 246 logError(err as Error) 247 return false 248 } 249} 250 251/** 252 * Releases the update lock if it's held by this process 253 */ 254async function releaseLock(): Promise<void> { 255 const fs = getFsImplementation() 256 const lockPath = getLockFilePath() 257 try { 258 const lockData = await fs.readFile(lockPath, { encoding: 'utf8' }) 259 if (lockData === `${process.pid}`) { 260 await fs.unlink(lockPath) 261 } 262 } catch (err) { 263 if (isENOENT(err)) { 264 return 265 } 266 logError(err as Error) 267 } 268} 269 270async function getInstallationPrefix(): Promise<string | null> { 271 // Run from home directory to avoid reading project-level .npmrc/.bunfig.toml 272 const isBun = env.isRunningWithBun() 273 let prefixResult = null 274 if (isBun) { 275 prefixResult = await execFileNoThrowWithCwd('bun', ['pm', 'bin', '-g'], { 276 cwd: homedir(), 277 }) 278 } else { 279 prefixResult = await execFileNoThrowWithCwd( 280 'npm', 281 ['-g', 'config', 'get', 'prefix'], 282 { cwd: homedir() }, 283 ) 284 } 285 if (prefixResult.code !== 0) { 286 logError(new Error(`Failed to check ${isBun ? 'bun' : 'npm'} permissions`)) 287 return null 288 } 289 return prefixResult.stdout.trim() 290} 291 292export async function checkGlobalInstallPermissions(): Promise<{ 293 hasPermissions: boolean 294 npmPrefix: string | null 295}> { 296 try { 297 const prefix = await getInstallationPrefix() 298 if (!prefix) { 299 return { hasPermissions: false, npmPrefix: null } 300 } 301 302 try { 303 await access(prefix, fsConstants.W_OK) 304 return { hasPermissions: true, npmPrefix: prefix } 305 } catch { 306 logError( 307 new AutoUpdaterError( 308 'Insufficient permissions for global npm install.', 309 ), 310 ) 311 return { hasPermissions: false, npmPrefix: prefix } 312 } 313 } catch (error) { 314 logError(error as Error) 315 return { hasPermissions: false, npmPrefix: null } 316 } 317} 318 319export async function getLatestVersion( 320 channel: ReleaseChannel, 321): Promise<string | null> { 322 const npmTag = channel === 'stable' ? 'stable' : 'latest' 323 324 // Run from home directory to avoid reading project-level .npmrc 325 // which could be maliciously crafted to redirect to an attacker's registry 326 const result = await execFileNoThrowWithCwd( 327 'npm', 328 ['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'], 329 { abortSignal: AbortSignal.timeout(5000), cwd: homedir() }, 330 ) 331 if (result.code !== 0) { 332 logForDebugging(`npm view failed with code ${result.code}`) 333 if (result.stderr) { 334 logForDebugging(`npm stderr: ${result.stderr.trim()}`) 335 } else { 336 logForDebugging('npm stderr: (empty)') 337 } 338 if (result.stdout) { 339 logForDebugging(`npm stdout: ${result.stdout.trim()}`) 340 } 341 return null 342 } 343 return result.stdout.trim() 344} 345 346export type NpmDistTags = { 347 latest: string | null 348 stable: string | null 349} 350 351/** 352 * Get npm dist-tags (latest and stable versions) from the registry. 353 * This is used by the doctor command to show users what versions are available. 354 */ 355export async function getNpmDistTags(): Promise<NpmDistTags> { 356 // Run from home directory to avoid reading project-level .npmrc 357 const result = await execFileNoThrowWithCwd( 358 'npm', 359 ['view', MACRO.PACKAGE_URL, 'dist-tags', '--json', '--prefer-online'], 360 { abortSignal: AbortSignal.timeout(5000), cwd: homedir() }, 361 ) 362 363 if (result.code !== 0) { 364 logForDebugging(`npm view dist-tags failed with code ${result.code}`) 365 return { latest: null, stable: null } 366 } 367 368 try { 369 const parsed = jsonParse(result.stdout.trim()) as Record<string, unknown> 370 return { 371 latest: typeof parsed.latest === 'string' ? parsed.latest : null, 372 stable: typeof parsed.stable === 'string' ? parsed.stable : null, 373 } 374 } catch (error) { 375 logForDebugging(`Failed to parse dist-tags: ${error}`) 376 return { latest: null, stable: null } 377 } 378} 379 380/** 381 * Get the latest version from GCS bucket for a given release channel. 382 * This is used by installations that don't have npm (e.g. package manager installs). 383 */ 384export async function getLatestVersionFromGcs( 385 channel: ReleaseChannel, 386): Promise<string | null> { 387 try { 388 const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, { 389 timeout: 5000, 390 responseType: 'text', 391 }) 392 return response.data.trim() 393 } catch (error) { 394 logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`) 395 return null 396 } 397} 398 399/** 400 * Get available versions from GCS bucket (for native installations). 401 * Fetches both latest and stable channel pointers. 402 */ 403export async function getGcsDistTags(): Promise<NpmDistTags> { 404 const [latest, stable] = await Promise.all([ 405 getLatestVersionFromGcs('latest'), 406 getLatestVersionFromGcs('stable'), 407 ]) 408 409 return { latest, stable } 410} 411 412/** 413 * Get version history from npm registry (ant-only feature) 414 * Returns versions sorted newest-first, limited to the specified count 415 * 416 * Uses NATIVE_PACKAGE_URL when available because: 417 * 1. Native installation is the primary installation method for ant users 418 * 2. Not all JS package versions have corresponding native packages 419 * 3. This prevents rollback from listing versions that don't have native binaries 420 */ 421export async function getVersionHistory(limit: number): Promise<string[]> { 422 if (process.env.USER_TYPE !== 'ant') { 423 return [] 424 } 425 426 // Use native package URL when available to ensure we only show versions 427 // that have native binaries (not all JS package versions have native builds) 428 const packageUrl = MACRO.NATIVE_PACKAGE_URL ?? MACRO.PACKAGE_URL 429 430 // Run from home directory to avoid reading project-level .npmrc 431 const result = await execFileNoThrowWithCwd( 432 'npm', 433 ['view', packageUrl, 'versions', '--json', '--prefer-online'], 434 // Longer timeout for version list 435 { abortSignal: AbortSignal.timeout(30000), cwd: homedir() }, 436 ) 437 438 if (result.code !== 0) { 439 logForDebugging(`npm view versions failed with code ${result.code}`) 440 if (result.stderr) { 441 logForDebugging(`npm stderr: ${result.stderr.trim()}`) 442 } 443 return [] 444 } 445 446 try { 447 const versions = jsonParse(result.stdout.trim()) as string[] 448 // Take last N versions, then reverse to get newest first 449 return versions.slice(-limit).reverse() 450 } catch (error) { 451 logForDebugging(`Failed to parse version history: ${error}`) 452 return [] 453 } 454} 455 456export async function installGlobalPackage( 457 specificVersion?: string | null, 458): Promise<InstallStatus> { 459 if (!(await acquireLock())) { 460 logError( 461 new AutoUpdaterError('Another process is currently installing an update'), 462 ) 463 // Log the lock contention 464 logEvent('tengu_auto_updater_lock_contention', { 465 pid: process.pid, 466 currentVersion: 467 MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 468 }) 469 return 'in_progress' 470 } 471 472 try { 473 await removeClaudeAliasesFromShellConfigs() 474 // Check if we're using npm from Windows path in WSL 475 if (!env.isRunningWithBun() && env.isNpmFromWindowsPath()) { 476 logError(new Error('Windows NPM detected in WSL environment')) 477 logEvent('tengu_auto_updater_windows_npm_in_wsl', { 478 currentVersion: 479 MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 480 }) 481 // biome-ignore lint/suspicious/noConsole:: intentional console output 482 console.error(` 483Error: Windows NPM detected in WSL 484 485You're running Claude Code in WSL but using the Windows NPM installation from /mnt/c/. 486This configuration is not supported for updates. 487 488To fix this issue: 489 1. Install Node.js within your Linux distribution: e.g. sudo apt install nodejs npm 490 2. Make sure Linux NPM is in your PATH before the Windows version 491 3. Try updating again with 'claude update' 492`) 493 return 'install_failed' 494 } 495 496 const { hasPermissions } = await checkGlobalInstallPermissions() 497 if (!hasPermissions) { 498 return 'no_permissions' 499 } 500 501 // Use specific version if provided, otherwise use latest 502 const packageSpec = specificVersion 503 ? `${MACRO.PACKAGE_URL}@${specificVersion}` 504 : MACRO.PACKAGE_URL 505 506 // Run from home directory to avoid reading project-level .npmrc/.bunfig.toml 507 // which could be maliciously crafted to redirect to an attacker's registry 508 const packageManager = env.isRunningWithBun() ? 'bun' : 'npm' 509 const installResult = await execFileNoThrowWithCwd( 510 packageManager, 511 ['install', '-g', packageSpec], 512 { cwd: homedir() }, 513 ) 514 if (installResult.code !== 0) { 515 const error = new AutoUpdaterError( 516 `Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`, 517 ) 518 logError(error) 519 return 'install_failed' 520 } 521 522 // Set installMethod to 'global' to track npm global installations 523 saveGlobalConfig(current => ({ 524 ...current, 525 installMethod: 'global', 526 })) 527 528 return 'success' 529 } finally { 530 // Ensure we always release the lock 531 await releaseLock() 532 } 533} 534 535/** 536 * Remove claude aliases from shell configuration files 537 * This helps clean up old installation methods when switching to native or npm global 538 */ 539async function removeClaudeAliasesFromShellConfigs(): Promise<void> { 540 const configMap = getShellConfigPaths() 541 542 // Process each shell config file 543 for (const [, configFile] of Object.entries(configMap)) { 544 try { 545 const lines = await readFileLines(configFile) 546 if (!lines) continue 547 548 const { filtered, hadAlias } = filterClaudeAliases(lines) 549 550 if (hadAlias) { 551 await writeFileLines(configFile, filtered) 552 logForDebugging(`Removed claude alias from ${configFile}`) 553 } 554 } catch (error) { 555 // Don't fail the whole operation if one file can't be processed 556 logForDebugging(`Failed to remove alias from ${configFile}: ${error}`, { 557 level: 'error', 558 }) 559 } 560 } 561}