diff --git a/internal/cli/gga_available_test.go b/internal/cli/gga_available_test.go index ef3628483..5b3550f9f 100644 --- a/internal/cli/gga_available_test.go +++ b/internal/cli/gga_available_test.go @@ -54,6 +54,64 @@ func TestGGAAvailableDetectsViaLocalBin(t *testing.T) { } } +// TestGGAAvailableDetectsViaWindowsPS1 verifies that ggaAvailable returns true +// when the Windows PowerShell shim exists in ~/bin/gga.ps1. +func TestGGAAvailableDetectsViaWindowsPS1(t *testing.T) { + tmpHome := t.TempDir() + binDir := filepath.Join(tmpHome, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "gga.ps1"), []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + + origLookPath := cmdLookPath + origHomeDir := osUserHomeDir + origStat := osStat + cmdLookPath = func(file string) (string, error) { return "", os.ErrNotExist } + osUserHomeDir = func() (string, error) { return tmpHome, nil } + osStat = os.Stat + t.Cleanup(func() { + cmdLookPath = origLookPath + osUserHomeDir = origHomeDir + osStat = origStat + }) + + if !ggaAvailable(system.PlatformProfile{OS: "windows", PackageManager: "winget"}) { + t.Fatal("ggaAvailable() = false, want true when gga.ps1 exists on Windows") + } +} + +// TestGGAAvailableDetectsViaWindowsExe verifies that ggaAvailable returns true +// when a native Windows executable exists in ~/bin/gga.exe. +func TestGGAAvailableDetectsViaWindowsExe(t *testing.T) { + tmpHome := t.TempDir() + binDir := filepath.Join(tmpHome, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "gga.exe"), []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + + origLookPath := cmdLookPath + origHomeDir := osUserHomeDir + origStat := osStat + cmdLookPath = func(file string) (string, error) { return "", os.ErrNotExist } + osUserHomeDir = func() (string, error) { return tmpHome, nil } + osStat = os.Stat + t.Cleanup(func() { + cmdLookPath = origLookPath + osUserHomeDir = origHomeDir + osStat = origStat + }) + + if !ggaAvailable(system.PlatformProfile{OS: "windows", PackageManager: "winget"}) { + t.Fatal("ggaAvailable() = false, want true when gga.exe exists on Windows") + } +} + // TestGGAAvailableDetectsViaHomebrewOptPrefix verifies that ggaAvailable returns // true when gga exists at /opt/homebrew/bin/gga (Apple Silicon Homebrew default). func TestGGAAvailableDetectsViaHomebrewOptPrefix(t *testing.T) { diff --git a/internal/cli/run.go b/internal/cli/run.go index b1e5853b6..55afd7e62 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -715,8 +715,10 @@ func ggaAvailable(profile system.PlatformProfile) bool { } } if profile.OS == "windows" { - if _, err := osStat(filepath.Join(homeDir, "bin", "gga")); err == nil { - return true + for _, name := range []string{"gga.ps1", "gga.exe", "gga"} { + if _, err := osStat(filepath.Join(homeDir, "bin", name)); err == nil { + return true + } } } return false diff --git a/internal/update/check_test.go b/internal/update/check_test.go index 255752cb3..59a2e46e9 100644 --- a/internal/update/check_test.go +++ b/internal/update/check_test.go @@ -6,7 +6,9 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "os/exec" + "path/filepath" "testing" "github.com/gentleman-programming/gentle-ai/internal/system" @@ -118,6 +120,97 @@ func TestDetectInstalledVersion(t *testing.T) { } } +func TestDetectInstalledVersionWindowsShim(t *testing.T) { + tmpHome := t.TempDir() + binDir := filepath.Join(tmpHome, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "gga.ps1"), []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + + origLookPath := lookPath + origExecCommand := execCommand + origRuntimeGOOS := runtimeGOOS + origHomeDir := osUserHomeDir + origStat := osStat + t.Cleanup(func() { + lookPath = origLookPath + execCommand = origExecCommand + runtimeGOOS = origRuntimeGOOS + osUserHomeDir = origHomeDir + osStat = origStat + }) + + lookPath = func(string) (string, error) { return "", os.ErrNotExist } + runtimeGOOS = "windows" + osUserHomeDir = func() (string, error) { return tmpHome, nil } + osStat = os.Stat + + got := detectInstalledVersion(context.Background(), ToolInfo{Name: "gga", DetectCmd: []string{"gga", "--version"}}, "dev") + if got != "unknown" { + t.Fatalf("detectInstalledVersion() = %q, want unknown when windows shim exists", got) + } +} + +// TestCheckFilteredWindowsShimKeepsGGAVisible verifies the integrated update +// flow keeps GGA visible on Windows when the PowerShell shim exists. +func TestCheckFilteredWindowsShimKeepsGGAVisible(t *testing.T) { + tmpHome := t.TempDir() + binDir := filepath.Join(tmpHome, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "gga.ps1"), []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(githubRelease{TagName: "v2.0.0", HTMLURL: "https://github.com/Gentleman-Programming/gentleman-guardian-angel/releases/tag/v2.0.0"}) + })) + defer server.Close() + + origClient := httpClient + origLookPath := lookPath + origExecCommand := execCommand + origRuntimeGOOS := runtimeGOOS + origHomeDir := osUserHomeDir + origStat := osStat + t.Cleanup(func() { + httpClient = origClient + lookPath = origLookPath + execCommand = origExecCommand + runtimeGOOS = origRuntimeGOOS + osUserHomeDir = origHomeDir + osStat = origStat + }) + + httpClient = server.Client() + httpClient.Transport = &testTransport{server: server} + lookPath = func(string) (string, error) { return "", os.ErrNotExist } + execCommand = func(name string, args ...string) *exec.Cmd { return exec.Command("false") } + runtimeGOOS = "windows" + osUserHomeDir = func() (string, error) { return tmpHome, nil } + osStat = os.Stat + + results := CheckFiltered(context.Background(), "1.0.0", system.PlatformProfile{OS: "windows", PackageManager: "winget", Supported: true}, []string{"gga"}) + if len(results) != 1 { + t.Fatalf("CheckFiltered() len = %d, want 1", len(results)) + } + if results[0].Tool.Name != "gga" { + t.Fatalf("CheckFiltered() tool = %q, want gga", results[0].Tool.Name) + } + if results[0].Status != VersionUnknown { + t.Fatalf("CheckFiltered() status = %q, want %q for Windows shim visibility", results[0].Status, VersionUnknown) + } + if results[0].Status == NotInstalled { + t.Fatal("CheckFiltered() unexpectedly reported GGA as NotInstalled on Windows shim path") + } +} + func TestParseVersionFromOutput_DevSentinel(t *testing.T) { if got := parseVersionFromOutput("engram dev"); got != "dev" { t.Fatalf("parseVersionFromOutput(engram dev) = %q, want %q", got, "dev") diff --git a/internal/update/detect.go b/internal/update/detect.go index 8349d99df..ab7d54639 100644 --- a/internal/update/detect.go +++ b/internal/update/detect.go @@ -2,16 +2,22 @@ package update import ( "context" + "os" "os/exec" + "path/filepath" "regexp" + "runtime" "strings" "time" ) // Package-level vars for testability (swap in tests via t.Cleanup). var ( - execCommand = exec.Command - lookPath = exec.LookPath + execCommand = exec.Command + lookPath = exec.LookPath + osUserHomeDir = os.UserHomeDir + osStat = os.Stat + runtimeGOOS = runtime.GOOS ) // versionRegexp extracts a semver-like version from command output. @@ -36,6 +42,9 @@ func detectInstalledVersion(ctx context.Context, tool ToolInfo, currentBuildVers binary := tool.DetectCmd[0] if _, err := lookPath(binary); err != nil { + if binary == "gga" && ggaShimInstalledOnWindows() { + return "unknown" + } return "" // binary not found } @@ -68,6 +77,25 @@ func detectInstalledVersion(ctx context.Context, tool ToolInfo, currentBuildVers return parseVersionFromOutput(strings.TrimSpace(string(out))) } +func ggaShimInstalledOnWindows() bool { + if runtimeGOOS != "windows" { + return false + } + + homeDir, err := osUserHomeDir() + if err != nil { + return false + } + + for _, name := range []string{"gga.ps1", "gga.exe", "gga"} { + if _, err := osStat(filepath.Join(homeDir, "bin", name)); err == nil { + return true + } + } + + return false +} + // parseVersionFromOutput extracts the first semver-like pattern from raw output. func parseVersionFromOutput(output string) string { if output == "" {