Repository: Gentleman-Programming/gentle-ai (2026-03-14T02:05:29Z)

Issue:
Issue: Engram installation fails when Go is missing or running in GOPATH mode
### Issue: Engram installation fails when Go is missing or running in GOPATH mode

During the installation process of **gentle-ai** on WSL, the installation pipeline fails when installing the **Engram** component.

The failure occurs because the installer runs:

```
go install github.com/Gentleman-Programming/engram/cmd/engram@latest
```

However, in my environment **Go was not installed**, which caused the pipeline to fail without a prior validation step.

Additionally, this command can also fail if Go is installed but running in **GOPATH mode** (older Go installations), which produces the following error:

```
cannot use path@version syntax in GOPATH mode
```

### Current Behavior

The installation proceeds until the `component:engram` step and then fails:

```
FAILED: component:engram - run command "env CGO_ENABLED=0 go install github.com/Gentleman-Programming/engram/cmd/engram@latest": exit status 1

can't load package: package github.com/Gentleman-Programming/engram/cmd/engram@latest:
cannot use path@version syntax in GOPATH mode
```

### Expected Behavior

Before attempting to install **Engram**, the installer should:

1. Validate that **Go is installed**.
2. Verify that the **Go version meets the required minimum** (Go 1.24+ according to the README).
3. Ensure **Go modules are enabled**.

If the requirement is not met, the installer should display a clear message such as:

```
Go 1.24+ is required to install Engram.
Please install Go or update your current version.
```

### Suggested Improvement

Add a dependency check step before installing Engram:

* Detect if `go` is available in PATH
* Validate `go version`
* Ensure modules support (`go env GO111MODULE`)

This would prevent the installation pipeline from failing midway and improve the user experience.

### Environment

* OS: WSL (Ubuntu)
* Installation method: TUI
* gentle-ai version: latest release

### Additional Context

Screenshot of the failure during installation is attached.

<img width="1660" height="734" alt="Image" src="https://github.com/user-attachments/assets/1e153cc5-1ac2-41b5-a0da-dc6155ba5ed1" />



Discussion:

Patch:
diff --git a/internal/installcmd/resolver.go b/internal/installcmd/resolver.go
index 4c125094..af5f758e 100644
--- a/internal/installcmd/resolver.go
+++ b/internal/installcmd/resolver.go
@@ -5,15 +5,20 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
 
 	"github.com/gentleman-programming/gentle-ai/internal/model"
 	"github.com/gentleman-programming/gentle-ai/internal/system"
 )
 
-// cmdLookPath and osStat are package-level vars for testability.
+// cmdLookPath, osStat, osGetenv, and cmdGoVersion are package-level vars for testability.
 var cmdLookPath = exec.LookPath
 var osStat = os.Stat
+var osGetenv = os.Getenv
+var cmdGoVersion = func() ([]byte, error) {
+	return exec.Command("go", "version").Output()
+}
 
 // CommandSequence represents an ordered list of commands to run in sequence.
 // Each inner slice is a single command with its arguments (e.g., ["brew", "install", "engram"]).
@@ -207,19 +212,70 @@ func gitBashPath() string {
 	return "bash"
 }
 
