fix(update): enhance failure diagnostics and prevent claude symlink deletion

Add analyzeNpmError() to classify npm install failures (permission, network, missing package, locked file, etc.) and return InstallResult with errorCategory, suggestion, and raw npmStderr.

Display targeted troubleshooting steps with colored cause and fix suggestions when csc update fails.

Fix removeInstalledSymlink() to only delete symlinks pointing into our managed ~/.local/share/claude/versions/ directory, preventing accidental removal of user-installed claude binaries from other sources.

Signed-off-by: 林凯90331 <90331@sangfor.com>

Co-authored-by: CoStrict <zgsm@sangfor.com.cn>
This commit is contained in:
林凯90331 2026-05-15 16:19:13 +08:00
parent 4dc157e493
commit c2d5077cf9
5 changed files with 269 additions and 56 deletions

View File

@ -2,7 +2,7 @@ import chalk from 'chalk'
import { logEvent } from 'src/services/analytics/index.js' import { logEvent } from 'src/services/analytics/index.js'
import { import {
getLatestVersion, getLatestVersion,
type InstallStatus, type InstallResult,
installGlobalPackage, installGlobalPackage,
} from 'src/utils/autoUpdater.js' } from 'src/utils/autoUpdater.js'
import { regenerateCompletionCache } from 'src/utils/completionCache.js' import { regenerateCompletionCache } from 'src/utils/completionCache.js'
@ -356,21 +356,21 @@ export async function update() {
logForDebugging(`update: Update method determined: ${updateMethodName}`) logForDebugging(`update: Update method determined: ${updateMethodName}`)
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`) logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
let status: InstallStatus let result: InstallResult
if (useLocalUpdate) { if (useLocalUpdate) {
logForDebugging( logForDebugging(
'update: Calling installOrUpdateClaudePackage() for local update', 'update: Calling installOrUpdateClaudePackage() for local update',
) )
status = await installOrUpdateClaudePackage(channel) result = await installOrUpdateClaudePackage(channel)
} else { } else {
logForDebugging('update: Calling installGlobalPackage() for global update') logForDebugging('update: Calling installGlobalPackage() for global update')
status = await installGlobalPackage() result = await installGlobalPackage()
} }
logForDebugging(`update: Installation status: ${status}`) logForDebugging(`update: Installation status: ${result.status}`)
switch (status) { switch (result.status) {
case 'success': case 'success':
writeToStdout( writeToStdout(
chalk.green( chalk.green(
@ -381,40 +381,59 @@ export async function update() {
break break
case 'no_permissions': case 'no_permissions':
process.stderr.write( process.stderr.write(
'Error: Insufficient permissions to install update\n', chalk.red('Error: Insufficient permissions to install update') + '\n',
) )
if (result.errorCategory) {
process.stderr.write(
chalk.yellow(`Cause: ${result.errorCategory}`) + '\n',
)
}
if (result.suggestion) {
process.stderr.write('\n')
process.stderr.write(result.suggestion + '\n')
}
if (useLocalUpdate) { if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n') process.stderr.write('\n')
process.stderr.write('Or try manually updating with:\n')
process.stderr.write( process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`, ` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
) )
} else {
process.stderr.write('Try running with sudo or fix npm permissions\n')
process.stderr.write(
'Or consider using native installation with: claude install\n',
)
} }
await gracefulShutdown(1) await gracefulShutdown(1)
break break
case 'install_failed': case 'install_failed':
process.stderr.write('Error: Failed to install update\n') process.stderr.write(chalk.red('Error: Failed to install update') + '\n')
if (result.errorCategory) {
process.stderr.write(
chalk.yellow(`Cause: ${result.errorCategory}`) + '\n',
)
}
if (result.suggestion) {
process.stderr.write('\n')
process.stderr.write(result.suggestion + '\n')
}
if (result.npmStderr) {
process.stderr.write('\n')
process.stderr.write('Raw error output:\n')
process.stderr.write(result.npmStderr + '\n')
}
if (useLocalUpdate) { if (useLocalUpdate) {
process.stderr.write('Try manually updating with:\n') process.stderr.write('\n')
process.stderr.write('Or try manually updating with:\n')
process.stderr.write( process.stderr.write(
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`, ` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
) )
} else {
process.stderr.write(
'Or consider using native installation with: claude install\n',
)
} }
await gracefulShutdown(1) await gracefulShutdown(1)
break break
case 'in_progress': case 'in_progress':
process.stderr.write( process.stderr.write(
'Error: Another instance is currently performing an update\n', chalk.red('Error: Another instance is currently performing an update') +
'\n',
) )
process.stderr.write('Please wait and try again later\n') process.stderr.write('Please wait a moment and try again.\n')
process.stderr.write('If the problem persists, delete the lock file:\n')
process.stderr.write(' rm ~/.claude/.update.lock\n')
await gracefulShutdown(1) await gracefulShutdown(1)
break break
} }

View File

@ -11,6 +11,7 @@ import {
type AutoUpdaterResult, type AutoUpdaterResult,
getLatestVersion, getLatestVersion,
getMaxVersion, getMaxVersion,
type InstallResult,
type InstallStatus, type InstallStatus,
installGlobalPackage, installGlobalPackage,
shouldSkipVersion, shouldSkipVersion,
@ -122,19 +123,19 @@ export function AutoUpdater({
} }
// Choose the appropriate update method based on what's actually running // Choose the appropriate update method based on what's actually running
let installStatus: InstallStatus; let installResult: InstallResult;
let updateMethod: 'local' | 'global'; let updateMethod: 'local' | 'global';
if (installationType === 'npm-local') { if (installationType === 'npm-local') {
// Use local update for local installations // Use local update for local installations
logForDebugging('AutoUpdater: Using local update method'); logForDebugging('AutoUpdater: Using local update method');
updateMethod = 'local'; updateMethod = 'local';
installStatus = await installOrUpdateClaudePackage(channel); installResult = await installOrUpdateClaudePackage(channel);
} else if (installationType === 'npm-global') { } else if (installationType === 'npm-global') {
// Use global update for global installations // Use global update for global installations
logForDebugging('AutoUpdater: Using global update method'); logForDebugging('AutoUpdater: Using global update method');
updateMethod = 'global'; updateMethod = 'global';
installStatus = await installGlobalPackage(); installResult = await installGlobalPackage();
} else if (installationType === 'native') { } else if (installationType === 'native') {
// This shouldn't happen - native should use NativeAutoUpdater // This shouldn't happen - native should use NativeAutoUpdater
logForDebugging('AutoUpdater: Unexpected native installation in non-native updater'); logForDebugging('AutoUpdater: Unexpected native installation in non-native updater');
@ -147,12 +148,14 @@ export function AutoUpdater({
updateMethod = isMigrated ? 'local' : 'global'; updateMethod = isMigrated ? 'local' : 'global';
if (isMigrated) { if (isMigrated) {
installStatus = await installOrUpdateClaudePackage(channel); installResult = await installOrUpdateClaudePackage(channel);
} else { } else {
installStatus = await installGlobalPackage(); installResult = await installGlobalPackage();
} }
} }
const installStatus: InstallStatus = installResult.status;
onChangeIsUpdating(false); onChangeIsUpdating(false);
if (installStatus === 'success') { if (installStatus === 'success') {

View File

@ -38,6 +38,16 @@ export type InstallStatus =
| 'install_failed' | 'install_failed'
| 'in_progress' | 'in_progress'
export type InstallResult = {
status: InstallStatus
/** Human-readable error category for targeted troubleshooting */
errorCategory?: string
/** Suggested fix based on the detected error */
suggestion?: string
/** Raw npm stderr for advanced debugging */
npmStderr?: string
}
export type AutoUpdaterResult = { export type AutoUpdaterResult = {
version: string | null version: string | null
status: InstallStatus status: InstallStatus
@ -452,9 +462,137 @@ export async function getVersionHistory(limit: number): Promise<string[]> {
} }
} }
/**
* Analyze npm/bun install stderr to produce a targeted error category and suggestion.
*/
export function analyzeNpmError(
stderr: string,
stdout: string,
): { category: string; suggestion: string } {
const combined = `${stderr} ${stdout}`.toLowerCase()
if (
combined.includes('eacces') ||
combined.includes('eperm') ||
combined.includes('permission denied') ||
combined.includes('operation not permitted')
) {
return {
category: 'Permission denied',
suggestion:
'The package manager lacks write permission to the global install directory.\n' +
'Options:\n' +
' 1. Fix npm permissions: https://docs.npmjs.com/resolving-eacces-permissions-errors\n' +
' 2. Run with sudo (not recommended long-term)\n' +
' 3. Switch to local installation: csc install --local\n' +
' 4. Switch to native installation: csc install',
}
}
if (
combined.includes('erofs') ||
combined.includes('read-only file system')
) {
return {
category: 'Read-only file system',
suggestion:
'The target directory is read-only.\n' +
'Try installing to a user-writable prefix:\n' +
' npm config set prefix ~/.npm-global\n' +
'Or switch to local/native installation.',
}
}
if (
combined.includes('etimedout') ||
combined.includes('econnrefused') ||
combined.includes('enotfound') ||
combined.includes('network timeout') ||
combined.includes('socket hang up')
) {
return {
category: 'Network error',
suggestion:
'Unable to reach the npm registry.\n' +
'Try:\n' +
' • Check your internet connection\n' +
' • If behind a proxy, configure npm: npm config set proxy http://...\n' +
' • Try again later (registry may be temporarily unavailable)',
}
}
if (
combined.includes('e404') ||
combined.includes('not found') ||
combined.includes('no matching version') ||
combined.includes('package not found')
) {
return {
category: 'Package or version not found',
suggestion:
`The package "${MACRO.PACKAGE_URL}" or the requested version could not be found.\n` +
'Possible causes:\n' +
' • Scoped package requires npm login: npm whoami\n' +
' • The version tag does not exist yet\n' +
' • Corporate registry mirror is missing this package',
}
}
if (
combined.includes('ebusy') ||
combined.includes('eexist') ||
combined.includes('resource busy') ||
combined.includes('file exists')
) {
return {
category: 'File locked or already exists',
suggestion:
'A file is locked or already exists.\n' +
'Try:\n' +
' • Close other CoStrict / Node processes\n' +
' • Delete the lock file and retry: rm ~/.claude/.update.lock',
}
}
if (combined.includes('elifecycle') || combined.includes('postinstall')) {
return {
category: 'Install script failed',
suggestion:
'A post-install script failed.\n' +
'Try:\n' +
' • npm install -g --ignore-scripts (workaround, skips lifecycle hooks)\n' +
' • Check that your Node.js version is supported',
}
}
if (combined.includes('enoent') || combined.includes('no such file')) {
return {
category: 'Missing file or directory',
suggestion:
'A required file or directory is missing.\n' +
'Try reinstalling:\n' +
' npm uninstall -g ' +
MACRO.PACKAGE_URL +
' && npm install -g ' +
MACRO.PACKAGE_URL,
}
}
// Fallback
return {
category: 'Installation failed',
suggestion:
'The package manager returned an unrecognized error.\n' +
'You can try:\n' +
' • Run the install command manually to see full output\n' +
' • Clear npm cache: npm cache clean --force\n' +
' • Switch to native installation: csc install',
}
}
export async function installGlobalPackage( export async function installGlobalPackage(
specificVersion?: string | null, specificVersion?: string | null,
): Promise<InstallStatus> { ): Promise<InstallResult> {
if (!(await acquireLock())) { if (!(await acquireLock())) {
logError( logError(
new AutoUpdaterError('Another process is currently installing an update'), new AutoUpdaterError('Another process is currently installing an update'),
@ -465,7 +603,7 @@ export async function installGlobalPackage(
currentVersion: currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}) })
return 'in_progress' return { status: 'in_progress' }
} }
try { try {
@ -477,23 +615,30 @@ export async function installGlobalPackage(
currentVersion: currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}) })
console.error(` return {
Error: Windows NPM detected in WSL status: 'install_failed',
errorCategory: 'Windows NPM in WSL',
You're running CoStrict in WSL but using the Windows NPM installation from /mnt/c/. suggestion:
This configuration is not supported for updates. 'You are running CoStrict in WSL but using the Windows NPM installation from /mnt/c/.\n' +
'To fix:\n' +
To fix this issue: ' 1. Install Node.js inside WSL: sudo apt install nodejs npm\n' +
1. Install Node.js within your Linux distribution: e.g. sudo apt install nodejs npm ' 2. Ensure Linux npm appears first in PATH\n' +
2. Make sure Linux NPM is in your PATH before the Windows version ' 3. Retry: csc update',
3. Try updating again with 'csc update' }
`)
return 'install_failed'
} }
const { hasPermissions } = await checkGlobalInstallPermissions() const { hasPermissions } = await checkGlobalInstallPermissions()
if (!hasPermissions) { if (!hasPermissions) {
return 'no_permissions' return {
status: 'no_permissions',
errorCategory: 'Insufficient permissions',
suggestion:
'No write access to the global npm prefix.\n' +
'Try:\n' +
' 1. Fix npm permissions: https://docs.npmjs.com/resolving-eacces-permissions-errors\n' +
' 2. Run: sudo csc update (temporary workaround)\n' +
' 3. Switch to native installation: csc install',
}
} }
// Use specific version if provided, otherwise use latest // Use specific version if provided, otherwise use latest
@ -510,11 +655,20 @@ To fix this issue:
{ cwd: homedir() }, { cwd: homedir() },
) )
if (installResult.code !== 0) { if (installResult.code !== 0) {
const { category, suggestion } = analyzeNpmError(
installResult.stderr,
installResult.stdout,
)
const error = new AutoUpdaterError( const error = new AutoUpdaterError(
`Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`, `Failed to install new version: ${category}\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`,
) )
logError(error) logError(error)
return 'install_failed' return {
status: 'install_failed',
errorCategory: category,
suggestion,
npmStderr: installResult.stderr,
}
} }
// Set installMethod to 'global' to track npm global installations // Set installMethod to 'global' to track npm global installations
@ -523,7 +677,7 @@ To fix this issue:
installMethod: 'global', installMethod: 'global',
})) }))
return 'success' return { status: 'success' }
} finally { } finally {
// Ensure we always release the lock // Ensure we always release the lock
await releaseLock() await releaseLock()

View File

@ -4,6 +4,7 @@
import { access, chmod, writeFile } from 'fs/promises' import { access, chmod, writeFile } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { type InstallResult, analyzeNpmError } from './autoUpdater.js'
import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { type ReleaseChannel, saveGlobalConfig } from './config.js'
import { getClaudeConfigHomeDir } from './envUtils.js' import { getClaudeConfigHomeDir } from './envUtils.js'
import { getErrnoCode } from './errors.js' import { getErrnoCode } from './errors.js'
@ -97,11 +98,17 @@ export async function ensureLocalPackageEnvironment(): Promise<boolean> {
export async function installOrUpdateClaudePackage( export async function installOrUpdateClaudePackage(
channel: ReleaseChannel, channel: ReleaseChannel,
specificVersion?: string | null, specificVersion?: string | null,
): Promise<'in_progress' | 'success' | 'install_failed'> { ): Promise<InstallResult> {
try { try {
// First ensure the environment is set up // First ensure the environment is set up
if (!(await ensureLocalPackageEnvironment())) { if (!(await ensureLocalPackageEnvironment())) {
return 'install_failed' return {
status: 'install_failed',
errorCategory: 'Failed to set up local install directory',
suggestion:
'Could not create ~/.claude/local directory or write package.json.\n' +
'Check disk space and permissions.',
}
} }
// Use specific version if provided, otherwise use channel tag // Use specific version if provided, otherwise use channel tag
@ -117,11 +124,20 @@ export async function installOrUpdateClaudePackage(
) )
if (result.code !== 0) { if (result.code !== 0) {
const { category, suggestion } = analyzeNpmError(
result.stderr,
result.stdout,
)
const error = new Error( const error = new Error(
`Failed to install Claude CLI package: ${result.stderr}`, `Failed to install Claude CLI package: ${category}\nstderr: ${result.stderr}`,
) )
logError(error) logError(error)
return result.code === 190 ? 'in_progress' : 'install_failed' return {
status: result.code === 190 ? 'in_progress' : 'install_failed',
errorCategory: category,
suggestion,
npmStderr: result.stderr,
}
} }
// Set installMethod to 'local' to prevent npm permission warnings // Set installMethod to 'local' to prevent npm permission warnings
@ -130,10 +146,20 @@ export async function installOrUpdateClaudePackage(
installMethod: 'local', installMethod: 'local',
})) }))
return 'success' return { status: 'success' }
} catch (error) { } catch (error) {
logError(error) logError(error)
return 'install_failed' return {
status: 'install_failed',
errorCategory: 'Unexpected error during local install',
suggestion:
'An unexpected error occurred.\n' +
'Try:\n' +
' • Manually update: cd ~/.claude/local && npm update ' +
MACRO.PACKAGE_URL +
'\n' +
' • Switch to native installation: csc install',
}
} }
} }

View File

@ -1456,22 +1456,33 @@ async function isNpmSymlink(executablePath: string): Promise<boolean> {
/** /**
* Remove the claude symlink from the executable directory * Remove the claude symlink from the executable directory
* This is used when switching away from native installation * This is used when switching away from native installation.
* Will only remove if it's a native binary symlink, not npm-managed JS files * Will only remove if it's a symlink pointing into our managed versions directory,
* ensuring we don't delete user-installed claude binaries from other sources.
*/ */
export async function removeInstalledSymlink(): Promise<void> { export async function removeInstalledSymlink(): Promise<void> {
const dirs = getBaseDirectories() const dirs = getBaseDirectories()
try { try {
// Check if this is an npm-managed installation const linkStats = await lstat(dirs.executable)
if (await isNpmSymlink(dirs.executable)) { if (!linkStats.isSymbolicLink()) {
logForDebugging(`Skipping removal of ${dirs.executable} - not a symlink`)
return
}
// Resolve symlink target to absolute path
const rawTarget = await readlink(dirs.executable)
const targetPath = resolve(dirname(dirs.executable), rawTarget)
// Only remove if it points into our managed versions directory
// This prevents deleting a user-installed claude from Homebrew, manually, etc.
if (!targetPath.startsWith(dirs.versions)) {
logForDebugging( logForDebugging(
`Skipping removal of ${dirs.executable} - appears to be npm-managed`, `Skipping removal of ${dirs.executable} - target ${targetPath} is not in ${dirs.versions}`,
) )
return return
} }
// It's a native binary symlink, safe to remove
await unlink(dirs.executable) await unlink(dirs.executable)
logForDebugging(`Removed claude symlink at ${dirs.executable}`) logForDebugging(`Removed claude symlink at ${dirs.executable}`)
} catch (error) { } catch (error) {