Skip to content
Closed
98 changes: 30 additions & 68 deletions src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,9 @@ export async function update() {
writeToStdout('\n')
for (const warning of diagnostic.warnings) {
logForDebugging(`update: Warning detected: ${warning.issue}`)

// Don't skip PATH warnings - they're always relevant
// The user needs to know that 'which claude' points elsewhere
logForDebugging(`update: Showing warning: ${warning.issue}`)

writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))

writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
}
}
Expand All @@ -97,7 +93,6 @@ export async function update() {
writeToStdout('Updating configuration to track installation method...\n')
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'

// Map diagnostic installation type to config install method
switch (diagnostic.installationType) {
case 'npm-local':
detectedMethod = 'local'
Expand All @@ -119,67 +114,58 @@ export async function update() {
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
}

// Check if running from development build
if (diagnostic.installationType === 'development') {
writeToStdout('\n')
writeToStdout(
chalk.yellow('Warning: Cannot update development build') + '\n',
)
writeToStdout('Pull the latest source and rebuild instead:\n')
writeToStdout(chalk.bold(' git pull && bun install && bun run build') + '\n')
await gracefulShutdown(1)
}

// Check if running from a package manager
if (diagnostic.installationType === 'package-manager') {
const packageManager = await getPackageManager()
writeToStdout('\n')

const latest = await getLatestVersion(channel)
const hasUpdate = latest && !gte(MACRO.VERSION, latest)

if (packageManager === 'homebrew') {
writeToStdout('Claude is managed by Homebrew.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
writeToStdout('OpenClaude is managed by Homebrew.\n')
if (hasUpdate) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n\n`)
writeToStdout('To update, run the Homebrew command for your OpenClaude formula/cask.\n')
} else {
writeToStdout('Claude is up to date!\n')
writeToStdout('OpenClaude is up to date!\n')
}
} else if (packageManager === 'winget') {
writeToStdout('Claude is managed by winget.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
)
writeToStdout('OpenClaude is managed by winget.\n')
if (hasUpdate) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n\n`)
writeToStdout('To update, run the winget command for your OpenClaude package.\n')
} else {
writeToStdout('Claude is up to date!\n')
writeToStdout('OpenClaude is up to date!\n')
}
} else if (packageManager === 'apk') {
writeToStdout('Claude is managed by apk.\n')
const latest = await getLatestVersion(channel)
if (latest && !gte(MACRO.VERSION, latest)) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
writeToStdout('\n')
writeToStdout('To update, run:\n')
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
writeToStdout('OpenClaude is managed by apk.\n')
if (hasUpdate) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n\n`)
writeToStdout('To update, run the apk command for your OpenClaude package.\n')
} else {
writeToStdout('Claude is up to date!\n')
writeToStdout('OpenClaude is up to date!\n')
}
} else {
// pacman, deb, and rpm don't get specific commands because they each have
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
// rpm: dnf/yum/zypper)
writeToStdout('Claude is managed by a package manager.\n')
writeToStdout('OpenClaude is managed by a package manager.\n')
if (hasUpdate) {
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
}
writeToStdout('Please use your package manager to update.\n')
}

await gracefulShutdown(0)
}

// Check for config/reality mismatch (skip for package-manager installs)
if (
config.installMethod &&
diagnostic.configInstallMethod !== 'not set' &&
Expand All @@ -188,7 +174,6 @@ export async function update() {
const runningType = diagnostic.installationType
const configExpects = diagnostic.configInstallMethod

// Map installation types for comparison
const typeMapping: Record<string, string> = {
'npm-local': 'local',
'npm-global': 'global',
Expand All @@ -213,7 +198,6 @@ export async function update() {
) + '\n',
)

// Update config to match reality
saveGlobalConfig(current => ({
...current,
installMethod: normalizedRunningType as InstallMethod,
Expand All @@ -224,22 +208,20 @@ export async function update() {
}
}

// Handle native installation updates first
if (diagnostic.installationType === 'native') {
logForDebugging(
'update: Detected native installation, using native updater',
)
try {
const result = await installLatestNative(channel, true)

// Handle lock contention gracefully
if (result.lockFailed) {
const pidInfo = result.lockHolderPid
? ` (PID ${result.lockHolderPid})`
: ''
writeToStdout(
chalk.yellow(
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
`Another OpenClaude process${pidInfo} is currently running. Please try again in a moment.`,
) + '\n',
)
await gracefulShutdown(0)
Expand All @@ -252,7 +234,7 @@ export async function update() {

if (result.latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
chalk.green(`OpenClaude is up to date (${MACRO.VERSION})`) + '\n',
)
} else {
writeToStdout(
Expand All @@ -266,14 +248,11 @@ export async function update() {
} catch (error) {
process.stderr.write('Error: Failed to install native update\n')
process.stderr.write(String(error) + '\n')
process.stderr.write('Try running "claude doctor" for diagnostics\n')
process.stderr.write('Try running "openclaude doctor" for diagnostics\n')
await gracefulShutdown(1)
}
}

// Fallback to existing JS/npm-based update logic
// Remove native installer symlink since we're not using native installation
// But only if user hasn't migrated to native installation
if (config.installMethod !== 'native') {
await removeInstalledSymlink()
}
Expand All @@ -289,40 +268,25 @@ export async function update() {
)

if (!latestVersion) {
logForDebugging('update: Failed to get latest version from npm registry')
logForDebugging('update: Failed to check for updates from npm registry')
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
process.stderr.write('Unable to fetch latest version from npm registry\n')
process.stderr.write('\n')
process.stderr.write('Possible causes:\n')
process.stderr.write(' • Network connectivity issues\n')
process.stderr.write(' • npm registry is unreachable\n')
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
process.stderr.write(
' • Internal/development build not published to npm\n',
)
}
process.stderr.write('\n')
process.stderr.write('Try:\n')
process.stderr.write(' • Check your internet connection\n')
process.stderr.write(' • Run with --debug flag for more details\n')
const packageName =
MACRO.PACKAGE_URL ||
(process.env.USER_TYPE === 'ant'
? '@anthropic-ai/claude-cli'
: '@anthropic-ai/claude-code')
process.stderr.write(
` • Manually check: npm view ${packageName} version\n`,
)

process.stderr.write(' • Check if you need to login: npm whoami\n')
process.stderr.write(` • Manually check: npm view ${MACRO.PACKAGE_URL} version\n`)
await gracefulShutdown(1)
}

// Check if versions match exactly, including any build metadata (like SHA)
if (latestVersion === MACRO.VERSION) {
writeToStdout(
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
chalk.green(`OpenClaude is up to date (${MACRO.VERSION})`) + '\n',
)
await gracefulShutdown(0)
}
Expand All @@ -332,7 +296,6 @@ export async function update() {
)
writeToStdout('Installing update...\n')

// Determine update method based on what's actually running
let useLocalUpdate = false
let updateMethodName = ''

Expand All @@ -346,7 +309,6 @@ export async function update() {
updateMethodName = 'global'
break
case 'unknown': {
// Fallback to detection if we can't determine installation type
const isLocal = await localInstallationExists()
useLocalUpdate = isLocal
updateMethodName = isLocal ? 'local' : 'global'
Expand Down
14 changes: 12 additions & 2 deletions src/components/AutoUpdater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function AutoUpdater({
if (isUpdatingRef.current) {
return;
}
if ("production" === 'test' || "production" === 'development') {
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
logForDebugging('AutoUpdater: Skipping update check in test/dev environment');
return;
}
Expand Down Expand Up @@ -93,9 +93,16 @@ export function AutoUpdater({
const installationType = await getCurrentInstallationType();
logForDebugging(`AutoUpdater: Detected installation type: ${installationType}`);

// Skip update for development builds
// Source/dev builds should surface an update notice, not try to use the
// inherited Claude native updater flow.
if (installationType === 'development') {
logForDebugging('AutoUpdater: Cannot auto-update development build');
onAutoUpdaterResult({
version: latestVersion,
currentVersion,
status: 'update_available',
actionLabel: 'git pull && bun install && bun run build'
});
onChangeIsUpdating(false);
return;
}
Expand Down Expand Up @@ -187,6 +194,9 @@ export function AutoUpdater({
</> : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && <Text color="success" wrap="truncate">
✓ Update installed · Restart to apply
</Text>}
{autoUpdaterResult?.status === 'update_available' && autoUpdaterResult.version && autoUpdaterResult.currentVersion && <Text color="warning" wrap="truncate">
Update available: {autoUpdaterResult.currentVersion} → {autoUpdaterResult.version} &middot; Run <Text bold>{autoUpdaterResult.actionLabel ?? `npm install -g ${MACRO.PACKAGE_URL}@latest`}</Text>
</Text>}
{(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && <Text color="error" wrap="truncate">
✗ Auto-update failed &middot; Try <Text bold>openclaude doctor</Text> or{' '}
<Text bold>
Expand Down
18 changes: 14 additions & 4 deletions src/components/NativeAutoUpdater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,14 @@ export function NativeAutoUpdater({
current: currentVersion,
latest: result.latestVersion
});
if (result.wasUpdated) {
if (result.latestVersion && result.latestVersion !== currentVersion && !result.wasUpdated) {
onAutoUpdaterResult({
version: result.latestVersion,
currentVersion,
status: 'update_available',
actionLabel: 'openclaude update'
});
} else if (result.wasUpdated) {
logEvent('tengu_native_auto_updater_success', {
latency_ms: latencyMs
});
Expand Down Expand Up @@ -160,7 +167,7 @@ export function NativeAutoUpdater({

// Check every 30 minutes
useInterval(checkForUpdates, 30 * 60 * 1000);
const hasUpdateResult = !!autoUpdaterResult?.version;
const hasUpdateResult = !!autoUpdaterResult?.version || autoUpdaterResult?.status === 'update_available';
const hasVersionInfo = !!versions.current && !!versions.latest;
// Show the component when:
// - warning banner needed (above max version), or
Expand All @@ -181,12 +188,15 @@ export function NativeAutoUpdater({
</Box> : autoUpdaterResult?.status === 'success' && showSuccessMessage && updateSemver && <Text color="success" wrap="truncate">
✓ Update installed · Restart to update
</Text>}
{autoUpdaterResult?.status === 'update_available' && autoUpdaterResult.version && autoUpdaterResult.currentVersion && <Text color="warning" wrap="truncate">
Update available: {autoUpdaterResult.currentVersion} → {autoUpdaterResult.version} &middot; Run <Text bold>{autoUpdaterResult.actionLabel ?? 'openclaude update'}</Text>
</Text>}
{autoUpdaterResult?.status === 'install_failed' && <Text color="error" wrap="truncate">
✗ Auto-update failed &middot; Try <Text bold>/status</Text>
✗ Auto-update failed &middot; Try <Text bold>openclaude doctor</Text>
</Text>}
{maxVersionIssue && "external" === 'ant' && <Text color="warning">
⚠ Known issue: {maxVersionIssue} &middot; Run{' '}
<Text bold>claude rollback --safe</Text> to downgrade
<Text bold>openclaude rollback --safe</Text> to downgrade
</Text>}
</Box>;
}
Loading
Loading