detached e1a1ab5

feat(core): add internal packages for UI, phases, system utilities and installation orchestration

commit
e1a1ab50f6f1f1c421b06810c1575d38c1225695
author
bnema · · 2025-10-26 09:42 UTC
parent
c03e113
signature
unsigned
27 files · +5710 -0
A internal/cleanup/cleanup.go +211 -0
@@ -0,0 +1,211 @@
1+package cleanup
2+
3+import (
4+ "os"
5+ "os/exec"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/logger"
11+)
12+
13+// Run performs a comprehensive cleanup of installation artifacts.
14+// This function is idempotent and safe to call multiple times.
15+// It performs the following operations:
16+// 1. Unmount /mnt hierarchy (recursive, force if needed)
17+// 2. Close LUKS/dm-crypt mappings (cryptroot and all others)
18+// 3. Disable swap
19+// 4. Kill stuck pacstrap/arch-chroot processes
20+// 5. Remove installation config file
21+// 6. Sync filesystems
22+func Run(log *logger.Logger) error {
23+ log.Info("Starting comprehensive cleanup")
24+
25+ cleanupMounts(log)
26+ cleanupEncryption(log)
27+ cleanupSwap(log)
28+ cleanupProcesses(log)
29+ cleanupConfig(log)
30+ cleanupSync(log)
31+
32+ log.Info("Cleanup complete")
33+ return nil
34+}
35+
36+// cleanupMounts unmounts the /mnt hierarchy in reverse order.
37+func cleanupMounts(log *logger.Logger) {
38+ log.Info("Cleaning up mounted filesystems")
39+
40+ // Unmount in reverse order (most specific first)
41+ mountsToClean := []string{
42+ "/mnt/boot",
43+ "/mnt/home",
44+ "/mnt",
45+ }
46+
47+ for _, mountPoint := range mountsToClean {
48+ // Check if mount point is mounted
49+ if !isMounted(mountPoint) {
50+ continue
51+ }
52+
53+ log.Info("Unmounting", "mount_point", mountPoint)
54+
55+ // Try normal unmount first
56+ cmd := exec.Command("umount", "-R", mountPoint)
57+ if err := cmd.Run(); err == nil {
58+ log.Info("Unmounted successfully", "mount_point", mountPoint)
59+ continue
60+ }
61+
62+ // Force unmount if normal unmount fails
63+ log.Info("Force unmounting", "mount_point", mountPoint)
64+ cmd = exec.Command("umount", "-R", "-l", mountPoint)
65+ if err := cmd.Run(); err == nil {
66+ log.Info("Force unmounted successfully", "mount_point", mountPoint)
67+ } else {
68+ log.Warn("Failed to unmount (may retry later)", "mount_point", mountPoint, "error", err)
69+ }
70+ }
71+
72+ // Wait for any lingering unmount operations
73+ time.Sleep(1 * time.Second)
74+}
75+
76+// cleanupEncryption closes LUKS/dm-crypt mappings.
77+func cleanupEncryption(log *logger.Logger) {
78+ log.Info("Cleaning up LUKS/dm-crypt mappings")
79+
80+ // Close cryptroot specifically
81+ if _, err := os.Stat("/dev/mapper/cryptroot"); err == nil {
82+ log.Info("Closing LUKS container", "device", "cryptroot")
83+ cmd := exec.Command("cryptsetup", "close", "cryptroot")
84+ if err := cmd.Run(); err == nil {
85+ log.Info("Closed LUKS container successfully", "device", "cryptroot")
86+ } else {
87+ log.Warn("Failed to close LUKS container", "device", "cryptroot", "error", err)
88+ }
89+ }
90+
91+ // Close ALL dm-crypt mappings
92+ cmd := exec.Command("dmsetup", "ls", "--target", "crypt")
93+ output, err := cmd.Output()
94+ if err == nil && len(output) > 0 {
95+ lines := strings.Split(string(output), "\n")
96+ for _, line := range lines {
97+ if line == "" {
98+ continue
99+ }
100+ // Extract mapping name (first field)
101+ fields := strings.Fields(line)
102+ if len(fields) == 0 {
103+ continue
104+ }
105+ mapping := fields[0]
106+
107+ log.Info("Closing dm-crypt mapping", "device", mapping)
108+ closeCmd := exec.Command("cryptsetup", "close", mapping)
109+ if err := closeCmd.Run(); err == nil {
110+ log.Info("Closed dm-crypt mapping successfully", "device", mapping)
111+ } else {
112+ log.Warn("Failed to close dm-crypt mapping", "device", mapping, "error", err)
113+ }
114+ }
115+ }
116+
117+ // Ensure all device mapper entries are removed
118+ cmd = exec.Command("dmsetup", "table")
119+ output, err = cmd.Output()
120+ if err == nil && strings.Contains(string(output), "crypt") {
121+ log.Info("Removing lingering device mapper entries")
122+ removeCmd := exec.Command("dmsetup", "remove_all")
123+ _ = removeCmd.Run() // Ignore errors
124+ log.Info("Device mapper cleanup attempted")
125+ }
126+}
127+
128+// cleanupSwap disables all active swap.
129+func cleanupSwap(log *logger.Logger) {
130+ log.Info("Disabling swap")
131+
132+ // Check if swap is active
133+ cmd := exec.Command("swapon", "--show")
134+ output, err := cmd.Output()
135+ if err == nil && len(output) > 0 {
136+ log.Info("Found active swap, disabling")
137+ swapoffCmd := exec.Command("swapoff", "-a")
138+ if err := swapoffCmd.Run(); err == nil {
139+ log.Info("Swap disabled successfully")
140+ } else {
141+ log.Warn("Failed to disable swap (non-critical)", "error", err)
142+ }
143+ }
144+}
145+
146+// cleanupProcesses kills stuck pacstrap/arch-chroot processes.
147+func cleanupProcesses(log *logger.Logger) {
148+ log.Info("Checking for stuck pacstrap/chroot processes")
149+
150+ // Find stuck processes
151+ cmd := exec.Command("pgrep", "-f", "pacstrap|arch-chroot")
152+ output, err := cmd.Output()
153+ if err != nil {
154+ // pgrep returns 1 if no processes found, which is expected/normal
155+ return
156+ }
157+
158+ if len(output) > 0 {
159+ pids := strings.TrimSpace(string(output))
160+ log.Info("Killing stuck processes", "pids", pids)
161+
162+ // Try SIGTERM first
163+ pidList := strings.Fields(pids)
164+ for _, pid := range pidList {
165+ killCmd := exec.Command("kill", pid)
166+ _ = killCmd.Run() // Ignore errors
167+ }
168+
169+ time.Sleep(1 * time.Second)
170+
171+ // Try SIGKILL if still running
172+ for _, pid := range pidList {
173+ killCmd := exec.Command("kill", "-9", pid)
174+ _ = killCmd.Run() // Ignore errors
175+ }
176+
177+ log.Info("Cleaned up stuck processes")
178+ }
179+}
180+
181+// cleanupConfig removes the installation config file.
182+func cleanupConfig(log *logger.Logger) {
183+ log.Info("Removing installation config file")
184+
185+ configPath := config.DefaultConfigPath
186+ if _, err := os.Stat(configPath); err == nil {
187+ if err := os.Remove(configPath); err == nil {
188+ log.Info("Config file removed", "path", configPath)
189+ } else {
190+ log.Warn("Failed to remove config file", "path", configPath, "error", err)
191+ }
192+ }
193+}
194+
195+// cleanupSync syncs all filesystems.
196+func cleanupSync(log *logger.Logger) {
197+ log.Info("Syncing filesystems")
198+
199+ cmd := exec.Command("sync")
200+ if err := cmd.Run(); err == nil {
201+ log.Info("Filesystems synced successfully")
202+ } else {
203+ log.Warn("Failed to sync filesystems (non-critical)", "error", err)
204+ }
205+}
206+
207+// isMounted checks if a mount point is currently mounted.
208+func isMounted(mountPoint string) bool {
209+ cmd := exec.Command("mountpoint", "-q", mountPoint)
210+ return cmd.Run() == nil
211+}
A internal/config/config.go +405 -0
@@ -0,0 +1,405 @@
1+package config
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "strings"
8+)
9+
10+const (
11+ DefaultConfigPath = "/var/log/archup-install.conf"
12+ DefaultLogPath = "/var/log/archup-install.log"
13+
14+ // Install paths
15+ DefaultInstallDir = "/tmp/archup-install"
16+ DefaultInstallPath = ".local/share/archup/install"
17+ BasePackagesFile = "base.packages"
18+ ExtraPackagesFile = "extra.packages"
19+ LimineConfigTemplate = "configs/limine.conf.template"
20+ ChaoticConfigFile = "configs/chaotic-aur.conf"
21+ StarshipConfigTemplate = "configs/shell/starship.toml"
22+ ShellConfigTemplate = "configs/shell/shell"
23+ ShellInitTemplate = "configs/shell/init"
24+ ShellAliasesTemplate = "configs/shell/aliases"
25+ ShellEnvsTemplate = "configs/shell/envs"
26+ ShellFunctionsTemplate = "configs/shell/functions"
27+ ShellRcTemplate = "configs/shell/rc"
28+ BashrcTemplate = "configs/shell/bashrc"
29+)
30+
31+// Encryption types
32+const (
33+ EncryptionNone = "none"
34+ EncryptionLUKS = "luks"
35+ EncryptionLUKSLVM = "luks-lvm"
36+)
37+
38+// Bootloader types
39+const (
40+ BootloaderLimine = "limine"
41+ BootloaderSystemdBoot = "systemd-boot"
42+)
43+
44+// Kernel choices
45+const (
46+ KernelLinux = "linux"
47+ KernelLinuxZen = "linux-zen"
48+ KernelLinuxLTS = "linux-lts"
49+ KernelLinuxHardened = "linux-hardened"
50+ KernelLinuxCachyOS = "linux-cachyos"
51+)
52+
53+// AUR helpers
54+const (
55+ AURHelperParu = "paru"
56+ AURHelperYay = "yay"
57+)
58+
59+// System paths
60+const (
61+ PathMnt = "/mnt"
62+ PathMntEtc = "/mnt/etc"
63+ PathMntEtcHostname = "/mnt/etc/hostname"
64+ PathMntEtcHosts = "/mnt/etc/hosts"
65+ PathMntEtcLocaleGen = "/mnt/etc/locale.gen"
66+ PathMntEtcLocaleConf = "/mnt/etc/locale.conf"
67+ PathMntEtcVconsole = "/mnt/etc/vconsole.conf"
68+ PathMntEtcSudoersD = "/mnt/etc/sudoers.d/wheel"
69+ PathMntEtcSystemd = "/mnt/etc/systemd"
70+ PathMntEtcSysctlD = "/mnt/etc/sysctl.d"
71+ PathUsrShareZoneinfo = "/usr/share/zoneinfo"
72+ PathEtcLocaltime = "/etc/localtime"
73+)
74+
75+// Config file names
76+const (
77+ FileZramGenerator = "zram-generator.conf"
78+ FileSysctlZramParams = "99-vm-zram-parameters.conf"
79+)
80+
81+// Services
82+const (
83+ ServiceNetworkManager = "NetworkManager"
84+)
85+
86+// User groups
87+const (
88+ GroupWheel = "wheel"
89+)
90+
91+// Shells
92+const (
93+ ShellBash = "/bin/bash"
94+)
95+
96+// Locale defaults
97+const (
98+ LocaleDefault = "en_US.UTF-8"
99+ LocaleDefaultGen = "en_US.UTF-8 UTF-8"
100+ TimezoneDefault = "UTC"
101+ KeymapDefault = "us"
102+)
103+
104+// Zram configuration
105+const (
106+ ZramConfigContent = `[zram0]
107+zram-size = min(ram / 2, 4096)
108+compression-algorithm = zstd
109+`
110+ ZramSysctlContent = `vm.swappiness = 180
111+vm.watermark_boost_factor = 0
112+vm.watermark_scale_factor = 125
113+vm.page-cluster = 0
114+`
115+)
116+
117+// Sudoers configuration
118+const (
119+ SudoersWheelContent = "%wheel ALL=(ALL:ALL) ALL\n"
120+ SudoersWheelPerms = 0440
121+)
122+
123+// Boot paths
124+const (
125+ PathMntBoot = "/mnt/boot"
126+ PathMntBootEFI = "/mnt/boot/EFI"
127+ PathMntBootEFILimine = "/mnt/boot/EFI/limine"
128+ PathMntEtcMkinitcpio = "/mnt/etc/mkinitcpio.conf"
129+ PathUsrShareLimine = "/mnt/usr/share/limine"
130+ FileLimineBootloader = "BOOTX64.EFI"
131+ FileLimineConfig = "limine.conf"
132+)
133+
134+// Mkinitcpio hooks
135+const (
136+ MkinitcpioHooksPlymouth = "HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block plymouth filesystems fsck)"
137+ MkinitcpioHooksEncrypted = "HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block plymouth encrypt filesystems fsck)"
138+)
139+
140+// Kernel parameters
141+const (
142+ KernelParamsQuiet = "quiet splash loglevel=3 rd.udev.log_priority=3 systemd.show_status=auto"
143+)
144+
145+// Limine configuration
146+const (
147+ LimineTimeout = "0"
148+ LimineEntry = "0"
149+ LimineBranding = "ArchUp"
150+ LimineColor = "6"
151+)
152+
153+// UEFI boot entry
154+const (
155+ UEFIBootLabel = "ArchUp"
156+ UEFIBootLoader = "\\\\EFI\\\\limine\\\\BOOTX64.EFI"
157+)
158+
159+// Repository paths
160+const (
161+ PathMntEtcPacmanConf = "/mnt/etc/pacman.conf"
162+)
163+
164+// PostInstall paths
165+const (
166+ PathMntBootLogo = "/mnt/boot/arch-logo.png"
167+ PathMntPlymouthThemes = "/mnt/usr/share/plymouth/themes"
168+ PathMntEtcDefaultLimine = "/mnt/etc/default/limine"
169+ PathMntEtcPacmanDHooks = "/mnt/etc/pacman.d/hooks"
170+ PathBootEFILimineConf = "/boot/EFI/limine/limine.conf"
171+ ArchLogoURL = "assets/Arch_Linux__Crystal__icon.png"
172+ PlymouthThemeName = "archup"
173+)
174+
175+// Plymouth files
176+var PlymouthFiles = []string{
177+ "archup.plymouth",
178+ "archup.script",
179+ "logo.png",
180+ "bullet.png",
181+ "entry.png",
182+ "lock.png",
183+ "progress_bar.png",
184+ "progress_box.png",
185+}
186+
187+// Config holds the installation configuration
188+// This mirrors the shell script's config format for compatibility
189+type Config struct {
190+ // Preflight
191+ Hostname string
192+ Username string
193+ UserPassword string
194+ RootPassword string
195+ Email string // Optional, for git and SSH configuration
196+ Keymap string
197+ Timezone string
198+ Locale string
199+ Bootloader string // "limine" or "systemd-boot"
200+ EncryptionType string // "none", "luks", or "luks-lvm"
201+ EncryptPassword string // Separate encryption password if different from user password
202+
203+ // Partitioning
204+ TargetDisk string
205+ BootPartition string
206+ RootPartition string
207+ SwapPartition string
208+ EFIPartition string
209+ CryptDevice string // /dev/mapper/cryptroot if encrypted
210+
211+ // Base
212+ KernelChoice string // "linux", "linux-zen", "linux-lts", "linux-hardened"
213+ Microcode string // "intel-ucode" or "amd-ucode"
214+ CPUVendor string // "Intel", "AMD", or "Unknown"
215+ AMDPState string // "active", "passive", "guided", or empty
216+
217+ // Config
218+ NetworkManager string // "NetworkManager" by default
219+
220+ // Repos
221+ AURHelper string // "paru" or "yay"
222+ EnableMultilib bool
223+ EnableChaotic bool
224+
225+ // Paths
226+ ConfigPath string
227+ LogPath string
228+ InstallPath string // ~/.local/share/archup/install
229+ RepoURL string
230+ RawURL string
231+}
232+
233+// NewConfig creates a new Config with sensible defaults
234+func NewConfig() *Config {
235+ return &Config{
236+ Locale: "en_US.UTF-8",
237+ Timezone: "UTC",
238+ Keymap: "us",
239+ Bootloader: "limine",
240+ EncryptionType: "none",
241+ KernelChoice: "linux",
242+ NetworkManager: "NetworkManager",
243+ AURHelper: "paru",
244+ EnableMultilib: true,
245+ EnableChaotic: true,
246+ ConfigPath: DefaultConfigPath,
247+ LogPath: DefaultLogPath,
248+ RepoURL: "https://github.com/bnema/archup",
249+ RawURL: "https://raw.githubusercontent.com/bnema/archup/main",
250+ }
251+}
252+
253+// Load reads configuration from a shell-compatible config file
254+func Load(path string) (*Config, error) {
255+ cfg := NewConfig()
256+ cfg.ConfigPath = path
257+
258+ file, err := os.Open(path)
259+ if err != nil {
260+ if os.IsNotExist(err) {
261+ return cfg, nil // Return defaults if file doesn't exist
262+ }
263+ return nil, fmt.Errorf("failed to open config: %w", err)
264+ }
265+ defer file.Close()
266+
267+ scanner := bufio.NewScanner(file)
268+ for scanner.Scan() {
269+ line := strings.TrimSpace(scanner.Text())
270+
271+ // Skip comments and empty lines
272+ if line == "" || strings.HasPrefix(line, "#") {
273+ continue
274+ }
275+
276+ // Parse KEY=VALUE format
277+ parts := strings.SplitN(line, "=", 2)
278+ if len(parts) != 2 {
279+ continue
280+ }
281+
282+ key := strings.TrimSpace(parts[0])
283+ value := strings.Trim(strings.TrimSpace(parts[1]), `"'`) // Remove quotes
284+
285+ cfg.setValue(key, value)
286+ }
287+
288+ if err := scanner.Err(); err != nil {
289+ return nil, fmt.Errorf("failed to read config: %w", err)
290+ }
291+
292+ return cfg, nil
293+}
294+
295+// Save writes configuration to a shell-compatible config file
296+func (c *Config) Save() error {
297+ file, err := os.Create(c.ConfigPath)
298+ if err != nil {
299+ return fmt.Errorf("failed to create config: %w", err)
300+ }
301+ defer file.Close()
302+
303+ writer := bufio.NewWriter(file)
304+ defer writer.Flush()
305+
306+ // Write header
307+ fmt.Fprintln(writer, "# ArchUp Installation Configuration")
308+ fmt.Fprintln(writer, "# This file is compatible with shell scripts")
309+ fmt.Fprintln(writer)
310+
311+ // Write all values
312+ entries := []struct {
313+ key string
314+ value string
315+ }{
316+ {"ARCHUP_HOSTNAME", c.Hostname},
317+ {"ARCHUP_USERNAME", c.Username},
318+ {"ARCHUP_USER_PASSWORD", c.UserPassword},
319+ {"ARCHUP_ROOT_PASSWORD", c.RootPassword},
320+ {"ARCHUP_KEYMAP", c.Keymap},
321+ {"ARCHUP_TIMEZONE", c.Timezone},
322+ {"ARCHUP_LOCALE", c.Locale},
323+ {"ARCHUP_BOOTLOADER", c.Bootloader},
324+ {"ARCHUP_ENCRYPTION", c.EncryptionType},
325+ {"ARCHUP_ENCRYPTION_PASSWORD", c.EncryptPassword},
326+ {"ARCHUP_TARGET_DISK", c.TargetDisk},
327+ {"ARCHUP_BOOT_PARTITION", c.BootPartition},
328+ {"ARCHUP_ROOT_PARTITION", c.RootPartition},
329+ {"ARCHUP_SWAP_PARTITION", c.SwapPartition},
330+ {"ARCHUP_EFI_PARTITION", c.EFIPartition},
331+ {"ARCHUP_KERNEL", c.KernelChoice},
332+ {"ARCHUP_AMD_PSTATE", c.AMDPState},
333+ {"ARCHUP_NETWORK_MANAGER", c.NetworkManager},
334+ {"ARCHUP_AUR_HELPER", c.AURHelper},
335+ {"ARCHUP_ENABLE_MULTILIB", boolToString(c.EnableMultilib)},
336+ {"ARCHUP_ENABLE_CHAOTIC", boolToString(c.EnableChaotic)},
337+ }
338+
339+ for _, entry := range entries {
340+ if entry.value != "" {
341+ fmt.Fprintf(writer, "%s=\"%s\"\n", entry.key, entry.value)
342+ }
343+ }
344+
345+ return nil
346+}
347+
348+// setValue sets a config value based on key name
349+func (c *Config) setValue(key, value string) {
350+ switch key {
351+ case "ARCHUP_HOSTNAME":
352+ c.Hostname = value
353+ case "ARCHUP_USERNAME":
354+ c.Username = value
355+ case "ARCHUP_USER_PASSWORD":
356+ c.UserPassword = value
357+ case "ARCHUP_ROOT_PASSWORD":
358+ c.RootPassword = value
359+ case "ARCHUP_KEYMAP":
360+ c.Keymap = value
361+ case "ARCHUP_TIMEZONE":
362+ c.Timezone = value
363+ case "ARCHUP_LOCALE":
364+ c.Locale = value
365+ case "ARCHUP_BOOTLOADER":
366+ c.Bootloader = value
367+ case "ARCHUP_ENCRYPTION":
368+ c.EncryptionType = value
369+ case "ARCHUP_ENCRYPTION_PASSWORD":
370+ c.EncryptPassword = value
371+ case "ARCHUP_TARGET_DISK":
372+ c.TargetDisk = value
373+ case "ARCHUP_BOOT_PARTITION":
374+ c.BootPartition = value
375+ case "ARCHUP_ROOT_PARTITION":
376+ c.RootPartition = value
377+ case "ARCHUP_SWAP_PARTITION":
378+ c.SwapPartition = value
379+ case "ARCHUP_EFI_PARTITION":
380+ c.EFIPartition = value
381+ case "ARCHUP_KERNEL":
382+ c.KernelChoice = value
383+ case "ARCHUP_AMD_PSTATE":
384+ c.AMDPState = value
385+ case "ARCHUP_NETWORK_MANAGER":
386+ c.NetworkManager = value
387+ case "ARCHUP_AUR_HELPER":
388+ c.AURHelper = value
389+ case "ARCHUP_ENABLE_MULTILIB":
390+ c.EnableMultilib = stringToBool(value)
391+ case "ARCHUP_ENABLE_CHAOTIC":
392+ c.EnableChaotic = stringToBool(value)
393+ }
394+}
395+
396+func boolToString(b bool) string {
397+ if b {
398+ return "true"
399+ }
400+ return "false"
401+}
402+
403+func stringToBool(s string) bool {
404+ return s == "true" || s == "1" || s == "yes"
405+}
A internal/logger/logger.go +128 -0
@@ -0,0 +1,128 @@
1+package logger
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "os"
7+ "strings"
8+
9+ "github.com/bnema/archup/internal/system"
10+)
11+
12+// Logger wraps slog.Logger and adds dry-run mode support
13+type Logger struct {
14+ slog *slog.Logger
15+ logPath string
16+ dryRun bool
17+}
18+
19+// New creates a new Logger with optional file logging and dry-run mode
20+func New(logPath string, dryRun bool) (*Logger, error) {
21+ // Create slog handler
22+ handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
23+ Level: slog.LevelInfo,
24+ })
25+
26+ return &Logger{
27+ slog: slog.New(handler),
28+ logPath: logPath,
29+ dryRun: dryRun,
30+ }, nil
31+}
32+
33+// NewFromSlog creates a Logger from an existing slog.Logger
34+func NewFromSlog(slogLogger *slog.Logger, logPath string, dryRun bool) *Logger {
35+ return &Logger{
36+ slog: slogLogger,
37+ logPath: logPath,
38+ dryRun: dryRun,
39+ }
40+}
41+
42+// Info logs an informational message
43+func (l *Logger) Info(msg string, args ...any) {
44+ l.slog.Info(msg, args...)
45+}
46+
47+// Warn logs a warning message
48+func (l *Logger) Warn(msg string, args ...any) {
49+ l.slog.Warn(msg, args...)
50+}
51+
52+// Error logs an error message
53+func (l *Logger) Error(msg string, args ...any) {
54+ l.slog.Error(msg, args...)
55+}
56+
57+// Debug logs a debug message
58+func (l *Logger) Debug(msg string, args ...any) {
59+ l.slog.Debug(msg, args...)
60+}
61+
62+// IsDryRun returns true if in dry-run mode
63+func (l *Logger) IsDryRun() bool {
64+ return l.dryRun
65+}
66+
67+// LogPath returns the log file path
68+func (l *Logger) LogPath() string {
69+ return l.logPath
70+}
71+
72+// ExecCommand executes a command, or logs it in dry-run mode
73+func (l *Logger) ExecCommand(command string, args ...string) system.CommandResult {
74+ if l.dryRun {
75+ l.slog.Info("DRY-RUN", "command", command, "args", args)
76+ return system.CommandResult{
77+ ExitCode: 0,
78+ Output: fmt.Sprintf("DRY-RUN: %s %s", command, strings.Join(args, " ")),
79+ Error: nil,
80+ }
81+ }
82+
83+ return system.Run(system.RunConfig{
84+ Command: command,
85+ Args: args,
86+ StreamToLog: true,
87+ LogPath: l.logPath,
88+ })
89+}
90+
91+// ExecSimple executes a command without logging to file
92+func (l *Logger) ExecSimple(command string, args ...string) system.CommandResult {
93+ if l.dryRun {
94+ l.slog.Info("DRY-RUN", "command", command, "args", args)
95+ return system.CommandResult{
96+ ExitCode: 0,
97+ Output: fmt.Sprintf("DRY-RUN: %s %s", command, strings.Join(args, " ")),
98+ Error: nil,
99+ }
100+ }
101+
102+ return system.RunSimple(command, args...)
103+}
104+
105+// ExecWithConfig executes a command with custom RunConfig
106+func (l *Logger) ExecWithConfig(cfg system.RunConfig) system.CommandResult {
107+ if l.dryRun {
108+ l.slog.Info("DRY-RUN", "command", cfg.Command, "args", cfg.Args)
109+ return system.CommandResult{
110+ ExitCode: 0,
111+ Output: fmt.Sprintf("DRY-RUN: %s %s", cfg.Command, strings.Join(cfg.Args, " ")),
112+ Error: nil,
113+ }
114+ }
115+
116+ // Set log path if not already set
117+ if cfg.LogPath == "" {
118+ cfg.LogPath = l.logPath
119+ cfg.StreamToLog = true
120+ }
121+
122+ return system.Run(cfg)
123+}
124+
125+// Slog returns the underlying slog.Logger for advanced usage
126+func (l *Logger) Slog() *slog.Logger {
127+ return l.slog
128+}
A internal/phases/base.go +292 -0
@@ -0,0 +1,292 @@
1+package phases
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "strings"
8+
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/logger"
11+ "github.com/bnema/archup/internal/system"
12+)
13+
14+// BaseInstallPhase handles base system installation
15+type BaseInstallPhase struct {
16+ *BasePhase
17+ packages []string
18+}
19+
20+// NewBaseInstallPhase creates a new base installation phase
21+func NewBaseInstallPhase(cfg *config.Config, log *logger.Logger) *BaseInstallPhase {
22+ return &BaseInstallPhase{
23+ BasePhase: NewBasePhase("base", "Base System Installation", cfg, log),
24+ }
25+}
26+
27+// PreCheck validates prerequisites
28+func (p *BaseInstallPhase) PreCheck() error {
29+ // Verify /mnt is mounted
30+ result := system.RunSimple("mountpoint", "-q", "/mnt")
31+ switch {
32+ case result.ExitCode != 0:
33+ return fmt.Errorf("/mnt is not mounted")
34+ }
35+
36+ return nil
37+}
38+
39+// Execute runs the base phase
40+func (p *BaseInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
41+ p.SendProgress(progressChan, "Starting base installation...", 1, 5)
42+
43+ // Step 1: Configure ISO pacman
44+ switch err := p.configurePacman(progressChan); {
45+ case err != nil:
46+ p.SendError(progressChan, err)
47+ return PhaseResult{Success: false, Error: err}
48+ }
49+
50+ p.SendProgress(progressChan, "Detecting CPU and microcode...", 2, 5)
51+
52+ // Step 2: Detect CPU and select microcode
53+ switch err := p.detectCPU(progressChan); {
54+ case err != nil:
55+ p.SendError(progressChan, err)
56+ return PhaseResult{Success: false, Error: err}
57+ }
58+
59+ p.SendProgress(progressChan, "Installing base system...", 3, 5)
60+
61+ // Step 3: Run pacstrap
62+ switch err := p.runPacstrap(progressChan); {
63+ case err != nil:
64+ p.SendError(progressChan, err)
65+ return PhaseResult{Success: false, Error: err}
66+ }
67+
68+ p.SendProgress(progressChan, "Generating fstab...", 4, 5)
69+
70+ // Step 4: Generate fstab
71+ switch err := p.generateFstab(progressChan); {
72+ case err != nil:
73+ p.SendError(progressChan, err)
74+ return PhaseResult{Success: false, Error: err}
75+ }
76+
77+ p.SendProgress(progressChan, "Base installation complete", 5, 5)
78+ p.SendComplete(progressChan, "Base system installed successfully")
79+
80+ return PhaseResult{Success: true, Message: "Base installation complete"}
81+}
82+
83+// configurePacman enables parallel downloads on ISO
84+func (p *BaseInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {
85+ p.SendOutput(progressChan, "Configuring pacman for faster downloads...")
86+
87+ // Enable ParallelDownloads=10
88+ result := p.logger.ExecCommand("sed", "-i",
89+ "s/^#ParallelDownloads = 5$/ParallelDownloads = 10/",
90+ "/etc/pacman.conf")
91+
92+ switch {
93+ case result.ExitCode != 0:
94+ return fmt.Errorf("pacman configuration failed: %w", result.Error)
95+ }
96+
97+ p.SendOutput(progressChan, "[OK] Pacman configured: ParallelDownloads=10")
98+ return nil
99+}
100+
101+// detectCPU detects CPU vendor and selects microcode
102+func (p *BaseInstallPhase) detectCPU(progressChan chan<- ProgressUpdate) error {
103+ p.SendOutput(progressChan, "Detecting CPU vendor...")
104+
105+ // Read /proc/cpuinfo to detect vendor
106+ file, err := os.Open("/proc/cpuinfo")
107+ switch {
108+ case err != nil:
109+ return fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
110+ }
111+ defer file.Close()
112+
113+ scanner := bufio.NewScanner(file)
114+ var vendor string
115+
116+ for scanner.Scan() {
117+ line := scanner.Text()
118+ if strings.HasPrefix(line, "vendor_id") {
119+ parts := strings.Fields(line)
120+ if len(parts) >= 3 {
121+ vendor = parts[2]
122+ break
123+ }
124+ }
125+ }
126+
127+ // Determine microcode package
128+ var microcode, cpuType string
129+
130+ switch vendor {
131+ case "GenuineIntel":
132+ microcode = "intel-ucode"
133+ cpuType = "Intel"
134+ case "AuthenticAMD":
135+ microcode = "amd-ucode"
136+ cpuType = "AMD"
137+ default:
138+ microcode = ""
139+ cpuType = "Unknown"
140+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Unknown CPU vendor: %s", vendor))
141+ }
142+
143+ p.config.Microcode = microcode
144+ p.config.CPUVendor = cpuType
145+
146+ switch {
147+ case microcode != "":
148+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Detected %s CPU", cpuType))
149+ p.SendOutput(progressChan, fmt.Sprintf(" Microcode: %s", microcode))
150+ }
151+
152+ return nil
153+}
154+
155+// runPacstrap installs base system with pacstrap
156+func (p *BaseInstallPhase) runPacstrap(progressChan chan<- ProgressUpdate) error {
157+ // Load base packages from embedded list
158+ packages, err := p.loadBasePackages()
159+ switch {
160+ case err != nil:
161+ return fmt.Errorf("failed to load package list: %w", err)
162+ }
163+
164+ // Add kernel
165+ switch {
166+ case p.config.KernelChoice != "":
167+ packages = append(packages, p.config.KernelChoice)
168+ p.SendOutput(progressChan, fmt.Sprintf("Adding kernel: %s", p.config.KernelChoice))
169+ default:
170+ packages = append(packages, config.KernelLinux)
171+ p.config.KernelChoice = config.KernelLinux
172+ p.SendOutput(progressChan, "Adding kernel: linux (default)")
173+ }
174+
175+ // Add microcode
176+ switch {
177+ case p.config.Microcode != "":
178+ packages = append(packages, p.config.Microcode)
179+ p.SendOutput(progressChan, fmt.Sprintf("Adding microcode: %s", p.config.Microcode))
180+ }
181+
182+ // Add cryptsetup if LUKS enabled
183+ switch p.config.EncryptionType {
184+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
185+ packages = append(packages, "cryptsetup")
186+ p.SendOutput(progressChan, "Adding cryptsetup for LUKS encryption")
187+ }
188+
189+ p.SendOutput(progressChan, fmt.Sprintf("Installing %d packages...", len(packages)))
190+ p.SendOutput(progressChan, "This may take several minutes...")
191+
192+ // Run pacstrap
193+ args := append([]string{"/mnt"}, packages...)
194+ result := p.logger.ExecCommand("pacstrap", args...)
195+
196+ switch {
197+ case result.ExitCode != 0:
198+ return fmt.Errorf("pacstrap failed: %w", result.Error)
199+ }
200+
201+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d packages", len(packages)))
202+ return nil
203+}
204+
205+// loadBasePackages reads the base package list from file
206+func (p *BaseInstallPhase) loadBasePackages() ([]string, error) {
207+ // Read from install directory (downloaded during bootstrap)
208+ installPath := os.Getenv("ARCHUP_INSTALL")
209+ switch {
210+ case installPath == "":
211+ home := os.Getenv("HOME")
212+ installPath = home + "/" + config.DefaultInstallPath
213+ }
214+
215+ packageFile := installPath + "/" + config.BasePackagesFile
216+
217+ file, err := os.Open(packageFile)
218+ switch {
219+ case err != nil:
220+ return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
221+ }
222+ defer file.Close()
223+
224+ var packages []string
225+ scanner := bufio.NewScanner(file)
226+
227+ for scanner.Scan() {
228+ line := strings.TrimSpace(scanner.Text())
229+
230+ // Skip comments and empty lines
231+ switch {
232+ case line == "":
233+ continue
234+ case strings.HasPrefix(line, "#"):
235+ continue
236+ }
237+
238+ packages = append(packages, line)
239+ }
240+
241+ switch err := scanner.Err(); {
242+ case err != nil:
243+ return nil, fmt.Errorf("error reading package file: %w", err)
244+ }
245+
246+ return packages, nil
247+}
248+
249+// generateFstab generates /etc/fstab using UUIDs
250+func (p *BaseInstallPhase) generateFstab(progressChan chan<- ProgressUpdate) error {
251+ p.SendOutput(progressChan, "Generating fstab with UUIDs...")
252+
253+ result := system.RunSimple("sh", "-c", "genfstab -U /mnt >> /mnt/etc/fstab")
254+ switch {
255+ case result.ExitCode != 0:
256+ return fmt.Errorf("genfstab failed: %w", result.Error)
257+ }
258+
259+ p.SendOutput(progressChan, "[OK] fstab generated")
260+ return nil
261+}
262+
263+// PostCheck validates installation
264+func (p *BaseInstallPhase) PostCheck() error {
265+ // Check if base system exists
266+ result := system.RunSimple("test", "-d", "/mnt/usr")
267+ switch {
268+ case result.ExitCode != 0:
269+ return fmt.Errorf("/mnt/usr does not exist")
270+ }
271+
272+ // Check if fstab exists
273+ result = system.RunSimple("test", "-f", "/mnt/etc/fstab")
274+ switch {
275+ case result.ExitCode != 0:
276+ return fmt.Errorf("/mnt/etc/fstab was not created")
277+ }
278+
279+ return p.config.Save()
280+}
281+
282+// Rollback removes installed system
283+func (p *BaseInstallPhase) Rollback() error {
284+ // Just log - actual cleanup happens in partitioning rollback
285+ p.SendOutput(nil, "[WARN] Base installation failed, partitions will be cleaned up")
286+ return nil
287+}
288+
289+// CanSkip returns false - base cannot be skipped
290+func (p *BaseInstallPhase) CanSkip() bool {
291+ return false
292+}
A internal/phases/boot.go +318 -0
@@ -0,0 +1,318 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+
10+ "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/logger"
12+ "github.com/bnema/archup/internal/system"
13+)
14+
15+// BootPhase handles bootloader installation
16+type BootPhase struct {
17+ *BasePhase
18+}
19+
20+// NewBootPhase creates a new boot phase
21+func NewBootPhase(cfg *config.Config, log *logger.Logger) *BootPhase {
22+ return &BootPhase{
23+ BasePhase: NewBasePhase("boot", "Bootloader Installation", cfg, log),
24+ }
25+}
26+
27+// PreCheck validates boot prerequisites
28+func (p *BootPhase) PreCheck() error {
29+ // Verify /mnt is mounted
30+ result := system.RunSimple("mountpoint", "-q", config.PathMnt)
31+ switch {
32+ case result.ExitCode != 0:
33+ return fmt.Errorf("%s is not mounted", config.PathMnt)
34+ }
35+
36+ // Verify boot partition is mounted
37+ result = system.RunSimple("mountpoint", "-q", config.PathMntBoot)
38+ switch {
39+ case result.ExitCode != 0:
40+ return fmt.Errorf("%s is not mounted", config.PathMntBoot)
41+ }
42+
43+ return nil
44+}
45+
46+// Execute runs the boot phase
47+func (p *BootPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
48+ p.SendProgress(progressChan, "Starting bootloader installation...", 1, 4)
49+
50+ // Step 1: Configure initramfs
51+ switch err := p.configureMkinitcpio(progressChan); {
52+ case err != nil:
53+ p.SendError(progressChan, err)
54+ return PhaseResult{Success: false, Error: err}
55+ }
56+
57+ p.SendProgress(progressChan, "Installing Limine bootloader...", 2, 4)
58+
59+ // Step 2: Install Limine
60+ switch err := p.installLimine(progressChan); {
61+ case err != nil:
62+ p.SendError(progressChan, err)
63+ return PhaseResult{Success: false, Error: err}
64+ }
65+
66+ p.SendProgress(progressChan, "Configuring Limine...", 3, 4)
67+
68+ // Step 3: Configure Limine
69+ switch err := p.configureLimine(progressChan); {
70+ case err != nil:
71+ p.SendError(progressChan, err)
72+ return PhaseResult{Success: false, Error: err}
73+ }
74+
75+ p.SendProgress(progressChan, "Creating UEFI boot entry...", 4, 4)
76+
77+ // Step 4: Create UEFI boot entry
78+ switch err := p.createUEFIEntry(progressChan); {
79+ case err != nil:
80+ p.SendError(progressChan, err)
81+ return PhaseResult{Success: false, Error: err}
82+ }
83+
84+ p.SendProgress(progressChan, "Bootloader installation complete", 4, 4)
85+ p.SendComplete(progressChan, "Limine installed successfully")
86+
87+ return PhaseResult{Success: true, Message: "Boot configuration complete"}
88+}
89+
90+// configureMkinitcpio configures initramfs hooks
91+func (p *BootPhase) configureMkinitcpio(progressChan chan<- ProgressUpdate) error {
92+ p.SendOutput(progressChan, "Configuring initramfs...")
93+
94+ // Read current mkinitcpio.conf
95+ content, err := os.ReadFile(config.PathMntEtcMkinitcpio)
96+ switch {
97+ case err != nil:
98+ return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
99+ }
100+
101+ // Determine hooks based on encryption
102+ var hooks string
103+ switch p.config.EncryptionType {
104+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
105+ hooks = config.MkinitcpioHooksEncrypted
106+ p.SendOutput(progressChan, "Configuring initramfs for encrypted Plymouth boot")
107+ default:
108+ hooks = config.MkinitcpioHooksPlymouth
109+ p.SendOutput(progressChan, "Configuring initramfs for Plymouth boot")
110+ }
111+
112+ // Replace HOOKS line
113+ hooksRegex := regexp.MustCompile(`(?m)^HOOKS=.*$`)
114+ newContent := hooksRegex.ReplaceAllString(string(content), hooks)
115+
116+ // Write updated config
117+ switch err := os.WriteFile(config.PathMntEtcMkinitcpio, []byte(newContent), 0644); {
118+ case err != nil:
119+ return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
120+ }
121+
122+ // Regenerate initramfs
123+ p.SendOutput(progressChan, "Regenerating initramfs...")
124+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
125+ case err != nil:
126+ return fmt.Errorf("failed to regenerate initramfs: %w", err)
127+ }
128+
129+ p.SendOutput(progressChan, "[OK] Initramfs configured")
130+
131+ return nil
132+}
133+
134+// installLimine installs Limine bootloader files
135+func (p *BootPhase) installLimine(progressChan chan<- ProgressUpdate) error {
136+ p.SendOutput(progressChan, "Installing Limine bootloader...")
137+
138+ // Install Limine to disk (BIOS - optional, may fail on UEFI-only)
139+ biosCmd := fmt.Sprintf("limine bios-install %s", p.config.TargetDisk)
140+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, biosCmd); {
141+ case err != nil:
142+ p.SendOutput(progressChan, "[WARN] BIOS installation skipped (UEFI-only system)")
143+ default:
144+ p.SendOutput(progressChan, "[OK] BIOS bootloader installed")
145+ }
146+
147+ // Create Limine directory
148+ switch err := os.MkdirAll(config.PathMntBootEFILimine, 0755); {
149+ case err != nil:
150+ return fmt.Errorf("failed to create Limine directory: %w", err)
151+ }
152+
153+ // Copy Limine EFI bootloader
154+ srcBootloader := filepath.Join(config.PathUsrShareLimine, config.FileLimineBootloader)
155+ dstBootloader := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
156+
157+ cpCmd := fmt.Sprintf("cp %s %s", srcBootloader, dstBootloader)
158+ result := p.logger.ExecCommand("sh", "-c", cpCmd)
159+ switch {
160+ case result.ExitCode != 0:
161+ return fmt.Errorf("failed to copy Limine bootloader: %w", result.Error)
162+ }
163+
164+ p.SendOutput(progressChan, "[OK] Limine files installed")
165+
166+ return nil
167+}
168+
169+// configureLimine creates Limine configuration from template
170+func (p *BootPhase) configureLimine(progressChan chan<- ProgressUpdate) error {
171+ p.SendOutput(progressChan, "Configuring Limine...")
172+
173+ // Get root partition UUID
174+ rootPartition := p.config.RootPartition
175+ result := system.RunSimple("blkid", "-s", "UUID", "-o", "value", rootPartition)
176+ switch {
177+ case result.ExitCode != 0:
178+ return fmt.Errorf("failed to get root partition UUID: %w", result.Error)
179+ }
180+
181+ rootUUID := strings.TrimSpace(result.Output)
182+
183+ // Build kernel parameters
184+ var kernelParams string
185+ switch p.config.EncryptionType {
186+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
187+ // For encrypted setups
188+ kernelParams = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw", rootUUID)
189+ p.SendOutput(progressChan, "Configured for encrypted root")
190+ default:
191+ // For non-encrypted setups
192+ kernelParams = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw", rootUUID)
193+ }
194+
195+ // Add AMD-specific kernel parameters if configured
196+ switch {
197+ case p.config.AMDPState != "":
198+ amdParams := fmt.Sprintf("amd_pstate=%s", p.config.AMDPState)
199+ kernelParams = fmt.Sprintf("%s %s", kernelParams, amdParams)
200+ p.SendOutput(progressChan, fmt.Sprintf("Added AMD P-State: %s", p.config.AMDPState))
201+ }
202+
203+ // Add quiet/splash parameters
204+ kernelParams = fmt.Sprintf("%s %s", kernelParams, config.KernelParamsQuiet)
205+
206+ // Read Limine config template
207+ templatePath := p.getInstallPath(config.LimineConfigTemplate)
208+ templateContent, err := os.ReadFile(templatePath)
209+ switch {
210+ case err != nil:
211+ return fmt.Errorf("failed to read Limine template: %w", err)
212+ }
213+
214+ // Replace template variables
215+ limineConfig := string(templateContent)
216+ limineConfig = strings.ReplaceAll(limineConfig, "{{TIMEOUT}}", config.LimineTimeout)
217+ limineConfig = strings.ReplaceAll(limineConfig, "{{DEFAULT_ENTRY}}", config.LimineEntry)
218+ limineConfig = strings.ReplaceAll(limineConfig, "{{BRANDING}}", config.LimineBranding)
219+ limineConfig = strings.ReplaceAll(limineConfig, "{{COLOR}}", config.LimineColor)
220+ limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL}}", p.config.KernelChoice)
221+ limineConfig = strings.ReplaceAll(limineConfig, "{{KERNEL_PARAMS}}", kernelParams)
222+
223+ // Write Limine configuration
224+ limineConfigPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
225+ switch err := os.WriteFile(limineConfigPath, []byte(limineConfig), 0644); {
226+ case err != nil:
227+ return fmt.Errorf("failed to write Limine config: %w", err)
228+ }
229+
230+ p.SendOutput(progressChan, "[OK] Limine configured")
231+
232+ return nil
233+}
234+
235+// createUEFIEntry creates UEFI boot entry
236+func (p *BootPhase) createUEFIEntry(progressChan chan<- ProgressUpdate) error {
237+ p.SendOutput(progressChan, "Creating UEFI boot entry...")
238+
239+ // Extract EFI partition number
240+ efiPartNum := p.extractPartitionNumber(p.config.EFIPartition)
241+ switch {
242+ case efiPartNum == "":
243+ return fmt.Errorf("failed to extract EFI partition number from %s", p.config.EFIPartition)
244+ }
245+
246+ // Create UEFI boot entry
247+ efiCmd := fmt.Sprintf("efibootmgr --create --disk %s --part %s --label \"%s\" --loader \"%s\" --unicode",
248+ p.config.TargetDisk,
249+ efiPartNum,
250+ config.UEFIBootLabel,
251+ config.UEFIBootLoader,
252+ )
253+
254+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, efiCmd); {
255+ case err != nil:
256+ return fmt.Errorf("failed to create UEFI boot entry: %w", err)
257+ }
258+
259+ p.SendOutput(progressChan, "[OK] UEFI boot entry created")
260+
261+ return nil
262+}
263+
264+// getInstallPath constructs full path to install file
265+func (p *BootPhase) getInstallPath(filename string) string {
266+ installPath := os.Getenv("ARCHUP_INSTALL")
267+ switch {
268+ case installPath == "":
269+ home := os.Getenv("HOME")
270+ installPath = filepath.Join(home, config.DefaultInstallPath)
271+ }
272+ return filepath.Join(installPath, filename)
273+}
274+
275+// extractPartitionNumber extracts partition number from device path
276+func (p *BootPhase) extractPartitionNumber(partPath string) string {
277+ // Match partition number at end (e.g., /dev/sda1 -> 1, /dev/nvme0n1p1 -> 1)
278+ re := regexp.MustCompile(`[0-9]+$`)
279+ match := re.FindString(partPath)
280+
281+ // Remove leading 'p' if present (nvme style)
282+ return strings.TrimPrefix(match, "p")
283+}
284+
285+// PostCheck validates boot configuration
286+func (p *BootPhase) PostCheck() error {
287+ // Verify Limine bootloader exists
288+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
289+ switch _, err := os.Stat(bootloaderPath); {
290+ case os.IsNotExist(err):
291+ return fmt.Errorf("Limine bootloader was not installed")
292+ }
293+
294+ // Verify Limine config exists
295+ configPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
296+ switch _, err := os.Stat(configPath); {
297+ case os.IsNotExist(err):
298+ return fmt.Errorf("Limine config was not created")
299+ }
300+
301+ return p.config.Save()
302+}
303+
304+// Rollback for boot phase
305+func (p *BootPhase) Rollback() error {
306+ // Remove Limine files if they exist
307+ switch err := os.RemoveAll(config.PathMntBootEFILimine); {
308+ case err != nil:
309+ p.SendOutput(nil, fmt.Sprintf("[WARN] Failed to remove Limine directory: %v", err))
310+ }
311+
312+ return nil
313+}
314+
315+// CanSkip returns false - boot cannot be skipped
316+func (p *BootPhase) CanSkip() bool {
317+ return false
318+}
A internal/phases/bootstrap.go +177 -0
@@ -0,0 +1,177 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "io"
6+ "net/http"
7+ "os"
8+ "path/filepath"
9+
10+ "github.com/bnema/archup/internal/config"
11+ "github.com/bnema/archup/internal/logger"
12+)
13+
14+// BootstrapPhase handles initial setup and file downloads
15+type BootstrapPhase struct {
16+ *BasePhase
17+}
18+
19+// NewBootstrapPhase creates a new bootstrap phase
20+func NewBootstrapPhase(cfg *config.Config, log *logger.Logger) *BootstrapPhase {
21+ return &BootstrapPhase{
22+ BasePhase: NewBasePhase("bootstrap", "Bootstrap", cfg, log),
23+ }
24+}
25+
26+// PreCheck validates bootstrap prerequisites
27+func (p *BootstrapPhase) PreCheck() error {
28+ // Check internet connectivity
29+ resp, err := http.Get("https://raw.githubusercontent.com")
30+ switch {
31+ case err != nil:
32+ return fmt.Errorf("no internet connectivity: %w", err)
33+ }
34+ defer resp.Body.Close()
35+
36+ return nil
37+}
38+
39+// Execute runs the bootstrap phase
40+func (p *BootstrapPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
41+ p.SendOutput(progressChan, "Starting bootstrap phase...")
42+
43+ // Create install directory
44+ switch err := os.MkdirAll(config.DefaultInstallDir, 0755); {
45+ case err != nil:
46+ p.SendError(progressChan, err)
47+ return PhaseResult{Success: false, Error: err}
48+ }
49+
50+ // Files to download
51+ files := []struct {
52+ url string
53+ destPath string
54+ }{
55+ // Package lists
56+ {
57+ url: fmt.Sprintf("%s/install/base.packages", p.config.RawURL),
58+ destPath: filepath.Join(config.DefaultInstallDir, "base.packages"),
59+ },
60+ {
61+ url: fmt.Sprintf("%s/install/extra.packages", p.config.RawURL),
62+ destPath: filepath.Join(config.DefaultInstallDir, "extra.packages"),
63+ },
64+ // Config files
65+ {
66+ url: fmt.Sprintf("%s/install/configs/limine.conf.template", p.config.RawURL),
67+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "limine.conf.template"),
68+ },
69+ {
70+ url: fmt.Sprintf("%s/install/configs/chaotic-aur.conf", p.config.RawURL),
71+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "chaotic-aur.conf"),
72+ },
73+ // Shell config files
74+ {
75+ url: fmt.Sprintf("%s/install/configs/shell/shell", p.config.RawURL),
76+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "shell"),
77+ },
78+ {
79+ url: fmt.Sprintf("%s/install/configs/shell/starship.toml", p.config.RawURL),
80+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "starship.toml"),
81+ },
82+ {
83+ url: fmt.Sprintf("%s/install/configs/shell/init", p.config.RawURL),
84+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "init"),
85+ },
86+ {
87+ url: fmt.Sprintf("%s/install/configs/shell/aliases", p.config.RawURL),
88+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "aliases"),
89+ },
90+ {
91+ url: fmt.Sprintf("%s/install/configs/shell/envs", p.config.RawURL),
92+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "envs"),
93+ },
94+ {
95+ url: fmt.Sprintf("%s/install/configs/shell/functions", p.config.RawURL),
96+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "functions"),
97+ },
98+ {
99+ url: fmt.Sprintf("%s/install/configs/shell/rc", p.config.RawURL),
100+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "rc"),
101+ },
102+ {
103+ url: fmt.Sprintf("%s/install/configs/shell/bashrc", p.config.RawURL),
104+ destPath: filepath.Join(config.DefaultInstallDir, "configs", "shell", "bashrc"),
105+ },
106+ }
107+
108+ totalFiles := len(files)
109+ p.SendOutput(progressChan, fmt.Sprintf("Downloading %d configuration files...", totalFiles))
110+
111+ for i, file := range files {
112+ fileName := filepath.Base(file.destPath)
113+ p.SendProgress(progressChan, fmt.Sprintf("Downloading %s...", fileName), i+1, totalFiles)
114+
115+ switch err := p.downloadFile(file.url, file.destPath); {
116+ case err != nil:
117+ errMsg := fmt.Errorf("failed to download %s: %w", fileName, err)
118+ p.SendError(progressChan, errMsg)
119+ return PhaseResult{Success: false, Error: errMsg}
120+ }
121+
122+ p.SendOutput(progressChan, fmt.Sprintf("[OK] %s", fileName))
123+ }
124+
125+ p.SendComplete(progressChan, "Bootstrap complete")
126+ return PhaseResult{Success: true, Message: "All configuration files downloaded"}
127+}
128+
129+// downloadFile downloads a file from URL to destPath
130+func (p *BootstrapPhase) downloadFile(url, destPath string) error {
131+ // Create destination directory if needed
132+ destDir := filepath.Dir(destPath)
133+ switch err := os.MkdirAll(destDir, 0755); {
134+ case err != nil:
135+ return fmt.Errorf("failed to create directory: %w", err)
136+ }
137+
138+ // Download file
139+ resp, err := http.Get(url)
140+ switch {
141+ case err != nil:
142+ return fmt.Errorf("HTTP request failed: %w", err)
143+ }
144+ defer resp.Body.Close()
145+
146+ switch {
147+ case resp.StatusCode != http.StatusOK:
148+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
149+ }
150+
151+ // Create destination file
152+ destFile, err := os.Create(destPath)
153+ switch {
154+ case err != nil:
155+ return fmt.Errorf("failed to create file: %w", err)
156+ }
157+ defer destFile.Close()
158+
159+ // Copy content
160+ switch _, err := io.Copy(destFile, resp.Body); {
161+ case err != nil:
162+ return fmt.Errorf("failed to write file: %w", err)
163+ }
164+
165+ return nil
166+}
167+
168+// Rollback removes downloaded files
169+func (p *BootstrapPhase) Rollback() error {
170+ // Remove install directory
171+ switch err := os.RemoveAll(config.DefaultInstallDir); {
172+ case err != nil:
173+ return fmt.Errorf("failed to remove install directory: %w", err)
174+ }
175+
176+ return nil
177+}
A internal/phases/orchestrator.go +175 -0
@@ -0,0 +1,175 @@
1+package phases
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/archup/internal/config"
7+)
8+
9+// Orchestrator manages the execution of installation phases
10+type Orchestrator struct {
11+ phases []Phase
12+ currentIdx int
13+ config *config.Config
14+ logPath string
15+ progressCh chan ProgressUpdate
16+}
17+
18+// NewOrchestrator creates a new phase orchestrator
19+func NewOrchestrator(cfg *config.Config, logPath string) *Orchestrator {
20+ return &Orchestrator{
21+ phases: make([]Phase, 0),
22+ currentIdx: -1,
23+ config: cfg,
24+ logPath: logPath,
25+ progressCh: make(chan ProgressUpdate, 100),
26+ }
27+}
28+
29+// RegisterPhase adds a phase to the execution sequence
30+func (o *Orchestrator) RegisterPhase(phase Phase) {
31+ o.phases = append(o.phases, phase)
32+}
33+
34+// Phases returns all registered phases
35+func (o *Orchestrator) Phases() []Phase {
36+ return o.phases
37+}
38+
39+// CurrentPhase returns the currently executing phase, or nil
40+func (o *Orchestrator) CurrentPhase() Phase {
41+ if o.currentIdx < 0 || o.currentIdx >= len(o.phases) {
42+ return nil
43+ }
44+ return o.phases[o.currentIdx]
45+}
46+
47+// NextPhase returns the next phase to execute, or nil if done
48+func (o *Orchestrator) NextPhase() Phase {
49+ // Find next pending phase
50+ for i := o.currentIdx + 1; i < len(o.phases); i++ {
51+ phase := o.phases[i]
52+ if phase.Status() == StatusPending {
53+ return phase
54+ }
55+ }
56+ return nil
57+}
58+
59+// ProgressChannel returns the progress update channel
60+func (o *Orchestrator) ProgressChannel() <-chan ProgressUpdate {
61+ return o.progressCh
62+}
63+
64+// ExecutePhase runs a specific phase
65+func (o *Orchestrator) ExecutePhase(phase Phase) error {
66+ // Update current index
67+ for i, p := range o.phases {
68+ if p.Name() == phase.Name() {
69+ o.currentIdx = i
70+ break
71+ }
72+ }
73+
74+ // Pre-check
75+ phase.SetStatus(StatusRunning)
76+ if err := phase.PreCheck(); err != nil {
77+ phase.SetStatus(StatusFailed)
78+ return fmt.Errorf("pre-check failed: %w", err)
79+ }
80+
81+ // Execute
82+ result := phase.Execute(o.progressCh)
83+ if !result.Success {
84+ phase.SetStatus(StatusFailed)
85+
86+ // Attempt rollback
87+ if rbErr := phase.Rollback(); rbErr != nil {
88+ return fmt.Errorf("execution failed: %v, rollback failed: %v", result.Error, rbErr)
89+ }
90+
91+ return fmt.Errorf("execution failed: %w", result.Error)
92+ }
93+
94+ // Post-check
95+ if err := phase.PostCheck(); err != nil {
96+ phase.SetStatus(StatusFailed)
97+ return fmt.Errorf("post-check failed: %w", err)
98+ }
99+
100+ phase.SetStatus(StatusCompleted)
101+ return nil
102+}
103+
104+// ExecuteNext executes the next pending phase
105+func (o *Orchestrator) ExecuteNext() error {
106+ phase := o.NextPhase()
107+ if phase == nil {
108+ return fmt.Errorf("no pending phases")
109+ }
110+ return o.ExecutePhase(phase)
111+}
112+
113+// ExecuteAll executes all pending phases in sequence
114+func (o *Orchestrator) ExecuteAll() error {
115+ for {
116+ phase := o.NextPhase()
117+ if phase == nil {
118+ break
119+ }
120+
121+ if err := o.ExecutePhase(phase); err != nil {
122+ return err
123+ }
124+ }
125+ return nil
126+}
127+
128+// SkipPhase marks a phase as skipped
129+func (o *Orchestrator) SkipPhase(phase Phase) error {
130+ if !phase.CanSkip() {
131+ return fmt.Errorf("phase %s cannot be skipped", phase.Name())
132+ }
133+ phase.SetStatus(StatusSkipped)
134+ return nil
135+}
136+
137+// Reset resets all phases to pending state
138+func (o *Orchestrator) Reset() {
139+ o.currentIdx = -1
140+ for _, phase := range o.phases {
141+ phase.SetStatus(StatusPending)
142+ }
143+}
144+
145+// Progress returns overall installation progress
146+func (o *Orchestrator) Progress() (completed int, total int) {
147+ total = len(o.phases)
148+ for _, phase := range o.phases {
149+ if phase.Status() == StatusCompleted || phase.Status() == StatusSkipped {
150+ completed++
151+ }
152+ }
153+ return completed, total
154+}
155+
156+// IsComplete returns true if all phases are done
157+func (o *Orchestrator) IsComplete() bool {
158+ for _, phase := range o.phases {
159+ status := phase.Status()
160+ if status != StatusCompleted && status != StatusSkipped {
161+ return false
162+ }
163+ }
164+ return true
165+}
166+
167+// HasFailed returns true if any phase has failed
168+func (o *Orchestrator) HasFailed() bool {
169+ for _, phase := range o.phases {
170+ if phase.Status() == StatusFailed {
171+ return true
172+ }
173+ }
174+ return false
175+}
A internal/phases/partitioning.go +368 -0
@@ -0,0 +1,368 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/archup/internal/config"
8+ "github.com/bnema/archup/internal/logger"
9+ "github.com/bnema/archup/internal/system"
10+)
11+
12+// PartitioningPhase handles disk partitioning, formatting, and mounting
13+type PartitioningPhase struct {
14+ *BasePhase
15+}
16+
17+// NewPartitioningPhase creates a new partitioning phase
18+func NewPartitioningPhase(cfg *config.Config, log *logger.Logger) *PartitioningPhase {
19+ return &PartitioningPhase{
20+ BasePhase: NewBasePhase("partitioning", "Disk Partitioning", cfg, log),
21+ }
22+}
23+
24+// PreCheck validates partitioning prerequisites
25+func (p *PartitioningPhase) PreCheck() error {
26+ switch {
27+ case p.config.TargetDisk == "":
28+ return fmt.Errorf("target disk not selected")
29+ }
30+
31+ return nil
32+}
33+
34+// Execute runs the partitioning phase
35+func (p *PartitioningPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
36+ p.SendProgress(progressChan, "Starting partitioning...", 1, 5)
37+
38+ // Step 1: Wipe and partition
39+ switch err := p.createPartitions(progressChan); {
40+ case err != nil:
41+ p.SendError(progressChan, err)
42+ return PhaseResult{Success: false, Error: err}
43+ }
44+
45+ p.SendProgress(progressChan, "Formatting partitions...", 2, 5)
46+
47+ // Step 2: Format partitions
48+ switch err := p.formatPartitions(progressChan); {
49+ case err != nil:
50+ p.SendError(progressChan, err)
51+ return PhaseResult{Success: false, Error: err}
52+ }
53+
54+ p.SendProgress(progressChan, "Creating btrfs subvolumes...", 3, 5)
55+
56+ // Step 3: Create subvolumes
57+ switch err := p.createSubvolumes(progressChan); {
58+ case err != nil:
59+ p.SendError(progressChan, err)
60+ return PhaseResult{Success: false, Error: err}
61+ }
62+
63+ p.SendProgress(progressChan, "Mounting filesystems...", 4, 5)
64+
65+ // Step 4: Mount
66+ switch err := p.mountFilesystems(progressChan); {
67+ case err != nil:
68+ p.SendError(progressChan, err)
69+ return PhaseResult{Success: false, Error: err}
70+ }
71+
72+ p.SendProgress(progressChan, "Partitioning complete", 5, 5)
73+ p.SendComplete(progressChan, "Disk prepared successfully")
74+
75+ return PhaseResult{Success: true, Message: "Partitioning complete"}
76+}
77+
78+// createPartitions wipes disk and creates GPT partitions
79+func (p *PartitioningPhase) createPartitions(progressChan chan<- ProgressUpdate) error {
80+ disk := p.config.TargetDisk
81+ p.SendOutput(progressChan, fmt.Sprintf("Wiping disk %s...", disk))
82+
83+ // Wipe filesystem signatures
84+ result := p.logger.ExecCommand("wipefs", "-af", disk)
85+ switch {
86+ case result.ExitCode != 0:
87+ return fmt.Errorf("wipefs failed: %w", result.Error)
88+ }
89+
90+ // Zap GPT and MBR
91+ result = p.logger.ExecCommand("sgdisk", "--zap-all", disk)
92+ switch {
93+ case result.ExitCode != 0:
94+ return fmt.Errorf("sgdisk zap failed: %w", result.Error)
95+ }
96+
97+ p.SendOutput(progressChan, "Creating GPT partition table...")
98+
99+ // Create partitions: 512MB EFI + remaining ROOT
100+ result = p.logger.ExecCommand("sgdisk",
101+ "--clear",
102+ "--new=1:0:+512M", "--typecode=1:ef00", "--change-name=1:EFI",
103+ "--new=2:0:0", "--typecode=2:8300", "--change-name=2:ROOT",
104+ disk)
105+
106+ switch {
107+ case result.ExitCode != 0:
108+ return fmt.Errorf("sgdisk partition creation failed: %w", result.Error)
109+ }
110+
111+ // Inform kernel of partition table changes
112+ result = p.logger.ExecCommand("partprobe", disk)
113+ switch {
114+ case result.ExitCode != 0:
115+ return fmt.Errorf("partprobe failed: %w", result.Error)
116+ }
117+
118+ // Determine partition naming (nvme vs sata/ssd)
119+ var efiPart, rootPart string
120+ switch {
121+ case strings.Contains(disk, "nvme"):
122+ efiPart = fmt.Sprintf("%sp1", disk)
123+ rootPart = fmt.Sprintf("%sp2", disk)
124+ default:
125+ efiPart = fmt.Sprintf("%s1", disk)
126+ rootPart = fmt.Sprintf("%s2", disk)
127+ }
128+
129+ p.config.EFIPartition = efiPart
130+ p.config.RootPartition = rootPart
131+
132+ p.SendOutput(progressChan, fmt.Sprintf("[OK] EFI: %s (512MB)", efiPart))
133+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Root: %s", rootPart))
134+
135+ return nil
136+}
137+
138+// formatPartitions formats EFI and ROOT partitions
139+func (p *PartitioningPhase) formatPartitions(progressChan chan<- ProgressUpdate) error {
140+ // Format EFI as FAT32
141+ p.SendOutput(progressChan, "Formatting EFI partition...")
142+
143+ result := p.logger.ExecCommand("wipefs", "-af", p.config.EFIPartition)
144+ switch {
145+ case result.ExitCode != 0:
146+ return fmt.Errorf("wipefs EFI failed: %w", result.Error)
147+ }
148+
149+ result = p.logger.ExecCommand("mkfs.fat", "-F32", "-n", "EFI", p.config.EFIPartition)
150+ switch {
151+ case result.ExitCode != 0:
152+ return fmt.Errorf("mkfs.fat failed: %w", result.Error)
153+ }
154+
155+ p.SendOutput(progressChan, "[OK] EFI formatted as FAT32")
156+
157+ // Handle root partition with optional encryption
158+ switch p.config.EncryptionType {
159+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
160+ return p.formatEncryptedRoot(progressChan)
161+ default:
162+ return p.formatPlainRoot(progressChan)
163+ }
164+}
165+
166+// formatEncryptedRoot sets up LUKS encryption on root partition
167+func (p *PartitioningPhase) formatEncryptedRoot(progressChan chan<- ProgressUpdate) error {
168+ p.SendOutput(progressChan, "Setting up LUKS encryption...")
169+
170+ // Use user password for encryption (as per preflight)
171+ password := p.config.UserPassword
172+ switch {
173+ case password == "":
174+ return fmt.Errorf("password required for encryption")
175+ }
176+
177+ // Wipe root partition
178+ result := p.logger.ExecCommand("wipefs", "-af", p.config.RootPartition)
179+ switch {
180+ case result.ExitCode != 0:
181+ return fmt.Errorf("wipefs root failed: %w", result.Error)
182+ }
183+
184+ // Create LUKS container with Argon2id
185+ p.SendOutput(progressChan, "Creating LUKS container (this may take a moment)...")
186+
187+ // Note: In real implementation, pipe password securely via stdin
188+ // For now, documenting the approach
189+ result = system.RunSimple("sh", "-c",
190+ fmt.Sprintf("printf '%%s' '%s' | cryptsetup luksFormat --type luks2 --batch-mode --pbkdf argon2id --iter-time 2000 --label ARCHUP_LUKS --key-file - %s",
191+ password, p.config.RootPartition))
192+
193+ switch {
194+ case result.ExitCode != 0:
195+ return fmt.Errorf("luksFormat failed: %w", result.Error)
196+ }
197+
198+ // Open LUKS container
199+ result = system.RunSimple("sh", "-c",
200+ fmt.Sprintf("printf '%%s' '%s' | cryptsetup open --key-file=- %s cryptroot",
201+ password, p.config.RootPartition))
202+
203+ switch {
204+ case result.ExitCode != 0:
205+ return fmt.Errorf("cryptsetup open failed: %w", result.Error)
206+ }
207+
208+ p.config.CryptDevice = "/dev/mapper/cryptroot"
209+ p.SendOutput(progressChan, "[OK] LUKS container created and opened")
210+
211+ // Format encrypted device with btrfs
212+ result = p.logger.ExecCommand("mkfs.btrfs", "-f", "-L", "ROOT", p.config.CryptDevice)
213+ switch {
214+ case result.ExitCode != 0:
215+ return fmt.Errorf("mkfs.btrfs on encrypted device failed: %w", result.Error)
216+ }
217+
218+ p.SendOutput(progressChan, "[OK] Encrypted partition formatted as btrfs")
219+ return nil
220+}
221+
222+// formatPlainRoot formats root partition without encryption
223+func (p *PartitioningPhase) formatPlainRoot(progressChan chan<- ProgressUpdate) error {
224+ p.SendOutput(progressChan, "Formatting root partition...")
225+
226+ result := p.logger.ExecCommand("mkfs.btrfs", "-f", "-L", "ROOT", p.config.RootPartition)
227+ switch {
228+ case result.ExitCode != 0:
229+ return fmt.Errorf("mkfs.btrfs failed: %w", result.Error)
230+ }
231+
232+ p.SendOutput(progressChan, "[OK] Root formatted as btrfs")
233+ return nil
234+}
235+
236+// createSubvolumes creates btrfs subvolumes
237+func (p *PartitioningPhase) createSubvolumes(progressChan chan<- ProgressUpdate) error {
238+ // Determine device to mount
239+ var device string
240+ switch p.config.EncryptionType {
241+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
242+ device = p.config.CryptDevice
243+ default:
244+ device = p.config.RootPartition
245+ }
246+
247+ // Mount temporarily
248+ result := p.logger.ExecCommand("mount", device, "/mnt")
249+ switch {
250+ case result.ExitCode != 0:
251+ return fmt.Errorf("temporary mount failed: %w", result.Error)
252+ }
253+
254+ // Create @ subvolume
255+ result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@")
256+ switch {
257+ case result.ExitCode != 0:
258+ system.RunSimple("umount", "/mnt") // Cleanup
259+ return fmt.Errorf("@ subvolume creation failed: %w", result.Error)
260+ }
261+
262+ // Create @home subvolume
263+ result = p.logger.ExecCommand("btrfs", "subvolume", "create", "/mnt/@home")
264+ switch {
265+ case result.ExitCode != 0:
266+ system.RunSimple("umount", "/mnt") // Cleanup
267+ return fmt.Errorf("@home subvolume creation failed: %w", result.Error)
268+ }
269+
270+ // Unmount
271+ result = p.logger.ExecCommand("umount", "/mnt")
272+ switch {
273+ case result.ExitCode != 0:
274+ return fmt.Errorf("unmount after subvolume creation failed: %w", result.Error)
275+ }
276+
277+ p.SendOutput(progressChan, "[OK] Btrfs subvolumes created: @ and @home")
278+ return nil
279+}
280+
281+// mountFilesystems mounts all filesystems to /mnt
282+func (p *PartitioningPhase) mountFilesystems(progressChan chan<- ProgressUpdate) error {
283+ // Determine device
284+ var device string
285+ switch p.config.EncryptionType {
286+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
287+ device = p.config.CryptDevice
288+ default:
289+ device = p.config.RootPartition
290+ }
291+
292+ // Mount @ subvolume as root
293+ result := p.logger.ExecCommand("mount", "-o", "noatime,compress=zstd,subvol=@", device, "/mnt")
294+ switch {
295+ case result.ExitCode != 0:
296+ return fmt.Errorf("mount @ failed: %w", result.Error)
297+ }
298+
299+ p.SendOutput(progressChan, "[OK] Mounted @ to /mnt")
300+
301+ // Create home directory
302+ result = p.logger.ExecCommand("mkdir", "-p", "/mnt/home")
303+ switch {
304+ case result.ExitCode != 0:
305+ return fmt.Errorf("mkdir /mnt/home failed: %w", result.Error)
306+ }
307+
308+ // Mount @home subvolume
309+ result = p.logger.ExecCommand("mount", "-o", "noatime,compress=zstd,subvol=@home", device, "/mnt/home")
310+ switch {
311+ case result.ExitCode != 0:
312+ return fmt.Errorf("mount @home failed: %w", result.Error)
313+ }
314+
315+ p.SendOutput(progressChan, "[OK] Mounted @home to /mnt/home")
316+
317+ // Create and mount EFI
318+ result = p.logger.ExecCommand("mkdir", "-p", "/mnt/boot")
319+ switch {
320+ case result.ExitCode != 0:
321+ return fmt.Errorf("mkdir /mnt/boot failed: %w", result.Error)
322+ }
323+
324+ result = p.logger.ExecCommand("mount", p.config.EFIPartition, "/mnt/boot")
325+ switch {
326+ case result.ExitCode != 0:
327+ return fmt.Errorf("mount EFI failed: %w", result.Error)
328+ }
329+
330+ p.SendOutput(progressChan, "[OK] Mounted EFI to /mnt/boot")
331+ return nil
332+}
333+
334+// PostCheck validates mounts
335+func (p *PartitioningPhase) PostCheck() error {
336+ // Verify /mnt is mounted
337+ result := system.RunSimple("mountpoint", "-q", "/mnt")
338+ switch {
339+ case result.ExitCode != 0:
340+ return fmt.Errorf("/mnt is not mounted")
341+ }
342+
343+ return p.config.Save()
344+}
345+
346+// Rollback unmounts filesystems
347+func (p *PartitioningPhase) Rollback() error {
348+ // Unmount in reverse order
349+ system.RunSimple("umount", "/mnt/boot")
350+ system.RunSimple("umount", "/mnt/home")
351+ system.RunSimple("umount", "/mnt")
352+
353+ // Close LUKS if encrypted
354+ switch p.config.EncryptionType {
355+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
356+ switch {
357+ case p.config.CryptDevice != "":
358+ system.RunSimple("cryptsetup", "close", "cryptroot")
359+ }
360+ }
361+
362+ return nil
363+}
364+
365+// CanSkip returns false - partitioning cannot be skipped
366+func (p *PartitioningPhase) CanSkip() bool {
367+ return false
368+}
A internal/phases/phase.go +210 -0
@@ -0,0 +1,210 @@
1+package phases
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/archup/internal/config"
7+ "github.com/bnema/archup/internal/logger"
8+)
9+
10+// PhaseStatus represents the current status of a phase
11+type PhaseStatus int
12+
13+const (
14+ StatusPending PhaseStatus = iota
15+ StatusRunning
16+ StatusCompleted
17+ StatusFailed
18+ StatusSkipped
19+)
20+
21+func (s PhaseStatus) String() string {
22+ switch s {
23+ case StatusPending:
24+ return "pending"
25+ case StatusRunning:
26+ return "running"
27+ case StatusCompleted:
28+ return "completed"
29+ case StatusFailed:
30+ return "failed"
31+ case StatusSkipped:
32+ return "skipped"
33+ default:
34+ return "unknown"
35+ }
36+}
37+
38+// PhaseResult represents the result of a phase execution
39+type PhaseResult struct {
40+ Success bool
41+ Message string
42+ Error error
43+}
44+
45+// ProgressUpdate represents a progress update from a phase
46+type ProgressUpdate struct {
47+ PhaseName string
48+ Step string
49+ OutputLine string // For streaming command output (e.g., pacman)
50+ Current int
51+ Total int
52+ IsComplete bool
53+ IsError bool
54+ ErrorMsg string
55+}
56+
57+// Phase represents a single installation phase
58+type Phase interface {
59+ // Name returns the phase identifier
60+ Name() string
61+
62+ // Description returns a human-readable description
63+ Description() string
64+
65+ // PreCheck validates if the phase can run
66+ // Returns nil if ready, error if preconditions not met
67+ PreCheck() error
68+
69+ // Execute runs the phase logic
70+ // Should send ProgressUpdate via progressChan
71+ Execute(progressChan chan<- ProgressUpdate) PhaseResult
72+
73+ // PostCheck validates the phase completed successfully
74+ // Returns nil if validation passes, error otherwise
75+ PostCheck() error
76+
77+ // Rollback attempts to undo phase changes on failure
78+ // Returns nil if rollback successful, error otherwise
79+ Rollback() error
80+
81+ // CanSkip returns true if this phase can be skipped
82+ CanSkip() bool
83+
84+ // Status returns the current phase status
85+ Status() PhaseStatus
86+
87+ // SetStatus updates the phase status
88+ SetStatus(status PhaseStatus)
89+}
90+
91+// BasePhase provides common functionality for all phases
92+type BasePhase struct {
93+ name string
94+ description string
95+ status PhaseStatus
96+ config *config.Config
97+ logger *logger.Logger // Logger with dry-run support
98+}
99+
100+// NewBasePhase creates a new base phase
101+func NewBasePhase(name, description string, cfg *config.Config, log *logger.Logger) *BasePhase {
102+ return &BasePhase{
103+ name: name,
104+ description: description,
105+ status: StatusPending,
106+ config: cfg,
107+ logger: log,
108+ }
109+}
110+
111+// Name returns the phase name
112+func (b *BasePhase) Name() string {
113+ return b.name
114+}
115+
116+// Description returns the phase description
117+func (b *BasePhase) Description() string {
118+ return b.description
119+}
120+
121+// Status returns the current status
122+func (b *BasePhase) Status() PhaseStatus {
123+ return b.status
124+}
125+
126+// SetStatus updates the status
127+func (b *BasePhase) SetStatus(status PhaseStatus) {
128+ b.status = status
129+}
130+
131+// Config returns the configuration
132+func (b *BasePhase) Config() *config.Config {
133+ return b.config
134+}
135+
136+// Logger returns the logger
137+func (b *BasePhase) Logger() *logger.Logger {
138+ return b.logger
139+}
140+
141+// SendProgress is a helper to send progress updates
142+func (b *BasePhase) SendProgress(progressChan chan<- ProgressUpdate, step string, current, total int) {
143+ if progressChan != nil {
144+ progressChan <- ProgressUpdate{
145+ PhaseName: b.name,
146+ Step: step,
147+ Current: current,
148+ Total: total,
149+ IsComplete: false,
150+ IsError: false,
151+ }
152+ }
153+}
154+
155+// SendComplete is a helper to send completion
156+func (b *BasePhase) SendComplete(progressChan chan<- ProgressUpdate, message string) {
157+ if progressChan != nil {
158+ progressChan <- ProgressUpdate{
159+ PhaseName: b.name,
160+ Step: message,
161+ IsComplete: true,
162+ IsError: false,
163+ }
164+ }
165+}
166+
167+// SendError is a helper to send error
168+func (b *BasePhase) SendError(progressChan chan<- ProgressUpdate, err error) {
169+ if progressChan != nil {
170+ progressChan <- ProgressUpdate{
171+ PhaseName: b.name,
172+ IsComplete: true,
173+ IsError: true,
174+ ErrorMsg: err.Error(),
175+ }
176+ }
177+}
178+
179+// SendOutput is a helper to send command output line
180+func (b *BasePhase) SendOutput(progressChan chan<- ProgressUpdate, line string) {
181+ if progressChan != nil {
182+ progressChan <- ProgressUpdate{
183+ PhaseName: b.name,
184+ OutputLine: line,
185+ }
186+ }
187+}
188+
189+// DefaultPreCheck provides a basic pre-check (override in specific phases)
190+func (b *BasePhase) PreCheck() error {
191+ if b.config == nil {
192+ return fmt.Errorf("config not initialized")
193+ }
194+ return nil
195+}
196+
197+// DefaultPostCheck provides a basic post-check (override in specific phases)
198+func (b *BasePhase) PostCheck() error {
199+ return nil
200+}
201+
202+// DefaultRollback provides a basic rollback (override in specific phases)
203+func (b *BasePhase) Rollback() error {
204+ return fmt.Errorf("rollback not implemented for phase: %s", b.name)
205+}
206+
207+// DefaultCanSkip returns false by default (override in specific phases)
208+func (b *BasePhase) CanSkip() bool {
209+ return false
210+}
A internal/phases/postinstall.go +651 -0
@@ -0,0 +1,651 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "io"
6+ "net/http"
7+ "os"
8+ "path/filepath"
9+ "regexp"
10+ "strings"
11+
12+ "github.com/bnema/archup/internal/config"
13+ "github.com/bnema/archup/internal/logger"
14+ "github.com/bnema/archup/internal/system"
15+)
16+
17+// PostInstallPhase handles post-installation tasks
18+type PostInstallPhase struct {
19+ *BasePhase
20+}
21+
22+// NewPostInstallPhase creates a new post-install phase
23+func NewPostInstallPhase(cfg *config.Config, log *logger.Logger) *PostInstallPhase {
24+ return &PostInstallPhase{
25+ BasePhase: NewBasePhase("postinstall", "Post-Installation", cfg, log),
26+ }
27+}
28+
29+// PreCheck validates post-install prerequisites
30+func (p *PostInstallPhase) PreCheck() error {
31+ // Verify /mnt is mounted
32+ result := system.RunSimple("mountpoint", "-q", config.PathMnt)
33+ switch {
34+ case result.ExitCode != 0:
35+ return fmt.Errorf("%s is not mounted", config.PathMnt)
36+ }
37+
38+ // Verify boot directory exists
39+ switch _, err := os.Stat(config.PathMntBoot); {
40+ case os.IsNotExist(err):
41+ return fmt.Errorf("boot directory not found")
42+ }
43+
44+ return nil
45+}
46+
47+// Execute runs the post-install phase
48+func (p *PostInstallPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
49+ totalSteps := 9
50+ currentStep := 0
51+
52+ // Step 1: Boot logo
53+ currentStep++
54+ p.SendProgress(progressChan, "Installing boot logo...", currentStep, totalSteps)
55+ switch err := p.installBootLogo(progressChan); {
56+ case err != nil:
57+ // Non-fatal, continue
58+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Boot logo failed: %v", err))
59+ }
60+
61+ // Step 2: Plymouth
62+ currentStep++
63+ p.SendProgress(progressChan, "Configuring Plymouth splash screen...", currentStep, totalSteps)
64+ switch err := p.configurePlymouth(progressChan); {
65+ case err != nil:
66+ // Non-fatal, continue
67+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Plymouth failed: %v", err))
68+ }
69+
70+ // Step 3: Snapper
71+ currentStep++
72+ p.SendProgress(progressChan, "Configuring btrfs snapshots...", currentStep, totalSteps)
73+ switch err := p.configureSnapper(progressChan); {
74+ case err != nil:
75+ // Non-fatal, continue
76+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Snapper failed: %v", err))
77+ }
78+
79+ // Step 4: Pacman configuration
80+ currentStep++
81+ p.SendProgress(progressChan, "Configuring pacman...", currentStep, totalSteps)
82+ switch err := p.configurePacman(progressChan); {
83+ case err != nil:
84+ p.SendError(progressChan, err)
85+ return PhaseResult{Success: false, Error: err}
86+ }
87+
88+ // Step 5: Post-boot setup (skipped for now)
89+ currentStep++
90+ p.SendProgress(progressChan, "Post-boot setup...", currentStep, totalSteps)
91+ p.SendOutput(progressChan, "[SKIP] Post-boot setup not implemented")
92+
93+ // Step 6: Pacman hooks
94+ currentStep++
95+ p.SendProgress(progressChan, "Installing pacman hooks...", currentStep, totalSteps)
96+ switch err := p.installPacmanHooks(progressChan); {
97+ case err != nil:
98+ p.SendError(progressChan, err)
99+ return PhaseResult{Success: false, Error: err}
100+ }
101+
102+ // Step 7: Shell configuration
103+ currentStep++
104+ p.SendProgress(progressChan, "Configuring shell environment...", currentStep, totalSteps)
105+ switch err := p.configureShell(progressChan); {
106+ case err != nil:
107+ p.SendError(progressChan, err)
108+ return PhaseResult{Success: false, Error: err}
109+ }
110+
111+ // Step 8: Verification
112+ currentStep++
113+ p.SendProgress(progressChan, "Verifying installation...", currentStep, totalSteps)
114+ switch err := p.verifyInstallation(progressChan); {
115+ case err != nil:
116+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Verification warnings: %v", err))
117+ }
118+
119+ // Step 9: Unmount
120+ currentStep++
121+ p.SendProgress(progressChan, "Unmounting filesystems...", currentStep, totalSteps)
122+ switch err := p.unmountAll(progressChan); {
123+ case err != nil:
124+ p.SendError(progressChan, err)
125+ return PhaseResult{Success: false, Error: err}
126+ }
127+
128+ p.SendComplete(progressChan, "Post-installation complete")
129+ return PhaseResult{Success: true, Message: "Post-installation complete"}
130+}
131+
132+// installBootLogo downloads and configures Limine boot logo
133+func (p *PostInstallPhase) installBootLogo(progressChan chan<- ProgressUpdate) error {
134+ p.SendOutput(progressChan, "Downloading Arch logo...")
135+
136+ // Build logo URL from config
137+ logoURL := fmt.Sprintf("%s/%s", p.config.RawURL, config.ArchLogoURL)
138+
139+ resp, err := http.Get(logoURL)
140+ switch {
141+ case err != nil:
142+ return fmt.Errorf("failed to download logo: %w", err)
143+ }
144+ defer resp.Body.Close()
145+
146+ switch {
147+ case resp.StatusCode != http.StatusOK:
148+ return fmt.Errorf("failed to download logo: HTTP %d", resp.StatusCode)
149+ }
150+
151+ // Save to boot partition
152+ logoFile, err := os.Create(config.PathMntBootLogo)
153+ switch {
154+ case err != nil:
155+ return fmt.Errorf("failed to create logo file: %w", err)
156+ }
157+ defer logoFile.Close()
158+
159+ _, err = io.Copy(logoFile, resp.Body)
160+ switch {
161+ case err != nil:
162+ return fmt.Errorf("failed to save logo: %w", err)
163+ }
164+
165+ p.SendOutput(progressChan, "[OK] Logo downloaded")
166+
167+ // Update Limine config
168+ limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
169+ content, err := os.ReadFile(limineConf)
170+ switch {
171+ case err != nil:
172+ return fmt.Errorf("failed to read limine.conf: %w", err)
173+ }
174+
175+ contentStr := string(content)
176+
177+ // Find "graphics: yes" and add wallpaper settings after it
178+ graphicsRegex := regexp.MustCompile(`(?m)^graphics: yes$`)
179+ switch {
180+ case graphicsRegex.MatchString(contentStr):
181+ wallpaperSettings := "\nwallpaper: boot():/arch-logo.png\nwallpaper_style: centered\nbackdrop: 000000"
182+ contentStr = graphicsRegex.ReplaceAllString(contentStr, "graphics: yes"+wallpaperSettings)
183+
184+ switch err := os.WriteFile(limineConf, []byte(contentStr), 0644); {
185+ case err != nil:
186+ return fmt.Errorf("failed to update limine.conf: %w", err)
187+ }
188+
189+ p.SendOutput(progressChan, "[OK] Boot logo configured")
190+ default:
191+ p.SendOutput(progressChan, "[WARN] graphics: yes not found in limine.conf")
192+ }
193+
194+ return nil
195+}
196+
197+// configurePlymouth sets up Plymouth splash screen
198+func (p *PostInstallPhase) configurePlymouth(progressChan chan<- ProgressUpdate) error {
199+ p.SendOutput(progressChan, "Setting up Plymouth theme...")
200+
201+ // Create theme directory
202+ themeDir := filepath.Join(config.PathMntPlymouthThemes, config.PlymouthThemeName)
203+ switch err := os.MkdirAll(themeDir, 0755); {
204+ case err != nil:
205+ return fmt.Errorf("failed to create theme directory: %w", err)
206+ }
207+
208+ // Download all Plymouth files from assets (local files in repo)
209+ for _, filename := range config.PlymouthFiles {
210+ p.SendOutput(progressChan, fmt.Sprintf("Downloading %s...", filename))
211+
212+ fileURL := fmt.Sprintf("%s/assets/plymouth/%s", p.config.RawURL, filename)
213+ resp, err := http.Get(fileURL)
214+ switch {
215+ case err != nil:
216+ return fmt.Errorf("failed to download %s: %w", filename, err)
217+ }
218+
219+ switch {
220+ case resp.StatusCode != http.StatusOK:
221+ resp.Body.Close()
222+ return fmt.Errorf("failed to download %s: HTTP %d", filename, resp.StatusCode)
223+ }
224+
225+ // Save file
226+ destPath := filepath.Join(themeDir, filename)
227+ destFile, err := os.Create(destPath)
228+ switch {
229+ case err != nil:
230+ resp.Body.Close()
231+ return fmt.Errorf("failed to create %s: %w", filename, err)
232+ }
233+
234+ _, err = io.Copy(destFile, resp.Body)
235+ destFile.Close()
236+ resp.Body.Close()
237+
238+ switch {
239+ case err != nil:
240+ return fmt.Errorf("failed to save %s: %w", filename, err)
241+ }
242+
243+ // Set permissions
244+ switch err := os.Chmod(destPath, 0644); {
245+ case err != nil:
246+ return fmt.Errorf("failed to set permissions on %s: %w", filename, err)
247+ }
248+ }
249+
250+ p.SendOutput(progressChan, "[OK] Plymouth files downloaded")
251+
252+ // Set as default theme
253+ setThemeCmd := fmt.Sprintf("plymouth-set-default-theme %s", config.PlymouthThemeName)
254+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, setThemeCmd); {
255+ case err != nil:
256+ return fmt.Errorf("failed to set Plymouth theme: %w", err)
257+ }
258+
259+ p.SendOutput(progressChan, "[OK] Plymouth theme set")
260+
261+ // Regenerate initramfs
262+ p.SendOutput(progressChan, "Regenerating initramfs with Plymouth...")
263+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "mkinitcpio -P"); {
264+ case err != nil:
265+ return fmt.Errorf("failed to regenerate initramfs: %w", err)
266+ }
267+
268+ p.SendOutput(progressChan, "[OK] Plymouth configured")
269+ return nil
270+}
271+
272+// configureSnapper sets up btrfs snapshots with Limine integration
273+func (p *PostInstallPhase) configureSnapper(progressChan chan<- ProgressUpdate) error {
274+ p.SendOutput(progressChan, "Installing limine-snapper-sync...")
275+
276+ // Install limine-snapper-sync
277+ switch err := system.ChrootPacman(p.logger.LogPath(),config.PathMnt, "-S", "--needed", "limine-snapper-sync"); {
278+ case err != nil:
279+ return fmt.Errorf("failed to install limine-snapper-sync: %w", err)
280+ }
281+
282+ p.SendOutput(progressChan, "[OK] Package installed")
283+
284+ // Get kernel cmdline from existing Limine config
285+ limineConf := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
286+ content, err := os.ReadFile(limineConf)
287+ switch {
288+ case err != nil:
289+ return fmt.Errorf("failed to read limine.conf: %w", err)
290+ }
291+
292+ // Extract cmdline
293+ cmdlineRegex := regexp.MustCompile(`(?m)^\s*cmdline:\s*(.+)$`)
294+ matches := cmdlineRegex.FindStringSubmatch(string(content))
295+ cmdline := ""
296+ switch {
297+ case len(matches) > 1:
298+ cmdline = strings.TrimSpace(matches[1])
299+ }
300+
301+ // Create Limine snapper config
302+ limineDefaultConfig := fmt.Sprintf(`TARGET_OS_NAME="ArchUp"
303+
304+ESP_PATH="/boot"
305+
306+KERNEL_CMDLINE[default]="%s"
307+
308+ENABLE_UKI=no
309+ENABLE_LIMINE_FALLBACK=yes
310+
311+FIND_BOOTLOADERS=yes
312+
313+BOOT_ORDER="*, *fallback, Snapshots"
314+
315+MAX_SNAPSHOT_ENTRIES=5
316+
317+SNAPSHOT_FORMAT_CHOICE=5
318+`, cmdline)
319+
320+ switch err := os.WriteFile(config.PathMntEtcDefaultLimine, []byte(limineDefaultConfig), 0644); {
321+ case err != nil:
322+ return fmt.Errorf("failed to write limine config: %w", err)
323+ }
324+
325+ p.SendOutput(progressChan, "[OK] Limine snapper config created")
326+
327+ // Enable service
328+ switch err := system.ChrootSystemctl(p.logger.LogPath(), config.PathMnt, "enable", "limine-snapper-sync.service"); {
329+ case err != nil:
330+ return fmt.Errorf("failed to enable limine-snapper-sync: %w", err)
331+ }
332+
333+ p.SendOutput(progressChan, "[OK] Btrfs snapshots configured")
334+ return nil
335+}
336+
337+// configurePacman configures pacman for better UX
338+func (p *PostInstallPhase) configurePacman(progressChan chan<- ProgressUpdate) error {
339+ p.SendOutput(progressChan, "Configuring pacman UX...")
340+
341+ content, err := os.ReadFile(config.PathMntEtcPacmanConf)
342+ switch {
343+ case err != nil:
344+ return fmt.Errorf("failed to read pacman.conf: %w", err)
345+ }
346+
347+ contentStr := string(content)
348+
349+ // Enable Color
350+ colorRegex := regexp.MustCompile(`(?m)^#Color$`)
351+ contentStr = colorRegex.ReplaceAllString(contentStr, "Color")
352+
353+ // Enable ParallelDownloads
354+ parallelRegex := regexp.MustCompile(`(?m)^#ParallelDownloads = 5$`)
355+ contentStr = parallelRegex.ReplaceAllString(contentStr, "ParallelDownloads = 5")
356+
357+ // Add ILoveCandy after Color
358+ switch {
359+ case !strings.Contains(contentStr, "ILoveCandy"):
360+ colorLineRegex := regexp.MustCompile(`(?m)^Color$`)
361+ contentStr = colorLineRegex.ReplaceAllString(contentStr, "Color\nILoveCandy")
362+ }
363+
364+ switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
365+ case err != nil:
366+ return fmt.Errorf("failed to write pacman.conf: %w", err)
367+ }
368+
369+ p.SendOutput(progressChan, "[OK] Pacman configured (color, parallel downloads, candy)")
370+ return nil
371+}
372+
373+// installPacmanHooks installs pacman hooks for maintenance
374+func (p *PostInstallPhase) installPacmanHooks(progressChan chan<- ProgressUpdate) error {
375+ p.SendOutput(progressChan, "Installing pacman hooks...")
376+
377+ // Create hooks directory
378+ switch err := os.MkdirAll(config.PathMntEtcPacmanDHooks, 0755); {
379+ case err != nil:
380+ return fmt.Errorf("failed to create hooks directory: %w", err)
381+ }
382+
383+ // Limine update hook
384+ limineHook := `[Trigger]
385+Operation = Install
386+Operation = Upgrade
387+Type = Package
388+Target = limine
389+
390+[Action]
391+Description = Deploying Limine after upgrade...
392+When = PostTransaction
393+Exec = /usr/bin/cp /usr/share/limine/BOOTX64.EFI /boot/EFI/limine/
394+`
395+
396+ hookPath := filepath.Join(config.PathMntEtcPacmanDHooks, "99-limine.hook")
397+ switch err := os.WriteFile(hookPath, []byte(limineHook), 0644); {
398+ case err != nil:
399+ return fmt.Errorf("failed to write limine hook: %w", err)
400+ }
401+
402+ p.SendOutput(progressChan, "[OK] Limine update hook installed")
403+ return nil
404+}
405+
406+// configureShell sets up bash shell with ArchUp defaults
407+func (p *PostInstallPhase) configureShell(progressChan chan<- ProgressUpdate) error {
408+ p.SendOutput(progressChan, "Configuring shell environment...")
409+
410+ // Get username from /mnt/home
411+ homeDir := filepath.Join(config.PathMnt, "home")
412+ entries, err := os.ReadDir(homeDir)
413+ switch {
414+ case err != nil:
415+ return fmt.Errorf("failed to read home directory: %w", err)
416+ case len(entries) == 0:
417+ return fmt.Errorf("no user found in /home")
418+ }
419+
420+ username := entries[0].Name()
421+ userHome := filepath.Join(config.PathMnt, "home", username)
422+ archupDefault := filepath.Join(userHome, ".local", "share", "archup", "default")
423+ archupDefaultBash := filepath.Join(archupDefault, "bash")
424+
425+ p.SendOutput(progressChan, fmt.Sprintf("Configuring shell for user: %s", username))
426+
427+ // Create directory structure
428+ switch err := os.MkdirAll(archupDefaultBash, 0755); {
429+ case err != nil:
430+ return fmt.Errorf("failed to create shell config directory: %w", err)
431+ }
432+
433+ // Copy shell configuration files from templates
434+ switch err := p.copyShellTemplates(archupDefault, archupDefaultBash); {
435+ case err != nil:
436+ return fmt.Errorf("failed to copy shell templates: %w", err)
437+ }
438+
439+ // Create .bashrc
440+ bashrcContent, err := p.readTemplate(config.BashrcTemplate)
441+ switch {
442+ case err != nil:
443+ return fmt.Errorf("failed to read bashrc template: %w", err)
444+ }
445+
446+ bashrcPath := filepath.Join(userHome, ".bashrc")
447+ switch err := os.WriteFile(bashrcPath, bashrcContent, 0644); {
448+ case err != nil:
449+ return fmt.Errorf("failed to write .bashrc: %w", err)
450+ }
451+
452+ // Set ownership to user
453+ switch err := p.setShellOwnership(userHome, username); {
454+ case err != nil:
455+ return fmt.Errorf("failed to set ownership: %w", err)
456+ }
457+
458+ // Configure git delta
459+ gitCommands := []string{
460+ fmt.Sprintf("su - %s -c 'git config --global core.pager delta'", username),
461+ fmt.Sprintf("su - %s -c 'git config --global interactive.diffFilter \"delta --color-only\"'", username),
462+ fmt.Sprintf("su - %s -c 'git config --global delta.navigate true'", username),
463+ fmt.Sprintf("su - %s -c 'git config --global delta.light false'", username),
464+ fmt.Sprintf("su - %s -c 'git config --global delta.side-by-side true'", username),
465+ fmt.Sprintf("su - %s -c 'git config --global merge.conflictstyle diff3'", username),
466+ fmt.Sprintf("su - %s -c 'git config --global diff.colorMoved default'", username),
467+ }
468+
469+ for _, gitCmd := range gitCommands {
470+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, gitCmd); {
471+ case err != nil:
472+ // Non-fatal
473+ p.SendOutput(progressChan, "[WARN] Failed to configure git delta")
474+ break
475+ }
476+ }
477+
478+ p.SendOutput(progressChan, "[OK] Shell environment configured")
479+ return nil
480+}
481+
482+// copyShellTemplates copies shell config templates to user directory
483+func (p *PostInstallPhase) copyShellTemplates(archupDefault, archupDefaultBash string) error {
484+ templates := map[string]string{
485+ config.ShellConfigTemplate: filepath.Join(archupDefaultBash, "shell"),
486+ config.ShellInitTemplate: filepath.Join(archupDefaultBash, "init"),
487+ config.ShellAliasesTemplate: filepath.Join(archupDefaultBash, "aliases"),
488+ config.ShellEnvsTemplate: filepath.Join(archupDefaultBash, "envs"),
489+ config.ShellFunctionsTemplate: filepath.Join(archupDefaultBash, "functions"),
490+ config.ShellRcTemplate: filepath.Join(archupDefaultBash, "rc"),
491+ config.StarshipConfigTemplate: filepath.Join(archupDefault, "starship.toml"),
492+ }
493+
494+ for template, dest := range templates {
495+ content, err := p.readTemplate(template)
496+ switch {
497+ case err != nil:
498+ return fmt.Errorf("failed to read template %s: %w", template, err)
499+ }
500+
501+ switch err := os.WriteFile(dest, content, 0644); {
502+ case err != nil:
503+ return fmt.Errorf("failed to write %s: %w", dest, err)
504+ }
505+ }
506+
507+ return nil
508+}
509+
510+// readTemplate reads a template file from install path
511+func (p *PostInstallPhase) readTemplate(filename string) ([]byte, error) {
512+ installPath := os.Getenv("ARCHUP_INSTALL")
513+ switch {
514+ case installPath == "":
515+ home := os.Getenv("HOME")
516+ installPath = filepath.Join(home, config.DefaultInstallPath)
517+ }
518+
519+ templatePath := filepath.Join(installPath, filename)
520+ return os.ReadFile(templatePath)
521+}
522+
523+// setShellOwnership sets ownership of shell config files to user
524+func (p *PostInstallPhase) setShellOwnership(userHome, username string) error {
525+ // Use chroot to run chown
526+ relativeHome := strings.TrimPrefix(userHome, config.PathMnt)
527+ chownCmd := fmt.Sprintf("chown -R %s:%s %s/.local %s/.bashrc",
528+ username, username, relativeHome, relativeHome)
529+ return system.ChrootExec(p.logger.LogPath(),config.PathMnt, chownCmd)
530+}
531+
532+// verifyInstallation performs comprehensive verification
533+func (p *PostInstallPhase) verifyInstallation(progressChan chan<- ProgressUpdate) error {
534+ p.SendOutput(progressChan, "Verifying installation components...")
535+
536+ verificationFailed := false
537+
538+ // Verify kernel
539+ kernelName := p.config.KernelChoice
540+ kernelPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("vmlinuz-%s", kernelName))
541+ switch _, err := os.Stat(kernelPath); {
542+ case os.IsNotExist(err):
543+ p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Kernel missing: %s", kernelPath))
544+ verificationFailed = true
545+ default:
546+ p.SendOutput(progressChan, "[OK] Kernel found")
547+ }
548+
549+ // Verify initramfs
550+ initramfsPath := filepath.Join(config.PathMntBoot, fmt.Sprintf("initramfs-%s.img", kernelName))
551+ switch _, err := os.Stat(initramfsPath); {
552+ case os.IsNotExist(err):
553+ p.SendOutput(progressChan, fmt.Sprintf("[ERROR] Initramfs missing: %s", initramfsPath))
554+ verificationFailed = true
555+ default:
556+ p.SendOutput(progressChan, "[OK] Initramfs found")
557+ }
558+
559+ // Verify bootloader
560+ bootloaderPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineBootloader)
561+ switch _, err := os.Stat(bootloaderPath); {
562+ case os.IsNotExist(err):
563+ p.SendOutput(progressChan, "[ERROR] Bootloader missing")
564+ verificationFailed = true
565+ default:
566+ p.SendOutput(progressChan, "[OK] Bootloader found")
567+ }
568+
569+ // Verify Limine config
570+ limineConfPath := filepath.Join(config.PathMntBootEFILimine, config.FileLimineConfig)
571+ switch content, err := os.ReadFile(limineConfPath); {
572+ case err != nil:
573+ p.SendOutput(progressChan, "[ERROR] Limine config missing")
574+ verificationFailed = true
575+ default:
576+ contentStr := string(content)
577+ switch {
578+ case strings.Contains(contentStr, "protocol:") && strings.Contains(contentStr, "path:"):
579+ p.SendOutput(progressChan, "[OK] Limine config validated")
580+ default:
581+ p.SendOutput(progressChan, "[WARN] Limine config may be incomplete")
582+ }
583+ }
584+
585+ // Verify NetworkManager is enabled
586+ result := system.RunSimple("arch-chroot", config.PathMnt, "systemctl", "is-enabled", "NetworkManager")
587+ switch {
588+ case result.ExitCode != 0:
589+ p.SendOutput(progressChan, "[ERROR] NetworkManager not enabled")
590+ verificationFailed = true
591+ default:
592+ p.SendOutput(progressChan, "[OK] NetworkManager enabled")
593+ }
594+
595+ switch {
596+ case verificationFailed:
597+ p.SendOutput(progressChan, "[WARN] Verification completed with warnings")
598+ return fmt.Errorf("verification completed with warnings")
599+ default:
600+ p.SendOutput(progressChan, "[OK] All critical components verified")
601+ }
602+
603+ return nil
604+}
605+
606+// unmountAll unmounts all filesystems
607+func (p *PostInstallPhase) unmountAll(progressChan chan<- ProgressUpdate) error {
608+ p.SendOutput(progressChan, "Unmounting all partitions...")
609+
610+ // Unmount /mnt recursively
611+ switch err := system.Unmount(p.logger.LogPath(), config.PathMnt); {
612+ case err != nil:
613+ p.SendOutput(progressChan, "[WARN] Some partitions may still be mounted")
614+ return fmt.Errorf("failed to unmount cleanly: %w", err)
615+ }
616+
617+ p.SendOutput(progressChan, "[OK] All partitions unmounted")
618+
619+ // Close encrypted volume if encryption was used
620+ switch p.config.EncryptionType {
621+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
622+ p.SendOutput(progressChan, "Closing encrypted volume...")
623+ result := p.logger.ExecCommand("cryptsetup", "close", "cryptroot")
624+ switch {
625+ case result.ExitCode != 0:
626+ p.SendOutput(progressChan, "[WARN] Failed to close encrypted volume")
627+ default:
628+ p.SendOutput(progressChan, "[OK] Encrypted volume closed")
629+ }
630+ }
631+
632+ return nil
633+}
634+
635+// PostCheck validates post-install completion
636+func (p *PostInstallPhase) PostCheck() error {
637+ // Verification already done in Execute
638+ return p.config.Save()
639+}
640+
641+// Rollback for post-install phase
642+func (p *PostInstallPhase) Rollback() error {
643+ // Attempt to unmount if something failed
644+ _ = system.Unmount(p.logger.LogPath(), config.PathMnt)
645+ return nil
646+}
647+
648+// CanSkip returns false - post-install cannot be skipped
649+func (p *PostInstallPhase) CanSkip() bool {
650+ return false
651+}
A internal/phases/preflight.go +170 -0
@@ -0,0 +1,170 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+ "strings"
8+
9+ "github.com/bnema/archup/internal/config"
10+ "github.com/bnema/archup/internal/logger"
11+)
12+
13+// PreflightPhase handles system validation and initial configuration
14+type PreflightPhase struct {
15+ *BasePhase
16+}
17+
18+// NewPreflightPhase creates a new preflight phase
19+func NewPreflightPhase(cfg *config.Config, log *logger.Logger) *PreflightPhase {
20+ return &PreflightPhase{
21+ BasePhase: NewBasePhase("preflight", "Preflight Checks", cfg, log),
22+ }
23+}
24+
25+// PreCheck validates system requirements
26+func (p *PreflightPhase) PreCheck() error {
27+ // Check for Arch Linux
28+ if _, err := os.Stat("/etc/arch-release"); os.IsNotExist(err) {
29+ return fmt.Errorf("must be running on Arch Linux or Arch ISO")
30+ }
31+
32+ // Check for derivatives
33+ derivatives := map[string]string{
34+ "/etc/cachyos-release": "CachyOS",
35+ "/etc/eos-release": "EndeavourOS",
36+ "/etc/garuda-release": "Garuda",
37+ "/etc/manjaro-release": "Manjaro",
38+ }
39+
40+ for marker, name := range derivatives {
41+ if _, err := os.Stat(marker); err == nil {
42+ return fmt.Errorf("must be vanilla Arch (detected %s)", name)
43+ }
44+ }
45+
46+ // Check architecture
47+ out, err := exec.Command("uname", "-m").Output()
48+ arch := strings.TrimSpace(string(out))
49+
50+ switch {
51+ case err != nil:
52+ return fmt.Errorf("failed to detect architecture: %w", err)
53+ case arch != "x86_64":
54+ return fmt.Errorf("must be x86_64 architecture (detected: %s)", arch)
55+ }
56+
57+ // Check UEFI
58+ if _, err := os.Stat("/sys/firmware/efi/efivars"); os.IsNotExist(err) {
59+ return fmt.Errorf("must be UEFI boot mode (legacy BIOS not supported)")
60+ }
61+
62+ // Check Secure Boot
63+ out, err = exec.Command("bootctl", "status").Output()
64+ switch {
65+ case err == nil && strings.Contains(string(out), "Secure Boot: enabled"):
66+ return fmt.Errorf("Secure Boot must be disabled")
67+ }
68+
69+ return nil
70+}
71+
72+// Execute runs the preflight phase
73+func (p *PreflightPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
74+ p.SendProgress(progressChan, "Running preflight checks...", 1, 2)
75+
76+ // Detect environment
77+ p.detectEnvironment(progressChan)
78+
79+ // Set defaults
80+ p.SetDefaults()
81+
82+ p.SendProgress(progressChan, "Environment detected", 2, 2)
83+ p.SendComplete(progressChan, "Preflight complete")
84+
85+ return PhaseResult{
86+ Success: true,
87+ Message: "Preflight checks passed",
88+ }
89+}
90+
91+// detectEnvironment auto-detects keyboard layout and network
92+func (p *PreflightPhase) detectEnvironment(progressChan chan<- ProgressUpdate) {
93+ p.SendOutput(progressChan, "Detecting keyboard layout...")
94+
95+ keymap := p.detectKeymap()
96+ p.config.Keymap = keymap
97+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Keyboard layout: %s", keymap))
98+}
99+
100+// detectKeymap detects current keyboard layout
101+func (p *PreflightPhase) detectKeymap() string {
102+ out, err := exec.Command("localectl", "status").Output()
103+
104+ switch {
105+ case err != nil:
106+ return "us" // Default fallback
107+ }
108+
109+ lines := strings.Split(string(out), "\n")
110+ for _, line := range lines {
111+ if !strings.Contains(line, "VC Keymap") {
112+ continue
113+ }
114+
115+ parts := strings.Fields(line)
116+ if len(parts) < 3 {
117+ continue
118+ }
119+
120+ keymap := parts[2]
121+ switch keymap {
122+ case "(unset)", "":
123+ return "us"
124+ default:
125+ return keymap
126+ }
127+ }
128+
129+ return "us"
130+}
131+
132+// SetDefaults sets default configuration values
133+func (p *PreflightPhase) SetDefaults() {
134+ switch {
135+ case p.config.Hostname == "":
136+ p.config.Hostname = "archup"
137+ }
138+
139+ switch {
140+ case p.config.Timezone == "":
141+ p.config.Timezone = "UTC"
142+ }
143+
144+ // Hardcoded bootloader
145+ p.config.Bootloader = config.BootloaderLimine
146+}
147+
148+// PostCheck validates configuration after form submission
149+func (p *PreflightPhase) PostCheck() error {
150+ switch {
151+ case p.config.Username == "":
152+ return fmt.Errorf("username is required")
153+ case p.config.Hostname == "":
154+ return fmt.Errorf("hostname is required")
155+ case p.config.Timezone == "":
156+ return fmt.Errorf("timezone is required")
157+ }
158+
159+ return p.config.Save()
160+}
161+
162+// Rollback for preflight (no-op as no system changes made)
163+func (p *PreflightPhase) Rollback() error {
164+ return nil
165+}
166+
167+// CanSkip returns false - preflight cannot be skipped
168+func (p *PreflightPhase) CanSkip() bool {
169+ return false
170+}
A internal/phases/repos.go +418 -0
@@ -0,0 +1,418 @@
1+package phases
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "strings"
10+
11+ "github.com/bnema/archup/internal/config"
12+ "github.com/bnema/archup/internal/logger"
13+ "github.com/bnema/archup/internal/system"
14+)
15+
16+// ReposPhase handles repository configuration
17+type ReposPhase struct {
18+ *BasePhase
19+ chaoticConfig map[string]string
20+}
21+
22+// NewReposPhase creates a new repos phase
23+func NewReposPhase(cfg *config.Config, log *logger.Logger) *ReposPhase {
24+ return &ReposPhase{
25+ BasePhase: NewBasePhase("repos", "Repository Configuration", cfg, log),
26+ chaoticConfig: make(map[string]string),
27+ }
28+}
29+
30+// PreCheck validates repos prerequisites
31+func (p *ReposPhase) PreCheck() error {
32+ // Verify /mnt is mounted
33+ result := system.RunSimple("mountpoint", "-q", config.PathMnt)
34+ switch {
35+ case result.ExitCode != 0:
36+ return fmt.Errorf("%s is not mounted", config.PathMnt)
37+ }
38+
39+ // Verify pacman.conf exists
40+ switch _, err := os.Stat(config.PathMntEtcPacmanConf); {
41+ case os.IsNotExist(err):
42+ return fmt.Errorf("pacman.conf not found")
43+ }
44+
45+ return nil
46+}
47+
48+// Execute runs the repos phase
49+func (p *ReposPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
50+ p.SendProgress(progressChan, "Starting repository configuration...", 1, 4)
51+
52+ // Step 1: Enable multilib (optional)
53+ switch {
54+ case p.config.EnableMultilib:
55+ switch err := p.enableMultilib(progressChan); {
56+ case err != nil:
57+ p.SendError(progressChan, err)
58+ return PhaseResult{Success: false, Error: err}
59+ }
60+ default:
61+ p.SendOutput(progressChan, "[SKIP] Multilib disabled")
62+ }
63+
64+ p.SendProgress(progressChan, "Installing extra packages...", 2, 4)
65+
66+ // Step 2: Install extra packages (always, from official repos)
67+ switch err := p.installExtraPackages(progressChan); {
68+ case err != nil:
69+ // Don't fail the phase, just warn
70+ p.SendOutput(progressChan, fmt.Sprintf("[WARN] Some extra packages failed: %v", err))
71+ }
72+
73+ p.SendProgress(progressChan, "Configuring Chaotic-AUR...", 3, 4)
74+
75+ // Step 3: Enable Chaotic-AUR (optional)
76+ switch {
77+ case p.config.EnableChaotic:
78+ switch err := p.enableChaoticAUR(progressChan); {
79+ case err != nil:
80+ p.SendError(progressChan, err)
81+ return PhaseResult{Success: false, Error: err}
82+ }
83+ default:
84+ p.SendOutput(progressChan, "[SKIP] Chaotic-AUR disabled")
85+ }
86+
87+ p.SendProgress(progressChan, "Installing AUR helper...", 4, 4)
88+
89+ // Step 4: Install AUR helper (optional)
90+ switch {
91+ case p.config.AURHelper != "":
92+ switch err := p.installAURHelper(progressChan); {
93+ case err != nil:
94+ p.SendError(progressChan, err)
95+ return PhaseResult{Success: false, Error: err}
96+ }
97+ default:
98+ p.SendOutput(progressChan, "[SKIP] AUR helper disabled")
99+ }
100+
101+ p.SendProgress(progressChan, "Repository configuration complete", 4, 4)
102+ p.SendComplete(progressChan, "Repositories configured successfully")
103+
104+ return PhaseResult{Success: true, Message: "Repos configuration complete"}
105+}
106+
107+// enableMultilib enables multilib repository
108+func (p *ReposPhase) enableMultilib(progressChan chan<- ProgressUpdate) error {
109+ p.SendOutput(progressChan, "Enabling multilib repository...")
110+
111+ // Read pacman.conf
112+ content, err := os.ReadFile(config.PathMntEtcPacmanConf)
113+ switch {
114+ case err != nil:
115+ return fmt.Errorf("failed to read pacman.conf: %w", err)
116+ }
117+
118+ contentStr := string(content)
119+
120+ // Check if already enabled
121+ switch {
122+ case strings.Contains(contentStr, "[multilib]") && !strings.Contains(contentStr, "#[multilib]"):
123+ p.SendOutput(progressChan, "[OK] Multilib already enabled")
124+ return nil
125+ }
126+
127+ // Uncomment multilib section
128+ multilibRegex := regexp.MustCompile(`(?m)^#\[multilib\]`)
129+ contentStr = multilibRegex.ReplaceAllString(contentStr, "[multilib]")
130+
131+ includeRegex := regexp.MustCompile(`(?m)^#Include = /etc/pacman.d/mirrorlist`)
132+ contentStr = includeRegex.ReplaceAllString(contentStr, "Include = /etc/pacman.d/mirrorlist")
133+
134+ // Write updated config
135+ switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
136+ case err != nil:
137+ return fmt.Errorf("failed to write pacman.conf: %w", err)
138+ }
139+
140+ p.SendOutput(progressChan, "[OK] Multilib enabled")
141+
142+ return nil
143+}
144+
145+// loadChaoticConfig loads Chaotic-AUR configuration from file
146+func (p *ReposPhase) loadChaoticConfig() error {
147+ configPath := p.getInstallPath(config.ChaoticConfigFile)
148+
149+ file, err := os.Open(configPath)
150+ switch {
151+ case err != nil:
152+ return fmt.Errorf("failed to open chaotic config: %w", err)
153+ }
154+ defer file.Close()
155+
156+ scanner := bufio.NewScanner(file)
157+ for scanner.Scan() {
158+ line := strings.TrimSpace(scanner.Text())
159+
160+ // Skip comments and empty lines
161+ switch {
162+ case line == "":
163+ continue
164+ case strings.HasPrefix(line, "#"):
165+ continue
166+ }
167+
168+ // Parse KEY=VALUE
169+ parts := strings.SplitN(line, "=", 2)
170+ switch {
171+ case len(parts) == 2:
172+ p.chaoticConfig[parts[0]] = parts[1]
173+ }
174+ }
175+
176+ switch err := scanner.Err(); {
177+ case err != nil:
178+ return fmt.Errorf("error reading chaotic config: %w", err)
179+ }
180+
181+ return nil
182+}
183+
184+// enableChaoticAUR enables Chaotic-AUR repository
185+func (p *ReposPhase) enableChaoticAUR(progressChan chan<- ProgressUpdate) error {
186+ p.SendOutput(progressChan, "Adding Chaotic-AUR repository...")
187+
188+ // Load configuration
189+ switch err := p.loadChaoticConfig(); {
190+ case err != nil:
191+ return fmt.Errorf("failed to load Chaotic config: %w", err)
192+ }
193+
194+ keyID := p.chaoticConfig["CHAOTIC_KEY_ID"]
195+ keyserver := p.chaoticConfig["CHAOTIC_KEYSERVER"]
196+ keyringURL := p.chaoticConfig["CHAOTIC_KEYRING_URL"]
197+ mirrorlistURL := p.chaoticConfig["CHAOTIC_MIRRORLIST_URL"]
198+ repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]
199+ mirrorlistPath := p.chaoticConfig["CHAOTIC_MIRRORLIST_PATH"]
200+
201+ // Import GPG key
202+ recvKeyCmd := fmt.Sprintf("pacman-key --recv-key %s --keyserver %s", keyID, keyserver)
203+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, recvKeyCmd); {
204+ case err != nil:
205+ return fmt.Errorf("failed to receive Chaotic GPG key: %w", err)
206+ }
207+
208+ // Locally sign the key
209+ lsignKeyCmd := fmt.Sprintf("pacman-key --lsign-key %s", keyID)
210+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, lsignKeyCmd); {
211+ case err != nil:
212+ return fmt.Errorf("failed to sign Chaotic GPG key: %w", err)
213+ }
214+
215+ p.SendOutput(progressChan, "[OK] Chaotic GPG key imported")
216+
217+ // Install chaotic-keyring and chaotic-mirrorlist
218+ installCmd := fmt.Sprintf("pacman -U --noconfirm '%s' '%s'", keyringURL, mirrorlistURL)
219+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, installCmd); {
220+ case err != nil:
221+ return fmt.Errorf("failed to install Chaotic packages: %w", err)
222+ }
223+
224+ p.SendOutput(progressChan, "[OK] Chaotic packages installed")
225+
226+ // Add Chaotic-AUR to pacman.conf
227+ content, err := os.ReadFile(config.PathMntEtcPacmanConf)
228+ switch {
229+ case err != nil:
230+ return fmt.Errorf("failed to read pacman.conf: %w", err)
231+ }
232+
233+ contentStr := string(content)
234+
235+ // Check if already added
236+ switch {
237+ case strings.Contains(contentStr, fmt.Sprintf("[%s]", repoName)):
238+ p.SendOutput(progressChan, "[OK] Chaotic-AUR already in pacman.conf")
239+ default:
240+ // Append Chaotic-AUR section
241+ chaoticSection := fmt.Sprintf("\n# Chaotic-AUR repository\n[%s]\nInclude = %s\n", repoName, mirrorlistPath)
242+ contentStr += chaoticSection
243+
244+ switch err := os.WriteFile(config.PathMntEtcPacmanConf, []byte(contentStr), 0644); {
245+ case err != nil:
246+ return fmt.Errorf("failed to write pacman.conf: %w", err)
247+ }
248+
249+ p.SendOutput(progressChan, "[OK] Added Chaotic-AUR to pacman.conf")
250+ }
251+
252+ // Update package databases
253+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "pacman -Sy --noconfirm"); {
254+ case err != nil:
255+ return fmt.Errorf("failed to update package databases: %w", err)
256+ }
257+
258+ // Verify Chaotic-AUR is working
259+ verifyCmd := fmt.Sprintf("pacman -Sl %s", repoName)
260+ result := system.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)
261+ switch {
262+ case result.ExitCode != 0:
263+ p.SendOutput(progressChan, "[WARN] Chaotic-AUR verification failed")
264+ return fmt.Errorf("Chaotic-AUR verification failed")
265+ }
266+
267+ p.SendOutput(progressChan, "[OK] Chaotic-AUR enabled successfully")
268+
269+ return nil
270+}
271+
272+// installExtraPackages installs packages from extra.packages file
273+func (p *ReposPhase) installExtraPackages(progressChan chan<- ProgressUpdate) error {
274+ p.SendOutput(progressChan, "Installing extra packages...")
275+
276+ // Load extra packages
277+ packages, err := p.loadExtraPackages()
278+ switch {
279+ case err != nil:
280+ return fmt.Errorf("failed to load extra packages: %w", err)
281+ case len(packages) == 0:
282+ p.SendOutput(progressChan, "[SKIP] No extra packages to install")
283+ return nil
284+ }
285+
286+ p.SendOutput(progressChan, fmt.Sprintf("Installing %d extra packages...", len(packages)))
287+
288+ // Install packages
289+ installCmd := fmt.Sprintf("pacman -S --noconfirm %s", strings.Join(packages, " "))
290+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, installCmd); {
291+ case err != nil:
292+ return fmt.Errorf("failed to install extra packages: %w", err)
293+ }
294+
295+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Installed %d extra packages", len(packages)))
296+
297+ return nil
298+}
299+
300+// loadExtraPackages reads extra package list from file
301+func (p *ReposPhase) loadExtraPackages() ([]string, error) {
302+ packageFile := p.getInstallPath(config.ExtraPackagesFile)
303+
304+ file, err := os.Open(packageFile)
305+ switch {
306+ case err != nil:
307+ return nil, fmt.Errorf("failed to open %s: %w", packageFile, err)
308+ }
309+ defer file.Close()
310+
311+ var packages []string
312+ scanner := bufio.NewScanner(file)
313+
314+ for scanner.Scan() {
315+ line := strings.TrimSpace(scanner.Text())
316+
317+ // Skip comments and empty lines
318+ switch {
319+ case line == "":
320+ continue
321+ case strings.HasPrefix(line, "#"):
322+ continue
323+ }
324+
325+ packages = append(packages, line)
326+ }
327+
328+ switch err := scanner.Err(); {
329+ case err != nil:
330+ return nil, fmt.Errorf("error reading package file: %w", err)
331+ }
332+
333+ return packages, nil
334+}
335+
336+// installAURHelper installs the selected AUR helper
337+func (p *ReposPhase) installAURHelper(progressChan chan<- ProgressUpdate) error {
338+ p.SendOutput(progressChan, fmt.Sprintf("Installing %s AUR helper...", p.config.AURHelper))
339+
340+ // Install AUR helper from Chaotic-AUR
341+ installCmd := fmt.Sprintf("pacman -S --noconfirm %s", p.config.AURHelper)
342+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, installCmd); {
343+ case err != nil:
344+ return fmt.Errorf("failed to install %s: %w", p.config.AURHelper, err)
345+ }
346+
347+ // Verify installation
348+ verifyCmd := fmt.Sprintf("su - %s -c '%s --version'", p.config.Username, p.config.AURHelper)
349+ result := system.RunSimple("arch-chroot", config.PathMnt, "sh", "-c", verifyCmd)
350+ switch {
351+ case result.ExitCode != 0:
352+ return fmt.Errorf("%s verification failed", p.config.AURHelper)
353+ }
354+
355+ p.SendOutput(progressChan, fmt.Sprintf("[OK] %s installed successfully", p.config.AURHelper))
356+
357+ return nil
358+}
359+
360+// getInstallPath constructs full path to install file
361+func (p *ReposPhase) getInstallPath(filename string) string {
362+ installPath := os.Getenv("ARCHUP_INSTALL")
363+ switch {
364+ case installPath == "":
365+ home := os.Getenv("HOME")
366+ installPath = filepath.Join(home, config.DefaultInstallPath)
367+ }
368+ return filepath.Join(installPath, filename)
369+}
370+
371+// PostCheck validates repository configuration
372+func (p *ReposPhase) PostCheck() error {
373+ // Verify pacman.conf was modified
374+ content, err := os.ReadFile(config.PathMntEtcPacmanConf)
375+ switch {
376+ case err != nil:
377+ return fmt.Errorf("failed to read pacman.conf: %w", err)
378+ }
379+
380+ contentStr := string(content)
381+
382+ // Check multilib if enabled
383+ switch {
384+ case p.config.EnableMultilib:
385+ switch {
386+ case !strings.Contains(contentStr, "[multilib]"):
387+ return fmt.Errorf("multilib was not enabled in pacman.conf")
388+ }
389+ }
390+
391+ // Check Chaotic-AUR if enabled
392+ switch {
393+ case p.config.EnableChaotic:
394+ repoName := p.chaoticConfig["CHAOTIC_REPO_NAME"]
395+ switch {
396+ case repoName == "":
397+ repoName = "chaotic-aur"
398+ }
399+
400+ switch {
401+ case !strings.Contains(contentStr, fmt.Sprintf("[%s]", repoName)):
402+ return fmt.Errorf("Chaotic-AUR was not added to pacman.conf")
403+ }
404+ }
405+
406+ return p.config.Save()
407+}
408+
409+// Rollback for repos phase
410+func (p *ReposPhase) Rollback() error {
411+ // Repository changes are in chroot, cleaned up by partitioning rollback
412+ return nil
413+}
414+
415+// CanSkip returns false - repos cannot be skipped
416+func (p *ReposPhase) CanSkip() bool {
417+ return false
418+}
A internal/phases/systemconfig.go +274 -0
@@ -0,0 +1,274 @@
1+package phases
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/logger"
10+ "github.com/bnema/archup/internal/system"
11+)
12+
13+// ConfigPhase handles system configuration
14+type ConfigPhase struct {
15+ *BasePhase
16+}
17+
18+// NewConfigPhase creates a new config phase
19+func NewConfigPhase(cfg *config.Config, log *logger.Logger) *ConfigPhase {
20+ return &ConfigPhase{
21+ BasePhase: NewBasePhase("config", "System Configuration", cfg, log),
22+ }
23+}
24+
25+// PreCheck validates configuration prerequisites
26+func (p *ConfigPhase) PreCheck() error {
27+ // Verify /mnt is mounted
28+ result := system.RunSimple("mountpoint", "-q", config.PathMnt)
29+ switch {
30+ case result.ExitCode != 0:
31+ return fmt.Errorf("%s is not mounted", config.PathMnt)
32+ }
33+
34+ // Verify required config values
35+ switch {
36+ case p.config.Hostname == "":
37+ return fmt.Errorf("hostname is required")
38+ case p.config.Username == "":
39+ return fmt.Errorf("username is required")
40+ case p.config.UserPassword == "":
41+ return fmt.Errorf("user password is required")
42+ case p.config.Timezone == "":
43+ return fmt.Errorf("timezone is required")
44+ }
45+
46+ return nil
47+}
48+
49+// Execute runs the config phase
50+func (p *ConfigPhase) Execute(progressChan chan<- ProgressUpdate) PhaseResult {
51+ p.SendProgress(progressChan, "Starting system configuration...", 1, 4)
52+
53+ // Step 1: System config (timezone, locale, hostname)
54+ switch err := p.configureSystem(progressChan); {
55+ case err != nil:
56+ p.SendError(progressChan, err)
57+ return PhaseResult{Success: false, Error: err}
58+ }
59+
60+ p.SendProgress(progressChan, "Configuring network...", 2, 4)
61+
62+ // Step 2: Network config
63+ switch err := p.configureNetwork(progressChan); {
64+ case err != nil:
65+ p.SendError(progressChan, err)
66+ return PhaseResult{Success: false, Error: err}
67+ }
68+
69+ p.SendProgress(progressChan, "Creating user account...", 3, 4)
70+
71+ // Step 3: User creation
72+ switch err := p.createUser(progressChan); {
73+ case err != nil:
74+ p.SendError(progressChan, err)
75+ return PhaseResult{Success: false, Error: err}
76+ }
77+
78+ p.SendProgress(progressChan, "Configuring zram swap...", 4, 4)
79+
80+ // Step 4: Configure zram
81+ switch err := p.configureZram(progressChan); {
82+ case err != nil:
83+ p.SendError(progressChan, err)
84+ return PhaseResult{Success: false, Error: err}
85+ }
86+
87+ p.SendProgress(progressChan, "Configuration complete", 4, 4)
88+ p.SendComplete(progressChan, "System configured successfully")
89+
90+ return PhaseResult{Success: true, Message: "Configuration complete"}
91+}
92+
93+// configureSystem sets timezone, locale, hostname
94+func (p *ConfigPhase) configureSystem(progressChan chan<- ProgressUpdate) error {
95+ p.SendOutput(progressChan, "Configuring system settings...")
96+
97+ // Set timezone
98+ timezonePath := filepath.Join(config.PathUsrShareZoneinfo, p.config.Timezone)
99+ tzCmd := fmt.Sprintf("ln -sf %s %s", timezonePath, config.PathEtcLocaltime)
100+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, tzCmd); {
101+ case err != nil:
102+ return fmt.Errorf("failed to set timezone: %w", err)
103+ }
104+
105+ // Set hardware clock
106+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "hwclock --systohc"); {
107+ case err != nil:
108+ return fmt.Errorf("failed to set hardware clock: %w", err)
109+ }
110+
111+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Timezone set to: %s", p.config.Timezone))
112+
113+ // Set locale
114+ localeGen := p.config.Locale + " UTF-8\n"
115+ switch err := os.WriteFile(config.PathMntEtcLocaleGen, []byte(localeGen), 0644); {
116+ case err != nil:
117+ return fmt.Errorf("failed to write locale.gen: %w", err)
118+ }
119+
120+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, "locale-gen"); {
121+ case err != nil:
122+ return fmt.Errorf("failed to generate locale: %w", err)
123+ }
124+
125+ localeConf := fmt.Sprintf("LANG=%s\n", p.config.Locale)
126+ switch err := os.WriteFile(config.PathMntEtcLocaleConf, []byte(localeConf), 0644); {
127+ case err != nil:
128+ return fmt.Errorf("failed to write locale.conf: %w", err)
129+ }
130+
131+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Locale set to: %s", p.config.Locale))
132+
133+ // Set console keymap
134+ switch {
135+ case p.config.Keymap != "":
136+ vconsoleConf := fmt.Sprintf("KEYMAP=%s\n", p.config.Keymap)
137+ switch err := os.WriteFile(config.PathMntEtcVconsole, []byte(vconsoleConf), 0644); {
138+ case err != nil:
139+ return fmt.Errorf("failed to write vconsole.conf: %w", err)
140+ }
141+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Console keymap set to: %s", p.config.Keymap))
142+ }
143+
144+ // Set hostname
145+ switch err := os.WriteFile(config.PathMntEtcHostname, []byte(p.config.Hostname+"\n"), 0644); {
146+ case err != nil:
147+ return fmt.Errorf("failed to write hostname: %w", err)
148+ }
149+
150+ // Configure hosts file
151+ hostsContent := fmt.Sprintf(`127.0.0.1 localhost
152+::1 localhost
153+127.0.1.1 %s.localdomain %s
154+`, p.config.Hostname, p.config.Hostname)
155+
156+ switch err := os.WriteFile(config.PathMntEtcHosts, []byte(hostsContent), 0644); {
157+ case err != nil:
158+ return fmt.Errorf("failed to write hosts file: %w", err)
159+ }
160+
161+ p.SendOutput(progressChan, fmt.Sprintf("[OK] Hostname set to: %s", p.config.Hostname))
162+
163+ return nil
164+}
165+
166+// configureNetwork enables NetworkManager
167+func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error {
168+ p.SendOutput(progressChan, "Configuring network...")
169+
170+ // Enable NetworkManager service
171+ switch err := system.ChrootSystemctl(p.logger.LogPath(),config.PathMnt, "enable", config.ServiceNetworkManager); {
172+ case err != nil:
173+ return fmt.Errorf("failed to enable NetworkManager: %w", err)
174+ }
175+
176+ p.SendOutput(progressChan, "[OK] Network configured")
177+
178+ return nil
179+}
180+
181+// createUser creates user account with sudo access
182+func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
183+ p.SendOutput(progressChan, "Creating user account...")
184+
185+ // Set root password
186+ rootPassCmd := fmt.Sprintf("echo 'root:%s' | chpasswd", p.config.UserPassword)
187+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, rootPassCmd); {
188+ case err != nil:
189+ return fmt.Errorf("failed to set root password: %w", err)
190+ }
191+
192+ // Create user with home directory
193+ userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)
194+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {
195+ case err != nil:
196+ return fmt.Errorf("failed to create user: %w", err)
197+ }
198+
199+ // Set user password
200+ userPassCmd := fmt.Sprintf("echo '%s:%s' | chpasswd", p.config.Username, p.config.UserPassword)
201+ switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userPassCmd); {
202+ case err != nil:
203+ return fmt.Errorf("failed to set user password: %w", err)
204+ }
205+
206+ // Enable sudo for wheel group
207+ switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {
208+ case err != nil:
209+ return fmt.Errorf("failed to write sudoers config: %w", err)
210+ }
211+
212+ p.SendOutput(progressChan, fmt.Sprintf("[OK] User created: %s", p.config.Username))
213+ p.SendOutput(progressChan, "[OK] Sudo enabled for wheel group")
214+
215+ return nil
216+}
217+
218+// configureZram sets up zram compressed swap
219+func (p *ConfigPhase) configureZram(progressChan chan<- ProgressUpdate) error {
220+ p.SendOutput(progressChan, "Configuring zram swap...")
221+
222+ // Create zram-generator config
223+ zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
224+ switch err := os.WriteFile(zramConfigPath, []byte(config.ZramConfigContent), 0644); {
225+ case err != nil:
226+ return fmt.Errorf("failed to write zram-generator.conf: %w", err)
227+ }
228+
229+ // Create sysctl config for zram optimization
230+ sysctlConfigPath := filepath.Join(config.PathMntEtcSysctlD, config.FileSysctlZramParams)
231+ switch err := os.WriteFile(sysctlConfigPath, []byte(config.ZramSysctlContent), 0644); {
232+ case err != nil:
233+ return fmt.Errorf("failed to write sysctl zram config: %w", err)
234+ }
235+
236+ p.SendOutput(progressChan, "[OK] zram swap configured")
237+
238+ return nil
239+}
240+
241+// PostCheck validates configuration
242+func (p *ConfigPhase) PostCheck() error {
243+ // Verify hostname was set
244+ switch _, err := os.Stat(config.PathMntEtcHostname); {
245+ case os.IsNotExist(err):
246+ return fmt.Errorf("hostname file was not created")
247+ }
248+
249+ // Verify locale was set
250+ switch _, err := os.Stat(config.PathMntEtcLocaleConf); {
251+ case os.IsNotExist(err):
252+ return fmt.Errorf("locale.conf was not created")
253+ }
254+
255+ // Verify zram config exists
256+ zramConfigPath := filepath.Join(config.PathMntEtcSystemd, config.FileZramGenerator)
257+ switch _, err := os.Stat(zramConfigPath); {
258+ case os.IsNotExist(err):
259+ return fmt.Errorf("zram-generator.conf was not created")
260+ }
261+
262+ return p.config.Save()
263+}
264+
265+// Rollback for config phase (limited - no-op)
266+func (p *ConfigPhase) Rollback() error {
267+ // Configuration changes are in chroot, cleaned up by partitioning rollback
268+ return nil
269+}
270+
271+// CanSkip returns false - config cannot be skipped
272+func (p *ConfigPhase) CanSkip() bool {
273+ return false
274+}
A internal/system/chroot.go +79 -0
@@ -0,0 +1,79 @@
1+package system
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// ChrootExec executes a command inside a chroot environment
9+func ChrootExec(logPath, mountPoint, command string, args ...string) error {
10+ // Construct the command to run in chroot
11+ fullCommand := command
12+ if len(args) > 0 {
13+ fullCommand = command + " " + strings.Join(args, " ")
14+ }
15+
16+ result := RunLogged(logPath, "arch-chroot", mountPoint, "bash", "-c", fullCommand)
17+ if result.Error != nil {
18+ return fmt.Errorf("failed to execute in chroot: %w", result.Error)
19+ }
20+ return nil
21+}
22+
23+// ChrootPacman runs pacman inside the chroot
24+func ChrootPacman(logPath, mountPoint, operation string, packages ...string) error {
25+ args := append([]string{operation, "--noconfirm"}, packages...)
26+ command := "pacman " + strings.Join(args, " ")
27+
28+ result := RunLogged(logPath, "arch-chroot", mountPoint, "bash", "-c", command)
29+ if result.Error != nil {
30+ return fmt.Errorf("failed to run pacman in chroot: %w", result.Error)
31+ }
32+ return nil
33+}
34+
35+// ChrootSystemctl runs systemctl inside the chroot
36+func ChrootSystemctl(logPath, mountPoint, action, service string) error {
37+ command := fmt.Sprintf("systemctl %s %s", action, service)
38+
39+ result := RunLogged(logPath, "arch-chroot", mountPoint, "bash", "-c", command)
40+ if result.Error != nil {
41+ return fmt.Errorf("failed to run systemctl in chroot: %w", result.Error)
42+ }
43+ return nil
44+}
45+
46+// Mount mounts a filesystem
47+func Mount(logPath, device, mountPoint string, options ...string) error {
48+ args := []string{device, mountPoint}
49+ if len(options) > 0 {
50+ args = append([]string{"-o", strings.Join(options, ",")}, args...)
51+ }
52+
53+ result := RunLogged(logPath, "mount", args...)
54+ if result.Error != nil {
55+ return fmt.Errorf("failed to mount %s: %w", device, result.Error)
56+ }
57+ return nil
58+}
59+
60+// Unmount unmounts a filesystem
61+func Unmount(logPath, mountPoint string) error {
62+ result := RunLogged(logPath, "umount", "-R", mountPoint)
63+ if result.Error != nil {
64+ return fmt.Errorf("failed to unmount %s: %w", mountPoint, result.Error)
65+ }
66+ return nil
67+}
68+
69+// Genfstab generates fstab for the installed system
70+func Genfstab(logPath, mountPoint, fstabPath string) error {
71+ result := RunLogged(logPath, "genfstab", "-U", mountPoint)
72+ if result.Error != nil {
73+ return fmt.Errorf("failed to generate fstab: %w", result.Error)
74+ }
75+
76+ // TODO: Write result.Output to fstabPath
77+ // This should be done using file operations
78+ return nil
79+}
A internal/system/cpu.go +184 -0
@@ -0,0 +1,184 @@
1+package system
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+)
8+
9+// CPUVendor represents CPU manufacturer
10+type CPUVendor string
11+
12+const (
13+ CPUVendorIntel CPUVendor = "Intel"
14+ CPUVendorAMD CPUVendor = "AMD"
15+ CPUVendorUnknown CPUVendor = "Unknown"
16+)
17+
18+// AMDPStateMode represents AMD P-State driver mode
19+type AMDPStateMode string
20+
21+const (
22+ AMDPStateModeActive AMDPStateMode = "active"
23+ AMDPStateModeGuided AMDPStateMode = "guided"
24+ AMDPStateModePassive AMDPStateMode = "passive"
25+)
26+
27+// CPUInfo holds detected CPU information
28+type CPUInfo struct {
29+ Vendor CPUVendor
30+ Microcode string
31+ AMDPStateModes []AMDPStateMode
32+}
33+
34+// DetectCPUVendor reads /proc/cpuinfo and returns the CPU vendor
35+func DetectCPUVendor() (CPUVendor, error) {
36+ data, err := os.ReadFile("/proc/cpuinfo")
37+ switch {
38+ case err != nil:
39+ return CPUVendorUnknown, fmt.Errorf("failed to read /proc/cpuinfo: %w", err)
40+ }
41+
42+ content := string(data)
43+ lines := strings.Split(content, "\n")
44+
45+ for _, line := range lines {
46+ switch {
47+ case strings.HasPrefix(line, "vendor_id"):
48+ parts := strings.Split(line, ":")
49+ switch {
50+ case len(parts) < 2:
51+ continue
52+ }
53+
54+ vendor := strings.TrimSpace(parts[1])
55+ switch vendor {
56+ case "GenuineIntel":
57+ return CPUVendorIntel, nil
58+ case "AuthenticAMD":
59+ return CPUVendorAMD, nil
60+ default:
61+ return CPUVendorUnknown, nil
62+ }
63+ }
64+ }
65+
66+ return CPUVendorUnknown, nil
67+}
68+
69+// DetectMicrocode returns the appropriate microcode package based on CPU vendor
70+func DetectMicrocode(vendor CPUVendor) string {
71+ switch vendor {
72+ case CPUVendorIntel:
73+ return "intel-ucode"
74+ case CPUVendorAMD:
75+ return "amd-ucode"
76+ default:
77+ return ""
78+ }
79+}
80+
81+// CheckCPPCSupport checks if CPU supports CPPC (required for AMD P-State active mode)
82+func CheckCPPCSupport() bool {
83+ // Check /proc/cpuinfo for cppc flag
84+ data, err := os.ReadFile("/proc/cpuinfo")
85+ switch {
86+ case err != nil:
87+ return false
88+ }
89+
90+ switch {
91+ case strings.Contains(string(data), "cppc"):
92+ return true
93+ }
94+
95+ // Check if amd_pstate directory exists
96+ _, err = os.Stat("/sys/devices/system/cpu/cpu0/cpufreq/amd_pstate")
97+ switch {
98+ case err == nil:
99+ return true
100+ }
101+
102+ return false
103+}
104+
105+// DetectAMDPStateModes detects available AMD P-State modes
106+func DetectAMDPStateModes() []AMDPStateMode {
107+ var modes []AMDPStateMode
108+
109+ // Try to read available modes from sysfs
110+ data, err := os.ReadFile("/sys/devices/system/cpu/amd_pstate/status")
111+ switch {
112+ case err == nil:
113+ // Parse available modes from status file
114+ content := string(data)
115+ switch {
116+ case strings.Contains(content, "active"):
117+ modes = append(modes, AMDPStateModeActive)
118+ }
119+ switch {
120+ case strings.Contains(content, "guided"):
121+ modes = append(modes, AMDPStateModeGuided)
122+ }
123+ switch {
124+ case strings.Contains(content, "passive"):
125+ modes = append(modes, AMDPStateModePassive)
126+ }
127+
128+ // If we found modes, return them
129+ switch {
130+ case len(modes) > 0:
131+ return modes
132+ }
133+ }
134+
135+ // Fallback: detect based on CPPC support
136+ switch CheckCPPCSupport() {
137+ case true:
138+ // Modern AMD Ryzen CPUs support all three modes
139+ return []AMDPStateMode{
140+ AMDPStateModeActive,
141+ AMDPStateModeGuided,
142+ AMDPStateModePassive,
143+ }
144+ default:
145+ // Older AMD CPUs only support passive mode
146+ return []AMDPStateMode{AMDPStateModePassive}
147+ }
148+}
149+
150+// GetAMDPStateModeDescription returns human-readable description for P-State mode
151+func GetAMDPStateModeDescription(mode AMDPStateMode) string {
152+ switch mode {
153+ case AMDPStateModeActive:
154+ return "active - Best performance (recommended for desktop/gaming)"
155+ case AMDPStateModeGuided:
156+ return "guided - Balanced performance and efficiency (recommended for laptops)"
157+ case AMDPStateModePassive:
158+ return "passive - Maximum compatibility (older CPUs)"
159+ default:
160+ return string(mode)
161+ }
162+}
163+
164+// DetectCPUInfo detects all CPU information in one call (DRY)
165+func DetectCPUInfo() (*CPUInfo, error) {
166+ vendor, err := DetectCPUVendor()
167+ switch {
168+ case err != nil:
169+ return nil, err
170+ }
171+
172+ info := &CPUInfo{
173+ Vendor: vendor,
174+ Microcode: DetectMicrocode(vendor),
175+ }
176+
177+ // Detect AMD P-State modes only for AMD CPUs
178+ switch vendor {
179+ case CPUVendorAMD:
180+ info.AMDPStateModes = DetectAMDPStateModes()
181+ }
182+
183+ return info, nil
184+}
A internal/system/disk.go +138 -0
@@ -0,0 +1,138 @@
1+package system
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+)
8+
9+// Disk represents a storage device
10+type Disk struct {
11+ Name string `json:"name"`
12+ Size string `json:"size"`
13+ Type string `json:"type"`
14+ Model string `json:"model"`
15+ Serial string `json:"serial"`
16+ Vendor string `json:"vendor"`
17+ Path string `json:"path"`
18+}
19+
20+// Partition represents a disk partition
21+type Partition struct {
22+ Name string `json:"name"`
23+ Size string `json:"size"`
24+ Type string `json:"type"`
25+ Mountpoint string `json:"mountpoint"`
26+ UUID string `json:"uuid"`
27+ Label string `json:"label"`
28+}
29+
30+// lsblkOutput represents the JSON output from lsblk
31+type lsblkOutput struct {
32+ BlockDevices []struct {
33+ Name string `json:"name"`
34+ Size string `json:"size"`
35+ Type string `json:"type"`
36+ Model string `json:"model"`
37+ Serial string `json:"serial"`
38+ Vendor string `json:"vendor"`
39+ Mountpoint string `json:"mountpoint"`
40+ UUID string `json:"uuid"`
41+ Label string `json:"label"`
42+ } `json:"blockdevices"`
43+}
44+
45+// ListDisks returns a list of available disks (excluding loop devices)
46+func ListDisks() ([]Disk, error) {
47+ result := RunSimple("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MODEL,SERIAL,VENDOR", "-d", "-e", "7")
48+ if result.Error != nil {
49+ return nil, fmt.Errorf("failed to list disks: %w", result.Error)
50+ }
51+
52+ var output lsblkOutput
53+ if err := json.Unmarshal([]byte(result.Output), &output); err != nil {
54+ return nil, fmt.Errorf("failed to parse lsblk output: %w", err)
55+ }
56+
57+ var disks []Disk
58+ for _, dev := range output.BlockDevices {
59+ if dev.Type == "disk" {
60+ disks = append(disks, Disk{
61+ Name: dev.Name,
62+ Size: dev.Size,
63+ Type: dev.Type,
64+ Model: dev.Model,
65+ Serial: dev.Serial,
66+ Vendor: dev.Vendor,
67+ Path: "/dev/" + dev.Name,
68+ })
69+ }
70+ }
71+
72+ return disks, nil
73+}
74+
75+// ListPartitions returns a list of partitions for a given disk
76+func ListPartitions(diskPath string) ([]Partition, error) {
77+ diskName := strings.TrimPrefix(diskPath, "/dev/")
78+
79+ result := RunSimple("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,UUID,LABEL", diskName)
80+ if result.Error != nil {
81+ return nil, fmt.Errorf("failed to list partitions: %w", result.Error)
82+ }
83+
84+ var output lsblkOutput
85+ if err := json.Unmarshal([]byte(result.Output), &output); err != nil {
86+ return nil, fmt.Errorf("failed to parse lsblk output: %w", err)
87+ }
88+
89+ var partitions []Partition
90+ for _, dev := range output.BlockDevices {
91+ if dev.Type == "part" {
92+ partitions = append(partitions, Partition{
93+ Name: dev.Name,
94+ Size: dev.Size,
95+ Type: dev.Type,
96+ Mountpoint: dev.Mountpoint,
97+ UUID: dev.UUID,
98+ Label: dev.Label,
99+ })
100+ }
101+ }
102+
103+ return partitions, nil
104+}
105+
106+// IsDiskMounted checks if any partition on a disk is mounted
107+func IsDiskMounted(diskPath string) (bool, error) {
108+ partitions, err := ListPartitions(diskPath)
109+ if err != nil {
110+ return false, err
111+ }
112+
113+ for _, part := range partitions {
114+ if part.Mountpoint != "" {
115+ return true, nil
116+ }
117+ }
118+
119+ return false, nil
120+}
121+
122+// WipeDisk wipes the partition table of a disk
123+func WipeDisk(diskPath string) error {
124+ result := RunSimple("wipefs", "-a", diskPath)
125+ if result.Error != nil {
126+ return fmt.Errorf("failed to wipe disk: %w", result.Error)
127+ }
128+ return nil
129+}
130+
131+// CreateGPTPartitionTable creates a new GPT partition table
132+func CreateGPTPartitionTable(diskPath string) error {
133+ result := RunSimple("parted", "-s", diskPath, "mklabel", "gpt")
134+ if result.Error != nil {
135+ return fmt.Errorf("failed to create GPT partition table: %w", result.Error)
136+ }
137+ return nil
138+}
A internal/system/exec.go +193 -0
@@ -0,0 +1,193 @@
1+package system
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "io"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "sync"
11+)
12+
13+// CommandResult holds the result of a command execution
14+type CommandResult struct {
15+ ExitCode int
16+ Output string
17+ Error error
18+}
19+
20+// RunConfig configures how a command should be executed
21+type RunConfig struct {
22+ Command string
23+ Args []string
24+ StreamToLog bool
25+ LogPath string
26+ WorkingDir string
27+ Env []string
28+}
29+
30+// Run executes a command with the given configuration
31+func Run(cfg RunConfig) CommandResult {
32+ cmd := exec.Command(cfg.Command, cfg.Args...)
33+
34+ if cfg.WorkingDir != "" {
35+ cmd.Dir = cfg.WorkingDir
36+ }
37+
38+ if len(cfg.Env) > 0 {
39+ cmd.Env = append(os.Environ(), cfg.Env...)
40+ }
41+
42+ // Create pipes for stdout and stderr
43+ stdout, err := cmd.StdoutPipe()
44+ if err != nil {
45+ return CommandResult{
46+ ExitCode: -1,
47+ Error: fmt.Errorf("failed to create stdout pipe: %w", err),
48+ }
49+ }
50+
51+ stderr, err := cmd.StderrPipe()
52+ if err != nil {
53+ return CommandResult{
54+ ExitCode: -1,
55+ Error: fmt.Errorf("failed to create stderr pipe: %w", err),
56+ }
57+ }
58+
59+ // Start the command
60+ if err := cmd.Start(); err != nil {
61+ return CommandResult{
62+ ExitCode: -1,
63+ Error: fmt.Errorf("failed to start command: %w", err),
64+ }
65+ }
66+
67+ var wg sync.WaitGroup
68+ var output strings.Builder
69+ var logFile *os.File
70+
71+ // Open log file if streaming is enabled
72+ if cfg.StreamToLog && cfg.LogPath != "" {
73+ logFile, err = os.OpenFile(cfg.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
74+ if err != nil {
75+ // Continue without logging if we can't open the file
76+ fmt.Fprintf(os.Stderr, "Warning: failed to open log file: %v\n", err)
77+ } else {
78+ defer logFile.Close()
79+ }
80+ }
81+
82+ // Process stdout
83+ wg.Add(1)
84+ go func() {
85+ defer wg.Done()
86+ scanner := bufio.NewScanner(stdout)
87+ for scanner.Scan() {
88+ line := scanner.Text()
89+ output.WriteString(line + "\n")
90+ if logFile != nil {
91+ fmt.Fprintln(logFile, line)
92+ }
93+ }
94+ }()
95+
96+ // Process stderr
97+ wg.Add(1)
98+ go func() {
99+ defer wg.Done()
100+ scanner := bufio.NewScanner(stderr)
101+ for scanner.Scan() {
102+ line := scanner.Text()
103+ output.WriteString(line + "\n")
104+ if logFile != nil {
105+ fmt.Fprintln(logFile, line)
106+ }
107+ }
108+ }()
109+
110+ // Wait for output processing to complete
111+ wg.Wait()
112+
113+ // Wait for command to finish
114+ err = cmd.Wait()
115+
116+ exitCode := 0
117+ if err != nil {
118+ if exitError, ok := err.(*exec.ExitError); ok {
119+ exitCode = exitError.ExitCode()
120+ } else {
121+ exitCode = -1
122+ }
123+ }
124+
125+ return CommandResult{
126+ ExitCode: exitCode,
127+ Output: output.String(),
128+ Error: err,
129+ }
130+}
131+
132+// RunSimple is a convenience function for simple command execution
133+func RunSimple(command string, args ...string) CommandResult {
134+ return Run(RunConfig{
135+ Command: command,
136+ Args: args,
137+ })
138+}
139+
140+// RunLogged runs a command and streams output to the log file
141+func RunLogged(logPath, command string, args ...string) CommandResult {
142+ return Run(RunConfig{
143+ Command: command,
144+ Args: args,
145+ StreamToLog: true,
146+ LogPath: logPath,
147+ })
148+}
149+
150+// RunWithOutput runs a command and captures output in real-time via a callback
151+func RunWithOutput(command string, args []string, callback func(line string)) error {
152+ cmd := exec.Command(command, args...)
153+
154+ stdout, err := cmd.StdoutPipe()
155+ if err != nil {
156+ return fmt.Errorf("failed to create stdout pipe: %w", err)
157+ }
158+
159+ stderr, err := cmd.StderrPipe()
160+ if err != nil {
161+ return fmt.Errorf("failed to create stderr pipe: %w", err)
162+ }
163+
164+ if err := cmd.Start(); err != nil {
165+ return fmt.Errorf("failed to start command: %w", err)
166+ }
167+
168+ var wg sync.WaitGroup
169+
170+ // Process stdout
171+ wg.Add(1)
172+ go func() {
173+ defer wg.Done()
174+ processOutput(stdout, callback)
175+ }()
176+
177+ // Process stderr
178+ wg.Add(1)
179+ go func() {
180+ defer wg.Done()
181+ processOutput(stderr, callback)
182+ }()
183+
184+ wg.Wait()
185+ return cmd.Wait()
186+}
187+
188+func processOutput(reader io.Reader, callback func(string)) {
189+ scanner := bufio.NewScanner(reader)
190+ for scanner.Scan() {
191+ callback(scanner.Text())
192+ }
193+}
A internal/system/pacman.go +44 -0
@@ -0,0 +1,44 @@
1+package system
2+
3+import (
4+ "fmt"
5+)
6+
7+// PacmanSync syncs the package database
8+func PacmanSync(logPath string) error {
9+ result := RunLogged(logPath, "pacman", "-Sy", "--noconfirm")
10+ if result.Error != nil {
11+ return fmt.Errorf("failed to sync pacman database: %w", result.Error)
12+ }
13+ return nil
14+}
15+
16+// PacmanInstall installs packages
17+func PacmanInstall(logPath string, packages ...string) error {
18+ args := append([]string{"-S", "--needed", "--noconfirm"}, packages...)
19+ result := RunLogged(logPath, "pacman", args...)
20+ if result.Error != nil {
21+ return fmt.Errorf("failed to install packages: %w", result.Error)
22+ }
23+ return nil
24+}
25+
26+// Pacstrap installs the base system to a mount point
27+func Pacstrap(logPath, mountPoint string, packages ...string) error {
28+ args := append([]string{mountPoint}, packages...)
29+ result := RunLogged(logPath, "pacstrap", args...)
30+ if result.Error != nil {
31+ return fmt.Errorf("failed to run pacstrap: %w", result.Error)
32+ }
33+ return nil
34+}
35+
36+// PacmanRemove removes packages
37+func PacmanRemove(logPath string, packages ...string) error {
38+ args := append([]string{"-R", "--noconfirm"}, packages...)
39+ result := RunLogged(logPath, "pacman", args...)
40+ if result.Error != nil {
41+ return fmt.Errorf("failed to remove packages: %w", result.Error)
42+ }
43+ return nil
44+}
A internal/system/timezone.go +62 -0
@@ -0,0 +1,62 @@
1+package system
2+
3+import (
4+ "io"
5+ "net/http"
6+ "strings"
7+ "time"
8+)
9+
10+const (
11+ timezoneAPIURL = "https://ipapi.co/timezone/"
12+ timezoneAPITimeout = 5 * time.Second
13+)
14+
15+// DetectTimezone fetches timezone from ipapi.co API
16+// Returns detected timezone string or empty string on failure
17+func DetectTimezone() string {
18+ client := &http.Client{
19+ Timeout: timezoneAPITimeout,
20+ }
21+
22+ resp, err := client.Get(timezoneAPIURL)
23+ switch {
24+ case err != nil:
25+ return ""
26+ }
27+ defer resp.Body.Close()
28+
29+ switch resp.StatusCode {
30+ case http.StatusOK:
31+ body, err := io.ReadAll(resp.Body)
32+ switch {
33+ case err != nil:
34+ return ""
35+ }
36+
37+ timezone := strings.TrimSpace(string(body))
38+
39+ // Validate timezone format (should be like "America/New_York")
40+ switch {
41+ case strings.Contains(timezone, "/"):
42+ return timezone
43+ default:
44+ return ""
45+ }
46+ default:
47+ return ""
48+ }
49+}
50+
51+// ValidateTimezone checks if a timezone string is valid
52+// This is a basic check - full validation would require checking against zoneinfo
53+func ValidateTimezone(tz string) bool {
54+ switch {
55+ case tz == "":
56+ return false
57+ case !strings.Contains(tz, "/"):
58+ return false
59+ default:
60+ return true
61+ }
62+}
A internal/ui/components/forms.go +141 -0
@@ -0,0 +1,141 @@
1+package components
2+
3+import "github.com/charmbracelet/huh"
4+
5+// FormBuilder provides reusable form components
6+type FormBuilder struct {
7+ accessible bool
8+}
9+
10+// NewFormBuilder creates a new form builder
11+func NewFormBuilder(accessible bool) *FormBuilder {
12+ return &FormBuilder{
13+ accessible: accessible,
14+ }
15+}
16+
17+// TextInput creates a text input field
18+func (fb *FormBuilder) TextInput(title, prompt string, value *string, validator func(string) error) *huh.Input {
19+ input := huh.NewInput().
20+ Title(title).
21+ Prompt(prompt).
22+ Value(value)
23+
24+ if validator != nil {
25+ input.Validate(validator)
26+ }
27+
28+ return input
29+}
30+
31+// Select creates a single-selection field
32+func (fb *FormBuilder) Select(title string, options []string, value *string) *huh.Select[string] {
33+ opts := make([]huh.Option[string], len(options))
34+ for i, opt := range options {
35+ opts[i] = huh.NewOption(opt, opt)
36+ }
37+
38+ return huh.NewSelect[string]().
39+ Title(title).
40+ Options(opts...).
41+ Value(value)
42+}
43+
44+// MultiSelect creates a multi-selection field
45+func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]string, limit int) *huh.MultiSelect[string] {
46+ opts := make([]huh.Option[string], len(options))
47+ for i, opt := range options {
48+ opts[i] = huh.NewOption(opt, opt)
49+ }
50+
51+ ms := huh.NewMultiSelect[string]().
52+ Title(title).
53+ Options(opts...).
54+ Value(value)
55+
56+ if limit > 0 {
57+ ms.Limit(limit)
58+ }
59+
60+ return ms
61+}
62+
63+// Confirm creates a yes/no confirmation
64+func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {
65+ return huh.NewConfirm().
66+ Title(title).
67+ Affirmative(affirmative).
68+ Negative(negative).
69+ Value(value)
70+}
71+
72+// CreateForm creates a form with accessibility settings
73+func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {
74+ form := huh.NewForm(groups...)
75+ if fb.accessible {
76+ form.WithAccessible(true)
77+ }
78+ return form
79+}
80+
81+// PasswordInput creates a password input field
82+func (fb *FormBuilder) PasswordInput(title, prompt string, value *string, validator func(string) error) *huh.Input {
83+ input := fb.TextInput(title, prompt, value, validator)
84+ input.EchoMode(huh.EchoModePassword)
85+ return input
86+}
87+
88+// SelectWithOptions creates a select field with option objects (for labels different from values)
89+func (fb *FormBuilder) SelectWithOptions(title string, options []huh.Option[string], value *string) *huh.Select[string] {
90+ return huh.NewSelect[string]().
91+ Title(title).
92+ Options(options...).
93+ Value(value)
94+}
95+
96+// CreateOption creates a labeled option for Select fields (DRY helper)
97+func CreateOption(label, description, value string) huh.Option[string] {
98+ switch {
99+ case description != "":
100+ return huh.NewOption(label+" - "+description, value)
101+ default:
102+ return huh.NewOption(label, value)
103+ }
104+}
105+
106+// CreateKernelOptions creates kernel selection options with descriptions
107+func CreateKernelOptions() []huh.Option[string] {
108+ return []huh.Option[string]{
109+ CreateOption("linux", "Stable mainline kernel (recommended)", "linux"),
110+ CreateOption("linux-lts", "Long-term support (maximum stability)", "linux-lts"),
111+ CreateOption("linux-zen", "Performance-optimized for general use", "linux-zen"),
112+ CreateOption("linux-hardened", "Security-focused kernel", "linux-hardened"),
113+ CreateOption("linux-cachyos", "Gaming-optimized", "linux-cachyos"),
114+ }
115+}
116+
117+// CreateAMDPStateOptions creates AMD P-State mode options with descriptions
118+func CreateAMDPStateOptions(modes []string) []huh.Option[string] {
119+ var options []huh.Option[string]
120+
121+ for _, mode := range modes {
122+ var description string
123+ switch mode {
124+ case "active":
125+ description = "Best performance (recommended for desktop/gaming)"
126+ case "guided":
127+ description = "Balanced performance and efficiency (recommended for laptops)"
128+ case "passive":
129+ description = "Maximum compatibility (older CPUs)"
130+ }
131+
132+ switch {
133+ case description != "":
134+ options = append(options, huh.NewOption(mode+" - "+description, mode))
135+ default:
136+ options = append(options, huh.NewOption(mode, mode))
137+ }
138+ }
139+
140+ return options
141+}
A internal/ui/components/output.go +67 -0
@@ -0,0 +1,67 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/bubbles/viewport"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/bnema/archup/internal/ui/styles"
9+)
10+
11+// OutputViewer displays scrolling command output (last N lines)
12+type OutputViewer struct {
13+ viewport viewport.Model
14+ lines []string
15+ maxLines int
16+}
17+
18+// NewOutputViewer creates a new output viewer
19+func NewOutputViewer(width, height, maxLines int) *OutputViewer {
20+ vp := viewport.New(width, height)
21+ vp.Style = styles.LogStyle
22+
23+ return &OutputViewer{
24+ viewport: vp,
25+ lines: make([]string, 0, maxLines),
26+ maxLines: maxLines,
27+ }
28+}
29+
30+// AddLine adds a new line of output (keeps only last N lines)
31+func (ov *OutputViewer) AddLine(line string) {
32+ ov.lines = append(ov.lines, line)
33+
34+ // Keep only last maxLines
35+ if len(ov.lines) > ov.maxLines {
36+ ov.lines = ov.lines[len(ov.lines)-ov.maxLines:]
37+ }
38+
39+ // Update viewport content
40+ ov.viewport.SetContent(strings.Join(ov.lines, "\n"))
41+ // Auto-scroll to bottom
42+ ov.viewport.GotoBottom()
43+}
44+
45+// Clear clears all output
46+func (ov *OutputViewer) Clear() {
47+ ov.lines = make([]string, 0, ov.maxLines)
48+ ov.viewport.SetContent("")
49+}
50+
51+// Update handles viewport updates
52+func (ov *OutputViewer) Update(msg tea.Msg) tea.Cmd {
53+ var cmd tea.Cmd
54+ ov.viewport, cmd = ov.viewport.Update(msg)
55+ return cmd
56+}
57+
58+// View renders the viewport
59+func (ov *OutputViewer) View() string {
60+ return ov.viewport.View()
61+}
62+
63+// SetSize updates the viewport dimensions
64+func (ov *OutputViewer) SetSize(width, height int) {
65+ ov.viewport.Width = width
66+ ov.viewport.Height = height
67+}
A internal/ui/installer_forms.go +220 -0
@@ -0,0 +1,220 @@
1+package ui
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/archup/internal/config"
7+ "github.com/bnema/archup/internal/system"
8+ "github.com/bnema/archup/internal/ui/components"
9+ "github.com/bnema/archup/internal/validation"
10+ "github.com/charmbracelet/huh"
11+)
12+
13+// CreatePreflightForm creates the initial configuration form with all user identity fields
14+func CreatePreflightForm(cfg *config.Config) *huh.Form {
15+ fb := components.NewFormBuilder(false)
16+
17+ // Auto-detect timezone from API
18+ detectedTimezone := system.DetectTimezone()
19+ switch {
20+ case detectedTimezone != "":
21+ cfg.Timezone = detectedTimezone
22+ case cfg.Timezone == "":
23+ cfg.Timezone = config.TimezoneDefault
24+ }
25+
26+ return fb.CreateForm(
27+ // System configuration
28+ huh.NewGroup(
29+ fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname),
30+ fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername),
31+ ).Title(SectionUserIdentity).Description(SectionUserIdentity),
32+
33+ // Passwords
34+ huh.NewGroup(
35+ fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword),
36+ fb.PasswordInput("Root Password", "> ", &cfg.RootPassword, validation.ValidatePassword),
37+ ),
38+
39+ // Email (optional)
40+ huh.NewGroup(
41+ fb.TextInput("Email (optional)", "> ", &cfg.Email, nil).
42+ Description(DescEmail),
43+ ),
44+
45+ // Localization
46+ huh.NewGroup(
47+ fb.TextInput("Timezone", "> ", &cfg.Timezone, validation.ValidateTimezone).
48+ Description(DescTimezone),
49+ fb.TextInput("Locale", "> ", &cfg.Locale, validation.ValidateLocale).
50+ Description(DescLocale),
51+ fb.TextInput("Keymap", "> ", &cfg.Keymap, nil).
52+ Description(DescKeymap),
53+ ),
54+ )
55+}
56+
57+// CreateDiskSelectionForm creates disk selection form with detailed disk information
58+func CreateDiskSelectionForm(cfg *config.Config) *huh.Form {
59+ fb := components.NewFormBuilder(false)
60+
61+ // Detect available disks
62+ disks, err := system.ListDisks()
63+
64+ var diskOptions []string
65+ switch {
66+ case err != nil:
67+ // Fallback to empty list if detection fails
68+ diskOptions = []string{"No disks detected"}
69+ default:
70+ // Format disk options with detailed info: "/dev/sda (500GB) Samsung SSD [S3Z9NB0K123456]"
71+ for _, disk := range disks {
72+ label := disk.Path + " (" + disk.Size + ")"
73+
74+ // Add model if available
75+ switch {
76+ case disk.Model != "":
77+ label += " " + disk.Model
78+ }
79+
80+ // Add serial if available
81+ switch {
82+ case disk.Serial != "":
83+ label += " [" + disk.Serial + "]"
84+ }
85+
86+ // Add vendor if available
87+ switch {
88+ case disk.Vendor != "" && disk.Model == "":
89+ label += " " + disk.Vendor
90+ }
91+
92+ diskOptions = append(diskOptions, label)
93+ }
94+ }
95+
96+ return fb.CreateForm(
97+ huh.NewGroup(
98+ fb.Select(SectionDiskSelection, diskOptions, &cfg.TargetDisk).
99+ Description(WarnDiskErase),
100+ ),
101+ )
102+}
103+
104+// CreateOptionsForm creates installation options form with CPU detection and descriptions
105+func CreateOptionsForm(cfg *config.Config) *huh.Form {
106+ fb := components.NewFormBuilder(false)
107+
108+ // Auto-detect CPU information
109+ cpuInfo, err := system.DetectCPUInfo()
110+ switch {
111+ case err == nil:
112+ cfg.CPUVendor = string(cpuInfo.Vendor)
113+ cfg.Microcode = cpuInfo.Microcode
114+ }
115+
116+ // Kernel selection (always shown)
117+ kernelGroup := huh.NewGroup(
118+ fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice),
119+ ).Title(SectionKernel)
120+
121+ // Encryption options
122+ encryptionOptions := []huh.Option[string]{
123+ huh.NewOption("No encryption", config.EncryptionNone),
124+ huh.NewOption("LUKS encryption", config.EncryptionLUKS),
125+ }
126+
127+ encryptionGroup := huh.NewGroup(
128+ fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType),
129+ ).Title(SectionDiskEncryption).Description(DescEncryption)
130+
131+ // Repository options
132+ repoGroup := huh.NewGroup(
133+ fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib).
134+ Description(DescMultilib),
135+ fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic).
136+ Description(DescChaoticAUR),
137+ )
138+
139+ // AUR helper options
140+ aurOptions := []huh.Option[string]{
141+ huh.NewOption("None", ""),
142+ components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu),
143+ components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay),
144+ }
145+
146+ aurGroup := huh.NewGroup(
147+ fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper).
148+ Description(DescAURHelper),
149+ )
150+
151+ // Build form groups list
152+ groups := []*huh.Group{
153+ kernelGroup,
154+ encryptionGroup,
155+ repoGroup,
156+ aurGroup,
157+ }
158+
159+ // Add AMD P-State tuning group (conditional - only for AMD CPUs)
160+ switch {
161+ case cpuInfo != nil && cpuInfo.Vendor == system.CPUVendorAMD:
162+ switch {
163+ case len(cpuInfo.AMDPStateModes) == 1:
164+ // Auto-select if only one mode available
165+ cfg.AMDPState = string(cpuInfo.AMDPStateModes[0])
166+ case len(cpuInfo.AMDPStateModes) > 1:
167+ // Show selection if multiple modes available
168+ modeStrings := make([]string, len(cpuInfo.AMDPStateModes))
169+ for i, mode := range cpuInfo.AMDPStateModes {
170+ modeStrings[i] = string(mode)
171+ }
172+
173+ amdGroup := huh.NewGroup(
174+ fb.SelectWithOptions("AMD P-State Mode",
175+ components.CreateAMDPStateOptions(modeStrings),
176+ &cfg.AMDPState),
177+ ).Title(SectionAMDTuning).Description(DescAMDPState)
178+
179+ // Insert AMD group after kernel group
180+ groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...)
181+ }
182+ }
183+
184+ return fb.CreateForm(groups...)
185+}
186+
187+// CreateEncryptionPasswordForm creates encryption password form
188+// User can leave empty to use same password as user account
189+func CreateEncryptionPasswordForm(cfg *config.Config) *huh.Form {
190+ fb := components.NewFormBuilder(false)
191+
192+ return fb.CreateForm(
193+ huh.NewGroup(
194+ fb.PasswordInput("Encryption Password", "> ", &cfg.EncryptPassword, nil).
195+ Description(DescEncryptionPassword),
196+ ).Title(SectionDiskEncryption).Description(DescEncryption),
197+ )
198+}
199+
200+// FormatDiskOption formats a disk into a user-friendly option string (DRY helper)
201+func FormatDiskOption(disk system.Disk) string {
202+ label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size)
203+
204+ switch {
205+ case disk.Model != "":
206+ label += " " + disk.Model
207+ }
208+
209+ switch {
210+ case disk.Serial != "":
211+ label += " [" + disk.Serial + "]"
212+ }
213+
214+ switch {
215+ case disk.Vendor != "" && disk.Model == "":
216+ label += " " + disk.Vendor
217+ }
218+
219+ return label
220+}
A internal/ui/logo.go +147 -0
@@ -0,0 +1,147 @@
1+package ui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/bnema/archup/internal/ui/styles"
9+)
10+
11+const (
12+ // Logo ASCII art (from logo.txt)
13+ // Split at column 34 for two-tone color scheme (cyan/white)
14+ logoASCII = ` ░█████╗░██████╗░░█████╗░██╗░░██╗██╗░░░██╗██████╗░
15+ ██╔══██╗██╔══██╗██╔══██╗██║░░██║██║░░░██║██╔══██╗
16+ ███████║██████╔╝██║░░╚═╝███████║██║░░░██║██████╔╝
17+ ██╔══██║██╔══██╗██║░░██╗██╔══██║██║░░░██║██╔═══╝░
18+ ██║░░██║██║░░██║╚█████╔╝██║░░██║╚██████╔╝██║░░░░░
19+ ╚═╝░░╚═╝╚═╝░░╚═╝░╚════╝░╚═╝░░╚═╝░╚═════╝░╚═╝░░░░░`
20+
21+ splitPos = 34 // Column where color split occurs
22+)
23+
24+// Logo represents the ArchUp logo display
25+type Logo struct {
26+ width int
27+ height int
28+ version string
29+}
30+
31+// NewLogo creates a new Logo instance
32+// version should be injected via ldflags: -ldflags "-X main.version=v1.0.0"
33+func NewLogo(version string) *Logo {
34+ lines := strings.Split(logoASCII, "\n")
35+ width := 0
36+ for _, line := range lines {
37+ if len(line) > width {
38+ width = len(line)
39+ }
40+ }
41+
42+ if version == "" {
43+ version = "dev"
44+ }
45+
46+ return &Logo{
47+ width: width,
48+ height: len(lines),
49+ version: version,
50+ }
51+}
52+
53+// Render renders the logo with two-tone coloring (cyan/white split)
54+func (l *Logo) Render() string {
55+ lines := strings.Split(logoASCII, "\n")
56+ var rendered strings.Builder
57+
58+ for _, line := range lines {
59+ if len(line) == 0 {
60+ rendered.WriteString("\n")
61+ continue
62+ }
63+
64+ // Split line at splitPos for two-tone effect
65+ var left, right string
66+ if len(line) > splitPos {
67+ left = line[:splitPos]
68+ right = line[splitPos:]
69+ } else {
70+ left = line
71+ right = ""
72+ }
73+
74+ // Render with colors
75+ rendered.WriteString(styles.LogoCyanStyle.Render(left))
76+ if right != "" {
77+ rendered.WriteString(styles.LogoWhiteStyle.Render(right))
78+ }
79+ rendered.WriteString("\n")
80+ }
81+
82+ // Add version below logo
83+ rendered.WriteString("\n")
84+ versionLine := " " + l.version
85+ rendered.WriteString(styles.LogoCyanStyle.Render(versionLine))
86+ rendered.WriteString("\n")
87+
88+ return rendered.String()
89+}
90+
91+// RenderCentered renders the logo centered horizontally within the terminal
92+func (l *Logo) RenderCentered(termWidth int) string {
93+ logo := l.Render()
94+ lines := strings.Split(logo, "\n")
95+
96+ var centered strings.Builder
97+ paddingLeft := (termWidth - l.width) / 2
98+
99+ if paddingLeft < 0 {
100+ paddingLeft = 0
101+ }
102+
103+ padding := strings.Repeat(" ", paddingLeft)
104+
105+ for _, line := range lines {
106+ if strings.TrimSpace(line) != "" {
107+ centered.WriteString(padding)
108+ }
109+ centered.WriteString(line)
110+ if !strings.HasSuffix(line, "\n") {
111+ centered.WriteString("\n")
112+ }
113+ }
114+
115+ return centered.String()
116+}
117+
118+// Width returns the logo width
119+func (l *Logo) Width() int {
120+ return l.width
121+}
122+
123+// Height returns the logo height (including version line)
124+func (l *Logo) Height() int {
125+ return l.height + 2 // +2 for blank line and version
126+}
127+
128+// ClearScreen clears the screen and displays the logo at the top
129+func ClearScreen(termWidth int, version string) string {
130+ logo := NewLogo(version)
131+ return fmt.Sprintf("\033[H\033[2J\n%s", logo.RenderCentered(termWidth))
132+}
133+
134+// RenderWithMessage renders the logo with a message below it
135+func RenderWithMessage(termWidth int, version, message string) string {
136+ logo := NewLogo(version)
137+ logoStr := logo.RenderCentered(termWidth)
138+
139+ // Center the message
140+ paddingLeft := (termWidth - lipgloss.Width(message)) / 2
141+ if paddingLeft < 0 {
142+ paddingLeft = 0
143+ }
144+ padding := strings.Repeat(" ", paddingLeft)
145+
146+ return fmt.Sprintf("%s\n%s%s\n", logoStr, padding, message)
147+}
A internal/ui/model.go +255 -0
@@ -0,0 +1,255 @@
1+package ui
2+
3+import (
4+ "fmt"
5+
6+ tea "github.com/charmbracelet/bubbletea"
7+ "github.com/charmbracelet/huh"
8+ "github.com/bnema/archup/internal/config"
9+ "github.com/bnema/archup/internal/phases"
10+ "github.com/bnema/archup/internal/ui/components"
11+ "github.com/bnema/archup/internal/ui/styles"
12+)
13+
14+// ModelState represents the current UI state
15+type ModelState int
16+
17+const (
18+ StateShowLogo ModelState = iota
19+ StatePreflightForm
20+ StateDiskForm
21+ StateOptionsForm
22+ StateConfirmation
23+ StateExecuting
24+ StateComplete
25+ StateError
26+)
27+
28+// Model is the main Bubbletea application model
29+type Model struct {
30+ state ModelState
31+ orchestrator *phases.Orchestrator
32+ config *config.Config
33+ currentForm *huh.Form
34+ output *components.OutputViewer
35+ version string
36+ width int
37+ height int
38+ err error
39+}
40+
41+// NewModel creates a new installer model
42+func NewModel(orchestrator *phases.Orchestrator, cfg *config.Config, version string) *Model {
43+ return &Model{
44+ state: StateShowLogo,
45+ orchestrator: orchestrator,
46+ config: cfg,
47+ version: version,
48+ width: 80,
49+ height: 24,
50+ output: components.NewOutputViewer(80, 20, 1000),
51+ }
52+}
53+
54+// Init initializes the model
55+func (m *Model) Init() tea.Cmd {
56+ return nil
57+}
58+
59+// Update handles messages
60+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61+ switch msg := msg.(type) {
62+ case tea.WindowSizeMsg:
63+ m.width = msg.Width
64+ m.height = msg.Height
65+ m.output.SetSize(msg.Width, msg.Height-10)
66+
67+ case tea.KeyMsg:
68+ switch msg.String() {
69+ case "ctrl+c":
70+ return m, tea.Quit
71+
72+ case "enter":
73+ switch m.state {
74+ case StateShowLogo:
75+ // Move to preflight form
76+ m.state = StatePreflightForm
77+ m.currentForm = CreatePreflightForm(m.config)
78+ return m, m.currentForm.Init()
79+ }
80+ }
81+
82+ case phaseCompleteMsg:
83+ switch {
84+ case msg.err != nil:
85+ m.err = msg.err
86+ m.state = StateError
87+ return m, tea.Quit
88+ }
89+
90+ // Check if all phases complete
91+ switch {
92+ case m.orchestrator.IsComplete():
93+ m.state = StateComplete
94+ return m, tea.Quit
95+ default:
96+ // Continue with next phase
97+ return m, m.executeNextPhase()
98+ }
99+
100+ case phases.ProgressUpdate:
101+ // Handle progress updates from phases
102+ switch {
103+ case msg.OutputLine != "":
104+ m.output.AddLine(msg.OutputLine)
105+ case msg.Step != "":
106+ m.output.AddLine(fmt.Sprintf("[%s] %s", msg.PhaseName, msg.Step))
107+ case msg.IsError:
108+ m.output.AddLine(fmt.Sprintf("[ERROR] %s", msg.ErrorMsg))
109+ case msg.IsComplete:
110+ m.output.AddLine(fmt.Sprintf("[COMPLETE] %s", msg.Step))
111+ }
112+ return m, waitForProgress(m.orchestrator.ProgressChannel())
113+ }
114+
115+ // Update current form if showing one
116+ switch m.state {
117+ case StatePreflightForm, StateDiskForm, StateOptionsForm:
118+ switch {
119+ case m.currentForm != nil:
120+ form, cmd := m.currentForm.Update(msg)
121+ if f, ok := form.(*huh.Form); ok {
122+ m.currentForm = f
123+
124+ // Check if form is completed
125+ switch {
126+ case m.currentForm.State == huh.StateCompleted:
127+ return m, m.handleFormComplete()
128+ }
129+ }
130+ return m, cmd
131+ }
132+ case StateExecuting:
133+ // Update output viewer
134+ return m, m.output.Update(msg)
135+ }
136+
137+ return m, nil
138+}
139+
140+// View renders the UI
141+func (m *Model) View() string {
142+ switch m.state {
143+ case StateShowLogo:
144+ logo := NewLogo(m.version)
145+ return logo.RenderCentered(m.width) + "\n\n" +
146+ centerText(WelcomeTagline, m.width) + "\n\n" +
147+ centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width)
148+
149+ case StatePreflightForm, StateDiskForm, StateOptionsForm:
150+ switch {
151+ case m.currentForm != nil:
152+ return m.currentForm.View()
153+ }
154+ return "Loading form..."
155+
156+ case StateConfirmation:
157+ return m.renderConfirmation()
158+
159+ case StateExecuting:
160+ return styles.TitleStyle.Render("Installing ArchUp...") + "\n\n" + m.output.View()
161+
162+ case StateError:
163+ return styles.RenderError(fmt.Sprintf("Error: %v", m.err))
164+
165+ case StateComplete:
166+ return styles.RenderSuccess("Installation complete! You can now reboot.")
167+
168+ default:
169+ return "Unknown state"
170+ }
171+}
172+
173+// handleFormComplete handles form completion and moves to next state
174+func (m *Model) handleFormComplete() tea.Cmd {
175+ switch m.state {
176+ case StatePreflightForm:
177+ m.state = StateDiskForm
178+ m.currentForm = CreateDiskSelectionForm(m.config)
179+ return m.currentForm.Init()
180+
181+ case StateDiskForm:
182+ m.state = StateOptionsForm
183+ m.currentForm = CreateOptionsForm(m.config)
184+ return m.currentForm.Init()
185+
186+ case StateOptionsForm:
187+ // Check if encryption password needed
188+ switch m.config.EncryptionType {
189+ case config.EncryptionLUKS, config.EncryptionLUKSLVM:
190+ m.currentForm = CreateEncryptionPasswordForm(m.config)
191+ return m.currentForm.Init()
192+ }
193+
194+ // Move to confirmation
195+ m.state = StateConfirmation
196+ return nil
197+
198+ default:
199+ // Encryption password form completed
200+ // If password is empty, use user password
201+ switch {
202+ case m.config.EncryptPassword == "":
203+ m.config.EncryptPassword = m.config.UserPassword
204+ }
205+
206+ // Move to confirmation
207+ m.state = StateConfirmation
208+ return nil
209+ }
210+}
211+
212+// renderConfirmation renders the confirmation screen
213+func (m *Model) renderConfirmation() string {
214+ s := styles.TitleStyle.Render("Confirm Installation") + "\n\n"
215+ s += fmt.Sprintf("Hostname: %s\n", m.config.Hostname)
216+ s += fmt.Sprintf("Username: %s\n", m.config.Username)
217+ s += fmt.Sprintf("Disk: %s\n", m.config.TargetDisk)
218+ s += fmt.Sprintf("Kernel: %s\n", m.config.KernelChoice)
219+ s += fmt.Sprintf("Encryption: %s\n", m.config.EncryptionType)
220+ s += "\n"
221+ s += styles.WarningStyle.Render("WARNING: This will erase all data on "+m.config.TargetDisk) + "\n\n"
222+ s += styles.HelpStyle.Render("Press ENTER to continue, Ctrl+C to cancel")
223+ return s
224+}
225+
226+// executeNextPhase executes the next pending phase
227+func (m *Model) executeNextPhase() tea.Cmd {
228+ return func() tea.Msg {
229+ err := m.orchestrator.ExecuteNext()
230+ return phaseCompleteMsg{err: err}
231+ }
232+}
233+
234+// waitForProgress waits for progress updates
235+func waitForProgress(progressChan <-chan phases.ProgressUpdate) tea.Cmd {
236+ return func() tea.Msg {
237+ return <-progressChan
238+ }
239+}
240+
241+// phaseCompleteMsg signals phase execution completion
242+type phaseCompleteMsg struct {
243+ err error
244+}
245+
246+// centerText centers text within a given width
247+func centerText(text string, width int) string {
248+ textLen := len(text)
249+ switch {
250+ case textLen >= width:
251+ return text
252+ }
253+ padding := (width - textLen) / 2
254+ return fmt.Sprintf("%*s", padding+textLen, text)
255+}
A internal/ui/sections.go +43 -0
@@ -0,0 +1,43 @@
1+package ui
2+
3+// Form section descriptions (DRY: centralized text)
4+const (
5+ // Welcome
6+ WelcomeTagline = "A minimal, slightly opinionated Arch Linux installer."
7+
8+ // Preflight sections
9+ SectionUserIdentity = "Configure your user account and system details"
10+ DescEmail = "Email is used for SSH key generation and git configuration"
11+ DescTimezone = "e.g. America/New_York, Europe/London, Asia/Tokyo"
12+ DescLocale = "e.g. en_US.UTF-8, en_GB.UTF-8, de_DE.UTF-8"
13+ DescKeymap = "e.g. us, uk, de, fr"
14+
15+ // Disk selection
16+ SectionDiskSelection = "Select installation disk (ALL DATA WILL BE ERASED)"
17+ WarnDiskErase = "WARNING: This will delete all data on the selected disk!"
18+
19+ // Encryption
20+ SectionDiskEncryption = "Disk Encryption"
21+ DescEncryption = "LUKS encryption protects your data with Argon2id (2000ms iteration)"
22+ DescEncryptionPassword = "Leave empty to use the same password as your user account"
23+ DescEncryptionSamePass = "Use the same password for disk encryption as your user account?"
24+
25+ // Kernel
26+ SectionKernel = "Kernel Selection"
27+ DescKernelLinux = "Stable mainline kernel (recommended)"
28+ DescKernelLTS = "Long-term support (maximum stability)"
29+ DescKernelZen = "Performance-optimized for general use"
30+ DescKernelHardened = "Security-focused kernel"
31+ DescKernelCachyOS = "Gaming-optimized (requires CachyOS repo)"
32+
33+ // AMD P-State
34+ SectionAMDTuning = "AMD CPU Tuning"
35+ DescAMDPState = "Select AMD P-State driver mode for CPU frequency scaling"
36+
37+ // Repository options
38+ DescMultilib = "Required for 32-bit applications and Wine"
39+ DescChaoticAUR = "Community repository with pre-built AUR packages"
40+ DescAURHelper = "Tool for installing packages from Arch User Repository"
41+ DescAURHelperParu = "Faster with more features (recommended)"
42+ DescAURHelperYay = "Classic, widely compatible"
43+)
A internal/ui/styles/theme.go +259 -0
@@ -0,0 +1,259 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/lipgloss"
5+)
6+
7+// Bleu Theme - Based on the bleu-theme color palette
8+// https://github.com/bnema/bleu-theme
9+// Uses CompleteColor with TrueColor > ANSI256 > ANSI16 fallback
10+// Arch ISO supports 256 colors (TERM=xterm-256color)
11+
12+var (
13+ // Background Colors
14+ ColorDeepNavy = lipgloss.CompleteColor{
15+ TrueColor: "#050a14",
16+ ANSI256: "16", // Pure black
17+ ANSI: "0", // Black
18+ }
19+
20+ ColorDarkBlue = lipgloss.CompleteColor{
21+ TrueColor: "#070c16",
22+ ANSI256: "233", // Very dark grey
23+ ANSI: "0", // Black
24+ }
25+
26+ ColorDarkerBlue = lipgloss.CompleteColor{
27+ TrueColor: "#0a1018",
28+ ANSI256: "234", // Very dark grey
29+ ANSI: "8", // Bright black (dark grey)
30+ }
31+
32+ ColorActiveTabBlue = lipgloss.CompleteColor{
33+ TrueColor: "#0f1520",
34+ ANSI256: "235", // Dark grey
35+ ANSI: "8", // Bright black
36+ }
37+
38+ ColorOceanBlue = lipgloss.CompleteColor{
39+ TrueColor: "#2d4a6b",
40+ ANSI256: "24", // Deep blue
41+ ANSI: "4", // Blue
42+ }
43+
44+ // Text Colors
45+ ColorPrimaryText = lipgloss.CompleteColor{
46+ TrueColor: "#e8f4f8",
47+ ANSI256: "255", // Bright white
48+ ANSI: "7", // White
49+ }
50+
51+ ColorPureWhite = lipgloss.CompleteColor{
52+ TrueColor: "#fefefe",
53+ ANSI256: "231", // Pure white
54+ ANSI: "15", // Bright white
55+ }
56+
57+ ColorDimmedText = lipgloss.CompleteColor{
58+ TrueColor: "#708090",
59+ ANSI256: "102", // Medium grey
60+ ANSI: "8", // Bright black (grey)
61+ }
62+
63+ // Accent Colors
64+ ColorBrightCyan = lipgloss.CompleteColor{
65+ TrueColor: "#00d4ff",
66+ ANSI256: "45", // Bright cyan
67+ ANSI: "14", // Bright cyan
68+ }
69+
70+ ColorPureBlue = lipgloss.CompleteColor{
71+ TrueColor: "#5588cc",
72+ ANSI256: "69", // Steel blue
73+ ANSI: "12", // Bright blue
74+ }
75+
76+ ColorLightSkyBlue = lipgloss.CompleteColor{
77+ TrueColor: "#87ceeb",
78+ ANSI256: "117", // Light sky blue
79+ ANSI: "14", // Bright cyan
80+ }
81+
82+ ColorSkyBlue = lipgloss.CompleteColor{
83+ TrueColor: "#4a7ba7",
84+ ANSI256: "67", // Steel blue
85+ ANSI: "4", // Blue
86+ }
87+
88+ // Status Colors
89+ ColorSuccessGreen = lipgloss.CompleteColor{
90+ TrueColor: "#99FFE4",
91+ ANSI256: "122", // Aquamarine green
92+ ANSI: "10", // Bright green
93+ }
94+
95+ ColorSoftRed = lipgloss.CompleteColor{
96+ TrueColor: "#ff6b8a",
97+ ANSI256: "204", // Light red
98+ ANSI: "9", // Bright red
99+ }
100+
101+ ColorWarmOrange = lipgloss.CompleteColor{
102+ TrueColor: "#ffb347",
103+ ANSI256: "215", // Light orange
104+ ANSI: "11", // Bright yellow
105+ }
106+)
107+
108+// Styles
109+var (
110+ // Base styles
111+ BaseStyle = lipgloss.NewStyle().
112+ Foreground(ColorPrimaryText)
113+
114+ // Title/Header styles
115+ TitleStyle = lipgloss.NewStyle().
116+ Foreground(ColorBrightCyan).
117+ Bold(true).
118+ MarginBottom(1)
119+
120+ SubtitleStyle = lipgloss.NewStyle().
121+ Foreground(ColorPureBlue)
122+
123+ // Interactive elements
124+ SelectedStyle = lipgloss.NewStyle().
125+ Foreground(ColorPureWhite).
126+ Background(ColorOceanBlue).
127+ Bold(true)
128+
129+ UnselectedStyle = lipgloss.NewStyle().
130+ Foreground(ColorDimmedText)
131+
132+ FocusedStyle = lipgloss.NewStyle().
133+ Foreground(ColorBrightCyan).
134+ Border(lipgloss.RoundedBorder()).
135+ BorderForeground(ColorPureBlue)
136+
137+ BlurredStyle = lipgloss.NewStyle().
138+ Foreground(ColorDimmedText).
139+ Border(lipgloss.RoundedBorder()).
140+ BorderForeground(ColorDarkBlue)
141+
142+ // Status styles
143+ SuccessStyle = lipgloss.NewStyle().
144+ Foreground(ColorSuccessGreen).
145+ Bold(true)
146+
147+ ErrorStyle = lipgloss.NewStyle().
148+ Foreground(ColorSoftRed).
149+ Bold(true)
150+
151+ WarningStyle = lipgloss.NewStyle().
152+ Foreground(ColorWarmOrange).
153+ Bold(true)
154+
155+ InfoStyle = lipgloss.NewStyle().
156+ Foreground(ColorLightSkyBlue)
157+
158+ // Logo styles (for ASCII art display)
159+ LogoCyanStyle = lipgloss.NewStyle().
160+ Foreground(ColorBrightCyan)
161+
162+ LogoWhiteStyle = lipgloss.NewStyle().
163+ Foreground(ColorPureWhite)
164+
165+ // Progress/Spinner styles
166+ SpinnerStyle = lipgloss.NewStyle().
167+ Foreground(ColorBrightCyan)
168+
169+ ProgressBarStyle = lipgloss.NewStyle().
170+ Foreground(ColorPureBlue)
171+
172+ // Prompt styles
173+ PromptStyle = lipgloss.NewStyle().
174+ Foreground(ColorBrightCyan).
175+ Bold(true)
176+
177+ InputStyle = lipgloss.NewStyle().
178+ Foreground(ColorPureWhite).
179+ Background(ColorActiveTabBlue).
180+ Padding(0, 1)
181+
182+ PlaceholderStyle = lipgloss.NewStyle().
183+ Foreground(ColorDimmedText)
184+
185+ // Box/Panel styles
186+ PanelStyle = lipgloss.NewStyle().
187+ Background(ColorDarkerBlue).
188+ Border(lipgloss.RoundedBorder()).
189+ BorderForeground(ColorDarkBlue).
190+ Padding(1, 2)
191+
192+ BorderStyle = lipgloss.NewStyle().
193+ BorderStyle(lipgloss.RoundedBorder()).
194+ BorderForeground(ColorDarkBlue)
195+
196+ // Log/Output styles
197+ LogStyle = lipgloss.NewStyle().
198+ Foreground(ColorDimmedText).
199+ Background(ColorDarkerBlue).
200+ Padding(1, 2)
201+
202+ // Help/Footer styles
203+ HelpStyle = lipgloss.NewStyle().
204+ Foreground(ColorDimmedText).
205+ MarginTop(1)
206+
207+ KeyStyle = lipgloss.NewStyle().
208+ Foreground(ColorPureBlue).
209+ Bold(true)
210+
211+ DescStyle = lipgloss.NewStyle().
212+ Foreground(ColorDimmedText)
213+)
214+
215+// Helper functions for common styling patterns
216+
217+// RenderTitle renders a section title
218+func RenderTitle(text string) string {
219+ return TitleStyle.Render(text)
220+}
221+
222+// RenderSuccess renders success message
223+func RenderSuccess(text string) string {
224+ return SuccessStyle.Render("[OK] " + text)
225+}
226+
227+// RenderError renders error message
228+func RenderError(text string) string {
229+ return ErrorStyle.Render("[ERROR] " + text)
230+}
231+
232+// RenderWarning renders warning message
233+func RenderWarning(text string) string {
234+ return WarningStyle.Render("[WARN] " + text)
235+}
236+
237+// RenderInfo renders info message
238+func RenderInfo(text string) string {
239+ return InfoStyle.Render("[INFO] " + text)
240+}
241+
242+// RenderKeyValue renders a key-value pair
243+func RenderKeyValue(key, value string) string {
244+ return KeyStyle.Render(key+": ") + lipgloss.NewStyle().Foreground(ColorPrimaryText).Render(value)
245+}
246+
247+// RenderPanel renders content in a panel
248+func RenderPanel(content string) string {
249+ return PanelStyle.Render(content)
250+}
251+
252+// RenderHelp renders help text with key bindings
253+func RenderHelp(bindings map[string]string) string {
254+ var help string
255+ for key, desc := range bindings {
256+ help += KeyStyle.Render(key) + " " + DescStyle.Render(desc) + " "
257+ }
258+ return HelpStyle.Render(help)
259+}
A internal/validation/validators.go +81 -0
@@ -0,0 +1,81 @@
1+package validation
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+// ValidateNotEmpty validates that a string is not empty
9+func ValidateNotEmpty(field string) func(string) error {
10+ return func(s string) error {
11+ if s == "" {
12+ return fmt.Errorf("%s cannot be empty", field)
13+ }
14+ return nil
15+ }
16+}
17+
18+// ValidateHostname validates a hostname
19+func ValidateHostname(s string) error {
20+ if s == "" {
21+ return errors.New("hostname cannot be empty")
22+ }
23+ if len(s) > 63 {
24+ return errors.New("hostname must be 63 characters or less")
25+ }
26+ // Basic validation
27+ for _, c := range s {
28+ if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
29+ return errors.New("hostname can only contain letters, numbers, and hyphens")
30+ }
31+ }
32+ return nil
33+}
34+
35+// ValidateUsername validates a username
36+func ValidateUsername(s string) error {
37+ if s == "" {
38+ return errors.New("username cannot be empty")
39+ }
40+ if len(s) > 32 {
41+ return errors.New("username must be 32 characters or less")
42+ }
43+ // Must start with lowercase letter
44+ if s[0] < 'a' || s[0] > 'z' {
45+ return errors.New("username must start with a lowercase letter")
46+ }
47+ // Can only contain lowercase letters, numbers, underscore, hyphen
48+ for _, c := range s {
49+ if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
50+ return errors.New("username can only contain lowercase letters, numbers, underscore, and hyphen")
51+ }
52+ }
53+ return nil
54+}
55+
56+// ValidatePassword validates a password (minimum length)
57+func ValidatePassword(s string) error {
58+ if len(s) < 8 {
59+ return errors.New("password must be at least 8 characters")
60+ }
61+ return nil
62+}
63+
64+// ValidateTimezone validates a timezone (basic check)
65+func ValidateTimezone(s string) error {
66+ if s == "" {
67+ return errors.New("timezone cannot be empty")
68+ }
69+ // Basic validation - should contain /
70+ // More thorough validation would check against zoneinfo database
71+ return nil
72+}
73+
74+// ValidateLocale validates a locale (basic check)
75+func ValidateLocale(s string) error {
76+ if s == "" {
77+ return errors.New("locale cannot be empty")
78+ }
79+ // Basic validation - should match pattern like en_US.UTF-8
80+ return nil
81+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close