package main import ( "context" "strings" "felis.lolicon.best/internal/cfsetup" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // sizeable is implemented by screens that need to know the terminal area // available to them (after the root reserves space for the step rail). huh-based // screens forward this to form.WithWidth/WithHeight; custom screens use it to // avoid overflowing the frame. type sizeable interface { setSize(width, height int) } // arrowNavigable is implemented by screens that don't need ←/→ for their own // input (selects, summaries), so the root may repurpose those keys to walk back // through completed steps. Text-input screens omit it and keep the arrows for // cursor movement — that's the "don't fight the input fields" rule. type arrowNavigable interface { arrowNavOK() bool } // ---- Connection methods ---- type connectMethod int const ( connectLocal connectMethod = iota connectCloudflare connectReverseProxy ) func connectMethodLabel(m connectMethod) string { switch m { case connectCloudflare: return "Cloudflare Tunnel + Access" case connectReverseProxy: return "Reverse proxy (your own front)" default: return "Local only (NodePort + self-signed TLS)" } } // ---- Messages: sub-model → root ---- type preflightDoneMsg struct{} type ownerResultMsg struct { username string displayPassword string mode string accountable string auditWarning string isOperator bool // true when an Operator was added rather than the Owner provisioned err error } // connectResultMsg is emitted by every connection method (the chooser for // local, the edge model for Cloudflare, the reverse-proxy model for BYO). It is // the single, method-agnostic outcome the root advances on. type connectResultMsg struct { method connectMethod panelHostname string adminHostname string edge *cfsetup.Result // Cloudflare only guide string // reverse-proxy only } // goBackMsg returns from a connection sub-screen to the chooser. type goBackMsg struct{} func goBack() tea.Cmd { return func() tea.Msg { return goBackMsg{} } } // reconfigureConnectMsg is sent from the re-run status screen to re-enter the // connection chooser. type reconfigureConnectMsg struct{} // ---- rootModel: top-level session ---- type wizardStage int const ( stagePreflight wizardStage = iota stageOwner stageConnect stageSummary // stageMenu is the break-glass operation menu. It is appended last so the // setup-flow rail indices (Preflight…Done) are unshifted; the rail is suppressed // in break-glass mode, so this stage never reaches it. stageMenu ) // setupRailSteps is the one progress rail shared by the whole first-run flow, // spanning both bubbletea programs: the host-bootstrap installer is rail cell 0, // and the post-install wizard owns cells 1–4. Defining it once keeps the two // programs' breadcrumbs identical so the rail reads as a single continuous bar // rather than restarting when the wizard takes over. var setupRailSteps = []string{"Bootstrap", "Preflight", "Owner", "Connection", "Done"} type rootModel struct { ctx context.Context screen tea.Model stage wizardStage // reviewing is the index of a completed step the operator is looking back at // (read-only), or -1 when the live screen is in front. Driven by ←/→. reviewing int width int height int result breakGlassResult err error mode consoleMode dbURL string store ownerStore osUser string rootDomain string adminHost string panelHost string accessAud string adminExists bool } func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) *rootModel { rm := &rootModel{ ctx: ctx, reviewing: -1, dbURL: dbURL, store: store, osUser: osUser, rootDomain: rootDomain, adminHost: adminHostname, panelHost: panelHostname, accessAud: accessAud, adminExists: adminExists, mode: mode, result: breakGlassResult{ osUser: osUser, rootDomain: rootDomain, adminHostname: adminHostname, }, } if mode == consoleModeBreakGlass { if adminExists { // A staff account exists, so account operations are peers: open on the menu // (provision/reset Owner, or add Operator). rm.stage = stageMenu rm.screen = newMenuModel() } else { // Fresh machine: bootstrapping the first Owner is the only sensible op, so skip // the menu and go straight to it (offering "add Operator" here would mint a // staff account the login gate still rejects). rm.stage = stageOwner rm.screen = newOwnerModel(ctx, store, osUser, adminExists) } } else { rm.stage = stagePreflight rm.screen = newPreflightModel(dbURL, rootDomain) } return rm } func (m *rootModel) Init() tea.Cmd { return m.screen.Init() } func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Rail navigation claims ←/→ before anything else sees them. While reviewing // it owns every key so nothing leaks into the live screen underneath; // otherwise it only takes ← (to enter review) and lets the rest fall through. if key, ok := msg.(tea.KeyMsg); ok { if handled, model, cmd := m.handleRailKey(key); handled { return model, cmd } } switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.pushSize() return m, nil case preflightDoneMsg: if m.adminExists { // Re-run: setup already happened. Land on the status screen. return m.showStatus() } m.stage = stageOwner return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false)) case menuChoiceMsg: // The break-glass menu picked an account operation; build its screen. Both reuse // stageOwner (the rail is suppressed in break-glass, so the stage is only a label). m.stage = stageOwner switch msg.op { case bgAddOperator: return m.adopt(newOperatorModel(m.ctx, m.store, m.osUser)) default: return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, m.adminExists)) } case ownerResultMsg: if msg.err != nil { m.err = msg.err return m, tea.Quit } m.result.provisioned = true m.result.isOperator = msg.isOperator m.result.username = msg.username m.result.displayPassword = msg.displayPassword m.result.mode = msg.mode m.result.accountable = msg.accountable m.result.auditWarning = msg.auditWarning if m.mode == consoleModeBreakGlass { return m, tea.Quit } m.adminExists = true m.stage = stageConnect return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) case connectResultMsg: m.applyConnectResult(msg) return m.showSummary() case goBackMsg: m.stage = stageConnect return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) case reconfigureConnectMsg: m.stage = stageConnect return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) } if m.screen != nil { newScreen, cmd := m.screen.Update(msg) if newScreen != nil && newScreen != m.screen { adopted, initCmd := m.adopt(newScreen) return adopted, tea.Batch(cmd, initCmd) } return m, cmd } return m, nil } // adopt installs a new screen, hands it the current size, and returns its Init. // Centralizing screen swaps here guarantees every screen is sized before its // first View — the fix for the rail being clipped off the top of the frame. func (m *rootModel) adopt(s tea.Model) (tea.Model, tea.Cmd) { m.screen = s m.reviewing = -1 // every stage change drops back to the live screen m.pushSize() return m, s.Init() } // handleRailKey implements ←/→ navigation of the step rail. While reviewing a // completed step it owns every key (so nothing leaks into the live screen); // otherwise it claims only ← to enter review, and only when the active screen // doesn't need the arrows for its own text input. func (m *rootModel) handleRailKey(k tea.KeyMsg) (bool, tea.Model, tea.Cmd) { if m.reviewing >= 0 { switch k.String() { case "ctrl+c": return true, m, tea.Quit case "left": if m.reviewing > 0 { m.reviewing-- } return true, m, nil case "right": m.reviewing++ if m.reviewing >= int(m.stage) { m.reviewing = -1 // caught up to the live step } return true, m, nil case "esc", "enter": m.reviewing = -1 return true, m, nil default: return true, m, nil // swallow everything else while reviewing } } if k.String() == "left" && m.canEnterReview() { m.reviewing = int(m.stage) - 1 return true, m, nil } return false, m, nil } // canEnterReview reports whether the live screen will yield ←/→ to the rail. func (m *rootModel) canEnterReview() bool { if m.mode != consoleModeSetup || m.adminExistsAtStart() || m.stage == 0 { return false } n, ok := m.screen.(arrowNavigable) return ok && n.arrowNavOK() } // displayStage is the rail position currently shown — the reviewed step when // looking back, otherwise the live stage. func (m *rootModel) displayStage() int { if m.reviewing >= 0 { return m.reviewing } return int(m.stage) } // reviewBody renders a read-only recap of an already-completed step. Steps in // this wizard commit as you finish them (the Owner account and its one-time // password are created on submit), so review is deliberately look-only — there // is no re-editing a step you've passed. func (m *rootModel) reviewBody(stage int) string { var b strings.Builder switch wizardStage(stage) { case stagePreflight: b.WriteString(tuiOK.Render("✓ Preflight") + "\n") b.WriteString(tuiHint.Render("Control plane verified before configuration.")) case stageOwner: b.WriteString(tuiOK.Render("✓ Owner account") + "\n") if m.result.username != "" { b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n") } b.WriteString(tuiHint.Render("Created and recorded. The one-time password was shown on the Owner step.")) case stageConnect: b.WriteString(tuiOK.Render("✓ Connection") + "\n") b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n") if m.result.panelURL != "" { b.WriteString(tuiLabel.Render("panel ") + m.result.panelURL) } } b.WriteString("\n\n" + tuiHint.Render("read-only · ") + tuiLabel.Render("←/→") + tuiHint.Render(" walk steps · ") + tuiLabel.Render("esc") + tuiHint.Render(" back")) return b.String() } // pushSize gives the active screen the area left after the step rail. func (m *rootModel) pushSize() { if m.height == 0 { return } if s, ok := m.screen.(sizeable); ok { // Reserve one extra row: huh renders a hair taller than its WithHeight // budget (the help line sits outside it). Clipping the rail off the top is // the bug we're fixing, so we round the budget down — a blank bottom row is // invisible, an overflowed top is not. avail := m.height - m.chromeHeight() - 1 if avail < 1 { avail = 1 } s.setSize(m.width, avail) } } // chromeHeight is the number of rows the root paints around the screen — the // rail plus its blank separator, or 0 when the rail is hidden. func (m *rootModel) chromeHeight() int { if m.mode != consoleModeSetup || m.adminExistsAtStart() { return 0 } return lipgloss.Height(m.railWithHint()) + 1 } func (m *rootModel) View() string { if m.screen == nil { return "" } if m.mode == consoleModeSetup && !m.adminExistsAtStart() { body := m.screen.View() if m.reviewing >= 0 { body = m.reviewBody(m.reviewing) } return m.railWithHint() + "\n\n" + body } return m.screen.View() } // adminExistsAtStart reports whether this run began with an Owner already // present (a re-run). The rail only makes sense for the first-run linear wizard. func (m *rootModel) adminExistsAtStart() bool { // adminExists flips true once we provision the Owner mid-run; the rail should // keep showing through the connect/summary stages of that same first run. So // only suppress the rail when the Owner pre-existed AND we never provisioned. return m.adminExists && !m.result.provisioned } func (m *rootModel) rail() string { // Bootstrap is rail cell 0 and is always done by the time the wizard runs (an // open DB is the proof), so the wizard's own stages render starting at cell 1. return tuiStepRail(setupRailSteps, m.displayStage()+1) } // railWithHint appends a discoverability hint when ←/→ can walk the rail — while // reviewing, or on a live screen that yields the arrows. func (m *rootModel) railWithHint() string { r := m.rail() switch { case m.reviewing >= 0: // Mid-review both directions move; → eventually returns to the live step. r += tuiRailSep.Render(" ") + tuiRailTodo.Render("←/→ review steps") case m.canEnterReview(): // At the live frontier only ← does anything — there's nothing ahead, so // don't advertise → and have it silently no-op. r += tuiRailSep.Render(" ") + tuiRailTodo.Render("← review steps") } return r } // applyConnectResult records the chosen connection outcome onto the result. func (m *rootModel) applyConnectResult(msg connectResultMsg) { m.result.connectMethod = msg.method if msg.panelHostname != "" { m.result.panelHostname = msg.panelHostname } if msg.adminHostname != "" { m.result.adminHostname = msg.adminHostname } switch msg.method { case connectCloudflare: m.result.connectConfigured = true m.result.edgeConfigured = true if msg.edge != nil { m.result.edgeAud = msg.edge.AccessAud m.result.edgeRoutedHosts = msg.edge.RoutedHostnames m.result.edgeConfigPath = msg.edge.ConfigPath } case connectReverseProxy: m.result.connectConfigured = true m.result.reverseProxyGuide = msg.guide } m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain) } func (m *rootModel) showSummary() (tea.Model, tea.Cmd) { m.stage = stageSummary routed := m.result.edgeRoutedHosts if len(routed) == 0 && m.result.connectMethod == connectReverseProxy && m.result.panelHostname != "" { routed = []string{m.result.panelHostname} } return m.adopt(&summaryModel{ panelURL: m.result.panelURL, ownerUsername: m.result.username, ownerPassword: m.result.displayPassword, accessLabel: connectMethodLabel(m.result.connectMethod), routedHosts: routed, localHint: m.result.connectMethod == connectLocal, }) } // showStatus is the re-run landing: prove the backend is up, then point the // operator at the panel without forcing any reconfiguration. func (m *rootModel) showStatus() (tea.Model, tea.Cmd) { m.stage = stageSummary method := connectLocal accessLabel := "configured (manage in panel)" if m.accessAud != "" { method = connectCloudflare accessLabel = connectMethodLabel(connectCloudflare) } m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain) return m.adopt(&summaryModel{ panelURL: m.result.panelURL, accessLabel: accessLabel, alreadySetUp: true, localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "", }) } func panelURLFor(method connectMethod, panelHostname, rootDomain string) string { if method != connectLocal && panelHostname != "" { return "https://" + panelHostname } return localPanelURL(rootDomain) }