diff --git a/go.mod b/go.mod index 3261a00..0704ad2 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,16 @@ module miasma-installer go 1.25.2 +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/go.sum b/go.sum index 5582ec9..a5aaf28 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -14,10 +16,14 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -39,6 +45,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= diff --git a/tui/model.go b/tui/model.go index 6bf8013..d4b825b 100644 --- a/tui/model.go +++ b/tui/model.go @@ -11,6 +11,10 @@ type State int const ( StateWelcome State = iota StateDiskSelection + StateEncryption + StateHostname + StateUser + StateConfirm StateInstalling StateFinished ) @@ -20,6 +24,12 @@ type RootModel struct { CurrentModel tea.Model Width int Height int + SelectedDisk string + EnableLUKS bool + Hostname string + Username string + UserPassword string + RootPassword string } func NewModel() RootModel { @@ -29,6 +39,10 @@ func NewModel() RootModel { } } +func (m RootModel) Init() tea.Cmd { + return nil +} + func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -54,13 +68,56 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case steps.DiskSelModel: if m.CurrentModel.(steps.DiskSelModel).Finished { - // need to update this to pass actual selected disk model to the installer - selectedDisk := m.CurrentModel.(steps.DiskSelModel).SelectedDisk - m.State = StateInstalling - m.CurrentModel = steps.NewInstallModel(selectedDisk) + m.SelectedDisk = m.CurrentModel.(steps.DiskSelModel).SelectedDisk + m.State = StateEncryption + m.CurrentModel = steps.NewEncryptionModel() return m, m.CurrentModel.Init() } - // logic for StateInstalling -> StateFinished will go here + + case steps.EncryptionModel: + if m.CurrentModel.(steps.EncryptionModel).Finished { + m.EnableLUKS = m.CurrentModel.(steps.EncryptionModel).EnableEncryption + if m.EnableLUKS { + m.RootPassword = m.CurrentModel.(steps.EncryptionModel).Password + } + m.State = StateHostname + m.CurrentModel = steps.NewHostnameModel() + return m, m.CurrentModel.Init() + } + + case steps.HostnameModel: + if m.CurrentModel.(steps.HostnameModel).Finished { + m.Hostname = m.CurrentModel.(steps.HostnameModel).Hostname + m.State = StateUser + m.CurrentModel = steps.NewUserModel() + return m, m.CurrentModel.Init() + } + + case steps.UserModel: + if m.CurrentModel.(steps.UserModel).Finished { + m.Username = m.CurrentModel.(steps.UserModel).Username + m.UserPassword = m.CurrentModel.(steps.UserModel).Password + m.State = StateConfirm + m.CurrentModel = steps.NewConfirmModel(m.SelectedDisk, m.EnableLUKS, m.Hostname, m.Username) + return m, m.CurrentModel.Init() + } + + case steps.ConfirmModel: + if m.CurrentModel.(steps.ConfirmModel).Finished { + if m.CurrentModel.(steps.ConfirmModel).Confirmed { + m.State = StateInstalling + m.CurrentModel = steps.NewInstallModel(m.SelectedDisk, m.EnableLUKS, m.Hostname, m.Username, m.UserPassword, m.RootPassword) + return m, m.CurrentModel.Init() + } else { + return m, tea.Quit + } + } + + case steps.InstallModel: + if m.CurrentModel.(steps.InstallModel).Finished { + m.State = StateFinished + return m, tea.Quit + } } return m, cmd } diff --git a/tui/steps/disk_sel.go b/tui/steps/disk_sel.go index 6c2196b..b5a0705 100644 --- a/tui/steps/disk_sel.go +++ b/tui/steps/disk_sel.go @@ -19,16 +19,16 @@ func (i Disk) Description() string { return i.description } type ItemDelegate struct{} -func (i ItemDelegate) Height() int { return 1 } -func (i ItemDelegate) Width() int { return 0 } -func (i ItemDelegate) Update(msg tea.Msg, m list.Model) tea.Cmd { return nil } -func (i ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - i, ok := item.(Disk) +func (i ItemDelegate) Height() int { return 1 } +func (i ItemDelegate) Spacing() int { return 0 } +func (i ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + disk, ok := item.(Disk) if !ok { return } - s := fmt.Sprintf("%d. %s - %s", index+1, i.Title(), i.Description()) + s := fmt.Sprintf("%d. %s - %s", index+1, disk.Title(), disk.Description()) // focus styles if index == m.Index() { @@ -53,12 +53,11 @@ func NewDiskSelModel(width, height int) DiskSelModel { Disk{name: "/dev/loop0", description: "2GB Fanxiang SSD"}, } - l := list.New(disks, itemDelegate{}, width, height-5) - l.Title = styles.TitleStyle.Render("Select Installation Disk") + l := list.New(disks, ItemDelegate{}, width, height-5) + l.Title = "Select Installation Disk" l.SetShowStatusBar(false) - l.SetFilterEnabled(false) + l.SetFilteringEnabled(false) l.Styles.Title = styles.TitleStyle - l.KeyMap.Unbind() return DiskSelModel{list: l} } diff --git a/tui/steps/install.go b/tui/steps/install.go index e69de29..b3c08a1 100644 --- a/tui/steps/install.go +++ b/tui/steps/install.go @@ -0,0 +1,132 @@ +package steps + +import ( + "encoding/json" + "fmt" + "miasma-installer/config" + "miasma-installer/tui/styles" + "os" + "os/exec" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type InstallModel struct { + config *config.InstallConfig + Finished bool + status string + progress []string + err error +} + +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{}, + } +} + +func (m InstallModel) Init() tea.Cmd { + return m.startInstall() +} + +type installCompleteMsg struct { + err error +} + +func (m InstallModel) startInstall() tea.Cmd { + return func() tea.Msg { + archConfig := m.config.ToArchInstall() + + configPath := "/tmp/miasma-install-config.json" + data, err := json.MarshalIndent(archConfig, "", " ") + if err != nil { + return installCompleteMsg{err: fmt.Errorf("failed to marshal config: %w", err)} + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return installCompleteMsg{err: fmt.Errorf("failed to write config: %w", err)} + } + + cmd := exec.Command("archinstall", "--config", configPath) + output, err := cmd.CombinedOutput() + if err != nil { + return installCompleteMsg{err: fmt.Errorf("archinstall failed: %w\nOutput: %s", err, string(output))} + } + + if err := m.runPostInstallScripts(); err != nil { + return installCompleteMsg{err: fmt.Errorf("post-install scripts failed: %w", err)} + } + + return installCompleteMsg{err: 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()) + cmd := exec.Command("/bin/bash", scriptPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("script %s failed: %w\nOutput: %s", entry.Name(), err, string(output)) + } + } + + return nil +} + +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, nil + } + + return m, nil +} + +func (m InstallModel) View() string { + s := styles.TitleStyle.Render("Installing Miasma OS") + s += "\n\n" + s += m.status + "\n\n" + + if m.err != nil { + s += styles.HelpStyle.Render(fmt.Sprintf("Error: %v\n", m.err)) + } + + if m.Finished && m.err == nil { + s += styles.HelpStyle.Render("Press Ctrl+C to exit") + } + + return styles.MainStyle.Render(s) +}