+// validateGoForModuleInstall checks that Go ≥1.24 is installed and GO111MODULE is not
+// disabled before attempting `go install`. Returns an actionable error if any check fails.
+// MUST NOT be called for brew-based installs (brew manages Go transitively).
+func validateGoForModuleInstall(profile system.PlatformProfile) error {
+	if _, err := cmdLookPath("go"); err != nil {
+		return fmt.Errorf(
+			"Go 1.24+ is required to install Engram but was not found in PATH.\n" +
+				"Please install Go from https://go.dev/dl/ and restart your terminal.")
+	}
+
+	out, err := cmdGoVersion()
+	if err != nil {
+		return fmt.Errorf(
+			"Go 1.24+ is required but could not verify the installed version.\n" +
+				"Please ensure Go is properly installed: https://go.dev/dl/")
+	}
+
+	// Parse "go version go1.XX.Y platform/arch"
+	parts := strings.Fields(string(out))
+	if len(parts) >= 3 {
+		versionStr := strings.TrimPrefix(parts[2], "go")
+		versionParts := strings.SplitN(versionStr, ".", 3)
+		if len(versionParts) >= 2 {
+			major, _ := strconv.Atoi(versionParts[0])
+			minor, _ := strconv.Atoi(versionParts[1])
+			if major < 1 || (major == 1 && minor < 24) {
+				return fmt.Errorf(
+					"Go 1.24+ is required to install Engram, but found go%s.\n"+
+						"Please update Go: https://go.dev/dl/", versionStr)
+			}
+		}
+	}
+
+	if osGetenv("GO111MODULE") == "off" {
+		fix := "export GO111MODULE=on  # then retry"
+		if profile.OS == "windows" {
+			fix = `$env:GO111MODULE = "on"  # PowerShell, then retry`
+		}
+		return fmt.Errorf("Go modules are disabled (GO111MODULE=off).\nRun: %s", fix)
+	}
+
+	return nil
+}
+
 // resolveEngramInstall returns the correct install command sequence for Engram per platform.
 // - darwin: brew tap + brew install (via Gentleman-Programming/homebrew-tap)
