/ utils / nativeInstaller / packageManagers.ts
packageManagers.ts
  1  /**
  2   * Package manager detection for Claude CLI
  3   */
  4  
  5  import { readFile } from 'fs/promises'
  6  import memoize from 'lodash-es/memoize.js'
  7  import { logForDebugging } from '../debug.js'
  8  import { execFileNoThrow } from '../execFileNoThrow.js'
  9  import { getPlatform } from '../platform.js'
 10  
 11  export type PackageManager =
 12    | 'homebrew'
 13    | 'winget'
 14    | 'pacman'
 15    | 'deb'
 16    | 'rpm'
 17    | 'apk'
 18    | 'mise'
 19    | 'asdf'
 20    | 'unknown'
 21  
 22  /**
 23   * Parses /etc/os-release to extract the distro ID and ID_LIKE fields.
 24   * ID_LIKE identifies the distro family (e.g. Ubuntu has ID_LIKE=debian),
 25   * letting us skip package manager execs on distros that can't have them.
 26   * Returns null if the file is unreadable (pre-systemd or non-standard systems);
 27   * callers fall through to the exec in that case as a conservative fallback.
 28   */
 29  export const getOsRelease = memoize(
 30    async (): Promise<{ id: string; idLike: string[] } | null> => {
 31      try {
 32        const content = await readFile('/etc/os-release', 'utf8')
 33        const idMatch = content.match(/^ID=["']?(\S+?)["']?\s*$/m)
 34        const idLikeMatch = content.match(/^ID_LIKE=["']?(.+?)["']?\s*$/m)
 35        return {
 36          id: idMatch?.[1] ?? '',
 37          idLike: idLikeMatch?.[1]?.split(' ') ?? [],
 38        }
 39      } catch {
 40        return null
 41      }
 42    },
 43  )
 44  
 45  function isDistroFamily(
 46    osRelease: { id: string; idLike: string[] },
 47    families: string[],
 48  ): boolean {
 49    return (
 50      families.includes(osRelease.id) ||
 51      osRelease.idLike.some(like => families.includes(like))
 52    )
 53  }
 54  
 55  /**
 56   * Detects if the currently running Claude instance was installed via mise
 57   * (a polyglot tool version manager) by checking if the executable path
 58   * is within a mise installs directory.
 59   *
 60   * mise installs to: ~/.local/share/mise/installs/<tool>/<version>/
 61   */
 62  export function detectMise(): boolean {
 63    const execPath = process.execPath || process.argv[0] || ''
 64  
 65    // Check if the executable is within a mise installs directory
 66    if (/[/\\]mise[/\\]installs[/\\]/i.test(execPath)) {
 67      logForDebugging(`Detected mise installation: ${execPath}`)
 68      return true
 69    }
 70  
 71    return false
 72  }
 73  
 74  /**
 75   * Detects if the currently running Claude instance was installed via asdf
 76   * (another polyglot tool version manager) by checking if the executable path
 77   * is within an asdf installs directory.
 78   *
 79   * asdf installs to: ~/.asdf/installs/<tool>/<version>/
 80   */
 81  export function detectAsdf(): boolean {
 82    const execPath = process.execPath || process.argv[0] || ''
 83  
 84    // Check if the executable is within an asdf installs directory
 85    if (/[/\\]\.?asdf[/\\]installs[/\\]/i.test(execPath)) {
 86      logForDebugging(`Detected asdf installation: ${execPath}`)
 87      return true
 88    }
 89  
 90    return false
 91  }
 92  
 93  /**
 94   * Detects if the currently running Claude instance was installed via Homebrew
 95   * by checking if the executable path is within a Homebrew Caskroom directory.
 96   *
 97   * Note: We specifically check for Caskroom because npm can also be installed via
 98   * Homebrew, which would place npm global packages under the same Homebrew prefix
 99   * (e.g., /opt/homebrew/lib/node_modules). We need to distinguish between:
100   * - Homebrew cask: /opt/homebrew/Caskroom/claude-code/...
101   * - npm-global (via Homebrew's npm): /opt/homebrew/lib/node_modules/@anthropic-ai/...
102   */
103  export function detectHomebrew(): boolean {
104    const platform = getPlatform()
105  
106    // Homebrew is only for macOS and Linux
107    if (platform !== 'macos' && platform !== 'linux' && platform !== 'wsl') {
108      return false
109    }
110  
111    // Get the path of the currently running executable
112    const execPath = process.execPath || process.argv[0] || ''
113  
114    // Check if the executable is within a Homebrew Caskroom directory
115    // This is specific to Homebrew cask installations
116    if (execPath.includes('/Caskroom/')) {
117      logForDebugging(`Detected Homebrew cask installation: ${execPath}`)
118      return true
119    }
120  
121    return false
122  }
123  
124  /**
125   * Detects if the currently running Claude instance was installed via winget
126   * by checking if the executable path is within a WinGet directory.
127   *
128   * Winget installs to:
129   * - User: %LOCALAPPDATA%\Microsoft\WinGet\Packages
130   * - System: C:\Program Files\WinGet\Packages
131   * And creates links at: %LOCALAPPDATA%\Microsoft\WinGet\Links\
132   */
133  export function detectWinget(): boolean {
134    const platform = getPlatform()
135  
136    // Winget is only for Windows
137    if (platform !== 'windows') {
138      return false
139    }
140  
141    const execPath = process.execPath || process.argv[0] || ''
142  
143    // Check for WinGet paths (handles both forward and backslashes)
144    const wingetPatterns = [
145      /Microsoft[/\\]WinGet[/\\]Packages/i,
146      /Microsoft[/\\]WinGet[/\\]Links/i,
147    ]
148  
149    for (const pattern of wingetPatterns) {
150      if (pattern.test(execPath)) {
151        logForDebugging(`Detected winget installation: ${execPath}`)
152        return true
153      }
154    }
155  
156    return false
157  }
158  
159  /**
160   * Detects if the currently running Claude instance was installed via pacman
161   * by querying pacman's database for file ownership.
162   *
163   * We gate on the Arch distro family before invoking pacman. On other distros
164   * like Ubuntu/Debian, 'pacman' in PATH may resolve to the pacman game
165   * (/usr/games/pacman) rather than the Arch package manager.
166   */
167  export const detectPacman = memoize(async (): Promise<boolean> => {
168    const platform = getPlatform()
169  
170    if (platform !== 'linux') {
171      return false
172    }
173  
174    const osRelease = await getOsRelease()
175    if (osRelease && !isDistroFamily(osRelease, ['arch'])) {
176      return false
177    }
178  
179    const execPath = process.execPath || process.argv[0] || ''
180  
181    const result = await execFileNoThrow('pacman', ['-Qo', execPath], {
182      timeout: 5000,
183      useCwd: false,
184    })
185  
186    if (result.code === 0 && result.stdout) {
187      logForDebugging(`Detected pacman installation: ${result.stdout.trim()}`)
188      return true
189    }
190  
191    return false
192  })
193  
194  /**
195   * Detects if the currently running Claude instance was installed via a .deb package
196   * by querying dpkg's database for file ownership.
197   *
198   * We use `dpkg -S <execPath>` to check if the executable is owned by a dpkg-managed package.
199   */
200  export const detectDeb = memoize(async (): Promise<boolean> => {
201    const platform = getPlatform()
202  
203    if (platform !== 'linux') {
204      return false
205    }
206  
207    const osRelease = await getOsRelease()
208    if (osRelease && !isDistroFamily(osRelease, ['debian'])) {
209      return false
210    }
211  
212    const execPath = process.execPath || process.argv[0] || ''
213  
214    const result = await execFileNoThrow('dpkg', ['-S', execPath], {
215      timeout: 5000,
216      useCwd: false,
217    })
218  
219    if (result.code === 0 && result.stdout) {
220      logForDebugging(`Detected deb installation: ${result.stdout.trim()}`)
221      return true
222    }
223  
224    return false
225  })
226  
227  /**
228   * Detects if the currently running Claude instance was installed via an RPM package
229   * by querying the RPM database for file ownership.
230   *
231   * We use `rpm -qf <execPath>` to check if the executable is owned by an RPM package.
232   */
233  export const detectRpm = memoize(async (): Promise<boolean> => {
234    const platform = getPlatform()
235  
236    if (platform !== 'linux') {
237      return false
238    }
239  
240    const osRelease = await getOsRelease()
241    if (osRelease && !isDistroFamily(osRelease, ['fedora', 'rhel', 'suse'])) {
242      return false
243    }
244  
245    const execPath = process.execPath || process.argv[0] || ''
246  
247    const result = await execFileNoThrow('rpm', ['-qf', execPath], {
248      timeout: 5000,
249      useCwd: false,
250    })
251  
252    if (result.code === 0 && result.stdout) {
253      logForDebugging(`Detected rpm installation: ${result.stdout.trim()}`)
254      return true
255    }
256  
257    return false
258  })
259  
260  /**
261   * Detects if the currently running Claude instance was installed via Alpine APK
262   * by querying apk's database for file ownership.
263   *
264   * We use `apk info --who-owns <execPath>` to check if the executable is owned
265   * by an apk-managed package.
266   */
267  export const detectApk = memoize(async (): Promise<boolean> => {
268    const platform = getPlatform()
269  
270    if (platform !== 'linux') {
271      return false
272    }
273  
274    const osRelease = await getOsRelease()
275    if (osRelease && !isDistroFamily(osRelease, ['alpine'])) {
276      return false
277    }
278  
279    const execPath = process.execPath || process.argv[0] || ''
280  
281    const result = await execFileNoThrow(
282      'apk',
283      ['info', '--who-owns', execPath],
284      {
285        timeout: 5000,
286        useCwd: false,
287      },
288    )
289  
290    if (result.code === 0 && result.stdout) {
291      logForDebugging(`Detected apk installation: ${result.stdout.trim()}`)
292      return true
293    }
294  
295    return false
296  })
297  
298  /**
299   * Memoized function to detect which package manager installed Claude
300   * Returns 'unknown' if no package manager is detected
301   */
302  export const getPackageManager = memoize(async (): Promise<PackageManager> => {
303    if (detectHomebrew()) {
304      return 'homebrew'
305    }
306  
307    if (detectWinget()) {
308      return 'winget'
309    }
310  
311    if (detectMise()) {
312      return 'mise'
313    }
314  
315    if (detectAsdf()) {
316      return 'asdf'
317    }
318  
319    if (await detectPacman()) {
320      return 'pacman'
321    }
322  
323    if (await detectApk()) {
324      return 'apk'
325    }
326  
327    if (await detectDeb()) {
328      return 'deb'
329    }
330  
331    if (await detectRpm()) {
332      return 'rpm'
333    }
334  
335    return 'unknown'
336  })