From 28c3eee361c7403e74be4fb33e8f27c7fb78cb48 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Tue, 30 Jun 2026 02:25:18 +0800 Subject: [PATCH] refactor(deploy): improved TUI walkthrough --- cmd/felis/breakglass.go | 22 +- cmd/felis/setup.go | 29 +- cmd/felis/tui_bootstrap.go | 2 +- cmd/felis/tui_connect.go | 349 +++++++++++++++++++ cmd/felis/tui_dashboard.go | 139 -------- cmd/felis/tui_edge.go | 11 +- cmd/felis/tui_edge_apply.go | 38 ++- cmd/felis/tui_height_measure_test.go | 41 +++ cmd/felis/tui_migration.go | 138 ++------ cmd/felis/tui_owner.go | 485 ++++++++++++++------------- cmd/felis/tui_postgres.go | 175 +--------- cmd/felis/tui_preflight.go | 237 +++++++++++++ cmd/felis/tui_root.go | 352 +++++++++++-------- cmd/felis/tui_root_test.go | 183 ++++++++++ cmd/felis/tui_styles.go | 43 +-- cmd/felis/tui_summary.go | 73 ++++ cmd/felis/tui_theme.go | 69 ++++ cmd/felis/tui_widgets.go | 86 ++--- go.mod | 9 +- go.sum | 34 +- 20 files changed, 1580 insertions(+), 935 deletions(-) create mode 100644 cmd/felis/tui_connect.go delete mode 100644 cmd/felis/tui_dashboard.go create mode 100644 cmd/felis/tui_height_measure_test.go create mode 100644 cmd/felis/tui_preflight.go create mode 100644 cmd/felis/tui_root_test.go create mode 100644 cmd/felis/tui_summary.go create mode 100644 cmd/felis/tui_theme.go diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 1c2a49e..2241e35 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -125,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 1 } - res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists) + res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 @@ -411,7 +411,13 @@ type breakGlassResult struct { adminHostname string panelURL string - // optional Cloudflare edge outcome (independent of provisioned) + // connection outcome (independent of provisioned) + connectMethod connectMethod + connectConfigured bool + panelHostname string + reverseProxyGuide string + + // Cloudflare-specific edge detail (set only when connectMethod is Cloudflare) edgeConfigured bool edgeAud string edgeRoutedHosts []string @@ -438,16 +444,16 @@ const ( cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" ) -func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { - return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass) +func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { + return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeBreakGlass) } -func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { - return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup) +func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { + return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeSetup) } -func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { - rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode) +func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { + rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, mode) final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 2288263..d21b3e0 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -84,7 +84,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } defer setup.drv.Close() - res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, accountableOSUser(), setup.adminExists) + res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, accountableOSUser(), setup.adminExists) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 @@ -94,9 +94,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { panelURL = localPanelURL(setup.cfg.Server.RootDomain) } - if !res.provisioned && !res.edgeConfigured { + if !res.provisioned && !res.connectConfigured { if bootstrapped { - fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/edge setup skipped.") + fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection setup skipped.") if panelURL != "" { fmt.Fprintf(stdout, "Panel: %s\n", panelURL) fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.") @@ -127,15 +127,22 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } } - if res.edgeConfigured { - fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access edge configured.\n") - if len(res.edgeRoutedHosts) > 0 { - fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", ")) + if res.connectConfigured { + switch res.connectMethod { + case connectCloudflare: + fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access configured.\n") + if len(res.edgeRoutedHosts) > 0 { + fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", ")) + } + if res.edgeConfigPath != "" { + fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath) + } + fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.") + case connectReverseProxy: + fmt.Fprintf(stdout, "\nfelis setup: reverse-proxy front configured. Point your proxy at the origin:\n\n") + fmt.Fprintln(stdout, res.reverseProxyGuide) + fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") } - if res.edgeConfigPath != "" { - fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath) - } - fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.") } return 0 } diff --git a/cmd/felis/tui_bootstrap.go b/cmd/felis/tui_bootstrap.go index 792ed81..a028604 100644 --- a/cmd/felis/tui_bootstrap.go +++ b/cmd/felis/tui_bootstrap.go @@ -97,7 +97,7 @@ func (m *hostBootstrapModel) View() string { b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n") case hostBootstrapDone: b.WriteString(tuiSuccessBanner("Host bootstrap completed.") + "\n\n") - b.WriteString(tuiInfo("Continue to create the Owner account and optional Cloudflare edge.") + "\n") + b.WriteString(tuiInfo("Continue to create the Owner account and choose how the panel is reached.") + "\n") b.WriteString("\n" + tuiSeparator() + "\n") b.WriteString(tuiAction("enter", "continue", "esc", "continue")) case hostBootstrapError: diff --git a/cmd/felis/tui_connect.go b/cmd/felis/tui_connect.go new file mode 100644 index 0000000..9db4dff --- /dev/null +++ b/cmd/felis/tui_connect.go @@ -0,0 +1,349 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +// connectChooserModel presents the ways to reach the panel as peer choices. +// None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a +// turnkey integration, and "Reverse proxy" just records hostnames and hands the +// operator a copy-paste guide. The admin console is gated by the Owner's +// local-password session regardless; Cloudflare Access is an *additional* layer. +type connectChooserModel struct { + rootDomain string + adminHost string + panelHost string + + form *huh.Form + choice connectMethod + width, height int +} + +func newConnectChooserModel(rootDomain, adminHost, panelHost string) *connectChooserModel { + m := &connectChooserModel{rootDomain: rootDomain, adminHost: adminHost, panelHost: panelHost} + m.form = m.build() + return m +} + +func (m *connectChooserModel) build() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewSelect[connectMethod](). + Title("How should people reach the panel?"). + Description("You can change this later in the panel."). + Value(&m.choice). + Options( + huh.NewOption("Local only · nothing installed", connectLocal), + 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."), + ))) +} + +func (m *connectChooserModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *connectChooserModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *connectChooserModel) Init() tea.Cmd { return m.form.Init() } + +func (m *connectChooserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + // Skipping is choosing local — the operator can change this later. + return m, m.chooseLocal() + } + } + + 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.onComplete() + case huh.StateAborted: + return m, tea.Quit + } + return m, cmd +} + +func (m *connectChooserModel) onComplete() (tea.Model, tea.Cmd) { + switch m.choice { + case connectCloudflare: + return newEdgeModel(m.rootDomain, m.adminHost, m.panelHost), nil + case connectReverseProxy: + return newReverseProxyModel(m.rootDomain, m.adminHost, m.panelHost), nil + default: + return m, m.chooseLocal() + } +} + +func (m *connectChooserModel) chooseLocal() tea.Cmd { + panel := defaultPanelHostname(m.rootDomain, m.panelHost) + admin := defaultAdminHostname(m.rootDomain, m.adminHost) + return func() tea.Msg { + return connectResultMsg{method: connectLocal, panelHostname: panel, adminHostname: admin} + } +} + +func (m *connectChooserModel) View() string { return m.form.View() } + +// ---- Reverse proxy: collect hostnames, record them, render a guide ---- + +type rpStep int + +const ( + rpForm rpStep = iota + rpWorking + rpGuide + rpError +) + +type rpApplyMsg struct{ err error } + +type reverseProxyModel struct { + rootDomain string + + step rpStep + form *huh.Form + sp spinner.Model + err error + panelHost string + adminHost string + width, height int +} + +func newReverseProxyModel(rootDomain, adminHost, panelHost string) *reverseProxyModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + + m := &reverseProxyModel{ + rootDomain: rootDomain, + step: rpForm, + sp: sp, + panelHost: defaultPanelHostname(rootDomain, panelHost), + adminHost: defaultAdminHostname(rootDomain, adminHost), + } + m.form = m.build() + return m +} + +func (m *reverseProxyModel) build() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewNote(). + Title("Reverse proxy"). + Description("Enter the public hostnames your reverse proxy will serve. We record them and show you the config — you point the proxy at the origin."), + huh.NewInput(). + Title("Player console hostname"). + Value(&m.panelHost). + Validate(func(s string) error { + return validateEdgeHostname("player console", normalizeEdgeHostname(s), false) + }), + huh.NewInput(). + Title("Admin console hostname"). + 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 + }), + ))) +} + +func (m *reverseProxyModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *reverseProxyModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *reverseProxyModel) Init() tea.Cmd { return m.form.Init() } + +func (m *reverseProxyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case rpApplyMsg: + if msg.err != nil { + m.step, m.err = rpError, msg.err + return m, nil + } + m.step = rpGuide + return m, nil + + case spinner.TickMsg: + if m.step == rpWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyMsg: + switch m.step { + case rpForm: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + return m, goBack() + } + case rpGuide: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, func() tea.Msg { + return connectResultMsg{ + method: connectReverseProxy, + panelHostname: m.panelHost, + adminHostname: m.adminHost, + guide: reverseProxyGuide(m.panelHost, m.adminHost), + } + } + } + return m, nil + case rpError: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.step, m.err = rpForm, nil + m.form = m.build() + return m, m.form.Init() + case "enter": + return m, m.apply() + } + return m, nil + case rpWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + } + } + + if m.step == rpForm && 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: + m.panelHost = normalizeEdgeHostname(m.panelHost) + m.adminHost = normalizeEdgeHostname(m.adminHost) + m.step = rpWorking + return m, tea.Batch(m.sp.Tick, m.apply()) + case huh.StateAborted: + return m, goBack() + } + return m, cmd + } + return m, nil +} + +func (m *reverseProxyModel) apply() tea.Cmd { + panel, admin := m.panelHost, m.adminHost + return func() tea.Msg { + return rpApplyMsg{err: applyReverseProxy(context.Background(), panel, admin)} + } +} + +func (m *reverseProxyModel) View() string { + switch m.step { + case rpWorking: + return " " + m.sp.View() + " " + tuiHint.Render("Recording hostnames and rolling the API…") + "\n" + case rpGuide: + var b strings.Builder + b.WriteString(tuiSuccessBanner("Hostnames recorded. Now point your reverse proxy at the origin.") + "\n\n") + b.WriteString(reverseProxyGuideView(m.panelHost, m.adminHost)) + b.WriteString("\n" + tuiAction("enter", "done")) + return b.String() + case rpError: + var b strings.Builder + b.WriteString(tuiErrorBanner("Could not record hostnames.") + "\n\n") + if m.err != nil { + b.WriteString(tuiHint.Render(m.err.Error()) + "\n") + } + b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit")) + return b.String() + default: + if m.form == nil { + return "" + } + return m.form.View() + } +} + +// reverseProxyGuideView renders the operator-facing guide with styled snippets. +func reverseProxyGuideView(panelHost, adminHost string) string { + var b strings.Builder + origin := localPanelOrigin() + b.WriteString(tuiInfo("Origin: "+origin+" · self-signed cert (skip upstream TLS verify) · forward the Host header") + "\n\n") + b.WriteString(tuiGuideBlock("Caddyfile", caddySnippet(adminHost, origin))) + if panelHost != "" && !strings.EqualFold(panelHost, adminHost) { + b.WriteString("\n" + tuiGuideBlock("", caddySnippet(panelHost, origin))) + } + return b.String() +} + +// reverseProxyGuide returns the same guidance as plain text for the post-TUI +// stdout summary (e.g. when piped to a log). +func reverseProxyGuide(panelHost, adminHost string) string { + origin := localPanelOrigin() + var b strings.Builder + fmt.Fprintf(&b, "Origin: %s (self-signed — skip upstream TLS verification; forward the Host header)\n\n", origin) + b.WriteString("Caddy example:\n") + b.WriteString(caddySnippet(adminHost, origin)) + if panelHost != "" && !strings.EqualFold(panelHost, adminHost) { + b.WriteString("\n") + b.WriteString(caddySnippet(panelHost, origin)) + } + return b.String() +} + +func caddySnippet(host, origin string) string { + if host == "" { + host = "your-hostname" + } + return fmt.Sprintf(`%s { + reverse_proxy %s { + transport http { tls_insecure_skip_verify } + header_up Host {host} + } +}`, host, origin) +} diff --git a/cmd/felis/tui_dashboard.go b/cmd/felis/tui_dashboard.go deleted file mode 100644 index 80b06d0..0000000 --- a/cmd/felis/tui_dashboard.go +++ /dev/null @@ -1,139 +0,0 @@ -package main - -import ( - "context" - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -type dashboardModel struct { - ctx context.Context - store ownerStore - rootDomain string - adminHost string - panelHost string - osUser string - dbURL string - adminExists bool - - focus int - - pgStatus stepStatus - pgDetail string - - mgStatus stepStatus - mgDetail string - - owStatus stepStatus - owDetail string - - paStatus stepStatus - paDetail string - - egStatus stepStatus - egDetail string -} - -func newDashboardModel(ctx context.Context, store ownerStore, dbURL, osUser, rootDomain, adminHost, panelHost string) *dashboardModel { - return &dashboardModel{ - ctx: ctx, - store: store, - dbURL: dbURL, - osUser: osUser, - rootDomain: rootDomain, - adminHost: adminHost, - panelHost: panelHost, - pgStatus: statusPending, - mgStatus: statusPending, - owStatus: statusOptional, - paStatus: statusPending, - egStatus: statusOptional, - } -} - -func (m *dashboardModel) Init() tea.Cmd { return nil } - -func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - case "up": - if m.focus > 0 { - m.focus-- - } - return m, nil - case "down": - if m.focus < 4 { - m.focus++ - } - return m, nil - case "enter": - return m.enterStep() - case "r", "R": - return m, func() tea.Msg { return switchToDashboard{} } - } - } - return m, nil -} - -func (m *dashboardModel) enterStep() (tea.Model, tea.Cmd) { - switch m.focus { - case 0: - return newPostgresModel(m.dbURL, m.osUser), nil - case 1: - return newMigrationModel(m.dbURL, m.osUser), nil - case 2: - if m.adminExists { - return m, nil - } - return newOwnerModel(m.ctx, m.store, m.osUser, false), nil - case 3: - return m, nil - case 4: - return newEdgeModel(m.rootDomain, m.adminHost, m.panelHost), nil - } - return m, nil -} - -func (m *dashboardModel) View() string { - var b strings.Builder - b.WriteString(tuiHeader("Setup")) - - type step struct { - title string - status stepStatus - detail string - idx int - } - - steps := []step{ - {"Database & Migrations", m.pgStatus, m.pgDetail, 0}, - {"Database Migrations", m.mgStatus, m.mgDetail, 1}, - {"Owner Account", m.owStatus, m.owDetail, 2}, - {"Panel Access", m.paStatus, m.paDetail, 3}, - {"Cloudflare Edge", m.egStatus, m.egDetail, 4}, - } - - for _, s := range steps { - icon := tuiIcon(s.status) - label := s.title - if s.detail != "" { - label += " " + tuiHint.Render(s.detail) - } - prefix := " " - if m.focus == s.idx { - prefix = tuiLabel.Render("▸ ") - } - b.WriteString(fmt.Sprintf("%s%s %s\n\n", prefix, icon, label)) - } - - b.WriteString("\n") - b.WriteString(tuiSeparator()) - b.WriteString("\n") - b.WriteString(tuiAction("enter", "configure", "r", "refresh", "↑↓", "navigate", "esc", "exit")) - return b.String() -} diff --git a/cmd/felis/tui_edge.go b/cmd/felis/tui_edge.go index 5ed48f4..9e8e5d3 100644 --- a/cmd/felis/tui_edge.go +++ b/cmd/felis/tui_edge.go @@ -154,7 +154,7 @@ func (m *edgeModel) View() string { switch m.step { case egIntro: - b.WriteString(tuiHint.Render("Publish web faces securely via Cloudflare Tunnel + Access.") + "\n\n") + 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") @@ -276,7 +276,7 @@ func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.step == egDone { return m, m.sendEdgeResult() } - return m, func() tea.Msg { return switchToDashboard{} } + return m, goBack() case "enter": if m.step == egDone { return m, m.sendEdgeResult() @@ -297,7 +297,7 @@ func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc": - return m, func() tea.Msg { return switchToDashboard{} } + return m, goBack() case "i", "I": if m.cloudflaredPath == "" { m.installing = true @@ -531,8 +531,9 @@ func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cm func (m *edgeModel) sendEdgeResult() tea.Cmd { return func() tea.Msg { - return edgeResultMsg{ - result: m.result, + return connectResultMsg{ + method: connectCloudflare, + edge: m.result, panelHostname: m.panelSet, adminHostname: m.adminSet, } diff --git a/cmd/felis/tui_edge_apply.go b/cmd/felis/tui_edge_apply.go index aba11e9..a01df9c 100644 --- a/cmd/felis/tui_edge_apply.go +++ b/cmd/felis/tui_edge_apply.go @@ -24,10 +24,8 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost, if adminHost == "" { return fmt.Errorf("admin hostname is required") } - for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} { - if err := updateAuthConfig(path, panelHost, adminHost, result.AccessAud); err != nil { - return err - } + if err := writeConnectionConfig(panelHost, adminHost, result.AccessAud); err != nil { + return err } if err := applyFelisConfigSecret(ctx); err != nil { return err @@ -41,6 +39,38 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost, return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s") } +// applyReverseProxy records the operator's chosen public hostnames and rolls the +// API so the panel serves them. No Access audience is set: the admin console is +// gated by the Owner's local-password session, and the operator's own reverse +// proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin. +func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error { + if adminHost == "" { + return fmt.Errorf("admin hostname is required") + } + if err := writeConnectionConfig(panelHost, adminHost, ""); err != nil { + return err + } + if err := applyFelisConfigSecret(ctx); err != nil { + return err + } + if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil { + return err + } + return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s") +} + +// writeConnectionConfig stamps the chosen hostnames (and optional Access audience) +// into both the host and pod config files. An empty aud clears any prior +// Cloudflare audience, which is correct when switching to a non-Access front. +func writeConnectionConfig(panelHost, adminHost, aud string) error { + for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} { + if err := updateAuthConfig(path, panelHost, adminHost, aud); err != nil { + return err + } + } + return nil +} + func updateAuthConfig(path, panelHost, adminHost, aud string) error { cfg, err := config.Load(path) if err != nil { diff --git a/cmd/felis/tui_height_measure_test.go b/cmd/felis/tui_height_measure_test.go new file mode 100644 index 0000000..315f303 --- /dev/null +++ b/cmd/felis/tui_height_measure_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// TestWizardViewsFitTerminal guards against the regression that motivated the +// redesign: a composed view taller than the terminal makes bubbletea clip the +// top (the step rail) and read as janky. After a WindowSizeMsg the root budgets +// height across the rail and the active screen, so no stage's rendered view may +// exceed the terminal height. +func TestWizardViewsFitTerminal(t *testing.T) { + stages := []struct { + name string + msg tea.Msg + }{ + {"owner", preflightDoneMsg{}}, + {"connect", ownerResultMsg{username: "owner", displayPassword: "hunter2pw"}}, + {"summary", connectResultMsg{method: connectLocal, panelHostname: "panel.example.com"}}, + } + + // Sweep widths too: narrow terminals wrap the long notes (e.g. the connect + // chooser's security warning), which is exactly where height can creep back + // over budget. 60 is about as narrow as a real terminal gets. + for _, w := range []int{60, 80, 90} { + for _, h := range []int{24, 30, 45} { + var m tea.Model = newTestRoot(false, consoleModeSetup, "") + m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + for _, s := range stages { + m, _ = m.Update(s.msg) + got := lipgloss.Height(m.(*rootModel).View()) + if got > h { + t.Errorf("terminal %dx%d: %s stage view = %d rows (exceeds height)", w, h, s.name, got) + } + } + } + } +} diff --git a/cmd/felis/tui_migration.go b/cmd/felis/tui_migration.go index c64b753..ebe1ae9 100644 --- a/cmd/felis/tui_migration.go +++ b/cmd/felis/tui_migration.go @@ -6,131 +6,31 @@ import ( "time" "felis.lolicon.best/internal/store" - - tea "github.com/charmbracelet/bubbletea" ) -type migRunMsg struct { - n int - total int - err error -} - -type migrationModel struct { - dbURL string - osUser string - - state string - applied int - total int - lastErr error -} - -func newMigrationModel(dbURL, osUser string) *migrationModel { - return &migrationModel{ - dbURL: dbURL, - osUser: osUser, - state: "checking", +// applyMigrations runs any pending schema migrations and returns the resulting +// applied count. Used by the preflight stage to self-heal a freshly bootstrapped +// (or upgraded) database. +func applyMigrations(dbURL string) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + drv, err := store.Open(ctx, dbURL) + if err != nil { + return 0, fmt.Errorf("open db: %w", err) } -} - -func (m *migrationModel) Init() tea.Cmd { - return func() tea.Msg { - n, err := countMigrations(m.dbURL) - if err != nil { - return migRunMsg{err: err} - } - total, err := totalMigrations() - return migRunMsg{n: n, total: total, err: err} + defer drv.Close() + migrations, err := store.LoadMigrations() + if err != nil { + return 0, err } -} - -func (m *migrationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case migRunMsg: - if msg.err != nil { - m.state = "error" - m.lastErr = msg.err - return m, nil - } - m.applied = msg.n - m.total = msg.total - if m.applied < m.total { - m.state = "pending" - return m, nil - } - return m, func() tea.Msg { return migrationDoneMsg{n: msg.n} } - - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, func() tea.Msg { return switchToDashboard{} } - case "enter": - if m.state == "pending" { - m.state = "running" - return m, runMigrationsCmd(m.dbURL) - } - } + if _, err := store.Up(ctx, drv, migrations); err != nil { + return 0, err } - return m, nil -} - -func (m *migrationModel) View() string { - var b string - b += tuiHeader("Database Migrations") + "\n" - - switch m.state { - case "checking": - b += tuiHint.Render("Checking migration status…") + "\n" - case "pending": - b += tuiWarn.Render(fmt.Sprintf("%d of %d migrations applied. %d pending.", m.applied, m.total, m.total-m.applied)) + "\n\n" - b += tuiInfo("Press enter to run pending migrations.") + "\n" - case "running": - b += tuiHint.Render("Running migrations…") + "\n" - b += tuiInfo("This should take only a moment.") + "\n" - case "error": - b += tuiErr.Render("Migration check failed:") + "\n" - b += tuiHint.Render(m.lastErr.Error()) + "\n" - default: - b += tuiOK.Render(fmt.Sprintf("✓ %d migrations applied.", m.applied)) + "\n" - } - - b += "\n" - b += tuiSeparator() + "\n" - switch m.state { - case "pending": - b += tuiAction("enter", "run", "esc", "back") - case "running": - b += tuiAction("esc", "cancel") - default: - b += tuiAction("esc", "back") - } - return b -} - -func runMigrationsCmd(dbURL string) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - drv, err := store.Open(ctx, dbURL) - if err != nil { - return migRunMsg{err: fmt.Errorf("open db: %w", err)} - } - defer drv.Close() - migrations, err := store.LoadMigrations() - if err != nil { - return migRunMsg{err: err} - } - _, err = store.Up(ctx, drv, migrations) - if err != nil { - return migRunMsg{err: err} - } - applied, err := drv.AppliedVersions(ctx) - if err != nil { - return migRunMsg{err: err} - } - return migRunMsg{n: len(applied), total: len(migrations)} + applied, err := drv.AppliedVersions(ctx) + if err != nil { + return 0, err } + return len(applied), nil } func totalMigrations() (int, error) { diff --git a/cmd/felis/tui_owner.go b/cmd/felis/tui_owner.go index c2b573b..01949ab 100644 --- a/cmd/felis/tui_owner.go +++ b/cmd/felis/tui_owner.go @@ -2,11 +2,13 @@ package main import ( "context" + "errors" "fmt" "strings" - "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" ) type owAuthMsg struct { @@ -31,6 +33,10 @@ const ( owError ) +// ownerModel collects the owner account. The input phases (admin auth, root +// override, owner details) are huh forms; the async phases (verifying, +// provisioning) show a spinner; the done phase shows the credential card. The +// outward contract is unchanged: it emits an ownerResultMsg when finished. type ownerModel struct { ctx context.Context store ownerStore @@ -38,13 +44,23 @@ type ownerModel struct { adminExists bool mode string // "bootstrap", "recovery", "root_override" accountable string + attempt string step owStep - inputs []textinput.Model - focus int - formErr string + form *huh.Form + sp spinner.Model working string - attempt string + + width, height int + + // huh-bound form values + authUser string + authPass string + overrideTok string + ownerUser string + ownerEmail string + ownerPass string + ownerConfirm string username string displayPassword string @@ -52,49 +68,72 @@ type ownerModel struct { } func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + m := &ownerModel{ ctx: ctx, store: store, osUser: osUser, adminExists: adminExists, + sp: sp, + ownerUser: "owner", } if adminExists { m.step = owAuth + m.form = m.buildAuthForm() } else { m.mode = "bootstrap" m.accountable = osUser m.step = owProvision + m.form = m.buildProvisionForm() } return m } -func (m *ownerModel) Init() tea.Cmd { - if m.step == owAuth { - return m.buildAuth() +func (m *ownerModel) Init() tea.Cmd { return m.form.Init() } + +func (m *ownerModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) } - return m.buildProvision(true) +} + +// sized applies the current terminal area to a freshly built form so phase +// transitions don't reset back to huh's default 80-column layout. +func (m *ownerModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *ownerModel) isFormStep() bool { + return m.step == owAuth || m.step == owOverride || m.step == owProvision } func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case owAuthMsg: - m.working = "" if msg.err != nil { - return m, func() tea.Msg { return ownerResultMsg{err: msg.err} } + return m, m.failCmd(msg.err) } if msg.ok { m.mode = "recovery" m.accountable = msg.matched m.step = owProvision - return m, m.buildProvision(false) + m.form = m.sized(m.buildProvisionForm()) + return m, m.form.Init() } m.step = owOverride - m.formErr = "" - return m, m.buildOverride() + m.form = m.sized(m.buildOverrideForm()) + return m, m.form.Init() case owProvisionMsg: if msg.err != nil { - return m, func() tea.Msg { return ownerResultMsg{err: msg.err} } + return m, m.failCmd(msg.err) } m.step = owDone m.displayPassword = msg.outcome.displayPassword @@ -103,157 +142,107 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case spinner.TickMsg: + if m.step == owWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + } + return m, nil + case tea.KeyMsg: switch m.step { - case owDone, owError: + case owDone: switch msg.String() { case "ctrl+c", "esc", "enter": - if m.step == owDone { - return m, m.ownerResultCmd() - } - return m, nil + return m, m.ownerResultCmd() } return m, nil case owWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } return m, nil - default: - return m.handleFormKey(msg) + default: // form steps — intercept cancel/back, let huh handle the rest + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + if m.step == owOverride { + m.step = owAuth + m.form = m.sized(m.buildAuthForm()) + return m, m.form.Init() + } + return m, tea.Quit + } } } - // Forward to inputs. - if m.step != owWorking && m.step != owDone && m.step != owError { - return m, m.updateInputs(msg) + + // Drive the active form. + if m.isFormStep() && 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.onFormComplete() + case huh.StateAborted: + return m, tea.Quit + } + return m, cmd } return m, nil } -func (m *ownerModel) View() string { - var b strings.Builder - b.WriteString(tuiHeader("Owner Account")) - +func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) { switch m.step { case owAuth: - b.WriteString(tuiHint.Render("A staff account exists. Identify yourself before proceeding.") + "\n\n") - b.WriteString(tuiWizardCard("Admin Authentication", "", - tuiFormField("Admin username", m.inputs[0])+"\n\n"+ - tuiFormField("Admin password", m.inputs[1]))) - if m.formErr != "" { - b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("tab/↑↓", "move", "enter", "verify", "esc", "cancel")) - + m.attempt = strings.TrimSpace(m.authUser) + m.step = owWorking + m.working = "Verifying admin credential…" + user, pass := m.authUser, m.authPass + return m, tea.Batch(m.sp.Tick, func() tea.Msg { + matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass) + return owAuthMsg{matched: matched, ok: ok, err: err} + }) case owOverride: - b.WriteString(tuiErrorBanner("That credential did not match.") + "\n\n") - b.WriteString(tuiHint.Render(fmt.Sprintf("You can proceed as OS user %q with root authority.", m.osUser)) + "\n\n") - b.WriteString(tuiWizardCard("Root Override", "", - tuiFormField("Type "+breakGlassOverrideToken+" to confirm", m.inputs[0]))) - if m.formErr != "" { - b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("enter", "confirm", "esc", "go back")) - + m.mode = "root_override" + m.accountable = m.osUser + m.step = owProvision + m.form = m.sized(m.buildProvisionForm()) + return m, m.form.Init() case owProvision: - if m.mode == "bootstrap" { - b.WriteString(tuiHint.Render(fmt.Sprintf("Creating the first Owner. Recorded as OS user %q.", m.osUser)) + "\n\n") - } else if m.mode == "root_override" { - b.WriteString(tuiWarn.Render("Root override — a one-time password will be generated.") + "\n\n") - } else { - b.WriteString(tuiHint.Render(fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable)) + "\n\n") - } - var fields string - fields = tuiFormField("Owner username", m.inputs[0]) + "\n\n" - fields += tuiFormField("Owner email (optional)", m.inputs[1]) - if m.mode == "bootstrap" { - fields += "\n\n" + tuiFormField("Owner password", m.inputs[2]) - fields += "\n\n" + tuiFormField("Confirm password", m.inputs[3]) - } - b.WriteString(tuiWizardCard("Account Details", "", fields)) - if m.formErr != "" { - b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n") - } - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("tab/↑↓", "move", "enter", "provision", "esc", "cancel")) - - case owWorking: - msg := m.working - if msg == "" { - msg = "Working…" - } - b.WriteString(tuiHint.Render(msg) + "\n") - - case owDone: - b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n") - var box strings.Builder - box.WriteString(tuiLabel.Render("username ") + m.username + "\n") - if m.displayPassword != "" { - box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n") - box.WriteString(tuiWarn.Render("Record this password — it is shown only once.") + "\n") - } else { - box.WriteString(tuiHint.Render("Log in with the password you entered.") + "\n") - } - if m.auditWarning != "" { - box.WriteString("\n" + tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n") - } - b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n") - b.WriteString(tuiSeparator() + "\n") - b.WriteString(tuiAction("enter/esc", "back")) - - case owError: - b.WriteString(tuiErrorBanner("Owner provisioning failed.") + "\n") - b.WriteString("\n" + tuiSeparator() + "\n") - b.WriteString(tuiAction("esc", "exit")) + m.username = strings.TrimSpace(m.ownerUser) + m.step = owWorking + m.working = "Provisioning Owner account…" + return m, tea.Batch(m.sp.Tick, m.provisionCmd()) } - return b.String() + return m, nil } -func (m *ownerModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "esc": - if m.step == owOverride { - m.step, m.formErr = owAuth, "" - return m, m.buildAuth() - } - return m, func() tea.Msg { return switchToDashboard{} } - case "tab", "down": - m.focus = m.focus + 1 - if m.focus >= len(m.inputs) { - m.focus = 0 - } - return m, m.focusInput(m.focus) - case "shift+tab", "up": - m.focus = m.focus - 1 - if m.focus < 0 { - m.focus = len(m.inputs) - 1 - } - return m, m.focusInput(m.focus) - case "enter": - return m.submit() +func (m *ownerModel) provisionCmd() tea.Cmd { + password := "" + if m.mode == "bootstrap" { + password = m.ownerPass + } + op := breakGlassOp{ + mode: m.mode, + accountable: m.accountable, + osUser: m.osUser, + ownerUsername: m.username, + ownerEmail: m.ownerEmail, + ownerPassword: password, + attemptedAdmin: m.attempt, + } + return func() tea.Msg { + out, err := performBreakGlass(m.ctx, m.store, op) + return owProvisionMsg{outcome: out, err: err} } - return m, m.updateInputs(msg) } -func (m *ownerModel) focusInput(i int) tea.Cmd { - var cmd tea.Cmd - for j := range m.inputs { - if j == i { - cmd = m.inputs[j].Focus() - } else { - m.inputs[j].Blur() - } - } - return cmd -} - -func (m *ownerModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.inputs)) - for i := range m.inputs { - m.inputs[i], cmds[i] = m.inputs[i].Update(msg) - } - return tea.Batch(cmds...) +func (m *ownerModel) failCmd(err error) tea.Cmd { + return func() tea.Msg { return ownerResultMsg{err: err} } } func (m *ownerModel) ownerResultCmd() tea.Cmd { @@ -268,113 +257,131 @@ func (m *ownerModel) ownerResultCmd() tea.Cmd { } } -func (m *ownerModel) setInputs(ins []textinput.Model) tea.Cmd { - m.inputs = ins - m.focus = 0 - return m.focusInput(0) +// ---- form builders ---- + +func (m *ownerModel) buildAuthForm() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewNote(). + Title("Admin authentication"). + Description("A staff account already exists. Identify yourself to continue."), + huh.NewInput(). + Title("Admin username"). + Value(&m.authUser). + Validate(requiredField("admin username")), + huh.NewInput(). + Title("Admin password"). + EchoMode(huh.EchoModePassword). + Value(&m.authPass). + Validate(requiredField("admin password")), + ))) } -func (m *ownerModel) buildAuth() tea.Cmd { - user := tuiInput("admin username", 64, false) - pass := tuiInput("admin password", 128, true) - return m.setInputs([]textinput.Model{user, pass}) +func (m *ownerModel) buildOverrideForm() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewNote(). + Title("Root override"). + Description(fmt.Sprintf("That credential did not match. Proceed as OS user %q with root authority by typing the confirmation token.", m.osUser)), + huh.NewInput(). + Title("Type "+breakGlassOverrideToken+" to confirm"). + Value(&m.overrideTok). + Validate(func(s string) error { + if s != breakGlassOverrideToken { + return errors.New("type " + breakGlassOverrideToken + " exactly to proceed") + } + return nil + }), + ))) } -func (m *ownerModel) buildOverride() tea.Cmd { - confirm := tuiInput("type "+breakGlassOverrideToken, 16, false) - return m.setInputs([]textinput.Model{confirm}) -} - -func (m *ownerModel) buildProvision(withPassword bool) tea.Cmd { - user := tuiInput("owner", 64, false) - user.SetValue("owner") - email := tuiInput("(optional)", 254, false) - ins := []textinput.Model{user, email} - if withPassword { - ins = append(ins, tuiInput("at least 8 characters", 128, true)) - ins = append(ins, tuiInput("re-enter password", 128, true)) +func (m *ownerModel) buildProvisionForm() *huh.Form { + desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser) + switch m.mode { + case "recovery": + desc = fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable) + case "root_override": + desc = "Root override — a one-time password will be generated." } - return m.setInputs(ins) -} -func (m *ownerModel) submit() (tea.Model, tea.Cmd) { - switch m.step { - case owAuth: - return m.submitAuth() - case owOverride: - return m.submitOverride() - case owProvision: - return m.submitProvision() + fields := []huh.Field{ + huh.NewNote().Title("Owner account").Description(desc), + huh.NewInput(). + Title("Owner username"). + Value(&m.ownerUser). + Validate(requiredField("owner username")), + huh.NewInput(). + Title("Owner email"). + Description("optional"). + Placeholder("you@example.com"). + Value(&m.ownerEmail), } - return m, nil -} - -func (m *ownerModel) submitAuth() (tea.Model, tea.Cmd) { - user := strings.TrimSpace(m.inputs[0].Value()) - pass := m.inputs[1].Value() - if user == "" || pass == "" { - m.formErr = "enter the username and password of an existing admin" - return m, nil - } - m.attempt = user - m.formErr, m.working = "", "Verifying admin credential…" - m.step = owWorking - return m, func() tea.Msg { - matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass) - return owAuthMsg{matched: matched, ok: ok, err: err} - } -} - -func (m *ownerModel) submitOverride() (tea.Model, tea.Cmd) { - if m.inputs[0].Value() != breakGlassOverrideToken { - m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed" - return m, nil - } - m.mode = "root_override" - m.accountable = m.osUser - m.step = owProvision - return m, m.buildProvision(false) -} - -func (m *ownerModel) submitProvision() (tea.Model, tea.Cmd) { - owner := strings.TrimSpace(m.inputs[0].Value()) - if owner == "" { - m.formErr = "owner username is required" - return m, m.focusForField(0) - } - email := m.inputs[1].Value() - password := "" if m.mode == "bootstrap" { - pw := m.inputs[2].Value() - confirm := m.inputs[3].Value() - if err := validateOwnerPassword(pw); err != nil { - m.formErr = err.Error() - return m, m.focusForField(2) - } - if pw != confirm { - m.formErr = "the two passwords do not match" - return m, m.focusForField(3) - } - password = pw + fields = append(fields, + huh.NewInput(). + Title("Owner password"). + Description("at least 8 characters"). + EchoMode(huh.EchoModePassword). + Value(&m.ownerPass). + Validate(validateOwnerPassword), + huh.NewInput(). + Title("Confirm password"). + EchoMode(huh.EchoModePassword). + Value(&m.ownerConfirm). + Validate(func(s string) error { + if s != m.ownerPass { + return errors.New("the two passwords do not match") + } + return nil + }), + ) } - m.username = owner - m.formErr, m.working = "", "Provisioning Owner account…" - m.step = owWorking - return m, func() tea.Msg { - out, err := performBreakGlass(m.ctx, m.store, breakGlassOp{ - mode: m.mode, - accountable: m.accountable, - osUser: m.osUser, - ownerUsername: owner, - ownerEmail: email, - ownerPassword: password, - attemptedAdmin: m.attempt, - }) - return owProvisionMsg{outcome: out, err: err} + return m.sized(newFelisForm(huh.NewGroup(fields...))) +} + +func requiredField(name string) func(string) error { + return func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New(name + " is required") + } + return nil } } -func (m *ownerModel) focusForField(i int) tea.Cmd { - m.focus = i - return m.focusInput(i) +// ---- views ---- + +func (m *ownerModel) View() string { + switch m.step { + case owWorking: + msg := m.working + if msg == "" { + msg = "Working…" + } + return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n" + case owDone: + return m.doneView() + default: + if m.form == nil { + return "" + } + return m.form.View() + } +} + +func (m *ownerModel) doneView() string { + var b strings.Builder + b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n") + + var box strings.Builder + box.WriteString(tuiLabel.Render("username ") + m.username + "\n") + if m.displayPassword != "" { + box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n") + box.WriteString(tuiWarn.Render("Record this password — it is shown only once.")) + } else { + box.WriteString(tuiHint.Render("Log in with the password you entered.")) + } + if m.auditWarning != "" { + box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning)) + } + b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n") + b.WriteString(tuiAction("enter", "continue")) + return b.String() } diff --git a/cmd/felis/tui_postgres.go b/cmd/felis/tui_postgres.go index cfc370e..f2df545 100644 --- a/cmd/felis/tui_postgres.go +++ b/cmd/felis/tui_postgres.go @@ -4,138 +4,15 @@ import ( "context" "fmt" "net" - "os/exec" "strings" "time" "felis.lolicon.best/internal/store" - - tea "github.com/charmbracelet/bubbletea" ) -type pgCheckMsg struct { - running bool - reachable bool - err error -} - -type pgInstallMsg struct{ err error } - -type pgCreateMsg struct{ err error } - -type postgresModel struct { - dbURL string - osUser string - - state string // "checking", "missing", "installing", "creating", "done", "error" - lastErr error - pgExists bool -} - -func newPostgresModel(dbURL, osUser string) *postgresModel { - return &postgresModel{ - dbURL: dbURL, - osUser: osUser, - state: "checking", - } -} - -func (m *postgresModel) Init() tea.Cmd { - return checkPostgresCmd(m.dbURL) -} - -func (m *postgresModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case pgCheckMsg: - if msg.err != nil || !msg.reachable { - m.state = "missing" - m.lastErr = msg.err - return m, nil - } - return m, func() tea.Msg { return pgDoneMsg{} } - - case pgInstallMsg: - if msg.err != nil { - m.state = "error" - m.lastErr = msg.err - return m, nil - } - m.state = "creating" - return m, createDatabaseCmd(m.dbURL) - - case pgCreateMsg: - if msg.err != nil { - m.state = "error" - m.lastErr = msg.err - return m, nil - } - return m, func() tea.Msg { return pgDoneMsg{} } - - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, func() tea.Msg { return switchToDashboard{} } - case "i", "I": - if m.state == "missing" { - m.state = "installing" - return m, installPostgresCmd() - } - } - } - return m, nil -} - -func (m *postgresModel) View() string { - var b strings.Builder - b.WriteString(tuiHeader("Database Setup")) - - switch m.state { - case "checking": - b.WriteString(tuiHint.Render("Checking PostgreSQL status…") + "\n") - case "missing": - b.WriteString(tuiWarn.Render("PostgreSQL is not reachable.") + "\n\n") - if m.lastErr != nil { - b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n\n") - } - b.WriteString(tuiInfo("Press i to install PostgreSQL, or esc to skip.") + "\n") - case "installing": - b.WriteString(tuiHint.Render("Installing PostgreSQL via apt…") + "\n") - b.WriteString(tuiInfo("This may take up to a minute.") + "\n") - case "creating": - b.WriteString(tuiHint.Render("PostgreSQL installed. Creating database…") + "\n") - case "done": - b.WriteString(tuiOK.Render("✓ Database is ready.") + "\n") - case "error": - b.WriteString(tuiErr.Render("Failed to set up PostgreSQL:") + "\n") - if m.lastErr != nil { - b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n") - } - } - - b.WriteString("\n") - b.WriteString(tuiSeparator()) - b.WriteString("\n") - switch m.state { - case "missing": - b.WriteString(tuiAction("i", "install", "esc", "back")) - case "done", "error": - b.WriteString(tuiAction("esc", "back")) - default: - b.WriteString(tuiAction("esc", "cancel")) - } - return b.String() -} - -func checkPostgresCmd(dbURL string) tea.Cmd { - return func() tea.Msg { - err := checkPostgres(dbURL) - if err != nil { - return pgCheckMsg{reachable: false, err: err} - } - return pgCheckMsg{reachable: true} - } -} - +// checkPostgres proves the configured database is reachable and accepts a +// connection. Host bootstrap provisions PostgreSQL, so in the normal setup flow +// this succeeds immediately; the preflight stage uses it to fail fast otherwise. func checkPostgres(dbURL string) error { cfg, err := parseDBURL(dbURL) if err != nil { @@ -157,52 +34,6 @@ func checkPostgres(dbURL string) error { return nil } -func installPostgresCmd() tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, "apt-get", "install", "-y", "postgresql") - out, err := cmd.CombinedOutput() - if err != nil { - return pgInstallMsg{err: fmt.Errorf("%w: %s", err, string(out))} - } - // Start the service. - start := exec.CommandContext(ctx, "systemctl", "restart", "postgresql") - start.CombinedOutput() - return pgInstallMsg{} - } -} - -func createDatabaseCmd(dbURL string) tea.Cmd { - return func() tea.Msg { - cfg, err := parseDBURL(dbURL) - if err != nil { - return pgCreateMsg{err: err} - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - // Create user and database via PostgreSQL command line. - cmds := [][]string{ - {"psql", "-c", fmt.Sprintf("CREATE USER %s WITH PASSWORD '%s';", cfg.user, cfg.pass)}, - {"psql", "-c", fmt.Sprintf("CREATE DATABASE %s OWNER %s;", cfg.db, cfg.user)}, - {"psql", "-c", fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s;", cfg.db, cfg.user)}, - } - for _, args := range cmds { - cmd := exec.CommandContext(ctx, "su", append([]string{"-", "postgres", "-c"}, strings.Join(args, " "))...) - out, err := cmd.CombinedOutput() - if err != nil { - // Ignore "already exists" errors. - s := string(out) - if strings.Contains(s, "already exists") { - continue - } - return pgCreateMsg{err: fmt.Errorf("%w: %s", err, s)} - } - } - return pgCreateMsg{} - } -} - type dbCfg struct { addr string user string diff --git a/cmd/felis/tui_preflight.go b/cmd/felis/tui_preflight.go new file mode 100644 index 0000000..f4d5353 --- /dev/null +++ b/cmd/felis/tui_preflight.go @@ -0,0 +1,237 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// preflightModel auto-verifies the control plane before the operator does any +// hands-on configuration: PostgreSQL must be reachable and schema migrations +// must be applied. Both are expected to already be true after host bootstrap; +// this stage simply proves it (and self-heals pending migrations) so the rest +// of the wizard can assume a healthy backend. It is non-interactive — it runs +// to completion and reports back to the root via preflightDoneMsg. +type preflightModel struct { + dbURL string + rootDomain string + + sp spinner.Model + state pfState + err error + + dbOK bool + applied int + total int + migrated bool + panelOK bool + panelNote string +} + +type pfState int + +const ( + pfCheckDB pfState = iota + pfCheckMig + pfApplyMig + pfCheckPanel + pfDone + pfError +) + +type pfDBMsg struct{ err error } + +type pfMigCheckMsg struct { + applied int + total int + err error +} + +type pfMigApplyMsg struct { + applied int + err error +} + +type pfPanelMsg struct{ err error } + +func newPreflightModel(dbURL, rootDomain string) *preflightModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + return &preflightModel{dbURL: dbURL, rootDomain: rootDomain, sp: sp, state: pfCheckDB} +} + +func (m *preflightModel) Init() tea.Cmd { + return tea.Batch(m.sp.Tick, m.checkDB()) +} + +func (m *preflightModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case pfDBMsg: + if msg.err != nil { + m.state, m.err = pfError, msg.err + return m, nil + } + m.dbOK = true + m.state = pfCheckMig + return m, m.checkMigrations() + + case pfMigCheckMsg: + if msg.err != nil { + m.state, m.err = pfError, msg.err + return m, nil + } + m.applied, m.total = msg.applied, msg.total + if msg.applied < msg.total { + m.state = pfApplyMig + return m, m.applyMigrations() + } + m.state = pfCheckPanel + return m, m.checkPanel() + + case pfMigApplyMsg: + if msg.err != nil { + m.state, m.err = pfError, msg.err + return m, nil + } + m.applied, m.migrated = msg.applied, true + m.state = pfCheckPanel + return m, m.checkPanel() + + case pfPanelMsg: + // Non-blocking: a panel that isn't serving yet is a warning, not a wall — + // it is often still starting. The operator can proceed regardless. + if msg.err != nil { + m.panelNote = "not serving yet (it may still be starting): " + msg.err.Error() + } else { + m.panelOK = true + } + m.state = pfDone + return m, m.finish() + + case spinner.TickMsg: + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "r", "R", "enter": + if m.state == pfError { + m.state, m.err = pfCheckDB, nil + m.dbOK, m.migrated, m.panelOK, m.panelNote = false, false, false, "" + return m, tea.Batch(m.sp.Tick, m.checkDB()) + } + } + } + return m, nil +} + +func (m *preflightModel) View() string { + var b string + b += tuiLabel.Render("Preflight") + " " + tuiHint.Render("verifying the control plane before configuration") + "\n\n" + + b += m.line(m.dbOK, m.state == pfCheckDB, "PostgreSQL reachable", "") + + migDetail := "" + if m.total > 0 { + migDetail = pluralMigrations(m.applied, m.total) + if m.migrated { + migDetail += " (just applied)" + } + } + migDone := m.state == pfCheckPanel || m.state == pfDone + migActive := m.state == pfCheckMig || m.state == pfApplyMig + b += m.line(migDone, migActive, "Database migrations", migDetail) + + // Panel serving is best-effort: ✓ when reachable, ○ + note otherwise. + panelIcon := tuiIconOpt + panelDetail := m.panelNote + switch { + case m.state == pfCheckPanel: + panelIcon = m.sp.View() + case m.panelOK: + panelIcon, panelDetail = tuiIconOK, "serving locally" + } + b += " " + panelIcon + " " + tuiLabel.Render("Panel serving") + if panelDetail != "" { + b += " " + tuiHint.Render(panelDetail) + } + b += "\n" + + b += "\n" + tuiSeparator() + "\n" + switch m.state { + case pfError: + b += "\n" + tuiErrorBanner(m.errText()) + "\n\n" + b += tuiAction("r", "retry", "esc", "exit") + case pfDone: + b += tuiHint.Render("Ready.") + "\n" + default: + b += tuiAction("esc", "cancel") + } + return b +} + +func (m *preflightModel) line(done, active bool, label, detail string) string { + icon := tuiIconOpt + switch { + case done: + icon = tuiIconOK + case active: + icon = m.sp.View() + } + s := " " + icon + " " + tuiLabel.Render(label) + if detail != "" { + s += " " + tuiHint.Render(detail) + } + return s + "\n" +} + +func (m *preflightModel) errText() string { + if m.err == nil { + return "preflight failed" + } + return m.err.Error() +} + +func (m *preflightModel) checkDB() tea.Cmd { + return func() tea.Msg { return pfDBMsg{err: checkPostgres(m.dbURL)} } +} + +func (m *preflightModel) checkMigrations() tea.Cmd { + return func() tea.Msg { + applied, err := countMigrations(m.dbURL) + if err != nil { + return pfMigCheckMsg{err: err} + } + total, err := totalMigrations() + return pfMigCheckMsg{applied: applied, total: total, err: err} + } +} + +func (m *preflightModel) applyMigrations() tea.Cmd { + return func() tea.Msg { + applied, err := applyMigrations(m.dbURL) + return pfMigApplyMsg{applied: applied, err: err} + } +} + +func (m *preflightModel) checkPanel() tea.Cmd { + return func() tea.Msg { + return pfPanelMsg{err: checkPanelAccess(m.rootDomain).err} + } +} + +func (m *preflightModel) finish() tea.Cmd { + return func() tea.Msg { return preflightDoneMsg{} } +} + +func pluralMigrations(applied, total int) string { + if applied == total { + return fmt.Sprintf("%d applied", total) + } + return fmt.Sprintf("%d of %d applied", applied, total) +} diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index d8615b7..a1026b1 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -2,21 +2,45 @@ package main import ( "context" - "fmt" "felis.lolicon.best/internal/cfsetup" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) +// sizeable is implemented by screens that need to know the terminal area +// available to them (after the root reserves space for the step rail). huh-based +// screens forward this to form.WithWidth/WithHeight; custom screens use it to +// avoid overflowing the frame. +type sizeable interface { + setSize(width, height int) +} + +// ---- Connection methods ---- + +type connectMethod int + +const ( + connectLocal connectMethod = iota + connectCloudflare + connectReverseProxy +) + +func connectMethodLabel(m connectMethod) string { + switch m { + case connectCloudflare: + return "Cloudflare Tunnel + Access" + case connectReverseProxy: + return "Reverse proxy (your own front)" + default: + return "Local only (NodePort + self-signed TLS)" + } +} + // ---- Messages: sub-model → root ---- -type pgDoneMsg struct{ err error } - -type migrationDoneMsg struct { - n int - err error -} +type preflightDoneMsg struct{} type ownerResultMsg struct { username string @@ -27,25 +51,45 @@ type ownerResultMsg struct { err error } -type edgeResultMsg struct { - result *cfsetup.Result +// connectResultMsg is emitted by every connection method (the chooser for +// local, the edge model for Cloudflare, the reverse-proxy model for BYO). It is +// the single, method-agnostic outcome the root advances on. +type connectResultMsg struct { + method connectMethod panelHostname string adminHostname string - err error + edge *cfsetup.Result // Cloudflare only + guide string // reverse-proxy only } -type panelCheckMsg struct{ result panelAccessResult } +// goBackMsg returns from a connection sub-screen to the chooser. +type goBackMsg struct{} -// switchToDashboard tells the root to show the dashboard. -type switchToDashboard struct{} +func goBack() tea.Cmd { return func() tea.Msg { return goBackMsg{} } } + +// reconfigureConnectMsg is sent from the re-run status screen to re-enter the +// connection chooser. +type reconfigureConnectMsg struct{} // ---- rootModel: top-level session ---- +type wizardStage int + +const ( + stagePreflight wizardStage = iota + stageOwner + stageConnect + stageSummary +) + type rootModel struct { ctx context.Context - screen tea.Model // current active screen - dashboard *dashboardModel // always preserved + screen tea.Model + stage wizardStage + + width int + height int result breakGlassResult err error @@ -57,10 +101,11 @@ type rootModel struct { rootDomain string adminHost string panelHost string + accessAud string adminExists bool } -func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *rootModel { +func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) *rootModel { rm := &rootModel{ ctx: ctx, dbURL: dbURL, @@ -69,70 +114,48 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi rootDomain: rootDomain, adminHost: adminHostname, panelHost: panelHostname, + accessAud: accessAud, adminExists: adminExists, mode: mode, - dashboard: newDashboardModel(ctx, store, dbURL, osUser, rootDomain, adminHostname, panelHostname), result: breakGlassResult{ osUser: osUser, rootDomain: rootDomain, adminHostname: adminHostname, }, } - rm.dashboard.adminExists = adminExists - rm.refreshDashboard() if mode == consoleModeBreakGlass { + rm.stage = stageOwner rm.screen = newOwnerModel(ctx, store, osUser, adminExists) } else { - rm.screen = rm.dashboard + rm.stage = stagePreflight + rm.screen = newPreflightModel(dbURL, rootDomain) } return rm } func (m *rootModel) Init() tea.Cmd { - if m.mode == consoleModeSetup { - return m.checkStatus() - } return m.screen.Init() } func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case pgDoneMsg: - if msg.err != nil { - m.dashboard.pgStatus = statusFailed - m.dashboard.pgDetail = msg.err.Error() - m.showDashboard() - return m, nil - } - m.dashboard.pgStatus = statusDone - m.dashboard.pgDetail = "ready" - m.showDashboard() - return m, m.checkMigrations() + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.pushSize() + return m, nil - case migrationDoneMsg: - if msg.err == nil { - m.dashboard.mgStatus = statusDone - m.dashboard.mgDetail = fmt.Sprintf("%d applied", msg.n) - } else { - m.dashboard.mgStatus = statusFailed - m.dashboard.mgDetail = msg.err.Error() + case preflightDoneMsg: + if m.adminExists { + // Re-run: setup already happened. Land on the status screen. + return m.showStatus() } - m.showDashboard() - if msg.err != nil { - return m, nil - } - return m, m.checkPanel() + m.stage = stageOwner + return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false)) case ownerResultMsg: if msg.err != nil { - if m.mode == consoleModeBreakGlass { - m.err = msg.err - return m, tea.Quit - } - m.dashboard.owStatus = statusFailed - m.dashboard.owDetail = msg.err.Error() - m.showDashboard() - return m, nil + m.err = msg.err + return m, tea.Quit } m.result.provisioned = true m.result.username = msg.username @@ -144,122 +167,155 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } m.adminExists = true - m.dashboard.adminExists = true - m.dashboard.owStatus = statusDone - m.dashboard.owDetail = msg.username - m.showDashboard() - return m, m.checkPanel() + m.stage = stageConnect + return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) - case panelCheckMsg: - m.result.panelURL = msg.result.url - if msg.result.err != nil { - m.dashboard.paStatus = statusFailed - m.dashboard.paDetail = msg.result.err.Error() - } else { - m.dashboard.paStatus = statusDone - m.dashboard.paDetail = msg.result.url - } - m.showDashboard() - return m, nil + case connectResultMsg: + m.applyConnectResult(msg) + return m.showSummary() - case edgeResultMsg: - if msg.err != nil { - if m.mode == consoleModeBreakGlass { - m.err = msg.err - return m, tea.Quit - } - m.dashboard.egStatus = statusFailed - m.dashboard.egDetail = msg.err.Error() - m.showDashboard() - return m, nil - } - m.result.edgeConfigured = true - m.result.edgeAud = msg.result.AccessAud - m.result.edgeRoutedHosts = msg.result.RoutedHostnames - m.result.edgeConfigPath = msg.result.ConfigPath - m.result.edgePanelHostname = msg.panelHostname - m.result.edgeAdminHostname = msg.adminHostname - if m.mode == consoleModeBreakGlass { - return m, tea.Quit - } - m.dashboard.egStatus = statusDone - m.dashboard.egDetail = "configured" - m.showDashboard() - return m, nil - - case switchToDashboard: - m.refreshDashboard() - return m, m.checkStatus() + case goBackMsg: + m.stage = stageConnect + return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) + case reconfigureConnectMsg: + m.stage = stageConnect + return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost)) } if m.screen != nil { newScreen, cmd := m.screen.Update(msg) - if newScreen != nil { - if newScreen != m.screen { - m.screen = newScreen - return m, tea.Batch(cmd, m.screen.Init()) - } + if newScreen != nil && newScreen != m.screen { + adopted, initCmd := m.adopt(newScreen) + return adopted, tea.Batch(cmd, initCmd) } return m, cmd } return m, nil } -func (m *rootModel) View() string { - if m.screen != nil { - return m.screen.View() - } - return "" +// adopt installs a new screen, hands it the current size, and returns its Init. +// Centralizing screen swaps here guarantees every screen is sized before its +// first View — the fix for the rail being clipped off the top of the frame. +func (m *rootModel) adopt(s tea.Model) (tea.Model, tea.Cmd) { + m.screen = s + m.pushSize() + return m, s.Init() } -func (m *rootModel) showDashboard() { - m.screen = m.dashboard -} - -func (m *rootModel) refreshDashboard() { - d := m.dashboard - d.pgStatus = statusPending - d.mgStatus = statusPending - d.owStatus = statusOptional - d.paStatus = statusPending - d.egStatus = statusOptional - if m.adminExists { - d.owStatus = statusDone - d.owDetail = "already exists (use breakGlass to reset)" +// pushSize gives the active screen the area left after the step rail. +func (m *rootModel) pushSize() { + if m.height == 0 { + return } - if m.result.provisioned { - d.owStatus = statusDone - d.owDetail = m.result.username - } - if m.result.edgeConfigured { - d.egStatus = statusDone - d.egDetail = "configured" - } - if m.result.panelURL != "" { - d.paStatus = statusDone - d.paDetail = m.result.panelURL - } -} - -func (m *rootModel) checkStatus() tea.Cmd { - return func() tea.Msg { - if err := checkPostgres(m.dbURL); err != nil { - return pgDoneMsg{err: err} + if s, ok := m.screen.(sizeable); ok { + // Reserve one extra row: huh renders a hair taller than its WithHeight + // budget (the help line sits outside it). Clipping the rail off the top is + // the bug we're fixing, so we round the budget down — a blank bottom row is + // invisible, an overflowed top is not. + avail := m.height - m.chromeHeight() - 1 + if avail < 1 { + avail = 1 } - return pgDoneMsg{} + s.setSize(m.width, avail) } } -func (m *rootModel) checkMigrations() tea.Cmd { - return func() tea.Msg { - n, err := countMigrations(m.dbURL) - return migrationDoneMsg{n: n, err: err} +// chromeHeight is the number of rows the root paints around the screen — the +// rail plus its blank separator, or 0 when the rail is hidden. +func (m *rootModel) chromeHeight() int { + if m.mode != consoleModeSetup || m.adminExistsAtStart() { + return 0 } + return lipgloss.Height(m.rail()) + 1 } -func (m *rootModel) checkPanel() tea.Cmd { - return func() tea.Msg { - return panelCheckMsg{result: checkPanelAccess(m.rootDomain)} +func (m *rootModel) View() string { + if m.screen == nil { + return "" } + if m.mode == consoleModeSetup && !m.adminExistsAtStart() { + return m.rail() + "\n\n" + m.screen.View() + } + return m.screen.View() +} + +// adminExistsAtStart reports whether this run began with an Owner already +// present (a re-run). The rail only makes sense for the first-run linear wizard. +func (m *rootModel) adminExistsAtStart() bool { + // adminExists flips true once we provision the Owner mid-run; the rail should + // keep showing through the connect/summary stages of that same first run. So + // only suppress the rail when the Owner pre-existed AND we never provisioned. + return m.adminExists && !m.result.provisioned +} + +func (m *rootModel) rail() string { + return tuiStepRail([]string{"Preflight", "Owner", "Connection", "Done"}, int(m.stage)) +} + +// applyConnectResult records the chosen connection outcome onto the result. +func (m *rootModel) applyConnectResult(msg connectResultMsg) { + m.result.connectMethod = msg.method + if msg.panelHostname != "" { + m.result.panelHostname = msg.panelHostname + } + if msg.adminHostname != "" { + m.result.adminHostname = msg.adminHostname + } + switch msg.method { + case connectCloudflare: + m.result.connectConfigured = true + m.result.edgeConfigured = true + if msg.edge != nil { + m.result.edgeAud = msg.edge.AccessAud + m.result.edgeRoutedHosts = msg.edge.RoutedHostnames + m.result.edgeConfigPath = msg.edge.ConfigPath + } + case connectReverseProxy: + m.result.connectConfigured = true + m.result.reverseProxyGuide = msg.guide + } + m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain) +} + +func (m *rootModel) showSummary() (tea.Model, tea.Cmd) { + m.stage = stageSummary + routed := m.result.edgeRoutedHosts + if len(routed) == 0 && m.result.connectMethod == connectReverseProxy && m.result.panelHostname != "" { + routed = []string{m.result.panelHostname} + } + return m.adopt(&summaryModel{ + panelURL: m.result.panelURL, + ownerUsername: m.result.username, + ownerPassword: m.result.displayPassword, + accessLabel: connectMethodLabel(m.result.connectMethod), + routedHosts: routed, + localHint: m.result.connectMethod == connectLocal, + }) +} + +// showStatus is the re-run landing: prove the backend is up, then point the +// operator at the panel without forcing any reconfiguration. +func (m *rootModel) showStatus() (tea.Model, tea.Cmd) { + m.stage = stageSummary + method := connectLocal + accessLabel := "configured (manage in panel)" + if m.accessAud != "" { + method = connectCloudflare + accessLabel = connectMethodLabel(connectCloudflare) + } + m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain) + return m.adopt(&summaryModel{ + panelURL: m.result.panelURL, + accessLabel: accessLabel, + alreadySetUp: true, + localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "", + }) +} + +func panelURLFor(method connectMethod, panelHostname, rootDomain string) string { + if method != connectLocal && panelHostname != "" { + return "https://" + panelHostname + } + return localPanelURL(rootDomain) } diff --git a/cmd/felis/tui_root_test.go b/cmd/felis/tui_root_test.go new file mode 100644 index 0000000..30ec4db --- /dev/null +++ b/cmd/felis/tui_root_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// These tests exercise the root wizard's state machine directly by injecting the +// inter-screen messages into rootModel.Update. They bypass all real I/O (DB, +// migrations, panel probe, provisioning) — the screens emit those messages via +// commands we don't run — so the only thing under test is the orchestration: +// which screen the root advances to, and what it records on the result. This is +// the part the dashboard→wizard refactor actually put at risk, and it needs no +// root, k3s, or database. + +func drive(t *testing.T, m *rootModel, msg tea.Msg) *rootModel { + t.Helper() + next, _ := m.Update(msg) + rm, ok := next.(*rootModel) + if !ok { + t.Fatalf("Update returned %T, want *rootModel", next) + } + return rm +} + +func newTestRoot(adminExists bool, mode consoleMode, accessAud string) *rootModel { + return newRootModel( + context.Background(), + &fakeOwnerStore{}, + "postgres://localhost/felis", + "felis.example.com", + "admin.felis.example.com", + "panel.felis.example.com", + accessAud, + "root", + adminExists, + mode, + ) +} + +func TestRootSetupHappyPath(t *testing.T) { + m := newTestRoot(false, consoleModeSetup, "") + + // First-run setup begins at preflight. + if m.stage != stagePreflight { + t.Fatalf("initial stage = %v, want stagePreflight", m.stage) + } + if _, ok := m.screen.(*preflightModel); !ok { + t.Fatalf("initial screen = %T, want *preflightModel", m.screen) + } + + // Preflight done → Owner. + m = drive(t, m, preflightDoneMsg{}) + if m.stage != stageOwner { + t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage) + } + if _, ok := m.screen.(*ownerModel); !ok { + t.Fatalf("after preflight, screen = %T, want *ownerModel", m.screen) + } + + // Owner provisioned → Connection chooser. + m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"}) + if m.stage != stageConnect { + t.Fatalf("after owner, stage = %v, want stageConnect", m.stage) + } + if _, ok := m.screen.(*connectChooserModel); !ok { + t.Fatalf("after owner, screen = %T, want *connectChooserModel", m.screen) + } + if !m.result.provisioned || m.result.username != "owner" || m.result.displayPassword != "hunter2" { + t.Fatalf("owner result not recorded: %+v", m.result) + } + + // Reverse-proxy chosen → Summary, with the connection recorded. + guide := "caddy config…" + m = drive(t, m, connectResultMsg{ + method: connectReverseProxy, + panelHostname: "panel.felis.example.com", + adminHostname: "admin.felis.example.com", + guide: guide, + }) + if m.stage != stageSummary { + t.Fatalf("after connect, stage = %v, want stageSummary", m.stage) + } + sum, ok := m.screen.(*summaryModel) + if !ok { + t.Fatalf("after connect, screen = %T, want *summaryModel", m.screen) + } + if !m.result.connectConfigured { + t.Fatalf("connectConfigured not set") + } + if m.result.connectMethod != connectReverseProxy { + t.Fatalf("connectMethod = %v, want connectReverseProxy", m.result.connectMethod) + } + if m.result.reverseProxyGuide != guide { + t.Fatalf("reverseProxyGuide = %q, want %q", m.result.reverseProxyGuide, guide) + } + if want := "https://panel.felis.example.com"; sum.panelURL != want { + t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want) + } + if sum.ownerPassword != "hunter2" { + t.Fatalf("summary ownerPassword = %q, want %q", sum.ownerPassword, "hunter2") + } + if sum.alreadySetUp { + t.Fatalf("first-run summary should not be marked alreadySetUp") + } +} + +func TestRootSetupLocalSummary(t *testing.T) { + m := newTestRoot(false, consoleModeSetup, "") + m = drive(t, m, preflightDoneMsg{}) + m = drive(t, m, ownerResultMsg{username: "owner"}) + m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"}) + + sum, ok := m.screen.(*summaryModel) + if !ok { + t.Fatalf("screen = %T, want *summaryModel", m.screen) + } + if !sum.localHint { + t.Fatalf("local connection summary should set localHint") + } + // Local never points at the public hostname. + if sum.panelURL == "https://panel.felis.example.com" { + t.Fatalf("local summary panelURL should be the local origin, got %q", sum.panelURL) + } +} + +func TestRootRerunLandsOnStatus(t *testing.T) { + // adminExists at start of a setup run = re-run: preflight should skip straight + // to the "manage in panel" status screen, never touching owner/connect. + m := newTestRoot(true, consoleModeSetup, "") + if _, ok := m.screen.(*preflightModel); !ok { + t.Fatalf("re-run initial screen = %T, want *preflightModel", m.screen) + } + + m = drive(t, m, preflightDoneMsg{}) + sum, ok := m.screen.(*summaryModel) + if !ok { + t.Fatalf("re-run after preflight, screen = %T, want *summaryModel", m.screen) + } + if !sum.alreadySetUp { + t.Fatalf("re-run summary should be marked alreadySetUp") + } + if m.result.provisioned { + t.Fatalf("re-run must not provision an owner") + } +} + +func TestRootBreakGlassQuitsAfterOwner(t *testing.T) { + // Break-glass starts at owner and must quit on owner completion without + // entering the connection chooser. + m := newTestRoot(true, consoleModeBreakGlass, "") + if m.stage != stageOwner { + t.Fatalf("break-glass initial stage = %v, want stageOwner", m.stage) + } + if _, ok := m.screen.(*ownerModel); !ok { + t.Fatalf("break-glass initial screen = %T, want *ownerModel", m.screen) + } + + next, cmd := m.Update(ownerResultMsg{username: "owner", displayPassword: "pw", mode: "recovery"}) + rm := next.(*rootModel) + if _, ok := rm.screen.(*connectChooserModel); ok { + t.Fatalf("break-glass must not enter the connection chooser") + } + if cmd == nil { + t.Fatalf("break-glass owner completion should return a command (tea.Quit)") + } + if msg := cmd(); !isQuit(msg) { + t.Fatalf("break-glass owner completion command = %T, want tea.Quit", msg) + } + if !rm.result.provisioned || rm.result.username != "owner" { + t.Fatalf("break-glass owner result not recorded: %+v", rm.result) + } +} + +// 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. +func isQuit(msg tea.Msg) bool { + _, ok := msg.(tea.QuitMsg) + return ok +} diff --git a/cmd/felis/tui_styles.go b/cmd/felis/tui_styles.go index 7e7ce7e..2dc751e 100644 --- a/cmd/felis/tui_styles.go +++ b/cmd/felis/tui_styles.go @@ -46,7 +46,7 @@ var ( // Hint / help text tuiHint = lipgloss.NewStyle(). - Foreground(cWhite) + Foreground(cWhite) // Success text tuiOK = lipgloss.NewStyle(). @@ -76,36 +76,25 @@ var ( Padding(0, 1) // Status icon styles - tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓") - tuiIconInPro = lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("→") - tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○") - tuiIconErr = lipgloss.NewStyle().Bold(true).Foreground(cError).Render("✗") - tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳") - - // Step card dimensions - tuiStepWidth = 60 - tuiStepHeight = 5 + tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓") + tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○") + tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳") // Info box — subtle background tuiInfoBox = lipgloss.NewStyle(). Background(cBgDark). Padding(1, 2). Width(60) -) -func tuiIcon(status stepStatus) string { - switch status { - case statusDone: - return tuiIconOK - case statusPending: - return tuiIconInPro - case statusOptional: - return tuiIconOpt - case statusFailed: - return tuiIconErr - case statusRunning: - return tuiIconSpin - default: - return " " - } -} + // Step rail (breadcrumb) — shows where you are in the linear wizard. + tuiRailDone = lipgloss.NewStyle().Foreground(cSuccess) + tuiRailActive = lipgloss.NewStyle().Bold(true).Foreground(cWhite).Background(cPrimary).Padding(0, 1) + tuiRailTodo = lipgloss.NewStyle().Foreground(cDim) + tuiRailSep = lipgloss.NewStyle().Foreground(cDim) + + // Code/guide block — monospace-ish snippet on a subtle background. + tuiCodeBox = lipgloss.NewStyle(). + Foreground(cWhite). + Background(cBgInput). + Padding(0, 1) +) diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go new file mode 100644 index 0000000..e5ccf76 --- /dev/null +++ b/cmd/felis/tui_summary.go @@ -0,0 +1,73 @@ +package main + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// summaryModel is the terminal screen of the setup wizard. On a first run it +// confirms what was just configured and points the operator at the panel for +// everything else. On a re-run (an Owner already exists) it doubles as a thin +// status landing screen — the whole point of the redesign is that setup runs +// once and the rest of administration happens in the panel. +type summaryModel struct { + panelURL string + ownerUsername string + ownerPassword string // one-time; shown once + accessLabel string + routedHosts []string + alreadySetUp bool // re-run: Owner pre-existed + localHint bool // show the self-signed-cert note +} + +func (m *summaryModel) Init() tea.Cmd { return nil } + +func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "c", "C": + return m, func() tea.Msg { return reconfigureConnectMsg{} } + case "ctrl+c", "esc", "enter", "q": + return m, tea.Quit + } + } + return m, nil +} + +func (m *summaryModel) View() string { + var b strings.Builder + + if m.alreadySetUp { + b.WriteString(tuiOK.Render("✓ Felis is already set up.") + "\n\n") + } else { + b.WriteString(tuiOK.Render("✓ Setup complete.") + "\n\n") + } + + var card strings.Builder + if m.ownerUsername != "" { + card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n") + } + if m.ownerPassword != "" { + card.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.ownerPassword) + "\n") + card.WriteString(" " + tuiWarn.Render("shown only once — record it now") + "\n") + } + if m.accessLabel != "" { + card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n") + } + if len(m.routedHosts) > 0 { + card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.routedHosts, ", ") + "\n") + } + if m.panelURL != "" { + card.WriteString(tuiLabel.Render("panel ") + m.panelURL + "\n") + } + b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n") + + b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n") + if m.localHint { + b.WriteString(tuiHint.Render(" The local certificate is self-signed; your browser may warn on first visit.") + "\n") + } + + b.WriteString("\n" + tuiAction("c", "change connection", "enter/esc", "exit")) + return b.String() +} diff --git a/cmd/felis/tui_theme.go b/cmd/felis/tui_theme.go new file mode 100644 index 0000000..3617eac --- /dev/null +++ b/cmd/felis/tui_theme.go @@ -0,0 +1,69 @@ +package main + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// felisTheme recolors huh's base theme to the Felis palette (cyan primary, +// purple accent, green success) so the forms feel like part of the product +// rather than a generic huh prompt. We start from ThemeBase — a neutral +// skeleton with the right structure — and override only the colored bits. +func felisTheme() *huh.Theme { + t := huh.ThemeBase() + + f := &t.Focused + f.Base = f.Base.BorderForeground(cPrimary) + f.Card = f.Base + f.Title = lipgloss.NewStyle().Foreground(cPrimary).Bold(true) + f.NoteTitle = f.Title + f.Description = lipgloss.NewStyle().Foreground(cDim) + f.ErrorIndicator = lipgloss.NewStyle().Foreground(cError).SetString(" ✗") + f.ErrorMessage = lipgloss.NewStyle().Foreground(cError) + + f.SelectSelector = lipgloss.NewStyle().Foreground(cPrimary).SetString("▸ ") + f.Option = lipgloss.NewStyle().Foreground(cWhite) + 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("←") + + f.TextInput.Cursor = lipgloss.NewStyle().Foreground(cPrimary) + f.TextInput.Prompt = lipgloss.NewStyle().Foreground(cPrimary) + f.TextInput.Placeholder = lipgloss.NewStyle().Foreground(cDim) + f.TextInput.Text = lipgloss.NewStyle().Foreground(cWhite) + + button := lipgloss.NewStyle().Padding(0, 2).MarginRight(1) + f.FocusedButton = button.Foreground(cWhite).Background(cPrimary).Bold(true) + f.BlurredButton = button.Foreground(cWhite).Background(cBgDark) + + // Blurred mirrors focused but hides the left bar and dims the title so the + // active field is unmistakable. + t.Blurred = *f + t.Blurred.Base = f.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.Title = lipgloss.NewStyle().Foreground(cDim).Bold(true) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + // Help line at the bottom of every form. + t.Help.ShortKey = lipgloss.NewStyle().Foreground(cPrimary) + t.Help.ShortDesc = lipgloss.NewStyle().Foreground(cDim) + t.Help.ShortSeparator = lipgloss.NewStyle().Foreground(cDim) + t.Help.FullKey = t.Help.ShortKey + t.Help.FullDesc = t.Help.ShortDesc + t.Help.FullSeparator = t.Help.ShortSeparator + + return t +} + +// theme is built once; lipgloss styles are immutable values so sharing is safe. +var felisFormTheme = felisTheme() + +// newFelisForm wires a form to the Felis theme with the conventions every wizard +// screen wants: themed, help line shown, errors shown inline. +func newFelisForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...). + WithTheme(felisFormTheme). + WithShowHelp(true). + WithShowErrors(true) +} diff --git a/cmd/felis/tui_widgets.go b/cmd/felis/tui_widgets.go index ddd5769..dadc93c 100644 --- a/cmd/felis/tui_widgets.go +++ b/cmd/felis/tui_widgets.go @@ -8,24 +8,8 @@ import ( "github.com/charmbracelet/lipgloss" ) -type stepStatus int - -const ( - statusDone stepStatus = iota - statusPending - statusOptional - statusRunning - statusFailed -) - -type dashboardStep struct { - title string - status stepStatus - detail string -} - func tuiHeader(title string) string { - return tuiTitle.Render("🐾 " + title) + "\n\n" + return tuiTitle.Render("🐾 "+title) + "\n\n" } func tuiAction(pairs ...string) string { @@ -36,17 +20,6 @@ func tuiAction(pairs ...string) string { return tuiActionBar.Render(strings.Join(parts, " · ")) } -func tuiStatusLine(icon, label, detail string, focused bool) string { - line := fmt.Sprintf(" %s %s", icon, tuiLabel.Render(label)) - if detail != "" { - line += "\n " + tuiHint.Render(detail) - } - if focused { - return tuiCardFocusedStyle.Width(tuiStepWidth).Render(line) - } - return tuiCardStyle.Width(tuiStepWidth).Render(line) -} - func tuiFormField(label string, input textinput.Model) string { return fmt.Sprintf("%s\n%s", tuiLabel.Render(label), @@ -57,25 +30,6 @@ func tuiInfo(text string) string { return tuiInfoBox.Render(tuiHint.Render("ℹ " + text)) } -type progressStep struct { - label string - done bool -} - -func tuiProgress(steps []progressStep) string { - var b strings.Builder - for _, s := range steps { - if s.done { - b.WriteString(" " + tuiIconOK + " " + s.label + "\n") - } else if len(steps) > 0 && s == steps[0] { - continue - } else { - b.WriteString(" " + tuiIconOpt + " " + s.label + "\n") - } - } - return b.String() -} - func tuiWizardCard(title, desc, body string) string { var b strings.Builder b.WriteString(tuiSection.Render(title)) @@ -88,15 +42,6 @@ func tuiWizardCard(title, desc, body string) string { return b.String() } -func tuiResultCard(title string, pairs ...string) string { - var b strings.Builder - b.WriteString(tuiOK.Render(title) + "\n\n") - for i := 0; i+1 < len(pairs); i += 2 { - b.WriteString(tuiLabel.Render(pairs[i]) + " " + tuiPassword.Render(pairs[i+1]) + "\n") - } - return tuiCardStyle.Render(b.String()) -} - func tuiErrorBanner(msg string) string { return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg)) } @@ -122,3 +67,32 @@ func tuiInput(placeholder string, charLimit int, password bool) textinput.Model func tuiSeparator() string { return tuiHint.Render(strings.Repeat("─", 70)) } + +// tuiStepRail renders a breadcrumb of wizard stages. Steps before `current` +// render as done, `current` is highlighted, and later steps are dimmed. +func tuiStepRail(steps []string, current int) string { + var parts []string + for i, s := range steps { + switch { + case i < current: + parts = append(parts, tuiRailDone.Render("✓ "+s)) + case i == current: + parts = append(parts, tuiRailActive.Render(fmt.Sprintf("%d. %s", i+1, s))) + default: + parts = append(parts, tuiRailTodo.Render(fmt.Sprintf("%d. %s", i+1, s))) + } + } + return tuiRailSep.Render(" ") + strings.Join(parts, tuiRailSep.Render(" → ")) +} + +// tuiGuideBlock renders a titled, copy-pasteable snippet (e.g. a Caddyfile). +func tuiGuideBlock(title, body string) string { + var b strings.Builder + if title != "" { + b.WriteString(tuiLabel.Render(title) + "\n") + } + for _, line := range strings.Split(body, "\n") { + b.WriteString(tuiCodeBox.Render(line) + "\n") + } + return b.String() +} diff --git a/go.mod b/go.mod index c02b97d..d96e88e 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.4.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/jackc/pgx/v5 v5.7.1 @@ -21,15 +22,18 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect @@ -56,6 +60,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -74,10 +79,10 @@ require ( golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sync v0.8.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 6186f96..06a2db0 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -14,14 +20,28 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -29,10 +49,14 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -109,6 +133,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -187,8 +213,8 @@ golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -200,8 +226,8 @@ golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=