diff --git a/docs/platforms.md b/docs/platforms.md index 8676096c6..ae9f0724b 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -10,11 +10,24 @@ | Linux (Ubuntu/Debian) | apt | Supported | | Linux (Arch) | pacman | Supported | | Linux (Fedora/RHEL family) | dnf | Supported | +| Android (Termux) | apt | Supported | | Windows 10/11 | Scoop | Supported | -Derivatives are detected via `ID_LIKE` in `/etc/os-release` (Linux Mint, Pop!_OS, Manjaro, EndeavourOS, CentOS Stream, Rocky Linux, AlmaLinux, etc.). +Derivatives are detected via `ID_LIKE` in `/etc/os-release` (Linux Mint, Pop!_OS, Manjaro, EndeavourOS, CentOS Stream, Rocky Linux, AlmaLinux, etc.). Termux is detected as Android/`GOOS=android` in the Go application; the shell installer also recognizes the Termux environment. -Release artifacts are produced by CI, but Windows users should install through Scoop so upgrades stay consistent. +Release binaries are built for `linux`, `darwin`, and `windows` on both `amd64` and `arm64`. Android (Termux) is supported via source compilation (`go install`) since pre-built glibc binaries are incompatible with Android's Bionic libc. + +Windows release artifacts are produced by CI, but Windows users should install through Scoop so upgrades stay consistent. + +--- + +## Termux (Android) Notes + +- **apt** is used as the default package manager within Termux. +- **Prefix Awareness**: gentle-ai automatically detects the Termux `$PREFIX` and adjusts system paths accordingly (e.g., using `$PREFIX/bin/bash` instead of `/bin/bash`). +- **PATH Persistence**: When installing tools, gentle-ai will automatically append the appropriate `export PATH` commands to your `~/.bashrc` or `~/.zshrc`. +- **PIE Requirement**: All binaries updated via `gentle-ai self-update` on Termux are automatically compiled as Position Independent Executables (PIE), as required by Android. +- **Sub-agents**: Sub-agents like GGA are installed into `$PREFIX/tmp` during the setup process to ensure execution permissions. --- diff --git a/e2e/docker-test.sh b/e2e/docker-test.sh index 9fa7bc733..b507a5093 100755 --- a/e2e/docker-test.sh +++ b/e2e/docker-test.sh @@ -21,6 +21,16 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +# --------------------------------------------------------------------------- +# Pre-flight: Docker must be available +# --------------------------------------------------------------------------- +if ! command -v docker &>/dev/null; then + printf '%s[ORCH]%s docker is not installed or not in PATH.\n' "$RED" "$NC" + printf '%s[ORCH]%s E2E tests require Docker. On platforms without Docker support\n' "$RED" "$NC" + printf '%s[ORCH]%s (e.g. Android/Termux), these tests cannot run.\n' "$RED" "$NC" + exit 1 +fi + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- diff --git a/internal/components/engram/download.go b/internal/components/engram/download.go index b12054e73..e28a44567 100644 --- a/internal/components/engram/download.go +++ b/internal/components/engram/download.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -28,10 +29,13 @@ const ( // Package-level vars for testability. var ( - engramHTTPClient = &http.Client{Timeout: 5 * time.Minute} - engramGitHubBaseURL = "https://github.com" - engramInstallDirFn = engramInstallDir - engramChecksumURLFn = engramChecksumURL + engramHTTPClient = &http.Client{Timeout: 5 * time.Minute} + engramGitHubBaseURL = "https://github.com" + engramInstallDirFn = engramInstallDir + engramChecksumURLFn = engramChecksumURL + engramExecCommand = exec.Command + engramGetenv = os.Getenv + engramUserHomeDir = os.UserHomeDir ) // DownloadLatestBinary fetches the latest engram release from GitHub and @@ -51,6 +55,9 @@ func DownloadLatestBinary(profile system.PlatformProfile) (string, error) { if err != nil { return "", fmt.Errorf("fetch latest engram version: %w", err) } + if profile.OS == "android" { + return installViaGo(version) + } // 2. Determine binary name and archive URL. goos := profile.OS @@ -122,6 +129,59 @@ func DownloadLatestBinary(profile system.PlatformProfile) (string, error) { return outPath, nil } +// installViaGo compiles engram from source for Android/Termux, where release +// binaries built against glibc are incompatible with Android's Bionic libc. +func installViaGo(version string) (string, error) { + if strings.TrimSpace(version) == "" { + return "", fmt.Errorf("go install engram: version is required") + } + + installDir, extraEnv, err := goInstallDestination() + if err != nil { + return "", err + } + if err := os.MkdirAll(installDir, 0o755); err != nil { + return "", fmt.Errorf("create engram install dir %q: %w", installDir, err) + } + + target := fmt.Sprintf("github.com/Gentleman-Programming/engram/cmd/engram@v%s", version) + args := []string{"install", "-ldflags=-extldflags=-pie", target} + cmd := engramExecCommand("go", args...) + if len(extraEnv) > 0 { + cmd.Env = append(os.Environ(), extraEnv...) + } + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("go %v: %w (output: %s)", args, err, string(out)) + } + + return filepath.Join(installDir, engramName), nil +} + +func goInstallDestination() (string, []string, error) { + // Priority 1: GOBIN environment variable. + if gobin := engramGetenv("GOBIN"); gobin != "" { + return gobin, nil, nil + } + + // Priority 2: GOPATH/bin + if gopath := engramGetenv("GOPATH"); gopath != "" { + return filepath.Join(gopath, "bin"), nil, nil + } + + installDir := engramInstallDirFn("android") + if strings.TrimSpace(installDir) == "" { + home, err := engramUserHomeDir() + if err != nil { + return "", nil, fmt.Errorf("find home dir: %w", err) + } + installDir = filepath.Join(home, ".local", "bin") + } + + // Go installs to GOBIN when set; without GOBIN and GOPATH it defaults to + // ~/go/bin, which is less predictable for a managed installer. + return installDir, []string{"GOBIN=" + installDir}, nil +} + // fetchLatestEngramVersion queries the GitHub Releases API for the latest engram // release and returns the version string (without leading "v"). func fetchLatestEngramVersion() (string, error) { @@ -316,6 +376,8 @@ func engramArchiveName(version, goos, goarch string) string { // engramAssetURL constructs the download URL for the engram release asset. func engramAssetURL(baseURL, version, goos, goarch string) string { + // Android/Termux intentionally bypasses release assets in DownloadLatestBinary + // and compiles from source because Linux glibc binaries do not run on Bionic. filename := engramArchiveName(version, goos, goarch) return fmt.Sprintf("%s/%s/%s/releases/download/v%s/%s", baseURL, engramOwner, engramRepo, version, filename) @@ -424,6 +486,7 @@ func extractZipBinary(data []byte, binaryName, outPath string) error { // for the given OS. // - Linux/macOS: /usr/local/bin (fallback: ~/.local/bin if not writable) // - Windows: %LOCALAPPDATA%\engram\bin +// - Android: ~/.local/bin when GOBIN/GOPATH are not already configured. func engramInstallDir(goos string) string { if goos == "windows" { localAppData := os.Getenv("LOCALAPPDATA") @@ -434,6 +497,16 @@ func engramInstallDir(goos string) string { return filepath.Join(localAppData, "engram", "bin") } + if goos == "android" { + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".local", "bin") + } + if home := os.Getenv("HOME"); home != "" { + return filepath.Join(home, ".local", "bin") + } + return "/data/data/com.termux/files/home/.local/bin" + } + // Linux/macOS: try /usr/local/bin first. candidate := "/usr/local/bin" if isWritableDir(candidate) { diff --git a/internal/components/engram/download_test.go b/internal/components/engram/download_test.go index ae9919082..4d06cc2a8 100644 --- a/internal/components/engram/download_test.go +++ b/internal/components/engram/download_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -237,6 +238,11 @@ func TestEngramInstallDir(t *testing.T) { goos: "darwin", wantSubstr: "bin", }, + { + name: "android returns ~/.local/bin", + goos: "android", + wantSubstr: filepath.Join(".local", "bin"), + }, } for _, tt := range tests { @@ -366,6 +372,108 @@ func TestDownloadLatestBinaryAPIError(t *testing.T) { } } +func TestDownloadLatestBinaryAndroidInstallsVersionedSourceWithPIE(t *testing.T) { + const version = "1.3.0" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "releases/latest") { + t.Fatalf("unexpected request: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"tag_name": "v" + version}) + })) + defer server.Close() + + origClient := engramHTTPClient + origBaseURL := engramGitHubBaseURL + origExecCommand := engramExecCommand + origGetenv := engramGetenv + origUserHomeDir := engramUserHomeDir + t.Cleanup(func() { + engramHTTPClient = origClient + engramGitHubBaseURL = origBaseURL + engramExecCommand = origExecCommand + engramGetenv = origGetenv + engramUserHomeDir = origUserHomeDir + }) + + engramHTTPClient = server.Client() + engramGitHubBaseURL = server.URL + + var gotName string + var gotArgs []string + engramExecCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string{}, args...) + cmd := exec.Command(os.Args[0], "-test.run=TestEngramCommandHelper", "--") + cmd.Env = append(os.Environ(), "ENGRAM_HELPER_PROCESS=1") + return cmd + } + + gobin := filepath.Join(t.TempDir(), "go-bin") + engramGetenv = func(key string) string { + if key == "GOBIN" { + return gobin + } + return "" + } + engramUserHomeDir = func() (string, error) { return t.TempDir(), nil } + + profile := system.PlatformProfile{OS: "android", LinuxDistro: system.LinuxDistroTermux, PackageManager: "apt"} + installedPath, err := DownloadLatestBinary(profile) + if err != nil { + t.Fatalf("DownloadLatestBinary() error = %v", err) + } + + wantArgs := []string{"install", "-ldflags=-extldflags=-pie", "github.com/Gentleman-Programming/engram/cmd/engram@v" + version} + if gotName != "go" { + t.Fatalf("exec name = %q, want go", gotName) + } + if strings.Join(gotArgs, " ") != strings.Join(wantArgs, " ") { + t.Fatalf("exec args = %v, want %v", gotArgs, wantArgs) + } + if installedPath != filepath.Join(gobin, engramName) { + t.Fatalf("installedPath = %q, want %q", installedPath, filepath.Join(gobin, engramName)) + } +} + +func TestGoInstallDestinationDefaultsToManagedAndroidDir(t *testing.T) { + origGetenv := engramGetenv + origInstallDirFn := engramInstallDirFn + t.Cleanup(func() { + engramGetenv = origGetenv + engramInstallDirFn = origInstallDirFn + }) + + installDir := filepath.Join(t.TempDir(), ".local", "bin") + engramGetenv = func(string) string { return "" } + engramInstallDirFn = func(goos string) string { + if goos != "android" { + t.Fatalf("engramInstallDirFn called with %q, want android", goos) + } + return installDir + } + + gotDir, gotEnv, err := goInstallDestination() + if err != nil { + t.Fatalf("goInstallDestination() error = %v", err) + } + if gotDir != installDir { + t.Fatalf("dir = %q, want %q", gotDir, installDir) + } + wantEnv := "GOBIN=" + installDir + if len(gotEnv) != 1 || gotEnv[0] != wantEnv { + t.Fatalf("env = %v, want [%q]", gotEnv, wantEnv) + } +} + +func TestEngramCommandHelper(t *testing.T) { + if os.Getenv("ENGRAM_HELPER_PROCESS") != "1" { + return + } + os.Exit(0) +} + func TestDownloadLatestBinarySkipsLatestReleaseWithoutBinaryAssets(t *testing.T) { const binaryVersion = "1.15.13" diff --git a/internal/installcmd/resolver.go b/internal/installcmd/resolver.go index 4cdf9cce8..a588d18fa 100644 --- a/internal/installcmd/resolver.go +++ b/internal/installcmd/resolver.go @@ -61,10 +61,7 @@ func (profileResolver) ResolveAgentInstall(profile system.PlatformProfile, agent // for npm packages. The version is pinned to avoid pulling a tampered "latest" tag. func resolveClaudeCodeInstall(profile system.PlatformProfile) CommandSequence { pkg := "@anthropic-ai/claude-code@" + versions.ClaudeCode - if profile.OS == "linux" && !profile.NpmWritable { - return CommandSequence{{"sudo", "npm", "install", "-g", "--ignore-scripts", pkg}} - } - return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", pkg}} + return CommandSequence{npmGlobalInstallCommand(profile, pkg)} } // resolveKilocodeInstall returns the npm install command sequence for Kilocode. @@ -72,10 +69,7 @@ func resolveClaudeCodeInstall(profile system.PlatformProfile) CommandSequence { // On Windows and macOS, sudo is never needed. func resolveKilocodeInstall(profile system.PlatformProfile) CommandSequence { pkg := "@kilocode/cli@" + versions.Kilocode - if profile.OS == "linux" && !profile.NpmWritable { - return CommandSequence{{"sudo", "npm", "install", "-g", "--ignore-scripts", pkg}} - } - return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", pkg}} + return CommandSequence{npmGlobalInstallCommand(profile, pkg)} } // resolveKimiInstall returns the official Kimi install command sequence. @@ -135,6 +129,9 @@ func uvInstallHint(profile system.PlatformProfile) string { case "brew": return "brew install uv" case "apt": + if isAndroidTermux(profile) { + return "apt-get install -y uv (or see https://docs.astral.sh/uv/getting-started/installation/)" + } return "sudo apt-get install -y uv (or see https://docs.astral.sh/uv/getting-started/installation/)" case "pacman": return "sudo pacman -S --noconfirm uv" @@ -158,6 +155,27 @@ func (profileResolver) ResolveComponentInstall(profile system.PlatformProfile, c } } +// withSudo prepends "sudo" to a command unless the profile is Android/Termux, +// which is rootless and does not provide sudo. +func withSudo(profile system.PlatformProfile, cmd []string) []string { + if isAndroidTermux(profile) { + return cmd + } + return append([]string{"sudo"}, cmd...) +} + +func npmGlobalInstallCommand(profile system.PlatformProfile, pkg string) []string { + cmd := []string{"npm", "install", "-g", "--ignore-scripts", pkg} + if profile.OS == "linux" && !profile.NpmWritable { + return withSudo(profile, cmd) + } + return cmd +} + +func isAndroidTermux(profile system.PlatformProfile) bool { + return profile.OS == "android" +} + func (profileResolver) ResolveDependencyInstall(profile system.PlatformProfile, dependency string) (CommandSequence, error) { if dependency == "" { return nil, fmt.Errorf("dependency name is required") @@ -167,11 +185,11 @@ func (profileResolver) ResolveDependencyInstall(profile system.PlatformProfile, case "brew": return CommandSequence{{"brew", "install", dependency}}, nil case "apt": - return CommandSequence{{"sudo", "apt-get", "install", "-y", dependency}}, nil + return CommandSequence{withSudo(profile, []string{"apt-get", "install", "-y", dependency})}, nil case "pacman": - return CommandSequence{{"sudo", "pacman", "-S", "--noconfirm", dependency}}, nil + return CommandSequence{withSudo(profile, []string{"pacman", "-S", "--noconfirm", dependency})}, nil case "dnf": - return CommandSequence{{"sudo", "dnf", "install", "-y", dependency}}, nil + return CommandSequence{withSudo(profile, []string{"dnf", "install", "-y", dependency})}, nil case "winget": return CommandSequence{{"winget", "install", "--id", dependency, "-e", "--accept-source-agreements", "--accept-package-agreements"}}, nil default: @@ -196,10 +214,7 @@ func resolveOpenCodeInstall(profile system.PlatformProfile) (CommandSequence, er }, nil case "apt", "pacman", "dnf": pkg := "opencode-ai@" + versions.OpenCode - if profile.NpmWritable { - return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", pkg}}, nil - } - return CommandSequence{{"sudo", "npm", "install", "-g", "--ignore-scripts", pkg}}, nil + return CommandSequence{npmGlobalInstallCommand(profile, pkg)}, nil case "winget": // On Windows, npm global installs do not require sudo. return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, nil @@ -215,6 +230,8 @@ func resolveOpenCodeInstall(profile system.PlatformProfile) (CommandSequence, er // - darwin: brew tap + brew install (via Gentleman-Programming/homebrew-tap) // - linux: git clone + install.sh (GGA is a pure Bash project, NOT a Go module) func resolveGGAInstall(profile system.PlatformProfile) (CommandSequence, error) { + resolver := system.NewResolverForProfile(profile) + switch profile.PackageManager { case "brew": return CommandSequence{ @@ -222,7 +239,7 @@ func resolveGGAInstall(profile system.PlatformProfile) (CommandSequence, error) {"brew", "reinstall", "gga"}, }, nil case "apt", "pacman", "dnf": - const tmpDir = "/tmp/gentleman-guardian-angel" + tmpDir := resolver.Resolve("/tmp/gentleman-guardian-angel") return CommandSequence{ {"rm", "-rf", tmpDir}, {"git", "clone", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", tmpDir}, diff --git a/internal/installcmd/resolver_test.go b/internal/installcmd/resolver_test.go index 8610d0ed0..7868b23af 100644 --- a/internal/installcmd/resolver_test.go +++ b/internal/installcmd/resolver_test.go @@ -165,6 +165,12 @@ func TestResolveDependencyInstall(t *testing.T) { dep: "somepkg", want: CommandSequence{{"sudo", "apt-get", "install", "-y", "somepkg"}}, }, + { + name: "android termux resolves apt command without sudo", + profile: system.PlatformProfile{OS: "android", LinuxDistro: system.LinuxDistroTermux, PackageManager: "apt"}, + dep: "somepkg", + want: CommandSequence{{"apt-get", "install", "-y", "somepkg"}}, + }, { name: "arch resolves pacman command", profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroArch, PackageManager: "pacman"}, @@ -340,6 +346,12 @@ func TestResolveAgentInstall(t *testing.T) { agent: model.AgentOpenCode, want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, }, + { + name: "opencode on android termux skips sudo", + profile: system.PlatformProfile{OS: "android", LinuxDistro: system.LinuxDistroTermux, PackageManager: "apt"}, + agent: model.AgentOpenCode, + want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, + }, { name: "opencode on arch system npm uses sudo", profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroArch, PackageManager: "pacman"}, @@ -415,6 +427,33 @@ func TestResolveAgentInstall(t *testing.T) { } } +func TestResolveGGAInstallAndroidUsesTermuxPrefix(t *testing.T) { + prefix := "/data/data/com.termux/files/usr" + t.Setenv("PREFIX", prefix) + + command, err := NewResolver().ResolveComponentInstall( + system.PlatformProfile{OS: "android", LinuxDistro: system.LinuxDistroTermux, PackageManager: "apt"}, + model.ComponentGGA, + ) + if err != nil { + t.Fatalf("ResolveComponentInstall() error = %v", err) + } + + wantDir := prefix + "/tmp/gentleman-guardian-angel" + if len(command) != 3 { + t.Fatalf("ResolveComponentInstall() returned %d commands, want 3: %v", len(command), command) + } + if got := filepath.ToSlash(command[0][2]); got != wantDir { + t.Fatalf("cleanup dir = %q, want %q", got, wantDir) + } + if got := filepath.ToSlash(command[1][3]); got != wantDir { + t.Fatalf("clone dir = %q, want %q", got, wantDir) + } + if got := filepath.ToSlash(command[2][1]); got != wantDir+"/install.sh" { + t.Fatalf("install script = %q, want %q", got, wantDir+"/install.sh") + } +} + func TestValidateAgentInstallPreflight(t *testing.T) { tests := []struct { name string diff --git a/internal/system/detect.go b/internal/system/detect.go index dea63d866..55f695148 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -31,6 +31,7 @@ const ( LinuxDistroDebian = "debian" LinuxDistroArch = "arch" LinuxDistroFedora = "fedora" + LinuxDistroTermux = "termux" ) type DetectionResult struct { @@ -41,7 +42,7 @@ type DetectionResult struct { } func IsSupportedOS(goos string) bool { - return goos == "darwin" || goos == "linux" || goos == "windows" + return goos == "darwin" || goos == "linux" || goos == "windows" || goos == "android" } func Detect(ctx context.Context) (DetectionResult, error) { @@ -123,6 +124,11 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt } switch goos { + case "android": + profile.LinuxDistro = LinuxDistroTermux + profile.PackageManager = "apt" + profile.Supported = true + return profile case "darwin": profile.PackageManager = "brew" profile.Supported = true @@ -204,6 +210,9 @@ func detectLinuxDistro(linuxOSRelease string) string { return LinuxDistroFedora } + // Note: Termux is NOT detected via os-release ID. GOOS=android handles + // Termux detection exclusively via the "android" case in resolvePlatformProfile. + return LinuxDistroUnknown } diff --git a/internal/system/detect_test.go b/internal/system/detect_test.go index 7b9df5ed9..49d385a74 100644 --- a/internal/system/detect_test.go +++ b/internal/system/detect_test.go @@ -11,6 +11,8 @@ func TestIsSupportedOS(t *testing.T) { {name: "darwin is supported", goos: "darwin", want: true}, {name: "linux is supported", goos: "linux", want: true}, {name: "windows is supported", goos: "windows", want: true}, + {name: "android is supported", goos: "android", want: true}, + {name: "freebsd is not supported", goos: "freebsd", want: false}, } for _, tc := range tests { @@ -73,6 +75,22 @@ func TestDetectFromInputsMarksUbuntuSupported(t *testing.T) { } } +func TestDetectFromInputsMarksAndroidSupported(t *testing.T) { + result := detectFromInputs("android", "arm64", "/bin/bash", "", nil, nil) + + if !result.System.Supported { + t.Fatalf("expected android to be supported") + } + + if result.System.Profile.LinuxDistro != LinuxDistroTermux { + t.Fatalf("expected termux distro, got %q", result.System.Profile.LinuxDistro) + } + + if result.System.Profile.PackageManager != "apt" { + t.Fatalf("expected apt package manager for android, got %q", result.System.Profile.PackageManager) + } +} + func TestDetectFromInputsMarksArchSupported(t *testing.T) { osRelease := "ID=arch\nID_LIKE=archlinux\n" result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil) @@ -168,6 +186,11 @@ func TestDetectLinuxDistroMatrix(t *testing.T) { osRelease: "ID=custom-linux\nID_LIKE=\"nobara\"\n", wantDistro: LinuxDistroFedora, }, + { + name: "termux os-release is not detected via linux path", + osRelease: "ID=termux\n", + wantDistro: LinuxDistroUnknown, + }, { name: "empty os-release", osRelease: "", diff --git a/internal/system/guard.go b/internal/system/guard.go index 341adaf6d..be396c581 100644 --- a/internal/system/guard.go +++ b/internal/system/guard.go @@ -18,7 +18,7 @@ func EnsureSupportedOS(goos string) error { return nil } - return fmt.Errorf("%w: only macOS, Linux, and Windows are supported (detected %s)", ErrUnsupportedOS, goos) + return fmt.Errorf("%w: only macOS, Linux, Windows, and Android are supported (detected %s)", ErrUnsupportedOS, goos) } func EnsureSupportedPlatform(profile PlatformProfile) error { diff --git a/internal/system/guard_test.go b/internal/system/guard_test.go index dab6993fa..f1766ad68 100644 --- a/internal/system/guard_test.go +++ b/internal/system/guard_test.go @@ -28,7 +28,7 @@ func TestEnsureSupportedOSRejectsUnsupported(t *testing.T) { t.Fatalf("expected ErrUnsupportedOS, got %v", err) } - if !strings.Contains(err.Error(), "only macOS, Linux, and Windows are supported") { + if !strings.Contains(err.Error(), "only macOS, Linux, Windows, and Android are supported") { t.Fatalf("expected explicit OS support message, got %q", err.Error()) } } diff --git a/internal/system/path.go b/internal/system/path.go index 2046409eb..94fffd74e 100644 --- a/internal/system/path.go +++ b/internal/system/path.go @@ -9,17 +9,30 @@ import ( "strings" ) -// AddToUserPath adds a directory to the Windows user PATH persistently. -// Uses PowerShell to modify the user-scoped environment variable in the registry, -// which survives terminal restarts without requiring admin privileges. +var pathGOOS = runtime.GOOS + +// AddToUserPath adds a directory to the user PATH persistently. +// +// On Windows, it modifies the user-scoped PATH in the registry via PowerShell, +// surviving terminal restarts without admin privileges. // -// On non-Windows platforms this is a no-op (returns nil immediately after adding -// to the current process PATH). This is safe to call on all platforms since the -// binary is cross-compiled — build tags are NOT used. +// On non-Windows platforms, the directory is added to the current process PATH. +// On Termux (Android), it is also persisted to ~/.bashrc or ~/.zshrc. +// +// This is safe to call on all platforms — build tags are NOT used. func AddToUserPath(dir string) error { - if runtime.GOOS != "windows" { + if pathGOOS != "windows" { // Still add to the current process PATH on non-Windows (harmless for callers). - return addToProcessPath(dir) + if err := addToProcessPath(dir); err != nil { + return err + } + + // Handle Termux persistence + if isTermux() { + return persistPathTermux(dir) + } + + return nil } // Check whether dir is already present in PATH (case-insensitive on Windows). @@ -77,3 +90,53 @@ func addToProcessPath(dir string) error { } return os.Setenv("PATH", dir+string(os.PathListSeparator)+currentPath) } + +func isTermux() bool { + return pathGOOS == "android" +} + +func persistPathTermux(dir string) error { + // Reject shell-unsafe characters to prevent rc file corruption. + // Only checks for characters that could cause injection in a POSIX shell context. + if strings.ContainsAny(dir, "`\"'$\n") { + return fmt.Errorf("refusing to write unsafe dir %q to rc file", dir) + } + + home := os.Getenv("HOME") + if home == "" { + return fmt.Errorf("HOME environment variable not set") + } + + shell := os.Getenv("SHELL") + var rcFile string + if strings.Contains(shell, "zsh") { + rcFile = filepath.Join(home, ".zshrc") + } else { + rcFile = filepath.Join(home, ".bashrc") + } + + // Check if already present in file + content, err := os.ReadFile(rcFile) + if err != nil && !os.IsNotExist(err) { + return err + } + if strings.Contains(string(content), dir) { + return nil + } + + // Avoid leading blank line when writing to a new (empty) rc file. + prefix := "\n" + if len(content) == 0 { + prefix = "" + } + exportCmd := fmt.Sprintf("%sexport PATH=\"%s:$PATH\"\n", prefix, dir) + + f, err := os.OpenFile(rcFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(exportCmd) + return err +} diff --git a/internal/system/path_test.go b/internal/system/path_test.go index ef8deef8e..9c4396e5c 100644 --- a/internal/system/path_test.go +++ b/internal/system/path_test.go @@ -84,3 +84,59 @@ func TestAddToUserPathNoOpOnNonWindows(t *testing.T) { t.Fatalf("AddToUserPath should be a no-op on non-Windows but returned error: %v", err) } } + +func TestAddToUserPathInTermux(t *testing.T) { + // Mock Termux environment + home := t.TempDir() + oldHome := os.Getenv("HOME") + oldTermuxVersion := os.Getenv("TERMUX_VERSION") + oldShell := os.Getenv("SHELL") + oldGOOS := pathGOOS + + t.Cleanup(func() { + os.Setenv("HOME", oldHome) + os.Setenv("TERMUX_VERSION", oldTermuxVersion) + os.Setenv("SHELL", oldShell) + pathGOOS = oldGOOS + }) + + os.Setenv("HOME", home) + os.Unsetenv("TERMUX_VERSION") + os.Setenv("SHELL", "/data/data/com.termux/files/usr/bin/bash") + pathGOOS = "android" + + targetDir := filepath.Join(home, ".gentle-ai", "bin") + + // Verify the Termux configuration is persisted for future shells. + err := AddToUserPath(targetDir) + if err != nil { + t.Fatalf("AddToUserPath returned unexpected error: %v", err) + } + + // Check if .bashrc was created and contains the export + bashrcPath := filepath.Join(home, ".bashrc") + data, err := os.ReadFile(bashrcPath) + if err != nil { + t.Fatalf("expected .bashrc to be created in Termux, got error: %v", err) + } + + if !strings.Contains(string(data), targetDir) { + t.Fatalf(".bashrc does not contain the target directory: %s", string(data)) + } +} + +func TestIsTermuxUsesAndroidGOOS(t *testing.T) { + oldTermuxVersion := os.Getenv("TERMUX_VERSION") + oldGOOS := pathGOOS + t.Cleanup(func() { + os.Setenv("TERMUX_VERSION", oldTermuxVersion) + pathGOOS = oldGOOS + }) + + os.Unsetenv("TERMUX_VERSION") + pathGOOS = "android" + + if !isTermux() { + t.Fatal("isTermux() = false, want true for android GOOS") + } +} diff --git a/internal/system/resolver.go b/internal/system/resolver.go new file mode 100644 index 000000000..2a716b338 --- /dev/null +++ b/internal/system/resolver.go @@ -0,0 +1,83 @@ +package system + +import ( + "os" + "path/filepath" + "strings" +) + +// PathResolver defines the contract for resolving system paths +// across different platform layouts (Standard Unix vs Termux). +type PathResolver interface { + Resolve(path string) string +} + +// DefaultResolver provides standard Unix path resolution (pass-through). +type DefaultResolver struct{} + +func (r *DefaultResolver) Resolve(path string) string { + return path +} + +// TermuxResolver resolves paths by prepending the Termux $PREFIX. +type TermuxResolver struct { + Prefix string +} + +func (r *TermuxResolver) Resolve(path string) string { + if path == "" { + return "" + } + + // Only resolve absolute paths that target standard Unix hierarchy. + // In Termux, even on Android, we check for leading slash to identify + // Unix-style absolute paths, regardless of host OS (for testing portability). + if !strings.HasPrefix(path, "/") { + return path + } + + // Handle /usr, /bin, /etc, /tmp prefixes for Termux layout. + // Match exact directory boundaries to avoid rewriting unrelated paths + // like "/usrbin" or "/etcetera" (requires trailing slash or exact match). + if path == "/usr" || strings.HasPrefix(path, "/usr/") { + return filepath.Join(r.Prefix, strings.TrimPrefix(path, "/usr")) + } + if path == "/bin" || strings.HasPrefix(path, "/bin/") { + return filepath.Join(r.Prefix, "bin", strings.TrimPrefix(path, "/bin")) + } + if path == "/etc" || strings.HasPrefix(path, "/etc/") { + return filepath.Join(r.Prefix, "etc", strings.TrimPrefix(path, "/etc")) + } + if path == "/tmp" || strings.HasPrefix(path, "/tmp/") { + return filepath.Join(r.Prefix, "tmp", strings.TrimPrefix(path, "/tmp")) + } + + return path +} + +// NewResolverForProfile returns the appropriate PathResolver for a resolved +// platform profile. Termux is a first-class Android OS profile, not a Linux +// distro detected through /etc/os-release. +func NewResolverForProfile(profile PlatformProfile) PathResolver { + if profile.OS == "android" { + return newTermuxResolver() + } + return &DefaultResolver{} +} + +// NewResolverForDistro returns the appropriate PathResolver for the given distro. +// Prefer NewResolverForProfile at platform call sites. Termux is intentionally +// not resolved from a Linux distro string because Android is the canonical +// Termux platform boundary. +func NewResolverForDistro(distro string) PathResolver { + return &DefaultResolver{} +} + +func newTermuxResolver() PathResolver { + prefix := os.Getenv("PREFIX") + if prefix == "" { + // Fallback to default Termux prefix if env var is missing. + prefix = "/data/data/com.termux/files/usr" + } + return &TermuxResolver{Prefix: prefix} +} diff --git a/internal/system/resolver_test.go b/internal/system/resolver_test.go new file mode 100644 index 000000000..f5b5b6e2b --- /dev/null +++ b/internal/system/resolver_test.go @@ -0,0 +1,168 @@ +package system + +import ( + "fmt" + "path/filepath" + "testing" +) + +func TestDefaultResolver_Resolve(t *testing.T) { + resolver := &DefaultResolver{} + + tests := []struct { + name string + path string + want string + }{ + {name: "absolute path remains same", path: "/usr/bin/bash", want: "/usr/bin/bash"}, + {name: "relative path remains same", path: "bin/bash", want: "bin/bash"}, + {name: "empty path", path: "", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := filepath.ToSlash(resolver.Resolve(tc.path)) + if got != tc.want { + t.Fatalf("Resolve(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} + +func TestTermuxResolver_Resolve(t *testing.T) { + prefix := "/data/data/com.termux/files/usr" + resolver := &TermuxResolver{Prefix: prefix} + + tests := []struct { + name string + path string + want string + }{ + { + name: "resolves /usr path to prefix", + path: "/usr/bin/bash", + want: "/data/data/com.termux/files/usr/bin/bash", + }, + { + name: "resolves /bin path to prefix", + path: "/bin/sh", + want: "/data/data/com.termux/files/usr/bin/sh", + }, + { + name: "resolves /etc path to prefix", + path: "/etc/os-release", + want: "/data/data/com.termux/files/usr/etc/os-release", + }, + { + name: "resolves /tmp path to prefix", + path: "/tmp/some-file", + want: "/data/data/com.termux/files/usr/tmp/some-file", + }, + { + name: "leaves non-standard paths alone", + path: "/home/user/test.txt", + want: "/home/user/test.txt", + }, + { + name: "handles relative paths", + path: "local/bin/myapp", + want: "local/bin/myapp", + }, + { + name: "does not rewrite /usrbin (no boundary)", + path: "/usrbin/tool", + want: "/usrbin/tool", + }, + { + name: "does not rewrite /binary (no boundary)", + path: "/binary/file", + want: "/binary/file", + }, + { + name: "does not rewrite /etcetera", + path: "/etcetera/conf", + want: "/etcetera/conf", + }, + { + name: "does not rewrite /tmpfile", + path: "/tmpfile/data", + want: "/tmpfile/data", + }, + { + name: "resolves exact /usr", + path: "/usr", + want: "/data/data/com.termux/files/usr", + }, + { + name: "resolves exact /bin", + path: "/bin", + want: "/data/data/com.termux/files/usr/bin", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := filepath.ToSlash(resolver.Resolve(tc.path)) + if got != tc.want { + t.Fatalf("Resolve(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} + +func TestNewResolverForDistro(t *testing.T) { + prefix := "/data/data/com.termux/files/usr" + t.Setenv("PREFIX", prefix) + + tests := []struct { + name string + distro string + want string // Type name as string for comparison + }{ + {name: "termux distro returns DefaultResolver to avoid half-detection", distro: LinuxDistroTermux, want: "*system.DefaultResolver"}, + {name: "ubuntu returns DefaultResolver", distro: LinuxDistroUbuntu, want: "*system.DefaultResolver"}, + {name: "unknown returns DefaultResolver", distro: LinuxDistroUnknown, want: "*system.DefaultResolver"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resolver := NewResolverForDistro(tc.distro) + typeName := fmt.Sprintf("%T", resolver) + if typeName != tc.want { + t.Fatalf("NewResolverForDistro(%q) returned %s, want %s", tc.distro, typeName, tc.want) + } + + }) + } +} + +func TestNewResolverForProfileUsesAndroidAsTermuxBoundary(t *testing.T) { + prefix := "/data/data/com.termux/files/usr" + t.Setenv("PREFIX", prefix) + + tests := []struct { + name string + profile PlatformProfile + want string + }{ + { + name: "android profile returns TermuxResolver", + profile: PlatformProfile{OS: "android", LinuxDistro: LinuxDistroTermux}, + want: "*system.TermuxResolver", + }, + { + name: "linux profile with termux distro does not create half-detected Termux", + profile: PlatformProfile{OS: "linux", LinuxDistro: LinuxDistroTermux}, + want: "*system.DefaultResolver", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resolver := NewResolverForProfile(tc.profile) + if got := fmt.Sprintf("%T", resolver); got != tc.want { + t.Fatalf("NewResolverForProfile(%+v) = %s, want %s", tc.profile, got, tc.want) + } + }) + } +} diff --git a/internal/update/registry.go b/internal/update/registry.go index 956e60cac..dc46b8f7c 100644 --- a/internal/update/registry.go +++ b/internal/update/registry.go @@ -20,9 +20,10 @@ var Tools = []ToolInfo{ Repo: "gentle-ai", DetectCmd: nil, // version comes from build-time ldflags (app.Version) VersionPrefix: "v", - // gentle-ai: brew on macOS, binary release download on Linux. + // gentle-ai: brew on macOS, binary release download on Linux, and go install on Android/Termux. // Windows self-upgrade uses the PowerShell installer so the running binary can exit before replacement. InstallMethod: InstallBinary, + GoImportPath: "github.com/gentleman-programming/gentle-ai/cmd/gentle-ai", }, { Name: "engram", diff --git a/internal/update/upgrade/executor.go b/internal/update/upgrade/executor.go index d9583d47d..7f0f6a809 100644 --- a/internal/update/upgrade/executor.go +++ b/internal/update/upgrade/executor.go @@ -605,9 +605,10 @@ func executeOne(ctx context.Context, r update.UpdateResult, profile system.Platf // 1. OpenCode plugins are always handled by their own method — never overridden. // 2. Brew-managed platforms always use brew regardless of the tool's declared method. // 3. gentle-ai on Windows uses the installer so the running binary can exit before replacement. -// 4. When Go is available on PATH and the tool has a GoImportPath, go-install is +// 4. gentle-ai on Android/Termux uses go-install because no compatible release assets exist. +// 5. When Go is available on PATH and the tool has a GoImportPath, go-install is // preferred over a direct binary download. -// 5. Otherwise the tool's declared InstallMethod is used as-is. +// 6. Otherwise the tool's declared InstallMethod is used as-is. func effectiveMethod(tool update.ToolInfo, profile system.PlatformProfile) update.InstallMethod { if tool.InstallMethod == update.InstallOpenCodePlugin { return update.InstallOpenCodePlugin @@ -619,7 +620,14 @@ func effectiveMethod(tool update.ToolInfo, profile system.PlatformProfile) updat if profile.OS == "windows" && tool.Name == "gentle-ai" { return update.InstallInstaller } - if profile.GoAvailable && tool.GoImportPath != "" { + // Android/Termux has no compatible release assets for gentle-ai, so source + // installation with PIE flags is the supported self-upgrade route. + if profile.OS == "android" && tool.Name == "gentle-ai" && tool.GoImportPath != "" { + return update.InstallGoInstall + } + // Keep gentle-ai on the binary path for standard Linux even when Go is + // installed; release binaries are the preferred non-Android self-upgrade path. + if profile.GoAvailable && tool.GoImportPath != "" && tool.Name != "gentle-ai" { return update.InstallGoInstall } return tool.InstallMethod diff --git a/internal/update/upgrade/executor_test.go b/internal/update/upgrade/executor_test.go index 7d366dc37..ca6ff7463 100644 --- a/internal/update/upgrade/executor_test.go +++ b/internal/update/upgrade/executor_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/gentleman-programming/gentle-ai/internal/backup" + "github.com/gentleman-programming/gentle-ai/internal/components/gga" "github.com/gentleman-programming/gentle-ai/internal/model" "github.com/gentleman-programming/gentle-ai/internal/state" "github.com/gentleman-programming/gentle-ai/internal/system" @@ -598,11 +599,11 @@ func TestConfigPathsForBackup_CoversManagedAgentPaths(t *testing.T) { homeDir := t.TempDir() managedFiles := map[string]string{ - ".claude/CLAUDE.md": "# Claude", - ".config/opencode/AGENTS.md": "# OpenCode", + ".claude/CLAUDE.md": "# Claude", + ".config/opencode/AGENTS.md": "# OpenCode", ".config/opencode/opencode.json": `{"model":"claude"}`, - ".gemini/GEMINI.md": "# Gemini", - ".cursor/rules/gentle-ai.mdc": "# Cursor rules", + ".gemini/GEMINI.md": "# Gemini", + ".cursor/rules/gentle-ai.mdc": "# Cursor rules", } unmanagedFile := filepath.Join(homeDir, ".claude", "conversation-transcript.md") @@ -856,8 +857,8 @@ func TestConfigPathsForBackup_CoversRegistryAgentsNotInOldList(t *testing.T) { func TestConfigPathsForBackup_GGAExtrasAreIncluded(t *testing.T) { homeDir := t.TempDir() - // Create GGA config file at ~/.config/gga/config - ggaConfigFile := filepath.Join(homeDir, ".config", "gga", "config") + // Create GGA config file at the platform path GGA actually reads from. + ggaConfigFile := gga.ConfigPath(homeDir) if err := os.MkdirAll(filepath.Dir(ggaConfigFile), 0o755); err != nil { t.Fatalf("MkdirAll gga config: %v", err) } @@ -865,8 +866,8 @@ func TestConfigPathsForBackup_GGAExtrasAreIncluded(t *testing.T) { t.Fatalf("WriteFile gga config: %v", err) } - // Create GGA runtime lib file at ~/.local/share/gga/lib/pr_mode.sh - ggaLibFile := filepath.Join(homeDir, ".local", "share", "gga", "lib", "pr_mode.sh") + // Create GGA runtime lib file at the managed runtime path. + ggaLibFile := gga.RuntimePRModePath(homeDir) if err := os.MkdirAll(filepath.Dir(ggaLibFile), 0o755); err != nil { t.Fatalf("MkdirAll gga lib: %v", err) } @@ -1444,4 +1445,3 @@ func mockCmd(name string, args ...string) *exec.Cmd { } return exec.Command(name, args...) } - diff --git a/internal/update/upgrade/strategy.go b/internal/update/upgrade/strategy.go index d50d1bb69..59bd97aed 100644 --- a/internal/update/upgrade/strategy.go +++ b/internal/update/upgrade/strategy.go @@ -59,7 +59,7 @@ func runStrategy(ctx context.Context, r update.UpdateResult, profile system.Plat case update.InstallBrew: return false, brewUpgrade(ctx, r.Tool.Name) case update.InstallGoInstall: - return false, goInstallUpgrade(ctx, r.Tool, r.LatestVersion) + return false, goInstallUpgrade(ctx, r.Tool, r.LatestVersion, profile) case update.InstallBinary: return false, binaryUpgrade(ctx, r, profile) case update.InstallInstaller: @@ -317,17 +317,26 @@ func brewUpgrade(ctx context.Context, toolName string) error { } // goInstallUpgrade runs `go install @v`. -func goInstallUpgrade(ctx context.Context, tool update.ToolInfo, latestVersion string) error { +func goInstallUpgrade(ctx context.Context, tool update.ToolInfo, latestVersion string, profile system.PlatformProfile) error { if tool.GoImportPath == "" { return fmt.Errorf("upgrade %q: GoImportPath is empty — cannot run go install", tool.Name) } // Pin to the exact release version. target := fmt.Sprintf("%s@v%s", tool.GoImportPath, latestVersion) - cmd := execCommand("go", "install", target) + + args := []string{"install"} + // Android/Termux requires Position Independent Executables (PIE). Termux is + // represented exclusively by profile.OS == "android" in platform detection. + if profile.OS == "android" { + args = append(args, "-ldflags=-extldflags=-pie") + } + args = append(args, target) + + cmd := execCommand("go", args...) cmd.Stdin = nil if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("go install %s: %w (output: %s)", target, err, string(out)) + return fmt.Errorf("go install %v: %w (output: %s)", args[1:], err, string(out)) } return nil } diff --git a/internal/update/upgrade/strategy_test.go b/internal/update/upgrade/strategy_test.go index 77d5b5ec6..dd1984a68 100644 --- a/internal/update/upgrade/strategy_test.go +++ b/internal/update/upgrade/strategy_test.go @@ -90,6 +90,83 @@ func TestRunStrategy_GoInstallUpgrade(t *testing.T) { } } +func TestRunStrategy_GoInstallAndroidPIE(t *testing.T) { + origExecCommand := execCommand + t.Cleanup(func() { execCommand = origExecCommand }) + + var gotArgs []string + execCommand = func(name string, args ...string) *exec.Cmd { + if name == "go" { + gotArgs = args + } + return mockCmd("true") + } + + r := update.UpdateResult{ + Tool: update.ToolInfo{ + Name: "engram", + InstallMethod: update.InstallGoInstall, + GoImportPath: "github.com/Gentleman-Programming/engram/cmd/engram", + }, + LatestVersion: "0.4.0", + } + // Simulate Android/Termux environment — GOOS=android is the canonical path. + profile := system.PlatformProfile{OS: "android", LinuxDistro: system.LinuxDistroTermux} + + _, err := runStrategy(context.Background(), r, profile) + if err != nil { + t.Fatalf("runStrategy: %v", err) + } + + // Should contain -ldflags="-extldflags=-pie" + foundPIE := false + for _, arg := range gotArgs { + if strings.Contains(arg, "-extldflags=-pie") { + foundPIE = true + break + } + } + if !foundPIE { + t.Errorf("expected PIE flags in go install args for Android, got: %v", gotArgs) + } +} + +func TestRunStrategy_GentleAIAndroidSelfUpgradeUsesGoInstallWithPIE(t *testing.T) { + origExecCommand := execCommand + t.Cleanup(func() { execCommand = origExecCommand }) + + var gotName string + var gotArgs []string + execCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return mockCmd("true") + } + + r := update.UpdateResult{ + Tool: update.ToolInfo{ + Name: "gentle-ai", + InstallMethod: update.InstallBinary, + GoImportPath: "github.com/gentleman-programming/gentle-ai/cmd/gentle-ai", + }, + LatestVersion: "1.36.4", + } + profile := system.PlatformProfile{OS: "android", LinuxDistro: system.LinuxDistroTermux, PackageManager: "apt", GoAvailable: true} + + _, err := runStrategy(context.Background(), r, profile) + if err != nil { + t.Fatalf("runStrategy: %v", err) + } + + wantArgs := []string{"install", "-ldflags=-extldflags=-pie", "github.com/gentleman-programming/gentle-ai/cmd/gentle-ai@v1.36.4"} + if gotName != "go" { + t.Fatalf("exec name = %q, want go", gotName) + } + if strings.Join(gotArgs, " ") != strings.Join(wantArgs, " ") { + t.Fatalf("exec args = %v, want %v", gotArgs, wantArgs) + } +} + // --- TestRunStrategy_GoInstallMissingImportPath --- func TestRunStrategy_GoInstallMissingImportPath(t *testing.T) { @@ -342,6 +419,18 @@ func TestEffectiveMethod(t *testing.T) { profile: system.PlatformProfile{PackageManager: "apt", GoAvailable: true}, want: update.InstallBinary, }, + { + name: "gentle-ai on Android uses go-install because no release asset exists", + tool: update.ToolInfo{Name: "gentle-ai", InstallMethod: update.InstallBinary, GoImportPath: "github.com/gentleman-programming/gentle-ai/cmd/gentle-ai"}, + profile: system.PlatformProfile{OS: "android", PackageManager: "apt", GoAvailable: true}, + want: update.InstallGoInstall, + }, + { + name: "gentle-ai on Linux keeps binary even when go is available", + tool: update.ToolInfo{Name: "gentle-ai", InstallMethod: update.InstallBinary, GoImportPath: "github.com/gentleman-programming/gentle-ai/cmd/gentle-ai"}, + profile: system.PlatformProfile{OS: "linux", PackageManager: "apt", GoAvailable: true}, + want: update.InstallBinary, + }, } for _, tc := range tests { diff --git a/openspec/changes/archive/2026-04-11-termux-compatibility/design.md b/openspec/changes/archive/2026-04-11-termux-compatibility/design.md new file mode 100644 index 000000000..90a7088ec --- /dev/null +++ b/openspec/changes/archive/2026-04-11-termux-compatibility/design.md @@ -0,0 +1,70 @@ +# Design: termux-compatibility + +## Technical Approach +Implement a `system.PathResolver` that dynamically adjusts filesystem paths based on the detected environment. In Termux, it prepends the `$PREFIX` variable. This approach avoids hardcoded `if` blocks throughout the codebase and ensures that the core logic remains platform-agnostic. + +## Architecture Decisions + +### Decision: Interface-based Path Resolution +**Choice**: Create a `PathResolver` interface and a `DefaultResolver` vs `TermuxResolver`. +**Alternatives considered**: Global `if` checks in every file using paths. +**Rationale**: Highly maintainable and testable. We can inject a mocked resolver in tests to verify Termux logic on any host OS. + +### Decision: Shell-based PATH Persistence +**Choice**: Direct modification of `~/.bashrc` and `~/.zshrc`. +**Alternatives considered**: Using `termux-setup-storage` or system-wide profile changes. +**Rationale**: Standard practice in Termux. It avoids requiring root/su permissions and ensures the PATH survives terminal restarts. + +## Data Flow +`System Detection` -> `Profile Assignment` -> `Resolver Injection` -> `Path Resolution` + +``` +[Main Context] -> [Detect()] -> [PlatformProfile (Termux)] + | + v +[Resolver] <--- [Injected into components] + | + +--> [TermuxResolver]: Prepend $PREFIX/bin + +--> [DefaultResolver]: Pass-through (/usr/bin) +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `internal/system/resolver.go` | Create | Define `PathResolver` interface and implementations. | +| `internal/system/detect.go` | Modify | Add Termux detection and resolver initialization. | +| `internal/system/path.go` | Modify | Update `AddToUserPath` to handle Termux shell config files. | +| `internal/update/upgrade/strategy.go` | Modify | Include `-extldflags=-pie` for Android builds. | +| `internal/installcmd/resolver.go` | Modify | Replace hardcoded `/usr/bin` with `Resolver.Resolve()`. | + +## Interfaces / Contracts + +```go +type PathResolver interface { + Resolve(path string) string +} + +type TermuxResolver struct { + Prefix string +} + +func (r *TermuxResolver) Resolve(path string) string { + // e.g., /usr/bin/bash -> $PREFIX/bin/bash + return filepath.Join(r.Prefix, strings.TrimPrefix(path, "/usr")) +} +``` + +## Testing Strategy + +| Layer | What to Test | Approach | +|-------|-------------|----------| +| Unit | `PathResolver` | Test both `Default` and `Termux` resolvers with various path inputs. | +| Unit | `detectFromInputs` | Mock `os-release` and env vars to verify `Termux` distro detection. | +| Integration | `AddToUserPath` | Mock filesystem to verify `.bashrc` modification in Termux mode. | + +## Migration / Rollout +No migration required for existing users on Windows/Linux. New users on Termux will get the correct environment detection automatically. + +## Open Questions +- [ ] Should we automatically run `termux-setup-storage` if access to `/sdcard` is needed? (Currently out of scope). diff --git a/openspec/changes/archive/2026-04-11-termux-compatibility/exploration.md b/openspec/changes/archive/2026-04-11-termux-compatibility/exploration.md new file mode 100644 index 000000000..9467ed22f --- /dev/null +++ b/openspec/changes/archive/2026-04-11-termux-compatibility/exploration.md @@ -0,0 +1,31 @@ +## Exploration: termux-compatibility + +### Current State +`gentle-ai` relies on `runtime.GOOS` for platform detection and assumes standard Unix paths (`/usr/bin/bash`) or Windows-specific tools (`powershell`). It currently does not recognize Android/Termux as a specific platform profile, which leads to path resolution issues and potential execution failures on Android due to missing PIE (Position Independent Executable) support. + +### Affected Areas +- `internal/system/detect.go` — Needs to recognize `GOOS=android` as the canonical Android/Termux platform profile. +- `internal/system/path.go` — Needs to handle PATH persistence in Termux (`~/.bashrc` instead of Windows registry). +- `internal/update/upgrade/strategy.go` — Needs to handle `android/arm64` binary downloads and PIE requirements. +- `internal/installcmd/resolver.go` — Installation of sub-agents needs to be prefix-aware (avoiding hardcoded `/usr/bin`). + +### Approaches +1. **Prefix-Aware Routing (Recommended)** — Dynamically resolve system paths using an internal `ResolvePath(path string)` helper that prepends `$PREFIX` if running in Termux. + - Pros: Transparent to the rest of the codebase, handles non-standard Termux paths. + - Cons: Requires wrapping common path operations. + - Effort: Medium + +2. **Environment-Specific Profiles** — Add a dedicated Android/Termux profile in `PlatformProfile` that overrides default Unix behavior for installation and updates. + - Pros: Explicit and maintainable, allows for Termux-specific features (Termux:API). + - Cons: More complex detection logic. + - Effort: Medium + +### Recommendation +Combine both approaches: add an Android/Termux platform profile keyed by `GOOS=android` and implement a path resolver utility. This ensures `gentle-ai` feels native in Termux while maintaining clean architecture for other platforms and avoiding a half-detected `linux + termux` state. + +### Risks +- **PIE Compilation**: Failure to compile with `-extldflags=-pie` will cause the binary to crash on modern Android versions. +- **Permission Denied**: Installing binaries in `/sdcard` or outside `$HOME` will fail due to `noexec` mounts. We must ensure the installer defaults to `$HOME` or `$PREFIX`. + +### Ready for Proposal +Yes — I have enough information to define the architectural changes and implementation tasks. diff --git a/openspec/changes/archive/2026-04-11-termux-compatibility/proposal.md b/openspec/changes/archive/2026-04-11-termux-compatibility/proposal.md new file mode 100644 index 000000000..c53b92851 --- /dev/null +++ b/openspec/changes/archive/2026-04-11-termux-compatibility/proposal.md @@ -0,0 +1,69 @@ +## Intent +Enable `gentle-ai` to run natively within the Termux environment on Android by addressing path resolution issues, platform detection gaps, and Android's mandatory PIE (Position Independent Executable) requirement. + +## Maintainability & Regression Strategy +To ensure zero impact on existing platforms (Windows, macOS, standard Linux): +- **Abstract Path Resolver**: Replace hardcoded paths with a `system.Resolver` interface that handles prefix-awareness. +- **Environment Mocking**: All Termux-specific logic MUST be unit-tested using explicit Android profile inputs and environment mocks for `$PREFIX`/shell filesystem layouts. +- **Strict Isolation**: Termux logic will be encapsulated in platform-specific adapters to prevent "spaghetti" `if` blocks in the core logic. +- **Regression Suite**: Existing tests for Windows (PowerShell) and Linux (standard paths) must remain unchanged and pass. + +## Scope + +### In Scope +- Recognize Android/Termux through the canonical `GOOS=android` platform profile. +- Implement prefix-aware path resolution for system binaries (e.g., `/usr/bin/bash` -> `$PREFIX/bin/bash`). +- Add support for PATH persistence in Termux shells (`.bashrc`, `.zshrc`). +- Ensure `go build` for Android targets uses PIE flags. +- Update `internal/update` to handle `android/arm64` releases correctly. + +### Out of Scope +- Integration with `termux-api` for notifications (deferred to a future change). +- Support for running `gentle-ai` outside of the Termux environment (e.g., standard Android shell). + +## Capabilities + +### New Capabilities +- `termux-support`: Core logic for detecting and adapting to the Termux prefix environment. +- `android-pie-compilation`: Build-time support for Position Independent Executables. + +### Modified Capabilities +- `system-detection`: Update `PlatformProfile` to include Android/Termux as a supported platform profile. +- `update-strategy`: Adapt binary download and replacement for Android's filesystem layout. + +## Approach +Implement a hybrid approach: +1. **Detection**: Update `internal/system/detect.go` so `GOOS=android` resolves to the Android/Termux profile. +2. **Path Resolution**: Create a helper that returns the correct path for the active `$PREFIX`. +3. **Compilation**: Configure update-time Go install flags to include `-extldflags=-pie` on Android. +4. **Installation**: Adjust `AddToUserPath` to append PATH exports to shell configuration files when the platform profile is Android/Termux. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `internal/system/detect.go` | Modified | Add Android/Termux platform detection logic. | +| `internal/system/path.go` | Modified | Add shell-config persistence for Termux. | +| `internal/update/upgrade/strategy.go` | Modified | Add Android/PIE build flags and arch detection. | +| `internal/installcmd/resolver.go` | Modified | Use prefix-aware paths for sub-agent installation. | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Regression on Windows/Linux | Medium | Exhaustive unit testing with mocked environments; no changes to core logic paths. | +| PIE Crash | High | Ensure all build steps for Android include PIE flags. | +| Permission Denied | Medium | Default all binary paths to `$HOME` or `$PREFIX/bin`. | + +## Rollback Plan +Since this change mostly adds conditional logic for Android/Termux, a rollback involves reverting the Android branch in `internal/system/detect.go`, which will cause the system to stop treating Termux as a supported first-class platform. + +## Dependencies +- Termux environment (v0.118+ recommended). +- Go 1.22+ installed within Termux. + +## Success Criteria +- [ ] `gentle-ai` starts correctly in Termux without path errors. +- [ ] `gentle-ai self-update` works and produces an executable binary. +- [ ] Sub-agents can be installed and run within the Termux `$PREFIX`. +- [ ] **100% of existing tests for Windows and Linux pass without modification.** diff --git a/openspec/changes/archive/2026-04-11-termux-compatibility/tasks.md b/openspec/changes/archive/2026-04-11-termux-compatibility/tasks.md new file mode 100644 index 000000000..756947356 --- /dev/null +++ b/openspec/changes/archive/2026-04-11-termux-compatibility/tasks.md @@ -0,0 +1,24 @@ +# Tasks: termux-compatibility + +## Phase 1: Infrastructure & Platform Detection (TDD) +- [x] 1.1 **RED**: Add failing unit test in `internal/system/detect_test.go` to simulate `GOOS=android`. +- [x] 1.2 **GREEN**: Update `internal/system/detect.go` to resolve Android as the supported Termux platform profile. +- [x] 1.3 **REFACTOR**: Ensure `detectFromInputs` remains clean and platform-agnostic. +- [x] 1.4 **VERIFY**: Run `go test ./internal/system/...` and confirm 100% pass for all distros. + +## Phase 2: Prefix-Aware Path Resolver (TDD) +- [x] 2.1 **RED**: Create `internal/system/resolver_test.go` with cases for standard Linux vs. Termux `$PREFIX` resolution. +- [x] 2.2 **GREEN**: Implement `PathResolver` interface and `TermuxResolver` in `internal/system/resolver.go`. +- [x] 2.3 **REFACTOR**: Extract prefix-aware logic into a reusable helper. +- [x] 2.4 **VERIFY**: Ensure resolver tests pass and do not affect non-Termux paths. + +## Phase 3: PATH Persistence & Android/PIE Strategy (TDD) +- [x] 3.1 **RED**: Add integration test in `internal/system/path_test.go` for `AddToUserPath` in Termux mode (mocking `.bashrc`). +- [x] 3.2 **GREEN**: Update `internal/system/path.go` to append PATH exports to shell config files in Termux. +- [x] 3.3 **RED**: Add unit test in `internal/update/upgrade/strategy_test.go` to verify `-extldflags=-pie` for Android builds. +- [x] 3.4 **GREEN**: Update `internal/update/upgrade/strategy.go` to include PIE flags when the platform profile is Android. + +## Phase 4: Integration & Verification +- [x] 4.1 Update `internal/installcmd/resolver.go` to use `system.Resolver` for sub-agent installation paths. +- [x] 4.2 **FINAL VERIFY**: Run full test suite (`go test ./...`) and ensure no regressions on Windows/Linux. +- [x] 4.3 Update `README.md` or `docs/platforms.md` with Termux-specific installation notes. diff --git a/openspec/specs/termux-support/spec.md b/openspec/specs/termux-support/spec.md new file mode 100644 index 000000000..27dd320db --- /dev/null +++ b/openspec/specs/termux-support/spec.md @@ -0,0 +1,39 @@ +# Termux Support Specification + +## Purpose +Define the requirements for `gentle-ai` to operate correctly within the Termux environment on Android, ensuring environment detection, path resolution, and execution safety. + +## Requirements + +### Requirement: Platform Detection (Termux) +The system MUST identify when it is running inside Termux to apply the correct environment overrides. + +#### Scenario: Detect Termux environment +- GIVEN the target OS input `GOOS` is `android` +- WHEN the system runs `detectFromInputs` +- THEN the `PlatformProfile.LinuxDistro` SHALL be `termux` +- AND `PlatformProfile.Supported` SHALL be `true` + +### Requirement: Prefix-Aware Path Resolution +The system SHALL dynamically resolve system paths by prepending the Termux `$PREFIX` when running with the Android/Termux platform profile. + +#### Scenario: Resolve shell path in Termux +- GIVEN the platform profile OS is `android` +- AND the environment variable `PREFIX` is `/data/data/com.termux/files/usr` +- WHEN the system resolves the path for `bash` +- THEN the result SHALL be `/data/data/com.termux/files/usr/bin/bash` + +#### Scenario: Fallback to standard path on non-Termux Linux +- GIVEN the platform profile OS is `linux` +- WHEN the system resolves the path for `bash` +- THEN the result SHALL be `/usr/bin/bash` (no prefix added) + +### Requirement: PATH Persistence (Termux Shells) +The system MUST persist the installation directory to the user's PATH by modifying Termux-specific shell configuration files. + +#### Scenario: Add to PATH in .bashrc +- GIVEN the platform profile OS is `android` +- AND the shell is `bash` +- WHEN `AddToUserPath` is called with `/data/data/com.termux/files/home/.gentle-ai/bin` +- THEN the system SHALL append the export command to `~/.bashrc` +- AND it SHALL NOT attempt to call `powershell` or modify the Windows registry. diff --git a/scripts/install.sh b/scripts/install.sh index 5c1e89896..7a68974b2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -67,8 +67,11 @@ Options: Install methods (auto-detected in priority order): 1. brew — Homebrew tap (recommended) - 2. go — go install from source - 3. binary — Pre-built binary from GitHub Releases + 2. binary — Pre-built binary from GitHub Releases + 3. go — go install from source + +Android (Termux) always uses go install from source with PIE flags because +Linux release binaries are built for glibc, not Android's Bionic libc. Examples: curl -sL https://raw.githubusercontent.com/${GITHUB_OWNER}/${GITHUB_REPO}/main/scripts/install.sh | bash @@ -89,10 +92,25 @@ detect_platform() { uname_os="$(uname -s)" uname_arch="$(uname -m)" + # Distinguish between Standard Linux and Android/Termux. + # Termux provides a Unix-like environment on Android, but requires + # specific handling (PIE binaries, different path prefixes, no sudo). case "$uname_os" in Darwin) OS="darwin"; OS_LABEL="macOS"; GORELEASER_OS="darwin" ;; - Linux) OS="linux"; OS_LABEL="Linux"; GORELEASER_OS="linux" ;; - *) fatal "Unsupported OS: $uname_os. Only macOS and Linux are supported." ;; + Linux) + if [ "$(uname -o 2>/dev/null)" = "Android" ] || [ -n "${TERMUX_VERSION:-}" ]; then + OS="android" + OS_LABEL="Android (Termux)" + # No GoReleaser assets exist for android — compilation from + # source via 'go install' is the only supported method. + GORELEASER_OS="" + else + OS="linux" + OS_LABEL="Linux" + GORELEASER_OS="linux" + fi + ;; + *) fatal "Unsupported OS: $uname_os. Supported: macOS, Linux, Android (Termux)." ;; esac case "$uname_arch" in @@ -151,11 +169,18 @@ check_prerequisites() { # ============================================================================ detect_install_method() { - if [ -n "${FORCE_METHOD:-}" ]; then - case "$FORCE_METHOD" in - brew|go|binary) INSTALL_METHOD="$FORCE_METHOD" ;; - *) fatal "Unknown install method: $FORCE_METHOD. Use: brew, go, or binary" ;; - esac + if [ -n "${FORCE_METHOD:-}" ]; then + case "$FORCE_METHOD" in + brew|go|binary) INSTALL_METHOD="$FORCE_METHOD" ;; + *) fatal "Unknown install method: $FORCE_METHOD. Use: brew, go, or binary" ;; + esac + + # Android has no compatible release assets or Homebrew flow — source + # compilation with PIE is mandatory. + if [ "${OS:-}" = "android" ] && [ "$INSTALL_METHOD" != "go" ]; then + fatal "Only go install is supported on Android (Termux). Pre-built glibc binaries and Homebrew installs are not supported on Bionic libc. Use: --method go" + fi + info "Using forced method: $INSTALL_METHOD" return fi @@ -168,12 +193,23 @@ detect_install_method() { # go install is last resort because the Go module proxy can lag # behind new tags for up to 30 minutes, causing @latest to install # a stale version. - if command -v brew &>/dev/null; then - INSTALL_METHOD="brew" - success "Homebrew found — will install via brew tap" - else - INSTALL_METHOD="binary" - info "Will download pre-built binary from GitHub Releases" + # + # Exception: on Android/Termux, go install is mandatory (no release + # assets exist and glibc binaries are incompatible with Bionic libc). + if [ "${OS:-}" = "android" ]; then + # Android (Bionic libc) requires Position Independent Executables. + # No pre-built release assets exist — source compilation is mandatory. + if ! command -v go &>/dev/null; then + fatal "Go is required to install on Android (Termux). Install it with: pkg install golang" + fi + INSTALL_METHOD="go" + success "Android (Termux) + Go detected — using 'go install' for PIE compatibility" + elif command -v brew &>/dev/null; then + INSTALL_METHOD="brew" + success "Homebrew found — will install via brew tap" + else + INSTALL_METHOD="binary" + info "Will download pre-built binary from GitHub Releases" fi } @@ -216,10 +252,18 @@ install_brew() { install_go() { step "Installing via go install" - local go_package="github.com/${GITHUB_OWNER,,}/${GITHUB_REPO}/cmd/${BINARY_NAME}@latest" + get_latest_version + + local go_package="github.com/${GITHUB_OWNER,,}/${GITHUB_REPO}/cmd/${BINARY_NAME}@${LATEST_VERSION}" + + # Android (Bionic libc) requires Position Independent Executables (PIE). + local go_flags=() + if [ "${OS:-}" = "android" ]; then + go_flags=("-ldflags=-extldflags=-pie") + fi - info "Running: go install ${go_package}" - if ! go install "$go_package"; then + info "Running: go install ${go_flags[*]} ${go_package}" + if ! go install "${go_flags[@]}" "$go_package"; then fatal "Failed to install via go install. Make sure Go is properly configured." fi @@ -274,6 +318,12 @@ get_latest_version() { install_binary() { step "Installing pre-built binary" + # Safety net: binary download requires a known GoReleaser OS target. + # Android has no release assets — this should never be reached, but guard anyway. + if [ -z "${GORELEASER_OS:-}" ]; then + fatal "No pre-built binary available for ${OS_LABEL}. Use 'go install' instead (--method go)." + fi + get_latest_version local archive_name