-// - linux: go install (engram is not in any Linux distro's repos)
+// - linux/windows: go install (engram is not in any distro repo or winget)
 func resolveEngramInstall(profile system.PlatformProfile) (CommandSequence, error) {
 	switch profile.PackageManager {
 	case "brew":
+		// macOS: brew manages Go transitively — no preflight needed.
 		return CommandSequence{
 			{"brew", "tap", "Gentleman-Programming/homebrew-tap"},
 			{"brew", "install", "engram"},
 		}, nil
 	case "apt", "pacman":
+		if err := validateGoForModuleInstall(profile); err != nil {
+			return nil, err
+		}
 		return CommandSequence{{"env", "CGO_ENABLED=0", "go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"}}, nil
 	case "winget":
+		if err := validateGoForModuleInstall(profile); err != nil {
+			return nil, err
+		}
 		// On Windows, use go install (Engram has no winget package yet).
 		return CommandSequence{{"go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"}}, nil
 	default:


Test patch:
diff --git a/e2e/Dockerfile.ubuntu b/e2e/Dockerfile.ubuntu
index e630beb1..ab95999d 100644
--- a/e2e/Dockerfile.ubuntu
+++ b/e2e/Dockerfile.ubuntu
@@ -24,7 +24,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
 # ---------------------------------------------------------------------------
 # Install Go (match go.mod version)
 # ---------------------------------------------------------------------------
-ARG GO_VERSION=1.22.5
+ARG GO_VERSION=1.24.2
 RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
     | tar -C /usr/local -xz
 ENV PATH="/usr/local/go/bin:${PATH}"
diff --git a/internal/cli/run_integration_test.go b/internal/cli/run_integration_test.go
index 29f86d4c..50e42a68 100644
--- a/internal/cli/run_integration_test.go
+++ b/internal/cli/run_integration_test.go
@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/gentleman-programming/gentle-ai/internal/agents/opencode"
+	"github.com/gentleman-programming/gentle-ai/internal/installcmd"
 	"github.com/gentleman-programming/gentle-ai/internal/system"
 )
 
@@ -147,6 +148,29 @@ func (r *commandRecorder) get() []string {
 	return cp
 }
 
+// setupValidGoEnvInInstallcmd overrides the installcmd package-level vars to simulate a valid
+// Go 1.24+ environment. It registers restores via t.Cleanup.
+//
+// Without this, integration tests that invoke resolveEngramInstall() on Linux/Windows
+// profiles will call the real `go version` binary — which fails the Go >= 1.24 preflight in
+// CI environments that ship an older Go (e.g. Docker images pinned to Go 1.22).
+//
+// Note: installcmd.cmdLookPath and cli.cmdLookPath are INDEPENDENT package-level vars.
+// This helper only overrides the installcmd ones; cli.cmdLookPath must be overridden separately.
+func setupValidGoEnvInInstallcmd(t *testing.T) {
+	t.Helper()
+	t.Cleanup(installcmd.OverrideGoVersion(func() ([]byte, error) {
+		return []byte("go version go1.24.0 linux/amd64"), nil
+	}))
+	t.Cleanup(installcmd.OverrideLookPath(func(name string) (string, error) {
+		if name == "go" {
+			return "/usr/local/bin/go", nil
+		}
+		return "", exec.ErrNotFound
+	}))
+	t.Cleanup(installcmd.OverrideGetenv(func(string) string { return "" }))
+}
+
 func TestRunInstallLinuxUbuntuResolvesAptCommands(t *testing.T) {
 	home := t.TempDir()
 	restoreHome := osUserHomeDir
@@ -234,6 +258,8 @@ func TestRunInstallLinuxUbuntuWithEngramResolvesGoInstallCommand(t *testing.T) {
 	cmdLookPath = missingBinaryLookPath
 	recorder := &commandRecorder{}
 	runCommand = recorder.record
+	// Isolate installcmd Go preflight from the real go binary on the test runner.
+	setupValidGoEnvInInstallcmd(t)
 
 	detection := linuxDetectionResult(system.LinuxDistroUbuntu, "apt")
 	result, err := RunInstall(
@@ -277,6 +303,8 @@ func TestRunInstallLinuxArchWithEngramResolvesGoInstallCommand(t *testing.T) {
 	cmdLookPath = missingBinaryLookPath
 	recorder := &commandRecorder{}
 	runCommand = recorder.record
+	// Isolate installcmd Go preflight from the real go binary on the test runner.
+	setupValidGoEnvInInstallcmd(t)
 
 	detection := linuxDetectionResult(system.LinuxDistroArch, "pacman")
 	result, err := RunInstall(
@@ -325,6 +353,8 @@ func TestRunInstallLinuxRollsBackOnComponentFailure(t *testing.T) {
 		cmdLookPath = restoreLookPath
 	})
 	cmdLookPath = missingBinaryLookPath
+	// Isolate installcmd Go preflight from the real go binary on the test runner.
+	setupValidGoEnvInInstallcmd(t)
 
 	osUserHomeDir = func() (string, error) { return home, nil }
 	runCommand = func(name string, args ...string) error {
@@ -985,12 +1015,17 @@ func TestRunInstallEngramAutoInstallsGoWhenMissing(t *testing.T) {
 	})
 
 	osUserHomeDir = func() (string, error) { return home, nil }
-	// Simulate: engram missing, Go missing.
+	// Simulate: engram missing, Go missing (in the cli package LookPath).
+	// Note: installcmd.cmdLookPath is a separate var — we mock it below to satisfy
+	// the Go >= 1.24 preflight that runs inside resolveEngramInstall.
 	cmdLookPath = func(string) (string, error) {
 		return "", exec.ErrNotFound
 	}
 	recorder := &commandRecorder{}
 	runCommand = recorder.record
+	// Isolate installcmd Go preflight from the real go binary on the test runner.
+	// installcmd.cmdLookPath (for "go") and cli.cmdLookPath are independent variables.
+	setupValidGoEnvInInstallcmd(t)
 
 	detection := linuxDetectionResult(system.LinuxDistroUbuntu, "apt")
 	result, err := RunInstall(
@@ -1048,6 +1083,8 @@ func TestRunInstallEngramSkipsGoInstallWhenGoPresent(t *testing.T) {
 	cmdLookPath = missingBinaryLookPath
 	recorder := &commandRecorder{}
 	runCommand = recorder.record
+	// Isolate installcmd Go preflight from the real go binary on the test runner.
+	setupValidGoEnvInInstallcmd(t)
 
 	detection := linuxDetectionResult(system.LinuxDistroUbuntu, "apt")
 	result, err := RunInstall(
diff --git a/internal/installcmd/resolver_test.go b/internal/installcmd/resolver_test.go
index 3d946bd5..989e4d08 100644
--- a/internal/installcmd/resolver_test.go
+++ b/internal/installcmd/resolver_test.go
@@ -5,12 +5,143 @@ import (
 	"os"
 	"path/filepath"
 	"reflect"
+	"strings"
 	"testing"
 
 	"github.com/gentleman-programming/gentle-ai/internal/model"
 	"github.com/gentleman-programming/gentle-ai/internal/system"
 )
 
+func TestValidateGoForModuleInstall(t *testing.T) {
+	tests := []struct {
+		name        string
+		profile     system.PlatformProfile
+		lookPath    func(string) (string, error)
+		goVersion   func() ([]byte, error)
+		env         map[string]string
+		wantErr     bool
+		errContains string
+	}{
+		{
+			name:    "go not in PATH returns error mentioning Go 1.24+",
+			profile: system.PlatformProfile{OS: "linux", PackageManager: "apt"},
+			lookPath: func(file string) (string, error) {
+				return "", fmt.Errorf("not found")
+			},
+			goVersion:   func() ([]byte, error) { return nil, nil },
+			env:         map[string]string{},
+			wantErr:     true,
+			errContains: "Go 1.24+",
+		},
+		{
+			name:    "go version below 1.24 returns error",
+			profile: system.PlatformProfile{OS: "linux", PackageManager: "apt"},
+			lookPath: func(file string) (string, error) {
+				return "/usr/bin/go", nil
+			},
+			goVersion:   func() ([]byte, error) { return []byte("go version go1.21.0 linux/amd64"), nil },
+			env:         map[string]string{},
+			wantErr:     true,
+			errContains: "Go 1.24+",
+		},
+		{
+			name:    "go version 1.23 returns error",
+			profile: system.PlatformProfile{OS: "linux", PackageManager: "apt"},
+			lookPath: func(file string) (string, error) {
+				return "/usr/bin/go", nil
+			},
+			goVersion:   func() ([]byte, error) { return []byte("go version go1.23.5 linux/amd64"), nil },
+			env:         map[string]string{},
+			wantErr:     true,
+			errContains: "Go 1.24+",
+		},
+		{
+			name:    "GO111MODULE=off on linux returns error with export fix",
+			profile: system.PlatformProfile{OS: "linux", PackageManager: "apt"},
+			lookPath: func(file string) (string, error) {
+				return "/usr/bin/go", nil
+			},
+			goVersion:   func() ([]byte, error) { return []byte("go version go1.24.0 linux/amd64"), nil },
+			env:         map[string]string{"GO111MODULE": "off"},
+			wantErr:     true,
+			errContains: "export GO111MODULE=on",
+		},
+		{
+			name:    "GO111MODULE=off on windows returns error with powershell fix",
+			profile: system.PlatformProfile{OS: "windows", PackageManager: "winget"},
+			lookPath: func(file string) (string, error) {
+				return `C:\Go\bin\go.exe`, nil
+			},
+			goVersion:   func() ([]byte, error) { return []byte("go version go1.24.0 windows/amd64"), nil },
+			env:         map[string]string{"GO111MODULE": "off"},
+			wantErr:     true,
+			errContains: "$env:GO111MODULE",
+		},
+		{
+			name:    "go 1.24 without GO111MODULE=off succeeds",
+			profile: system.PlatformProfile{OS: "linux", PackageManager: "apt"},
+			lookPath: func(file string) (string, error) {
+				return "/usr/bin/go", nil
+			},
+			goVersion: func() ([]byte, error) { return []byte("go version go1.24.0 linux/amd64"), nil },
+			env:       map[string]string{},
+			wantErr:   false,
+		},
+		{
+			name:    "go 1.25 succeeds",
+			profile: system.PlatformProfile{OS: "linux", PackageManager: "apt"},
+			lookPath: func(file string) (string, error) {
+				return "/usr/bin/go", nil
+			},
+			goVersion: func() ([]byte, error) { return []byte("go version go1.25.0 linux/amd64"), nil },
+			env:       map[string]string{},
+			wantErr:   false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			origLookPath := cmdLookPath
+			origGoVersion := cmdGoVersion
+			origGetenv := osGetenv
+			cmdLookPath = tt.lookPath
+			cmdGoVersion = tt.goVersion
+			osGetenv = func(key string) string { return tt.env[key] }
+			t.Cleanup(func() {
+				cmdLookPath = origLookPath
+				cmdGoVersion = origGoVersion
+				osGetenv = origGetenv
+			})
+
+			err := validateGoForModuleInstall(tt.profile)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("validateGoForModuleInstall() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if tt.wantErr && !strings.Contains(err.Error(), tt.errContains) {
+				t.Errorf("error = %q, want it to contain %q", err.Error(), tt.errContains)
+			}
+		})
+	}
+}
+
+func TestResolveEngramBrewBypassesGoValidation(t *testing.T) {
+	// On macOS, brew manages Go — validation must be skipped entirely.
+	origLookPath := cmdLookPath
+	cmdLookPath = func(file string) (string, error) {
+		return "", fmt.Errorf("go not found")
+	}
+	t.Cleanup(func() { cmdLookPath = origLookPath })
+
+	profile := system.PlatformProfile{OS: "darwin", PackageManager: "brew"}
+	cmds, err := resolveEngramInstall(profile)
+	if err != nil {
+		t.Fatalf("resolveEngramInstall() unexpected error = %v", err)
+	}
+	if len(cmds) == 0 {
+		t.Fatal("resolveEngramInstall() returned empty CommandSequence")
+	}
+}
+
 func TestResolveDependencyInstall(t *testing.T) {
 	r := NewResolver()
 
@@ -243,6 +374,25 @@ func TestResolveAgentInstall(t *testing.T) {
 func TestResolveComponentInstall(t *testing.T) {
 	r := NewResolver()
 
+	// Simulate a valid Go 1.24+ environment for all engram resolution tests.
+	// Specific error scenarios are covered in TestValidateGoForModuleInstall.
+	origGoVersion := cmdGoVersion
+	origGetenv := osGetenv
+	origLookPath := cmdLookPath
+	cmdGoVersion = func() ([]byte, error) { return []byte("go version go1.24.0 linux/amd64"), nil }
+	osGetenv = func(key string) string { return "" }
+	cmdLookPath = func(file string) (string, error) {
+		if file == "go" {
+			return "/usr/bin/go", nil
+		}
+		return origLookPath(file)
+	}
+	t.Cleanup(func() {
+		cmdGoVersion = origGoVersion
+		osGetenv = origGetenv
+		cmdLookPath = origLookPath
+	})
+
 	tests := []struct {
 		name      string
 		profile   system.PlatformProfile
diff --git a/internal/installcmd/testhelper.go b/internal/installcmd/testhelper.go
new file mode 100644
index 00000000..b6cb77fa
--- /dev/null
+++ b/internal/installcmd/testhelper.go
@@ -0,0 +1,29 @@
+// testhelper.go — test setup helpers for the installcmd package.
+// These functions are exported so that integration tests in other packages (e.g. internal/cli)
+// can override the package-level vars that normally call real system binaries.
+// They must NOT be used in production code paths.
+package installcmd
+
+// OverrideGoVersion replaces cmdGoVersion with fn and returns a restore function.
+// Intended for tests in external packages that exercise the Go >= 1.24 preflight
+// without shelling out to the real `go` binary.
+func OverrideGoVersion(fn func() ([]byte, error)) (restore func()) {
+	prev := cmdGoVersion
+	cmdGoVersion = fn
+	return func() { cmdGoVersion = prev }
+}
+
+// OverrideLookPath replaces cmdLookPath (installcmd's internal lookup, independent from
+// cli.cmdLookPath) with fn and returns a restore function.
+func OverrideLookPath(fn func(string) (string, error)) (restore func()) {
+	prev := cmdLookPath
+	cmdLookPath = fn
+	return func() { cmdLookPath = prev }
+}
+
+// OverrideGetenv replaces osGetenv with fn and returns a restore function.
+func OverrideGetenv(fn func(string) string) (restore func()) {
+	prev := osGetenv
+	osGetenv = fn
+	return func() { osGetenv = prev }
+}