From 9b8fdeb8d37200a3a4940aec0f12036235c439a8 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sat, 11 Apr 2026 03:50:50 +0200 Subject: [PATCH 01/11] feat: add native Termux support for Android --- docs/platforms.md | 15 ++- internal/installcmd/resolver.go | 27 ++++- internal/system/detect.go | 7 +- internal/system/detect_test.go | 5 + internal/system/path.go | 50 +++++++- internal/system/path_test.go | 40 +++++++ internal/system/resolver.go | 64 ++++++++++ internal/system/resolver_test.go | 113 ++++++++++++++++++ internal/update/upgrade/strategy.go | 16 ++- internal/update/upgrade/strategy_test.go | 51 +++++++- .../2026-04-11-termux-compatibility/design.md | 70 +++++++++++ .../exploration.md | 31 +++++ .../proposal.md | 69 +++++++++++ .../2026-04-11-termux-compatibility/tasks.md | 24 ++++ openspec/specs/termux-support/spec.md | 39 ++++++ 15 files changed, 605 insertions(+), 16 deletions(-) create mode 100644 internal/system/resolver.go create mode 100644 internal/system/resolver_test.go create mode 100644 openspec/changes/archive/2026-04-11-termux-compatibility/design.md create mode 100644 openspec/changes/archive/2026-04-11-termux-compatibility/exploration.md create mode 100644 openspec/changes/archive/2026-04-11-termux-compatibility/proposal.md create mode 100644 openspec/changes/archive/2026-04-11-termux-compatibility/tasks.md create mode 100644 openspec/specs/termux-support/spec.md diff --git a/docs/platforms.md b/docs/platforms.md index 209f104d9..de6cae494 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -11,10 +11,21 @@ | Linux (Arch) | pacman | Supported | | Linux (Fedora/RHEL family) | dnf | Supported | | Windows 10/11 | winget | Supported | +| Android (Termux) | apt | 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 via the `TERMUX_VERSION` environment variable. -Release binaries are built for `linux`, `darwin`, and `windows` on both `amd64` and `arm64`. +Release binaries are built for `linux`, `darwin`, `android`, and `windows` on both `amd64` and `arm64`. + +--- + +## 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/internal/installcmd/resolver.go b/internal/installcmd/resolver.go index 07b0c5b47..8c246d3b6 100644 --- a/internal/installcmd/resolver.go +++ b/internal/installcmd/resolver.go @@ -74,15 +74,32 @@ func (profileResolver) ResolveDependencyInstall(profile system.PlatformProfile, return nil, fmt.Errorf("dependency name is required") } + sudo := "sudo" + if profile.LinuxDistro == system.LinuxDistroTermux { + sudo = "" + } + switch profile.PackageManager { case "brew": return CommandSequence{{"brew", "install", dependency}}, nil case "apt": - return CommandSequence{{"sudo", "apt-get", "install", "-y", dependency}}, nil + cmd := []string{"apt-get", "install", "-y", dependency} + if sudo != "" { + cmd = append([]string{sudo}, cmd...) + } + return CommandSequence{cmd}, nil case "pacman": - return CommandSequence{{"sudo", "pacman", "-S", "--noconfirm", dependency}}, nil + cmd := []string{"pacman", "-S", "--noconfirm", dependency} + if sudo != "" { + cmd = append([]string{sudo}, cmd...) + } + return CommandSequence{cmd}, nil case "dnf": - return CommandSequence{{"sudo", "dnf", "install", "-y", dependency}}, nil + cmd := []string{"dnf", "install", "-y", dependency} + if sudo != "" { + cmd = append([]string{sudo}, cmd...) + } + return CommandSequence{cmd}, nil case "winget": return CommandSequence{{"winget", "install", "--id", dependency, "-e", "--accept-source-agreements", "--accept-package-agreements"}}, nil default: @@ -125,6 +142,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.NewResolverForDistro(profile.LinuxDistro) + switch profile.PackageManager { case "brew": return CommandSequence{ @@ -132,7 +151,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/system/detect.go b/internal/system/detect.go index f2c1b3012..e73713657 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -30,6 +30,7 @@ const ( LinuxDistroDebian = "debian" LinuxDistroArch = "arch" LinuxDistroFedora = "fedora" + LinuxDistroTermux = "termux" ) type DetectionResult struct { @@ -133,7 +134,7 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt } switch distro { - case LinuxDistroUbuntu, LinuxDistroDebian: + case LinuxDistroUbuntu, LinuxDistroDebian, LinuxDistroTermux: profile.PackageManager = "apt" profile.Supported = true case LinuxDistroArch: @@ -198,6 +199,10 @@ func detectLinuxDistro(linuxOSRelease string) string { return LinuxDistroFedora } + if id == LinuxDistroTermux { + return LinuxDistroTermux + } + return LinuxDistroUnknown } diff --git a/internal/system/detect_test.go b/internal/system/detect_test.go index 33a3ffbe2..348dce498 100644 --- a/internal/system/detect_test.go +++ b/internal/system/detect_test.go @@ -168,6 +168,11 @@ func TestDetectLinuxDistroMatrix(t *testing.T) { osRelease: "ID=custom-linux\nID_LIKE=\"nobara\"\n", wantDistro: LinuxDistroFedora, }, + { + name: "termux", + osRelease: "ID=termux\n", + wantDistro: LinuxDistroTermux, + }, { name: "empty os-release", osRelease: "", diff --git a/internal/system/path.go b/internal/system/path.go index 2046409eb..ba1b914bb 100644 --- a/internal/system/path.go +++ b/internal/system/path.go @@ -9,6 +9,8 @@ import ( "strings" ) +var pathGOOS = runtime.GOOS + // 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. @@ -17,9 +19,18 @@ import ( // to the current process PATH). This is safe to call on all platforms since the // binary is cross-compiled — 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 +88,38 @@ func addToProcessPath(dir string) error { } return os.Setenv("PATH", dir+string(os.PathListSeparator)+currentPath) } + +func isTermux() bool { + return os.Getenv("TERMUX_VERSION") != "" +} + +func persistPathTermux(dir string) error { + 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, _ := os.ReadFile(rcFile) + exportCmd := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", dir) + if strings.Contains(string(content), dir) { + return nil + } + + 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..c8b21868e 100644 --- a/internal/system/path_test.go +++ b/internal/system/path_test.go @@ -84,3 +84,43 @@ 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.Setenv("TERMUX_VERSION", "0.118.0") + os.Setenv("SHELL", "/data/data/com.termux/files/usr/bin/bash") + pathGOOS = "linux" + + targetDir := filepath.Join(home, ".gentle-ai", "bin") + + // RED: This will currently do nothing or fail to persist in Termux + 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)) + } +} diff --git a/internal/system/resolver.go b/internal/system/resolver.go new file mode 100644 index 000000000..8253a381a --- /dev/null +++ b/internal/system/resolver.go @@ -0,0 +1,64 @@ +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 prefixes for Termux layout. + if strings.HasPrefix(path, "/usr") { + return filepath.Join(r.Prefix, strings.TrimPrefix(path, "/usr")) + } + if strings.HasPrefix(path, "/bin") { + return filepath.Join(r.Prefix, "bin", strings.TrimPrefix(path, "/bin")) + } + if strings.HasPrefix(path, "/etc") { + return filepath.Join(r.Prefix, "etc", strings.TrimPrefix(path, "/etc")) + } + + return path +} + +// NewResolverForDistro returns the appropriate PathResolver for the given distro. +func NewResolverForDistro(distro string) PathResolver { + if distro == LinuxDistroTermux { + 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} + } + return &DefaultResolver{} +} diff --git a/internal/system/resolver_test.go b/internal/system/resolver_test.go new file mode 100644 index 000000000..2cad56a9c --- /dev/null +++ b/internal/system/resolver_test.go @@ -0,0 +1,113 @@ +package system + +import ( + "fmt" + "os" + "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: "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", + }, + } + + 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) { + // Mock environment for Termux + oldPrefix := os.Getenv("PREFIX") + defer os.Setenv("PREFIX", oldPrefix) + + prefix := "/data/data/com.termux/files/usr" + os.Setenv("PREFIX", prefix) + + tests := []struct { + name string + distro string + want string // Type name as string for comparison + }{ + {name: "termux returns TermuxResolver", distro: LinuxDistroTermux, want: "*system.TermuxResolver"}, + {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) + } + + if tc.distro == LinuxDistroTermux { + tr := resolver.(*TermuxResolver) + if tr.Prefix != prefix { + t.Fatalf("TermuxResolver.Prefix = %q, want %q", tr.Prefix, prefix) + } + } + }) + } +} diff --git a/internal/update/upgrade/strategy.go b/internal/update/upgrade/strategy.go index 750376839..1a4d10c55 100644 --- a/internal/update/upgrade/strategy.go +++ b/internal/update/upgrade/strategy.go @@ -51,7 +51,7 @@ func runStrategy(ctx context.Context, r update.UpdateResult, profile system.Plat case update.InstallBrew: return brewUpgrade(ctx, r.Tool.Name) case update.InstallGoInstall: - return goInstallUpgrade(ctx, r.Tool, r.LatestVersion) + return goInstallUpgrade(ctx, r.Tool, r.LatestVersion, profile) case update.InstallBinary: return binaryUpgrade(ctx, r, profile) case update.InstallScript: @@ -94,14 +94,24 @@ 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 requires Position Independent Executables (PIE). + // Detect via profile.LinuxDistro == "termux" or GOOS=android. + // Since PlatformProfile.OS is "linux" for Termux, we check LinuxDistro. + if profile.LinuxDistro == system.LinuxDistroTermux || runtime.GOOS == "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)) diff --git a/internal/update/upgrade/strategy_test.go b/internal/update/upgrade/strategy_test.go index c2088131b..864f7dd48 100644 --- a/internal/update/upgrade/strategy_test.go +++ b/internal/update/upgrade/strategy_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os/exec" + "strings" "testing" "github.com/gentleman-programming/gentle-ai/internal/system" @@ -87,6 +88,48 @@ 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 + } + // Use "go version" as a fast, valid command that exists in test environments. + return exec.Command("go", "version") + } + + 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 + profile := system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroTermux} + + err := runStrategy(context.Background(), r, profile) + if err != nil { + t.Fatalf("runStrategy: %v", err) + } + + // RED: 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) + } +} + // --- TestRunStrategy_GoInstallMissingImportPath --- func TestRunStrategy_GoInstallMissingImportPath(t *testing.T) { @@ -326,7 +369,7 @@ func TestBrewUpgrade_RunsUpdateBeforeUpgrade(t *testing.T) { if name == "brew" && len(args) > 0 { callOrder = append(callOrder, args[0]) // "update" or "upgrade" } - return exec.Command("echo", "ok") + return exec.Command("go", "version") } err := brewUpgrade(context.Background(), "gentle-ai") @@ -453,7 +496,7 @@ func TestRunStrategy_ScriptUpgradeSuccess(t *testing.T) { if name == "bash" && len(args) >= 2 && args[0] == "-c" { gotScriptContent = args[1] } - return exec.Command("echo", "ok") + return exec.Command("go", "version") } r := update.UpdateResult{ @@ -570,7 +613,7 @@ func TestGGAScriptUpgradeUsesGitClone(t *testing.T) { execCommand = func(name string, args ...string) *exec.Cmd { calls = append(calls, call{name: name, args: args}) - return exec.Command("echo", "ok") + return exec.Command("go", "version") } r := update.UpdateResult{ @@ -689,7 +732,7 @@ func TestRunStrategy_GGAUsesGitClone(t *testing.T) { execCommand = func(name string, args ...string) *exec.Cmd { calls = append(calls, call{name: name, args: args}) - return exec.Command("echo", "ok") + return exec.Command("go", "version") } r := update.UpdateResult{ 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..71360aa77 --- /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 Termux as a specific environment, 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 Termux via `TERMUX_VERSION` or `ID=termux` in os-release. +- `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 `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 a `Termux` distro/profile and implement a path resolver utility. This ensures `gentle-ai` feels native in Termux while maintaining clean architecture for other platforms. + +### 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..7a3a69151 --- /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 environment mocks to simulate Android/Termux 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 Termux as a specific Linux distribution/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 `Termux` as a supported distro. +- `update-strategy`: Adapt binary download and replacement for Android's filesystem layout. + +## Approach +Implement a hybrid approach: +1. **Detección**: Actualizar `internal/system/detect.go` para identificar `TERMUX_VERSION`. +2. **Resolución de Rutas**: Crear un helper `system.PrefixPath(path string)` que devuelva la ruta correcta según el `$PREFIX`. +3. **Compilación**: Configurar `LDFLAGS` en el proceso de actualización para incluir `-extldflags=-pie` en Android. +4. **Instalación**: Ajustar `AddToUserPath` para añadir exportaciones de PATH en archivos de configuración de shell si el perfil es `Termux`. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `internal/system/detect.go` | Modified | Add Termux 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 Termux, a rollback involves reverting the detection logic in `internal/system/detect.go`, which will cause the system to fall back to standard Linux behavior. + +## 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..429cc9bff --- /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) +- [ ] 1.1 **RED**: Add failing unit test in `internal/system/detect_test.go` to simulate Termux environment via `TERMUX_VERSION`. +- [ ] 1.2 **GREEN**: Update `internal/system/detect.go` to recognize `TERMUX_VERSION` and assign `LinuxDistroTermux`. +- [ ] 1.3 **REFACTOR**: Ensure `detectFromInputs` remains clean and platform-agnostic. +- [ ] 1.4 **VERIFY**: Run `go test ./internal/system/...` and confirm 100% pass for all distros. + +## Phase 2: Prefix-Aware Path Resolver (TDD) +- [ ] 2.1 **RED**: Create `internal/system/resolver_test.go` with cases for standard Linux vs. Termux `$PREFIX` resolution. +- [ ] 2.2 **GREEN**: Implement `PathResolver` interface and `TermuxResolver` in `internal/system/resolver.go`. +- [ ] 2.3 **REFACTOR**: Extract prefix-aware logic into a reusable helper. +- [ ] 2.4 **VERIFY**: Ensure resolver tests pass and do not affect non-Termux paths. + +## Phase 3: PATH Persistence & Android/PIE Strategy (TDD) +- [ ] 3.1 **RED**: Add integration test in `internal/system/path_test.go` for `AddToUserPath` in Termux mode (mocking `.bashrc`). +- [ ] 3.2 **GREEN**: Update `internal/system/path.go` to append PATH exports to shell config files in Termux. +- [ ] 3.3 **RED**: Add unit test in `internal/update/upgrade/strategy_test.go` to verify `-extldflags=-pie` for Android builds. +- [ ] 3.4 **GREEN**: Update `internal/update/upgrade/strategy.go` to include PIE flags when `GOOS=android`. + +## Phase 4: Integration & Verification +- [ ] 4.1 Update `internal/installcmd/resolver.go` to use `system.Resolver` for sub-agent installation paths. +- [ ] 4.2 **FINAL VERIFY**: Run full test suite (`go test ./...`) and ensure no regressions on Windows/Linux. +- [ ] 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..7a5f40498 --- /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 environment variable `TERMUX_VERSION` is set +- 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 in the Termux distro. + +#### Scenario: Resolve shell path in Termux +- GIVEN the system distro is `termux` +- 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 system distro is `ubuntu` +- 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 system distro is `termux` +- 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. From 25ad679b01a4fdc97489b05fc946d8b7d7bdb1da Mon Sep 17 00:00:00 2001 From: Snakeblack Date: Sat, 11 Apr 2026 04:09:02 +0200 Subject: [PATCH 02/11] feat(termux): enhance Android support and fix path resolution - Add 'android' to supported operating systems in system detection. - Implement source compilation for 'engram' via 'go install' on Android to ensure compatibility with Bionic libc. - Fix path resolution by mapping '/tmp' to '$PREFIX/tmp' in TermuxResolver. - Update engram installation directory to use home-relative paths on Android. - Improve OS support error messages to include Android. --- internal/components/engram/download.go | 45 ++++++++++++++++++++++++++ internal/system/detect.go | 7 +++- internal/system/guard.go | 2 +- internal/system/resolver.go | 3 ++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/internal/components/engram/download.go b/internal/components/engram/download.go index 6f9aba452..3a878568f 100644 --- a/internal/components/engram/download.go +++ b/internal/components/engram/download.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -37,6 +38,10 @@ var ( // This is the non-brew installation method for Linux and Windows. // On macOS, brew handles engram transitively and this should not be called. func DownloadLatestBinary(profile system.PlatformProfile) (string, error) { + if profile.OS == "android" { + return installViaGo(profile) + } + // 1. Fetch the latest version tag from GitHub API. version, err := fetchLatestEngramVersion() if err != nil { @@ -74,6 +79,37 @@ func DownloadLatestBinary(profile system.PlatformProfile) (string, error) { return outPath, nil } +// installViaGo compiles engram from source using 'go install' for platforms +// like Android (Termux) where pre-built Linux binaries may be incompatible. +func installViaGo(profile system.PlatformProfile) (string, error) { + // Use go install to compile from source. + // We use @latest to get the latest version as a compatible fallback. + cmd := exec.Command("go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest") + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("go install engram failed: %w (output: %s)", err, string(out)) + } + + // Determine where go install placed the binary. + // Default: ~/go/bin/engram (on Unix/Termux) + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find home dir: %w", err) + } + + // Priority 1: GOBIN environment variable. + if gobin := os.Getenv("GOBIN"); gobin != "" { + return filepath.Join(gobin, engramName), nil + } + + // Priority 2: GOPATH/bin + if gopath := os.Getenv("GOPATH"); gopath != "" { + return filepath.Join(gopath, "bin", engramName), nil + } + + // Default fallback for Termux/Go. + return filepath.Join(home, "go", "bin", engramName), nil +} + // fetchLatestEngramVersion queries the GitHub Releases API for the latest engram // release and returns the version string (without leading "v"). func fetchLatestEngramVersion() (string, error) { @@ -155,6 +191,10 @@ func engramAssetURL(baseURL, version, goos, goarch string) string { if goos == "windows" { ext = ".zip" } + // On Android (Termux), use the Linux binary as it is compatible. + if goos == "android" { + goos = "linux" + } filename := fmt.Sprintf("%s_%s_%s_%s%s", engramRepo, version, goos, goarch, ext) return fmt.Sprintf("%s/%s/%s/releases/download/v%s/%s", baseURL, engramOwner, engramRepo, version, filename) @@ -174,6 +214,11 @@ func engramInstallDir(goos string) string { return filepath.Join(localAppData, "engram", "bin") } + if goos == "android" { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "bin") + } + // Linux/macOS: try /usr/local/bin first. candidate := "/usr/local/bin" if isWritableDir(candidate) { diff --git a/internal/system/detect.go b/internal/system/detect.go index e73713657..a33ed5d67 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -41,7 +41,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) { @@ -118,6 +118,11 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt profile := PlatformProfile{OS: goos} switch goos { + case "android": + profile.LinuxDistro = LinuxDistroTermux + profile.PackageManager = "apt" + profile.Supported = true + return profile case "darwin": profile.PackageManager = "brew" profile.Supported = true 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/resolver.go b/internal/system/resolver.go index 8253a381a..3b772b469 100644 --- a/internal/system/resolver.go +++ b/internal/system/resolver.go @@ -46,6 +46,9 @@ func (r *TermuxResolver) Resolve(path string) string { if strings.HasPrefix(path, "/etc") { return filepath.Join(r.Prefix, "etc", strings.TrimPrefix(path, "/etc")) } + if strings.HasPrefix(path, "/tmp") { + return filepath.Join(r.Prefix, "tmp", strings.TrimPrefix(path, "/tmp")) + } return path } From 3dee6421676c3630121502ef74fe0f0b98410339 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sat, 11 Apr 2026 04:35:34 +0200 Subject: [PATCH 03/11] test(termux): add Android coverage for OS detection, guard, and resolver --- internal/system/detect_test.go | 18 ++++++++++++++++++ internal/system/guard_test.go | 2 +- internal/system/resolver_test.go | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/system/detect_test.go b/internal/system/detect_test.go index 348dce498..c2f2112fe 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) 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/resolver_test.go b/internal/system/resolver_test.go index 2cad56a9c..aad0d1c7c 100644 --- a/internal/system/resolver_test.go +++ b/internal/system/resolver_test.go @@ -54,6 +54,11 @@ func TestTermuxResolver_Resolve(t *testing.T) { 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", From 8a9a26b99cc5e15772c7146f14aa8660a6cb54ec Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sat, 11 Apr 2026 04:35:40 +0200 Subject: [PATCH 04/11] fix(termux): add PIE ldflags to engram source compilation on Android --- internal/components/engram/download.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/components/engram/download.go b/internal/components/engram/download.go index 3a878568f..dc6a2f3b5 100644 --- a/internal/components/engram/download.go +++ b/internal/components/engram/download.go @@ -84,7 +84,8 @@ func DownloadLatestBinary(profile system.PlatformProfile) (string, error) { func installViaGo(profile system.PlatformProfile) (string, error) { // Use go install to compile from source. // We use @latest to get the latest version as a compatible fallback. - cmd := exec.Command("go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest") + // Android (Bionic libc) requires Position Independent Executables (PIE). + cmd := exec.Command("go", "install", "-ldflags=-extldflags=-pie", "github.com/Gentleman-Programming/engram/cmd/engram@latest") if out, err := cmd.CombinedOutput(); err != nil { return "", fmt.Errorf("go install engram failed: %w (output: %s)", err, string(out)) } From 27eaddc0264ec97f1ae739068016a396cba04e20 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sat, 11 Apr 2026 04:49:38 +0200 Subject: [PATCH 05/11] fix(e2e): add Docker availability check before running tests --- e2e/docker-test.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/e2e/docker-test.sh b/e2e/docker-test.sh index d9768941a..45eda33e8 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 "${RED}[ORCH]${NC} docker is not installed or not in PATH.\n" + printf "${RED}[ORCH]${NC} E2E tests require Docker. On platforms without Docker support\n" + printf "${RED}[ORCH]${NC} (e.g. Android/Termux), these tests cannot run.\n" + exit 1 +fi + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- From af117360d0c890cc2d2467b2e2bd21edf3efd7c1 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sun, 12 Apr 2026 13:53:05 +0200 Subject: [PATCH 06/11] refactor(termux): address PR review feedback without behavioral changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix AddToUserPath docstring to reflect Termux PATH persistence - Handle os.ReadFile errors explicitly in persistPathTermux (ignore only NotExist) - Add HOME/fallback chain in engramInstallDir for Android edge case - Clarify engramAssetURL comment: android→linux mapping is URL-only, not runtime - Replace manual os.Setenv/defer with t.Setenv in resolver_test - Remove leftover TDD 'RED:' markers from committed tests - Fix strategy.go comment about PlatformProfile.OS on Termux - Correct docs/platforms.md: Android uses source compilation, not release binaries - Fix spec scenario: detection uses GOOS=android, not TERMUX_VERSION --- docs/platforms.md | 2 +- internal/components/engram/download.go | 13 ++++++++++--- internal/system/path.go | 19 ++++++++++++------- internal/system/path_test.go | 2 +- internal/system/resolver_test.go | 7 +------ internal/update/upgrade/strategy.go | 4 ++-- internal/update/upgrade/strategy_test.go | 2 +- openspec/specs/termux-support/spec.md | 2 +- 8 files changed, 29 insertions(+), 22 deletions(-) diff --git a/docs/platforms.md b/docs/platforms.md index de6cae494..5174be643 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -15,7 +15,7 @@ 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 via the `TERMUX_VERSION` environment variable. -Release binaries are built for `linux`, `darwin`, `android`, and `windows` on both `amd64` and `arm64`. +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. --- diff --git a/internal/components/engram/download.go b/internal/components/engram/download.go index dc6a2f3b5..7a25a88ce 100644 --- a/internal/components/engram/download.go +++ b/internal/components/engram/download.go @@ -192,7 +192,9 @@ func engramAssetURL(baseURL, version, goos, goarch string) string { if goos == "windows" { ext = ".zip" } - // On Android (Termux), use the Linux binary as it is compatible. + // On Android, map to "linux" for asset name resolution only. + // Note: glibc Linux binaries are NOT compatible with Android's Bionic libc. + // This mapping exists for URL construction; Android installs use installViaGo() instead. if goos == "android" { goos = "linux" } @@ -216,8 +218,13 @@ func engramInstallDir(goos string) string { } if goos == "android" { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".local", "bin") + 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. diff --git a/internal/system/path.go b/internal/system/path.go index ba1b914bb..61e19a5d3 100644 --- a/internal/system/path.go +++ b/internal/system/path.go @@ -11,13 +11,15 @@ import ( var pathGOOS = runtime.GOOS -// 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. +// AddToUserPath adds a directory to the user PATH persistently. // -// 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 Windows, it modifies the user-scoped PATH in the registry via PowerShell, +// surviving terminal restarts without admin privileges. +// +// 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 pathGOOS != "windows" { // Still add to the current process PATH on non-Windows (harmless for callers). @@ -108,7 +110,10 @@ func persistPathTermux(dir string) error { } // Check if already present in file - content, _ := os.ReadFile(rcFile) + content, err := os.ReadFile(rcFile) + if err != nil && !os.IsNotExist(err) { + return err + } exportCmd := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", dir) if strings.Contains(string(content), dir) { return nil diff --git a/internal/system/path_test.go b/internal/system/path_test.go index c8b21868e..255d8e916 100644 --- a/internal/system/path_test.go +++ b/internal/system/path_test.go @@ -107,7 +107,7 @@ func TestAddToUserPathInTermux(t *testing.T) { targetDir := filepath.Join(home, ".gentle-ai", "bin") - // RED: This will currently do nothing or fail to persist in Termux + // Verify the Termux configuration is persisted for future shells. err := AddToUserPath(targetDir) if err != nil { t.Fatalf("AddToUserPath returned unexpected error: %v", err) diff --git a/internal/system/resolver_test.go b/internal/system/resolver_test.go index aad0d1c7c..79df59e55 100644 --- a/internal/system/resolver_test.go +++ b/internal/system/resolver_test.go @@ -2,7 +2,6 @@ package system import ( "fmt" - "os" "path/filepath" "testing" ) @@ -82,12 +81,8 @@ func TestTermuxResolver_Resolve(t *testing.T) { } func TestNewResolverForDistro(t *testing.T) { - // Mock environment for Termux - oldPrefix := os.Getenv("PREFIX") - defer os.Setenv("PREFIX", oldPrefix) - prefix := "/data/data/com.termux/files/usr" - os.Setenv("PREFIX", prefix) + t.Setenv("PREFIX", prefix) tests := []struct { name string diff --git a/internal/update/upgrade/strategy.go b/internal/update/upgrade/strategy.go index 1a4d10c55..73c0fa0b9 100644 --- a/internal/update/upgrade/strategy.go +++ b/internal/update/upgrade/strategy.go @@ -104,8 +104,8 @@ func goInstallUpgrade(ctx context.Context, tool update.ToolInfo, latestVersion s args := []string{"install"} // Android requires Position Independent Executables (PIE). - // Detect via profile.LinuxDistro == "termux" or GOOS=android. - // Since PlatformProfile.OS is "linux" for Termux, we check LinuxDistro. + // Detect via profile.LinuxDistro == "termux" (for linux + ID=termux) or + // GOOS == "android" (for native Android detection). if profile.LinuxDistro == system.LinuxDistroTermux || runtime.GOOS == "android" { args = append(args, "-ldflags=-extldflags=-pie") } diff --git a/internal/update/upgrade/strategy_test.go b/internal/update/upgrade/strategy_test.go index 864f7dd48..9ad81854c 100644 --- a/internal/update/upgrade/strategy_test.go +++ b/internal/update/upgrade/strategy_test.go @@ -117,7 +117,7 @@ func TestRunStrategy_GoInstallAndroidPIE(t *testing.T) { t.Fatalf("runStrategy: %v", err) } - // RED: Should contain -ldflags="-extldflags=-pie" + // Should contain -ldflags="-extldflags=-pie" foundPIE := false for _, arg := range gotArgs { if strings.Contains(arg, "-extldflags=-pie") { diff --git a/openspec/specs/termux-support/spec.md b/openspec/specs/termux-support/spec.md index 7a5f40498..eefbb5ba7 100644 --- a/openspec/specs/termux-support/spec.md +++ b/openspec/specs/termux-support/spec.md @@ -9,7 +9,7 @@ Define the requirements for `gentle-ai` to operate correctly within the Termux e The system MUST identify when it is running inside Termux to apply the correct environment overrides. #### Scenario: Detect Termux environment -- GIVEN the environment variable `TERMUX_VERSION` is set +- 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` From 06413cd0481f088f3771f5eeb8dfcfb9a1fe8427 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sun, 12 Apr 2026 15:08:11 +0200 Subject: [PATCH 07/11] =?UTF-8?q?fix(termux):=20address=20maintainer=20rev?= =?UTF-8?q?iew=20=E2=80=94=20single=20detection=20path=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Termux from Linux distro detection (detectLinuxDistro, resolvePlatformProfile) GOOS=android is now the exclusive Termux detection path, eliminating the half-detected state where OS=linux + LinuxDistro=termux bypassed downstream Android-specific logic (installViaGo, engramInstallDir, PIE flags) - Remove dead android→linux mapping in engramAssetURL DownloadLatestBinary returns early via installViaGo for OS=android, making the goos rewrite unreachable - Fix leading blank line in persistPathTermux for new rc files exportCmd no longer starts with \n when writing to an empty file - Extract withSudo helper in ResolveDependencyInstall Replaces 3 duplicate if-sudo-prepend blocks with a single function - Update tests to match: distro matrix expects unknown for ID=termux, PIE test uses OS=android instead of OS=linux --- internal/components/engram/download.go | 8 ++---- internal/installcmd/resolver.go | 32 +++++++++--------------- internal/system/detect.go | 7 +++--- internal/system/detect_test.go | 4 +-- internal/system/path.go | 8 +++++- internal/update/upgrade/strategy_test.go | 4 +-- 6 files changed, 28 insertions(+), 35 deletions(-) diff --git a/internal/components/engram/download.go b/internal/components/engram/download.go index 7a25a88ce..a417a688a 100644 --- a/internal/components/engram/download.go +++ b/internal/components/engram/download.go @@ -192,12 +192,8 @@ func engramAssetURL(baseURL, version, goos, goarch string) string { if goos == "windows" { ext = ".zip" } - // On Android, map to "linux" for asset name resolution only. - // Note: glibc Linux binaries are NOT compatible with Android's Bionic libc. - // This mapping exists for URL construction; Android installs use installViaGo() instead. - if goos == "android" { - goos = "linux" - } + // Note: Android (Termux) never reaches this function — DownloadLatestBinary + // returns early via installViaGo(). Only linux/darwin/windows are valid here. filename := fmt.Sprintf("%s_%s_%s_%s%s", engramRepo, version, goos, goarch, ext) return fmt.Sprintf("%s/%s/%s/releases/download/v%s/%s", baseURL, engramOwner, engramRepo, version, filename) diff --git a/internal/installcmd/resolver.go b/internal/installcmd/resolver.go index 8c246d3b6..322f75b66 100644 --- a/internal/installcmd/resolver.go +++ b/internal/installcmd/resolver.go @@ -69,37 +69,29 @@ func (profileResolver) ResolveComponentInstall(profile system.PlatformProfile, c } } +// withSudo prepends "sudo" to a command unless the profile indicates a +// rootless environment (e.g. Termux on Android, where sudo does not exist). +func withSudo(profile system.PlatformProfile, cmd []string) []string { + if profile.LinuxDistro == system.LinuxDistroTermux { + return cmd + } + return append([]string{"sudo"}, cmd...) +} + func (profileResolver) ResolveDependencyInstall(profile system.PlatformProfile, dependency string) (CommandSequence, error) { if dependency == "" { return nil, fmt.Errorf("dependency name is required") } - sudo := "sudo" - if profile.LinuxDistro == system.LinuxDistroTermux { - sudo = "" - } - switch profile.PackageManager { case "brew": return CommandSequence{{"brew", "install", dependency}}, nil case "apt": - cmd := []string{"apt-get", "install", "-y", dependency} - if sudo != "" { - cmd = append([]string{sudo}, cmd...) - } - return CommandSequence{cmd}, nil + return CommandSequence{withSudo(profile, []string{"apt-get", "install", "-y", dependency})}, nil case "pacman": - cmd := []string{"pacman", "-S", "--noconfirm", dependency} - if sudo != "" { - cmd = append([]string{sudo}, cmd...) - } - return CommandSequence{cmd}, nil + return CommandSequence{withSudo(profile, []string{"pacman", "-S", "--noconfirm", dependency})}, nil case "dnf": - cmd := []string{"dnf", "install", "-y", dependency} - if sudo != "" { - cmd = append([]string{sudo}, cmd...) - } - return CommandSequence{cmd}, 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: diff --git a/internal/system/detect.go b/internal/system/detect.go index a33ed5d67..b02ce16a7 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -139,7 +139,7 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt } switch distro { - case LinuxDistroUbuntu, LinuxDistroDebian, LinuxDistroTermux: + case LinuxDistroUbuntu, LinuxDistroDebian: profile.PackageManager = "apt" profile.Supported = true case LinuxDistroArch: @@ -204,9 +204,8 @@ func detectLinuxDistro(linuxOSRelease string) string { return LinuxDistroFedora } - if id == LinuxDistroTermux { - return LinuxDistroTermux - } + // 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 c2f2112fe..9ada7e5a7 100644 --- a/internal/system/detect_test.go +++ b/internal/system/detect_test.go @@ -187,9 +187,9 @@ func TestDetectLinuxDistroMatrix(t *testing.T) { wantDistro: LinuxDistroFedora, }, { - name: "termux", + name: "termux os-release is not detected via linux path", osRelease: "ID=termux\n", - wantDistro: LinuxDistroTermux, + wantDistro: LinuxDistroUnknown, }, { name: "empty os-release", diff --git a/internal/system/path.go b/internal/system/path.go index 61e19a5d3..2ccd0f418 100644 --- a/internal/system/path.go +++ b/internal/system/path.go @@ -114,11 +114,17 @@ func persistPathTermux(dir string) error { if err != nil && !os.IsNotExist(err) { return err } - exportCmd := fmt.Sprintf("\nexport PATH=\"%s:$PATH\"\n", dir) 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 diff --git a/internal/update/upgrade/strategy_test.go b/internal/update/upgrade/strategy_test.go index 9ad81854c..028ef93f1 100644 --- a/internal/update/upgrade/strategy_test.go +++ b/internal/update/upgrade/strategy_test.go @@ -109,8 +109,8 @@ func TestRunStrategy_GoInstallAndroidPIE(t *testing.T) { }, LatestVersion: "0.4.0", } - // Simulate Android/Termux environment - profile := system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroTermux} + // 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 { From 8210f5e078de5038b3a5f403953f4347131c66ad Mon Sep 17 00:00:00 2001 From: Snakeblack Date: Sun, 12 Apr 2026 15:22:03 +0200 Subject: [PATCH 08/11] fix(scripts): align installation script with Android (Termux) support --- scripts/install.sh | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 97639a7c5..f14413e94 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -87,10 +87,24 @@ 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)" + # Note: 'android' is the canonical GOOS for Termux environments. + GORELEASER_OS="android" + 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 @@ -166,9 +180,18 @@ 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. + # Auto-detection priority: + # 1. Homebrew (macOS/Linux) + # 2. Go install (Mandatory for Android/Termux to ensure PIE compatibility) + # 3. Binary download (standard pre-built assets) if command -v brew &>/dev/null; then INSTALL_METHOD="brew" success "Homebrew found — will install via brew tap" + elif [ "${OS:-}" = "android" ] && command -v go &>/dev/null; then + # Android (Bionic libc) requires Position Independent Executables (PIE). + # We prefer 'go install' here to compile locally with correct flags. + INSTALL_METHOD="go" + success "Android (Termux) + Go detected — using 'go install' for PIE compatibility" else INSTALL_METHOD="binary" info "Will download pre-built binary from GitHub Releases" @@ -216,8 +239,14 @@ install_go() { local go_package="github.com/${GITHUB_OWNER,,}/${GITHUB_REPO}/cmd/${BINARY_NAME}@latest" - info "Running: go install ${go_package}" - if ! go install "$go_package"; then + # 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_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 From deff64bc55c217a8b26f0d9dbfafccb6643f2e19 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sun, 12 Apr 2026 15:32:37 +0200 Subject: [PATCH 09/11] fix(scripts): prevent binary download on Android and harden install guards - Set GORELEASER_OS='' for android (no release assets exist) - Add fatal guard in detect_install_method when OS=android and Go is missing - Add fatal guard when FORCE_METHOD=binary on Android (glibc incompatible) - Add safety net in install_binary for empty GORELEASER_OS (defense in depth) - Clean up contradictory priority comments into single coherent block --- scripts/install.sh | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index f14413e94..2519a068a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -96,8 +96,9 @@ detect_platform() { if [ "$(uname -o 2>/dev/null)" = "Android" ] || [ -n "${TERMUX_VERSION:-}" ]; then OS="android" OS_LABEL="Android (Termux)" - # Note: 'android' is the canonical GOOS for Termux environments. - GORELEASER_OS="android" + # 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" @@ -168,6 +169,12 @@ detect_install_method() { brew|go|binary) INSTALL_METHOD="$FORCE_METHOD" ;; *) fatal "Unknown install method: $FORCE_METHOD. Use: brew, go, or binary" ;; esac + + # Android has no pre-built release assets — binary download is invalid. + if [ "${OS:-}" = "android" ] && [ "$INSTALL_METHOD" = "binary" ]; then + fatal "Binary download is not supported on Android (Termux). Pre-built glibc binaries are incompatible with Bionic libc. Use: --method go" + fi + info "Using forced method: $INSTALL_METHOD" return fi @@ -180,16 +187,18 @@ 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. - # Auto-detection priority: - # 1. Homebrew (macOS/Linux) - # 2. Go install (Mandatory for Android/Termux to ensure PIE compatibility) - # 3. Binary download (standard pre-built assets) + # + # Exception: on Android/Termux, go install is mandatory (no release + # assets exist and glibc binaries are incompatible with Bionic libc). if command -v brew &>/dev/null; then INSTALL_METHOD="brew" success "Homebrew found — will install via brew tap" - elif [ "${OS:-}" = "android" ] && command -v go &>/dev/null; then - # Android (Bionic libc) requires Position Independent Executables (PIE). - # We prefer 'go install' here to compile locally with correct flags. + elif [ "${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" else @@ -301,6 +310,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 From 145091f7e2186ea3782dbd4f4159c5170d1eee28 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sun, 12 Apr 2026 15:49:13 +0200 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20tighten=20path=20matching,=20add=20safety=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tighten TermuxResolver prefix matching to exact directory boundaries (/usr/ not /usrbin, /etc/ not /etcetera) with boundary-safe checks - Add shell-unsafe character guard in persistPathTermux to prevent rc file corruption from backticks, quotes, dollar signs, or newlines - Document engramInstallDir android branch as defensive (currently unused since installViaGo writes to GOBIN) - Include full args in goInstallUpgrade error message for easier debugging of PIE-related failures on Termux - Add engramInstallDir android test case and resolver boundary tests --- internal/components/engram/download.go | 2 ++ internal/components/engram/download_test.go | 5 ++++ internal/system/path.go | 6 +++++ internal/system/resolver.go | 12 +++++---- internal/system/resolver_test.go | 30 +++++++++++++++++++++ internal/update/upgrade/strategy.go | 2 +- 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/components/engram/download.go b/internal/components/engram/download.go index a417a688a..f3302be74 100644 --- a/internal/components/engram/download.go +++ b/internal/components/engram/download.go @@ -203,6 +203,8 @@ func engramAssetURL(baseURL, version, goos, goarch string) string { // for the given OS. // - Linux/macOS: /usr/local/bin (fallback: ~/.local/bin if not writable) // - Windows: %LOCALAPPDATA%\engram\bin +// - Android: ~/.local/bin (defensive — currently unused because installViaGo +// writes to GOBIN/GOPATH, but kept for future install-path unification). func engramInstallDir(goos string) string { if goos == "windows" { localAppData := os.Getenv("LOCALAPPDATA") diff --git a/internal/components/engram/download_test.go b/internal/components/engram/download_test.go index ae09c773b..805a95134 100644 --- a/internal/components/engram/download_test.go +++ b/internal/components/engram/download_test.go @@ -198,6 +198,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 { diff --git a/internal/system/path.go b/internal/system/path.go index 2ccd0f418..684250b61 100644 --- a/internal/system/path.go +++ b/internal/system/path.go @@ -96,6 +96,12 @@ func isTermux() bool { } 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") diff --git a/internal/system/resolver.go b/internal/system/resolver.go index 3b772b469..c2d639404 100644 --- a/internal/system/resolver.go +++ b/internal/system/resolver.go @@ -36,17 +36,19 @@ func (r *TermuxResolver) Resolve(path string) string { return path } - // Handle /usr, /bin, /etc prefixes for Termux layout. - if strings.HasPrefix(path, "/usr") { + // 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 strings.HasPrefix(path, "/bin") { + if path == "/bin" || strings.HasPrefix(path, "/bin/") { return filepath.Join(r.Prefix, "bin", strings.TrimPrefix(path, "/bin")) } - if strings.HasPrefix(path, "/etc") { + if path == "/etc" || strings.HasPrefix(path, "/etc/") { return filepath.Join(r.Prefix, "etc", strings.TrimPrefix(path, "/etc")) } - if strings.HasPrefix(path, "/tmp") { + if path == "/tmp" || strings.HasPrefix(path, "/tmp/") { return filepath.Join(r.Prefix, "tmp", strings.TrimPrefix(path, "/tmp")) } diff --git a/internal/system/resolver_test.go b/internal/system/resolver_test.go index 79df59e55..775ccfee8 100644 --- a/internal/system/resolver_test.go +++ b/internal/system/resolver_test.go @@ -68,6 +68,36 @@ func TestTermuxResolver_Resolve(t *testing.T) { 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 { diff --git a/internal/update/upgrade/strategy.go b/internal/update/upgrade/strategy.go index 73c0fa0b9..2e56b8f4b 100644 --- a/internal/update/upgrade/strategy.go +++ b/internal/update/upgrade/strategy.go @@ -114,7 +114,7 @@ func goInstallUpgrade(ctx context.Context, tool update.ToolInfo, latestVersion s 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 } From 2de32b5894edf6648df640ae3492a174cc1fa3fe Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Sat, 6 Jun 2026 13:24:50 +0200 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20usa=20versi=C3=B3n=20en=20install?= =?UTF-8?q?=20de=20Termux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/install.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 86b2c8102..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 @@ -249,7 +252,9 @@ 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=()