562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
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<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 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<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',
|
|
})
|
|
}
|
|
}
|
|
}
|