diff --git a/cmd/installer/branding/branding.go b/cmd/installer/branding/branding.go
new file mode 100644
index 000000000..6c0353ed4
--- /dev/null
+++ b/cmd/installer/branding/branding.go
@@ -0,0 +1,56 @@
+/*
+Copyright © 2026 Mulga Defense Corporation
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+// Package branding centralises Mulga/Spinifex visual identity constants used
+// by the installer TUI.
+//
+// TODO: Replace placeholder values with official Mulga/Spinifex assets once
+// the brand guide and logo files are located.
+package branding
+
+import "github.com/charmbracelet/lipgloss"
+
+// Logo is the ASCII art rendered on the installer welcome screen.
+// TODO: Replace with official Spinifex/Mulga ASCII logo.
+const Logo = `
+ ███████╗██████╗ ██╗███╗ ██╗██╗███████╗███████╗██╗ ██╗
+ ██╔════╝██╔══██╗██║████╗ ██║██║██╔════╝██╔════╝╚██╗██╔╝
+ ███████╗██████╔╝██║██╔██╗ ██║██║█████╗ █████╗ ╚███╔╝
+ ╚════██║██╔═══╝ ██║██║╚██╗██║██║██╔══╝ ██╔══╝ ██╔██╗
+ ███████║██║ ██║██║ ╚████║██║██║ ███████╗██╔╝ ██╗
+ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝`
+
+// Subtitle is displayed beneath the logo on the welcome screen.
+const Subtitle = "Bare-Metal Node Provisioner"
+
+// Publisher is shown in the welcome screen footer.
+const Publisher = "Mulga Defense Corporation"
+
+// Mulga color palette.
+// TODO: Replace with official hex values from the Mulga brand guide.
+const (
+ ColorPrimary = lipgloss.Color("#00A3E0") // Mulga blue (placeholder)
+ ColorAccent = lipgloss.Color("#FF6B00") // Mulga orange (placeholder)
+ ColorBackground = lipgloss.Color("#0D1117") // Dark background
+ ColorSurface = lipgloss.Color("#161B22") // Slightly lighter surface
+ ColorText = lipgloss.Color("#E6EDF3") // Light text
+ ColorMuted = lipgloss.Color("#8B949E") // Muted / secondary text
+ ColorSuccess = lipgloss.Color("#3FB950") // Green
+ ColorWarning = lipgloss.Color("#D29922") // Amber
+ ColorError = lipgloss.Color("#F85149") // Red
+ ColorBorder = lipgloss.Color("#30363D") // Border
+)
diff --git a/cmd/installer/main.go b/cmd/installer/main.go
index 761880de6..a755c03d4 100644
--- a/cmd/installer/main.go
+++ b/cmd/installer/main.go
@@ -19,13 +19,16 @@ package main
import (
"fmt"
"os"
+ "strings"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/cmd/installer/ui"
)
func main() {
- cfg, err := ui.Run()
+ ttyPath := detectTTY()
+
+ cfg, err := ui.Run(ttyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "installer: %v\n", err)
os.Exit(1)
@@ -36,3 +39,22 @@ func main() {
os.Exit(1)
}
}
+
+// detectTTY returns the TTY device path the installer should attach to.
+// It reads SPINIFEX_CONSOLE from the kernel cmdline (/proc/cmdline) so that
+// spinifex-init can direct the installer to the correct console (ttyS0 for
+// serial, tty1 for VGA). Falls back to tty1.
+func detectTTY() string {
+ data, err := os.ReadFile("/proc/cmdline")
+ if err == nil {
+ for param := range strings.FieldsSeq(string(data)) {
+ if after, ok := strings.CutPrefix(param, "SPINIFEX_CONSOLE="); ok {
+ val := after
+ if val != "" && val != "auto" {
+ return "/dev/" + val
+ }
+ }
+ }
+ }
+ return "/dev/tty1"
+}
diff --git a/cmd/installer/ui/ui.go b/cmd/installer/ui/ui.go
index 6d8992ada..0b3db6bf4 100644
--- a/cmd/installer/ui/ui.go
+++ b/cmd/installer/ui/ui.go
@@ -15,7 +15,7 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
-// Package ui presents the interactive installer forms using huh.
+// Package ui presents the interactive installer TUI using bubbletea and lipgloss.
package ui
import (
@@ -25,15 +25,80 @@ import (
"os"
"strings"
- "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/mulgadc/spinifex/cmd/installer/branding"
"github.com/mulgadc/spinifex/cmd/installer/install"
)
-// Run walks the operator through all installer forms and returns a completed
-// Config ready to hand to install.Run.
-func Run() (*install.Config, error) {
- cfg := &install.Config{}
+// screen represents which step of the wizard is active.
+type screen int
+const (
+ screenWelcome screen = iota
+ screenDisk
+ screenDiskConfirm
+ screenNetwork
+ screenIdentity
+ screenJoinConfig
+ screenCACertPrompt
+ screenCACert
+ screenConfirm
+ screenDone // signals completion; program exits
+)
+
+// networkField tracks which text input is focused on the network screen.
+type networkField int
+
+const (
+ fieldIP networkField = iota
+ fieldMask
+ fieldGateway
+ fieldNIC
+ fieldNetworkCount
+)
+
+// model is the top-level bubbletea model for the installer wizard.
+type model struct {
+ screen screen
+ width int
+ height int
+
+ // Disk selection
+ disks []diskInfo
+ diskCursor int
+ eraseInput textinput.Model
+
+ // Network
+ netInputs [3]textinput.Model // IP, mask, gateway
+ netFocus networkField
+ nics []string
+ nicCursor int
+
+ // Identity
+ hostnameInput textinput.Model
+ clusterRole int // 0 = init, 1 = join
+
+ // Join config
+ joinIPInput textinput.Model
+ joinPortInput textinput.Model
+
+ // CA cert
+ hasCACert int // 0 = no, 1 = yes
+ caCertInput textinput.Model
+
+ // Accumulated validation error shown on current screen
+ validationErr string
+
+ // Final result — set when screenDone is reached
+ result *install.Config
+ err error
+}
+
+// Run launches the bubbletea program connected to ttyPath and returns the
+// completed Config when the user finishes the wizard.
+func Run(ttyPath string) (*install.Config, error) {
disks, err := availableDisks()
if err != nil {
return nil, fmt.Errorf("listing disks: %w", err)
@@ -47,198 +112,777 @@ func Run() (*install.Config, error) {
return nil, fmt.Errorf("listing network interfaces: %w", err)
}
- var joinHost string
- var joinPort string
- var caCert string
+ m := newModel(disks, nics)
- form := huh.NewForm(
- // Step 1: Welcome
- huh.NewGroup(
- huh.NewNote().
- Title("Spinifex Installer").
- Description("This will install Spinifex onto this machine.\n\nAll data on the selected disk will be erased.\nPress Enter to continue."),
- ),
+ var opts []tea.ProgramOption
+ opts = append(opts, tea.WithAltScreen())
- // Step 2: Disk selection
- huh.NewGroup(
- huh.NewSelect[string]().
- Title("Select installation disk").
- Description("All data on the selected disk will be permanently erased.").
- Options(diskOptions(disks)...).
- Value(&cfg.Disk),
- huh.NewConfirm().
- Title("Confirm disk erasure").
- Description("Are you sure? This cannot be undone.").
- Affirmative("Yes, erase and install").
- Negative("Cancel").
- Validate(func(v bool) error {
- if !v {
- return errors.New("installation cancelled")
- }
- return nil
- }).
- Value(new(bool)),
- ),
+ if ttyPath != "" {
+ tty, err := os.OpenFile(ttyPath, os.O_RDWR, 0)
+ if err == nil {
+ opts = append(opts, tea.WithInput(tty), tea.WithOutput(tty))
+ }
+ }
- // Step 3: Network configuration
- huh.NewGroup(
- huh.NewInput().
- Title("Management IP address").
- Description("Static IP address for this node (e.g. 192.168.1.10)").
- Validate(validateIP).
- Value(&cfg.ManagementIP),
- huh.NewInput().
- Title("Subnet mask").
- Placeholder("255.255.255.0").
- Validate(validateIP).
- Value(&cfg.SubnetMask),
- huh.NewInput().
- Title("Default gateway").
- Validate(validateIP).
- Value(&cfg.Gateway),
- huh.NewSelect[string]().
- Title("OVN network interface").
- Description("Physical NIC to use for VM networking").
- Options(nicOptions(nics)...).
- Value(&cfg.OVNInterface),
- ),
-
- // Step 4: Node configuration
- huh.NewGroup(
- huh.NewInput().
- Title("Hostname").
- Description("Name for this node (e.g. node1)").
- Validate(func(s string) error {
- if strings.TrimSpace(s) == "" {
- return errors.New("hostname is required")
- }
- return nil
- }).
- Value(&cfg.Hostname),
- huh.NewSelect[string]().
- Title("Cluster role").
- Options(
- huh.NewOption("Initialize new cluster (single or first node)", "init"),
- huh.NewOption("Join existing cluster", "join"),
- ).
- Value(&cfg.ClusterRole),
- ),
-
- // Step 5: Join configuration (shown when joining)
- huh.NewGroup(
- huh.NewInput().
- Title("Primary node IP").
- Description("IP address of the node running spx admin init").
- Validate(func(s string) error {
- if cfg.ClusterRole != "join" {
- return nil
- }
- return validateIP(s)
- }).
- Value(&joinHost),
- huh.NewInput().
- Title("Formation port").
- Placeholder("4432").
- Value(&joinPort),
- ).WithHideFunc(func() bool {
- return cfg.ClusterRole != "join"
- }),
-
- // Step 6: Optional CA certificate
- huh.NewGroup(
- huh.NewConfirm().
- Title("Install a custom CA certificate?").
- Description("Required for air-gapped deployments with a private certificate authority.").
- Affirmative("Yes").
- Negative("No").
- Value(&cfg.HasCACert),
- ),
- huh.NewGroup(
- huh.NewText().
- Title("CA certificate (PEM format)").
- Description("Paste the full PEM certificate block.").
- Validate(func(s string) error {
- if !cfg.HasCACert {
- return nil
- }
- if !strings.Contains(s, "BEGIN CERTIFICATE") {
- return errors.New("does not look like a PEM certificate")
- }
- return nil
- }).
- Value(&caCert),
- ).WithHideFunc(func() bool {
- return !cfg.HasCACert
- }),
-
- // Step 7: Final confirmation
- huh.NewGroup(
- huh.NewConfirm().
- Title("Begin installation?").
- Description("Review your choices above. Installation will begin immediately.").
- Affirmative("Install").
- Negative("Cancel").
- Validate(func(v bool) error {
- if !v {
- return errors.New("installation cancelled")
- }
- return nil
- }).
- Value(new(bool)),
- ),
- )
-
- if err := form.Run(); err != nil {
+ p := tea.NewProgram(m, opts...)
+ final, err := p.Run()
+ if err != nil {
return nil, err
}
+ fm := final.(model)
+ if fm.err != nil {
+ return nil, fm.err
+ }
+ return fm.result, nil
+}
+
+func newModel(disks []diskInfo, nics []string) model {
+ eraseIn := textinput.New()
+ eraseIn.Placeholder = "yes"
+ eraseIn.CharLimit = 3
+
+ ipIn := textinput.New()
+ ipIn.Placeholder = "192.168.1.10"
+
+ maskIn := textinput.New()
+ maskIn.Placeholder = "255.255.255.0"
+
+ gwIn := textinput.New()
+ gwIn.Placeholder = "192.168.1.1"
+ ipIn.Focus()
+
+ hostnameIn := textinput.New()
+ hostnameIn.Placeholder = "node1"
+ hostnameIn.CharLimit = 64
+
+ joinIPIn := textinput.New()
+ joinIPIn.Placeholder = "192.168.1.10"
+
+ joinPortIn := textinput.New()
+ joinPortIn.Placeholder = "4432"
+ joinPortIn.CharLimit = 5
+
+ caCertIn := textinput.New()
+ caCertIn.Placeholder = "-----BEGIN CERTIFICATE-----"
+ caCertIn.CharLimit = 0
+
+ return model{
+ screen: screenWelcome,
+ disks: disks,
+ nics: nics,
+ eraseInput: eraseIn,
+ netInputs: [3]textinput.Model{ipIn, maskIn, gwIn},
+ hostnameInput: hostnameIn,
+ joinIPInput: joinIPIn,
+ joinPortInput: joinPortIn,
+ caCertInput: caCertIn,
+ }
+}
+
+// ── Styles ────────────────────────────────────────────────────────────────────
+
+var (
+ styleLogo = lipgloss.NewStyle().
+ Foreground(branding.ColorPrimary).
+ Bold(true)
+
+ styleTitle = lipgloss.NewStyle().
+ Foreground(branding.ColorPrimary).
+ Bold(true).
+ MarginBottom(1)
+
+ styleSubtitle = lipgloss.NewStyle().
+ Foreground(branding.ColorMuted)
+
+ styleBox = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(branding.ColorBorder).
+ Padding(1, 2)
+
+ styleSelected = lipgloss.NewStyle().
+ Foreground(branding.ColorBackground).
+ Background(branding.ColorPrimary).
+ Bold(true)
+
+ styleWarning = lipgloss.NewStyle().
+ Foreground(branding.ColorWarning).
+ Bold(true)
+
+ styleError = lipgloss.NewStyle().
+ Foreground(branding.ColorError)
+
+ styleMuted = lipgloss.NewStyle().
+ Foreground(branding.ColorMuted)
+
+ styleSuccess = lipgloss.NewStyle().
+ Foreground(branding.ColorSuccess)
+
+ styleLabel = lipgloss.NewStyle().
+ Foreground(branding.ColorAccent).
+ Bold(true)
+
+ styleHelp = lipgloss.NewStyle().
+ Foreground(branding.ColorMuted).
+ MarginTop(1)
+)
+
+// ── Init / Update / View ──────────────────────────────────────────────────────
+
+func (m model) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m, nil
+
+ case tea.KeyMsg:
+ m.validationErr = ""
+ switch msg.String() {
+ case "ctrl+c":
+ m.err = errors.New("installation cancelled")
+ return m, tea.Quit
+ }
+ return m.handleKey(msg)
+ }
+
+ // Forward to active input
+ return m.updateActiveInput(msg)
+}
+
+func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ key := msg.String()
+ switch m.screen {
+ case screenWelcome:
+ if key == "enter" || key == " " {
+ m.screen = screenDisk
+ }
+
+ case screenDisk:
+ switch key {
+ case "up", "k":
+ if m.diskCursor > 0 {
+ m.diskCursor--
+ }
+ case "down", "j":
+ if m.diskCursor < len(m.disks)-1 {
+ m.diskCursor++
+ }
+ case "enter":
+ m.screen = screenDiskConfirm
+ m.eraseInput.Focus()
+ m.eraseInput.SetValue("")
+ }
+
+ case screenDiskConfirm:
+ switch key {
+ case "enter":
+ if strings.ToLower(strings.TrimSpace(m.eraseInput.Value())) != "yes" {
+ m.validationErr = "Type 'yes' to confirm disk erasure"
+ return m, nil
+ }
+ m.screen = screenNetwork
+ m.netFocus = fieldIP
+ m.netInputs[0].Focus()
+ m.netInputs[1].Blur()
+ m.netInputs[2].Blur()
+ case "esc":
+ m.screen = screenDisk
+ return m, nil
+ default:
+ var cmd tea.Cmd
+ m.eraseInput, cmd = m.eraseInput.Update(msg)
+ return m, cmd
+ }
+
+ case screenNetwork:
+ switch key {
+ case "tab", "down":
+ m.netFocus = (m.netFocus + 1) % fieldNetworkCount
+ m = m.withFocusedNetworkField()
+ case "shift+tab", "up":
+ m.netFocus = (m.netFocus - 1 + fieldNetworkCount) % fieldNetworkCount
+ m = m.withFocusedNetworkField()
+ case "enter":
+ if m.netFocus < fieldNIC {
+ m.netFocus++
+ m = m.withFocusedNetworkField()
+ return m, nil
+ }
+ // Validate all fields
+ ip := strings.TrimSpace(m.netInputs[fieldIP].Value())
+ mask := strings.TrimSpace(m.netInputs[fieldMask].Value())
+ gw := strings.TrimSpace(m.netInputs[fieldGateway].Value())
+ if net.ParseIP(ip) == nil {
+ m.validationErr = "Invalid management IP address"
+ return m, nil
+ }
+ if net.ParseIP(mask) == nil {
+ m.validationErr = "Invalid subnet mask"
+ return m, nil
+ }
+ if net.ParseIP(gw) == nil {
+ m.validationErr = "Invalid gateway address"
+ return m, nil
+ }
+ if len(m.nics) == 0 {
+ m.validationErr = "No network interfaces available"
+ return m, nil
+ }
+ m.screen = screenIdentity
+ m.hostnameInput.Focus()
+ case "left", "h":
+ if m.netFocus == fieldNIC && m.nicCursor > 0 {
+ m.nicCursor--
+ }
+ case "right", "l":
+ if m.netFocus == fieldNIC && m.nicCursor < len(m.nics)-1 {
+ m.nicCursor++
+ }
+ default:
+ if m.netFocus < fieldNIC {
+ var cmd tea.Cmd
+ m.netInputs[m.netFocus], cmd = m.netInputs[m.netFocus].Update(msg)
+ return m, cmd
+ }
+ }
+
+ case screenIdentity:
+ switch key {
+ case "tab", "down":
+ if m.hostnameInput.Focused() {
+ m.hostnameInput.Blur()
+ } else {
+ m.hostnameInput.Focus()
+ }
+ case "left", "h", "right", "l":
+ if !m.hostnameInput.Focused() {
+ if key == "left" || key == "h" {
+ m.clusterRole = 0
+ } else {
+ m.clusterRole = 1
+ }
+ }
+ case "enter":
+ if m.hostnameInput.Focused() {
+ if strings.TrimSpace(m.hostnameInput.Value()) == "" {
+ m.validationErr = "Hostname is required"
+ return m, nil
+ }
+ m.hostnameInput.Blur()
+ return m, nil
+ }
+ if m.clusterRole == 1 {
+ m.screen = screenJoinConfig
+ m.joinIPInput.Focus()
+ } else {
+ m.screen = screenCACertPrompt
+ }
+ default:
+ if m.hostnameInput.Focused() {
+ var cmd tea.Cmd
+ m.hostnameInput, cmd = m.hostnameInput.Update(msg)
+ return m, cmd
+ }
+ }
+
+ case screenJoinConfig:
+ switch key {
+ case "tab", "down":
+ if m.joinIPInput.Focused() {
+ m.joinIPInput.Blur()
+ m.joinPortInput.Focus()
+ } else {
+ m.joinPortInput.Blur()
+ m.joinIPInput.Focus()
+ }
+ case "enter":
+ if m.joinIPInput.Focused() {
+ m.joinIPInput.Blur()
+ m.joinPortInput.Focus()
+ return m, nil
+ }
+ joinIP := strings.TrimSpace(m.joinIPInput.Value())
+ if net.ParseIP(joinIP) == nil {
+ m.validationErr = "Invalid primary node IP"
+ return m, nil
+ }
+ m.screen = screenCACertPrompt
+ case "esc":
+ m.screen = screenIdentity
+ default:
+ var cmd tea.Cmd
+ if m.joinIPInput.Focused() {
+ m.joinIPInput, cmd = m.joinIPInput.Update(msg)
+ } else {
+ m.joinPortInput, cmd = m.joinPortInput.Update(msg)
+ }
+ return m, cmd
+ }
+
+ case screenCACertPrompt:
+ switch key {
+ case "left", "h", "right", "l":
+ if m.hasCACert == 0 {
+ m.hasCACert = 1
+ } else {
+ m.hasCACert = 0
+ }
+ case "enter":
+ if m.hasCACert == 1 {
+ m.screen = screenCACert
+ m.caCertInput.Focus()
+ } else {
+ m.screen = screenConfirm
+ }
+ }
+
+ case screenCACert:
+ switch key {
+ case "enter":
+ cert := strings.TrimSpace(m.caCertInput.Value())
+ if !strings.Contains(cert, "BEGIN CERTIFICATE") {
+ m.validationErr = "Does not look like a PEM certificate"
+ return m, nil
+ }
+ m.screen = screenConfirm
+ case "esc":
+ m.screen = screenCACertPrompt
+ default:
+ var cmd tea.Cmd
+ m.caCertInput, cmd = m.caCertInput.Update(msg)
+ return m, cmd
+ }
+
+ case screenConfirm:
+ switch key {
+ case "enter", "y", "Y":
+ m.result = m.buildConfig()
+ m.screen = screenDone
+ return m, tea.Quit
+ case "n", "N", "esc":
+ m.err = errors.New("installation cancelled")
+ return m, tea.Quit
+ }
+ }
+
+ return m, nil
+}
+
+func (m model) withFocusedNetworkField() model {
+ for i := range m.netInputs {
+ if networkField(i) == m.netFocus {
+ m.netInputs[i].Focus()
+ } else {
+ m.netInputs[i].Blur()
+ }
+ }
+ return m
+}
+
+func (m model) updateActiveInput(msg tea.Msg) (tea.Model, tea.Cmd) {
+ // Delegate to the currently-focused text input for cursor blink, etc.
+ switch m.screen {
+ case screenDiskConfirm:
+ var cmd tea.Cmd
+ m.eraseInput, cmd = m.eraseInput.Update(msg)
+ return m, cmd
+ case screenNetwork:
+ if m.netFocus < fieldNIC {
+ var cmd tea.Cmd
+ m.netInputs[m.netFocus], cmd = m.netInputs[m.netFocus].Update(msg)
+ return m, cmd
+ }
+ case screenIdentity:
+ if m.hostnameInput.Focused() {
+ var cmd tea.Cmd
+ m.hostnameInput, cmd = m.hostnameInput.Update(msg)
+ return m, cmd
+ }
+ }
+ return m, nil
+}
+
+// ── View ──────────────────────────────────────────────────────────────────────
+
+func (m model) View() string {
+ w := m.width
+ if w == 0 {
+ w = 80
+ }
+
+ var content string
+ switch m.screen {
+ case screenWelcome:
+ content = m.viewWelcome(w)
+ case screenDisk:
+ content = m.viewDisk(w)
+ case screenDiskConfirm:
+ content = m.viewDiskConfirm(w)
+ case screenNetwork:
+ content = m.viewNetwork(w)
+ case screenIdentity:
+ content = m.viewIdentity(w)
+ case screenJoinConfig:
+ content = m.viewJoinConfig(w)
+ case screenCACertPrompt:
+ content = m.viewCACertPrompt(w)
+ case screenCACert:
+ content = m.viewCACert(w)
+ case screenConfirm:
+ content = m.viewConfirm(w)
+ case screenDone:
+ content = m.viewDone(w)
+ }
+
+ return content
+}
+
+func (m model) viewWelcome(w int) string {
+ logo := styleLogo.Render(branding.Logo)
+ subtitle := styleSubtitle.Render(branding.Subtitle)
+ publisher := styleMuted.Render(branding.Publisher)
+
+ warning := styleWarning.Render("WARNING: Installation will erase the selected disk entirely.")
+ help := styleHelp.Render("Press Enter to begin")
+
+ body := lipgloss.JoinVertical(lipgloss.Center,
+ logo,
+ subtitle,
+ publisher,
+ "",
+ warning,
+ "",
+ help,
+ )
+
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 72)).Render(body),
+ )
+}
+
+func (m model) viewDisk(w int) string {
+ title := styleTitle.Render("Select Installation Disk")
+ subtitle := styleMuted.Render("All data on the selected disk will be permanently erased.")
+
+ var rows []string
+ for i, d := range m.disks {
+ line := fmt.Sprintf(" %-20s %-8s %s", d.Path, d.Size, d.Model)
+ if i == m.diskCursor {
+ line = styleSelected.Render("> " + line[2:])
+ } else {
+ line = styleMuted.Render(line)
+ }
+ rows = append(rows, line)
+ }
+
+ help := styleHelp.Render("↑/↓ to select • Enter to confirm")
+ body := lipgloss.JoinVertical(lipgloss.Left, append([]string{title, subtitle, ""}, append(rows, "", help)...)...)
+
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 72)).Render(body),
+ )
+}
+
+func (m model) viewDiskConfirm(w int) string {
+ title := styleTitle.Render("Confirm Disk Erasure")
+ disk := styleLabel.Render(m.disks[m.diskCursor].Path)
+ msg := fmt.Sprintf("All data on %s will be permanently erased.\nType 'yes' to confirm:", disk)
+
+ var lines []string
+ lines = append(lines, title, msg, "", m.eraseInput.View())
+ if m.validationErr != "" {
+ lines = append(lines, "", styleError.Render(m.validationErr))
+ }
+ lines = append(lines, styleHelp.Render("Enter to confirm • Esc to go back"))
+
+ body := lipgloss.JoinVertical(lipgloss.Left, lines...)
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 64)).Render(body),
+ )
+}
+
+func (m model) viewNetwork(w int) string {
+ title := styleTitle.Render("Network Configuration")
+
+ labels := []string{"Management IP", "Subnet mask", "Default gateway"}
+ var rows []string
+ for i, inp := range m.netInputs {
+ label := styleLabel.Render(labels[i])
+ if networkField(i) == m.netFocus {
+ rows = append(rows, label, inp.View(), "")
+ } else {
+ rows = append(rows, label, styleMuted.Render(inp.Value()), "")
+ }
+ }
+
+ // NIC selector
+ nicLabel := styleLabel.Render("OVN network interface")
+ var nicLine string
+ if len(m.nics) == 0 {
+ nicLine = styleError.Render("no interfaces found")
+ } else {
+ var parts []string
+ for i, nic := range m.nics {
+ if i == m.nicCursor {
+ if m.netFocus == fieldNIC {
+ parts = append(parts, styleSelected.Render(" "+nic+" "))
+ } else {
+ parts = append(parts, styleLabel.Render("["+nic+"]"))
+ }
+ } else {
+ parts = append(parts, styleMuted.Render(nic))
+ }
+ }
+ nicLine = strings.Join(parts, " ")
+ }
+ rows = append(rows, nicLabel, nicLine)
+
+ var errLine []string
+ if m.validationErr != "" {
+ errLine = []string{"", styleError.Render(m.validationErr)}
+ }
+
+ help := styleHelp.Render("Tab/↑↓ to move • ←/→ to select NIC • Enter to proceed")
+ all := append([]string{title, ""}, append(rows, append(errLine, "", help)...)...)
+ body := lipgloss.JoinVertical(lipgloss.Left, all...)
+
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 64)).Render(body),
+ )
+}
+
+func (m model) viewIdentity(w int) string {
+ title := styleTitle.Render("Node Identity")
+
+ hostnameLabel := styleLabel.Render("Hostname")
+
+ roleLabel := styleLabel.Render("Cluster role")
+ roles := []string{"Initialize new cluster", "Join existing cluster"}
+ var roleParts []string
+ for i, r := range roles {
+ if i == m.clusterRole && !m.hostnameInput.Focused() {
+ roleParts = append(roleParts, styleSelected.Render(" "+r+" "))
+ } else if i == m.clusterRole {
+ roleParts = append(roleParts, styleLabel.Render("["+r+"]"))
+ } else {
+ roleParts = append(roleParts, styleMuted.Render(r))
+ }
+ }
+
+ var lines []string
+ lines = append(lines, title, "", hostnameLabel, m.hostnameInput.View(), "", roleLabel, strings.Join(roleParts, " "))
+ if m.validationErr != "" {
+ lines = append(lines, "", styleError.Render(m.validationErr))
+ }
+ lines = append(lines, styleHelp.Render("Tab to toggle focus • ←/→ to select role • Enter to proceed"))
+
+ body := lipgloss.JoinVertical(lipgloss.Left, lines...)
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 64)).Render(body),
+ )
+}
+
+func (m model) viewJoinConfig(w int) string {
+ title := styleTitle.Render("Join Existing Cluster")
+ ipLabel := styleLabel.Render("Primary node IP")
+ portLabel := styleLabel.Render("Formation port")
+
+ var lines []string
+ lines = append(lines, title, "", ipLabel, m.joinIPInput.View(), "", portLabel, m.joinPortInput.View())
+ if m.validationErr != "" {
+ lines = append(lines, "", styleError.Render(m.validationErr))
+ }
+ lines = append(lines, styleHelp.Render("Tab to move • Enter to proceed • Esc to go back"))
+
+ body := lipgloss.JoinVertical(lipgloss.Left, lines...)
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 64)).Render(body),
+ )
+}
+
+func (m model) viewCACertPrompt(w int) string {
+ title := styleTitle.Render("Custom CA Certificate")
+ subtitle := styleMuted.Render("Required for air-gapped deployments with a private CA.")
+
+ options := []string{"No", "Yes"}
+ var parts []string
+ for i, o := range options {
+ if i == m.hasCACert {
+ parts = append(parts, styleSelected.Render(" "+o+" "))
+ } else {
+ parts = append(parts, styleMuted.Render(o))
+ }
+ }
+
+ body := lipgloss.JoinVertical(lipgloss.Left,
+ title, subtitle, "", styleLabel.Render("Install a custom CA certificate?"),
+ "", strings.Join(parts, " "),
+ "", styleHelp.Render("←/→ to select • Enter to proceed"),
+ )
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 64)).Render(body),
+ )
+}
+
+func (m model) viewCACert(w int) string {
+ title := styleTitle.Render("CA Certificate (PEM)")
+ subtitle := styleMuted.Render("Paste the full PEM certificate block.")
+
+ var lines []string
+ lines = append(lines, title, subtitle, "", m.caCertInput.View())
+ if m.validationErr != "" {
+ lines = append(lines, "", styleError.Render(m.validationErr))
+ }
+ lines = append(lines, styleHelp.Render("Enter to confirm • Esc to go back"))
+
+ body := lipgloss.JoinVertical(lipgloss.Left, lines...)
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 64)).Render(body),
+ )
+}
+
+func (m model) viewConfirm(w int) string {
+ title := styleTitle.Render("Confirm Installation")
+
+ cfg := m.buildConfig()
+ role := "Initialize new cluster"
if cfg.ClusterRole == "join" {
- port := strings.TrimSpace(joinPort)
+ role = fmt.Sprintf("Join cluster at %s", cfg.JoinAddr)
+ }
+
+ summary := []struct{ k, v string }{
+ {"Disk", cfg.Disk},
+ {"Management IP", cfg.ManagementIP},
+ {"Subnet mask", cfg.SubnetMask},
+ {"Gateway", cfg.Gateway},
+ {"OVN interface", cfg.OVNInterface},
+ {"Hostname", cfg.Hostname},
+ {"Cluster role", role},
+ }
+ if cfg.HasCACert {
+ summary = append(summary, struct{ k, v string }{"CA certificate", "provided"})
+ }
+
+ var rows []string
+ for _, s := range summary {
+ rows = append(rows, fmt.Sprintf(" %s%-20s%s %s",
+ styleLabel.Render(""), styleLabel.Render(s.k), "", s.v))
+ }
+
+ warning := styleWarning.Render("This will erase " + cfg.Disk + " and begin installation.")
+
+ body := lipgloss.JoinVertical(lipgloss.Left,
+ title, "",
+ strings.Join(rows, "\n"), "",
+ warning, "",
+ styleHelp.Render("Enter/Y to install • N/Esc to cancel"),
+ )
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 72)).Render(body),
+ )
+}
+
+func (m model) viewDone(w int) string {
+ body := lipgloss.JoinVertical(lipgloss.Center,
+ styleSuccess.Render("Installation complete."),
+ "",
+ styleMuted.Render("The system will reboot shortly."),
+ )
+ return lipgloss.Place(w, m.height, lipgloss.Center, lipgloss.Center,
+ styleBox.Width(min(w-4, 48)).Render(body),
+ )
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+func (m model) buildConfig() *install.Config {
+ cfg := &install.Config{}
+ if len(m.disks) > m.diskCursor {
+ cfg.Disk = m.disks[m.diskCursor].Path
+ }
+ cfg.ManagementIP = strings.TrimSpace(m.netInputs[fieldIP].Value())
+ cfg.SubnetMask = strings.TrimSpace(m.netInputs[fieldMask].Value())
+ cfg.Gateway = strings.TrimSpace(m.netInputs[fieldGateway].Value())
+ if len(m.nics) > m.nicCursor {
+ cfg.OVNInterface = m.nics[m.nicCursor]
+ }
+ cfg.Hostname = strings.TrimSpace(m.hostnameInput.Value())
+ if m.clusterRole == 0 {
+ cfg.ClusterRole = "init"
+ } else {
+ cfg.ClusterRole = "join"
+ port := strings.TrimSpace(m.joinPortInput.Value())
if port == "" {
port = "4432"
}
- cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(joinHost), port)
+ cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(m.joinIPInput.Value()), port)
}
-
- cfg.CACert = strings.TrimSpace(caCert)
-
- return cfg, nil
+ cfg.HasCACert = m.hasCACert == 1
+ cfg.CACert = strings.TrimSpace(m.caCertInput.Value())
+ return cfg
}
-func validateIP(s string) error {
- if net.ParseIP(strings.TrimSpace(s)) == nil {
- return errors.New("invalid IP address")
- }
- return nil
+// diskInfo holds display info for a block device.
+type diskInfo struct {
+ Path string
+ Size string
+ Model string
}
-// availableDisks returns block device paths from /sys/block, excluding loop
-// and RAM devices.
-func availableDisks() ([]string, error) {
+func availableDisks() ([]diskInfo, error) {
entries, err := os.ReadDir("/sys/block")
if err != nil {
return nil, err
}
- var disks []string
+ var disks []diskInfo
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") {
continue
}
- disks = append(disks, "/dev/"+name)
+ d := diskInfo{Path: "/dev/" + name}
+ d.Size = readSysBlockFile(name, "size")
+ if d.Size != "" {
+ // Convert 512-byte sectors to human-readable
+ d.Size = formatSectors(d.Size)
+ }
+ d.Model = strings.TrimSpace(readSysBlockFile(name, "device/model"))
+ disks = append(disks, d)
}
return disks, nil
}
-func diskOptions(disks []string) []huh.Option[string] {
- opts := make([]huh.Option[string], len(disks))
- for i, d := range disks {
- opts[i] = huh.NewOption(d, d)
+func readSysBlockFile(dev, file string) string {
+ data, err := os.ReadFile("/sys/block/" + dev + "/" + file)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(data))
+}
+
+func formatSectors(sectors string) string {
+ var n int64
+ if _, err := fmt.Sscan(sectors, &n); err != nil {
+ return ""
+ }
+ bytes := n * 512
+ switch {
+ case bytes >= 1<<40:
+ return fmt.Sprintf("%.1fT", float64(bytes)/(1<<40))
+ case bytes >= 1<<30:
+ return fmt.Sprintf("%.1fG", float64(bytes)/(1<<30))
+ case bytes >= 1<<20:
+ return fmt.Sprintf("%.1fM", float64(bytes)/(1<<20))
+ default:
+ return fmt.Sprintf("%dB", bytes)
}
- return opts
}
-// availableNICs returns non-loopback network interface names.
func availableNICs() ([]string, error) {
ifaces, err := net.Interfaces()
if err != nil {
@@ -253,11 +897,3 @@ func availableNICs() ([]string, error) {
}
return nics, nil
}
-
-func nicOptions(nics []string) []huh.Option[string] {
- opts := make([]huh.Option[string], len(nics))
- for i, n := range nics {
- opts[i] = huh.NewOption(n, n)
- }
- return opts
-}
diff --git a/go.mod b/go.mod
index 156ead98d..00e75c161 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,9 @@ go 1.26.1
require (
github.com/aws/aws-sdk-go v1.55.8
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/kdomanski/iso9660 v0.4.0
@@ -32,20 +35,16 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
github.com/buraksezer/consistent v0.10.0 // indirect
- github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/hub v1.0.2 // indirect
github.com/cenkalti/rpc2 v1.0.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
- github.com/charmbracelet/bubbletea v1.3.6 // indirect
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
- github.com/charmbracelet/huh v1.0.0 // indirect
- github.com/charmbracelet/lipgloss v1.1.0 // indirect
- github.com/charmbracelet/x/ansi v0.9.3 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
- github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
- github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -79,14 +78,13 @@ require (
github.com/klauspost/reedsolomon v1.13.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
- github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
diff --git a/go.sum b/go.sum
index 48bef4402..571682caa 100644
--- a/go.sum
+++ b/go.sum
@@ -79,8 +79,6 @@ github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW25rrZlU=
github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw=
-github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
-github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8=
@@ -90,26 +88,26 @@ github.com/cenkalti/rpc2 v1.0.5/go.mod h1:2yfU5b86vOr16+iY1jN3MvT6Kxc9Nf8j5iZWwU
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
-github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
-github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
-github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
-github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
-github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
-github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
-github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
-github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
-github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
-github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
-github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
@@ -261,8 +259,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
-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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -281,8 +279,6 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
-github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
-github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=