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 | + | } | |