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) {