diff --git a/cmd/felis/tui_bootstrap.go b/cmd/felis/tui_bootstrap.go index a028604..6227a8c 100644 --- a/cmd/felis/tui_bootstrap.go +++ b/cmd/felis/tui_bootstrap.go @@ -83,7 +83,15 @@ func (m *hostBootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *hostBootstrapModel) View() string { var b strings.Builder - b.WriteString(tuiHeader("Host Bootstrap")) + // 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: @@ -105,6 +113,9 @@ func (m *hostBootstrapModel) View() string { 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")) } @@ -128,7 +139,10 @@ func (m *hostBootstrapModel) runBootstrap() tea.Cmd { } func runHostBootstrapTUI(ctx context.Context) (bool, error) { - final, err := tea.NewProgram(newHostBootstrapModel(ctx)).Run() + // 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 } diff --git a/cmd/felis/tui_connect.go b/cmd/felis/tui_connect.go index 9db4dff..35a3ef5 100644 --- a/cmd/felis/tui_connect.go +++ b/cmd/felis/tui_connect.go @@ -43,12 +43,11 @@ func (m *connectChooserModel) build() *huh.Form { huh.NewOption("Cloudflare Tunnel + Access · no open ports", connectCloudflare), huh.NewOption("Reverse proxy (bring your own) · guided", connectReverseProxy), ), - huh.NewNote(). - Title("⚠ Security"). - Description( - "With Local or reverse proxy, anyone who can reach the admin hostname can attempt "+ - "login — the admin console is gated by your Owner password alone. "+ - "Cloudflare Access adds an edge check in front of it."), + // A dim, untitled footnote — deliberately subordinate to the picker above + // so the screen reads as a menu, not an info page. + huh.NewNote().Description( + "⚠ Local / reverse proxy gate the admin console on your Owner password alone. "+ + "Cloudflare Access adds an edge check in front."), ))) } @@ -113,6 +112,10 @@ func (m *connectChooserModel) chooseLocal() tea.Cmd { func (m *connectChooserModel) View() string { return m.form.View() } +// arrowNavOK lets the root repurpose ←/→ to walk the step rail: this screen +// navigates its options with ↑/↓, so the horizontal arrows are free. +func (m *connectChooserModel) arrowNavOK() bool { return true } + // ---- Reverse proxy: collect hostnames, record them, render a guide ---- type rpStep int diff --git a/cmd/felis/tui_edge.go b/cmd/felis/tui_edge.go index 9e8e5d3..b7b3bad 100644 --- a/cmd/felis/tui_edge.go +++ b/cmd/felis/tui_edge.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -12,34 +13,36 @@ import ( "felis.lolicon.best/internal/cfsetup" - "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" ) +// edgeModel publishes the panel via Cloudflare Tunnel + Access. It shares the +// wizard's chrome with every other connection screen: no banner of its own (the +// root paints the step rail), one huh form for input (the two credential/host +// groups), and the same locked, clear-screen styling as Local and Reverse-proxy. +// The intro/working/done/error states stay custom — they show status and actions +// rather than collect input — but they use the shared widgets so nothing reads +// as a separate popup. type egStep int const ( egIntro egStep = iota - egAuth - egConfig + egForm egWorking egDone egError ) -type egAuthDoneMsg struct { - token string - account string - err error -} - type egLoginDoneMsg struct{ err error } type egInstallDoneMsg struct{ err error } type egSetupDoneMsg struct { - result *cfsetup.Result - err error + result *cfsetup.Result + progress []string + err error } type edgeModel struct { @@ -49,39 +52,50 @@ type edgeModel struct { adminHostname string panelHostname string - // cloudflared detection + // cloudflared detection (intro gate) cloudflaredPath string certExists bool installing bool loginNote string - // auth step inputs - authInputs []textinput.Model - authFocus int - authErr string + // form-bound inputs (one huh form, two groups: credentials + hosts) + form *huh.Form authToken string authAccount string - - // config step inputs - cfgInputs []textinput.Model - cfgFocus int - cfgErr string + identity string + panelHost string + adminHost string + tunnelName string + cfgPath string // working / result + sp spinner.Model working string lastErr error prog []string result *cfsetup.Result panelSet string adminSet string + + width, height int } func newEdgeModel(rootDomain, adminHost, panelHost string) *edgeModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + m := &edgeModel{ step: egIntro, rootDomain: rootDomain, adminHostname: adminHost, panelHostname: panelHost, + sp: sp, + // Prefill the host/identity fields so the common case is enter-through. + panelHost: defaultPanelHostname(rootDomain, panelHost), + adminHost: defaultAdminHostname(rootDomain, adminHost), + tunnelName: defaultTunnelName, + cfgPath: defaultTunnelConfigPath, } m.detectCloudflared() return m @@ -93,8 +107,134 @@ func (m *edgeModel) detectCloudflared() { m.certExists = pre.CertExists } +// build assembles the single two-group form. Group 1 collects the API +// credentials; group 2 the hostnames and who Access admits. huh owns the +// inter-group navigation natively — enter/tab advances (and runs that group's +// validators first), shift+tab from the top of group 2 steps back to group 1, +// and esc aborts the whole form, which we treat as "back to the intro". This is +// why edge no longer hand-manages two separate input screens. +func (m *edgeModel) build() *huh.Form { + return m.sized(newFelisForm( + huh.NewGroup( + huh.NewNote(). + Title("Cloudflare credentials"). + Description("Create a scoped Bearer token (Account › Access: Edit) at:\n"+cloudflareAccessTokenTemplateURL), + huh.NewInput(). + Title("API token"). + Description("starts cfat_… — not your Global API Key"). + EchoMode(huh.EchoModePassword). + CharLimit(200). + Value(&m.authToken). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("a Cloudflare API token is required") + } + return nil + }), + huh.NewInput(). + Title("Account ID"). + Description("32-char hex from the dashboard URL: dash.cloudflare.com/"). + CharLimit(64). + Value(&m.authAccount). + Validate(validateAccountID), + ).Title("Step 1 · Credentials"), + huh.NewGroup( + huh.NewInput(). + Title("Admit"). + Description("Who Access lets in: your email, or @your-domain"). + CharLimit(254). + Value(&m.identity). + Validate(validateAccessIdentity), + huh.NewInput(). + Title("Player console hostname"). + CharLimit(253). + Value(&m.panelHost). + Validate(func(s string) error { + return validateEdgeHostname("player console", normalizeEdgeHostname(s), false) + }), + huh.NewInput(). + Title("Admin console hostname"). + Description("fronted by Cloudflare Access"). + CharLimit(253). + Value(&m.adminHost). + Validate(func(s string) error { + admin := normalizeEdgeHostname(s) + if err := validateEdgeHostname("admin console", admin, true); err != nil { + return err + } + if panel := normalizeEdgeHostname(m.panelHost); panel != "" && strings.EqualFold(panel, admin) { + return errors.New("player and admin console hostnames must be different") + } + return nil + }), + huh.NewInput(). + Title("Tunnel name"). + CharLimit(64). + Value(&m.tunnelName), + huh.NewInput(). + Title("Tunnel config path"). + CharLimit(256). + Value(&m.cfgPath), + ).Title("Step 2 · Hostnames & identity"), + )) +} + +// validateAccountID accepts the 32-char hex account id and gives a targeted nudge +// when the operator pastes the API token into the wrong field. +func validateAccountID(s string) error { + a := strings.TrimSpace(s) + if a == "" { + return errors.New("the Cloudflare account ID is required") + } + if !isHex32(a) { + if strings.HasPrefix(a, "cfat_") { + return errors.New("that looks like an API token — the Account ID is a 32-char hex string") + } + return errors.New("the Account ID must be 32 hex characters") + } + return nil +} + +// validateAccessIdentity accepts an email or an @domain wildcard. +func validateAccessIdentity(s string) error { + id := strings.TrimSpace(s) + if id == "" { + return errors.New("enter who Access should admit") + } + if strings.HasPrefix(id, "@") && strings.TrimPrefix(id, "@") == "" { + return errors.New("enter a domain after the @, e.g. @your-domain") + } + return nil +} + +func (m *edgeModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *edgeModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + func (m *edgeModel) Init() tea.Cmd { return nil } +// arrowNavOK yields ←/→ to the root's step rail only when no text field is +// focused: the intro and the terminal states take single-key actions, so the +// horizontal arrows are free, but while the form is up they belong to the cursor. +func (m *edgeModel) arrowNavOK() bool { + switch m.step { + case egForm, egWorking: + return false + default: + return true + } +} + func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case egLoginDoneMsg: @@ -118,18 +258,9 @@ func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case egAuthDoneMsg: - if msg.err != nil { - m.authErr = msg.err.Error() - return m, nil - } - m.authToken = msg.token - m.authAccount = msg.account - m.step = egConfig - return m, m.enterConfig() - case egSetupDoneMsg: m.working = "" + m.prog = msg.progress if msg.err != nil { m.step = egError m.lastErr = msg.err @@ -137,161 +268,177 @@ func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.step = egDone m.result = msg.result - if msg.result != nil { - m.prog = msg.result.Progress + return m, nil + + case spinner.TickMsg: + if m.step == egWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd } return m, nil case tea.KeyMsg: - return m.handleKey(msg) + switch m.step { + case egIntro: + return m.handleIntroKey(msg) + case egWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + case egDone: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, m.sendEdgeResult() + } + return m, nil + case egError: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "enter": + // Re-run with the same (still-valid) inputs. Setup is idempotent, + // so a retry recovers cleanly from a transient failure. + return m, m.startSetup() + case "esc": + m.step = egForm + m.lastErr, m.prog = nil, nil + m.form = m.build() + return m, m.form.Init() + } + return m, nil + case egForm: + // Intercept the exits before huh sees them: huh collapses esc and ctrl+c + // into a single StateAborted, so we can't tell them apart afterward. + // esc steps back to the intro/status screen; ctrl+c quits, matching every + // sibling screen. Any other key falls through to the form below. + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.step = egIntro + return m, nil + } + } + } + + if m.step == egForm && m.form != nil { + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + return m, m.startSetup() + case huh.StateAborted: + // esc on the form steps back to the intro/status screen. + m.step = egIntro + return m, nil + } + return m, cmd } return m, nil } func (m *edgeModel) View() string { - var b strings.Builder - b.WriteString(tuiHeader("Cloudflare Edge")) - switch m.step { - case egIntro: - b.WriteString(tuiHint.Render("Publish the panel via Cloudflare Tunnel + Access — no open ports, TLS and admin identity handled by Cloudflare. Press esc to pick a different method.") + "\n\n") - b.WriteString(tuiLabel.Render("Status") + "\n") - if m.cloudflaredPath == "" { - b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n") - } else { - b.WriteString(" " + tuiOK.Render("✓ cloudflared") + " " + tuiHint.Render(m.cloudflaredPath) + "\n") - if m.certExists { - b.WriteString(" " + tuiOK.Render("✓ logged in") + "\n\n") - } else { - b.WriteString(" " + tuiWarn.Render("⟳ not logged in") + " — press l for browser login\n\n") - } + case egForm: + if m.form == nil { + return "" } - if m.loginNote != "" { - b.WriteString(tuiHint.Render(m.loginNote) + "\n\n") - } - if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" { - b.WriteString(tuiHint.Render("Player console: "+panel) + "\n") - } - if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" { - b.WriteString(tuiHint.Render("Admin console: "+admin) + " (Access-guarded)\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - switch { - case m.cloudflaredPath != "" && m.certExists: - b.WriteString(tuiAction("enter", "continue", "esc", "back")) - case m.cloudflaredPath == "": - b.WriteString(tuiAction("i", "install cloudflared", "esc", "back")) - default: - b.WriteString(tuiAction("l", "login", "esc", "back")) - } - - case egAuth: - b.WriteString(tuiHint.Render("Step 1/2: Enter your Cloudflare credentials.") + "\n\n") - b.WriteString(tuiWizardCard("API Token & Account ID", - "Create a Bearer token at: "+cloudflareAccessTokenTemplateURL, - tuiFormField("API token", m.authInputs[0])+"\n\n"+ - tuiFormField("Account ID", m.authInputs[1]))) - b.WriteString("\n" + tuiInfo("Account ID is in the Cloudflare Dashboard URL: dash.cloudflare.com/") + "\n") - if m.authErr != "" { - b.WriteString("\n" + tuiErrorBanner(m.authErr) + "\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("tab/↑↓", "move", "enter", "continue", "esc", "back")) - - case egConfig: - b.WriteString(tuiHint.Render("Step 2/2: Choose hostnames and who gets access.") + "\n\n") - labels := []string{"Admit (your email, or @your-domain)", "Player console hostname", "Admin console hostname", "Tunnel name", "Config path"} - var fields string - for i, lbl := range labels { - if i > 0 { - fields += "\n\n" - } - fields += tuiFormField(lbl, m.cfgInputs[i]) - } - b.WriteString(tuiWizardCard("Hostnames & Identity", "", fields)) - if m.cfgErr != "" { - b.WriteString("\n" + tuiErrorBanner(m.cfgErr) + "\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("tab/↑↓", "move", "enter", "configure", "esc", "back")) + return m.form.View() case egWorking: - b.WriteString(tuiHint.Render("Configuring Cloudflare Tunnel + Access edge…") + "\n\n") - for _, s := range m.prog { - b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n") - } - if m.working != "" { - b.WriteString(" " + tuiIconSpin + " " + m.working + "\n") - } - if len(m.prog) == 0 && m.working == "" { - b.WriteString(tuiHint.Render("Starting…") + "\n") + msg := m.working + if msg == "" { + msg = "Configuring Cloudflare Tunnel + Access edge…" } + return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n" case egDone: - b.WriteString(tuiSuccessBanner("Cloudflare edge configured.") + "\n\n") - var box string - if m.result != nil { - box = tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n" - if len(m.result.RoutedHostnames) > 0 { - box += tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n" - } - if m.result.ConfigPath != "" { - box += tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n" - } - } - b.WriteString(tuiCardStyle.Render(box) + "\n\n") - b.WriteString(tuiOK.Render("✓") + " Felis config, Kubernetes Secret, API rollout and cloudflared service updated.\n") - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("enter/esc", "back")) + return m.doneView() case egError: - b.WriteString(tuiErrorBanner("Edge setup failed.") + "\n\n") - if len(m.prog) > 0 { - b.WriteString(tuiHint.Render("Completed before failure:") + "\n") - for _, s := range m.prog { - b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n") - } - b.WriteString("\n") + return m.errorView() + + default: + return m.introView() + } +} + +func (m *edgeModel) introView() string { + var b strings.Builder + b.WriteString(tuiHint.Render("Publish the panel via Cloudflare Tunnel + Access — no open ports; TLS and the admin identity check are handled at Cloudflare's edge.") + "\n\n") + b.WriteString(tuiLabel.Render("Status") + "\n") + if m.cloudflaredPath == "" { + b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n") + } else { + b.WriteString(" " + tuiOK.Render("✓ cloudflared") + " " + tuiHint.Render(m.cloudflaredPath) + "\n") + if m.certExists { + b.WriteString(" " + tuiOK.Render("✓ logged in") + "\n\n") + } else { + b.WriteString(" " + tuiWarn.Render("⟳ not logged in") + " — press l for browser login\n\n") } - if m.lastErr != nil { - b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("enter", "retry", "esc", "back")) + } + if m.installing { + b.WriteString(" " + tuiIconSpin + " " + tuiHint.Render("downloading cloudflared…") + "\n\n") + } + if m.loginNote != "" { + b.WriteString(tuiHint.Render(m.loginNote) + "\n\n") + } + if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" { + b.WriteString(tuiHint.Render("Player console: "+panel) + "\n") + } + if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" { + b.WriteString(tuiHint.Render("Admin console: "+admin) + tuiHint.Render(" (Access-guarded)") + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + switch { + case m.cloudflaredPath != "" && m.certExists: + b.WriteString(tuiAction("enter", "continue", "esc", "back")) + case m.cloudflaredPath == "": + b.WriteString(tuiAction("i", "install cloudflared", "esc", "back")) + default: + b.WriteString(tuiAction("l", "login", "esc", "back")) } return b.String() } -func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.step { - case egIntro: - return m.handleIntroKey(msg) - case egAuth: - return m.handleAuthKey(msg) - case egConfig: - return m.handleCfgKey(msg) - case egDone, egError: - switch msg.String() { - case "ctrl+c", "esc": - if m.step == egDone { - return m, m.sendEdgeResult() - } - return m, goBack() - case "enter": - if m.step == egDone { - return m, m.sendEdgeResult() - } - if m.step == egError { - m.step = egConfig - m.lastErr = nil - m.prog = nil - return m, nil - } - return m, nil +func (m *edgeModel) doneView() string { + var b strings.Builder + b.WriteString(tuiOK.Render("✓ Cloudflare edge configured.") + "\n\n") + var card strings.Builder + if m.result != nil { + card.WriteString(tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n") + if len(m.result.RoutedHostnames) > 0 { + card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n") + } + if m.result.ConfigPath != "" { + card.WriteString(tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n") } - default: } - return m, nil + b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n") + b.WriteString(tuiHint.Render("ℹ Felis config, Kubernetes Secret, API rollout and the cloudflared service were all updated.") + "\n") + b.WriteString("\n" + tuiAction("enter/esc", "continue")) + return b.String() +} + +func (m *edgeModel) errorView() string { + var b strings.Builder + b.WriteString(tuiErr.Render("✗ Edge setup failed.") + "\n\n") + if len(m.prog) > 0 { + b.WriteString(tuiHint.Render("Completed before the failure:") + "\n") + for _, s := range m.prog { + b.WriteString(" " + tuiOK.Render("✓") + " " + tuiHint.Render(s) + "\n") + } + b.WriteString("\n") + } + if m.lastErr != nil { + b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n") + } + b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit")) + return b.String() } func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -305,184 +452,41 @@ func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "l", "L": if m.cloudflaredPath != "" && !m.certExists { - nm, cmd := m.startLogin() - return nm, cmd + return m.startLogin() } case "enter": if m.cloudflaredPath != "" && m.certExists { - m.step = egAuth - return m, m.enterAuth() + m.step = egForm + m.form = m.build() + return m, m.form.Init() } } return m, nil } -func (m *edgeModel) handleAuthKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "esc": - m.step = egIntro - m.authErr = "" - return m, nil - case "tab", "down": - m.authFocus = (m.authFocus + 1) % 2 - return m, m.focusAuthInput(m.authFocus) - case "shift+tab", "up": - m.authFocus = (m.authFocus + 1) % 2 - return m, m.focusAuthInput(m.authFocus) - case "enter": - return m.submitAuth() - } - return m, m.updateAuthInputs(msg) -} - -func (m *edgeModel) handleCfgKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "esc": - m.step = egAuth - m.cfgErr = "" - return m, nil - case "tab", "down": - m.cfgFocus = (m.cfgFocus + 1) % 5 - return m, m.focusCfgInput(m.cfgFocus) - case "shift+tab", "up": - m.cfgFocus = (m.cfgFocus + 4) % 5 - return m, m.focusCfgInput(m.cfgFocus) - case "enter": - return m.submitConfig() - } - return m, m.updateCfgInputs(msg) -} - -func (m *edgeModel) enterAuth() tea.Cmd { - token := tuiInput("cfat_…", 200, true) - account := tuiInput("32-char account ID", 64, false) - m.authInputs = []textinput.Model{token, account} - m.authFocus = 0 - return m.focusAuthInput(0) -} - -func (m *edgeModel) focusAuthInput(i int) tea.Cmd { - var cmd tea.Cmd - for j := range m.authInputs { - if j == i { - cmd = m.authInputs[j].Focus() - } else { - m.authInputs[j].Blur() - } - } - return cmd -} - -func (m *edgeModel) updateAuthInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.authInputs)) - for i := range m.authInputs { - m.authInputs[i], cmds[i] = m.authInputs[i].Update(msg) - } - return tea.Batch(cmds...) -} - -func (m *edgeModel) submitAuth() (tea.Model, tea.Cmd) { - token := strings.TrimSpace(m.authInputs[0].Value()) - account := strings.TrimSpace(m.authInputs[1].Value()) - if token == "" { - m.authErr = "a Cloudflare API token is required" - return m, nil - } - if account == "" { - m.authErr = "the Cloudflare account ID is required" - return m, nil - } - if !isHex32(account) { - if strings.HasPrefix(account, "cfat_") { - m.authErr = "that looks like an API token — the Account ID is a 32-char hex string" - } else { - m.authErr = "the Account ID must be 32 hex characters" - } - return m, nil - } - m.authErr = "" - return m, func() tea.Msg { return egAuthDoneMsg{token: token, account: account} } -} - -func (m *edgeModel) enterConfig() tea.Cmd { - identity := tuiInput("you@example.com or @your-domain", 254, false) - panelHost := tuiInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false) - panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname)) - adminHost := tuiInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false) - adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname)) - tunnel := tuiInput(defaultTunnelName, 64, false) - tunnel.SetValue(defaultTunnelName) - cfgPath := tuiInput(defaultTunnelConfigPath, 256, false) - cfgPath.SetValue(defaultTunnelConfigPath) - m.cfgInputs = []textinput.Model{identity, panelHost, adminHost, tunnel, cfgPath} - m.cfgFocus = 0 - return m.focusCfgInput(0) -} - -func (m *edgeModel) focusCfgInput(i int) tea.Cmd { - var cmd tea.Cmd - for j := range m.cfgInputs { - if j == i { - cmd = m.cfgInputs[j].Focus() - } else { - m.cfgInputs[j].Blur() - } - } - return cmd -} - -func (m *edgeModel) updateCfgInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.cfgInputs)) - for i := range m.cfgInputs { - m.cfgInputs[i], cmds[i] = m.cfgInputs[i].Update(msg) - } - return tea.Batch(cmds...) -} - -func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) { - identity := strings.TrimSpace(m.cfgInputs[0].Value()) - panelHost := normalizeEdgeHostname(m.cfgInputs[1].Value()) - adminHost := normalizeEdgeHostname(m.cfgInputs[2].Value()) - tunnel := strings.TrimSpace(m.cfgInputs[3].Value()) - cfgPath := strings.TrimSpace(m.cfgInputs[4].Value()) - - if identity == "" { - m.cfgErr = "enter who Access should admit" - m.cfgFocus = 0 - return m, nil - } - if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" { - m.cfgErr = "enter a domain after the @, e.g. @your-domain" - m.cfgFocus = 0 - return m, nil - } - if err := validateEdgeHostname("player console", panelHost, false); err != nil { - m.cfgErr = err.Error() - m.cfgFocus = 1 - return m, nil - } - if err := validateEdgeHostname("admin console", adminHost, true); err != nil { - m.cfgErr = err.Error() - m.cfgFocus = 2 - return m, nil - } - if panelHost != "" && strings.EqualFold(panelHost, adminHost) { - m.cfgErr = "player console and admin console hostnames must be different" - m.cfgFocus = 2 - return m, nil - } +// startSetup reads the form-bound fields (already validated by huh), normalizes +// the hostnames, applies the tunnel/config defaults, and kicks off the live +// Cloudflare run. It is shared by the form-completed path and the error retry, so +// a retry re-runs against the same inputs without a re-entry. +func (m *edgeModel) startSetup() tea.Cmd { + m.panelHost = normalizeEdgeHostname(m.panelHost) + m.adminHost = normalizeEdgeHostname(m.adminHost) + identity := strings.TrimSpace(m.identity) + tunnel := strings.TrimSpace(m.tunnelName) if tunnel == "" { tunnel = defaultTunnelName } + cfgPath := strings.TrimSpace(m.cfgPath) if cfgPath == "" { cfgPath = defaultTunnelConfigPath } + m.tunnelName, m.cfgPath = tunnel, cfgPath - m.panelSet = panelHost - m.adminSet = adminHost - m.cfgErr = "" + m.panelSet, m.adminSet = m.panelHost, m.adminHost + m.prog = nil + m.lastErr = nil m.step = egWorking - m.working = "Starting…" + m.working = "Configuring Cloudflare Tunnel + Access edge…" var id cfsetup.AccessIdentity if strings.HasPrefix(identity, "@") { @@ -497,8 +501,8 @@ func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) { AccountID: m.authAccount, } p := cfsetup.Params{ - PanelHostname: panelHost, - AdminHostname: adminHost, + PanelHostname: m.panelHost, + AdminHostname: m.adminHost, PanelOrigin: localPanelOrigin(), TunnelName: tunnel, ConfigPath: cfgPath, @@ -506,7 +510,7 @@ func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) { Pre: cfsetup.DetectPreconditions(m.authToken), } - return m, m.runEdgeSetup(runner, p) + return tea.Batch(m.sp.Tick, m.runEdgeSetup(runner, p)) } func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cmd { @@ -517,15 +521,15 @@ func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cm return func() tea.Msg { result, err := cfsetup.Setup(context.Background(), runner, p) if err != nil { - return egSetupDoneMsg{err: err} + return egSetupDoneMsg{err: err, progress: progress} } progress = append(progress, "Applying Felis config and starting cloudflared…") if err := applyCloudflareEdge(context.Background(), result, p.PanelHostname, p.AdminHostname, m.cloudflaredPath); err != nil { - return egSetupDoneMsg{err: err} + return egSetupDoneMsg{err: err, progress: progress} } progress = append(progress, "Updated Felis config and started cloudflared") result.Progress = progress - return egSetupDoneMsg{result: result} + return egSetupDoneMsg{result: result, progress: progress} } } diff --git a/cmd/felis/tui_height_measure_test.go b/cmd/felis/tui_height_measure_test.go index 315f303..e4f7d76 100644 --- a/cmd/felis/tui_height_measure_test.go +++ b/cmd/felis/tui_height_measure_test.go @@ -1,6 +1,7 @@ package main import ( + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -39,3 +40,139 @@ func TestWizardViewsFitTerminal(t *testing.T) { } } } + +// TestEdgeFormFitsTerminal covers the screen the existing sweep can't reach: the +// Cloudflare edge form. The chooser only adopts the edge model when "Cloudflare" +// is picked, and its form only opens once cloudflared + the login cert are +// present — neither is available in CI — so the connectLocal sweep above never +// constructs it. Here we adopt a cloudflared-satisfied edge model through the +// root (so the real step-rail chrome is in the budget) and measure BOTH form +// groups: credentials (group 1) and the taller hostnames group (group 2, five +// inputs). huh clamps each group to a scrolling viewport, but a tall group title +// or help footer can still push the composed view past the terminal — which is +// exactly the clipping regression this guards. +func TestEdgeFormFitsTerminal(t *testing.T) { + for _, w := range []int{60, 80, 90} { + for _, h := range []int{24, 30, 45} { + root := newTestRoot(false, consoleModeSetup, "") + root = drive(t, root, tea.WindowSizeMsg{Width: w, Height: h}) + root.stage = stageConnect + + edge := newEdgeModel("felis.example.com", "admin.felis.example.com", "panel.felis.example.com") + // Pretend the operator already installed cloudflared + logged in so the + // intro lets us open the form without a live cloudflared/cert. + edge.cloudflaredPath = "/usr/local/bin/cloudflared" + edge.certExists = true + root.adopt(edge) // sizes the edge model with the post-rail budget + + // intro → form (group 1: credentials) + root = drive(t, root, key(tea.KeyEnter)) + if edge.step != egForm { + t.Fatalf("terminal %dx%d: enter on a ready intro should open the form, step = %v", w, h, edge.step) + } + // huh clamps every group to a scrolling viewport sized to the budget, so + // the rendered height is the same whether the viewport content has been + // built yet or not — measuring the freshly-opened frame is a faithful + // height check. The group title renders outside the viewport, so it is + // present even before content builds, which is how we confirm we are on + // the right group. + if v := root.View(); !strings.Contains(v, "Step 1 · Credentials") { + t.Fatalf("terminal %dx%d: form should open on the credentials group, got:\n%s", w, h, v) + } + if got := lipgloss.Height(root.View()); got > h { + t.Errorf("terminal %dx%d: edge credentials view = %d rows (exceeds height)", w, h, got) + } + + // Advance to the taller hostnames group (5 inputs). huh advances groups + // natively when the current group has no errors; NextGroup mutates the + // form in place, and edge holds the same form pointer the root renders. + edge.form.NextGroup() + if v := root.View(); !strings.Contains(v, "Step 2 · Hostnames & identity") { + t.Fatalf("terminal %dx%d: NextGroup should reveal the hostnames group, got:\n%s", w, h, v) + } + if got := lipgloss.Height(root.View()); got > h { + t.Errorf("terminal %dx%d: edge hostnames view = %d rows (exceeds height)", w, h, got) + } + } + } +} + +// TestEdgeValidators locks the input-validation logic the edge form relies on — +// the part that decides whether a friend's real run is accepted or rejected +// before any Cloudflare call. These are the validators wired into the huh fields; +// testing them directly is honest coverage that does not depend on driving huh. +func TestEdgeValidators(t *testing.T) { + accountCases := []struct { + in string + ok bool + }{ + {"", false}, + {"0123456789abcdef0123456789abcdef", true}, + {"0123456789ABCDEF0123456789abcdef", true}, + {"cfat_looks_like_a_token", false}, // token pasted into the account field + {"too-short", false}, + {"0123456789abcdef0123456789abcde", false}, // 31 chars + } + for _, c := range accountCases { + if err := validateAccountID(c.in); (err == nil) != c.ok { + t.Errorf("validateAccountID(%q): ok=%v, err=%v", c.in, c.ok, err) + } + } + + identityCases := []struct { + in string + ok bool + }{ + {"", false}, + {"@", false}, // bare @ with no domain + {"you@example.com", true}, + {"@your-domain.com", true}, + } + for _, c := range identityCases { + if err := validateAccessIdentity(c.in); (err == nil) != c.ok { + t.Errorf("validateAccessIdentity(%q): ok=%v, err=%v", c.in, c.ok, err) + } + } +} + +// TestEdgeFormExitKeys locks the form's exit keys, which huh cannot disambiguate +// on its own: it collapses esc and ctrl+c into a single StateAborted, so the edge +// model must intercept them before delegating. esc steps back to the intro/status +// screen (matching reverseProxyModel's esc=back); ctrl+c quits like every sibling +// screen. Without this, ctrl+c from the form would fall through huh's abort path +// and return to the intro instead of quitting, and a failed setup could strand the +// user on the form. The keys only matter live, so the contract lives here. +func TestEdgeFormExitKeys(t *testing.T) { + open := func(t *testing.T) *edgeModel { + t.Helper() + e := newEdgeModel("felis.example.com", "admin.felis.example.com", "panel.felis.example.com") + e.cloudflaredPath = "/usr/local/bin/cloudflared" + e.certExists = true + next, _ := e.Update(key(tea.KeyEnter)) // ready intro → form + em, ok := next.(*edgeModel) + if !ok { + t.Fatalf("intro enter returned %T, want *edgeModel", next) + } + if em.step != egForm { + t.Fatalf("intro enter should open the form, step = %v", em.step) + } + return em + } + + // esc steps back to the intro/status screen — not quit, not stuck on the form. + e := open(t) + next, _ := e.Update(key(tea.KeyEsc)) + if got := next.(*edgeModel).step; got != egIntro { + t.Fatalf("esc on the edge form should return to the intro, step = %v", got) + } + + // ctrl+c quits, like every sibling screen. + e = open(t) + _, cmd := e.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatal("ctrl+c on the edge form should return a command (tea.Quit)") + } + if msg := cmd(); !isQuit(msg) { + t.Fatalf("ctrl+c command = %T, want tea.Quit", msg) + } +} diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index a1026b1..8a98bc3 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -2,6 +2,7 @@ package main import ( "context" + "strings" "felis.lolicon.best/internal/cfsetup" @@ -17,6 +18,14 @@ 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 @@ -82,12 +91,23 @@ const ( stageSummary ) +// 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 @@ -108,6 +128,7 @@ type rootModel struct { 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, @@ -138,6 +159,15 @@ func (m *rootModel) Init() tea.Cmd { } 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 @@ -199,10 +229,91 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 { @@ -227,7 +338,7 @@ func (m *rootModel) chromeHeight() int { if m.mode != consoleModeSetup || m.adminExistsAtStart() { return 0 } - return lipgloss.Height(m.rail()) + 1 + return lipgloss.Height(m.railWithHint()) + 1 } func (m *rootModel) View() string { @@ -235,7 +346,11 @@ func (m *rootModel) View() string { return "" } if m.mode == consoleModeSetup && !m.adminExistsAtStart() { - return m.rail() + "\n\n" + m.screen.View() + body := m.screen.View() + if m.reviewing >= 0 { + body = m.reviewBody(m.reviewing) + } + return m.railWithHint() + "\n\n" + body } return m.screen.View() } @@ -250,7 +365,25 @@ func (m *rootModel) adminExistsAtStart() bool { } func (m *rootModel) rail() string { - return tuiStepRail([]string{"Preflight", "Owner", "Connection", "Done"}, int(m.stage)) + // 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. diff --git a/cmd/felis/tui_root_test.go b/cmd/felis/tui_root_test.go index 30ec4db..6e51954 100644 --- a/cmd/felis/tui_root_test.go +++ b/cmd/felis/tui_root_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -174,6 +175,93 @@ func TestRootBreakGlassQuitsAfterOwner(t *testing.T) { } } +func key(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } + +// TestRootRailReviewNavigation locks the ←/→ rail-walk added for ergonomics: +// from a yielding screen ← steps back through completed stages read-only, +// → / esc return to the live screen, and ← is ignored on text-input screens +// (which need the arrow for their cursor). A screenshot can't verify this — the +// keys only matter live — so the contract lives here. +func TestRootRailReviewNavigation(t *testing.T) { + m := newTestRoot(false, consoleModeSetup, "") + m = drive(t, m, preflightDoneMsg{}) + + // On the Owner screen (text inputs) ← must NOT hijack the arrow: it stays + // with the field, so we remain on the live screen. + m = drive(t, m, key(tea.KeyLeft)) + if m.reviewing != -1 { + t.Fatalf("← on the owner (text-input) screen entered review (%d); arrows belong to the field", m.reviewing) + } + + // Advance to the Connection chooser (a select — it yields ←/→). + m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"}) + if m.reviewing != -1 { + t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing) + } + + // ← walks back to Owner (read-only recap), then Preflight, then clamps. + m = drive(t, m, key(tea.KeyLeft)) + if m.reviewing != int(stageOwner) { + t.Fatalf("first ← = stage %d, want stageOwner %d", m.reviewing, stageOwner) + } + if v := m.View(); !strings.Contains(v, "Owner account") || !strings.Contains(v, "username") { + t.Fatalf("owner review body missing recap, got:\n%s", v) + } + m = drive(t, m, key(tea.KeyLeft)) + if m.reviewing != int(stagePreflight) { + t.Fatalf("second ← = stage %d, want stagePreflight %d", m.reviewing, stagePreflight) + } + m = drive(t, m, key(tea.KeyLeft)) + if m.reviewing != int(stagePreflight) { + t.Fatalf("← past the first step should clamp, got %d", m.reviewing) + } + + // → walks forward; stepping past the last completed step returns to live. + m = drive(t, m, key(tea.KeyRight)) + if m.reviewing != int(stageOwner) { + t.Fatalf("→ = stage %d, want stageOwner %d", m.reviewing, stageOwner) + } + m = drive(t, m, key(tea.KeyRight)) + if m.reviewing != -1 { + t.Fatalf("→ past the last completed step should return live, reviewing = %d", m.reviewing) + } + if v := m.View(); !strings.Contains(v, "reach the panel") { + t.Fatalf("returning live should show the chooser, got:\n%s", v) + } + + // esc is an immediate escape hatch back to the live screen. + m = drive(t, m, key(tea.KeyLeft)) + if m.reviewing < 0 { + t.Fatalf("← should re-enter review") + } + m = drive(t, m, key(tea.KeyEsc)) + if m.reviewing != -1 { + t.Fatalf("esc should return to the live screen, reviewing = %d", m.reviewing) + } +} + +// TestSetupRailSpansBootstrap locks the cross-program progress rail: the +// host-bootstrap screen shows Bootstrap as the live step 1, and once the wizard +// takes over Bootstrap is carried as a completed (✓) step ahead of the live one. +// This is what makes the rail read as one continuous bar across the two separate +// bubbletea programs instead of restarting when the wizard launches. +func TestSetupRailSpansBootstrap(t *testing.T) { + boot := newHostBootstrapModel(context.Background()) + if v := boot.View(); !strings.Contains(v, "1. Bootstrap") || !strings.Contains(v, "Preflight") { + t.Fatalf("bootstrap screen should show the shared rail with Bootstrap as step 1, got:\n%s", v) + } + + m := newTestRoot(false, consoleModeSetup, "") + m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30}) + m = drive(t, m, preflightDoneMsg{}) + if _, ok := m.screen.(*ownerModel); !ok { + t.Fatalf("expected owner screen after preflight, got %T", m.screen) + } + if v := m.View(); !strings.Contains(v, "✓ Bootstrap") { + t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v) + } +} + // isQuit reports whether a command's message is tea.Quit's sentinel. tea.Quit // returns an unexported tea.quitMsg, so compare against the documented value // produced by calling tea.Quit itself. diff --git a/cmd/felis/tui_styles.go b/cmd/felis/tui_styles.go index 2dc751e..48fb64e 100644 --- a/cmd/felis/tui_styles.go +++ b/cmd/felis/tui_styles.go @@ -14,20 +14,6 @@ var ( cBgInput = lipgloss.Color("235") // input field bg cWhite = lipgloss.Color("15") - // Title bar — inverted primary - tuiTitle = lipgloss.NewStyle(). - Bold(true). - Foreground(cWhite). - Background(cPrimary). - Padding(0, 2). - Width(70) - - // Section header - tuiSection = lipgloss.NewStyle(). - Bold(true). - Foreground(cPrimary). - Padding(0, 1) - // Card styles tuiCardStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go index e5ccf76..9719c9c 100644 --- a/cmd/felis/tui_summary.go +++ b/cmd/felis/tui_summary.go @@ -23,6 +23,10 @@ type summaryModel struct { func (m *summaryModel) Init() tea.Cmd { return nil } +// arrowNavOK lets the root repurpose ←/→ to walk back through completed steps; +// the summary takes no text input, so the horizontal arrows are free. +func (m *summaryModel) arrowNavOK() bool { return true } + func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { diff --git a/cmd/felis/tui_theme.go b/cmd/felis/tui_theme.go index 3617eac..9315b14 100644 --- a/cmd/felis/tui_theme.go +++ b/cmd/felis/tui_theme.go @@ -22,7 +22,12 @@ func felisTheme() *huh.Theme { f.ErrorMessage = lipgloss.NewStyle().Foreground(cError) f.SelectSelector = lipgloss.NewStyle().Foreground(cPrimary).SetString("▸ ") - f.Option = lipgloss.NewStyle().Foreground(cWhite) + // Unselected rows recede to dim and the active row is bright cyan + bold, so + // the list reads at a glance as a picker with one row chosen — not a wall of + // equally-lit info text. huh's single-select paints UnselectedOption (not + // Option), which ThemeBase leaves bright; overriding it is the key fix. + f.Option = lipgloss.NewStyle().Foreground(cDim) + f.UnselectedOption = lipgloss.NewStyle().Foreground(cDim) f.SelectedOption = lipgloss.NewStyle().Foreground(cPrimary).Bold(true) f.NextIndicator = lipgloss.NewStyle().Foreground(cAccent).MarginLeft(1).SetString("→") f.PrevIndicator = lipgloss.NewStyle().Foreground(cAccent).MarginRight(1).SetString("←") diff --git a/cmd/felis/tui_widgets.go b/cmd/felis/tui_widgets.go index dadc93c..4e9b61a 100644 --- a/cmd/felis/tui_widgets.go +++ b/cmd/felis/tui_widgets.go @@ -3,15 +3,8 @@ package main import ( "fmt" "strings" - - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/lipgloss" ) -func tuiHeader(title string) string { - return tuiTitle.Render("🐾 "+title) + "\n\n" -} - func tuiAction(pairs ...string) string { var parts []string for i := 0; i+1 < len(pairs); i += 2 { @@ -20,28 +13,10 @@ func tuiAction(pairs ...string) string { return tuiActionBar.Render(strings.Join(parts, " · ")) } -func tuiFormField(label string, input textinput.Model) string { - return fmt.Sprintf("%s\n%s", - tuiLabel.Render(label), - input.View()) -} - func tuiInfo(text string) string { return tuiInfoBox.Render(tuiHint.Render("ℹ " + text)) } -func tuiWizardCard(title, desc, body string) string { - var b strings.Builder - b.WriteString(tuiSection.Render(title)) - if desc != "" { - b.WriteString("\n") - b.WriteString(tuiHint.Render(desc)) - } - b.WriteString("\n\n") - b.WriteString(tuiCardStyle.Render(body)) - return b.String() -} - func tuiErrorBanner(msg string) string { return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg)) } @@ -50,20 +25,6 @@ func tuiSuccessBanner(msg string) string { return tuiCardFocusedStyle.Render(tuiOK.Render("✓ " + msg)) } -func tuiInput(placeholder string, charLimit int, password bool) textinput.Model { - ti := textinput.New() - ti.Placeholder = placeholder - ti.CharLimit = charLimit - ti.Width = 44 - ti.Prompt = "" - ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(cWhite) - if password { - ti.EchoMode = textinput.EchoPassword - ti.EchoCharacter = '•' - } - return ti -} - func tuiSeparator() string { return tuiHint.Render(strings.Repeat("─", 70)) } diff --git a/internal/cfsetup/runner.go b/internal/cfsetup/runner.go index 42a3d2c..43e611f 100644 --- a/internal/cfsetup/runner.go +++ b/internal/cfsetup/runner.go @@ -72,6 +72,28 @@ func (r *ExecRunner) apiBase() string { return defaultAPIBase } +// VerifyAPIToken satisfies the apiTokenVerifier seam Setup probes for: it does a +// read-only Cloudflare API call so a bad, expired, wrong-account or +// under-permissioned token fails BEFORE any tunnel/DNS/config is created, instead +// of surfacing late at CreateAccessApplication with a half-built edge left behind. +// +// It lists Access apps (per_page=1 — the cheapest authenticated read) against the +// exact account and permission Setup will write to, so a green result means the +// credential that actually gates the side-effecting calls works. The tunnel and +// DNS authenticate via cert.pem, not this token, so the Access read is precisely +// the credential worth pre-checking. apiGet surfaces 401/403 with the actionable +// permission checklist; this method only adds the cheap pre-flight argument +// validation so an empty token/account never reaches the wire. +func (r *ExecRunner) VerifyAPIToken(ctx context.Context) error { + if strings.TrimSpace(r.APIToken) == "" { + return fmt.Errorf("cfsetup: Cloudflare API token is required") + } + if strings.TrimSpace(r.AccountID) == "" { + return fmt.Errorf("cfsetup: Cloudflare account ID is required") + } + return r.apiGet(ctx, fmt.Sprintf("/accounts/%s/access/apps?per_page=1", r.AccountID), nil) +} + // tunnelIDRE extracts the UUID cloudflared prints when a tunnel is created or // already exists. var tunnelIDRE = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) diff --git a/internal/cfsetup/runner_test.go b/internal/cfsetup/runner_test.go new file mode 100644 index 0000000..b96f995 --- /dev/null +++ b/internal/cfsetup/runner_test.go @@ -0,0 +1,118 @@ +package cfsetup + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// These exercise ExecRunner.VerifyAPIToken directly against an httptest server. +// This is the load-bearing coverage for "is the CF path usable": Setup only runs +// the pre-flight token check when the runner satisfies apiTokenVerifier, and the +// existing TestSetupVerifiesAPITokenBeforeCloudflareMutations only proves a *fake* +// runner is consulted — it says nothing about whether the production ExecRunner +// actually verifies anything. Without these, the token never gets checked before +// the tunnel and DNS are created on a real account. + +// TestExecRunnerVerifyAPITokenHitsAccessApps confirms the happy path probes the +// exact account + Access-read permission Setup needs, with the Bearer token, and +// returns nil when Cloudflare answers success. +func TestExecRunnerVerifyAPITokenHitsAccessApps(t *testing.T) { + const account = "0123456789abcdef0123456789abcdef" + var gotPath, gotAuth, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true,"errors":[],"result":[]}`)) + })) + defer srv.Close() + + r := &ExecRunner{APIToken: "cfat_secret", AccountID: account, APIBase: srv.URL} + if err := r.VerifyAPIToken(context.Background()); err != nil { + t.Fatalf("VerifyAPIToken on a good token = %v, want nil", err) + } + if want := "/accounts/" + account + "/access/apps"; gotPath != want { + t.Fatalf("verify hit path %q, want %q", gotPath, want) + } + if !strings.Contains(gotQuery, "per_page=1") { + t.Fatalf("verify query = %q, want it to request a single page (per_page=1)", gotQuery) + } + if gotAuth != "Bearer cfat_secret" { + t.Fatalf("verify Authorization = %q, want Bearer token", gotAuth) + } +} + +// TestExecRunnerVerifyAPITokenSurfaces401 locks that an invalid/expired token is +// reported with the actionable permission checklist apiGet renders — this is the +// message the operator sees BEFORE anything is created, which is the whole point. +func TestExecRunnerVerifyAPITokenSurfaces401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}`)) + })) + defer srv.Close() + + r := &ExecRunner{APIToken: "bad", AccountID: "0123456789abcdef0123456789abcdef", APIBase: srv.URL} + err := r.VerifyAPIToken(context.Background()) + if err == nil { + t.Fatal("VerifyAPIToken on a 401 = nil, want an error") + } + if !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "Bearer API Token") { + t.Fatalf("401 error should carry the actionable checklist, got: %v", err) + } +} + +// TestExecRunnerVerifyAPITokenSurfaces403 locks that a wrong-account or +// under-permissioned token names the account in the guidance. +func TestExecRunnerVerifyAPITokenSurfaces403(t *testing.T) { + const account = "ffffffffffffffffffffffffffffffff" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":9109,"message":"Unauthorized to access requested resource"}]}`)) + })) + defer srv.Close() + + r := &ExecRunner{APIToken: "scoped-wrong", AccountID: account, APIBase: srv.URL} + err := r.VerifyAPIToken(context.Background()) + if err == nil { + t.Fatal("VerifyAPIToken on a 403 = nil, want an error") + } + if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), account) { + t.Fatalf("403 error should name the account, got: %v", err) + } +} + +// TestExecRunnerVerifyAPITokenPreflight confirms empty credentials fail without a +// network call — the http handler would panic the test if it were reached. +func TestExecRunnerVerifyAPITokenPreflight(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("VerifyAPIToken must not hit the wire when credentials are empty") + })) + defer srv.Close() + + cases := []struct{ token, account string }{ + {"", "0123456789abcdef0123456789abcdef"}, + {"cfat_x", ""}, + } + for _, c := range cases { + r := &ExecRunner{APIToken: c.token, AccountID: c.account, APIBase: srv.URL} + if err := r.VerifyAPIToken(context.Background()); err == nil { + t.Fatalf("VerifyAPIToken(token=%q account=%q) = nil, want a pre-flight error", c.token, c.account) + } + } +} + +// TestExecRunnerSatisfiesAPITokenVerifier is a compile-time-ish guard that the +// production runner actually implements the seam Setup probes for. If someone +// renames or changes the method signature, Setup would silently skip the check +// again (the bug this whole effort fixes); this fails loudly instead. +func TestExecRunnerSatisfiesAPITokenVerifier(t *testing.T) { + var r Runner = &ExecRunner{} + if _, ok := r.(apiTokenVerifier); !ok { + t.Fatal("ExecRunner must implement apiTokenVerifier so Setup verifies the token before side effects") + } +}