Skip to content
Open
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
21 changes: 21 additions & 0 deletions internal/installcmd/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ func uvInstallHint(profile system.PlatformProfile) string {
return "sudo pacman -S --noconfirm uv"
case "dnf":
return "sudo dnf install -y uv"
case "apk":
return "apk add --no-cache uv (requires community); otherwise: apk add --no-cache curl && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\""
case "winget":
return "winget install --id astral-sh.uv -e --accept-source-agreements --accept-package-agreements"
default:
Expand Down Expand Up @@ -211,6 +213,8 @@ func (profileResolver) ResolveDependencyInstall(profile system.PlatformProfile,
return CommandSequence{{"sudo", "pacman", "-S", "--noconfirm", dependency}}, nil
case "dnf":
return CommandSequence{{"sudo", "dnf", "install", "-y", dependency}}, nil
case "apk":
return CommandSequence{{"apk", "add", "--no-cache", dependency}}, nil
case "winget":
return CommandSequence{{"winget", "install", "--id", dependency, "-e", "--accept-source-agreements", "--accept-package-agreements"}}, nil
default:
Expand Down Expand Up @@ -239,6 +243,11 @@ func resolveOpenCodeInstall(profile system.PlatformProfile) (CommandSequence, er
return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", pkg}}, nil
}
return CommandSequence{{"sudo", "npm", "install", "-g", "--ignore-scripts", pkg}}, nil
case "apk":
if !profile.NpmWritable {
return nil, fmt.Errorf("OpenCode auto-install on Alpine requires a user-writable npm prefix on PATH; run npm config set prefix \"$HOME/.local\", add \"$HOME/.local/bin\" to PATH, then rerun gentle-ai install")
}
return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, nil
case "winget":
// On Windows, npm global installs do not require sudo.
return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, nil
Expand Down Expand Up @@ -271,6 +280,18 @@ func resolveGGAInstall(profile system.PlatformProfile) (CommandSequence, error)
{"git", "-C", tmpDir, "checkout", "-f", tagRef},
{"bash", tmpDir + "/install.sh"},
}, nil
case "apk":
const tmpDir = "/tmp/gentleman-guardian-angel"
tagRef := "refs/tags/v" + versions.GGAVersion
return CommandSequence{
{"apk", "add", "--no-cache", "git", "bash"},
{"rm", "-rf", tmpDir},
{"mkdir", "-p", tmpDir},
{"git", "init", tmpDir},
{"git", "-C", tmpDir, "fetch", "--depth=1", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", tagRef + ":" + tagRef},
{"git", "-C", tmpDir, "checkout", "-f", tagRef},
{"bash", tmpDir + "/install.sh"},
}, nil
case "winget":
// On Windows, use Git Bash explicitly to avoid bare "bash" resolving to
// C:\Windows\System32\bash.exe (WSL), which cannot run the script.
Expand Down
99 changes: 94 additions & 5 deletions internal/installcmd/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ func TestResolveDependencyInstall(t *testing.T) {
dep: "somepkg",
want: CommandSequence{{"sudo", "dnf", "install", "-y", "somepkg"}},
},
{
name: "alpine resolves apk command",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk"},
dep: "somepkg",
want: CommandSequence{{"apk", "add", "--no-cache", "somepkg"}},
},
{
name: "windows resolves winget command",
profile: system.PlatformProfile{OS: "windows", PackageManager: "winget"},
Expand Down Expand Up @@ -288,11 +294,12 @@ func TestResolveAgentInstall(t *testing.T) {
r := NewResolver()

tests := []struct {
name string
profile system.PlatformProfile
agent model.AgentID
want CommandSequence
wantErr bool
name string
profile system.PlatformProfile
agent model.AgentID
want CommandSequence
wantErr bool
errContains string
}{
{
name: "claude-code on darwin uses npm without sudo",
Expand Down Expand Up @@ -360,6 +367,19 @@ func TestResolveAgentInstall(t *testing.T) {
agent: model.AgentOpenCode,
want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}},
},
{
name: "opencode on alpine writable npm prefix runs npm without sudo",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk", NpmWritable: true},
agent: model.AgentOpenCode,
want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}},
},
{
name: "opencode on alpine non-writable npm prefix returns actionable error",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk"},
agent: model.AgentOpenCode,
wantErr: true,
errContains: "gentle-ai install",
},
{
name: "claude-code on windows uses npm without sudo",
profile: system.PlatformProfile{OS: "windows", PackageManager: "winget", NpmWritable: true},
Expand Down Expand Up @@ -407,6 +427,9 @@ func TestResolveAgentInstall(t *testing.T) {
}

if tt.wantErr {
if !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("ResolveAgentInstall() error = %q, want to contain %q", err.Error(), tt.errContains)
}
return
}

Expand All @@ -417,6 +440,58 @@ func TestResolveAgentInstall(t *testing.T) {
}
}

func TestUVInstallHint(t *testing.T) {
tests := []struct {
name string
profile system.PlatformProfile
want string
}{
{
name: "alpine fallback installs curl and updates current shell PATH",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk"},
want: "apk add --no-cache uv (requires community); otherwise: apk add --no-cache curl && curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=\"$HOME/.local/bin:$PATH\"",
},
{
name: "brew hint is unchanged",
profile: system.PlatformProfile{PackageManager: "brew"},
want: "brew install uv",
},
{
name: "apt hint is unchanged",
profile: system.PlatformProfile{PackageManager: "apt"},
want: "sudo apt-get install -y uv (or see https://docs.astral.sh/uv/getting-started/installation/)",
},
{
name: "pacman hint is unchanged",
profile: system.PlatformProfile{PackageManager: "pacman"},
want: "sudo pacman -S --noconfirm uv",
},
{
name: "dnf hint is unchanged",
profile: system.PlatformProfile{PackageManager: "dnf"},
want: "sudo dnf install -y uv",
},
{
name: "winget hint is unchanged",
profile: system.PlatformProfile{PackageManager: "winget"},
want: "winget install --id astral-sh.uv -e --accept-source-agreements --accept-package-agreements",
},
{
name: "unsupported package manager uses documentation",
profile: system.PlatformProfile{PackageManager: "zypper"},
want: "https://docs.astral.sh/uv/getting-started/installation/",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := uvInstallHint(tt.profile); got != tt.want {
t.Fatalf("uvInstallHint() = %q, want %q", got, tt.want)
}
})
}
}

