refactor(deploy): improved TUI walkthrough

This commit is contained in:
Lemon-miaow committed 2026-06-30 02:25:18 +08:00
1 parent e5f1682898
commit 28c3eee361
20 files changed
+1551 -906

No files matched your search

+14 -8
View File
@@ -125,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
return 1 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 { if err != nil {
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
return 1 return 1
@@ -411,7 +411,13 @@ type breakGlassResult struct {
adminHostname string adminHostname string
panelURL 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 edgeConfigured bool
edgeAud string edgeAud string
edgeRoutedHosts []string edgeRoutedHosts []string
@@ -438,16 +444,16 @@ const (
cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" 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) { 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, osUser, adminExists, consoleModeBreakGlass) 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) { 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, osUser, adminExists, consoleModeSetup) 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) { 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, osUser, adminExists, mode) rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, mode)
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
if err != nil { if err != nil {
return breakGlassResult{}, err return breakGlassResult{}, err
+12 -5
View File
@@ -84,7 +84,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
} }
defer setup.drv.Close() 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 { if err != nil {
fmt.Fprintf(stderr, "felis setup: %v\n", err) fmt.Fprintf(stderr, "felis setup: %v\n", err)
return 1 return 1
@@ -94,9 +94,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
panelURL = localPanelURL(setup.cfg.Server.RootDomain) panelURL = localPanelURL(setup.cfg.Server.RootDomain)
} }
if !res.provisioned && !res.edgeConfigured { if !res.provisioned && !res.connectConfigured {
if bootstrapped { 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 != "" { if panelURL != "" {
fmt.Fprintf(stdout, "Panel: %s\n", 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.") fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
@@ -127,8 +127,10 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
} }
} }
if res.edgeConfigured { if res.connectConfigured {
fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access edge configured.\n") switch res.connectMethod {
case connectCloudflare:
fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access configured.\n")
if len(res.edgeRoutedHosts) > 0 { if len(res.edgeRoutedHosts) > 0 {
fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", ")) fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", "))
} }
@@ -136,6 +138,11 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", 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.") 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.")
}
} }
return 0 return 0
} }
+1 -1
View File
@@ -97,7 +97,7 @@ func (m *hostBootstrapModel) View() string {
b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n") b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n")
case hostBootstrapDone: case hostBootstrapDone:
b.WriteString(tuiSuccessBanner("Host bootstrap completed.") + "\n\n") 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("\n" + tuiSeparator() + "\n")
b.WriteString(tuiAction("enter", "continue", "esc", "continue")) b.WriteString(tuiAction("enter", "continue", "esc", "continue"))
case hostBootstrapError: case hostBootstrapError:
+349
View File
@@ -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)
}
-139
View File
@@ -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()
}
+6 -5
View File
@@ -154,7 +154,7 @@ func (m *edgeModel) View() string {
switch m.step { switch m.step {
case egIntro: 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") b.WriteString(tuiLabel.Render("Status") + "\n")
if m.cloudflaredPath == "" { if m.cloudflaredPath == "" {
b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n") 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 { if m.step == egDone {
return m, m.sendEdgeResult() return m, m.sendEdgeResult()
} }
return m, func() tea.Msg { return switchToDashboard{} } return m, goBack()
case "enter": case "enter":
if m.step == egDone { if m.step == egDone {
return m, m.sendEdgeResult() 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) { func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
return m, func() tea.Msg { return switchToDashboard{} } return m, goBack()
case "i", "I": case "i", "I":
if m.cloudflaredPath == "" { if m.cloudflaredPath == "" {
m.installing = true 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 { func (m *edgeModel) sendEdgeResult() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
return edgeResultMsg{ return connectResultMsg{
result: m.result, method: connectCloudflare,
edge: m.result,
panelHostname: m.panelSet, panelHostname: m.panelSet,
adminHostname: m.adminSet, adminHostname: m.adminSet,
} }
+33 -3
View File
@@ -24,11 +24,9 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost,
if adminHost == "" { if adminHost == "" {
return fmt.Errorf("admin hostname is required") return fmt.Errorf("admin hostname is required")
} }
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} { if err := writeConnectionConfig(panelHost, adminHost, result.AccessAud); err != nil {
if err := updateAuthConfig(path, panelHost, adminHost, result.AccessAud); err != nil {
return err return err
} }
}
if err := applyFelisConfigSecret(ctx); err != nil { if err := applyFelisConfigSecret(ctx); err != nil {
return err 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") 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 { func updateAuthConfig(path, panelHost, adminHost, aud string) error {
cfg, err := config.Load(path) cfg, err := config.Load(path)
if err != nil { if err != nil {
+41
View File
@@ -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)
}
}
}
}
}
+10 -110
View File
@@ -6,131 +6,31 @@ import (
"time" "time"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
tea "github.com/charmbracelet/bubbletea"
) )
type migRunMsg struct { // applyMigrations runs any pending schema migrations and returns the resulting
n int // applied count. Used by the preflight stage to self-heal a freshly bootstrapped
total int // (or upgraded) database.
err error func applyMigrations(dbURL string) (int, 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",
}
}
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}
}
}
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)
}
}
}
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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
drv, err := store.Open(ctx, dbURL) drv, err := store.Open(ctx, dbURL)
if err != nil { if err != nil {
return migRunMsg{err: fmt.Errorf("open db: %w", err)} return 0, fmt.Errorf("open db: %w", err)
} }
defer drv.Close() defer drv.Close()
migrations, err := store.LoadMigrations() migrations, err := store.LoadMigrations()
if err != nil { if err != nil {
return migRunMsg{err: err} return 0, err
} }
_, err = store.Up(ctx, drv, migrations) if _, err := store.Up(ctx, drv, migrations); err != nil {
if err != nil { return 0, err
return migRunMsg{err: err}
} }
applied, err := drv.AppliedVersions(ctx) applied, err := drv.AppliedVersions(ctx)
if err != nil { if err != nil {
return migRunMsg{err: err} return 0, err
}
return migRunMsg{n: len(applied), total: len(migrations)}
} }
return len(applied), nil
} }
func totalMigrations() (int, error) { func totalMigrations() (int, error) {
+243 -236
View File
@@ -2,11 +2,13 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
) )
type owAuthMsg struct { type owAuthMsg struct {
@@ -31,6 +33,10 @@ const (
owError 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 { type ownerModel struct {
ctx context.Context ctx context.Context
store ownerStore store ownerStore
@@ -38,13 +44,23 @@ type ownerModel struct {
adminExists bool adminExists bool
mode string // "bootstrap", "recovery", "root_override" mode string // "bootstrap", "recovery", "root_override"
accountable string accountable string
attempt string
step owStep step owStep
inputs []textinput.Model form *huh.Form
focus int sp spinner.Model
formErr string
working string 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 username string
displayPassword string displayPassword string
@@ -52,49 +68,72 @@ type ownerModel struct {
} }
func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel { func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
m := &ownerModel{ m := &ownerModel{
ctx: ctx, ctx: ctx,
store: store, store: store,
osUser: osUser, osUser: osUser,
adminExists: adminExists, adminExists: adminExists,
sp: sp,
ownerUser: "owner",
} }
if adminExists { if adminExists {
m.step = owAuth m.step = owAuth
m.form = m.buildAuthForm()
} else { } else {
m.mode = "bootstrap" m.mode = "bootstrap"
m.accountable = osUser m.accountable = osUser
m.step = owProvision m.step = owProvision
m.form = m.buildProvisionForm()
} }
return m return m
} }
func (m *ownerModel) Init() tea.Cmd { func (m *ownerModel) Init() tea.Cmd { return m.form.Init() }
if m.step == owAuth {
return m.buildAuth() 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) { func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case owAuthMsg: case owAuthMsg:
m.working = ""
if msg.err != nil { if msg.err != nil {
return m, func() tea.Msg { return ownerResultMsg{err: msg.err} } return m, m.failCmd(msg.err)
} }
if msg.ok { if msg.ok {
m.mode = "recovery" m.mode = "recovery"
m.accountable = msg.matched m.accountable = msg.matched
m.step = owProvision m.step = owProvision
return m, m.buildProvision(false) m.form = m.sized(m.buildProvisionForm())
return m, m.form.Init()
} }
m.step = owOverride m.step = owOverride
m.formErr = "" m.form = m.sized(m.buildOverrideForm())
return m, m.buildOverride() return m, m.form.Init()
case owProvisionMsg: case owProvisionMsg:
if msg.err != nil { if msg.err != nil {
return m, func() tea.Msg { return ownerResultMsg{err: msg.err} } return m, m.failCmd(msg.err)
} }
m.step = owDone m.step = owDone
m.displayPassword = msg.outcome.displayPassword m.displayPassword = msg.outcome.displayPassword
@@ -103,157 +142,107 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil 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: case tea.KeyMsg:
switch m.step { switch m.step {
case owDone, owError: case owDone:
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc", "enter": case "ctrl+c", "esc", "enter":
if m.step == owDone {
return m, m.ownerResultCmd() return m, m.ownerResultCmd()
} }
return m, nil return m, nil
}
return m, nil
case owWorking: case owWorking:
return m, nil if msg.String() == "ctrl+c" {
default: return m, tea.Quit
return m.handleFormKey(msg)
}
}
// Forward to inputs.
if m.step != owWorking && m.step != owDone && m.step != owError {
return m, m.updateInputs(msg)
} }
return m, nil return m, nil
} default: // form steps — intercept cancel/back, let huh handle the rest
func (m *ownerModel) View() string {
var b strings.Builder
b.WriteString(tuiHeader("Owner Account"))
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"))
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"))
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"))
}
return b.String()
}
func (m *ownerModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
case "esc": case "esc":
if m.step == owOverride { if m.step == owOverride {
m.step, m.formErr = owAuth, "" m.step = owAuth
return m, m.buildAuth() m.form = m.sized(m.buildAuthForm())
return m, m.form.Init()
} }
return m, func() tea.Msg { return switchToDashboard{} } return m, tea.Quit
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()
}
return m, m.updateInputs(msg)
} }
func (m *ownerModel) focusInput(i int) tea.Cmd { // Drive the active form.
var cmd tea.Cmd if m.isFormStep() && m.form != nil {
for j := range m.inputs { form, cmd := m.form.Update(msg)
if j == i { if f, ok := form.(*huh.Form); ok {
cmd = m.inputs[j].Focus() m.form = f
} else {
m.inputs[j].Blur()
} }
switch m.form.State {
case huh.StateCompleted:
return m.onFormComplete()
case huh.StateAborted:
return m, tea.Quit
} }
return cmd return m, cmd
}
return m, nil
} }
func (m *ownerModel) updateInputs(msg tea.Msg) tea.Cmd { func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
cmds := make([]tea.Cmd, len(m.inputs)) switch m.step {
for i := range m.inputs { case owAuth:
m.inputs[i], cmds[i] = m.inputs[i].Update(msg) 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:
m.mode = "root_override"
m.accountable = m.osUser
m.step = owProvision
m.form = m.sized(m.buildProvisionForm())
return m, m.form.Init()
case owProvision:
m.username = strings.TrimSpace(m.ownerUser)
m.step = owWorking
m.working = "Provisioning Owner account…"
return m, tea.Batch(m.sp.Tick, m.provisionCmd())
} }
return tea.Batch(cmds...) return m, nil
}
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}
}
}
func (m *ownerModel) failCmd(err error) tea.Cmd {
return func() tea.Msg { return ownerResultMsg{err: err} }
} }
func (m *ownerModel) ownerResultCmd() tea.Cmd { 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 { // ---- form builders ----
m.inputs = ins
m.focus = 0 func (m *ownerModel) buildAuthForm() *huh.Form {
return m.focusInput(0) 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 { func (m *ownerModel) buildOverrideForm() *huh.Form {
user := tuiInput("admin username", 64, false) return m.sized(newFelisForm(huh.NewGroup(
pass := tuiInput("admin password", 128, true) huh.NewNote().
return m.setInputs([]textinput.Model{user, pass}) 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 { func (m *ownerModel) buildProvisionForm() *huh.Form {
confirm := tuiInput("type "+breakGlassOverrideToken, 16, false) desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
return m.setInputs([]textinput.Model{confirm}) 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."
} }
func (m *ownerModel) buildProvision(withPassword bool) tea.Cmd { fields := []huh.Field{
user := tuiInput("owner", 64, false) huh.NewNote().Title("Owner account").Description(desc),
user.SetValue("owner") huh.NewInput().
email := tuiInput("(optional)", 254, false) Title("Owner username").
ins := []textinput.Model{user, email} Value(&m.ownerUser).
if withPassword { Validate(requiredField("owner username")),
ins = append(ins, tuiInput("at least 8 characters", 128, true)) huh.NewInput().
ins = append(ins, tuiInput("re-enter password", 128, true)) Title("Owner email").
Description("optional").
Placeholder("[email protected]").
Value(&m.ownerEmail),
} }
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()
}
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" { if m.mode == "bootstrap" {
pw := m.inputs[2].Value() fields = append(fields,
confirm := m.inputs[3].Value() huh.NewInput().
if err := validateOwnerPassword(pw); err != nil { Title("Owner password").
m.formErr = err.Error() Description("at least 8 characters").
return m, m.focusForField(2) 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")
} }
if pw != confirm { return nil
m.formErr = "the two passwords do not match" }),
return m, m.focusForField(3) )
} }
password = pw return m.sized(newFelisForm(huh.NewGroup(fields...)))
} }
m.username = owner
m.formErr, m.working = "", "Provisioning Owner account…" func requiredField(name string) func(string) error {
m.step = owWorking return func(s string) error {
return m, func() tea.Msg { if strings.TrimSpace(s) == "" {
out, err := performBreakGlass(m.ctx, m.store, breakGlassOp{ return errors.New(name + " is required")
mode: m.mode, }
accountable: m.accountable, return nil
osUser: m.osUser,
ownerUsername: owner,
ownerEmail: email,
ownerPassword: password,
attemptedAdmin: m.attempt,
})
return owProvisionMsg{outcome: out, err: err}
} }
} }
func (m *ownerModel) focusForField(i int) tea.Cmd { // ---- views ----
m.focus = i
return m.focusInput(i) 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()
} }
+3 -172
View File
@@ -4,138 +4,15 @@ import (
"context" "context"
"fmt" "fmt"
"net" "net"
"os/exec"
"strings" "strings"
"time" "time"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
tea "github.com/charmbracelet/bubbletea"
) )
type pgCheckMsg struct { // checkPostgres proves the configured database is reachable and accepts a
running bool // connection. Host bootstrap provisions PostgreSQL, so in the normal setup flow
reachable bool // this succeeds immediately; the preflight stage uses it to fail fast otherwise.
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}
}
}
func checkPostgres(dbURL string) error { func checkPostgres(dbURL string) error {
cfg, err := parseDBURL(dbURL) cfg, err := parseDBURL(dbURL)
if err != nil { if err != nil {
@@ -157,52 +34,6 @@ func checkPostgres(dbURL string) error {
return nil 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 { type dbCfg struct {
addr string addr string
user string user string
+237
View File
@@ -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)
}
+199 -143
View File
@@ -2,21 +2,45 @@ package main
import ( import (
"context" "context"
"fmt"
"felis.lolicon.best/internal/cfsetup" "felis.lolicon.best/internal/cfsetup"
tea "github.com/charmbracelet/bubbletea" 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 ---- // ---- Messages: sub-model → root ----
type pgDoneMsg struct{ err error } type preflightDoneMsg struct{}
type migrationDoneMsg struct {
n int
err error
}
type ownerResultMsg struct { type ownerResultMsg struct {
username string username string
@@ -27,25 +51,45 @@ type ownerResultMsg struct {
err error err error
} }
type edgeResultMsg struct { // connectResultMsg is emitted by every connection method (the chooser for
result *cfsetup.Result // 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 panelHostname string
adminHostname 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. func goBack() tea.Cmd { return func() tea.Msg { return goBackMsg{} } }
type switchToDashboard struct{}
// reconfigureConnectMsg is sent from the re-run status screen to re-enter the
// connection chooser.
type reconfigureConnectMsg struct{}
// ---- rootModel: top-level session ---- // ---- rootModel: top-level session ----
type wizardStage int
const (
stagePreflight wizardStage = iota
stageOwner
stageConnect
stageSummary
)
type rootModel struct { type rootModel struct {
ctx context.Context ctx context.Context
screen tea.Model // current active screen screen tea.Model
dashboard *dashboardModel // always preserved stage wizardStage
width int
height int
result breakGlassResult result breakGlassResult
err error err error
@@ -57,10 +101,11 @@ type rootModel struct {
rootDomain string rootDomain string
adminHost string adminHost string
panelHost string panelHost string
accessAud string
adminExists bool 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{ rm := &rootModel{
ctx: ctx, ctx: ctx,
dbURL: dbURL, dbURL: dbURL,
@@ -69,71 +114,49 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
rootDomain: rootDomain, rootDomain: rootDomain,
adminHost: adminHostname, adminHost: adminHostname,
panelHost: panelHostname, panelHost: panelHostname,
accessAud: accessAud,
adminExists: adminExists, adminExists: adminExists,
mode: mode, mode: mode,
dashboard: newDashboardModel(ctx, store, dbURL, osUser, rootDomain, adminHostname, panelHostname),
result: breakGlassResult{ result: breakGlassResult{
osUser: osUser, osUser: osUser,
rootDomain: rootDomain, rootDomain: rootDomain,
adminHostname: adminHostname, adminHostname: adminHostname,
}, },
} }
rm.dashboard.adminExists = adminExists
rm.refreshDashboard()
if mode == consoleModeBreakGlass { if mode == consoleModeBreakGlass {
rm.stage = stageOwner
rm.screen = newOwnerModel(ctx, store, osUser, adminExists) rm.screen = newOwnerModel(ctx, store, osUser, adminExists)
} else { } else {
rm.screen = rm.dashboard rm.stage = stagePreflight
rm.screen = newPreflightModel(dbURL, rootDomain)
} }
return rm return rm
} }
func (m *rootModel) Init() tea.Cmd { func (m *rootModel) Init() tea.Cmd {
if m.mode == consoleModeSetup {
return m.checkStatus()
}
return m.screen.Init() return m.screen.Init()
} }
func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case pgDoneMsg: case tea.WindowSizeMsg:
if msg.err != nil { m.width, m.height = msg.Width, msg.Height
m.dashboard.pgStatus = statusFailed m.pushSize()
m.dashboard.pgDetail = msg.err.Error()
m.showDashboard()
return m, nil return m, nil
}
m.dashboard.pgStatus = statusDone
m.dashboard.pgDetail = "ready"
m.showDashboard()
return m, m.checkMigrations()
case migrationDoneMsg: case preflightDoneMsg:
if msg.err == nil { if m.adminExists {
m.dashboard.mgStatus = statusDone // Re-run: setup already happened. Land on the status screen.
m.dashboard.mgDetail = fmt.Sprintf("%d applied", msg.n) return m.showStatus()
} else {
m.dashboard.mgStatus = statusFailed
m.dashboard.mgDetail = msg.err.Error()
} }
m.showDashboard() m.stage = stageOwner
if msg.err != nil { return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
return m, nil
}
return m, m.checkPanel()
case ownerResultMsg: case ownerResultMsg:
if msg.err != nil { if msg.err != nil {
if m.mode == consoleModeBreakGlass {
m.err = msg.err m.err = msg.err
return m, tea.Quit return m, tea.Quit
} }
m.dashboard.owStatus = statusFailed
m.dashboard.owDetail = msg.err.Error()
m.showDashboard()
return m, nil
}
m.result.provisioned = true m.result.provisioned = true
m.result.username = msg.username m.result.username = msg.username
m.result.displayPassword = msg.displayPassword m.result.displayPassword = msg.displayPassword
@@ -144,122 +167,155 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
} }
m.adminExists = true m.adminExists = true
m.dashboard.adminExists = true m.stage = stageConnect
m.dashboard.owStatus = statusDone return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
m.dashboard.owDetail = msg.username
m.showDashboard()
return m, m.checkPanel()
case panelCheckMsg: case connectResultMsg:
m.result.panelURL = msg.result.url m.applyConnectResult(msg)
if msg.result.err != nil { return m.showSummary()
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 edgeResultMsg: case goBackMsg:
if msg.err != nil { m.stage = stageConnect
if m.mode == consoleModeBreakGlass { return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
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 reconfigureConnectMsg:
m.stage = stageConnect
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
} }
if m.screen != nil { if m.screen != nil {
newScreen, cmd := m.screen.Update(msg) newScreen, cmd := m.screen.Update(msg)
if newScreen != nil { if newScreen != nil && newScreen != m.screen {
if newScreen != m.screen { adopted, initCmd := m.adopt(newScreen)
m.screen = newScreen return adopted, tea.Batch(cmd, initCmd)
return m, tea.Batch(cmd, m.screen.Init())
}
} }
return m, cmd return m, cmd
} }
return m, nil return m, nil
} }
func (m *rootModel) View() string { // adopt installs a new screen, hands it the current size, and returns its Init.
if m.screen != nil { // Centralizing screen swaps here guarantees every screen is sized before its
return m.screen.View() // 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()
} }
// pushSize gives the active screen the area left after the step rail.
func (m *rootModel) pushSize() {
if m.height == 0 {
return
}
if s, ok := m.screen.(sizeable); ok {
// Reserve one extra row: huh renders a hair taller than its WithHeight
// budget (the help line sits outside it). Clipping the rail off the top is
// the bug we're fixing, so we round the budget down — a blank bottom row is
// invisible, an overflowed top is not.
avail := m.height - m.chromeHeight() - 1
if avail < 1 {
avail = 1
}
s.setSize(m.width, avail)
}
}
// chromeHeight is the number of rows the root paints around the screen — the
// rail plus its blank separator, or 0 when the rail is hidden.
func (m *rootModel) chromeHeight() int {
if m.mode != consoleModeSetup || m.adminExistsAtStart() {
return 0
}
return lipgloss.Height(m.rail()) + 1
}
func (m *rootModel) View() string {
if m.screen == nil {
return "" return ""
} }
if m.mode == consoleModeSetup && !m.adminExistsAtStart() {
func (m *rootModel) showDashboard() { return m.rail() + "\n\n" + m.screen.View()
m.screen = m.dashboard }
return m.screen.View()
} }
func (m *rootModel) refreshDashboard() { // adminExistsAtStart reports whether this run began with an Owner already
d := m.dashboard // present (a re-run). The rail only makes sense for the first-run linear wizard.
d.pgStatus = statusPending func (m *rootModel) adminExistsAtStart() bool {
d.mgStatus = statusPending // adminExists flips true once we provision the Owner mid-run; the rail should
d.owStatus = statusOptional // keep showing through the connect/summary stages of that same first run. So
d.paStatus = statusPending // only suppress the rail when the Owner pre-existed AND we never provisioned.
d.egStatus = statusOptional return m.adminExists && !m.result.provisioned
if m.adminExists {
d.owStatus = statusDone
d.owDetail = "already exists (use breakGlass to reset)"
}
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 { func (m *rootModel) rail() string {
return func() tea.Msg { return tuiStepRail([]string{"Preflight", "Owner", "Connection", "Done"}, int(m.stage))
if err := checkPostgres(m.dbURL); err != nil {
return pgDoneMsg{err: err}
}
return pgDoneMsg{}
}
} }
func (m *rootModel) checkMigrations() tea.Cmd { // applyConnectResult records the chosen connection outcome onto the result.
return func() tea.Msg { func (m *rootModel) applyConnectResult(msg connectResultMsg) {
n, err := countMigrations(m.dbURL) m.result.connectMethod = msg.method
return migrationDoneMsg{n: n, err: err} 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) checkPanel() tea.Cmd { func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
return func() tea.Msg { m.stage = stageSummary
return panelCheckMsg{result: checkPanelAccess(m.rootDomain)} 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)
} }
+183
View File
@@ -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
}
+12 -23
View File
@@ -77,35 +77,24 @@ var (
// Status icon styles // Status icon styles
tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓") tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓")
tuiIconInPro = lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("→")
tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○") tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○")
tuiIconErr = lipgloss.NewStyle().Bold(true).Foreground(cError).Render("✗")
tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳") tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳")
// Step card dimensions
tuiStepWidth = 60
tuiStepHeight = 5
// Info box — subtle background // Info box — subtle background
tuiInfoBox = lipgloss.NewStyle(). tuiInfoBox = lipgloss.NewStyle().
Background(cBgDark). Background(cBgDark).
Padding(1, 2). Padding(1, 2).
Width(60) Width(60)
)
func tuiIcon(status stepStatus) string { // Step rail (breadcrumb) — shows where you are in the linear wizard.
switch status { tuiRailDone = lipgloss.NewStyle().Foreground(cSuccess)
case statusDone: tuiRailActive = lipgloss.NewStyle().Bold(true).Foreground(cWhite).Background(cPrimary).Padding(0, 1)
return tuiIconOK tuiRailTodo = lipgloss.NewStyle().Foreground(cDim)
case statusPending: tuiRailSep = lipgloss.NewStyle().Foreground(cDim)
return tuiIconInPro
case statusOptional: // Code/guide block — monospace-ish snippet on a subtle background.
return tuiIconOpt tuiCodeBox = lipgloss.NewStyle().
case statusFailed: Foreground(cWhite).
return tuiIconErr Background(cBgInput).
case statusRunning: Padding(0, 1)
return tuiIconSpin )
default:
return " "
}
}
+73
View File
@@ -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()
}
+69
View File
@@ -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)
}
+29 -55
View File
@@ -8,22 +8,6 @@ import (
"github.com/charmbracelet/lipgloss" "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 { func tuiHeader(title string) string {
return tuiTitle.Render("🐾 "+title) + "\n\n" return tuiTitle.Render("🐾 "+title) + "\n\n"
} }
@@ -36,17 +20,6 @@ func tuiAction(pairs ...string) string {
return tuiActionBar.Render(strings.Join(parts, " · ")) 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 { func tuiFormField(label string, input textinput.Model) string {
return fmt.Sprintf("%s\n%s", return fmt.Sprintf("%s\n%s",
tuiLabel.Render(label), tuiLabel.Render(label),
@@ -57,25 +30,6 @@ func tuiInfo(text string) string {
return tuiInfoBox.Render(tuiHint.Render("ℹ " + text)) 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 { func tuiWizardCard(title, desc, body string) string {
var b strings.Builder var b strings.Builder
b.WriteString(tuiSection.Render(title)) b.WriteString(tuiSection.Render(title))
@@ -88,15 +42,6 @@ func tuiWizardCard(title, desc, body string) string {
return b.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 { func tuiErrorBanner(msg string) string {
return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg)) return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg))
} }
@@ -122,3 +67,32 @@ func tuiInput(placeholder string, charLimit int, password bool) textinput.Model
func tuiSeparator() string { func tuiSeparator() string {
return tuiHint.Render(strings.Repeat("─", 70)) 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()
}
+7 -2
View File
@@ -6,6 +6,7 @@ require (
github.com/BurntSushi/toml v1.4.0 github.com/BurntSushi/toml v1.4.0
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/jackc/pgx/v5 v5.7.1 github.com/jackc/pgx/v5 v5.7.1
@@ -21,15 +22,18 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // 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/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // 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/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.26.0 // indirect golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.21.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/sys v0.38.0 // indirect
golang.org/x/term v0.24.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 golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
+30 -4
View File
@@ -1,11 +1,17 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 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/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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= 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/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 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 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 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 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 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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.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.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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 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= 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-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 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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-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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 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 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=