/ utils / envDynamic.ts
envDynamic.ts
  1  import { feature } from 'bun:bundle'
  2  import { stat } from 'fs/promises'
  3  import memoize from 'lodash-es/memoize.js'
  4  import { env, JETBRAINS_IDES } from './env.js'
  5  import { isEnvTruthy } from './envUtils.js'
  6  import { execFileNoThrow } from './execFileNoThrow.js'
  7  import { getAncestorCommandsAsync } from './genericProcessUtils.js'
  8  
  9  // Functions that require execFileNoThrow and thus cannot be in env.ts
 10  
 11  const getIsDocker = memoize(async (): Promise<boolean> => {
 12    if (process.platform !== 'linux') return false
 13    // Check for .dockerenv file
 14    const { code } = await execFileNoThrow('test', ['-f', '/.dockerenv'])
 15    return code === 0
 16  })
 17  
 18  function getIsBubblewrapSandbox(): boolean {
 19    return (
 20      process.platform === 'linux' &&
 21      isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
 22    )
 23  }
 24  
 25  // Cache for the runtime musl detection fallback (node/unbundled only).
 26  // In native linux builds, feature flags resolve this at compile time, so the
 27  // cache is only consulted when both IS_LIBC_MUSL and IS_LIBC_GLIBC are false.
 28  let muslRuntimeCache: boolean | null = null
 29  
 30  // Fire-and-forget: populate the musl cache for the node fallback path.
 31  // Native builds never reach this (feature flags short-circuit), so this only
 32  // matters for unbundled node on Linux. Installer calls on native builds are
 33  // unaffected since feature() resolves at compile time.
 34  if (process.platform === 'linux') {
 35    const muslArch = process.arch === 'x64' ? 'x86_64' : 'aarch64'
 36    void stat(`/lib/libc.musl-${muslArch}.so.1`).then(
 37      () => {
 38        muslRuntimeCache = true
 39      },
 40      () => {
 41        muslRuntimeCache = false
 42      },
 43    )
 44  }
 45  
 46  /**
 47   * Checks if the system is using MUSL libc instead of glibc.
 48   * In native linux builds, this is statically known at compile time via IS_LIBC_MUSL/IS_LIBC_GLIBC flags.
 49   * In node (unbundled), both flags are false and we fall back to a runtime async stat check
 50   * whose result is cached at module load. If the cache isn't populated yet, returns false.
 51   */
 52  function isMuslEnvironment(): boolean {
 53    if (feature('IS_LIBC_MUSL')) return true
 54    if (feature('IS_LIBC_GLIBC')) return false
 55  
 56    // Fallback for node: runtime detection via pre-populated cache
 57    if (process.platform !== 'linux') return false
 58    return muslRuntimeCache ?? false
 59  }
 60  
 61  // Cache for async JetBrains detection
 62  let jetBrainsIDECache: string | null | undefined
 63  
 64  async function detectJetBrainsIDEFromParentProcessAsync(): Promise<
 65    string | null
 66  > {
 67    if (jetBrainsIDECache !== undefined) {
 68      return jetBrainsIDECache
 69    }
 70  
 71    if (process.platform === 'darwin') {
 72      jetBrainsIDECache = null
 73      return null // macOS uses bundle ID detection which is already handled
 74    }
 75  
 76    try {
 77      // Get ancestor commands in a single call (avoids sync bash in loop)
 78      const commands = await getAncestorCommandsAsync(process.pid, 10)
 79  
 80      for (const command of commands) {
 81        const lowerCommand = command.toLowerCase()
 82        // Check for specific JetBrains IDEs in the command line
 83        for (const ide of JETBRAINS_IDES) {
 84          if (lowerCommand.includes(ide)) {
 85            jetBrainsIDECache = ide
 86            return ide
 87          }
 88        }
 89      }
 90    } catch {
 91      // Silently fail - this is a best-effort detection
 92    }
 93  
 94    jetBrainsIDECache = null
 95    return null
 96  }
 97  
 98  export async function getTerminalWithJetBrainsDetectionAsync(): Promise<
 99    string | null
100  > {
101    // Check for JetBrains terminal on Linux/Windows
102    if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') {
103      // For macOS, bundle ID detection above already handles JetBrains IDEs
104      if (env.platform !== 'darwin') {
105        const specificIDE = await detectJetBrainsIDEFromParentProcessAsync()
106        return specificIDE || 'pycharm'
107      }
108    }
109    return env.terminal
110  }
111  
112  // Synchronous version that returns cached result or falls back to env.terminal
113  // Used for backward compatibility - callers should migrate to async version
114  export function getTerminalWithJetBrainsDetection(): string | null {
115    // Check for JetBrains terminal on Linux/Windows
116    if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') {
117      // For macOS, bundle ID detection above already handles JetBrains IDEs
118      if (env.platform !== 'darwin') {
119        // Return cached value if available, otherwise fall back to generic detection
120        // The async version should be called early in app initialization to populate cache
121        if (jetBrainsIDECache !== undefined) {
122          return jetBrainsIDECache || 'pycharm'
123        }
124        // Fall back to generic 'pycharm' if cache not populated yet
125        return 'pycharm'
126      }
127    }
128    return env.terminal
129  }
130  
131  /**
132   * Initialize JetBrains IDE detection asynchronously.
133   * Call this early in app initialization to populate the cache.
134   * After this resolves, getTerminalWithJetBrainsDetection() will return accurate results.
135   */
136  export async function initJetBrainsDetection(): Promise<void> {
137    if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') {
138      await detectJetBrainsIDEFromParentProcessAsync()
139    }
140  }
141  
142  // Combined export that includes all env properties plus dynamic functions
143  export const envDynamic = {
144    ...env, // Include all properties from env
145    terminal: getTerminalWithJetBrainsDetection(),
146    getIsDocker,
147    getIsBubblewrapSandbox,
148    isMuslEnvironment,
149    getTerminalWithJetBrainsDetectionAsync,
150    initJetBrainsDetection,
151  }