func TestValidateAgentInstallPreflight(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -658,6 +733,20 @@ func TestResolveComponentInstall(t *testing.T) {
{"bash", "/tmp/gentleman-guardian-angel/install.sh"},
},
},
{
name: "gga on alpine installs git and bash before cleanup and installation",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk"},
component: model.ComponentGGA,
want: CommandSequence{
{"apk", "add", "--no-cache", "git", "bash"},
{"rm", "-rf", "/tmp/gentleman-guardian-angel"},
{"mkdir", "-p", "/tmp/gentleman-guardian-angel"},
{"git", "init", "/tmp/gentleman-guardian-angel"},
{"git", "-C", "/tmp/gentleman-guardian-angel", "fetch", "--depth=1", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", "refs/tags/v" + versions.GGAVersion + ":refs/tags/v" + versions.GGAVersion},
{"git", "-C", "/tmp/gentleman-guardian-angel", "checkout", "-f", "refs/tags/v" + versions.GGAVersion},
{"bash", "/tmp/gentleman-guardian-angel/install.sh"},
},
},
{
name: "engram on windows returns error (uses DownloadLatestBinary instead)",
profile: system.PlatformProfile{OS: "windows", PackageManager: "winget"},
Expand Down
22 changes: 22 additions & 0 deletions internal/system/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
LinuxDistroDebian = "debian"
LinuxDistroArch = "arch"
LinuxDistroFedora = "fedora"
LinuxDistroAlpine = "alpine"
)

type DetectionResult struct {
Expand Down Expand Up @@ -148,6 +149,9 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt
case LinuxDistroFedora:
profile.PackageManager = "dnf"
profile.Supported = true
case LinuxDistroAlpine:
profile.PackageManager = "apk"
profile.Supported = true
default:
profile.PackageManager = ""
profile.Supported = false
Expand Down Expand Up @@ -204,6 +208,10 @@ func detectLinuxDistro(linuxOSRelease string) string {
return LinuxDistroFedora
}

if isAlpineLike(id, idLike) {
return LinuxDistroAlpine
}

return LinuxDistroUnknown
}

Expand Down Expand Up @@ -248,3 +256,17 @@ func isFedoraLike(id, idLike string) bool {

return false
}

func isAlpineLike(id, idLike string) bool {
if id == LinuxDistroAlpine {
return true
}

for _, token := range strings.Fields(idLike) {
if token == LinuxDistroAlpine {
return true
}
}

return false
}
19 changes: 19 additions & 0 deletions internal/system/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ func TestDetectLinuxDistroMatrix(t *testing.T) {
osRelease: "ID=custom-linux\nID_LIKE=\"nobara\"\n",
wantDistro: LinuxDistroFedora,
},
{
name: "alpine",
osRelease: "ID=alpine\n",
wantDistro: LinuxDistroAlpine,
},
{
name: "alpine family via id_like token",
osRelease: "ID=custom-linux\nID_LIKE=\"alpine linux\"\n",
wantDistro: LinuxDistroAlpine,
},
{
name: "empty os-release",
osRelease: "",
Expand Down Expand Up @@ -269,6 +279,15 @@ func TestResolvePlatformProfileMatrix(t *testing.T) {
wantDistro: LinuxDistroFedora,
wantSupported: true,
},
{
name: "alpine profile",
goos: "linux",
osRelease: "ID=alpine\n",
wantOS: "linux",
wantPM: "apk",
wantDistro: LinuxDistroAlpine,
wantSupported: true,
},
{
name: "windows profile",
goos: "windows",
Expand Down
2 changes: 1 addition & 1 deletion internal/system/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func EnsureSupportedPlatform(profile PlatformProfile) error {
}

if profile.OS == "linux" && !profile.Supported {
return fmt.Errorf("%w: Linux support is limited to Ubuntu/Debian, Arch, and Fedora/RHEL family (detected %s)", ErrUnsupportedLinuxDistro, profile.LinuxDistro)
return fmt.Errorf("%w: Linux support is limited to Ubuntu/Debian, Arch, Fedora/RHEL family, and Alpine (detected %s)", ErrUnsupportedLinuxDistro, profile.LinuxDistro)
}

return nil
Expand Down
9 changes: 8 additions & 1 deletion internal/system/guard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ func TestEnsureSupportedPlatformAllowsSupportedFedoraLinux(t *testing.T) {
}
}

func TestEnsureSupportedPlatformAllowsAlpineLinux(t *testing.T) {
err := EnsureSupportedPlatform(PlatformProfile{OS: "linux", LinuxDistro: LinuxDistroAlpine, PackageManager: "apk", Supported: true})
if err != nil {
t.Fatalf("expected alpine profile to be supported, got %v", err)
}
}

func TestEnsureSupportedPlatformRejectsUnsupportedLinuxDistro(t *testing.T) {
err := EnsureSupportedPlatform(PlatformProfile{OS: "linux", LinuxDistro: LinuxDistroUnknown, Supported: false})
if err == nil {
Expand All @@ -57,7 +64,7 @@ func TestEnsureSupportedPlatformRejectsUnsupportedLinuxDistro(t *testing.T) {
t.Fatalf("expected ErrUnsupportedLinuxDistro, got %v", err)
}

if !strings.Contains(err.Error(), "Linux support is limited to Ubuntu/Debian, Arch, and Fedora/RHEL family") {
if !strings.Contains(err.Error(), "Linux support is limited to Ubuntu/Debian, Arch, Fedora/RHEL family, and Alpine") {
t.Fatalf("expected distro guard message, got %q", err.Error())
}
}
16 changes: 16 additions & 0 deletions internal/system/install_deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ func installHintGit(profile PlatformProfile) string {
return "sudo pacman -S --noconfirm git"
case profile.PackageManager == "dnf":
return "sudo dnf install -y git"
case profile.PackageManager == "apk":
return "apk add --no-cache git"
default:
return "install git from https://git-scm.com/"
}
Expand All @@ -33,6 +35,8 @@ func installHintCurl(profile PlatformProfile) string {
return "sudo pacman -S --noconfirm curl"
case profile.PackageManager == "dnf":
return "sudo dnf install -y curl"
case profile.PackageManager == "apk":
return "apk add --no-cache curl"
default:
return "install curl from https://curl.se/"
}
Expand All @@ -51,6 +55,8 @@ func installHintNode(profile PlatformProfile) string {
return "sudo pacman -S --noconfirm nodejs npm"
case profile.PackageManager == "dnf":
return "curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash - && sudo dnf install -y nodejs"
case profile.PackageManager == "apk":
return "apk add --no-cache nodejs npm"
default:
return "install node from https://nodejs.org/"
}
Expand Down Expand Up @@ -80,6 +86,8 @@ func installHintGo(profile PlatformProfile) string {
return "sudo pacman -S --noconfirm go"
case profile.PackageManager == "dnf":
return "sudo dnf install -y golang"
case profile.PackageManager == "apk":
return "apk add --no-cache go"
default:
return "install go from https://go.dev/dl/"
}
Expand Down Expand Up @@ -140,6 +148,8 @@ func installCommandsGit(profile PlatformProfile) [][]string {
return [][]string{{"sudo", "pacman", "-S", "--noconfirm", "git"}}
case profile.PackageManager == "dnf":
return [][]string{{"sudo", "dnf", "install", "-y", "git"}}
case profile.PackageManager == "apk":
return [][]string{{"apk", "add", "--no-cache", "git"}}
default:
return nil
}
Expand All @@ -158,6 +168,8 @@ func installCommandsCurl(profile PlatformProfile) [][]string {
return [][]string{{"sudo", "pacman", "-S", "--noconfirm", "curl"}}
case profile.PackageManager == "dnf":
return [][]string{{"sudo", "dnf", "install", "-y", "curl"}}
case profile.PackageManager == "apk":
return [][]string{{"apk", "add", "--no-cache", "curl"}}
default:
return nil
}
Expand All @@ -183,6 +195,8 @@ func installCommandsNode(profile PlatformProfile) [][]string {
{"bash", "-c", "curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash -"},
{"sudo", "dnf", "install", "-y", "nodejs"},
}
case profile.PackageManager == "apk":
return [][]string{{"apk", "add", "--no-cache", "nodejs", "npm"}}
default:
return nil
}
Expand All @@ -209,6 +223,8 @@ func installCommandsGo(profile PlatformProfile) [][]string {
return [][]string{{"sudo", "pacman", "-S", "--noconfirm", "go"}}
case profile.PackageManager == "dnf":
return [][]string{{"sudo", "dnf", "install", "-y", "golang"}}
case profile.PackageManager == "apk":
return [][]string{{"apk", "add", "--no-cache", "go"}}
default:
return nil
}
Expand Down
Loading
Loading