/ utils / env.ts
env.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import { homedir } from 'os'
  3  import { join } from 'path'
  4  import { fileSuffixForOauthConfig } from '../constants/oauth.js'
  5  import { isRunningWithBun } from './bundledMode.js'
  6  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
  7  import { findExecutable } from './findExecutable.js'
  8  import { getFsImplementation } from './fsOperations.js'
  9  import { which } from './which.js'
 10  
 11  type Platform = 'win32' | 'darwin' | 'linux'
 12  
 13  // Config and data paths
 14  export const getGlobalClaudeFile = memoize((): string => {
 15    // Legacy fallback for backwards compatibility
 16    if (
 17      getFsImplementation().existsSync(
 18        join(getClaudeConfigHomeDir(), '.config.json'),
 19      )
 20    ) {
 21      return join(getClaudeConfigHomeDir(), '.config.json')
 22    }
 23  
 24    const filename = `.claude${fileSuffixForOauthConfig()}.json`
 25    return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename)
 26  })
 27  
 28  const hasInternetAccess = memoize(async (): Promise<boolean> => {
 29    try {
 30      const { default: axiosClient } = await import('axios')
 31      await axiosClient.head('http://1.1.1.1', {
 32        signal: AbortSignal.timeout(1000),
 33      })
 34      return true
 35    } catch {
 36      return false
 37    }
 38  })
 39  
 40  async function isCommandAvailable(command: string): Promise<boolean> {
 41    try {
 42      // which does not execute the file.
 43      return !!(await which(command))
 44    } catch {
 45      return false
 46    }
 47  }
 48  
 49  const detectPackageManagers = memoize(async (): Promise<string[]> => {
 50    const packageManagers = []
 51  
 52    if (await isCommandAvailable('npm')) packageManagers.push('npm')
 53    if (await isCommandAvailable('yarn')) packageManagers.push('yarn')
 54    if (await isCommandAvailable('pnpm')) packageManagers.push('pnpm')
 55  
 56    return packageManagers
 57  })
 58  
 59  const detectRuntimes = memoize(async (): Promise<string[]> => {
 60    const runtimes = []
 61  
 62    if (await isCommandAvailable('bun')) runtimes.push('bun')
 63    if (await isCommandAvailable('deno')) runtimes.push('deno')
 64    if (await isCommandAvailable('node')) runtimes.push('node')
 65  
 66    return runtimes
 67  })
 68  
 69  /**
 70   * Checks if we're running in a WSL environment
 71   * @returns true if running in WSL, false otherwise
 72   */
 73  const isWslEnvironment = memoize((): boolean => {
 74    try {
 75      // Check for WSLInterop file which is a reliable indicator of WSL
 76      return getFsImplementation().existsSync(
 77        '/proc/sys/fs/binfmt_misc/WSLInterop',
 78      )
 79    } catch (_error) {
 80      // If there's an error checking, assume not WSL
 81      return false
 82    }
 83  })
 84  
 85  /**
 86   * Checks if the npm executable is located in the Windows filesystem within WSL
 87   * @returns true if npm is from Windows (starts with /mnt/c/), false otherwise
 88   */
 89  const isNpmFromWindowsPath = memoize((): boolean => {
 90    try {
 91      // Only relevant in WSL environment
 92      if (!isWslEnvironment()) {
 93        return false
 94      }
 95  
 96      // Find the actual npm executable path
 97      const { cmd } = findExecutable('npm', [])
 98  
 99      // If npm is in Windows path, it will start with /mnt/c/
100      return cmd.startsWith('/mnt/c/')
101    } catch (_error) {
102      // If there's an error, assume it's not from Windows
103      return false
104    }
105  })
106  
107  /**
108   * Checks if we're running via Conductor
109   * @returns true if running via Conductor, false otherwise
110   */
111  function isConductor(): boolean {
112    return process.env.__CFBundleIdentifier === 'com.conductor.app'
113  }
114  
115  export const JETBRAINS_IDES = [
116    'pycharm',
117    'intellij',
118    'webstorm',
119    'phpstorm',
120    'rubymine',
121    'clion',
122    'goland',
123    'rider',
124    'datagrip',
125    'appcode',
126    'dataspell',
127    'aqua',
128    'gateway',
129    'fleet',
130    'jetbrains',
131    'androidstudio',
132  ]
133  
134  // Detect terminal type with fallbacks for all platforms
135  function detectTerminal(): string | null {
136    if (process.env.CURSOR_TRACE_ID) return 'cursor'
137    // Cursor and Windsurf under WSL have TERM_PROGRAM=vscode
138    if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('cursor')) {
139      return 'cursor'
140    }
141    if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('windsurf')) {
142      return 'windsurf'
143    }
144    if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('antigravity')) {
145      return 'antigravity'
146    }
147    const bundleId = process.env.__CFBundleIdentifier?.toLowerCase()
148    if (bundleId?.includes('vscodium')) return 'codium'
149    if (bundleId?.includes('windsurf')) return 'windsurf'
150    if (bundleId?.includes('com.google.android.studio')) return 'androidstudio'
151    // Check for JetBrains IDEs in bundle ID
152    if (bundleId) {
153      for (const ide of JETBRAINS_IDES) {
154        if (bundleId.includes(ide)) return ide
155      }
156    }
157  
158    if (process.env.VisualStudioVersion) {
159      // This is desktop Visual Studio, not VS Code
160      return 'visualstudio'
161    }
162  
163    // Check for JetBrains terminal on Linux/Windows
164    if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') {
165      // For macOS, bundle ID detection above already handles JetBrains IDEs
166      if (process.platform === 'darwin') return 'pycharm'
167  
168      // For finegrained detection on Linux/Windows use envDynamic.getTerminalWithJetBrainsDetection()
169      return 'pycharm'
170    }
171  
172    // Check for specific terminals by TERM before TERM_PROGRAM
173    // This handles cases where TERM and TERM_PROGRAM might be inconsistent
174    if (process.env.TERM === 'xterm-ghostty') {
175      return 'ghostty'
176    }
177    if (process.env.TERM?.includes('kitty')) {
178      return 'kitty'
179    }
180  
181    if (process.env.TERM_PROGRAM) {
182      return process.env.TERM_PROGRAM
183    }
184  
185    if (process.env.TMUX) return 'tmux'
186    if (process.env.STY) return 'screen'
187  
188    // Check for terminal-specific environment variables (common on Linux)
189    if (process.env.KONSOLE_VERSION) return 'konsole'
190    if (process.env.GNOME_TERMINAL_SERVICE) return 'gnome-terminal'
191    if (process.env.XTERM_VERSION) return 'xterm'
192    if (process.env.VTE_VERSION) return 'vte-based'
193    if (process.env.TERMINATOR_UUID) return 'terminator'
194    if (process.env.KITTY_WINDOW_ID) {
195      return 'kitty'
196    }
197    if (process.env.ALACRITTY_LOG) return 'alacritty'
198    if (process.env.TILIX_ID) return 'tilix'
199  
200    // Windows-specific detection
201    if (process.env.WT_SESSION) return 'windows-terminal'
202    if (process.env.SESSIONNAME && process.env.TERM === 'cygwin') return 'cygwin'
203    if (process.env.MSYSTEM) return process.env.MSYSTEM.toLowerCase() // MINGW64, MSYS2, etc.
204    if (
205      process.env.ConEmuANSI ||
206      process.env.ConEmuPID ||
207      process.env.ConEmuTask
208    ) {
209      return 'conemu'
210    }
211  
212    // WSL detection
213    if (process.env.WSL_DISTRO_NAME) return `wsl-${process.env.WSL_DISTRO_NAME}`
214  
215    // SSH session detection
216    if (isSSHSession()) {
217      return 'ssh-session'
218    }
219  
220    // Fall back to TERM which is more universally available
221    // Special case for common terminal identifiers in TERM
222    if (process.env.TERM) {
223      const term = process.env.TERM
224      if (term.includes('alacritty')) return 'alacritty'
225      if (term.includes('rxvt')) return 'rxvt'
226      if (term.includes('termite')) return 'termite'
227      return process.env.TERM
228    }
229  
230    // Detect non-interactive environment
231    if (!process.stdout.isTTY) return 'non-interactive'
232  
233    return null
234  }
235  
236  /**
237   * Detects the deployment environment/platform based on environment variables
238   * @returns The deployment platform name, or 'unknown' if not detected
239   */
240  export const detectDeploymentEnvironment = memoize((): string => {
241    // Cloud development environments
242    if (isEnvTruthy(process.env.CODESPACES)) return 'codespaces'
243    if (process.env.GITPOD_WORKSPACE_ID) return 'gitpod'
244    if (process.env.REPL_ID || process.env.REPL_SLUG) return 'replit'
245    if (process.env.PROJECT_DOMAIN) return 'glitch'
246  
247    // Cloud platforms
248    if (isEnvTruthy(process.env.VERCEL)) return 'vercel'
249    if (
250      process.env.RAILWAY_ENVIRONMENT_NAME ||
251      process.env.RAILWAY_SERVICE_NAME
252    ) {
253      return 'railway'
254    }
255    if (isEnvTruthy(process.env.RENDER)) return 'render'
256    if (isEnvTruthy(process.env.NETLIFY)) return 'netlify'
257    if (process.env.DYNO) return 'heroku'
258    if (process.env.FLY_APP_NAME || process.env.FLY_MACHINE_ID) return 'fly.io'
259    if (isEnvTruthy(process.env.CF_PAGES)) return 'cloudflare-pages'
260    if (process.env.DENO_DEPLOYMENT_ID) return 'deno-deploy'
261    if (process.env.AWS_LAMBDA_FUNCTION_NAME) return 'aws-lambda'
262    if (process.env.AWS_EXECUTION_ENV === 'AWS_ECS_FARGATE') return 'aws-fargate'
263    if (process.env.AWS_EXECUTION_ENV === 'AWS_ECS_EC2') return 'aws-ecs'
264    // Check for EC2 via hypervisor UUID
265    try {
266      const uuid = getFsImplementation()
267        .readFileSync('/sys/hypervisor/uuid', { encoding: 'utf8' })
268        .trim()
269        .toLowerCase()
270      if (uuid.startsWith('ec2')) return 'aws-ec2'
271    } catch {
272      // Ignore errors reading hypervisor UUID (ENOENT on non-EC2, etc.)
273    }
274    if (process.env.K_SERVICE) return 'gcp-cloud-run'
275    if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp'
276    if (process.env.WEBSITE_SITE_NAME || process.env.WEBSITE_SKU)
277      return 'azure-app-service'
278    if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure-functions'
279    if (process.env.APP_URL?.includes('ondigitalocean.app')) {
280      return 'digitalocean-app-platform'
281    }
282    if (process.env.SPACE_CREATOR_USER_ID) return 'huggingface-spaces'
283  
284    // CI/CD platforms
285    if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-actions'
286    if (isEnvTruthy(process.env.GITLAB_CI)) return 'gitlab-ci'
287    if (process.env.CIRCLECI) return 'circleci'
288    if (process.env.BUILDKITE) return 'buildkite'
289    if (isEnvTruthy(process.env.CI)) return 'ci'
290  
291    // Container orchestration
292    if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes'
293    try {
294      if (getFsImplementation().existsSync('/.dockerenv')) return 'docker'
295    } catch {
296      // Ignore errors checking for Docker
297    }
298  
299    // Platform-specific fallback for undetected environments
300    if (env.platform === 'darwin') return 'unknown-darwin'
301    if (env.platform === 'linux') return 'unknown-linux'
302    if (env.platform === 'win32') return 'unknown-win32'
303  
304    return 'unknown'
305  })
306  
307  // all of these should be immutable
308  function isSSHSession(): boolean {
309    return !!(
310      process.env.SSH_CONNECTION ||
311      process.env.SSH_CLIENT ||
312      process.env.SSH_TTY
313    )
314  }
315  
316  export const env = {
317    hasInternetAccess,
318    isCI: isEnvTruthy(process.env.CI),
319    platform: (['win32', 'darwin'].includes(process.platform)
320      ? process.platform
321      : 'linux') as Platform,
322    arch: process.arch,
323    nodeVersion: process.version,
324    terminal: detectTerminal(),
325    isSSH: isSSHSession,
326    getPackageManagers: detectPackageManagers,
327    getRuntimes: detectRuntimes,
328    isRunningWithBun: memoize(isRunningWithBun),
329    isWslEnvironment,
330    isNpmFromWindowsPath,
331    isConductor,
332    detectDeploymentEnvironment,
333  }
334  
335  /**
336   * Returns the host platform for analytics reporting.
337   * If CLAUDE_CODE_HOST_PLATFORM is set to a valid platform value, that overrides
338   * the detected platform. This is useful for container/remote environments where
339   * process.platform reports the container OS but the actual host platform differs.
340   */
341  export function getHostPlatformForAnalytics(): Platform {
342    const override = process.env.CLAUDE_CODE_HOST_PLATFORM
343    if (override === 'win32' || override === 'darwin' || override === 'linux') {
344      return override
345    }
346    return env.platform
347  }