From c2d5077cf9ec4b695b8a4584f83064c169cfd85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=87=AF90331?= <90331@sangfor.com> Date: Fri, 15 May 2026 16:19:13 +0800 Subject: [PATCH] fix(update): enhance failure diagnostics and prevent claude symlink deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/cli/update.ts | 61 +++++--- src/components/AutoUpdater.tsx | 13 +- src/utils/autoUpdater.ts | 190 ++++++++++++++++++++++--- src/utils/localInstaller.ts | 38 ++++- src/utils/nativeInstaller/installer.ts | 23 ++- 5 files changed, 269 insertions(+), 56 deletions(-) diff --git a/src/cli/update.ts b/src/cli/update.ts index d6bb0a507..c54fb8454 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import { logEvent } from 'src/services/analytics/index.js' import { getLatestVersion, - type InstallStatus, + type InstallResult, installGlobalPackage, } from 'src/utils/autoUpdater.js' import { regenerateCompletionCache } from 'src/utils/completionCache.js' @@ -356,21 +356,21 @@ export async function update() { logForDebugging(`update: Update method determined: ${updateMethodName}`) logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`) - let status: InstallStatus + let result: InstallResult if (useLocalUpdate) { logForDebugging( 'update: Calling installOrUpdateClaudePackage() for local update', ) - status = await installOrUpdateClaudePackage(channel) + result = await installOrUpdateClaudePackage(channel) } else { 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': writeToStdout( chalk.green( @@ -381,40 +381,59 @@ export async function update() { break case 'no_permissions': 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) { - process.stderr.write('Try manually updating with:\n') + process.stderr.write('\n') + process.stderr.write('Or try manually updating with:\n') process.stderr.write( ` 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) break 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) { - process.stderr.write('Try manually updating with:\n') + process.stderr.write('\n') + process.stderr.write('Or try manually updating with:\n') process.stderr.write( ` 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) break case 'in_progress': 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) break } diff --git a/src/components/AutoUpdater.tsx b/src/components/AutoUpdater.tsx index 3d468a2c6..b50f78bf1 100644 --- a/src/components/AutoUpdater.tsx +++ b/src/components/AutoUpdater.tsx @@ -11,6 +11,7 @@ import { type AutoUpdaterResult, getLatestVersion, getMaxVersion, + type InstallResult, type InstallStatus, installGlobalPackage, shouldSkipVersion, @@ -122,19 +123,19 @@ export function AutoUpdater({ } // Choose the appropriate update method based on what's actually running - let installStatus: InstallStatus; + let installResult: InstallResult; 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); + installResult = 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(); + installResult = await installGlobalPackage(); } else if (installationType === 'native') { // This shouldn't happen - native should use NativeAutoUpdater logForDebugging('AutoUpdater: Unexpected native installation in non-native updater'); @@ -147,12 +148,14 @@ export function AutoUpdater({ updateMethod = isMigrated ? 'local' : 'global'; if (isMigrated) { - installStatus = await installOrUpdateClaudePackage(channel); + installResult = await installOrUpdateClaudePackage(channel); } else { - installStatus = await installGlobalPackage(); + installResult = await installGlobalPackage(); } } + const installStatus: InstallStatus = installResult.status; + onChangeIsUpdating(false); if (installStatus === 'success') { diff --git a/src/utils/autoUpdater.ts b/src/utils/autoUpdater.ts index 68d6f1108..84b939bc1 100644 --- a/src/utils/autoUpdater.ts +++ b/src/utils/autoUpdater.ts @@ -38,6 +38,16 @@ export type InstallStatus = | 'install_failed' | '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 = { version: string | null status: InstallStatus @@ -452,9 +462,137 @@ export async function getVersionHistory(limit: number): Promise { } } +/** + * 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( specificVersion?: string | null, -): Promise { +): Promise { if (!(await acquireLock())) { logError( new AutoUpdaterError('Another process is currently installing an update'), @@ -465,7 +603,7 @@ export async function installGlobalPackage( currentVersion: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) - return 'in_progress' + return { status: 'in_progress' } } try { @@ -477,23 +615,30 @@ export async function installGlobalPackage( currentVersion: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) - console.error(` -Error: Windows NPM detected in WSL - -You're running CoStrict 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 'csc update' -`) - return 'install_failed' + return { + status: 'install_failed', + errorCategory: 'Windows NPM in WSL', + suggestion: + 'You are running CoStrict in WSL but using the Windows NPM installation from /mnt/c/.\n' + + 'To fix:\n' + + ' 1. Install Node.js inside WSL: sudo apt install nodejs npm\n' + + ' 2. Ensure Linux npm appears first in PATH\n' + + ' 3. Retry: csc update', + } } const { hasPermissions } = await checkGlobalInstallPermissions() 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 @@ -510,11 +655,20 @@ To fix this issue: { cwd: homedir() }, ) if (installResult.code !== 0) { + const { category, suggestion } = analyzeNpmError( + installResult.stderr, + installResult.stdout, + ) 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) - return 'install_failed' + return { + status: 'install_failed', + errorCategory: category, + suggestion, + npmStderr: installResult.stderr, + } } // Set installMethod to 'global' to track npm global installations @@ -523,7 +677,7 @@ To fix this issue: installMethod: 'global', })) - return 'success' + return { status: 'success' } } finally { // Ensure we always release the lock await releaseLock() diff --git a/src/utils/localInstaller.ts b/src/utils/localInstaller.ts index 2532076f2..ff95c778a 100644 --- a/src/utils/localInstaller.ts +++ b/src/utils/localInstaller.ts @@ -4,6 +4,7 @@ import { access, chmod, writeFile } from 'fs/promises' import { join } from 'path' +import { type InstallResult, analyzeNpmError } from './autoUpdater.js' import { type ReleaseChannel, saveGlobalConfig } from './config.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { getErrnoCode } from './errors.js' @@ -97,11 +98,17 @@ export async function ensureLocalPackageEnvironment(): Promise { export async function installOrUpdateClaudePackage( channel: ReleaseChannel, specificVersion?: string | null, -): Promise<'in_progress' | 'success' | 'install_failed'> { +): Promise { try { // First ensure the environment is set up 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 @@ -117,11 +124,20 @@ export async function installOrUpdateClaudePackage( ) if (result.code !== 0) { + const { category, suggestion } = analyzeNpmError( + result.stderr, + result.stdout, + ) const error = new Error( - `Failed to install Claude CLI package: ${result.stderr}`, + `Failed to install Claude CLI package: ${category}\nstderr: ${result.stderr}`, ) 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 @@ -130,10 +146,20 @@ export async function installOrUpdateClaudePackage( installMethod: 'local', })) - return 'success' + return { status: 'success' } } catch (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', + } } } diff --git a/src/utils/nativeInstaller/installer.ts b/src/utils/nativeInstaller/installer.ts index f11981528..5a291b202 100644 --- a/src/utils/nativeInstaller/installer.ts +++ b/src/utils/nativeInstaller/installer.ts @@ -1456,22 +1456,33 @@ async function isNpmSymlink(executablePath: string): Promise { /** * Remove the claude symlink from the executable directory - * This is used when switching away from native installation - * Will only remove if it's a native binary symlink, not npm-managed JS files + * This is used when switching away from native installation. + * 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 { const dirs = getBaseDirectories() try { - // Check if this is an npm-managed installation - if (await isNpmSymlink(dirs.executable)) { + const linkStats = await lstat(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( - `Skipping removal of ${dirs.executable} - appears to be npm-managed`, + `Skipping removal of ${dirs.executable} - target ${targetPath} is not in ${dirs.versions}`, ) return } - // It's a native binary symlink, safe to remove await unlink(dirs.executable) logForDebugging(`Removed claude symlink at ${dirs.executable}`) } catch (error) {