diff --git a/PRD.md b/PRD.md index daac8ec07..0e53c641f 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 60f09600f..fd70cf78e 100644 --- a/docs/non-interactive.md +++ b/docs/non-interactive.md @@ -37,6 +37,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 8676096c6..dbbfec3d2 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 | 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, openSUSE Tumbleweed, openSUSE Leap, etc.). Release artifacts are produced by CI, but Windows users should install through Scoop so upgrades stay consistent. diff --git a/docs/quickstart.md b/docs/quickstart.md index abbe4a829..1295c7394 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`). - Pi installed and available as `pi` on `PATH` if you select the Pi agent. @@ -50,7 +57,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. @@ -96,4 +103,4 @@ Optional wrapper tools for extra defense: 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 e88d7ef7a..76ce523ce 100644 --- a/docs/rollback.md +++ b/docs/rollback.md @@ -68,5 +68,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 1586b1a87..d6614ae0d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -281,6 +281,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 9fa7bc733..a52a3c220 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 3be10562c..29ccdb8bd 100644 --- a/internal/agents/opencode/adapter_test.go +++ b/internal/agents/opencode/adapter_test.go @@ -122,9 +122,14 @@ func TestInstallCommand(t *testing.T) { profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroFedora, PackageManager: "dnf", NpmWritable: true}, want: [][]string{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, }, + { + name: "opensuse resolves npm install", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroOpenSUSE, PackageManager: "zypper"}, + want: [][]string{{"sudo", "npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, + }, { 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 18fed5fd1..c1ce22a77 100644 --- a/internal/app/parity_test.go +++ b/internal/app/parity_test.go @@ -129,6 +129,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{ @@ -339,6 +346,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/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 4cdf9cce8..261156f51 100644 --- a/internal/installcmd/resolver.go +++ b/internal/installcmd/resolver.go @@ -140,6 +140,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: @@ -172,6 +174,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: @@ -194,7 +198,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": pkg := "opencode-ai@" + versions.OpenCode if profile.NpmWritable { return CommandSequence{{"npm", "install", "-g", "--ignore-scripts", pkg}}, nil @@ -221,7 +225,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}, @@ -304,8 +308,8 @@ 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 { diff --git a/internal/installcmd/resolver_test.go b/internal/installcmd/resolver_test.go index 8610d0ed0..38c44a10e 100644 --- a/internal/installcmd/resolver_test.go +++ b/internal/installcmd/resolver_test.go @@ -177,6 +177,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"}, @@ -185,7 +191,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, }, @@ -358,6 +364,12 @@ func TestResolveAgentInstall(t *testing.T) { agent: model.AgentOpenCode, want: CommandSequence{{"npm", "install", "-g", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, }, + { + 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", "--ignore-scripts", "opencode-ai@" + versions.OpenCode}}, + }, { name: "claude-code on windows uses npm without sudo", profile: system.PlatformProfile{OS: "windows", PackageManager: "winget", NpmWritable: true}, @@ -447,6 +459,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}, @@ -565,6 +590,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"}, @@ -601,6 +632,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 4260f9939..41971c7f6 100644 --- a/internal/system/deps_test.go +++ b/internal/system/deps_test.go @@ -232,13 +232,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 dea63d866..9eb8aa837 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -26,11 +26,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 { @@ -148,6 +149,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 @@ -204,6 +208,10 @@ func detectLinuxDistro(linuxOSRelease string) string { return LinuxDistroFedora } + if isOpenSUSELike(id, idLike) { + return LinuxDistroOpenSUSE + } + return LinuxDistroUnknown } @@ -248,3 +256,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 7b9df5ed9..d1772b92d 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"}