155 lines
4.5 KiB
Go
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
|
|
}
|