refactor(deploy): improved cloudflare walkthrough

This commit is contained in:
Lemon-miaow committed 2026-06-30 04:20:36 +08:00
1 parent a94b0015c4
commit 346ec68e51
12 files changed
+869 -394

No files matched your search

+16 -2
View File
@@ -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
}
+9 -6
View File
@@ -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
+333 -329
View File
@@ -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/<this>").
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/<this-part>") + "\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("[email protected] 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}
}
}
+137
View File
@@ -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
{"[email protected]", 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)
}
}
+136 -3
View File
@@ -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.
+88
View File
@@ -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.
-14
View File
@@ -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()).
+4
View File
@@ -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() {
+6 -1
View File
@@ -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("←")
-39
View File
@@ -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))
}
+22
View File
@@ -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}`)
+118
View File
@@ -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")
}
}