Unverified Commit 346ec68e authored by Lemon-miaow's avatar Lemon-miaow
Browse files

refactor(deploy): improved cloudflare walkthrough

parent a94b0015
Loading
Loading
Loading
Loading
+16 −2
Changes for cmd/felis/tui_bootstrap.go: 16 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for cmd/felis/tui_connect.go: 9 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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
+300 −296

File changed.

Preview size limit exceeded, changes collapsed.

+137 −0
Changes for cmd/felis/tui_height_measure_test.go: 137 added lines, 0 removed lines.
Original line number Diff line number Diff line
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
Changes for cmd/felis/tui_root.go: 136 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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.
Loading