diff --git a/installer/cmd/gentleman-installer/main.go b/installer/cmd/gentleman-installer/main.go index 1664127f..dcef2b6f 100644 --- a/installer/cmd/gentleman-installer/main.go +++ b/installer/cmd/gentleman-installer/main.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/Gentleman-Programming/Gentleman.Dots/installer/internal/system" "github.com/Gentleman-Programming/Gentleman.Dots/installer/internal/tui" tea "github.com/charmbracelet/bubbletea" ) @@ -140,8 +141,14 @@ func runNonInteractive(flags *cliFlags) error { CreateBackup: flags.backup, } + sysInfo := system.Detect() + fmt.Println("🚀 Gentleman.Dots Non-Interactive Installer") fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Printf(" System: %s\n", sysInfo.OSName) + if sysInfo.IsAtomic { + fmt.Printf(" Mode: 🌀 Atomic distro — using Homebrew, no sudo\n") + } fmt.Printf(" Terminal: %s\n", choices.Terminal) fmt.Printf(" Shell: %s\n", choices.Shell) fmt.Printf(" Window Mgr: %s\n", choices.WindowMgr) diff --git a/installer/internal/system/detect.go b/installer/internal/system/detect.go index b015df47..94bd36ef 100644 --- a/installer/internal/system/detect.go +++ b/installer/internal/system/detect.go @@ -7,6 +7,21 @@ import ( "strings" ) +// atomicDistros contains os-release ID values that are known atomic/immutable. +// This is the authoritative source for detection. +var atomicDistroIDs = map[string]bool{ + "silverblue": true, + "fedora-silverblue": true, + "kinoite": true, + "fedora-kinoite": true, + "bazzite": true, + "bluefin": true, + "aurora": true, + "sericea": true, + "wayblue": true, + "fedora-iot": true, +} + type OSType int const ( @@ -20,17 +35,19 @@ const ( ) type SystemInfo struct { - OS OSType - OSName string - IsWSL bool - IsARM bool - IsTermux bool - HomeDir string - HasBrew bool - HasPkg bool // Termux package manager - HasXcode bool - UserShell string - Prefix string // Termux $PREFIX or empty for other systems + OS OSType + OSName string + IsWSL bool + IsARM bool + IsTermux bool + IsAtomic bool // Atomic/immutable distro (Silverblue, Bazzite, etc.) + HomeDir string + HasBrew bool + HasPkg bool // Termux package manager + HasXcode bool + HasFlatpak bool + UserShell string + Prefix string // Termux $PREFIX or empty for other systems } func Detect() *SystemInfo { @@ -75,7 +92,12 @@ func Detect() *SystemInfo { } } + info.IsAtomic = checkAtomic() + if info.IsAtomic { + info.OSName += " (Atomic)" + } info.HasBrew = checkBrew() + info.HasFlatpak = checkFlatpak() info.UserShell = detectCurrentShell() return info @@ -146,6 +168,45 @@ func checkXcode() bool { return cmd.Run() == nil } +// checkAtomic detects if we're running on an atomic/immutable Linux distro. +// Priority: /run/ostree-booted (most reliable), rpm-ostree binary, os-release ID. +func checkAtomic() bool { + if runtime.GOOS != "linux" { + return false + } + + // Most reliable indicator: /run/ostree-booted exists on ostree-based systems + if _, err := os.Stat("/run/ostree-booted"); err == nil { + return true + } + + // Check for rpm-ostree binary (present on Fedora Atomic derivatives) + if _, err := exec.LookPath("rpm-ostree"); err == nil { + return true + } + + // Check os-release for known atomic distro IDs + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return false + } + + content := strings.ToLower(string(data)) + for id := range atomicDistroIDs { + if strings.Contains(content, "id="+id) || strings.Contains(content, "id=\""+id+"\"") || strings.Contains(content, "variant_id="+id) || strings.Contains(content, "variant_id=\""+id+"\"") { + return true + } + } + + return false +} + +// checkFlatpak detects if the flatpak command is available +func checkFlatpak() bool { + _, err := exec.LookPath("flatpak") + return err == nil +} + func detectCurrentShell() string { shell := os.Getenv("SHELL") if shell == "" { diff --git a/installer/internal/system/detect_test.go b/installer/internal/system/detect_test.go index 0c54a2e2..deea2dbd 100644 --- a/installer/internal/system/detect_test.go +++ b/installer/internal/system/detect_test.go @@ -2,6 +2,7 @@ package system import ( "os" + "os/exec" "runtime" "testing" ) @@ -56,7 +57,8 @@ func TestDetect(t *testing.T) { t.Errorf("Expected OSName to be 'macOS', got '%s'", info.OSName) } case "linux": - validNames := []string{"Linux", "Arch Linux", "Debian/Ubuntu", "Fedora/RHEL", "Termux"} + validNames := []string{"Linux", "Arch Linux", "Debian/Ubuntu", "Fedora/RHEL", "Termux", + "Linux (Atomic)", "Arch Linux (Atomic)", "Debian/Ubuntu (Atomic)", "Fedora/RHEL (Atomic)"} found := false for _, name := range validNames { if info.OSName == name { @@ -263,6 +265,64 @@ func TestDetectTermuxFields(t *testing.T) { }) } +func TestCheckAtomic(t *testing.T) { + t.Run("should not panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("checkAtomic panicked: %v", r) + } + }() + _ = checkAtomic() + }) + + t.Run("detects atomic via ostree-booted", func(t *testing.T) { + if _, err := os.Stat("/run/ostree-booted"); err == nil { + if !checkAtomic() { + t.Error("checkAtomic should return true when /run/ostree-booted exists") + } + } + }) + + t.Run("detects atomic via rpm-ostree", func(t *testing.T) { + if _, err := exec.LookPath("rpm-ostree"); err == nil { + // Should already be detected, but check consistency + if !checkAtomic() { + t.Error("checkAtomic should return true when rpm-ostree is in PATH") + } + } + }) +} + +func TestCheckFlatpak(t *testing.T) { + t.Run("should not panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("checkFlatpak panicked: %v", r) + } + }() + _ = checkFlatpak() + }) + + t.Run("should detect flatpak if available", func(t *testing.T) { + result := checkFlatpak() + // Just verify it returns a boolean without panicking + if result { + t.Log("Flatpak is available on this system") + } else { + t.Log("Flatpak is not available on this system") + } + }) +} + +func TestSystemInfoAtomicFields(t *testing.T) { + t.Run("SystemInfo should have atomic fields", func(t *testing.T) { + info := Detect() + // Just verify the field exists and is initialized + _ = info.IsAtomic + _ = info.HasFlatpak + }) +} + // Helper to check if string contains termux func containsTermux(s string) bool { return len(s) > 0 && (s == "/data/data/com.termux/files/usr" || diff --git a/installer/internal/system/exec.go b/installer/internal/system/exec.go index fc6eabc9..bbfc9898 100644 --- a/installer/internal/system/exec.go +++ b/installer/internal/system/exec.go @@ -267,6 +267,16 @@ func RunBrew(args string, opts *ExecOptions) *ExecResult { return Run(brewPath+" "+args, opts) } +// RunRpmOstree runs an rpm-ostree command (for atomic distros) +func RunRpmOstree(args string, opts *ExecOptions) *ExecResult { + return Run("rpm-ostree "+args, opts) +} + +// RunFlatpak runs a flatpak command +func RunFlatpak(args string, opts *ExecOptions) *ExecResult { + return Run("flatpak "+args, opts) +} + // RunPkg runs a Termux pkg command (install packages) func RunPkg(args string, opts *ExecOptions) *ExecResult { return Run("pkg "+args, opts) @@ -660,6 +670,18 @@ func RunSudoWithLogs(command string, opts *ExecOptions, onLog LogCallback) *Exec return RunWithLogs("sudo "+command, opts, onLog) } +// RunBrewWithLogs runs a brew command with log streaming (already defined above, keeping the first one) + +// RunRpmOstreeWithLogs runs an rpm-ostree command with log streaming +func RunRpmOstreeWithLogs(args string, opts *ExecOptions, onLog LogCallback) *ExecResult { + return RunWithLogs("rpm-ostree "+args, opts, onLog) +} + +// RunFlatpakWithLogs runs a flatpak command with log streaming +func RunFlatpakWithLogs(args string, opts *ExecOptions, onLog LogCallback) *ExecResult { + return RunWithLogs("flatpak "+args, opts, onLog) +} + // PatchZshForWM modifies .zshrc based on window manager choice. func PatchZshForWM(zshrcPath string, wm string, installNvim bool) error { content, err := os.ReadFile(zshrcPath) diff --git a/installer/internal/tui/installer.go b/installer/internal/tui/installer.go index 2e973d59..31b24ecc 100644 --- a/installer/internal/tui/installer.go +++ b/installer/internal/tui/installer.go @@ -221,6 +221,25 @@ func stepInstallDeps(m *Model) error { return nil } + // Atomic/immutable distro: try brew for deps, skip system packages + if m.SystemInfo.IsAtomic { + SendLog(stepID, "Atomic distro detected — using Homebrew instead of system packages") + if m.SystemInfo.HasBrew { + SendLog(stepID, "Installing base dependencies via Homebrew...") + result := system.RunBrewWithLogs("install git curl wget unzip", nil, func(line string) { + SendLog(stepID, line) + }) + if result.Error != nil { + // Non-critical - these may already be available + SendLog(stepID, "Warning: some brew packages failed (may already be installed)") + } + } else { + SendLog(stepID, "Homebrew not available — skipping system package installation") + SendLog(stepID, "ℹ Ensure git and curl are available before proceeding") + } + return nil + } + // Arch Linux if m.SystemInfo.OS == system.OSArch { result := system.RunSudo("pacman -Syu --noconfirm", nil) @@ -280,12 +299,142 @@ func stepInstallXcode(m *Model) error { return nil } +// copyTerminalConfig copies terminal configuration files to the user's home directory. +// Used by both atomic and non-atomic install paths. +func copyTerminalConfig(terminal, homeDir, repoDir, stepID string) { + switch terminal { + case "alacritty": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/alacritty")); err == nil { + system.CopyFile(filepath.Join(repoDir, "alacritty.toml"), filepath.Join(homeDir, ".config/alacritty/alacritty.toml")) + SendLog(stepID, "✓ Alacritty configured") + } + case "wezterm": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/wezterm")); err == nil { + system.CopyFile(filepath.Join(repoDir, ".wezterm.lua"), filepath.Join(homeDir, ".config/wezterm/wezterm.lua")) + SendLog(stepID, "✓ WezTerm configured") + } + case "kitty": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/kitty")); err == nil { + system.CopyDir(filepath.Join(repoDir, "GentlemanKitty"), filepath.Join(homeDir, ".config", "kitty")) + SendLog(stepID, "✓ Kitty configured") + } + case "ghostty": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/ghostty")); err == nil { + system.CopyDir(filepath.Join(repoDir, "GentlemanGhostty"), filepath.Join(homeDir, ".config", "ghostty")) + SendLog(stepID, "✓ Ghostty configured") + } + } +} + func stepInstallTerminal(m *Model) error { terminal := m.Choices.Terminal homeDir := os.Getenv("HOME") repoDir := "Gentleman.Dots" stepID := "terminal" + // Atomic/immutable distro: if already installed, just copy config + if m.SystemInfo.IsAtomic && system.CommandExists(terminal) { + SendLog(stepID, fmt.Sprintf("%s already installed, copying configuration...", terminal)) + copyTerminalConfig(terminal, homeDir, repoDir, stepID) + return nil + } + + // Atomic/immutable distro: brew first, flatpak second, suggest rpm-ostree as last resort + if m.SystemInfo.IsAtomic && !system.CommandExists(terminal) { + SendLog(stepID, "Atomic distro detected — trying Homebrew first") + if m.SystemInfo.HasBrew { + var brewResult *system.ExecResult + switch terminal { + case "wezterm": + system.Run("brew tap wez/wezterm-linuxbrew", nil) + brewResult = system.RunBrewWithLogs("install wezterm", nil, func(line string) { + SendLog(stepID, line) + }) + case "ghostty": + // Ghostty may be available via brew on Linux in the future + brewResult = system.RunBrewWithLogs("install ghostty", nil, func(line string) { + SendLog(stepID, line) + }) + case "kitty": + brewResult = system.RunBrewWithLogs("install kitty", nil, func(line string) { + SendLog(stepID, line) + }) + case "alacritty": + brewResult = system.RunBrewWithLogs("install alacritty", nil, func(line string) { + SendLog(stepID, line) + }) + } + if brewResult != nil && brewResult.Error == nil { + SendLog(stepID, "✓ Installed via Homebrew") + } else { + // Brew failed, try flatpak for supported terminals + SendLog(stepID, "Homebrew not available for this terminal") + switch terminal { + case "wezterm": + if m.SystemInfo.HasFlatpak { + SendLog(stepID, "Installing via Flatpak...") + if result := system.RunFlatpakWithLogs("install -y flathub org.wezfurlong.wezterm", nil, func(line string) { + SendLog(stepID, line) + }); result.Error != nil { + SendLog(stepID, "Flatpak install failed — you may need to run: flatpak install flathub org.wezfurlong.wezterm") + } else { + SendLog(stepID, "✓ Installed via Flatpak") + } + } else { + SendLog(stepID, "ℹ Install Flatpak first, then: flatpak install flathub org.wezfurlong.wezterm") + } + case "ghostty": + SendLog(stepID, "ℹ Install Ghostty manually: rpm-ostree install ghostty") + case "kitty": + SendLog(stepID, "ℹ Install Kitty manually: curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh") + case "alacritty": + SendLog(stepID, "ℹ Install Alacritty manually: rpm-ostree install alacritty") + } + } + } else { + SendLog(stepID, "Homebrew not installed — showing manual install options") + switch terminal { + case "wezterm": + if m.SystemInfo.HasFlatpak { + SendLog(stepID, "ℹ Install via Flatpak: flatpak install flathub org.wezfurlong.wezterm") + } else { + SendLog(stepID, "ℹ Install WezTerm via: rpm-ostree install wezterm OR flatpak install flathub org.wezfurlong.wezterm") + } + case "ghostty": + SendLog(stepID, "ℹ Install Ghostty via: rpm-ostree install ghostty") + case "kitty": + SendLog(stepID, "ℹ Install Kitty via: curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh") + case "alacritty": + SendLog(stepID, "ℹ Install Alacritty via: rpm-ostree install alacritty") + } + } + // Always copy config files regardless of package installation + SendLog(stepID, "Copying terminal configuration...") + switch terminal { + case "alacritty": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/alacritty")); err == nil { + system.CopyFile(filepath.Join(repoDir, "alacritty.toml"), filepath.Join(homeDir, ".config/alacritty/alacritty.toml")) + SendLog(stepID, "✓ Alacritty configured") + } + case "wezterm": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/wezterm")); err == nil { + system.CopyFile(filepath.Join(repoDir, ".wezterm.lua"), filepath.Join(homeDir, ".config/wezterm/wezterm.lua")) + SendLog(stepID, "✓ WezTerm configured") + } + case "kitty": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/kitty")); err == nil { + system.CopyDir(filepath.Join(repoDir, "GentlemanKitty"), filepath.Join(homeDir, ".config", "kitty")) + SendLog(stepID, "✓ Kitty configured") + } + case "ghostty": + if err := system.EnsureDir(filepath.Join(homeDir, ".config/ghostty")); err == nil { + system.CopyDir(filepath.Join(repoDir, "GentlemanGhostty"), filepath.Join(homeDir, ".config", "ghostty")) + SendLog(stepID, "✓ Ghostty configured") + } + } + return nil + } + switch terminal { case "alacritty": if !system.CommandExists("alacritty") { @@ -616,6 +765,21 @@ func installPlatformPackages(m *Model, stepID string, packages platformPackages, switch { case m.SystemInfo.IsTermux: return runPkgInstallWithLogs(packages.Termux, nil, onLog) + case m.SystemInfo.IsAtomic: + // On atomic distros, always prefer Homebrew (userspace, no sudo needed) + if m.SystemInfo.HasBrew && packages.Brew != "" { + onLog("Atomic distro: installing via Homebrew") + return system.RunBrewWithLogs("install "+packages.Brew, nil, onLog) + } + // No brew available or no brew packages defined, show manual instructions + suggested := packages.Fedora + if suggested == "" { + suggested = packages.Brew + } + onLog("Atomic distro: skipping system package installation") + onLog("ℹ Install manually via: rpm-ostree install " + suggested) + onLog(" Or use: brew install " + packages.Brew) + return &system.ExecResult{ExitCode: 0} case m.SystemInfo.OS == system.OSArch && packages.Arch != "": return runNativeWithBrewFallback("pacman -S --needed --noconfirm "+packages.Arch, packages.Brew, m.SystemInfo.HasBrew, onLog) case m.SystemInfo.OS == system.OSFedora && packages.Fedora != "": @@ -1280,6 +1444,16 @@ fi return nil } + // Atomic distros: /etc/shells is read-only, skip sudo usermod/chsh + if m.SystemInfo.IsAtomic { + SendLog(stepID, "Atomic distro detected — skipping shell change via /etc/shells") + SendLog(stepID, "ℹ Set your default shell manually: chsh -s $(which "+shellCmd+")") + if m.Choices.Shell == "fish" || m.Choices.Shell == "nushell" { + SendLog(stepID, " Or add 'exec "+shellCmd+"' to your .bashrc") + } + return nil + } + // Non-Termux: Try to set shell using sudo usermod (works if NOPASSWD configured) // Find the shell path first shellPath := system.Run(fmt.Sprintf("which %s", shellCmd), nil) diff --git a/installer/internal/tui/interactive.go b/installer/internal/tui/interactive.go index 8f5ae256..b30981dd 100644 --- a/installer/internal/tui/interactive.go +++ b/installer/internal/tui/interactive.go @@ -107,6 +107,27 @@ eval "$(%s/bin/brew shellenv)" // getDepsScript returns script to install dependencies on Linux (needs sudo) func getDepsScript(m *Model) (string, error) { + // Atomic distro: skip system deps, use brew if available + if m.SystemInfo.IsAtomic { + if m.SystemInfo.HasBrew { + script := `#!/bin/sh +set -e +echo "" +echo "🌀 Atomic distro detected — installing deps via Homebrew..." +echo "" +brew install git curl wget unzip 2>/dev/null || echo "Some packages may already be installed" +echo "" +echo "✅ Dependencies ready!" +echo "" +echo "Press Enter to continue..." +read dummy +` + return script, nil + } + // No brew, just skip + return "", nil + } + var script string if m.SystemInfo.OS == system.OSArch { @@ -172,6 +193,57 @@ func getTerminalScript(m *Model) (string, error) { terminal := m.Choices.Terminal homeDir := os.Getenv("HOME") + // Atomic distro: skip sudo, show manual instructions, copy config + if m.SystemInfo.IsAtomic { + if system.CommandExists(terminal) { + return "", nil // Already installed, just copy config below + } + + var manualCmd string + switch terminal { + case "wezterm": + if m.SystemInfo.HasFlatpak { + manualCmd = `echo "📦 Installing WezTerm via Flatpak..." +flatpak install -y flathub org.wezfurlong.wezterm 2>/dev/null || echo "ℹ Run manually: flatpak install flathub org.wezfurlong.wezterm"` + } else { + manualCmd = `echo "ℹ Install WezTerm via: flatpak install flathub org.wezfurlong.wezterm"` + } + case "ghostty": + manualCmd = `echo "📦 Installing Ghostty via rpm-ostree..." +if command -v rpm-ostree &> /dev/null; then + rpm-ostree install ghostty 2>/dev/null && echo "✓ Ghostty installed (reboot required)" || echo "ℹ Run manually: rpm-ostree install ghostty" +else + echo "ℹ Install Ghostty via: brew install ghostty OR rpm-ostree install ghostty" +fi` + case "kitty": + manualCmd = `echo "📦 Installing Kitty via official installer..." +curl -fsSL https://sw.kovidgoyal.net/kitty/installer.sh | sh +echo "✓ Kitty installed to ~/.local/kitty.app/"` + case "alacritty": + manualCmd = `echo "ℹ Install Alacritty via: brew install alacritty OR rpm-ostree install alacritty"` + } + + configCmd := getTerminalConfigCmd(terminal, homeDir) + + script := fmt.Sprintf(`#!/bin/sh +set -e +echo "" +echo "🖥️ Installing %s on atomic distro..." +echo "" +%s +echo "" +echo "📝 Copying %s configuration..." +%s +echo "" +echo "✅ %s configured!" +echo "" +echo "Press Enter to continue..." +read dummy +`, terminal, manualCmd, terminal, configCmd, terminal) + + return script, nil + } + var installCmd string var configCmd string @@ -216,8 +288,7 @@ cd - echo "✓ Alacritty built and installed from source"` } - configCmd = fmt.Sprintf(`mkdir -p "%s/.config/alacritty" -cp "Gentleman.Dots/alacritty.toml" "%s/.config/alacritty/alacritty.toml"`, homeDir, homeDir) + configCmd = getTerminalConfigCmd(terminal, homeDir) case "wezterm": if system.CommandExists("wezterm") { @@ -231,8 +302,7 @@ sudo dnf install -y wezterm` // Debian uses brew, not interactive return "", nil } - configCmd = fmt.Sprintf(`mkdir -p "%s/.config/wezterm" -cp "Gentleman.Dots/.wezterm.lua" "%s/.config/wezterm/wezterm.lua"`, homeDir, homeDir) + configCmd = getTerminalConfigCmd(terminal, homeDir) case "ghostty": if system.CommandExists("ghostty") { @@ -246,8 +316,7 @@ sudo dnf install -y ghostty` // Debian uses install script installCmd = `curl -fsSL https://raw.githubusercontent.com/mkasberg/ghostty-ubuntu/HEAD/install.sh | bash` } - configCmd = fmt.Sprintf(`mkdir -p "%s/.config/ghostty" -cp -r Gentleman.Dots/GentlemanGhostty/* "%s/.config/ghostty/"`, homeDir, homeDir) + configCmd = getTerminalConfigCmd(terminal, homeDir) default: return "", nil @@ -273,6 +342,26 @@ read dummy return script, nil } +// getTerminalConfigCmd returns the shell commands to copy terminal config files +func getTerminalConfigCmd(terminal, homeDir string) string { + switch terminal { + case "alacritty": + return fmt.Sprintf(`mkdir -p "%s/.config/alacritty" +cp "Gentleman.Dots/alacritty.toml" "%s/.config/alacritty/alacritty.toml"`, homeDir, homeDir) + case "wezterm": + return fmt.Sprintf(`mkdir -p "%s/.config/wezterm" +cp "Gentleman.Dots/.wezterm.lua" "%s/.config/wezterm/wezterm.lua"`, homeDir, homeDir) + case "kitty": + return fmt.Sprintf(`mkdir -p "%s/.config/kitty" +cp -r Gentleman.Dots/GentlemanKitty/* "%s/.config/kitty/"`, homeDir, homeDir) + case "ghostty": + return fmt.Sprintf(`mkdir -p "%s/.config/ghostty" +cp -r Gentleman.Dots/GentlemanGhostty/* "%s/.config/ghostty/"`, homeDir, homeDir) + default: + return "" + } +} + // getSetShellScript returns script to set the default shell (needs chsh password) func getSetShellScript(m *Model) (string, error) { shell := m.Choices.Shell @@ -294,6 +383,24 @@ func getSetShellScript(m *Model) (string, error) { return getSetShellScriptTermux(shellCmd) } + // Atomic distro: /etc/shells is read-only, skip sudo + if m.SystemInfo.IsAtomic { + script := fmt.Sprintf(`#!/bin/sh +set -e +echo "" +echo "🌀 Atomic distro detected — skipping shell change via /etc/shells" +echo "" +echo "ℹ Set your default shell manually:" +echo " chsh -s $(which %s)" +echo "" +echo " Or add 'exec %s' to your ~/.bashrc" +echo "" +echo "Press Enter to continue..." +read dummy +`, shellCmd, shellCmd) + return script, nil + } + brewPrefix := system.GetBrewPrefix() script := fmt.Sprintf(`#!/bin/sh diff --git a/installer/internal/tui/model.go b/installer/internal/tui/model.go index 4fa780fe..df26ad24 100644 --- a/installer/internal/tui/model.go +++ b/installer/internal/tui/model.go @@ -135,6 +135,8 @@ type Model struct { AvailableBackups []system.BackupInfo // Available backups for restore SelectedBackup int // Selected backup index BackupDir string // Last backup directory created + // Atomic/immutable distro: packages the user should install manually + ManualPackages []string // Vim Trainer mode TrainerStats *trainer.UserStats // User's training stats TrainerGameState *trainer.GameState // Current game session state @@ -510,7 +512,8 @@ func (m *Model) SetupInstallSteps() { // Check both Choices.OS and SystemInfo for Termux detection (redundancy) // Must run BEFORE clone and homebrew on Linux so git is available for clone isTermux := m.Choices.OS == "termux" || m.SystemInfo.IsTermux - if m.Choices.OS == "linux" && !isTermux { + isAtomic := m.SystemInfo.IsAtomic + if m.Choices.OS == "linux" && !isTermux && !isAtomic { m.Steps = append(m.Steps, InstallStep{ ID: "deps", Name: "Install Dependencies", @@ -526,6 +529,14 @@ func (m *Model) SetupInstallSteps() { Status: StatusPending, Interactive: false, // Termux doesn't need sudo }) + } else if isAtomic { + m.Steps = append(m.Steps, InstallStep{ + ID: "deps", + Name: "Install Dependencies", + Description: "Base packages (brew) on atomic distro", + Status: StatusPending, + Interactive: false, // Brew doesn't need sudo + }) } else if m.Choices.OS == "mac" && !m.SystemInfo.HasXcode { m.Steps = append(m.Steps, InstallStep{ ID: "xcode", @@ -544,15 +555,18 @@ func (m *Model) SetupInstallSteps() { }) // Homebrew (interactive - first install needs password) - // Skip Termux and native package manager Linux distributions. - if !m.SystemInfo.HasBrew && !m.SystemInfo.IsTermux && m.SystemInfo.OS != system.OSArch && m.SystemInfo.OS != system.OSFedora { - m.Steps = append(m.Steps, InstallStep{ - ID: "homebrew", - Name: "Install Homebrew", - Description: "Package manager", - Status: StatusPending, - Interactive: true, - }) + // Skip Termux. On atomic distros, always try brew (it works in userspace). + // On traditional Fedora/Arch, brew is optional and not the main path. + if !m.SystemInfo.HasBrew && !m.SystemInfo.IsTermux { + if m.SystemInfo.IsAtomic || (m.SystemInfo.OS != system.OSArch && m.SystemInfo.OS != system.OSFedora) { + m.Steps = append(m.Steps, InstallStep{ + ID: "homebrew", + Name: "Install Homebrew", + Description: "Package manager", + Status: StatusPending, + Interactive: true, + }) + } } // Terminal