diff --git a/PRD.md b/PRD.md index 7586f5625..b8576a33d 100644 --- a/PRD.md +++ b/PRD.md @@ -75,6 +75,7 @@ This is NOT an "AI agent installer." Most agents are already easy to install (`n | Linux - Ubuntu/Debian | apt + Homebrew | P0 | | Linux - Arch | pacman | P0 | | Linux - Fedora/RHEL | dnf | P1 | +| Linux - openSUSE | zypper | P1 | | WSL 2 (Windows) | apt + Homebrew | P1 | | Windows (native) | winget / scoop / choco | P2 | | Termux (Android) | pkg | P2 | @@ -130,7 +131,7 @@ These are the base tools the installer itself and the ecosystem need. | Dependency | Min Version | Why | Install Method | |-----------|-------------|-----|----------------| | `bash` | 3.2+ | GGA, install scripts, Engram plugin hooks | Pre-installed on all targets | -| `git` | 2.x | GGA (diff/staging), Engram (git sync), skills clone, agent integrations | `brew`/`apt`/`pacman`/`dnf`/`pkg` | +| `git` | 2.x | GGA (diff/staging), Engram (git sync), skills clone, agent integrations | `brew`/`apt`/`pacman`/`dnf`/`zypper`/`pkg` | | `curl` | Any | Binary downloads, GGA providers (lmstudio, github), installer script | Pre-installed on most systems | #### Conditionally Required (based on user's selections) @@ -140,7 +141,7 @@ These are the base tools the installer itself and the ecosystem need. | **Homebrew** | Any | macOS (primary pkg manager), Linux (recommended for Engram, agents) | Official install script | | **Node.js** | 20+ | Claude Code (needs 18+), Gemini CLI (needs 20+) — installer picks the highest required version | `brew install node` / `nvm` / `fnm` / distro package | | **npm** | Comes with Node.js | Installing Claude Code, Gemini CLI, Codex | Bundled with Node.js | -| **Go** | 1.25+ | ONLY if building Engram from source (NOT needed for binary/Homebrew install) | `brew install go` / distro package | +| **Go** | 1.25+ | ONLY for local development/source builds (NOT needed for release-binary/Homebrew installs) | `brew install go` / distro package | | **python3** | 3.x | GGA with Ollama API mode or LM Studio provider (has fallback without it) | Pre-installed on macOS, `apt`/`pacman`/`dnf` on Linux | | **gh** (GitHub CLI) | Any | GGA with `github:` provider | `brew install gh` / distro package | @@ -152,6 +153,7 @@ These are the base tools the installer itself and the ecosystem need. | **Ubuntu/Debian** | bash, curl, git, sha256sum | Homebrew (optional), Node.js (apt version is often outdated → use NodeSource or fnm) | Node.js from apt is often v12/v16 — MUST use NodeSource repo or version manager for v20+ | | **Arch** | bash, curl, git, python3, sha256sum | Node.js (`pacman -S nodejs npm`) | Arch packages are usually current — `pacman` versions are fine | | **Fedora/RHEL** | bash, curl, git, sha256sum | Node.js (`dnf install nodejs`) | May need `dnf module enable nodejs:20` for correct version | +| **openSUSE** | bash, curl, git, sha256sum | Node.js (`zypper install nodejs npm`) | Tumbleweed packages are usually current; Leap may require a newer Node.js source if distro packages lag | | **WSL 2** | Same as host Linux distro | Same as Linux + note about Windows-side agents (Cursor, VSCode) | Windows-side agents use Windows paths; WSL agents use Linux paths | | **Windows native** | None guaranteed | Everything: git (Git for Windows), Node.js (winget/scoop), bash (Git Bash) | GGA needs bash — Git for Windows includes Git Bash | | **Termux** | bash, curl, git | Node.js (`pkg install nodejs`), python (`pkg install python`) | No sudo, no Homebrew. Commands run directly, not via `sh -c`. Go cross-compile has limitations on Android. | @@ -174,7 +176,7 @@ Node.js is the most critical dependency — multiple agents depend on it, and di - R-DEP-02: The installer MUST show the complete dependency tree to the user and get confirmation before installing anything - R-DEP-03: The installer MUST install missing dependencies automatically (with user consent) using the platform's preferred package manager - R-DEP-04: The installer MUST handle Node.js version requirements intelligently — Claude Code needs 18+, Gemini CLI needs 20+, so install 20+ to satisfy both -- R-DEP-05: The installer MUST NOT install Go unless the user explicitly chooses to build Engram from source (pre-compiled binaries are the default) +- R-DEP-05: The installer MUST NOT install Go unless the user explicitly chooses a source-build/development workflow (pre-compiled binaries are the default) - R-DEP-06: On Linux, the installer MUST NOT use distro-default Node.js if it's below v20 — use NodeSource, fnm, or Homebrew instead - R-DEP-07: The installer MUST handle platform-specific differences transparently (BSD sed vs GNU sed, sha256sum vs shasum, Xcode CLT on macOS) - R-DEP-08: The installer MUST detect existing version managers (fnm, nvm, n) and use them instead of installing Node.js system-wide @@ -252,7 +254,7 @@ The installer supports configuring the Gentleman ecosystem into ANY AI coding ag | Component | Method | Notes | |-----------|--------|-------| -| Engram binary | Go install / Homebrew / direct download | Single binary, no deps | +| Engram binary | Homebrew / direct download | Single binary, no deps | | Engram plugin for Claude Code | `claude plugin marketplace add` | Automatic | | Engram plugin for OpenCode | Copy `engram.ts` to plugins dir | Automatic | | Engram config for Gemini CLI | Write `~/.gemini/settings.json` + `system.md` | Automatic | diff --git a/docs/architecture.md b/docs/architecture.md index 0a213cc72..e1af77521 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ internal/ system/ OS/distro detection, dependency checks, platform guards cli/ Install flags, validation, orchestration, dry-run planner/ Dependency graph, resolution, ordering, review payloads - installcmd/ Profile-aware command resolver (brew/apt/pacman/dnf/winget/go install) + installcmd/ Profile-aware command resolver (brew/apt/pacman/dnf/zypper/winget/go install) pipeline/ Staged execution + rollback orchestration backup/ Config snapshot + restore assets/ Embedded skill files + persona templates @@ -31,7 +31,7 @@ internal/ tui/ Bubbletea TUI (Rose Pine theme) styles/ screens/ scripts/ Installer scripts (bash + PowerShell) -e2e/ Docker-based E2E tests (Ubuntu + Arch) +e2e/ Docker-based E2E tests (Ubuntu + Arch + Fedora + openSUSE) testdata/ Golden test fixtures ``` @@ -43,7 +43,7 @@ testdata/ Golden test fixtures # Unit tests go test ./... -# Docker E2E (Ubuntu + Arch, requires Docker) +# Docker E2E (Ubuntu + Arch + Fedora + openSUSE, requires Docker) RUN_FULL_E2E=1 RUN_BACKUP_TESTS=1 ./e2e/docker-test.sh # Dry-run smoke test (macOS/Linux) @@ -57,7 +57,7 @@ Test coverage: - **26 test packages** across the codebase - **260+ test functions** covering all agent adapters, components, and system detection -- **78 E2E test functions** running in Docker containers (Ubuntu + Arch) +- **78 E2E test functions** running in Docker containers (Ubuntu + Arch + Fedora + openSUSE) - **17 golden files** for snapshot testing component output - Full pipeline tested: detection, planning, execution, backup, restore, verification - All 8 agent adapters have unit tests with cross-platform path validation diff --git a/docs/docker-e2e-testing.md b/docs/docker-e2e-testing.md index 958c96e16..6d429d13b 100644 --- a/docs/docker-e2e-testing.md +++ b/docs/docker-e2e-testing.md @@ -10,6 +10,8 @@ e2e/ e2e_test.sh # All test cases, tiered by env vars Dockerfile.ubuntu # Ubuntu 22.04 test image Dockerfile.arch # Arch Linux test image + Dockerfile.fedora # Fedora test image + Dockerfile.opensuse # openSUSE Tumbleweed test image docker-test.sh # Orchestrator: build + run all platforms ``` @@ -37,6 +39,8 @@ RUN_FULL_E2E=1 RUN_BACKUP_TESTS=1 ./e2e/docker-test.sh |----------|-----------|-----------------| | Ubuntu 22.04 | `Dockerfile.ubuntu` | apt | | Arch Linux | `Dockerfile.arch` | pacman | +| Fedora | `Dockerfile.fedora` | dnf | +| openSUSE Tumbleweed | `Dockerfile.opensuse` | zypper | ## How it works @@ -60,6 +64,10 @@ docker run --rm gentle-ai-e2e-ubuntu docker build -f e2e/Dockerfile.arch -t gentle-ai-e2e-arch . docker run --rm -e RUN_FULL_E2E=1 gentle-ai-e2e-arch +# Run openSUSE only +docker build -f e2e/Dockerfile.opensuse -t gentle-ai-e2e-opensuse . +docker run --rm gentle-ai-e2e-opensuse + # Interactive debugging docker run --rm -it gentle-ai-e2e-ubuntu /bin/bash ``` diff --git a/docs/non-interactive.md b/docs/non-interactive.md index 71ba98a7f..317b29421 100644 --- a/docs/non-interactive.md +++ b/docs/non-interactive.md @@ -27,6 +27,7 @@ The installer detects the platform automatically at runtime — there is no flag | Ubuntu/Debian | `apt` | `sudo npm install -g opencode-ai` | | Arch | `pacman` | `sudo npm install -g opencode-ai` | | Fedora/RHEL family | `dnf` | `sudo npm install -g opencode-ai` | +| openSUSE family | `zypper` | `sudo npm install -g opencode-ai` | The `--dry-run` output includes a `Platform decision` line showing `os`, `distro`, `package-manager`, and `status`. diff --git a/docs/platforms.md b/docs/platforms.md index 54a59f67e..0d80b6ca6 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -10,9 +10,10 @@ | Linux (Ubuntu/Debian) | apt | Supported | | Linux (Arch) | pacman | Supported | | Linux (Fedora/RHEL family) | dnf | Supported | +| Linux (openSUSE family) | zypper | Supported | | Windows 10/11 | winget | 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, openSUSE Tumbleweed, openSUSE Leap, etc.). Release binaries are built for `linux`, `darwin`, and `windows` on both `amd64` and `arm64`. diff --git a/docs/quickstart.md b/docs/quickstart.md index 4a7f2a0fa..41cf98c89 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -26,9 +26,16 @@ - `git` available. - Node.js installs use NodeSource LTS setup + `dnf install -y nodejs` during dependency remediation. +### openSUSE family (Tumbleweed, Leap) + +- `zypper` available (standard on these distros). +- `sudo` access for package installs. +- `git` available. +- Node.js installs use distro packages via `zypper install nodejs npm` during dependency remediation. + ### All platforms -- Go 1.24+ (for building from source). +- Go 1.24+ only if you are building Gentle AI from source; it is not required for normal release-binary installs. - Node.js / npm if installing Claude Code (agent is installed via `npm install -g`). ## Run @@ -45,7 +52,7 @@ Use `--dry-run` first to validate selections and execution plan without applying go run ./cmd/gentle-ai install ``` -The installer detects your platform automatically — no flags needed to select macOS vs Linux. Install commands are resolved through the appropriate package manager (brew, apt, pacman, or dnf) based on detection. +The installer detects your platform automatically — no flags needed to select macOS vs Linux. Install commands are resolved through the appropriate package manager (brew, apt, pacman, dnf, or zypper) based on detection. After completion, verify that agent configs and selected components were installed to their expected paths. @@ -60,4 +67,4 @@ When checks pass, installer reports: If you run the installer on an unsupported OS or Linux distro, it exits immediately with an error: - `unsupported operating system: only macOS, Linux, and Windows are supported (detected )` -- `unsupported linux distro: Linux support is limited to Ubuntu/Debian, Arch, and Fedora/RHEL family (detected )` +- `unsupported linux distro: Linux support is limited to Ubuntu/Debian, Arch, Fedora/RHEL family, and openSUSE family (detected )` diff --git a/docs/rollback.md b/docs/rollback.md index e5289124c..2e46d263e 100644 --- a/docs/rollback.md +++ b/docs/rollback.md @@ -66,5 +66,5 @@ Pinned backups are never automatically deleted, even when the retention limit is ## What rollback does NOT cover -- Packages installed via `brew install`, `apt-get install`, or `pacman -S` are not uninstalled during rollback. The snapshot system handles configuration files only. -- If you need to undo a package install, use your platform's package manager directly (e.g., `brew uninstall`, `sudo apt-get remove`, `sudo pacman -R`). +- Packages installed via `brew install`, `apt-get install`, `pacman -S`, `dnf install`, or `zypper install` are not uninstalled during rollback. The snapshot system handles configuration files only. +- If you need to undo a package install, use your platform's package manager directly (e.g., `brew uninstall`, `sudo apt-get remove`, `sudo pacman -R`, `sudo dnf remove`, `sudo zypper remove`). diff --git a/docs/usage.md b/docs/usage.md index c18eeeb6a..ec160b7ad 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -215,6 +215,7 @@ gentle-ai install --agent windsurf --preset full-gentleman - **Detected tools**: git, curl, node, npm, brew, go - **Version checks**: validates minimum versions where applicable -- **Platform-aware hints**: suggests `brew install`, `apt install`, `pacman -S`, `dnf install`, or `winget install` depending on your OS +- **Platform-aware hints**: suggests `brew install`, `apt install`, `pacman -S`, `dnf install`, `zypper install`, or `winget install` depending on your OS - **Node LTS alignment**: on apt/dnf systems, Node.js hints use NodeSource LTS bootstrap before package install +- **Optional Go**: Go is only needed for local development/source builds, not normal release-binary installs - **Dependency-first approach**: detects what's installed, calculates what's needed, shows the full dependency tree before installing anything, then verifies each dependency after installation diff --git a/e2e/Dockerfile.opensuse b/e2e/Dockerfile.opensuse new file mode 100644 index 000000000..4db206a5e --- /dev/null +++ b/e2e/Dockerfile.opensuse @@ -0,0 +1,54 @@ +# Dockerfile.opensuse — E2E test image for gentle-ai on openSUSE Tumbleweed +# Builds the binary from source and runs the E2E test suite as a non-root user. +FROM opensuse/tumbleweed:latest + +# --------------------------------------------------------------------------- +# System dependencies (includes npm/node for claude-code tests) +# --------------------------------------------------------------------------- +RUN zypper --non-interactive refresh && \ + zypper --non-interactive install --no-recommends \ + git \ + curl \ + sudo \ + ca-certificates \ + go \ + nodejs \ + npm \ + && zypper clean --all + +# --------------------------------------------------------------------------- +# Create non-root test user with passwordless sudo +# --------------------------------------------------------------------------- +RUN useradd -m -s /bin/bash testuser && \ + echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# --------------------------------------------------------------------------- +# Copy project source and build +# --------------------------------------------------------------------------- +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -o /usr/local/bin/gentle-ai ./cmd/gentle-ai + +# --------------------------------------------------------------------------- +# Prepare test environment +# --------------------------------------------------------------------------- +COPY e2e/lib.sh /home/testuser/e2e/lib.sh +COPY e2e/e2e_test.sh /home/testuser/e2e/e2e_test.sh +RUN chmod +x /home/testuser/e2e/e2e_test.sh /home/testuser/e2e/lib.sh && \ + chown -R testuser:testuser /home/testuser + +USER testuser + +# Ensure go install binaries are on PATH for testuser +ENV GOPATH="/home/testuser/go" +ENV PATH="${GOPATH}/bin:${PATH}" + +WORKDIR /home/testuser + +# --------------------------------------------------------------------------- +# Default: run Tier 1 tests only +# --------------------------------------------------------------------------- +CMD ["./e2e/e2e_test.sh"] diff --git a/e2e/docker-test.sh b/e2e/docker-test.sh index d9768941a..54e90cb16 100755 --- a/e2e/docker-test.sh +++ b/e2e/docker-test.sh @@ -32,6 +32,7 @@ PLATFORMS=( "ubuntu:Dockerfile.ubuntu" "arch:Dockerfile.arch" "fedora:Dockerfile.fedora" + "opensuse:Dockerfile.opensuse" ) # Environment variables to forward into containers diff --git a/internal/agents/opencode/adapter_test.go b/internal/agents/opencode/adapter_test.go index 5388f9e43..785619878 100644 --- a/internal/agents/opencode/adapter_test.go +++ b/internal/agents/opencode/adapter_test.go @@ -121,9 +121,14 @@ func TestInstallCommand(t *testing.T) { profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroFedora, PackageManager: "dnf", NpmWritable: true}, want: [][]string{{"npm", "install", "-g", "opencode-ai"}}, }, + { + name: "opensuse resolves npm install", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + want: [][]string{{"sudo", "npm", "install", "-g", "opencode-ai"}}, + }, { name: "unsupported package manager returns error", - profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroUbuntu, PackageManager: "zypper"}, + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroUbuntu, PackageManager: "unknownpm"}, wantErr: true, }, } diff --git a/internal/app/parity_test.go b/internal/app/parity_test.go index e817e5698..4128b273a 100644 --- a/internal/app/parity_test.go +++ b/internal/app/parity_test.go @@ -128,6 +128,13 @@ func TestGuardAcceptsFedoraProfile(t *testing.T) { } } +func TestGuardAcceptsOpenSUSEProfile(t *testing.T) { + profile := system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper", Supported: true} + if err := system.EnsureSupportedPlatform(profile); err != nil { + t.Fatalf("expected opensuse profile to be accepted, got %v", err) + } +} + func TestGuardFlowLinuxDryRunPropagatesDecision(t *testing.T) { detection := system.DetectionResult{ System: system.SystemInfo{ @@ -332,6 +339,13 @@ func TestGuardFlowLinuxArchProfileExplicitlyPasses(t *testing.T) { } } +func TestGuardFlowLinuxOpenSUSEProfileExplicitlyPasses(t *testing.T) { + profile := system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper", Supported: true} + if err := system.EnsureSupportedPlatform(profile); err != nil { + t.Fatalf("openSUSE profile should pass guard, got %v", err) + } +} + func TestInstallPlannerParityLinuxPreservesComponentOrder(t *testing.T) { linuxDetection := system.DetectionResult{ System: system.SystemInfo{ diff --git a/internal/cli/run.go b/internal/cli/run.go index 3e406c559..6ae9fedf9 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -164,46 +164,38 @@ func withPostInstallNotes(report verify.Report, resolved planner.ResolvedPlan) v if hasComponent(resolved.OrderedComponents, model.ComponentGGA) && report.Ready { report.FinalNote = report.FinalNote + "\n\nGGA is now installed globally. To enable project hooks, run in each repo:\n- gga init\n- gga install" } - report = withGoInstallPathNote(report, resolved) + report = withEngramPathNote(report, resolved) return report } -// withGoInstallPathNote appends a PATH guidance note when engram was installed -// on a non-brew platform (Linux/Windows). Since engram is now installed via -// direct binary download to /usr/local/bin or ~/.local/bin, this note helps -// users who may need to add the install directory to their PATH. -func withGoInstallPathNote(report verify.Report, resolved planner.ResolvedPlan) verify.Report { +// withEngramPathNote appends PATH guidance for non-brew engram installs. +// Engram is installed via a pre-built binary on Linux/Windows, not via Go. +func withEngramPathNote(report verify.Report, resolved planner.ResolvedPlan) verify.Report { if !hasComponent(resolved.OrderedComponents, model.ComponentEngram) { return report } if resolved.PlatformDecision.PackageManager == "brew" { return report } - binDir := goInstallBinDir() - if isInPATH(binDir) { + binDirs := engramDownloadBinDirs() + if allInPATH(binDirs) { return report } + binDir := binDirs[len(binDirs)-1] report.FinalNote = report.FinalNote + fmt.Sprintf( - "\n\nThe engram binary was installed to %s via `go install`.\nAdd it to your PATH: %s", + "\n\nEngram is installed as a pre-built binary on non-brew platforms.\nIf future shells cannot find `engram`, add its install directory to PATH (usually %s): %s", binDir, - engramPathGuidance(os.Getenv("SHELL")), + engramPathGuidance(os.Getenv("SHELL"), binDir), ) return report } -// goInstallBinDir returns the directory where `go install` places binaries. -// Resolution order: $GOBIN > $GOPATH/bin > $HOME/go/bin. -func goInstallBinDir() string { - if gobin := os.Getenv("GOBIN"); gobin != "" { - return gobin - } - if gopath := os.Getenv("GOPATH"); gopath != "" { - return filepath.Join(gopath, "bin") - } +func engramDownloadBinDirs() []string { + dirs := []string{"/usr/local/bin"} if home, err := osUserHomeDir(); err == nil { - return filepath.Join(home, "go", "bin") + dirs = append(dirs, filepath.Join(home, ".local", "bin")) } - return filepath.Join("~", "go", "bin") + return dirs } // isInPATH reports whether dir is present in the current PATH. @@ -216,6 +208,15 @@ func isInPATH(dir string) bool { return false } +func allInPATH(dirs []string) bool { + for _, dir := range dirs { + if !isInPATH(dir) { + return false + } + } + return true +} + func buildStagePlan(selection model.Selection, resolved planner.ResolvedPlan) pipeline.StagePlan { prepare := []pipeline.Step{ noopStep{id: "prepare:system-check"}, @@ -1077,7 +1078,9 @@ func engramHealthChecks() []verify.Check { Soft: true, Run: func(context.Context) error { if err := engram.VerifyInstalled(); err != nil { - return fmt.Errorf("%w\nIf engram was installed via `go install`, add it to PATH:\n %s", err, engramPathGuidance(os.Getenv("SHELL"))) + binDirs := engramDownloadBinDirs() + binDir := binDirs[len(binDirs)-1] + return fmt.Errorf("%w\nIf engram was installed as a downloaded binary, add its install directory to PATH:\n %s", err, engramPathGuidance(os.Getenv("SHELL"), binDir)) } return nil }, @@ -1132,8 +1135,7 @@ func antigravityCollisionCheck(agents []model.AgentID) []verify.Check { } } -func engramPathGuidance(shellPath string) string { - binDir := goInstallBinDir() +func engramPathGuidance(shellPath, binDir string) string { if strings.Contains(shellPath, "fish") { return fmt.Sprintf("set -Ux fish_user_paths %s $fish_user_paths", binDir) } diff --git a/internal/cli/run_notes_test.go b/internal/cli/run_notes_test.go index 4d768dd4b..8325ede04 100644 --- a/internal/cli/run_notes_test.go +++ b/internal/cli/run_notes_test.go @@ -1,6 +1,8 @@ package cli import ( + "os" + "path/filepath" "strings" "testing" @@ -23,9 +25,9 @@ func TestWithPostInstallNotesAddsGGANextSteps(t *testing.T) { } func TestWithPostInstallNotesDoesNotChangeNonGGA(t *testing.T) { - // Set GOBIN to a directory already in PATH so that withGoInstallPathNote - // does not append a PATH guidance note for the Engram component. - t.Setenv("GOBIN", "/usr/local/bin") + // Put both direct-download locations in PATH so the Engram guidance note is skipped. + home, _ := os.UserHomeDir() + t.Setenv("PATH", "/usr/bin"+string(os.PathListSeparator)+"/usr/local/bin"+string(os.PathListSeparator)+filepath.Join(home, ".local", "bin")) report := verify.Report{Ready: true, FinalNote: "You're ready."} resolved := planner.ResolvedPlan{OrderedComponents: []model.ComponentID{model.ComponentEngram}} diff --git a/internal/cli/run_path_guidance_test.go b/internal/cli/run_path_guidance_test.go index 00c446858..bbacc5a0b 100644 --- a/internal/cli/run_path_guidance_test.go +++ b/internal/cli/run_path_guidance_test.go @@ -12,59 +12,38 @@ import ( ) func TestEngramPathGuidanceFish(t *testing.T) { - msg := engramPathGuidance("/usr/bin/fish") + msg := engramPathGuidance("/usr/bin/fish", "/home/user/.local/bin") if want := "fish_user_paths"; !strings.Contains(msg, want) { t.Fatalf("engramPathGuidance(fish) missing %q: %s", want, msg) } } func TestEngramPathGuidanceZsh(t *testing.T) { - msg := engramPathGuidance("/bin/zsh") + msg := engramPathGuidance("/bin/zsh", "/home/user/.local/bin") if want := ".zshrc"; !strings.Contains(msg, want) { t.Fatalf("engramPathGuidance(zsh) missing %q: %s", want, msg) } } func TestEngramPathGuidanceDefault(t *testing.T) { - msg := engramPathGuidance("") - if want := "go/bin"; !strings.Contains(msg, want) { + msg := engramPathGuidance("", "/home/user/.local/bin") + if want := "Add "; !strings.Contains(msg, want) { t.Fatalf("engramPathGuidance(default) missing %q: %s", want, msg) } } -func TestGoInstallBinDirDefaultsToHomeGoBin(t *testing.T) { - t.Setenv("GOBIN", "") - t.Setenv("GOPATH", "") - - dir := goInstallBinDir() +func TestEngramDownloadBinDirsIncludesLocalBin(t *testing.T) { + dirs := engramDownloadBinDirs() home, _ := os.UserHomeDir() - want := filepath.Join(home, "go", "bin") - if dir != want { - t.Fatalf("goInstallBinDir() = %q, want %q", dir, want) - } -} - -func TestGoInstallBinDirRespectsGOBIN(t *testing.T) { - t.Setenv("GOBIN", "/custom/gobin") - dir := goInstallBinDir() - if dir != "/custom/gobin" { - t.Fatalf("goInstallBinDir() = %q, want /custom/gobin", dir) - } -} - -func TestGoInstallBinDirRespectsGOPATH(t *testing.T) { - t.Setenv("GOBIN", "") - t.Setenv("GOPATH", "/custom/gopath") - dir := goInstallBinDir() - want := filepath.Join("/custom/gopath", "bin") - if dir != want { - t.Fatalf("goInstallBinDir() = %q, want %q", dir, want) + want := filepath.Join(home, ".local", "bin") + if len(dirs) != 2 || dirs[0] != "/usr/local/bin" || dirs[1] != want { + t.Fatalf("engramDownloadBinDirs() = %v, want [/usr/local/bin %s]", dirs, want) } } func TestIsInPATH(t *testing.T) { - t.Setenv("PATH", "/usr/bin"+string(os.PathListSeparator)+"/home/user/go/bin") - if !isInPATH("/home/user/go/bin") { + t.Setenv("PATH", "/usr/bin"+string(os.PathListSeparator)+"/home/user/.local/bin") + if !isInPATH("/home/user/.local/bin") { t.Fatal("isInPATH should return true for entry in PATH") } if isInPATH("/not/in/path") { @@ -72,10 +51,18 @@ func TestIsInPATH(t *testing.T) { } } -func TestWithGoInstallPathNoteAddsNoteWhenNotInPATH(t *testing.T) { - t.Setenv("GOBIN", "") - t.Setenv("GOPATH", "") - // Set PATH to something that does NOT contain ~/go/bin. +func TestAllInPATH(t *testing.T) { + t.Setenv("PATH", "/usr/bin"+string(os.PathListSeparator)+"/home/user/.local/bin") + if !allInPATH([]string{"/usr/bin", "/home/user/.local/bin"}) { + t.Fatal("allInPATH should return true when all entries are in PATH") + } + if allInPATH([]string{"/usr/bin", "/not/in/path"}) { + t.Fatal("allInPATH should return false when an entry is missing") + } +} + +func TestWithEngramPathNoteAddsNoteWhenNotInPATH(t *testing.T) { + // Set PATH to something that does NOT contain the direct-download install dirs. t.Setenv("PATH", "/usr/bin:/usr/local/bin") report := verify.Report{Ready: true, FinalNote: "You're ready."} @@ -84,34 +71,35 @@ func TestWithGoInstallPathNoteAddsNoteWhenNotInPATH(t *testing.T) { PlatformDecision: planner.PlatformDecision{PackageManager: "apt"}, } - updated := withGoInstallPathNote(report, resolved) - if !strings.Contains(updated.FinalNote, "go install") { - t.Fatalf("FinalNote should contain go install guidance, got: %q", updated.FinalNote) + updated := withEngramPathNote(report, resolved) + if !strings.Contains(updated.FinalNote, "pre-built binary") { + t.Fatalf("FinalNote should contain direct binary guidance, got: %q", updated.FinalNote) + } + if strings.Contains(updated.FinalNote, "go install") { + t.Fatalf("FinalNote should not mention go install, got: %q", updated.FinalNote) } - if !strings.Contains(updated.FinalNote, "go/bin") { - t.Fatalf("FinalNote should reference go/bin dir, got: %q", updated.FinalNote) + if !strings.Contains(updated.FinalNote, ".local/bin") { + t.Fatalf("FinalNote should reference .local/bin dir, got: %q", updated.FinalNote) } } -func TestWithGoInstallPathNoteSkipsWhenBrew(t *testing.T) { +func TestWithEngramPathNoteSkipsWhenBrew(t *testing.T) { report := verify.Report{Ready: true, FinalNote: "You're ready."} resolved := planner.ResolvedPlan{ OrderedComponents: []model.ComponentID{model.ComponentEngram}, PlatformDecision: planner.PlatformDecision{PackageManager: "brew"}, } - updated := withGoInstallPathNote(report, resolved) + updated := withEngramPathNote(report, resolved) if updated.FinalNote != report.FinalNote { t.Fatalf("FinalNote should be unchanged for brew, got: %q", updated.FinalNote) } } -func TestWithGoInstallPathNoteSkipsWhenInPATH(t *testing.T) { - t.Setenv("GOBIN", "") - t.Setenv("GOPATH", "") +func TestWithEngramPathNoteSkipsWhenInPATH(t *testing.T) { home, _ := os.UserHomeDir() - goBin := filepath.Join(home, "go", "bin") - t.Setenv("PATH", "/usr/bin"+string(os.PathListSeparator)+goBin) + localBin := filepath.Join(home, ".local", "bin") + t.Setenv("PATH", "/usr/bin"+string(os.PathListSeparator)+"/usr/local/bin"+string(os.PathListSeparator)+localBin) report := verify.Report{Ready: true, FinalNote: "You're ready."} resolved := planner.ResolvedPlan{ @@ -119,20 +107,20 @@ func TestWithGoInstallPathNoteSkipsWhenInPATH(t *testing.T) { PlatformDecision: planner.PlatformDecision{PackageManager: "apt"}, } - updated := withGoInstallPathNote(report, resolved) + updated := withEngramPathNote(report, resolved) if updated.FinalNote != report.FinalNote { - t.Fatalf("FinalNote should be unchanged when go/bin is in PATH, got: %q", updated.FinalNote) + t.Fatalf("FinalNote should be unchanged when engram install dirs are in PATH, got: %q", updated.FinalNote) } } -func TestWithGoInstallPathNoteSkipsWithoutEngram(t *testing.T) { +func TestWithEngramPathNoteSkipsWithoutEngram(t *testing.T) { report := verify.Report{Ready: true, FinalNote: "You're ready."} resolved := planner.ResolvedPlan{ OrderedComponents: []model.ComponentID{model.ComponentGGA}, PlatformDecision: planner.PlatformDecision{PackageManager: "apt"}, } - updated := withGoInstallPathNote(report, resolved) + updated := withEngramPathNote(report, resolved) if updated.FinalNote != report.FinalNote { t.Fatalf("FinalNote should be unchanged without engram, got: %q", updated.FinalNote) } diff --git a/internal/components/engram/install_test.go b/internal/components/engram/install_test.go index 61c68da95..3e8d34b8b 100644 --- a/internal/components/engram/install_test.go +++ b/internal/components/engram/install_test.go @@ -37,8 +37,8 @@ func TestInstallCommandByProfile(t *testing.T) { wantErr: true, }, { - name: "unsupported package manager returns error", - profile: system.PlatformProfile{OS: "linux", PackageManager: "zypper"}, + name: "opensuse returns error (uses DownloadLatestBinary instead of go install)", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, wantErr: true, }, } diff --git a/internal/components/gga/install_test.go b/internal/components/gga/install_test.go index 0548cbada..0e8ec7a11 100644 --- a/internal/components/gga/install_test.go +++ b/internal/components/gga/install_test.go @@ -99,11 +99,20 @@ func TestInstallCommandByProfile(t *testing.T) { {"bash", "/tmp/gentleman-guardian-angel/install.sh"}, }, }, + { + name: "opensuse uses git clone and install.sh", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + want: [][]string{ + {"rm", "-rf", "/tmp/gentleman-guardian-angel"}, + {"git", "clone", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", "/tmp/gentleman-guardian-angel"}, + {"bash", "/tmp/gentleman-guardian-angel/install.sh"}, + }, + }, { name: "unsupported package manager returns error", profile: system.PlatformProfile{ OS: "linux", - PackageManager: "zypper", + PackageManager: "unknownpm", }, wantErr: true, }, diff --git a/internal/installcmd/resolver.go b/internal/installcmd/resolver.go index 2de600de6..1ecb4830e 100644 --- a/internal/installcmd/resolver.go +++ b/internal/installcmd/resolver.go @@ -124,6 +124,8 @@ func uvInstallHint(profile system.PlatformProfile) string { return "sudo pacman -S --noconfirm uv" case "dnf": return "sudo dnf install -y uv" + case "zypper": + return "sudo zypper --non-interactive install uv" case "winget": return "winget install --id astral-sh.uv -e --accept-source-agreements --accept-package-agreements" default: @@ -156,6 +158,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 "zypper": + return CommandSequence{{"sudo", "zypper", "--non-interactive", "install", dependency}}, nil case "winget": return CommandSequence{{"winget", "install", "--id", dependency, "-e", "--accept-source-agreements", "--accept-package-agreements"}}, nil default: @@ -178,7 +182,7 @@ func resolveOpenCodeInstall(profile system.PlatformProfile) (CommandSequence, er return CommandSequence{ {"brew", "install", "anomalyco/tap/opencode"}, }, nil - case "apt", "pacman", "dnf": + case "apt", "pacman", "dnf", "zypper": if profile.NpmWritable { return CommandSequence{{"npm", "install", "-g", "opencode-ai"}}, nil } @@ -204,7 +208,7 @@ func resolveGGAInstall(profile system.PlatformProfile) (CommandSequence, error) {"brew", "tap", "Gentleman-Programming/homebrew-tap"}, {"brew", "reinstall", "gga"}, }, nil - case "apt", "pacman", "dnf": + case "apt", "pacman", "dnf", "zypper": const tmpDir = "/tmp/gentleman-guardian-angel" return CommandSequence{ {"rm", "-rf", tmpDir}, @@ -287,20 +291,20 @@ func gitBashPath() string { return "bash" } -// validateGoForModuleInstall checks that Go ≥1.24 is installed and GO111MODULE is not -// disabled before attempting `go install`. Returns an actionable error if any check fails. +// validateGoForModuleInstall checks that Go >=1.24 is installed and GO111MODULE is not +// disabled before attempting a source module install. Returns an actionable error if any check fails. // MUST NOT be called for brew-based installs (brew manages Go transitively). func validateGoForModuleInstall(profile system.PlatformProfile) error { if _, err := cmdLookPath("go"); err != nil { return fmt.Errorf( - "Go 1.24+ is required to install Engram but was not found in PATH.\n" + + "Go 1.24+ is required for source module installs but was not found in PATH.\n" + "Please install Go from https://go.dev/dl/ and restart your terminal.") } out, err := cmdGoVersion() if err != nil { return fmt.Errorf( - "Go 1.24+ is required but could not verify the installed version.\n" + + "Go 1.24+ is required for source module installs but could not verify the installed version.\n" + "Please ensure Go is properly installed: https://go.dev/dl/") } @@ -314,7 +318,7 @@ func validateGoForModuleInstall(profile system.PlatformProfile) error { minor, _ := strconv.Atoi(versionParts[1]) if major < 1 || (major == 1 && minor < 24) { return fmt.Errorf( - "Go 1.24+ is required to install Engram, but found go%s.\n"+ + "Go 1.24+ is required for source module installs, but found go%s.\n"+ "Please update Go: https://go.dev/dl/", versionStr) } } diff --git a/internal/installcmd/resolver_test.go b/internal/installcmd/resolver_test.go index 79b30d4ab..ff217aaad 100644 --- a/internal/installcmd/resolver_test.go +++ b/internal/installcmd/resolver_test.go @@ -176,6 +176,12 @@ func TestResolveDependencyInstall(t *testing.T) { dep: "somepkg", want: CommandSequence{{"sudo", "dnf", "install", "-y", "somepkg"}}, }, + { + name: "opensuse resolves zypper command", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + dep: "somepkg", + want: CommandSequence{{"sudo", "zypper", "--non-interactive", "install", "somepkg"}}, + }, { name: "windows resolves winget command", profile: system.PlatformProfile{OS: "windows", PackageManager: "winget"}, @@ -184,7 +190,7 @@ func TestResolveDependencyInstall(t *testing.T) { }, { name: "unsupported package manager returns error", - profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroUbuntu, PackageManager: "zypper"}, + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroUbuntu, PackageManager: "unknownpm"}, dep: "somepkg", wantErr: true, }, @@ -357,6 +363,12 @@ func TestResolveAgentInstall(t *testing.T) { agent: model.AgentOpenCode, want: CommandSequence{{"npm", "install", "-g", "opencode-ai"}}, }, + { + name: "opencode on opensuse system npm uses sudo", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + agent: model.AgentOpenCode, + want: CommandSequence{{"sudo", "npm", "install", "-g", "opencode-ai"}}, + }, { name: "claude-code on windows uses npm without sudo", profile: system.PlatformProfile{OS: "windows", PackageManager: "winget", NpmWritable: true}, @@ -446,6 +458,19 @@ func TestValidateAgentInstallPreflight(t *testing.T) { wantErr: true, errContains: "brew install uv", }, + { + name: "kimi missing uv on opensuse returns zypper remediation", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper", Supported: true}, + agent: model.AgentKimi, + lookPath: func(file string) (string, error) { + if file == "uv" { + return "", fmt.Errorf("not found") + } + return "/usr/bin/" + file, nil + }, + wantErr: true, + errContains: "sudo zypper --non-interactive install uv", + }, { name: "kimi with uv present passes preflight", profile: system.PlatformProfile{OS: "linux", PackageManager: "apt", Supported: true}, @@ -539,6 +564,12 @@ func TestResolveComponentInstall(t *testing.T) { component: model.ComponentEngram, wantErr: true, }, + { + name: "engram on opensuse returns error (uses DownloadLatestBinary instead)", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + component: model.ComponentEngram, + wantErr: true, + }, { name: "gga on darwin uses brew tap and reinstall", profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew"}, @@ -575,6 +606,16 @@ func TestResolveComponentInstall(t *testing.T) { {"bash", "/tmp/gentleman-guardian-angel/install.sh"}, }, }, + { + name: "gga on opensuse uses git clone and install.sh", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + component: model.ComponentGGA, + want: CommandSequence{ + {"rm", "-rf", "/tmp/gentleman-guardian-angel"}, + {"git", "clone", "https://github.com/Gentleman-Programming/gentleman-guardian-angel.git", "/tmp/gentleman-guardian-angel"}, + {"bash", "/tmp/gentleman-guardian-angel/install.sh"}, + }, + }, { name: "engram on windows returns error (uses DownloadLatestBinary instead)", profile: system.PlatformProfile{OS: "windows", PackageManager: "winget"}, diff --git a/internal/system/deps.go b/internal/system/deps.go index 8bd6c103e..c93fb7e7f 100644 --- a/internal/system/deps.go +++ b/internal/system/deps.go @@ -76,7 +76,8 @@ func defineDependencies(profile PlatformProfile) []Dependency { }) } - // go is optional (needed for Engram on Linux via go install). + // go is optional. It is only needed for local development and source builds; + // normal installs use released binaries and do not require Go. deps = append(deps, Dependency{ Name: "go", Required: false, @@ -245,8 +246,20 @@ func RenderDependencyReport(report DependencyReport) string { } if len(report.MissingOptional) > 0 { - b.WriteString(fmt.Sprintf("Missing optional: %s\n", strings.Join(report.MissingOptional, ", "))) + b.WriteString(fmt.Sprintf("Missing optional: %s\n", formatMissingOptional(report.MissingOptional))) } return strings.TrimRight(b.String(), "\n") } + +func formatMissingOptional(missing []string) string { + formatted := make([]string, 0, len(missing)) + for _, dep := range missing { + if dep == "go" { + formatted = append(formatted, "go (only needed for local development/source builds)") + continue + } + formatted = append(formatted, dep) + } + return strings.Join(formatted, ", ") +} diff --git a/internal/system/deps_test.go b/internal/system/deps_test.go index 4778eff7f..48e8a4e7b 100644 --- a/internal/system/deps_test.go +++ b/internal/system/deps_test.go @@ -231,13 +231,15 @@ func TestRenderDependencyReportMissing(t *testing.T) { Dependencies: []Dependency{ {Name: "git", Required: true, Installed: true, Version: "2.43.0"}, {Name: "node", Required: true, Installed: false, InstallHint: "brew install node"}, + {Name: "go", Required: false, Installed: false, InstallHint: "brew install go"}, }, AllPresent: false, MissingRequired: []string{"node"}, + MissingOptional: []string{"go"}, } output := RenderDependencyReport(report) - if !containsAll(output, "node", "x NOT FOUND", "Missing required: node") { + if !containsAll(output, "node", "x NOT FOUND", "Missing required: node", "Missing optional: go (only needed for local development/source builds)") { t.Fatalf("output missing expected content:\n%s", output) } } diff --git a/internal/system/detect.go b/internal/system/detect.go index f2c1b3012..f87a20507 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -25,11 +25,12 @@ type PlatformProfile struct { } const ( - LinuxDistroUnknown = "unknown" - LinuxDistroUbuntu = "ubuntu" - LinuxDistroDebian = "debian" - LinuxDistroArch = "arch" - LinuxDistroFedora = "fedora" + LinuxDistroUnknown = "unknown" + LinuxDistroUbuntu = "ubuntu" + LinuxDistroDebian = "debian" + LinuxDistroArch = "arch" + LinuxDistroFedora = "fedora" + LinuxDistroOpenSUSE = "opensuse" ) type DetectionResult struct { @@ -142,6 +143,9 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt case LinuxDistroFedora: profile.PackageManager = "dnf" profile.Supported = true + case LinuxDistroOpenSUSE: + profile.PackageManager = "zypper" + profile.Supported = true default: profile.PackageManager = "" profile.Supported = false @@ -198,6 +202,10 @@ func detectLinuxDistro(linuxOSRelease string) string { return LinuxDistroFedora } + if isOpenSUSELike(id, idLike) { + return LinuxDistroOpenSUSE + } + return LinuxDistroUnknown } @@ -242,3 +250,17 @@ func isFedoraLike(id, idLike string) bool { return false } + +func isOpenSUSELike(id, idLike string) bool { + if id == LinuxDistroOpenSUSE || strings.HasPrefix(id, "opensuse-") { + return true + } + + for _, token := range strings.Fields(idLike) { + if token == LinuxDistroOpenSUSE || strings.HasPrefix(token, "opensuse-") { + return true + } + } + + return false +} diff --git a/internal/system/detect_test.go b/internal/system/detect_test.go index 33a3ffbe2..d1bf44c9b 100644 --- a/internal/system/detect_test.go +++ b/internal/system/detect_test.go @@ -56,6 +56,23 @@ func TestDetectFromInputsMarksFedoraSupported(t *testing.T) { } } +func TestDetectFromInputsMarksOpenSUSESupported(t *testing.T) { + osRelease := "ID=opensuse-tumbleweed\nID_LIKE=\"opensuse suse\"\n" + result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil) + + if !result.System.Supported { + t.Fatalf("expected openSUSE linux distro to be supported") + } + + if result.System.Profile.LinuxDistro != LinuxDistroOpenSUSE { + t.Fatalf("expected opensuse distro, got %q", result.System.Profile.LinuxDistro) + } + + if result.System.Profile.PackageManager != "zypper" { + t.Fatalf("expected zypper package manager, got %q", result.System.Profile.PackageManager) + } +} + func TestDetectFromInputsMarksUbuntuSupported(t *testing.T) { osRelease := "ID=ubuntu\nID_LIKE=debian\n" result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil) @@ -168,6 +185,21 @@ func TestDetectLinuxDistroMatrix(t *testing.T) { osRelease: "ID=custom-linux\nID_LIKE=\"nobara\"\n", wantDistro: LinuxDistroFedora, }, + { + name: "opensuse tumbleweed", + osRelease: "ID=opensuse-tumbleweed\nID_LIKE=\"opensuse suse\"\nVERSION_ID=\"20260426\"\n", + wantDistro: LinuxDistroOpenSUSE, + }, + { + name: "opensuse leap", + osRelease: "ID=opensuse-leap\nID_LIKE=\"suse opensuse\"\n", + wantDistro: LinuxDistroOpenSUSE, + }, + { + name: "opensuse via id_like token", + osRelease: "ID=custom-linux\nID_LIKE=\"opensuse\"\n", + wantDistro: LinuxDistroOpenSUSE, + }, { name: "empty os-release", osRelease: "", @@ -269,6 +301,15 @@ func TestResolvePlatformProfileMatrix(t *testing.T) { wantDistro: LinuxDistroFedora, wantSupported: true, }, + { + name: "opensuse profile", + goos: "linux", + osRelease: "ID=opensuse-tumbleweed\nID_LIKE=\"opensuse suse\"\n", + wantOS: "linux", + wantPM: "zypper", + wantDistro: LinuxDistroOpenSUSE, + wantSupported: true, + }, { name: "windows profile", goos: "windows", diff --git a/internal/system/guard.go b/internal/system/guard.go index 341adaf6d..364a0cd74 100644 --- a/internal/system/guard.go +++ b/internal/system/guard.go @@ -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 openSUSE family (detected %s)", ErrUnsupportedLinuxDistro, profile.LinuxDistro) } return nil diff --git a/internal/system/guard_test.go b/internal/system/guard_test.go index dab6993fa..cd6e1a30e 100644 --- a/internal/system/guard_test.go +++ b/internal/system/guard_test.go @@ -47,6 +47,13 @@ func TestEnsureSupportedPlatformAllowsSupportedFedoraLinux(t *testing.T) { } } +func TestEnsureSupportedPlatformAllowsSupportedOpenSUSELinux(t *testing.T) { + err := EnsureSupportedPlatform(PlatformProfile{OS: "linux", LinuxDistro: LinuxDistroOpenSUSE, PackageManager: "zypper", Supported: true}) + if err != nil { + t.Fatalf("expected opensuse profile to be supported, got %v", err) + } +} + func TestEnsureSupportedPlatformRejectsUnsupportedLinuxDistro(t *testing.T) { err := EnsureSupportedPlatform(PlatformProfile{OS: "linux", LinuxDistro: LinuxDistroUnknown, Supported: false}) if err == nil { @@ -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 openSUSE family") { t.Fatalf("expected distro guard message, got %q", err.Error()) } } diff --git a/internal/system/install_deps.go b/internal/system/install_deps.go index 37b7e4445..9bdafd6f1 100644 --- a/internal/system/install_deps.go +++ b/internal/system/install_deps.go @@ -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 == "zypper": + return "sudo zypper --non-interactive install git" default: return "install git from https://git-scm.com/" } @@ -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 == "zypper": + return "sudo zypper --non-interactive install curl" default: return "install curl from https://curl.se/" } @@ -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 == "zypper": + return "sudo zypper --non-interactive install nodejs npm" default: return "install node from https://nodejs.org/" } @@ -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 == "zypper": + return "sudo zypper --non-interactive install go" default: return "install go from https://go.dev/dl/" } @@ -119,6 +127,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 == "zypper": + return [][]string{{"sudo", "zypper", "--non-interactive", "install", "git"}} default: return nil } @@ -137,6 +147,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 == "zypper": + return [][]string{{"sudo", "zypper", "--non-interactive", "install", "curl"}} default: return nil } @@ -162,6 +174,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 == "zypper": + return [][]string{{"sudo", "zypper", "--non-interactive", "install", "nodejs", "npm"}} default: return nil } @@ -188,6 +202,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 == "zypper": + return [][]string{{"sudo", "zypper", "--non-interactive", "install", "go"}} default: return nil } diff --git a/internal/system/install_deps_test.go b/internal/system/install_deps_test.go index 153b9e638..18ced666b 100644 --- a/internal/system/install_deps_test.go +++ b/internal/system/install_deps_test.go @@ -61,6 +61,14 @@ func TestInstallHintNodeFedora(t *testing.T) { } } +func TestInstallHintNodeOpenSUSE(t *testing.T) { + profile := PlatformProfile{OS: "linux", PackageManager: "zypper", LinuxDistro: LinuxDistroOpenSUSE} + hint := installHintNode(profile) + if !strings.Contains(hint, "zypper") || !strings.Contains(hint, "nodejs npm") { + t.Fatalf("installHintNode(opensuse) = %q, want zypper nodejs npm install", hint) + } +} + func TestInstallHintBrew(t *testing.T) { hint := installHintBrew() if !strings.Contains(hint, "Homebrew") { @@ -84,6 +92,14 @@ func TestInstallHintGoUbuntu(t *testing.T) { } } +func TestInstallHintGoOpenSUSE(t *testing.T) { + profile := PlatformProfile{OS: "linux", PackageManager: "zypper", LinuxDistro: LinuxDistroOpenSUSE} + hint := installHintGo(profile) + if !strings.Contains(hint, "zypper") || !strings.Contains(hint, "install go") { + t.Fatalf("installHintGo(opensuse) = %q", hint) + } +} + func TestInstallCommandsForDepGitDarwin(t *testing.T) { profile := PlatformProfile{OS: "darwin", PackageManager: "brew"} cmds := InstallCommandsForDep("git", profile) @@ -182,6 +198,28 @@ func TestInstallCommandsForDepGitFedoraUsesDnf(t *testing.T) { } } +func TestInstallCommandsForDepGitOpenSUSEUsesZypper(t *testing.T) { + profile := PlatformProfile{OS: "linux", PackageManager: "zypper", LinuxDistro: LinuxDistroOpenSUSE} + cmds := InstallCommandsForDep("git", profile) + if len(cmds) != 1 { + t.Fatalf("git opensuse commands = %d, want 1", len(cmds)) + } + if cmds[0][0] != "sudo" || cmds[0][1] != "zypper" || cmds[0][2] != "--non-interactive" { + t.Fatalf("git opensuse command = %v, want sudo zypper --non-interactive", cmds[0]) + } +} + +func TestInstallCommandsForDepNodeOpenSUSEUsesZypper(t *testing.T) { + profile := PlatformProfile{OS: "linux", PackageManager: "zypper", LinuxDistro: LinuxDistroOpenSUSE} + cmds := InstallCommandsForDep("node", profile) + if len(cmds) != 1 { + t.Fatalf("node opensuse commands = %d, want 1", len(cmds)) + } + if cmds[0][0] != "sudo" || cmds[0][1] != "zypper" || cmds[0][4] != "nodejs" || cmds[0][5] != "npm" { + t.Fatalf("node opensuse command = %v, want sudo zypper --non-interactive install nodejs npm", cmds[0]) + } +} + func TestFormatMissingDepsMessageAllPresent(t *testing.T) { report := DependencyReport{AllPresent: true} msg := FormatMissingDepsMessage(report) @@ -281,6 +319,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: "zypper", LinuxDistro: LinuxDistroOpenSUSE}, } deps := []string{"git", "curl", "node", "go"}