diff --git a/docs/platforms.md b/docs/platforms.md index 748d316fd..384265c9d 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -10,6 +10,7 @@ | Linux (Ubuntu/Debian) | apt | Supported | | Linux (Arch) | pacman | Supported | | Linux (Fedora/RHEL family) | dnf | Supported | +| Linux (NixOS) | nix | 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.). diff --git a/internal/cli/run.go b/internal/cli/run.go index 75e7748d1..ad0ba64ea 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -793,8 +793,8 @@ func (s componentApplyStep) Run() error { engramCommand = binaryPath } else if installedPath, err := cmdLookPath("engram"); err != nil { // Engram not on PATH — install it. - if s.profile.PackageManager == "brew" { - // macOS (or Linux with Homebrew): use brew tap + brew install. + if s.profile.PackageManager == "brew" || s.profile.PackageManager == "nix" { + // macOS/Brew or NixOS: use component resolver. NixOS installs from source via go install. commands, err := engram.InstallCommand(s.profile) if err != nil { return fmt.Errorf("resolve install command for component %q: %w", s.component, err) @@ -802,6 +802,17 @@ func (s componentApplyStep) Run() error { if err := runCommandSequence(commands); err != nil { return err } + if s.profile.PackageManager == "nix" { + // Prepends GOBIN/GOPATH bin to path so subsequent commands find the new binary. + if binDir, err := goInstallBinDirFromGoEnv(); err == nil && binDir != "" { + _ = prependToPath(binDir) + binaryName := "engram" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + engramCommand = filepath.Join(binDir, binaryName) + } + } } else { // Linux / Windows: download the pre-built binary from GitHub Releases. // No Go required — engram ships pre-built binaries. diff --git a/internal/cli/run_engram_download_test.go b/internal/cli/run_engram_download_test.go index bc0ad5ea0..a00368b9a 100644 --- a/internal/cli/run_engram_download_test.go +++ b/internal/cli/run_engram_download_test.go @@ -3,6 +3,7 @@ package cli import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -501,7 +502,11 @@ func TestRunInstallMacOSEngramStillUsesBrew(t *testing.T) { func TestRunInstallBetaEngramUsesMainGoInstallAndInstalledBinary(t *testing.T) { home := t.TempDir() gobin := filepath.Join(home, "go-bin") - betaEngram := filepath.Join(gobin, "engram") + betaEngramName := "engram" + if runtime.GOOS == "windows" { + betaEngramName += ".exe" + } + betaEngram := filepath.Join(gobin, betaEngramName) restoreCommand := runCommand restoreLookPath := cmdLookPath @@ -554,5 +559,61 @@ func TestRunInstallBetaEngramUsesMainGoInstallAndInstalledBinary(t *testing.T) { } } +// TestRunInstallNixOSEngramUsesGoInstall verifies NixOS uses go install. +func TestRunInstallNixOSEngramUsesGoInstall(t *testing.T) { + home := t.TempDir() + restoreHome := osUserHomeDir + restoreCommand := runCommand + restoreLookPath := cmdLookPath + restoreGoEnv := goEnv + t.Cleanup(func() { + osUserHomeDir = restoreHome + runCommand = restoreCommand + cmdLookPath = restoreLookPath + goEnv = restoreGoEnv + }) + + osUserHomeDir = func() (string, error) { return home, nil } + cmdLookPath = missingBinaryLookPath + gobin := filepath.Join(home, "go-bin") + goEnv = func(keys ...string) (map[string]string, error) { + return map[string]string{"GOBIN": gobin, "GOPATH": filepath.Join(home, "go")}, nil + } + recorder := &commandRecorder{} + runCommand = recorder.record + + // DownloadFn should NOT be called for NixOS (go install handles it). + origDownloadFn := engramDownloadFn + engramDownloadFn = func(profile system.PlatformProfile) (string, error) { + t.Error("DownloadLatestBinary should NOT be called on NixOS (go install handles it)") + return "", nil + } + t.Cleanup(func() { engramDownloadFn = origDownloadFn }) + + detection := linuxDetectionResult(system.LinuxDistroNixOS, "nix") + result, err := RunInstall( + []string{"--agent", "opencode", "--component", "engram"}, + detection, + ) + if err != nil { + t.Fatalf("RunInstall() error = %v", err) + } + if !result.Verify.Ready { + t.Fatalf("verification ready = false") + } + + // Must use go install. + commands := recorder.get() + foundGoInstall := false + for _, cmd := range commands { + if strings.Contains(cmd, "go install github.com/Gentleman-Programming/engram/cmd/engram@latest") { + foundGoInstall = true + } + } + if !foundGoInstall { + t.Fatalf("expected go install on NixOS, got commands: %v", commands) + } +} + // Make sure the engram package's DownloadLatestBinary is accessible. var _ = engram.DownloadLatestBinary diff --git a/internal/components/gga/config_test.go b/internal/components/gga/config_test.go index ec9021c5b..29c10daa6 100644 --- a/internal/components/gga/config_test.go +++ b/internal/components/gga/config_test.go @@ -3,6 +3,7 @@ package gga import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -119,6 +120,10 @@ func TestBuildConfigDifferentProviders(t *testing.T) { func TestInjectWritesConfigAndAgents(t *testing.T) { home := t.TempDir() + if runtime.GOOS == "windows" { + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + } + result, err := Inject(home, []model.AgentID{model.AgentClaudeCode}) if err != nil { t.Fatalf("Inject() error = %v", err) @@ -163,6 +168,10 @@ func TestInjectWritesConfigAndAgents(t *testing.T) { func TestInjectIsIdempotent(t *testing.T) { home := t.TempDir() + if runtime.GOOS == "windows" { + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + } + first, err := Inject(home, []model.AgentID{model.AgentOpenCode}) if err != nil { t.Fatalf("Inject() first error = %v", err) diff --git a/internal/components/gga/install_test.go b/internal/components/gga/install_test.go index 0548cbada..74fdea609 100644 --- a/internal/components/gga/install_test.go +++ b/internal/components/gga/install_test.go @@ -99,6 +99,15 @@ func TestInstallCommandByProfile(t *testing.T) { {"bash", "/tmp/gentleman-guardian-angel/install.sh"}, }, }, + { + name: "nixos uses git clone and install.sh", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroNixOS, PackageManager: "nix"}, + 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{ diff --git a/internal/installcmd/resolver.go b/internal/installcmd/resolver.go index a3023c066..b8427ae09 100644 --- a/internal/installcmd/resolver.go +++ b/internal/installcmd/resolver.go @@ -260,7 +260,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", "nix": const tmpDir = "/tmp/gentleman-guardian-angel" return CommandSequence{ {"rm", "-rf", tmpDir}, @@ -402,6 +402,12 @@ func resolveEngramInstall(profile system.PlatformProfile) (CommandSequence, erro {"brew", "tap", "Gentleman-Programming/homebrew-tap"}, {"brew", "install", "engram"}, }, nil + case "nix": + // NixOS requires installing from source via go install because pre-built release + // binaries fail to run natively due to hardcoded dynamic linker paths. + return CommandSequence{ + {"go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"}, + }, nil default: return nil, fmt.Errorf( "engram on %q/%q uses direct binary download — use engram.DownloadLatestBinary() instead of CommandSequence", diff --git a/internal/installcmd/resolver_test.go b/internal/installcmd/resolver_test.go index bbc0d736f..e67a28fb0 100644 --- a/internal/installcmd/resolver_test.go +++ b/internal/installcmd/resolver_test.go @@ -611,6 +611,12 @@ func TestResolveComponentInstall(t *testing.T) { component: model.ComponentEngram, wantErr: true, }, + { + name: "engram on nixos uses go install", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroNixOS, PackageManager: "nix"}, + component: model.ComponentEngram, + want: CommandSequence{{"go", "install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"}}, + }, { name: "gga on darwin uses brew tap and reinstall", profile: system.PlatformProfile{OS: "darwin", PackageManager: "brew"}, @@ -647,6 +653,16 @@ func TestResolveComponentInstall(t *testing.T) { {"bash", "/tmp/gentleman-guardian-angel/install.sh"}, }, }, + { + name: "gga on nixos uses git clone and install.sh", + profile: system.PlatformProfile{OS: "linux", LinuxDistro: system.LinuxDistroNixOS, PackageManager: "nix"}, + 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/detect.go b/internal/system/detect.go index dea63d866..c58b8f091 100644 --- a/internal/system/detect.go +++ b/internal/system/detect.go @@ -31,6 +31,7 @@ const ( LinuxDistroDebian = "debian" LinuxDistroArch = "arch" LinuxDistroFedora = "fedora" + LinuxDistroNixOS = "nixos" ) type DetectionResult struct { @@ -50,7 +51,7 @@ func Detect(ctx context.Context) (DetectionResult, error) { return DetectionResult{}, err } - tools := DetectTools(ctx, []string{"git", "curl", "brew", "node", "go"}) + tools := DetectTools(ctx, []string{"git", "curl", "brew", "node", "go", "nix"}) configs := ScanConfigs(homeDir) osReleaseContent, _ := osReleaseContent(runtime.GOOS) @@ -131,6 +132,13 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt distro := detectLinuxDistro(linuxOSRelease) profile.LinuxDistro = distro + // NixOS should always resolve to nix, even if Homebrew is also installed. + if distro == LinuxDistroNixOS { + profile.PackageManager = "nix" + profile.Supported = true + return profile + } + // Check if brew is available on Linux if brew, ok := tools["brew"]; ok && brew.Installed { profile.PackageManager = "brew" @@ -148,9 +156,17 @@ func resolvePlatformProfile(goos, linuxOSRelease string, tools map[string]ToolSt case LinuxDistroFedora: profile.PackageManager = "dnf" profile.Supported = true + case LinuxDistroNixOS: + profile.PackageManager = "nix" + profile.Supported = true default: - profile.PackageManager = "" - profile.Supported = false + if nix, ok := tools["nix"]; ok && nix.Installed { + profile.PackageManager = "nix" + profile.Supported = true + } else { + profile.PackageManager = "" + profile.Supported = false + } } return profile @@ -204,6 +220,10 @@ func detectLinuxDistro(linuxOSRelease string) string { return LinuxDistroFedora } + if isNixOSLike(id, idLike) { + return LinuxDistroNixOS + } + return LinuxDistroUnknown } @@ -248,3 +268,17 @@ func isFedoraLike(id, idLike string) bool { return false } + +func isNixOSLike(id, idLike string) bool { + if id == LinuxDistroNixOS { + return true + } + + for _, token := range strings.Fields(idLike) { + if token == LinuxDistroNixOS { + return true + } + } + + return false +} diff --git a/internal/system/detect_test.go b/internal/system/detect_test.go index 7b9df5ed9..8bbed64c1 100644 --- a/internal/system/detect_test.go +++ b/internal/system/detect_test.go @@ -90,6 +90,81 @@ func TestDetectFromInputsMarksArchSupported(t *testing.T) { } } +func TestDetectFromInputsMarksNixOSSupported(t *testing.T) { + osRelease := "ID=nixos\n" + result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil) + + if !result.System.Supported { + t.Fatalf("expected nixos linux to be supported") + } + + if result.System.Profile.LinuxDistro != LinuxDistroNixOS { + t.Fatalf("expected nixos distro, got %q", result.System.Profile.LinuxDistro) + } + + if result.System.Profile.PackageManager != "nix" { + t.Fatalf("expected nix package manager, got %q", result.System.Profile.PackageManager) + } +} + +func TestDetectFromInputsMarksNixOSLikeSupported(t *testing.T) { + osRelease := "ID=custom-distro\nID_LIKE=nixos\n" + result := detectFromInputs("linux", "amd64", "/bin/bash", osRelease, nil, nil) + + if !result.System.Supported { + t.Fatalf("expected nixos-like linux to be supported") + } + + if result.System.Profile.LinuxDistro != LinuxDistroNixOS { + t.Fatalf("expected nixos distro for ID_LIKE=nixos, got %q", result.System.Profile.LinuxDistro) + } +} + +func TestDetectFromInputsPrefersBrewOverNixOnUbuntu(t *testing.T) { + tools := map[string]ToolStatus{ + "nix": {Name: "nix", Installed: true, Path: "/nix/var/nix/profiles/default/bin/nix"}, + "brew": {Name: "brew", Installed: true, Path: "/home/linuxbrew/.linuxbrew/bin/brew"}, + } + result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=ubuntu\n", tools, nil) + + if result.System.Profile.PackageManager != "brew" { + t.Fatalf("expected brew package manager when both nix and brew installed on Ubuntu, got %q", result.System.Profile.PackageManager) + } +} + +func TestDetectFromInputsPrefersAptOverNixOnUbuntu(t *testing.T) { + tools := map[string]ToolStatus{ + "nix": {Name: "nix", Installed: true, Path: "/nix/var/nix/profiles/default/bin/nix"}, + } + result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=ubuntu\n", tools, nil) + + if result.System.Profile.PackageManager != "apt" { + t.Fatalf("expected apt package manager when only nix is installed on Ubuntu, got %q", result.System.Profile.PackageManager) + } +} + +func TestDetectFromInputsFallsBackToNixOnUnsupportedLinux(t *testing.T) { + tools := map[string]ToolStatus{ + "nix": {Name: "nix", Installed: true, Path: "/nix/var/nix/profiles/default/bin/nix"}, + } + result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=alpine\n", tools, nil) + + if result.System.Profile.PackageManager != "nix" { + t.Fatalf("expected nix package manager fallback on unsupported Linux with nix installed, got %q", result.System.Profile.PackageManager) + } +} + +func TestDetectFromInputsNixOSPrefersNixOverBrew(t *testing.T) { + tools := map[string]ToolStatus{ + "brew": {Name: "brew", Installed: true, Path: "/home/linuxbrew/.linuxbrew/bin/brew"}, + } + result := detectFromInputs("linux", "amd64", "/bin/bash", "ID=nixos\n", tools, nil) + + if result.System.Profile.PackageManager != "nix" { + t.Fatalf("expected nix package manager for NixOS even when brew is installed, got %q", result.System.Profile.PackageManager) + } +} + // --- Batch E: Comprehensive platform detection matrix --- func TestDetectLinuxDistroMatrix(t *testing.T) { @@ -98,6 +173,11 @@ func TestDetectLinuxDistroMatrix(t *testing.T) { osRelease string wantDistro string }{ + { + name: "nixos", + osRelease: "ID=nixos\n", + wantDistro: LinuxDistroNixOS, + }, { name: "ubuntu 22.04", osRelease: "ID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"22.04\"\n", diff --git a/internal/system/install_deps.go b/internal/system/install_deps.go index 538805e47..d58308cad 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 == "nix": + return "nix-env -iA nixpkgs.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 == "nix": + return "nix-env -iA nixpkgs.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 == "nix": + return "nix-env -iA nixpkgs.nodejs" 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 == "nix": + return "nix-env -iA nixpkgs.go" default: return "install go from https://go.dev/dl/" } @@ -140,6 +148,8 @@ func installCommandsGit(profile PlatformProfile) [][]string { return [][]string{{"sudo", "pacman", "-S", "--noconfirm", "git"}} case profile.PackageManager == "dnf": return [][]string{{"sudo", "dnf", "install", "-y", "git"}} + case profile.PackageManager == "nix": + return [][]string{{"nix-env", "-iA", "nixpkgs.git"}} default: return nil } @@ -158,6 +168,8 @@ func installCommandsCurl(profile PlatformProfile) [][]string { return [][]string{{"sudo", "pacman", "-S", "--noconfirm", "curl"}} case profile.PackageManager == "dnf": return [][]string{{"sudo", "dnf", "install", "-y", "curl"}} + case profile.PackageManager == "nix": + return [][]string{{"nix-env", "-iA", "nixpkgs.curl"}} default: return nil } @@ -183,6 +195,8 @@ func installCommandsNode(profile PlatformProfile) [][]string { {"bash", "-c", "curl -fsSL https://rpm.nodesource.com/setup_lts.x | sudo bash -"}, {"sudo", "dnf", "install", "-y", "nodejs"}, } + case profile.PackageManager == "nix": + return [][]string{{"nix-env", "-iA", "nixpkgs.nodejs"}} default: return nil } @@ -209,6 +223,8 @@ func installCommandsGo(profile PlatformProfile) [][]string { return [][]string{{"sudo", "pacman", "-S", "--noconfirm", "go"}} case profile.PackageManager == "dnf": return [][]string{{"sudo", "dnf", "install", "-y", "golang"}} + case profile.PackageManager == "nix": + return [][]string{{"nix-env", "-iA", "nixpkgs.go"}} default: return nil } diff --git a/internal/system/install_deps_test.go b/internal/system/install_deps_test.go index 153b9e638..20e435367 100644 --- a/internal/system/install_deps_test.go +++ b/internal/system/install_deps_test.go @@ -281,6 +281,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: "nix", LinuxDistro: LinuxDistroNixOS}, } deps := []string{"git", "curl", "node", "go"}