import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { useInterval } from 'usehooks-ts' import { useUpdateNotification } from '../hooks/useUpdateNotification.js' import { Box, Text } from '@anthropic/ink' import { type AutoUpdaterResult, getLatestVersion, getMaxVersion, type InstallStatus, installGlobalPackage, shouldSkipVersion, } from '../utils/autoUpdater.js' import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js' import { logForDebugging } from '../utils/debug.js' import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' import { installOrUpdateClaudePackage, localInstallationExists, } from '../utils/localInstaller.js' import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js' import { gt, gte } from '../utils/semver.js' import { getInitialSettings } from '../utils/settings/settings.js' type Props = { isUpdating: boolean onChangeIsUpdating: (isUpdating: boolean) => void onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void autoUpdaterResult: AutoUpdaterResult | null showSuccessMessage: boolean verbose: boolean } export function AutoUpdater({ isUpdating, onChangeIsUpdating, onAutoUpdaterResult, autoUpdaterResult, showSuccessMessage, verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ global?: string | null latest?: string | null }>({}) const [hasLocalInstall, setHasLocalInstall] = useState(false) const updateSemver = useUpdateNotification(autoUpdaterResult?.version) useEffect(() => { void localInstallationExists().then(setHasLocalInstall) }, []) // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value. Without this, the 30-minute // interval fires with a stale closure where isUpdating is false, allowing // a concurrent installGlobalPackage() to run while one is already in // progress. const isUpdatingRef = useRef(isUpdating) isUpdatingRef.current = isUpdating const checkForUpdates = React.useCallback(async () => { if (isUpdatingRef.current) { return } if ( process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development' ) { logForDebugging( 'AutoUpdater: Skipping update check in test/dev environment', ) return } const currentVersion = MACRO.VERSION const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' let latestVersion = await getLatestVersion(channel) const isDisabled = isAutoUpdaterDisabled() // Check if max version is set (server-side kill switch for auto-updates) const maxVersion = await getMaxVersion() if (maxVersion && latestVersion && gt(latestVersion, maxVersion)) { logForDebugging( `AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`, ) if (gte(currentVersion, maxVersion)) { logForDebugging( `AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`, ) setVersions({ global: currentVersion, latest: latestVersion }) return } latestVersion = maxVersion } setVersions({ global: currentVersion, latest: latestVersion }) // Check if update needed and perform update if ( !isDisabled && currentVersion && latestVersion && !gte(currentVersion, latestVersion) && !shouldSkipVersion(latestVersion) ) { const startTime = Date.now() onChangeIsUpdating(true) // Remove native installer symlink since we're using JS-based updates // But only if user hasn't migrated to native installation const config = getGlobalConfig() if (config.installMethod !== 'native') { await removeInstalledSymlink() } // Detect actual running installation type const installationType = await getCurrentInstallationType() logForDebugging( `AutoUpdater: Detected installation type: ${installationType}`, ) // Skip update for development builds if (installationType === 'development') { logForDebugging('AutoUpdater: Cannot auto-update development build') onChangeIsUpdating(false) return } // Choose the appropriate update method based on what's actually running let installStatus: InstallStatus let updateMethod: 'local' | 'global' if (installationType === 'npm-local') { // Use local update for local installations logForDebugging('AutoUpdater: Using local update method') updateMethod = 'local' installStatus = await installOrUpdateClaudePackage(channel) } else if (installationType === 'npm-global') { // Use global update for global installations logForDebugging('AutoUpdater: Using global update method') updateMethod = 'global' installStatus = await installGlobalPackage() } else if (installationType === 'native') { // This shouldn't happen - native should use NativeAutoUpdater logForDebugging( 'AutoUpdater: Unexpected native installation in non-native updater', ) onChangeIsUpdating(false) return } else { // Fallback to config-based detection for unknown types logForDebugging( `AutoUpdater: Unknown installation type, falling back to config`, ) const isMigrated = config.installMethod === 'local' updateMethod = isMigrated ? 'local' : 'global' if (isMigrated) { installStatus = await installOrUpdateClaudePackage(channel) } else { installStatus = await installGlobalPackage() } } onChangeIsUpdating(false) if (installStatus === 'success') { logEvent('tengu_auto_updater_success', { fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } else { logEvent('tengu_auto_updater_fail', { fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, attemptedVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, status: installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } onAutoUpdaterResult({ version: latestVersion, status: installStatus, }) } // isUpdating intentionally omitted from deps; we read isUpdatingRef // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps }, [onAutoUpdaterResult]) // Initial check useEffect(() => { void checkForUpdates() }, [checkForUpdates]) // Check every 30 minutes useInterval(checkForUpdates, 30 * 60 * 1000) if (!autoUpdaterResult?.version && (!versions.global || !versions.latest)) { return null } if (!autoUpdaterResult?.version && !isUpdating) { return null } return ( {verbose && ( globalVersion: {versions.global} · latestVersion:{' '} {versions.latest} )} {isUpdating ? ( <> Auto-updating… ) : ( autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && ( ✓ Update installed · Restart to apply ) )} {(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && ( ✗ Auto-update failed · Try claude doctor or{' '} {hasLocalInstall ? `cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}` : `npm i -g ${MACRO.PACKAGE_URL}`} )} ) }