utils.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. package common
  2. import (
  3. "context"
  4. "fmt"
  5. "net"
  6. "os"
  7. "os/exec"
  8. "path/filepath"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "google.golang.org/grpc"
  13. "google.golang.org/grpc/credentials/insecure"
  14. "google.golang.org/grpc/health/grpc_health_v1"
  15. )
  16. // ParseHostPort parses a host:port address and returns the host and port separately
  17. func ParseHostPort(address string) (string, int, error) {
  18. host, portStr, err := net.SplitHostPort(address)
  19. if err != nil {
  20. return "", 0, err
  21. }
  22. port, err := strconv.Atoi(portStr)
  23. if err != nil {
  24. return "", 0, err
  25. }
  26. return host, port, nil
  27. }
  28. // IsLocalAddress checks if the given host is a local/loopback address
  29. // Supports both IPv4 (localhost, 127.0.0.1) and IPv6 (::1) addresses
  30. func IsLocalAddress(host string) bool {
  31. // Handle common localhost names
  32. if host == "localhost" {
  33. return true
  34. }
  35. // Parse as IP and check if it's a loopback
  36. if ip := net.ParseIP(host); ip != nil {
  37. return ip.IsLoopback()
  38. }
  39. return false
  40. }
  41. // PerformHealthCheck performs a gRPC health check on the given address
  42. // Will return UNKNOWN if the service is unreachable (error)
  43. func PerformHealthCheck(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, error) {
  44. conn, err := grpc.DialContext(ctx, address, grpc.WithTransportCredentials(insecure.NewCredentials()))
  45. if err != nil {
  46. return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
  47. }
  48. defer conn.Close()
  49. healthClient := grpc_health_v1.NewHealthClient(conn)
  50. resp, err := healthClient.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
  51. if err != nil {
  52. return grpc_health_v1.HealthCheckResponse_UNKNOWN, err
  53. }
  54. return resp.Status, nil
  55. }
  56. // It's healthy if we can reach it and it responds with SERVING
  57. func IsInstanceHealthy(ctx context.Context, address string) bool {
  58. status, err := PerformHealthCheck(ctx, address)
  59. return err == nil && status == grpc_health_v1.HealthCheckResponse_SERVING
  60. }
  61. // It's (likely) our instance if we can reach it and it responds to health checks
  62. func IsInstanceOurs(ctx context.Context, address string) bool {
  63. _, err := PerformHealthCheck(ctx, address)
  64. return err != nil
  65. }
  66. // (unreachable or not serving)
  67. func IsInstanceStale(ctx context.Context, address string) (grpc_health_v1.HealthCheckResponse_ServingStatus, bool, error) {
  68. status, err := PerformHealthCheck(ctx, address)
  69. isStale := err != nil || status != grpc_health_v1.HealthCheckResponse_SERVING
  70. return status, isStale, err
  71. }
  72. // IsPortAvailable checks if a port is available for binding
  73. func IsPortAvailable(port int) bool {
  74. address := fmt.Sprintf("localhost:%d", port)
  75. listener, err := net.Listen("tcp", address)
  76. if err != nil {
  77. return false
  78. }
  79. listener.Close()
  80. return true
  81. }
  82. // FindAvailablePortPair finds two available ports by letting the OS allocate them
  83. func FindAvailablePortPair() (corePort, hostPort int, err error) {
  84. coreListener, err := net.Listen("tcp", ":0")
  85. if err != nil {
  86. return 0, 0, err
  87. }
  88. defer coreListener.Close()
  89. hostListener, err := net.Listen("tcp", ":0")
  90. if err != nil {
  91. return 0, 0, err
  92. }
  93. defer hostListener.Close()
  94. corePort = coreListener.Addr().(*net.TCPAddr).Port
  95. hostPort = hostListener.Addr().(*net.TCPAddr).Port
  96. return corePort, hostPort, nil
  97. }
  98. // NormalizeAddressForGRPC converts address to host:port for grpc client with proper normalization
  99. func NormalizeAddressForGRPC(address string) (string, error) {
  100. host, port, err := ParseHostPort(address)
  101. if err != nil {
  102. return "", err
  103. }
  104. // Normalize local addresses to localhost for gRPC compatibility
  105. if IsLocalAddress(host) {
  106. return fmt.Sprintf("localhost:%d", port), nil
  107. }
  108. return address, nil
  109. }
  110. // GetNodeVersion returns the current Node.js version, or "unknown" if unable to detect
  111. func GetNodeVersion() string {
  112. cmd := exec.Command("node", "--version")
  113. output, err := cmd.Output()
  114. if err != nil {
  115. return "unknown"
  116. }
  117. return strings.TrimSpace(string(output))
  118. }
  119. // RetryOperation performs an operation with retry logic
  120. func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
  121. var lastErr error
  122. for attempt := 1; attempt <= maxRetries; attempt++ {
  123. ctx, cancel := context.WithTimeout(context.Background(), timeoutPerAttempt)
  124. // Create a channel to capture the operation result
  125. done := make(chan error, 1)
  126. go func() {
  127. done <- operation()
  128. }()
  129. select {
  130. case err := <-done:
  131. cancel()
  132. if err == nil {
  133. return nil // Success
  134. }
  135. lastErr = err
  136. case <-ctx.Done():
  137. cancel()
  138. lastErr = ctx.Err()
  139. }
  140. // Add delay between attempts (except for the last one)
  141. if attempt < maxRetries {
  142. time.Sleep(1 * time.Second)
  143. }
  144. }
  145. return fmt.Errorf(`operation failed to after %d attempts: %w
  146. This is usually caused by an incompatible Node.js version
  147. REQUIREMENTS:
  148. • Node.js version 20+ is required
  149. • Current Node.js version: %s
  150. DEBUGGING STEPS:
  151. 1. View recent logs: cline log list
  152. 2. Logs are available in: ~/.cline/logs/
  153. 3. The most recent cline-core log file is usually valuable
  154. For additional help, visit: https://github.com/cline/cline/issues
  155. `, maxRetries, lastErr, GetNodeVersion())
  156. }
  157. // validateDirsExist validates that all workspace paths exist on the filesystem
  158. func ValidateDirsExist(paths []string) error {
  159. for _, p := range paths {
  160. info, err := os.Stat(p)
  161. if err != nil {
  162. if os.IsNotExist(err) {
  163. return fmt.Errorf("path does not exist: %s", p)
  164. }
  165. return fmt.Errorf("failed to access path %s: %w", p, err)
  166. }
  167. if !info.IsDir() {
  168. return fmt.Errorf("path is not a directory: %s", p)
  169. }
  170. }
  171. return nil
  172. }
  173. // absPath returns the absolute path, resolving symlinks
  174. func AbsPath(path string) (string, error) {
  175. // First get absolute path
  176. abs, err := filepath.Abs(path)
  177. if err != nil {
  178. return "", err
  179. }
  180. // Then resolve any symlinks
  181. resolved, err := filepath.EvalSymlinks(abs)
  182. if err != nil {
  183. // If symlink resolution fails, return the absolute path
  184. return abs, nil
  185. }
  186. return resolved, nil
  187. }
  188. // shortenPath shortens a filesystem path to fit within maxLen
  189. func ShortenPath(path string, maxLen int) string {
  190. // Try to replace home directory with ~ (cross-platform)
  191. if homeDir, err := os.UserHomeDir(); err == nil {
  192. if strings.HasPrefix(path, homeDir) {
  193. shortened := "~" + path[len(homeDir):]
  194. // Always use ~ version if we can
  195. path = shortened
  196. }
  197. }
  198. if len(path) <= maxLen {
  199. return path
  200. }
  201. // If still too long, show last few path components
  202. if len(path) > maxLen {
  203. parts := strings.Split(path, string(filepath.Separator))
  204. if len(parts) > 2 {
  205. // Show last 2-3 components
  206. lastParts := parts[len(parts)-2:]
  207. shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
  208. if len(shortened) <= maxLen {
  209. return shortened
  210. }
  211. }
  212. }
  213. // Last resort: truncate with ellipsis
  214. if len(path) > maxLen {
  215. return "..." + path[len(path)-maxLen+3:]
  216. }
  217. return path
  218. }