/ utils / doctorDiagnostic.ts
doctorDiagnostic.ts
  1  import { execa } from 'execa'
  2  import { readFile, realpath } from 'fs/promises'
  3  import { homedir } from 'os'
  4  import { delimiter, join, posix, win32 } from 'path'
  5  import { checkGlobalInstallPermissions } from './autoUpdater.js'
  6  import { isInBundledMode } from './bundledMode.js'
  7  import {
  8    formatAutoUpdaterDisabledReason,
  9    getAutoUpdaterDisabledReason,
 10    getGlobalConfig,
 11    type InstallMethod,
 12  } from './config.js'
 13  import { getCwd } from './cwd.js'
 14  import { isEnvTruthy } from './envUtils.js'
 15  import { execFileNoThrow } from './execFileNoThrow.js'
 16  import { getFsImplementation } from './fsOperations.js'
 17  import {
 18    getShellType,
 19    isRunningFromLocalInstallation,
 20    localInstallationExists,
 21  } from './localInstaller.js'
 22  import {
 23    detectApk,
 24    detectAsdf,
 25    detectDeb,
 26    detectHomebrew,
 27    detectMise,
 28    detectPacman,
 29    detectRpm,
 30    detectWinget,
 31    getPackageManager,
 32  } from './nativeInstaller/packageManagers.js'
 33  import { getPlatform } from './platform.js'
 34  import { getRipgrepStatus } from './ripgrep.js'
 35  import { SandboxManager } from './sandbox/sandbox-adapter.js'
 36  import { getManagedFilePath } from './settings/managedPath.js'
 37  import { CUSTOMIZATION_SURFACES } from './settings/types.js'
 38  import {
 39    findClaudeAlias,
 40    findValidClaudeAlias,
 41    getShellConfigPaths,
 42  } from './shellConfig.js'
 43  import { jsonParse } from './slowOperations.js'
 44  import { which } from './which.js'
 45  
 46  export type InstallationType =
 47    | 'npm-global'
 48    | 'npm-local'
 49    | 'native'
 50    | 'package-manager'
 51    | 'development'
 52    | 'unknown'
 53  
 54  export type DiagnosticInfo = {
 55    installationType: InstallationType
 56    version: string
 57    installationPath: string
 58    invokedBinary: string
 59    configInstallMethod: InstallMethod | 'not set'
 60    autoUpdates: string
 61    hasUpdatePermissions: boolean | null
 62    multipleInstallations: Array<{ type: string; path: string }>
 63    warnings: Array<{ issue: string; fix: string }>
 64    recommendation?: string
 65    packageManager?: string
 66    ripgrepStatus: {
 67      working: boolean
 68      mode: 'system' | 'builtin' | 'embedded'
 69      systemPath: string | null
 70    }
 71  }
 72  
 73  function getNormalizedPaths(): [invokedPath: string, execPath: string] {
 74    let invokedPath = process.argv[1] || ''
 75    let execPath = process.execPath || process.argv[0] || ''
 76  
 77    // On Windows, convert backslashes to forward slashes for consistent path matching
 78    if (getPlatform() === 'windows') {
 79      invokedPath = invokedPath.split(win32.sep).join(posix.sep)
 80      execPath = execPath.split(win32.sep).join(posix.sep)
 81    }
 82  
 83    return [invokedPath, execPath]
 84  }
 85  
 86  export async function getCurrentInstallationType(): Promise<InstallationType> {
 87    if (process.env.NODE_ENV === 'development') {
 88      return 'development'
 89    }
 90  
 91    const [invokedPath] = getNormalizedPaths()
 92  
 93    // Check if running in bundled mode first
 94    if (isInBundledMode()) {
 95      // Check if this bundled instance was installed by a package manager
 96      if (
 97        detectHomebrew() ||
 98        detectWinget() ||
 99        detectMise() ||
100        detectAsdf() ||
101        (await detectPacman()) ||
102        (await detectDeb()) ||
103        (await detectRpm()) ||
104        (await detectApk())
105      ) {
106        return 'package-manager'
107      }
108      return 'native'
109    }
110  
111    // Check if running from local npm installation
112    if (isRunningFromLocalInstallation()) {
113      return 'npm-local'
114    }
115  
116    // Check if we're in a typical npm global location
117    const npmGlobalPaths = [
118      '/usr/local/lib/node_modules',
119      '/usr/lib/node_modules',
120      '/opt/homebrew/lib/node_modules',
121      '/opt/homebrew/bin',
122      '/usr/local/bin',
123      '/.nvm/versions/node/', // nvm installations
124    ]
125  
126    if (npmGlobalPaths.some(path => invokedPath.includes(path))) {
127      return 'npm-global'
128    }
129  
130    // Also check for npm/nvm in the path even if not in standard locations
131    if (invokedPath.includes('/npm/') || invokedPath.includes('/nvm/')) {
132      return 'npm-global'
133    }
134  
135    const npmConfigResult = await execa('npm config get prefix', {
136      shell: true,
137      reject: false,
138    })
139    const globalPrefix =
140      npmConfigResult.exitCode === 0 ? npmConfigResult.stdout.trim() : null
141  
142    if (globalPrefix && invokedPath.startsWith(globalPrefix)) {
143      return 'npm-global'
144    }
145  
146    // If we can't determine, return unknown
147    return 'unknown'
148  }
149  
150  async function getInstallationPath(): Promise<string> {
151    if (process.env.NODE_ENV === 'development') {
152      return getCwd()
153    }
154  
155    // For bundled/native builds, show the binary location
156    if (isInBundledMode()) {
157      // Try to find the actual binary that was invoked
158      try {
159        return await realpath(process.execPath)
160      } catch {
161        // This function doesn't expect errors
162      }
163  
164      try {
165        const path = await which('claude')
166        if (path) {
167          return path
168        }
169      } catch {
170        // This function doesn't expect errors
171      }
172  
173      // If we can't find it, check common locations
174      try {
175        await getFsImplementation().stat(join(homedir(), '.local/bin/claude'))
176        return join(homedir(), '.local/bin/claude')
177      } catch {
178        // Not found
179      }
180      return 'native'
181    }
182  
183    // For npm installations, use the path of the executable
184    try {
185      return process.argv[0] || 'unknown'
186    } catch {
187      return 'unknown'
188    }
189  }
190  
191  export function getInvokedBinary(): string {
192    try {
193      // For bundled/compiled executables, show the actual binary path
194      if (isInBundledMode()) {
195        return process.execPath || 'unknown'
196      }
197  
198      // For npm/development, show the script path
199      return process.argv[1] || 'unknown'
200    } catch {
201      return 'unknown'
202    }
203  }
204  
205  async function detectMultipleInstallations(): Promise<
206    Array<{ type: string; path: string }>
207  > {
208    const fs = getFsImplementation()
209    const installations: Array<{ type: string; path: string }> = []
210  
211    // Check for local installation
212    const localPath = join(homedir(), '.claude', 'local')
213    if (await localInstallationExists()) {
214      installations.push({ type: 'npm-local', path: localPath })
215    }
216  
217    // Check for global npm installation
218    const packagesToCheck = ['@anthropic-ai/claude-code']
219    if (MACRO.PACKAGE_URL && MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code') {
220      packagesToCheck.push(MACRO.PACKAGE_URL)
221    }
222    const npmResult = await execFileNoThrow('npm', [
223      '-g',
224      'config',
225      'get',
226      'prefix',
227    ])
228    if (npmResult.code === 0 && npmResult.stdout) {
229      const npmPrefix = npmResult.stdout.trim()
230      const isWindows = getPlatform() === 'windows'
231  
232      // First check for active installations via bin/claude
233      // Linux / macOS have prefix/bin/claude and prefix/lib/node_modules
234      // Windows has prefix/claude and prefix/node_modules
235      const globalBinPath = isWindows
236        ? join(npmPrefix, 'claude')
237        : join(npmPrefix, 'bin', 'claude')
238  
239      let globalBinExists = false
240      try {
241        await fs.stat(globalBinPath)
242        globalBinExists = true
243      } catch {
244        // Not found
245      }
246  
247      if (globalBinExists) {
248        // Check if this is actually a Homebrew cask installation, not npm-global
249        // When npm is installed via Homebrew, both can exist at /opt/homebrew/bin/claude
250        // We need to resolve the symlink to see where it actually points
251        let isCurrentHomebrewInstallation = false
252  
253        try {
254          // Resolve the symlink to get the actual target
255          const realPath = await realpath(globalBinPath)
256  
257          // If the symlink points to a Caskroom directory, it's a Homebrew cask
258          // Only skip it if it's the same Homebrew installation we're currently running from
259          if (realPath.includes('/Caskroom/')) {
260            isCurrentHomebrewInstallation = detectHomebrew()
261          }
262        } catch {
263          // If we can't resolve the symlink, include it anyway
264        }
265  
266        if (!isCurrentHomebrewInstallation) {
267          installations.push({ type: 'npm-global', path: globalBinPath })
268        }
269      } else {
270        // If no bin/claude exists, check for orphaned packages (no bin/claude symlink)
271        for (const packageName of packagesToCheck) {
272          const globalPackagePath = isWindows
273            ? join(npmPrefix, 'node_modules', packageName)
274            : join(npmPrefix, 'lib', 'node_modules', packageName)
275  
276          try {
277            await fs.stat(globalPackagePath)
278            installations.push({
279              type: 'npm-global-orphan',
280              path: globalPackagePath,
281            })
282          } catch {
283            // Package not found
284          }
285        }
286      }
287    }
288  
289    // Check for native installation
290  
291    // Check common native installation paths
292    const nativeBinPath = join(homedir(), '.local', 'bin', 'claude')
293    try {
294      await fs.stat(nativeBinPath)
295      installations.push({ type: 'native', path: nativeBinPath })
296    } catch {
297      // Not found
298    }
299  
300    // Also check if config indicates native installation
301    const config = getGlobalConfig()
302    if (config.installMethod === 'native') {
303      const nativeDataPath = join(homedir(), '.local', 'share', 'claude')
304      try {
305        await fs.stat(nativeDataPath)
306        if (!installations.some(i => i.type === 'native')) {
307          installations.push({ type: 'native', path: nativeDataPath })
308        }
309      } catch {
310        // Not found
311      }
312    }
313  
314    return installations
315  }
316  
317  async function detectConfigurationIssues(
318    type: InstallationType,
319  ): Promise<Array<{ issue: string; fix: string }>> {
320    const warnings: Array<{ issue: string; fix: string }> = []
321  
322    // Managed-settings forwards-compat: the schema preprocess silently drops
323    // unknown strictPluginOnlyCustomization surface names so one future enum
324    // value doesn't null out the entire policy file (settings.ts:101). But
325    // admins should KNOW — read the raw file and diff. Runs before the
326    // development-mode early return: this is config correctness, not an
327    // install-path check, and it's useful to see during dev testing.
328    try {
329      const raw = await readFile(
330        join(getManagedFilePath(), 'managed-settings.json'),
331        'utf-8',
332      )
333      const parsed: unknown = jsonParse(raw)
334      const field =
335        parsed && typeof parsed === 'object'
336          ? (parsed as Record<string, unknown>).strictPluginOnlyCustomization
337          : undefined
338      if (field !== undefined && typeof field !== 'boolean') {
339        if (!Array.isArray(field)) {
340          // .catch(undefined) in the schema silently drops this, so the rest
341          // of managed settings survive — but the admin typed something
342          // wrong (an object, a string, etc.).
343          warnings.push({
344            issue: `managed-settings.json: strictPluginOnlyCustomization has an invalid value (expected true or an array, got ${typeof field})`,
345            fix: `The field is silently ignored (schema .catch rescues it). Set it to true, or an array of: ${CUSTOMIZATION_SURFACES.join(', ')}.`,
346          })
347        } else {
348          const unknown = field.filter(
349            x =>
350              typeof x === 'string' &&
351              !(CUSTOMIZATION_SURFACES as readonly string[]).includes(x),
352          )
353          if (unknown.length > 0) {
354            warnings.push({
355              issue: `managed-settings.json: strictPluginOnlyCustomization has ${unknown.length} value(s) this client doesn't recognize: ${unknown.map(String).join(', ')}`,
356              fix: `These are silently ignored (forwards-compat). Known surfaces for this version: ${CUSTOMIZATION_SURFACES.join(', ')}. Either remove them, or this client is older than the managed-settings intended.`,
357            })
358          }
359        }
360      }
361    } catch {
362      // ENOENT (no managed settings) / parse error — not this check's concern.
363      // Parse errors are surfaced by the settings loader itself.
364    }
365  
366    const config = getGlobalConfig()
367  
368    // Skip most warnings for development mode
369    if (type === 'development') {
370      return warnings
371    }
372  
373    // Check if ~/.local/bin is in PATH for native installations
374    if (type === 'native') {
375      const path = process.env.PATH || ''
376      const pathDirectories = path.split(delimiter)
377      const homeDir = homedir()
378      const localBinPath = join(homeDir, '.local', 'bin')
379  
380      // On Windows, convert backslashes to forward slashes for consistent path matching
381      let normalizedLocalBinPath = localBinPath
382      if (getPlatform() === 'windows') {
383        normalizedLocalBinPath = localBinPath.split(win32.sep).join(posix.sep)
384      }
385  
386      // Check if ~/.local/bin is in PATH (handle both expanded and unexpanded forms)
387      // Also handle trailing slashes that users may have in their PATH
388      const localBinInPath = pathDirectories.some(dir => {
389        let normalizedDir = dir
390        if (getPlatform() === 'windows') {
391          normalizedDir = dir.split(win32.sep).join(posix.sep)
392        }
393        // Remove trailing slashes for comparison (handles paths like /home/user/.local/bin/)
394        const trimmedDir = normalizedDir.replace(/\/+$/, '')
395        const trimmedRawDir = dir.replace(/[/\\]+$/, '')
396        return (
397          trimmedDir === normalizedLocalBinPath ||
398          trimmedRawDir === '~/.local/bin' ||
399          trimmedRawDir === '$HOME/.local/bin'
400        )
401      })
402  
403      if (!localBinInPath) {
404        const isWindows = getPlatform() === 'windows'
405        if (isWindows) {
406          // Windows-specific PATH instructions
407          const windowsLocalBinPath = localBinPath
408            .split(posix.sep)
409            .join(win32.sep)
410          warnings.push({
411            issue: `Native installation exists but ${windowsLocalBinPath} is not in your PATH`,
412            fix: `Add it by opening: System Properties → Environment Variables → Edit User PATH → New → Add the path above. Then restart your terminal.`,
413          })
414        } else {
415          // Unix-style PATH instructions
416          const shellType = getShellType()
417          const configPaths = getShellConfigPaths()
418          const configFile = configPaths[shellType as keyof typeof configPaths]
419          const displayPath = configFile
420            ? configFile.replace(homedir(), '~')
421            : 'your shell config file'
422  
423          warnings.push({
424            issue:
425              'Native installation exists but ~/.local/bin is not in your PATH',
426            fix: `Run: echo 'export PATH="$HOME/.local/bin:$PATH"' >> ${displayPath} then open a new terminal or run: source ${displayPath}`,
427          })
428        }
429      }
430    }
431  
432    // Check for configuration mismatches
433    // Skip these checks if DISABLE_INSTALLATION_CHECKS is set (e.g., in HFI)
434    if (!isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) {
435      if (type === 'npm-local' && config.installMethod !== 'local') {
436        warnings.push({
437          issue: `Running from local installation but config install method is '${config.installMethod}'`,
438          fix: 'Consider using native installation: claude install',
439        })
440      }
441  
442      if (type === 'native' && config.installMethod !== 'native') {
443        warnings.push({
444          issue: `Running native installation but config install method is '${config.installMethod}'`,
445          fix: 'Run claude install to update configuration',
446        })
447      }
448    }
449  
450    if (type === 'npm-global' && (await localInstallationExists())) {
451      warnings.push({
452        issue: 'Local installation exists but not being used',
453        fix: 'Consider using native installation: claude install',
454      })
455    }
456  
457    const existingAlias = await findClaudeAlias()
458    const validAlias = await findValidClaudeAlias()
459  
460    // Check if running local installation but it's not in PATH
461    if (type === 'npm-local') {
462      // Check if claude is already accessible via PATH
463      const whichResult = await which('claude')
464      const claudeInPath = !!whichResult
465  
466      // Only show warning if claude is NOT in PATH AND no valid alias exists
467      if (!claudeInPath && !validAlias) {
468        if (existingAlias) {
469          // Alias exists but points to invalid target
470          warnings.push({
471            issue: 'Local installation not accessible',
472            fix: `Alias exists but points to invalid target: ${existingAlias}. Update alias: alias claude="~/.claude/local/claude"`,
473          })
474        } else {
475          // No alias exists and not in PATH
476          warnings.push({
477            issue: 'Local installation not accessible',
478            fix: 'Create alias: alias claude="~/.claude/local/claude"',
479          })
480        }
481      }
482    }
483  
484    return warnings
485  }
486  
487  export function detectLinuxGlobPatternWarnings(): Array<{
488    issue: string
489    fix: string
490  }> {
491    if (getPlatform() !== 'linux') {
492      return []
493    }
494  
495    const warnings: Array<{ issue: string; fix: string }> = []
496    const globPatterns = SandboxManager.getLinuxGlobPatternWarnings()
497  
498    if (globPatterns.length > 0) {
499      // Show first 3 patterns, then indicate if there are more
500      const displayPatterns = globPatterns.slice(0, 3).join(', ')
501      const remaining = globPatterns.length - 3
502      const patternList =
503        remaining > 0 ? `${displayPatterns} (${remaining} more)` : displayPatterns
504  
505      warnings.push({
506        issue: `Glob patterns in sandbox permission rules are not fully supported on Linux`,
507        fix: `Found ${globPatterns.length} pattern(s): ${patternList}. On Linux, glob patterns in Edit/Read rules will be ignored.`,
508      })
509    }
510  
511    return warnings
512  }
513  
514  export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> {
515    const installationType = await getCurrentInstallationType()
516    const version =
517      typeof MACRO !== 'undefined' && MACRO.VERSION ? MACRO.VERSION : 'unknown'
518    const installationPath = await getInstallationPath()
519    const invokedBinary = getInvokedBinary()
520    const multipleInstallations = await detectMultipleInstallations()
521    const warnings = await detectConfigurationIssues(installationType)
522  
523    // Add glob pattern warnings for Linux sandboxing
524    warnings.push(...detectLinuxGlobPatternWarnings())
525  
526    // Add warnings for leftover npm installations when running native
527    if (installationType === 'native') {
528      const npmInstalls = multipleInstallations.filter(
529        i =>
530          i.type === 'npm-global' ||
531          i.type === 'npm-global-orphan' ||
532          i.type === 'npm-local',
533      )
534  
535      const isWindows = getPlatform() === 'windows'
536  
537      for (const install of npmInstalls) {
538        if (install.type === 'npm-global') {
539          let uninstallCmd = 'npm -g uninstall @anthropic-ai/claude-code'
540          if (
541            MACRO.PACKAGE_URL &&
542            MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code'
543          ) {
544            uninstallCmd += ` && npm -g uninstall ${MACRO.PACKAGE_URL}`
545          }
546          warnings.push({
547            issue: `Leftover npm global installation at ${install.path}`,
548            fix: `Run: ${uninstallCmd}`,
549          })
550        } else if (install.type === 'npm-global-orphan') {
551          warnings.push({
552            issue: `Orphaned npm global package at ${install.path}`,
553            fix: isWindows
554              ? `Run: rmdir /s /q "${install.path}"`
555              : `Run: rm -rf ${install.path}`,
556          })
557        } else if (install.type === 'npm-local') {
558          warnings.push({
559            issue: `Leftover npm local installation at ${install.path}`,
560            fix: isWindows
561              ? `Run: rmdir /s /q "${install.path}"`
562              : `Run: rm -rf ${install.path}`,
563          })
564        }
565      }
566    }
567  
568    const config = getGlobalConfig()
569  
570    // Get config values for display
571    const configInstallMethod = config.installMethod || 'not set'
572  
573    // Check permissions for global installations
574    let hasUpdatePermissions: boolean | null = null
575    if (installationType === 'npm-global') {
576      const permCheck = await checkGlobalInstallPermissions()
577      hasUpdatePermissions = permCheck.hasPermissions
578  
579      // Add warning if no permissions
580      if (!hasUpdatePermissions && !getAutoUpdaterDisabledReason()) {
581        warnings.push({
582          issue: 'Insufficient permissions for auto-updates',
583          fix: 'Do one of: (1) Re-install node without sudo, or (2) Use `claude install` for native installation',
584        })
585      }
586    }
587  
588    // Get ripgrep status and configuration
589    const ripgrepStatusRaw = getRipgrepStatus()
590  
591    // Provide simple ripgrep status info
592    const ripgrepStatus = {
593      working: ripgrepStatusRaw.working ?? true, // Assume working if not yet tested
594      mode: ripgrepStatusRaw.mode,
595      systemPath:
596        ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
597    }
598  
599    // Get package manager info if running from package manager
600    const packageManager =
601      installationType === 'package-manager'
602        ? await getPackageManager()
603        : undefined
604  
605    const diagnostic: DiagnosticInfo = {
606      installationType,
607      version,
608      installationPath,
609      invokedBinary,
610      configInstallMethod,
611      autoUpdates: (() => {
612        const reason = getAutoUpdaterDisabledReason()
613        return reason
614          ? `disabled (${formatAutoUpdaterDisabledReason(reason)})`
615          : 'enabled'
616      })(),
617      hasUpdatePermissions,
618      multipleInstallations,
619      warnings,
620      packageManager,
621      ripgrepStatus,
622    }
623  
624    return diagnostic
625  }