/ utils / localInstaller.ts
localInstaller.ts
  1  /**
  2   * Utilities for handling local installation
  3   */
  4  
  5  import { access, chmod, writeFile } from 'fs/promises'
  6  import { join } from 'path'
  7  import { type ReleaseChannel, saveGlobalConfig } from './config.js'
  8  import { getClaudeConfigHomeDir } from './envUtils.js'
  9  import { getErrnoCode } from './errors.js'
 10  import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
 11  import { getFsImplementation } from './fsOperations.js'
 12  import { logError } from './log.js'
 13  import { jsonStringify } from './slowOperations.js'
 14  
 15  // Lazy getters: getClaudeConfigHomeDir() is memoized and reads process.env.
 16  // Evaluating at module scope would capture the value before entrypoints like
 17  // hfi.tsx get a chance to set CLAUDE_CONFIG_DIR in main(), and would also
 18  // populate the memoize cache with that stale value for all 150+ other callers.
 19  function getLocalInstallDir(): string {
 20    return join(getClaudeConfigHomeDir(), 'local')
 21  }
 22  export function getLocalClaudePath(): string {
 23    return join(getLocalInstallDir(), 'claude')
 24  }
 25  
 26  /**
 27   * Check if we're running from our managed local installation
 28   */
 29  export function isRunningFromLocalInstallation(): boolean {
 30    const execPath = process.argv[1] || ''
 31    return execPath.includes('/.claude/local/node_modules/')
 32  }
 33  
 34  /**
 35   * Write `content` to `path` only if the file does not already exist.
 36   * Uses O_EXCL ('wx') for atomic create-if-missing.
 37   */
 38  async function writeIfMissing(
 39    path: string,
 40    content: string,
 41    mode?: number,
 42  ): Promise<boolean> {
 43    try {
 44      await writeFile(path, content, { encoding: 'utf8', flag: 'wx', mode })
 45      return true
 46    } catch (e) {
 47      if (getErrnoCode(e) === 'EEXIST') return false
 48      throw e
 49    }
 50  }
 51  
 52  /**
 53   * Ensure the local package environment is set up
 54   * Creates the directory, package.json, and wrapper script
 55   */
 56  export async function ensureLocalPackageEnvironment(): Promise<boolean> {
 57    try {
 58      const localInstallDir = getLocalInstallDir()
 59  
 60      // Create installation directory (recursive, idempotent)
 61      await getFsImplementation().mkdir(localInstallDir)
 62  
 63      // Create package.json if it doesn't exist
 64      await writeIfMissing(
 65        join(localInstallDir, 'package.json'),
 66        jsonStringify(
 67          { name: 'claude-local', version: '0.0.1', private: true },
 68          null,
 69          2,
 70        ),
 71      )
 72  
 73      // Create the wrapper script if it doesn't exist
 74      const wrapperPath = join(localInstallDir, 'claude')
 75      const created = await writeIfMissing(
 76        wrapperPath,
 77        `#!/bin/sh\nexec "${localInstallDir}/node_modules/.bin/claude" "$@"`,
 78        0o755,
 79      )
 80      if (created) {
 81        // Mode in writeFile is masked by umask; chmod to ensure executable bit.
 82        await chmod(wrapperPath, 0o755)
 83      }
 84  
 85      return true
 86    } catch (error) {
 87      logError(error)
 88      return false
 89    }
 90  }
 91  
 92  /**
 93   * Install or update Claude CLI package in the local directory
 94   * @param channel - Release channel to use (latest or stable)
 95   * @param specificVersion - Optional specific version to install (overrides channel)
 96   */
 97  export async function installOrUpdateClaudePackage(
 98    channel: ReleaseChannel,
 99    specificVersion?: string | null,
100  ): Promise<'in_progress' | 'success' | 'install_failed'> {
101    try {
102      // First ensure the environment is set up
103      if (!(await ensureLocalPackageEnvironment())) {
104        return 'install_failed'
105      }
106  
107      // Use specific version if provided, otherwise use channel tag
108      const versionSpec = specificVersion
109        ? specificVersion
110        : channel === 'stable'
111          ? 'stable'
112          : 'latest'
113      const result = await execFileNoThrowWithCwd(
114        'npm',
115        ['install', `${MACRO.PACKAGE_URL}@${versionSpec}`],
116        { cwd: getLocalInstallDir(), maxBuffer: 1000000 },
117      )
118  
119      if (result.code !== 0) {
120        const error = new Error(
121          `Failed to install Claude CLI package: ${result.stderr}`,
122        )
123        logError(error)
124        return result.code === 190 ? 'in_progress' : 'install_failed'
125      }
126  
127      // Set installMethod to 'local' to prevent npm permission warnings
128      saveGlobalConfig(current => ({
129        ...current,
130        installMethod: 'local',
131      }))
132  
133      return 'success'
134    } catch (error) {
135      logError(error)
136      return 'install_failed'
137    }
138  }
139  
140  /**
141   * Check if local installation exists.
142   * Pure existence probe — callers use this to choose update path / UI hints.
143   */
144  export async function localInstallationExists(): Promise<boolean> {
145    try {
146      await access(join(getLocalInstallDir(), 'node_modules', '.bin', 'claude'))
147      return true
148    } catch {
149      return false
150    }
151  }
152  
153  /**
154   * Get shell type to determine appropriate path setup
155   */
156  export function getShellType(): string {
157    const shellPath = process.env.SHELL || ''
158    if (shellPath.includes('zsh')) return 'zsh'
159    if (shellPath.includes('bash')) return 'bash'
160    if (shellPath.includes('fish')) return 'fish'
161    return 'unknown'
162  }