Files
Felis/cmd/felis/tui_bootstrap.go
T

155 lines
4.5 KiB
Go

package main
import (
"context"
"errors"
"os"
"os/exec"
"strings"
felis "felis.lolicon.best"
tea "github.com/charmbracelet/bubbletea"
)
type hostBootstrapState int
const (
hostBootstrapIntro hostBootstrapState = iota
hostBootstrapRunning
hostBootstrapDone
hostBootstrapError
)
type hostBootstrapDoneMsg struct {
err error
}
type hostBootstrapModel struct {
ctx context.Context
state hostBootstrapState
completed bool
err error
}
func newHostBootstrapModel(ctx context.Context) *hostBootstrapModel {
return &hostBootstrapModel{ctx: ctx}
}
func (m *hostBootstrapModel) Init() tea.Cmd { return nil }
func (m *hostBootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case hostBootstrapDoneMsg:
if msg.err != nil {
m.state = hostBootstrapError
m.err = msg.err
return m, nil
}
m.state = hostBootstrapDone
m.completed = true
m.err = nil
return m, nil
case tea.KeyMsg:
switch m.state {
case hostBootstrapIntro:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter":
m.state = hostBootstrapRunning
m.err = nil
return m, m.runBootstrap()
}
case hostBootstrapDone:
switch msg.String() {
case "ctrl+c", "esc", "enter":
return m, tea.Quit
}
case hostBootstrapError:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "enter":
m.state = hostBootstrapRunning
m.err = nil
return m, m.runBootstrap()
}
}
}
return m, nil
}
func (m *hostBootstrapModel) View() string {
var b strings.Builder
// Share the wizard's progress rail so bootstrap reads as step 1 of one
// continuous flow rather than a separate popup with its own banner. Cell 0
// (Bootstrap) is active while installing; once it completes we light cell 1
// (Preflight) to foreshadow the hand-off to the wizard that runs next.
railAt := 0
if m.state == hostBootstrapDone {
railAt = 1
}
b.WriteString(tuiStepRail(setupRailSteps, railAt) + "\n\n")
switch m.state {
case hostBootstrapIntro:
b.WriteString(tuiHint.Render("Host bootstrap has not been marked complete.") + "\n\n")
b.WriteString(tuiInfo("The embedded installer will configure system packages, Docker, k3s, PostgreSQL, migrations, and the Felis control plane.") + "\n\n")
b.WriteString(tuiWarn.Render("Run this on a disposable Linux host or VM. It changes system services.") + "\n")
b.WriteString("\n" + tuiSeparator() + "\n")
b.WriteString(tuiAction("enter", "run bootstrap", "esc", "cancel"))
case hostBootstrapRunning:
b.WriteString(tuiHint.Render("Running host bootstrap...") + "\n")
b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n")
case hostBootstrapDone:
b.WriteString(tuiSuccessBanner("Host bootstrap completed.") + "\n\n")
b.WriteString(tuiInfo("Continue to create the Owner account and choose how the panel is reached.") + "\n")
b.WriteString("\n" + tuiSeparator() + "\n")
b.WriteString(tuiAction("enter", "continue", "esc", "continue"))
case hostBootstrapError:
b.WriteString(tuiErrorBanner("Host bootstrap failed.") + "\n\n")
if m.err != nil {
b.WriteString(tuiHint.Render(m.err.Error()) + "\n")
}
// The installer streams on the normal screen during the run; alt-screen
// restores it on exit, so the full log is still there to inspect.
b.WriteString(tuiHint.Render("The installer's full output remains on screen after you exit.") + "\n")
b.WriteString("\n" + tuiSeparator() + "\n")
b.WriteString(tuiAction("enter", "retry", "esc", "exit"))
}
return b.String()
}
func (m *hostBootstrapModel) runBootstrap() tea.Cmd {
exe, err := os.Executable()
if err != nil {
return func() tea.Msg { return hostBootstrapDoneMsg{err: err} }
}
cmd := exec.CommandContext(m.ctx, "bash", "-s")
cmd.Stdin = strings.NewReader(felis.BootstrapScript())
cmd.Env = append(os.Environ(),
"FELIS_BOOTSTRAP_FROM_TUI=1",
"FELIS_BOOTSTRAP_BINARY="+exe,
)
return tea.ExecProcess(cmd, func(err error) tea.Msg {
return hostBootstrapDoneMsg{err: err}
})
}
func runHostBootstrapTUI(ctx context.Context) (bool, error) {
// Alt-screen matches the wizard's locked, clear-screen chrome so the two
// programs feel like one flow. tea.ExecProcess drops out of alt-screen for the
// installer (its output streams on the normal screen) and restores it after.
final, err := tea.NewProgram(newHostBootstrapModel(ctx), tea.WithAltScreen()).Run()
if err != nil {
return false, err
}
m, ok := final.(*hostBootstrapModel)
if !ok {
return false, errors.New("unexpected bootstrap model type")
}
return m.completed, m.err
}