348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import memoize from 'lodash-es/memoize.js'
|
|
import { homedir } from 'os'
|
|
import { join } from 'path'
|
|
import { fileSuffixForOauthConfig } from '../constants/oauth.js'
|
|
import { isRunningWithBun } from './bundledMode.js'
|
|
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
|
|
import { findExecutable } from './findExecutable.js'
|
|
import { getFsImplementation } from './fsOperations.js'
|
|
import { which } from './which.js'
|
|
|
|
type Platform = 'win32' | 'darwin' | 'linux'
|
|
|
|
// Config and data paths
|
|
export const getGlobalClaudeFile = memoize((): string => {
|
|
// Legacy fallback for backwards compatibility
|
|
if (
|
|
getFsImplementation().existsSync(
|
|
join(getClaudeConfigHomeDir(), '.config.json'),
|
|
)
|
|
) {
|
|
return join(getClaudeConfigHomeDir(), '.config.json')
|
|
}
|
|
|
|
const filename = `.claude${fileSuffixForOauthConfig()}.json`
|
|
return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename)
|
|
})
|
|
|
|
const hasInternetAccess = memoize(async (): Promise<boolean> => {
|
|
try {
|
|
const { default: axiosClient } = await import('axios')
|
|
await axiosClient.head('http://1.1.1.1', {
|
|
signal: AbortSignal.timeout(1000),
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
async function isCommandAvailable(command: string): Promise<boolean> {
|
|
try {
|
|
// which does not execute the file.
|
|
return !!(await which(command))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const detectPackageManagers = memoize(async (): Promise<string[]> => {
|
|
const packageManagers = []
|
|
|
|
if (await isCommandAvailable('npm')) packageManagers.push('npm')
|
|
if (await isCommandAvailable('yarn')) packageManagers.push('yarn')
|
|
if (await isCommandAvailable('pnpm')) packageManagers.push('pnpm')
|
|
|
|
return packageManagers
|
|
})
|
|
|
|
const detectRuntimes = memoize(async (): Promise<string[]> => {
|
|
const runtimes = []
|
|
|
|
if (await isCommandAvailable('bun')) runtimes.push('bun')
|
|
if (await isCommandAvailable('deno')) runtimes.push('deno')
|
|
if (await isCommandAvailable('node')) runtimes.push('node')
|
|
|
|
return runtimes
|
|
})
|
|
|
|
/**
|
|
* Checks if we're running in a WSL environment
|
|
* @returns true if running in WSL, false otherwise
|
|
*/
|
|
const isWslEnvironment = memoize((): boolean => {
|
|
try {
|
|
// Check for WSLInterop file which is a reliable indicator of WSL
|
|
return getFsImplementation().existsSync(
|
|
'/proc/sys/fs/binfmt_misc/WSLInterop',
|
|
)
|
|
} catch (_error) {
|
|
// If there's an error checking, assume not WSL
|
|
return false
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Checks if the npm executable is located in the Windows filesystem within WSL
|
|
* @returns true if npm is from Windows (starts with /mnt/c/), false otherwise
|
|
*/
|
|
const isNpmFromWindowsPath = memoize((): boolean => {
|
|
try {
|
|
// Only relevant in WSL environment
|
|
if (!isWslEnvironment()) {
|
|
return false
|
|
}
|
|
|
|
// Find the actual npm executable path
|
|
const { cmd } = findExecutable('npm', [])
|
|
|
|
// If npm is in Windows path, it will start with /mnt/c/
|
|
return cmd.startsWith('/mnt/c/')
|
|
} catch (_error) {
|
|
// If there's an error, assume it's not from Windows
|
|
return false
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Checks if we're running via Conductor
|
|
* @returns true if running via Conductor, false otherwise
|
|
*/
|
|
function isConductor(): boolean {
|
|
return process.env.__CFBundleIdentifier === 'com.conductor.app'
|
|
}
|
|
|
|
export const JETBRAINS_IDES = [
|
|
'pycharm',
|
|
'intellij',
|
|
'webstorm',
|
|
'phpstorm',
|
|
'rubymine',
|
|
'clion',
|
|
'goland',
|
|
'rider',
|
|
'datagrip',
|
|
'appcode',
|
|
'dataspell',
|
|
'aqua',
|
|
'gateway',
|
|
'fleet',
|
|
'jetbrains',
|
|
'androidstudio',
|
|
]
|
|
|
|
// Detect terminal type with fallbacks for all platforms
|
|
function detectTerminal(): string | null {
|
|
if (process.env.CURSOR_TRACE_ID) return 'cursor'
|
|
// Cursor and Windsurf under WSL have TERM_PROGRAM=vscode
|
|
if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('cursor')) {
|
|
return 'cursor'
|
|
}
|
|
if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('windsurf')) {
|
|
return 'windsurf'
|
|
}
|
|
if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('antigravity')) {
|
|
return 'antigravity'
|
|
}
|
|
const bundleId = process.env.__CFBundleIdentifier?.toLowerCase()
|
|
if (bundleId?.includes('vscodium')) return 'codium'
|
|
if (bundleId?.includes('windsurf')) return 'windsurf'
|
|
if (bundleId?.includes('com.google.android.studio')) return 'androidstudio'
|
|
// Check for JetBrains IDEs in bundle ID
|
|
if (bundleId) {
|
|
for (const ide of JETBRAINS_IDES) {
|
|
if (bundleId.includes(ide)) return ide
|
|
}
|
|
}
|
|
|
|
if (process.env.VisualStudioVersion) {
|
|
// This is desktop Visual Studio, not VS Code
|
|
return 'visualstudio'
|
|
}
|
|
|
|
// Check for JetBrains terminal on Linux/Windows
|
|
if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') {
|
|
// For macOS, bundle ID detection above already handles JetBrains IDEs
|
|
if (process.platform === 'darwin') return 'pycharm'
|
|
|
|
// For finegrained detection on Linux/Windows use envDynamic.getTerminalWithJetBrainsDetection()
|
|
return 'pycharm'
|
|
}
|
|
|
|
// Check for specific terminals by TERM before TERM_PROGRAM
|
|
// This handles cases where TERM and TERM_PROGRAM might be inconsistent
|
|
if (process.env.TERM === 'xterm-ghostty') {
|
|
return 'ghostty'
|
|
}
|
|
if (process.env.TERM?.includes('kitty')) {
|
|
return 'kitty'
|
|
}
|
|
|
|
if (process.env.TERM_PROGRAM) {
|
|
return process.env.TERM_PROGRAM
|
|
}
|
|
|
|
if (process.env.TMUX) return 'tmux'
|
|
if (process.env.STY) return 'screen'
|
|
|
|
// Check for terminal-specific environment variables (common on Linux)
|
|
if (process.env.KONSOLE_VERSION) return 'konsole'
|
|
if (process.env.GNOME_TERMINAL_SERVICE) return 'gnome-terminal'
|
|
if (process.env.XTERM_VERSION) return 'xterm'
|
|
if (process.env.VTE_VERSION) return 'vte-based'
|
|
if (process.env.TERMINATOR_UUID) return 'terminator'
|
|
if (process.env.KITTY_WINDOW_ID) {
|
|
return 'kitty'
|
|
}
|
|
if (process.env.ALACRITTY_LOG) return 'alacritty'
|
|
if (process.env.TILIX_ID) return 'tilix'
|
|
|
|
// Windows-specific detection
|
|
if (process.env.WT_SESSION) return 'windows-terminal'
|
|
if (process.env.SESSIONNAME && process.env.TERM === 'cygwin') return 'cygwin'
|
|
if (process.env.MSYSTEM) return process.env.MSYSTEM.toLowerCase() // MINGW64, MSYS2, etc.
|
|
if (
|
|
process.env.ConEmuANSI ||
|
|
process.env.ConEmuPID ||
|
|
process.env.ConEmuTask
|
|
) {
|
|
return 'conemu'
|
|
}
|
|
|
|
// WSL detection
|
|
if (process.env.WSL_DISTRO_NAME) return `wsl-${process.env.WSL_DISTRO_NAME}`
|
|
|
|
// SSH session detection
|
|
if (isSSHSession()) {
|
|
return 'ssh-session'
|
|
}
|
|
|
|
// Fall back to TERM which is more universally available
|
|
// Special case for common terminal identifiers in TERM
|
|
if (process.env.TERM) {
|
|
const term = process.env.TERM
|
|
if (term.includes('alacritty')) return 'alacritty'
|
|
if (term.includes('rxvt')) return 'rxvt'
|
|
if (term.includes('termite')) return 'termite'
|
|
return process.env.TERM
|
|
}
|
|
|
|
// Detect non-interactive environment
|
|
if (!process.stdout.isTTY) return 'non-interactive'
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Detects the deployment environment/platform based on environment variables
|
|
* @returns The deployment platform name, or 'unknown' if not detected
|
|
*/
|
|
export const detectDeploymentEnvironment = memoize((): string => {
|
|
// Cloud development environments
|
|
if (isEnvTruthy(process.env.CODESPACES)) return 'codespaces'
|
|
if (process.env.GITPOD_WORKSPACE_ID) return 'gitpod'
|
|
if (process.env.REPL_ID || process.env.REPL_SLUG) return 'replit'
|
|
if (process.env.PROJECT_DOMAIN) return 'glitch'
|
|
|
|
// Cloud platforms
|
|
if (isEnvTruthy(process.env.VERCEL)) return 'vercel'
|
|
if (
|
|
process.env.RAILWAY_ENVIRONMENT_NAME ||
|
|
process.env.RAILWAY_SERVICE_NAME
|
|
) {
|
|
return 'railway'
|
|
}
|
|
if (isEnvTruthy(process.env.RENDER)) return 'render'
|
|
if (isEnvTruthy(process.env.NETLIFY)) return 'netlify'
|
|
if (process.env.DYNO) return 'heroku'
|
|
if (process.env.FLY_APP_NAME || process.env.FLY_MACHINE_ID) return 'fly.io'
|
|
if (isEnvTruthy(process.env.CF_PAGES)) return 'cloudflare-pages'
|
|
if (process.env.DENO_DEPLOYMENT_ID) return 'deno-deploy'
|
|
if (process.env.AWS_LAMBDA_FUNCTION_NAME) return 'aws-lambda'
|
|
if (process.env.AWS_EXECUTION_ENV === 'AWS_ECS_FARGATE') return 'aws-fargate'
|
|
if (process.env.AWS_EXECUTION_ENV === 'AWS_ECS_EC2') return 'aws-ecs'
|
|
// Check for EC2 via hypervisor UUID
|
|
try {
|
|
const uuid = getFsImplementation()
|
|
.readFileSync('/sys/hypervisor/uuid', { encoding: 'utf8' })
|
|
.trim()
|
|
.toLowerCase()
|
|
if (uuid.startsWith('ec2')) return 'aws-ec2'
|
|
} catch {
|
|
// Ignore errors reading hypervisor UUID (ENOENT on non-EC2, etc.)
|
|
}
|
|
if (process.env.K_SERVICE) return 'gcp-cloud-run'
|
|
if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp'
|
|
if (process.env.WEBSITE_SITE_NAME || process.env.WEBSITE_SKU)
|
|
return 'azure-app-service'
|
|
if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure-functions'
|
|
if (process.env.APP_URL?.includes('ondigitalocean.app')) {
|
|
return 'digitalocean-app-platform'
|
|
}
|
|
if (process.env.SPACE_CREATOR_USER_ID) return 'huggingface-spaces'
|
|
|
|
// CI/CD platforms
|
|
if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-actions'
|
|
if (isEnvTruthy(process.env.GITLAB_CI)) return 'gitlab-ci'
|
|
if (process.env.CIRCLECI) return 'circleci'
|
|
if (process.env.BUILDKITE) return 'buildkite'
|
|
if (isEnvTruthy(process.env.CI)) return 'ci'
|
|
|
|
// Container orchestration
|
|
if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes'
|
|
try {
|
|
if (getFsImplementation().existsSync('/.dockerenv')) return 'docker'
|
|
} catch {
|
|
// Ignore errors checking for Docker
|
|
}
|
|
|
|
// Platform-specific fallback for undetected environments
|
|
if (env.platform === 'darwin') return 'unknown-darwin'
|
|
if (env.platform === 'linux') return 'unknown-linux'
|
|
if (env.platform === 'win32') return 'unknown-win32'
|
|
|
|
return 'unknown'
|
|
})
|
|
|
|
// all of these should be immutable
|
|
function isSSHSession(): boolean {
|
|
return !!(
|
|
process.env.SSH_CONNECTION ||
|
|
process.env.SSH_CLIENT ||
|
|
process.env.SSH_TTY
|
|
)
|
|
}
|
|
|
|
export const env = {
|
|
hasInternetAccess,
|
|
isCI: isEnvTruthy(process.env.CI),
|
|
platform: (['win32', 'darwin'].includes(process.platform)
|
|
? process.platform
|
|
: 'linux') as Platform,
|
|
arch: process.arch,
|
|
nodeVersion: process.version,
|
|
terminal: detectTerminal(),
|
|
isSSH: isSSHSession,
|
|
getPackageManagers: detectPackageManagers,
|
|
getRuntimes: detectRuntimes,
|
|
isRunningWithBun: memoize(isRunningWithBun),
|
|
isWslEnvironment,
|
|
isNpmFromWindowsPath,
|
|
isConductor,
|
|
detectDeploymentEnvironment,
|
|
}
|
|
|
|
/**
|
|
* Returns the host platform for analytics reporting.
|
|
* If CLAUDE_CODE_HOST_PLATFORM is set to a valid platform value, that overrides
|
|
* the detected platform. This is useful for container/remote environments where
|
|
* process.platform reports the container OS but the actual host platform differs.
|
|
*/
|
|
export function getHostPlatformForAnalytics(): Platform {
|
|
const override = process.env.CLAUDE_CODE_HOST_PLATFORM
|
|
if (override === 'win32' || override === 'darwin' || override === 'linux') {
|
|
return override
|
|
}
|
|
return env.platform
|
|
}
|