/ utils / autoUpdater.ts
autoUpdater.ts
  1  import axios from 'axios'
  2  import { constants as fsConstants } from 'fs'
  3  import { access, writeFile } from 'fs/promises'
  4  import { homedir } from 'os'
  5  import { join } from 'path'
  6  import { getDynamicConfig_BLOCKS_ON_INIT } from 'src/services/analytics/growthbook.js'
  7  import {
  8    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  9    logEvent,
 10  } from 'src/services/analytics/index.js'
 11  import { type ReleaseChannel, saveGlobalConfig } from './config.js'
 12  import { logForDebugging } from './debug.js'
 13  import { env } from './env.js'
 14  import { getClaudeConfigHomeDir } from './envUtils.js'
 15  import { ClaudeError, getErrnoCode, isENOENT } from './errors.js'
 16  import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
 17  import { getFsImplementation } from './fsOperations.js'
 18  import { gracefulShutdownSync } from './gracefulShutdown.js'
 19  import { logError } from './log.js'
 20  import { gte, lt } from './semver.js'
 21  import { getInitialSettings } from './settings/settings.js'
 22  import {
 23    filterClaudeAliases,
 24    getShellConfigPaths,
 25    readFileLines,
 26    writeFileLines,
 27  } from './shellConfig.js'
 28  import { jsonParse } from './slowOperations.js'
 29  
 30  const GCS_BUCKET_URL =
 31    'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'
 32  
 33  class AutoUpdaterError extends ClaudeError {}
 34  
 35  export type InstallStatus =
 36    | 'success'
 37    | 'no_permissions'
 38    | 'install_failed'
 39    | 'in_progress'
 40  
 41  export type AutoUpdaterResult = {
 42    version: string | null
 43    status: InstallStatus
 44    notifications?: string[]
 45  }
 46  
 47  export 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   */
 70  export 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(`
 86  It looks like your version of Claude Code (${MACRO.VERSION}) needs an update.
 87  A newer version (${versionConfig.minVersion} or higher) is required to continue.
 88  
 89  To update, please run:
 90      claude update
 91  
 92  This 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   */
108  export 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   */
120  export 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  
128  async 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   */
145  export 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
162  const 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   */
168  export 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   */
176  async 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   */
254  async 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  
270  async 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  
292  export 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  
319  export 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  
346  export 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   */
355  export 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   */
384  export 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   */
403  export 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   */
421  export 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  
456  export 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(`
483  Error: Windows NPM detected in WSL
484  
485  You're running Claude Code in WSL but using the Windows NPM installation from /mnt/c/.
486  This configuration is not supported for updates.
487  
488  To 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   */
539  async 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  }