windowsPaths.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import memoize from 'lodash-es/memoize.js'
  2. import * as path from 'path'
  3. import * as pathWin32 from 'path/win32'
  4. import { getCwd } from './cwd.js'
  5. import { logForDebugging } from './debug.js'
  6. import { execSync_DEPRECATED } from './execSyncWrapper.js'
  7. import { memoizeWithLRU } from './memoize.js'
  8. import { getPlatform } from './platform.js'
  9. /**
  10. * Check if a file or directory exists on Windows using the dir command
  11. * @param path - The path to check
  12. * @returns true if the path exists, false otherwise
  13. */
  14. function checkPathExists(path: string): boolean {
  15. try {
  16. execSync_DEPRECATED(`dir "${path}"`, { stdio: 'pipe' })
  17. return true
  18. } catch {
  19. return false
  20. }
  21. }
  22. /**
  23. * Find an executable using where.exe on Windows
  24. * @param executable - The name of the executable to find
  25. * @returns The path to the executable or null if not found
  26. */
  27. function findExecutable(executable: string): string | null {
  28. // For git, check common installation locations first
  29. if (executable === 'git') {
  30. const defaultLocations = [
  31. // check 64 bit before 32 bit
  32. 'C:\\Program Files\\Git\\cmd\\git.exe',
  33. 'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
  34. // intentionally don't look for C:\Program Files\Git\mingw64\bin\git.exe
  35. // because that directory is the "raw" tools with no environment setup
  36. ]
  37. for (const location of defaultLocations) {
  38. if (checkPathExists(location)) {
  39. return location
  40. }
  41. }
  42. }
  43. // Fall back to where.exe
  44. try {
  45. const result = execSync_DEPRECATED(`where.exe ${executable}`, {
  46. stdio: 'pipe',
  47. encoding: 'utf8',
  48. }).trim()
  49. // SECURITY: Filter out any results from the current directory
  50. // to prevent executing malicious git.bat/cmd/exe files
  51. const paths = result.split('\r\n').filter(Boolean)
  52. const cwd = getCwd().toLowerCase()
  53. for (const candidatePath of paths) {
  54. // Normalize and compare paths to ensure we're not in current directory
  55. const normalizedPath = path.resolve(candidatePath).toLowerCase()
  56. const pathDir = path.dirname(normalizedPath).toLowerCase()
  57. // Skip if the executable is in the current working directory
  58. if (pathDir === cwd || normalizedPath.startsWith(cwd + path.sep)) {
  59. logForDebugging(
  60. `Skipping potentially malicious executable in current directory: ${candidatePath}`,
  61. )
  62. continue
  63. }
  64. // Return the first valid path that's not in the current directory
  65. return candidatePath
  66. }
  67. return null
  68. } catch {
  69. return null
  70. }
  71. }
  72. /**
  73. * If Windows, set the SHELL environment variable to git-bash path.
  74. * This is used by BashTool and Shell.ts for user shell commands.
  75. * COMSPEC is left unchanged for system process execution.
  76. */
  77. export function setShellIfWindows(): void {
  78. if (getPlatform() === 'windows') {
  79. const gitBashPath = findGitBashPath()
  80. process.env.SHELL = gitBashPath
  81. logForDebugging(`Using bash path: "${gitBashPath}"`)
  82. }
  83. }
  84. /**
  85. * Find the path where `bash.exe` included with git-bash exists, exiting the process if not found.
  86. */
  87. export const findGitBashPath = memoize((): string => {
  88. if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
  89. if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) {
  90. return process.env.CLAUDE_CODE_GIT_BASH_PATH
  91. }
  92. // biome-ignore lint/suspicious/noConsole:: intentional console output
  93. console.error(
  94. `Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
  95. )
  96. // eslint-disable-next-line custom-rules/no-process-exit
  97. process.exit(1)
  98. }
  99. const gitPath = findExecutable('git')
  100. if (gitPath) {
  101. const bashPath = pathWin32.join(gitPath, '..', '..', 'bin', 'bash.exe')
  102. if (checkPathExists(bashPath)) {
  103. return bashPath
  104. }
  105. }
  106. // biome-ignore lint/suspicious/noConsole:: intentional console output
  107. console.error(
  108. 'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe',
  109. )
  110. // eslint-disable-next-line custom-rules/no-process-exit
  111. process.exit(1)
  112. })
  113. /** Convert a Windows path to a POSIX path using pure JS. */
  114. export const windowsPathToPosixPath = memoizeWithLRU(
  115. (windowsPath: string): string => {
  116. // Handle UNC paths: \\server\share -> //server/share
  117. if (windowsPath.startsWith('\\\\')) {
  118. return windowsPath.replace(/\\/g, '/')
  119. }
  120. // Handle drive letter paths: C:\Users\foo -> /c/Users/foo
  121. const match = windowsPath.match(/^([A-Za-z]):[/\\]/)
  122. if (match) {
  123. const driveLetter = match[1]!.toLowerCase()
  124. return '/' + driveLetter + windowsPath.slice(2).replace(/\\/g, '/')
  125. }
  126. // Already POSIX or relative — just flip slashes
  127. return windowsPath.replace(/\\/g, '/')
  128. },
  129. (p: string) => p,
  130. 500,
  131. )
  132. /** Convert a POSIX path to a Windows path using pure JS. */
  133. export const posixPathToWindowsPath = memoizeWithLRU(
  134. (posixPath: string): string => {
  135. // Handle UNC paths: //server/share -> \\server\share
  136. if (posixPath.startsWith('//')) {
  137. return posixPath.replace(/\//g, '\\')
  138. }
  139. // Handle /cygdrive/c/... format
  140. const cygdriveMatch = posixPath.match(/^\/cygdrive\/([A-Za-z])(\/|$)/)
  141. if (cygdriveMatch) {
  142. const driveLetter = cygdriveMatch[1]!.toUpperCase()
  143. const rest = posixPath.slice(('/cygdrive/' + cygdriveMatch[1]).length)
  144. return driveLetter + ':' + (rest || '\\').replace(/\//g, '\\')
  145. }
  146. // Handle /c/... format (MSYS2/Git Bash)
  147. const driveMatch = posixPath.match(/^\/([A-Za-z])(\/|$)/)
  148. if (driveMatch) {
  149. const driveLetter = driveMatch[1]!.toUpperCase()
  150. const rest = posixPath.slice(2)
  151. return driveLetter + ':' + (rest || '\\').replace(/\//g, '\\')
  152. }
  153. // Already Windows or relative — just flip slashes
  154. return posixPath.replace(/\//g, '\\')
  155. },
  156. (p: string) => p,
  157. 500,
  158. )