514 lines
13 KiB
Go
514 lines
13 KiB
Go
package steps
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"miasma-installer/config"
|
|
"miasma-installer/tui/styles"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
type InstallModel struct {
|
|
config *config.InstallConfig
|
|
Finished bool
|
|
status string
|
|
progress []string
|
|
err error
|
|
currentStep int
|
|
totalSteps int
|
|
}
|
|
|
|
func NewInstallModel(disk string, enableLUKS bool, hostname string, username string, userPassword string, rootPassword string) InstallModel {
|
|
return InstallModel{
|
|
config: &config.InstallConfig{
|
|
Disk: disk,
|
|
EnableLUKS: enableLUKS,
|
|
Hostname: hostname,
|
|
Username: username,
|
|
UserPassword: userPassword,
|
|
RootPassword: rootPassword,
|
|
Timezone: "UTC",
|
|
Locale: "en_US.UTF-8",
|
|
},
|
|
status: "Preparing installation...",
|
|
progress: []string{},
|
|
currentStep: 0,
|
|
totalSteps: 8,
|
|
}
|
|
}
|
|
|
|
func (m InstallModel) Init() tea.Cmd {
|
|
return m.startInstall()
|
|
}
|
|
|
|
type installCompleteMsg struct {
|
|
err error
|
|
}
|
|
|
|
type installProgressMsg struct {
|
|
step int
|
|
message string
|
|
}
|
|
|
|
func (m InstallModel) startInstall() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := m.performInstall(); err != nil {
|
|
return installCompleteMsg{err: err}
|
|
}
|
|
return installCompleteMsg{err: nil}
|
|
}
|
|
}
|
|
|
|
func (m InstallModel) performInstall() error {
|
|
m.updateProgress(1, "Partitioning disk...")
|
|
if err := m.partitionDisk(); err != nil {
|
|
return fmt.Errorf("partitioning failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(2, "Formatting partitions...")
|
|
if err := m.formatPartitions(); err != nil {
|
|
return fmt.Errorf("formatting failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(3, "Mounting filesystems...")
|
|
if err := m.mountFilesystems(); err != nil {
|
|
return fmt.Errorf("mounting failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(4, "Installing base system...")
|
|
if err := m.installBase(); err != nil {
|
|
return fmt.Errorf("base install failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(5, "Configuring system...")
|
|
if err := m.configureSystem(); err != nil {
|
|
return fmt.Errorf("configuration failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(6, "Installing bootloader...")
|
|
if err := m.installBootloader(); err != nil {
|
|
return fmt.Errorf("bootloader install failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(7, "Installing desktop and packages...")
|
|
if err := m.installDesktop(); err != nil {
|
|
return fmt.Errorf("desktop install failed: %w", err)
|
|
}
|
|
|
|
m.updateProgress(8, "Running post-install scripts...")
|
|
if err := m.runPostInstallScripts(); err != nil {
|
|
return fmt.Errorf("post-install scripts failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) updateProgress(step int, message string) {
|
|
m.currentStep = step
|
|
m.status = message
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
func (m InstallModel) partitionDisk() error {
|
|
disk := m.config.Disk
|
|
|
|
wipeDisk := exec.Command("wipefs", "-af", disk)
|
|
if err := wipeDisk.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
sgdisk := exec.Command("sgdisk", "-Z", disk)
|
|
if err := sgdisk.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
createEFI := exec.Command("sgdisk", "-n", "1:0:+512M", "-t", "1:ef00", disk)
|
|
if err := createEFI.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
createRoot := exec.Command("sgdisk", "-n", "2:0:0", "-t", "2:8300", disk)
|
|
if err := createRoot.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
time.Sleep(1 * time.Second)
|
|
exec.Command("partprobe", disk).Run()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) formatPartitions() error {
|
|
disk := m.config.Disk
|
|
efiPart := disk + "1"
|
|
rootPart := disk + "2"
|
|
|
|
if strings.Contains(disk, "nvme") || strings.Contains(disk, "mmcblk") {
|
|
efiPart = disk + "p1"
|
|
rootPart = disk + "p2"
|
|
}
|
|
|
|
mkfsEFI := exec.Command("mkfs.fat", "-F32", efiPart)
|
|
if err := mkfsEFI.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.config.EnableLUKS {
|
|
cryptsetup := exec.Command("cryptsetup", "luksFormat", "--type", "luks2", rootPart)
|
|
cryptsetup.Stdin = strings.NewReader(m.config.RootPassword + "\n")
|
|
if err := cryptsetup.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
cryptOpen := exec.Command("cryptsetup", "open", rootPart, "cryptroot")
|
|
cryptOpen.Stdin = strings.NewReader(m.config.RootPassword + "\n")
|
|
if err := cryptOpen.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
rootPart = "/dev/mapper/cryptroot"
|
|
}
|
|
|
|
mkfsBtrfs := exec.Command("mkfs.btrfs", "-f", rootPart)
|
|
if err := mkfsBtrfs.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) mountFilesystems() error {
|
|
disk := m.config.Disk
|
|
rootPart := disk + "2"
|
|
efiPart := disk + "1"
|
|
|
|
if strings.Contains(disk, "nvme") || strings.Contains(disk, "mmcblk") {
|
|
efiPart = disk + "p1"
|
|
rootPart = disk + "p2"
|
|
}
|
|
|
|
if m.config.EnableLUKS {
|
|
rootPart = "/dev/mapper/cryptroot"
|
|
}
|
|
|
|
os.MkdirAll("/mnt", 0755)
|
|
mount := exec.Command("mount", rootPart, "/mnt")
|
|
if err := mount.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
btrfsOpts := "subvol=@,compress=zstd,noatime"
|
|
for _, subvol := range []string{"@", "@home", "@var", "@snapshots"} {
|
|
create := exec.Command("btrfs", "subvolume", "create", "/mnt/"+subvol)
|
|
create.Run()
|
|
}
|
|
|
|
exec.Command("umount", "/mnt").Run()
|
|
|
|
mount = exec.Command("mount", "-o", btrfsOpts, rootPart, "/mnt")
|
|
if err := mount.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
os.MkdirAll("/mnt/home", 0755)
|
|
os.MkdirAll("/mnt/var", 0755)
|
|
os.MkdirAll("/mnt/.snapshots", 0755)
|
|
os.MkdirAll("/mnt/boot", 0755)
|
|
|
|
mountHome := exec.Command("mount", "-o", "subvol=@home,compress=zstd,noatime", rootPart, "/mnt/home")
|
|
mountHome.Run()
|
|
|
|
mountVar := exec.Command("mount", "-o", "subvol=@var,compress=zstd,noatime", rootPart, "/mnt/var")
|
|
mountVar.Run()
|
|
|
|
mountSnap := exec.Command("mount", "-o", "subvol=@snapshots,compress=zstd,noatime", rootPart, "/mnt/.snapshots")
|
|
mountSnap.Run()
|
|
|
|
mountEFI := exec.Command("mount", efiPart, "/mnt/boot")
|
|
if err := mountEFI.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) installBase() error {
|
|
packages := []string{
|
|
"base",
|
|
"base-devel",
|
|
"linux-hardened",
|
|
"linux-firmware",
|
|
"btrfs-progs",
|
|
"neovim",
|
|
"tmux",
|
|
"git",
|
|
"networkmanager",
|
|
"sudo",
|
|
}
|
|
|
|
pacstrap := exec.Command("pacstrap", append([]string{"/mnt"}, packages...)...)
|
|
pacstrap.Stdout = os.Stdout
|
|
pacstrap.Stderr = os.Stderr
|
|
if err := pacstrap.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) configureSystem() error {
|
|
genfstab := exec.Command("genfstab", "-U", "/mnt")
|
|
fstab, err := genfstab.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("genfstab failed: %w", err)
|
|
}
|
|
os.WriteFile("/mnt/etc/fstab", fstab, 0644)
|
|
|
|
os.WriteFile("/mnt/etc/hostname", []byte(m.config.Hostname+"\n"), 0644)
|
|
|
|
hosts := fmt.Sprintf("127.0.0.1\tlocalhost\n::1\t\tlocalhost\n127.0.1.1\t%s.localdomain\t%s\n",
|
|
m.config.Hostname, m.config.Hostname)
|
|
os.WriteFile("/mnt/etc/hosts", []byte(hosts), 0644)
|
|
|
|
os.WriteFile("/mnt/etc/locale.gen", []byte("en_US.UTF-8 UTF-8\n"), 0644)
|
|
if _, err := chroot([]string{"locale-gen"}); err != nil {
|
|
return fmt.Errorf("locale-gen failed: %w", err)
|
|
}
|
|
|
|
os.WriteFile("/mnt/etc/locale.conf", []byte("LANG=en_US.UTF-8\n"), 0644)
|
|
|
|
chroot([]string{"ln", "-sf", "/usr/share/zoneinfo/UTC", "/etc/localtime"})
|
|
chroot([]string{"hwclock", "--systohc"})
|
|
|
|
useraddCmd := fmt.Sprintf("useradd -m -G wheel -s /bin/bash %s", m.config.Username)
|
|
if _, err := chroot([]string{"sh", "-c", useraddCmd}); err != nil {
|
|
return fmt.Errorf("useradd failed: %w", err)
|
|
}
|
|
|
|
passwdCmd := fmt.Sprintf("echo '%s:%s' | chpasswd", m.config.Username, m.config.UserPassword)
|
|
if _, err := chroot([]string{"sh", "-c", passwdCmd}); err != nil {
|
|
return fmt.Errorf("user password set failed: %w", err)
|
|
}
|
|
|
|
rootPasswd := m.config.RootPassword
|
|
if rootPasswd == "" {
|
|
rootPasswd = m.config.UserPassword
|
|
}
|
|
rootPasswdCmd := fmt.Sprintf("echo 'root:%s' | chpasswd", rootPasswd)
|
|
if _, err := chroot([]string{"sh", "-c", rootPasswdCmd}); err != nil {
|
|
return fmt.Errorf("root password set failed: %w", err)
|
|
}
|
|
|
|
sudoers := "%wheel ALL=(ALL:ALL) ALL\n"
|
|
os.WriteFile("/mnt/etc/sudoers.d/wheel", []byte(sudoers), 0440)
|
|
|
|
if _, err := chroot([]string{"systemctl", "enable", "NetworkManager"}); err != nil {
|
|
return fmt.Errorf("failed to enable NetworkManager: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) installBootloader() error {
|
|
if _, err := chroot([]string{"bootctl", "install"}); err != nil {
|
|
return fmt.Errorf("bootctl install failed: %w", err)
|
|
}
|
|
|
|
loaderConf := `default arch.conf
|
|
timeout 3
|
|
console-mode max
|
|
editor no
|
|
`
|
|
os.WriteFile("/mnt/boot/loader/loader.conf", []byte(loaderConf), 0644)
|
|
|
|
disk := m.config.Disk
|
|
rootPart := disk + "2"
|
|
if strings.Contains(disk, "nvme") || strings.Contains(disk, "mmcblk") {
|
|
rootPart = disk + "p2"
|
|
}
|
|
|
|
var options string
|
|
if m.config.EnableLUKS {
|
|
cryptUUID := ""
|
|
blkid := exec.Command("blkid", "-s", "UUID", "-o", "value", rootPart)
|
|
output, _ := blkid.Output()
|
|
cryptUUID = strings.TrimSpace(string(output))
|
|
options = fmt.Sprintf("cryptdevice=UUID=%s:cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ rw quiet splash", cryptUUID)
|
|
} else {
|
|
blkid := exec.Command("blkid", "-s", "UUID", "-o", "value", rootPart)
|
|
output, _ := blkid.Output()
|
|
rootUUID := strings.TrimSpace(string(output))
|
|
options = fmt.Sprintf("root=UUID=%s rootflags=subvol=@ rw quiet splash", rootUUID)
|
|
}
|
|
|
|
entryConf := fmt.Sprintf(`title Miasma OS
|
|
linux /vmlinuz-linux-hardened
|
|
initrd /initramfs-linux-hardened.img
|
|
options %s
|
|
`, options)
|
|
os.WriteFile("/mnt/boot/loader/entries/arch.conf", []byte(entryConf), 0644)
|
|
|
|
if m.config.EnableLUKS {
|
|
mkinitcpioConf, err := os.ReadFile("/mnt/etc/mkinitcpio.conf")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read mkinitcpio.conf: %w", err)
|
|
}
|
|
|
|
newConf := string(mkinitcpioConf)
|
|
newConf = strings.Replace(newConf,
|
|
"HOOKS=(base udev autodetect modconf kms keyboard keymap consolefont block filesystems fsck)",
|
|
"HOOKS=(base udev autodetect keyboard keymap consolefont modconf kms block encrypt filesystems fsck)",
|
|
1)
|
|
newConf = strings.Replace(newConf,
|
|
"HOOKS=(base udev autodetect modconf block filesystems keyboard fsck)",
|
|
"HOOKS=(base udev autodetect keyboard keymap consolefont modconf block encrypt filesystems fsck)",
|
|
1)
|
|
|
|
if err := os.WriteFile("/mnt/etc/mkinitcpio.conf", []byte(newConf), 0644); err != nil {
|
|
return fmt.Errorf("failed to write mkinitcpio.conf: %w", err)
|
|
}
|
|
|
|
if _, err := chroot([]string{"mkinitcpio", "-P"}); err != nil {
|
|
return fmt.Errorf("mkinitcpio failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) installDesktop() error {
|
|
cosmicPackages := []string{
|
|
"cosmic-session",
|
|
"cosmic-greeter",
|
|
"cosmic-files",
|
|
"cosmic-edit",
|
|
"cosmic-term",
|
|
"cosmic-store",
|
|
"cosmic-settings",
|
|
"xorg-xwayland",
|
|
}
|
|
|
|
for _, pkg := range cosmicPackages {
|
|
if _, err := chroot([]string{"pacman", "-Sy", "--noconfirm", pkg}); err != nil {
|
|
return fmt.Errorf("failed to install %s: %w", pkg, err)
|
|
}
|
|
}
|
|
|
|
if _, err := chroot([]string{"systemctl", "enable", "cosmic-greeter"}); err != nil {
|
|
return fmt.Errorf("failed to enable cosmic-greeter: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m InstallModel) runPostInstallScripts() error {
|
|
scriptsDir := "./scripts"
|
|
entries, err := os.ReadDir(scriptsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") {
|
|
continue
|
|
}
|
|
|
|
scriptPath := filepath.Join(scriptsDir, entry.Name())
|
|
scriptContent, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
chrootScript := "/tmp/" + entry.Name()
|
|
os.WriteFile("/mnt"+chrootScript, scriptContent, 0755)
|
|
|
|
if output, err := chroot([]string{chrootScript}); err != nil {
|
|
return fmt.Errorf("script %s failed: %w\nOutput: %s", entry.Name(), err, output)
|
|
}
|
|
|
|
os.Remove("/mnt" + chrootScript)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func chroot(args []string) (string, error) {
|
|
cmd := exec.Command("arch-chroot", append([]string{"/mnt"}, args...)...)
|
|
output, err := cmd.CombinedOutput()
|
|
return string(output), err
|
|
}
|
|
|
|
func (m InstallModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case installCompleteMsg:
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
m.status = "Installation failed"
|
|
} else {
|
|
m.status = "Installation complete!"
|
|
}
|
|
m.Finished = true
|
|
return m, tea.Quit
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m InstallModel) View() string {
|
|
var content strings.Builder
|
|
|
|
content.WriteString(styles.TitleStyle.Render("⚙️ Installing Miasma OS"))
|
|
content.WriteString("\n\n")
|
|
|
|
progress := fmt.Sprintf("Step %d/%d", m.currentStep, m.totalSteps)
|
|
content.WriteString(styles.ProgressTextStyle.Render(progress))
|
|
content.WriteString("\n")
|
|
|
|
bar := strings.Repeat("█", m.currentStep) + strings.Repeat("░", m.totalSteps-m.currentStep)
|
|
content.WriteString(styles.ProgressBarStyle.Render(bar))
|
|
content.WriteString("\n\n")
|
|
|
|
content.WriteString(styles.SpinnerStyle.Render("● ") + m.status)
|
|
content.WriteString("\n\n")
|
|
|
|
if m.err != nil {
|
|
errMsg := fmt.Sprintf("✗ Error: %v", m.err)
|
|
content.WriteString(styles.ErrorBoxStyle.Render(errMsg))
|
|
content.WriteString("\n\n")
|
|
content.WriteString(styles.HelpStyle.Render("Press Ctrl+C to exit"))
|
|
}
|
|
|
|
if m.Finished && m.err == nil {
|
|
success := "✓ Installation successful!\n\nPress Ctrl+C to exit and reboot."
|
|
content.WriteString(styles.BoxStyle.Render(success))
|
|
content.WriteString("\n\n")
|
|
content.WriteString(styles.RenderHelp("Ctrl+C", "Exit and reboot"))
|
|
}
|
|
|
|
return styles.AppStyle.Render(content.String())
|
|
}
|
|
|
|
func readLines(path string) ([]string, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
var lines []string
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
lines = append(lines, scanner.Text())
|
|
}
|
|
return lines, scanner.Err()
|
|
}
|