diff --git a/internal/pkg/envcheck/capabilities.go b/internal/pkg/envcheck/capabilities.go index 36e66ff..30f2117 100644 --- a/internal/pkg/envcheck/capabilities.go +++ b/internal/pkg/envcheck/capabilities.go @@ -9,6 +9,7 @@ import ( "encoding/json" "os" "os/exec" + "path/filepath" "runtime" "strings" "time" @@ -31,16 +32,27 @@ type DistroInfo struct { PrettyName string `json:"pretty_name,omitempty"` // e.g., "Ubuntu 24.04 LTS" } +// XcodeInfo holds Xcode and iOS development information +type XcodeInfo struct { + Version string `json:"version,omitempty"` + Build string `json:"build,omitempty"` + SDKs []string `json:"sdks,omitempty"` // e.g., ["iOS 17.0", "macOS 14.0"] + Simulators []string `json:"simulators,omitempty"` // Available iOS simulators +} + // RunnerCapabilities represents the capabilities of a runner for AI consumption type RunnerCapabilities struct { OS string `json:"os"` Arch string `json:"arch"` Distro *DistroInfo `json:"distro,omitempty"` + Xcode *XcodeInfo `json:"xcode,omitempty"` Docker bool `json:"docker"` DockerCompose bool `json:"docker_compose"` ContainerRuntime string `json:"container_runtime,omitempty"` Shell []string `json:"shell,omitempty"` Tools map[string][]string `json:"tools,omitempty"` + BuildTools []string `json:"build_tools,omitempty"` // Available build/installer tools + PackageManagers []string `json:"package_managers,omitempty"` Features *CapabilityFeatures `json:"features,omitempty"` Limitations []string `json:"limitations,omitempty"` Disk *DiskInfo `json:"disk,omitempty"` @@ -59,10 +71,12 @@ type CapabilityFeatures struct { // DetectCapabilities detects the runner's capabilities func DetectCapabilities(ctx context.Context, dockerHost string) *RunnerCapabilities { cap := &RunnerCapabilities{ - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Tools: make(map[string][]string), - Shell: detectShells(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Tools: make(map[string][]string), + BuildTools: []string{}, + PackageManagers: []string{}, + Shell: detectShells(), Features: &CapabilityFeatures{ ArtifactsV4: false, // Gitea doesn't support v4 artifacts Cache: true, @@ -80,6 +94,11 @@ func DetectCapabilities(ctx context.Context, dockerHost string) *RunnerCapabilit cap.Distro = detectLinuxDistro() } + // Detect macOS Xcode/iOS + if runtime.GOOS == "darwin" { + cap.Xcode = detectXcode(ctx) + } + // Detect Docker cap.Docker, cap.ContainerRuntime = detectDocker(ctx, dockerHost) if cap.Docker { @@ -90,6 +109,12 @@ func DetectCapabilities(ctx context.Context, dockerHost string) *RunnerCapabilit // Detect common tools detectTools(ctx, cap) + // Detect build tools + detectBuildTools(ctx, cap) + + // Detect package managers + detectPackageManagers(ctx, cap) + // Detect disk space cap.Disk = detectDiskSpace() @@ -99,6 +124,85 @@ func DetectCapabilities(ctx context.Context, dockerHost string) *RunnerCapabilit return cap } +// detectXcode detects Xcode and iOS development capabilities on macOS +func detectXcode(ctx context.Context) *XcodeInfo { + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // Check for xcodebuild + cmd := exec.CommandContext(timeoutCtx, "xcodebuild", "-version") + output, err := cmd.Output() + if err != nil { + return nil + } + + xcode := &XcodeInfo{} + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "Xcode ") { + xcode.Version = strings.TrimPrefix(line, "Xcode ") + } else if strings.HasPrefix(line, "Build version ") { + xcode.Build = strings.TrimPrefix(line, "Build version ") + } + } + + // Get available SDKs + cmd = exec.CommandContext(timeoutCtx, "xcodebuild", "-showsdks") + output, err = cmd.Output() + if err == nil { + lines = strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // Look for SDK lines like "-sdk iphoneos17.0" or "iOS 17.0" + if strings.Contains(line, "SDK") || strings.HasPrefix(line, "-sdk") { + continue // Skip header lines + } + if strings.Contains(line, "iOS") || strings.Contains(line, "macOS") || + strings.Contains(line, "watchOS") || strings.Contains(line, "tvOS") { + // Extract SDK name + if idx := strings.Index(line, "-sdk"); idx != -1 { + sdkPart := strings.TrimSpace(line[:idx]) + if sdkPart != "" { + xcode.SDKs = append(xcode.SDKs, sdkPart) + } + } + } + } + } + + // Get available simulators + cmd = exec.CommandContext(timeoutCtx, "xcrun", "simctl", "list", "devices", "available", "-j") + output, err = cmd.Output() + if err == nil { + var simData struct { + Devices map[string][]struct { + Name string `json:"name"` + State string `json:"state"` + } `json:"devices"` + } + if json.Unmarshal(output, &simData) == nil { + seen := make(map[string]bool) + for runtime, devices := range simData.Devices { + if strings.Contains(runtime, "iOS") { + for _, dev := range devices { + key := dev.Name + if !seen[key] { + seen[key] = true + xcode.Simulators = append(xcode.Simulators, dev.Name) + } + } + } + } + } + } + + if xcode.Version == "" { + return nil + } + + return xcode +} + // detectLinuxDistro reads /etc/os-release to get distribution info func detectLinuxDistro() *DistroInfo { file, err := os.Open("/etc/os-release") @@ -160,10 +264,50 @@ func generateSuggestedLabels(cap *RunnerCapabilities) []string { addLabel(distro + "-latest") } + // Xcode/iOS labels (macOS only) + if cap.Xcode != nil { + addLabel("xcode") + // Check for iOS SDK + for _, sdk := range cap.Xcode.SDKs { + if strings.Contains(strings.ToLower(sdk), "ios") { + addLabel("ios") + break + } + } + // If simulators available, add simulator label + if len(cap.Xcode.Simulators) > 0 { + addLabel("ios-simulator") + } + } + + // Tool-based labels + if _, ok := cap.Tools["dotnet"]; ok { + addLabel("dotnet") + } + if _, ok := cap.Tools["java"]; ok { + addLabel("java") + } + if _, ok := cap.Tools["node"]; ok { + addLabel("node") + } + + // Build tool labels + for _, tool := range cap.BuildTools { + switch tool { + case "msbuild": + addLabel("msbuild") + case "visual-studio": + addLabel("vs2022") // or detect actual version + case "inno-setup": + addLabel("inno-setup") + case "nsis": + addLabel("nsis") + } + } + return labels } - // ToJSON converts capabilities to JSON string for transmission func (c *RunnerCapabilities) ToJSON() string { data, err := json.Marshal(c) @@ -251,12 +395,18 @@ func detectDockerCompose(ctx context.Context) bool { func detectTools(ctx context.Context, cap *RunnerCapabilities) { toolDetectors := map[string]func(context.Context) []string{ - "node": detectNodeVersions, - "go": detectGoVersions, - "python": detectPythonVersions, - "java": detectJavaVersions, - "dotnet": detectDotnetVersions, - "rust": detectRustVersions, + "node": detectNodeVersions, + "go": detectGoVersions, + "python": detectPythonVersions, + "java": detectJavaVersions, + "dotnet": detectDotnetVersions, + "rust": detectRustVersions, + "ruby": detectRubyVersions, + "php": detectPHPVersions, + "swift": detectSwiftVersions, + "kotlin": detectKotlinVersions, + "flutter": detectFlutterVersions, + "dart": detectDartVersions, } for tool, detector := range toolDetectors { @@ -264,6 +414,242 @@ func detectTools(ctx context.Context, cap *RunnerCapabilities) { cap.Tools[tool] = versions } } + + // Detect additional tools that just need presence check + simpleTools := map[string]string{ + "git": "git", + "cmake": "cmake", + "make": "make", + "ninja": "ninja", + "gradle": "gradle", + "maven": "mvn", + "npm": "npm", + "yarn": "yarn", + "pnpm": "pnpm", + "cargo": "cargo", + "pip": "pip3", + } + + for name, cmd := range simpleTools { + if v := detectSimpleToolVersion(ctx, cmd); v != "" { + cap.Tools[name] = []string{v} + } + } +} + +func detectBuildTools(ctx context.Context, cap *RunnerCapabilities) { + switch runtime.GOOS { + case "windows": + detectWindowsBuildTools(ctx, cap) + case "darwin": + detectMacOSBuildTools(ctx, cap) + case "linux": + detectLinuxBuildTools(ctx, cap) + } +} + +func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) { + // Check for Visual Studio via vswhere + vswherePaths := []string{ + `C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe`, + `C:\Program Files\Microsoft Visual Studio\Installer\vswhere.exe`, + } + for _, vswhere := range vswherePaths { + if _, err := os.Stat(vswhere); err == nil { + cmd := exec.CommandContext(ctx, vswhere, "-latest", "-property", "displayName") + if output, err := cmd.Output(); err == nil && len(output) > 0 { + cap.BuildTools = append(cap.BuildTools, "visual-studio") + break + } + } + } + + // Check for MSBuild + msbuildPaths := []string{ + `C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe`, + `C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe`, + `C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe`, + `C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe`, + `C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe`, + `C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe`, + } + for _, msbuild := range msbuildPaths { + if _, err := os.Stat(msbuild); err == nil { + cap.BuildTools = append(cap.BuildTools, "msbuild") + break + } + } + + // Check for Inno Setup + innoSetupPaths := []string{ + `C:\Program Files (x86)\Inno Setup 6\ISCC.exe`, + `C:\Program Files\Inno Setup 6\ISCC.exe`, + `C:\Program Files (x86)\Inno Setup 5\ISCC.exe`, + `C:\Program Files\Inno Setup 5\ISCC.exe`, + } + for _, iscc := range innoSetupPaths { + if _, err := os.Stat(iscc); err == nil { + cap.BuildTools = append(cap.BuildTools, "inno-setup") + break + } + } + // Also check PATH + if _, err := exec.LookPath("iscc"); err == nil { + if !contains(cap.BuildTools, "inno-setup") { + cap.BuildTools = append(cap.BuildTools, "inno-setup") + } + } + + // Check for NSIS + nsisPaths := []string{ + `C:\Program Files (x86)\NSIS\makensis.exe`, + `C:\Program Files\NSIS\makensis.exe`, + } + for _, nsis := range nsisPaths { + if _, err := os.Stat(nsis); err == nil { + cap.BuildTools = append(cap.BuildTools, "nsis") + break + } + } + if _, err := exec.LookPath("makensis"); err == nil { + if !contains(cap.BuildTools, "nsis") { + cap.BuildTools = append(cap.BuildTools, "nsis") + } + } + + // Check for WiX Toolset + wixPaths := []string{ + `C:\Program Files (x86)\WiX Toolset v3.11\bin\candle.exe`, + `C:\Program Files (x86)\WiX Toolset v3.14\bin\candle.exe`, + } + for _, wix := range wixPaths { + if _, err := os.Stat(wix); err == nil { + cap.BuildTools = append(cap.BuildTools, "wix") + break + } + } + + // Check for signtool (Windows SDK) + signtoolPaths, _ := filepath.Glob(`C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe`) + if len(signtoolPaths) > 0 { + cap.BuildTools = append(cap.BuildTools, "signtool") + } +} + +func detectMacOSBuildTools(ctx context.Context, cap *RunnerCapabilities) { + // Check for xcpretty + if _, err := exec.LookPath("xcpretty"); err == nil { + cap.BuildTools = append(cap.BuildTools, "xcpretty") + } + + // Check for fastlane + if _, err := exec.LookPath("fastlane"); err == nil { + cap.BuildTools = append(cap.BuildTools, "fastlane") + } + + // Check for CocoaPods + if _, err := exec.LookPath("pod"); err == nil { + cap.BuildTools = append(cap.BuildTools, "cocoapods") + } + + // Check for Carthage + if _, err := exec.LookPath("carthage"); err == nil { + cap.BuildTools = append(cap.BuildTools, "carthage") + } + + // Check for SwiftLint + if _, err := exec.LookPath("swiftlint"); err == nil { + cap.BuildTools = append(cap.BuildTools, "swiftlint") + } + + // Check for create-dmg or similar + if _, err := exec.LookPath("create-dmg"); err == nil { + cap.BuildTools = append(cap.BuildTools, "create-dmg") + } + + // Check for Packages (packagesbuild) + if _, err := exec.LookPath("packagesbuild"); err == nil { + cap.BuildTools = append(cap.BuildTools, "packages") + } + + // Check for pkgbuild (built-in) + if _, err := exec.LookPath("pkgbuild"); err == nil { + cap.BuildTools = append(cap.BuildTools, "pkgbuild") + } + + // Check for codesign (built-in) + if _, err := exec.LookPath("codesign"); err == nil { + cap.BuildTools = append(cap.BuildTools, "codesign") + } + + // Check for notarytool (built-in with Xcode) + if _, err := exec.LookPath("notarytool"); err == nil { + cap.BuildTools = append(cap.BuildTools, "notarytool") + } +} + +func detectLinuxBuildTools(ctx context.Context, cap *RunnerCapabilities) { + // Check for common Linux build tools + tools := []string{ + "gcc", "g++", "clang", "clang++", + "autoconf", "automake", "libtool", + "pkg-config", "meson", + "dpkg-deb", "rpmbuild", "fpm", + "appimage-builder", "linuxdeploy", + } + + for _, tool := range tools { + if _, err := exec.LookPath(tool); err == nil { + cap.BuildTools = append(cap.BuildTools, tool) + } + } +} + +func detectPackageManagers(ctx context.Context, cap *RunnerCapabilities) { + switch runtime.GOOS { + case "windows": + if _, err := exec.LookPath("choco"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "chocolatey") + } + if _, err := exec.LookPath("scoop"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "scoop") + } + if _, err := exec.LookPath("winget"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "winget") + } + case "darwin": + if _, err := exec.LookPath("brew"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "homebrew") + } + if _, err := exec.LookPath("port"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "macports") + } + case "linux": + if _, err := exec.LookPath("apt"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "apt") + } + if _, err := exec.LookPath("yum"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "yum") + } + if _, err := exec.LookPath("dnf"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "dnf") + } + if _, err := exec.LookPath("pacman"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "pacman") + } + if _, err := exec.LookPath("zypper"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "zypper") + } + if _, err := exec.LookPath("apk"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "apk") + } + if _, err := exec.LookPath("snap"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "snap") + } + if _, err := exec.LookPath("flatpak"); err == nil { + cap.PackageManagers = append(cap.PackageManagers, "flatpak") + } + } } func detectNodeVersions(ctx context.Context) []string { @@ -284,16 +670,8 @@ func detectPythonVersions(ctx context.Context) []string { // Also try python if v := detectToolVersion(ctx, "python", "--version", "Python "); len(v) > 0 { - // Avoid duplicates for _, ver := range v { - found := false - for _, existing := range versions { - if existing == ver { - found = true - break - } - } - if !found { + if !contains(versions, ver) { versions = append(versions, ver) } } @@ -309,20 +687,17 @@ func detectJavaVersions(ctx context.Context) []string { return nil } - // Java version output goes to stderr and looks like: openjdk version "17.0.1" or java version "1.8.0_301" lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.Contains(line, "version") { - // Extract version from quotes start := strings.Index(line, "\"") end := strings.LastIndex(line, "\"") if start != -1 && end > start { version := line[start+1 : end] - // Simplify version (e.g., "17.0.1" -> "17") parts := strings.Split(version, ".") if len(parts) > 0 { if parts[0] == "1" && len(parts) > 1 { - return []string{parts[1]} // Java 8 style: 1.8 -> 8 + return []string{parts[1]} } return []string{parts[0]} } @@ -347,21 +722,11 @@ func detectDotnetVersions(ctx context.Context) []string { if line == "" { continue } - // Format: "8.0.100 [/path/to/sdk]" parts := strings.Split(line, " ") if len(parts) > 0 { version := parts[0] - // Simplify to major version major := strings.Split(version, ".")[0] - // Avoid duplicates - found := false - for _, v := range versions { - if v == major { - found = true - break - } - } - if !found { + if !contains(versions, major) { versions = append(versions, major) } } @@ -374,6 +739,58 @@ func detectRustVersions(ctx context.Context) []string { return detectToolVersion(ctx, "rustc", "--version", "rustc ") } +func detectRubyVersions(ctx context.Context) []string { + return detectToolVersion(ctx, "ruby", "--version", "ruby ") +} + +func detectPHPVersions(ctx context.Context) []string { + return detectToolVersion(ctx, "php", "--version", "PHP ") +} + +func detectSwiftVersions(ctx context.Context) []string { + return detectToolVersion(ctx, "swift", "--version", "Swift version ") +} + +func detectKotlinVersions(ctx context.Context) []string { + return detectToolVersion(ctx, "kotlin", "-version", "Kotlin version ") +} + +func detectFlutterVersions(ctx context.Context) []string { + return detectToolVersion(ctx, "flutter", "--version", "Flutter ") +} + +func detectDartVersions(ctx context.Context) []string { + return detectToolVersion(ctx, "dart", "--version", "Dart SDK version: ") +} + +func detectSimpleToolVersion(ctx context.Context, cmd string) string { + if _, err := exec.LookPath(cmd); err != nil { + return "" + } + + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + c := exec.CommandContext(timeoutCtx, cmd, "--version") + output, err := c.Output() + if err != nil { + // Try without --version for tools that don't support it + return "installed" + } + + line := strings.TrimSpace(strings.Split(string(output), "\n")[0]) + // Extract version number if possible + parts := strings.Fields(line) + for _, part := range parts { + // Look for something that looks like a version + if len(part) > 0 && (part[0] >= '0' && part[0] <= '9' || part[0] == 'v') { + return strings.TrimPrefix(part, "v") + } + } + + return "installed" +} + func detectToolVersion(ctx context.Context, cmd string, args string, prefix string) []string { timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -391,13 +808,10 @@ func detectToolVersion(ctx context.Context, cmd string, args string, prefix stri } } - // Get just the version number parts := strings.Fields(line) if len(parts) > 0 { version := parts[0] - // Clean up version string version = strings.TrimPrefix(version, "v") - // Return major.minor or just major vparts := strings.Split(version, ".") if len(vparts) >= 2 { return []string{vparts[0] + "." + vparts[1]} @@ -407,3 +821,12 @@ func detectToolVersion(ctx context.Context, cmd string, args string, prefix stri return nil } + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +}