Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
10 changes: 10 additions & 0 deletions e2e/docker-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Snakeblack marked this conversation as resolved.

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
Expand Down
81 changes: 77 additions & 4 deletions internal/components/engram/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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"
}
Comment thread
Snakeblack marked this conversation as resolved.
Comment thread
Snakeblack marked this conversation as resolved.

// Linux/macOS: try /usr/local/bin first.
candidate := "/usr/local/bin"
if isWritableDir(candidate) {
Expand Down
108 changes: 108 additions & 0 deletions internal/components/engram/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"

Expand Down
Loading