Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions installer/cmd/gentleman-installer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down
83 changes: 72 additions & 11 deletions installer/internal/system/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down
62 changes: 61 additions & 1 deletion installer/internal/system/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package system

import (
"os"
"os/exec"
"runtime"
"testing"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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" ||
Expand Down
22 changes: 22 additions & 0 deletions installer/internal/system/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading