Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 18 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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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,8 @@ 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":
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 +277,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
48 changes: 48 additions & 0 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 @@ -360,6 +366,12 @@ func TestResolveAgentInstall(t *testing.T) {
agent: model.AgentOpenCode,
want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}},
},
{
name: "opencode on alpine runs npm without sudo",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk"},
agent: model.AgentOpenCode,
want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}},
},
{
name: "claude-code on windows uses npm without sudo",
profile: system.PlatformProfile{OS: "windows", PackageManager: "winget", NpmWritable: true},
Expand Down Expand Up @@ -417,6 +429,28 @@ func TestResolveAgentInstall(t *testing.T) {
}
}

func TestUVInstallHint(t *testing.T) {
tests := []struct {
name string
profile system.PlatformProfile
want string
}{
{
name: "alpine uses apk",
profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroAlpine, PackageManager: "apk"},
want: "apk add --no-cache uv",
},
}

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 +692,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
29 changes: 29 additions & 0 deletions internal/system/install_deps_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package system

import (
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -281,6 +282,7 @@ func TestInstallCommandsFullMatrix(t *testing.T) {
{OS: "linux", PackageManager: "apt", LinuxDistro: "ubuntu"},
{OS: "linux", PackageManager: "pacman", LinuxDistro: "arch"},
{OS: "linux", PackageManager: "dnf", LinuxDistro: LinuxDistroFedora},
{OS: "linux", PackageManager: "apk", LinuxDistro: LinuxDistroAlpine},
}

deps := []string{"git", "curl", "node", "go"}
Expand All @@ -304,3 +306,30 @@ func TestInstallCommandsFullMatrix(t *testing.T) {
}
}
}

func TestAlpineDependencyInstallHintsAndCommands(t *testing.T) {
profile := PlatformProfile{OS: "linux", PackageManager: "apk", LinuxDistro: LinuxDistroAlpine}
tests := []struct {
name string
dep string
wantHint string
wantCmds [][]string
}{
{name: "git", dep: "git", wantHint: "apk add --no-cache git", wantCmds: [][]string{{"apk", "add", "--no-cache", "git"}}},
{name: "curl", dep: "curl", wantHint: "apk add --no-cache curl", wantCmds: [][]string{{"apk", "add", "--no-cache", "curl"}}},
{name: "node includes npm", dep: "node", wantHint: "apk add --no-cache nodejs npm", wantCmds: [][]string{{"apk", "add", "--no-cache", "nodejs", "npm"}}},
{name: "go", dep: "go", wantHint: "apk add --no-cache go", wantCmds: [][]string{{"apk", "add", "--no-cache", "go"}}},
{name: "npm comes with node", dep: "npm", wantHint: "npm is included with node — install node first"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if hint := InstallHintForDep(tt.dep, profile); hint != tt.wantHint {
t.Fatalf("InstallHintForDep(%q) = %q, want %q", tt.dep, hint, tt.wantHint)
}
if cmds := InstallCommandsForDep(tt.dep, profile); !reflect.DeepEqual(cmds, tt.wantCmds) {
t.Fatalf("InstallCommandsForDep(%q) = %v, want %v", tt.dep, cmds, tt.wantCmds)
}
})
}
}
Loading